diff --git a/mikupad.html b/mikupad.html index d94a84f..f56eda0 100644 --- a/mikupad.html +++ b/mikupad.html @@ -348,6 +348,106 @@ font-size: 0.8rem; } +.flexfiller { + flex-grow: 1; +} + +.widget-body { + z-index: 10; + color: var(--color-light); + box-shadow: #0004 2px 2px 6px 2px; +} +html.nockoffAI #searchAndReplace { + background: var(--color-sidebar); +} + +.widget-title-bar { + display: flex; + align-items: center; +} +.button-widget-top { + all:unset; + margin: auto; + width:1.25em; + height:1.25em; + border-radius: 3px; +} +.widget-title { + -webkit-user-select: none; /* Safari */ + -ms-user-select: none; /* IE 10 and IE 11 */ + user-select: none; /* Standard syntax */ + padding:0.2em; + font-weight: bold; + font-size: 110%; + background-color: var(--color-base-40); + border-radius:2px; + width: calc(100% - 2em); + display: flex; + align-items: center; +} +html.nockoffAI .widget-title { + background: var(--color-input); + color: var(--color-base-50); +} +html.monospace-dark .widget-title { + background: #46465a; +} + +.widget-title svg { + height: 1em; + margin-right: .25em; +} + +#searchAndReplace { + position: absolute; + width: 40vw; + background: var(--color-base-50); + top:.75em; + left:.75em; + border-radius:3px; + padding:0.25em; +} + +html.monospace-dark #searchAndReplace { + background: #282833; +} + +#searchAndReplace .widget-content { + padding: .2em; +} + +.searchAndReplace-inputs { + display: flex; + flex-direction: row; + gap: 8px; + margin-bottom:8px; +} +.searchAndReplace-inputs .InputBox { + flex: 1 +} +.searchAndReplace-inputs .SelectBox { + width: max-content; +} + +.searchAndReplace-buttons { + display: flex; + gap: 8px; +} +#searchAndReplace .findButton { + display: flex; + align-items: center; +} +#searchAndReplace .findButtonText svg { + margin-right:.25em; +} +#searchAndReplace .findButton svg { + width: .75em; +} +.number-matches { + align-content: center; +} + + .modal-overlay { position: fixed; top: 0; @@ -358,6 +458,7 @@ display: flex; align-items: center; justify-content: center; + z-index: 20; } .modal-container { width: 100%; @@ -2166,6 +2267,26 @@ ...${props} style=${{ 'transform':'scaleX(-1)' }}/> `}; +const SVG_SearchAndReplace = ({...props}) => { + return html` + <${SVG} + ...${props} + viewBox="0 0 29.4 35.4" + stroke-linecap="round" + stroke-width="5"> + + + + +`}; +const SVG_Moveable = ({...props}) => { + return html` + <${SVG} + ...${props} + viewBox="0 0 11 11"> + + +`}; function Modal({ isOpen, onClose, title, description, children, ...props }) { if (!isOpen) { @@ -3188,6 +3309,346 @@ `; } +function Widget({ isOpen, onClose, title, id, children, ...props }) { + if (!isOpen) { + return null; + } + + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const dragRef = useRef(null); + + useEffect(() => { + const handleMouseMove = (e) => { + if (!isDragging) return; + const deltaX = e.clientX - dragRef.current.startX; + const deltaY = e.clientY - dragRef.current.startY; + setPosition({ + x: dragRef.current.initialX + deltaX, + y: dragRef.current.initialY + deltaY, + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging]); + + const handleMouseDown = (e) => { + setIsDragging(true); + dragRef.current = { + startX: e.clientX, + startY: e.clientY, + initialX: position.x, + initialY: position.y, + }; + }; + + useEffect(() => { + const onKeyDown = (event) => { + if (event.key === 'Escape') { + onClose(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, []); + + return html` +
+
+
+
{}} + onMouseUp=${() => {}} + onMouseLeave=${() => {}}> + <${SVG_Moveable}/> + ${title} +
+ +
+
+ ${children} +
+
+
`; +} +function SearchAndReplaceWidget({ isOpen, closeWidget, id, children, promptArea, cancel, ...props }) { + const [searchAndReplaceError, setSearchAndReplaceError] = useState(undefined); + const [searchAndReplaceMode, setSearchAndReplaceMode] = usePersistentState('searchAndReplaceMode', 0); + const [searchTerm, setSearchTerm] = usePersistentState('searchTerm',''); + const [searchFlags, setSearchFlags] = usePersistentState('searchFlags','gi'); + const [replaceTerm, setReplaceTerm] = usePersistentState('replaceTerm',''); + const [numMatches, setNumMatches] = useState(0); + const [inputElement, setInputElement] = useState(null); + const [replacedTrigger, setReplacedTrigger] = useState(false); + + useEffect(() => { + if (promptArea.current) { + setInputElement(promptArea.current); + } + }, [promptArea]); + + function handleFindNext(mode,search,flags) { + setSearchAndReplaceError(undefined) + if (!search) + return + switch(mode) { + case 0: + findNextMatch(mode,search,flags,inputElement) + break; + case 1: + findNextMatch(mode,search,flags,inputElement) + break; + case 2: + templateFindNext(search,inputElement) + break; + } + } + function handleFindPrev(mode,search,flags) { + setSearchAndReplaceError(undefined) + if (!search) + return + switch(mode) { + case 0: + findPrevMatch(mode,search,flags,inputElement) + break; + case 1: + findPrevMatch(mode,search,flags,inputElement) + break; + case 2: + templateFindPrev(mode,search,inputElement) + break; + } + } + + let positions = []; + let currentIndex = -1; + + function findAllMatches(mode, search, flags, elem) { + if (!inputElement) + return 0 + setSearchAndReplaceError(undefined) + let startIndex = 0; + let index; + let match; + let positions = []; + let text = elem.value; + + if (mode == 0) { + while ((index = text.indexOf(search, startIndex)) > -1) { + positions.push({ start: index, end: index + search.length }); + startIndex = index + search.length; + } + } + else if (mode == 1) { + try { + if (flags && !flags.includes("g")) + flags += "g" // if no global flag, while loop is infinite + else if (flags == "") + flags = "g" + let re = new RegExp(String.raw`${search}`, String.raw`${flags ?? "g"}`); + while ((match = re.exec(text)) !== null) { + positions.push({ start: match.index, end: re.lastIndex }); + if (match.index === re.lastIndex) { + re.lastIndex++; + } + } + } + catch (e) { + reportError(e); + const errStr = e.toString() + setSearchAndReplaceError(errStr) + return null + } + } + return positions; + } + function highlightCurrent(elem) { + if (positions.length > 0 && currentIndex >= 0 && currentIndex < positions.length) { + const position = positions[currentIndex]; + elem.focus(); + elem.setSelectionRange(position.start, position.end); + } + } + function findNextMatch(mode,search,flags,elem) { + if (positions.length === 0) { + findAndStorePositions(mode,search,flags,elem); + } + if (positions.length > 0) { + currentIndex = (currentIndex + 1) % positions.length; + highlightCurrent(elem); + } + } + + function findPrevMatch(mode,search,flags,elem) { + if (positions.length === 0) { + findAndStorePositions(mode,search,flags,elem); + } + if (positions.length > 0) { + currentIndex = (currentIndex - 1 + positions.length) % positions.length; + highlightCurrent(elem); + } + } + + function findAndStorePositions(mode,search,flags,elem) { + positions = findAllMatches(mode, search, flags, elem); + currentIndex = -1; + if (positions.length === 0) + setSearchAndReplaceError(`Warning: No matches found for ${ (mode==0?"Plaintext":mode==1?"RegEx":"Template") } \'${search}\'`) + } + + function handleSearchAndReplace(mode,search,flags,replace) { + // TODO + // Add this to undo/redo + setSearchAndReplaceError(undefined) + if (!search) + return + positions = findAllMatches(mode, search, flags, inputElement); + if (positions.length === 0) { + setSearchAndReplaceError(`Warning: No matches found for ${ (mode==0?"Plaintext":mode==1?"RegEx":"Template") } \'${search}\'`) + return + } + setReplacedTrigger((prev) => !prev) + + switch(mode) { + case 0: + plaintextReplace(search,replace,inputElement) + break; + case 1: + regexReplace(search,flags,replace,inputElement) + break; + case 2: + templateReplace(search,replace,inputElement) + break; + } + } + + function plaintextReplace(search,replace,elem) { + // need to figure out a smart way to keep the cursor position + elem.value = elem.value.replaceAll(search,replace) + const event = new Event('input', { bubbles: true }); + elem.dispatchEvent(event); + } + function regexReplace(search,flags,replace,elem) { + try { + let re = new RegExp(String.raw`${search}`, String.raw`${flags ?? ""}`); + elem.value = elem.value.replace(re,replace) + const event = new Event('input', { bubbles: true }); + elem.dispatchEvent(event); + } + catch (e) { + reportError(e); + const errStr = e.toString() + setSearchAndReplaceError(errStr) + } + } + + function countMatches(mode, search, flags) { + setSearchAndReplaceError(undefined) + if (!searchTerm) { + setNumMatches(0) + return + } + positions = findAllMatches(mode, search, flags, inputElement); + try { + setNumMatches(positions.length ?? 0) + } + catch { + setNumMatches(0) + } + } + + useEffect(() => { + countMatches(searchAndReplaceMode,searchTerm,searchFlags) + }, [searchAndReplaceMode,searchTerm,searchFlags,isOpen,replacedTrigger]); + + return html` + <${Widget} isOpen=${isOpen} onClose=${closeWidget} + title="Search and Replace" + id="${id}"> + ${children} +
+ <${SelectBox} + label="Mode" + value=${searchAndReplaceMode} + onValueChange=${setSearchAndReplaceMode} + options=${[ + { name: 'Plaintext', value: 0 }, + { name: 'RegEx', value: 1 }, + // { name: 'Template', value: 2 }, + ]}/> + ${searchAndReplaceMode == 0 && html` + <${InputBox} label="Search This" type="text" + placeholder="Hatsune Miku" + readOnly=${!!cancel} value=${searchTerm} onValueChange=${setSearchTerm}/> + <${InputBox} label="Replace With" type="text" + placeholder="GUMI" + readOnly=${!!cancel} value=${replaceTerm} onValueChange=${setReplaceTerm}/> + `} + ${searchAndReplaceMode == 1 && html` + <${InputBox} label="Search This RegEx" type="text" + placeholder="(\\w+) Miku" + readOnly=${!!cancel} value=${searchTerm} onValueChange=${setSearchTerm}/> +
+ <${InputBox} label="Flags" type="text" + placeholder="gi" + readOnly=${!!cancel} value=${searchFlags} onValueChange=${setSearchFlags}/> +
+ <${InputBox} label="Replace With" type="text" + placeholder="$1 GUMI" + readOnly=${!!cancel} value=${replaceTerm} onValueChange=${setReplaceTerm}/> + `} +
+
+
+
+ ${ searchTerm != "" ? numMatches + (numMatches == 1 ? " Match" : " Matches") : ""} +
+ + + +
+ ${!!searchAndReplaceError && html` +
${searchAndReplaceError}
`} + `; +} + class IndexedDBAdapter { constructor() { this.dbName = 'MikuPad'; @@ -3971,6 +4432,7 @@ const [worldInfo, setWorldInfo] = useSessionState('worldInfo', defaultPresets.worldInfo); + function replacePlaceholders(string,placeholders) { // give placeholders as json object // { "placeholder":"replacement" } @@ -4764,30 +5226,30 @@ if (defaultPrevented) return; switch (`${altKey}:${ctrlKey}:${shiftKey}:${key}`) { - case 'false:false:true:Enter': - case 'false:true:false:Enter': - predict(); - break; - case 'false:false:false:Escape': - cancel(); - break; - case 'false:true:false:r': - case 'false:false:true:r': - undoAndPredict(); - break; - case 'false:true:false:z': - case 'false:false:true:z': - if (cancel || !undo()) return; - break; - case 'false:true:true:Z': - case 'false:true:false:y': - case 'false:false:true:y': - if (cancel || !redo()) return; - break; + case 'false:false:true:Enter': + case 'false:true:false:Enter': + predict(); + break; + case 'false:false:false:Escape': + cancel(); + break; + case 'false:true:false:r': + case 'false:false:true:r': + undoAndPredict(); + break; + case 'false:true:false:z': + case 'false:false:true:z': + if (cancel || !undo()) return; + break; + case 'false:true:true:Z': + case 'false:true:false:y': + case 'false:false:true:y': + if (cancel || !redo()) return; + break; - default: - keyState.current = e; - return; + default: + keyState.current = e; + return; } e.preventDefault(); } @@ -5134,10 +5596,18 @@ return html`
+