diff --git a/.gitignore b/.gitignore index 76c47fe66f..d10c54241f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ dist/lib/json/internalRelations.json dist/lib/json/internalTypes.json dist/lib/json/systemRelations.json dist/lib/json/systemTypes.json +bin/ diff --git a/.husky/pre-commit b/.husky/pre-commit index a84c746ddb..ba37e5977b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,7 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -npx lint-staged --concurrent false +bunx lint-staged --concurrent false # npm run typecheck diff --git a/src/json/shortcut.ts b/src/json/shortcut.ts index 00e9752827..4790b2172d 100644 --- a/src/json/shortcut.ts +++ b/src/json/shortcut.ts @@ -108,6 +108,7 @@ const getSections = () => { { id: 'print', name: translate('popupShortcutMainBasics8'), keys: [ cmdKey, 'p' ] }, { id: 'history', name: translate('popupShortcutMainBasics11'), keys: [ cmdKey, 'alt', 'h' ] }, { id: 'searchText', name: translate('popupShortcutMainBasics9'), keys: [ cmdKey, 'f' ] }, + { id: 'replaceText', name: translate('menuObjectSearchAndReplace'), keys: [ 'ctrl', 'h' ] }, { id: 'pageLock', name: translate('popupShortcutMainBasics24'), keys: [ 'ctrl', 'shift', 'l' ] }, ] }, diff --git a/src/json/text.json b/src/json/text.json index d6e902df87..a00bc68cd9 100644 --- a/src/json/text.json +++ b/src/json/text.json @@ -11,6 +11,9 @@ "commonCreate": "Create", "commonSave": "Save", "commonCancel": "Cancel", + "commonReplace": "Replace", + "commonReplaceAll": "Replace All", + "commonReplaceWith": "Replace with...", "commonRestart": "Restart", "commonDelete": "Delete", "commonDeleteImmediately": "Delete permanently", @@ -2081,6 +2084,7 @@ "menuObjectTypeSettings": "Type settings", "menuObjectPrint": "Print", "menuObjectSearchOnPage": "Search in Object", + "menuObjectSearchAndReplace": "Search & Replace", "menuObjectSearchInTemplate": "Search in Template", "menuObjectSearchInChat": "Search in Chat", "menuObjectExport": "Export", diff --git a/src/scss/menu/search/text.scss b/src/scss/menu/search/text.scss index 9ba43067fb..d251a6618d 100644 --- a/src/scss/menu/search/text.scss +++ b/src/scss/menu/search/text.scss @@ -10,14 +10,14 @@ .content { padding: 0px; overflow: visible; } .wrap { height: 100%; width: 100%; display: flex; flex-direction: column; } - .filterWrapper { - padding: 10px 0px 4px 0px; display: flex; flex-direction: row; gap: 0px 8px; + .searchReplaceLayout { + padding: 10px 0px 4px 0px; display: flex; flex-direction: row; gap: 0px 8px; align-items: center; } .filterContainer { display: flex; flex-direction: row; align-items: center; flex-grow: 1; - background-color: var(--color-shape-highlight-medium); border-radius: 16px; padding: 0px 12px; height: 32px; - border: 1px solid var(--color-shape-secondary); gap: 0px 6px; + background-color: var(--color-shape-tertiary); border-radius: 16px; padding: 0px 12px; height: 32px; + gap: 0px 6px; } .filterContainer { .icon.search { margin: 0px; } @@ -34,13 +34,70 @@ } .arrowWrapper { - display: flex; flex-direction: row; padding: 6px 8px; flex-shrink: 0; gap: 0px 8px; - background-color: var(--color-shape-highlight-medium); border-radius: 16px; align-items: center; + display: flex; flex-direction: row; padding: 6px 12px; flex-shrink: 0; gap: 0px 8px; + background-color: var(--color-shape-tertiary); border-radius: 16px; align-items: center; } .arrowWrapper { .icon { width: 20px !important; height: 20px !important; } - .icon.arrow.up { transform: rotate(180deg); } - .icon.arrow.disabled { opacity: 0.3; pointer-events: none; } + .icon.arrow { + color: var(--color-icon); + &.up { transform: rotate(180deg); } + &.disabled { color: var(--color-control-inactive) !important; opacity: 1; pointer-events: none; } + } + .icon.replaceAction { + cursor: pointer; + color: var(--color-icon); + &:hover { color: var(--color-text-primary); } + &.active { + color: var(--color-control-accent); + &:hover { color: var(--color-control-accent); } + } + } + } + + .replaceWrapper { + padding: 12px; display: flex; flex-direction: column; gap: 8px; + background-color: var(--color-shape-tertiary); border-radius: 12px; + margin-top: 6px; + + .input { width: 100%; height: 24px; padding: 0px; border: 0px; background: none; border-radius: 0px; } + } + + .replaceButtons { + display: flex; flex-direction: row; gap: 0px 16px; align-items: center; justify-content: flex-end; + + .button.replaceAllBtn.simple { + @include text-small; + height: auto; + padding: 0px; + color: var(--color-text-secondary) !important; + font-weight: 400; + background: none !important; + border: none !important; + cursor: pointer; + &:hover { + color: var(--color-text-primary) !important; + } + &.disabled { + opacity: 0.3; + pointer-events: none; + } + } + + .button.c28.accent { + @include text-small; + height: 28px; + padding: 0px 16px; + border-radius: 14px; + font-weight: 500; + white-space: nowrap; + &.disabled { + background-color: var(--color-shape-secondary) !important; + color: var(--color-text-tertiary) !important; + opacity: 0.5; + pointer-events: none; + } + } } } } diff --git a/src/ts/component/editor/page.tsx b/src/ts/component/editor/page.tsx index 41a2486050..cbb7c58efc 100644 --- a/src/ts/component/editor/page.tsx +++ b/src/ts/component/editor/page.tsx @@ -1041,6 +1041,11 @@ const EditorPage = forwardRef((props, ref) => { keyboard.onSearchText(text.substring(range.from, range.to), 'editor'); }); + // Search & Replace + keyboard.shortcut('replaceText', e, () => { + keyboard.onSearchText(text.substring(range.from, range.to), 'editor', true); + }); + if (block.canToggle()) { keyboard.shortcut(`${cmd}+shift+t`, e, () => { S.Block.toggle(rootId, block.id, !Storage.checkToggle(rootId, block.id)); diff --git a/src/ts/component/menu/search/text.tsx b/src/ts/component/menu/search/text.tsx index 0edd004dde..224a8c2596 100644 --- a/src/ts/component/menu/search/text.tsx +++ b/src/ts/component/menu/search/text.tsx @@ -1,6 +1,6 @@ -import React, { forwardRef, useEffect, useRef, useImperativeHandle } from 'react'; +import React, { forwardRef, useEffect, useRef, useState, useImperativeHandle } from 'react'; import findAndReplaceDOMText from 'findandreplacedomtext'; -import { Icon, Input } from 'Component'; +import { Icon, Input, Button } from 'Component'; import * as I from 'Interface'; import { focus } from 'Lib/focus'; @@ -28,13 +28,18 @@ const MenuSearchText = forwardRef((props, ref) => { const nodeRef = useRef(null); const inputRef = useRef(null); + const replaceInputRef = useRef(null); const searchTimeoutRef = useRef(0); const lastSearchRef = useRef(''); const n = useRef(0); const matchElementsRef = useRef(null); + const [ replaceMode, setReplaceMode ] = useState(!!data.replaceMode); const expandedRef = useRef({ toggles: [] }); const activeMatchRef = useRef({ toggleId: '', position: null }); + const isSearchingRef = useRef(false); + const observerRef = useRef(null); + const mutationTimeoutRef = useRef(0); const getRootId = () => keyboard.getRootId(isPopup); const getContainer = () => U.Dom.getScrollContainer(isPopup); @@ -85,11 +90,28 @@ const MenuSearchText = forwardRef((props, ref) => { }); }; + const disconnectObserver = () => { + observerRef.current?.disconnect(); + }; + + const connectObserver = () => { + const containerEl = getContainer(); + if (containerEl && observerRef.current) { + observerRef.current.observe(containerEl, { + childList: true, + subtree: true, + characterData: true, + }); + }; + }; + const clearSearch = (keepToggleId?: string) => { + disconnectObserver(); removeHighlights(); collapseExpanded(keepToggleId); U.Dom.removeClass(U.Dom.select('#switcher', nodeRef.current), 'active'); + connectObserver(); }; const isElementVisible = (el: HTMLElement): boolean => { @@ -137,7 +159,7 @@ const MenuSearchText = forwardRef((props, ref) => { }; const blockId = blockClass.substring(1); - const containerEl = U.Dom.select('.editable', focusable); + const containerEl = U.Dom.hasClass(focusable, 'editable') ? focusable : U.Dom.select('.editable', focusable); if (!containerEl) { activeMatchRef.current.position = null; @@ -249,11 +271,13 @@ const MenuSearchText = forwardRef((props, ref) => { U.Dom.removeClass(switcher, 'active'); n.current = 0; + isSearchingRef.current = true; clearSearch(); lastSearchRef.current = value; storageSet({ search: value }); if (!value || !containerEl) { + isSearchingRef.current = false; return; }; @@ -274,6 +298,137 @@ const MenuSearchText = forwardRef((props, ref) => { updateMatchCounter(); focusCurrentMatch(); + isSearchingRef.current = false; + }; + + const onReplace = () => { + const pos = activeMatchRef.current.position; + if (!pos) { + return; + }; + + const rootId = getRootId(); + const searchValue = inputRef.current?.getValue() || ''; + const replaceValue = replaceInputRef.current?.getValue() || ''; + + if (!searchValue) { + return; + }; + + // Verify position against the store's actual text to prevent wrong-place replacements. + // After a previous replace, the DOM may be stale (block components don't auto-re-render + // from MobX store changes since they aren't wrapped in observer), so the cached + // position from updateActivePosition may point to the wrong offset. + const block = S.Block.getLeaf(rootId, pos.blockId); + if (!block?.content) { + return; + }; + + const blockText = block.content.text || ''; + const matchedText = blockText.substring(pos.range.from, pos.range.to); + + let actualFrom = pos.range.from; + let actualTo = pos.range.to; + + if (matchedText.toLowerCase() !== searchValue.toLowerCase()) { + // Position is stale — find the correct occurrence in the block's store text. + // Search near the original position first, then fall back to any occurrence. + const lowerText = blockText.toLowerCase(); + const lowerSearch = searchValue.toLowerCase(); + let idx = lowerText.indexOf(lowerSearch, Math.max(0, pos.range.from - searchValue.length)); + + if (idx < 0) { + idx = lowerText.indexOf(lowerSearch); + }; + + if (idx < 0) { + // Search term not found in this block at all — re-search to update state + lastSearchRef.current = ''; + search(); + return; + }; + + actualFrom = idx; + actualTo = idx + searchValue.length; + }; + + U.Data.blockInsertText(rootId, pos.blockId, replaceValue, actualFrom, actualTo, () => { + lastSearchRef.current = ''; + // Use rAF chaining to ensure the DOM has been updated by the contentEditable + // before re-searching. setTimeout(50) is unreliable because block text + // components don't re-render from MobX store changes automatically. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + search(); + }); + }); + }); + }; + + const onReplaceAll = () => { + const searchValue = inputRef.current?.getValue() || ''; + const replaceValue = replaceInputRef.current?.getValue() || ''; + + if (!searchValue) { + return; + }; + + const rootId = getRootId(); + const blocks = S.Block.getBlocks(rootId, (it: any) => it.isText()); + + if (!blocks.length) { + return; + }; + + clearSearch(); + + let completed = 0; + let total = 0; + const regex = new RegExp(U.String.regexEscape(searchValue), 'gi'); + + for (const block of blocks) { + const text = block.content.text || ''; + const matches: { from: number; to: number }[] = []; + let match; + + while ((match = regex.exec(text)) !== null) { + matches.push({ from: match.index, to: match.index + match[0].length }); + }; + + if (!matches.length) { + continue; + }; + + total++; + + // Replace from end to start to preserve offsets + let newText = text; + let newMarks = block.content.marks || []; + + for (let i = matches.length - 1; i >= 0; i--) { + const m = matches[i]; + const diff = replaceValue.length - (m.to - m.from); + newText = U.String.insert(newText, replaceValue, m.from, m.to); + newMarks = Mark.adjust(newMarks, m.from, diff); + }; + + U.Data.blockSetText(rootId, block.id, newText, newMarks, true, () => { + completed++; + if (completed >= total) { + lastSearchRef.current = ''; + matchElementsRef.current = null; + n.current = 0; + updateMatchCounter(); + }; + }); + }; + + if (total === 0) { + lastSearchRef.current = ''; + matchElementsRef.current = null; + n.current = 0; + updateMatchCounter(); + }; }; const onKeyDown = (e: any, v: string) => { @@ -287,6 +442,13 @@ const MenuSearchText = forwardRef((props, ref) => { navigateMatch(1); window.clearTimeout(searchTimeoutRef.current); }); + + keyboard.shortcut('replaceText', e, () => { + e.preventDefault(); + e.stopPropagation(); + setReplaceMode(v => !v); + window.clearTimeout(searchTimeoutRef.current); + }); }; const onKeyUp = (e: any, v: string) => { @@ -310,6 +472,13 @@ const MenuSearchText = forwardRef((props, ref) => { }; }; + const onReplaceKeyDown = (e: any, v: string) => { + keyboard.shortcut('enter', e, () => { + e.preventDefault(); + onReplace(); + }); + }; + const onClear = () => { inputRef.current?.setValue(''); storageSet({ search: '' }); @@ -343,6 +512,27 @@ const MenuSearchText = forwardRef((props, ref) => { useEffect(() => { position(); + }, [replaceMode]); + + useEffect(() => { + position(); + + // Set up MutationObserver to detect content changes (undo/redo/external edits) + // and re-run the search so the match counter stays in sync. + observerRef.current = new MutationObserver(() => { + if (isSearchingRef.current) { + return; + }; + + window.clearTimeout(mutationTimeoutRef.current); + mutationTimeoutRef.current = window.setTimeout(() => { + const value = inputRef.current?.getValue() || ''; + if (value) { + lastSearchRef.current = ''; + search(); + }; + }, 200); + }); const initTimeout = window.setTimeout(() => { const value = String(data.value || storageGet().search || ''); @@ -350,11 +540,15 @@ const MenuSearchText = forwardRef((props, ref) => { inputRef.current?.setRange({ from: 0, to: value.length }); inputRef.current?.focus(); search(); + connectObserver(); }, 100); return () => { window.clearTimeout(initTimeout); window.clearTimeout(searchTimeoutRef.current); + window.clearTimeout(mutationTimeoutRef.current); + disconnectObserver(); + observerRef.current = null; const { toggleId } = activeMatchRef.current; clearSearch(toggleId); @@ -362,26 +556,59 @@ const MenuSearchText = forwardRef((props, ref) => { }; }, []); + const readonly = !!data.isReadonly; + return ( -
-
+
+
- +
navigateMatch(-1)} /> navigateMatch(1)} /> + setReplaceMode(!replaceMode)} + />
+ + {replaceMode ? ( +
+ +
+
+
+ ) : null}
); diff --git a/src/ts/lib/keyboard.ts b/src/ts/lib/keyboard.ts index 999dd4c82a..669237f3f8 100644 --- a/src/ts/lib/keyboard.ts +++ b/src/ts/lib/keyboard.ts @@ -502,6 +502,12 @@ class Keyboard { // Lock/Unlock this.shortcut('pageLock', e, () => this.onToggleLock()); + // Search & Replace + this.shortcut('replaceText', e, () => { + e.preventDefault(); + this.onSearchText('', route, true); + }); + // Move to bin this.shortcut('moveToBin', e, () => { e.preventDefault(); @@ -1381,8 +1387,9 @@ class Keyboard { * Handles search menu action. * @param {string} value - The search value. * @param {string} [route] - The route context. + * @param {boolean} [replaceMode] - Whether to open in replace mode. */ - onSearchText (value: string, route?: string) { + onSearchText (value: string, route?: string, replaceMode?: boolean) { const isPopup = this.isPopup(); const popupMatch = this.getPopupMatch(); const match = this.getMatch(); @@ -1427,11 +1434,20 @@ class Keyboard { }, }); } else { + const rootId = this.getRootId(); + const canWrite = U.Space.canMyParticipantWrite(); + const allowed = S.Block.checkFlags(rootId, rootId, [ I.RestrictionObject.Block ]); + const object = S.Detail.get(rootId, rootId, [ 'isArchived', 'isDeleted' ], true); + const root = S.Block.getLeaf(rootId, rootId); + const isReadonly = !canWrite || !allowed || object.isArchived || object.isDeleted || (root ? root.isLocked() : false); + menuParam.passThrough = true; menuParam.data = Object.assign(menuParam.data, { isPopup, value, route, + replaceMode: !!replaceMode, + isReadonly, }); };