diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f05e0733aa..02cf59af8c 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,17 @@ "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-search": "^1.1.0", + "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 +160,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/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; + }); +} 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..f9d4b2c648 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/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 }) { diff --git a/apps/desktop/src/editor/chat/index.tsx b/apps/desktop/src/editor/chat/index.tsx new file mode 100644 index 0000000000..2ddd0b623a --- /dev/null +++ b/apps/desktop/src/editor/chat/index.tsx @@ -0,0 +1,283 @@ +import "prosemirror-view/style/prosemirror.css"; + +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 "@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, + findMention, + mentionSkipPlugin, +} 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 (mentionConfig && findMention(state, mentionConfig.trigger)) { + 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 ? [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..b923c97c0f --- /dev/null +++ b/apps/desktop/src/editor/chat/schema.ts @@ -0,0 +1,33 @@ +import { type NodeSpec, Schema } from "prosemirror-model"; + +import { attachmentNodeSpec, mentionNodeSpec } from "../node-views"; + +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-@": 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 new file mode 100644 index 0000000000..bc6f8c622a --- /dev/null +++ b/apps/desktop/src/editor/node-views/attachment-view.tsx @@ -0,0 +1,97 @@ +import { + type NodeViewComponentProps, + 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 +>(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/node-views/image-view.tsx b/apps/desktop/src/editor/node-views/image-view.tsx new file mode 100644 index 0000000000..c2f5032d7b --- /dev/null +++ b/apps/desktop/src/editor/node-views/image-view.tsx @@ -0,0 +1,229 @@ +import { + type NodeViewComponentProps, + useEditorEventCallback, + useEditorState, +} from "@handlewithcare/react-prosemirror"; +import type { NodeSpec } from "prosemirror-model"; +import { forwardRef, useCallback, useRef, useState } from "react"; + +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, + attrs: { + src: { default: null }, + alt: { default: null }, + title: { default: null }, + attachmentId: { default: null }, + editorWidth: { default: DEFAULT_IMAGE_WIDTH }, + }, + parseDOM: [ + { + tag: "img[src]", + getAttrs(dom) { + const el = dom as HTMLElement; + const title = el.getAttribute("title") ?? undefined; + const metadata = parseImageMetadata(title); + return { + src: el.getAttribute("src"), + alt: el.getAttribute("alt"), + title: metadata.title, + attachmentId: el.getAttribute("data-attachment-id"), + editorWidth: clampImageWidth( + parseInt( + el.getAttribute("data-editor-width") ?? + String(metadata.editorWidth), + 10, + ), + ), + }; + }, + }, + ], + 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 +>(function ResizableImageView({ nodeProps, ...htmlAttrs }, ref) { + const { node, getPos } = nodeProps; + const [isHovered, setIsHovered] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [draftWidth, setDraftWidth] = useState(null); + const containerRef = useRef(null); + const imageRef = useRef(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); + }, + ); + + // 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 containerEl = containerRef.current; + const imageEl = imageRef.current; + if (!containerEl || !imageEl) return; + + event.preventDefault(); + event.stopPropagation(); + + 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); + + const handlePointerMove = (e: PointerEvent) => { + const deltaX = (e.clientX - startX) * (direction === "left" ? -1 : 1); + currentWidth = Math.min(maxWidth, Math.max(120, startWidth + deltaX)); + setDraftWidth(currentWidth); + }; + + const handlePointerUp = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + + updateAttributes({ + editorWidth: clampImageWidth((currentWidth / maxWidth) * 100), + }); + + setIsResizing(false); + setDraftWidth(null); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + }, + [updateAttributes], + ); + + const showControls = isHovered || isSelected || isResizing; + const editorWidth = clampImageWidth(node.attrs.editorWidth); + const imageWidth = + draftWidth !== null ? `${draftWidth}px` : `${editorWidth}%`; + + return ( +
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {node.attrs.alt + {showControls && ( + <> + + ); +}); 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..e1f06983bd --- /dev/null +++ b/apps/desktop/src/editor/node-views/index.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000000..71a4e6cec4 --- /dev/null +++ b/apps/desktop/src/editor/node-views/mention-view.tsx @@ -0,0 +1,157 @@ +import { type NodeViewComponentProps } from "@handlewithcare/react-prosemirror"; +import { Facehash, stringHash } from "facehash"; +import { + Building2Icon, + MessageSquareIcon, + 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 = [ + "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/node-views/task-item-view.tsx b/apps/desktop/src/editor/node-views/task-item-view.tsx new file mode 100644 index 0000000000..ad6edd999f --- /dev/null +++ b/apps/desktop/src/editor/node-views/task-item-view.tsx @@ -0,0 +1,85 @@ +import { + type NodeViewComponentProps, + 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 } +>(function TaskItemView({ 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}
    +
  • + ); +}); 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 new file mode 100644 index 0000000000..8e80510e33 --- /dev/null +++ b/apps/desktop/src/editor/plugins/index.ts @@ -0,0 +1,29 @@ +export { clearMarksOnEnterPlugin } from "./clear-marks-on-enter"; +export { + clipNodeSpec, + clipPastePlugin, + parseYouTubeClipId, + parseYouTubeEmbedSnippet, + parseYouTubeUrl, + resolveYouTubeClipUrl, +} 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 { + SearchQuery, + getMatchHighlights, + getSearchState, + searchFindNext, + searchFindPrev, + searchPlugin, + searchReplaceAll, + searchReplaceCurrent, + searchReplaceNext, + setSearchState, +} 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/index.tsx b/apps/desktop/src/editor/session/index.tsx new file mode 100644 index 0000000000..e50db0a875 --- /dev/null +++ b/apps/desktop/src/editor/session/index.tsx @@ -0,0 +1,385 @@ +import "prosemirror-gapcursor/style/gapcursor.css"; + +import { + ProseMirror, + 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, + Selection, + TextSelection, + 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 { + MentionNodeView, + ResizableImageView, + TaskItemView, +} from "../node-views"; +import { + type FileHandlerConfig, + type PlaceholderFunction, + SearchQuery, + clearMarksOnEnterPlugin, + clipPastePlugin, + fileHandlerPlugin, + getSearchState, + hashtagPlugin, + linkBoundaryGuardPlugin, + placeholderPlugin, + searchPlugin, + searchReplaceAll, + searchReplaceCurrent, + setSearchState, +} from "../plugins"; +import { + type MentionConfig, + MentionSuggestion, + SlashCommandMenu, + mentionSkipPlugin, +} from "../widgets"; +import { buildInputRules, buildKeymap } from "./keymap"; +import { schema } from "./schema"; + +export type { MentionConfig, FileHandlerConfig, PlaceholderFunction }; +export { schema }; + +export interface JSONContent { + type?: string; + attrs?: Record; + content?: JSONContent[]; + marks?: { type: string; attrs?: Record }[]; + 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; + commands: EditorCommands; +} + +interface EditorProps { + handleChange?: (content: JSONContent) => void; + initialContent?: JSONContent; + mentionConfig?: MentionConfig; + placeholderComponent?: PlaceholderFunction; + fileHandlerConfig?: FileHandlerConfig; + onNavigateToTitle?: (pixelWidth?: number) => void; +} + +const nodeViews = { + image: ResizableImageView, + "mention-@": MentionNodeView, + taskItem: TaskItemView, +}; + +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 noopCommands: EditorCommands = { + focus: () => {}, + focusAtStart: () => {}, + focusAtPixelWidth: () => {}, + insertAtStartAndFocus: () => {}, + setSearch: () => {}, + replace: () => {}, +}; + +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(); + }, + ); + + 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; +} + +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; + }, + }), + [], + ); + + 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 ? [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; + + 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 + } + } + }, [initialContent]); + + const onViewReady = useCallback( + (view: EditorView) => { + onUpdate(view); + }, + [onUpdate], + ); + + return ( + + + + + + {mentionConfig && } + + ); + }, +); diff --git a/apps/desktop/src/editor/session/keymap.ts b/apps/desktop/src/editor/session/keymap.ts new file mode 100644 index 0000000000..80c7281aa4 --- /dev/null +++ b/apps/desktop/src/editor/session/keymap.ts @@ -0,0 +1,335 @@ +import { + chainCommands, + createParagraphNear, + deleteSelection, + exitCode, + joinBackward, + joinForward, + liftEmptyBlock, + newlineInCode, + selectAll, + selectNodeBackward, + selectNodeForward, + selectTextblockEnd, + selectTextblockStart, + setBlockType, + 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 { + Selection, + TextSelection, + type Command, + type 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?: (pixelWidth?: number) => 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; + + 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); + 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, + ); + + 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) => { + const { selection } = state; + if (selection.$head.pos === 0 && selection.empty) return true; + return false; + }, + revertBlockToParagraph, + joinBackward, + selectNodeBackward, + ); + keys["Backspace"] = backspaceCmd; + keys["Mod-Backspace"] = backspaceCmd; + keys["Shift-Backspace"] = backspaceCmd; + + 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; + } + + // Prevent Tab from moving focus outside the editor + keys["Tab"] = (state, dispatch) => { + const itemName = isInListItem(state); + if (!itemName) return true; + const nodeType = state.schema.nodes[itemName]; + if (!nodeType) return true; + 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["ArrowLeft"] = (state) => { + const { $head, empty } = state.selection; + if (!empty) return false; + if ($head.pos !== Selection.atStart(state.doc).from) return false; + + onNavigateToTitle(); + return true; + }; + + 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 (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; + }; + } + + return keymap(keys); +} diff --git a/apps/desktop/src/editor/session/schema.ts b/apps/desktop/src/editor/session/schema.ts new file mode 100644 index 0000000000..02a67726bf --- /dev/null +++ b/apps/desktop/src/editor/session/schema.ts @@ -0,0 +1,226 @@ +import { type MarkSpec, type NodeSpec, Schema } from "prosemirror-model"; + +import { + imageNodeSpec, + mentionNodeSpec, + taskItemNodeSpec, + taskListNodeSpec, +} from "../node-views"; +import { clipNodeSpec } from "../plugins"; + +// 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: taskListNodeSpec, + taskItem: taskItemNodeSpec, + image: imageNodeSpec, + "mention-@": mentionNodeSpec, + clip: clipNodeSpec, +}; + +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/editor/widgets/index.ts b/apps/desktop/src/editor/widgets/index.ts new file mode 100644 index 0000000000..25efcabf6e --- /dev/null +++ b/apps/desktop/src/editor/widgets/index.ts @@ -0,0 +1,7 @@ +export { + type MentionConfig, + MentionSuggestion, + findMention, + mentionSkipPlugin, +} 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 new file mode 100644 index 0000000000..2b46d43814 --- /dev/null +++ b/apps/desktop/src/editor/widgets/mention.tsx @@ -0,0 +1,291 @@ +import { + autoUpdate, + computePosition, + flip, + limitShift, + offset, + shift, + type VirtualElement, +} from "@floating-ui/dom"; +import { + useEditorEffect, + useEditorEventCallback, + useEditorEventListener, + useEditorState, +} from "@handlewithcare/react-prosemirror"; +import { + Building2Icon, + MessageSquareIcon, + StickyNoteIcon, + UserIcon, +} from "lucide-react"; +import { + type EditorState, + NodeSelection, + Plugin, + PluginKey, + TextSelection, +} from "prosemirror-state"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +export interface MentionItem { + id: string; + type: string; + label: string; + content?: string; +} + +export type MentionConfig = { + trigger: string; + handleSearch: (query: string) => Promise; +}; + +// --------------------------------------------------------------------------- +// Derive mention state from EditorState (no plugin needed) +// --------------------------------------------------------------------------- +interface MentionState { + query: string; + from: number; + to: number; +} + +export function findMention( + state: EditorState, + trigger: string, +): MentionState | 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 { query, from, to }; +} + +// --------------------------------------------------------------------------- +// Mention popup +// --------------------------------------------------------------------------- +export function MentionSuggestion({ config }: { config: MentionConfig }) { + const [items, setItems] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + 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 || !mentionState) return; + + const { schema } = view.state; + 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(mentionState.from, mentionState.to, [ + mentionNode, + space, + ]); + + view.dispatch(tr); + view.focus(); + setDismissedFrom(mentionState.from); + }); + + useEditorEventListener("keydown", (_view, event) => { + if (!active || items.length === 0) return false; + + if (event.key === "Escape") { + if (mentionState) setDismissedFrom(mentionState.from); + return true; + } + + 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; + } + + const popup = popupRef.current; + if (!popup) return; + + const coords = view.coordsAtPos(mentionState!.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(mentionState!.query) + .then((results) => { + setItems(results.slice(0, 5)); + setSelectedIndex(0); + }) + .catch(() => { + setItems([]); + }); + }, [active, mentionState?.query, config]); + + if (!active || items.length === 0) return null; + + return createPortal( +
    + {items.map((item, index) => ( + + ))} +
    , + document.body, + ); +} + +// --------------------------------------------------------------------------- +// 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/widgets/slash-command.tsx b/apps/desktop/src/editor/widgets/slash-command.tsx new file mode 100644 index 0000000000..cdf5619de0 --- /dev/null +++ b/apps/desktop/src/editor/widgets/slash-command.tsx @@ -0,0 +1,393 @@ +import { + autoUpdate, + computePosition, + flip, + limitShift, + offset, + shift, + type VirtualElement, +} from "@floating-ui/dom"; +import { + useEditorEffect, + useEditorEventCallback, + useEditorEventListener, + 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 type { EditorState, Transaction } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import { useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { cn } from "@hypr/utils"; + +import { schema } from "../session/schema"; + +// --------------------------------------------------------------------------- +// Slash command items +// --------------------------------------------------------------------------- +export interface SlashCommandItem { + id: string; + label: string; + description: string; + icon: React.ComponentType<{ className?: string }>; + keywords: string[]; + action: (view: EditorView, from: number, to: number) => void; +} + +function clearSlashAndRun( + view: EditorView, + from: number, + to: number, + command: ( + state: EditorState, + dispatch?: (tr: 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]), + ); + }, + }, +]; + +// --------------------------------------------------------------------------- +// Derive slash command state from EditorState (no plugin needed) +// --------------------------------------------------------------------------- +interface SlashCommandState { + query: string; + from: number; + to: number; +} + +function findSlashCommand(state: EditorState): SlashCommandState | null { + const { $from } = state.selection; + if (!state.selection.empty) 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 { query, from, to }; +} + +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)), + ); +} + +// --------------------------------------------------------------------------- +// React component +// --------------------------------------------------------------------------- +export function SlashCommandMenu() { + const popupRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + const [selectedIndex, setSelectedIndex] = useState(0); + const [dismissedFrom, setDismissedFrom] = useState(null); + + const editorState = useEditorState(); + const slashState = editorState ? findSlashCommand(editorState) : null; + + 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 || !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 || items.length === 0) { + cleanupRef.current?.(); + cleanupRef.current = null; + return; + } + + const popup = popupRef.current; + if (!popup) return; + + const coords = view.coordsAtPos(slashState!.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(); + }); + + if (!active || items.length === 0) return null; + + return createPortal( +
    +
    + Commands +
    + {items.map((item, index) => ( + + ))} +
    , + document.body, + ); +} 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..8339e9fc29 100644 --- a/apps/desktop/src/session/components/note-input/enhanced/editor.tsx +++ b/apps/desktop/src/session/components/note-input/enhanced/editor.tsx @@ -1,17 +1,23 @@ 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/session"; 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 }, - { sessionId: string; enhancedNoteId: string; onNavigateToTitle?: () => void } + NoteEditorRef, + { + sessionId: string; + enhancedNoteId: string; + onNavigateToTitle?: (pixelWidth?: number) => void; + } >(({ sessionId, enhancedNoteId, onNavigateToTitle }, ref) => { const onImageUpload = useImageUpload(sessionId); const content = main.UI.useCell( @@ -88,15 +94,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..68c9daaaf2 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,12 +7,17 @@ import { StreamingView } from "./streaming"; import { useAITaskTask } from "~/ai/hooks"; import { useLLMConnectionStatus } from "~/ai/hooks"; +import type { NoteEditorRef } from "~/editor/session"; import * as main from "~/store/tinybase/store/main"; import { createTaskId } from "~/store/zustand/ai-task/task-configs"; export const Enhanced = forwardRef< - { editor: TiptapEditor | null }, - { sessionId: string; enhancedNoteId: string; onNavigateToTitle?: () => void } + NoteEditorRef, + { + 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/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/note-input/index.tsx b/apps/desktop/src/session/components/note-input/index.tsx index ebe3b5d6fa..5edeb5670e 100644 --- a/apps/desktop/src/session/components/note-input/index.tsx +++ b/apps/desktop/src/session/components/note-input/index.tsx @@ -1,4 +1,5 @@ import { convertFileSrc } from "@tauri-apps/api/core"; +import type { EditorView } from "prosemirror-view"; import { forwardRef, useCallback, @@ -11,7 +12,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, @@ -22,38 +22,55 @@ 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/session"; 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 interface NoteInputHandle { + focus: () => void; + focusAtStart: () => void; + focusAtPixelWidth: (pixelWidth: number) => void; + insertAtStartAndFocus: (content: string) => void; +} + export const NoteInput = forwardRef< - { editor: TiptapEditor | null }, + NoteInputHandle, { tab: Extract; - onNavigateToTitle?: () => void; + onNavigateToTitle?: (pixelWidth?: number) => void; } >(({ 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 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 }, + () => ({ + 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], ); @@ -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,132 +111,38 @@ 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?.commands.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); } }); - useEffect(() => { - const handleContentTransfer = (e: Event) => { - const customEvent = e as CustomEvent<{ content: string }>; - const content = customEvent.detail.content; - const editorInstance = internalEditorRef.current?.editor; - - if (editorInstance && content) { - editorInstance.commands.insertContentAt(0, content); - editorInstance.commands.setTextSelection(0); - editorInstance.commands.focus(); - } - }; - - const handleMoveToEditorStart = () => { - const editorInstance = internalEditorRef.current?.editor; - if (editorInstance) { - editorInstance.commands.setTextSelection(0); - editorInstance.commands.focus(); - } - }; - - const handleMoveToEditorPosition = (e: Event) => { - const customEvent = e as CustomEvent<{ pixelWidth: number }>; - const pixelWidth = customEvent.detail.pixelWidth; - const editorInstance = internalEditorRef.current?.editor; - - if (editorInstance) { - const editorDom = editorInstance.view.dom; - const firstTextNode = editorDom.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 = editorInstance.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, - editorInstance.state.doc.content.size - 1, - ); - editorInstance.commands.setTextSelection(targetPos); - editorInstance.commands.focus(); - return; - } - } - } - - editorInstance.commands.setTextSelection(0); - editorInstance.commands.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({ - editor, + view, container, enabled: currentTab.type !== "transcript" && currentTab.type !== "attachments", }); - const { showSearchBar } = useSearchSync({ - editor, - currentTab, - sessionId, - editorRef: internalEditorRef, - }); + const search = useSearch(); + const showSearchBar = search?.isVisible ?? false; + + useEffect(() => { + search?.close(); + }, [currentTab]); const handleContainerClick = () => { if (currentTab.type !== "transcript" && currentTab.type !== "attachments") { - internalEditorRef.current?.editor?.commands.focus(); + internalEditorRef.current?.commands.focus(); } }; @@ -236,7 +159,10 @@ export const NoteInput = forwardRef< {showSearchBar && (
    - +
    )} @@ -297,9 +223,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..70f9c22c51 100644 --- a/apps/desktop/src/session/components/note-input/raw.tsx +++ b/apps/desktop/src/session/components/note-input/raw.tsx @@ -1,23 +1,24 @@ 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 NoteEditor, { - type JSONContent, - type TiptapEditor, -} from "@hypr/tiptap/editor"; +import { parseJsonContent } from "@hypr/tiptap/shared"; + import { - parseJsonContent, + NoteEditor, + type JSONContent, + type NoteEditorRef, type PlaceholderFunction, -} from "@hypr/tiptap/shared"; - +} from "~/editor/session"; 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 }, - { sessionId: string; onNavigateToTitle?: () => void } + NoteEditorRef, + { + 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); @@ -120,15 +121,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 deleted file mode 100644 index 7b0adfeb12..0000000000 --- a/apps/desktop/src/session/components/note-input/search-replace.ts +++ /dev/null @@ -1,312 +0,0 @@ -import type { TiptapEditor } from "@hypr/tiptap/editor"; - -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, - editor: TiptapEditor | null, -) { - if (!editor) return; - - const doc = editor.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 = editor.state.tr; - - for (const hit of toReplace) { - const adjustedFrom = hit.from + offset; - const adjustedTo = hit.to + offset; - if (detail.replacement) { - tr.replaceWith( - adjustedFrom, - adjustedTo, - editor.state.schema.text(detail.replacement), - ); - } else { - tr.delete(adjustedFrom, adjustedTo); - } - offset += detail.replacement.length - detail.query.length; - } - - editor.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..6adfa98def 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/session"; 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 b69c4938d2..0000000000 --- a/apps/desktop/src/session/components/note-input/use-search-sync.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 * as main from "~/store/tinybase/store/main"; -import { type EditorView } from "~/store/zustand/tabs/schema"; - -export function useSearchSync({ - editor, - currentTab, - sessionId, - editorRef, -}: { - editor: TiptapEditor | null; - currentTab: EditorView; - sessionId: string; - editorRef: MutableRefObject<{ editor: TiptapEditor | null } | null>; -}) { - const search = useTranscriptSearch(); - const showSearchBar = search?.isVisible ?? false; - - useEffect(() => { - search?.close(); - }, [currentTab]); - - useEffect(() => { - if (!editor?.storage?.searchAndReplace) return; - - 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; - - try { - editor.view.dispatch(editor.state.tr); - } catch { - return; - } - - if (query) { - requestAnimationFrame(() => { - const el = editor.view.dom.querySelector(".search-result-current"); - el?.scrollIntoView({ behavior: "smooth", block: "center" }); - }); - } - }, [ - editor, - 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?.editor ?? 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/components/session-preview-card.tsx b/apps/desktop/src/session/components/session-preview-card.tsx index ef6b4051bb..7306dc849c 100644 --- a/apps/desktop/src/session/components/session-preview-card.tsx +++ b/apps/desktop/src/session/components/session-preview-card.tsx @@ -2,12 +2,7 @@ import { useMotionValue, useSpring, useTransform } from "motion/react"; import { useCallback, useMemo, useRef, useState } from "react"; import { defaultRehypePlugins, Streamdown } from "streamdown"; -import { - isValidTiptapContent, - json2md, - parseImageTitleMetadata, - streamdownComponents, -} from "@hypr/tiptap/shared"; +import { isValidTiptapContent, json2md } from "@hypr/tiptap/shared"; import { HoverCard, HoverCardContent, @@ -15,7 +10,9 @@ import { } from "@hypr/ui/components/ui/hover-card"; import { cn, format, safeParseDate } from "@hypr/utils"; +import { parseImageMetadata } from "~/editor/node-views/image-view"; import { extractPlainText } from "~/search/contexts/engine/utils"; +import { streamdownComponents } from "~/session/components/streamdown"; import { useEnhancedNote, useEnhancedNotes, @@ -45,12 +42,12 @@ const previewCardComponents: typeof streamdownComponents = { ), img: (props) => { - const { editorWidth, title } = parseImageTitleMetadata(props.title); + const { editorWidth, title } = parseImageMetadata(props.title); 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; 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 b3980583be..02de67080d 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 { SearchProvider } from "./components/note-input/transcript/search/context"; +import { NoteInput, type NoteInputHandle } from "./components/note-input"; +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"; -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,10 +199,8 @@ function TabContentNoteInner({ tab: Extract; showTimeline: boolean; }) { - const titleInputRef = React.useRef(null); - const noteInputRef = React.useRef<{ - editor: import("@hypr/tiptap/editor").TiptapEditor | null; - }>(null); + const titleInputRef = React.useRef(null); + const noteInputRef = React.useRef(null); const currentView = useCurrentNoteTab(tab); const { generateTitle } = useTitleGeneration(tab); @@ -244,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?.editor?.commands.focus(); + const handleFocusEditorAtStart = React.useCallback(() => { + noteInputRef.current?.focusAtStart(); }, []); + const handleFocusEditorAtPixelWidth = React.useCallback( + (pixelWidth: number) => { + noteInputRef.current?.focusAtPixelWidth(pixelWidth); + }, + [], + ); + return ( <>
    @@ -275,7 +290,7 @@ function TabContentNoteInner({ @@ -377,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/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/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/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; } } } 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/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/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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6748fc914d..5e1d51188f 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,39 @@ 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-search: + specifier: ^1.1.0 + version: 1.1.0 + 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 +587,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 @@ -651,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 @@ -3662,6 +3708,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'} @@ -14986,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==} @@ -14997,7 +15057,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 +15364,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'} @@ -18245,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 @@ -18264,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 @@ -18278,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 @@ -18429,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 @@ -18486,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 @@ -18582,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 @@ -19206,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 @@ -19526,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 @@ -19542,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 @@ -19616,7 +19682,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 +19700,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 +19748,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) @@ -19737,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) @@ -20117,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 @@ -20197,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)) @@ -20242,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 @@ -20272,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)) @@ -20481,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 @@ -20662,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 @@ -20881,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 @@ -20983,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 @@ -21144,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 @@ -21191,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 @@ -21272,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 @@ -21653,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) @@ -21787,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: @@ -21824,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: @@ -22154,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: @@ -22164,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 @@ -22212,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: @@ -22224,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: @@ -22236,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: @@ -22702,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 @@ -22793,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 @@ -22808,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 @@ -26103,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: @@ -26120,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 @@ -26141,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 @@ -26167,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: @@ -26200,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 @@ -26342,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) @@ -26988,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 @@ -27266,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 @@ -27640,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 @@ -28711,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 @@ -28733,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 @@ -28882,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 @@ -29023,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 @@ -29189,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 @@ -29398,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 @@ -29447,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: @@ -29614,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 @@ -29685,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: @@ -29782,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: @@ -29802,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 @@ -29873,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 @@ -30361,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 @@ -30394,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 @@ -30556,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 @@ -30937,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 @@ -31142,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 @@ -31218,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 @@ -32603,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 @@ -32626,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 @@ -32861,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 @@ -32879,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 @@ -32901,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 @@ -33429,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: @@ -33899,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 @@ -34119,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 @@ -34176,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 @@ -34224,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: @@ -34540,6 +34388,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): @@ -34965,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) @@ -34983,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 @@ -35161,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 @@ -35257,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 @@ -35488,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 @@ -35797,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: @@ -36204,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 @@ -36463,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 @@ -36629,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) @@ -36647,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) @@ -36668,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) @@ -36830,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 @@ -36868,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 @@ -36912,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 @@ -37084,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 @@ -37140,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: