Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/app/f/[token]/public-file-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export function PublicFileView({
contentSource={source}
canEdit={false}
readOnly
enableFind
/>
</main>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement | null>
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])
}
1 change: 1 addition & 0 deletions apps/sim/app/workspace/[workspaceId]/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -165,6 +172,7 @@ function FileViewerContent({
previewContextKey,
collaborative,
onDeriveTitleFromHeading,
enableFind = false,
}: FileViewerProps) {
const category = resolveFileCategory(file.type, file.name)

Expand All @@ -181,7 +189,13 @@ function FileViewerContent({
// the bubble menu, and every other editing affordance.
if (isMarkdownFile(file)) {
return (
<RichMarkdownEditor key={file.id} file={file} workspaceId={workspaceId} canEdit={false} />
<RichMarkdownEditor
key={file.id}
file={file}
workspaceId={workspaceId}
canEdit={false}
enableFind={enableFind}
/>
)
}
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
Expand Down Expand Up @@ -212,6 +226,7 @@ function FileViewerContent({
previewContextKey={previewContextKey}
collaborative={collaborative}
onDeriveTitleFromHeading={onDeriveTitleFromHeading}
enableFind={enableFind}
/>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -94,6 +95,7 @@ export function createMarkdownEditorExtensions({
]
: []),
CodeBlockHighlight,
RichMarkdownFind,
SlashCommand,
Mention,
RichMarkdownKeymap,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading