From 0c3fa7bc394f30cbd7f2b15f44129635bcba1d69 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Mon, 23 Mar 2026 19:12:01 +0900 Subject: [PATCH 01/21] 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 f05e0733aa..986de76584 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -33,7 +33,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:^", @@ -133,6 +135,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", @@ -147,6 +159,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 7b0821c0f0..5659fc2dbf 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 b3980583be..bd04abc4e1 100644 --- a/apps/desktop/src/session/index.tsx +++ b/apps/desktop/src/session/index.tsx @@ -200,9 +200,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); @@ -249,7 +247,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 6748fc914d..3566a54cea 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 @@ -199,9 +203,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 @@ -502,6 +512,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) @@ -544,6 +584,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 @@ -3662,6 +3705,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'} @@ -14997,7 +15051,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==} @@ -15304,6 +15358,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'} @@ -19616,7 +19676,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: @@ -19634,7 +19694,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) @@ -19682,6 +19742,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) @@ -34540,6 +34610,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 f43f073f96e973d63e68e655f9dc3f0c9b2b3b26 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Tue, 24 Mar 2026 17:06:01 +0900 Subject: [PATCH 02/21] 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 ed69a5a1d62eb384ab232c23c14d4ce820c115ae Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Wed, 25 Mar 2026 12:56:42 +0900 Subject: [PATCH 03/21] 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 67ea74e024ec683b27dd2c6d9db41217c28152c5 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Wed, 25 Mar 2026 16:26:36 +0900 Subject: [PATCH 04/21] 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 5659fc2dbf..c4093d73bb 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 bd04abc4e1..469e50ff14 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 { getSessionTabStatus } from "./tab-visual-state"; @@ -199,8 +199,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); @@ -242,14 +242,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 ( <>
@@ -273,7 +290,7 @@ function TabContentNoteInner({
@@ -375,7 +392,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 1b29d98b9964ae3b8d674015df889f1db3262af5 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Thu, 26 Mar 2026 10:41:45 +0900 Subject: [PATCH 05/21] 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 69670e244e22a440aea67d868807d0581f95b4e7 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Fri, 27 Mar 2026 11:45:40 +0900 Subject: [PATCH 06/21] 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 986de76584..02cf59af8c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -143,6 +143,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 469e50ff14..02de67080d 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 3566a54cea..5e1d51188f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,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 @@ -694,10 +697,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 @@ -15040,6 +15043,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==} @@ -18305,14 +18311,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 @@ -18324,7 +18330,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 @@ -18338,7 +18344,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 @@ -18489,7 +18495,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 @@ -18546,7 +18552,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 @@ -18642,7 +18648,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 @@ -19266,7 +19272,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 @@ -19586,7 +19592,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 @@ -19602,7 +19608,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 @@ -19807,7 +19813,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) @@ -20187,24 +20193,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 @@ -20267,11 +20255,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)) @@ -20312,17 +20295,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 @@ -20342,20 +20314,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)) @@ -20551,19 +20509,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 @@ -20732,14 +20677,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 @@ -20951,7 +20888,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 @@ -21053,10 +20990,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 @@ -21214,16 +21151,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 @@ -21261,7 +21188,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 @@ -21342,47 +21269,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 @@ -21723,7 +21609,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) @@ -21857,7 +21743,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: @@ -21894,7 +21780,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: @@ -22224,7 +22110,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: @@ -22234,21 +22120,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 @@ -22282,7 +22159,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: @@ -22294,7 +22171,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: @@ -22306,7 +22183,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: @@ -22772,7 +22649,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 @@ -22863,7 +22740,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 @@ -22878,7 +22755,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 @@ -26173,9 +26050,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: @@ -26190,15 +26067,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 @@ -26211,9 +26079,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 @@ -26237,27 +26105,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: @@ -26270,7 +26123,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 @@ -26412,25 +26265,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) @@ -27058,7 +26892,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 @@ -27336,7 +27170,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 @@ -27710,7 +27544,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 @@ -28781,15 +28615,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 @@ -28803,19 +28628,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 @@ -28952,7 +28764,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 @@ -29093,7 +28905,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 @@ -29259,7 +29071,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 @@ -29468,7 +29280,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 @@ -29517,7 +29329,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: @@ -29684,7 +29496,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 @@ -29755,7 +29567,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: @@ -29852,7 +29664,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: @@ -29872,7 +29684,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 @@ -29943,7 +29755,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 @@ -30431,14 +30243,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 @@ -30464,14 +30276,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 @@ -30626,7 +30431,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 @@ -31007,7 +30812,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 @@ -31212,19 +31017,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 @@ -31288,7 +31080,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 @@ -32673,7 +32465,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 @@ -32696,7 +32488,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 @@ -32931,7 +32723,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 @@ -32949,7 +32741,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 @@ -32971,7 +32763,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 @@ -33499,10 +33291,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: @@ -33969,26 +33761,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 @@ -34189,6 +33961,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 @@ -34246,9 +34024,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 @@ -34294,7 +34072,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: @@ -35040,14 +34818,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) @@ -35058,7 +34828,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 @@ -35236,7 +35006,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 @@ -35332,7 +35102,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 @@ -35563,7 +35333,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 @@ -35872,7 +35642,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: @@ -36279,7 +36049,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 @@ -36538,7 +36308,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 @@ -36704,7 +36474,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) @@ -36722,7 +36492,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) @@ -36743,7 +36513,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) @@ -36905,7 +36675,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 @@ -36943,7 +36713,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 @@ -36987,7 +36757,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 @@ -37159,7 +36929,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 @@ -37215,7 +36985,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: From e1586dcba9d9992f9f3b85733c83480080d6db09 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Sun, 29 Mar 2026 18:52:10 +0900 Subject: [PATCH 07/21] add task item view --- apps/desktop/src/editor/index.tsx | 2 + apps/desktop/src/editor/task-item-view.tsx | 51 +++++++++++++++++++ packages/tiptap/src/styles/nodes/list.css | 4 -- .../tiptap/src/styles/nodes/task-list.css | 8 +++ 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/editor/task-item-view.tsx diff --git a/apps/desktop/src/editor/index.tsx b/apps/desktop/src/editor/index.tsx index 0d351003d4..dbd3af32fc 100644 --- a/apps/desktop/src/editor/index.tsx +++ b/apps/desktop/src/editor/index.tsx @@ -56,6 +56,7 @@ import { setSearchState, } from "./plugins"; import { schema } from "./schema"; +import { TaskItemView } from "./task-item-view"; export type { MentionConfig, FileHandlerConfig, PlaceholderFunction }; export { schema }; @@ -103,6 +104,7 @@ interface EditorProps { const nodeViews = { image: ResizableImageView, "mention-@": MentionNodeView, + taskItem: TaskItemView, }; function ViewCapture({ diff --git a/apps/desktop/src/editor/task-item-view.tsx b/apps/desktop/src/editor/task-item-view.tsx new file mode 100644 index 0000000000..6d02063400 --- /dev/null +++ b/apps/desktop/src/editor/task-item-view.tsx @@ -0,0 +1,51 @@ +import { + type NodeViewComponentProps, + useEditorEventCallback, + useEditorState, +} from "@handlewithcare/react-prosemirror"; +import { forwardRef, type ReactNode } from "react"; + +export const TaskItemView = forwardRef< + HTMLLIElement, + NodeViewComponentProps & { children?: ReactNode } +>(({ nodeProps, children, ...htmlAttrs }, ref) => { + const { node, getPos } = nodeProps; + const checked = node.attrs.checked; + + const pos = getPos(); + const { selection } = useEditorState(); + const isSelected = + pos >= selection.from && pos + node.nodeSize <= selection.to - 1; + + const handleChange = useEditorEventCallback((view) => { + if (!view) return; + const pos = getPos(); + const tr = view.state.tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + checked: !checked, + }); + view.dispatch(tr); + }); + + return ( +
  • + +
    {children}
    +
  • + ); +}); + +TaskItemView.displayName = "TaskItemView"; diff --git a/packages/tiptap/src/styles/nodes/list.css b/packages/tiptap/src/styles/nodes/list.css index 1e79a3abe2..9cde6ae122 100644 --- a/packages/tiptap/src/styles/nodes/list.css +++ b/packages/tiptap/src/styles/nodes/list.css @@ -44,8 +44,4 @@ } } } - - li p { - display: inline; - } } diff --git a/packages/tiptap/src/styles/nodes/task-list.css b/packages/tiptap/src/styles/nodes/task-list.css index fca9704d27..585efcb8ec 100644 --- a/packages/tiptap/src/styles/nodes/task-list.css +++ b/packages/tiptap/src/styles/nodes/task-list.css @@ -17,6 +17,8 @@ > label { flex: 0 0 auto; margin-right: 6px; + -webkit-user-select: none; + -moz-user-select: none; user-select: none; position: relative; display: inline-flex; @@ -36,11 +38,17 @@ width: 18px; height: 18px; border: 1.5px solid #000000; + border-radius: 4px; margin: 0; transition: all 0.2s ease; position: relative; margin-top: 3px; + &[data-selected="true"] { + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); + } + &:checked { background-color: #3b82f6; border-color: #3b82f6; From 4ca3ce7f11aad178974088b387ab4562d79e5887 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Sun, 29 Mar 2026 18:52:45 +0900 Subject: [PATCH 08/21] add additional common keybindings --- apps/desktop/src/editor/keymap.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/editor/keymap.ts b/apps/desktop/src/editor/keymap.ts index d16d05c1ea..50671941e0 100644 --- a/apps/desktop/src/editor/keymap.ts +++ b/apps/desktop/src/editor/keymap.ts @@ -7,8 +7,11 @@ import { joinForward, liftEmptyBlock, newlineInCode, + selectAll, selectNodeBackward, selectNodeForward, + selectTextblockEnd, + selectTextblockStart, splitBlock, toggleMark, } from "prosemirror-commands"; @@ -165,7 +168,7 @@ export function buildKeymap(onNavigateToTitle?: (pixelWidth?: number) => void) { splitBlock, ); - keys["Backspace"] = chainCommands( + const backspaceCmd: Command = chainCommands( deleteSelection, (state, _dispatch) => { const { selection } = state; @@ -175,12 +178,30 @@ export function buildKeymap(onNavigateToTitle?: (pixelWidth?: number) => void) { joinBackward, selectNodeBackward, ); + keys["Backspace"] = backspaceCmd; + keys["Mod-Backspace"] = backspaceCmd; + keys["Shift-Backspace"] = backspaceCmd; - keys["Delete"] = chainCommands( + const deleteCmd: Command = chainCommands( deleteSelection, joinForward, selectNodeForward, ); + keys["Delete"] = deleteCmd; + keys["Mod-Delete"] = deleteCmd; + + keys["Mod-a"] = selectAll; + + if (mac) { + keys["Ctrl-h"] = backspaceCmd; + keys["Alt-Backspace"] = backspaceCmd; + keys["Ctrl-d"] = deleteCmd; + keys["Ctrl-Alt-Backspace"] = deleteCmd; + keys["Alt-Delete"] = deleteCmd; + keys["Alt-d"] = deleteCmd; + keys["Ctrl-a"] = selectTextblockStart; + keys["Ctrl-e"] = selectTextblockEnd; + } keys["Tab"] = (state, dispatch) => { const itemName = isInListItem(state); From afe2ae049cff45a5cfa0948659f01458fe8fb29d Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Mon, 30 Mar 2026 14:17:20 +0900 Subject: [PATCH 09/21] add some more bindings related to formatting --- apps/desktop/src/editor/keymap.ts | 57 ++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/editor/keymap.ts b/apps/desktop/src/editor/keymap.ts index 50671941e0..640bbbdf83 100644 --- a/apps/desktop/src/editor/keymap.ts +++ b/apps/desktop/src/editor/keymap.ts @@ -12,6 +12,7 @@ import { selectNodeForward, selectTextblockEnd, selectTextblockStart, + setBlockType, splitBlock, toggleMark, } from "prosemirror-commands"; @@ -29,7 +30,12 @@ import { sinkListItem, splitListItem, } from "prosemirror-schema-list"; -import { Selection, type Command, type EditorState } from "prosemirror-state"; +import { + Selection, + TextSelection, + type Command, + type EditorState, +} from "prosemirror-state"; import { schema } from "./schema"; @@ -145,7 +151,42 @@ export function buildKeymap(onNavigateToTitle?: (pixelWidth?: number) => void) { keys["Shift-Enter"] = hardBreakCmd; if (mac) keys["Mod-Enter"] = hardBreakCmd; + const exitCodeBlockOnEmptyLine: Command = (state, dispatch) => { + const { $from } = state.selection; + if (!$from.parent.type.spec.code) return false; + + const lastLine = $from.parent.textContent.split("\n").pop() ?? ""; + const atEnd = $from.parentOffset === $from.parent.content.size; + if (!atEnd || lastLine !== "") return false; + + if (dispatch) { + const codeBlockPos = $from.before($from.depth); + const codeBlock = $from.parent; + const textContent = codeBlock.textContent.replace(/\n$/, ""); + const tr = state.tr; + + tr.replaceWith( + codeBlockPos, + codeBlockPos + codeBlock.nodeSize, + textContent + ? [ + schema.nodes.codeBlock.create(null, schema.text(textContent)), + schema.nodes.paragraph.create(), + ] + : [schema.nodes.paragraph.create()], + ); + + const newParaPos = textContent + ? codeBlockPos + textContent.length + 2 + 1 + : codeBlockPos + 1; + tr.setSelection(TextSelection.create(tr.doc, newParaPos)); + dispatch(tr.scrollIntoView()); + } + return true; + }; + keys["Enter"] = chainCommands( + exitCodeBlockOnEmptyLine, newlineInCode, (state, dispatch) => { const itemName = isInListItem(state); @@ -168,6 +209,19 @@ export function buildKeymap(onNavigateToTitle?: (pixelWidth?: number) => void) { splitBlock, ); + const revertBlockToParagraph: Command = (state, dispatch) => { + const { $from } = state.selection; + if (!state.selection.empty || $from.parentOffset !== 0) return false; + const node = $from.parent; + if ( + node.type !== schema.nodes.heading && + node.type !== schema.nodes.codeBlock + ) { + return false; + } + return setBlockType(schema.nodes.paragraph)(state, dispatch); + }; + const backspaceCmd: Command = chainCommands( deleteSelection, (state, _dispatch) => { @@ -175,6 +229,7 @@ export function buildKeymap(onNavigateToTitle?: (pixelWidth?: number) => void) { if (selection.$head.pos === 0 && selection.empty) return true; return false; }, + revertBlockToParagraph, joinBackward, selectNodeBackward, ); From f4c8fcddddfebf1fb2d9bda13a220f47cf5bafa8 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Mon, 30 Mar 2026 14:30:10 +0900 Subject: [PATCH 10/21] add editor slash command --- apps/desktop/src/editor/index.tsx | 3 + apps/desktop/src/editor/slash-command.tsx | 442 ++++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 apps/desktop/src/editor/slash-command.tsx diff --git a/apps/desktop/src/editor/index.tsx b/apps/desktop/src/editor/index.tsx index dbd3af32fc..cf28e8319f 100644 --- a/apps/desktop/src/editor/index.tsx +++ b/apps/desktop/src/editor/index.tsx @@ -56,6 +56,7 @@ import { setSearchState, } from "./plugins"; import { schema } from "./schema"; +import { SlashCommandMenu, slashCommandPlugin } from "./slash-command"; import { TaskItemView } from "./task-item-view"; export type { MentionConfig, FileHandlerConfig, PlaceholderFunction }; @@ -298,6 +299,7 @@ const NoteEditor = forwardRef((props, ref) => { clearMarksOnEnterPlugin(), clipPastePlugin(), linkBoundaryGuardPlugin(), + slashCommandPlugin(), ...(mentionConfig ? [mentionSuggestionPlugin(mentionConfig.trigger), mentionSkipPlugin()] : []), @@ -371,6 +373,7 @@ const NoteEditor = forwardRef((props, ref) => { + {mentionConfig && } ); diff --git a/apps/desktop/src/editor/slash-command.tsx b/apps/desktop/src/editor/slash-command.tsx new file mode 100644 index 0000000000..6b195ada57 --- /dev/null +++ b/apps/desktop/src/editor/slash-command.tsx @@ -0,0 +1,442 @@ +import { + autoUpdate, + computePosition, + flip, + limitShift, + offset, + shift, + type VirtualElement, +} from "@floating-ui/dom"; +import { + useEditorEffect, + useEditorEventCallback, + useEditorState, +} from "@handlewithcare/react-prosemirror"; +import { + CodeIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + ListIcon, + ListOrderedIcon, + ListTodoIcon, + MinusIcon, + QuoteIcon, + TextIcon, +} from "lucide-react"; +import { setBlockType } from "prosemirror-commands"; +import { wrapInList } from "prosemirror-schema-list"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { cn } from "@hypr/utils"; + +import { isMentionActive } from "./mention"; +import { schema } from "./schema"; + +// --------------------------------------------------------------------------- +// Slash command items +// --------------------------------------------------------------------------- +export interface SlashCommandItem { + id: string; + label: string; + description: string; + icon: React.ComponentType<{ className?: string }>; + keywords: string[]; + action: ( + view: import("prosemirror-view").EditorView, + from: number, + to: number, + ) => void; +} + +function clearSlashAndRun( + view: import("prosemirror-view").EditorView, + from: number, + to: number, + command: ( + state: import("prosemirror-state").EditorState, + dispatch?: (tr: import("prosemirror-state").Transaction) => void, + ) => boolean, +) { + const tr = view.state.tr.delete(from, to); + view.dispatch(tr); + command(view.state, (tr) => view.dispatch(tr)); +} + +const SLASH_COMMANDS: SlashCommandItem[] = [ + { + id: "paragraph", + label: "Text", + description: "Plain text", + icon: TextIcon, + keywords: ["text", "paragraph", "plain"], + action(view, from, to) { + clearSlashAndRun(view, from, to, setBlockType(schema.nodes.paragraph)); + }, + }, + { + id: "heading1", + label: "Heading 1", + description: "Large heading", + icon: Heading1Icon, + keywords: ["heading", "h1", "title", "large"], + action(view, from, to) { + clearSlashAndRun( + view, + from, + to, + setBlockType(schema.nodes.heading, { level: 1 }), + ); + }, + }, + { + id: "heading2", + label: "Heading 2", + description: "Medium heading", + icon: Heading2Icon, + keywords: ["heading", "h2", "subtitle", "medium"], + action(view, from, to) { + clearSlashAndRun( + view, + from, + to, + setBlockType(schema.nodes.heading, { level: 2 }), + ); + }, + }, + { + id: "heading3", + label: "Heading 3", + description: "Small heading", + icon: Heading3Icon, + keywords: ["heading", "h3", "small"], + action(view, from, to) { + clearSlashAndRun( + view, + from, + to, + setBlockType(schema.nodes.heading, { level: 3 }), + ); + }, + }, + { + id: "bulletList", + label: "Bullet List", + description: "Unordered list", + icon: ListIcon, + keywords: ["bullet", "list", "unordered", "ul"], + action(view, from, to) { + clearSlashAndRun(view, from, to, wrapInList(schema.nodes.bulletList)); + }, + }, + { + id: "orderedList", + label: "Numbered List", + description: "Ordered list", + icon: ListOrderedIcon, + keywords: ["numbered", "list", "ordered", "ol"], + action(view, from, to) { + clearSlashAndRun(view, from, to, wrapInList(schema.nodes.orderedList)); + }, + }, + { + id: "taskList", + label: "Task List", + description: "List with checkboxes", + icon: ListTodoIcon, + keywords: ["task", "todo", "checkbox", "check"], + action(view, from, to) { + const tr = view.state.tr.delete(from, to); + view.dispatch(tr); + const taskItem = schema.nodes.taskItem.create( + { checked: false }, + schema.nodes.paragraph.create(), + ); + const taskList = schema.nodes.taskList.create(null, taskItem); + const { $from } = view.state.selection; + const blockStart = $from.start($from.depth) - 1; + const blockEnd = $from.end($from.depth) + 1; + view.dispatch(view.state.tr.replaceWith(blockStart, blockEnd, taskList)); + }, + }, + { + id: "blockquote", + label: "Quote", + description: "Block quote", + icon: QuoteIcon, + keywords: ["quote", "blockquote", "callout"], + action(view, from, to) { + clearSlashAndRun(view, from, to, (state, dispatch) => { + const { $from, $to } = state.selection; + const range = $from.blockRange($to); + if (!range) return false; + if (dispatch) { + const tr = state.tr.wrap(range, [{ type: schema.nodes.blockquote }]); + dispatch(tr); + } + return true; + }); + }, + }, + { + id: "codeBlock", + label: "Code Block", + description: "Code with syntax highlighting", + icon: CodeIcon, + keywords: ["code", "pre", "block", "snippet"], + action(view, from, to) { + clearSlashAndRun(view, from, to, setBlockType(schema.nodes.codeBlock)); + }, + }, + { + id: "horizontalRule", + label: "Divider", + description: "Horizontal rule", + icon: MinusIcon, + keywords: ["divider", "horizontal", "rule", "line", "hr"], + action(view, from, to) { + const tr = view.state.tr.delete(from, to); + view.dispatch(tr); + const hr = schema.nodes.horizontalRule.create(); + const paragraph = schema.nodes.paragraph.create(); + const { $from } = view.state.selection; + const blockStart = $from.start($from.depth) - 1; + const blockEnd = $from.end($from.depth) + 1; + view.dispatch( + view.state.tr.replaceWith(blockStart, blockEnd, [hr, paragraph]), + ); + }, + }, +]; + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- +interface SlashCommandState { + active: boolean; + query: string; + from: number; + to: number; +} + +export const slashCommandKey = new PluginKey("slashCommand"); + +function findSlashCommand( + state: import("prosemirror-state").EditorState, +): SlashCommandState | null { + const { $from } = state.selection; + if (!state.selection.empty) return null; + if (isMentionActive(state)) return null; + + const textBefore = $from.parent.textBetween( + 0, + $from.parentOffset, + undefined, + "\ufffc", + ); + + const slashIndex = textBefore.lastIndexOf("/"); + if (slashIndex === -1) return null; + if (slashIndex > 0 && !/\s/.test(textBefore[slashIndex - 1])) return null; + + const query = textBefore.slice(slashIndex + 1); + if (/\s/.test(query)) return null; + + const from = $from.start() + slashIndex; + const to = $from.pos; + + return { active: true, query, from, to }; +} + +export function slashCommandPlugin() { + return new Plugin({ + key: slashCommandKey, + state: { + init: () => ({ active: false, query: "", from: 0, to: 0 }), + apply(tr, prev, _oldState, newState) { + const meta = tr.getMeta(slashCommandKey); + if (meta?.deactivate) { + return { active: false, query: "", from: 0, to: 0 }; + } + if (tr.docChanged || tr.selectionSet) { + return ( + findSlashCommand(newState) ?? { + active: false, + query: "", + from: 0, + to: 0, + } + ); + } + return prev; + }, + }, + props: { + handleKeyDown(view, event) { + const state = slashCommandKey.getState(view.state); + if (!state?.active) return false; + + if (event.key === "Escape") { + view.dispatch( + view.state.tr.setMeta(slashCommandKey, { deactivate: true }), + ); + return true; + } + + if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { + return true; + } + + return false; + }, + }, + }); +} + +// --------------------------------------------------------------------------- +// React component +// --------------------------------------------------------------------------- +function filterCommands(query: string): SlashCommandItem[] { + if (!query) return SLASH_COMMANDS; + const q = query.toLowerCase(); + return SLASH_COMMANDS.filter( + (cmd) => + cmd.label.toLowerCase().includes(q) || + cmd.keywords.some((kw) => kw.includes(q)), + ); +} + +export function SlashCommandMenu() { + const [selectedIndex, setSelectedIndex] = useState(0); + const popupRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + const prevQueryRef = useRef(""); + + const editorState = useEditorState(); + const pluginState = editorState + ? slashCommandKey.getState(editorState) + : null; + const active = pluginState?.active ?? false; + const query = pluginState?.query ?? ""; + const items = active ? filterCommands(query) : []; + + if (prevQueryRef.current !== query) { + prevQueryRef.current = query; + setSelectedIndex(0); + } + + const executeCommand = useEditorEventCallback( + (view, item: SlashCommandItem) => { + if (!view) return; + const state = slashCommandKey.getState(view.state); + if (!state?.active) return; + + view.dispatch( + view.state.tr.setMeta(slashCommandKey, { deactivate: true }), + ); + item.action(view, state.from, state.to); + view.focus(); + }, + ); + + useEditorEffect((view) => { + if (!view || !active) { + cleanupRef.current?.(); + cleanupRef.current = null; + return; + } + + const popup = popupRef.current; + if (!popup) return; + + const coords = view.coordsAtPos(pluginState!.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) 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) executeCommand(item); + } + }; + + document.addEventListener("keydown", handleKeyDown, true); + return () => document.removeEventListener("keydown", handleKeyDown, true); + }, [active, items, selectedIndex, executeCommand]); + + if (!active || items.length === 0) return null; + + return createPortal( +
    +
    + Commands +
    + {items.map((item, index) => ( + + ))} +
    , + document.body, + ); +} From 600f266b2ede85943fe190db7ea97e3f6612514d Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Mon, 30 Mar 2026 14:33:05 +0900 Subject: [PATCH 11/21] remove inline imported types --- apps/desktop/src/editor/mention.tsx | 7 +++---- apps/desktop/src/editor/plugins.ts | 10 +++------- apps/desktop/src/editor/slash-command.tsx | 24 +++++++++++------------ 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/editor/mention.tsx b/apps/desktop/src/editor/mention.tsx index 39688e199d..a060509110 100644 --- a/apps/desktop/src/editor/mention.tsx +++ b/apps/desktop/src/editor/mention.tsx @@ -20,6 +20,7 @@ import { UserIcon, } from "lucide-react"; import { + type EditorState, NodeSelection, Plugin, PluginKey, @@ -60,15 +61,13 @@ export const mentionSuggestionKey = new PluginKey( "mentionSuggestion", ); -export function isMentionActive( - state: import("prosemirror-state").EditorState, -): boolean { +export function isMentionActive(state: EditorState): boolean { const pluginState = mentionSuggestionKey.getState(state); return pluginState?.active === true; } function findSuggestion( - state: import("prosemirror-state").EditorState, + state: EditorState, trigger: string, ): SuggestionState | null { const { $from } = state.selection; diff --git a/apps/desktop/src/editor/plugins.ts b/apps/desktop/src/editor/plugins.ts index 6c714700f9..b9929e7474 100644 --- a/apps/desktop/src/editor/plugins.ts +++ b/apps/desktop/src/editor/plugins.ts @@ -1,6 +1,6 @@ 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 { Decoration, DecorationSet, type EditorView } from "prosemirror-view"; import tldList from "tlds"; import { @@ -315,7 +315,7 @@ export function fileHandlerPlugin(config: FileHandlerConfig) { const imageType = schema.nodes.image; function insertImage( - view: import("prosemirror-view").EditorView, + view: EditorView, url: string, attachmentId: string | null, pos?: number, @@ -328,11 +328,7 @@ export function fileHandlerPlugin(config: FileHandlerConfig) { view.dispatch(tr); } - async function handleFiles( - view: import("prosemirror-view").EditorView, - files: File[], - pos?: number, - ) { + async function handleFiles(view: EditorView, files: File[], pos?: number) { for (const file of files) { if (!IMAGE_MIME_TYPES.includes(file.type)) continue; diff --git a/apps/desktop/src/editor/slash-command.tsx b/apps/desktop/src/editor/slash-command.tsx index 6b195ada57..fd05bc6533 100644 --- a/apps/desktop/src/editor/slash-command.tsx +++ b/apps/desktop/src/editor/slash-command.tsx @@ -26,7 +26,13 @@ import { } from "lucide-react"; import { setBlockType } from "prosemirror-commands"; import { wrapInList } from "prosemirror-schema-list"; -import { Plugin, PluginKey } from "prosemirror-state"; +import { + type EditorState, + Plugin, + PluginKey, + type Transaction, +} from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; @@ -44,20 +50,16 @@ export interface SlashCommandItem { description: string; icon: React.ComponentType<{ className?: string }>; keywords: string[]; - action: ( - view: import("prosemirror-view").EditorView, - from: number, - to: number, - ) => void; + action: (view: EditorView, from: number, to: number) => void; } function clearSlashAndRun( - view: import("prosemirror-view").EditorView, + view: EditorView, from: number, to: number, command: ( - state: import("prosemirror-state").EditorState, - dispatch?: (tr: import("prosemirror-state").Transaction) => void, + state: EditorState, + dispatch?: (tr: Transaction) => void, ) => boolean, ) { const tr = view.state.tr.delete(from, to); @@ -223,9 +225,7 @@ interface SlashCommandState { export const slashCommandKey = new PluginKey("slashCommand"); -function findSlashCommand( - state: import("prosemirror-state").EditorState, -): SlashCommandState | null { +function findSlashCommand(state: EditorState): SlashCommandState | null { const { $from } = state.selection; if (!state.selection.empty) return null; if (isMentionActive(state)) return null; From 180b2d80cd420123954e8a44a924b3f2ee83ccdb Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Mon, 30 Mar 2026 14:55:32 +0900 Subject: [PATCH 12/21] simplify slash command --- apps/desktop/src/editor/index.tsx | 3 +- apps/desktop/src/editor/slash-command.tsx | 161 ++++++++-------------- 2 files changed, 57 insertions(+), 107 deletions(-) diff --git a/apps/desktop/src/editor/index.tsx b/apps/desktop/src/editor/index.tsx index cf28e8319f..d08f592144 100644 --- a/apps/desktop/src/editor/index.tsx +++ b/apps/desktop/src/editor/index.tsx @@ -56,7 +56,7 @@ import { setSearchState, } from "./plugins"; import { schema } from "./schema"; -import { SlashCommandMenu, slashCommandPlugin } from "./slash-command"; +import { SlashCommandMenu } from "./slash-command"; import { TaskItemView } from "./task-item-view"; export type { MentionConfig, FileHandlerConfig, PlaceholderFunction }; @@ -299,7 +299,6 @@ const NoteEditor = forwardRef((props, ref) => { clearMarksOnEnterPlugin(), clipPastePlugin(), linkBoundaryGuardPlugin(), - slashCommandPlugin(), ...(mentionConfig ? [mentionSuggestionPlugin(mentionConfig.trigger), mentionSkipPlugin()] : []), diff --git a/apps/desktop/src/editor/slash-command.tsx b/apps/desktop/src/editor/slash-command.tsx index fd05bc6533..e61ee72877 100644 --- a/apps/desktop/src/editor/slash-command.tsx +++ b/apps/desktop/src/editor/slash-command.tsx @@ -10,6 +10,7 @@ import { import { useEditorEffect, useEditorEventCallback, + useEditorEventListener, useEditorState, } from "@handlewithcare/react-prosemirror"; import { @@ -26,19 +27,13 @@ import { } from "lucide-react"; import { setBlockType } from "prosemirror-commands"; import { wrapInList } from "prosemirror-schema-list"; -import { - type EditorState, - Plugin, - PluginKey, - type Transaction, -} from "prosemirror-state"; +import type { EditorState, Transaction } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { createPortal } from "react-dom"; import { cn } from "@hypr/utils"; -import { isMentionActive } from "./mention"; import { schema } from "./schema"; // --------------------------------------------------------------------------- @@ -214,21 +209,17 @@ const SLASH_COMMANDS: SlashCommandItem[] = [ ]; // --------------------------------------------------------------------------- -// Plugin +// Derive slash command state from EditorState (no plugin needed) // --------------------------------------------------------------------------- interface SlashCommandState { - active: boolean; query: string; from: number; to: number; } -export const slashCommandKey = new PluginKey("slashCommand"); - function findSlashCommand(state: EditorState): SlashCommandState | null { const { $from } = state.selection; if (!state.selection.empty) return null; - if (isMentionActive(state)) return null; const textBefore = $from.parent.textBetween( 0, @@ -247,57 +238,9 @@ function findSlashCommand(state: EditorState): SlashCommandState | null { const from = $from.start() + slashIndex; const to = $from.pos; - return { active: true, query, from, to }; -} - -export function slashCommandPlugin() { - return new Plugin({ - key: slashCommandKey, - state: { - init: () => ({ active: false, query: "", from: 0, to: 0 }), - apply(tr, prev, _oldState, newState) { - const meta = tr.getMeta(slashCommandKey); - if (meta?.deactivate) { - return { active: false, query: "", from: 0, to: 0 }; - } - if (tr.docChanged || tr.selectionSet) { - return ( - findSlashCommand(newState) ?? { - active: false, - query: "", - from: 0, - to: 0, - } - ); - } - return prev; - }, - }, - props: { - handleKeyDown(view, event) { - const state = slashCommandKey.getState(view.state); - if (!state?.active) return false; - - if (event.key === "Escape") { - view.dispatch( - view.state.tr.setMeta(slashCommandKey, { deactivate: true }), - ); - return true; - } - - if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { - return true; - } - - return false; - }, - }, - }); + return { query, from, to }; } -// --------------------------------------------------------------------------- -// React component -// --------------------------------------------------------------------------- function filterCommands(query: string): SlashCommandItem[] { if (!query) return SLASH_COMMANDS; const q = query.toLowerCase(); @@ -308,41 +251,72 @@ function filterCommands(query: string): SlashCommandItem[] { ); } +// --------------------------------------------------------------------------- +// React component +// --------------------------------------------------------------------------- export function SlashCommandMenu() { - const [selectedIndex, setSelectedIndex] = useState(0); const popupRef = useRef(null); const cleanupRef = useRef<(() => void) | null>(null); - const prevQueryRef = useRef(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [dismissedFrom, setDismissedFrom] = useState(null); const editorState = useEditorState(); - const pluginState = editorState - ? slashCommandKey.getState(editorState) - : null; - const active = pluginState?.active ?? false; - const query = pluginState?.query ?? ""; - const items = active ? filterCommands(query) : []; + const slashState = editorState ? findSlashCommand(editorState) : null; - if (prevQueryRef.current !== query) { - prevQueryRef.current = query; + const dismissed = slashState !== null && dismissedFrom === slashState.from; + const active = slashState !== null && !dismissed; + const items = active ? filterCommands(slashState.query) : []; + + if (!active && selectedIndex !== 0) { setSelectedIndex(0); } + if (slashState === null && dismissedFrom !== null) { + setDismissedFrom(null); + } const executeCommand = useEditorEventCallback( (view, item: SlashCommandItem) => { - if (!view) return; - const state = slashCommandKey.getState(view.state); - if (!state?.active) return; - - view.dispatch( - view.state.tr.setMeta(slashCommandKey, { deactivate: true }), - ); - item.action(view, state.from, state.to); + if (!view || !slashState) return; + setDismissedFrom(slashState.from); + item.action(view, slashState.from, slashState.to); view.focus(); }, ); + useEditorEventListener("keydown", (_view, event) => { + if (!active || items.length === 0) return false; + + if (event.key === "Escape") { + if (slashState) { + setDismissedFrom(slashState.from); + } + return true; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((prev) => (prev + items.length - 1) % items.length); + return true; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % items.length); + return true; + } + + if (event.key === "Enter") { + event.preventDefault(); + const item = items[selectedIndex]; + if (item) executeCommand(item); + return true; + } + + return false; + }); + useEditorEffect((view) => { - if (!view || !active) { + if (!view || !active || items.length === 0) { cleanupRef.current?.(); cleanupRef.current = null; return; @@ -351,7 +325,7 @@ export function SlashCommandMenu() { const popup = popupRef.current; if (!popup) return; - const coords = view.coordsAtPos(pluginState!.from); + const coords = view.coordsAtPos(slashState!.from); const referenceEl: VirtualElement = { getBoundingClientRect: () => new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top), @@ -374,29 +348,6 @@ export function SlashCommandMenu() { update(); }); - 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) executeCommand(item); - } - }; - - document.addEventListener("keydown", handleKeyDown, true); - return () => document.removeEventListener("keydown", handleKeyDown, true); - }, [active, items, selectedIndex, executeCommand]); - if (!active || items.length === 0) return null; return createPortal( From 14d9bd81c709c79c34a07f904482f535e44cd99f Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Mon, 30 Mar 2026 17:05:37 +0900 Subject: [PATCH 13/21] prevent tab focus --- apps/desktop/src/editor/keymap.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/editor/keymap.ts b/apps/desktop/src/editor/keymap.ts index 640bbbdf83..80c7281aa4 100644 --- a/apps/desktop/src/editor/keymap.ts +++ b/apps/desktop/src/editor/keymap.ts @@ -258,11 +258,12 @@ export function buildKeymap(onNavigateToTitle?: (pixelWidth?: number) => void) { keys["Ctrl-e"] = selectTextblockEnd; } + // Prevent Tab from moving focus outside the editor keys["Tab"] = (state, dispatch) => { const itemName = isInListItem(state); - if (!itemName) return false; + if (!itemName) return true; const nodeType = state.schema.nodes[itemName]; - if (!nodeType) return false; + if (!nodeType) return true; return sinkListItem(nodeType)(state, dispatch); }; From 538c6186fd6d49c8692529431959199ce4adced4 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Tue, 31 Mar 2026 10:35:36 +0900 Subject: [PATCH 14/21] port chat editor over to prosemirror --- .../src/chat/components/input/hooks.ts | 30 +- .../src/chat/components/input/index.tsx | 25 +- apps/desktop/src/editor/chat/index.tsx | 282 ++++++++++++++++++ apps/desktop/src/editor/chat/schema.ts | 107 +++++++ .../src/editor/node-views/attachment-view.tsx | 56 ++++ .../editor/{ => node-views}/image-view.tsx | 4 +- apps/desktop/src/editor/node-views/index.ts | 4 + .../src/editor/node-views/mention-view.tsx | 118 ++++++++ .../{ => node-views}/task-item-view.tsx | 4 +- .../editor/{plugins.ts => plugins/index.ts} | 2 +- .../src/editor/{ => session}/index.tsx | 266 +++++++++-------- .../src/editor/{ => session}/keymap.ts | 0 .../src/editor/{ => session}/schema.ts | 0 apps/desktop/src/editor/widgets/index.ts | 8 + .../src/editor/{ => widgets}/mention.tsx | 121 +------- .../editor/{ => widgets}/slash-command.tsx | 2 +- .../components/note-input/enhanced/editor.tsx | 6 +- .../components/note-input/enhanced/index.tsx | 2 +- .../session/components/note-input/index.tsx | 2 +- .../src/session/components/note-input/raw.tsx | 5 +- .../components/note-input/search/bar.tsx | 2 +- 21 files changed, 751 insertions(+), 295 deletions(-) create mode 100644 apps/desktop/src/editor/chat/index.tsx create mode 100644 apps/desktop/src/editor/chat/schema.ts create mode 100644 apps/desktop/src/editor/node-views/attachment-view.tsx rename apps/desktop/src/editor/{ => node-views}/image-view.tsx (98%) create mode 100644 apps/desktop/src/editor/node-views/index.ts create mode 100644 apps/desktop/src/editor/node-views/mention-view.tsx rename apps/desktop/src/editor/{ => node-views}/task-item-view.tsx (92%) rename apps/desktop/src/editor/{plugins.ts => plugins/index.ts} (99%) rename apps/desktop/src/editor/{ => session}/index.tsx (64%) rename apps/desktop/src/editor/{ => session}/keymap.ts (100%) rename apps/desktop/src/editor/{ => session}/schema.ts (100%) create mode 100644 apps/desktop/src/editor/widgets/index.ts rename apps/desktop/src/editor/{ => widgets}/mention.tsx (74%) rename apps/desktop/src/editor/{ => widgets}/slash-command.tsx (99%) diff --git a/apps/desktop/src/chat/components/input/hooks.ts b/apps/desktop/src/chat/components/input/hooks.ts index eb7d4b9e1b..c376275252 100644 --- a/apps/desktop/src/chat/components/input/hooks.ts +++ b/apps/desktop/src/chat/components/input/hooks.ts @@ -1,14 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; -import type { - JSONContent, - SlashCommandConfig, - TiptapEditor, -} from "@hypr/tiptap/chat"; import { EMPTY_TIPTAP_DOC } from "@hypr/tiptap/shared"; import type { ContextRef } from "~/chat/context/entities"; +import type { ChatEditorHandle, JSONContent } from "~/editor/chat"; +import type { MentionConfig } from "~/editor/widgets"; import { useSearchEngine } from "~/search/contexts/engine"; import * as main from "~/store/tinybase/store/main"; @@ -56,7 +53,7 @@ export function useSubmit({ onContextRefsChange, }: { draftKey: string; - editorRef: React.RefObject<{ editor: TiptapEditor | null } | null>; + editorRef: React.RefObject; disabled?: boolean; isStreaming?: boolean; onSendMessage: ( @@ -67,7 +64,7 @@ export function useSubmit({ onContextRefsChange?: (refs: ContextRef[]) => void; }) { return useCallback(() => { - const json = editorRef.current?.editor?.getJSON(); + const json = editorRef.current?.getJSON(); const text = tiptapJsonToText(json).trim(); const mentionRefs = extractContextRefsFromTiptapJson(json); @@ -77,7 +74,7 @@ export function useSubmit({ void analyticsCommands.event({ event: "message_sent" }); onSendMessage(text, [{ type: "text", text }], mentionRefs); - editorRef.current?.editor?.commands.clearContent(); + editorRef.current?.clearContent(); draftsByKey.delete(draftKey); onContextRefsChange?.([]); }, [ @@ -95,7 +92,7 @@ export function useAutoFocusEditor({ disabled, shouldFocus = true, }: { - editorRef: React.RefObject<{ editor: TiptapEditor | null } | null>; + editorRef: React.RefObject; disabled?: boolean; shouldFocus?: boolean; }) { @@ -108,11 +105,9 @@ export function useAutoFocusEditor({ let attempts = 0; const maxAttempts = 20; - const focusWhenReady = () => { - const editor = editorRef.current?.editor; - - if (editor && !editor.isDestroyed && editor.isInitialized) { - editor.commands.focus(); + const tryFocus = () => { + if (editorRef.current) { + editorRef.current.focus(); return; } @@ -121,10 +116,10 @@ export function useAutoFocusEditor({ } attempts += 1; - rafId = window.requestAnimationFrame(focusWhenReady); + rafId = window.requestAnimationFrame(tryFocus); }; - focusWhenReady(); + tryFocus(); return () => { if (rafId !== null) { @@ -134,7 +129,7 @@ export function useAutoFocusEditor({ }, [editorRef, disabled, shouldFocus]); } -export function useSlashCommandConfig(): SlashCommandConfig { +export function useMentionConfig(): MentionConfig { const sessions = main.UI.useResultTable( main.QUERIES.timelineSessions, main.STORE_ID, @@ -151,6 +146,7 @@ export function useSlashCommandConfig(): SlashCommandConfig { return useMemo( () => ({ + trigger: "@", handleSearch: async (query: string) => { const results: { id: string; diff --git a/apps/desktop/src/chat/components/input/index.tsx b/apps/desktop/src/chat/components/input/index.tsx index b0790f9c5d..2a32a2172d 100644 --- a/apps/desktop/src/chat/components/input/index.tsx +++ b/apps/desktop/src/chat/components/input/index.tsx @@ -1,22 +1,21 @@ import { SquareIcon } from "lucide-react"; import { useRef } from "react"; -import type { TiptapEditor } from "@hypr/tiptap/chat"; -import ChatEditor from "@hypr/tiptap/chat"; -import type { PlaceholderFunction } from "@hypr/tiptap/shared"; import { Button } from "@hypr/ui/components/ui/button"; import { cn } from "@hypr/utils"; import { useAutoFocusEditor, useDraftState, - useSlashCommandConfig, + useMentionConfig, useSubmit, } from "./hooks"; import { type McpIndicator, McpIndicatorBadge } from "./mcp"; import type { ContextRef } from "~/chat/context/entities"; import { useShell } from "~/contexts/shell"; +import { ChatEditor, type ChatEditorHandle } from "~/editor/chat"; +import type { PlaceholderFunction } from "~/editor/plugins"; export type { McpIndicator } from "./mcp"; @@ -44,7 +43,7 @@ export function ChatMessageInput({ onContextRefsChange?: (refs: ContextRef[]) => void; }) { const { chat } = useShell(); - const editorRef = useRef<{ editor: TiptapEditor | null }>(null); + const editorRef = useRef(null); const disabled = typeof disabledProp === "object" ? disabledProp.disabled : disabledProp; const shouldFocus = @@ -63,7 +62,7 @@ export function ChatMessageInput({ onContextRefsChange, }); useAutoFocusEditor({ editorRef, disabled, shouldFocus }); - const slashCommandConfig = useSlashCommandConfig(); + const mentionConfig = useMentionConfig(); return ( @@ -171,14 +169,9 @@ function Container({ ); } -const ChatPlaceholder: PlaceholderFunction = ({ node, pos }) => { - "use no memo"; +const chatPlaceholder: PlaceholderFunction = ({ node, pos }) => { if (node.type.name === "paragraph" && pos === 0) { - return ( -

    - Ask & search about anything, or be creative! -

    - ); + return "Ask & search about anything, or be creative!"; } return ""; }; diff --git a/apps/desktop/src/editor/chat/index.tsx b/apps/desktop/src/editor/chat/index.tsx new file mode 100644 index 0000000000..4035de63f1 --- /dev/null +++ b/apps/desktop/src/editor/chat/index.tsx @@ -0,0 +1,282 @@ +import { + ProseMirror, + ProseMirrorDoc, + reactKeys, + useEditorEffect, +} from "@handlewithcare/react-prosemirror"; +import { + chainCommands, + createParagraphNear, + deleteSelection, + exitCode, + joinBackward, + joinForward, + liftEmptyBlock, + selectAll, + selectNodeBackward, + selectNodeForward, + splitBlock, +} from "prosemirror-commands"; +import { history, redo, undo } from "prosemirror-history"; +import { keymap } from "prosemirror-keymap"; +import { Node as PMNode } from "prosemirror-model"; +import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"; + +import { AttachmentChipView, MentionNodeView } from "../node-views"; +import { type PlaceholderFunction, placeholderPlugin } from "../plugins"; +import { + type MentionConfig, + MentionSuggestion, + isMentionActive, + mentionSkipPlugin, + mentionSuggestionPlugin, +} from "../widgets"; +import { chatSchema } from "./schema"; + +export { chatSchema }; +export type { MentionConfig }; + +export interface JSONContent { + type?: string; + attrs?: Record; + content?: JSONContent[]; + marks?: { type: string; attrs?: Record }[]; + text?: string; +} + +export interface ChatEditorHandle { + focus(): void; + getJSON(): JSONContent | undefined; + clearContent(): void; +} + +interface ChatEditorProps { + className?: string; + initialContent?: JSONContent; + mentionConfig?: MentionConfig; + placeholder?: PlaceholderFunction; + onUpdate?: (json: JSONContent) => void; + onSubmit?: () => void; +} + +const nodeViews = { + "mention-@": MentionNodeView, + attachment: AttachmentChipView, +}; + +function ViewCapture({ + viewRef, +}: { + viewRef: React.RefObject; +}) { + useEditorEffect((view) => { + if (view && viewRef.current !== view) { + viewRef.current = view; + } + }); + return null; +} + +const mac = + typeof navigator !== "undefined" + ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) + : false; + +function fileHandlerPlugin() { + return new Plugin({ + key: new PluginKey("chatFileHandler"), + props: { + handleDrop(view, event) { + const files = Array.from(event.dataTransfer?.files ?? []); + if (files.length === 0) return false; + event.preventDefault(); + insertFiles(view, files); + return true; + }, + handlePaste(view, event) { + const files = Array.from(event.clipboardData?.files ?? []); + if (files.length === 0) return false; + insertFiles(view, files); + return true; + }, + }, + }); +} + +function insertFiles(view: EditorView, files: File[]) { + for (const file of files) { + if (file.type.startsWith("image/")) { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + insertAttachmentNode(view, { + id: crypto.randomUUID(), + name: file.name, + mimeType: file.type, + url: reader.result as string, + size: file.size, + }); + }; + } else { + insertAttachmentNode(view, { + id: crypto.randomUUID(), + name: file.name, + mimeType: file.type, + url: null, + size: file.size, + }); + } + } +} + +function insertAttachmentNode( + view: EditorView, + attrs: { + id: string; + name: string; + mimeType: string; + url: string | null; + size: number; + }, +) { + const { schema } = view.state; + const node = schema.nodes.attachment.create(attrs); + const space = schema.text(" "); + const { from, to } = view.state.selection; + const tr = view.state.tr.replaceWith(from, to, [node, space]); + view.dispatch(tr); + view.focus(); +} + +export const ChatEditor = forwardRef( + function ChatEditor(props, ref) { + const { + className, + initialContent, + mentionConfig, + placeholder, + onUpdate, + onSubmit, + } = props; + + const viewRef = useRef(null); + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; + const onUpdateRef = useRef(onUpdate); + onUpdateRef.current = onUpdate; + + useImperativeHandle( + ref, + () => ({ + focus() { + viewRef.current?.focus(); + }, + getJSON() { + return viewRef.current?.state.doc.toJSON() as JSONContent | undefined; + }, + clearContent() { + const view = viewRef.current; + if (!view) return; + const doc = chatSchema.node("doc", null, [ + chatSchema.node("paragraph"), + ]); + const state = EditorState.create({ + doc, + plugins: view.state.plugins, + }); + view.updateState(state); + }, + }), + [], + ); + + const plugins = useMemo( + () => [ + reactKeys(), + keymap({ + "Mod-z": undo, + "Mod-Shift-z": redo, + ...(!mac ? { "Mod-y": redo } : {}), + "Mod-Enter": (state: EditorState) => { + if (isMentionActive(state)) return false; + onSubmitRef.current?.(); + return true; + }, + "Shift-Enter": chainCommands(exitCode, (state, dispatch) => { + if (dispatch) { + dispatch( + state.tr + .replaceSelectionWith(chatSchema.nodes.hardBreak.create()) + .scrollIntoView(), + ); + } + return true; + }), + Enter: chainCommands(createParagraphNear, liftEmptyBlock, splitBlock), + Backspace: chainCommands( + deleteSelection, + joinBackward, + selectNodeBackward, + ), + Delete: chainCommands( + deleteSelection, + joinForward, + selectNodeForward, + ), + "Mod-a": selectAll, + }), + history(), + placeholderPlugin(placeholder), + ...(mentionConfig + ? [ + mentionSuggestionPlugin(mentionConfig.trigger), + mentionSkipPlugin(), + ] + : []), + fileHandlerPlugin(), + ], + [mentionConfig, placeholder], + ); + + const defaultState = useMemo(() => { + let doc: PMNode; + try { + doc = + initialContent && initialContent.type === "doc" + ? PMNode.fromJSON(chatSchema, initialContent) + : chatSchema.node("doc", null, [chatSchema.node("paragraph")]); + } catch { + doc = chatSchema.node("doc", null, [chatSchema.node("paragraph")]); + } + return EditorState.create({ doc, plugins }); + }, []); + + return ( + + + + {mentionConfig && } + + ); + }, +); diff --git a/apps/desktop/src/editor/chat/schema.ts b/apps/desktop/src/editor/chat/schema.ts new file mode 100644 index 0000000000..97f8c50311 --- /dev/null +++ b/apps/desktop/src/editor/chat/schema.ts @@ -0,0 +1,107 @@ +import { type NodeSpec, Schema } from "prosemirror-model"; + +const nodes: Record = { + doc: { content: "block+" }, + + paragraph: { + content: "inline*", + group: "block", + parseDOM: [{ tag: "p" }], + toDOM() { + return ["p", 0]; + }, + }, + + text: { group: "inline" }, + + hardBreak: { + inline: true, + group: "inline", + selectable: false, + parseDOM: [{ tag: "br" }], + toDOM() { + return ["br"]; + }, + }, + + "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"), + }; + }, + }, + ], + 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 || "", + ]; + }, + }, + + attachment: { + group: "inline", + inline: true, + atom: true, + selectable: true, + attrs: { + id: { default: null }, + name: { default: "" }, + mimeType: { default: "" }, + url: { default: null }, + size: { default: null }, + }, + parseDOM: [ + { + tag: 'span[data-type="attachment"]', + getAttrs(dom) { + const el = dom as HTMLElement; + return { + id: el.getAttribute("data-id"), + name: el.getAttribute("data-name"), + mimeType: el.getAttribute("data-mime-type"), + url: el.getAttribute("data-url"), + size: el.getAttribute("data-size") + ? Number(el.getAttribute("data-size")) + : null, + }; + }, + }, + ], + toDOM(node) { + const attrs: Record = { "data-type": "attachment" }; + if (node.attrs.id) attrs["data-id"] = node.attrs.id; + if (node.attrs.name) attrs["data-name"] = node.attrs.name; + if (node.attrs.mimeType) attrs["data-mime-type"] = node.attrs.mimeType; + if (node.attrs.url) attrs["data-url"] = node.attrs.url; + if (node.attrs.size != null) attrs["data-size"] = String(node.attrs.size); + return ["span", attrs, node.attrs.name || "attachment"]; + }, + }, +}; + +export const chatSchema = new Schema({ nodes }); diff --git a/apps/desktop/src/editor/node-views/attachment-view.tsx b/apps/desktop/src/editor/node-views/attachment-view.tsx new file mode 100644 index 0000000000..3f94146346 --- /dev/null +++ b/apps/desktop/src/editor/node-views/attachment-view.tsx @@ -0,0 +1,56 @@ +import { + type NodeViewComponentProps, + useEditorEventCallback, +} from "@handlewithcare/react-prosemirror"; +import { FileIcon, XIcon } from "lucide-react"; +import { forwardRef } from "react"; + +export const AttachmentChipView = forwardRef< + HTMLSpanElement, + NodeViewComponentProps +>(function AttachmentChipView({ nodeProps, ...htmlAttrs }, ref) { + const { node, getPos } = nodeProps; + const { name, mimeType, url } = node.attrs; + const isImage = typeof mimeType === "string" && mimeType.startsWith("image/"); + const displayName = + name && name.length > 24 ? name.slice(0, 24) + "\u2026" : name || "file"; + + const handleRemove = useEditorEventCallback((view) => { + if (!view) return; + const pos = getPos(); + view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize)); + view.focus(); + }); + + return ( + + + {isImage && url ? ( + {name} + ) : ( + + )} + {displayName} + + + + ); +}); diff --git a/apps/desktop/src/editor/image-view.tsx b/apps/desktop/src/editor/node-views/image-view.tsx similarity index 98% rename from apps/desktop/src/editor/image-view.tsx rename to apps/desktop/src/editor/node-views/image-view.tsx index d2e2188c76..0da4223157 100644 --- a/apps/desktop/src/editor/image-view.tsx +++ b/apps/desktop/src/editor/node-views/image-view.tsx @@ -15,7 +15,7 @@ import { cn } from "@hypr/utils"; export const ResizableImageView = forwardRef< HTMLDivElement, NodeViewComponentProps ->(({ nodeProps, ...htmlAttrs }, ref) => { +>(function ResizableImageView({ nodeProps, ...htmlAttrs }, ref) { const { node, getPos } = nodeProps; const [isHovered, setIsHovered] = useState(false); const [isResizing, setIsResizing] = useState(false); @@ -161,5 +161,3 @@ export const ResizableImageView = forwardRef<
    ); }); - -ResizableImageView.displayName = "ResizableImageView"; diff --git a/apps/desktop/src/editor/node-views/index.ts b/apps/desktop/src/editor/node-views/index.ts new file mode 100644 index 0000000000..3e573be64f --- /dev/null +++ b/apps/desktop/src/editor/node-views/index.ts @@ -0,0 +1,4 @@ +export { AttachmentChipView } from "./attachment-view"; +export { ResizableImageView } from "./image-view"; +export { MentionNodeView } from "./mention-view"; +export { TaskItemView } from "./task-item-view"; diff --git a/apps/desktop/src/editor/node-views/mention-view.tsx b/apps/desktop/src/editor/node-views/mention-view.tsx new file mode 100644 index 0000000000..2ef8a50cca --- /dev/null +++ b/apps/desktop/src/editor/node-views/mention-view.tsx @@ -0,0 +1,118 @@ +import { type NodeViewComponentProps } from "@handlewithcare/react-prosemirror"; +import { Facehash, stringHash } from "facehash"; +import { + Building2Icon, + MessageSquareIcon, + StickyNoteIcon, + UserIcon, +} from "lucide-react"; +import { forwardRef, useCallback } from "react"; + +import { cn } from "@hypr/utils"; + +const GLOBAL_NAVIGATE_FUNCTION = "__HYPR_NAVIGATE__"; + +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 const MentionNodeView = forwardRef( + function MentionNodeView({ 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) + "\u2026" + : 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} + + + ); + }, +); diff --git a/apps/desktop/src/editor/task-item-view.tsx b/apps/desktop/src/editor/node-views/task-item-view.tsx similarity index 92% rename from apps/desktop/src/editor/task-item-view.tsx rename to apps/desktop/src/editor/node-views/task-item-view.tsx index 6d02063400..5a042ab11b 100644 --- a/apps/desktop/src/editor/task-item-view.tsx +++ b/apps/desktop/src/editor/node-views/task-item-view.tsx @@ -8,7 +8,7 @@ import { forwardRef, type ReactNode } from "react"; export const TaskItemView = forwardRef< HTMLLIElement, NodeViewComponentProps & { children?: ReactNode } ->(({ nodeProps, children, ...htmlAttrs }, ref) => { +>(function TaskItemView({ nodeProps, children, ...htmlAttrs }, ref) { const { node, getPos } = nodeProps; const checked = node.attrs.checked; @@ -47,5 +47,3 @@ export const TaskItemView = forwardRef< ); }); - -TaskItemView.displayName = "TaskItemView"; diff --git a/apps/desktop/src/editor/plugins.ts b/apps/desktop/src/editor/plugins/index.ts similarity index 99% rename from apps/desktop/src/editor/plugins.ts rename to apps/desktop/src/editor/plugins/index.ts index b9929e7474..d48df9b21c 100644 --- a/apps/desktop/src/editor/plugins.ts +++ b/apps/desktop/src/editor/plugins/index.ts @@ -11,7 +11,7 @@ import { resolveYouTubeClipUrl, } from "@hypr/tiptap/shared"; -import { schema } from "./schema"; +import { schema } from "../session/schema"; // --------------------------------------------------------------------------- // Hashtag decorations diff --git a/apps/desktop/src/editor/index.tsx b/apps/desktop/src/editor/session/index.tsx similarity index 64% rename from apps/desktop/src/editor/index.tsx rename to apps/desktop/src/editor/session/index.tsx index d08f592144..db06036501 100644 --- a/apps/desktop/src/editor/index.tsx +++ b/apps/desktop/src/editor/session/index.tsx @@ -30,15 +30,11 @@ 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"; + ResizableImageView, + TaskItemView, +} from "../node-views"; import { type FileHandlerConfig, type PlaceholderFunction, @@ -54,10 +50,16 @@ import { searchReplaceAll, searchReplaceCurrent, setSearchState, -} from "./plugins"; +} from "../plugins"; +import { + type MentionConfig, + MentionSuggestion, + SlashCommandMenu, + mentionSkipPlugin, + mentionSuggestionPlugin, +} from "../widgets"; +import { buildInputRules, buildKeymap } from "./keymap"; import { schema } from "./schema"; -import { SlashCommandMenu } from "./slash-command"; -import { TaskItemView } from "./task-item-view"; export type { MentionConfig, FileHandlerConfig, PlaceholderFunction }; export { schema }; @@ -253,131 +255,137 @@ function EditorCommandsBridge({ return null; } -const NoteEditor = forwardRef((props, ref) => { - const { - handleChange, - initialContent, - mentionConfig, - placeholderComponent, - fileHandlerConfig, - onNavigateToTitle, - } = props; - - const previousContentRef = useRef(initialContent); - const viewRef = useRef(null); - const commandsRef = useRef(noopCommands); - - useImperativeHandle( - ref, - () => ({ - get view() { - return viewRef.current; - }, - get commands() { - return commandsRef.current; - }, - }), - [], - ); - - 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(), - searchPlugin(), - placeholderPlugin(placeholderComponent), - clearMarksOnEnterPlugin(), - clipPastePlugin(), - linkBoundaryGuardPlugin(), - ...(mentionConfig - ? [mentionSuggestionPlugin(mentionConfig.trigger), mentionSkipPlugin()] - : []), - ...(fileHandlerConfig ? [fileHandlerPlugin(fileHandlerConfig)] : []), - ], - [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; +export const NoteEditor = forwardRef( + function NoteEditor(props, ref) { + const { + handleChange, + initialContent, + mentionConfig, + placeholderComponent, + fileHandlerConfig, + onNavigateToTitle, + } = props; + + const previousContentRef = useRef(initialContent); + const viewRef = useRef(null); + const commandsRef = useRef(noopCommands); + + useImperativeHandle( + ref, + () => ({ + get view() { + return viewRef.current; + }, + get commands() { + return commandsRef.current; + }, + }), + [], + ); - if (!initialContent || initialContent.type !== "doc") return; + 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(), + searchPlugin(), + placeholderPlugin(placeholderComponent), + clearMarksOnEnterPlugin(), + clipPastePlugin(), + linkBoundaryGuardPlugin(), + ...(mentionConfig + ? [ + mentionSuggestionPlugin(mentionConfig.trigger), + mentionSkipPlugin(), + ] + : []), + ...(fileHandlerConfig ? [fileHandlerPlugin(fileHandlerConfig)] : []), + ], + [ + placeholderComponent, + fileHandlerConfig, + mentionConfig, + onNavigateToTitle, + ], + ); - if (!view.hasFocus()) { + const defaultState = useMemo(() => { + let doc: PMNode; try { - const doc = PMNode.fromJSON(schema, initialContent); - const state = EditorState.create({ - doc, - plugins: view.state.plugins, - }); - view.updateState(state); + doc = + initialContent && initialContent.type === "doc" + ? PMNode.fromJSON(schema, initialContent) + : schema.node("doc", null, [schema.node("paragraph")]); } catch { - // invalid content + doc = schema.node("doc", null, [schema.node("paragraph")]); } - } - }, [initialContent]); - - const onViewReady = useCallback( - (view: EditorView) => { - onUpdate(view); - }, - [onUpdate], - ); + return EditorState.create({ doc, plugins }); + }, []); - return ( - { + const view = viewRef.current; + if (!view) return; + if (previousContentRef.current === initialContent) return; + previousContentRef.current = initialContent; + + if (!initialContent || initialContent.type !== "doc") return; + + if (!view.hasFocus()) { + try { + const doc = PMNode.fromJSON(schema, initialContent); + const state = EditorState.create({ + doc, + plugins: view.state.plugins, + }); + view.updateState(state); + } catch { + // invalid content } - }} - attributes={{ - spellcheck: "false", - autocomplete: "off", - autocorrect: "off", - autocapitalize: "off", - role: "textbox", - }} - className="tiptap" - > - - - - - {mentionConfig && } - - ); -}); + } + }, [initialContent]); -NoteEditor.displayName = "NoteEditor"; + const onViewReady = useCallback( + (view: EditorView) => { + onUpdate(view); + }, + [onUpdate], + ); -export default NoteEditor; + return ( + + + + + + {mentionConfig && } + + ); + }, +); diff --git a/apps/desktop/src/editor/keymap.ts b/apps/desktop/src/editor/session/keymap.ts similarity index 100% rename from apps/desktop/src/editor/keymap.ts rename to apps/desktop/src/editor/session/keymap.ts diff --git a/apps/desktop/src/editor/schema.ts b/apps/desktop/src/editor/session/schema.ts similarity index 100% rename from apps/desktop/src/editor/schema.ts rename to apps/desktop/src/editor/session/schema.ts diff --git a/apps/desktop/src/editor/widgets/index.ts b/apps/desktop/src/editor/widgets/index.ts new file mode 100644 index 0000000000..1e394f02b8 --- /dev/null +++ b/apps/desktop/src/editor/widgets/index.ts @@ -0,0 +1,8 @@ +export { + type MentionConfig, + MentionSuggestion, + isMentionActive, + mentionSkipPlugin, + mentionSuggestionPlugin, +} from "./mention"; +export { SlashCommandMenu } from "./slash-command"; diff --git a/apps/desktop/src/editor/mention.tsx b/apps/desktop/src/editor/widgets/mention.tsx similarity index 74% rename from apps/desktop/src/editor/mention.tsx rename to apps/desktop/src/editor/widgets/mention.tsx index a060509110..bfda92fc87 100644 --- a/apps/desktop/src/editor/mention.tsx +++ b/apps/desktop/src/editor/widgets/mention.tsx @@ -8,11 +8,9 @@ import { type VirtualElement, } from "@floating-ui/dom"; import { - type NodeViewComponentProps, useEditorEffect, useEditorEventCallback, } from "@handlewithcare/react-prosemirror"; -import { Facehash, stringHash } from "facehash"; import { Building2Icon, MessageSquareIcon, @@ -26,15 +24,9 @@ import { PluginKey, TextSelection, } from "prosemirror-state"; -import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { 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; @@ -141,65 +133,6 @@ export function mentionSuggestionPlugin(trigger: string) { // --------------------------------------------------------------------------- // 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); @@ -213,6 +146,7 @@ export function MentionSuggestion({ config }: { config: MentionConfig }) { const state = mentionSuggestionKey.getState(view.state); if (!state?.active) return; + const { schema } = view.state; const mentionNode = schema.nodes["mention-@"].create({ id: item.id, type: item.type, @@ -341,57 +275,6 @@ export function MentionSuggestion({ config }: { config: MentionConfig }) { ); } -// --------------------------------------------------------------------------- -// 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 // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/editor/slash-command.tsx b/apps/desktop/src/editor/widgets/slash-command.tsx similarity index 99% rename from apps/desktop/src/editor/slash-command.tsx rename to apps/desktop/src/editor/widgets/slash-command.tsx index e61ee72877..cdf5619de0 100644 --- a/apps/desktop/src/editor/slash-command.tsx +++ b/apps/desktop/src/editor/widgets/slash-command.tsx @@ -34,7 +34,7 @@ import { createPortal } from "react-dom"; import { cn } from "@hypr/utils"; -import { schema } from "./schema"; +import { schema } from "../session/schema"; // --------------------------------------------------------------------------- // Slash command items 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 046f203eaa..8339e9fc29 100644 --- a/apps/desktop/src/session/components/note-input/enhanced/editor.tsx +++ b/apps/desktop/src/session/components/note-input/enhanced/editor.tsx @@ -2,7 +2,11 @@ import { forwardRef, useMemo } from "react"; import { parseJsonContent } from "@hypr/tiptap/shared"; -import NoteEditor, { type JSONContent, type NoteEditorRef } from "~/editor"; +import { + NoteEditor, + type JSONContent, + type NoteEditorRef, +} from "~/editor/session"; import { useSearchEngine } from "~/search/contexts/engine"; import { useImageUpload } from "~/shared/hooks/useImageUpload"; import * as main from "~/store/tinybase/store/main"; 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 c4093d73bb..68c9daaaf2 100644 --- a/apps/desktop/src/session/components/note-input/enhanced/index.tsx +++ b/apps/desktop/src/session/components/note-input/enhanced/index.tsx @@ -7,7 +7,7 @@ import { StreamingView } from "./streaming"; import { useAITaskTask } from "~/ai/hooks"; import { useLLMConnectionStatus } from "~/ai/hooks"; -import type { NoteEditorRef } from "~/editor"; +import type { NoteEditorRef } from "~/editor/session"; import * as main from "~/store/tinybase/store/main"; import { createTaskId } from "~/store/zustand/ai-task/task-configs"; diff --git a/apps/desktop/src/session/components/note-input/index.tsx b/apps/desktop/src/session/components/note-input/index.tsx index 9d21bd910c..5edeb5670e 100644 --- a/apps/desktop/src/session/components/note-input/index.tsx +++ b/apps/desktop/src/session/components/note-input/index.tsx @@ -26,7 +26,7 @@ import { SearchBar } from "./search/bar"; import { useSearch } from "./search/context"; import { Transcript } from "./transcript"; -import type { NoteEditorRef } from "~/editor"; +import type { NoteEditorRef } from "~/editor/session"; import { useCaretNearBottom } from "~/session/components/caret-position-context"; import { useCurrentNoteTab } from "~/session/components/shared"; import { useScrollPreservation } from "~/shared/hooks/useScrollPreservation"; diff --git a/apps/desktop/src/session/components/note-input/raw.tsx b/apps/desktop/src/session/components/note-input/raw.tsx index 75e2ebdafe..70f9c22c51 100644 --- a/apps/desktop/src/session/components/note-input/raw.tsx +++ b/apps/desktop/src/session/components/note-input/raw.tsx @@ -3,11 +3,12 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; import { parseJsonContent } from "@hypr/tiptap/shared"; -import NoteEditor, { +import { + NoteEditor, type JSONContent, type NoteEditorRef, type PlaceholderFunction, -} from "~/editor"; +} from "~/editor/session"; import { useSearchEngine } from "~/search/contexts/engine"; import { useImageUpload } from "~/shared/hooks/useImageUpload"; import * as main from "~/store/tinybase/store/main"; diff --git a/apps/desktop/src/session/components/note-input/search/bar.tsx b/apps/desktop/src/session/components/note-input/search/bar.tsx index b0672d981e..6adfa98def 100644 --- a/apps/desktop/src/session/components/note-input/search/bar.tsx +++ b/apps/desktop/src/session/components/note-input/search/bar.tsx @@ -19,7 +19,7 @@ import { cn } from "@hypr/utils"; import { useSearch } from "./context"; -import type { NoteEditorRef } from "~/editor"; +import type { NoteEditorRef } from "~/editor/session"; function ToggleButton({ active, From bf805b135a38035ca39cbb7ab59185b6696b0543 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Tue, 31 Mar 2026 15:35:45 +0900 Subject: [PATCH 15/21] fix mention copy/pasting and visuals --- apps/desktop/src/editor/chat/index.tsx | 21 +-- apps/desktop/src/editor/chat/schema.ts | 5 +- .../src/editor/node-views/mention-view.tsx | 2 +- apps/desktop/src/editor/session/index.tsx | 8 +- apps/desktop/src/editor/session/schema.ts | 16 +- apps/desktop/src/editor/widgets/index.ts | 3 +- apps/desktop/src/editor/widgets/mention.tsx | 166 +++++++----------- 7 files changed, 83 insertions(+), 138 deletions(-) diff --git a/apps/desktop/src/editor/chat/index.tsx b/apps/desktop/src/editor/chat/index.tsx index 4035de63f1..2ddd0b623a 100644 --- a/apps/desktop/src/editor/chat/index.tsx +++ b/apps/desktop/src/editor/chat/index.tsx @@ -1,3 +1,5 @@ +import "prosemirror-view/style/prosemirror.css"; + import { ProseMirror, ProseMirrorDoc, @@ -24,14 +26,16 @@ import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"; +import "@hypr/tiptap/styles.css"; +import { cn } from "@hypr/utils"; + import { AttachmentChipView, MentionNodeView } from "../node-views"; import { type PlaceholderFunction, placeholderPlugin } from "../plugins"; import { type MentionConfig, MentionSuggestion, - isMentionActive, + findMention, mentionSkipPlugin, - mentionSuggestionPlugin, } from "../widgets"; import { chatSchema } from "./schema"; @@ -200,7 +204,9 @@ export const ChatEditor = forwardRef( "Mod-Shift-z": redo, ...(!mac ? { "Mod-y": redo } : {}), "Mod-Enter": (state: EditorState) => { - if (isMentionActive(state)) return false; + if (mentionConfig && findMention(state, mentionConfig.trigger)) { + return false; + } onSubmitRef.current?.(); return true; }, @@ -229,12 +235,7 @@ export const ChatEditor = forwardRef( }), history(), placeholderPlugin(placeholder), - ...(mentionConfig - ? [ - mentionSuggestionPlugin(mentionConfig.trigger), - mentionSkipPlugin(), - ] - : []), + ...(mentionConfig ? [mentionSkipPlugin()] : []), fileHandlerPlugin(), ], [mentionConfig, placeholder], @@ -271,7 +272,7 @@ export const ChatEditor = forwardRef( autocapitalize: "off", role: "textbox", }} - className={className} + className={cn(className, "tiptap")} > diff --git a/apps/desktop/src/editor/chat/schema.ts b/apps/desktop/src/editor/chat/schema.ts index 97f8c50311..45b125e586 100644 --- a/apps/desktop/src/editor/chat/schema.ts +++ b/apps/desktop/src/editor/chat/schema.ts @@ -36,7 +36,7 @@ const nodes: Record = { }, parseDOM: [ { - tag: 'a.mention[data-mention="true"]', + tag: 'span.mention[data-mention="true"]', getAttrs(dom) { const el = dom as HTMLElement; return { @@ -49,14 +49,13 @@ const nodes: Record = { ], toDOM(node) { return [ - "a", + "span", { 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 || "", ]; diff --git a/apps/desktop/src/editor/node-views/mention-view.tsx b/apps/desktop/src/editor/node-views/mention-view.tsx index 2ef8a50cca..020c215feb 100644 --- a/apps/desktop/src/editor/node-views/mention-view.tsx +++ b/apps/desktop/src/editor/node-views/mention-view.tsx @@ -95,7 +95,7 @@ export const MentionNodeView = forwardRef( ); return ( - + ( clearMarksOnEnterPlugin(), clipPastePlugin(), linkBoundaryGuardPlugin(), - ...(mentionConfig - ? [ - mentionSuggestionPlugin(mentionConfig.trigger), - mentionSkipPlugin(), - ] - : []), + ...(mentionConfig ? [mentionSkipPlugin()] : []), ...(fileHandlerConfig ? [fileHandlerPlugin(fileHandlerConfig)] : []), ], [ diff --git a/apps/desktop/src/editor/session/schema.ts b/apps/desktop/src/editor/session/schema.ts index fcd6b41bf8..0f2bebab53 100644 --- a/apps/desktop/src/editor/session/schema.ts +++ b/apps/desktop/src/editor/session/schema.ts @@ -212,18 +212,7 @@ const nodes: Record = { }, 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", + tag: 'span.mention[data-mention="true"]', getAttrs(dom) { const el = dom as HTMLElement; return { @@ -236,14 +225,13 @@ const nodes: Record = { ], toDOM(node) { return [ - "a", + "span", { 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 || "", ]; diff --git a/apps/desktop/src/editor/widgets/index.ts b/apps/desktop/src/editor/widgets/index.ts index 1e394f02b8..25efcabf6e 100644 --- a/apps/desktop/src/editor/widgets/index.ts +++ b/apps/desktop/src/editor/widgets/index.ts @@ -1,8 +1,7 @@ export { type MentionConfig, MentionSuggestion, - isMentionActive, + findMention, mentionSkipPlugin, - mentionSuggestionPlugin, } from "./mention"; export { SlashCommandMenu } from "./slash-command"; diff --git a/apps/desktop/src/editor/widgets/mention.tsx b/apps/desktop/src/editor/widgets/mention.tsx index bfda92fc87..2b46d43814 100644 --- a/apps/desktop/src/editor/widgets/mention.tsx +++ b/apps/desktop/src/editor/widgets/mention.tsx @@ -10,6 +10,8 @@ import { import { useEditorEffect, useEditorEventCallback, + useEditorEventListener, + useEditorState, } from "@handlewithcare/react-prosemirror"; import { Building2Icon, @@ -40,28 +42,18 @@ export type MentionConfig = { }; // --------------------------------------------------------------------------- -// Suggestion plugin +// Derive mention state from EditorState (no plugin needed) // --------------------------------------------------------------------------- -interface SuggestionState { - active: boolean; +interface MentionState { query: string; from: number; to: number; } -export const mentionSuggestionKey = new PluginKey( - "mentionSuggestion", -); - -export function isMentionActive(state: EditorState): boolean { - const pluginState = mentionSuggestionKey.getState(state); - return pluginState?.active === true; -} - -function findSuggestion( +export function findMention( state: EditorState, trigger: string, -): SuggestionState | null { +): MentionState | null { const { $from } = state.selection; if (!state.selection.empty) return null; @@ -82,52 +74,7 @@ function findSuggestion( 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; - }, - }, - }); + return { query, from, to }; } // --------------------------------------------------------------------------- @@ -136,15 +83,28 @@ export function mentionSuggestionPlugin(trigger: string) { 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 [dismissedFrom, setDismissedFrom] = useState(null); const popupRef = useRef(null); const cleanupRef = useRef<(() => void) | null>(null); + const editorState = useEditorState(); + const mentionState = editorState + ? findMention(editorState, config.trigger) + : null; + + const dismissed = + mentionState !== null && dismissedFrom === mentionState.from; + const active = mentionState !== null && !dismissed; + + if (!active && selectedIndex !== 0) { + setSelectedIndex(0); + } + if (mentionState === null && dismissedFrom !== null) { + setDismissedFrom(null); + } + const insertMention = useEditorEventCallback((view, item: MentionItem) => { - if (!view) return; - const state = mentionSuggestionKey.getState(view.state); - if (!state?.active) return; + if (!view || !mentionState) return; const { schema } = view.state; const mentionNode = schema.nodes["mention-@"].create({ @@ -154,23 +114,50 @@ export function MentionSuggestion({ config }: { config: MentionConfig }) { }); const space = schema.text(" "); - const tr = view.state.tr - .replaceWith(state.from, state.to, [mentionNode, space]) - .setMeta(mentionSuggestionKey, { deactivate: true }); + const tr = view.state.tr.replaceWith(mentionState.from, mentionState.to, [ + mentionNode, + space, + ]); view.dispatch(tr); view.focus(); + setDismissedFrom(mentionState.from); }); - useEditorEffect((view) => { - if (!view) return; - const state = mentionSuggestionKey.getState(view.state); - const isActive = state?.active ?? false; + useEditorEventListener("keydown", (_view, event) => { + if (!active || items.length === 0) return false; - setActive(isActive); - setQuery(state?.query ?? ""); + if (event.key === "Escape") { + if (mentionState) setDismissedFrom(mentionState.from); + return true; + } - if (!isActive) { + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex( + (prev) => (prev + items.length - 1) % Math.max(items.length, 1), + ); + return true; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % Math.max(items.length, 1)); + return true; + } + + if (event.key === "Enter") { + event.preventDefault(); + const item = items[selectedIndex]; + if (item) insertMention(item); + return true; + } + + return false; + }); + + useEditorEffect((view) => { + if (!view || !active || items.length === 0) { cleanupRef.current?.(); cleanupRef.current = null; return; @@ -179,7 +166,7 @@ export function MentionSuggestion({ config }: { config: MentionConfig }) { const popup = popupRef.current; if (!popup) return; - const coords = view.coordsAtPos(state!.from); + const coords = view.coordsAtPos(mentionState!.from); const referenceEl: VirtualElement = { getBoundingClientRect: () => new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top), @@ -210,7 +197,7 @@ export function MentionSuggestion({ config }: { config: MentionConfig }) { } config - .handleSearch(query) + .handleSearch(mentionState!.query) .then((results) => { setItems(results.slice(0, 5)); setSelectedIndex(0); @@ -218,30 +205,7 @@ export function MentionSuggestion({ config }: { config: MentionConfig }) { .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]); + }, [active, mentionState?.query, config]); if (!active || items.length === 0) return null; From 9d8a75e584cb3f29823847cd1b0ce27b66014d3f Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Tue, 31 Mar 2026 15:39:55 +0900 Subject: [PATCH 16/21] move daily note editor to session editor --- apps/desktop/src/daily/note-editor.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/daily/note-editor.tsx b/apps/desktop/src/daily/note-editor.tsx index 93267d2464..e1017d7cf3 100644 --- a/apps/desktop/src/daily/note-editor.tsx +++ b/apps/desktop/src/daily/note-editor.tsx @@ -1,9 +1,8 @@ import { useMemo } from "react"; -import { type JSONContent } from "@hypr/tiptap/editor"; -import NoteEditor from "@hypr/tiptap/editor"; import { parseJsonContent } from "@hypr/tiptap/shared"; +import { type JSONContent, NoteEditor } from "~/editor/session"; import * as main from "~/store/tinybase/store/main"; export function DailyNoteEditor({ date }: { date: string }) { From f63684c30d7a3cc4b2003857cddd3b3b32b53186 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Tue, 31 Mar 2026 15:44:18 +0900 Subject: [PATCH 17/21] move prompt editor outside of the tiptap package --- apps/desktop/src/ai/prompts/details.tsx | 3 +- apps/desktop/src/ai/prompts/editor.tsx | 140 ++++++++++++++ apps/desktop/src/ai/prompts/jinja.ts | 232 ++++++++++++++++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/ai/prompts/editor.tsx create mode 100644 apps/desktop/src/ai/prompts/jinja.ts diff --git a/apps/desktop/src/ai/prompts/details.tsx b/apps/desktop/src/ai/prompts/details.tsx index a4e3a307b1..16272d5989 100644 --- a/apps/desktop/src/ai/prompts/details.tsx +++ b/apps/desktop/src/ai/prompts/details.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from "react"; import { commands as templateCommands } from "@hypr/plugin-template"; -import { PromptEditor } from "@hypr/tiptap/prompt"; import { Button } from "@hypr/ui/components/ui/button"; +import { PromptEditor } from "./editor"; + import * as main from "~/store/tinybase/store/main"; import { AVAILABLE_FILTERS, diff --git a/apps/desktop/src/ai/prompts/editor.tsx b/apps/desktop/src/ai/prompts/editor.tsx new file mode 100644 index 0000000000..11dfa2ccf0 --- /dev/null +++ b/apps/desktop/src/ai/prompts/editor.tsx @@ -0,0 +1,140 @@ +import { EditorState, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import CodeMirror from "@uiw/react-codemirror"; +import readOnlyRangesExtension from "codemirror-readonly-ranges"; +import { useCallback, useMemo } from "react"; + +import { jinjaLanguage, jinjaLinter, readonlyVisuals } from "./jinja"; + +export interface ReadOnlyRange { + from: number; + to: number; +} + +interface PromptEditorProps { + value: string; + onChange?: (value: string) => void; + placeholder?: string; + readOnly?: boolean; + readOnlyRanges?: ReadOnlyRange[]; + variables?: string[]; + filters?: string[]; +} + +export function PromptEditor({ + value, + onChange, + placeholder, + readOnly = false, + readOnlyRanges = [], + variables = [], + filters = [], +}: PromptEditorProps) { + const getReadOnlyRanges = useCallback( + (_state: EditorState) => { + if (readOnly || readOnlyRanges.length === 0) { + return []; + } + + return readOnlyRanges.map((range) => ({ + from: range.from, + to: range.to, + })); + }, + [readOnly, readOnlyRanges], + ); + + const getRangesForVisuals = useCallback(() => { + return readOnlyRanges; + }, [readOnlyRanges]); + + const extensions = useMemo(() => { + const exts: Extension[] = [ + jinjaLanguage(variables, filters), + jinjaLinter(), + ]; + + if (!readOnly && readOnlyRanges.length > 0) { + exts.push(readOnlyRangesExtension(getReadOnlyRanges)); + exts.push(readonlyVisuals(getRangesForVisuals)); + } + + return exts; + }, [ + readOnly, + readOnlyRanges, + getReadOnlyRanges, + getRangesForVisuals, + variables, + filters, + ]); + + const theme = useMemo( + () => + EditorView.theme({ + "&": { + height: "100%", + fontFamily: + "var(--font-mono, 'Menlo', 'Monaco', 'Courier New', monospace)", + fontSize: "13px", + lineHeight: "1.6", + }, + ".cm-content": { + padding: "8px 0", + }, + ".cm-line": { + padding: "0 12px", + }, + ".cm-scroller": { + overflow: "auto", + }, + "&.cm-focused": { + outline: "none", + }, + ".cm-placeholder": { + color: "#999", + fontStyle: "italic", + }, + ".cm-readonly-region": { + backgroundColor: "rgba(0, 0, 0, 0.04)", + borderRadius: "2px", + }, + ".cm-tooltip-autocomplete": { + border: "1px solid #e5e7eb", + borderRadius: "6px", + boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)", + backgroundColor: "#fff", + }, + ".cm-tooltip-autocomplete ul li": { + padding: "4px 8px", + }, + ".cm-tooltip-autocomplete ul li[aria-selected]": { + backgroundColor: "#f3f4f6", + }, + ".cm-diagnostic-error": { + borderBottom: "2px wavy #ef4444", + }, + ".cm-lintPoint-error:after": { + borderBottomColor: "#ef4444", + }, + }), + [], + ); + + return ( + + ); +} diff --git a/apps/desktop/src/ai/prompts/jinja.ts b/apps/desktop/src/ai/prompts/jinja.ts new file mode 100644 index 0000000000..b7fe2b5c7b --- /dev/null +++ b/apps/desktop/src/ai/prompts/jinja.ts @@ -0,0 +1,232 @@ +import { + type Completion, + type CompletionContext, + type CompletionResult, + type CompletionSource, +} from "@codemirror/autocomplete"; +import { closePercentBrace, jinja } from "@codemirror/lang-jinja"; +import { type Diagnostic, linter } from "@codemirror/lint"; +import { type Extension, RangeSetBuilder } from "@codemirror/state"; +import { + Decoration, + type DecorationSet, + type EditorView, + ViewPlugin, + type ViewUpdate, +} from "@codemirror/view"; + +function filterCompletionSource(filters: string[]): CompletionSource { + const filterCompletions: Completion[] = filters.map((f) => ({ + label: f, + type: "function", + detail: "filter", + })); + + return (context: CompletionContext): CompletionResult | null => { + const { state, pos } = context; + const textBefore = state.sliceDoc(Math.max(0, pos - 50), pos); + + const pipeMatch = textBefore.match(/\|\s*(\w*)$/); + if (pipeMatch) { + const word = context.matchBefore(/\w*/); + return { + from: word?.from ?? pos, + options: filterCompletions, + validFor: /^\w*$/, + }; + } + + return null; + }; +} + +export function jinjaLanguage( + variables: string[], + filters: string[], +): Extension[] { + const variableCompletions: Completion[] = variables.map((v) => ({ + label: v, + type: "variable", + })); + + const jinjaSupport = jinja({ + variables: variableCompletions, + }); + + const exts: Extension[] = [jinjaSupport, closePercentBrace]; + + if (filters.length > 0) { + exts.push( + jinjaSupport.language.data.of({ + autocomplete: filterCompletionSource(filters), + }), + ); + } + + return exts; +} + +const readonlyMark = Decoration.mark({ class: "cm-readonly-region" }); + +export function readonlyVisuals( + getRanges: () => Array<{ from: number; to: number }>, +): Extension { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + + buildDecorations(_view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const ranges = getRanges().sort((a, b) => a.from - b.from); + + for (const { from, to } of ranges) { + builder.add(from, to, readonlyMark); + } + + return builder.finish(); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + }, + { decorations: (v) => v.decorations }, + ); +} + +const STATEMENT_REGEX = /\{%[\s\S]*?%\}/g; + +interface JinjaBlock { + type: "if" | "for" | "block" | "macro"; + keyword: string; + start: number; + end: number; +} + +export function jinjaLinter(): Extension { + return linter((view) => { + const diagnostics: Diagnostic[] = []; + const doc = view.state.doc.toString(); + + let pos = 0; + + while (pos < doc.length) { + if (doc.slice(pos, pos + 2) === "{{") { + const endPos = doc.indexOf("}}", pos + 2); + if (endPos === -1) { + diagnostics.push({ + from: pos, + to: pos + 2, + severity: "error", + message: "Unclosed expression: missing }}", + }); + break; + } + pos = endPos + 2; + continue; + } + + if (doc.slice(pos, pos + 2) === "{%") { + const endPos = doc.indexOf("%}", pos + 2); + if (endPos === -1) { + diagnostics.push({ + from: pos, + to: pos + 2, + severity: "error", + message: "Unclosed statement: missing %}", + }); + break; + } + pos = endPos + 2; + continue; + } + + if (doc.slice(pos, pos + 2) === "{#") { + const endPos = doc.indexOf("#}", pos + 2); + if (endPos === -1) { + diagnostics.push({ + from: pos, + to: pos + 2, + severity: "error", + message: "Unclosed comment: missing #}", + }); + break; + } + pos = endPos + 2; + continue; + } + + pos++; + } + + const blockStack: JinjaBlock[] = []; + + const openingKeywords = ["if", "for", "block", "macro"]; + const closingKeywords = ["endif", "endfor", "endblock", "endmacro"]; + + for (const match of doc.matchAll(STATEMENT_REGEX)) { + if (match.index === undefined) continue; + + const content = match[0].slice(2, -2).trim(); + const parts = content.split(/\s+/); + const keyword = parts[0]; + + if (openingKeywords.includes(keyword)) { + blockStack.push({ + type: keyword as JinjaBlock["type"], + keyword, + start: match.index, + end: match.index + match[0].length, + }); + } else if (closingKeywords.includes(keyword)) { + const expectedOpening = keyword.slice(3); + const lastBlock = blockStack.pop(); + + if (!lastBlock) { + diagnostics.push({ + from: match.index, + to: match.index + match[0].length, + severity: "error", + message: `Unexpected ${keyword}: no matching opening block`, + }); + } else if (lastBlock.type !== expectedOpening) { + diagnostics.push({ + from: match.index, + to: match.index + match[0].length, + severity: "error", + message: `Mismatched block: expected end${lastBlock.type}, found ${keyword}`, + }); + } + } else if (keyword === "elif" || keyword === "else") { + if ( + blockStack.length === 0 || + blockStack[blockStack.length - 1].type !== "if" + ) { + diagnostics.push({ + from: match.index, + to: match.index + match[0].length, + severity: "error", + message: `${keyword} outside of if block`, + }); + } + } + } + + for (const unclosed of blockStack) { + diagnostics.push({ + from: unclosed.start, + to: unclosed.end, + severity: "error", + message: `Unclosed ${unclosed.keyword} block: missing end${unclosed.type}`, + }); + } + + return diagnostics; + }); +} From d47dbe27ceb82401cc7655d0f907af816701e183 Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Tue, 31 Mar 2026 16:41:36 +0900 Subject: [PATCH 18/21] split plugins and colocate schema --- apps/desktop/src/editor/chat/schema.ts | 81 +--- .../src/editor/node-views/attachment-view.tsx | 41 ++ .../src/editor/node-views/image-view.tsx | 49 +++ apps/desktop/src/editor/node-views/index.ts | 12 +- .../src/editor/node-views/mention-view.tsx | 39 ++ .../src/editor/node-views/task-item-view.tsx | 36 ++ .../editor/plugins/clear-marks-on-enter.ts | 34 ++ apps/desktop/src/editor/plugins/clip-paste.ts | 231 ++++++++++ .../src/editor/plugins/file-handler.ts | 91 ++++ apps/desktop/src/editor/plugins/hashtag.ts | 100 +++++ apps/desktop/src/editor/plugins/index.ts | 405 +----------------- .../src/editor/plugins/link-boundary-guard.ts | 99 +++++ .../desktop/src/editor/plugins/placeholder.ts | 56 +++ apps/desktop/src/editor/plugins/search.ts | 13 + apps/desktop/src/editor/session/schema.ts | 164 +------ 15 files changed, 833 insertions(+), 618 deletions(-) create mode 100644 apps/desktop/src/editor/plugins/clear-marks-on-enter.ts create mode 100644 apps/desktop/src/editor/plugins/clip-paste.ts create mode 100644 apps/desktop/src/editor/plugins/file-handler.ts create mode 100644 apps/desktop/src/editor/plugins/hashtag.ts create mode 100644 apps/desktop/src/editor/plugins/link-boundary-guard.ts create mode 100644 apps/desktop/src/editor/plugins/placeholder.ts create mode 100644 apps/desktop/src/editor/plugins/search.ts diff --git a/apps/desktop/src/editor/chat/schema.ts b/apps/desktop/src/editor/chat/schema.ts index 45b125e586..b923c97c0f 100644 --- a/apps/desktop/src/editor/chat/schema.ts +++ b/apps/desktop/src/editor/chat/schema.ts @@ -1,5 +1,7 @@ import { type NodeSpec, Schema } from "prosemirror-model"; +import { attachmentNodeSpec, mentionNodeSpec } from "../node-views"; + const nodes: Record = { doc: { content: "block+" }, @@ -24,83 +26,8 @@ const nodes: Record = { }, }, - "mention-@": { - group: "inline", - inline: true, - atom: true, - selectable: true, - attrs: { - id: { default: null }, - type: { default: null }, - label: { default: null }, - }, - parseDOM: [ - { - tag: 'span.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"), - }; - }, - }, - ], - toDOM(node) { - return [ - "span", - { - class: "mention", - "data-mention": "true", - "data-id": node.attrs.id, - "data-type": node.attrs.type, - "data-label": node.attrs.label, - }, - node.attrs.label || "", - ]; - }, - }, - - attachment: { - group: "inline", - inline: true, - atom: true, - selectable: true, - attrs: { - id: { default: null }, - name: { default: "" }, - mimeType: { default: "" }, - url: { default: null }, - size: { default: null }, - }, - parseDOM: [ - { - tag: 'span[data-type="attachment"]', - getAttrs(dom) { - const el = dom as HTMLElement; - return { - id: el.getAttribute("data-id"), - name: el.getAttribute("data-name"), - mimeType: el.getAttribute("data-mime-type"), - url: el.getAttribute("data-url"), - size: el.getAttribute("data-size") - ? Number(el.getAttribute("data-size")) - : null, - }; - }, - }, - ], - toDOM(node) { - const attrs: Record = { "data-type": "attachment" }; - if (node.attrs.id) attrs["data-id"] = node.attrs.id; - if (node.attrs.name) attrs["data-name"] = node.attrs.name; - if (node.attrs.mimeType) attrs["data-mime-type"] = node.attrs.mimeType; - if (node.attrs.url) attrs["data-url"] = node.attrs.url; - if (node.attrs.size != null) attrs["data-size"] = String(node.attrs.size); - return ["span", attrs, node.attrs.name || "attachment"]; - }, - }, + "mention-@": mentionNodeSpec, + attachment: attachmentNodeSpec, }; export const chatSchema = new Schema({ nodes }); diff --git a/apps/desktop/src/editor/node-views/attachment-view.tsx b/apps/desktop/src/editor/node-views/attachment-view.tsx index 3f94146346..bc6f8c622a 100644 --- a/apps/desktop/src/editor/node-views/attachment-view.tsx +++ b/apps/desktop/src/editor/node-views/attachment-view.tsx @@ -3,8 +3,49 @@ import { useEditorEventCallback, } from "@handlewithcare/react-prosemirror"; import { FileIcon, XIcon } from "lucide-react"; +import type { NodeSpec } from "prosemirror-model"; import { forwardRef } from "react"; +export const attachmentNodeSpec: NodeSpec = { + group: "inline", + inline: true, + atom: true, + selectable: true, + attrs: { + id: { default: null }, + name: { default: "" }, + mimeType: { default: "" }, + url: { default: null }, + size: { default: null }, + }, + parseDOM: [ + { + tag: 'span[data-type="attachment"]', + getAttrs(dom) { + const el = dom as HTMLElement; + return { + id: el.getAttribute("data-id"), + name: el.getAttribute("data-name"), + mimeType: el.getAttribute("data-mime-type"), + url: el.getAttribute("data-url"), + size: el.getAttribute("data-size") + ? Number(el.getAttribute("data-size")) + : null, + }; + }, + }, + ], + toDOM(node) { + const attrs: Record = { "data-type": "attachment" }; + if (node.attrs.id) attrs["data-id"] = node.attrs.id; + if (node.attrs.name) attrs["data-name"] = node.attrs.name; + if (node.attrs.mimeType) attrs["data-mime-type"] = node.attrs.mimeType; + if (node.attrs.url) attrs["data-url"] = node.attrs.url; + if (node.attrs.size != null) attrs["data-size"] = String(node.attrs.size); + return ["span", attrs, node.attrs.name || "attachment"]; + }, +}; + export const AttachmentChipView = forwardRef< HTMLSpanElement, NodeViewComponentProps diff --git a/apps/desktop/src/editor/node-views/image-view.tsx b/apps/desktop/src/editor/node-views/image-view.tsx index 0da4223157..34a04f7d70 100644 --- a/apps/desktop/src/editor/node-views/image-view.tsx +++ b/apps/desktop/src/editor/node-views/image-view.tsx @@ -3,15 +3,64 @@ import { useEditorEventCallback, useEditorState, } from "@handlewithcare/react-prosemirror"; +import type { NodeSpec } from "prosemirror-model"; import { forwardRef, useCallback, useRef, useState } from "react"; import { DEFAULT_EDITOR_WIDTH, normalizeEditorWidth, + parseImageTitleMetadata, stripEditorWidthFromTitle, } from "@hypr/tiptap/shared"; import { cn } from "@hypr/utils"; +export const imageNodeSpec: NodeSpec = { + 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]; + }, +}; + export const ResizableImageView = forwardRef< HTMLDivElement, NodeViewComponentProps diff --git a/apps/desktop/src/editor/node-views/index.ts b/apps/desktop/src/editor/node-views/index.ts index 3e573be64f..e1f06983bd 100644 --- a/apps/desktop/src/editor/node-views/index.ts +++ b/apps/desktop/src/editor/node-views/index.ts @@ -1,4 +1,8 @@ -export { AttachmentChipView } from "./attachment-view"; -export { ResizableImageView } from "./image-view"; -export { MentionNodeView } from "./mention-view"; -export { TaskItemView } from "./task-item-view"; +export { attachmentNodeSpec, AttachmentChipView } from "./attachment-view"; +export { imageNodeSpec, ResizableImageView } from "./image-view"; +export { mentionNodeSpec, MentionNodeView } from "./mention-view"; +export { + taskItemNodeSpec, + taskListNodeSpec, + TaskItemView, +} from "./task-item-view"; diff --git a/apps/desktop/src/editor/node-views/mention-view.tsx b/apps/desktop/src/editor/node-views/mention-view.tsx index 020c215feb..71a4e6cec4 100644 --- a/apps/desktop/src/editor/node-views/mention-view.tsx +++ b/apps/desktop/src/editor/node-views/mention-view.tsx @@ -6,10 +6,49 @@ import { StickyNoteIcon, UserIcon, } from "lucide-react"; +import type { NodeSpec } from "prosemirror-model"; import { forwardRef, useCallback } from "react"; import { cn } from "@hypr/utils"; +export const mentionNodeSpec: NodeSpec = { + group: "inline", + inline: true, + atom: true, + selectable: true, + attrs: { + id: { default: null }, + type: { default: null }, + label: { default: null }, + }, + parseDOM: [ + { + tag: 'span.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"), + }; + }, + }, + ], + toDOM(node) { + return [ + "span", + { + class: "mention", + "data-mention": "true", + "data-id": node.attrs.id, + "data-type": node.attrs.type, + "data-label": node.attrs.label, + }, + node.attrs.label || "", + ]; + }, +}; + const GLOBAL_NAVIGATE_FUNCTION = "__HYPR_NAVIGATE__"; const FACEHASH_BG_CLASSES = [ diff --git a/apps/desktop/src/editor/node-views/task-item-view.tsx b/apps/desktop/src/editor/node-views/task-item-view.tsx index 5a042ab11b..ad6edd999f 100644 --- a/apps/desktop/src/editor/node-views/task-item-view.tsx +++ b/apps/desktop/src/editor/node-views/task-item-view.tsx @@ -3,8 +3,44 @@ import { useEditorEventCallback, useEditorState, } from "@handlewithcare/react-prosemirror"; +import type { NodeSpec } from "prosemirror-model"; import { forwardRef, type ReactNode } from "react"; +export const taskListNodeSpec: NodeSpec = { + content: "taskItem+", + group: "block", + parseDOM: [{ tag: 'ul[data-type="taskList"]' }], + toDOM() { + return ["ul", { "data-type": "taskList", class: "task-list" }, 0]; + }, +}; + +export const taskItemNodeSpec: NodeSpec = { + 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, + ]; + }, +}; + export const TaskItemView = forwardRef< HTMLLIElement, NodeViewComponentProps & { children?: ReactNode } diff --git a/apps/desktop/src/editor/plugins/clear-marks-on-enter.ts b/apps/desktop/src/editor/plugins/clear-marks-on-enter.ts new file mode 100644 index 0000000000..06db7251af --- /dev/null +++ b/apps/desktop/src/editor/plugins/clear-marks-on-enter.ts @@ -0,0 +1,34 @@ +import { Plugin, PluginKey } from "prosemirror-state"; + +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); + }, + }); +} diff --git a/apps/desktop/src/editor/plugins/clip-paste.ts b/apps/desktop/src/editor/plugins/clip-paste.ts new file mode 100644 index 0000000000..563c5a7290 --- /dev/null +++ b/apps/desktop/src/editor/plugins/clip-paste.ts @@ -0,0 +1,231 @@ +import type { NodeSpec } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; + +// --------------------------------------------------------------------------- +// YouTube URL parsing utilities +// --------------------------------------------------------------------------- + +export function parseYouTubeClipId(url: string): string | null { + const match = url + .trim() + .match(/(?:youtube\.com|youtu\.be)\/clip\/([a-zA-Z0-9_-]+)/); + return match ? match[1] : null; +} + +function normalizeYouTubeTime(value: string | null): string | null { + if (!value) return null; + return value.replace(/s$/, ""); +} + +function buildYouTubeEmbedUrl(videoId: string, url: URL): string { + const params = new URLSearchParams(); + const clip = url.searchParams.get("clip"); + const clipt = url.searchParams.get("clipt"); + const start = + normalizeYouTubeTime(url.searchParams.get("t")) || + normalizeYouTubeTime(url.searchParams.get("start")); + + if (clip) params.set("clip", clip); + if (clipt) params.set("clipt", clipt); + if (start) params.set("start", start); + + const qs = params.toString(); + + return `https://www.youtube.com/embed/${videoId}${qs ? `?${qs}` : ""}`; +} + +function extractHtmlAttributeValue( + html: string, + attributeName: string, +): string | null { + const match = html.match( + new RegExp(`\\b${attributeName}\\s*=\\s*["']([^"']+)["']`, "i"), + ); + + return match?.[1] ?? null; +} + +function parseClipMarkdown( + markdown: string, +): { raw: string; embedUrl: string } | null { + const clipMatch = markdown.match( + /^]*\bsrc\s*=\s*["']([^"']+)["'][^>]*(?:\/>|><\/Clip>)/i, + ); + + if (clipMatch) { + const parsed = parseYouTubeUrl(clipMatch[1]); + if (parsed) { + return { raw: clipMatch[0], embedUrl: parsed.embedUrl }; + } + } + + const iframeMatch = markdown.match( + /^]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>\s*<\/iframe>/i, + ); + + if (iframeMatch) { + const parsed = parseYouTubeUrl(iframeMatch[1]); + if (parsed) { + return { raw: iframeMatch[0], embedUrl: parsed.embedUrl }; + } + } + + return null; +} + +export function parseYouTubeUrl(url: string): { embedUrl: string } | null { + const trimmed = url.trim(); + + if (parseYouTubeClipId(trimmed)) return null; + + try { + const urlObj = new URL(trimmed); + const hostname = urlObj.hostname.toLowerCase().replace(/^www\./, ""); + const pathParts = urlObj.pathname.split("/").filter(Boolean); + + let videoId = ""; + + if (hostname === "youtu.be") { + videoId = pathParts[0] || ""; + } else if ( + hostname === "youtube.com" || + hostname === "m.youtube.com" || + hostname === "youtube-nocookie.com" + ) { + if (pathParts[0] === "watch") { + videoId = urlObj.searchParams.get("v") || ""; + } else if (pathParts[0] === "embed" || pathParts[0] === "shorts") { + videoId = pathParts[1] || ""; + } + } + + if (!videoId) { + return null; + } + + return { embedUrl: buildYouTubeEmbedUrl(videoId, urlObj) }; + } catch { + return null; + } +} + +export function parseYouTubeEmbedSnippet( + snippet: string, +): { embedUrl: string } | null { + const trimmed = snippet.trim(); + + if (!trimmed) { + return null; + } + + const parsedMarkdown = parseClipMarkdown(trimmed); + if (parsedMarkdown) { + return { embedUrl: parsedMarkdown.embedUrl }; + } + + if (!/^ { + try { + const res = await fetch(`https://www.youtube.com/clip/${clipId}`); + const html = await res.text(); + + const videoIdMatch = html.match(/"videoId":"([a-zA-Z0-9_-]+)"/); + if (!videoIdMatch) return null; + + return { + embedUrl: `https://www.youtube.com/embed/${videoIdMatch[1]}`, + }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Node spec +// --------------------------------------------------------------------------- + +export const clipNodeSpec: NodeSpec = { + 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 }]; + }, +}; + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +export function clipPastePlugin() { + return new Plugin({ + key: new PluginKey("clipPaste"), + props: { + handlePaste(view, event) { + const nodeType = view.state.schema.nodes.clip; + if (!nodeType) return false; + + 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; + }, + }, + }); +} diff --git a/apps/desktop/src/editor/plugins/file-handler.ts b/apps/desktop/src/editor/plugins/file-handler.ts new file mode 100644 index 0000000000..f52b16e4d9 --- /dev/null +++ b/apps/desktop/src/editor/plugins/file-handler.ts @@ -0,0 +1,91 @@ +import { Plugin, PluginKey } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; + +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) { + function insertImage( + view: EditorView, + url: string, + attachmentId: string | null, + pos?: number, + ) { + const imageType = view.state.schema.nodes.image; + 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: 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/plugins/hashtag.ts b/apps/desktop/src/editor/plugins/hashtag.ts new file mode 100644 index 0000000000..1d954f1728 --- /dev/null +++ b/apps/desktop/src/editor/plugins/hashtag.ts @@ -0,0 +1,100 @@ +import { type Node as PMNode } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; + +const HASHTAG_REGEX = /#([\p{L}\p{N}_\p{Emoji}\p{Emoji_Component}]+)/gu; +const LEADING_PUNCTUATION_REGEX = /^[([{<"'`]+/u; +const HTTP_PREFIXES = ["http://", "https://", "www."]; + +const normalizeUrlToken = (token: string): string => { + const normalized = token.replace(LEADING_PUNCTUATION_REGEX, ""); + + if (normalized.toLowerCase().startsWith("www.")) { + return `https://${normalized}`; + } + + return normalized; +}; + +const isUrlFragmentHashtag = (text: string, hashtagStart: number): boolean => { + const beforeHashtag = text.slice(0, hashtagStart); + const tokenStart = beforeHashtag.search(/\S+$/u); + + if (tokenStart < 0) { + return false; + } + + const token = beforeHashtag.slice(tokenStart); + const normalizedToken = token + .replace(LEADING_PUNCTUATION_REGEX, "") + .toLowerCase(); + + if (!HTTP_PREFIXES.some((prefix) => normalizedToken.startsWith(prefix))) { + return false; + } + + try { + const parsed = new URL(normalizeUrlToken(token)); + return Boolean(parsed.hostname && parsed.hostname.includes(".")); + } catch { + return false; + } +}; + +export const findHashtags = ( + text: string, +): Array<{ tag: string; start: number; end: number }> => { + const matches: Array<{ tag: string; start: number; end: number }> = []; + let match; + + HASHTAG_REGEX.lastIndex = 0; + + while ((match = HASHTAG_REGEX.exec(text)) !== null) { + const start = match.index; + + if (isUrlFragmentHashtag(text, start)) { + continue; + } + + const tag = match[1].trim(); + + if (!tag) { + continue; + } + + matches.push({ + tag, + start, + end: start + match[0].length, + }); + } + + return matches; +}; + +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); + }, + }, + }); +} diff --git a/apps/desktop/src/editor/plugins/index.ts b/apps/desktop/src/editor/plugins/index.ts index d48df9b21c..8e80510e33 100644 --- a/apps/desktop/src/editor/plugins/index.ts +++ b/apps/desktop/src/editor/plugins/index.ts @@ -1,392 +1,29 @@ -import { type Mark, type Node as PMNode } from "prosemirror-model"; -import { Plugin, PluginKey, type Transaction } from "prosemirror-state"; -import { Decoration, DecorationSet, type EditorView } from "prosemirror-view"; -import tldList from "tlds"; - -import { - findHashtags, +export { clearMarksOnEnterPlugin } from "./clear-marks-on-enter"; +export { + clipNodeSpec, + clipPastePlugin, parseYouTubeClipId, parseYouTubeEmbedSnippet, parseYouTubeUrl, resolveYouTubeClipUrl, -} from "@hypr/tiptap/shared"; - -import { schema } from "../session/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 (prosemirror-search) -// --------------------------------------------------------------------------- +} from "./clip-paste"; +export { type FileHandlerConfig, fileHandlerPlugin } from "./file-handler"; +export { findHashtags, hashtagPlugin, hashtagPluginKey } from "./hashtag"; +export { linkBoundaryGuardPlugin } from "./link-boundary-guard"; +export { + type PlaceholderFunction, + placeholderPlugin, + placeholderPluginKey, +} from "./placeholder"; export { - search as searchPlugin, SearchQuery, + getMatchHighlights, getSearchState, + searchFindNext, + searchFindPrev, + searchPlugin, + searchReplaceAll, + searchReplaceCurrent, + searchReplaceNext, 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 -// --------------------------------------------------------------------------- -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: 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: 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; - }, - }, - }); -} +} from "./search"; diff --git a/apps/desktop/src/editor/plugins/link-boundary-guard.ts b/apps/desktop/src/editor/plugins/link-boundary-guard.ts new file mode 100644 index 0000000000..500ad5fffc --- /dev/null +++ b/apps/desktop/src/editor/plugins/link-boundary-guard.ts @@ -0,0 +1,99 @@ +import { type Mark } from "prosemirror-model"; +import { Plugin, PluginKey, type Transaction } from "prosemirror-state"; +import tldList from "tlds"; + +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; + }, + }); +} diff --git a/apps/desktop/src/editor/plugins/placeholder.ts b/apps/desktop/src/editor/plugins/placeholder.ts new file mode 100644 index 0000000000..2ffd3c0d0f --- /dev/null +++ b/apps/desktop/src/editor/plugins/placeholder.ts @@ -0,0 +1,56 @@ +import { type Node as PMNode } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; + +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); + }, + }, + }); +} diff --git a/apps/desktop/src/editor/plugins/search.ts b/apps/desktop/src/editor/plugins/search.ts new file mode 100644 index 0000000000..3f4a9858b3 --- /dev/null +++ b/apps/desktop/src/editor/plugins/search.ts @@ -0,0 +1,13 @@ +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"; diff --git a/apps/desktop/src/editor/session/schema.ts b/apps/desktop/src/editor/session/schema.ts index 0f2bebab53..02a67726bf 100644 --- a/apps/desktop/src/editor/session/schema.ts +++ b/apps/desktop/src/editor/session/schema.ts @@ -1,12 +1,12 @@ 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"; + imageNodeSpec, + mentionNodeSpec, + taskItemNodeSpec, + taskListNodeSpec, +} from "../node-views"; +import { clipNodeSpec } from "../plugins"; // Node names match Tiptap for JSON content compatibility. const nodes: Record = { @@ -117,153 +117,11 @@ const nodes: Record = { }, }, - 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: 'span.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"), - }; - }, - }, - ], - toDOM(node) { - return [ - "span", - { - class: "mention", - "data-mention": "true", - "data-id": node.attrs.id, - "data-type": node.attrs.type, - "data-label": node.attrs.label, - }, - 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 }]; - }, - }, + taskList: taskListNodeSpec, + taskItem: taskItemNodeSpec, + image: imageNodeSpec, + "mention-@": mentionNodeSpec, + clip: clipNodeSpec, }; const marks: Record = { From 344cedd4ad66082b86c3b37582b7a0ecf131bb4e Mon Sep 17 00:00:00 2001 From: Sungbin Jo Date: Tue, 31 Mar 2026 16:42:01 +0900 Subject: [PATCH 19/21] make text small for chat editor --- apps/desktop/src/chat/components/input/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/chat/components/input/index.tsx b/apps/desktop/src/chat/components/input/index.tsx index 2a32a2172d..f9d4b2c648 100644 --- a/apps/desktop/src/chat/components/input/index.tsx +++ b/apps/desktop/src/chat/components/input/index.tsx @@ -79,7 +79,7 @@ export function ChatMessageInput({
    Date: Tue, 31 Mar 2026 17:02:26 +0900 Subject: [PATCH 20/21] move image width handling over outside of package --- .../src/editor/node-views/image-view.tsx | 57 ++++++++++++------- apps/desktop/src/editor/node-views/index.ts | 6 +- .../components/session-preview-card.tsx | 4 +- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/editor/node-views/image-view.tsx b/apps/desktop/src/editor/node-views/image-view.tsx index 34a04f7d70..c2f5032d7b 100644 --- a/apps/desktop/src/editor/node-views/image-view.tsx +++ b/apps/desktop/src/editor/node-views/image-view.tsx @@ -6,14 +6,31 @@ import { import type { NodeSpec } from "prosemirror-model"; import { forwardRef, useCallback, useRef, useState } from "react"; -import { - DEFAULT_EDITOR_WIDTH, - normalizeEditorWidth, - parseImageTitleMetadata, - stripEditorWidthFromTitle, -} from "@hypr/tiptap/shared"; import { cn } from "@hypr/utils"; +const MIN_IMAGE_WIDTH = 15; +const MAX_IMAGE_WIDTH = 100; +const DEFAULT_IMAGE_WIDTH = 80; + +function clampImageWidth(value: number) { + if (Number.isNaN(value)) return DEFAULT_IMAGE_WIDTH; + return Math.min( + MAX_IMAGE_WIDTH, + Math.max(MIN_IMAGE_WIDTH, Math.round(value)), + ); +} + +export function parseImageMetadata(title?: string) { + const match = title?.match(/^char-editor-width=(\d{1,3})(?:\|(.*))?$/s); + return { + editorWidth: + match && match.length >= 1 + ? clampImageWidth(parseInt(match[1], 10)) + : undefined, + title: match && match.length >= 2 ? match[2] : title, + }; +} + export const imageNodeSpec: NodeSpec = { group: "block", draggable: true, @@ -22,26 +39,27 @@ export const imageNodeSpec: NodeSpec = { alt: { default: null }, title: { default: null }, attachmentId: { default: null }, - editorWidth: { default: DEFAULT_EDITOR_WIDTH }, + editorWidth: { default: DEFAULT_IMAGE_WIDTH }, }, parseDOM: [ { tag: "img[src]", getAttrs(dom) { const el = dom as HTMLElement; - const title = el.getAttribute("title"); - const metadata = parseImageTitleMetadata(title); + const title = el.getAttribute("title") ?? undefined; + const metadata = parseImageMetadata(title); return { src: el.getAttribute("src"), alt: el.getAttribute("alt"), - title: stripEditorWidthFromTitle(title), + title: metadata.title, attachmentId: el.getAttribute("data-attachment-id"), - editorWidth: - normalizeEditorWidth( - Number(el.getAttribute("data-editor-width")), - ) ?? - metadata.editorWidth ?? - DEFAULT_EDITOR_WIDTH, + editorWidth: clampImageWidth( + parseInt( + el.getAttribute("data-editor-width") ?? + String(metadata.editorWidth), + 10, + ), + ), }; }, }, @@ -129,7 +147,7 @@ export const ResizableImageView = forwardRef< window.removeEventListener("pointerup", handlePointerUp); updateAttributes({ - editorWidth: normalizeEditorWidth((currentWidth / maxWidth) * 100), + editorWidth: clampImageWidth((currentWidth / maxWidth) * 100), }); setIsResizing(false); @@ -143,8 +161,7 @@ export const ResizableImageView = forwardRef< ); const showControls = isHovered || isSelected || isResizing; - const editorWidth = - normalizeEditorWidth(node.attrs.editorWidth) ?? DEFAULT_EDITOR_WIDTH; + const editorWidth = clampImageWidth(node.attrs.editorWidth); const imageWidth = draftWidth !== null ? `${draftWidth}px` : `${editorWidth}%`; @@ -165,7 +182,7 @@ export const ResizableImageView = forwardRef< ref={imageRef} src={node.attrs.src} alt={node.attrs.alt || ""} - title={stripEditorWidthFromTitle(node.attrs.title)} + title={parseImageMetadata(node.attrs.title).title ?? undefined} className={cn([ "tiptap-image max-w-full rounded-md bg-white transition-[box-shadow,border-color] select-none", isSelected diff --git a/apps/desktop/src/editor/node-views/index.ts b/apps/desktop/src/editor/node-views/index.ts index e1f06983bd..a413822ed3 100644 --- a/apps/desktop/src/editor/node-views/index.ts +++ b/apps/desktop/src/editor/node-views/index.ts @@ -1,5 +1,9 @@ export { attachmentNodeSpec, AttachmentChipView } from "./attachment-view"; -export { imageNodeSpec, ResizableImageView } from "./image-view"; +export { + imageNodeSpec, + parseImageMetadata, + ResizableImageView, +} from "./image-view"; export { mentionNodeSpec, MentionNodeView } from "./mention-view"; export { taskItemNodeSpec, diff --git a/apps/desktop/src/session/components/session-preview-card.tsx b/apps/desktop/src/session/components/session-preview-card.tsx index ef6b4051bb..f3e71a6e64 100644 --- a/apps/desktop/src/session/components/session-preview-card.tsx +++ b/apps/desktop/src/session/components/session-preview-card.tsx @@ -5,7 +5,6 @@ import { defaultRehypePlugins, Streamdown } from "streamdown"; import { isValidTiptapContent, json2md, - parseImageTitleMetadata, streamdownComponents, } from "@hypr/tiptap/shared"; import { @@ -15,6 +14,7 @@ import { } from "@hypr/ui/components/ui/hover-card"; import { cn, format, safeParseDate } from "@hypr/utils"; +import { parseImageMetadata } from "~/editor/node-views"; import { extractPlainText } from "~/search/contexts/engine/utils"; import { useEnhancedNote, @@ -45,7 +45,7 @@ const previewCardComponents: typeof streamdownComponents = { ), img: (props) => { - const { editorWidth, title } = parseImageTitleMetadata(props.title); + const { editorWidth, title } = parseImageMetadata(props.title); return ( Date: Tue, 31 Mar 2026 17:48:01 +0900 Subject: [PATCH 21/21] pull streamdown out from tiptap package --- apps/desktop/src/editor/node-views/index.ts | 6 +- .../note-input/enhanced/streaming.tsx | 3 +- .../components/session-preview-card.tsx | 11 ++- .../src/session/components/streamdown.tsx | 70 +++++++++++++++++++ 4 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/session/components/streamdown.tsx diff --git a/apps/desktop/src/editor/node-views/index.ts b/apps/desktop/src/editor/node-views/index.ts index a413822ed3..e1f06983bd 100644 --- a/apps/desktop/src/editor/node-views/index.ts +++ b/apps/desktop/src/editor/node-views/index.ts @@ -1,9 +1,5 @@ export { attachmentNodeSpec, AttachmentChipView } from "./attachment-view"; -export { - imageNodeSpec, - parseImageMetadata, - ResizableImageView, -} from "./image-view"; +export { imageNodeSpec, ResizableImageView } from "./image-view"; export { mentionNodeSpec, MentionNodeView } from "./mention-view"; export { taskItemNodeSpec, diff --git a/apps/desktop/src/session/components/note-input/enhanced/streaming.tsx b/apps/desktop/src/session/components/note-input/enhanced/streaming.tsx index 82a6bcd229..addccdae5a 100644 --- a/apps/desktop/src/session/components/note-input/enhanced/streaming.tsx +++ b/apps/desktop/src/session/components/note-input/enhanced/streaming.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from "react"; import { Streamdown } from "streamdown"; -import { streamdownComponents } from "@hypr/tiptap/shared"; import { cn } from "@hypr/utils"; +import { streamdownComponents } from "../../streamdown"; + import { useAITaskTask } from "~/ai/hooks"; import { createTaskId } from "~/store/zustand/ai-task/task-configs"; import { type TaskStepInfo } from "~/store/zustand/ai-task/tasks"; diff --git a/apps/desktop/src/session/components/session-preview-card.tsx b/apps/desktop/src/session/components/session-preview-card.tsx index f3e71a6e64..7306dc849c 100644 --- a/apps/desktop/src/session/components/session-preview-card.tsx +++ b/apps/desktop/src/session/components/session-preview-card.tsx @@ -2,11 +2,7 @@ import { useMotionValue, useSpring, useTransform } from "motion/react"; import { useCallback, useMemo, useRef, useState } from "react"; import { defaultRehypePlugins, Streamdown } from "streamdown"; -import { - isValidTiptapContent, - json2md, - streamdownComponents, -} from "@hypr/tiptap/shared"; +import { isValidTiptapContent, json2md } from "@hypr/tiptap/shared"; import { HoverCard, HoverCardContent, @@ -14,8 +10,9 @@ import { } from "@hypr/ui/components/ui/hover-card"; import { cn, format, safeParseDate } from "@hypr/utils"; -import { parseImageMetadata } from "~/editor/node-views"; +import { parseImageMetadata } from "~/editor/node-views/image-view"; import { extractPlainText } from "~/search/contexts/engine/utils"; +import { streamdownComponents } from "~/session/components/streamdown"; import { useEnhancedNote, useEnhancedNotes, @@ -50,7 +47,7 @@ const previewCardComponents: typeof streamdownComponents = { return ( ) => ( +

    + {props.children as React.ReactNode} +

    + ), + h2: (props: React.HTMLAttributes) => ( +

    + {props.children as React.ReactNode} +

    + ), + h3: (props: React.HTMLAttributes) => ( +

    + {props.children as React.ReactNode} +

    + ), + h4: (props: React.HTMLAttributes) => ( +

    + {props.children as React.ReactNode} +

    + ), + h5: (props: React.HTMLAttributes) => ( +
    + {props.children as React.ReactNode} +
    + ), + h6: (props: React.HTMLAttributes) => ( +
    + {props.children as React.ReactNode} +
    + ), + ul: (props: React.HTMLAttributes) => ( +
      + {props.children as React.ReactNode} +
    + ), + ol: (props: React.HTMLAttributes) => ( +
      + {props.children as React.ReactNode} +
    + ), + li: (props: React.HTMLAttributes) => ( +
  • {props.children as React.ReactNode}
  • + ), + p: (props: React.HTMLAttributes) => ( +

    {props.children as React.ReactNode}

    + ), + img: (props: React.ImgHTMLAttributes) => { + const { editorWidth, title } = parseImageMetadata(props.title); + + return ( + + ); + }, +} as const;