From 1e0a77fe8bf8a98f05ee92e6c0a15f0b589dead5 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Mon, 23 Mar 2026 19:12:01 +0900 Subject: [PATCH 1/6] feat: move main editor over to react-prosemirror --- apps/desktop/package.json | 13 + apps/desktop/src/editor/image-view.tsx | 226 ++++++++ apps/desktop/src/editor/index.tsx | 226 ++++++++ apps/desktop/src/editor/keymap.ts | 227 ++++++++ apps/desktop/src/editor/mention.tsx | 445 +++++++++++++++ apps/desktop/src/editor/plugins.ts | 519 ++++++++++++++++++ apps/desktop/src/editor/schema.ts | 380 +++++++++++++ .../components/caret-position-context.tsx | 29 +- .../components/note-input/enhanced/editor.tsx | 16 +- .../components/note-input/enhanced/index.tsx | 5 +- .../session/components/note-input/index.tsx | 106 ++-- .../src/session/components/note-input/raw.tsx | 33 +- .../components/note-input/search-replace.ts | 14 +- .../components/note-input/use-search-sync.ts | 29 +- apps/desktop/src/session/index.tsx | 6 +- package.json | 4 + pnpm-lock.yaml | 81 ++- 17 files changed, 2228 insertions(+), 131 deletions(-) create mode 100644 apps/desktop/src/editor/image-view.tsx create mode 100644 apps/desktop/src/editor/index.tsx create mode 100644 apps/desktop/src/editor/keymap.ts create mode 100644 apps/desktop/src/editor/mention.tsx create mode 100644 apps/desktop/src/editor/plugins.ts create mode 100644 apps/desktop/src/editor/schema.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6ac40ca7a8..ad316166df 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -32,7 +32,9 @@ "@codemirror/state": "^6.5.4", "@codemirror/view": "^6.39.13", "@effect/schema": "^0.75.5", + "@floating-ui/dom": "^1.7.6", "@floating-ui/react": "^0.27.17", + "@handlewithcare/react-prosemirror": "^2.8.4", "@hypr/api-client": "workspace:*", "@hypr/changelog": "workspace:^", "@hypr/codemirror": "workspace:^", @@ -131,6 +133,16 @@ "nlcst-to-string": "^4.0.0", "ollama": "^0.6.3", "posthog-js": "^1.358.0", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.41.6", "re-resizable": "^6.11.2", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -145,6 +157,7 @@ "streamdown": "^2.2.0", "tinybase": "^7.3.2", "tinytick": "^1.2.8", + "tlds": "^1.261.0", "unified": "^11.0.5", "usehooks-ts": "^3.1.1", "vfile": "^6.0.3", diff --git a/apps/desktop/src/editor/image-view.tsx b/apps/desktop/src/editor/image-view.tsx new file mode 100644 index 0000000000..117ccee614 --- /dev/null +++ b/apps/desktop/src/editor/image-view.tsx @@ -0,0 +1,226 @@ +import { + type NodeViewComponentProps, + useEditorEventCallback, +} from "@handlewithcare/react-prosemirror"; +import { AllSelection, NodeSelection } from "prosemirror-state"; +import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; + +import { + DEFAULT_EDITOR_WIDTH, + normalizeEditorWidth, + stripEditorWidthFromTitle, +} from "@hypr/tiptap/shared"; +import { cn } from "@hypr/utils"; + +export const ResizableImageView = forwardRef< + HTMLElement, + NodeViewComponentProps +>(({ nodeProps, ...htmlAttrs }, ref) => { + const { node, getPos } = nodeProps; + const [isHovered, setIsHovered] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [isRangeSelected, setIsRangeSelected] = useState(false); + const [isAllSelected, setIsAllSelected] = useState(false); + const [draftWidth, setDraftWidth] = useState(null); + const containerRef = useRef(null); + const imageRef = useRef(null); + const draftWidthRef = useRef(null); + const resizeStateRef = useRef<{ + direction: "left" | "right"; + editorWidth: number; + startWidth: number; + startX: number; + } | null>(null); + + const updateAttributes = useEditorEventCallback( + (view, attrs: Record) => { + if (!view) return; + const pos = getPos(); + const tr = view.state.tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + ...attrs, + }); + view.dispatch(tr); + }, + ); + + const checkSelection = useEditorEventCallback((view) => { + if (!view) return; + const pos = getPos(); + const { doc, selection } = view.state; + const nodeStart = pos; + const nodeEnd = pos + node.nodeSize; + const isNodeSel = + selection instanceof NodeSelection && selection.from === nodeStart; + const includesNode = + !selection.empty && + !isNodeSel && + selection.from <= nodeStart && + selection.to >= nodeEnd; + + setIsRangeSelected(includesNode); + setIsAllSelected( + selection instanceof AllSelection || + (selection.from <= 1 && selection.to >= doc.content.size - 1), + ); + }); + + useEffect(() => { + checkSelection(); + }); + + useEffect(() => { + if (!isResizing) return; + + const handlePointerMove = (event: PointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState) return; + + const deltaX = + (event.clientX - resizeState.startX) * + (resizeState.direction === "left" ? -1 : 1); + const nextWidth = Math.min( + resizeState.editorWidth, + Math.max(120, resizeState.startWidth + deltaX), + ); + + draftWidthRef.current = nextWidth; + setDraftWidth(nextWidth); + }; + + const handlePointerUp = () => { + const resizeState = resizeStateRef.current; + if (!resizeState || !draftWidthRef.current) { + resizeStateRef.current = null; + draftWidthRef.current = null; + setIsResizing(false); + setDraftWidth(null); + return; + } + + updateAttributes({ + editorWidth: normalizeEditorWidth( + (draftWidthRef.current / resizeState.editorWidth) * 100, + ), + }); + + resizeStateRef.current = null; + draftWidthRef.current = null; + setIsResizing(false); + setDraftWidth(null); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; + }, [isResizing, updateAttributes]); + + const handleResizeStart = useCallback( + ( + direction: "left" | "right", + event: React.PointerEvent, + ) => { + const container = containerRef.current; + const image = imageRef.current; + if (!container || !image) return; + + event.preventDefault(); + event.stopPropagation(); + + const editorElement = container.closest(".ProseMirror"); + const editorWidth = + editorElement?.getBoundingClientRect().width ?? + container.getBoundingClientRect().width; + + resizeStateRef.current = { + direction, + editorWidth, + startWidth: image.getBoundingClientRect().width, + startX: event.clientX, + }; + + draftWidthRef.current = image.getBoundingClientRect().width; + setIsResizing(true); + setDraftWidth(image.getBoundingClientRect().width); + }, + [], + ); + + const selected = nodeProps.decorations.some( + (d) => (d as any).type?.name === "selected", + ); + + const isSelected = selected || isRangeSelected; + const showControls = !isAllSelected && (isHovered || selected || isResizing); + const editorWidth = + normalizeEditorWidth(node.attrs.editorWidth) ?? DEFAULT_EDITOR_WIDTH; + const imageWidth = + draftWidth !== null ? `${draftWidth}px` : `${editorWidth}%`; + + return ( +
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {node.attrs.alt + {showControls && ( + <> + + ); +}); + +ResizableImageView.displayName = "ResizableImageView"; diff --git a/apps/desktop/src/editor/index.tsx b/apps/desktop/src/editor/index.tsx new file mode 100644 index 0000000000..1fd7b212a6 --- /dev/null +++ b/apps/desktop/src/editor/index.tsx @@ -0,0 +1,226 @@ +import { + ProseMirror, + ProseMirrorDoc, + reactKeys, + useEditorEffect, +} from "@handlewithcare/react-prosemirror"; +import { dropCursor } from "prosemirror-dropcursor"; +import { gapCursor } from "prosemirror-gapcursor"; +import { history } from "prosemirror-history"; +import { Node as PMNode } from "prosemirror-model"; +import { EditorState, type Transaction } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from "react"; +import { useDebounceCallback } from "usehooks-ts"; + +import "@hypr/tiptap/styles.css"; + +import { ResizableImageView } from "./image-view"; +import { buildInputRules, buildKeymap } from "./keymap"; +import { + type MentionConfig, + MentionNodeView, + MentionSuggestion, + mentionSkipPlugin, + mentionSuggestionPlugin, +} from "./mention"; +import { + type FileHandlerConfig, + type PlaceholderFunction, + type SearchAndReplaceStorage, + clearMarksOnEnterPlugin, + clipPastePlugin, + createSearchStorage, + fileHandlerPlugin, + hashtagPlugin, + linkBoundaryGuardPlugin, + placeholderPlugin, + searchAndReplacePlugin, +} from "./plugins"; +import { schema } from "./schema"; + +export type { MentionConfig, FileHandlerConfig, PlaceholderFunction }; +export { schema }; +export type { SearchAndReplaceStorage }; + +export interface JSONContent { + type?: string; + attrs?: Record; + content?: JSONContent[]; + marks?: { type: string; attrs?: Record }[]; + text?: string; +} + +export interface NoteEditorRef { + view: EditorView | null; + searchStorage: SearchAndReplaceStorage; +} + +interface EditorProps { + handleChange?: (content: JSONContent) => void; + initialContent?: JSONContent; + editable?: boolean; + setContentFromOutside?: boolean; + mentionConfig?: MentionConfig; + placeholderComponent?: PlaceholderFunction; + fileHandlerConfig?: FileHandlerConfig; + onNavigateToTitle?: () => void; +} + +const nodeViews = { + image: ResizableImageView, + "mention-@": MentionNodeView, +}; + +function ViewCapture({ + viewRef, + onViewReady, +}: { + viewRef: React.RefObject; + onViewReady: (view: EditorView) => void; +}) { + useEditorEffect((view) => { + if (view && viewRef.current !== view) { + viewRef.current = view; + onViewReady(view); + } + }); + return null; +} + +const NoteEditor = forwardRef((props, ref) => { + const { + handleChange, + initialContent, + editable = true, + setContentFromOutside = false, + mentionConfig, + placeholderComponent, + fileHandlerConfig, + onNavigateToTitle, + } = props; + + const previousContentRef = useRef(initialContent); + const searchStorage = useMemo(() => createSearchStorage(), []); + const viewRef = useRef(null); + + useImperativeHandle(ref, () => ({ view: viewRef.current, searchStorage }), [ + searchStorage, + ]); + + const onUpdate = useDebounceCallback((view: EditorView) => { + if (!handleChange) return; + handleChange(view.state.doc.toJSON() as JSONContent); + }, 500); + + const plugins = useMemo( + () => [ + reactKeys(), + buildInputRules(), + buildKeymap(onNavigateToTitle), + history(), + dropCursor(), + gapCursor(), + hashtagPlugin(), + searchAndReplacePlugin(searchStorage), + placeholderPlugin(placeholderComponent), + clearMarksOnEnterPlugin(), + clipPastePlugin(), + linkBoundaryGuardPlugin(), + ...(mentionConfig + ? [mentionSuggestionPlugin(mentionConfig.trigger), mentionSkipPlugin()] + : []), + ...(fileHandlerConfig ? [fileHandlerPlugin(fileHandlerConfig)] : []), + ], + [ + searchStorage, + placeholderComponent, + fileHandlerConfig, + mentionConfig, + onNavigateToTitle, + ], + ); + + const defaultState = useMemo(() => { + let doc: PMNode; + try { + doc = + initialContent && initialContent.type === "doc" + ? PMNode.fromJSON(schema, initialContent) + : schema.node("doc", null, [schema.node("paragraph")]); + } catch { + doc = schema.node("doc", null, [schema.node("paragraph")]); + } + return EditorState.create({ doc, plugins }); + }, []); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + if (previousContentRef.current === initialContent) return; + previousContentRef.current = initialContent; + + if (!initialContent || initialContent.type !== "doc") return; + + if (setContentFromOutside || !view.hasFocus()) { + try { + const doc = PMNode.fromJSON(schema, initialContent); + const state = EditorState.create({ + doc, + plugins: view.state.plugins, + }); + view.updateState(state); + } catch { + // invalid content + } + } + }, [initialContent, setContentFromOutside]); + + const editableRef = useRef(editable); + editableRef.current = editable; + + const onViewReady = useCallback( + (view: EditorView) => { + onUpdate(view); + }, + [onUpdate], + ); + + return ( + editableRef.current} + dispatchTransaction={function (this: EditorView, tr: Transaction) { + const newState = this.state.apply(tr); + this.updateState(newState); + if (tr.docChanged) { + onUpdate(this); + } + }} + attributes={{ + spellcheck: "false", + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + role: "textbox", + }} + className="tiptap" + > + + + {mentionConfig && } + + ); +}); + +NoteEditor.displayName = "NoteEditor"; + +export default NoteEditor; diff --git a/apps/desktop/src/editor/keymap.ts b/apps/desktop/src/editor/keymap.ts new file mode 100644 index 0000000000..8cf20755dc --- /dev/null +++ b/apps/desktop/src/editor/keymap.ts @@ -0,0 +1,227 @@ +import { + chainCommands, + createParagraphNear, + exitCode, + joinBackward, + joinForward, + liftEmptyBlock, + newlineInCode, + selectNodeBackward, + selectNodeForward, + splitBlock, + toggleMark, +} from "prosemirror-commands"; +import { redo, undo } from "prosemirror-history"; +import { + InputRule, + inputRules, + textblockTypeInputRule, + wrappingInputRule, +} from "prosemirror-inputrules"; +import { keymap } from "prosemirror-keymap"; +import type { NodeType } from "prosemirror-model"; +import { + liftListItem, + sinkListItem, + splitListItem, +} from "prosemirror-schema-list"; +import type { Command, EditorState } from "prosemirror-state"; + +import { schema } from "./schema"; + +function isInListItem(state: EditorState): string | null { + const { $from } = state.selection; + for (let depth = $from.depth; depth > 0; depth--) { + const name = $from.node(depth).type.name; + if (name === "listItem" || name === "taskItem") return name; + } + return null; +} + +// --------------------------------------------------------------------------- +// Input rules +// --------------------------------------------------------------------------- +function headingRule(nodeType: NodeType, maxLevel: number) { + return textblockTypeInputRule( + new RegExp(`^(#{1,${maxLevel}})\\s$`), + nodeType, + (match) => ({ level: match[1].length }), + ); +} + +function blockquoteRule(nodeType: NodeType) { + return wrappingInputRule(/^\s*>\s$/, nodeType); +} + +function bulletListRule(nodeType: NodeType) { + return wrappingInputRule(/^\s*([-+*])\s$/, nodeType); +} + +function orderedListRule(nodeType: NodeType) { + return wrappingInputRule( + /^\s*(\d+)\.\s$/, + nodeType, + (match) => ({ start: +match[1] }), + (match, node) => node.childCount + node.attrs.start === +match[1], + ); +} + +function codeBlockRule(nodeType: NodeType) { + return textblockTypeInputRule(/^```$/, nodeType); +} + +function horizontalRuleRule() { + return new InputRule( + /^(?:---|___|\*\*\*)\s$/, + (state, _match, start, end) => { + const hr = schema.nodes.horizontalRule.create(); + return state.tr.replaceWith(start - 1, end, [ + hr, + schema.nodes.paragraph.create(), + ]); + }, + ); +} + +function taskListRule() { + return new InputRule(/^\s*\[([ x])\]\s$/, (state, match, start, end) => { + const checked = match[1] === "x"; + const taskItem = schema.nodes.taskItem.create( + { checked }, + schema.nodes.paragraph.create(), + ); + const taskList = schema.nodes.taskList.create(null, taskItem); + return state.tr.replaceWith(start - 1, end, taskList); + }); +} + +export function buildInputRules() { + return inputRules({ + rules: [ + headingRule(schema.nodes.heading, 6), + blockquoteRule(schema.nodes.blockquote), + bulletListRule(schema.nodes.bulletList), + orderedListRule(schema.nodes.orderedList), + codeBlockRule(schema.nodes.codeBlock), + horizontalRuleRule(), + taskListRule(), + ], + }); +} + +// --------------------------------------------------------------------------- +// Keymaps +// --------------------------------------------------------------------------- +const mac = + typeof navigator !== "undefined" + ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) + : false; + +export function buildKeymap(onNavigateToTitle?: () => void) { + const hardBreak = schema.nodes.hardBreak; + + const keys: Record = {}; + + keys["Mod-z"] = undo; + keys["Mod-Shift-z"] = redo; + if (!mac) keys["Mod-y"] = redo; + + keys["Mod-b"] = toggleMark(schema.marks.bold); + keys["Mod-i"] = toggleMark(schema.marks.italic); + keys["Mod-`"] = toggleMark(schema.marks.code); + + const hardBreakCmd: Command = chainCommands(exitCode, (state, dispatch) => { + if (dispatch) { + dispatch( + state.tr.replaceSelectionWith(hardBreak.create()).scrollIntoView(), + ); + } + return true; + }); + keys["Shift-Enter"] = hardBreakCmd; + if (mac) keys["Mod-Enter"] = hardBreakCmd; + + keys["Enter"] = chainCommands( + newlineInCode, + (state, dispatch) => { + const itemName = isInListItem(state); + if (!itemName) return false; + const { $from } = state.selection; + if ($from.parent.content.size !== 0) return false; + const nodeType = state.schema.nodes[itemName]; + if (!nodeType) return false; + return liftListItem(nodeType)(state, dispatch); + }, + (state, dispatch) => { + const itemName = isInListItem(state); + if (!itemName) return false; + const nodeType = state.schema.nodes[itemName]; + if (!nodeType) return false; + return splitListItem(nodeType)(state, dispatch); + }, + createParagraphNear, + liftEmptyBlock, + splitBlock, + ); + + keys["Backspace"] = chainCommands( + (state, _dispatch) => { + const { selection } = state; + if (selection.$head.pos === 0 && selection.empty) return true; + return false; + }, + joinBackward, + selectNodeBackward, + ); + + keys["Delete"] = chainCommands(joinForward, selectNodeForward); + + keys["Tab"] = (state, dispatch) => { + const itemName = isInListItem(state); + if (!itemName) return false; + const nodeType = state.schema.nodes[itemName]; + if (!nodeType) return false; + return sinkListItem(nodeType)(state, dispatch); + }; + + keys["Shift-Tab"] = (state, dispatch) => { + const itemName = isInListItem(state); + if (!itemName) { + if (onNavigateToTitle) { + const { $from } = state.selection; + const firstBlock = state.doc.firstChild; + if (firstBlock && $from.start($from.depth) <= 2) { + onNavigateToTitle(); + return true; + } + } + return false; + } + const nodeType = state.schema.nodes[itemName]; + if (!nodeType) return false; + return liftListItem(nodeType)(state, dispatch); + }; + + if (onNavigateToTitle) { + keys["ArrowUp"] = (state) => { + const { $head } = state.selection; + + let node = state.doc.firstChild; + let firstTextBlockPos = 0; + while (node && !node.isTextblock) { + firstTextBlockPos += 1; + node = node.firstChild; + } + + if (!node) return false; + const isInFirstBlock = $head.start($head.depth) === firstTextBlockPos + 1; + + if (!isInFirstBlock) return false; + + onNavigateToTitle(); + return true; + }; + } + + return keymap(keys); +} diff --git a/apps/desktop/src/editor/mention.tsx b/apps/desktop/src/editor/mention.tsx new file mode 100644 index 0000000000..39688e199d --- /dev/null +++ b/apps/desktop/src/editor/mention.tsx @@ -0,0 +1,445 @@ +import { + autoUpdate, + computePosition, + flip, + limitShift, + offset, + shift, + type VirtualElement, +} from "@floating-ui/dom"; +import { + type NodeViewComponentProps, + useEditorEffect, + useEditorEventCallback, +} from "@handlewithcare/react-prosemirror"; +import { Facehash, stringHash } from "facehash"; +import { + Building2Icon, + MessageSquareIcon, + StickyNoteIcon, + UserIcon, +} from "lucide-react"; +import { + NodeSelection, + Plugin, + PluginKey, + TextSelection, +} from "prosemirror-state"; +import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { cn } from "@hypr/utils"; + +import { schema } from "./schema"; + +const GLOBAL_NAVIGATE_FUNCTION = "__HYPR_NAVIGATE__"; + +export interface MentionItem { + id: string; + type: string; + label: string; + content?: string; +} + +export type MentionConfig = { + trigger: string; + handleSearch: (query: string) => Promise; +}; + +// --------------------------------------------------------------------------- +// Suggestion plugin +// --------------------------------------------------------------------------- +interface SuggestionState { + active: boolean; + query: string; + from: number; + to: number; +} + +export const mentionSuggestionKey = new PluginKey( + "mentionSuggestion", +); + +export function isMentionActive( + state: import("prosemirror-state").EditorState, +): boolean { + const pluginState = mentionSuggestionKey.getState(state); + return pluginState?.active === true; +} + +function findSuggestion( + state: import("prosemirror-state").EditorState, + trigger: string, +): SuggestionState | null { + const { $from } = state.selection; + if (!state.selection.empty) return null; + + const textBefore = $from.parent.textBetween( + 0, + $from.parentOffset, + undefined, + "\ufffc", + ); + + const triggerIndex = textBefore.lastIndexOf(trigger); + if (triggerIndex === -1) return null; + if (triggerIndex > 0 && !/\s/.test(textBefore[triggerIndex - 1])) return null; + + const query = textBefore.slice(triggerIndex + trigger.length); + if (/\s/.test(query)) return null; + + const from = $from.start() + triggerIndex; + const to = $from.pos; + + return { active: true, query, from, to }; +} + +export function mentionSuggestionPlugin(trigger: string) { + return new Plugin({ + key: mentionSuggestionKey, + state: { + init: () => ({ active: false, query: "", from: 0, to: 0 }), + apply(tr, prev, _oldState, newState) { + const meta = tr.getMeta(mentionSuggestionKey); + if (meta?.deactivate) { + return { active: false, query: "", from: 0, to: 0 }; + } + if (tr.docChanged || tr.selectionSet) { + return ( + findSuggestion(newState, trigger) ?? { + active: false, + query: "", + from: 0, + to: 0, + } + ); + } + return prev; + }, + }, + props: { + handleKeyDown(view, event) { + const state = mentionSuggestionKey.getState(view.state); + if (!state?.active) return false; + + if (event.key === "Escape") { + view.dispatch( + view.state.tr.setMeta(mentionSuggestionKey, { deactivate: true }), + ); + return true; + } + + if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { + return true; + } + + return false; + }, + }, + }); +} + +// --------------------------------------------------------------------------- +// Mention popup +// --------------------------------------------------------------------------- +const FACEHASH_BG_CLASSES = [ + "bg-amber-50", + "bg-rose-50", + "bg-violet-50", + "bg-blue-50", + "bg-teal-50", + "bg-green-50", + "bg-cyan-50", + "bg-fuchsia-50", + "bg-indigo-50", + "bg-yellow-50", +]; + +function getMentionFacehashBgClass(name: string) { + const hash = stringHash(name); + return FACEHASH_BG_CLASSES[hash % FACEHASH_BG_CLASSES.length]; +} + +function MentionAvatar({ + id, + type, + label, +}: { + id: string; + type: string; + label: string; +}) { + if (type === "human") { + const facehashName = label || id || "?"; + const bgClass = getMentionFacehashBgClass(facehashName); + return ( + + + + ); + } + + const Icon = + type === "session" + ? StickyNoteIcon + : type === "organization" + ? Building2Icon + : type === "chat_shortcut" + ? MessageSquareIcon + : UserIcon; + + return ( + + + + ); +} + +export function MentionSuggestion({ config }: { config: MentionConfig }) { + const [items, setItems] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [active, setActive] = useState(false); + const [query, setQuery] = useState(""); + const popupRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + + const insertMention = useEditorEventCallback((view, item: MentionItem) => { + if (!view) return; + const state = mentionSuggestionKey.getState(view.state); + if (!state?.active) return; + + const mentionNode = schema.nodes["mention-@"].create({ + id: item.id, + type: item.type, + label: item.label, + }); + const space = schema.text(" "); + + const tr = view.state.tr + .replaceWith(state.from, state.to, [mentionNode, space]) + .setMeta(mentionSuggestionKey, { deactivate: true }); + + view.dispatch(tr); + view.focus(); + }); + + useEditorEffect((view) => { + if (!view) return; + const state = mentionSuggestionKey.getState(view.state); + const isActive = state?.active ?? false; + + setActive(isActive); + setQuery(state?.query ?? ""); + + if (!isActive) { + cleanupRef.current?.(); + cleanupRef.current = null; + return; + } + + const popup = popupRef.current; + if (!popup) return; + + const coords = view.coordsAtPos(state!.from); + const referenceEl: VirtualElement = { + getBoundingClientRect: () => + new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top), + }; + + const update = () => { + void computePosition(referenceEl, popup, { + placement: "bottom-start", + middleware: [offset(4), flip(), shift({ limiter: limitShift() })], + }).then(({ x, y }) => { + Object.assign(popup.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); + }; + + cleanupRef.current?.(); + cleanupRef.current = autoUpdate(referenceEl, popup, update); + update(); + }); + + useEffect(() => { + if (!active) { + setItems([]); + setSelectedIndex(0); + return; + } + + config + .handleSearch(query) + .then((results) => { + setItems(results.slice(0, 5)); + setSelectedIndex(0); + }) + .catch(() => { + setItems([]); + }); + }, [active, query, config]); + + useEffect(() => { + if (!active) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex( + (prev) => (prev + items.length - 1) % Math.max(items.length, 1), + ); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % Math.max(items.length, 1)); + } else if (e.key === "Enter") { + e.preventDefault(); + const item = items[selectedIndex]; + if (item) insertMention(item); + } + }; + + document.addEventListener("keydown", handleKeyDown, true); + return () => document.removeEventListener("keydown", handleKeyDown, true); + }, [active, items, selectedIndex, insertMention]); + + if (!active || items.length === 0) return null; + + return createPortal( +
+ {items.map((item, index) => ( + + ))} +
, + document.body, + ); +} + +// --------------------------------------------------------------------------- +// Mention node view +// --------------------------------------------------------------------------- +export const MentionNodeView = forwardRef( + ({ nodeProps, ...htmlAttrs }, ref) => { + const { node } = nodeProps; + const { id, type, label } = node.attrs; + const mentionId = String(id ?? ""); + const mentionType = String(type ?? ""); + const mentionLabel = String(label ?? ""); + const MAX_MENTION_LENGTH = 20; + const displayLabel = + mentionLabel.length > MAX_MENTION_LENGTH + ? mentionLabel.slice(0, MAX_MENTION_LENGTH) + "…" + : mentionLabel; + const path = `/app/${mentionType}/${mentionId}`; + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const navigate = (window as any)[GLOBAL_NAVIGATE_FUNCTION]; + if (navigate) navigate(path); + }, + [path], + ); + + return ( + + + + {displayLabel} + + + ); + }, +); + +MentionNodeView.displayName = "MentionNodeView"; + +// --------------------------------------------------------------------------- +// Mention keyboard skip plugin +// --------------------------------------------------------------------------- +export function mentionSkipPlugin() { + const mentionName = "mention-@"; + + return new Plugin({ + key: new PluginKey("mentionSkip"), + props: { + handleKeyDown(view, event) { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { + return false; + } + + const { state } = view; + const { selection } = state; + const direction = event.key === "ArrowLeft" ? "left" : "right"; + + if ( + selection instanceof NodeSelection && + selection.node.type.name === mentionName + ) { + const pos = direction === "left" ? selection.from : selection.to; + view.dispatch( + state.tr.setSelection(TextSelection.create(state.doc, pos)), + ); + return true; + } + + if (!selection.empty) return false; + + const $pos = selection.$head; + const node = direction === "left" ? $pos.nodeBefore : $pos.nodeAfter; + + if (node && node.type.name === mentionName) { + const newPos = + direction === "left" + ? $pos.pos - node.nodeSize + : $pos.pos + node.nodeSize; + view.dispatch( + state.tr.setSelection(TextSelection.create(state.doc, newPos)), + ); + return true; + } + + return false; + }, + }, + }); +} diff --git a/apps/desktop/src/editor/plugins.ts b/apps/desktop/src/editor/plugins.ts new file mode 100644 index 0000000000..2c3f61b904 --- /dev/null +++ b/apps/desktop/src/editor/plugins.ts @@ -0,0 +1,519 @@ +import { type Mark, type Node as PMNode } from "prosemirror-model"; +import { Plugin, PluginKey, type Transaction } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import tldList from "tlds"; + +import { + findHashtags, + parseYouTubeClipId, + parseYouTubeEmbedSnippet, + parseYouTubeUrl, + resolveYouTubeClipUrl, +} from "@hypr/tiptap/shared"; + +import { schema } from "./schema"; + +// --------------------------------------------------------------------------- +// Hashtag decorations +// --------------------------------------------------------------------------- +export const hashtagPluginKey = new PluginKey("hashtagDecoration"); + +export function hashtagPlugin() { + return new Plugin({ + key: hashtagPluginKey, + props: { + decorations(state) { + const { doc } = state; + const decorations: Decoration[] = []; + + doc.descendants((node: PMNode, pos: number) => { + if (!node.isText || !node.text) return; + for (const match of findHashtags(node.text)) { + decorations.push( + Decoration.inline(pos + match.start, pos + match.end, { + class: "hashtag", + }), + ); + } + }); + + return DecorationSet.create(doc, decorations); + }, + }, + }); +} + +// --------------------------------------------------------------------------- +// Search and Replace +// --------------------------------------------------------------------------- +export interface SearchAndReplaceStorage { + searchTerm: string; + replaceTerm: string; + results: { from: number; to: number }[]; + lastSearchTerm: string; + caseSensitive: boolean; + lastCaseSensitive: boolean; + resultIndex: number; + lastResultIndex: number; +} + +export function createSearchStorage(): SearchAndReplaceStorage { + return { + searchTerm: "", + replaceTerm: "", + results: [], + lastSearchTerm: "", + caseSensitive: false, + lastCaseSensitive: false, + resultIndex: 0, + lastResultIndex: 0, + }; +} + +export const searchPluginKey = new PluginKey("searchAndReplace"); + +function processSearches( + doc: PMNode, + searchTerm: RegExp, + searchResultClass: string, + resultIndex: number, +): { decorations: DecorationSet; results: { from: number; to: number }[] } { + const decorations: Decoration[] = []; + const results: { from: number; to: number }[] = []; + + let textNodesWithPosition: { text: string; pos: number }[] = []; + let index = 0; + + doc.descendants((node, pos) => { + if (node.isText) { + if (textNodesWithPosition[index]) { + textNodesWithPosition[index] = { + text: textNodesWithPosition[index].text + node.text, + pos: textNodesWithPosition[index].pos, + }; + } else { + textNodesWithPosition[index] = { text: `${node.text}`, pos }; + } + } else { + index += 1; + } + }); + + textNodesWithPosition = textNodesWithPosition.filter(Boolean); + + for (const element of textNodesWithPosition) { + const { text, pos } = element; + const matches = Array.from(text.matchAll(searchTerm)).filter( + ([matchText]) => matchText.trim(), + ); + + for (const m of matches) { + if (m[0] === "" || m.index === undefined) continue; + results.push({ from: pos + m.index, to: pos + m.index + m[0].length }); + } + } + + for (let i = 0; i < results.length; i++) { + const r = results[i]; + const className = + i === resultIndex + ? `${searchResultClass} ${searchResultClass}-current` + : searchResultClass; + decorations.push(Decoration.inline(r.from, r.to, { class: className })); + } + + return { + decorations: DecorationSet.create(doc, decorations), + results, + }; +} + +export function searchAndReplacePlugin(storage: SearchAndReplaceStorage) { + return new Plugin({ + key: searchPluginKey, + state: { + init: () => DecorationSet.empty, + apply({ doc, docChanged }, oldState) { + const { + searchTerm, + lastSearchTerm, + caseSensitive, + lastCaseSensitive, + resultIndex, + lastResultIndex, + } = storage; + + if ( + !docChanged && + lastSearchTerm === searchTerm && + lastCaseSensitive === caseSensitive && + lastResultIndex === resultIndex + ) { + return oldState; + } + + storage.lastSearchTerm = searchTerm; + storage.lastCaseSensitive = caseSensitive; + storage.lastResultIndex = resultIndex; + + if (!searchTerm) { + storage.results = []; + return DecorationSet.empty; + } + + const escaped = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escaped, caseSensitive ? "gu" : "gui"); + + const { decorations, results } = processSearches( + doc, + regex, + "search-result", + resultIndex, + ); + + storage.results = results; + return decorations; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); +} + +// --------------------------------------------------------------------------- +// Placeholder +// --------------------------------------------------------------------------- +export type PlaceholderFunction = (props: { + node: PMNode; + pos: number; + hasAnchor: boolean; +}) => string; + +export const placeholderPluginKey = new PluginKey("placeholder"); + +export function placeholderPlugin(placeholder?: PlaceholderFunction) { + return new Plugin({ + key: placeholderPluginKey, + props: { + decorations(state) { + const { doc, selection } = state; + const { anchor } = selection; + const decorations: Decoration[] = []; + + const isEmptyDoc = + doc.childCount === 1 && + doc.firstChild!.isTextblock && + doc.firstChild!.content.size === 0; + + doc.descendants((node, pos) => { + const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize; + const isEmpty = !node.isLeaf && node.content.size === 0; + + if (hasAnchor && isEmpty) { + const classes = ["is-empty"]; + if (isEmptyDoc) classes.push("is-editor-empty"); + + const text = placeholder + ? placeholder({ node, pos, hasAnchor }) + : ""; + + if (text) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + class: classes.join(" "), + "data-placeholder": text, + }), + ); + } + } + + return false; + }); + + return DecorationSet.create(doc, decorations); + }, + }, + }); +} + +// --------------------------------------------------------------------------- +// Clear marks on enter +// --------------------------------------------------------------------------- +const INLINE_MARK_NAMES = ["bold", "italic"]; + +export function clearMarksOnEnterPlugin() { + return new Plugin({ + key: new PluginKey("clearMarksOnEnter"), + appendTransaction(transactions, oldState, newState) { + if (!transactions.some((tr) => tr.docChanged)) return null; + if (newState.doc.content.size <= oldState.doc.content.size) return null; + + const { $head } = newState.selection; + const currentNode = $head.parent; + + if ( + currentNode.type.name !== "paragraph" || + currentNode.content.size !== 0 || + $head.parentOffset !== 0 + ) { + return null; + } + + const storedMarks = newState.storedMarks; + if (!storedMarks || storedMarks.length === 0) return null; + + const filtered = storedMarks.filter( + (mark) => !INLINE_MARK_NAMES.includes(mark.type.name), + ); + + if (filtered.length === storedMarks.length) return null; + return newState.tr.setStoredMarks(filtered); + }, + }); +} + +// --------------------------------------------------------------------------- +// Clip paste handler (YouTube embeds) +// --------------------------------------------------------------------------- +export function clipPastePlugin() { + const nodeType = schema.nodes.clip; + return new Plugin({ + key: new PluginKey("clipPaste"), + props: { + handlePaste(view, event) { + const text = event.clipboardData?.getData("text/plain") || ""; + const html = event.clipboardData?.getData("text/html") || ""; + + const embedSnippet = parseYouTubeEmbedSnippet(html || text); + if (embedSnippet) { + const { tr } = view.state; + const node = nodeType.create({ src: embedSnippet.embedUrl }); + tr.replaceSelectionWith(node); + view.dispatch(tr); + return true; + } + + if (!text) return false; + + const clipId = parseYouTubeClipId(text); + if (clipId) { + resolveYouTubeClipUrl(clipId).then((resolved) => { + if (!resolved) return; + const node = nodeType.create({ src: resolved.embedUrl }); + const tr = view.state.tr.replaceSelectionWith(node); + view.dispatch(tr); + }); + return true; + } + + const parsed = parseYouTubeUrl(text); + if (!parsed) return false; + + const { tr } = view.state; + const node = nodeType.create({ src: parsed.embedUrl }); + tr.replaceSelectionWith(node); + view.dispatch(tr); + return true; + }, + }, + }); +} + +// --------------------------------------------------------------------------- +// Link boundary guard +// --------------------------------------------------------------------------- +const VALID_TLDS = new Set(tldList.map((t: string) => t.toLowerCase())); + +function isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return false; + } + const parts = parsed.hostname.split("."); + if (parts.length < 2) return false; + return VALID_TLDS.has(parts[parts.length - 1].toLowerCase()); + } catch { + return false; + } +} + +export function linkBoundaryGuardPlugin() { + return new Plugin({ + key: new PluginKey("linkBoundaryGuard"), + appendTransaction(transactions, _oldState, newState) { + if (!transactions.some((tr) => tr.docChanged)) return null; + const linkType = newState.schema.marks.link; + if (!linkType) return null; + + let tr: Transaction | null = null; + let prevLink: { + startPos: number; + endPos: number; + mark: Mark; + } | null = null; + + newState.doc.descendants((node, pos) => { + if (!node.isText || !node.text) { + prevLink = null; + return; + } + + const linkMark = node.marks.find((m) => m.type === linkType); + + if (linkMark) { + const textLooksLikeUrl = + node.text.startsWith("https://") || node.text.startsWith("http://"); + + if (textLooksLikeUrl && !isValidUrl(node.text)) { + if (!tr) tr = newState.tr; + tr.removeMark(pos, pos + node.text.length, linkType); + prevLink = null; + } else if (node.text === linkMark.attrs.href) { + prevLink = { + startPos: pos, + endPos: pos + node.text.length, + mark: linkMark, + }; + } else if (textLooksLikeUrl) { + const updatedMark = linkType.create({ + ...linkMark.attrs, + href: node.text, + }); + if (!tr) tr = newState.tr; + tr.removeMark(pos, pos + node.text.length, linkType); + tr.addMark(pos, pos + node.text.length, updatedMark); + prevLink = { + startPos: pos, + endPos: pos + node.text.length, + mark: updatedMark, + }; + } else { + prevLink = null; + } + } else if (prevLink && pos === prevLink.endPos && node.text) { + if (!/^\s/.test(node.text[0])) { + const wsIdx = node.text.search(/\s/); + const extendLen = wsIdx >= 0 ? wsIdx : node.text.length; + const newHref = + prevLink.mark.attrs.href + node.text.slice(0, extendLen); + if (isValidUrl(newHref)) { + if (!tr) tr = newState.tr; + tr.removeMark(prevLink.startPos, prevLink.endPos, linkType); + tr.addMark( + prevLink.startPos, + pos + extendLen, + linkType.create({ ...prevLink.mark.attrs, href: newHref }), + ); + } + } + prevLink = null; + } else { + prevLink = null; + } + }); + + return tr; + }, + }); +} + +// --------------------------------------------------------------------------- +// File handler (image drop/paste) +// --------------------------------------------------------------------------- +export type FileHandlerConfig = { + onDrop?: (files: File[], pos?: number) => boolean | void; + onPaste?: (files: File[]) => boolean | void; + onImageUpload?: ( + file: File, + ) => Promise<{ url: string; attachmentId: string }>; +}; + +const IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; + +export function fileHandlerPlugin(config: FileHandlerConfig) { + const imageType = schema.nodes.image; + + function insertImage( + view: import("prosemirror-view").EditorView, + url: string, + attachmentId: string | null, + pos?: number, + ) { + const node = imageType.create({ src: url, attachmentId }); + const tr = + pos != null + ? view.state.tr.insert(pos, node) + : view.state.tr.replaceSelectionWith(node); + view.dispatch(tr); + } + + async function handleFiles( + view: import("prosemirror-view").EditorView, + files: File[], + pos?: number, + ) { + for (const file of files) { + if (!IMAGE_MIME_TYPES.includes(file.type)) continue; + + if (config.onImageUpload) { + try { + const { url, attachmentId } = await config.onImageUpload(file); + insertImage(view, url, attachmentId, pos); + } catch (error) { + console.error("Failed to upload image:", error); + } + } else { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + insertImage(view, reader.result as string, null, pos); + }; + } + } + } + + return new Plugin({ + key: new PluginKey("fileHandler"), + props: { + handleDrop(view, event) { + const files = Array.from(event.dataTransfer?.files ?? []).filter((f) => + IMAGE_MIME_TYPES.includes(f.type), + ); + if (files.length === 0) return false; + + event.preventDefault(); + const pos = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + })?.pos; + + if (config.onDrop) { + const result = config.onDrop(files, pos); + if (result === false) return false; + } + + handleFiles(view, files, pos); + return true; + }, + + handlePaste(view, event) { + const files = Array.from(event.clipboardData?.files ?? []).filter((f) => + IMAGE_MIME_TYPES.includes(f.type), + ); + if (files.length === 0) return false; + + if (config.onPaste) { + const result = config.onPaste(files); + if (result === false) return false; + } + + handleFiles(view, files); + return true; + }, + }, + }); +} diff --git a/apps/desktop/src/editor/schema.ts b/apps/desktop/src/editor/schema.ts new file mode 100644 index 0000000000..fcd6b41bf8 --- /dev/null +++ b/apps/desktop/src/editor/schema.ts @@ -0,0 +1,380 @@ +import { type MarkSpec, type NodeSpec, Schema } from "prosemirror-model"; + +import { parseYouTubeUrl } from "@hypr/tiptap/shared"; +import { + DEFAULT_EDITOR_WIDTH, + normalizeEditorWidth, + parseImageTitleMetadata, + stripEditorWidthFromTitle, +} from "@hypr/tiptap/shared"; + +// Node names match Tiptap for JSON content compatibility. +const nodes: Record = { + doc: { content: "block+" }, + + paragraph: { + content: "inline*", + group: "block", + parseDOM: [{ tag: "p" }], + toDOM() { + return ["p", 0]; + }, + }, + + text: { group: "inline" }, + + heading: { + content: "inline*", + group: "block", + attrs: { level: { default: 1 } }, + defining: true, + parseDOM: [1, 2, 3, 4, 5, 6].map((level) => ({ + tag: `h${level}`, + attrs: { level }, + })), + toDOM(node) { + return [`h${node.attrs.level}`, 0]; + }, + }, + + blockquote: { + content: "block+", + group: "block", + defining: true, + parseDOM: [{ tag: "blockquote" }], + toDOM() { + return ["blockquote", 0]; + }, + }, + + codeBlock: { + content: "text*", + marks: "", + group: "block", + code: true, + defining: true, + parseDOM: [{ tag: "pre", preserveWhitespace: "full" }], + toDOM() { + return ["pre", ["code", 0]]; + }, + }, + + horizontalRule: { + group: "block", + parseDOM: [{ tag: "hr" }], + toDOM() { + return ["hr"]; + }, + }, + + hardBreak: { + inline: true, + group: "inline", + selectable: false, + parseDOM: [{ tag: "br" }], + toDOM() { + return ["br"]; + }, + }, + + bulletList: { + content: "listItem+", + group: "block", + parseDOM: [{ tag: "ul:not([data-type])" }], + toDOM() { + return ["ul", 0]; + }, + }, + + orderedList: { + content: "listItem+", + group: "block", + attrs: { start: { default: 1 } }, + parseDOM: [ + { + tag: "ol", + getAttrs(dom) { + const el = dom as HTMLElement; + return { + start: el.hasAttribute("start") ? +el.getAttribute("start")! : 1, + }; + }, + }, + ], + toDOM(node) { + return node.attrs.start === 1 + ? ["ol", 0] + : ["ol", { start: node.attrs.start }, 0]; + }, + }, + + listItem: { + content: "paragraph block*", + defining: true, + parseDOM: [{ tag: "li:not([data-type])" }], + toDOM() { + return ["li", 0]; + }, + }, + + taskList: { + content: "taskItem+", + group: "block", + parseDOM: [{ tag: 'ul[data-type="taskList"]' }], + toDOM() { + return ["ul", { "data-type": "taskList", class: "task-list" }, 0]; + }, + }, + + taskItem: { + content: "paragraph block*", + defining: true, + attrs: { checked: { default: false } }, + parseDOM: [ + { + tag: 'li[data-type="taskItem"]', + getAttrs(dom) { + return { + checked: + (dom as HTMLElement).getAttribute("data-checked") === "true", + }; + }, + }, + ], + toDOM(node) { + return [ + "li", + { + "data-type": "taskItem", + "data-checked": node.attrs.checked ? "true" : "false", + }, + 0, + ]; + }, + }, + + image: { + group: "block", + draggable: true, + attrs: { + src: { default: null }, + alt: { default: null }, + title: { default: null }, + attachmentId: { default: null }, + editorWidth: { default: DEFAULT_EDITOR_WIDTH }, + }, + parseDOM: [ + { + tag: "img[src]", + getAttrs(dom) { + const el = dom as HTMLElement; + const title = el.getAttribute("title"); + const metadata = parseImageTitleMetadata(title); + return { + src: el.getAttribute("src"), + alt: el.getAttribute("alt"), + title: stripEditorWidthFromTitle(title), + attachmentId: el.getAttribute("data-attachment-id"), + editorWidth: + normalizeEditorWidth( + Number(el.getAttribute("data-editor-width")), + ) ?? + metadata.editorWidth ?? + DEFAULT_EDITOR_WIDTH, + }; + }, + }, + ], + toDOM(node) { + const attrs: Record = {}; + if (node.attrs.src) attrs.src = node.attrs.src; + if (node.attrs.alt) attrs.alt = node.attrs.alt; + if (node.attrs.title) attrs.title = node.attrs.title; + if (node.attrs.attachmentId) { + attrs["data-attachment-id"] = node.attrs.attachmentId; + } + if (node.attrs.editorWidth) { + attrs["data-editor-width"] = String(node.attrs.editorWidth); + } + return ["img", attrs]; + }, + }, + + "mention-@": { + group: "inline", + inline: true, + atom: true, + selectable: true, + attrs: { + id: { default: null }, + type: { default: null }, + label: { default: null }, + }, + parseDOM: [ + { + tag: 'a.mention[data-mention="true"]', + getAttrs(dom) { + const el = dom as HTMLElement; + return { + id: el.getAttribute("data-id"), + type: el.getAttribute("data-type"), + label: el.getAttribute("data-label"), + }; + }, + }, + { + tag: "mention", + getAttrs(dom) { + const el = dom as HTMLElement; + return { + id: el.getAttribute("data-id"), + type: el.getAttribute("data-type"), + label: el.getAttribute("data-label"), + }; + }, + }, + ], + toDOM(node) { + return [ + "a", + { + class: "mention", + "data-mention": "true", + "data-id": node.attrs.id, + "data-type": node.attrs.type, + "data-label": node.attrs.label, + href: "javascript:void(0)", + }, + node.attrs.label || "", + ]; + }, + }, + + clip: { + group: "block", + atom: true, + attrs: { src: { default: null } }, + parseDOM: [ + { + tag: 'div[data-type="clip"]', + getAttrs(dom) { + const src = (dom as HTMLElement).getAttribute("data-src"); + const parsed = src ? parseYouTubeUrl(src) : null; + return parsed ? { src: parsed.embedUrl } : false; + }, + }, + { + tag: "iframe[src]", + getAttrs(dom) { + const src = (dom as HTMLElement).getAttribute("src"); + const parsed = src ? parseYouTubeUrl(src) : null; + return parsed ? { src: parsed.embedUrl } : false; + }, + }, + ], + toDOM(node) { + return ["div", { "data-type": "clip", "data-src": node.attrs.src }]; + }, + }, +}; + +const marks: Record = { + bold: { + parseDOM: [ + { tag: "strong" }, + { + tag: "b", + getAttrs: (node) => + (node as HTMLElement).style.fontWeight !== "normal" && null, + }, + { + style: "font-weight=400", + clearMark: (m) => m.type.name === "bold", + }, + { + style: "font-weight", + getAttrs: (value) => + /^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null, + }, + ], + toDOM() { + return ["strong", 0]; + }, + }, + + italic: { + parseDOM: [ + { tag: "em" }, + { + tag: "i", + getAttrs: (node) => + (node as HTMLElement).style.fontStyle !== "normal" && null, + }, + { style: "font-style=italic" }, + ], + toDOM() { + return ["em", 0]; + }, + }, + + strike: { + parseDOM: [ + { tag: "s" }, + { tag: "del" }, + { + style: "text-decoration", + getAttrs: (value) => (value as string).includes("line-through") && null, + }, + ], + toDOM() { + return ["s", 0]; + }, + }, + + code: { + excludes: "_", + parseDOM: [{ tag: "code" }], + toDOM() { + return ["code", 0]; + }, + }, + + link: { + attrs: { + href: {}, + target: { default: null }, + }, + inclusive: false, + parseDOM: [ + { + tag: "a[href]", + getAttrs(dom) { + return { + href: (dom as HTMLElement).getAttribute("href"), + target: (dom as HTMLElement).getAttribute("target"), + }; + }, + }, + ], + toDOM(node) { + return [ + "a", + { + href: node.attrs.href, + target: node.attrs.target, + rel: "noopener noreferrer nofollow", + }, + 0, + ]; + }, + }, + + highlight: { + parseDOM: [{ tag: "mark" }], + toDOM() { + return ["mark", 0]; + }, + }, +}; + +export const schema = new Schema({ nodes, marks }); diff --git a/apps/desktop/src/session/components/caret-position-context.tsx b/apps/desktop/src/session/components/caret-position-context.tsx index c2c8d68c33..ddc036c743 100644 --- a/apps/desktop/src/session/components/caret-position-context.tsx +++ b/apps/desktop/src/session/components/caret-position-context.tsx @@ -1,3 +1,4 @@ +import type { EditorView } from "prosemirror-view"; import { createContext, type ReactNode, @@ -8,8 +9,6 @@ import { useState, } from "react"; -import type { TiptapEditor } from "@hypr/tiptap/editor"; - interface CaretPositionContextValue { isCaretNearBottom: boolean; setCaretNearBottom: (value: boolean) => void; @@ -45,11 +44,11 @@ export function useCaretPosition() { const BOTTOM_THRESHOLD = 70; export function useCaretNearBottom({ - editor, + view, container, enabled, }: { - editor: TiptapEditor | null; + view: EditorView | null; container: HTMLDivElement | null; enabled: boolean; }) { @@ -60,17 +59,16 @@ export function useCaretNearBottom({ return; } - if (!editor || !container || !enabled) { + if (!view || !container || !enabled) { setCaretNearBottom(false); return; } const checkCaretPosition = () => { - if (!container || !editor.isFocused) { + if (!container || !view.hasFocus()) { return; } - const { view } = editor; const { from } = view.state.selection; const coords = view.coordsAtPos(from); @@ -81,20 +79,23 @@ export function useCaretNearBottom({ const handleBlur = () => setCaretNearBottom(false); - editor.on("selectionUpdate", checkCaretPosition); - editor.on("focus", checkCaretPosition); - editor.on("blur", handleBlur); + const dom = view.dom; + dom.addEventListener("focus", checkCaretPosition); + dom.addEventListener("blur", handleBlur); + dom.addEventListener("keyup", checkCaretPosition); + dom.addEventListener("mouseup", checkCaretPosition); container.addEventListener("scroll", checkCaretPosition); window.addEventListener("resize", checkCaretPosition); checkCaretPosition(); return () => { - editor.off("selectionUpdate", checkCaretPosition); - editor.off("focus", checkCaretPosition); - editor.off("blur", handleBlur); + dom.removeEventListener("focus", checkCaretPosition); + dom.removeEventListener("blur", handleBlur); + dom.removeEventListener("keyup", checkCaretPosition); + dom.removeEventListener("mouseup", checkCaretPosition); container.removeEventListener("scroll", checkCaretPosition); window.removeEventListener("resize", checkCaretPosition); }; - }, [editor, setCaretNearBottom, container, enabled]); + }, [view, setCaretNearBottom, container, enabled]); } diff --git a/apps/desktop/src/session/components/note-input/enhanced/editor.tsx b/apps/desktop/src/session/components/note-input/enhanced/editor.tsx index 1569966c3d..7df5ff0c98 100644 --- a/apps/desktop/src/session/components/note-input/enhanced/editor.tsx +++ b/apps/desktop/src/session/components/note-input/enhanced/editor.tsx @@ -1,16 +1,14 @@ import { forwardRef, useMemo } from "react"; -import { commands as openerCommands } from "@hypr/plugin-opener2"; -import { type JSONContent, TiptapEditor } from "@hypr/tiptap/editor"; -import NoteEditor from "@hypr/tiptap/editor"; import { parseJsonContent } from "@hypr/tiptap/shared"; +import NoteEditor, { type JSONContent, type NoteEditorRef } from "~/editor"; import { useSearchEngine } from "~/search/contexts/engine"; import { useImageUpload } from "~/shared/hooks/useImageUpload"; import * as main from "~/store/tinybase/store/main"; export const EnhancedEditor = forwardRef< - { editor: TiptapEditor | null }, + NoteEditorRef, { sessionId: string; enhancedNoteId: string; onNavigateToTitle?: () => void } >(({ sessionId, enhancedNoteId, onNavigateToTitle }, ref) => { const onImageUpload = useImageUpload(sessionId); @@ -88,15 +86,6 @@ export const EnhancedEditor = forwardRef< const fileHandlerConfig = useMemo(() => ({ onImageUpload }), [onImageUpload]); - const extensionOptions = useMemo( - () => ({ - onLinkOpen: (url: string) => { - void openerCommands.openUrl(url, null); - }, - }), - [], - ); - return (
); diff --git a/apps/desktop/src/session/components/note-input/enhanced/index.tsx b/apps/desktop/src/session/components/note-input/enhanced/index.tsx index 985690eeff..f7b820a786 100644 --- a/apps/desktop/src/session/components/note-input/enhanced/index.tsx +++ b/apps/desktop/src/session/components/note-input/enhanced/index.tsx @@ -1,7 +1,5 @@ import { forwardRef } from "react"; -import { type TiptapEditor } from "@hypr/tiptap/editor"; - import { ConfigError } from "./config-error"; import { EnhancedEditor } from "./editor"; import { EnhanceError } from "./enhance-error"; @@ -9,11 +7,12 @@ import { StreamingView } from "./streaming"; import { useAITaskTask } from "~/ai/hooks"; import { useLLMConnectionStatus } from "~/ai/hooks"; +import type { NoteEditorRef } from "~/editor"; import * as main from "~/store/tinybase/store/main"; import { createTaskId } from "~/store/zustand/ai-task/task-configs"; export const Enhanced = forwardRef< - { editor: TiptapEditor | null }, + NoteEditorRef, { sessionId: string; enhancedNoteId: string; onNavigateToTitle?: () => void } >(({ sessionId, enhancedNoteId, onNavigateToTitle }, ref) => { const taskId = createTaskId(enhancedNoteId, "enhance"); diff --git a/apps/desktop/src/session/components/note-input/index.tsx b/apps/desktop/src/session/components/note-input/index.tsx index ebe3b5d6fa..3369922e95 100644 --- a/apps/desktop/src/session/components/note-input/index.tsx +++ b/apps/desktop/src/session/components/note-input/index.tsx @@ -1,4 +1,6 @@ import { convertFileSrc } from "@tauri-apps/api/core"; +import { TextSelection } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; import { forwardRef, useCallback, @@ -11,7 +13,6 @@ import { import { useHotkeys } from "react-hotkeys-hook"; import { commands as fsSyncCommands } from "@hypr/plugin-fs-sync"; -import type { TiptapEditor } from "@hypr/tiptap/editor"; import { ScrollFadeOverlay, useScrollFade, @@ -26,15 +27,16 @@ import { Transcript } from "./transcript"; import { SearchBar } from "./transcript/search/bar"; import { useSearchSync } from "./use-search-sync"; +import type { NoteEditorRef } from "~/editor"; import { useCaretNearBottom } from "~/session/components/caret-position-context"; import { useCurrentNoteTab } from "~/session/components/shared"; import { useScrollPreservation } from "~/shared/hooks/useScrollPreservation"; import { type Tab, useTabs } from "~/store/zustand/tabs"; -import { type EditorView } from "~/store/zustand/tabs/schema"; +import { type EditorView as TabEditorView } from "~/store/zustand/tabs/schema"; import { useListener } from "~/stt/contexts"; export const NoteInput = forwardRef< - { editor: TiptapEditor | null }, + NoteEditorRef, { tab: Extract; onNavigateToTitle?: () => void; @@ -42,18 +44,33 @@ export const NoteInput = forwardRef< >(({ tab, onNavigateToTitle }, ref) => { const editorTabs = useEditorTabs({ sessionId: tab.id }); const updateSessionTabState = useTabs((state) => state.updateSessionTabState); - const internalEditorRef = useRef<{ editor: TiptapEditor | null }>(null); + const internalEditorRef = useRef(null); const [container, setContainer] = useState(null); - const [editor, setEditor] = useState(null); + const [view, setView] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const sessionId = tab.id; const tabRef = useRef(tab); tabRef.current = tab; - const currentTab: EditorView = useCurrentNoteTab(tab); + const currentTab: TabEditorView = useCurrentNoteTab(tab); useImperativeHandle( ref, - () => internalEditorRef.current ?? { editor: null }, + () => + internalEditorRef.current ?? { + view: null, + searchStorage: { + searchTerm: "", + replaceTerm: "", + results: [], + lastSearchTerm: "", + caseSensitive: false, + lastCaseSensitive: false, + resultIndex: 0, + lastResultIndex: 0, + }, + }, [currentTab], ); @@ -76,11 +93,11 @@ export const NoteInput = forwardRef< const { atStart, atEnd } = useScrollFade(fadeRef, "vertical", [currentTab]); const handleTabChange = useCallback( - (view: EditorView) => { + (tabView: TabEditorView) => { onBeforeTabChange(); updateSessionTabState(tabRef.current, { ...tabRef.current.state, - view, + view: tabView, }); }, [onBeforeTabChange, updateSessionTabState], @@ -94,19 +111,18 @@ export const NoteInput = forwardRef< useEffect(() => { if (currentTab.type === "transcript" || currentTab.type === "attachments") { - internalEditorRef.current = { editor: null }; - setEditor(null); + setView(null); } else if (currentTab.type === "raw" && isMeetingInProgress) { requestAnimationFrame(() => { - internalEditorRef.current?.editor?.commands.focus(); + internalEditorRef.current?.view?.focus(); }); } }, [currentTab, isMeetingInProgress]); useEffect(() => { - const editorInstance = internalEditorRef.current?.editor ?? null; - if (editorInstance !== editor) { - setEditor(editorInstance); + const editorView = internalEditorRef.current?.view ?? null; + if (editorView !== view) { + setView(editorView); } }); @@ -114,31 +130,33 @@ export const NoteInput = forwardRef< const handleContentTransfer = (e: Event) => { const customEvent = e as CustomEvent<{ content: string }>; const content = customEvent.detail.content; - const editorInstance = internalEditorRef.current?.editor; + const v = internalEditorRef.current?.view; - if (editorInstance && content) { - editorInstance.commands.insertContentAt(0, content); - editorInstance.commands.setTextSelection(0); - editorInstance.commands.focus(); + if (v && content) { + const tr = v.state.tr.insertText(content, 0); + tr.setSelection(TextSelection.create(tr.doc, 0)); + v.dispatch(tr); + v.focus(); } }; const handleMoveToEditorStart = () => { - const editorInstance = internalEditorRef.current?.editor; - if (editorInstance) { - editorInstance.commands.setTextSelection(0); - editorInstance.commands.focus(); + const v = internalEditorRef.current?.view; + if (v) { + v.dispatch( + v.state.tr.setSelection(TextSelection.create(v.state.doc, 0)), + ); + v.focus(); } }; const handleMoveToEditorPosition = (e: Event) => { const customEvent = e as CustomEvent<{ pixelWidth: number }>; const pixelWidth = customEvent.detail.pixelWidth; - const editorInstance = internalEditorRef.current?.editor; + const v = internalEditorRef.current?.view; - if (editorInstance) { - const editorDom = editorInstance.view.dom; - const firstTextNode = editorDom.querySelector(".ProseMirror > *"); + if (v) { + const firstTextNode = v.dom.querySelector(".ProseMirror > *"); if (firstTextNode) { const editorStyle = window.getComputedStyle(firstTextNode); @@ -148,7 +166,7 @@ export const NoteInput = forwardRef< if (ctx) { ctx.font = `${editorStyle.fontWeight} ${editorStyle.fontSize} ${editorStyle.fontFamily}`; - const firstBlock = editorInstance.state.doc.firstChild; + const firstBlock = v.state.doc.firstChild; if (firstBlock && firstBlock.textContent) { const text = firstBlock.textContent; let charPos = 0; @@ -162,19 +180,22 @@ export const NoteInput = forwardRef< charPos = i; } - const targetPos = Math.min( - charPos, - editorInstance.state.doc.content.size - 1, + const targetPos = Math.min(charPos, v.state.doc.content.size - 1); + v.dispatch( + v.state.tr.setSelection( + TextSelection.create(v.state.doc, targetPos), + ), ); - editorInstance.commands.setTextSelection(targetPos); - editorInstance.commands.focus(); + v.focus(); return; } } } - editorInstance.commands.setTextSelection(0); - editorInstance.commands.focus(); + v.dispatch( + v.state.tr.setSelection(TextSelection.create(v.state.doc, 0)), + ); + v.focus(); } }; @@ -204,22 +225,21 @@ export const NoteInput = forwardRef< }, []); useCaretNearBottom({ - editor, + view, container, enabled: currentTab.type !== "transcript" && currentTab.type !== "attachments", }); const { showSearchBar } = useSearchSync({ - editor, + editorRef: internalEditorRef, currentTab, sessionId, - editorRef: internalEditorRef, }); const handleContainerClick = () => { if (currentTab.type !== "transcript" && currentTab.type !== "attachments") { - internalEditorRef.current?.editor?.commands.focus(); + internalEditorRef.current?.view?.focus(); } }; @@ -297,9 +317,9 @@ function useTabShortcuts({ currentTab, handleTabChange, }: { - editorTabs: EditorView[]; - currentTab: EditorView; - handleTabChange: (view: EditorView) => void; + editorTabs: TabEditorView[]; + currentTab: TabEditorView; + handleTabChange: (view: TabEditorView) => void; }) { useHotkeys( "alt+s", diff --git a/apps/desktop/src/session/components/note-input/raw.tsx b/apps/desktop/src/session/components/note-input/raw.tsx index 3b72a6dcee..8980a45874 100644 --- a/apps/desktop/src/session/components/note-input/raw.tsx +++ b/apps/desktop/src/session/components/note-input/raw.tsx @@ -1,22 +1,19 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; -import { commands as openerCommands } from "@hypr/plugin-opener2"; +import { parseJsonContent } from "@hypr/tiptap/shared"; + import NoteEditor, { type JSONContent, - type TiptapEditor, -} from "@hypr/tiptap/editor"; -import { - parseJsonContent, + type NoteEditorRef, type PlaceholderFunction, -} from "@hypr/tiptap/shared"; - +} from "~/editor"; import { useSearchEngine } from "~/search/contexts/engine"; import { useImageUpload } from "~/shared/hooks/useImageUpload"; import * as main from "~/store/tinybase/store/main"; export const RawEditor = forwardRef< - { editor: TiptapEditor | null }, + NoteEditorRef, { sessionId: string; onNavigateToTitle?: () => void } >(({ sessionId, onNavigateToTitle }, ref) => { const rawMd = main.UI.useCell("sessions", sessionId, "raw_md", main.STORE_ID); @@ -120,15 +117,6 @@ export const RawEditor = forwardRef< const fileHandlerConfig = useMemo(() => ({ onImageUpload }), [onImageUpload]); - const extensionOptions = useMemo( - () => ({ - onLinkOpen: (url: string) => { - void openerCommands.openUrl(url, null); - }, - }), - [], - ); - return ( ); }); const Placeholder: PlaceholderFunction = ({ node, pos }) => { - "use no memo"; if (node.type.name !== "paragraph") { return ""; } if (pos === 0) { - return ( -

- Take notes to guide Char's meeting notes.{" "} - - Press / for commands. - -

- ); + return "Take notes to guide Char's meeting notes. Press / for commands."; } return "Press / for commands."; diff --git a/apps/desktop/src/session/components/note-input/search-replace.ts b/apps/desktop/src/session/components/note-input/search-replace.ts index 7b0adfeb12..8202695b6f 100644 --- a/apps/desktop/src/session/components/note-input/search-replace.ts +++ b/apps/desktop/src/session/components/note-input/search-replace.ts @@ -1,4 +1,4 @@ -import type { TiptapEditor } from "@hypr/tiptap/editor"; +import type { EditorView } from "prosemirror-view"; import type { SearchReplaceDetail } from "./transcript/search/context"; @@ -223,11 +223,11 @@ export function handleTranscriptReplace( export function handleEditorReplace( detail: SearchReplaceDetail, - editor: TiptapEditor | null, + view: EditorView | null, ) { - if (!editor) return; + if (!view) return; - const doc = editor.state.doc; + const doc = view.state.doc; const searchQuery = detail.caseSensitive ? detail.query : detail.query.toLowerCase(); @@ -291,7 +291,7 @@ export function handleEditorReplace( if (!toReplace[0]) return; let offset = 0; - const tr = editor.state.tr; + const tr = view.state.tr; for (const hit of toReplace) { const adjustedFrom = hit.from + offset; @@ -300,7 +300,7 @@ export function handleEditorReplace( tr.replaceWith( adjustedFrom, adjustedTo, - editor.state.schema.text(detail.replacement), + view.state.schema.text(detail.replacement), ); } else { tr.delete(adjustedFrom, adjustedTo); @@ -308,5 +308,5 @@ export function handleEditorReplace( offset += detail.replacement.length - detail.query.length; } - editor.view.dispatch(tr); + view.dispatch(tr); } diff --git a/apps/desktop/src/session/components/note-input/use-search-sync.ts b/apps/desktop/src/session/components/note-input/use-search-sync.ts index b69c4938d2..5bca9c0b52 100644 --- a/apps/desktop/src/session/components/note-input/use-search-sync.ts +++ b/apps/desktop/src/session/components/note-input/use-search-sync.ts @@ -1,26 +1,23 @@ import { type MutableRefObject, useEffect } from "react"; -import type { TiptapEditor } from "@hypr/tiptap/editor"; - import { handleEditorReplace, handleTranscriptReplace } from "./search-replace"; import { type SearchReplaceDetail, useTranscriptSearch, } from "./transcript/search/context"; +import type { NoteEditorRef } from "~/editor"; import * as main from "~/store/tinybase/store/main"; import { type EditorView } from "~/store/zustand/tabs/schema"; export function useSearchSync({ - editor, + editorRef, currentTab, sessionId, - editorRef, }: { - editor: TiptapEditor | null; + editorRef: MutableRefObject; currentTab: EditorView; sessionId: string; - editorRef: MutableRefObject<{ editor: TiptapEditor | null } | null>; }) { const search = useTranscriptSearch(); const showSearchBar = search?.isVisible ?? false; @@ -30,32 +27,32 @@ export function useSearchSync({ }, [currentTab]); useEffect(() => { - if (!editor?.storage?.searchAndReplace) return; + const noteRef = editorRef.current; + if (!noteRef?.view) return; + const { searchStorage } = noteRef; const isEditorTab = currentTab.type !== "transcript" && currentTab.type !== "attachments"; const query = isEditorTab && search?.isVisible ? (search.query ?? "") : ""; - editor.storage.searchAndReplace.searchTerm = query; - editor.storage.searchAndReplace.caseSensitive = - search?.caseSensitive ?? false; - editor.storage.searchAndReplace.resultIndex = - search?.currentMatchIndex ?? 0; + searchStorage.searchTerm = query; + searchStorage.caseSensitive = search?.caseSensitive ?? false; + searchStorage.resultIndex = search?.currentMatchIndex ?? 0; try { - editor.view.dispatch(editor.state.tr); + noteRef.view.dispatch(noteRef.view.state.tr); } catch { return; } if (query) { requestAnimationFrame(() => { - const el = editor.view.dom.querySelector(".search-result-current"); + const el = noteRef.view?.dom.querySelector(".search-result-current"); el?.scrollIntoView({ behavior: "smooth", block: "center" }); }); } }, [ - editor, + editorRef, currentTab.type, search?.isVisible, search?.query, @@ -73,7 +70,7 @@ export function useSearchSync({ if (currentTab.type === "transcript") { handleTranscriptReplace(detail, store, indexes, checkpoints, sessionId); } else { - handleEditorReplace(detail, editorRef.current?.editor ?? null); + handleEditorReplace(detail, editorRef.current?.view ?? null); } }; window.addEventListener("search-replace", handler); diff --git a/apps/desktop/src/session/index.tsx b/apps/desktop/src/session/index.tsx index 71c076638d..fe521cb90a 100644 --- a/apps/desktop/src/session/index.tsx +++ b/apps/desktop/src/session/index.tsx @@ -196,9 +196,7 @@ function TabContentNoteInner({ showTimeline: boolean; }) { const titleInputRef = React.useRef(null); - const noteInputRef = React.useRef<{ - editor: import("@hypr/tiptap/editor").TiptapEditor | null; - }>(null); + const noteInputRef = React.useRef(null); const currentView = useCurrentNoteTab(tab); const { generateTitle } = useTitleGeneration(tab); @@ -245,7 +243,7 @@ function TabContentNoteInner({ }, []); const focusEditor = React.useCallback(() => { - noteInputRef.current?.editor?.commands.focus(); + noteInputRef.current?.view?.focus(); }, []); return ( diff --git a/package.json b/package.json index e612c74563..7c771148b3 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,10 @@ "pnpm": { "patchedDependencies": { "@tiptap/extension-paragraph@3.20.1": "patches/@tiptap__extension-paragraph@3.20.1.patch" + }, + "overrides": { + "prosemirror-view": "1.41.6", + "prosemirror-gapcursor": "1.4.0" } }, "packageManager": "pnpm@10.32.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cabb908210..3e84e436cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + prosemirror-view: 1.41.6 + prosemirror-gapcursor: 1.4.0 + patchedDependencies: '@tiptap/extension-paragraph@3.20.1': hash: a5224547138264350497377569c51d2eeda60aab423e7a8516dd15485021191d @@ -196,9 +200,15 @@ importers: '@effect/schema': specifier: ^0.75.5 version: 0.75.5(effect@3.19.16) + '@floating-ui/dom': + specifier: ^1.7.6 + version: 1.7.6 '@floating-ui/react': specifier: ^0.27.17 version: 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@handlewithcare/react-prosemirror': + specifier: ^2.8.4 + version: 2.8.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(react-dom@19.2.4(react@19.2.4))(react-reconciler@0.33.0(react@19.2.4))(react@19.2.4) '@hypr/api-client': specifier: workspace:* version: link:../../packages/api-client @@ -493,6 +503,36 @@ importers: posthog-js: specifier: ^1.358.0 version: 1.358.0 + prosemirror-commands: + specifier: ^1.7.1 + version: 1.7.1 + prosemirror-dropcursor: + specifier: ^1.8.2 + version: 1.8.2 + prosemirror-gapcursor: + specifier: 1.4.0 + version: 1.4.0 + prosemirror-history: + specifier: ^1.5.0 + version: 1.5.0 + prosemirror-inputrules: + specifier: ^1.5.1 + version: 1.5.1 + prosemirror-keymap: + specifier: ^1.2.3 + version: 1.2.3 + prosemirror-model: + specifier: ^1.25.4 + version: 1.25.4 + prosemirror-schema-list: + specifier: ^1.5.1 + version: 1.5.1 + prosemirror-state: + specifier: ^1.4.4 + version: 1.4.4 + prosemirror-view: + specifier: 1.41.6 + version: 1.41.6 re-resizable: specifier: ^6.11.2 version: 6.11.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -535,6 +575,9 @@ importers: tinytick: specifier: ^1.2.8 version: 1.2.8 + tlds: + specifier: ^1.261.0 + version: 1.261.0 unified: specifier: ^11.0.5 version: 11.0.5 @@ -3631,6 +3674,17 @@ packages: engines: {node: '>=6'} hasBin: true + '@handlewithcare/react-prosemirror@2.8.4': + resolution: {integrity: sha512-En9hc5P4xw6WvRhDpzzj3uokIuZ54UoZPc31vdudKuMAlY1vUGeQ8zR5XzDP3Rpk3X75JrfoIX1IsC3aJS/j0Q==} + engines: {node: '>=16.9'} + peerDependencies: + prosemirror-model: ^1.0.0 + prosemirror-state: ^1.0.0 + prosemirror-view: 1.41.6 + react: '>=17 <20' + react-dom: '>=17 <20' + react-reconciler: '>=0.26.1 <=0.33.0' + '@hey-api/codegen-core@0.6.1': resolution: {integrity: sha512-khTIpxhKEAqmRmeLUnAFJQs4Sbg9RPokovJk9rRcC8B5MWH1j3/BRSqfpAIiJUBDU1+nbVg2RVCV+eQ174cdvw==} engines: {node: '>=20.19.0'} @@ -14976,7 +15030,7 @@ packages: peerDependencies: prosemirror-model: ^1.22.1 prosemirror-state: ^1.4.2 - prosemirror-view: ^1.33.8 + prosemirror-view: 1.41.6 prosemirror-transform@1.11.0: resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} @@ -15283,6 +15337,12 @@ packages: react: optional: true + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -19595,7 +19655,7 @@ snapshots: '@floating-ui/core@1.7.4': dependencies: - '@floating-ui/utils': 0.2.10 + '@floating-ui/utils': 0.2.11 '@floating-ui/core@1.7.5': dependencies: @@ -19613,7 +19673,7 @@ snapshots: '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/dom': 1.7.5 + '@floating-ui/dom': 1.7.6 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -19661,6 +19721,16 @@ snapshots: protobufjs: 7.5.4 yargs: 17.7.2 + '@handlewithcare/react-prosemirror@2.8.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(react-dom@19.2.4(react@19.2.4))(react-reconciler@0.33.0(react@19.2.4))(react@19.2.4)': + dependencies: + classnames: 2.5.1 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-reconciler: 0.33.0(react@19.2.4) + '@hey-api/codegen-core@0.6.1(magicast@0.5.2)(typescript@5.9.3)': dependencies: '@hey-api/types': 0.1.3(typescript@5.9.3) @@ -34519,6 +34589,11 @@ snapshots: optionalDependencies: react: 19.2.4 + react-reconciler@0.33.0(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.13)(react@19.2.4): From 431f0cefaae5d8bf7119e1ff5a818f777697dd17 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Tue, 24 Mar 2026 17:06:01 +0900 Subject: [PATCH 2/6] clean up image view --- apps/desktop/src/editor/image-view.tsx | 159 ++++++++----------------- apps/desktop/src/editor/index.tsx | 12 +- 2 files changed, 51 insertions(+), 120 deletions(-) diff --git a/apps/desktop/src/editor/image-view.tsx b/apps/desktop/src/editor/image-view.tsx index 117ccee614..d2e2188c76 100644 --- a/apps/desktop/src/editor/image-view.tsx +++ b/apps/desktop/src/editor/image-view.tsx @@ -1,9 +1,9 @@ import { type NodeViewComponentProps, useEditorEventCallback, + useEditorState, } from "@handlewithcare/react-prosemirror"; -import { AllSelection, NodeSelection } from "prosemirror-state"; -import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { forwardRef, useCallback, useRef, useState } from "react"; import { DEFAULT_EDITOR_WIDTH, @@ -13,25 +13,15 @@ import { import { cn } from "@hypr/utils"; export const ResizableImageView = forwardRef< - HTMLElement, + HTMLDivElement, NodeViewComponentProps >(({ nodeProps, ...htmlAttrs }, ref) => { const { node, getPos } = nodeProps; const [isHovered, setIsHovered] = useState(false); const [isResizing, setIsResizing] = useState(false); - const [isRangeSelected, setIsRangeSelected] = useState(false); - const [isAllSelected, setIsAllSelected] = useState(false); const [draftWidth, setDraftWidth] = useState(null); const containerRef = useRef(null); const imageRef = useRef(null); - const draftWidthRef = useRef(null); - const resizeStateRef = useRef<{ - direction: "left" | "right"; - editorWidth: number; - startWidth: number; - startX: number; - } | null>(null); - const updateAttributes = useEditorEventCallback( (view, attrs: Record) => { if (!view) return; @@ -44,117 +34,66 @@ export const ResizableImageView = forwardRef< }, ); - const checkSelection = useEditorEventCallback((view) => { - if (!view) return; - const pos = getPos(); - const { doc, selection } = view.state; - const nodeStart = pos; - const nodeEnd = pos + node.nodeSize; - const isNodeSel = - selection instanceof NodeSelection && selection.from === nodeStart; - const includesNode = - !selection.empty && - !isNodeSel && - selection.from <= nodeStart && - selection.to >= nodeEnd; - - setIsRangeSelected(includesNode); - setIsAllSelected( - selection instanceof AllSelection || - (selection.from <= 1 && selection.to >= doc.content.size - 1), - ); - }); - - useEffect(() => { - checkSelection(); - }); - - useEffect(() => { - if (!isResizing) return; - - const handlePointerMove = (event: PointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState) return; - - const deltaX = - (event.clientX - resizeState.startX) * - (resizeState.direction === "left" ? -1 : 1); - const nextWidth = Math.min( - resizeState.editorWidth, - Math.max(120, resizeState.startWidth + deltaX), - ); - - draftWidthRef.current = nextWidth; - setDraftWidth(nextWidth); - }; - - const handlePointerUp = () => { - const resizeState = resizeStateRef.current; - if (!resizeState || !draftWidthRef.current) { - resizeStateRef.current = null; - draftWidthRef.current = null; - setIsResizing(false); - setDraftWidth(null); - return; - } - - updateAttributes({ - editorWidth: normalizeEditorWidth( - (draftWidthRef.current / resizeState.editorWidth) * 100, - ), - }); - - resizeStateRef.current = null; - draftWidthRef.current = null; - setIsResizing(false); - setDraftWidth(null); - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp); - return () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - }; - }, [isResizing, updateAttributes]); - + // to detect whether a nodeview is selected: + // see: https://discuss.prosemirror.net/t/is-this-the-right-way-to-determine-if-a-nodeview-is-selected/2208/2 + // also: https://github.com/handlewithcarecollective/react-prosemirror/issues/161 + const pos = getPos(); + const { selection } = useEditorState(); + const isSelected = + pos >= selection.from && pos + node.nodeSize <= selection.to; + + // we register all resize event handlers during resize start and unregister them on resize end. + // all drag state lives inside this callback scope. + // during a drag, draftWidth is a pixel value for immediate visual feedback. + // once the drag ends, draftWidth resets to null and we calculate and persist the percentage as attributes. const handleResizeStart = useCallback( ( direction: "left" | "right", event: React.PointerEvent, ) => { - const container = containerRef.current; - const image = imageRef.current; - if (!container || !image) return; + const containerEl = containerRef.current; + const imageEl = imageRef.current; + if (!containerEl || !imageEl) return; event.preventDefault(); event.stopPropagation(); - const editorElement = container.closest(".ProseMirror"); - const editorWidth = - editorElement?.getBoundingClientRect().width ?? - container.getBoundingClientRect().width; + const editorEl = containerEl.closest(".ProseMirror"); + const maxWidth = + editorEl?.getBoundingClientRect().width ?? + containerEl.getBoundingClientRect().width; + const startWidth = imageEl.getBoundingClientRect().width; + const startX = event.clientX; + + let currentWidth = startWidth; + setIsResizing(true); + setDraftWidth(startWidth); - resizeStateRef.current = { - direction, - editorWidth, - startWidth: image.getBoundingClientRect().width, - startX: event.clientX, + const handlePointerMove = (e: PointerEvent) => { + const deltaX = (e.clientX - startX) * (direction === "left" ? -1 : 1); + currentWidth = Math.min(maxWidth, Math.max(120, startWidth + deltaX)); + setDraftWidth(currentWidth); }; - draftWidthRef.current = image.getBoundingClientRect().width; - setIsResizing(true); - setDraftWidth(image.getBoundingClientRect().width); - }, - [], - ); + const handlePointerUp = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); - const selected = nodeProps.decorations.some( - (d) => (d as any).type?.name === "selected", + updateAttributes({ + editorWidth: normalizeEditorWidth((currentWidth / maxWidth) * 100), + }); + + setIsResizing(false); + setDraftWidth(null); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + }, + [updateAttributes], ); - const isSelected = selected || isRangeSelected; - const showControls = !isAllSelected && (isHovered || selected || isResizing); + const showControls = isHovered || isSelected || isResizing; const editorWidth = normalizeEditorWidth(node.attrs.editorWidth) ?? DEFAULT_EDITOR_WIDTH; const imageWidth = @@ -162,7 +101,7 @@ export const ResizableImageView = forwardRef< return (
diff --git a/apps/desktop/src/editor/index.tsx b/apps/desktop/src/editor/index.tsx index 1fd7b212a6..0b8d94d467 100644 --- a/apps/desktop/src/editor/index.tsx +++ b/apps/desktop/src/editor/index.tsx @@ -66,8 +66,6 @@ export interface NoteEditorRef { interface EditorProps { handleChange?: (content: JSONContent) => void; initialContent?: JSONContent; - editable?: boolean; - setContentFromOutside?: boolean; mentionConfig?: MentionConfig; placeholderComponent?: PlaceholderFunction; fileHandlerConfig?: FileHandlerConfig; @@ -99,8 +97,6 @@ const NoteEditor = forwardRef((props, ref) => { const { handleChange, initialContent, - editable = true, - setContentFromOutside = false, mentionConfig, placeholderComponent, fileHandlerConfig, @@ -169,7 +165,7 @@ const NoteEditor = forwardRef((props, ref) => { if (!initialContent || initialContent.type !== "doc") return; - if (setContentFromOutside || !view.hasFocus()) { + if (!view.hasFocus()) { try { const doc = PMNode.fromJSON(schema, initialContent); const state = EditorState.create({ @@ -181,10 +177,7 @@ const NoteEditor = forwardRef((props, ref) => { // invalid content } } - }, [initialContent, setContentFromOutside]); - - const editableRef = useRef(editable); - editableRef.current = editable; + }, [initialContent]); const onViewReady = useCallback( (view: EditorView) => { @@ -197,7 +190,6 @@ const NoteEditor = forwardRef((props, ref) => { editableRef.current} dispatchTransaction={function (this: EditorView, tr: Transaction) { const newState = this.state.apply(tr); this.updateState(newState); From c325547ffda5b9080f9297f84a6d27d5c125de08 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Wed, 25 Mar 2026 12:56:42 +0900 Subject: [PATCH 3/6] add delete selection command --- apps/desktop/src/editor/keymap.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/editor/keymap.ts b/apps/desktop/src/editor/keymap.ts index 8cf20755dc..6ad68721e7 100644 --- a/apps/desktop/src/editor/keymap.ts +++ b/apps/desktop/src/editor/keymap.ts @@ -1,6 +1,7 @@ import { chainCommands, createParagraphNear, + deleteSelection, exitCode, joinBackward, joinForward, @@ -165,6 +166,7 @@ export function buildKeymap(onNavigateToTitle?: () => void) { ); keys["Backspace"] = chainCommands( + deleteSelection, (state, _dispatch) => { const { selection } = state; if (selection.$head.pos === 0 && selection.empty) return true; @@ -174,7 +176,11 @@ export function buildKeymap(onNavigateToTitle?: () => void) { selectNodeBackward, ); - keys["Delete"] = chainCommands(joinForward, selectNodeForward); + keys["Delete"] = chainCommands( + deleteSelection, + joinForward, + selectNodeForward, + ); keys["Tab"] = (state, dispatch) => { const itemName = isInListItem(state); From aa5c46ccc8f7450079761d70b2550ef864812ad9 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Wed, 25 Mar 2026 16:26:36 +0900 Subject: [PATCH 4/6] wire up title input and session input movement --- apps/desktop/src/editor/index.tsx | 121 +++++++- apps/desktop/src/editor/keymap.ts | 51 +++- .../components/note-input/enhanced/editor.tsx | 6 +- .../components/note-input/enhanced/index.tsx | 6 +- .../session/components/note-input/index.tsx | 151 +++------- .../src/session/components/note-input/raw.tsx | 2 +- .../src/session/components/title-input.tsx | 281 +++++++++--------- apps/desktop/src/session/index.tsx | 39 ++- packages/tiptap/src/editor/index.tsx | 14 +- 9 files changed, 376 insertions(+), 295 deletions(-) diff --git a/apps/desktop/src/editor/index.tsx b/apps/desktop/src/editor/index.tsx index 0b8d94d467..563930c4b8 100644 --- a/apps/desktop/src/editor/index.tsx +++ b/apps/desktop/src/editor/index.tsx @@ -3,12 +3,18 @@ import { ProseMirrorDoc, reactKeys, useEditorEffect, + useEditorEventCallback, } from "@handlewithcare/react-prosemirror"; import { dropCursor } from "prosemirror-dropcursor"; import { gapCursor } from "prosemirror-gapcursor"; import { history } from "prosemirror-history"; import { Node as PMNode } from "prosemirror-model"; -import { EditorState, type Transaction } from "prosemirror-state"; +import { + EditorState, + Selection, + TextSelection, + type Transaction, +} from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; import { forwardRef, @@ -58,9 +64,17 @@ export interface JSONContent { text?: string; } +export interface EditorCommands { + focus: () => void; + focusAtStart: () => void; + focusAtPixelWidth: (pixelWidth: number) => void; + insertAtStartAndFocus: (content: string) => void; +} + export interface NoteEditorRef { view: EditorView | null; searchStorage: SearchAndReplaceStorage; + commands: EditorCommands; } interface EditorProps { @@ -69,7 +83,7 @@ interface EditorProps { mentionConfig?: MentionConfig; placeholderComponent?: PlaceholderFunction; fileHandlerConfig?: FileHandlerConfig; - onNavigateToTitle?: () => void; + onNavigateToTitle?: (pixelWidth?: number) => void; } const nodeViews = { @@ -93,6 +107,91 @@ function ViewCapture({ return null; } +const noopCommands: EditorCommands = { + focus: () => {}, + focusAtStart: () => {}, + focusAtPixelWidth: () => {}, + insertAtStartAndFocus: () => {}, +}; + +function EditorCommandsBridge({ + commandsRef, +}: { + commandsRef: React.RefObject; +}) { + commandsRef.current.focus = useEditorEventCallback((view) => { + if (!view) return; + view.focus(); + }); + + commandsRef.current.focusAtStart = useEditorEventCallback((view) => { + if (!view) return; + view.dispatch( + view.state.tr.setSelection(Selection.atStart(view.state.doc)), + ); + view.focus(); + }); + + commandsRef.current.focusAtPixelWidth = useEditorEventCallback( + (view, pixelWidth: number) => { + if (!view) return; + + const blockStart = Selection.atStart(view.state.doc).from; + const firstTextNode = view.dom.querySelector(".ProseMirror > *"); + if (firstTextNode) { + const editorStyle = window.getComputedStyle(firstTextNode); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.font = `${editorStyle.fontWeight} ${editorStyle.fontSize} ${editorStyle.fontFamily}`; + const firstBlock = view.state.doc.firstChild; + if (firstBlock && firstBlock.textContent) { + const text = firstBlock.textContent; + let charPos = 0; + for (let i = 0; i <= text.length; i++) { + const currentWidth = ctx.measureText(text.slice(0, i)).width; + if (currentWidth >= pixelWidth) { + charPos = i; + break; + } + charPos = i; + } + const targetPos = Math.min( + blockStart + charPos, + view.state.doc.content.size - 1, + ); + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, targetPos), + ), + ); + view.focus(); + return; + } + } + } + + view.dispatch( + view.state.tr.setSelection(Selection.atStart(view.state.doc)), + ); + view.focus(); + }, + ); + + commandsRef.current.insertAtStartAndFocus = useEditorEventCallback( + (view, content: string) => { + if (!view || !content) return; + const pos = Selection.atStart(view.state.doc).from; + const tr = view.state.tr.insertText(content, pos); + tr.setSelection(TextSelection.create(tr.doc, pos)); + view.dispatch(tr); + view.focus(); + }, + ); + + return null; +} + const NoteEditor = forwardRef((props, ref) => { const { handleChange, @@ -106,10 +205,21 @@ const NoteEditor = forwardRef((props, ref) => { const previousContentRef = useRef(initialContent); const searchStorage = useMemo(() => createSearchStorage(), []); const viewRef = useRef(null); + const commandsRef = useRef(noopCommands); - useImperativeHandle(ref, () => ({ view: viewRef.current, searchStorage }), [ - searchStorage, - ]); + useImperativeHandle( + ref, + () => ({ + get view() { + return viewRef.current; + }, + searchStorage, + get commands() { + return commandsRef.current; + }, + }), + [searchStorage], + ); const onUpdate = useDebounceCallback((view: EditorView) => { if (!handleChange) return; @@ -208,6 +318,7 @@ const NoteEditor = forwardRef((props, ref) => { > + {mentionConfig && } ); diff --git a/apps/desktop/src/editor/keymap.ts b/apps/desktop/src/editor/keymap.ts index 6ad68721e7..d16d05c1ea 100644 --- a/apps/desktop/src/editor/keymap.ts +++ b/apps/desktop/src/editor/keymap.ts @@ -26,7 +26,7 @@ import { sinkListItem, splitListItem, } from "prosemirror-schema-list"; -import type { Command, EditorState } from "prosemirror-state"; +import { Selection, type Command, type EditorState } from "prosemirror-state"; import { schema } from "./schema"; @@ -118,7 +118,7 @@ const mac = ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) : false; -export function buildKeymap(onNavigateToTitle?: () => void) { +export function buildKeymap(onNavigateToTitle?: (pixelWidth?: number) => void) { const hardBreak = schema.nodes.hardBreak; const keys: Record = {}; @@ -209,20 +209,45 @@ export function buildKeymap(onNavigateToTitle?: () => void) { }; if (onNavigateToTitle) { - keys["ArrowUp"] = (state) => { - const { $head } = state.selection; + keys["ArrowLeft"] = (state) => { + const { $head, empty } = state.selection; + if (!empty) return false; + if ($head.pos !== Selection.atStart(state.doc).from) return false; - let node = state.doc.firstChild; - let firstTextBlockPos = 0; - while (node && !node.isTextblock) { - firstTextBlockPos += 1; - node = node.firstChild; - } + onNavigateToTitle(); + return true; + }; - if (!node) return false; - const isInFirstBlock = $head.start($head.depth) === firstTextBlockPos + 1; + keys["ArrowUp"] = (state, _dispatch, view) => { + const { $head } = state.selection; + const firstBlockStart = Selection.atStart(state.doc).from; + if ( + $head.start($head.depth) !== + state.doc.resolve(firstBlockStart).start($head.depth) + ) { + return false; + } - if (!isInFirstBlock) return false; + if (view) { + const firstBlock = state.doc.firstChild; + if (firstBlock && firstBlock.textContent) { + const text = firstBlock.textContent; + const posInBlock = $head.pos - $head.start(); + const textBeforeCursor = text.slice(0, posInBlock); + const firstTextNode = view.dom.querySelector(".ProseMirror > *"); + if (firstTextNode) { + const style = window.getComputedStyle(firstTextNode); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const pixelWidth = ctx.measureText(textBeforeCursor).width; + onNavigateToTitle(pixelWidth); + return true; + } + } + } + } onNavigateToTitle(); return true; diff --git a/apps/desktop/src/session/components/note-input/enhanced/editor.tsx b/apps/desktop/src/session/components/note-input/enhanced/editor.tsx index 7df5ff0c98..046f203eaa 100644 --- a/apps/desktop/src/session/components/note-input/enhanced/editor.tsx +++ b/apps/desktop/src/session/components/note-input/enhanced/editor.tsx @@ -9,7 +9,11 @@ import * as main from "~/store/tinybase/store/main"; export const EnhancedEditor = forwardRef< NoteEditorRef, - { sessionId: string; enhancedNoteId: string; onNavigateToTitle?: () => void } + { + sessionId: string; + enhancedNoteId: string; + onNavigateToTitle?: (pixelWidth?: number) => void; + } >(({ sessionId, enhancedNoteId, onNavigateToTitle }, ref) => { const onImageUpload = useImageUpload(sessionId); const content = main.UI.useCell( diff --git a/apps/desktop/src/session/components/note-input/enhanced/index.tsx b/apps/desktop/src/session/components/note-input/enhanced/index.tsx index f7b820a786..5107eb6708 100644 --- a/apps/desktop/src/session/components/note-input/enhanced/index.tsx +++ b/apps/desktop/src/session/components/note-input/enhanced/index.tsx @@ -13,7 +13,11 @@ import { createTaskId } from "~/store/zustand/ai-task/task-configs"; export const Enhanced = forwardRef< NoteEditorRef, - { sessionId: string; enhancedNoteId: string; onNavigateToTitle?: () => void } + { + sessionId: string; + enhancedNoteId: string; + onNavigateToTitle?: (pixelWidth?: number) => void; + } >(({ sessionId, enhancedNoteId, onNavigateToTitle }, ref) => { const taskId = createTaskId(enhancedNoteId, "enhance"); const llmStatus = useLLMConnectionStatus(); diff --git a/apps/desktop/src/session/components/note-input/index.tsx b/apps/desktop/src/session/components/note-input/index.tsx index 3369922e95..bdfe6174c4 100644 --- a/apps/desktop/src/session/components/note-input/index.tsx +++ b/apps/desktop/src/session/components/note-input/index.tsx @@ -1,5 +1,4 @@ import { convertFileSrc } from "@tauri-apps/api/core"; -import { TextSelection } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; import { forwardRef, @@ -35,11 +34,19 @@ import { type Tab, useTabs } from "~/store/zustand/tabs"; import { type EditorView as TabEditorView } from "~/store/zustand/tabs/schema"; import { useListener } from "~/stt/contexts"; +export interface NoteInputHandle { + searchStorage: NoteEditorRef["searchStorage"]; + focus: () => void; + focusAtStart: () => void; + focusAtPixelWidth: (pixelWidth: number) => void; + insertAtStartAndFocus: (content: string) => void; +} + export const NoteInput = forwardRef< - NoteEditorRef, + NoteInputHandle, { tab: Extract; - onNavigateToTitle?: () => void; + onNavigateToTitle?: (pixelWidth?: number) => void; } >(({ tab, onNavigateToTitle }, ref) => { const editorTabs = useEditorTabs({ sessionId: tab.id }); @@ -47,7 +54,6 @@ export const NoteInput = forwardRef< const internalEditorRef = useRef(null); const [container, setContainer] = useState(null); const [view, setView] = useState(null); - const [isEditing, setIsEditing] = useState(false); const sessionId = tab.id; @@ -55,22 +61,31 @@ export const NoteInput = forwardRef< tabRef.current = tab; const currentTab: TabEditorView = useCurrentNoteTab(tab); + + const defaultSearchStorage: NoteEditorRef["searchStorage"] = { + searchTerm: "", + replaceTerm: "", + results: [], + lastSearchTerm: "", + caseSensitive: false, + lastCaseSensitive: false, + resultIndex: 0, + lastResultIndex: 0, + }; + useImperativeHandle( ref, - () => - internalEditorRef.current ?? { - view: null, - searchStorage: { - searchTerm: "", - replaceTerm: "", - results: [], - lastSearchTerm: "", - caseSensitive: false, - lastCaseSensitive: false, - resultIndex: 0, - lastResultIndex: 0, - }, + () => ({ + get searchStorage() { + return internalEditorRef.current?.searchStorage ?? defaultSearchStorage; }, + focus: () => internalEditorRef.current?.commands.focus(), + focusAtStart: () => internalEditorRef.current?.commands.focusAtStart(), + focusAtPixelWidth: (px) => + internalEditorRef.current?.commands.focusAtPixelWidth(px), + insertAtStartAndFocus: (content) => + internalEditorRef.current?.commands.insertAtStartAndFocus(content), + }), [currentTab], ); @@ -114,7 +129,7 @@ export const NoteInput = forwardRef< setView(null); } else if (currentTab.type === "raw" && isMeetingInProgress) { requestAnimationFrame(() => { - internalEditorRef.current?.view?.focus(); + internalEditorRef.current?.commands.focus(); }); } }, [currentTab, isMeetingInProgress]); @@ -126,104 +141,6 @@ export const NoteInput = forwardRef< } }); - useEffect(() => { - const handleContentTransfer = (e: Event) => { - const customEvent = e as CustomEvent<{ content: string }>; - const content = customEvent.detail.content; - const v = internalEditorRef.current?.view; - - if (v && content) { - const tr = v.state.tr.insertText(content, 0); - tr.setSelection(TextSelection.create(tr.doc, 0)); - v.dispatch(tr); - v.focus(); - } - }; - - const handleMoveToEditorStart = () => { - const v = internalEditorRef.current?.view; - if (v) { - v.dispatch( - v.state.tr.setSelection(TextSelection.create(v.state.doc, 0)), - ); - v.focus(); - } - }; - - const handleMoveToEditorPosition = (e: Event) => { - const customEvent = e as CustomEvent<{ pixelWidth: number }>; - const pixelWidth = customEvent.detail.pixelWidth; - const v = internalEditorRef.current?.view; - - if (v) { - const firstTextNode = v.dom.querySelector(".ProseMirror > *"); - - if (firstTextNode) { - const editorStyle = window.getComputedStyle(firstTextNode); - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - - if (ctx) { - ctx.font = `${editorStyle.fontWeight} ${editorStyle.fontSize} ${editorStyle.fontFamily}`; - - const firstBlock = v.state.doc.firstChild; - if (firstBlock && firstBlock.textContent) { - const text = firstBlock.textContent; - let charPos = 0; - - for (let i = 0; i <= text.length; i++) { - const currentWidth = ctx.measureText(text.slice(0, i)).width; - if (currentWidth >= pixelWidth) { - charPos = i; - break; - } - charPos = i; - } - - const targetPos = Math.min(charPos, v.state.doc.content.size - 1); - v.dispatch( - v.state.tr.setSelection( - TextSelection.create(v.state.doc, targetPos), - ), - ); - v.focus(); - return; - } - } - } - - v.dispatch( - v.state.tr.setSelection(TextSelection.create(v.state.doc, 0)), - ); - v.focus(); - } - }; - - window.addEventListener("title-content-transfer", handleContentTransfer); - window.addEventListener( - "title-move-to-editor-start", - handleMoveToEditorStart, - ); - window.addEventListener( - "title-move-to-editor-position", - handleMoveToEditorPosition, - ); - return () => { - window.removeEventListener( - "title-content-transfer", - handleContentTransfer, - ); - window.removeEventListener( - "title-move-to-editor-start", - handleMoveToEditorStart, - ); - window.removeEventListener( - "title-move-to-editor-position", - handleMoveToEditorPosition, - ); - }; - }, []); - useCaretNearBottom({ view, container, @@ -239,7 +156,7 @@ export const NoteInput = forwardRef< const handleContainerClick = () => { if (currentTab.type !== "transcript" && currentTab.type !== "attachments") { - internalEditorRef.current?.view?.focus(); + internalEditorRef.current?.commands.focus(); } }; diff --git a/apps/desktop/src/session/components/note-input/raw.tsx b/apps/desktop/src/session/components/note-input/raw.tsx index 8980a45874..9d5ae8e390 100644 --- a/apps/desktop/src/session/components/note-input/raw.tsx +++ b/apps/desktop/src/session/components/note-input/raw.tsx @@ -14,7 +14,7 @@ import * as main from "~/store/tinybase/store/main"; export const RawEditor = forwardRef< NoteEditorRef, - { sessionId: string; onNavigateToTitle?: () => void } + { sessionId: string; onNavigateToTitle?: (pixelWidth?: number) => void } >(({ sessionId, onNavigateToTitle }, ref) => { const rawMd = main.UI.useCell("sessions", sessionId, "raw_md", main.STORE_ID); const onImageUpload = useImageUpload(sessionId); diff --git a/apps/desktop/src/session/components/title-input.tsx b/apps/desktop/src/session/components/title-input.tsx index d1be19c8ca..c447e78d85 100644 --- a/apps/desktop/src/session/components/title-input.tsx +++ b/apps/desktop/src/session/components/title-input.tsx @@ -22,87 +22,110 @@ import * as main from "~/store/tinybase/store/main"; import { useLiveTitle } from "~/store/zustand/live-title"; import { type Tab } from "~/store/zustand/tabs"; +export interface TitleInputHandle { + focus: () => void; + focusAtEnd: () => void; + focusAtPixelWidth: (pixelWidth: number) => void; +} + export const TitleInput = forwardRef< - HTMLInputElement, + TitleInputHandle, { tab: Extract; - onNavigateToEditor?: () => void; + onTransferContentToEditor?: (content: string) => void; + onFocusEditorAtStart?: () => void; + onFocusEditorAtPixelWidth?: (pixelWidth: number) => void; onGenerateTitle?: () => void; } ->(({ tab, onNavigateToEditor, onGenerateTitle }, ref) => { - const { - id: sessionId, - state: { view }, - } = tab; - const store = main.UI.useStore(main.STORE_ID); - const isGenerating = useTitleGenerating(sessionId); - const wasGenerating = usePrevious(isGenerating); - const [showRevealAnimation, setShowRevealAnimation] = useState(false); - const [generatedTitle, setGeneratedTitle] = useState(null); - - const editorId = view ? "active" : "inactive"; - const inputRef = useRef(null); - - useImperativeHandle(ref, () => inputRef.current!, []); - - useEffect(() => { - if (wasGenerating && !isGenerating) { - const title = store?.getCell("sessions", sessionId, "title") as - | string - | undefined; - setGeneratedTitle(title ?? null); - setShowRevealAnimation(true); - const timer = setTimeout(() => { - setShowRevealAnimation(false); - }, 1000); - return () => clearTimeout(timer); +>( + ( + { + tab, + onTransferContentToEditor, + onFocusEditorAtStart, + onFocusEditorAtPixelWidth, + onGenerateTitle, + }, + ref, + ) => { + const { + id: sessionId, + state: { view }, + } = tab; + const store = main.UI.useStore(main.STORE_ID); + const isGenerating = useTitleGenerating(sessionId); + const wasGenerating = usePrevious(isGenerating); + const [showRevealAnimation, setShowRevealAnimation] = useState(false); + const [generatedTitle, setGeneratedTitle] = useState(null); + + const editorId = view ? "active" : "inactive"; + const inputRef = useRef(null); + + useImperativeHandle(ref, () => inputRef.current!, []); + + useEffect(() => { + if (wasGenerating && !isGenerating) { + const title = store?.getCell("sessions", sessionId, "title") as + | string + | undefined; + setGeneratedTitle(title ?? null); + setShowRevealAnimation(true); + const timer = setTimeout(() => { + setShowRevealAnimation(false); + }, 1000); + return () => clearTimeout(timer); + } + }, [wasGenerating, isGenerating, store, sessionId]); + + const getInitialTitle = useCallback(() => { + return (store?.getCell("sessions", sessionId, "title") as string) ?? ""; + }, [store, sessionId]); + + if (isGenerating) { + return ( +
+ + Generating title... + +
+ ); } - }, [wasGenerating, isGenerating, store, sessionId]); - - const getInitialTitle = useCallback(() => { - return (store?.getCell("sessions", sessionId, "title") as string) ?? ""; - }, [store, sessionId]); - if (isGenerating) { - return ( -
- - Generating title... - -
- ); - } + if (showRevealAnimation && generatedTitle) { + return ( +
+ + {generatedTitle} + +
+ ); + } - if (showRevealAnimation && generatedTitle) { return ( -
- - {generatedTitle} - -
+ ); - } - - return ( - - ); -}); + }, +); const TitleInputInner = memo( forwardRef< - HTMLInputElement, + TitleInputHandle, { sessionId: string; editorId: string; getInitialTitle: () => string; - onNavigateToEditor?: () => void; + onTransferContentToEditor?: (content: string) => void; + onFocusEditorAtStart?: () => void; + onFocusEditorAtPixelWidth?: (pixelWidth: number) => void; onGenerateTitle?: () => void; } >( @@ -111,7 +134,9 @@ const TitleInputInner = memo( sessionId, editorId, getInitialTitle, - onNavigateToEditor, + onTransferContentToEditor, + onFocusEditorAtStart, + onFocusEditorAtPixelWidth, onGenerateTitle, }, ref, @@ -123,7 +148,46 @@ const TitleInputInner = memo( const setLiveTitle = useLiveTitle((s) => s.setTitle); const clearLiveTitle = useLiveTitle((s) => s.clearTitle); - useImperativeHandle(ref, () => internalRef.current!, []); + useImperativeHandle( + ref, + () => ({ + focus: () => internalRef.current?.focus(), + focusAtEnd: () => { + const input = internalRef.current; + if (input) { + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + } + }, + focusAtPixelWidth: (pixelWidth: number) => { + const input = internalRef.current; + if (input && input.value) { + input.focus(); + const titleStyle = window.getComputedStyle(input); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.font = `${titleStyle.fontWeight} ${titleStyle.fontSize} ${titleStyle.fontFamily}`; + let charPos = 0; + for (let i = 0; i <= input.value.length; i++) { + const currentWidth = ctx.measureText( + input.value.slice(0, i), + ).width; + if (currentWidth >= pixelWidth) { + charPos = i; + break; + } + charPos = i; + } + input.setSelectionRange(charPos, charPos); + } + } else if (input) { + input.focus(); + } + }, + }), + [], + ); useEffect(() => { if (!store) return; @@ -152,49 +216,6 @@ const TitleInputInner = memo( main.STORE_ID, ); - useEffect(() => { - const handleMoveToTitlePosition = (e: Event) => { - const customEvent = e as CustomEvent<{ pixelWidth: number }>; - const pixelWidth = customEvent.detail.pixelWidth; - const input = internalRef.current; - - if (input && input.value) { - const titleStyle = window.getComputedStyle(input); - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - - if (ctx) { - ctx.font = `${titleStyle.fontWeight} ${titleStyle.fontSize} ${titleStyle.fontFamily}`; - - let charPos = 0; - for (let i = 0; i <= input.value.length; i++) { - const currentWidth = ctx.measureText( - input.value.slice(0, i), - ).width; - if (currentWidth >= pixelWidth) { - charPos = i; - break; - } - charPos = i; - } - - input.setSelectionRange(charPos, charPos); - } - } - }; - - window.addEventListener( - "editor-move-to-title-position", - handleMoveToTitlePosition, - ); - return () => { - window.removeEventListener( - "editor-move-to-title-position", - handleMoveToTitlePosition, - ); - }; - }, []); - const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowUp") { e.preventDefault(); @@ -210,31 +231,29 @@ const TitleInputInner = memo( const beforeCursor = input.value.slice(0, cursorPos); const afterCursor = input.value.slice(cursorPos); + setLocalTitle(beforeCursor); setStoreTitle(beforeCursor); clearLiveTitle(sessionId); if (afterCursor) { - setTimeout(() => { - const event = new CustomEvent("title-content-transfer", { - detail: { content: afterCursor }, - }); - window.dispatchEvent(event); - }, 0); + setTimeout(() => onTransferContentToEditor?.(afterCursor), 0); } else { - setTimeout(() => { - const event = new CustomEvent("title-move-to-editor-start"); - window.dispatchEvent(event); - }, 0); + setTimeout(() => onFocusEditorAtStart?.(), 0); } - - onNavigateToEditor?.(); } else if (e.key === "Tab") { e.preventDefault(); - setTimeout(() => { - const event = new CustomEvent("title-move-to-editor-start"); - window.dispatchEvent(event); - }, 0); - onNavigateToEditor?.(); + setTimeout(() => onFocusEditorAtStart?.(), 0); + } else if (e.key === "ArrowRight") { + const input = internalRef.current; + if (!input) return; + const cursorPos = input.selectionStart ?? 0; + if ( + cursorPos === input.value.length && + input.selectionEnd === cursorPos + ) { + e.preventDefault(); + setTimeout(() => onFocusEditorAtStart?.(), 0); + } } else if (e.key === "ArrowDown") { e.preventDefault(); const input = internalRef.current; @@ -249,16 +268,8 @@ const TitleInputInner = memo( const titleStyle = window.getComputedStyle(input); ctx.font = `${titleStyle.fontWeight} ${titleStyle.fontSize} ${titleStyle.fontFamily}`; const titleWidth = ctx.measureText(textBeforeCursor).width; - - setTimeout(() => { - const event = new CustomEvent("title-move-to-editor-position", { - detail: { pixelWidth: titleWidth }, - }); - window.dispatchEvent(event); - }, 0); + setTimeout(() => onFocusEditorAtPixelWidth?.(titleWidth), 0); } - - onNavigateToEditor?.(); } }; diff --git a/apps/desktop/src/session/index.tsx b/apps/desktop/src/session/index.tsx index fe521cb90a..3c2c661eb2 100644 --- a/apps/desktop/src/session/index.tsx +++ b/apps/desktop/src/session/index.tsx @@ -10,12 +10,12 @@ import { cn } from "@hypr/utils"; import { CaretPositionProvider } from "./components/caret-position-context"; import { FloatingActionButton } from "./components/floating"; -import { NoteInput } from "./components/note-input"; +import { NoteInput, type NoteInputHandle } from "./components/note-input"; import { SearchProvider } from "./components/note-input/transcript/search/context"; import { OuterHeader } from "./components/outer-header"; import { SessionPreviewCard } from "./components/session-preview-card"; import { useCurrentNoteTab, useHasTranscript } from "./components/shared"; -import { TitleInput } from "./components/title-input"; +import { TitleInput, type TitleInputHandle } from "./components/title-input"; import { useAutoEnhance } from "./hooks/useAutoEnhance"; import { useIsSessionEnhancing } from "./hooks/useEnhancedNotes"; import { getSessionTabVisualState } from "./tab-visual-state"; @@ -195,8 +195,8 @@ function TabContentNoteInner({ tab: Extract; showTimeline: boolean; }) { - const titleInputRef = React.useRef(null); - const noteInputRef = React.useRef(null); + const titleInputRef = React.useRef(null); + const noteInputRef = React.useRef(null); const currentView = useCurrentNoteTab(tab); const { generateTitle } = useTitleGeneration(tab); @@ -238,14 +238,29 @@ function TabContentNoteInner({ return () => clearTimeout(timer); }, [showConsentBanner]); - const focusTitle = React.useCallback(() => { - titleInputRef.current?.focus(); + const handleNavigateToTitle = React.useCallback((pixelWidth?: number) => { + if (pixelWidth !== undefined) { + titleInputRef.current?.focusAtPixelWidth(pixelWidth); + } else { + titleInputRef.current?.focusAtEnd(); + } + }, []); + + const handleTransferContentToEditor = React.useCallback((content: string) => { + noteInputRef.current?.insertAtStartAndFocus(content); }, []); - const focusEditor = React.useCallback(() => { - noteInputRef.current?.view?.focus(); + const handleFocusEditorAtStart = React.useCallback(() => { + noteInputRef.current?.focusAtStart(); }, []); + const handleFocusEditorAtPixelWidth = React.useCallback( + (pixelWidth: number) => { + noteInputRef.current?.focusAtPixelWidth(pixelWidth); + }, + [], + ); + return ( <>
@@ -269,7 +286,7 @@ function TabContentNoteInner({
@@ -371,7 +388,7 @@ function useAutoFocusTitle({ titleInputRef, }: { sessionId: string; - titleInputRef: React.RefObject; + titleInputRef: React.RefObject; }) { // Prevent re-focusing when the user intentionally leaves the title empty. const didAutoFocus = useRef(false); diff --git a/packages/tiptap/src/editor/index.tsx b/packages/tiptap/src/editor/index.tsx index 80c2ae2e1b..9c31e79fe8 100644 --- a/packages/tiptap/src/editor/index.tsx +++ b/packages/tiptap/src/editor/index.tsx @@ -38,7 +38,7 @@ interface EditorProps { placeholderComponent?: PlaceholderFunction; fileHandlerConfig?: FileHandlerConfig; extensionOptions?: ExtensionOptions; - onNavigateToTitle?: () => void; + onNavigateToTitle?: (pixelWidth?: number) => void; } const Editor = forwardRef<{ editor: TiptapEditor | null }, EditorProps>( @@ -142,16 +142,8 @@ const Editor = forwardRef<{ editor: TiptapEditor | null }, EditorProps>( if (ctx) { ctx.font = `${editorStyle.fontWeight} ${editorStyle.fontSize} ${editorStyle.fontFamily}`; const editorWidth = ctx.measureText(textBeforeCursor).width; - - setTimeout(() => { - const navEvent = new CustomEvent( - "editor-move-to-title-position", - { - detail: { pixelWidth: editorWidth }, - }, - ); - window.dispatchEvent(navEvent); - }, 0); + onNavigateToTitle(editorWidth); + return true; } } } From f9479830d93923d7957769d05aaa57d6857757e0 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Thu, 26 Mar 2026 10:41:45 +0900 Subject: [PATCH 5/6] add gapcursor css --- apps/desktop/src/editor/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/editor/index.tsx b/apps/desktop/src/editor/index.tsx index 563930c4b8..6622be26e8 100644 --- a/apps/desktop/src/editor/index.tsx +++ b/apps/desktop/src/editor/index.tsx @@ -27,6 +27,7 @@ import { import { useDebounceCallback } from "usehooks-ts"; import "@hypr/tiptap/styles.css"; +import "prosemirror-gapcursor/style/gapcursor.css"; import { ResizableImageView } from "./image-view"; import { buildInputRules, buildKeymap } from "./keymap"; From 0ecc2961039b313efb6a939e61771c9253462331 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Fri, 27 Mar 2026 11:45:40 +0900 Subject: [PATCH 6/6] adopt search plugin to search --- apps/desktop/package.json | 1 + apps/desktop/src/editor/index.tsx | 83 +++- apps/desktop/src/editor/plugins.ts | 151 +------ .../session/components/note-input/index.tsx | 35 +- .../src/session/components/note-input/raw.tsx | 5 +- .../components/note-input/search-replace.ts | 312 ------------- .../{transcript => }/search/bar.tsx | 88 +++- .../{transcript => }/search/context.tsx | 58 +-- .../{transcript => }/search/matching.ts | 5 +- .../transcript/renderer/word-span.tsx | 6 +- .../components/note-input/use-search-sync.ts | 81 ---- apps/desktop/src/session/index.tsx | 2 +- apps/desktop/src/styles/globals.css | 4 +- packages/tiptap/src/styles/nodes/search.css | 4 +- pnpm-lock.yaml | 410 ++++-------------- 15 files changed, 268 insertions(+), 977 deletions(-) delete mode 100644 apps/desktop/src/session/components/note-input/search-replace.ts rename apps/desktop/src/session/components/note-input/{transcript => }/search/bar.tsx (80%) rename apps/desktop/src/session/components/note-input/{transcript => }/search/context.tsx (82%) rename apps/desktop/src/session/components/note-input/{transcript => }/search/matching.ts (97%) delete mode 100644 apps/desktop/src/session/components/note-input/use-search-sync.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ad316166df..49092d782e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -141,6 +141,7 @@ "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-schema-list": "^1.5.1", + "prosemirror-search": "^1.1.0", "prosemirror-state": "^1.4.4", "prosemirror-view": "^1.41.6", "re-resizable": "^6.11.2", diff --git a/apps/desktop/src/editor/index.tsx b/apps/desktop/src/editor/index.tsx index 6622be26e8..0d351003d4 100644 --- a/apps/desktop/src/editor/index.tsx +++ b/apps/desktop/src/editor/index.tsx @@ -1,3 +1,5 @@ +import "prosemirror-gapcursor/style/gapcursor.css"; + import { ProseMirror, ProseMirrorDoc, @@ -27,7 +29,6 @@ import { import { useDebounceCallback } from "usehooks-ts"; import "@hypr/tiptap/styles.css"; -import "prosemirror-gapcursor/style/gapcursor.css"; import { ResizableImageView } from "./image-view"; import { buildInputRules, buildKeymap } from "./keymap"; @@ -41,21 +42,23 @@ import { import { type FileHandlerConfig, type PlaceholderFunction, - type SearchAndReplaceStorage, + SearchQuery, clearMarksOnEnterPlugin, clipPastePlugin, - createSearchStorage, fileHandlerPlugin, + getSearchState, hashtagPlugin, linkBoundaryGuardPlugin, placeholderPlugin, - searchAndReplacePlugin, + searchPlugin, + searchReplaceAll, + searchReplaceCurrent, + setSearchState, } from "./plugins"; import { schema } from "./schema"; export type { MentionConfig, FileHandlerConfig, PlaceholderFunction }; export { schema }; -export type { SearchAndReplaceStorage }; export interface JSONContent { type?: string; @@ -65,16 +68,26 @@ export interface JSONContent { text?: string; } +export interface SearchReplaceParams { + query: string; + replacement: string; + caseSensitive: boolean; + wholeWord: boolean; + all: boolean; + matchIndex: number; +} + export interface EditorCommands { focus: () => void; focusAtStart: () => void; focusAtPixelWidth: (pixelWidth: number) => void; insertAtStartAndFocus: (content: string) => void; + setSearch: (query: string, caseSensitive: boolean) => void; + replace: (params: SearchReplaceParams) => void; } export interface NoteEditorRef { view: EditorView | null; - searchStorage: SearchAndReplaceStorage; commands: EditorCommands; } @@ -113,6 +126,8 @@ const noopCommands: EditorCommands = { focusAtStart: () => {}, focusAtPixelWidth: () => {}, insertAtStartAndFocus: () => {}, + setSearch: () => {}, + replace: () => {}, }; function EditorCommandsBridge({ @@ -190,6 +205,48 @@ function EditorCommandsBridge({ }, ); + commandsRef.current.setSearch = useEditorEventCallback( + (view, query: string, caseSensitive: boolean) => { + if (!view) return; + const q = new SearchQuery({ search: query, caseSensitive }); + const current = getSearchState(view.state); + if (current && current.query.eq(q)) return; + view.dispatch(setSearchState(view.state.tr, q)); + }, + ); + + commandsRef.current.replace = useEditorEventCallback( + (view, params: SearchReplaceParams) => { + if (!view) return; + const query = new SearchQuery({ + search: params.query, + replace: params.replacement, + caseSensitive: params.caseSensitive, + wholeWord: params.wholeWord, + }); + + view.dispatch(setSearchState(view.state.tr, query)); + + if (params.all) { + searchReplaceAll(view.state, (tr) => view.dispatch(tr)); + } else { + let result = query.findNext(view.state); + let idx = 0; + while (result && idx < params.matchIndex) { + result = query.findNext(view.state, result.to); + idx++; + } + if (!result) return; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, result.from, result.to), + ), + ); + searchReplaceCurrent(view.state, (tr) => view.dispatch(tr)); + } + }, + ); + return null; } @@ -204,7 +261,6 @@ const NoteEditor = forwardRef((props, ref) => { } = props; const previousContentRef = useRef(initialContent); - const searchStorage = useMemo(() => createSearchStorage(), []); const viewRef = useRef(null); const commandsRef = useRef(noopCommands); @@ -214,12 +270,11 @@ const NoteEditor = forwardRef((props, ref) => { get view() { return viewRef.current; }, - searchStorage, get commands() { return commandsRef.current; }, }), - [searchStorage], + [], ); const onUpdate = useDebounceCallback((view: EditorView) => { @@ -236,7 +291,7 @@ const NoteEditor = forwardRef((props, ref) => { dropCursor(), gapCursor(), hashtagPlugin(), - searchAndReplacePlugin(searchStorage), + searchPlugin(), placeholderPlugin(placeholderComponent), clearMarksOnEnterPlugin(), clipPastePlugin(), @@ -246,13 +301,7 @@ const NoteEditor = forwardRef((props, ref) => { : []), ...(fileHandlerConfig ? [fileHandlerPlugin(fileHandlerConfig)] : []), ], - [ - searchStorage, - placeholderComponent, - fileHandlerConfig, - mentionConfig, - onNavigateToTitle, - ], + [placeholderComponent, fileHandlerConfig, mentionConfig, onNavigateToTitle], ); const defaultState = useMemo(() => { diff --git a/apps/desktop/src/editor/plugins.ts b/apps/desktop/src/editor/plugins.ts index 2c3f61b904..6c714700f9 100644 --- a/apps/desktop/src/editor/plugins.ts +++ b/apps/desktop/src/editor/plugins.ts @@ -44,144 +44,21 @@ export function hashtagPlugin() { } // --------------------------------------------------------------------------- -// Search and Replace +// Search and Replace (prosemirror-search) // --------------------------------------------------------------------------- -export interface SearchAndReplaceStorage { - searchTerm: string; - replaceTerm: string; - results: { from: number; to: number }[]; - lastSearchTerm: string; - caseSensitive: boolean; - lastCaseSensitive: boolean; - resultIndex: number; - lastResultIndex: number; -} - -export function createSearchStorage(): SearchAndReplaceStorage { - return { - searchTerm: "", - replaceTerm: "", - results: [], - lastSearchTerm: "", - caseSensitive: false, - lastCaseSensitive: false, - resultIndex: 0, - lastResultIndex: 0, - }; -} - -export const searchPluginKey = new PluginKey("searchAndReplace"); - -function processSearches( - doc: PMNode, - searchTerm: RegExp, - searchResultClass: string, - resultIndex: number, -): { decorations: DecorationSet; results: { from: number; to: number }[] } { - const decorations: Decoration[] = []; - const results: { from: number; to: number }[] = []; - - let textNodesWithPosition: { text: string; pos: number }[] = []; - let index = 0; - - doc.descendants((node, pos) => { - if (node.isText) { - if (textNodesWithPosition[index]) { - textNodesWithPosition[index] = { - text: textNodesWithPosition[index].text + node.text, - pos: textNodesWithPosition[index].pos, - }; - } else { - textNodesWithPosition[index] = { text: `${node.text}`, pos }; - } - } else { - index += 1; - } - }); - - textNodesWithPosition = textNodesWithPosition.filter(Boolean); - - for (const element of textNodesWithPosition) { - const { text, pos } = element; - const matches = Array.from(text.matchAll(searchTerm)).filter( - ([matchText]) => matchText.trim(), - ); - - for (const m of matches) { - if (m[0] === "" || m.index === undefined) continue; - results.push({ from: pos + m.index, to: pos + m.index + m[0].length }); - } - } - - for (let i = 0; i < results.length; i++) { - const r = results[i]; - const className = - i === resultIndex - ? `${searchResultClass} ${searchResultClass}-current` - : searchResultClass; - decorations.push(Decoration.inline(r.from, r.to, { class: className })); - } - - return { - decorations: DecorationSet.create(doc, decorations), - results, - }; -} - -export function searchAndReplacePlugin(storage: SearchAndReplaceStorage) { - return new Plugin({ - key: searchPluginKey, - state: { - init: () => DecorationSet.empty, - apply({ doc, docChanged }, oldState) { - const { - searchTerm, - lastSearchTerm, - caseSensitive, - lastCaseSensitive, - resultIndex, - lastResultIndex, - } = storage; - - if ( - !docChanged && - lastSearchTerm === searchTerm && - lastCaseSensitive === caseSensitive && - lastResultIndex === resultIndex - ) { - return oldState; - } - - storage.lastSearchTerm = searchTerm; - storage.lastCaseSensitive = caseSensitive; - storage.lastResultIndex = resultIndex; - - if (!searchTerm) { - storage.results = []; - return DecorationSet.empty; - } - - const escaped = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const regex = new RegExp(escaped, caseSensitive ? "gu" : "gui"); - - const { decorations, results } = processSearches( - doc, - regex, - "search-result", - resultIndex, - ); - - storage.results = results; - return decorations; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, - }); -} +export { + search as searchPlugin, + SearchQuery, + getSearchState, + setSearchState, + getMatchHighlights, + findNext as searchFindNext, + findPrev as searchFindPrev, + replaceAll as searchReplaceAll, + replaceCurrent as searchReplaceCurrent, + replaceNext as searchReplaceNext, +} from "prosemirror-search"; +import "prosemirror-search/style/search.css"; // --------------------------------------------------------------------------- // Placeholder diff --git a/apps/desktop/src/session/components/note-input/index.tsx b/apps/desktop/src/session/components/note-input/index.tsx index bdfe6174c4..9d21bd910c 100644 --- a/apps/desktop/src/session/components/note-input/index.tsx +++ b/apps/desktop/src/session/components/note-input/index.tsx @@ -22,9 +22,9 @@ import { type Attachment, Attachments } from "./attachments"; import { Enhanced } from "./enhanced"; import { Header, useAttachments, useEditorTabs } from "./header"; import { RawEditor } from "./raw"; +import { SearchBar } from "./search/bar"; +import { useSearch } from "./search/context"; import { Transcript } from "./transcript"; -import { SearchBar } from "./transcript/search/bar"; -import { useSearchSync } from "./use-search-sync"; import type { NoteEditorRef } from "~/editor"; import { useCaretNearBottom } from "~/session/components/caret-position-context"; @@ -35,7 +35,6 @@ import { type EditorView as TabEditorView } from "~/store/zustand/tabs/schema"; import { useListener } from "~/stt/contexts"; export interface NoteInputHandle { - searchStorage: NoteEditorRef["searchStorage"]; focus: () => void; focusAtStart: () => void; focusAtPixelWidth: (pixelWidth: number) => void; @@ -62,23 +61,9 @@ export const NoteInput = forwardRef< const currentTab: TabEditorView = useCurrentNoteTab(tab); - const defaultSearchStorage: NoteEditorRef["searchStorage"] = { - searchTerm: "", - replaceTerm: "", - results: [], - lastSearchTerm: "", - caseSensitive: false, - lastCaseSensitive: false, - resultIndex: 0, - lastResultIndex: 0, - }; - useImperativeHandle( ref, () => ({ - get searchStorage() { - return internalEditorRef.current?.searchStorage ?? defaultSearchStorage; - }, focus: () => internalEditorRef.current?.commands.focus(), focusAtStart: () => internalEditorRef.current?.commands.focusAtStart(), focusAtPixelWidth: (px) => @@ -148,11 +133,12 @@ export const NoteInput = forwardRef< currentTab.type !== "transcript" && currentTab.type !== "attachments", }); - const { showSearchBar } = useSearchSync({ - editorRef: internalEditorRef, - currentTab, - sessionId, - }); + const search = useSearch(); + const showSearchBar = search?.isVisible ?? false; + + useEffect(() => { + search?.close(); + }, [currentTab]); const handleContainerClick = () => { if (currentTab.type !== "transcript" && currentTab.type !== "attachments") { @@ -173,7 +159,10 @@ export const NoteInput = forwardRef< {showSearchBar && (
- +
)} diff --git a/apps/desktop/src/session/components/note-input/raw.tsx b/apps/desktop/src/session/components/note-input/raw.tsx index 9d5ae8e390..75e2ebdafe 100644 --- a/apps/desktop/src/session/components/note-input/raw.tsx +++ b/apps/desktop/src/session/components/note-input/raw.tsx @@ -14,7 +14,10 @@ import * as main from "~/store/tinybase/store/main"; export const RawEditor = forwardRef< NoteEditorRef, - { sessionId: string; onNavigateToTitle?: (pixelWidth?: number) => void } + { + sessionId: string; + onNavigateToTitle?: (pixelWidth?: number) => void; + } >(({ sessionId, onNavigateToTitle }, ref) => { const rawMd = main.UI.useCell("sessions", sessionId, "raw_md", main.STORE_ID); const onImageUpload = useImageUpload(sessionId); diff --git a/apps/desktop/src/session/components/note-input/search-replace.ts b/apps/desktop/src/session/components/note-input/search-replace.ts deleted file mode 100644 index 8202695b6f..0000000000 --- a/apps/desktop/src/session/components/note-input/search-replace.ts +++ /dev/null @@ -1,312 +0,0 @@ -import type { EditorView } from "prosemirror-view"; - -import type { SearchReplaceDetail } from "./transcript/search/context"; - -import * as main from "~/store/tinybase/store/main"; -import { parseTranscriptWords, updateTranscriptWords } from "~/stt/utils"; - -type Store = NonNullable>; -type Indexes = ReturnType; -type Checkpoints = ReturnType; - -export function isWordBoundary(text: string, index: number): boolean { - if (index < 0 || index >= text.length) return true; - return !/\w/.test(text[index]); -} - -function replaceInText( - text: string, - query: string, - replacement: string, - caseSensitive: boolean, - wholeWord: boolean, - all: boolean, - nth: number, -): string { - let searchText = caseSensitive ? text : text.toLowerCase(); - const searchQuery = caseSensitive ? query : query.toLowerCase(); - let count = 0; - let from = 0; - - while (from <= searchText.length - searchQuery.length) { - const idx = searchText.indexOf(searchQuery, from); - if (idx === -1) break; - - if (wholeWord) { - const beforeOk = isWordBoundary(searchText, idx - 1); - const afterOk = isWordBoundary(searchText, idx + searchQuery.length); - if (!beforeOk || !afterOk) { - from = idx + 1; - continue; - } - } - - if (all || count === nth) { - const before = text.slice(0, idx); - const after = text.slice(idx + query.length); - if (all) { - text = before + replacement + after; - searchText = caseSensitive ? text : text.toLowerCase(); - from = idx + replacement.length; - continue; - } - return before + replacement + after; - } - - count++; - from = idx + 1; - } - - return text; -} - -export function handleTranscriptReplace( - detail: SearchReplaceDetail, - store: Store | undefined, - indexes: Indexes, - checkpoints: Checkpoints, - sessionId: string, -) { - if (!store || !indexes || !checkpoints) return; - - const transcriptIds = indexes.getSliceRowIds( - main.INDEXES.transcriptBySession, - sessionId, - ); - if (!transcriptIds) return; - - const searchQuery = detail.caseSensitive - ? detail.query - : detail.query.toLowerCase(); - - let globalMatchIndex = 0; - - for (const transcriptId of transcriptIds) { - const words = parseTranscriptWords(store, transcriptId); - if (words.length === 0) continue; - - type WordPosition = { start: number; end: number; wordIndex: number }; - const wordPositions: WordPosition[] = []; - let fullText = ""; - - for (let i = 0; i < words.length; i++) { - const text = (words[i].text ?? "").normalize("NFC"); - if (i > 0) fullText += " "; - const start = fullText.length; - fullText += text; - wordPositions.push({ start, end: fullText.length, wordIndex: i }); - } - - const searchText = detail.caseSensitive ? fullText : fullText.toLowerCase(); - let from = 0; - - type Match = { textPos: number; wordIndex: number; offsetInWord: number }; - const matches: Match[] = []; - - while (from <= searchText.length - searchQuery.length) { - const idx = searchText.indexOf(searchQuery, from); - if (idx === -1) break; - - if (detail.wholeWord) { - const beforeOk = isWordBoundary(searchText, idx - 1); - const afterOk = isWordBoundary(searchText, idx + searchQuery.length); - if (!beforeOk || !afterOk) { - from = idx + 1; - continue; - } - } - - for (let i = 0; i < wordPositions.length; i++) { - const { start, end, wordIndex } = wordPositions[i]; - if (idx >= start && idx < end) { - matches.push({ - textPos: idx, - wordIndex, - offsetInWord: idx - start, - }); - break; - } - if ( - i < wordPositions.length - 1 && - idx >= end && - idx < wordPositions[i + 1].start - ) { - matches.push({ - textPos: idx, - wordIndex: wordPositions[i + 1].wordIndex, - offsetInWord: 0, - }); - break; - } - } - - from = idx + 1; - } - - let changed = false; - - if (detail.all) { - for (const match of matches) { - const word = words[match.wordIndex]; - const originalText = word.text ?? ""; - word.text = replaceInText( - originalText, - detail.query, - detail.replacement, - detail.caseSensitive, - detail.wholeWord, - true, - 0, - ); - if (word.text !== originalText) changed = true; - } - } else { - for (const match of matches) { - if (globalMatchIndex === detail.matchIndex) { - const word = words[match.wordIndex]; - const originalText = word.text ?? ""; - const searchTextInWord = detail.caseSensitive - ? originalText - : originalText.toLowerCase(); - const searchQueryInWord = detail.caseSensitive - ? detail.query - : detail.query.toLowerCase(); - - let nthInWord = 0; - let pos = 0; - while (pos <= searchTextInWord.length - searchQueryInWord.length) { - const foundIdx = searchTextInWord.indexOf(searchQueryInWord, pos); - if (foundIdx === -1) break; - - if (detail.wholeWord) { - const beforeOk = isWordBoundary(searchTextInWord, foundIdx - 1); - const afterOk = isWordBoundary( - searchTextInWord, - foundIdx + searchQueryInWord.length, - ); - if (!beforeOk || !afterOk) { - pos = foundIdx + 1; - continue; - } - } - - if (foundIdx === match.offsetInWord) { - break; - } - nthInWord++; - pos = foundIdx + 1; - } - - word.text = replaceInText( - originalText, - detail.query, - detail.replacement, - detail.caseSensitive, - detail.wholeWord, - false, - nthInWord, - ); - changed = true; - break; - } - globalMatchIndex++; - } - } - - if (changed) { - updateTranscriptWords(store, transcriptId, words); - checkpoints.addCheckpoint("replace_word"); - if (!detail.all) return; - } - } -} - -export function handleEditorReplace( - detail: SearchReplaceDetail, - view: EditorView | null, -) { - if (!view) return; - - const doc = view.state.doc; - const searchQuery = detail.caseSensitive - ? detail.query - : detail.query.toLowerCase(); - - type TextNodeWithPosition = { text: string; pos: number }; - const textNodesWithPosition: TextNodeWithPosition[] = []; - let index = 0; - - doc.descendants((node, pos) => { - if (node.isText) { - if (textNodesWithPosition[index]) { - textNodesWithPosition[index] = { - text: textNodesWithPosition[index].text + node.text, - pos: textNodesWithPosition[index].pos, - }; - } else { - textNodesWithPosition[index] = { - text: node.text ?? "", - pos, - }; - } - } else { - index += 1; - } - }); - - type Hit = { from: number; to: number }; - const hits: Hit[] = []; - - for (const entry of textNodesWithPosition) { - if (!entry) continue; - const { text, pos } = entry; - - const searchText = detail.caseSensitive ? text : text.toLowerCase(); - let from = 0; - - while (from <= searchText.length - searchQuery.length) { - const idx = searchText.indexOf(searchQuery, from); - if (idx === -1) break; - - if (detail.wholeWord) { - const beforeOk = isWordBoundary(searchText, idx - 1); - const afterOk = isWordBoundary(searchText, idx + searchQuery.length); - if (!beforeOk || !afterOk) { - from = idx + 1; - continue; - } - } - - hits.push({ - from: pos + idx, - to: pos + idx + detail.query.length, - }); - from = idx + 1; - } - } - - if (hits.length === 0) return; - - const toReplace = detail.all ? hits : [hits[detail.matchIndex]]; - if (!toReplace[0]) return; - - let offset = 0; - const tr = view.state.tr; - - for (const hit of toReplace) { - const adjustedFrom = hit.from + offset; - const adjustedTo = hit.to + offset; - if (detail.replacement) { - tr.replaceWith( - adjustedFrom, - adjustedTo, - view.state.schema.text(detail.replacement), - ); - } else { - tr.delete(adjustedFrom, adjustedTo); - } - offset += detail.replacement.length - detail.query.length; - } - - view.dispatch(tr); -} diff --git a/apps/desktop/src/session/components/note-input/transcript/search/bar.tsx b/apps/desktop/src/session/components/note-input/search/bar.tsx similarity index 80% rename from apps/desktop/src/session/components/note-input/transcript/search/bar.tsx rename to apps/desktop/src/session/components/note-input/search/bar.tsx index 2df62263bd..b0672d981e 100644 --- a/apps/desktop/src/session/components/note-input/transcript/search/bar.tsx +++ b/apps/desktop/src/session/components/note-input/search/bar.tsx @@ -17,7 +17,9 @@ import { } from "@hypr/ui/components/ui/tooltip"; import { cn } from "@hypr/utils"; -import { useTranscriptSearch } from "./context"; +import { useSearch } from "./context"; + +import type { NoteEditorRef } from "~/editor"; function ToggleButton({ active, @@ -90,8 +92,14 @@ function IconButton({ ); } -export function SearchBar() { - const search = useTranscriptSearch(); +export function SearchBar({ + editorRef, + isTranscript, +}: { + editorRef: React.RefObject; + isTranscript?: boolean; +}) { + const search = useSearch(); const searchInputRef = useRef(null); const replaceInputRef = useRef(null); @@ -111,24 +119,60 @@ export function SearchBar() { const { query, - setQuery, currentMatchIndex, totalMatches, onNext, onPrev, - close, caseSensitive, wholeWord, showReplace, replaceQuery, - toggleCaseSensitive, toggleWholeWord, toggleReplace, setReplaceQuery, - replaceCurrent, - replaceAll, } = search; + const commands = isTranscript ? null : editorRef.current?.commands; + + const setQuery = (q: string) => { + search.setQuery(q); + commands?.setSearch(q, caseSensitive); + }; + + const toggleCaseSensitive = () => { + search.toggleCaseSensitive(); + commands?.setSearch(query, !caseSensitive); + }; + + const close = () => { + search.close(); + commands?.setSearch("", false); + }; + + const replaceCurrent = () => { + if (!query || totalMatches === 0) return; + commands?.replace({ + query, + replacement: replaceQuery, + caseSensitive, + wholeWord, + all: false, + matchIndex: currentMatchIndex, + }); + }; + + const replaceAll = () => { + if (!query) return; + commands?.replace({ + query, + replacement: replaceQuery, + caseSensitive, + wholeWord, + all: true, + matchIndex: 0, + }); + }; + const handleSearchKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); @@ -181,18 +225,20 @@ export function SearchBar() { > - - Replace - ⌘ H - - } - > - - + {!isTranscript && ( + + Replace + ⌘ H + + } + > + + + )} {displayCount} @@ -236,7 +282,7 @@ export function SearchBar() { - {showReplace && ( + {showReplace && !isTranscript && (
void; toggleReplace: () => void; setReplaceQuery: (query: string) => void; - replaceCurrent: () => void; - replaceAll: () => void; } const SearchContext = createContext(null); -export function useTranscriptSearch() { +export function useSearch() { return useContext(SearchContext); } -export interface SearchReplaceDetail { - query: string; - replacement: string; - caseSensitive: boolean; - wholeWord: boolean; - all: boolean; - matchIndex: number; -} - interface SearchState { isVisible: boolean; query: string; @@ -252,47 +241,6 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { }); }, [state.currentMatchIndex]); - const replaceCurrent = useCallback(() => { - if (!state.query || matchesRef.current.length === 0) return; - const detail: SearchReplaceDetail = { - query: state.query, - replacement: state.replaceQuery, - caseSensitive: state.caseSensitive, - wholeWord: state.wholeWord, - all: false, - matchIndex: state.currentMatchIndex, - }; - window.dispatchEvent(new CustomEvent("search-replace", { detail })); - setTimeout(runSearch, 50); - }, [ - state.query, - state.replaceQuery, - state.caseSensitive, - state.wholeWord, - state.currentMatchIndex, - runSearch, - ]); - - const replaceAllFn = useCallback(() => { - if (!state.query) return; - const detail: SearchReplaceDetail = { - query: state.query, - replacement: state.replaceQuery, - caseSensitive: state.caseSensitive, - wholeWord: state.wholeWord, - all: true, - matchIndex: 0, - }; - window.dispatchEvent(new CustomEvent("search-replace", { detail })); - setTimeout(runSearch, 50); - }, [ - state.query, - state.replaceQuery, - state.caseSensitive, - state.wholeWord, - runSearch, - ]); - useEffect(() => { if (!state.isVisible || !state.activeMatchId) return; @@ -328,10 +276,8 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { toggleReplace: () => dispatch({ type: "toggle_replace" }), setReplaceQuery: (query: string) => dispatch({ type: "set_replace_query", query }), - replaceCurrent, - replaceAll: replaceAllFn, }), - [state, onNext, onPrev, replaceCurrent, replaceAllFn], + [state, onNext, onPrev], ); return ( diff --git a/apps/desktop/src/session/components/note-input/transcript/search/matching.ts b/apps/desktop/src/session/components/note-input/search/matching.ts similarity index 97% rename from apps/desktop/src/session/components/note-input/transcript/search/matching.ts rename to apps/desktop/src/session/components/note-input/search/matching.ts index 9e1c7b6fc8..c16ab9c616 100644 --- a/apps/desktop/src/session/components/note-input/transcript/search/matching.ts +++ b/apps/desktop/src/session/components/note-input/search/matching.ts @@ -1,4 +1,7 @@ -import { isWordBoundary } from "../../search-replace"; +function isWordBoundary(text: string, index: number): boolean { + if (index < 0 || index >= text.length) return true; + return !/\w/.test(text[index]); +} export interface SearchOptions { caseSensitive: boolean; diff --git a/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx b/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx index 076fd8de9b..7e2491b433 100644 --- a/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx +++ b/apps/desktop/src/session/components/note-input/transcript/renderer/word-span.tsx @@ -4,8 +4,8 @@ import { cn } from "@hypr/utils"; import type { HighlightSegment } from "./utils"; -import { useTranscriptSearch } from "~/session/components/note-input/transcript/search/context"; -import { createHighlightSegments } from "~/session/components/note-input/transcript/search/matching"; +import { useSearch } from "~/session/components/note-input/search/context"; +import { createHighlightSegments } from "~/session/components/note-input/search/matching"; import type { SegmentWord } from "~/stt/live-segment"; interface WordSpanProps { @@ -50,7 +50,7 @@ export function WordSpan(props: WordSpanProps) { } function useTranscriptSearchHighlights(word: SegmentWord, displayText: string) { - const search = useTranscriptSearch(); + const search = useSearch(); const query = search?.query?.trim() ?? ""; const isVisible = Boolean(search?.isVisible); const activeMatchId = search?.activeMatchId ?? null; diff --git a/apps/desktop/src/session/components/note-input/use-search-sync.ts b/apps/desktop/src/session/components/note-input/use-search-sync.ts deleted file mode 100644 index 5bca9c0b52..0000000000 --- a/apps/desktop/src/session/components/note-input/use-search-sync.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { type MutableRefObject, useEffect } from "react"; - -import { handleEditorReplace, handleTranscriptReplace } from "./search-replace"; -import { - type SearchReplaceDetail, - useTranscriptSearch, -} from "./transcript/search/context"; - -import type { NoteEditorRef } from "~/editor"; -import * as main from "~/store/tinybase/store/main"; -import { type EditorView } from "~/store/zustand/tabs/schema"; - -export function useSearchSync({ - editorRef, - currentTab, - sessionId, -}: { - editorRef: MutableRefObject; - currentTab: EditorView; - sessionId: string; -}) { - const search = useTranscriptSearch(); - const showSearchBar = search?.isVisible ?? false; - - useEffect(() => { - search?.close(); - }, [currentTab]); - - useEffect(() => { - const noteRef = editorRef.current; - if (!noteRef?.view) return; - const { searchStorage } = noteRef; - - const isEditorTab = - currentTab.type !== "transcript" && currentTab.type !== "attachments"; - const query = isEditorTab && search?.isVisible ? (search.query ?? "") : ""; - - searchStorage.searchTerm = query; - searchStorage.caseSensitive = search?.caseSensitive ?? false; - searchStorage.resultIndex = search?.currentMatchIndex ?? 0; - - try { - noteRef.view.dispatch(noteRef.view.state.tr); - } catch { - return; - } - - if (query) { - requestAnimationFrame(() => { - const el = noteRef.view?.dom.querySelector(".search-result-current"); - el?.scrollIntoView({ behavior: "smooth", block: "center" }); - }); - } - }, [ - editorRef, - currentTab.type, - search?.isVisible, - search?.query, - search?.caseSensitive, - search?.currentMatchIndex, - ]); - - const store = main.UI.useStore(main.STORE_ID); - const indexes = main.UI.useIndexes(main.STORE_ID); - const checkpoints = main.UI.useCheckpoints(main.STORE_ID); - - useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (currentTab.type === "transcript") { - handleTranscriptReplace(detail, store, indexes, checkpoints, sessionId); - } else { - handleEditorReplace(detail, editorRef.current?.view ?? null); - } - }; - window.addEventListener("search-replace", handler); - return () => window.removeEventListener("search-replace", handler); - }, [currentTab, store, indexes, checkpoints, sessionId, editorRef]); - - return { showSearchBar }; -} diff --git a/apps/desktop/src/session/index.tsx b/apps/desktop/src/session/index.tsx index 3c2c661eb2..5c857a9634 100644 --- a/apps/desktop/src/session/index.tsx +++ b/apps/desktop/src/session/index.tsx @@ -11,7 +11,7 @@ import { cn } from "@hypr/utils"; import { CaretPositionProvider } from "./components/caret-position-context"; import { FloatingActionButton } from "./components/floating"; import { NoteInput, type NoteInputHandle } from "./components/note-input"; -import { SearchProvider } from "./components/note-input/transcript/search/context"; +import { SearchProvider } from "./components/note-input/search/context"; import { OuterHeader } from "./components/outer-header"; import { SessionPreviewCard } from "./components/session-preview-card"; import { useCurrentNoteTab, useHasTranscript } from "./components/shared"; diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css index 866dd78b5f..5b3dff5c00 100644 --- a/apps/desktop/src/styles/globals.css +++ b/apps/desktop/src/styles/globals.css @@ -220,12 +220,12 @@ } /* Search result highlighting styles — matches transcript's bg-yellow-200/50 and bg-yellow-500 */ -.search-result { +.ProseMirror-search-match { background-color: rgb(254 240 138 / 0.5); border-radius: 2px; } -.search-result-current { +.ProseMirror-active-search-match { background-color: rgb(234 179 8) !important; border-radius: 2px; } diff --git a/packages/tiptap/src/styles/nodes/search.css b/packages/tiptap/src/styles/nodes/search.css index 1a1692eb09..37a343f51f 100644 --- a/packages/tiptap/src/styles/nodes/search.css +++ b/packages/tiptap/src/styles/nodes/search.css @@ -1,7 +1,7 @@ -.ProseMirror .search-result { +.ProseMirror .ProseMirror-search-match { background-color: #fef08a; } -.ProseMirror .search-result-current { +.ProseMirror .ProseMirror-active-search-match { background-color: #fb923c; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e84e436cc..edb8f5aa5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -527,6 +527,9 @@ importers: prosemirror-schema-list: specifier: ^1.5.1 version: 1.5.1 + prosemirror-search: + specifier: ^1.1.0 + version: 1.1.0 prosemirror-state: specifier: ^1.4.4 version: 1.4.4 @@ -685,10 +688,10 @@ importers: version: link:../../packages/agent-support '@langchain/core': specifier: ^1.1.22 - version: 1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)) + version: 1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)) '@langchain/langgraph': specifier: ^1.1.4 - version: 1.1.4(@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) + version: 1.1.4(@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) '@sentry/bun': specifier: ^10.38.0 version: 10.38.0 @@ -15019,6 +15022,9 @@ packages: prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + prosemirror-search@1.1.0: + resolution: {integrity: sha512-hnGINlrRs+St6scaF4hoGiR8b7V0ffddzvO/zy+ON8RwvVinfLk4rVsuSztLNthgvfE2LAOU4blsPr7yoeoLOQ==} + prosemirror-state@1.4.4: resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} @@ -18284,14 +18290,14 @@ snapshots: '@apm-js-collab/tracing-hooks@0.3.1': dependencies: '@apm-js-collab/code-transformer': 0.8.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) module-details-from-path: 1.0.4 transitivePeerDependencies: - supports-color '@argos-ci/api-client@0.16.0': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) openapi-fetch: 0.15.2 transitivePeerDependencies: - supports-color @@ -18303,7 +18309,7 @@ snapshots: '@argos-ci/api-client': 0.16.0 '@argos-ci/util': 3.2.0 convict: 6.2.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) fast-glob: 3.3.3 mime-types: 3.0.2 sharp: 0.34.5 @@ -18317,7 +18323,7 @@ snapshots: '@argos-ci/core': 5.1.0 '@argos-ci/util': 3.2.0 chalk: 5.6.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -18468,7 +18474,7 @@ snapshots: '@astrojs/telemetry@3.3.0': dependencies: ci-info: 4.4.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) dlv: 1.1.3 dset: 3.1.4 is-docker: 3.0.0 @@ -18525,7 +18531,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -18621,7 +18627,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -19245,7 +19251,7 @@ snapshots: '@esbuild-plugins/node-resolve@0.2.2(esbuild@0.25.12)': dependencies: '@types/resolve': 1.20.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) esbuild: 0.25.12 escape-string-regexp: 4.0.0 resolve: 1.22.11 @@ -19565,7 +19571,7 @@ snapshots: '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -19581,7 +19587,7 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -19786,7 +19792,7 @@ snapshots: '@opentelemetry/exporter-logs-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-trace-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.2.2) '@opentelemetry/opentelemetry-browser-detector': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.5.0(@opentelemetry/api@1.9.0) @@ -20166,24 +20172,6 @@ snapshots: '@kurkle/color@0.3.4': {} - '@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6))': - dependencies: - '@cfworker/json-schema': 4.1.1 - ansi-styles: 5.2.0 - camelcase: 6.3.0 - decamelize: 1.2.0 - js-tiktoken: 1.0.21 - langsmith: 0.5.2(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)) - mustache: 4.2.0 - p-queue: 6.6.2 - uuid: 10.0.0 - zod: 4.3.6 - transitivePeerDependencies: - - '@opentelemetry/api' - - '@opentelemetry/exporter-trace-otlp-proto' - - '@opentelemetry/sdk-trace-base' - - openai - '@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6))': dependencies: '@cfworker/json-schema': 4.1.1 @@ -20246,11 +20234,6 @@ snapshots: transitivePeerDependencies: - pg-native - '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)))': - dependencies: - '@langchain/core': 1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)) - uuid: 10.0.0 - '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)))': dependencies: '@langchain/core': 1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)) @@ -20291,17 +20274,6 @@ snapshots: - supports-color - typescript - '@langchain/langgraph-sdk@1.6.1(@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@types/json-schema': 7.0.15 - p-queue: 9.1.0 - p-retry: 7.1.1 - uuid: 13.0.0 - optionalDependencies: - '@langchain/core': 1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - '@langchain/langgraph-sdk@1.6.1(@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@types/json-schema': 7.0.15 @@ -20321,20 +20293,6 @@ snapshots: esbuild-plugin-tailwindcss: 2.1.0 zod: 4.3.6 - '@langchain/langgraph@1.1.4(@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6)': - dependencies: - '@langchain/core': 1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6))) - '@langchain/langgraph-sdk': 1.6.1(@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@standard-schema/spec': 1.1.0 - uuid: 10.0.0 - zod: 4.3.6 - optionalDependencies: - zod-to-json-schema: 3.25.1(zod@4.3.6) - transitivePeerDependencies: - - react - - react-dom - '@langchain/langgraph@1.1.4(@langchain/core@1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6)': dependencies: '@langchain/core': 1.1.22(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)) @@ -20530,19 +20488,6 @@ snapshots: '@lukeed/ms@2.0.2': {} - '@mapbox/node-pre-gyp@2.0.3': - dependencies: - consola: 3.4.2 - detect-libc: 2.1.2 - https-proxy-agent: 7.0.6 - node-fetch: 2.7.0 - nopt: 8.1.0 - semver: 7.6.3 - tar: 7.5.7 - transitivePeerDependencies: - - encoding - - supports-color - '@mapbox/node-pre-gyp@2.0.3(supports-color@10.2.2)': dependencies: consola: 3.4.2 @@ -20711,14 +20656,6 @@ snapshots: '@netlify/dev-utils': 4.3.0 '@netlify/runtime-utils': 2.2.0 - '@netlify/blobs@10.6.0': - dependencies: - '@netlify/dev-utils': 4.3.3 - '@netlify/otel': 5.1.1 - '@netlify/runtime-utils': 2.3.0 - transitivePeerDependencies: - - supports-color - '@netlify/blobs@10.6.0(supports-color@10.2.2)': dependencies: '@netlify/dev-utils': 4.3.3 @@ -20930,7 +20867,7 @@ snapshots: '@netlify/dev@4.10.0(@netlify/api@14.0.14)(aws4fetch@1.0.20)(ioredis@5.9.2)(rollup@4.57.1)': dependencies: '@netlify/ai': 0.3.6(@netlify/api@14.0.14) - '@netlify/blobs': 10.6.0 + '@netlify/blobs': 10.6.0(supports-color@10.2.2) '@netlify/config': 24.3.1 '@netlify/dev-utils': 4.3.3 '@netlify/edge-functions-dev': 1.0.10 @@ -21032,10 +20969,10 @@ snapshots: '@netlify/functions-dev@1.1.10(rollup@4.57.1)': dependencies: - '@netlify/blobs': 10.6.0 + '@netlify/blobs': 10.6.0(supports-color@10.2.2) '@netlify/dev-utils': 4.3.3 '@netlify/functions': 5.1.2 - '@netlify/zip-it-and-ship-it': 14.3.1(rollup@4.57.1) + '@netlify/zip-it-and-ship-it': 14.3.1(rollup@4.57.1)(supports-color@10.2.2) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -21193,16 +21130,6 @@ snapshots: dependencies: '@opentelemetry/api': 1.8.0 - '@netlify/otel@5.1.1': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-node': 1.30.1(@opentelemetry/api@1.9.0) - transitivePeerDependencies: - - supports-color - '@netlify/otel@5.1.1(supports-color@10.2.2)': dependencies: '@opentelemetry/api': 1.9.0 @@ -21240,7 +21167,7 @@ snapshots: '@netlify/runtime@4.1.14': dependencies: - '@netlify/blobs': 10.6.0 + '@netlify/blobs': 10.6.0(supports-color@10.2.2) '@netlify/cache': 3.3.5 '@netlify/runtime-utils': 2.3.0 '@netlify/types': 2.3.0 @@ -21321,47 +21248,6 @@ snapshots: - supports-color - uploadthing - '@netlify/zip-it-and-ship-it@14.3.1(rollup@4.57.1)': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@netlify/binary-info': 1.0.0 - '@netlify/serverless-functions-api': 2.8.3 - '@vercel/nft': 0.29.4(rollup@4.57.1) - archiver: 7.0.1 - common-path-prefix: 3.0.0 - copy-file: 11.1.0 - es-module-lexer: 1.7.0 - esbuild: 0.27.2 - execa: 8.0.1 - fast-glob: 3.3.3 - filter-obj: 6.1.0 - find-up: 7.0.0 - is-path-inside: 4.0.0 - junk: 4.0.1 - locate-path: 7.2.0 - merge-options: 3.0.4 - minimatch: 9.0.5 - normalize-path: 3.0.0 - p-map: 7.0.3 - path-exists: 5.0.0 - precinct: 12.2.0 - require-package-name: 2.0.1 - resolve: 2.0.0-next.5 - semver: 7.6.3 - tmp-promise: 3.0.3 - toml: 3.0.0 - unixify: 1.0.0 - urlpattern-polyfill: 8.0.2 - yargs: 17.7.2 - zod: 3.25.76 - transitivePeerDependencies: - - bare-abort-controller - - encoding - - react-native-b4a - - rollup - - supports-color - '@netlify/zip-it-and-ship-it@14.3.1(rollup@4.57.1)(supports-color@10.2.2)': dependencies: '@babel/parser': 7.29.0 @@ -21702,7 +21588,7 @@ snapshots: '@opentelemetry/auto-instrumentations-web@0.49.1(@opentelemetry/api@1.9.0)(zone.js@0.16.1)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.2.2) '@opentelemetry/instrumentation-document-load': 0.48.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-fetch': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-user-interaction': 0.48.1(@opentelemetry/api@1.9.0)(zone.js@0.16.1) @@ -21836,7 +21722,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.2.2) '@opentelemetry/sdk-trace-web': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: @@ -21873,7 +21759,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.2.2) '@opentelemetry/sdk-trace-web': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: @@ -22203,7 +22089,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.2.2) '@opentelemetry/sdk-trace-web': 2.6.0(@opentelemetry/api@1.9.0) zone.js: 0.16.1 transitivePeerDependencies: @@ -22213,21 +22099,12 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.2.2) '@opentelemetry/sdk-trace-web': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.203.0 - import-in-the-middle: 1.15.0 - require-in-the-middle: 7.5.2 - transitivePeerDependencies: - - supports-color - '@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.2.2)': dependencies: '@opentelemetry/api': 1.9.0 @@ -22261,7 +22138,7 @@ snapshots: '@opentelemetry/api-logs': 0.53.0 '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 - require-in-the-middle: 7.5.2 + require-in-the-middle: 7.5.2(supports-color@10.2.2) semver: 7.6.3 shimmer: 1.2.1 transitivePeerDependencies: @@ -22273,7 +22150,7 @@ snapshots: '@opentelemetry/api-logs': 0.57.1 '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 - require-in-the-middle: 7.5.2 + require-in-the-middle: 7.5.2(supports-color@10.2.2) semver: 7.6.3 shimmer: 1.2.1 transitivePeerDependencies: @@ -22285,7 +22162,7 @@ snapshots: '@opentelemetry/api-logs': 0.57.2 '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 - require-in-the-middle: 7.5.2 + require-in-the-middle: 7.5.2(supports-color@10.2.2) semver: 7.6.3 shimmer: 1.2.1 transitivePeerDependencies: @@ -22759,7 +22636,7 @@ snapshots: '@pnpm/tabtab@0.5.4': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) enquirer: 2.4.1 minimist: 1.2.8 untildify: 4.0.0 @@ -22850,7 +22727,7 @@ snapshots: '@puppeteer/browsers@2.12.0': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -22865,7 +22742,7 @@ snapshots: '@puppeteer/browsers@2.3.0': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -26160,9 +26037,9 @@ snapshots: dependencies: '@typescript-eslint/scope-manager': 8.55.0 '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.55.0(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -26177,15 +26054,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - debug: 4.4.3(supports-color@8.1.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/scope-manager@8.55.0': dependencies: '@typescript-eslint/types': 8.55.0 @@ -26198,9 +26066,9 @@ snapshots: '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.55.0(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -26224,27 +26092,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3(supports-color@8.1.1) - minimatch: 9.0.5 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.55.0 '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.55.0(supports-color@10.2.2)(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -26257,7 +26110,7 @@ snapshots: '@typescript/vfs@1.6.2(typescript@5.9.3)': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -26399,25 +26252,6 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.4 - '@vercel/nft@0.29.4(rollup@4.57.1)': - dependencies: - '@mapbox/node-pre-gyp': 2.0.3 - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - acorn: 8.16.0 - acorn-import-attributes: 1.9.5(acorn@8.16.0) - async-sema: 3.1.1 - bindings: 1.5.0 - estree-walker: 2.0.2 - glob: 10.5.0 - graceful-fs: 4.2.11 - node-gyp-build: 4.8.4 - picomatch: 4.0.3 - resolve-from: 5.0.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - '@vercel/nft@0.29.4(rollup@4.57.1)(supports-color@10.2.2)': dependencies: '@mapbox/node-pre-gyp': 2.0.3(supports-color@10.2.2) @@ -27045,7 +26879,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -27315,7 +27149,7 @@ snapshots: common-ancestor-path: 1.0.1 cookie: 1.1.1 cssesc: 3.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) deterministic-object-hash: 2.0.2 devalue: 5.6.2 diff: 8.0.3 @@ -27689,7 +27523,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -28760,15 +28594,6 @@ snapshots: transitivePeerDependencies: - supports-color - detective-typescript@14.0.0(typescript@5.9.3): - dependencies: - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - ast-module-types: 6.0.1 - node-source-walk: 7.0.1 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - detective-vue2@2.2.0(supports-color@10.2.2)(typescript@5.9.3): dependencies: '@dependents/detective-less': 5.0.1 @@ -28782,19 +28607,6 @@ snapshots: transitivePeerDependencies: - supports-color - detective-vue2@2.2.0(typescript@5.9.3): - dependencies: - '@dependents/detective-less': 5.0.1 - '@vue/compiler-sfc': 3.5.28 - detective-es6: 5.0.1 - detective-sass: 6.0.1 - detective-scss: 5.0.1 - detective-stylus: 5.0.1 - detective-typescript: 14.0.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - deterministic-object-hash@2.0.2: dependencies: base-64: 1.0.0 @@ -28931,7 +28743,7 @@ snapshots: edge-paths: 3.0.5 fast-xml-parser: 5.3.5 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) which: 6.0.1 transitivePeerDependencies: - supports-color @@ -29072,7 +28884,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.12): dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) esbuild: 0.25.12 transitivePeerDependencies: - supports-color @@ -29238,7 +29050,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -29447,7 +29259,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -29496,7 +29308,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -29663,7 +29475,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -29734,7 +29546,7 @@ snapshots: follow-redirects@1.15.11(debug@4.4.3): optionalDependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) fontace@0.4.1: dependencies: @@ -29831,7 +29643,7 @@ snapshots: gaxios@7.1.3: dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) node-fetch: 3.3.2 rimraf: 5.0.10 transitivePeerDependencies: @@ -29851,7 +29663,7 @@ snapshots: '@zip.js/zip.js': 2.8.19 decamelize: 6.0.1 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) modern-tar: 0.7.3 transitivePeerDependencies: - supports-color @@ -29922,7 +29734,7 @@ snapshots: dependencies: basic-ftp: 5.1.0 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -30410,14 +30222,14 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color http-proxy-middleware@3.0.5: dependencies: '@types/http-proxy': 1.17.17 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) http-proxy: 1.18.1(debug@4.4.3) is-glob: 4.0.3 is-plain-object: 5.0.0 @@ -30443,14 +30255,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -30605,7 +30410,7 @@ snapshots: dependencies: '@ioredis/commands': 1.5.0 cluster-key-slot: 1.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -30986,7 +30791,7 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) is-potential-custom-element-name: 1.0.1 parse5: 8.0.0 saxes: 6.0.0 @@ -31191,19 +30996,6 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) openai: 6.21.0(ws@8.19.0)(zod@4.3.6) - langsmith@0.5.2(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)): - dependencies: - '@types/uuid': 10.0.0 - chalk: 4.1.2 - console-table-printer: 2.15.0 - p-queue: 6.6.2 - semver: 7.6.3 - uuid: 10.0.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/sdk-trace-base': 2.5.0(@opentelemetry/api@1.9.0) - openai: 6.21.0(ws@8.19.0)(zod@4.3.6) - langsmith@0.5.2(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.21.0(ws@8.19.0)(zod@4.3.6)): dependencies: '@types/uuid': 10.0.0 @@ -31267,7 +31059,7 @@ snapshots: lighthouse-logger@2.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) marky: 1.3.0 transitivePeerDependencies: - supports-color @@ -32652,7 +32444,7 @@ snapshots: micromark@3.2.0: dependencies: '@types/debug': 4.1.12 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) decode-named-character-reference: 1.3.0 micromark-core-commonmark: 1.1.0 micromark-factory-space: 1.1.0 @@ -32675,7 +32467,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -32910,7 +32702,7 @@ snapshots: '@netlify/images': 1.2.5(@netlify/blobs@10.1.0)(aws4fetch@1.0.20)(ioredis@5.9.2) '@netlify/local-functions-proxy': 2.0.3 '@netlify/redirect-parser': 15.0.3 - '@netlify/zip-it-and-ship-it': 14.3.1(rollup@4.57.1) + '@netlify/zip-it-and-ship-it': 14.3.1(rollup@4.57.1)(supports-color@10.2.2) '@octokit/rest': 22.0.0 '@opentelemetry/api': 1.8.0 '@pnpm/tabtab': 0.5.4 @@ -32928,7 +32720,7 @@ snapshots: content-type: 1.0.5 cookie: 1.0.2 cron-parser: 4.9.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) decache: 4.6.2 dot-prop: 10.1.0 dotenv: 17.2.3 @@ -32950,7 +32742,7 @@ snapshots: gitconfiglocal: 2.1.0 http-proxy: 1.18.1(debug@4.4.3) http-proxy-middleware: 3.0.5 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) inquirer: 8.2.7(@types/node@22.19.11) inquirer-autocomplete-prompt: 1.4.0(inquirer@8.2.7(@types/node@22.19.11)) is-docker: 3.0.0 @@ -33478,10 +33270,10 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) get-uri: 6.0.5 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) pac-resolver: 7.0.1 socks-proxy-agent: 8.0.5 transitivePeerDependencies: @@ -33948,26 +33740,6 @@ snapshots: preact@10.28.4: {} - precinct@12.2.0: - dependencies: - '@dependents/detective-less': 5.0.1 - commander: 12.1.0 - detective-amd: 6.0.1 - detective-cjs: 6.0.1 - detective-es6: 5.0.1 - detective-postcss: 7.0.1(postcss@8.5.6) - detective-sass: 6.0.1 - detective-scss: 5.0.1 - detective-stylus: 5.0.1 - detective-typescript: 14.0.0(typescript@5.9.3) - detective-vue2: 2.2.0(typescript@5.9.3) - module-definition: 6.0.1 - node-source-walk: 7.0.1 - postcss: 8.5.6 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - precinct@12.2.0(supports-color@10.2.2): dependencies: '@dependents/detective-less': 5.0.1 @@ -34168,6 +33940,12 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.11.0 + prosemirror-search@1.1.0: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.6 + prosemirror-state@1.4.4: dependencies: prosemirror-model: 1.25.4 @@ -34225,9 +34003,9 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) lru-cache: 7.18.3 pac-proxy-agent: 7.2.0 proxy-from-env: 1.1.0 @@ -34273,7 +34051,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.3.0 chromium-bidi: 0.6.3(devtools-protocol@0.0.1312386) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) devtools-protocol: 0.0.1312386 ws: 8.19.0 transitivePeerDependencies: @@ -35019,14 +34797,6 @@ snapshots: require-from-string@2.0.2: {} - require-in-the-middle@7.5.2: - dependencies: - debug: 4.4.3(supports-color@8.1.1) - module-details-from-path: 1.0.4 - resolve: 1.22.11 - transitivePeerDependencies: - - supports-color - require-in-the-middle@7.5.2(supports-color@10.2.2): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -35037,7 +34807,7 @@ snapshots: require-in-the-middle@8.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) module-details-from-path: 1.0.4 transitivePeerDependencies: - supports-color @@ -35215,7 +34985,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -35311,7 +35081,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -35542,7 +35312,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) socks: 2.8.7 transitivePeerDependencies: - supports-color @@ -35851,7 +35621,7 @@ snapshots: supabase@2.76.10: dependencies: bin-links: 6.0.0 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) node-fetch: 3.3.2 tar: 7.5.9 transitivePeerDependencies: @@ -36258,7 +36028,7 @@ snapshots: dependencies: '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.55.0(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 @@ -36517,7 +36287,7 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.3 optionalDependencies: - '@netlify/blobs': 10.6.0 + '@netlify/blobs': 10.6.0(supports-color@10.2.2) aws4fetch: 1.0.20 ioredis: 5.9.2 @@ -36683,7 +36453,7 @@ snapshots: vite-node@2.1.9(@types/node@20.19.33)(lightningcss@1.32.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@20.19.33)(lightningcss@1.32.0) @@ -36701,7 +36471,7 @@ snapshots: vite-node@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) @@ -36722,7 +36492,7 @@ snapshots: vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) @@ -36884,7 +36654,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -36922,7 +36692,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -36966,7 +36736,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -37138,7 +36908,7 @@ snapshots: dependencies: chalk: 4.1.2 commander: 9.5.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -37194,7 +36964,7 @@ snapshots: '@wdio/types': 9.24.0 '@wdio/utils': 9.24.0 deepmerge-ts: 7.1.5 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) undici: 6.23.0 ws: 8.19.0 transitivePeerDependencies: