diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index 8247cfe24d7..5f4cd049cb2 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -114,6 +114,7 @@ export function PublicFileView({ contentSource={source} canEdit={false} readOnly + enableFind /> diff --git a/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts b/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts new file mode 100644 index 00000000000..f0bd842f6c9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut.ts @@ -0,0 +1,46 @@ +'use client' + +import type React from 'react' +import { useEffect } from 'react' + +interface UseFindShortcutOptions { + /** + * Whether this surface currently owns Cmd/Ctrl+F. Every find surface binds its own listener, so + * exactly one owner may be enabled at a time — the surfaces arbitrate by mounting (the Files list + * disables itself while a file is open, and the file editor enables itself only where the document + * is the page), by an embed flag (the table grid), or by DOM containment (the browser session). + * Two enabled owners mounted at once would race, and first-registered would win. + */ + enabled: boolean + /** The find bar's input, focused and selected once the bar opens. */ + inputRef: React.RefObject + onOpen: () => void +} + +/** + * Binds Cmd/Ctrl+F to open a find bar, overriding the browser's own find. + * + * Listens on the document rather than a container so the shortcut answers before anything inside the + * surface has been focused — a file that has only been opened, never clicked into, still responds. + * A press another surface already consumed is left alone (`defaultPrevented`), and any chord with a + * further modifier falls through to the browser, so Cmd+Shift+F and Cmd+Alt+F keep their meanings. + */ +export function useFindShortcut({ enabled, inputRef, onOpen }: UseFindShortcutOptions): void { + useEffect(() => { + if (!enabled) return + const handleFindShortcut = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return + if (event.key.toLowerCase() !== 'f') return + if (event.defaultPrevented) return + event.preventDefault() + onOpen() + // After the open has painted the bar, so there is an input to focus. + requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + } + document.addEventListener('keydown', handleFindShortcut) + return () => document.removeEventListener('keydown', handleFindShortcut) + }, [enabled, inputRef, onOpen]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 95adab9b7bb..9791199d107 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -8,6 +8,7 @@ export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' export type { FindBarProps } from './find-bar/find-bar' export { FindBar } from './find-bar/find-bar' +export { useFindShortcut } from './find-bar/use-find-shortcut' export { InlineRenameInput } from './inline-rename-input' export { IntegrationTabsHeader } from './integration-tabs-header' export { MessageActions } from './message-actions' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 9b878147309..d41df8897e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -127,6 +127,13 @@ interface FileViewerProps { * untitled, so the caller can name the file after it. Only wired for the editable markdown editor. */ onDeriveTitleFromHeading?: (headingText: string) => void + /** + * Let an open markdown file claim Cmd/Ctrl+F for find-in-document. Set wherever the file is the + * whole pane the user is reading — the Files page, the mothership file view, the public share + * page. Left off for the streaming-file preview, which is a pane beside a conversation that owns + * its own find. See {@link RichMarkdownEditorProps.enableFind}. + */ + enableFind?: boolean } export function FileViewer(props: FileViewerProps) { @@ -165,6 +172,7 @@ function FileViewerContent({ previewContextKey, collaborative, onDeriveTitleFromHeading, + enableFind = false, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -181,7 +189,13 @@ function FileViewerContent({ // the bubble menu, and every other editing affordance. if (isMarkdownFile(file)) { return ( - + ) } return @@ -212,6 +226,7 @@ function FileViewerContent({ previewContextKey={previewContextKey} collaborative={collaborative} onDeriveTitleFromHeading={onDeriveTitleFromHeading} + enableFind={enableFind} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index ef9f7f6cf5d..771a242ab2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -15,6 +15,7 @@ import { } from './collaboration/caret-presence' import { LinkEmbed } from './embed/link-embed' import { createMarkdownContentExtensions } from './extensions' +import { RichMarkdownFind } from './find' import { ResizableImage } from './image' import { RichMarkdownKeymap } from './keymap' import { MarkdownPaste } from './markdown-paste' @@ -48,8 +49,8 @@ interface MarkdownEditorExtensionOptions { * The full extension set for the live editor: the content extensions with their React node-view nodes * injected (code-block language picker, resizable image, mention chip) plus the UI-only extensions — * `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), `Mention` (the `@` menu), - * `RichMarkdownKeymap`, `MarkdownPaste`, `Placeholder`, and — when `embeds` is set — `LinkEmbed` - * (media players for standalone links). + * `RichMarkdownKeymap`, `MarkdownPaste`, `Placeholder`, `RichMarkdownFind` (the Cmd/Ctrl+F match + * highlights), and — when `embeds` is set — `LinkEmbed` (media players for standalone links). * * Kept separate from `extensions.ts` so those node views (and the block registry the mention chip pulls * in for brand icons) stay out of the headless round-trip path, which only needs the schema. @@ -94,6 +95,7 @@ export function createMarkdownEditorExtensions({ ] : []), CodeBlockHighlight, + RichMarkdownFind, SlashCommand, Mention, RichMarkdownKeymap, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.test.ts new file mode 100644 index 00000000000..2f730b40771 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.test.ts @@ -0,0 +1,132 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { undoDepth } from '@tiptap/pm/history' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from '../extensions' +import { getFindTally, RichMarkdownFind, setFindQuery, stepFindMatch } from './find-extension' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +function mountEditor(markdown: string): Editor { + const element = document.createElement('div') + document.body.append(element) + editor = new Editor({ + element, + extensions: [...createMarkdownContentExtensions(), RichMarkdownFind], + }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor +} + +/** The painted highlights, in document order, with the active one marked. */ +function paintedMatches(instance: Editor): string[] { + return Array.from(instance.view.dom.querySelectorAll('.rich-find-match')).map((element) => + element.classList.contains('rich-find-match-active') + ? `[${element.textContent}]` + : (element.textContent ?? '') + ) +} + +describe('RichMarkdownFind', () => { + it('paints nothing until a term is set', () => { + const instance = mountEditor('alpha beta alpha') + expect(paintedMatches(instance)).toEqual([]) + expect(getFindTally(instance.state).matches).toHaveLength(0) + }) + + it('paints every match and marks the first one active', () => { + const instance = mountEditor('alpha beta alpha') + setFindQuery(instance, 'alpha') + expect(paintedMatches(instance)).toEqual(['[alpha]', 'alpha']) + }) + + it('steps the active match forward and backward, wrapping at both ends', () => { + const instance = mountEditor('one one one') + setFindQuery(instance, 'one') + + stepFindMatch(instance, 1) + expect(paintedMatches(instance)).toEqual(['one', '[one]', 'one']) + + stepFindMatch(instance, 1) + expect(paintedMatches(instance)).toEqual(['one', 'one', '[one]']) + + // Past the end wraps to the first, and back past the start wraps to the last. + stepFindMatch(instance, 1) + expect(paintedMatches(instance)).toEqual(['[one]', 'one', 'one']) + stepFindMatch(instance, -1) + expect(paintedMatches(instance)).toEqual(['one', 'one', '[one]']) + }) + + it('re-searches when the document changes under a live search', () => { + const instance = mountEditor('alpha') + setFindQuery(instance, 'alpha') + expect(getFindTally(instance.state).matches).toHaveLength(1) + + instance.commands.insertContentAt(instance.state.doc.content.size, ' and alpha again') + expect(getFindTally(instance.state).matches).toHaveLength(2) + expect(paintedMatches(instance)).toEqual(['[alpha]', 'alpha']) + }) + + it('drops a match the document no longer contains, without leaving a stale highlight', () => { + const instance = mountEditor('alpha beta') + setFindQuery(instance, 'beta') + expect(paintedMatches(instance)).toEqual(['[beta]']) + + instance.commands.setContent('alpha only', { contentType: 'markdown' }) + expect(paintedMatches(instance)).toEqual([]) + expect(getFindTally(instance.state).matches).toHaveLength(0) + }) + + it('clamps the active index when an edit shrinks the match set', () => { + const instance = mountEditor('x x x') + setFindQuery(instance, 'x') + stepFindMatch(instance, 2) + expect(getFindTally(instance.state).activeIndex).toBe(2) + + instance.commands.setContent('x', { contentType: 'markdown' }) + const tally = getFindTally(instance.state) + expect(tally.matches).toHaveLength(1) + expect(tally.activeIndex).toBe(0) + expect(paintedMatches(instance)).toEqual(['[x]']) + }) + + it('searches a term applied before any other transaction', () => { + // The hook re-applies a pending term the moment the editor exists; setting a query as the very + // first thing that happens to a fresh editor must land, not wait for a later transaction. + const instance = mountEditor('alpha beta') + setFindQuery(instance, 'beta') + expect(getFindTally(instance.state).matches).toHaveLength(1) + expect(paintedMatches(instance)).toEqual(['[beta]']) + }) + + it('clears every highlight when the term is emptied', () => { + const instance = mountEditor('alpha') + setFindQuery(instance, 'alpha') + expect(paintedMatches(instance)).toEqual(['[alpha]']) + + setFindQuery(instance, '') + expect(paintedMatches(instance)).toEqual([]) + }) + + it('never writes to the document, the selection, or the undo history', () => { + const instance = mountEditor('alpha beta alpha') + const before = instance.getMarkdown() + const selectionBefore = instance.state.selection.from + const undoBefore = undoDepth(instance.state) + + setFindQuery(instance, 'alpha') + stepFindMatch(instance, 1) + + expect(instance.getMarkdown()).toBe(before) + expect(instance.state.selection.from).toBe(selectionBefore) + // A search that added an undo step would make the user's next Cmd+Z undo the search + // instead of their real last edit. + expect(undoDepth(instance.state)).toBe(undoBefore) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.ts new file mode 100644 index 00000000000..e2ac3f0a20a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-extension.ts @@ -0,0 +1,151 @@ +import type { Editor } from '@tiptap/core' +import { Extension } from '@tiptap/core' +import type { EditorState } from '@tiptap/pm/state' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { EMPTY_FIND_RESULT, type FindMatch, findMatches } from './find-matches' + +/** Class on every match. The active one carries {@link ACTIVE_MATCH_CLASS} as well. */ +const MATCH_CLASS = 'rich-find-match' + +/** Class on the one match the bar is currently pointing at. Also how the hook finds it to scroll to. */ +export const ACTIVE_MATCH_CLASS = 'rich-find-match-active' + +interface RichMarkdownFindState { + query: string + matches: readonly FindMatch[] + truncated: boolean + /** 0-based index into `matches`. Meaningless, but still 0, when there are none. */ + activeIndex: number + /** + * The rendered highlights, built here rather than in the `decorations` prop. ProseMirror asks for + * decorations on every view update — including caret moves and remote cursor traffic — so building + * them there would rebuild all 500 for transactions that changed nothing about the search. + */ + decorations: DecorationSet +} + +/** What the surface reads back off the plugin. */ +export type FindTally = Pick + +/** Transaction meta the surface sets to drive the search. Absent fields keep their current value. */ +interface FindMeta { + query?: string + /** Any integer; wrapped into range against the match count, so stepping past either end cycles. */ + activeIndex?: number +} + +const RICH_FIND_PLUGIN_KEY = new PluginKey('richMarkdownFind') + +const INITIAL_STATE: RichMarkdownFindState = { + query: '', + matches: EMPTY_FIND_RESULT.matches, + truncated: false, + activeIndex: 0, + decorations: DecorationSet.empty, +} + +function wrapIndex(index: number, length: number): number { + if (length === 0) return 0 + return ((index % length) + length) % length +} + +function buildDecorations( + doc: EditorState['doc'], + matches: readonly FindMatch[], + activeIndex: number +): DecorationSet { + if (matches.length === 0) return DecorationSet.empty + return DecorationSet.create( + doc, + matches.map((match, index) => + Decoration.inline(match.from, match.to, { + class: index === activeIndex ? `${MATCH_CLASS} ${ACTIVE_MATCH_CLASS}` : MATCH_CLASS, + }) + ) + ) +} + +/** + * Renders the find highlights over the document, and owns the match set they are built from. + * + * The surface never passes matches in — it sets only the term and the active index as transaction + * meta ({@link setFindQuery}, {@link stepFindMatch}) and reads the resulting state back with + * {@link getFindTally}. Keeping the search here is what makes it survive editing: the plugin + * re-searches on any transaction that changed the document, so a highlight can never be left + * pointing at a position the edit moved or deleted (a stale `Decoration` on a removed range throws). + * + * Decorations are inline and add no node to the document, so the search leaves the markdown — and + * the collaborative Y.Doc behind it — completely untouched. Nothing here dispatches, and with no + * term set every branch short-circuits to the untouched previous state, so the plugin is inert + * until a find bar opens. + */ +export const RichMarkdownFind = Extension.create({ + name: 'richMarkdownFind', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: RICH_FIND_PLUGIN_KEY, + state: { + init: () => INITIAL_STATE, + apply(transaction, value, _oldState, newState) { + const meta = transaction.getMeta(RICH_FIND_PLUGIN_KEY) as FindMeta | undefined + const query = meta?.query ?? value.query + const requestedIndex = meta?.activeIndex ?? value.activeIndex + const unsearched = query.trim().length === 0 + if (unsearched && value.matches.length === 0) { + return query === value.query && value.activeIndex === 0 + ? value + : { ...INITIAL_STATE, query } + } + if (!transaction.docChanged && query === value.query) { + const activeIndex = wrapIndex(requestedIndex, value.matches.length) + if (activeIndex === value.activeIndex) return value + return { + ...value, + activeIndex, + decorations: buildDecorations(newState.doc, value.matches, activeIndex), + } + } + const { matches, truncated } = unsearched + ? EMPTY_FIND_RESULT + : findMatches(newState.doc, query) + const activeIndex = wrapIndex(requestedIndex, matches.length) + return { + query, + matches, + truncated, + activeIndex, + decorations: buildDecorations(newState.doc, matches, activeIndex), + } + }, + }, + props: { + decorations: (state) => RICH_FIND_PLUGIN_KEY.getState(state)?.decorations ?? null, + }, + }), + ] + }, +}) + +/** The match set, cap flag and active index the find bar renders from. */ +export function getFindTally(state: EditorState): FindTally { + return RICH_FIND_PLUGIN_KEY.getState(state) ?? INITIAL_STATE +} + +function dispatchFindMeta(editor: Editor, meta: FindMeta): void { + // `setMeta` alone leaves the transaction with no steps, so this never touches the document, + // the undo history, or the collaborative document. + editor.view.dispatch(editor.state.tr.setMeta(RICH_FIND_PLUGIN_KEY, meta)) +} + +/** Searches for `query` and makes its first match active. An empty term clears the highlights. */ +export function setFindQuery(editor: Editor, query: string): void { + dispatchFindMeta(editor, { query, activeIndex: 0 }) +} + +/** Moves the active match by `delta`, cycling past either end. */ +export function stepFindMatch(editor: Editor, delta: number): void { + dispatchFindMeta(editor, { activeIndex: getFindTally(editor.state).activeIndex + delta }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.test.ts new file mode 100644 index 00000000000..b62f392cea7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from '../extensions' +import { FIND_MATCH_LIMIT, findMatches } from './find-matches' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +/** Parses markdown through the real schema, so matches are checked against real document positions. */ +function docFor(markdown: string) { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor.state.doc +} + +/** The text each match actually covers — the only assertion that proves the positions are right. */ +function matchedText(markdown: string, query: string): string[] { + const doc = docFor(markdown) + return findMatches(doc, query).matches.map((match) => doc.textBetween(match.from, match.to)) +} + +describe('findMatches', () => { + it('finds every occurrence across blocks, case-insensitively', () => { + const doc = docFor('# Report\n\nthe report is ready') + const { matches, truncated } = findMatches(doc, 'report') + expect(matches).toHaveLength(2) + expect(truncated).toBe(false) + expect(matches.map((m) => doc.textBetween(m.from, m.to))).toEqual(['Report', 'report']) + }) + + it('returns nothing for an empty, whitespace-only, or unmatched term', () => { + expect(findMatches(docFor('hello'), '').matches).toHaveLength(0) + expect(findMatches(docFor('hello'), ' ').matches).toHaveLength(0) + expect(findMatches(docFor('hello'), 'zzz').matches).toHaveLength(0) + }) + + it('folds whitespace the way the rest of the app\u2019s search does', () => { + // Inherited from `forEachSearchOccurrence`: a typed space matches a non-breaking one, so a + // term copied out of agent-written prose still finds itself. + expect(matchedText('one\u00a0two', 'one two')).toEqual(['one\u00a0two']) + }) + + it('keeps positions correct after a code point that lowercases to two characters', () => { + expect(matchedText('\u0130stanbul and target', 'target')).toEqual(['target']) + }) + + it('matches across a mark boundary within a block', () => { + // `he**llo**` is two text nodes in one paragraph; a per-text-node search would miss it. + expect(matchedText('he**llo** world', 'hello')).toEqual(['hello']) + }) + + it('never matches across a block boundary', () => { + expect(matchedText('ab\n\ncd', 'abcd')).toEqual([]) + }) + + it('never matches across an inline atom', () => { + // The image between them occupies a position; joining `a` to `b` would be a phantom match. + expect(matchedText('a![alt](https://x.com/i.png)b', 'ab')).toEqual([]) + }) + + it('keeps positions correct after an inline atom', () => { + expect(matchedText('![alt](https://x.com/i.png) target', 'target')).toEqual(['target']) + }) + + it('does not overlap matches of a self-overlapping term', () => { + expect(matchedText('aaaa', 'aa')).toEqual(['aa', 'aa']) + }) + + it('caps the match set and reports it as truncated', () => { + const doc = docFor(Array.from({ length: FIND_MATCH_LIMIT + 10 }, () => 'x').join(' ')) + const { matches, truncated } = findMatches(doc, 'x') + expect(matches).toHaveLength(FIND_MATCH_LIMIT) + expect(truncated).toBe(true) + }) + + it('honors a caller-supplied limit', () => { + const { matches, truncated } = findMatches(docFor('x x x x'), 'x', 2) + expect(matches).toHaveLength(2) + expect(truncated).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.ts new file mode 100644 index 00000000000..5133557d52f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/find-matches.ts @@ -0,0 +1,108 @@ +import { forEachSearchOccurrence } from '@sim/utils/string' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + +/** One match, as a document position range that `Decoration.inline` can be built from. */ +export interface FindMatch { + from: number + to: number +} + +export interface FindResult { + matches: readonly FindMatch[] + /** More matches existed than the cap allowed; the tail was dropped. */ + truncated: boolean +} + +/** + * Cap on matches collected per search. A find bar is a navigation affordance, not a report: past a + * few hundred hits the tally stops meaning anything, while the decoration set it would build grows + * with the document. The bar renders the cap as `500+` so the number on screen is never a lie. + */ +export const FIND_MATCH_LIMIT = 500 + +export const EMPTY_FIND_RESULT: FindResult = { matches: [], truncated: false } + +/** + * Stands in for one position of a non-text inline node (an image, a mention chip) so a match can + * never span one — searching `ab` must not join the `a` before an image to the `b` after it. U+FFFF + * is a permanent Unicode non-character, so no query can contain it and match the placeholder itself, + * and it is not whitespace, so the shared scan's whitespace folding leaves it alone. + */ +const ATOM_PLACEHOLDER = '￿' + +/** A run of the flattened block text, and the document position its first character sits at. */ +interface TextSegment { + textStart: number + docStart: number +} + +/** + * Case-insensitive, non-overlapping search of `query` across every textblock in `doc`, returning + * document position ranges. + * + * What counts as an occurrence is not decided here — {@link forEachSearchOccurrence} owns that for + * the whole app (workflow search, the canvas Note card, and this), including the whitespace fold + * that makes a typed space match a non-breaking one. This module only supplies the text to scan and + * maps the indices back to ProseMirror positions. + * + * Text is flattened per textblock rather than per text node, so a term still matches when it runs + * across a mark boundary (`he**llo**`), and never across a block boundary — which no on-screen line + * does. Each block flattens to a string whose length equals the block's content size, so a string + * index maps back to a document position by walking the segment it falls in. + */ +export function findMatches( + doc: ProseMirrorNode, + query: string, + limit: number = FIND_MATCH_LIMIT +): FindResult { + if (query.trim().length === 0) return EMPTY_FIND_RESULT + + const matches: FindMatch[] = [] + let truncated = false + + doc.descendants((node, pos) => { + if (truncated) return false + if (!node.isTextblock) return true + + // The common paragraph is a single text node, where the mapping is a constant offset and the + // segment table is pure garbage. Only a block mixing marks or atoms needs one built. + const soleChild = node.childCount === 1 ? node.firstChild : null + const soleText = soleChild?.isText ? (soleChild.text ?? null) : null + + let text = soleText ?? '' + let segments: TextSegment[] | null = null + if (soleText === null) { + const built: TextSegment[] = [] + node.forEach((child, offset) => { + built.push({ textStart: text.length, docStart: pos + 1 + offset }) + text += child.isText && child.text ? child.text : ATOM_PLACEHOLDER.repeat(child.nodeSize) + }) + segments = built + } + + let segmentIndex = 0 + forEachSearchOccurrence(text, query, (start, end) => { + if (truncated) return + if (matches.length >= limit) { + truncated = true + return + } + if (!segments) { + matches.push({ from: pos + 1 + start, to: pos + 1 + end }) + return + } + // Segments are ordered and occurrences arrive left to right, so the cursor only moves forward. + while (segmentIndex + 1 < segments.length && segments[segmentIndex + 1].textStart <= start) { + segmentIndex++ + } + const segment = segments[segmentIndex] + const from = segment.docStart + (start - segment.textStart) + matches.push({ from, to: from + (end - start) }) + }) + + // Textblocks do not nest, so there is nothing below one to search. + return false + }) + + return matches.length === 0 && !truncated ? EMPTY_FIND_RESULT : { matches, truncated } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts new file mode 100644 index 00000000000..8f9b8b15704 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts @@ -0,0 +1,2 @@ +export { RichMarkdownFind } from './find-extension' +export { type MarkdownFindController, useMarkdownFind } from './use-markdown-find' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/use-markdown-find.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/use-markdown-find.ts new file mode 100644 index 00000000000..b0a97d5a6f6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/use-markdown-find.ts @@ -0,0 +1,168 @@ +'use client' + +import type React from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { Editor } from '@tiptap/react' +import { useFindShortcut } from '@/app/workspace/[workspaceId]/components' +import { ACTIVE_MATCH_CLASS, getFindTally, setFindQuery, stepFindMatch } from './find-extension' + +/** What the surface hands `FindBar`, plus the open state the shortcut drives. */ +export interface MarkdownFindController { + isOpen: boolean + query: string + count: number + currentIndex: number + truncated: boolean + inputRef: React.RefObject + setQuery: (query: string) => void + next: () => void + prev: () => void + close: () => void +} + +/** The only three values the bar renders, mirrored out of the plugin. */ +interface FindTally { + count: number + currentIndex: number + truncated: boolean +} + +const EMPTY_TALLY: FindTally = { count: 0, currentIndex: 0, truncated: false } + +interface UseMarkdownFindOptions { + editor: Editor | null + /** + * Whether this editor claims Cmd/Ctrl+F. Off wherever the component renders inside a preview or + * embedded pane, where the surface around it — not the document — owns the shortcut. See + * {@link useFindShortcut} for how the surfaces arbitrate. + */ + enabled: boolean +} + +/** + * Find-in-document for the rich markdown editor: owns the term, the tally and the stepping that + * `FindBar` renders, and reveals each match as it becomes active. + * + * The match set itself lives in the ProseMirror plugin (`./find-extension`), which re-searches on + * every document change. This subscribes to the editor's transactions while the bar is open and + * mirrors only the three numbers the bar shows, so a keystroke that leaves the tally identical + * re-renders nothing — the editor is configured not to re-render React on transactions, and this + * must not undo that. + */ +export function useMarkdownFind({ + editor, + enabled, +}: UseMarkdownFindOptions): MarkdownFindController { + const [isOpen, setIsOpen] = useState(false) + const [query, setQueryState] = useState('') + const [tally, setTally] = useState(EMPTY_TALLY) + const inputRef = useRef(null) + const editorRef = useRef(editor) + editorRef.current = editor + + /** + * Scrolls the active highlight into view. Read from the DOM rather than mapped from the position + * so it lands on what is actually painted — a match inside a node view (a code block, a table + * cell) is rendered by that view, and its own scroll container is the one that has to move. + */ + const revealActiveMatch = useCallback(() => { + requestAnimationFrame(() => { + editorRef.current?.view.dom + .querySelector(`.${ACTIVE_MATCH_CLASS}`) + ?.scrollIntoView({ block: 'center' }) + }) + }, []) + + /** + * The single point where plugin state becomes React state. Driven by the editor's own + * `transaction` event, which TipTap emits synchronously from every dispatch — including the ones + * `setQuery` and `step` make below, so neither needs to sync by hand. + */ + const syncTally = useCallback(() => { + const current = editorRef.current + if (!current) return + const { matches, activeIndex, truncated } = getFindTally(current.state) + setTally((previous) => + previous.count === matches.length && + previous.currentIndex === activeIndex && + previous.truncated === truncated + ? previous + : { count: matches.length, currentIndex: activeIndex, truncated } + ) + }, []) + + /** + * Keeps the tally honest while the bar is open — the document can change underneath a live search + * from the user's own typing, a collaborator, or a streaming agent edit, and the plugin re-searches + * on each of those. Not subscribed while the bar is closed, so typing costs nothing then. + */ + useEffect(() => { + if (!editor || !isOpen) return + syncTally() + editor.on('transaction', syncTally) + return () => { + editor.off('transaction', syncTally) + } + }, [editor, isOpen, syncTally]) + + /** + * Re-applies the live term to a newly arrived editor. `useEditor` returns null on the first render, + * so a term typed into the bar before the editor mounts would be held in React and never searched, + * leaving the bar at "No results" until the next keystroke pushed it through. + */ + const queryRef = useRef(query) + queryRef.current = query + useEffect(() => { + if (!editor || queryRef.current.length === 0) return + setFindQuery(editor, queryRef.current) + }, [editor]) + + const setQuery = useCallback( + (next: string) => { + setQueryState(next) + const current = editorRef.current + if (!current) return + setFindQuery(current, next) + revealActiveMatch() + }, + [revealActiveMatch] + ) + + const step = useCallback( + (delta: number) => { + const current = editorRef.current + if (!current) return + stepFindMatch(current, delta) + revealActiveMatch() + }, + [revealActiveMatch] + ) + + const next = useCallback(() => step(1), [step]) + const prev = useCallback(() => step(-1), [step]) + + /** Closing ends the search: term, highlights and active match all go. */ + const close = useCallback(() => { + setIsOpen(false) + setQueryState('') + setTally(EMPTY_TALLY) + const current = editorRef.current + if (current) setFindQuery(current, '') + }, []) + + const open = useCallback(() => setIsOpen(true), []) + useFindShortcut({ enabled, inputRef, onOpen: open }) + + return { + isOpen, + query, + count: tally.count, + currentIndex: tally.currentIndex, + truncated: tally.truncated, + inputRef, + setQuery, + next, + prev, + close, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index b148b2904f2..99e30205923 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -585,3 +585,16 @@ border-radius: 2px; pointer-events: none; } + +/* Cmd/Ctrl+F match highlights. Every hit carries the same tint the rest of the app + * paints a search match with; the active one is ringed rather than recolored, so the + * two read as one family and neither needs a token that only exists here. */ +.rich-markdown-nodes .rich-find-match { + background-color: var(--highlight-match-bg); + color: var(--highlight-match-text); + border-radius: 2px; +} + +.rich-markdown-nodes .rich-find-match-active { + box-shadow: 0 0 0 1.5px var(--highlight-match-text); +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 9684f1bab84..e9c171c59fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -16,6 +16,7 @@ import { } from '@/lib/copilot/chat/selection-context' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { extractEmbeddedFileRef, extractImgSrcs } from '@/lib/uploads/utils/embedded-image-ref' +import { FindBar } from '@/app/workspace/[workspaceId]/components' import { isUntitledName } from '@/app/workspace/[workspaceId]/files/untitled-title' import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' import { useAddToChat } from '@/hooks/use-add-to-chat' @@ -39,6 +40,7 @@ import { import { nextCollabReadiness } from './collaboration/readiness' import { useFileDocCollaboration } from './collaboration/use-file-doc-collaboration' import { createMarkdownEditorExtensions } from './editor-extensions' +import { useMarkdownFind } from './find' import { findHeadingPos } from './heading-anchors' import { moveDraggedImageNode } from './image-drag-move' import { extractImageFiles, findHostedImageAttrs, shouldSkipFileUpload } from './image-paste' @@ -159,6 +161,12 @@ interface RichMarkdownEditorProps { * {@link isUntitledName}. */ onDeriveTitleFromHeading?: (headingText: string) => void + /** + * Claim Cmd/Ctrl+F for find-in-document. Off by default, because this editor also renders as a + * preview pane beside something that owns the shortcut itself. Every find surface binds its own + * listener, so only one may be enabled at a time — see {@link useFindShortcut}. + */ + enableFind?: boolean } /** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */ @@ -180,6 +188,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ disableTagging, collaborative = false, onDeriveTitleFromHeading, + enableFind = false, }: RichMarkdownEditorProps) { const { data: session, isPending: isSessionPending } = useSession() const userId = session?.user?.id ?? '' @@ -253,6 +262,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ onSaveShortcut={saveImmediately} onCollabReadyChange={setCollabReady} onDeriveTitleFromHeading={onDeriveTitleFromHeading} + enableFind={enableFind} /> ) }) @@ -283,6 +293,8 @@ interface LoadedRichMarkdownEditorProps { onCollabReadyChange: (ready: boolean) => void /** See {@link RichMarkdownEditorProps.onDeriveTitleFromHeading}. */ onDeriveTitleFromHeading?: (headingText: string) => void + /** See {@link RichMarkdownEditorProps.enableFind}. */ + enableFind: boolean } interface SettledContent { @@ -314,6 +326,7 @@ export function LoadedRichMarkdownEditor({ onSaveShortcut, onCollabReadyChange, onDeriveTitleFromHeading, + enableFind, }: LoadedRichMarkdownEditorProps) { /** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */ const streamingAtMountRef = useRef(isStreaming) @@ -1208,43 +1221,70 @@ export function LoadedRichMarkdownEditor({ // shows the base content until the seed swaps it in, avoiding both a blank frame and a garbled merge. const showPlaceholder = collaborationEnabled && !collabReady + /** + * Find is off while the placeholder is up. The text on screen then belongs to the placeholder's own + * editor, not to `editor` — which is still empty and hidden — so searching `editor` would answer + * "No results" for text the user can see. Declining the shortcut hands it back to the browser, whose + * native find reads the rendered placeholder correctly; it becomes ours once the seed lands. + */ + const find = useMarkdownFind({ editor, enabled: enableFind && !showPlaceholder }) + return ( -
- {editor && ( - + {find.isOpen && ( + )} - {editor && } - {editor && } - { - const input = event.currentTarget - const images = Array.from(input.files ?? []).filter((f) => f.type.startsWith('image/')) - const at = - pendingImagePosRef.current ?? editorInstanceRef.current?.state.selection.from ?? 0 - pendingImagePosRef.current = null - input.value = '' - if (images.length > 0) void insertImagesRef.current(images, at) - }} - /> - {showPlaceholder && placeholderContent && ( - - )} - +
+ {editor && ( + + )} + {editor && } + {editor && } + { + const input = event.currentTarget + const images = Array.from(input.files ?? []).filter((f) => f.type.startsWith('image/')) + const at = + pendingImagePosRef.current ?? editorInstanceRef.current?.state.selection.from ?? 0 + pendingImagePosRef.current = null + input.value = '' + if (images.length > 0) void insertImagesRef.current(images, at) + }} + /> + {showPlaceholder && placeholderContent && ( + + )} + +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index f81334b9512..dc7d32ec991 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -70,6 +70,7 @@ import { resourceListState, selectionLabel, timeCell, + useFindShortcut, useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { @@ -1584,25 +1585,14 @@ export function Files() { /** * Overrides the browser's Cmd/Ctrl+F with the in-list find while the list is - * showing. Skipped when a file is open — its editor owns the shortcut there — - * and when another surface already claimed the press. + * showing. Handed to the open file's editor instead once one is open. */ - useEffect(() => { - const handleFindShortcut = (e: KeyboardEvent) => { - if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return - if (e.key.toLowerCase() !== 'f') return - if (fileIdFromRouteRef.current) return - if (e.defaultPrevented) return - e.preventDefault() - setFindOpen(true) - requestAnimationFrame(() => { - findInputRef.current?.focus() - findInputRef.current?.select() - }) - } - document.addEventListener('keydown', handleFindShortcut) - return () => document.removeEventListener('keydown', handleFindShortcut) - }, []) + const handleFindOpen = useCallback(() => setFindOpen(true), []) + useFindShortcut({ + enabled: !fileIdFromRoute, + inputRef: findInputRef, + onOpen: handleFindOpen, + }) const handleCyclePreviewMode = useCallback(() => { setPreviewMode((prev) => { @@ -2138,6 +2128,7 @@ export function Files() { discardRef={discardRef} collaborative onDeriveTitleFromHeading={handleDeriveTitleFromHeading} + enableFind /> ) diff --git a/packages/utils/src/string.test.ts b/packages/utils/src/string.test.ts index abac5dc7f45..718079ebf2a 100644 --- a/packages/utils/src/string.test.ts +++ b/packages/utils/src/string.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { + forEachSearchOccurrence, formatQuotedNameList, isVersionedType, normalizeEmail, @@ -205,3 +206,43 @@ describe('projectEscapedMarkdownForSearch', () => { expect(starts[text.length]).toBe(source.length) }) }) + +describe('forEachSearchOccurrence', () => { + const spans = (text: string, query: string, caseSensitive?: boolean) => { + const found: string[] = [] + forEachSearchOccurrence( + text, + query, + (start, end) => found.push(text.slice(start, end)), + caseSensitive + ) + return found + } + + it('visits every non-overlapping occurrence, case-insensitively by default', () => { + expect(spans('Ab ab AB', 'ab')).toEqual(['Ab', 'ab', 'AB']) + expect(spans('aaaa', 'aa')).toEqual(['aa', 'aa']) + expect(spans('Ab', 'ab', true)).toEqual([]) + }) + + it('folds whitespace so a typed space matches a non-breaking one', () => { + expect(spans('a\u00a0b', 'a b')).toEqual(['a\u00a0b']) + }) + + it('visits nothing for an empty query', () => { + expect(spans('abc', '')).toEqual([]) + }) + + it('keeps context-sensitive lowercasing in the length-preserving fallback', () => { + // '\u0130' expands, so the whole-string fast path is unavailable. Folding the rest character by + // character would lowercase the word-final '\u03a3' to '\u03c3' instead of '\u03c2', making one + // unrelated code point change how every sigma in the string matches. + expect(spans('\u0130\u03a3', '\u0130\u03c2')).toEqual(['\u0130\u03a3']) + }) + + it('reports bounds into the caller\u2019s own string after a length-changing lowercase', () => { + // '\u0130'.toLowerCase() is TWO characters. A plain lowercase would slide every later index by + // one, so the caller would slice the wrong span out of the string it passed in. + expect(spans('\u0130xyz target', 'target')).toEqual(['target']) + }) +}) diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 8d9a26b7a11..c4ba5d8d221 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -207,6 +207,40 @@ export function foldSearchWhitespace(value: string): string { return value.replace(/\s/g, ' ') } +/** + * Lowercases without ever changing the string's length. + * + * A plain `toLowerCase()` cannot be used where an index into the result has to + * address the same character of the input: a few code points lowercase to more + * than one (`'\u0130'.toLowerCase()` is two characters), which slides every + * later index. Characters that would grow are left alone — they simply match + * case-sensitively. The whole-string form is tried first because it is a single + * intrinsic and is length-preserving for every input that contains no such code + * point, i.e. essentially all of them. + * + * The fallback still reads each character's replacement out of the whole-string + * result rather than lowercasing it in isolation, because some lowercasing is + * context-sensitive: a word-final `\u03a3` lowercases to `\u03c2` in the string + * but to `\u03c3` on its own. Folding character by character would make one + * expanding code point elsewhere in the string silently change how every sigma + * in it matches. + */ +function lowerPreservingLength(value: string): string { + const lowered = value.toLowerCase() + if (lowered.length === value.length) return lowered + let result = '' + let loweredOffset = 0 + for (const char of value) { + const loweredChar = char.toLowerCase() + result += + loweredChar.length === char.length + ? lowered.slice(loweredOffset, loweredOffset + loweredChar.length) + : char + loweredOffset += loweredChar.length + } + return result +} + /** * Visits every occurrence of `query` in `text`, without overlaps. * @@ -217,8 +251,11 @@ export function foldSearchWhitespace(value: string): string { * panel counts a match the card never paints, which is exactly the bug that * arrived when only the whitespace fold was shared and the scan was not. * - * Whitespace is folded first (see {@link foldSearchWhitespace}) and the fold is - * one-to-one, so both bounds index the caller's own unfolded string. + * Whitespace is folded first (see {@link foldSearchWhitespace}) and case is + * folded with {@link lowerPreservingLength}; both are one-to-one, so the bounds + * index the caller's own unfolded string. A plain `toLowerCase()` here would + * break that guarantee for the handful of code points that lowercase to two — + * every occurrence after one would be reported a character late. */ export function forEachSearchOccurrence( text: string, @@ -230,7 +267,7 @@ export function forEachSearchOccurrence( const normalize = (value: string) => { const folded = foldSearchWhitespace(value) - return caseSensitive ? folded : folded.toLowerCase() + return caseSensitive ? folded : lowerPreservingLength(folded) } const haystack = normalize(text) const needle = normalize(query)