| author | |
| committer | |
| log | d359e94cabca4722029359c77d60cb6d13da1e59 |
| tree | ab1108d46ea7dcec1692b2da73d54375abc2d01d |
| parent | df466c79810046d20c0f3ccc4e51709c5703814e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <maria@kuuro.net>28 files changed, 3557 insertions(+), 4071 deletions(-)
apps/web/package.json+6-2| ... | @@ -23,7 +23,6 @@ | ... | @@ -23,7 +23,6 @@ |
| 23 | "@effect/atom-react": "catalog:", | 23 | "@effect/atom-react": "catalog:", |
| 24 | "@formkit/auto-animate": "^0.9.0", | 24 | "@formkit/auto-animate": "^0.9.0", |
| 25 | "@legendapp/list": "catalog:", | 25 | "@legendapp/list": "catalog:", |
| 26 | "@lexical/react": "^0.41.0", | ||
| 27 | "@noble/hashes": "catalog:", | 26 | "@noble/hashes": "catalog:", |
| 28 | "@pierre/diffs": "catalog:", | 27 | "@pierre/diffs": "catalog:", |
| 29 | "@pierre/trees": "1.0.0-beta.4", | 28 | "@pierre/trees": "1.0.0-beta.4", |
| ... | @@ -32,6 +31,12 @@ | ... | @@ -32,6 +31,12 @@ |
| 32 | "@t3tools/shared": "workspace:*", | 31 | "@t3tools/shared": "workspace:*", |
| 33 | "@tanstack/react-pacer": "^0.19.4", | 32 | "@tanstack/react-pacer": "^0.19.4", |
| 34 | "@tanstack/react-router": "^1.160.2", | 33 | "@tanstack/react-router": "^1.160.2", |
| 34 | "@tiptap/core": "^3.31.3", | ||
| 35 | "@tiptap/extension-task-item": "^3.31.3", | ||
| 36 | "@tiptap/extension-task-list": "^3.31.3", | ||
| 37 | "@tiptap/pm": "^3.31.3", | ||
| 38 | "@tiptap/react": "^3.31.3", | ||
| 39 | "@tiptap/starter-kit": "^3.31.3", | ||
| 35 | "class-variance-authority": "^0.7.1", | 40 | "class-variance-authority": "^0.7.1", |
| 36 | "culori": "^4.0.2", | 41 | "culori": "^4.0.2", |
| 37 | "effect": "catalog:", | 42 | "effect": "catalog:", |
| ... | @@ -41,7 +46,6 @@ | ... | @@ -41,7 +46,6 @@ |
| 41 | "jose": "catalog:", | 46 | "jose": "catalog:", |
| 42 | "jsonc-parser": "3.3.1", | 47 | "jsonc-parser": "3.3.1", |
| 43 | "jszip": "3.10.1", | 48 | "jszip": "3.10.1", |
| 44 | "lexical": "^0.41.0", | ||
| 45 | "lucide-react": "^0.564.0", | 49 | "lucide-react": "^0.564.0", |
| 46 | "react": "19.2.6", | 50 | "react": "19.2.6", |
| 47 | "react-dom": "19.2.6", | 51 | "react-dom": "19.2.6", |
apps/web/src/components/AppSidebarLayout.tsx+14-1| ... | @@ -11,7 +11,11 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; | ... | @@ -11,7 +11,11 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 11 | 11 | ||
| 12 | import { isElectron } from "../env"; | 12 | import { isElectron } from "../env"; |
| 13 | import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; | 13 | import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; |
| 14 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 14 | import { |
| 15 | isRichTextBoldShortcut, | ||
| 16 | resolveShortcutCommand, | ||
| 17 | shortcutLabelForCommand, | ||
| 18 | } from "../keybindings"; | ||
| 15 | import { cn, isMacPlatform } from "../lib/utils"; | 19 | import { cn, isMacPlatform } from "../lib/utils"; |
| 16 | import { primaryServerKeybindingsAtom } from "../state/server"; | 20 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 17 | import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; | 21 | import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; |
| ... | @@ -88,6 +92,15 @@ function SidebarControl() { | ... | @@ -88,6 +92,15 @@ function SidebarControl() { |
| 88 | ) { | 92 | ) { |
| 89 | return; | 93 | return; |
| 90 | } | 94 | } |
| 95 | if ( | ||
| 96 | isRichTextBoldShortcut(event) && | ||
| 97 | event.target instanceof HTMLElement && | ||
| 98 | event.target.closest('[data-composer-rich-text="true"]') | ||
| 99 | ) { | ||
| 100 | // The rich-text composer claims Mod+B for bold; the toggle stays | ||
| 101 | // available everywhere else, including the plain-text composer. | ||
| 102 | return; | ||
| 103 | } | ||
| 91 | if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; | 104 | if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; |
| 92 | 105 | ||
| 93 | event.preventDefault(); | 106 | event.preventDefault(); |
apps/web/src/components/ComposerCitationNode.tsx deleted-223| ... | @@ -1,223 +0,0 @@ | ||
| 1 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; | ||
| 2 | import type { AssistantCitation } from "@t3tools/contracts"; | ||
| 3 | import { | ||
| 4 | serializeAssistantCitation, | ||
| 5 | withAssistantCitationComment, | ||
| 6 | } from "@t3tools/shared/assistantCitations"; | ||
| 7 | import { | ||
| 8 | $applyNodeReplacement, | ||
| 9 | $getNodeByKey, | ||
| 10 | $getRoot, | ||
| 11 | $isElementNode, | ||
| 12 | DecoratorNode, | ||
| 13 | HISTORY_PUSH_TAG, | ||
| 14 | SKIP_DOM_SELECTION_TAG, | ||
| 15 | type NodeKey, | ||
| 16 | type SerializedLexicalNode, | ||
| 17 | type Spread, | ||
| 18 | } from "lexical"; | ||
| 19 | import { createContext, use, type ReactElement } from "react"; | ||
| 20 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | ||
| 21 | |||
| 22 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; | ||
| 23 | import { COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME } from "./composerInlineChip"; | ||
| 24 | |||
| 25 | type SerializedComposerCitationNode = Spread< | ||
| 26 | { | ||
| 27 | citation: AssistantCitation; | ||
| 28 | source: string; | ||
| 29 | type: "composer-citation"; | ||
| 30 | version: 1; | ||
| 31 | }, | ||
| 32 | SerializedLexicalNode | ||
| 33 | >; | ||
| 34 | |||
| 35 | export type ComposerCitationCommentRequest = { | ||
| 36 | previousValue: string; | ||
| 37 | value: string; | ||
| 38 | citationStart: number; | ||
| 39 | sourceAnchor: AssistantCitationSourceAnchor; | ||
| 40 | }; | ||
| 41 | |||
| 42 | export type ComposerCitationCommentTarget = { | ||
| 43 | nodeKey: NodeKey; | ||
| 44 | sourceAnchor?: AssistantCitationSourceAnchor; | ||
| 45 | removeOnCancel?: boolean; | ||
| 46 | }; | ||
| 47 | |||
| 48 | export const ComposerCitationCommentContext = createContext<{ | ||
| 49 | openComment: ComposerCitationCommentTarget | null; | ||
| 50 | onOpenChange: (nodeKey: NodeKey, open: boolean) => void; | ||
| 51 | onSubmitAndSend: () => void; | ||
| 52 | }>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); | ||
| 53 | |||
| 54 | /** Consume a cite action once its controlled prompt has been committed to the editor. */ | ||
| 55 | export function $consumeComposerCitationCommentRequest(requestRef: { | ||
| 56 | current: ComposerCitationCommentRequest | null; | ||
| 57 | }): ComposerCitationCommentTarget | null { | ||
| 58 | const request = requestRef.current; | ||
| 59 | if (!request) return null; | ||
| 60 | const root = $getRoot(); | ||
| 61 | const value = root.getTextContent(); | ||
| 62 | if (value === request.previousValue) return null; | ||
| 63 | requestRef.current = null; | ||
| 64 | if (value !== request.value) return null; | ||
| 65 | |||
| 66 | // Controlled prompts use one paragraph with inline nodes and explicit line breaks. | ||
| 67 | const paragraph = root.getFirstChild(); | ||
| 68 | if (!$isElementNode(paragraph)) return null; | ||
| 69 | let offset = 0; | ||
| 70 | for (const node of paragraph.getChildren()) { | ||
| 71 | if (offset === request.citationStart && node instanceof ComposerCitationNode) { | ||
| 72 | return { | ||
| 73 | nodeKey: node.getKey(), | ||
| 74 | sourceAnchor: request.sourceAnchor, | ||
| 75 | removeOnCancel: true, | ||
| 76 | }; | ||
| 77 | } | ||
| 78 | offset += node.getTextContentSize(); | ||
| 79 | } | ||
| 80 | return null; | ||
| 81 | } | ||
| 82 | |||
| 83 | function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey: NodeKey }) { | ||
| 84 | const [editor] = useLexicalComposerContext(); | ||
| 85 | const commentContext = use(ComposerCitationCommentContext); | ||
| 86 | const commentTarget = | ||
| 87 | commentContext.openComment?.nodeKey === props.nodeKey ? commentContext.openComment : null; | ||
| 88 | const onSaveComment = (comment: string): boolean => { | ||
| 89 | if (!editor.isEditable()) return false; | ||
| 90 | let accepted = false; | ||
| 91 | editor.update( | ||
| 92 | () => { | ||
| 93 | const node = $getNodeByKey(props.nodeKey); | ||
| 94 | if (node instanceof ComposerCitationNode && node.isAttached()) { | ||
| 95 | node.setComment(comment); | ||
| 96 | accepted = true; | ||
| 97 | } | ||
| 98 | }, | ||
| 99 | { discrete: true, tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] }, | ||
| 100 | ); | ||
| 101 | return accepted; | ||
| 102 | }; | ||
| 103 | /** Cancelling a comment on a just-created citation removes the chip the cite action added. */ | ||
| 104 | const onRemove = () => { | ||
| 105 | if (!editor.isEditable()) return; | ||
| 106 | editor.update( | ||
| 107 | () => { | ||
| 108 | const node = $getNodeByKey(props.nodeKey); | ||
| 109 | if (node instanceof ComposerCitationNode) { | ||
| 110 | node.selectPrevious(); | ||
| 111 | node.remove(); | ||
| 112 | } | ||
| 113 | }, | ||
| 114 | { tag: HISTORY_PUSH_TAG }, | ||
| 115 | ); | ||
| 116 | editor.getRootElement()?.focus({ preventScroll: true }); | ||
| 117 | }; | ||
| 118 | return ( | ||
| 119 | <span | ||
| 120 | className="inline-flex min-w-0 max-w-full" | ||
| 121 | contentEditable={false} | ||
| 122 | spellCheck={false} | ||
| 123 | data-composer-citation-chip="true" | ||
| 124 | > | ||
| 125 | <AssistantCitationChip | ||
| 126 | citation={props.citation} | ||
| 127 | composer | ||
| 128 | commentEditor={{ | ||
| 129 | open: commentTarget !== null, | ||
| 130 | sourceAnchor: commentTarget?.sourceAnchor, | ||
| 131 | onOpenChange: (open) => { | ||
| 132 | if (open && !editor.isEditable()) return; | ||
| 133 | commentContext.onOpenChange(props.nodeKey, open); | ||
| 134 | }, | ||
| 135 | ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), | ||
| 136 | onSave: onSaveComment, | ||
| 137 | onSaveAndSend: (comment) => { | ||
| 138 | if (!onSaveComment(comment)) return false; | ||
| 139 | commentContext.onSubmitAndSend(); | ||
| 140 | return true; | ||
| 141 | }, | ||
| 142 | }} | ||
| 143 | /> | ||
| 144 | </span> | ||
| 145 | ); | ||
| 146 | } | ||
| 147 | |||
| 148 | export class ComposerCitationNode extends DecoratorNode<ReactElement> { | ||
| 149 | __citation: AssistantCitation; | ||
| 150 | __source: string; | ||
| 151 | |||
| 152 | static override getType(): string { | ||
| 153 | return "composer-citation"; | ||
| 154 | } | ||
| 155 | |||
| 156 | static override clone(node: ComposerCitationNode): ComposerCitationNode { | ||
| 157 | return new ComposerCitationNode(node.__citation, node.__source, node.__key); | ||
| 158 | } | ||
| 159 | |||
| 160 | static override importJSON(serializedNode: SerializedComposerCitationNode): ComposerCitationNode { | ||
| 161 | return $createComposerCitationNode( | ||
| 162 | serializedNode.citation, | ||
| 163 | serializedNode.source, | ||
| 164 | ).updateFromJSON(serializedNode); | ||
| 165 | } | ||
| 166 | |||
| 167 | constructor(citation: AssistantCitation, source: string, key?: NodeKey) { | ||
| 168 | super(key); | ||
| 169 | this.__citation = citation; | ||
| 170 | this.__source = source; | ||
| 171 | } | ||
| 172 | |||
| 173 | override exportJSON(): SerializedComposerCitationNode { | ||
| 174 | const node = this.getLatest(); | ||
| 175 | return { | ||
| 176 | ...super.exportJSON(), | ||
| 177 | citation: node.__citation, | ||
| 178 | source: node.__source, | ||
| 179 | type: "composer-citation", | ||
| 180 | version: 1, | ||
| 181 | }; | ||
| 182 | } | ||
| 183 | |||
| 184 | override createDOM(): HTMLElement { | ||
| 185 | const dom = document.createElement("span"); | ||
| 186 | dom.className = `${COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME} max-w-full`; | ||
| 187 | return dom; | ||
| 188 | } | ||
| 189 | |||
| 190 | override updateDOM(): false { | ||
| 191 | return false; | ||
| 192 | } | ||
| 193 | |||
| 194 | override getTextContent(): string { | ||
| 195 | return this.getLatest().__source; | ||
| 196 | } | ||
| 197 | |||
| 198 | setComment(comment: string): this { | ||
| 199 | const latest = this.getLatest(); | ||
| 200 | const citation = withAssistantCitationComment(latest.__citation, comment); | ||
| 201 | if (citation.comment === latest.__citation.comment) return latest; | ||
| 202 | const source = serializeAssistantCitation(citation); | ||
| 203 | const writable = this.getWritable(); | ||
| 204 | writable.__citation = citation; | ||
| 205 | writable.__source = source; | ||
| 206 | return writable; | ||
| 207 | } | ||
| 208 | |||
| 209 | override isInline(): true { | ||
| 210 | return true; | ||
| 211 | } | ||
| 212 | |||
| 213 | override decorate(): ReactElement { | ||
| 214 | return <ComposerCitationDecorator citation={this.__citation} nodeKey={this.__key} />; | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 218 | export function $createComposerCitationNode( | ||
| 219 | citation: AssistantCitation, | ||
| 220 | source: string, | ||
| 221 | ): ComposerCitationNode { | ||
| 222 | return $applyNodeReplacement(new ComposerCitationNode(citation, source)); | ||
| 223 | } | ||
apps/web/src/components/ComposerContextReferenceNode.test.ts deleted-92| ... | @@ -1,92 +0,0 @@ | ||
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | import { $createParagraphNode, $getRoot, $isElementNode, createEditor } from "lexical"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | $createComposerContextReferenceNode, | ||
| 6 | ComposerContextReferenceNode, | ||
| 7 | } from "./ComposerContextReferenceNode"; | ||
| 8 | import { splitPromptIntoComposerSegments } from "../composer-editor-mentions"; | ||
| 9 | |||
| 10 | vi.mock("./composerContextPresentation", () => ({ | ||
| 11 | ComposerContextReferenceChip: () => null, | ||
| 12 | })); | ||
| 13 | |||
| 14 | const reference = { kind: "terminal", contextId: "ctx-1", label: "Terminal 1 lines 3-4" }; | ||
| 15 | const link = "[Terminal 1 lines 3-4](t3-context://v1/terminal/ctx-1)"; | ||
| 16 | |||
| 17 | function createReferenceEditor() { | ||
| 18 | const editor = createEditor({ nodes: [ComposerContextReferenceNode] }); | ||
| 19 | editor.update( | ||
| 20 | () => { | ||
| 21 | $getRoot().append($createParagraphNode()); | ||
| 22 | }, | ||
| 23 | { discrete: true }, | ||
| 24 | ); | ||
| 25 | return editor; | ||
| 26 | } | ||
| 27 | |||
| 28 | function $referenceNodes() { | ||
| 29 | const paragraph = $getRoot().getFirstChildOrThrow(); | ||
| 30 | if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph"); | ||
| 31 | return paragraph.getChildren().filter((node) => node instanceof ComposerContextReferenceNode); | ||
| 32 | } | ||
| 33 | |||
| 34 | describe("ComposerContextReferenceNode", () => { | ||
| 35 | it("renders its canonical link as text so the prompt carries identity", () => { | ||
| 36 | const editor = createReferenceEditor(); | ||
| 37 | editor.update( | ||
| 38 | () => { | ||
| 39 | const paragraph = $getRoot().getFirstChild(); | ||
| 40 | if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph"); | ||
| 41 | paragraph.append($createComposerContextReferenceNode(reference, "ref-1")); | ||
| 42 | }, | ||
| 43 | { discrete: true }, | ||
| 44 | ); | ||
| 45 | const text = editor.getEditorState().read(() => $getRoot().getTextContent()); | ||
| 46 | expect(text).toBe(link); | ||
| 47 | expect(splitPromptIntoComposerSegments(text)).toEqual([ | ||
| 48 | { type: "context-reference", ...reference, source: link }, | ||
| 49 | ]); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it("round-trips through JSON keeping the occurrence id", () => { | ||
| 53 | const editor = createReferenceEditor(); | ||
| 54 | editor.update( | ||
| 55 | () => { | ||
| 56 | const paragraph = $getRoot().getFirstChild(); | ||
| 57 | if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph"); | ||
| 58 | paragraph.append($createComposerContextReferenceNode(reference, "ref-1")); | ||
| 59 | }, | ||
| 60 | { discrete: true }, | ||
| 61 | ); | ||
| 62 | const serialized = editor.getEditorState().toJSON(); | ||
| 63 | const restored = createReferenceEditor(); | ||
| 64 | restored.setEditorState(restored.parseEditorState(serialized)); | ||
| 65 | const nodes = restored.getEditorState().read(() => | ||
| 66 | $referenceNodes().map((node) => ({ | ||
| 67 | referenceId: node.__referenceId, | ||
| 68 | reference: node.getReference(), | ||
| 69 | })), | ||
| 70 | ); | ||
| 71 | expect(nodes).toEqual([{ referenceId: "ref-1", reference }]); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("mints a distinct occurrence id per created node while sharing the payload id", () => { | ||
| 75 | const editor = createReferenceEditor(); | ||
| 76 | editor.update( | ||
| 77 | () => { | ||
| 78 | const paragraph = $getRoot().getFirstChild(); | ||
| 79 | if (!$isElementNode(paragraph)) throw new Error("Expected a paragraph"); | ||
| 80 | paragraph.append( | ||
| 81 | $createComposerContextReferenceNode(reference), | ||
| 82 | $createComposerContextReferenceNode(reference), | ||
| 83 | ); | ||
| 84 | }, | ||
| 85 | { discrete: true }, | ||
| 86 | ); | ||
| 87 | const nodes = editor.getEditorState().read(() => $referenceNodes()); | ||
| 88 | expect(nodes[0]!.__contextId).toBe(nodes[1]!.__contextId); | ||
| 89 | expect(nodes[0]!.__referenceId).not.toBe(nodes[1]!.__referenceId); | ||
| 90 | expect(nodes[0]!.__referenceId).toMatch(/^[0-9a-f-]{36}$/); | ||
| 91 | }); | ||
| 92 | }); | ||
apps/web/src/components/ComposerContextReferenceNode.tsx deleted-133| ... | @@ -1,133 +0,0 @@ | ||
| 1 | import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; | ||
| 2 | import type { ComposerContextId } from "@t3tools/contracts"; | ||
| 3 | import { | ||
| 4 | $applyNodeReplacement, | ||
| 5 | DecoratorNode, | ||
| 6 | type NodeKey, | ||
| 7 | type SerializedLexicalNode, | ||
| 8 | type Spread, | ||
| 9 | } from "lexical"; | ||
| 10 | import type { ReactElement } from "react"; | ||
| 11 | |||
| 12 | import { randomUUID } from "~/lib/utils"; | ||
| 13 | import { COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME } from "./composerInlineChip"; | ||
| 14 | import { ComposerContextReferenceChip } from "./composerContextPresentation"; | ||
| 15 | |||
| 16 | export interface ComposerContextReference { | ||
| 17 | kind: string; | ||
| 18 | contextId: string; | ||
| 19 | label: string; | ||
| 20 | } | ||
| 21 | |||
| 22 | export type SerializedComposerContextReferenceNode = Spread< | ||
| 23 | ComposerContextReference & { | ||
| 24 | referenceId: string; | ||
| 25 | type: "composer-context-reference"; | ||
| 26 | version: 1; | ||
| 27 | }, | ||
| 28 | SerializedLexicalNode | ||
| 29 | >; | ||
| 30 | |||
| 31 | /** | ||
| 32 | * One inline occurrence of a context payload. The node's text is the canonical link, so the | ||
| 33 | * prompt string carries kind and id and rebuilding the editor from it restores the same chip. | ||
| 34 | * `referenceId` identifies this occurrence; duplicating the node mints a new one. | ||
| 35 | */ | ||
| 36 | export class ComposerContextReferenceNode extends DecoratorNode<ReactElement> { | ||
| 37 | __kind: string; | ||
| 38 | __contextId: string; | ||
| 39 | __label: string; | ||
| 40 | __referenceId: string; | ||
| 41 | |||
| 42 | static override getType(): "composer-context-reference" { | ||
| 43 | return "composer-context-reference"; | ||
| 44 | } | ||
| 45 | |||
| 46 | static override clone(node: ComposerContextReferenceNode): ComposerContextReferenceNode { | ||
| 47 | return new ComposerContextReferenceNode( | ||
| 48 | { kind: node.__kind, contextId: node.__contextId, label: node.__label }, | ||
| 49 | node.__referenceId, | ||
| 50 | node.__key, | ||
| 51 | ); | ||
| 52 | } | ||
| 53 | |||
| 54 | static override importJSON( | ||
| 55 | serializedNode: SerializedComposerContextReferenceNode, | ||
| 56 | ): ComposerContextReferenceNode { | ||
| 57 | return $createComposerContextReferenceNode( | ||
| 58 | { | ||
| 59 | kind: serializedNode.kind, | ||
| 60 | contextId: serializedNode.contextId, | ||
| 61 | label: serializedNode.label, | ||
| 62 | }, | ||
| 63 | serializedNode.referenceId, | ||
| 64 | ).updateFromJSON(serializedNode); | ||
| 65 | } | ||
| 66 | |||
| 67 | constructor(reference: ComposerContextReference, referenceId: string, key?: NodeKey) { | ||
| 68 | super(key); | ||
| 69 | this.__kind = reference.kind; | ||
| 70 | this.__contextId = reference.contextId; | ||
| 71 | this.__label = reference.label; | ||
| 72 | this.__referenceId = referenceId; | ||
| 73 | } | ||
| 74 | |||
| 75 | override exportJSON(): SerializedComposerContextReferenceNode { | ||
| 76 | const latest = this.getLatest(); | ||
| 77 | return { | ||
| 78 | ...super.exportJSON(), | ||
| 79 | kind: latest.__kind, | ||
| 80 | contextId: latest.__contextId, | ||
| 81 | label: latest.__label, | ||
| 82 | referenceId: latest.__referenceId, | ||
| 83 | type: "composer-context-reference", | ||
| 84 | version: 1, | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | |||
| 88 | override createDOM(): HTMLElement { | ||
| 89 | const dom = document.createElement("span"); | ||
| 90 | dom.className = `${COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME} max-w-full`; | ||
| 91 | return dom; | ||
| 92 | } | ||
| 93 | |||
| 94 | override updateDOM(): false { | ||
| 95 | return false; | ||
| 96 | } | ||
| 97 | |||
| 98 | override getTextContent(): string { | ||
| 99 | const latest = this.getLatest(); | ||
| 100 | return formatComposerContextReference({ | ||
| 101 | kind: latest.__kind, | ||
| 102 | contextId: latest.__contextId as ComposerContextId, | ||
| 103 | label: latest.__label, | ||
| 104 | }); | ||
| 105 | } | ||
| 106 | |||
| 107 | override isInline(): true { | ||
| 108 | return true; | ||
| 109 | } | ||
| 110 | |||
| 111 | getReference(): ComposerContextReference { | ||
| 112 | const latest = this.getLatest(); | ||
| 113 | return { kind: latest.__kind, contextId: latest.__contextId, label: latest.__label }; | ||
| 114 | } | ||
| 115 | |||
| 116 | override decorate(): ReactElement { | ||
| 117 | const latest = this.getLatest(); | ||
| 118 | return ( | ||
| 119 | <ComposerContextReferenceChip | ||
| 120 | kind={latest.__kind} | ||
| 121 | contextId={latest.__contextId} | ||
| 122 | label={latest.__label} | ||
| 123 | /> | ||
| 124 | ); | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | export function $createComposerContextReferenceNode( | ||
| 129 | reference: ComposerContextReference, | ||
| 130 | referenceId: string = randomUUID(), | ||
| 131 | ): ComposerContextReferenceNode { | ||
| 132 | return $applyNodeReplacement(new ComposerContextReferenceNode(reference, referenceId)); | ||
| 133 | } | ||
apps/web/src/components/ComposerPromptEditor.serialization.test.tsx deleted-183| ... | @@ -1,183 +0,0 @@ | ||
| 1 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; | ||
| 2 | import { $copyNode, $getRoot, $isElementNode, PASTE_COMMAND, type LexicalEditor } from "lexical"; | ||
| 3 | import { act, createRef } from "react"; | ||
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { collapseExpandedComposerCursor } from "../composer-logic"; | ||
| 8 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | ||
| 9 | |||
| 10 | vi.mock("./chat/FileTagChip", () => ({ | ||
| 11 | FILE_TAG_CHIP_CLASS_NAME: "", | ||
| 12 | FileTagChipContent: () => null, | ||
| 13 | })); | ||
| 14 | vi.mock("./chat/ComposerPendingTerminalContexts", () => ({ | ||
| 15 | ComposerPendingTerminalContextChip: () => null, | ||
| 16 | })); | ||
| 17 | vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null })); | ||
| 18 | |||
| 19 | let lexicalEditor: LexicalEditor; | ||
| 20 | // Keep the real composer, registered nodes, updates, and snapshot API. Only the | ||
| 21 | // DOM view is omitted so Lexical runs headlessly in this component test. | ||
| 22 | vi.mock("@lexical/react/LexicalPlainTextPlugin", () => ({ | ||
| 23 | PlainTextPlugin: function HeadlessEditor() { | ||
| 24 | [lexicalEditor] = useLexicalComposerContext(); | ||
| 25 | return null; | ||
| 26 | }, | ||
| 27 | })); | ||
| 28 | |||
| 29 | let renderer: ReactTestRenderer | undefined; | ||
| 30 | const editorRef = createRef<ComposerPromptEditorHandle>(); | ||
| 31 | |||
| 32 | function composer(value: string) { | ||
| 33 | return ( | ||
| 34 | <ComposerPromptEditor | ||
| 35 | value={value} | ||
| 36 | cursor={collapseExpandedComposerCursor(value, value.length)} | ||
| 37 | contextRecords={new Map()} | ||
| 38 | skills={[]} | ||
| 39 | disabled={false} | ||
| 40 | placeholder="Write a prompt" | ||
| 41 | onChange={() => {}} | ||
| 42 | onPaste={() => {}} | ||
| 43 | editorRef={editorRef} | ||
| 44 | /> | ||
| 45 | ); | ||
| 46 | } | ||
| 47 | |||
| 48 | async function renderPrompt(value: string) { | ||
| 49 | await act(() => { | ||
| 50 | if (renderer) renderer.update(composer(value)); | ||
| 51 | else renderer = create(composer(value)); | ||
| 52 | }); | ||
| 53 | } | ||
| 54 | |||
| 55 | function $firstMention() { | ||
| 56 | const paragraph = $getRoot().getFirstChildOrThrow(); | ||
| 57 | if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); | ||
| 58 | const mention = paragraph.getFirstChildOrThrow(); | ||
| 59 | if (mention.getType() !== "composer-mention") throw new Error("Expected a mention"); | ||
| 60 | return mention; | ||
| 61 | } | ||
| 62 | |||
| 63 | class TestClipboardEvent extends Event { | ||
| 64 | readonly clipboardData: DataTransfer; | ||
| 65 | |||
| 66 | constructor(text: string) { | ||
| 67 | super("paste", { cancelable: true }); | ||
| 68 | this.clipboardData = { | ||
| 69 | files: [], | ||
| 70 | getData: (type: string) => (type === "text/plain" ? text : ""), | ||
| 71 | } as unknown as DataTransfer; | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | beforeEach(() => { | ||
| 76 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 77 | vi.stubGlobal("document", { activeElement: null }); | ||
| 78 | }); | ||
| 79 | |||
| 80 | afterEach(async () => { | ||
| 81 | await act(() => renderer?.unmount()); | ||
| 82 | renderer = undefined; | ||
| 83 | vi.unstubAllGlobals(); | ||
| 84 | }); | ||
| 85 | |||
| 86 | describe("composer mention serialization", () => { | ||
| 87 | it.each([ | ||
| 88 | "@README.md control", | ||
| 89 | "@terminal-1:3 Explain this output\n\n<terminal_context>\n- Terminal 1 line 3:\n 3 | output\n</terminal_context>", | ||
| 90 | '@"docs/My \\"File\\".md" please', | ||
| 91 | '@"docs/雪 👋.md" please', | ||
| 92 | "[README.md](README.md) control", | ||
| 93 | "[config#draft?.json](config%23draft%3f.json) control", | ||
| 94 | "Plain text\n Keep indentation 👋", | ||
| 95 | ])("preserves the initial prompt %s", async (prompt) => { | ||
| 96 | await renderPrompt(prompt); | ||
| 97 | expect(editorRef.current?.readSnapshot().value).toBe(prompt); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("preserves original source when replacing the controlled prompt", async () => { | ||
| 101 | for (const prompt of ["", "@README.md control", "Older plain control", "@README.md control"]) { | ||
| 102 | await renderPrompt(prompt); | ||
| 103 | expect(editorRef.current?.readSnapshot()).toMatchObject({ | ||
| 104 | value: prompt, | ||
| 105 | expandedCursor: prompt.length, | ||
| 106 | }); | ||
| 107 | if (prompt === "@README.md control") { | ||
| 108 | expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); | ||
| 109 | } | ||
| 110 | } | ||
| 111 | }); | ||
| 112 | |||
| 113 | it("preserves source when Lexical clones the mention and reloads exported state", async () => { | ||
| 114 | const prompt = '@"docs/雪 👋.md" remains a chip'; | ||
| 115 | await renderPrompt(prompt); | ||
| 116 | const originalKey = lexicalEditor.getEditorState().read(() => $firstMention().getKey()); | ||
| 117 | |||
| 118 | await act(() => { | ||
| 119 | lexicalEditor.update( | ||
| 120 | () => { | ||
| 121 | const mention = $firstMention(); | ||
| 122 | mention.replace($copyNode(mention)); | ||
| 123 | }, | ||
| 124 | { discrete: true }, | ||
| 125 | ); | ||
| 126 | }); | ||
| 127 | expect(lexicalEditor.getEditorState().read(() => $firstMention().getKey())).not.toBe( | ||
| 128 | originalKey, | ||
| 129 | ); | ||
| 130 | expect(editorRef.current?.readSnapshot().value).toBe(prompt); | ||
| 131 | const exportedState = lexicalEditor.getEditorState().toJSON(); | ||
| 132 | |||
| 133 | await renderPrompt(""); | ||
| 134 | await act(() => { | ||
| 135 | lexicalEditor.setEditorState(lexicalEditor.parseEditorState(exportedState)); | ||
| 136 | }); | ||
| 137 | expect(editorRef.current?.readSnapshot().value).toBe(prompt); | ||
| 138 | expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); | ||
| 139 | }); | ||
| 140 | |||
| 141 | it("keeps canonical serialization when importing legacy mention JSON without source", async () => { | ||
| 142 | await renderPrompt(""); | ||
| 143 | await act(() => { | ||
| 144 | lexicalEditor.setEditorState( | ||
| 145 | lexicalEditor.parseEditorState( | ||
| 146 | JSON.stringify({ | ||
| 147 | root: { | ||
| 148 | type: "root", | ||
| 149 | version: 1, | ||
| 150 | children: [ | ||
| 151 | { | ||
| 152 | type: "paragraph", | ||
| 153 | version: 1, | ||
| 154 | children: [{ type: "composer-mention", version: 1, path: "README.md" }], | ||
| 155 | }, | ||
| 156 | ], | ||
| 157 | }, | ||
| 158 | }), | ||
| 159 | ), | ||
| 160 | ); | ||
| 161 | }); | ||
| 162 | expect(editorRef.current?.readSnapshot().value).toBe("[README.md](README.md)"); | ||
| 163 | expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); | ||
| 164 | }); | ||
| 165 | |||
| 166 | it("still serializes a newly inserted mention canonically", async () => { | ||
| 167 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 168 | await renderPrompt(""); | ||
| 169 | const event = new TestClipboardEvent("@README.md "); | ||
| 170 | await act(() => { | ||
| 171 | lexicalEditor.update( | ||
| 172 | () => { | ||
| 173 | $getRoot().selectEnd(); | ||
| 174 | lexicalEditor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | ||
| 175 | }, | ||
| 176 | { discrete: true }, | ||
| 177 | ); | ||
| 178 | }); | ||
| 179 | expect(event.defaultPrevented).toBe(true); | ||
| 180 | expect(editorRef.current?.readSnapshot().value).toBe("[README.md](README.md) "); | ||
| 181 | expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); | ||
| 182 | }); | ||
| 183 | }); | ||
apps/web/src/components/ComposerPromptEditor.test.ts deleted-864| ... | @@ -1,864 +0,0 @@ | ||
| 1 | import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; | ||
| 2 | import { elementContextToPreviewAnnotation } from "../lib/elementContext"; | ||
| 3 | import { previewAnnotationContextRecord } from "../lib/composerContextRecords"; | ||
| 4 | import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; | ||
| 5 | import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; | ||
| 6 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 7 | import { | ||
| 8 | $createParagraphNode, | ||
| 9 | $createTextNode, | ||
| 10 | $getRoot, | ||
| 11 | $getSelection, | ||
| 12 | $isElementNode, | ||
| 13 | $isRangeSelection, | ||
| 14 | COMMAND_PRIORITY_EDITOR, | ||
| 15 | createEditor, | ||
| 16 | PASTE_COMMAND, | ||
| 17 | } from "lexical"; | ||
| 18 | |||
| 19 | import { | ||
| 20 | importPastedComposerText, | ||
| 21 | readPastedComposerContext, | ||
| 22 | registerComposerInlineTokenPaste, | ||
| 23 | } from "./composerInlineTokenPaste"; | ||
| 24 | import { | ||
| 25 | $consumeComposerCitationCommentRequest, | ||
| 26 | $createComposerCitationNode, | ||
| 27 | ComposerCitationNode, | ||
| 28 | type ComposerCitationCommentRequest, | ||
| 29 | } from "./ComposerCitationNode"; | ||
| 30 | import { splitPromptIntoComposerSegments } from "../composer-editor-mentions"; | ||
| 31 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | ||
| 32 | |||
| 33 | vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null })); | ||
| 34 | |||
| 35 | const citation: AssistantCitation = { | ||
| 36 | version: 1, | ||
| 37 | environmentId: EnvironmentId.make("remote/雪"), | ||
| 38 | threadId: ThreadId.make("thread-1"), | ||
| 39 | messageId: MessageId.make("message-1"), | ||
| 40 | text: 'Keep "👋", (punctuation) and @AGENTS.md.', | ||
| 41 | start: 5, | ||
| 42 | end: 43, | ||
| 43 | prefix: "前: ", | ||
| 44 | suffix: " 後", | ||
| 45 | }; | ||
| 46 | const citationSource = serializeAssistantCitation(citation).replaceAll("+", "%20"); | ||
| 47 | |||
| 48 | function createCitationEditor(text = "") { | ||
| 49 | const editor = createEditor({ nodes: [ComposerCitationNode] }); | ||
| 50 | editor.update( | ||
| 51 | () => { | ||
| 52 | const paragraph = $createParagraphNode(); | ||
| 53 | $getRoot().append(paragraph); | ||
| 54 | if (text) paragraph.append($createTextNode(text)); | ||
| 55 | paragraph.selectEnd(); | ||
| 56 | }, | ||
| 57 | { discrete: true }, | ||
| 58 | ); | ||
| 59 | registerComposerInlineTokenPaste(editor, { | ||
| 60 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | ||
| 61 | createCitationNode: $createComposerCitationNode, | ||
| 62 | createContextReferenceNode: (reference) => $createTextNode(`<context:${reference.contextId}>`), | ||
| 63 | getExpandedAbsoluteOffsetForPoint: (_node, offset) => offset, | ||
| 64 | }); | ||
| 65 | return editor; | ||
| 66 | } | ||
| 67 | |||
| 68 | function $citationNodes() { | ||
| 69 | const paragraph = $getRoot().getFirstChildOrThrow(); | ||
| 70 | if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); | ||
| 71 | return paragraph.getChildren().filter((node) => node instanceof ComposerCitationNode); | ||
| 72 | } | ||
| 73 | |||
| 74 | function pasteText(editor: ReturnType<typeof createEditor>, text: string) { | ||
| 75 | const event = new TestClipboardEvent(text); | ||
| 76 | editor.update( | ||
| 77 | () => { | ||
| 78 | editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | ||
| 79 | }, | ||
| 80 | { discrete: true }, | ||
| 81 | ); | ||
| 82 | return event; | ||
| 83 | } | ||
| 84 | |||
| 85 | class TestClipboardEvent extends Event { | ||
| 86 | readonly clipboardData: DataTransfer; | ||
| 87 | |||
| 88 | constructor(text: string, extra: Record<string, string> = {}) { | ||
| 89 | super("paste", { cancelable: true }); | ||
| 90 | this.clipboardData = { | ||
| 91 | files: [], | ||
| 92 | getData: (type: string) => (type === "text/plain" ? text : (extra[type] ?? "")), | ||
| 93 | } as unknown as DataTransfer; | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | describe("registerComposerInlineTokenPaste", () => { | ||
| 98 | afterEach(() => { | ||
| 99 | vi.unstubAllGlobals(); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("handles a copied mention without also running the plain-text paste fallback", () => { | ||
| 103 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 104 | const editor = createEditor(); | ||
| 105 | const mention = "[improve-deploy-error-logging.md](.changeset/improve-deploy-error-logging.md)"; | ||
| 106 | const plainTextFallback = vi.fn(() => { | ||
| 107 | const selection = $getSelection(); | ||
| 108 | if (!$isRangeSelection(selection)) return false; | ||
| 109 | selection.insertText(mention); | ||
| 110 | return true; | ||
| 111 | }); | ||
| 112 | |||
| 113 | editor.update( | ||
| 114 | () => { | ||
| 115 | const paragraph = $createParagraphNode(); | ||
| 116 | $getRoot().append(paragraph); | ||
| 117 | paragraph.selectEnd(); | ||
| 118 | }, | ||
| 119 | { discrete: true }, | ||
| 120 | ); | ||
| 121 | registerComposerInlineTokenPaste(editor, { | ||
| 122 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | ||
| 123 | createCitationNode: $createComposerCitationNode, | ||
| 124 | createContextReferenceNode: (reference) => | ||
| 125 | $createTextNode(`<context:${reference.contextId}>`), | ||
| 126 | getExpandedAbsoluteOffsetForPoint: () => 0, | ||
| 127 | }); | ||
| 128 | editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); | ||
| 129 | |||
| 130 | const event = new TestClipboardEvent(mention); | ||
| 131 | let handled = false; | ||
| 132 | editor.update( | ||
| 133 | () => { | ||
| 134 | handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | ||
| 135 | }, | ||
| 136 | { discrete: true }, | ||
| 137 | ); | ||
| 138 | |||
| 139 | expect(handled).toBe(true); | ||
| 140 | expect(plainTextFallback).not.toHaveBeenCalled(); | ||
| 141 | expect(event.defaultPrevented).toBe(true); | ||
| 142 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | ||
| 143 | "<mention:.changeset/improve-deploy-error-logging.md> ", | ||
| 144 | ); | ||
| 145 | }); | ||
| 146 | |||
| 147 | it.each([ | ||
| 148 | "yarn expo install @expo/ui", | ||
| 149 | "npm install @jane/foo.js", | ||
| 150 | "import '@scope/pkg/sub/path'", | ||
| 151 | ])("leaves scoped package command %s to the plain-text paste fallback", (command) => { | ||
| 152 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 153 | const editor = createEditor(); | ||
| 154 | const plainTextFallback = vi.fn((event: ClipboardEvent) => { | ||
| 155 | const selection = $getSelection(); | ||
| 156 | if (!$isRangeSelection(selection)) return false; | ||
| 157 | selection.insertText(event.clipboardData?.getData("text/plain") ?? ""); | ||
| 158 | return true; | ||
| 159 | }); | ||
| 160 | |||
| 161 | editor.update( | ||
| 162 | () => { | ||
| 163 | const paragraph = $createParagraphNode(); | ||
| 164 | $getRoot().append(paragraph); | ||
| 165 | paragraph.selectEnd(); | ||
| 166 | }, | ||
| 167 | { discrete: true }, | ||
| 168 | ); | ||
| 169 | registerComposerInlineTokenPaste(editor, { | ||
| 170 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | ||
| 171 | createCitationNode: $createComposerCitationNode, | ||
| 172 | createContextReferenceNode: (reference) => | ||
| 173 | $createTextNode(`<context:${reference.contextId}>`), | ||
| 174 | getExpandedAbsoluteOffsetForPoint: () => 0, | ||
| 175 | }); | ||
| 176 | editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); | ||
| 177 | |||
| 178 | const event = new TestClipboardEvent(command); | ||
| 179 | let handled = false; | ||
| 180 | editor.update( | ||
| 181 | () => { | ||
| 182 | handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | ||
| 183 | }, | ||
| 184 | { discrete: true }, | ||
| 185 | ); | ||
| 186 | |||
| 187 | expect(handled).toBe(true); | ||
| 188 | expect(plainTextFallback).toHaveBeenCalledOnce(); | ||
| 189 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(command); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it("pastes a canonical scoped folder link as a mention", () => { | ||
| 193 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 194 | const editor = createEditor(); | ||
| 195 | const mention = "[sub](@scope/pkg/sub)"; | ||
| 196 | const plainTextFallback = vi.fn(() => true); | ||
| 197 | |||
| 198 | editor.update( | ||
| 199 | () => { | ||
| 200 | const paragraph = $createParagraphNode(); | ||
| 201 | $getRoot().append(paragraph); | ||
| 202 | paragraph.selectEnd(); | ||
| 203 | }, | ||
| 204 | { discrete: true }, | ||
| 205 | ); | ||
| 206 | registerComposerInlineTokenPaste(editor, { | ||
| 207 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | ||
| 208 | createCitationNode: $createComposerCitationNode, | ||
| 209 | createContextReferenceNode: (reference) => | ||
| 210 | $createTextNode(`<context:${reference.contextId}>`), | ||
| 211 | getExpandedAbsoluteOffsetForPoint: () => 0, | ||
| 212 | }); | ||
| 213 | editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); | ||
| 214 | |||
| 215 | const event = new TestClipboardEvent(mention); | ||
| 216 | let handled = false; | ||
| 217 | editor.update( | ||
| 218 | () => { | ||
| 219 | handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | ||
| 220 | }, | ||
| 221 | { discrete: true }, | ||
| 222 | ); | ||
| 223 | |||
| 224 | expect(handled).toBe(true); | ||
| 225 | expect(plainTextFallback).not.toHaveBeenCalled(); | ||
| 226 | expect(event.defaultPrevented).toBe(true); | ||
| 227 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | ||
| 228 | "<mention:@scope/pkg/sub> ", | ||
| 229 | ); | ||
| 230 | }); | ||
| 231 | |||
| 232 | it("restores multiple citation nodes beside punctuation and Unicode without changing their source", () => { | ||
| 233 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 234 | const editor = createCitationEditor(); | ||
| 235 | const secondSource = serializeAssistantCitation({ | ||
| 236 | ...citation, | ||
| 237 | messageId: MessageId.make("message-2"), | ||
| 238 | text: "Another quote 👋", | ||
| 239 | }); | ||
| 240 | const text = `前(${citationSource}),${secondSource}${citationSource}`; | ||
| 241 | const plainTextFallback = vi.fn(() => true); | ||
| 242 | editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); | ||
| 243 | |||
| 244 | const event = pasteText(editor, text); | ||
| 245 | |||
| 246 | expect(event.defaultPrevented).toBe(true); | ||
| 247 | expect(plainTextFallback).not.toHaveBeenCalled(); | ||
| 248 | editor.getEditorState().read(() => { | ||
| 249 | expect($getRoot().getTextContent()).toBe(text); | ||
| 250 | expect($citationNodes().map((node) => node.getTextContent())).toEqual([ | ||
| 251 | citationSource, | ||
| 252 | secondSource, | ||
| 253 | citationSource, | ||
| 254 | ]); | ||
| 255 | }); | ||
| 256 | }); | ||
| 257 | |||
| 258 | it("restores citations alongside mentions and only pads a trailing mention", () => { | ||
| 259 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 260 | const editor = createCitationEditor(); | ||
| 261 | |||
| 262 | pasteText(editor, `${citationSource}\n[AGENTS.md](AGENTS.md) ${citationSource}`); | ||
| 263 | |||
| 264 | editor.getEditorState().read(() => { | ||
| 265 | expect($getRoot().getTextContent()).toBe( | ||
| 266 | `${citationSource}\n<mention:AGENTS.md> ${citationSource}`, | ||
| 267 | ); | ||
| 268 | expect($citationNodes()).toHaveLength(2); | ||
| 269 | }); | ||
| 270 | |||
| 271 | const trailingMentionEditor = createCitationEditor(); | ||
| 272 | pasteText(trailingMentionEditor, `${citationSource} [AGENTS.md](AGENTS.md)`); | ||
| 273 | |||
| 274 | expect(trailingMentionEditor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | ||
| 275 | `${citationSource} <mention:AGENTS.md> `, | ||
| 276 | ); | ||
| 277 | }); | ||
| 278 | |||
| 279 | it.each(["what do you mean??\nPlease keep the indentation:\n café 👋"])( | ||
| 280 | "keeps the edited comment %j bound to its quote through an editor reload and paste", | ||
| 281 | (comment) => { | ||
| 282 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 283 | const editor = createCitationEditor(); | ||
| 284 | const commentedCitation = { ...citation, comment }; | ||
| 285 | const source = serializeAssistantCitation(commentedCitation); | ||
| 286 | pasteText(editor, `前(${citationSource})後`); | ||
| 287 | editor.update( | ||
| 288 | () => { | ||
| 289 | $citationNodes()[0]!.setComment(comment); | ||
| 290 | }, | ||
| 291 | { discrete: true }, | ||
| 292 | ); | ||
| 293 | |||
| 294 | editor.getEditorState().read(() => { | ||
| 295 | expect($getRoot().getTextContent()).toBe(`前(${source})後`); | ||
| 296 | expect($citationNodes()[0]!.exportJSON().citation).toEqual(commentedCitation); | ||
| 297 | }); | ||
| 298 | |||
| 299 | const restored = createCitationEditor(); | ||
| 300 | restored.setEditorState(restored.parseEditorState(editor.getEditorState().toJSON())); | ||
| 301 | restored.update( | ||
| 302 | () => { | ||
| 303 | const paragraph = $getRoot().getFirstChildOrThrow(); | ||
| 304 | if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); | ||
| 305 | paragraph.selectStart().insertText("About "); | ||
| 306 | }, | ||
| 307 | { discrete: true }, | ||
| 308 | ); | ||
| 309 | |||
| 310 | restored.getEditorState().read(() => { | ||
| 311 | expect($getRoot().getTextContent()).toBe(`About 前(${source})後`); | ||
| 312 | expect($getRoot().getChildrenSize()).toBe(1); | ||
| 313 | expect($citationNodes()[0]!.exportJSON().citation).toEqual(commentedCitation); | ||
| 314 | }); | ||
| 315 | |||
| 316 | const pasted = createCitationEditor(); | ||
| 317 | pasteText( | ||
| 318 | pasted, | ||
| 319 | restored.getEditorState().read(() => $getRoot().getTextContent()), | ||
| 320 | ); | ||
| 321 | pasted.getEditorState().read(() => { | ||
| 322 | expect($getRoot().getTextContent()).toBe(`About 前(${source})後`); | ||
| 323 | expect($citationNodes()[0]!.exportJSON().citation).toEqual(commentedCitation); | ||
| 324 | }); | ||
| 325 | }, | ||
| 326 | ); | ||
| 327 | |||
| 328 | it("edits only one repeated quote without moving selection or mutating earlier editor states", () => { | ||
| 329 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 330 | const editor = createCitationEditor(); | ||
| 331 | const prompt = `前(${citationSource}),(${citationSource})後`; | ||
| 332 | pasteText(editor, prompt); | ||
| 333 | editor.update( | ||
| 334 | () => { | ||
| 335 | $getRoot().getAllTextNodes().at(-1)!.select(2, 1); | ||
| 336 | }, | ||
| 337 | { discrete: true }, | ||
| 338 | ); | ||
| 339 | const beforeEdit = editor.getEditorState(); | ||
| 340 | const selection = beforeEdit.read(() => $getSelection()!.clone()); | ||
| 341 | const node = beforeEdit.read(() => $citationNodes()[1]!); | ||
| 342 | editor.update(() => node.setComment("First draft"), { discrete: true }); | ||
| 343 | const comment = "Use this occurrence, not the earlier quote."; | ||
| 344 | editor.update(() => node.setComment(comment), { discrete: true }); | ||
| 345 | const afterEdit = editor.getEditorState(); | ||
| 346 | const source = serializeAssistantCitation({ ...citation, comment }); | ||
| 347 | |||
| 348 | afterEdit.read(() => { | ||
| 349 | expect($getRoot().getTextContent()).toBe(`前(${citationSource}),(${source})後`); | ||
| 350 | expect($citationNodes().map((citationNode) => citationNode.exportJSON().citation)).toEqual([ | ||
| 351 | citation, | ||
| 352 | { ...citation, comment }, | ||
| 353 | ]); | ||
| 354 | expect($getSelection()?.is(selection)).toBe(true); | ||
| 355 | }); | ||
| 356 | beforeEdit.read(() => { | ||
| 357 | expect($getRoot().getTextContent()).toBe(prompt); | ||
| 358 | expect($citationNodes().map((citationNode) => citationNode.exportJSON().citation)).toEqual([ | ||
| 359 | citation, | ||
| 360 | citation, | ||
| 361 | ]); | ||
| 362 | }); | ||
| 363 | |||
| 364 | editor.setEditorState(beforeEdit); | ||
| 365 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(prompt); | ||
| 366 | editor.setEditorState(afterEdit); | ||
| 367 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | ||
| 368 | `前(${citationSource}),(${source})後`, | ||
| 369 | ); | ||
| 370 | }); | ||
| 371 | |||
| 372 | it.each(["", " \n\t "])("clears a comment with %j without removing the quote", (comment) => { | ||
| 373 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 374 | const editor = createCitationEditor(); | ||
| 375 | pasteText(editor, citationSource); | ||
| 376 | editor.update(() => $citationNodes()[0]!.setComment("Remove this comment."), { | ||
| 377 | discrete: true, | ||
| 378 | }); | ||
| 379 | editor.update(() => $citationNodes()[0]!.setComment(comment), { discrete: true }); | ||
| 380 | |||
| 381 | editor.getEditorState().read(() => { | ||
| 382 | expect($citationNodes()).toHaveLength(1); | ||
| 383 | expect($citationNodes()[0]!.exportJSON().citation).toStrictEqual(citation); | ||
| 384 | expect($getRoot().getTextContent()).toBe(serializeAssistantCitation(citation)); | ||
| 385 | }); | ||
| 386 | }); | ||
| 387 | |||
| 388 | it("keeps a citation pasted after an unfinished @ restorable as a chip", () => { | ||
| 389 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 390 | const editor = createCitationEditor("@"); | ||
| 391 | |||
| 392 | pasteText(editor, citationSource); | ||
| 393 | |||
| 394 | const prompt = editor.getEditorState().read(() => $getRoot().getTextContent()); | ||
| 395 | expect(splitPromptIntoComposerSegments(prompt)).toEqual([ | ||
| 396 | { type: "text", text: "@" }, | ||
| 397 | { type: "citation", citation, source: citationSource }, | ||
| 398 | ]); | ||
| 399 | }); | ||
| 400 | |||
| 401 | it("copies, cuts, and pastes selected citation nodes with all source metadata intact", () => { | ||
| 402 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 403 | const editor = createCitationEditor(); | ||
| 404 | const firstCitation = { ...citation, comment: "Explain the first occurrence." }; | ||
| 405 | const secondCitation = { ...citation, comment: "Keep the second occurrence.\n café 👋" }; | ||
| 406 | const selectedText = `${serializeAssistantCitation(firstCitation)},${serializeAssistantCitation(secondCitation)}`; | ||
| 407 | pasteText(editor, `before ${citationSource},${citationSource} after`); | ||
| 408 | editor.update( | ||
| 409 | () => { | ||
| 410 | $citationNodes()[0]!.setComment(firstCitation.comment); | ||
| 411 | $citationNodes()[1]!.setComment(secondCitation.comment); | ||
| 412 | }, | ||
| 413 | { discrete: true }, | ||
| 414 | ); | ||
| 415 | let copied = ""; | ||
| 416 | editor.update( | ||
| 417 | () => { | ||
| 418 | const paragraph = $getRoot().getFirstChildOrThrow(); | ||
| 419 | if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); | ||
| 420 | paragraph.select(1, 4); | ||
| 421 | const selection = $getSelection(); | ||
| 422 | if (!$isRangeSelection(selection)) throw new Error("Expected a range selection"); | ||
| 423 | copied = selection.getTextContent(); | ||
| 424 | selection.removeText(); | ||
| 425 | }, | ||
| 426 | { discrete: true }, | ||
| 427 | ); | ||
| 428 | |||
| 429 | expect(copied).toBe(selectedText); | ||
| 430 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe("before after"); | ||
| 431 | |||
| 432 | pasteText(editor, copied); | ||
| 433 | |||
| 434 | editor.getEditorState().read(() => { | ||
| 435 | expect($getRoot().getTextContent()).toBe(`before ${selectedText} after`); | ||
| 436 | expect($citationNodes().map((node) => node.exportJSON().citation)).toEqual([ | ||
| 437 | firstCitation, | ||
| 438 | secondCitation, | ||
| 439 | ]); | ||
| 440 | }); | ||
| 441 | }); | ||
| 442 | |||
| 443 | it.each([true, false])( | ||
| 444 | "deletes a citation and its comment atomically, backwards=%s", | ||
| 445 | (backwards) => { | ||
| 446 | const editor = createCitationEditor(); | ||
| 447 | const commentedCitation = { ...citation, comment: "Delete this with its quote.\n café 👋" }; | ||
| 448 | const source = serializeAssistantCitation(commentedCitation); | ||
| 449 | editor.update( | ||
| 450 | () => { | ||
| 451 | const paragraph = $getRoot().getFirstChildOrThrow(); | ||
| 452 | if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); | ||
| 453 | const before = $createTextNode("前("); | ||
| 454 | const after = $createTextNode(")後"); | ||
| 455 | paragraph.append( | ||
| 456 | before, | ||
| 457 | $createComposerCitationNode(citation, citationSource).setComment( | ||
| 458 | commentedCitation.comment, | ||
| 459 | ), | ||
| 460 | after, | ||
| 461 | ); | ||
| 462 | if (backwards) { | ||
| 463 | after.selectStart(); | ||
| 464 | } else { | ||
| 465 | before.selectEnd(); | ||
| 466 | } | ||
| 467 | }, | ||
| 468 | { discrete: true }, | ||
| 469 | ); | ||
| 470 | const beforeDeletion = editor.getEditorState(); | ||
| 471 | const serialized = beforeDeletion.toJSON(); | ||
| 472 | |||
| 473 | editor.update( | ||
| 474 | () => { | ||
| 475 | const selection = $getSelection(); | ||
| 476 | if (!$isRangeSelection(selection)) throw new Error("Expected a range selection"); | ||
| 477 | selection.deleteCharacter(backwards); | ||
| 478 | }, | ||
| 479 | { discrete: true }, | ||
| 480 | ); | ||
| 481 | |||
| 482 | const afterDeletion = editor.getEditorState(); | ||
| 483 | expect(afterDeletion.read(() => $getRoot().getTextContent())).toBe("前()後"); | ||
| 484 | |||
| 485 | editor.setEditorState(beforeDeletion); | ||
| 486 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | ||
| 487 | `前(${source})後`, | ||
| 488 | ); | ||
| 489 | editor.setEditorState(afterDeletion); | ||
| 490 | expect(editor.getEditorState().read(() => $citationNodes())).toHaveLength(0); | ||
| 491 | |||
| 492 | editor.setEditorState(editor.parseEditorState(serialized)); | ||
| 493 | editor.getEditorState().read(() => { | ||
| 494 | expect($getRoot().getTextContent()).toBe(`前(${source})後`); | ||
| 495 | expect($citationNodes()[0]?.exportJSON().citation).toEqual(commentedCitation); | ||
| 496 | }); | ||
| 497 | }, | ||
| 498 | ); | ||
| 499 | }); | ||
| 500 | |||
| 501 | describe("context reference paste", () => { | ||
| 502 | it.each([ | ||
| 503 | { focus: "focused", prefix: "" }, | ||
| 504 | { focus: "blurred", prefix: "" }, | ||
| 505 | { focus: "focused", prefix: "log ".repeat(10_000) }, | ||
| 506 | { focus: "blurred", prefix: "log ".repeat(10_000) }, | ||
| 507 | ])( | ||
| 508 | "imports structured paste when $focus with $prefix.length extra characters", | ||
| 509 | ({ focus, prefix }) => { | ||
| 510 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 511 | const editor = createEditor({ nodes: [ComposerCitationNode] }); | ||
| 512 | editor.update( | ||
| 513 | () => { | ||
| 514 | const paragraph = $createParagraphNode(); | ||
| 515 | $getRoot().append(paragraph); | ||
| 516 | paragraph.selectEnd(); | ||
| 517 | }, | ||
| 518 | { discrete: true }, | ||
| 519 | ); | ||
| 520 | const imported: string[] = []; | ||
| 521 | const importFragment = ( | ||
| 522 | fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, | ||
| 523 | ) => { | ||
| 524 | imported.push(...fragment.records.map((record) => record.contextId)); | ||
| 525 | return new Map([["img-old", "img-new"]]); | ||
| 526 | }; | ||
| 527 | registerComposerInlineTokenPaste(editor, { | ||
| 528 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | ||
| 529 | createCitationNode: $createComposerCitationNode, | ||
| 530 | createContextReferenceNode: (reference) => | ||
| 531 | $createTextNode(`<context:${reference.contextId}>`), | ||
| 532 | getExpandedAbsoluteOffsetForPoint: () => 0, | ||
| 533 | importContextFragment: importFragment, | ||
| 534 | }); | ||
| 535 | const event = new TestClipboardEvent( | ||
| 536 | `${prefix} and [T](t3-context://v1/terminal/ctx-t)`, | ||
| 537 | { | ||
| 538 | "web application/x-t3-context-fragment+json": JSON.stringify({ | ||
| 539 | version: 1, | ||
| 540 | source: { environmentId: "env-1" }, | ||
| 541 | records: [ | ||
| 542 | { | ||
| 543 | version: 1, | ||
| 544 | contextId: "img-old", | ||
| 545 | kind: "image", | ||
| 546 | label: "shot", | ||
| 547 | attachmentId: "a", | ||
| 548 | name: "shot.png", | ||
| 549 | mimeType: "image/png", | ||
| 550 | sizeBytes: 1, | ||
| 551 | }, | ||
| 552 | { | ||
| 553 | version: 1, | ||
| 554 | contextId: "ctx-t", | ||
| 555 | kind: "terminal", | ||
| 556 | label: "T", | ||
| 557 | terminalId: "t", | ||
| 558 | terminalLabel: "T", | ||
| 559 | lineStart: 1, | ||
| 560 | lineEnd: 1, | ||
| 561 | text: "x", | ||
| 562 | }, | ||
| 563 | { | ||
| 564 | version: 1, | ||
| 565 | contextId: "img-unrelated", | ||
| 566 | kind: "image", | ||
| 567 | label: "other", | ||
| 568 | attachmentId: "b", | ||
| 569 | name: "other.png", | ||
| 570 | mimeType: "image/png", | ||
| 571 | sizeBytes: 1, | ||
| 572 | }, | ||
| 573 | ], | ||
| 574 | }), | ||
| 575 | }, | ||
| 576 | ); | ||
| 577 | expect( | ||
| 578 | readPastedComposerContext(event.clipboardData)?.records.map((record) => record.contextId), | ||
| 579 | ).toEqual(["img-old", "ctx-t"]); | ||
| 580 | if (focus === "blurred") { | ||
| 581 | expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( | ||
| 582 | `${prefix} and [T](t3-context://v1/terminal/ctx-t)`, | ||
| 583 | ); | ||
| 584 | expect(imported).toEqual(["img-old", "ctx-t"]); | ||
| 585 | return; | ||
| 586 | } | ||
| 587 | editor.update( | ||
| 588 | () => { | ||
| 589 | editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | ||
| 590 | }, | ||
| 591 | { discrete: true }, | ||
| 592 | ); | ||
| 593 | expect(imported).toEqual(["img-old", "ctx-t"]); | ||
| 594 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | ||
| 595 | `${prefix}<context:img-new> and <context:ctx-t>`, | ||
| 596 | ); | ||
| 597 | }, | ||
| 598 | ); | ||
| 599 | |||
| 600 | it("converts a copied legacy element into a sendable annotation and rewrites its link", () => { | ||
| 601 | const copied = upgradeLegacyContextMessage( | ||
| 602 | [ | ||
| 603 | "Fix this", | ||
| 604 | "", | ||
| 605 | "<element_context>", | ||
| 606 | "- <button>:", | ||
| 607 | " url: https://example.com", | ||
| 608 | " selector: #save", | ||
| 609 | " html:", | ||
| 610 | " <button>Save</button>", | ||
| 611 | " styles:", | ||
| 612 | " color: red;", | ||
| 613 | "</element_context>", | ||
| 614 | ].join("\n"), | ||
| 615 | ); | ||
| 616 | const event = new TestClipboardEvent(copied.text, { | ||
| 617 | "web application/x-t3-context-fragment+json": JSON.stringify({ | ||
| 618 | version: 1, | ||
| 619 | source: { environmentId: "env-1" }, | ||
| 620 | records: copied.records, | ||
| 621 | }), | ||
| 622 | }); | ||
| 623 | const annotations: ReturnType<typeof previewAnnotationContextRecord>[] = []; | ||
| 624 | const text = importPastedComposerText(event.clipboardData, (fragment) => { | ||
| 625 | const record = fragment.records[0]!; | ||
| 626 | if (record.kind !== "element" || "payload" in record) throw new Error("Expected element"); | ||
| 627 | const annotation = previewAnnotationContextRecord( | ||
| 628 | elementContextToPreviewAnnotation(record, "imported", "2026-01-01T00:00:00Z"), | ||
| 629 | ); | ||
| 630 | annotations.push(annotation); | ||
| 631 | return new Map([[record.contextId, annotation.contextId]]); | ||
| 632 | }); | ||
| 633 | expect(text).toContain("t3-context://v1/preview-annotation/preview-annotation_imported"); | ||
| 634 | expect(annotations[0]?.elements?.[0]).toMatchObject({ | ||
| 635 | selector: "#save", | ||
| 636 | htmlPreview: "<button>Save</button>", | ||
| 637 | styles: "color: red;", | ||
| 638 | }); | ||
| 639 | }); | ||
| 640 | |||
| 641 | it("imports an annotation's dependent screenshot when only its chip is pasted", () => { | ||
| 642 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 643 | const editor = createEditor({ nodes: [ComposerCitationNode] }); | ||
| 644 | editor.update( | ||
| 645 | () => { | ||
| 646 | const paragraph = $createParagraphNode(); | ||
| 647 | $getRoot().append(paragraph); | ||
| 648 | paragraph.selectEnd(); | ||
| 649 | }, | ||
| 650 | { discrete: true }, | ||
| 651 | ); | ||
| 652 | const imported: string[] = []; | ||
| 653 | registerComposerInlineTokenPaste(editor, { | ||
| 654 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | ||
| 655 | createCitationNode: $createComposerCitationNode, | ||
| 656 | createContextReferenceNode: (reference) => | ||
| 657 | $createTextNode(`<context:${reference.contextId}>`), | ||
| 658 | getExpandedAbsoluteOffsetForPoint: () => 0, | ||
| 659 | importContextFragment: (fragment) => { | ||
| 660 | imported.push(...fragment.records.map((record) => record.contextId)); | ||
| 661 | return new Map(); | ||
| 662 | }, | ||
| 663 | }); | ||
| 664 | const annotationId = "preview-annotation_ann-1"; | ||
| 665 | const screenshotId = "image_ann-1"; | ||
| 666 | const event = new TestClipboardEvent( | ||
| 667 | `[Fix button](t3-context://v1/preview-annotation/${annotationId})`, | ||
| 668 | { | ||
| 669 | "web application/x-t3-context-fragment+json": JSON.stringify({ | ||
| 670 | version: 1, | ||
| 671 | source: { environmentId: "env-1" }, | ||
| 672 | records: [ | ||
| 673 | { | ||
| 674 | version: 1, | ||
| 675 | contextId: annotationId, | ||
| 676 | kind: "preview-annotation", | ||
| 677 | label: "Fix button", | ||
| 678 | annotationId: "ann-1", | ||
| 679 | pageUrl: "https://example.com", | ||
| 680 | pageTitle: "Example", | ||
| 681 | comment: "Fix button", | ||
| 682 | targetSummary: "1 selected element", | ||
| 683 | styleChanges: [], | ||
| 684 | screenshotContextId: screenshotId, | ||
| 685 | }, | ||
| 686 | { | ||
| 687 | version: 1, | ||
| 688 | contextId: screenshotId, | ||
| 689 | kind: "image", | ||
| 690 | label: "annotation.png", | ||
| 691 | attachmentId: "attachment-1", | ||
| 692 | name: "annotation.png", | ||
| 693 | mimeType: "image/png", | ||
| 694 | sizeBytes: 10, | ||
| 695 | }, | ||
| 696 | { | ||
| 697 | version: 1, | ||
| 698 | contextId: "image_unrelated", | ||
| 699 | kind: "image", | ||
| 700 | label: "unrelated.png", | ||
| 701 | attachmentId: "attachment-2", | ||
| 702 | name: "unrelated.png", | ||
| 703 | mimeType: "image/png", | ||
| 704 | sizeBytes: 10, | ||
| 705 | }, | ||
| 706 | ], | ||
| 707 | }), | ||
| 708 | }, | ||
| 709 | ); | ||
| 710 | editor.update( | ||
| 711 | () => { | ||
| 712 | editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | ||
| 713 | }, | ||
| 714 | { discrete: true }, | ||
| 715 | ); | ||
| 716 | |||
| 717 | expect(imported).toEqual([annotationId, screenshotId]); | ||
| 718 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | ||
| 719 | `<context:${annotationId}>`, | ||
| 720 | ); | ||
| 721 | }); | ||
| 722 | |||
| 723 | it("turns pasted context links into reference nodes", () => { | ||
| 724 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 725 | const editor = createCitationEditor("see "); | ||
| 726 | pasteText(editor, "[Terminal 1 line 4](t3-context://v1/terminal/ctx-1) now"); | ||
| 727 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | ||
| 728 | "see <context:ctx-1> now", | ||
| 729 | ); | ||
| 730 | }); | ||
| 731 | }); | ||
| 732 | |||
| 733 | describe("citation comment opening", () => { | ||
| 734 | const sourceAnchor: AssistantCitationSourceAnchor = { | ||
| 735 | source: { nodeType: 1 } as HTMLElement, | ||
| 736 | range: { collapsed: false } as Range, | ||
| 737 | viewport: { nodeType: 1 } as HTMLElement, | ||
| 738 | }; | ||
| 739 | afterEach(() => { | ||
| 740 | vi.unstubAllGlobals(); | ||
| 741 | }); | ||
| 742 | |||
| 743 | it("opens only the newly inserted occurrence after committing its exact prompt", () => { | ||
| 744 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 745 | const editor = createCitationEditor(); | ||
| 746 | const previousValue = `前 ${citationSource} middle\nbefore after ${citationSource} 後`; | ||
| 747 | pasteText(editor, previousValue); | ||
| 748 | const citationStart = `前 ${citationSource} middle\nbefore `.length; | ||
| 749 | const value = | ||
| 750 | previousValue.slice(0, citationStart) + | ||
| 751 | `${citationSource} ` + | ||
| 752 | previousValue.slice(citationStart); | ||
| 753 | const request: ComposerCitationCommentRequest = { | ||
| 754 | previousValue, | ||
| 755 | value, | ||
| 756 | citationStart, | ||
| 757 | sourceAnchor, | ||
| 758 | }; | ||
| 759 | const requestRef = { current: request as ComposerCitationCommentRequest | null }; | ||
| 760 | |||
| 761 | editor.getEditorState().read(() => { | ||
| 762 | expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); | ||
| 763 | expect(requestRef.current).toBe(request); | ||
| 764 | }); | ||
| 765 | editor.update( | ||
| 766 | () => { | ||
| 767 | const text = $getRoot() | ||
| 768 | .getAllTextNodes() | ||
| 769 | .find((node) => node.getTextContent() === "before after "); | ||
| 770 | if (!text) throw new Error("Expected insertion text"); | ||
| 771 | text.select("before ".length, "before ".length); | ||
| 772 | }, | ||
| 773 | { discrete: true }, | ||
| 774 | ); | ||
| 775 | pasteText(editor, `${citationSource} `); | ||
| 776 | |||
| 777 | editor.getEditorState().read(() => { | ||
| 778 | const selection = $getSelection()?.clone(); | ||
| 779 | expect($getRoot().getTextContent()).toBe(value); | ||
| 780 | expect($citationNodes()).toHaveLength(3); | ||
| 781 | const target = $consumeComposerCitationCommentRequest(requestRef); | ||
| 782 | expect(target?.nodeKey).toBe($citationNodes()[1]!.getKey()); | ||
| 783 | expect(target?.sourceAnchor).toBe(sourceAnchor); | ||
| 784 | expect($getSelection()?.is(selection!)).toBe(true); | ||
| 785 | expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); | ||
| 786 | }); | ||
| 787 | }); | ||
| 788 | |||
| 789 | it("does not reopen comments when an inserted citation is restored or pasted", () => { | ||
| 790 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 791 | const editor = createCitationEditor("before "); | ||
| 792 | const previousState = editor.getEditorState(); | ||
| 793 | const value = `before ${citationSource} `; | ||
| 794 | const requestRef: { current: ComposerCitationCommentRequest | null } = { | ||
| 795 | current: { previousValue: "before ", value, citationStart: "before ".length, sourceAnchor }, | ||
| 796 | }; | ||
| 797 | pasteText(editor, `${citationSource} `); | ||
| 798 | const insertedState = editor.getEditorState(); | ||
| 799 | insertedState.read(() => { | ||
| 800 | expect($consumeComposerCitationCommentRequest(requestRef)?.nodeKey).toBe( | ||
| 801 | $citationNodes()[0]!.getKey(), | ||
| 802 | ); | ||
| 803 | }); | ||
| 804 | editor.update(() => $citationNodes()[0]!.setComment("Keep this attached."), { | ||
| 805 | discrete: true, | ||
| 806 | }); | ||
| 807 | const commentedState = editor.getEditorState(); | ||
| 808 | |||
| 809 | for (const restoredState of [previousState, insertedState, commentedState]) { | ||
| 810 | editor.setEditorState(restoredState); | ||
| 811 | editor.getEditorState().read(() => { | ||
| 812 | expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); | ||
| 813 | }); | ||
| 814 | } | ||
| 815 | |||
| 816 | const reloaded = createCitationEditor(); | ||
| 817 | reloaded.setEditorState(reloaded.parseEditorState(commentedState.toJSON())); | ||
| 818 | reloaded.getEditorState().read(() => { | ||
| 819 | expect($consumeComposerCitationCommentRequest({ current: null })).toBeNull(); | ||
| 820 | expect($citationNodes()[0]!.exportJSON().citation.comment).toBe("Keep this attached."); | ||
| 821 | }); | ||
| 822 | const pasted = createCitationEditor(); | ||
| 823 | pasteText( | ||
| 824 | pasted, | ||
| 825 | commentedState.read(() => $getRoot().getTextContent()), | ||
| 826 | ); | ||
| 827 | pasted.getEditorState().read(() => { | ||
| 828 | expect($consumeComposerCitationCommentRequest({ current: null })).toBeNull(); | ||
| 829 | expect($citationNodes()[0]!.exportJSON().citation.comment).toBe("Keep this attached."); | ||
| 830 | }); | ||
| 831 | }); | ||
| 832 | |||
| 833 | it("discards an opening request if another prompt replaces the insertion", () => { | ||
| 834 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 835 | const editor = createCitationEditor(); | ||
| 836 | const requestRef: { current: ComposerCitationCommentRequest | null } = { | ||
| 837 | current: { previousValue: "", value: citationSource, citationStart: 0, sourceAnchor }, | ||
| 838 | }; | ||
| 839 | editor.update( | ||
| 840 | () => { | ||
| 841 | const selection = $getSelection(); | ||
| 842 | if (!$isRangeSelection(selection)) throw new Error("Expected an insertion point"); | ||
| 843 | selection.insertText("A different draft"); | ||
| 844 | }, | ||
| 845 | { discrete: true }, | ||
| 846 | ); | ||
| 847 | editor.getEditorState().read(() => { | ||
| 848 | expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); | ||
| 849 | }); | ||
| 850 | editor.update( | ||
| 851 | () => { | ||
| 852 | const paragraph = $getRoot().getFirstChildOrThrow(); | ||
| 853 | if (!$isElementNode(paragraph)) throw new Error("Expected a composer paragraph"); | ||
| 854 | paragraph.clear().selectStart(); | ||
| 855 | }, | ||
| 856 | { discrete: true }, | ||
| 857 | ); | ||
| 858 | pasteText(editor, citationSource); | ||
| 859 | editor.getEditorState().read(() => { | ||
| 860 | expect($getRoot().getTextContent()).toBe(citationSource); | ||
| 861 | expect($consumeComposerCitationCommentRequest(requestRef)).toBeNull(); | ||
| 862 | }); | ||
| 863 | }); | ||
| 864 | }); | ||
apps/web/src/components/ComposerPromptEditor.tsx+12-2132| ... | @@ -1,2137 +1,17 @@ | ... | @@ -1,2137 +1,17 @@ |
| 1 | import { ContextChipPopover } from "./contextChipParts"; | 1 | import { ComposerPromptEditorTiptap } from "./ComposerPromptEditorTiptap"; |
| 2 | import { Button } from "./ui/button"; | 2 | import type { ComposerPromptEditorProps } from "./ComposerPromptEditorTiptap"; |
| 3 | import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; | ||
| 4 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; | ||
| 5 | import { ContentEditable } from "@lexical/react/LexicalContentEditable"; | ||
| 6 | import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; | ||
| 7 | import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; | ||
| 8 | import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; | ||
| 9 | import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; | ||
| 10 | import { | ||
| 11 | type ComposerContextClipboardFragment, | ||
| 12 | type ServerProviderSkill, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { | ||
| 15 | COMPOSER_CONTEXT_CLIPBOARD_MIME, | ||
| 16 | encodeComposerContextClipboardHtml, | ||
| 17 | } from "@t3tools/shared/composerContextClipboard"; | ||
| 18 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | ||
| 19 | import { | ||
| 20 | $applyNodeReplacement, | ||
| 21 | $createRangeSelectionFromDom, | ||
| 22 | $createRangeSelection, | ||
| 23 | $getSelection, | ||
| 24 | $setSelection, | ||
| 25 | $isElementNode, | ||
| 26 | $isLineBreakNode, | ||
| 27 | $isRangeSelection, | ||
| 28 | $isTextNode, | ||
| 29 | $createLineBreakNode, | ||
| 30 | $createParagraphNode, | ||
| 31 | $createTextNode, | ||
| 32 | KEY_ARROW_DOWN_COMMAND, | ||
| 33 | KEY_ARROW_LEFT_COMMAND, | ||
| 34 | KEY_ARROW_RIGHT_COMMAND, | ||
| 35 | KEY_ARROW_UP_COMMAND, | ||
| 36 | KEY_DOWN_COMMAND, | ||
| 37 | KEY_ENTER_COMMAND, | ||
| 38 | KEY_TAB_COMMAND, | ||
| 39 | COMMAND_PRIORITY_HIGH, | ||
| 40 | COPY_COMMAND, | ||
| 41 | CUT_COMMAND, | ||
| 42 | COMMAND_PRIORITY_LOW, | ||
| 43 | KEY_BACKSPACE_COMMAND, | ||
| 44 | BLUR_COMMAND, | ||
| 45 | FOCUS_COMMAND, | ||
| 46 | $getRoot, | ||
| 47 | $getNodeByKey, | ||
| 48 | HISTORY_MERGE_TAG, | ||
| 49 | HISTORY_PUSH_TAG, | ||
| 50 | SKIP_DOM_SELECTION_TAG, | ||
| 51 | DecoratorNode, | ||
| 52 | type ElementNode, | ||
| 53 | type LexicalNode, | ||
| 54 | type SerializedLexicalNode, | ||
| 55 | type EditorState, | ||
| 56 | type NodeKey, | ||
| 57 | type Spread, | ||
| 58 | } from "lexical"; | ||
| 59 | import { | ||
| 60 | createContext, | ||
| 61 | use, | ||
| 62 | useCallback, | ||
| 63 | useEffect, | ||
| 64 | useEffectEvent, | ||
| 65 | useImperativeHandle, | ||
| 66 | useLayoutEffect, | ||
| 67 | useMemo, | ||
| 68 | useRef, | ||
| 69 | useState, | ||
| 70 | } from "react"; | ||
| 71 | 3 | ||
| 72 | import { | 4 | export type { |
| 73 | clampCollapsedComposerCursor, | 5 | ComposerCitationCommentRequest, |
| 74 | collapseExpandedComposerCursor, | 6 | ComposerPromptEditorHandle, |
| 75 | expandCollapsedComposerCursor, | 7 | ComposerPromptEditorProps, |
| 76 | isCollapsedCursorAdjacentToInlineToken, | 8 | } from "./ComposerPromptEditorTiptap"; |
| 77 | } from "~/composer-logic"; | ||
| 78 | import { | ||
| 79 | selectionTouchesMentionBoundary, | ||
| 80 | splitPromptIntoComposerSegments, | ||
| 81 | } from "~/composer-editor-mentions"; | ||
| 82 | import { collectInlineContextIds } from "~/lib/composerContextReferences"; | ||
| 83 | import { cn, isMacPlatform } from "~/lib/utils"; | ||
| 84 | import { basenameOfPath } from "~/pierre-icons"; | ||
| 85 | import { | ||
| 86 | COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, | ||
| 87 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, | ||
| 88 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, | ||
| 89 | COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, | ||
| 90 | SKILL_CHIP_ICON_SVG, | ||
| 91 | } from "./composerInlineChip"; | ||
| 92 | import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; | ||
| 93 | import { getTimelinePageScrollKey } from "./chat/pageScrollController"; | ||
| 94 | import { | ||
| 95 | $createComposerContextReferenceNode, | ||
| 96 | ComposerContextReferenceNode, | ||
| 97 | } from "./ComposerContextReferenceNode"; | ||
| 98 | import { | ||
| 99 | ComposerContextActionsContext, | ||
| 100 | ComposerContextRecordsContext, | ||
| 101 | type ComposerDraftContextRecords, | ||
| 102 | } from "./composerContextPresentation"; | ||
| 103 | import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; | ||
| 104 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | ||
| 105 | import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; | ||
| 106 | import { didComposerSelectionChangeVisibly } from "./composerSelection"; | ||
| 107 | import { | ||
| 108 | $consumeComposerCitationCommentRequest, | ||
| 109 | $createComposerCitationNode, | ||
| 110 | ComposerCitationCommentContext, | ||
| 111 | ComposerCitationNode, | ||
| 112 | type ComposerCitationCommentRequest, | ||
| 113 | type ComposerCitationCommentTarget, | ||
| 114 | } from "./ComposerCitationNode"; | ||
| 115 | |||
| 116 | const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; | ||
| 117 | const SURROUND_SYMBOLS: [string, string][] = [ | ||
| 118 | ["(", ")"], | ||
| 119 | ["[", "]"], | ||
| 120 | ["{", "}"], | ||
| 121 | ["'", "'"], | ||
| 122 | ['"', '"'], | ||
| 123 | ["“", "”"], | ||
| 124 | ["`", "`"], | ||
| 125 | ["<", ">"], | ||
| 126 | ["«", "»"], | ||
| 127 | ["*", "*"], | ||
| 128 | ["_", "_"], | ||
| 129 | ]; | ||
| 130 | const SURROUND_SYMBOLS_MAP = new Map<string, string>(SURROUND_SYMBOLS); | ||
| 131 | const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null; | ||
| 132 | |||
| 133 | type SerializedComposerMentionNode = Spread< | ||
| 134 | { | ||
| 135 | path: string; | ||
| 136 | source?: string; | ||
| 137 | type: "composer-mention"; | ||
| 138 | version: 1; | ||
| 139 | }, | ||
| 140 | SerializedLexicalNode | ||
| 141 | >; | ||
| 142 | |||
| 143 | type SerializedComposerSkillNode = Spread< | ||
| 144 | { | ||
| 145 | skillName: string; | ||
| 146 | skillLabel?: string; | ||
| 147 | skillDescription?: string; | ||
| 148 | type: "composer-skill"; | ||
| 149 | version: 1; | ||
| 150 | }, | ||
| 151 | SerializedLexicalNode | ||
| 152 | >; | ||
| 153 | |||
| 154 | function ComposerMentionDecorator(props: { path: string }) { | ||
| 155 | const actions = use(ComposerContextActionsContext); | ||
| 156 | const theme = resolvedThemeFromDocument(); | ||
| 157 | const chip = ( | ||
| 158 | <button | ||
| 159 | type="button" | ||
| 160 | onClick={() => actions.openMention(props.path)} | ||
| 161 | aria-label={`Preview ${props.path}`} | ||
| 162 | className={`${FILE_TAG_CHIP_CLASS_NAME} cursor-pointer focus-visible:outline-2`} | ||
| 163 | contentEditable={false} | ||
| 164 | spellCheck={false} | ||
| 165 | data-composer-mention-chip="true" | ||
| 166 | > | ||
| 167 | <FileTagChipContent path={props.path} label={basenameOfPath(props.path)} theme={theme} /> | ||
| 168 | </button> | ||
| 169 | ); | ||
| 170 | |||
| 171 | return ( | ||
| 172 | <Tooltip> | ||
| 173 | <TooltipTrigger render={chip} /> | ||
| 174 | <TooltipPopup side="top" className="max-w-120 whitespace-normal leading-tight wrap-anywhere"> | ||
| 175 | {props.path} | ||
| 176 | </TooltipPopup> | ||
| 177 | </Tooltip> | ||
| 178 | ); | ||
| 179 | } | ||
| 180 | |||
| 181 | class ComposerMentionNode extends DecoratorNode<React.ReactElement> { | ||
| 182 | __path: string; | ||
| 183 | __source: string; | ||
| 184 | |||
| 185 | static override getType(): string { | ||
| 186 | return "composer-mention"; | ||
| 187 | } | ||
| 188 | |||
| 189 | static override clone(node: ComposerMentionNode): ComposerMentionNode { | ||
| 190 | return new ComposerMentionNode(node.__path, node.__source, node.__key); | ||
| 191 | } | ||
| 192 | |||
| 193 | static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { | ||
| 194 | return $createComposerMentionNode(serializedNode.path, serializedNode.source).updateFromJSON( | ||
| 195 | serializedNode, | ||
| 196 | ); | ||
| 197 | } | ||
| 198 | |||
| 199 | constructor(path: string, source = serializeComposerFileLink(path), key?: NodeKey) { | ||
| 200 | super(key); | ||
| 201 | this.__path = path; | ||
| 202 | this.__source = source; | ||
| 203 | } | ||
| 204 | |||
| 205 | override exportJSON(): SerializedComposerMentionNode { | ||
| 206 | return { | ||
| 207 | ...super.exportJSON(), | ||
| 208 | path: this.__path, | ||
| 209 | source: this.__source, | ||
| 210 | type: "composer-mention", | ||
| 211 | version: 1, | ||
| 212 | }; | ||
| 213 | } | ||
| 214 | |||
| 215 | override createDOM(): HTMLElement { | ||
| 216 | const dom = document.createElement("span"); | ||
| 217 | dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; | ||
| 218 | return dom; | ||
| 219 | } | ||
| 220 | |||
| 221 | override updateDOM(): false { | ||
| 222 | return false; | ||
| 223 | } | ||
| 224 | |||
| 225 | override getTextContent(): string { | ||
| 226 | return this.__source; | ||
| 227 | } | ||
| 228 | |||
| 229 | override isInline(): true { | ||
| 230 | return true; | ||
| 231 | } | ||
| 232 | |||
| 233 | override decorate(): React.ReactElement { | ||
| 234 | return <ComposerMentionDecorator path={this.__path} />; | ||
| 235 | } | ||
| 236 | } | ||
| 237 | |||
| 238 | function $createComposerMentionNode(path: string, source?: string): ComposerMentionNode { | ||
| 239 | return $applyNodeReplacement(new ComposerMentionNode(path, source)); | ||
| 240 | } | ||
| 241 | |||
| 242 | function resolveSkillDescription( | ||
| 243 | skill: Pick<ServerProviderSkill, "shortDescription" | "description">, | ||
| 244 | ): string | null { | ||
| 245 | const shortDescription = skill.shortDescription?.trim(); | ||
| 246 | if (shortDescription) { | ||
| 247 | return shortDescription; | ||
| 248 | } | ||
| 249 | const description = skill.description?.trim(); | ||
| 250 | return description || null; | ||
| 251 | } | ||
| 252 | |||
| 253 | type ComposerSkillMetadata = { | ||
| 254 | label: string; | ||
| 255 | description: string | null; | ||
| 256 | }; | ||
| 257 | |||
| 258 | function skillMetadataByName( | ||
| 259 | skills: ReadonlyArray<ServerProviderSkill>, | ||
| 260 | ): ReadonlyMap<string, ComposerSkillMetadata> { | ||
| 261 | return new Map( | ||
| 262 | skills.map((skill) => [ | ||
| 263 | skill.name, | ||
| 264 | { | ||
| 265 | label: formatProviderSkillDisplayName(skill), | ||
| 266 | description: resolveSkillDescription(skill), | ||
| 267 | }, | ||
| 268 | ]), | ||
| 269 | ); | ||
| 270 | } | ||
| 271 | |||
| 272 | const ComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]); | ||
| 273 | |||
| 274 | function ComposerSkillDecorator(props: { | ||
| 275 | skillName: string; | ||
| 276 | skillLabel: string; | ||
| 277 | skillDescription: string | null; | ||
| 278 | }) { | ||
| 279 | const actions = use(ComposerContextActionsContext); | ||
| 280 | const skill = use(ComposerSkillsContext).find((candidate) => candidate.name === props.skillName); | ||
| 281 | return ( | ||
| 282 | <ContextChipPopover | ||
| 283 | accessibleLabel={`Skill ${props.skillLabel}`} | ||
| 284 | triggerClassName={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME} | ||
| 285 | chip={ | ||
| 286 | <> | ||
| 287 | <span | ||
| 288 | aria-hidden="true" | ||
| 289 | className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} | ||
| 290 | dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }} | ||
| 291 | /> | ||
| 292 | <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{props.skillLabel}</span> | ||
| 293 | </> | ||
| 294 | } | ||
| 295 | > | ||
| 296 | <div className="space-y-3 p-2 text-sm"> | ||
| 297 | <p className="font-medium">{props.skillLabel}</p> | ||
| 298 | <p> | ||
| 299 | {skill?.description ?? | ||
| 300 | props.skillDescription ?? | ||
| 301 | "No description is available for this skill."} | ||
| 302 | </p> | ||
| 303 | {skill?.path ? ( | ||
| 304 | <Button variant="outline" size="sm" onClick={() => actions.openMention(skill.path)}> | ||
| 305 | View instructions | ||
| 306 | </Button> | ||
| 307 | ) : null} | ||
| 308 | </div> | ||
| 309 | </ContextChipPopover> | ||
| 310 | ); | ||
| 311 | } | ||
| 312 | |||
| 313 | class ComposerSkillNode extends DecoratorNode<React.ReactElement> { | ||
| 314 | __skillName: string; | ||
| 315 | __skillLabel: string; | ||
| 316 | __skillDescription: string | null; | ||
| 317 | |||
| 318 | static override getType(): string { | ||
| 319 | return "composer-skill"; | ||
| 320 | } | ||
| 321 | |||
| 322 | static override clone(node: ComposerSkillNode): ComposerSkillNode { | ||
| 323 | return new ComposerSkillNode( | ||
| 324 | node.__skillName, | ||
| 325 | node.__skillLabel, | ||
| 326 | node.__skillDescription, | ||
| 327 | node.__key, | ||
| 328 | ); | ||
| 329 | } | ||
| 330 | |||
| 331 | static override importJSON(serializedNode: SerializedComposerSkillNode): ComposerSkillNode { | ||
| 332 | return $createComposerSkillNode( | ||
| 333 | serializedNode.skillName, | ||
| 334 | serializedNode.skillLabel ?? serializedNode.skillName, | ||
| 335 | serializedNode.skillDescription ?? null, | ||
| 336 | ).updateFromJSON(serializedNode); | ||
| 337 | } | ||
| 338 | |||
| 339 | constructor( | ||
| 340 | skillName: string, | ||
| 341 | skillLabel: string, | ||
| 342 | skillDescription: string | null, | ||
| 343 | key?: NodeKey, | ||
| 344 | ) { | ||
| 345 | super(key); | ||
| 346 | const normalizedSkillName = skillName.startsWith("$") ? skillName.slice(1) : skillName; | ||
| 347 | this.__skillName = normalizedSkillName; | ||
| 348 | this.__skillLabel = skillLabel; | ||
| 349 | this.__skillDescription = skillDescription; | ||
| 350 | } | ||
| 351 | |||
| 352 | override exportJSON(): SerializedComposerSkillNode { | ||
| 353 | return { | ||
| 354 | ...super.exportJSON(), | ||
| 355 | skillName: this.__skillName, | ||
| 356 | skillLabel: this.__skillLabel, | ||
| 357 | ...(this.__skillDescription ? { skillDescription: this.__skillDescription } : {}), | ||
| 358 | type: "composer-skill", | ||
| 359 | version: 1, | ||
| 360 | }; | ||
| 361 | } | ||
| 362 | |||
| 363 | override createDOM(): HTMLElement { | ||
| 364 | const dom = document.createElement("span"); | ||
| 365 | dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; | ||
| 366 | return dom; | ||
| 367 | } | ||
| 368 | |||
| 369 | override updateDOM(): false { | ||
| 370 | return false; | ||
| 371 | } | ||
| 372 | |||
| 373 | override getTextContent(): string { | ||
| 374 | return `$${this.__skillName}`; | ||
| 375 | } | ||
| 376 | |||
| 377 | override isInline(): true { | ||
| 378 | return true; | ||
| 379 | } | ||
| 380 | |||
| 381 | override decorate(): React.ReactElement { | ||
| 382 | return ( | ||
| 383 | <ComposerSkillDecorator | ||
| 384 | skillName={this.__skillName} | ||
| 385 | skillLabel={this.__skillLabel} | ||
| 386 | skillDescription={this.__skillDescription} | ||
| 387 | /> | ||
| 388 | ); | ||
| 389 | } | ||
| 390 | } | ||
| 391 | |||
| 392 | function $createComposerSkillNode( | ||
| 393 | skillName: string, | ||
| 394 | skillLabel: string, | ||
| 395 | skillDescription: string | null, | ||
| 396 | ): ComposerSkillNode { | ||
| 397 | return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); | ||
| 398 | } | ||
| 399 | |||
| 400 | type ComposerInlineTokenNode = | ||
| 401 | | ComposerMentionNode | ||
| 402 | | ComposerSkillNode | ||
| 403 | | ComposerCitationNode | ||
| 404 | | ComposerContextReferenceNode; | ||
| 405 | |||
| 406 | function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { | ||
| 407 | return ( | ||
| 408 | candidate instanceof ComposerMentionNode || | ||
| 409 | candidate instanceof ComposerSkillNode || | ||
| 410 | candidate instanceof ComposerCitationNode || | ||
| 411 | candidate instanceof ComposerContextReferenceNode | ||
| 412 | ); | ||
| 413 | } | ||
| 414 | |||
| 415 | function resolvedThemeFromDocument(): "light" | "dark" { | ||
| 416 | return document.documentElement.classList.contains("dark") ? "dark" : "light"; | ||
| 417 | } | ||
| 418 | |||
| 419 | function skillSignature(skills: ReadonlyArray<ServerProviderSkill>): string { | ||
| 420 | return skills | ||
| 421 | .map((skill) => | ||
| 422 | [ | ||
| 423 | skill.name, | ||
| 424 | skill.displayName ?? "", | ||
| 425 | skill.shortDescription ?? "", | ||
| 426 | skill.description ?? "", | ||
| 427 | skill.path, | ||
| 428 | skill.scope ?? "", | ||
| 429 | skill.enabled ? "1" : "0", | ||
| 430 | ].join("\u001f"), | ||
| 431 | ) | ||
| 432 | .join("\u001e"); | ||
| 433 | } | ||
| 434 | |||
| 435 | function clampExpandedCursor(value: string, cursor: number): number { | ||
| 436 | if (!Number.isFinite(cursor)) return value.length; | ||
| 437 | return Math.max(0, Math.min(value.length, Math.floor(cursor))); | ||
| 438 | } | ||
| 439 | |||
| 440 | function getComposerInlineTokenTextLength(_node: ComposerInlineTokenNode): 1 { | ||
| 441 | return 1; | ||
| 442 | } | ||
| 443 | |||
| 444 | function getComposerInlineTokenExpandedTextLength(node: ComposerInlineTokenNode): number { | ||
| 445 | return node.getTextContentSize(); | ||
| 446 | } | ||
| 447 | |||
| 448 | function getAbsoluteOffsetForInlineTokenPoint( | ||
| 449 | node: ComposerInlineTokenNode, | ||
| 450 | absoluteOffset: number, | ||
| 451 | pointOffset: number, | ||
| 452 | ): number { | ||
| 453 | return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenTextLength(node) : 0); | ||
| 454 | } | ||
| 455 | |||
| 456 | function getExpandedAbsoluteOffsetForInlineTokenPoint( | ||
| 457 | node: ComposerInlineTokenNode, | ||
| 458 | absoluteOffset: number, | ||
| 459 | pointOffset: number, | ||
| 460 | ): number { | ||
| 461 | return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenExpandedTextLength(node) : 0); | ||
| 462 | } | ||
| 463 | |||
| 464 | function findSelectionPointForInlineToken( | ||
| 465 | node: ComposerInlineTokenNode, | ||
| 466 | remainingRef: { value: number }, | ||
| 467 | ): { key: string; offset: number; type: "element" } | null { | ||
| 468 | const parent = node.getParent(); | ||
| 469 | if (!parent || !$isElementNode(parent)) return null; | ||
| 470 | const index = node.getIndexWithinParent(); | ||
| 471 | if (remainingRef.value === 0) { | ||
| 472 | return { | ||
| 473 | key: parent.getKey(), | ||
| 474 | offset: index, | ||
| 475 | type: "element", | ||
| 476 | }; | ||
| 477 | } | ||
| 478 | if (remainingRef.value === getComposerInlineTokenTextLength(node)) { | ||
| 479 | return { | ||
| 480 | key: parent.getKey(), | ||
| 481 | offset: index + 1, | ||
| 482 | type: "element", | ||
| 483 | }; | ||
| 484 | } | ||
| 485 | remainingRef.value -= getComposerInlineTokenTextLength(node); | ||
| 486 | return null; | ||
| 487 | } | ||
| 488 | |||
| 489 | function getComposerNodeTextLength(node: LexicalNode): number { | ||
| 490 | if (isComposerInlineTokenNode(node)) { | ||
| 491 | return getComposerInlineTokenTextLength(node); | ||
| 492 | } | ||
| 493 | if ($isTextNode(node)) { | ||
| 494 | return node.getTextContentSize(); | ||
| 495 | } | ||
| 496 | if ($isLineBreakNode(node)) { | ||
| 497 | return 1; | ||
| 498 | } | ||
| 499 | if ($isElementNode(node)) { | ||
| 500 | return node.getChildren().reduce((total, child) => total + getComposerNodeTextLength(child), 0); | ||
| 501 | } | ||
| 502 | return 0; | ||
| 503 | } | ||
| 504 | |||
| 505 | function getComposerNodeExpandedTextLength(node: LexicalNode): number { | ||
| 506 | if (isComposerInlineTokenNode(node)) { | ||
| 507 | return getComposerInlineTokenExpandedTextLength(node); | ||
| 508 | } | ||
| 509 | if ($isTextNode(node)) { | ||
| 510 | return node.getTextContentSize(); | ||
| 511 | } | ||
| 512 | if ($isLineBreakNode(node)) { | ||
| 513 | return 1; | ||
| 514 | } | ||
| 515 | if ($isElementNode(node)) { | ||
| 516 | return node | ||
| 517 | .getChildren() | ||
| 518 | .reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0); | ||
| 519 | } | ||
| 520 | return 0; | ||
| 521 | } | ||
| 522 | |||
| 523 | function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { | ||
| 524 | let offset = 0; | ||
| 525 | let current: LexicalNode | null = node; | ||
| 526 | |||
| 527 | while (current) { | ||
| 528 | const nextParent = current.getParent() as LexicalNode | null; | ||
| 529 | if (!nextParent || !$isElementNode(nextParent)) { | ||
| 530 | break; | ||
| 531 | } | ||
| 532 | const siblings = nextParent.getChildren(); | ||
| 533 | const index = current.getIndexWithinParent(); | ||
| 534 | for (let i = 0; i < index; i += 1) { | ||
| 535 | const sibling = siblings[i]; | ||
| 536 | if (!sibling) continue; | ||
| 537 | offset += getComposerNodeTextLength(sibling); | ||
| 538 | } | ||
| 539 | current = nextParent; | ||
| 540 | } | ||
| 541 | |||
| 542 | if ($isTextNode(node)) { | ||
| 543 | return offset + Math.min(pointOffset, node.getTextContentSize()); | ||
| 544 | } | ||
| 545 | if (isComposerInlineTokenNode(node)) { | ||
| 546 | return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); | ||
| 547 | } | ||
| 548 | |||
| 549 | if ($isLineBreakNode(node)) { | ||
| 550 | return offset + Math.min(pointOffset, 1); | ||
| 551 | } | ||
| 552 | |||
| 553 | if ($isElementNode(node)) { | ||
| 554 | const children = node.getChildren(); | ||
| 555 | const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); | ||
| 556 | for (let i = 0; i < clampedOffset; i += 1) { | ||
| 557 | const child = children[i]; | ||
| 558 | if (!child) continue; | ||
| 559 | offset += getComposerNodeTextLength(child); | ||
| 560 | } | ||
| 561 | return offset; | ||
| 562 | } | ||
| 563 | |||
| 564 | return offset; | ||
| 565 | } | ||
| 566 | |||
| 567 | function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { | ||
| 568 | let offset = 0; | ||
| 569 | let current: LexicalNode | null = node; | ||
| 570 | |||
| 571 | while (current) { | ||
| 572 | const nextParent = current.getParent() as LexicalNode | null; | ||
| 573 | if (!nextParent || !$isElementNode(nextParent)) { | ||
| 574 | break; | ||
| 575 | } | ||
| 576 | const siblings = nextParent.getChildren(); | ||
| 577 | const index = current.getIndexWithinParent(); | ||
| 578 | for (let i = 0; i < index; i += 1) { | ||
| 579 | const sibling = siblings[i]; | ||
| 580 | if (!sibling) continue; | ||
| 581 | offset += getComposerNodeExpandedTextLength(sibling); | ||
| 582 | } | ||
| 583 | current = nextParent; | ||
| 584 | } | ||
| 585 | |||
| 586 | if ($isTextNode(node)) { | ||
| 587 | return offset + Math.min(pointOffset, node.getTextContentSize()); | ||
| 588 | } | ||
| 589 | if (isComposerInlineTokenNode(node)) { | ||
| 590 | return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); | ||
| 591 | } | ||
| 592 | |||
| 593 | if ($isLineBreakNode(node)) { | ||
| 594 | return offset + Math.min(pointOffset, 1); | ||
| 595 | } | ||
| 596 | |||
| 597 | if ($isElementNode(node)) { | ||
| 598 | const children = node.getChildren(); | ||
| 599 | const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); | ||
| 600 | for (let i = 0; i < clampedOffset; i += 1) { | ||
| 601 | const child = children[i]; | ||
| 602 | if (!child) continue; | ||
| 603 | offset += getComposerNodeExpandedTextLength(child); | ||
| 604 | } | ||
| 605 | return offset; | ||
| 606 | } | ||
| 607 | |||
| 608 | return offset; | ||
| 609 | } | ||
| 610 | |||
| 611 | function findSelectionPointAtOffset( | ||
| 612 | node: LexicalNode, | ||
| 613 | remainingRef: { value: number }, | ||
| 614 | ): { key: string; offset: number; type: "text" | "element" } | null { | ||
| 615 | if (isComposerInlineTokenNode(node)) { | ||
| 616 | return findSelectionPointForInlineToken(node, remainingRef); | ||
| 617 | } | ||
| 618 | |||
| 619 | if ($isTextNode(node)) { | ||
| 620 | const size = node.getTextContentSize(); | ||
| 621 | if (remainingRef.value <= size) { | ||
| 622 | return { | ||
| 623 | key: node.getKey(), | ||
| 624 | offset: remainingRef.value, | ||
| 625 | type: "text", | ||
| 626 | }; | ||
| 627 | } | ||
| 628 | remainingRef.value -= size; | ||
| 629 | return null; | ||
| 630 | } | ||
| 631 | |||
| 632 | if ($isLineBreakNode(node)) { | ||
| 633 | const parent = node.getParent(); | ||
| 634 | if (!parent) return null; | ||
| 635 | const index = node.getIndexWithinParent(); | ||
| 636 | if (remainingRef.value === 0) { | ||
| 637 | return { | ||
| 638 | key: parent.getKey(), | ||
| 639 | offset: index, | ||
| 640 | type: "element", | ||
| 641 | }; | ||
| 642 | } | ||
| 643 | if (remainingRef.value === 1) { | ||
| 644 | return { | ||
| 645 | key: parent.getKey(), | ||
| 646 | offset: index + 1, | ||
| 647 | type: "element", | ||
| 648 | }; | ||
| 649 | } | ||
| 650 | remainingRef.value -= 1; | ||
| 651 | return null; | ||
| 652 | } | ||
| 653 | |||
| 654 | if ($isElementNode(node)) { | ||
| 655 | const children = node.getChildren(); | ||
| 656 | for (const child of children) { | ||
| 657 | const point = findSelectionPointAtOffset(child, remainingRef); | ||
| 658 | if (point) { | ||
| 659 | return point; | ||
| 660 | } | ||
| 661 | } | ||
| 662 | if (remainingRef.value === 0) { | ||
| 663 | return { | ||
| 664 | key: node.getKey(), | ||
| 665 | offset: children.length, | ||
| 666 | type: "element", | ||
| 667 | }; | ||
| 668 | } | ||
| 669 | } | ||
| 670 | |||
| 671 | return null; | ||
| 672 | } | ||
| 673 | |||
| 674 | function $getComposerRootLength(): number { | ||
| 675 | const root = $getRoot(); | ||
| 676 | const children = root.getChildren(); | ||
| 677 | return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0); | ||
| 678 | } | ||
| 679 | |||
| 680 | function $setSelectionAtComposerOffset(nextOffset: number): void { | ||
| 681 | const root = $getRoot(); | ||
| 682 | const composerLength = $getComposerRootLength(); | ||
| 683 | const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength)); | ||
| 684 | const remainingRef = { value: boundedOffset }; | ||
| 685 | const point = findSelectionPointAtOffset(root, remainingRef) ?? { | ||
| 686 | key: root.getKey(), | ||
| 687 | offset: root.getChildren().length, | ||
| 688 | type: "element" as const, | ||
| 689 | }; | ||
| 690 | const selection = $createRangeSelection(); | ||
| 691 | selection.anchor.set(point.key, point.offset, point.type); | ||
| 692 | selection.focus.set(point.key, point.offset, point.type); | ||
| 693 | $setSelection(selection); | ||
| 694 | } | ||
| 695 | |||
| 696 | function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: number): void { | ||
| 697 | const root = $getRoot(); | ||
| 698 | const composerLength = $getComposerRootLength(); | ||
| 699 | const boundedStart = Math.max(0, Math.min(startOffset, composerLength)); | ||
| 700 | const boundedEnd = Math.max(0, Math.min(endOffset, composerLength)); | ||
| 701 | const anchorRemainingRef = { value: boundedStart }; | ||
| 702 | const focusRemainingRef = { value: boundedEnd }; | ||
| 703 | const anchorPoint = findSelectionPointAtOffset(root, anchorRemainingRef) ?? { | ||
| 704 | key: root.getKey(), | ||
| 705 | offset: root.getChildren().length, | ||
| 706 | type: "element" as const, | ||
| 707 | }; | ||
| 708 | const focusPoint = findSelectionPointAtOffset(root, focusRemainingRef) ?? { | ||
| 709 | key: root.getKey(), | ||
| 710 | offset: root.getChildren().length, | ||
| 711 | type: "element" as const, | ||
| 712 | }; | ||
| 713 | const selection = $createRangeSelection(); | ||
| 714 | selection.anchor.set(anchorPoint.key, anchorPoint.offset, anchorPoint.type); | ||
| 715 | selection.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type); | ||
| 716 | $setSelection(selection); | ||
| 717 | } | ||
| 718 | |||
| 719 | function getSelectionRangeForExpandedComposerOffsets(selection: ReturnType<typeof $getSelection>): { | ||
| 720 | start: number; | ||
| 721 | end: number; | ||
| 722 | } | null { | ||
| 723 | if (!$isRangeSelection(selection)) { | ||
| 724 | return null; | ||
| 725 | } | ||
| 726 | const anchorNode = selection.anchor.getNode(); | ||
| 727 | const focusNode = selection.focus.getNode(); | ||
| 728 | const anchorOffset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); | ||
| 729 | const focusOffset = getExpandedAbsoluteOffsetForPoint(focusNode, selection.focus.offset); | ||
| 730 | return { | ||
| 731 | start: Math.min(anchorOffset, focusOffset), | ||
| 732 | end: Math.max(anchorOffset, focusOffset), | ||
| 733 | }; | ||
| 734 | } | ||
| 735 | |||
| 736 | function $selectionTouchesInlineToken(selection: ReturnType<typeof $getSelection>): boolean { | ||
| 737 | if (!$isRangeSelection(selection)) { | ||
| 738 | return false; | ||
| 739 | } | ||
| 740 | return selection.getNodes().some((node) => isComposerInlineTokenNode(node)); | ||
| 741 | } | ||
| 742 | |||
| 743 | function $readSelectionOffsetFromEditorState(fallback: number): number { | ||
| 744 | const selection = $getSelection(); | ||
| 745 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) { | ||
| 746 | return fallback; | ||
| 747 | } | ||
| 748 | const anchorNode = selection.anchor.getNode(); | ||
| 749 | const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); | ||
| 750 | const composerLength = $getComposerRootLength(); | ||
| 751 | return Math.max(0, Math.min(offset, composerLength)); | ||
| 752 | } | ||
| 753 | |||
| 754 | function $readExpandedSelectionOffsetFromEditorState(fallback: number): number { | ||
| 755 | const selection = $getSelection(); | ||
| 756 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) { | ||
| 757 | return fallback; | ||
| 758 | } | ||
| 759 | const anchorNode = selection.anchor.getNode(); | ||
| 760 | const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); | ||
| 761 | const expandedLength = $getRoot().getTextContent().length; | ||
| 762 | return Math.max(0, Math.min(offset, expandedLength)); | ||
| 763 | } | ||
| 764 | |||
| 765 | function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { | ||
| 766 | const lines = text.split("\n"); | ||
| 767 | for (let index = 0; index < lines.length; index += 1) { | ||
| 768 | const line = lines[index] ?? ""; | ||
| 769 | if (line.length > 0) { | ||
| 770 | parent.append($createTextNode(line)); | ||
| 771 | } | ||
| 772 | if (index < lines.length - 1) { | ||
| 773 | parent.append($createLineBreakNode()); | ||
| 774 | } | ||
| 775 | } | ||
| 776 | } | ||
| 777 | |||
| 778 | function $setComposerEditorPrompt( | ||
| 779 | prompt: string, | ||
| 780 | skillMetadata: ReadonlyMap<string, ComposerSkillMetadata>, | ||
| 781 | ): void { | ||
| 782 | const root = $getRoot(); | ||
| 783 | root.clear(); | ||
| 784 | const paragraph = $createParagraphNode(); | ||
| 785 | root.append(paragraph); | ||
| 786 | |||
| 787 | const segments = splitPromptIntoComposerSegments(prompt); | ||
| 788 | for (const segment of segments) { | ||
| 789 | if (segment.type === "citation") { | ||
| 790 | paragraph.append($createComposerCitationNode(segment.citation, segment.source)); | ||
| 791 | continue; | ||
| 792 | } | ||
| 793 | if (segment.type === "mention") { | ||
| 794 | paragraph.append($createComposerMentionNode(segment.path, segment.source)); | ||
| 795 | continue; | ||
| 796 | } | ||
| 797 | if (segment.type === "skill") { | ||
| 798 | const metadata = skillMetadata.get(segment.name); | ||
| 799 | paragraph.append( | ||
| 800 | $createComposerSkillNode( | ||
| 801 | segment.name, | ||
| 802 | metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }), | ||
| 803 | metadata?.description ?? null, | ||
| 804 | ), | ||
| 805 | ); | ||
| 806 | continue; | ||
| 807 | } | ||
| 808 | if (segment.type === "context-reference") { | ||
| 809 | paragraph.append( | ||
| 810 | $createComposerContextReferenceNode({ | ||
| 811 | kind: segment.kind, | ||
| 812 | contextId: segment.contextId, | ||
| 813 | label: segment.label, | ||
| 814 | }), | ||
| 815 | ); | ||
| 816 | continue; | ||
| 817 | } | ||
| 818 | $appendTextWithLineBreaks(paragraph, segment.text); | ||
| 819 | } | ||
| 820 | } | ||
| 821 | |||
| 822 | function collectContextIdOccurrences(node: LexicalNode): string[] { | ||
| 823 | if (node instanceof ComposerContextReferenceNode) { | ||
| 824 | return [node.__contextId]; | ||
| 825 | } | ||
| 826 | if ($isElementNode(node)) { | ||
| 827 | return node.getChildren().flatMap((child) => collectContextIdOccurrences(child)); | ||
| 828 | } | ||
| 829 | return []; | ||
| 830 | } | ||
| 831 | |||
| 832 | /** Payload ids referenced by the document, once each in first-occurrence order. */ | ||
| 833 | function collectContextIds(node: LexicalNode): string[] { | ||
| 834 | return Array.from(new Set(collectContextIdOccurrences(node))); | ||
| 835 | } | ||
| 836 | |||
| 837 | export interface ComposerPromptEditorHandle { | ||
| 838 | focus: () => void; | ||
| 839 | focusAt: (cursor: number) => void; | ||
| 840 | focusAtEnd: () => void; | ||
| 841 | readSelectionRange: () => { start: number; end: number }; | ||
| 842 | requestCitationComment: (request: ComposerCitationCommentRequest) => void; | ||
| 843 | readSnapshot: () => { | ||
| 844 | value: string; | ||
| 845 | cursor: number; | ||
| 846 | expandedCursor: number; | ||
| 847 | contextIds: string[]; | ||
| 848 | }; | ||
| 849 | /** | ||
| 850 | * True when a collapsed caret sits on the first ("start") or last ("end") | ||
| 851 | * visual line, counting soft wraps. Prompt history only claims ArrowUp and | ||
| 852 | * ArrowDown at these edges so arrows still move the caret inside multiline | ||
| 853 | * text. | ||
| 854 | */ | ||
| 855 | isCaretOnVisualEdge: (edge: "start" | "end") => boolean; | ||
| 856 | } | ||
| 857 | |||
| 858 | interface ComposerPromptEditorProps { | ||
| 859 | value: string; | ||
| 860 | cursor: number; | ||
| 861 | /** Draft records behind the prompt's context references, keyed by context id. */ | ||
| 862 | contextRecords: ComposerDraftContextRecords; | ||
| 863 | /** Structured clipboard payload for the given referenced ids, or null to skip. */ | ||
| 864 | buildContextClipboardFragment?: | ||
| 865 | | ((contextIds: ReadonlyArray<string>) => string | null) | ||
| 866 | | undefined; | ||
| 867 | /** Imports a structured paste's records; returns ids that changed. */ | ||
| 868 | importContextFragment?: | ||
| 869 | | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap<string, string>) | ||
| 870 | | undefined; | ||
| 871 | skills: ReadonlyArray<ServerProviderSkill>; | ||
| 872 | disabled: boolean; | ||
| 873 | placeholder: string; | ||
| 874 | containerClassName?: string; | ||
| 875 | className?: string; | ||
| 876 | placeholderClassName?: string; | ||
| 877 | onChange: ( | ||
| 878 | nextValue: string, | ||
| 879 | nextCursor: number, | ||
| 880 | expandedCursor: number, | ||
| 881 | cursorAdjacentToMention: boolean, | ||
| 882 | contextIds: string[], | ||
| 883 | ) => void; | ||
| 884 | onVisibleSelectionChange?: () => void; | ||
| 885 | onCommandKeyDown?: ( | ||
| 886 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | ||
| 887 | event: KeyboardEvent, | ||
| 888 | ) => boolean; | ||
| 889 | onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; | ||
| 890 | onPageScrollKeyUp?: (key: string) => void; | ||
| 891 | onPageScrollRelease?: () => void; | ||
| 892 | onCitationSubmitAndSend?: () => void; | ||
| 893 | onPaste: React.ClipboardEventHandler<HTMLElement>; | ||
| 894 | editorRef: React.RefObject<ComposerPromptEditorHandle | null>; | ||
| 895 | } | ||
| 896 | 9 | ||
| 897 | /** | 10 | /** |
| 898 | * Client rect of the line the collapsed caret is on, as seen from `edge`. | 11 | * The composer editor. Tiptap in both modes: the `richTextEnabled` setting |
| 899 | * A caret at a soft-wrap boundary belongs to two visual lines and the | 12 | * toggles Markdown styling, never the engine. Plain mode renders every |
| 900 | * range reports a rect for each, so take the one farthest from the edge | 13 | * marker as a literal character and serializes byte-identically. |
| 901 | * under test: an ambiguous caret then never claims the key and the arrow | ||
| 902 | * moves the caret as usual. A collapsed range reports zero-height rects at | ||
| 903 | * some positions, so probe the adjacent character on the same side. When | ||
| 904 | * the range container is the paragraph itself (an empty line, or a caret | ||
| 905 | * beside an inline chip) measure the child next to the caret before | ||
| 906 | * falling back to the paragraph. | ||
| 907 | */ | 14 | */ |
| 908 | function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { | 15 | export function ComposerPromptEditor(props: ComposerPromptEditorProps) { |
| 909 | const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); | 16 | return <ComposerPromptEditorTiptap {...props} />; |
| 910 | const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; | ||
| 911 | if (collapsedRect) return collapsedRect; | ||
| 912 | |||
| 913 | const container = range.startContainer; | ||
| 914 | if (container.nodeType === Node.TEXT_NODE) { | ||
| 915 | const textNode = container as Text; | ||
| 916 | if (textNode.data.length === 0) return null; | ||
| 917 | const probeStart = Math.max( | ||
| 918 | 0, | ||
| 919 | Math.min( | ||
| 920 | edge === "start" ? range.startOffset : range.startOffset - 1, | ||
| 921 | textNode.data.length - 1, | ||
| 922 | ), | ||
| 923 | ); | ||
| 924 | const probeRange = document.createRange(); | ||
| 925 | probeRange.setStart(textNode, probeStart); | ||
| 926 | probeRange.setEnd(textNode, probeStart + 1); | ||
| 927 | const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); | ||
| 928 | if (probeRect) return probeRect; | ||
| 929 | const boundingRect = probeRange.getBoundingClientRect(); | ||
| 930 | return boundingRect.height > 0 ? boundingRect : null; | ||
| 931 | } | ||
| 932 | |||
| 933 | if (!(container instanceof HTMLElement)) return null; | ||
| 934 | // The caret sits between the paragraph's children, which is where Lexical | ||
| 935 | // puts it next to an inline chip. Measure the neighbouring child. | ||
| 936 | const neighbour = | ||
| 937 | container.childNodes[Math.max(0, range.startOffset - 1)] ?? | ||
| 938 | container.childNodes[range.startOffset]; | ||
| 939 | if (neighbour instanceof HTMLElement) { | ||
| 940 | const neighbourRect = neighbour.getBoundingClientRect(); | ||
| 941 | if (neighbourRect.height > 0) return neighbourRect; | ||
| 942 | } else if (neighbour instanceof Text && neighbour.data.length > 0) { | ||
| 943 | // Probe the character on the caret's side. A soft-wrapped text node's | ||
| 944 | // first rect is its first visual line, which may not be the caret's. | ||
| 945 | const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; | ||
| 946 | const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; | ||
| 947 | const probeRange = document.createRange(); | ||
| 948 | probeRange.setStart(neighbour, probeStart); | ||
| 949 | probeRange.setEnd(neighbour, probeStart + 1); | ||
| 950 | const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); | ||
| 951 | if (probeRect) return probeRect; | ||
| 952 | } | ||
| 953 | const containerRect = container.getBoundingClientRect(); | ||
| 954 | return containerRect.height > 0 ? containerRect : null; | ||
| 955 | } | ||
| 956 | |||
| 957 | function ComposerCommandKeyPlugin(props: { | ||
| 958 | onCommandKeyDown?: ( | ||
| 959 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | ||
| 960 | event: KeyboardEvent, | ||
| 961 | ) => boolean; | ||
| 962 | }) { | ||
| 963 | const [editor] = useLexicalComposerContext(); | ||
| 964 | |||
| 965 | useEffect(() => { | ||
| 966 | const handleCommand = ( | ||
| 967 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | ||
| 968 | event: KeyboardEvent | null, | ||
| 969 | ): boolean => { | ||
| 970 | if (!props.onCommandKeyDown || !event) { | ||
| 971 | return false; | ||
| 972 | } | ||
| 973 | |||
| 974 | if (key === "Enter" && (event.isComposing || event.keyCode === 229)) { | ||
| 975 | event.stopPropagation(); | ||
| 976 | return true; | ||
| 977 | } | ||
| 978 | |||
| 979 | const handled = props.onCommandKeyDown(key, event); | ||
| 980 | if (handled) { | ||
| 981 | event.preventDefault(); | ||
| 982 | event.stopPropagation(); | ||
| 983 | } | ||
| 984 | return handled; | ||
| 985 | }; | ||
| 986 | |||
| 987 | const unregisterArrowDown = editor.registerCommand( | ||
| 988 | KEY_ARROW_DOWN_COMMAND, | ||
| 989 | (event) => handleCommand("ArrowDown", event), | ||
| 990 | COMMAND_PRIORITY_HIGH, | ||
| 991 | ); | ||
| 992 | const unregisterArrowUp = editor.registerCommand( | ||
| 993 | KEY_ARROW_UP_COMMAND, | ||
| 994 | (event) => handleCommand("ArrowUp", event), | ||
| 995 | COMMAND_PRIORITY_HIGH, | ||
| 996 | ); | ||
| 997 | const unregisterEnter = editor.registerCommand( | ||
| 998 | KEY_ENTER_COMMAND, | ||
| 999 | (event) => handleCommand("Enter", event), | ||
| 1000 | COMMAND_PRIORITY_HIGH, | ||
| 1001 | ); | ||
| 1002 | const unregisterTab = editor.registerCommand( | ||
| 1003 | KEY_TAB_COMMAND, | ||
| 1004 | (event) => handleCommand("Tab", event), | ||
| 1005 | COMMAND_PRIORITY_HIGH, | ||
| 1006 | ); | ||
| 1007 | |||
| 1008 | return () => { | ||
| 1009 | unregisterArrowDown(); | ||
| 1010 | unregisterArrowUp(); | ||
| 1011 | unregisterEnter(); | ||
| 1012 | unregisterTab(); | ||
| 1013 | }; | ||
| 1014 | }, [editor, props]); | ||
| 1015 | |||
| 1016 | return null; | ||
| 1017 | } | ||
| 1018 | |||
| 1019 | function ComposerInlineTokenArrowPlugin() { | ||
| 1020 | const [editor] = useLexicalComposerContext(); | ||
| 1021 | |||
| 1022 | useEffect(() => { | ||
| 1023 | const unregisterLeft = editor.registerCommand( | ||
| 1024 | KEY_ARROW_LEFT_COMMAND, | ||
| 1025 | (event) => { | ||
| 1026 | let nextOffset: number | null = null; | ||
| 1027 | editor.getEditorState().read(() => { | ||
| 1028 | const selection = $getSelection(); | ||
| 1029 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; | ||
| 1030 | const currentOffset = $readSelectionOffsetFromEditorState(0); | ||
| 1031 | if (currentOffset <= 0) return; | ||
| 1032 | const promptValue = $getRoot().getTextContent(); | ||
| 1033 | if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { | ||
| 1034 | return; | ||
| 1035 | } | ||
| 1036 | nextOffset = currentOffset - 1; | ||
| 1037 | }); | ||
| 1038 | if (nextOffset === null) return false; | ||
| 1039 | const selectionOffset = nextOffset; | ||
| 1040 | event?.preventDefault(); | ||
| 1041 | event?.stopPropagation(); | ||
| 1042 | editor.update(() => { | ||
| 1043 | $setSelectionAtComposerOffset(selectionOffset); | ||
| 1044 | }); | ||
| 1045 | return true; | ||
| 1046 | }, | ||
| 1047 | COMMAND_PRIORITY_HIGH, | ||
| 1048 | ); | ||
| 1049 | const unregisterRight = editor.registerCommand( | ||
| 1050 | KEY_ARROW_RIGHT_COMMAND, | ||
| 1051 | (event) => { | ||
| 1052 | let nextOffset: number | null = null; | ||
| 1053 | editor.getEditorState().read(() => { | ||
| 1054 | const selection = $getSelection(); | ||
| 1055 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; | ||
| 1056 | const currentOffset = $readSelectionOffsetFromEditorState(0); | ||
| 1057 | const composerLength = $getComposerRootLength(); | ||
| 1058 | if (currentOffset >= composerLength) return; | ||
| 1059 | const promptValue = $getRoot().getTextContent(); | ||
| 1060 | if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { | ||
| 1061 | return; | ||
| 1062 | } | ||
| 1063 | nextOffset = currentOffset + 1; | ||
| 1064 | }); | ||
| 1065 | if (nextOffset === null) return false; | ||
| 1066 | const selectionOffset = nextOffset; | ||
| 1067 | event?.preventDefault(); | ||
| 1068 | event?.stopPropagation(); | ||
| 1069 | editor.update(() => { | ||
| 1070 | $setSelectionAtComposerOffset(selectionOffset); | ||
| 1071 | }); | ||
| 1072 | return true; | ||
| 1073 | }, | ||
| 1074 | COMMAND_PRIORITY_HIGH, | ||
| 1075 | ); | ||
| 1076 | return () => { | ||
| 1077 | unregisterLeft(); | ||
| 1078 | unregisterRight(); | ||
| 1079 | }; | ||
| 1080 | }, [editor]); | ||
| 1081 | |||
| 1082 | return null; | ||
| 1083 | } | ||
| 1084 | |||
| 1085 | function ComposerHomeEndKeyPlugin() { | ||
| 1086 | const [editor] = useLexicalComposerContext(); | ||
| 1087 | |||
| 1088 | useEffect(() => { | ||
| 1089 | return editor.registerCommand( | ||
| 1090 | KEY_DOWN_COMMAND, | ||
| 1091 | (event) => { | ||
| 1092 | if (!isMacPlatform(navigator.platform)) { | ||
| 1093 | return false; | ||
| 1094 | } | ||
| 1095 | if (event.key !== "Home" && event.key !== "End") { | ||
| 1096 | return false; | ||
| 1097 | } | ||
| 1098 | if (event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { | ||
| 1099 | return false; | ||
| 1100 | } | ||
| 1101 | |||
| 1102 | const rootElement = editor.getRootElement(); | ||
| 1103 | const selection = window.getSelection(); | ||
| 1104 | const anchorNode = selection?.anchorNode; | ||
| 1105 | if (!rootElement || !selection || !anchorNode || !rootElement.contains(anchorNode)) { | ||
| 1106 | return false; | ||
| 1107 | } | ||
| 1108 | if (selection.rangeCount === 0 || typeof selection.modify !== "function") { | ||
| 1109 | return false; | ||
| 1110 | } | ||
| 1111 | |||
| 1112 | event.preventDefault(); | ||
| 1113 | event.stopPropagation(); | ||
| 1114 | |||
| 1115 | selection.modify( | ||
| 1116 | event.shiftKey ? "extend" : "move", | ||
| 1117 | event.key === "Home" ? "backward" : "forward", | ||
| 1118 | "lineboundary", | ||
| 1119 | ); | ||
| 1120 | editor.update(() => { | ||
| 1121 | $setSelection($createRangeSelectionFromDom(selection, editor)); | ||
| 1122 | }); | ||
| 1123 | return true; | ||
| 1124 | }, | ||
| 1125 | COMMAND_PRIORITY_HIGH, | ||
| 1126 | ); | ||
| 1127 | }, [editor]); | ||
| 1128 | |||
| 1129 | return null; | ||
| 1130 | } | ||
| 1131 | |||
| 1132 | function ComposerInlineTokenSelectionNormalizePlugin() { | ||
| 1133 | const [editor] = useLexicalComposerContext(); | ||
| 1134 | |||
| 1135 | useEffect(() => { | ||
| 1136 | return editor.registerUpdateListener(({ editorState }) => { | ||
| 1137 | let afterOffset: number | null = null; | ||
| 1138 | editorState.read(() => { | ||
| 1139 | const selection = $getSelection(); | ||
| 1140 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; | ||
| 1141 | const anchorNode = selection.anchor.getNode(); | ||
| 1142 | if (!isComposerInlineTokenNode(anchorNode)) return; | ||
| 1143 | if (selection.anchor.offset === 0) return; | ||
| 1144 | const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0); | ||
| 1145 | afterOffset = beforeOffset + 1; | ||
| 1146 | }); | ||
| 1147 | if (afterOffset !== null) { | ||
| 1148 | queueMicrotask(() => { | ||
| 1149 | editor.update(() => { | ||
| 1150 | $setSelectionAtComposerOffset(afterOffset!); | ||
| 1151 | }); | ||
| 1152 | }); | ||
| 1153 | } | ||
| 1154 | }); | ||
| 1155 | }, [editor]); | ||
| 1156 | |||
| 1157 | return null; | ||
| 1158 | } | ||
| 1159 | |||
| 1160 | function ComposerInlineTokenBackspacePlugin() { | ||
| 1161 | const [editor] = useLexicalComposerContext(); | ||
| 1162 | |||
| 1163 | useEffect(() => { | ||
| 1164 | return editor.registerCommand( | ||
| 1165 | KEY_BACKSPACE_COMMAND, | ||
| 1166 | (event) => { | ||
| 1167 | const selection = $getSelection(); | ||
| 1168 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) { | ||
| 1169 | return false; | ||
| 1170 | } | ||
| 1171 | |||
| 1172 | const anchorNode = selection.anchor.getNode(); | ||
| 1173 | const removeInlineTokenNode = (candidate: unknown): boolean => { | ||
| 1174 | if (!isComposerInlineTokenNode(candidate)) { | ||
| 1175 | return false; | ||
| 1176 | } | ||
| 1177 | const tokenStart = getAbsoluteOffsetForPoint(candidate, 0); | ||
| 1178 | candidate.remove(); | ||
| 1179 | $setSelectionAtComposerOffset(tokenStart); | ||
| 1180 | event?.preventDefault(); | ||
| 1181 | return true; | ||
| 1182 | }; | ||
| 1183 | if (removeInlineTokenNode(anchorNode)) { | ||
| 1184 | return true; | ||
| 1185 | } | ||
| 1186 | |||
| 1187 | if ($isTextNode(anchorNode)) { | ||
| 1188 | if (selection.anchor.offset > 0) { | ||
| 1189 | return false; | ||
| 1190 | } | ||
| 1191 | if (removeInlineTokenNode(anchorNode.getPreviousSibling())) { | ||
| 1192 | return true; | ||
| 1193 | } | ||
| 1194 | const parent = anchorNode.getParent(); | ||
| 1195 | if ($isElementNode(parent)) { | ||
| 1196 | const index = anchorNode.getIndexWithinParent(); | ||
| 1197 | if (index > 0 && removeInlineTokenNode(parent.getChildAtIndex(index - 1))) { | ||
| 1198 | return true; | ||
| 1199 | } | ||
| 1200 | } | ||
| 1201 | return false; | ||
| 1202 | } | ||
| 1203 | |||
| 1204 | if ($isElementNode(anchorNode)) { | ||
| 1205 | const childIndex = selection.anchor.offset - 1; | ||
| 1206 | if (childIndex >= 0 && removeInlineTokenNode(anchorNode.getChildAtIndex(childIndex))) { | ||
| 1207 | return true; | ||
| 1208 | } | ||
| 1209 | } | ||
| 1210 | |||
| 1211 | return false; | ||
| 1212 | }, | ||
| 1213 | COMMAND_PRIORITY_HIGH, | ||
| 1214 | ); | ||
| 1215 | }, [editor]); | ||
| 1216 | |||
| 1217 | return null; | ||
| 1218 | } | ||
| 1219 | |||
| 1220 | /** | ||
| 1221 | * Chips render as non-editable decorators, so the browser never paints the | ||
| 1222 | * native text selection over them; without help, a selection spanning chips | ||
| 1223 | * is only visible in the slivers between them. Mirror the selection onto the | ||
| 1224 | * chips with a data attribute the stylesheet turns into a highlight overlay. | ||
| 1225 | */ | ||
| 1226 | function ComposerChipSelectionPlugin() { | ||
| 1227 | const [editor] = useLexicalComposerContext(); | ||
| 1228 | |||
| 1229 | useEffect(() => { | ||
| 1230 | let selectedKeys = new Set<string>(); | ||
| 1231 | // Lexical keeps the range selection on blur without emitting an update, | ||
| 1232 | // so focus is tracked separately; while blurred the native highlight is | ||
| 1233 | // gone and the mirrored one has to go with it. | ||
| 1234 | let hasFocus = editor.getRootElement() === document.activeElement; | ||
| 1235 | |||
| 1236 | const applyKeys = (nextKeys: Set<string>) => { | ||
| 1237 | for (const key of selectedKeys) { | ||
| 1238 | if (!nextKeys.has(key)) { | ||
| 1239 | editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected"); | ||
| 1240 | } | ||
| 1241 | } | ||
| 1242 | for (const key of nextKeys) { | ||
| 1243 | editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true"); | ||
| 1244 | } | ||
| 1245 | selectedKeys = nextKeys; | ||
| 1246 | }; | ||
| 1247 | |||
| 1248 | const readSelectedKeys = () => { | ||
| 1249 | const nextKeys = new Set<string>(); | ||
| 1250 | editor.getEditorState().read(() => { | ||
| 1251 | const selection = $getSelection(); | ||
| 1252 | if ($isRangeSelection(selection) && !selection.isCollapsed()) { | ||
| 1253 | for (const node of selection.getNodes()) { | ||
| 1254 | if (node instanceof DecoratorNode) { | ||
| 1255 | nextKeys.add(node.getKey()); | ||
| 1256 | } | ||
| 1257 | } | ||
| 1258 | } | ||
| 1259 | }); | ||
| 1260 | return nextKeys; | ||
| 1261 | }; | ||
| 1262 | |||
| 1263 | const unregisterUpdate = editor.registerUpdateListener(() => { | ||
| 1264 | applyKeys(hasFocus ? readSelectedKeys() : new Set()); | ||
| 1265 | }); | ||
| 1266 | const unregisterFocus = editor.registerCommand( | ||
| 1267 | FOCUS_COMMAND, | ||
| 1268 | () => { | ||
| 1269 | hasFocus = true; | ||
| 1270 | applyKeys(readSelectedKeys()); | ||
| 1271 | return false; | ||
| 1272 | }, | ||
| 1273 | COMMAND_PRIORITY_LOW, | ||
| 1274 | ); | ||
| 1275 | const unregisterBlur = editor.registerCommand( | ||
| 1276 | BLUR_COMMAND, | ||
| 1277 | () => { | ||
| 1278 | hasFocus = false; | ||
| 1279 | applyKeys(new Set()); | ||
| 1280 | return false; | ||
| 1281 | }, | ||
| 1282 | COMMAND_PRIORITY_LOW, | ||
| 1283 | ); | ||
| 1284 | return () => { | ||
| 1285 | unregisterUpdate(); | ||
| 1286 | unregisterFocus(); | ||
| 1287 | unregisterBlur(); | ||
| 1288 | }; | ||
| 1289 | }, [editor]); | ||
| 1290 | |||
| 1291 | return null; | ||
| 1292 | } | ||
| 1293 | |||
| 1294 | function ComposerInlineTokenPastePlugin(props: { | ||
| 1295 | importContextFragment?: ComposerPromptEditorProps["importContextFragment"]; | ||
| 1296 | }) { | ||
| 1297 | const [editor] = useLexicalComposerContext(); | ||
| 1298 | const importContextFragment = props.importContextFragment; | ||
| 1299 | |||
| 1300 | useEffect( | ||
| 1301 | () => | ||
| 1302 | registerComposerInlineTokenPaste(editor, { | ||
| 1303 | createMentionNode: $createComposerMentionNode, | ||
| 1304 | createCitationNode: $createComposerCitationNode, | ||
| 1305 | createContextReferenceNode: $createComposerContextReferenceNode, | ||
| 1306 | getExpandedAbsoluteOffsetForPoint, | ||
| 1307 | ...(importContextFragment ? { importContextFragment } : {}), | ||
| 1308 | }), | ||
| 1309 | [editor, importContextFragment], | ||
| 1310 | ); | ||
| 1311 | |||
| 1312 | return null; | ||
| 1313 | } | ||
| 1314 | |||
| 1315 | /** | ||
| 1316 | * Copying chips must carry their payloads: the default copy writes the canonical links as | ||
| 1317 | * text, and this adds the structured fragment for the referenced records beside it. | ||
| 1318 | */ | ||
| 1319 | function ComposerContextClipboardPlugin(props: { | ||
| 1320 | buildContextClipboardFragment?: ComposerPromptEditorProps["buildContextClipboardFragment"]; | ||
| 1321 | }) { | ||
| 1322 | const [editor] = useLexicalComposerContext(); | ||
| 1323 | const build = props.buildContextClipboardFragment; | ||
| 1324 | |||
| 1325 | useEffect(() => { | ||
| 1326 | if (!build) return; | ||
| 1327 | const listener = (event: ClipboardEvent | KeyboardEvent | null, cut: boolean) => { | ||
| 1328 | if (!event || !("clipboardData" in event) || !event.clipboardData) return false; | ||
| 1329 | const selection = $getSelection(); | ||
| 1330 | if (!$isRangeSelection(selection) || selection.isCollapsed()) return false; | ||
| 1331 | const text = selection.getTextContent(); | ||
| 1332 | const contextIds = collectInlineContextIds(text); | ||
| 1333 | if (contextIds.length === 0) return false; | ||
| 1334 | const fragment = build(contextIds); | ||
| 1335 | if (!fragment) return false; | ||
| 1336 | event.preventDefault(); | ||
| 1337 | event.clipboardData.setData("text/plain", text); | ||
| 1338 | event.clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment); | ||
| 1339 | event.clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment)); | ||
| 1340 | if (cut) selection.removeText(); | ||
| 1341 | return true; | ||
| 1342 | }; | ||
| 1343 | const unregisterCopy = editor.registerCommand( | ||
| 1344 | COPY_COMMAND, | ||
| 1345 | (event) => listener(event, false), | ||
| 1346 | COMMAND_PRIORITY_HIGH, | ||
| 1347 | ); | ||
| 1348 | const unregisterCut = editor.registerCommand( | ||
| 1349 | CUT_COMMAND, | ||
| 1350 | (event) => listener(event, true), | ||
| 1351 | COMMAND_PRIORITY_HIGH, | ||
| 1352 | ); | ||
| 1353 | return () => { | ||
| 1354 | unregisterCopy(); | ||
| 1355 | unregisterCut(); | ||
| 1356 | }; | ||
| 1357 | }, [build, editor]); | ||
| 1358 | |||
| 1359 | return null; | ||
| 1360 | } | ||
| 1361 | |||
| 1362 | function ComposerSurroundSelectionPlugin(props: { skills: ReadonlyArray<ServerProviderSkill> }) { | ||
| 1363 | const [editor] = useLexicalComposerContext(); | ||
| 1364 | const skillMetadataRef = useRef(skillMetadataByName(props.skills)); | ||
| 1365 | const pendingSurroundSelectionRef = useRef<{ | ||
| 1366 | value: string; | ||
| 1367 | expandedStart: number; | ||
| 1368 | expandedEnd: number; | ||
| 1369 | } | null>(null); | ||
| 1370 | const pendingDeadKeySelectionRef = useRef<{ | ||
| 1371 | value: string; | ||
| 1372 | expandedStart: number; | ||
| 1373 | expandedEnd: number; | ||
| 1374 | } | null>(null); | ||
| 1375 | |||
| 1376 | useEffect(() => { | ||
| 1377 | skillMetadataRef.current = skillMetadataByName(props.skills); | ||
| 1378 | }, [props.skills]); | ||
| 1379 | |||
| 1380 | const applySurroundInsertion = useEffectEvent((inputData: string): boolean => { | ||
| 1381 | const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); | ||
| 1382 | const pendingSurroundSelection = pendingSurroundSelectionRef.current; | ||
| 1383 | if (!surroundCloseSymbol) { | ||
| 1384 | pendingSurroundSelectionRef.current = null; | ||
| 1385 | return false; | ||
| 1386 | } | ||
| 1387 | |||
| 1388 | let handled = false; | ||
| 1389 | editor.update(() => { | ||
| 1390 | const selectionSnapshot = | ||
| 1391 | pendingSurroundSelection ?? | ||
| 1392 | (() => { | ||
| 1393 | const selection = $getSelection(); | ||
| 1394 | if (!$isRangeSelection(selection) || selection.isCollapsed()) { | ||
| 1395 | return null; | ||
| 1396 | } | ||
| 1397 | if ($selectionTouchesInlineToken(selection)) { | ||
| 1398 | return null; | ||
| 1399 | } | ||
| 1400 | const range = getSelectionRangeForExpandedComposerOffsets(selection); | ||
| 1401 | if (!range || range.start === range.end) { | ||
| 1402 | return null; | ||
| 1403 | } | ||
| 1404 | const value = $getRoot().getTextContent(); | ||
| 1405 | if (selectionTouchesMentionBoundary(value, range.start, range.end)) { | ||
| 1406 | return null; | ||
| 1407 | } | ||
| 1408 | return { | ||
| 1409 | value, | ||
| 1410 | expandedStart: range.start, | ||
| 1411 | expandedEnd: range.end, | ||
| 1412 | }; | ||
| 1413 | })(); | ||
| 1414 | |||
| 1415 | if (!selectionSnapshot || !surroundCloseSymbol) { | ||
| 1416 | return; | ||
| 1417 | } | ||
| 1418 | |||
| 1419 | const selectedText = selectionSnapshot.value.slice( | ||
| 1420 | selectionSnapshot.expandedStart, | ||
| 1421 | selectionSnapshot.expandedEnd, | ||
| 1422 | ); | ||
| 1423 | const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; | ||
| 1424 | $setComposerEditorPrompt(nextValue, skillMetadataRef.current); | ||
| 1425 | const selectionStart = collapseExpandedComposerCursor( | ||
| 1426 | nextValue, | ||
| 1427 | selectionSnapshot.expandedStart, | ||
| 1428 | ); | ||
| 1429 | $setSelectionRangeAtComposerOffsets( | ||
| 1430 | selectionStart + inputData.length, | ||
| 1431 | selectionStart + inputData.length + selectedText.length, | ||
| 1432 | ); | ||
| 1433 | handled = true; | ||
| 1434 | pendingSurroundSelectionRef.current = null; | ||
| 1435 | }); | ||
| 1436 | |||
| 1437 | return handled; | ||
| 1438 | }); | ||
| 1439 | |||
| 1440 | useEffect(() => { | ||
| 1441 | const onKeyDown = (event: KeyboardEvent) => { | ||
| 1442 | if (pendingDeadKeySelectionRef.current) { | ||
| 1443 | if (event.key === "Dead" || event.key === " " || event.code === "Space") { | ||
| 1444 | return; | ||
| 1445 | } | ||
| 1446 | pendingDeadKeySelectionRef.current = null; | ||
| 1447 | } | ||
| 1448 | |||
| 1449 | if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { | ||
| 1450 | pendingSurroundSelectionRef.current = null; | ||
| 1451 | pendingDeadKeySelectionRef.current = null; | ||
| 1452 | return; | ||
| 1453 | } | ||
| 1454 | |||
| 1455 | editor.getEditorState().read(() => { | ||
| 1456 | const selection = $getSelection(); | ||
| 1457 | if (!$isRangeSelection(selection) || selection.isCollapsed()) { | ||
| 1458 | pendingSurroundSelectionRef.current = null; | ||
| 1459 | pendingDeadKeySelectionRef.current = null; | ||
| 1460 | return; | ||
| 1461 | } | ||
| 1462 | if ($selectionTouchesInlineToken(selection)) { | ||
| 1463 | pendingSurroundSelectionRef.current = null; | ||
| 1464 | pendingDeadKeySelectionRef.current = null; | ||
| 1465 | return; | ||
| 1466 | } | ||
| 1467 | const range = getSelectionRangeForExpandedComposerOffsets(selection); | ||
| 1468 | if (!range || range.start === range.end) { | ||
| 1469 | pendingSurroundSelectionRef.current = null; | ||
| 1470 | pendingDeadKeySelectionRef.current = null; | ||
| 1471 | return; | ||
| 1472 | } | ||
| 1473 | const value = $getRoot().getTextContent(); | ||
| 1474 | if (selectionTouchesMentionBoundary(value, range.start, range.end)) { | ||
| 1475 | pendingSurroundSelectionRef.current = null; | ||
| 1476 | pendingDeadKeySelectionRef.current = null; | ||
| 1477 | return; | ||
| 1478 | } | ||
| 1479 | const snapshot = { | ||
| 1480 | value, | ||
| 1481 | expandedStart: range.start, | ||
| 1482 | expandedEnd: range.end, | ||
| 1483 | }; | ||
| 1484 | pendingSurroundSelectionRef.current = snapshot; | ||
| 1485 | pendingDeadKeySelectionRef.current = null; | ||
| 1486 | }); | ||
| 1487 | }; | ||
| 1488 | |||
| 1489 | const onBeforeInput = (event: InputEvent) => { | ||
| 1490 | if ( | ||
| 1491 | event.inputType === "insertCompositionText" && | ||
| 1492 | event.data === "`" && | ||
| 1493 | BACKTICK_SURROUND_CLOSE_SYMBOL !== null && | ||
| 1494 | pendingSurroundSelectionRef.current | ||
| 1495 | ) { | ||
| 1496 | pendingDeadKeySelectionRef.current = pendingSurroundSelectionRef.current; | ||
| 1497 | return; | ||
| 1498 | } | ||
| 1499 | |||
| 1500 | if (pendingDeadKeySelectionRef.current) { | ||
| 1501 | return; | ||
| 1502 | } | ||
| 1503 | |||
| 1504 | if (event.inputType === "insertCompositionText") { | ||
| 1505 | return; | ||
| 1506 | } | ||
| 1507 | |||
| 1508 | if (typeof event.data !== "string") { | ||
| 1509 | pendingSurroundSelectionRef.current = null; | ||
| 1510 | return; | ||
| 1511 | } | ||
| 1512 | const inputData = event.inputType === "insertText" ? event.data : null; | ||
| 1513 | if (!inputData || inputData.length !== 1) { | ||
| 1514 | pendingSurroundSelectionRef.current = null; | ||
| 1515 | return; | ||
| 1516 | } | ||
| 1517 | if (!applySurroundInsertion(inputData)) { | ||
| 1518 | return; | ||
| 1519 | } | ||
| 1520 | |||
| 1521 | event.preventDefault(); | ||
| 1522 | event.stopPropagation(); | ||
| 1523 | event.stopImmediatePropagation(); | ||
| 1524 | }; | ||
| 1525 | |||
| 1526 | const tryApplyDeadKeyBacktickSurround = (options?: { finalAttempt?: boolean }) => { | ||
| 1527 | queueMicrotask(() => { | ||
| 1528 | editor.update( | ||
| 1529 | () => { | ||
| 1530 | const pendingDeadKeySelection = pendingDeadKeySelectionRef.current; | ||
| 1531 | if (!pendingDeadKeySelection) { | ||
| 1532 | return; | ||
| 1533 | } | ||
| 1534 | |||
| 1535 | const currentValue = $getRoot().getTextContent(); | ||
| 1536 | const backtickCloseSymbol = BACKTICK_SURROUND_CLOSE_SYMBOL; | ||
| 1537 | if (backtickCloseSymbol === null) { | ||
| 1538 | pendingDeadKeySelectionRef.current = null; | ||
| 1539 | return; | ||
| 1540 | } | ||
| 1541 | |||
| 1542 | const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.expandedStart)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.expandedEnd)}`; | ||
| 1543 | if (currentValue !== expectedResolvedValue) { | ||
| 1544 | if (options?.finalAttempt) { | ||
| 1545 | pendingSurroundSelectionRef.current = null; | ||
| 1546 | pendingDeadKeySelectionRef.current = null; | ||
| 1547 | } | ||
| 1548 | return; | ||
| 1549 | } | ||
| 1550 | |||
| 1551 | const selectedText = pendingDeadKeySelection.value.slice( | ||
| 1552 | pendingDeadKeySelection.expandedStart, | ||
| 1553 | pendingDeadKeySelection.expandedEnd, | ||
| 1554 | ); | ||
| 1555 | const replacementStart = collapseExpandedComposerCursor( | ||
| 1556 | currentValue, | ||
| 1557 | pendingDeadKeySelection.expandedStart, | ||
| 1558 | ); | ||
| 1559 | $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1); | ||
| 1560 | const replacementSelection = $getSelection(); | ||
| 1561 | if (!$isRangeSelection(replacementSelection)) { | ||
| 1562 | pendingSurroundSelectionRef.current = null; | ||
| 1563 | pendingDeadKeySelectionRef.current = null; | ||
| 1564 | return; | ||
| 1565 | } | ||
| 1566 | replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`); | ||
| 1567 | $setSelectionRangeAtComposerOffsets( | ||
| 1568 | replacementStart + 1, | ||
| 1569 | replacementStart + 1 + selectedText.length, | ||
| 1570 | ); | ||
| 1571 | pendingSurroundSelectionRef.current = null; | ||
| 1572 | pendingDeadKeySelectionRef.current = null; | ||
| 1573 | }, | ||
| 1574 | { tag: HISTORY_MERGE_TAG }, | ||
| 1575 | ); | ||
| 1576 | }); | ||
| 1577 | }; | ||
| 1578 | |||
| 1579 | const onInput = (event: Event) => { | ||
| 1580 | const inputEvent = event as InputEvent; | ||
| 1581 | if ( | ||
| 1582 | inputEvent.inputType === "insertText" || | ||
| 1583 | inputEvent.inputType === "insertCompositionText" | ||
| 1584 | ) { | ||
| 1585 | tryApplyDeadKeyBacktickSurround(); | ||
| 1586 | } | ||
| 1587 | }; | ||
| 1588 | |||
| 1589 | const onCompositionEnd = () => { | ||
| 1590 | tryApplyDeadKeyBacktickSurround({ finalAttempt: true }); | ||
| 1591 | }; | ||
| 1592 | |||
| 1593 | let activeRootElement: HTMLElement | null = null; | ||
| 1594 | const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => { | ||
| 1595 | prevRootElement?.removeEventListener("keydown", onKeyDown); | ||
| 1596 | prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true); | ||
| 1597 | prevRootElement?.removeEventListener("input", onInput); | ||
| 1598 | prevRootElement?.removeEventListener("compositionend", onCompositionEnd); | ||
| 1599 | rootElement?.addEventListener("keydown", onKeyDown); | ||
| 1600 | rootElement?.addEventListener("beforeinput", onBeforeInput, true); | ||
| 1601 | rootElement?.addEventListener("input", onInput); | ||
| 1602 | rootElement?.addEventListener("compositionend", onCompositionEnd); | ||
| 1603 | activeRootElement = rootElement; | ||
| 1604 | }); | ||
| 1605 | |||
| 1606 | return () => { | ||
| 1607 | if (activeRootElement) { | ||
| 1608 | activeRootElement.removeEventListener("keydown", onKeyDown); | ||
| 1609 | activeRootElement.removeEventListener("beforeinput", onBeforeInput, true); | ||
| 1610 | activeRootElement.removeEventListener("input", onInput); | ||
| 1611 | activeRootElement.removeEventListener("compositionend", onCompositionEnd); | ||
| 1612 | } | ||
| 1613 | unregisterRootListener(); | ||
| 1614 | }; | ||
| 1615 | }, [editor]); | ||
| 1616 | |||
| 1617 | return null; | ||
| 1618 | } | ||
| 1619 | |||
| 1620 | function ComposerPromptEditorInner({ | ||
| 1621 | value, | ||
| 1622 | cursor, | ||
| 1623 | contextRecords, | ||
| 1624 | buildContextClipboardFragment, | ||
| 1625 | importContextFragment, | ||
| 1626 | skills, | ||
| 1627 | disabled, | ||
| 1628 | placeholder, | ||
| 1629 | containerClassName, | ||
| 1630 | className, | ||
| 1631 | placeholderClassName, | ||
| 1632 | onChange, | ||
| 1633 | onVisibleSelectionChange, | ||
| 1634 | onCommandKeyDown, | ||
| 1635 | onPageScrollKeyDown, | ||
| 1636 | onPageScrollKeyUp, | ||
| 1637 | onPageScrollRelease, | ||
| 1638 | onCitationSubmitAndSend, | ||
| 1639 | onPaste, | ||
| 1640 | editorRef, | ||
| 1641 | }: ComposerPromptEditorProps) { | ||
| 1642 | const [editor] = useLexicalComposerContext(); | ||
| 1643 | const onChangeRef = useRef(onChange); | ||
| 1644 | const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); | ||
| 1645 | const initialCursor = clampCollapsedComposerCursor(value, cursor); | ||
| 1646 | const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); | ||
| 1647 | const skillsSignature = skillSignature(skills); | ||
| 1648 | const skillsSignatureRef = useRef(skillsSignature); | ||
| 1649 | const skillMetadataRef = useRef(skillMetadataByName(skills)); | ||
| 1650 | const snapshotRef = useRef({ | ||
| 1651 | value, | ||
| 1652 | cursor: initialCursor, | ||
| 1653 | expandedCursor: initialExpandedCursor, | ||
| 1654 | contextIds: collectInlineContextIds(value), | ||
| 1655 | }); | ||
| 1656 | const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); | ||
| 1657 | const isApplyingControlledUpdateRef = useRef(false); | ||
| 1658 | // Latest controlled value, readable from editor listeners that fire before the layout | ||
| 1659 | // effect has rewritten the editor to match it. | ||
| 1660 | const latestValueRef = useRef(value); | ||
| 1661 | useLayoutEffect(() => { | ||
| 1662 | latestValueRef.current = value; | ||
| 1663 | }, [value]); | ||
| 1664 | const citationCommentRequestRef = useRef<ComposerCitationCommentRequest | null>(null); | ||
| 1665 | const [openCitationComment, setOpenCitationComment] = | ||
| 1666 | useState<ComposerCitationCommentTarget | null>(null); | ||
| 1667 | const citationCommentActions = useMemo( | ||
| 1668 | () => ({ | ||
| 1669 | openComment: openCitationComment, | ||
| 1670 | onOpenChange: (nodeKey: NodeKey, open: boolean) => { | ||
| 1671 | setOpenCitationComment((current) => | ||
| 1672 | open ? { nodeKey } : current?.nodeKey === nodeKey ? null : current, | ||
| 1673 | ); | ||
| 1674 | }, | ||
| 1675 | onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), | ||
| 1676 | }), | ||
| 1677 | [onCitationSubmitAndSend, openCitationComment], | ||
| 1678 | ); | ||
| 1679 | |||
| 1680 | useEffect(() => { | ||
| 1681 | onChangeRef.current = onChange; | ||
| 1682 | }, [onChange]); | ||
| 1683 | |||
| 1684 | useEffect(() => { | ||
| 1685 | onVisibleSelectionChangeRef.current = onVisibleSelectionChange; | ||
| 1686 | }, [onVisibleSelectionChange]); | ||
| 1687 | |||
| 1688 | useLayoutEffect(() => { | ||
| 1689 | skillMetadataRef.current = skillMetadataByName(skills); | ||
| 1690 | }, [skills]); | ||
| 1691 | |||
| 1692 | useEffect(() => { | ||
| 1693 | editor.setEditable(!disabled); | ||
| 1694 | }, [disabled, editor]); | ||
| 1695 | |||
| 1696 | useEffect(() => { | ||
| 1697 | const openCitationNodeKey = openCitationComment?.nodeKey; | ||
| 1698 | if (!openCitationNodeKey) return; | ||
| 1699 | return editor.registerUpdateListener(({ editorState }) => { | ||
| 1700 | const isAttached = editorState.read(() => { | ||
| 1701 | const node = $getNodeByKey(openCitationNodeKey); | ||
| 1702 | return node instanceof ComposerCitationNode && node.isAttached(); | ||
| 1703 | }); | ||
| 1704 | if (!isAttached) { | ||
| 1705 | setOpenCitationComment((current) => | ||
| 1706 | current?.nodeKey === openCitationNodeKey ? null : current, | ||
| 1707 | ); | ||
| 1708 | } | ||
| 1709 | }); | ||
| 1710 | }, [editor, openCitationComment?.nodeKey]); | ||
| 1711 | |||
| 1712 | useLayoutEffect(() => { | ||
| 1713 | const normalizedCursor = clampCollapsedComposerCursor(value, cursor); | ||
| 1714 | const previousSnapshot = snapshotRef.current; | ||
| 1715 | const skillsChanged = skillsSignatureRef.current !== skillsSignature; | ||
| 1716 | if ( | ||
| 1717 | previousSnapshot.value === value && | ||
| 1718 | previousSnapshot.cursor === normalizedCursor && | ||
| 1719 | !skillsChanged | ||
| 1720 | ) { | ||
| 1721 | return; | ||
| 1722 | } | ||
| 1723 | |||
| 1724 | const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); | ||
| 1725 | snapshotRef.current = { | ||
| 1726 | value, | ||
| 1727 | cursor: normalizedCursor, | ||
| 1728 | expandedCursor: normalizedExpandedCursor, | ||
| 1729 | contextIds: collectInlineContextIds(value), | ||
| 1730 | }; | ||
| 1731 | selectionRangeRef.current = { | ||
| 1732 | start: normalizedExpandedCursor, | ||
| 1733 | end: normalizedExpandedCursor, | ||
| 1734 | }; | ||
| 1735 | skillsSignatureRef.current = skillsSignature; | ||
| 1736 | |||
| 1737 | const rootElement = editor.getRootElement(); | ||
| 1738 | const isFocused = Boolean(rootElement && document.activeElement === rootElement); | ||
| 1739 | if (previousSnapshot.value === value && !skillsChanged && !isFocused) { | ||
| 1740 | return; | ||
| 1741 | } | ||
| 1742 | |||
| 1743 | isApplyingControlledUpdateRef.current = true; | ||
| 1744 | const isCiteInsertion = citationCommentRequestRef.current?.value === value; | ||
| 1745 | let citationToOpen: ComposerCitationCommentTarget | null = null; | ||
| 1746 | editor.update( | ||
| 1747 | () => { | ||
| 1748 | const shouldRewriteEditorState = previousSnapshot.value !== value || skillsChanged; | ||
| 1749 | if (shouldRewriteEditorState) { | ||
| 1750 | $setComposerEditorPrompt(value, skillMetadataRef.current); | ||
| 1751 | } | ||
| 1752 | if (shouldRewriteEditorState || isFocused) { | ||
| 1753 | $setSelectionAtComposerOffset(normalizedCursor); | ||
| 1754 | } | ||
| 1755 | citationToOpen = $consumeComposerCitationCommentRequest(citationCommentRequestRef); | ||
| 1756 | }, | ||
| 1757 | { | ||
| 1758 | ...(isCiteInsertion ? { tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] } : {}), | ||
| 1759 | onUpdate: () => { | ||
| 1760 | if (citationToOpen) setOpenCitationComment(citationToOpen); | ||
| 1761 | }, | ||
| 1762 | }, | ||
| 1763 | ); | ||
| 1764 | queueMicrotask(() => { | ||
| 1765 | isApplyingControlledUpdateRef.current = false; | ||
| 1766 | }); | ||
| 1767 | }, [cursor, editor, skillsSignature, value]); | ||
| 1768 | |||
| 1769 | const focusAt = useCallback( | ||
| 1770 | (nextCursor: number) => { | ||
| 1771 | const rootElement = editor.getRootElement(); | ||
| 1772 | if (!rootElement) return; | ||
| 1773 | rootElement.focus({ preventScroll: true }); | ||
| 1774 | // A newer prompt is waiting to be applied (a chip was just inserted through the store). | ||
| 1775 | // Reporting the editor's stale text now would overwrite that prompt; the pending rewrite | ||
| 1776 | // places the caret from the store's cursor instead. | ||
| 1777 | if (snapshotRef.current.value !== latestValueRef.current) return; | ||
| 1778 | const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); | ||
| 1779 | editor.update(() => { | ||
| 1780 | $setSelectionAtComposerOffset(boundedCursor); | ||
| 1781 | }); | ||
| 1782 | if (boundedCursor === snapshotRef.current.cursor) return; | ||
| 1783 | snapshotRef.current = { | ||
| 1784 | value: snapshotRef.current.value, | ||
| 1785 | cursor: boundedCursor, | ||
| 1786 | expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), | ||
| 1787 | contextIds: snapshotRef.current.contextIds, | ||
| 1788 | }; | ||
| 1789 | selectionRangeRef.current = { | ||
| 1790 | start: snapshotRef.current.expandedCursor, | ||
| 1791 | end: snapshotRef.current.expandedCursor, | ||
| 1792 | }; | ||
| 1793 | onChangeRef.current( | ||
| 1794 | snapshotRef.current.value, | ||
| 1795 | boundedCursor, | ||
| 1796 | snapshotRef.current.expandedCursor, | ||
| 1797 | false, | ||
| 1798 | snapshotRef.current.contextIds, | ||
| 1799 | ); | ||
| 1800 | }, | ||
| 1801 | [editor], | ||
| 1802 | ); | ||
| 1803 | |||
| 1804 | const readSnapshot = useCallback((): { | ||
| 1805 | value: string; | ||
| 1806 | cursor: number; | ||
| 1807 | expandedCursor: number; | ||
| 1808 | contextIds: string[]; | ||
| 1809 | } => { | ||
| 1810 | let snapshot = snapshotRef.current; | ||
| 1811 | editor.getEditorState().read(() => { | ||
| 1812 | const nextValue = $getRoot().getTextContent(); | ||
| 1813 | const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); | ||
| 1814 | const nextCursor = clampCollapsedComposerCursor( | ||
| 1815 | nextValue, | ||
| 1816 | $readSelectionOffsetFromEditorState(fallbackCursor), | ||
| 1817 | ); | ||
| 1818 | const fallbackExpandedCursor = clampExpandedCursor( | ||
| 1819 | nextValue, | ||
| 1820 | snapshotRef.current.expandedCursor, | ||
| 1821 | ); | ||
| 1822 | const nextExpandedCursor = clampExpandedCursor( | ||
| 1823 | nextValue, | ||
| 1824 | $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), | ||
| 1825 | ); | ||
| 1826 | const selectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); | ||
| 1827 | const contextIds = collectContextIds($getRoot()); | ||
| 1828 | snapshot = { | ||
| 1829 | value: nextValue, | ||
| 1830 | cursor: nextCursor, | ||
| 1831 | expandedCursor: nextExpandedCursor, | ||
| 1832 | contextIds, | ||
| 1833 | }; | ||
| 1834 | selectionRangeRef.current = selectionRange ?? { | ||
| 1835 | start: nextExpandedCursor, | ||
| 1836 | end: nextExpandedCursor, | ||
| 1837 | }; | ||
| 1838 | }); | ||
| 1839 | snapshotRef.current = snapshot; | ||
| 1840 | return snapshot; | ||
| 1841 | }, [editor]); | ||
| 1842 | |||
| 1843 | useImperativeHandle( | ||
| 1844 | editorRef, | ||
| 1845 | () => ({ | ||
| 1846 | focus: () => { | ||
| 1847 | focusAt(snapshotRef.current.cursor); | ||
| 1848 | }, | ||
| 1849 | focusAt, | ||
| 1850 | focusAtEnd: () => { | ||
| 1851 | focusAt( | ||
| 1852 | collapseExpandedComposerCursor( | ||
| 1853 | snapshotRef.current.value, | ||
| 1854 | snapshotRef.current.value.length, | ||
| 1855 | ), | ||
| 1856 | ); | ||
| 1857 | }, | ||
| 1858 | readSelectionRange: () => { | ||
| 1859 | readSnapshot(); | ||
| 1860 | return selectionRangeRef.current; | ||
| 1861 | }, | ||
| 1862 | requestCitationComment: (request) => { | ||
| 1863 | citationCommentRequestRef.current = request; | ||
| 1864 | const target = editor | ||
| 1865 | .getEditorState() | ||
| 1866 | .read(() => $consumeComposerCitationCommentRequest(citationCommentRequestRef)); | ||
| 1867 | if (target) setOpenCitationComment(target); | ||
| 1868 | }, | ||
| 1869 | readSnapshot, | ||
| 1870 | isCaretOnVisualEdge: (edge) => { | ||
| 1871 | const snapshot = readSnapshot(); | ||
| 1872 | if (snapshot.value.length === 0) return true; | ||
| 1873 | const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); | ||
| 1874 | const afterCaret = snapshot.value.slice(snapshot.expandedCursor); | ||
| 1875 | if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { | ||
| 1876 | return false; | ||
| 1877 | } | ||
| 1878 | const rootElement = editor.getRootElement(); | ||
| 1879 | const selection = window.getSelection(); | ||
| 1880 | if ( | ||
| 1881 | !rootElement || | ||
| 1882 | !selection || | ||
| 1883 | !selection.isCollapsed || | ||
| 1884 | selection.rangeCount === 0 || | ||
| 1885 | !selection.anchorNode || | ||
| 1886 | !rootElement.contains(selection.anchorNode) | ||
| 1887 | ) { | ||
| 1888 | return false; | ||
| 1889 | } | ||
| 1890 | const caretRect = caretLineRect(selection.getRangeAt(0), edge); | ||
| 1891 | if (!caretRect) return false; | ||
| 1892 | const edgeElement = | ||
| 1893 | edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; | ||
| 1894 | const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); | ||
| 1895 | const threshold = caretRect.height / 2; | ||
| 1896 | return edge === "start" | ||
| 1897 | ? caretRect.top - edgeRect.top < threshold | ||
| 1898 | : edgeRect.bottom - caretRect.bottom < threshold; | ||
| 1899 | }, | ||
| 1900 | }), | ||
| 1901 | [editor, focusAt, readSnapshot], | ||
| 1902 | ); | ||
| 1903 | |||
| 1904 | const handleEditorChange = useCallback((editorState: EditorState) => { | ||
| 1905 | editorState.read(() => { | ||
| 1906 | const nextValue = $getRoot().getTextContent(); | ||
| 1907 | const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); | ||
| 1908 | const nextCursor = clampCollapsedComposerCursor( | ||
| 1909 | nextValue, | ||
| 1910 | $readSelectionOffsetFromEditorState(fallbackCursor), | ||
| 1911 | ); | ||
| 1912 | const fallbackExpandedCursor = clampExpandedCursor( | ||
| 1913 | nextValue, | ||
| 1914 | snapshotRef.current.expandedCursor, | ||
| 1915 | ); | ||
| 1916 | const nextExpandedCursor = clampExpandedCursor( | ||
| 1917 | nextValue, | ||
| 1918 | $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), | ||
| 1919 | ); | ||
| 1920 | const nextSelectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); | ||
| 1921 | const previousSelectionRange = selectionRangeRef.current; | ||
| 1922 | selectionRangeRef.current = nextSelectionRange ?? { | ||
| 1923 | start: nextExpandedCursor, | ||
| 1924 | end: nextExpandedCursor, | ||
| 1925 | }; | ||
| 1926 | const contextIds = collectContextIds($getRoot()); | ||
| 1927 | const previousSnapshot = snapshotRef.current; | ||
| 1928 | const snapshotChanged = !( | ||
| 1929 | previousSnapshot.value === nextValue && | ||
| 1930 | previousSnapshot.cursor === nextCursor && | ||
| 1931 | previousSnapshot.expandedCursor === nextExpandedCursor && | ||
| 1932 | previousSnapshot.contextIds.length === contextIds.length && | ||
| 1933 | previousSnapshot.contextIds.every((id, index) => id === contextIds[index]) | ||
| 1934 | ); | ||
| 1935 | if (isApplyingControlledUpdateRef.current) { | ||
| 1936 | return; | ||
| 1937 | } | ||
| 1938 | if (!snapshotChanged) { | ||
| 1939 | if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { | ||
| 1940 | onVisibleSelectionChangeRef.current?.(); | ||
| 1941 | } | ||
| 1942 | return; | ||
| 1943 | } | ||
| 1944 | // A selection-only update while a newer prompt waits to be applied (an attachment | ||
| 1945 | // chip was just inserted through the store) would report stale text and stale | ||
| 1946 | // context ids, clobbering the prompt and dropping the record. Let the rewrite land. | ||
| 1947 | if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { | ||
| 1948 | return; | ||
| 1949 | } | ||
| 1950 | snapshotRef.current = { | ||
| 1951 | value: nextValue, | ||
| 1952 | cursor: nextCursor, | ||
| 1953 | expandedCursor: nextExpandedCursor, | ||
| 1954 | contextIds, | ||
| 1955 | }; | ||
| 1956 | const cursorAdjacentToMention = | ||
| 1957 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || | ||
| 1958 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); | ||
| 1959 | onChangeRef.current( | ||
| 1960 | nextValue, | ||
| 1961 | nextCursor, | ||
| 1962 | nextExpandedCursor, | ||
| 1963 | cursorAdjacentToMention, | ||
| 1964 | contextIds, | ||
| 1965 | ); | ||
| 1966 | }); | ||
| 1967 | }, []); | ||
| 1968 | |||
| 1969 | return ( | ||
| 1970 | <ComposerContextRecordsContext value={contextRecords}> | ||
| 1971 | <ComposerCitationCommentContext value={citationCommentActions}> | ||
| 1972 | <div | ||
| 1973 | className={cn( | ||
| 1974 | "relative [font-family:var(--font-composer,var(--font-sans))] [font-size:var(--font-size-prompt,0.875rem)] [@media(max-width:39.999rem)_and_(pointer:coarse)]:[font-size:max(var(--font-size-prompt,1rem),16px)]", | ||
| 1975 | containerClassName, | ||
| 1976 | )} | ||
| 1977 | > | ||
| 1978 | <PlainTextPlugin | ||
| 1979 | contentEditable={ | ||
| 1980 | <ContentEditable | ||
| 1981 | className={cn( | ||
| 1982 | // The wrapper owns the appearance preference; keep everything else here. | ||
| 1983 | "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", | ||
| 1984 | className, | ||
| 1985 | )} | ||
| 1986 | data-testid="composer-editor" | ||
| 1987 | aria-placeholder={placeholder} | ||
| 1988 | placeholder={<span />} | ||
| 1989 | onKeyDown={(event) => { | ||
| 1990 | if ( | ||
| 1991 | event.key === "Control" || | ||
| 1992 | event.key === "Meta" || | ||
| 1993 | event.key === "Alt" || | ||
| 1994 | event.key === "Shift" | ||
| 1995 | ) { | ||
| 1996 | onPageScrollRelease?.(); | ||
| 1997 | } | ||
| 1998 | |||
| 1999 | if (event.key !== "PageUp" && event.key !== "PageDown") { | ||
| 2000 | return; | ||
| 2001 | } | ||
| 2002 | |||
| 2003 | const pageScrollKey = getTimelinePageScrollKey({ | ||
| 2004 | altKey: event.altKey, | ||
| 2005 | clientHeight: event.currentTarget.clientHeight, | ||
| 2006 | ctrlKey: event.ctrlKey, | ||
| 2007 | defaultPrevented: event.defaultPrevented, | ||
| 2008 | isComposing: event.nativeEvent.isComposing, | ||
| 2009 | key: event.key, | ||
| 2010 | keyCode: event.keyCode, | ||
| 2011 | metaKey: event.metaKey, | ||
| 2012 | scrollHeight: event.currentTarget.scrollHeight, | ||
| 2013 | scrollTop: event.currentTarget.scrollTop, | ||
| 2014 | shiftKey: event.shiftKey, | ||
| 2015 | }); | ||
| 2016 | if (!pageScrollKey) { | ||
| 2017 | onPageScrollRelease?.(); | ||
| 2018 | return; | ||
| 2019 | } | ||
| 2020 | if (!onPageScrollKeyDown) { | ||
| 2021 | return; | ||
| 2022 | } | ||
| 2023 | |||
| 2024 | event.preventDefault(); | ||
| 2025 | onPageScrollKeyDown(pageScrollKey); | ||
| 2026 | }} | ||
| 2027 | onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} | ||
| 2028 | onBlur={onPageScrollRelease} | ||
| 2029 | onPasteCapture={onPaste} | ||
| 2030 | /> | ||
| 2031 | } | ||
| 2032 | placeholder={ | ||
| 2033 | contextRecords.size > 0 ? null : ( | ||
| 2034 | <div | ||
| 2035 | className={cn( | ||
| 2036 | "pointer-events-none absolute inset-0 leading-relaxed text-placeholder/75", | ||
| 2037 | placeholderClassName, | ||
| 2038 | )} | ||
| 2039 | > | ||
| 2040 | {placeholder} | ||
| 2041 | </div> | ||
| 2042 | ) | ||
| 2043 | } | ||
| 2044 | ErrorBoundary={LexicalErrorBoundary} | ||
| 2045 | /> | ||
| 2046 | <OnChangePlugin onChange={handleEditorChange} /> | ||
| 2047 | <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} /> | ||
| 2048 | <ComposerSurroundSelectionPlugin skills={skills} /> | ||
| 2049 | <ComposerHomeEndKeyPlugin /> | ||
| 2050 | <ComposerInlineTokenArrowPlugin /> | ||
| 2051 | <ComposerInlineTokenSelectionNormalizePlugin /> | ||
| 2052 | <ComposerInlineTokenBackspacePlugin /> | ||
| 2053 | <ComposerInlineTokenPastePlugin importContextFragment={importContextFragment} /> | ||
| 2054 | <ComposerContextClipboardPlugin | ||
| 2055 | buildContextClipboardFragment={buildContextClipboardFragment} | ||
| 2056 | /> | ||
| 2057 | <ComposerChipSelectionPlugin /> | ||
| 2058 | <HistoryPlugin /> | ||
| 2059 | </div> | ||
| 2060 | </ComposerCitationCommentContext> | ||
| 2061 | </ComposerContextRecordsContext> | ||
| 2062 | ); | ||
| 2063 | } | ||
| 2064 | |||
| 2065 | export function ComposerPromptEditor({ | ||
| 2066 | value, | ||
| 2067 | cursor, | ||
| 2068 | contextRecords, | ||
| 2069 | buildContextClipboardFragment, | ||
| 2070 | importContextFragment, | ||
| 2071 | skills, | ||
| 2072 | disabled, | ||
| 2073 | placeholder, | ||
| 2074 | containerClassName, | ||
| 2075 | className, | ||
| 2076 | placeholderClassName, | ||
| 2077 | onChange, | ||
| 2078 | onVisibleSelectionChange, | ||
| 2079 | onCommandKeyDown, | ||
| 2080 | onPageScrollKeyDown, | ||
| 2081 | onPageScrollKeyUp, | ||
| 2082 | onPageScrollRelease, | ||
| 2083 | onCitationSubmitAndSend, | ||
| 2084 | onPaste, | ||
| 2085 | editorRef, | ||
| 2086 | }: ComposerPromptEditorProps) { | ||
| 2087 | const initialValueRef = useRef(value); | ||
| 2088 | const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); | ||
| 2089 | const initialConfig = useMemo<InitialConfigType>( | ||
| 2090 | () => ({ | ||
| 2091 | namespace: "t3tools-composer-editor", | ||
| 2092 | editable: true, | ||
| 2093 | nodes: [ | ||
| 2094 | ComposerMentionNode, | ||
| 2095 | ComposerSkillNode, | ||
| 2096 | ComposerCitationNode, | ||
| 2097 | ComposerContextReferenceNode, | ||
| 2098 | ], | ||
| 2099 | editorState: () => { | ||
| 2100 | $setComposerEditorPrompt(initialValueRef.current, initialSkillMetadataRef.current); | ||
| 2101 | }, | ||
| 2102 | onError: (error) => { | ||
| 2103 | throw error; | ||
| 2104 | }, | ||
| 2105 | }), | ||
| 2106 | [], | ||
| 2107 | ); | ||
| 2108 | |||
| 2109 | return ( | ||
| 2110 | <ComposerSkillsContext value={skills}> | ||
| 2111 | <LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}> | ||
| 2112 | <ComposerPromptEditorInner | ||
| 2113 | value={value} | ||
| 2114 | cursor={cursor} | ||
| 2115 | contextRecords={contextRecords} | ||
| 2116 | buildContextClipboardFragment={buildContextClipboardFragment} | ||
| 2117 | importContextFragment={importContextFragment} | ||
| 2118 | skills={skills} | ||
| 2119 | disabled={disabled} | ||
| 2120 | placeholder={placeholder} | ||
| 2121 | {...(containerClassName ? { containerClassName } : {})} | ||
| 2122 | onChange={onChange} | ||
| 2123 | {...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})} | ||
| 2124 | onPaste={onPaste} | ||
| 2125 | {...(onCitationSubmitAndSend ? { onCitationSubmitAndSend } : {})} | ||
| 2126 | editorRef={editorRef} | ||
| 2127 | {...(onCommandKeyDown ? { onCommandKeyDown } : {})} | ||
| 2128 | {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})} | ||
| 2129 | {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})} | ||
| 2130 | {...(onPageScrollRelease ? { onPageScrollRelease } : {})} | ||
| 2131 | {...(className ? { className } : {})} | ||
| 2132 | {...(placeholderClassName ? { placeholderClassName } : {})} | ||
| 2133 | /> | ||
| 2134 | </LexicalComposer> | ||
| 2135 | </ComposerSkillsContext> | ||
| 2136 | ); | ||
| 2137 | } | 17 | } |
apps/web/src/components/ComposerPromptEditorTiptap.tsx created+1379| ... | @@ -0,0 +1,1379 @@ | ||
| 1 | import { Extension, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; | ||
| 2 | import { TaskList } from "@tiptap/extension-task-list"; | ||
| 3 | import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; | ||
| 4 | import StarterKit from "@tiptap/starter-kit"; | ||
| 5 | import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; | ||
| 6 | import { splitBlockKeepMarks } from "@tiptap/pm/commands"; | ||
| 7 | import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; | ||
| 8 | import { Decoration, DecorationSet } from "@tiptap/pm/view"; | ||
| 9 | import type { | ||
| 10 | AssistantCitation, | ||
| 11 | ComposerContextClipboardFragment, | ||
| 12 | ServerProviderSkill, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { | ||
| 15 | serializeAssistantCitation, | ||
| 16 | withAssistantCitationComment, | ||
| 17 | } from "@t3tools/shared/assistantCitations"; | ||
| 18 | import { | ||
| 19 | COMPOSER_CONTEXT_CLIPBOARD_MIME, | ||
| 20 | encodeComposerContextClipboardHtml, | ||
| 21 | } from "@t3tools/shared/composerContextClipboard"; | ||
| 22 | import { | ||
| 23 | createContext, | ||
| 24 | use, | ||
| 25 | useCallback, | ||
| 26 | useEffect, | ||
| 27 | useImperativeHandle, | ||
| 28 | useLayoutEffect, | ||
| 29 | useMemo, | ||
| 30 | useRef, | ||
| 31 | useState, | ||
| 32 | } from "react"; | ||
| 33 | import { EditorContent, useEditor } from "@tiptap/react"; | ||
| 34 | |||
| 35 | import { | ||
| 36 | clampCollapsedComposerCursor, | ||
| 37 | collapseExpandedComposerCursor, | ||
| 38 | expandCollapsedComposerCursor, | ||
| 39 | isCollapsedCursorAdjacentToInlineToken, | ||
| 40 | } from "~/composer-logic"; | ||
| 41 | import { | ||
| 42 | collectComposerPromptInlineTokens, | ||
| 43 | selectionTouchesMentionBoundary, | ||
| 44 | } from "~/composer-editor-mentions"; | ||
| 45 | import { | ||
| 46 | buildDocJson, | ||
| 47 | buildTiptapContent, | ||
| 48 | collapsedToFlat, | ||
| 49 | ComposerTaskItemExtension, | ||
| 50 | flatToCollapsed, | ||
| 51 | flatToMarkdown, | ||
| 52 | flatToPm, | ||
| 53 | pmToFlat, | ||
| 54 | serializeEditorDoc, | ||
| 55 | type SkillMeta, | ||
| 56 | } from "~/composer-rich-text-doc"; | ||
| 57 | import { collectInlineContextIds } from "~/lib/composerContextReferences"; | ||
| 58 | import { cn, isMacPlatform } from "~/lib/utils"; | ||
| 59 | import { basenameOfPath } from "~/pierre-icons"; | ||
| 60 | import { | ||
| 61 | COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, | ||
| 62 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, | ||
| 63 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, | ||
| 64 | COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, | ||
| 65 | SKILL_CHIP_ICON_SVG, | ||
| 66 | } from "./composerInlineChip"; | ||
| 67 | import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; | ||
| 68 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; | ||
| 69 | import { getTimelinePageScrollKey } from "./chat/pageScrollController"; | ||
| 70 | import { ContextChipPopover } from "./contextChipParts"; | ||
| 71 | import { Button } from "./ui/button"; | ||
| 72 | import { | ||
| 73 | ComposerContextActionsContext, | ||
| 74 | ComposerContextReferenceChip, | ||
| 75 | ComposerContextRecordsContext, | ||
| 76 | } from "./composerContextPresentation"; | ||
| 77 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | ||
| 78 | import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; | ||
| 79 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | ||
| 80 | import { importPastedComposerText } from "./composerInlineTokenPaste"; | ||
| 81 | import { didComposerSelectionChangeVisibly } from "./composerSelection"; | ||
| 82 | import type { ComposerDraftContextRecords } from "./composerContextPresentation"; | ||
| 83 | |||
| 84 | export interface ComposerPromptEditorHandle { | ||
| 85 | focus: () => void; | ||
| 86 | focusAt: (cursor: number) => void; | ||
| 87 | focusAtEnd: () => void; | ||
| 88 | readSelectionRange: () => { start: number; end: number }; | ||
| 89 | requestCitationComment: (request: ComposerCitationCommentRequest) => void; | ||
| 90 | readSnapshot: () => { | ||
| 91 | value: string; | ||
| 92 | cursor: number; | ||
| 93 | expandedCursor: number; | ||
| 94 | contextIds: string[]; | ||
| 95 | }; | ||
| 96 | /** | ||
| 97 | * True when a collapsed caret sits on the first ("start") or last ("end") | ||
| 98 | * visual line, counting soft wraps. Prompt history only claims ArrowUp and | ||
| 99 | * ArrowDown at these edges so arrows still move the caret inside multiline | ||
| 100 | * text. | ||
| 101 | */ | ||
| 102 | isCaretOnVisualEdge: (edge: "start" | "end") => boolean; | ||
| 103 | } | ||
| 104 | |||
| 105 | export interface ComposerPromptEditorProps { | ||
| 106 | value: string; | ||
| 107 | cursor: number; | ||
| 108 | /** | ||
| 109 | * Render Markdown styling (bold, italic, code, strike, task checkboxes). | ||
| 110 | * Off renders the same Tiptap engine as plain text: every marker stays a | ||
| 111 | * literal character. | ||
| 112 | */ | ||
| 113 | richTextEnabled?: boolean; | ||
| 114 | /** Draft records behind the prompt's context references, keyed by context id. */ | ||
| 115 | contextRecords: ComposerDraftContextRecords; | ||
| 116 | /** Structured clipboard payload for the given referenced ids, or null to skip. */ | ||
| 117 | buildContextClipboardFragment?: | ||
| 118 | | ((contextIds: ReadonlyArray<string>) => string | null) | ||
| 119 | | undefined; | ||
| 120 | /** Imports a structured paste's records; returns ids that changed. */ | ||
| 121 | importContextFragment?: | ||
| 122 | | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap<string, string>) | ||
| 123 | | undefined; | ||
| 124 | skills: ReadonlyArray<ServerProviderSkill>; | ||
| 125 | disabled: boolean; | ||
| 126 | placeholder: string; | ||
| 127 | containerClassName?: string; | ||
| 128 | className?: string; | ||
| 129 | placeholderClassName?: string; | ||
| 130 | onChange: ( | ||
| 131 | nextValue: string, | ||
| 132 | nextCursor: number, | ||
| 133 | expandedCursor: number, | ||
| 134 | cursorAdjacentToMention: boolean, | ||
| 135 | contextIds: string[], | ||
| 136 | ) => void; | ||
| 137 | onVisibleSelectionChange?: () => void; | ||
| 138 | onCommandKeyDown?: ( | ||
| 139 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | ||
| 140 | event: KeyboardEvent, | ||
| 141 | isTaskItem?: boolean, | ||
| 142 | ) => boolean; | ||
| 143 | onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; | ||
| 144 | onPageScrollKeyUp?: (key: string) => void; | ||
| 145 | onPageScrollRelease?: () => void; | ||
| 146 | onCitationSubmitAndSend?: () => void; | ||
| 147 | onPaste: React.ClipboardEventHandler<HTMLElement>; | ||
| 148 | editorRef: React.RefObject<ComposerPromptEditorHandle | null>; | ||
| 149 | } | ||
| 150 | |||
| 151 | export type ComposerCitationCommentRequest = { | ||
| 152 | previousValue: string; | ||
| 153 | value: string; | ||
| 154 | citationStart: number; | ||
| 155 | sourceAnchor: AssistantCitationSourceAnchor; | ||
| 156 | }; | ||
| 157 | |||
| 158 | type OpenCitationComment = { | ||
| 159 | key: string; | ||
| 160 | sourceAnchor?: AssistantCitationSourceAnchor; | ||
| 161 | removeOnCancel?: boolean; | ||
| 162 | }; | ||
| 163 | |||
| 164 | const ComposerCitationCommentContext = createContext<{ | ||
| 165 | openComment: OpenCitationComment | null; | ||
| 166 | onOpenChange: (citeKey: string, open: boolean) => void; | ||
| 167 | onSubmitAndSend: () => void; | ||
| 168 | }>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); | ||
| 169 | |||
| 170 | const RichComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]); | ||
| 171 | |||
| 172 | const SURROUND_CLOSE: Record<string, string> = { | ||
| 173 | "(": ")", | ||
| 174 | "[": "]", | ||
| 175 | "{": "}", | ||
| 176 | "'": "'", | ||
| 177 | '"': '"', | ||
| 178 | "“": "”", | ||
| 179 | "`": "`", | ||
| 180 | "<": ">", | ||
| 181 | "«": "»", | ||
| 182 | "*": "*", | ||
| 183 | _: "_", | ||
| 184 | }; | ||
| 185 | |||
| 186 | function resolvedThemeFromDocument(): "light" | "dark" { | ||
| 187 | return document.documentElement.classList.contains("dark") ? "dark" : "light"; | ||
| 188 | } | ||
| 189 | |||
| 190 | // ── Inline atom nodes (chips) ───────────────────────────────────────────── | ||
| 191 | |||
| 192 | const ComposerMentionExtension = Node.create({ | ||
| 193 | name: "composer-mention", | ||
| 194 | group: "inline", | ||
| 195 | inline: true, | ||
| 196 | atom: true, | ||
| 197 | selectable: true, | ||
| 198 | addAttributes() { | ||
| 199 | return { | ||
| 200 | path: { default: "" }, | ||
| 201 | source: { default: "" }, | ||
| 202 | }; | ||
| 203 | }, | ||
| 204 | parseHTML() { | ||
| 205 | return [{ tag: "span[data-composer-mention]" }]; | ||
| 206 | }, | ||
| 207 | renderHTML({ HTMLAttributes }) { | ||
| 208 | return ["span", { "data-composer-mention": "", ...HTMLAttributes }]; | ||
| 209 | }, | ||
| 210 | addNodeView() { | ||
| 211 | return ReactNodeViewRenderer(ComposerMentionNodeView); | ||
| 212 | }, | ||
| 213 | }); | ||
| 214 | |||
| 215 | function ComposerMentionNodeView({ node }: NodeViewProps) { | ||
| 216 | const actions = use(ComposerContextActionsContext); | ||
| 217 | const path = (node.attrs.path as string) ?? ""; | ||
| 218 | const chip = ( | ||
| 219 | <Button | ||
| 220 | variant="chip" | ||
| 221 | onClick={() => actions.openMention(path)} | ||
| 222 | aria-label={`Preview ${path}`} | ||
| 223 | className={`${FILE_TAG_CHIP_CLASS_NAME} cursor-pointer focus-visible:outline-2`} | ||
| 224 | contentEditable={false} | ||
| 225 | spellCheck={false} | ||
| 226 | data-composer-mention-chip="true" | ||
| 227 | > | ||
| 228 | <FileTagChipContent | ||
| 229 | path={path} | ||
| 230 | label={basenameOfPath(path)} | ||
| 231 | theme={resolvedThemeFromDocument()} | ||
| 232 | /> | ||
| 233 | </Button> | ||
| 234 | ); | ||
| 235 | return ( | ||
| 236 | <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}> | ||
| 237 | <Tooltip> | ||
| 238 | <TooltipTrigger render={chip} /> | ||
| 239 | <TooltipPopup | ||
| 240 | side="top" | ||
| 241 | className="max-w-120 whitespace-normal leading-tight wrap-anywhere" | ||
| 242 | > | ||
| 243 | {path} | ||
| 244 | </TooltipPopup> | ||
| 245 | </Tooltip> | ||
| 246 | </NodeViewWrapper> | ||
| 247 | ); | ||
| 248 | } | ||
| 249 | |||
| 250 | const ComposerSkillExtension = Node.create({ | ||
| 251 | name: "composer-skill", | ||
| 252 | group: "inline", | ||
| 253 | inline: true, | ||
| 254 | atom: true, | ||
| 255 | selectable: true, | ||
| 256 | addAttributes() { | ||
| 257 | return { | ||
| 258 | skillName: { default: "" }, | ||
| 259 | skillLabel: { default: "" }, | ||
| 260 | skillDescription: { default: null }, | ||
| 261 | }; | ||
| 262 | }, | ||
| 263 | parseHTML() { | ||
| 264 | return [{ tag: "span[data-composer-skill]" }]; | ||
| 265 | }, | ||
| 266 | renderHTML({ HTMLAttributes }) { | ||
| 267 | return ["span", { "data-composer-skill": "", ...HTMLAttributes }]; | ||
| 268 | }, | ||
| 269 | addNodeView() { | ||
| 270 | return ReactNodeViewRenderer(ComposerSkillNodeView); | ||
| 271 | }, | ||
| 272 | }); | ||
| 273 | |||
| 274 | function ComposerSkillNodeView({ node }: NodeViewProps) { | ||
| 275 | const actions = use(ComposerContextActionsContext); | ||
| 276 | const skills = use(RichComposerSkillsContext); | ||
| 277 | const skillName = (node.attrs.skillName as string) ?? ""; | ||
| 278 | const skillLabel = (node.attrs.skillLabel as string) || skillName; | ||
| 279 | const skillDescription = (node.attrs.skillDescription as string | null) ?? null; | ||
| 280 | const skill = skills.find((candidate) => candidate.name === skillName); | ||
| 281 | return ( | ||
| 282 | <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}> | ||
| 283 | <ContextChipPopover | ||
| 284 | accessibleLabel={`Skill ${skillLabel}`} | ||
| 285 | triggerClassName={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME} | ||
| 286 | chip={ | ||
| 287 | <> | ||
| 288 | <span | ||
| 289 | aria-hidden="true" | ||
| 290 | className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} | ||
| 291 | dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }} | ||
| 292 | /> | ||
| 293 | <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{skillLabel}</span> | ||
| 294 | </> | ||
| 295 | } | ||
| 296 | > | ||
| 297 | <div className="space-y-3 p-2 text-sm"> | ||
| 298 | <p className="font-medium">{skillLabel}</p> | ||
| 299 | <p> | ||
| 300 | {skill?.description ?? | ||
| 301 | skillDescription ?? | ||
| 302 | "No description is available for this skill."} | ||
| 303 | </p> | ||
| 304 | {skill?.path ? ( | ||
| 305 | <Button variant="outline" size="sm" onClick={() => actions.openMention(skill.path)}> | ||
| 306 | View instructions | ||
| 307 | </Button> | ||
| 308 | ) : null} | ||
| 309 | </div> | ||
| 310 | </ContextChipPopover> | ||
| 311 | </NodeViewWrapper> | ||
| 312 | ); | ||
| 313 | } | ||
| 314 | |||
| 315 | const ComposerCitationExtension = Node.create({ | ||
| 316 | name: "composer-citation", | ||
| 317 | group: "inline", | ||
| 318 | inline: true, | ||
| 319 | atom: true, | ||
| 320 | selectable: true, | ||
| 321 | addAttributes() { | ||
| 322 | return { | ||
| 323 | citation: { default: null }, | ||
| 324 | source: { default: "" }, | ||
| 325 | citeKey: { default: "" }, | ||
| 326 | }; | ||
| 327 | }, | ||
| 328 | parseHTML() { | ||
| 329 | return [{ tag: "span[data-composer-citation]" }]; | ||
| 330 | }, | ||
| 331 | renderHTML({ HTMLAttributes }) { | ||
| 332 | return ["span", { "data-composer-citation": "", ...HTMLAttributes }]; | ||
| 333 | }, | ||
| 334 | addNodeView() { | ||
| 335 | return ReactNodeViewRenderer(ComposerCitationNodeView); | ||
| 336 | }, | ||
| 337 | }); | ||
| 338 | |||
| 339 | function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { | ||
| 340 | const commentContext = use(ComposerCitationCommentContext); | ||
| 341 | const citation = node.attrs.citation as AssistantCitation; | ||
| 342 | const citeKey = node.attrs.citeKey as string; | ||
| 343 | const commentTarget = | ||
| 344 | commentContext.openComment?.key === citeKey ? commentContext.openComment : null; | ||
| 345 | |||
| 346 | const nodePos = useCallback(() => { | ||
| 347 | const pos = typeof getPos === "function" ? getPos() : null; | ||
| 348 | return typeof pos === "number" ? pos : null; | ||
| 349 | }, [getPos]); | ||
| 350 | |||
| 351 | const onSaveComment = useCallback( | ||
| 352 | (comment: string): boolean => { | ||
| 353 | if (!editor.isEditable) return false; | ||
| 354 | const pos = nodePos(); | ||
| 355 | if (pos === null) return false; | ||
| 356 | const current = editor.state.doc.nodeAt(pos); | ||
| 357 | if (!current || current.type.name !== "composer-citation") return false; | ||
| 358 | const currentCitation = current.attrs.citation as AssistantCitation; | ||
| 359 | const next = withAssistantCitationComment(currentCitation, comment); | ||
| 360 | const tr = editor.state.tr.setNodeMarkup(pos, undefined, { | ||
| 361 | ...current.attrs, | ||
| 362 | citation: next, | ||
| 363 | source: serializeAssistantCitation(next), | ||
| 364 | }); | ||
| 365 | editor.view.dispatch(tr); | ||
| 366 | return true; | ||
| 367 | }, | ||
| 368 | [editor, nodePos], | ||
| 369 | ); | ||
| 370 | |||
| 371 | const onRemove = useCallback(() => { | ||
| 372 | if (!editor.isEditable) return; | ||
| 373 | const pos = nodePos(); | ||
| 374 | if (pos === null) return; | ||
| 375 | const current = editor.state.doc.nodeAt(pos); | ||
| 376 | if (!current) return; | ||
| 377 | editor | ||
| 378 | .chain() | ||
| 379 | .focus() | ||
| 380 | .deleteRange({ from: pos, to: pos + current.nodeSize }) | ||
| 381 | .run(); | ||
| 382 | }, [editor, nodePos]); | ||
| 383 | |||
| 384 | return ( | ||
| 385 | <NodeViewWrapper | ||
| 386 | as="span" | ||
| 387 | className="inline-flex min-w-0 max-w-full" | ||
| 388 | contentEditable={false} | ||
| 389 | spellCheck={false} | ||
| 390 | data-composer-citation-chip="true" | ||
| 391 | > | ||
| 392 | <AssistantCitationChip | ||
| 393 | citation={citation} | ||
| 394 | composer | ||
| 395 | commentEditor={{ | ||
| 396 | open: commentTarget !== null, | ||
| 397 | sourceAnchor: commentTarget?.sourceAnchor, | ||
| 398 | onOpenChange: (open) => { | ||
| 399 | if (open && !editor.isEditable) return; | ||
| 400 | commentContext.onOpenChange(citeKey, open); | ||
| 401 | }, | ||
| 402 | ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), | ||
| 403 | onSave: onSaveComment, | ||
| 404 | onSaveAndSend: (comment) => { | ||
| 405 | if (!onSaveComment(comment)) return false; | ||
| 406 | commentContext.onSubmitAndSend(); | ||
| 407 | return true; | ||
| 408 | }, | ||
| 409 | }} | ||
| 410 | /> | ||
| 411 | </NodeViewWrapper> | ||
| 412 | ); | ||
| 413 | } | ||
| 414 | |||
| 415 | const ComposerContextReferenceExtension = Node.create({ | ||
| 416 | name: "composer-context-reference", | ||
| 417 | group: "inline", | ||
| 418 | inline: true, | ||
| 419 | atom: true, | ||
| 420 | selectable: true, | ||
| 421 | addAttributes() { | ||
| 422 | return { | ||
| 423 | kind: { default: "" }, | ||
| 424 | contextId: { default: "" }, | ||
| 425 | label: { default: "" }, | ||
| 426 | source: { default: "" }, | ||
| 427 | }; | ||
| 428 | }, | ||
| 429 | parseHTML() { | ||
| 430 | return [{ tag: "span[data-composer-context-reference]" }]; | ||
| 431 | }, | ||
| 432 | renderHTML({ HTMLAttributes }) { | ||
| 433 | return ["span", { "data-composer-context-reference": "", ...HTMLAttributes }]; | ||
| 434 | }, | ||
| 435 | addNodeView() { | ||
| 436 | return ReactNodeViewRenderer(ComposerContextReferenceNodeView); | ||
| 437 | }, | ||
| 438 | }); | ||
| 439 | |||
| 440 | function ComposerContextReferenceNodeView({ node }: NodeViewProps) { | ||
| 441 | return ( | ||
| 442 | <NodeViewWrapper as="span" className={COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME}> | ||
| 443 | <ComposerContextReferenceChip | ||
| 444 | kind={(node.attrs.kind as string) ?? ""} | ||
| 445 | contextId={(node.attrs.contextId as string) ?? ""} | ||
| 446 | label={(node.attrs.label as string) ?? ""} | ||
| 447 | /> | ||
| 448 | </NodeViewWrapper> | ||
| 449 | ); | ||
| 450 | } | ||
| 451 | |||
| 452 | // ── Marker reveal (show ** when the cursor is on styled text) ────────────── | ||
| 453 | |||
| 454 | type StyledRange = { | ||
| 455 | from: number; | ||
| 456 | to: number; | ||
| 457 | markers: { at: number; side: number; text: string }[]; | ||
| 458 | }; | ||
| 459 | |||
| 460 | function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { | ||
| 461 | const ranges: StyledRange[] = []; | ||
| 462 | const map = serializeEditorDoc(doc); | ||
| 463 | let range: StyledRange | null = null; | ||
| 464 | let openLength = 0; | ||
| 465 | for (const run of map.runs) { | ||
| 466 | if (run.openLen > 0) { | ||
| 467 | range ??= { from: run.pmPos, to: run.pmPos, markers: [] }; | ||
| 468 | range.markers.push({ | ||
| 469 | at: run.pmPos, | ||
| 470 | side: -1, | ||
| 471 | text: map.value.slice(run.mdStart, run.mdStart + run.openLen), | ||
| 472 | }); | ||
| 473 | } | ||
| 474 | if (range === null) continue; | ||
| 475 | range.to = run.pmPos + run.docLen; | ||
| 476 | if (run.closeLen > 0) { | ||
| 477 | const end = run.mdStart + run.mdLen; | ||
| 478 | range.markers.push({ | ||
| 479 | at: range.to, | ||
| 480 | side: -2, | ||
| 481 | text: map.value.slice(end - run.closeLen, end), | ||
| 482 | }); | ||
| 483 | } | ||
| 484 | openLength += run.openLen - run.closeLen; | ||
| 485 | if (openLength === 0) { | ||
| 486 | ranges.push(range); | ||
| 487 | range = null; | ||
| 488 | } | ||
| 489 | } | ||
| 490 | return ranges; | ||
| 491 | } | ||
| 492 | |||
| 493 | const MarkerPluginKey = new PluginKey("composer-rich-markers"); | ||
| 494 | |||
| 495 | const ComposerMarkerPlugin = new Plugin({ | ||
| 496 | key: MarkerPluginKey, | ||
| 497 | state: { | ||
| 498 | init: (_, state) => decorationsForSelection(state.doc, state.selection), | ||
| 499 | apply: (tr, old) => | ||
| 500 | tr.docChanged || tr.selectionSet ? decorationsForSelection(tr.doc, tr.selection) : old, | ||
| 501 | }, | ||
| 502 | props: { | ||
| 503 | decorations(state) { | ||
| 504 | return MarkerPluginKey.getState(state); | ||
| 505 | }, | ||
| 506 | }, | ||
| 507 | }); | ||
| 508 | |||
| 509 | function decorationsForSelection( | ||
| 510 | doc: ProseMirrorNode, | ||
| 511 | selection: { from: number; to: number; empty: boolean }, | ||
| 512 | ): DecorationSet { | ||
| 513 | const decorations: Decoration[] = []; | ||
| 514 | if (!selection.empty) { | ||
| 515 | doc.nodesBetween(selection.from, selection.to, (node, pos) => { | ||
| 516 | if (node.type.name.startsWith("composer-")) { | ||
| 517 | decorations.push( | ||
| 518 | Decoration.node(pos, pos + node.nodeSize, { class: "composer-chip-range-selected" }), | ||
| 519 | ); | ||
| 520 | return false; | ||
| 521 | } | ||
| 522 | return true; | ||
| 523 | }); | ||
| 524 | } | ||
| 525 | for (const range of collectStyledRanges(doc)) { | ||
| 526 | const active = selection.empty | ||
| 527 | ? selection.from >= range.from && selection.from <= range.to | ||
| 528 | : selection.from < range.to && selection.to > range.from; | ||
| 529 | if (!active) continue; | ||
| 530 | for (const { at, side, text } of range.markers) { | ||
| 531 | const marker = document.createElement("span"); | ||
| 532 | marker.className = "composer-rich-marker"; | ||
| 533 | marker.textContent = text; | ||
| 534 | marker.setAttribute("aria-hidden", "true"); | ||
| 535 | decorations.push( | ||
| 536 | Decoration.widget(at, marker, { side, key: `marker-${at}-${side}-${marker.textContent}` }), | ||
| 537 | ); | ||
| 538 | } | ||
| 539 | } | ||
| 540 | return DecorationSet.create(doc, decorations); | ||
| 541 | } | ||
| 542 | |||
| 543 | const ComposerMarkersExtension = Extension.create({ | ||
| 544 | name: "composer-rich-markers", | ||
| 545 | addProseMirrorPlugins() { | ||
| 546 | return [ComposerMarkerPlugin]; | ||
| 547 | }, | ||
| 548 | }); | ||
| 549 | |||
| 550 | // Document model (markdown ⇄ ProseMirror) lives in ~/composer-rich-text-doc so | ||
| 551 | // unit tests can round-trip it without a browser. | ||
| 552 | // ── Editor component ─────────────────────────────────────────────────────── | ||
| 553 | |||
| 554 | type TiptapEditor = NonNullable<ReturnType<typeof useEditor>>; | ||
| 555 | |||
| 556 | export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { | ||
| 557 | // Extensions are creation-time: flipping the setting remounts the editor. | ||
| 558 | // Both halves initialize from the controlled Markdown value, so the draft | ||
| 559 | // survives the flip. | ||
| 560 | return ( | ||
| 561 | <ComposerPromptEditorTiptapInner key={props.richTextEnabled ? "rich" : "plain"} {...props} /> | ||
| 562 | ); | ||
| 563 | } | ||
| 564 | |||
| 565 | function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ||
| 566 | const { | ||
| 567 | value, | ||
| 568 | cursor, | ||
| 569 | richTextEnabled, | ||
| 570 | contextRecords, | ||
| 571 | buildContextClipboardFragment, | ||
| 572 | importContextFragment, | ||
| 573 | skills, | ||
| 574 | disabled, | ||
| 575 | placeholder, | ||
| 576 | containerClassName, | ||
| 577 | className, | ||
| 578 | placeholderClassName, | ||
| 579 | onChange, | ||
| 580 | onVisibleSelectionChange, | ||
| 581 | onCommandKeyDown, | ||
| 582 | onPageScrollKeyDown, | ||
| 583 | onPageScrollKeyUp, | ||
| 584 | onPageScrollRelease, | ||
| 585 | onCitationSubmitAndSend, | ||
| 586 | onPaste, | ||
| 587 | editorRef, | ||
| 588 | } = props; | ||
| 589 | // The setting toggles styling, not the engine: both modes are Tiptap. | ||
| 590 | // Plain mode disables the mark extensions, so markers stay literal text. | ||
| 591 | const richText = richTextEnabled ?? false; | ||
| 592 | |||
| 593 | const onChangeRef = useRef(onChange); | ||
| 594 | const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); | ||
| 595 | const onCommandKeyDownRef = useRef(onCommandKeyDown); | ||
| 596 | const buildFragmentRef = useRef(buildContextClipboardFragment); | ||
| 597 | const importFragmentRef = useRef(importContextFragment); | ||
| 598 | const skillsRef = useRef(skills); | ||
| 599 | const latestValueRef = useRef(value); | ||
| 600 | // The editor instance for callbacks created before it exists (paste). | ||
| 601 | // Effects flush before any user interaction, so this is always set. | ||
| 602 | const editorHolder = useRef<TiptapEditor | null>(null); | ||
| 603 | |||
| 604 | useEffect(() => { | ||
| 605 | onChangeRef.current = onChange; | ||
| 606 | }, [onChange]); | ||
| 607 | useEffect(() => { | ||
| 608 | onVisibleSelectionChangeRef.current = onVisibleSelectionChange; | ||
| 609 | }, [onVisibleSelectionChange]); | ||
| 610 | useEffect(() => { | ||
| 611 | onCommandKeyDownRef.current = onCommandKeyDown; | ||
| 612 | }, [onCommandKeyDown]); | ||
| 613 | useEffect(() => { | ||
| 614 | buildFragmentRef.current = buildContextClipboardFragment; | ||
| 615 | }, [buildContextClipboardFragment]); | ||
| 616 | useEffect(() => { | ||
| 617 | importFragmentRef.current = importContextFragment; | ||
| 618 | }, [importContextFragment]); | ||
| 619 | useEffect(() => { | ||
| 620 | skillsRef.current = skills; | ||
| 621 | }, [skills]); | ||
| 622 | useLayoutEffect(() => { | ||
| 623 | latestValueRef.current = value; | ||
| 624 | }, [value]); | ||
| 625 | |||
| 626 | const skillLabelFor = useCallback((name: string): SkillMeta => { | ||
| 627 | const normalized = name.startsWith("$") ? name.slice(1) : name; | ||
| 628 | const skill = skillsRef.current.find((candidate) => candidate.name === normalized); | ||
| 629 | if (!skill) { | ||
| 630 | return { label: formatProviderSkillDisplayName({ name: normalized }), description: null }; | ||
| 631 | } | ||
| 632 | const shortDescription = skill.shortDescription?.trim(); | ||
| 633 | return { | ||
| 634 | label: formatProviderSkillDisplayName(skill), | ||
| 635 | description: shortDescription || skill.description?.trim() || null, | ||
| 636 | }; | ||
| 637 | }, []); | ||
| 638 | |||
| 639 | const initialCursor = clampCollapsedComposerCursor(value, cursor); | ||
| 640 | const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); | ||
| 641 | const snapshotRef = useRef({ | ||
| 642 | value, | ||
| 643 | cursor: initialCursor, | ||
| 644 | expandedCursor: initialExpandedCursor, | ||
| 645 | contextIds: collectInlineContextIds(value), | ||
| 646 | }); | ||
| 647 | const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); | ||
| 648 | const isApplyingControlledUpdateRef = useRef(false); | ||
| 649 | const hasAppliedControlledSelectionRef = useRef(false); | ||
| 650 | const citationRequestRef = useRef<ComposerCitationCommentRequest | null>(null); | ||
| 651 | const [openCitation, setOpenCitation] = useState<OpenCitationComment | null>(null); | ||
| 652 | const [isEmpty, setIsEmpty] = useState(value.length === 0); | ||
| 653 | |||
| 654 | const citationCommentActions = useMemo( | ||
| 655 | () => ({ | ||
| 656 | openComment: openCitation, | ||
| 657 | onOpenChange: (nodeKey: string, open: boolean) => { | ||
| 658 | setOpenCitation((current) => { | ||
| 659 | if (open) { | ||
| 660 | if (current?.key === nodeKey) return current; | ||
| 661 | return { key: nodeKey }; | ||
| 662 | } | ||
| 663 | return current?.key === nodeKey ? null : current; | ||
| 664 | }); | ||
| 665 | }, | ||
| 666 | onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), | ||
| 667 | }), | ||
| 668 | [onCitationSubmitAndSend, openCitation], | ||
| 669 | ); | ||
| 670 | |||
| 671 | const handleEditorChange = useCallback((updated: TiptapEditor) => { | ||
| 672 | const map = serializeEditorDoc(updated.state.doc); | ||
| 673 | const { from, to } = updated.state.selection; | ||
| 674 | const fromFlat = pmToFlat(map, from); | ||
| 675 | const toFlat = pmToFlat(map, to); | ||
| 676 | const nextValue = map.value; | ||
| 677 | const nextCursor = clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)); | ||
| 678 | const nextExpandedCursor = Math.max( | ||
| 679 | 0, | ||
| 680 | Math.min(map.value.length, flatToMarkdown(map, fromFlat)), | ||
| 681 | ); | ||
| 682 | const nextSelectionRange = { | ||
| 683 | start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)), | ||
| 684 | end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)), | ||
| 685 | }; | ||
| 686 | const previousSelectionRange = selectionRangeRef.current; | ||
| 687 | selectionRangeRef.current = nextSelectionRange; | ||
| 688 | setIsEmpty(nextValue.length === 0); | ||
| 689 | const previousSnapshot = snapshotRef.current; | ||
| 690 | const snapshotChanged = !( | ||
| 691 | previousSnapshot.value === nextValue && | ||
| 692 | previousSnapshot.cursor === nextCursor && | ||
| 693 | previousSnapshot.expandedCursor === nextExpandedCursor && | ||
| 694 | previousSnapshot.contextIds.length === map.contextIds.length && | ||
| 695 | previousSnapshot.contextIds.every((id, index) => id === map.contextIds[index]) | ||
| 696 | ); | ||
| 697 | if (isApplyingControlledUpdateRef.current) return; | ||
| 698 | if (!snapshotChanged) { | ||
| 699 | if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { | ||
| 700 | onVisibleSelectionChangeRef.current?.(); | ||
| 701 | } | ||
| 702 | return; | ||
| 703 | } | ||
| 704 | // A selection-only update while a newer prompt waits to be applied (a chip | ||
| 705 | // was just inserted through the store) would report stale text and clobber | ||
| 706 | // the prompt. Let the controlled rewrite land instead. | ||
| 707 | if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { | ||
| 708 | return; | ||
| 709 | } | ||
| 710 | snapshotRef.current = { | ||
| 711 | value: nextValue, | ||
| 712 | cursor: nextCursor, | ||
| 713 | expandedCursor: nextExpandedCursor, | ||
| 714 | contextIds: map.contextIds, | ||
| 715 | }; | ||
| 716 | const cursorAdjacentToMention = | ||
| 717 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || | ||
| 718 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); | ||
| 719 | onChangeRef.current( | ||
| 720 | nextValue, | ||
| 721 | nextCursor, | ||
| 722 | nextExpandedCursor, | ||
| 723 | cursorAdjacentToMention, | ||
| 724 | map.contextIds, | ||
| 725 | ); | ||
| 726 | }, []); | ||
| 727 | |||
| 728 | const editor = useEditor( | ||
| 729 | { | ||
| 730 | extensions: [ | ||
| 731 | StarterKit.configure({ | ||
| 732 | blockquote: false, | ||
| 733 | bulletList: false, | ||
| 734 | codeBlock: false, | ||
| 735 | heading: false, | ||
| 736 | horizontalRule: false, | ||
| 737 | listItem: false, | ||
| 738 | link: false, | ||
| 739 | orderedList: false, | ||
| 740 | underline: false, | ||
| 741 | dropcursor: false, | ||
| 742 | gapcursor: false, | ||
| 743 | trailingNode: false, | ||
| 744 | // Plain mode has no marks: typed markers stay literal characters. | ||
| 745 | ...(richText ? {} : { bold: false, italic: false, strike: false, code: false }), | ||
| 746 | }), | ||
| 747 | ComposerMentionExtension, | ||
| 748 | ComposerSkillExtension, | ||
| 749 | ComposerCitationExtension, | ||
| 750 | ComposerContextReferenceExtension, | ||
| 751 | ComposerMarkersExtension, | ||
| 752 | ...(richText | ||
| 753 | ? [ | ||
| 754 | TaskList, | ||
| 755 | ComposerTaskItemExtension.extend({ | ||
| 756 | addInputRules() { | ||
| 757 | return [ | ||
| 758 | wrappingInputRule({ | ||
| 759 | find: /^- \[([ xX])\] $/, | ||
| 760 | type: this.type, | ||
| 761 | getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), | ||
| 762 | }), | ||
| 763 | ]; | ||
| 764 | }, | ||
| 765 | }), | ||
| 766 | ] | ||
| 767 | : []), | ||
| 768 | ], | ||
| 769 | content: buildDocJson( | ||
| 770 | value, | ||
| 771 | (name) => { | ||
| 772 | const normalized = name.startsWith("$") ? name.slice(1) : name; | ||
| 773 | const found = skills.find((candidate) => candidate.name === normalized); | ||
| 774 | if (!found) { | ||
| 775 | return { | ||
| 776 | label: formatProviderSkillDisplayName({ name: normalized }), | ||
| 777 | description: null, | ||
| 778 | }; | ||
| 779 | } | ||
| 780 | const shortDescription = found.shortDescription?.trim(); | ||
| 781 | return { | ||
| 782 | label: formatProviderSkillDisplayName(found), | ||
| 783 | description: shortDescription || found.description?.trim() || null, | ||
| 784 | }; | ||
| 785 | }, | ||
| 786 | { styling: richText }, | ||
| 787 | ), | ||
| 788 | editable: !disabled, | ||
| 789 | editorProps: { | ||
| 790 | attributes: { | ||
| 791 | class: cn( | ||
| 792 | "composer-tiptap block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", | ||
| 793 | className, | ||
| 794 | ), | ||
| 795 | "data-testid": "composer-editor", | ||
| 796 | "data-composer-rich-text": richText ? "true" : "false", | ||
| 797 | "aria-placeholder": placeholder, | ||
| 798 | }, | ||
| 799 | handleKeyDown: (view, event) => { | ||
| 800 | if ( | ||
| 801 | isMacPlatform(navigator.platform) && | ||
| 802 | (event.key === "Home" || event.key === "End") && | ||
| 803 | !event.altKey && | ||
| 804 | !event.metaKey && | ||
| 805 | !event.ctrlKey && | ||
| 806 | !event.isComposing | ||
| 807 | ) { | ||
| 808 | const selection = window.getSelection(); | ||
| 809 | if ( | ||
| 810 | selection?.anchorNode && | ||
| 811 | view.dom.contains(selection.anchorNode) && | ||
| 812 | typeof selection.modify === "function" | ||
| 813 | ) { | ||
| 814 | event.preventDefault(); | ||
| 815 | event.stopPropagation(); | ||
| 816 | selection.modify( | ||
| 817 | event.shiftKey ? "extend" : "move", | ||
| 818 | event.key === "Home" ? "backward" : "forward", | ||
| 819 | "lineboundary", | ||
| 820 | ); | ||
| 821 | if (selection.anchorNode && selection.focusNode) { | ||
| 822 | view.dispatch( | ||
| 823 | view.state.tr | ||
| 824 | .setSelection( | ||
| 825 | TextSelection.create( | ||
| 826 | view.state.doc, | ||
| 827 | view.posAtDOM(selection.anchorNode, selection.anchorOffset), | ||
| 828 | view.posAtDOM(selection.focusNode, selection.focusOffset), | ||
| 829 | ), | ||
| 830 | ) | ||
| 831 | .scrollIntoView(), | ||
| 832 | ); | ||
| 833 | } | ||
| 834 | return true; | ||
| 835 | } | ||
| 836 | } | ||
| 837 | if ( | ||
| 838 | (event.key === "ArrowLeft" || event.key === "ArrowRight") && | ||
| 839 | !event.shiftKey && | ||
| 840 | !event.altKey && | ||
| 841 | !event.metaKey && | ||
| 842 | !event.ctrlKey && | ||
| 843 | !event.isComposing && | ||
| 844 | view.state.selection.empty | ||
| 845 | ) { | ||
| 846 | const { $from } = view.state.selection; | ||
| 847 | const direction = event.key === "ArrowLeft" ? -1 : 1; | ||
| 848 | const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter; | ||
| 849 | if (adjacent?.type.name.startsWith("composer-")) { | ||
| 850 | event.preventDefault(); | ||
| 851 | event.stopPropagation(); | ||
| 852 | view.dispatch( | ||
| 853 | view.state.tr | ||
| 854 | .setSelection( | ||
| 855 | TextSelection.create(view.state.doc, $from.pos + direction * adjacent.nodeSize), | ||
| 856 | ) | ||
| 857 | .scrollIntoView(), | ||
| 858 | ); | ||
| 859 | return true; | ||
| 860 | } | ||
| 861 | } | ||
| 862 | if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) { | ||
| 863 | event.stopPropagation(); | ||
| 864 | return true; | ||
| 865 | } | ||
| 866 | // Enter on a focused task checkbox must not send the prompt (or | ||
| 867 | // split anything): Space toggles it, Enter does nothing. | ||
| 868 | if (event.key === "Enter" && event.target instanceof HTMLInputElement) { | ||
| 869 | event.preventDefault(); | ||
| 870 | return true; | ||
| 871 | } | ||
| 872 | const handler = onCommandKeyDownRef.current; | ||
| 873 | if (event.key === "Enter") { | ||
| 874 | const instance = editorHolder.current; | ||
| 875 | const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); | ||
| 876 | const handled = handler?.("Enter", event, isTaskItem) ?? false; | ||
| 877 | if (handled) { | ||
| 878 | event.preventDefault(); | ||
| 879 | event.stopPropagation(); | ||
| 880 | return true; | ||
| 881 | } | ||
| 882 | event.preventDefault(); | ||
| 883 | if ( | ||
| 884 | isTaskItem && | ||
| 885 | instance && | ||
| 886 | (instance.commands.splitListItem("taskItem", { checked: false }) || | ||
| 887 | (view.state.selection.$from.parent.content.size === 0 && | ||
| 888 | instance.commands.liftListItem("taskItem"))) | ||
| 889 | ) { | ||
| 890 | return true; | ||
| 891 | } | ||
| 892 | // Split the paragraph so a single newline visibly advances the caret. | ||
| 893 | return splitBlockKeepMarks(view.state, (tr) => { | ||
| 894 | // The split is programmatic, so the browser won't follow the | ||
| 895 | // caret into view on its own. | ||
| 896 | view.dispatch(tr.scrollIntoView()); | ||
| 897 | }); | ||
| 898 | } | ||
| 899 | if (!handler) return false; | ||
| 900 | const key = | ||
| 901 | event.key === "Tab" | ||
| 902 | ? ("Tab" as const) | ||
| 903 | : event.key === "ArrowDown" | ||
| 904 | ? ("ArrowDown" as const) | ||
| 905 | : event.key === "ArrowUp" | ||
| 906 | ? ("ArrowUp" as const) | ||
| 907 | : null; | ||
| 908 | if (!key) return false; | ||
| 909 | const handled = handler(key, event); | ||
| 910 | if (handled) { | ||
| 911 | event.preventDefault(); | ||
| 912 | event.stopPropagation(); | ||
| 913 | } | ||
| 914 | return handled; | ||
| 915 | }, | ||
| 916 | handleTextInput: (view, from, to, text) => { | ||
| 917 | if (text.length !== 1) return false; | ||
| 918 | const closer = SURROUND_CLOSE[text]; | ||
| 919 | if (!closer || from === to) return false; | ||
| 920 | // Never wrap chips or other atoms, and never wrap styled text: the | ||
| 921 | // default replace keeps marks intact, wrapping would drop them. | ||
| 922 | let touchesSpecial = false; | ||
| 923 | view.state.doc.nodesBetween(from, to, (node) => { | ||
| 924 | if ( | ||
| 925 | (node.isAtom && node.isInline && !node.isText) || | ||
| 926 | (node.isText && node.marks.length > 0) | ||
| 927 | ) { | ||
| 928 | touchesSpecial = true; | ||
| 929 | return false; | ||
| 930 | } | ||
| 931 | return true; | ||
| 932 | }); | ||
| 933 | if (touchesSpecial) return false; | ||
| 934 | const map = serializeEditorDoc(view.state.doc); | ||
| 935 | const startMd = flatToMarkdown(map, pmToFlat(map, from)); | ||
| 936 | const endMd = flatToMarkdown(map, pmToFlat(map, to)); | ||
| 937 | if (selectionTouchesMentionBoundary(map.value, startMd, endMd)) return false; | ||
| 938 | const tr = view.state.tr.insertText(closer, to).insertText(text, from); | ||
| 939 | tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length)); | ||
| 940 | view.dispatch(tr); | ||
| 941 | return true; | ||
| 942 | }, | ||
| 943 | handlePaste: (view, event) => { | ||
| 944 | const clipboardData = event.clipboardData; | ||
| 945 | if (!clipboardData || clipboardData.files.length > 0) return false; | ||
| 946 | const pastedText = clipboardData.getData("text/plain"); | ||
| 947 | if (!pastedText) return false; | ||
| 948 | event.preventDefault(); | ||
| 949 | const importFragment = importFragmentRef.current; | ||
| 950 | let text = importFragment | ||
| 951 | ? importPastedComposerText(clipboardData, importFragment) | ||
| 952 | : pastedText; | ||
| 953 | // Complete chips at paste boundaries just as autocomplete does. | ||
| 954 | const tokens = collectComposerPromptInlineTokens(`${text}\n`); | ||
| 955 | const lastToken = tokens.at(-1); | ||
| 956 | if ( | ||
| 957 | (lastToken?.type === "mention" || lastToken?.type === "skill") && | ||
| 958 | lastToken.end === text.length | ||
| 959 | ) { | ||
| 960 | text += " "; | ||
| 961 | } | ||
| 962 | if ( | ||
| 963 | (tokens[0]?.type === "mention" || tokens[0]?.type === "skill") && | ||
| 964 | tokens[0].start === 0 | ||
| 965 | ) { | ||
| 966 | const map = serializeEditorDoc(view.state.doc); | ||
| 967 | const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); | ||
| 968 | if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; | ||
| 969 | } | ||
| 970 | const editorInstance = editorHolder.current; | ||
| 971 | if (editorInstance) { | ||
| 972 | insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => { | ||
| 973 | editorInstance.commands.insertContent(content); | ||
| 974 | }); | ||
| 975 | scrollTiptapCaretIntoView(editorInstance); | ||
| 976 | } | ||
| 977 | return true; | ||
| 978 | }, | ||
| 979 | }, | ||
| 980 | onUpdate: ({ editor: updated }) => { | ||
| 981 | handleEditorChange(updated); | ||
| 982 | }, | ||
| 983 | onSelectionUpdate: ({ editor: updated }) => { | ||
| 984 | handleEditorChange(updated); | ||
| 985 | }, | ||
| 986 | }, | ||
| 987 | [], | ||
| 988 | ); | ||
| 989 | |||
| 990 | useEffect(() => { | ||
| 991 | editor?.setEditable(!disabled); | ||
| 992 | }, [disabled, editor]); | ||
| 993 | |||
| 994 | useEffect(() => { | ||
| 995 | editorHolder.current = editor; | ||
| 996 | }, [editor]); | ||
| 997 | |||
| 998 | const readSnapshot = useCallback(() => { | ||
| 999 | const snapshot = snapshotRef.current; | ||
| 1000 | if (!editor) return snapshot; | ||
| 1001 | const map = serializeEditorDoc(editor.state.doc); | ||
| 1002 | const { from, to } = editor.state.selection; | ||
| 1003 | const fromFlat = pmToFlat(map, from); | ||
| 1004 | const next: typeof snapshot = { | ||
| 1005 | value: map.value, | ||
| 1006 | cursor: clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)), | ||
| 1007 | expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))), | ||
| 1008 | contextIds: map.contextIds, | ||
| 1009 | }; | ||
| 1010 | const toFlat = pmToFlat(map, to); | ||
| 1011 | selectionRangeRef.current = { | ||
| 1012 | start: Math.min(next.expandedCursor, flatToMarkdown(map, toFlat)), | ||
| 1013 | end: Math.max(next.expandedCursor, flatToMarkdown(map, toFlat)), | ||
| 1014 | }; | ||
| 1015 | snapshotRef.current = next; | ||
| 1016 | return next; | ||
| 1017 | }, [editor]); | ||
| 1018 | |||
| 1019 | // Controlled value/cursor from the store (history recall, chip insertion…). | ||
| 1020 | useLayoutEffect(() => { | ||
| 1021 | if (!editor) return; | ||
| 1022 | const initialSelection = !hasAppliedControlledSelectionRef.current; | ||
| 1023 | hasAppliedControlledSelectionRef.current = true; | ||
| 1024 | const normalizedCursor = clampCollapsedComposerCursor(value, cursor); | ||
| 1025 | const previousSnapshot = snapshotRef.current; | ||
| 1026 | if ( | ||
| 1027 | !initialSelection && | ||
| 1028 | previousSnapshot.value === value && | ||
| 1029 | previousSnapshot.cursor === normalizedCursor | ||
| 1030 | ) { | ||
| 1031 | return; | ||
| 1032 | } | ||
| 1033 | const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); | ||
| 1034 | snapshotRef.current = { | ||
| 1035 | value, | ||
| 1036 | cursor: normalizedCursor, | ||
| 1037 | expandedCursor: normalizedExpandedCursor, | ||
| 1038 | contextIds: collectInlineContextIds(value), | ||
| 1039 | }; | ||
| 1040 | selectionRangeRef.current = { | ||
| 1041 | start: normalizedExpandedCursor, | ||
| 1042 | end: normalizedExpandedCursor, | ||
| 1043 | }; | ||
| 1044 | setIsEmpty(value.length === 0); | ||
| 1045 | const rootElement = editor.view.dom; | ||
| 1046 | const isFocused = Boolean(rootElement && document.activeElement === rootElement); | ||
| 1047 | if (!initialSelection && previousSnapshot.value === value && !isFocused) return; | ||
| 1048 | |||
| 1049 | isApplyingControlledUpdateRef.current = true; | ||
| 1050 | const pendingCitation = | ||
| 1051 | citationRequestRef.current?.value === value ? citationRequestRef.current : null; | ||
| 1052 | if (previousSnapshot.value !== value) { | ||
| 1053 | editor.commands.setContent(buildDocJson(value, skillLabelFor, { styling: richText }), { | ||
| 1054 | emitUpdate: false, | ||
| 1055 | }); | ||
| 1056 | } | ||
| 1057 | const map = serializeEditorDoc(editor.state.doc); | ||
| 1058 | const flat = collapsedToFlat(map, normalizedCursor); | ||
| 1059 | editor.commands.setTextSelection(flatToPm(map, flat)); | ||
| 1060 | if (isFocused) scrollTiptapCaretIntoView(editor); | ||
| 1061 | if (pendingCitation) { | ||
| 1062 | citationRequestRef.current = null; | ||
| 1063 | const target = map.runs.find( | ||
| 1064 | (run) => | ||
| 1065 | run.kind === "token" && | ||
| 1066 | run.nodeName === "composer-citation" && | ||
| 1067 | run.mdStart === pendingCitation.citationStart, | ||
| 1068 | ); | ||
| 1069 | if (target) { | ||
| 1070 | const node = editor.state.doc.nodeAt(target.pmPos); | ||
| 1071 | const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey; | ||
| 1072 | if (citeKey) { | ||
| 1073 | setOpenCitation({ | ||
| 1074 | key: citeKey, | ||
| 1075 | sourceAnchor: pendingCitation.sourceAnchor, | ||
| 1076 | removeOnCancel: true, | ||
| 1077 | }); | ||
| 1078 | } | ||
| 1079 | } | ||
| 1080 | } | ||
| 1081 | queueMicrotask(() => { | ||
| 1082 | isApplyingControlledUpdateRef.current = false; | ||
| 1083 | }); | ||
| 1084 | }, [cursor, editor, richText, skillLabelFor, value]); | ||
| 1085 | |||
| 1086 | const focusAt = useCallback( | ||
| 1087 | (nextCursor: number) => { | ||
| 1088 | if (!editor) return; | ||
| 1089 | editor.view.dom.focus({ preventScroll: true }); | ||
| 1090 | // A newer prompt is waiting to be applied (a chip was just inserted | ||
| 1091 | // through the store). Reporting the editor's stale text now would | ||
| 1092 | // overwrite that prompt; the pending rewrite places the caret instead. | ||
| 1093 | if (snapshotRef.current.value !== latestValueRef.current) return; | ||
| 1094 | const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); | ||
| 1095 | const map = serializeEditorDoc(editor.state.doc); | ||
| 1096 | const flat = collapsedToFlat(map, boundedCursor); | ||
| 1097 | editor.commands.setTextSelection(flatToPm(map, flat)); | ||
| 1098 | scrollTiptapCaretIntoView(editor); | ||
| 1099 | if (boundedCursor === snapshotRef.current.cursor) return; | ||
| 1100 | snapshotRef.current = { | ||
| 1101 | value: snapshotRef.current.value, | ||
| 1102 | cursor: boundedCursor, | ||
| 1103 | expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), | ||
| 1104 | contextIds: snapshotRef.current.contextIds, | ||
| 1105 | }; | ||
| 1106 | selectionRangeRef.current = { | ||
| 1107 | start: snapshotRef.current.expandedCursor, | ||
| 1108 | end: snapshotRef.current.expandedCursor, | ||
| 1109 | }; | ||
| 1110 | onChangeRef.current( | ||
| 1111 | snapshotRef.current.value, | ||
| 1112 | boundedCursor, | ||
| 1113 | snapshotRef.current.expandedCursor, | ||
| 1114 | false, | ||
| 1115 | snapshotRef.current.contextIds, | ||
| 1116 | ); | ||
| 1117 | }, | ||
| 1118 | [editor], | ||
| 1119 | ); | ||
| 1120 | |||
| 1121 | useImperativeHandle( | ||
| 1122 | editorRef, | ||
| 1123 | () => ({ | ||
| 1124 | focus: () => { | ||
| 1125 | focusAt(snapshotRef.current.cursor); | ||
| 1126 | }, | ||
| 1127 | focusAt, | ||
| 1128 | focusAtEnd: () => { | ||
| 1129 | focusAt( | ||
| 1130 | collapseExpandedComposerCursor( | ||
| 1131 | snapshotRef.current.value, | ||
| 1132 | snapshotRef.current.value.length, | ||
| 1133 | ), | ||
| 1134 | ); | ||
| 1135 | }, | ||
| 1136 | readSelectionRange: () => { | ||
| 1137 | readSnapshot(); | ||
| 1138 | return selectionRangeRef.current; | ||
| 1139 | }, | ||
| 1140 | requestCitationComment: (request) => { | ||
| 1141 | citationRequestRef.current = request; | ||
| 1142 | if (!editor) return; | ||
| 1143 | const map = serializeEditorDoc(editor.state.doc); | ||
| 1144 | if (map.value !== request.value) return; | ||
| 1145 | const target = map.runs.find( | ||
| 1146 | (run) => | ||
| 1147 | run.kind === "token" && | ||
| 1148 | run.nodeName === "composer-citation" && | ||
| 1149 | run.mdStart === request.citationStart, | ||
| 1150 | ); | ||
| 1151 | if (!target) return; | ||
| 1152 | const node = editor.state.doc.nodeAt(target.pmPos); | ||
| 1153 | const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey; | ||
| 1154 | if (citeKey) { | ||
| 1155 | citationRequestRef.current = null; | ||
| 1156 | setOpenCitation({ | ||
| 1157 | key: citeKey, | ||
| 1158 | sourceAnchor: request.sourceAnchor, | ||
| 1159 | removeOnCancel: true, | ||
| 1160 | }); | ||
| 1161 | } | ||
| 1162 | }, | ||
| 1163 | readSnapshot, | ||
| 1164 | isCaretOnVisualEdge: (edge) => { | ||
| 1165 | const snapshot = readSnapshot(); | ||
| 1166 | if (snapshot.value.length === 0) return true; | ||
| 1167 | const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); | ||
| 1168 | const afterCaret = snapshot.value.slice(snapshot.expandedCursor); | ||
| 1169 | if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { | ||
| 1170 | return false; | ||
| 1171 | } | ||
| 1172 | const rootElement = editor?.view.dom; | ||
| 1173 | const selection = window.getSelection(); | ||
| 1174 | if ( | ||
| 1175 | !rootElement || | ||
| 1176 | !selection || | ||
| 1177 | !selection.isCollapsed || | ||
| 1178 | selection.rangeCount === 0 || | ||
| 1179 | !selection.anchorNode || | ||
| 1180 | !rootElement.contains(selection.anchorNode) | ||
| 1181 | ) { | ||
| 1182 | return false; | ||
| 1183 | } | ||
| 1184 | const caretRect = caretLineRect(selection.getRangeAt(0), edge); | ||
| 1185 | if (!caretRect) return false; | ||
| 1186 | const edgeElement = | ||
| 1187 | edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; | ||
| 1188 | const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); | ||
| 1189 | const threshold = caretRect.height / 2; | ||
| 1190 | return edge === "start" | ||
| 1191 | ? caretRect.top - edgeRect.top < threshold | ||
| 1192 | : edgeRect.bottom - caretRect.bottom < threshold; | ||
| 1193 | }, | ||
| 1194 | }), | ||
| 1195 | [editor, focusAt, readSnapshot], | ||
| 1196 | ); | ||
| 1197 | |||
| 1198 | const handleCopyCut = useCallback( | ||
| 1199 | (event: React.ClipboardEvent, cut: boolean) => { | ||
| 1200 | const build = buildFragmentRef.current; | ||
| 1201 | if (!editor || (cut && !editor.isEditable)) return; | ||
| 1202 | const clipboardData = event.clipboardData; | ||
| 1203 | const { from, to } = editor.state.selection; | ||
| 1204 | if (from === to) return; | ||
| 1205 | const { doc, schema } = editor.state; | ||
| 1206 | const slice = doc.slice(from, to); | ||
| 1207 | const first = slice.content.firstChild; | ||
| 1208 | const content = first?.isInline | ||
| 1209 | ? schema.nodes.paragraph!.create(null, slice.content) | ||
| 1210 | : first?.type.name === "taskItem" | ||
| 1211 | ? schema.nodes.taskList!.create(null, slice.content) | ||
| 1212 | : slice.content; | ||
| 1213 | const text = serializeEditorDoc(doc.type.create(null, content)).value; | ||
| 1214 | const contextIds = Array.from(new Set(collectInlineContextIds(text))); | ||
| 1215 | const fragment = contextIds.length > 0 ? build?.(contextIds) : null; | ||
| 1216 | event.preventDefault(); | ||
| 1217 | clipboardData.setData("text/plain", text); | ||
| 1218 | if (fragment) { | ||
| 1219 | clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment); | ||
| 1220 | clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment)); | ||
| 1221 | } | ||
| 1222 | if (cut) { | ||
| 1223 | editor.chain().focus().deleteSelection().run(); | ||
| 1224 | } | ||
| 1225 | }, | ||
| 1226 | [editor], | ||
| 1227 | ); | ||
| 1228 | |||
| 1229 | return ( | ||
| 1230 | <RichComposerSkillsContext value={skills}> | ||
| 1231 | <ComposerContextRecordsContext value={contextRecords}> | ||
| 1232 | <ComposerCitationCommentContext value={citationCommentActions}> | ||
| 1233 | <div | ||
| 1234 | className={cn( | ||
| 1235 | "relative [font-family:var(--font-composer,var(--font-sans))] [font-size:var(--font-size-prompt,0.875rem)] [@media(max-width:39.999rem)_and_(pointer:coarse)]:[font-size:max(var(--font-size-prompt,1rem),16px)]", | ||
| 1236 | containerClassName, | ||
| 1237 | )} | ||
| 1238 | > | ||
| 1239 | <EditorContent | ||
| 1240 | editor={editor} | ||
| 1241 | onKeyDown={(event) => { | ||
| 1242 | if ( | ||
| 1243 | event.key === "Control" || | ||
| 1244 | event.key === "Meta" || | ||
| 1245 | event.key === "Alt" || | ||
| 1246 | event.key === "Shift" | ||
| 1247 | ) { | ||
| 1248 | onPageScrollRelease?.(); | ||
| 1249 | } | ||
| 1250 | if (event.key !== "PageUp" && event.key !== "PageDown") return; | ||
| 1251 | const target = event.currentTarget.querySelector( | ||
| 1252 | '[data-testid="composer-editor"]', | ||
| 1253 | ) as HTMLElement | null; | ||
| 1254 | if (!target) return; | ||
| 1255 | const pageScrollKey = getTimelinePageScrollKey({ | ||
| 1256 | altKey: event.altKey, | ||
| 1257 | clientHeight: target.clientHeight, | ||
| 1258 | ctrlKey: event.ctrlKey, | ||
| 1259 | defaultPrevented: event.defaultPrevented, | ||
| 1260 | isComposing: event.nativeEvent.isComposing, | ||
| 1261 | key: event.key, | ||
| 1262 | keyCode: event.keyCode, | ||
| 1263 | metaKey: event.metaKey, | ||
| 1264 | scrollHeight: target.scrollHeight, | ||
| 1265 | scrollTop: target.scrollTop, | ||
| 1266 | shiftKey: event.shiftKey, | ||
| 1267 | }); | ||
| 1268 | if (!pageScrollKey) { | ||
| 1269 | onPageScrollRelease?.(); | ||
| 1270 | return; | ||
| 1271 | } | ||
| 1272 | if (!onPageScrollKeyDown) return; | ||
| 1273 | event.preventDefault(); | ||
| 1274 | onPageScrollKeyDown(pageScrollKey); | ||
| 1275 | }} | ||
| 1276 | onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} | ||
| 1277 | onBlur={onPageScrollRelease} | ||
| 1278 | onPasteCapture={onPaste} | ||
| 1279 | onCopyCapture={(event) => handleCopyCut(event, false)} | ||
| 1280 | onCutCapture={(event) => handleCopyCut(event, true)} | ||
| 1281 | /> | ||
| 1282 | {isEmpty && contextRecords.size === 0 && placeholder ? ( | ||
| 1283 | <div | ||
| 1284 | className={cn( | ||
| 1285 | "pointer-events-none absolute inset-0 leading-relaxed text-placeholder/75", | ||
| 1286 | placeholderClassName, | ||
| 1287 | )} | ||
| 1288 | > | ||
| 1289 | {placeholder} | ||
| 1290 | </div> | ||
| 1291 | ) : null} | ||
| 1292 | </div> | ||
| 1293 | </ComposerCitationCommentContext> | ||
| 1294 | </ComposerContextRecordsContext> | ||
| 1295 | </RichComposerSkillsContext> | ||
| 1296 | ); | ||
| 1297 | } | ||
| 1298 | |||
| 1299 | /** | ||
| 1300 | * Insert pasted markdown at the selection, rebuilding inline tokens as chips | ||
| 1301 | * and styled spans as marks. | ||
| 1302 | * | ||
| 1303 | * Newlines always become paragraph splits — never trailing hard breaks, which | ||
| 1304 | * render no visible line — so pasted text lands exactly as typed. | ||
| 1305 | */ | ||
| 1306 | function insertMarkdownParagraphs( | ||
| 1307 | value: string, | ||
| 1308 | skillLabelFor: (name: string) => SkillMeta, | ||
| 1309 | options: { styling: boolean }, | ||
| 1310 | insertContent: (content: JSONContent[] | JSONContent) => void, | ||
| 1311 | ): void { | ||
| 1312 | const blocks = buildTiptapContent(value, skillLabelFor, options); | ||
| 1313 | if (blocks.length === 1 && blocks[0]?.type === "paragraph") { | ||
| 1314 | const inline = (blocks[0]?.content ?? []) as JSONContent[]; | ||
| 1315 | if (inline.length === 0) return; | ||
| 1316 | insertContent(inline); | ||
| 1317 | return; | ||
| 1318 | } | ||
| 1319 | insertContent(blocks as JSONContent[]); | ||
| 1320 | } | ||
| 1321 | |||
| 1322 | /** | ||
| 1323 | * Follow a programmatically placed caret: native scrolling only happens for | ||
| 1324 | * real input, so controlled rewrites, pastes, and focus restores scroll the | ||
| 1325 | * composer to the caret explicitly. | ||
| 1326 | */ | ||
| 1327 | function scrollTiptapCaretIntoView(editor: TiptapEditor): void { | ||
| 1328 | editor.view.dispatch(editor.state.tr.scrollIntoView()); | ||
| 1329 | } | ||
| 1330 | |||
| 1331 | /** | ||
| 1332 | * Client rect of the caret's visual line, so prompt history keeps claiming | ||
| 1333 | * ArrowUp/Down only at the first and last soft-wrapped lines. | ||
| 1334 | */ | ||
| 1335 | function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { | ||
| 1336 | const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); | ||
| 1337 | const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; | ||
| 1338 | if (collapsedRect) return collapsedRect; | ||
| 1339 | |||
| 1340 | const container = range.startContainer; | ||
| 1341 | // TEXT_NODE without importing the DOM lib's Node (shadowed by Tiptap's). | ||
| 1342 | if (container.nodeType === 3) { | ||
| 1343 | const textNode = container as Text; | ||
| 1344 | if (textNode.data.length === 0) return null; | ||
| 1345 | const probeStart = Math.max( | ||
| 1346 | 0, | ||
| 1347 | Math.min( | ||
| 1348 | edge === "start" ? range.startOffset : range.startOffset - 1, | ||
| 1349 | textNode.data.length - 1, | ||
| 1350 | ), | ||
| 1351 | ); | ||
| 1352 | const probeRange = document.createRange(); | ||
| 1353 | probeRange.setStart(textNode, probeStart); | ||
| 1354 | probeRange.setEnd(textNode, probeStart + 1); | ||
| 1355 | const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); | ||
| 1356 | if (probeRect) return probeRect; | ||
| 1357 | const boundingRect = probeRange.getBoundingClientRect(); | ||
| 1358 | return boundingRect.height > 0 ? boundingRect : null; | ||
| 1359 | } | ||
| 1360 | |||
| 1361 | if (!(container instanceof HTMLElement)) return null; | ||
| 1362 | const neighbour = | ||
| 1363 | container.childNodes[Math.max(0, range.startOffset - 1)] ?? | ||
| 1364 | container.childNodes[range.startOffset]; | ||
| 1365 | if (neighbour instanceof HTMLElement) { | ||
| 1366 | const neighbourRect = neighbour.getBoundingClientRect(); | ||
| 1367 | if (neighbourRect.height > 0) return neighbourRect; | ||
| 1368 | } else if (neighbour instanceof Text && neighbour.data.length > 0) { | ||
| 1369 | const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; | ||
| 1370 | const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; | ||
| 1371 | const probeRange = document.createRange(); | ||
| 1372 | probeRange.setStart(neighbour, probeStart); | ||
| 1373 | probeRange.setEnd(neighbour, probeStart + 1); | ||
| 1374 | const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); | ||
| 1375 | if (probeRect) return probeRect; | ||
| 1376 | } | ||
| 1377 | const containerRect = container.getBoundingClientRect(); | ||
| 1378 | return containerRect.height > 0 ? containerRect : null; | ||
| 1379 | } | ||
apps/web/src/components/chat/ChatComposer.tsx+34-2| ... | @@ -79,6 +79,7 @@ import { | ... | @@ -79,6 +79,7 @@ import { |
| 79 | replaceTextRange, | 79 | replaceTextRange, |
| 80 | } from "../../composer-logic"; | 80 | } from "../../composer-logic"; |
| 81 | import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; | 81 | import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; |
| 82 | import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation"; | ||
| 82 | import { | 83 | import { |
| 83 | deriveComposerSendState, | 84 | deriveComposerSendState, |
| 84 | getAntigravitySendBlockReason, | 85 | getAntigravitySendBlockReason, |
| ... | @@ -3264,7 +3265,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3264,7 +3265,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3264 | }, [setIsComposerScrollCollapsed]); | 3265 | }, [setIsComposerScrollCollapsed]); |
| 3265 | 3266 | ||
| 3266 | /** | 3267 | /** |
| 3267 | * Payloads for chips the prompt no longer references. Lexical's history restores the | 3268 | * Payloads for chips the prompt no longer references. History undo restores the |
| 3268 | * reference text but knows nothing about the draft records behind it, so a delete keeps its | 3269 | * reference text but knows nothing about the draft records behind it, so a delete keeps its |
| 3269 | * payload here and an undo puts it back rather than leaving a dangling chip. | 3270 | * payload here and an undo puts it back rather than leaving a dangling chip. |
| 3270 | */ | 3271 | */ |
| ... | @@ -3420,6 +3421,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3420,6 +3421,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3420 | replacement: string, | 3421 | replacement: string, |
| 3421 | options?: { | 3422 | options?: { |
| 3422 | expectedText?: string; | 3423 | expectedText?: string; |
| 3424 | expandedCursorAfterReplace?: number; | ||
| 3423 | focusEditorAfterReplace?: boolean; | 3425 | focusEditorAfterReplace?: boolean; |
| 3424 | citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor }; | 3426 | citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor }; |
| 3425 | }, | 3427 | }, |
| ... | @@ -3440,7 +3442,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3440,7 +3442,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3440 | return false; | 3442 | return false; |
| 3441 | } | 3443 | } |
| 3442 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); | 3444 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); |
| 3443 | const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); | 3445 | const nextCursor = collapseExpandedComposerCursor( |
| 3446 | next.text, | ||
| 3447 | options?.expandedCursorAfterReplace ?? next.cursor, | ||
| 3448 | ); | ||
| 3444 | const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); | 3449 | const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); |
| 3445 | if (options?.citationComment) { | 3450 | if (options?.citationComment) { |
| 3446 | composerEditorRef.current?.requestCitationComment({ | 3451 | composerEditorRef.current?.requestCitationComment({ |
| ... | @@ -3927,6 +3932,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3927,6 +3932,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3927 | const onComposerCommandKey = ( | 3932 | const onComposerCommandKey = ( |
| 3928 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | 3933 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", |
| 3929 | event: KeyboardEvent, | 3934 | event: KeyboardEvent, |
| 3935 | isTaskItem = false, | ||
| 3930 | ) => { | 3936 | ) => { |
| 3931 | if (key === "Tab" && event.shiftKey) { | 3937 | if (key === "Tab" && event.shiftKey) { |
| 3932 | if (!planModeUiEnabled) return false; | 3938 | if (!planModeUiEnabled) return false; |
| ... | @@ -3970,6 +3976,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3970,6 +3976,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3970 | submitComposer(undefined, submissionIntent); | 3976 | submitComposer(undefined, submissionIntent); |
| 3971 | return true; | 3977 | return true; |
| 3972 | } | 3978 | } |
| 3979 | // Native task splitting preserves marks and chips on both sides of the caret. | ||
| 3980 | if (key === "Enter" && isTaskItem) return false; | ||
| 3981 | if (!event.isComposing && (key === "Enter" || (key === "Tab" && !event.shiftKey))) { | ||
| 3982 | const selection = composerEditorRef.current?.readSelectionRange(); | ||
| 3983 | const snapshot = readComposerSnapshot(); | ||
| 3984 | if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { | ||
| 3985 | const edit = | ||
| 3986 | key === "Enter" | ||
| 3987 | ? listContinuationForEnter(snapshot.value, selection.start) | ||
| 3988 | : listIndentForTab(snapshot.value, selection.start, selection.end); | ||
| 3989 | if ( | ||
| 3990 | edit && | ||
| 3991 | applyPromptReplacement( | ||
| 3992 | edit.start, | ||
| 3993 | edit.end, | ||
| 3994 | edit.replacement, | ||
| 3995 | key === "Tab" | ||
| 3996 | ? { expandedCursorAfterReplace: selection.start + edit.replacement.length } | ||
| 3997 | : undefined, | ||
| 3998 | ) | ||
| 3999 | ) { | ||
| 4000 | return true; | ||
| 4001 | } | ||
| 4002 | } | ||
| 4003 | } | ||
| 3973 | return false; | 4004 | return false; |
| 3974 | }; | 4005 | }; |
| 3975 | 4006 | ||
| ... | @@ -6699,6 +6730,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -6699,6 +6730,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6699 | <ComposerContextActionsContext value={composerContextActions}> | 6730 | <ComposerContextActionsContext value={composerContextActions}> |
| 6700 | <ComposerPromptEditor | 6731 | <ComposerPromptEditor |
| 6701 | editorRef={composerEditorRef} | 6732 | editorRef={composerEditorRef} |
| 6733 | richTextEnabled={settings.composerRichTextEnabled} | ||
| 6702 | value={ | 6734 | value={ |
| 6703 | isComposerApprovalState | 6735 | isComposerApprovalState |
| 6704 | ? "" | 6736 | ? "" |
apps/web/src/components/chat/ComposerStashMenu.tsx+1-1| ... | @@ -33,7 +33,7 @@ function stashEntrySnippet(entry: PromptStashEntry): string { | ... | @@ -33,7 +33,7 @@ function stashEntrySnippet(entry: PromptStashEntry): string { |
| 33 | * Attached banner listing the stashed prompts. Opened by the stash badge or ⌘S | 33 | * Attached banner listing the stashed prompts. Opened by the stash badge or ⌘S |
| 34 | * when the empty composer cannot restore a single entry. Navigated with arrows, | 34 | * when the empty composer cannot restore a single entry. Navigated with arrows, |
| 35 | * restored with Enter, dismissed with Escape. The listener runs capture-phase | 35 | * restored with Enter, dismissed with Escape. The listener runs capture-phase |
| 36 | * on window so it wins over the Lexical editor's handlers while the menu is open. | 36 | * on window so it wins over the composer's handlers while the menu is open. |
| 37 | */ | 37 | */ |
| 38 | export const ComposerStashMenu = memo(function ComposerStashMenu(props: { | 38 | export const ComposerStashMenu = memo(function ComposerStashMenu(props: { |
| 39 | entries: ReadonlyArray<PromptStashEntry>; | 39 | entries: ReadonlyArray<PromptStashEntry>; |
apps/web/src/components/composerInlineTokenPaste.ts+4-136| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { ComposerContextId } from "@t3tools/contracts"; | 1 | import { ComposerContextId } from "@t3tools/contracts"; |
| 2 | import type { AssistantCitation, ComposerContextClipboardFragment } from "@t3tools/contracts"; | 2 | import type { ComposerContextClipboardFragment } from "@t3tools/contracts"; |
| 3 | import { | 3 | import { |
| 4 | COMPOSER_CONTEXT_CLIPBOARD_MIME, | 4 | COMPOSER_CONTEXT_CLIPBOARD_MIME, |
| 5 | decodeComposerContextFragment, | 5 | decodeComposerContextFragment, |
| ... | @@ -10,140 +10,6 @@ import { | ... | @@ -10,140 +10,6 @@ import { |
| 10 | formatComposerContextReference, | 10 | formatComposerContextReference, |
| 11 | replaceComposerContextReferences, | 11 | replaceComposerContextReferences, |
| 12 | } from "@t3tools/shared/composerContextReferences"; | 12 | } from "@t3tools/shared/composerContextReferences"; |
| 13 | import { | ||
| 14 | $createLineBreakNode, | ||
| 15 | $createTextNode, | ||
| 16 | $getRoot, | ||
| 17 | $getSelection, | ||
| 18 | $isRangeSelection, | ||
| 19 | COMMAND_PRIORITY_HIGH, | ||
| 20 | PASTE_COMMAND, | ||
| 21 | type LexicalEditor, | ||
| 22 | type LexicalNode, | ||
| 23 | } from "lexical"; | ||
| 24 | |||
| 25 | import { collectComposerPromptInlineTokens } from "../composer-editor-mentions"; | ||
| 26 | |||
| 27 | interface ComposerInlineTokenPasteOptions { | ||
| 28 | createMentionNode: (path: string) => LexicalNode; | ||
| 29 | createCitationNode: (citation: AssistantCitation, source: string) => LexicalNode; | ||
| 30 | createContextReferenceNode: (reference: { | ||
| 31 | kind: string; | ||
| 32 | contextId: string; | ||
| 33 | label: string; | ||
| 34 | }) => LexicalNode; | ||
| 35 | getExpandedAbsoluteOffsetForPoint: (node: LexicalNode, pointOffset: number) => number; | ||
| 36 | /** | ||
| 37 | * Imports the records behind a structured paste into the draft. Returns the ids that had | ||
| 38 | * to change (a re-attached binary gets a fresh local id) so the pasted links follow. | ||
| 39 | */ | ||
| 40 | importContextFragment?: ( | ||
| 41 | fragment: ComposerContextClipboardFragment, | ||
| 42 | ) => ReadonlyMap<string, string>; | ||
| 43 | } | ||
| 44 | |||
| 45 | export function registerComposerInlineTokenPaste( | ||
| 46 | editor: LexicalEditor, | ||
| 47 | options: ComposerInlineTokenPasteOptions, | ||
| 48 | ): () => void { | ||
| 49 | return editor.registerCommand( | ||
| 50 | PASTE_COMMAND, | ||
| 51 | (event) => { | ||
| 52 | if (!(event instanceof ClipboardEvent) || event.clipboardData === null) { | ||
| 53 | return false; | ||
| 54 | } | ||
| 55 | if (event.clipboardData.files.length > 0) { | ||
| 56 | return false; | ||
| 57 | } | ||
| 58 | const pastedText = event.clipboardData.getData("text/plain"); | ||
| 59 | if (pastedText.length === 0) { | ||
| 60 | return false; | ||
| 61 | } | ||
| 62 | const text = importPastedComposerText(event.clipboardData, options.importContextFragment); | ||
| 63 | // Token grammar requires trailing whitespace; a virtual newline lets a | ||
| 64 | // mention at the very end of the pasted text still parse. | ||
| 65 | const tokens = collectComposerPromptInlineTokens(`${text}\n`).filter( | ||
| 66 | (token) => | ||
| 67 | (token.type === "mention" || | ||
| 68 | token.type === "citation" || | ||
| 69 | token.type === "context-reference") && | ||
| 70 | token.end <= text.length, | ||
| 71 | ); | ||
| 72 | if (tokens.length === 0) { | ||
| 73 | return false; | ||
| 74 | } | ||
| 75 | |||
| 76 | // Lexical command listeners already run inside an editor update. Starting | ||
| 77 | // a nested update here queues the token insertion until after this | ||
| 78 | // listener returns, which lets the plain-text paste handler run as well. | ||
| 79 | const selection = $getSelection(); | ||
| 80 | if (!$isRangeSelection(selection)) { | ||
| 81 | return false; | ||
| 82 | } | ||
| 83 | const nodes: LexicalNode[] = []; | ||
| 84 | const appendText = (value: string) => { | ||
| 85 | const lines = value.split("\n"); | ||
| 86 | for (let index = 0; index < lines.length; index += 1) { | ||
| 87 | const line = lines[index] ?? ""; | ||
| 88 | if (line.length > 0) { | ||
| 89 | nodes.push($createTextNode(line)); | ||
| 90 | } | ||
| 91 | if (index < lines.length - 1) { | ||
| 92 | nodes.push($createLineBreakNode()); | ||
| 93 | } | ||
| 94 | } | ||
| 95 | }; | ||
| 96 | const firstToken = tokens[0]; | ||
| 97 | if (firstToken?.type === "mention" && firstToken.start === 0) { | ||
| 98 | const startPoint = selection.isBackward() ? selection.focus : selection.anchor; | ||
| 99 | const insertionOffset = options.getExpandedAbsoluteOffsetForPoint( | ||
| 100 | startPoint.getNode(), | ||
| 101 | startPoint.offset, | ||
| 102 | ); | ||
| 103 | const precedingChar = $getRoot() | ||
| 104 | .getTextContent() | ||
| 105 | .slice(insertionOffset - 1, insertionOffset); | ||
| 106 | if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { | ||
| 107 | nodes.push($createTextNode(" ")); | ||
| 108 | } | ||
| 109 | } | ||
| 110 | let cursor = 0; | ||
| 111 | for (const token of tokens) { | ||
| 112 | if (token.start < cursor) { | ||
| 113 | continue; | ||
| 114 | } | ||
| 115 | if (token.start > cursor) { | ||
| 116 | appendText(text.slice(cursor, token.start)); | ||
| 117 | } | ||
| 118 | nodes.push( | ||
| 119 | token.type === "citation" | ||
| 120 | ? options.createCitationNode(token.citation, token.source) | ||
| 121 | : token.type === "context-reference" | ||
| 122 | ? options.createContextReferenceNode({ | ||
| 123 | kind: token.kind, | ||
| 124 | contextId: token.contextId, | ||
| 125 | label: token.label, | ||
| 126 | }) | ||
| 127 | : options.createMentionNode(token.value), | ||
| 128 | ); | ||
| 129 | cursor = token.end; | ||
| 130 | } | ||
| 131 | if (cursor < text.length) { | ||
| 132 | appendText(text.slice(cursor)); | ||
| 133 | } else if (tokens.at(-1)?.type === "mention") { | ||
| 134 | // Keep the serialized prompt valid: mention tokens need trailing | ||
| 135 | // whitespace, so a paste ending in a mention gets the same | ||
| 136 | // trailing space the autocomplete inserts. | ||
| 137 | nodes.push($createTextNode(" ")); | ||
| 138 | } | ||
| 139 | selection.insertNodes(nodes); | ||
| 140 | event.preventDefault(); | ||
| 141 | return true; | ||
| 142 | }, | ||
| 143 | COMMAND_PRIORITY_HIGH, | ||
| 144 | ); | ||
| 145 | } | ||
| 146 | |||
| 147 | /** Clipboard records referenced by the copied text, including dependent screenshots. */ | 13 | /** Clipboard records referenced by the copied text, including dependent screenshots. */ |
| 148 | export function readPastedComposerContext( | 14 | export function readPastedComposerContext( |
| 149 | clipboardData: Pick<DataTransfer, "getData">, | 15 | clipboardData: Pick<DataTransfer, "getData">, |
| ... | @@ -177,7 +43,9 @@ export function readPastedComposerContext( | ... | @@ -177,7 +43,9 @@ export function readPastedComposerContext( |
| 177 | /** Imports the same structured clipboard payload for focused paste and paste-to-focus. */ | 43 | /** Imports the same structured clipboard payload for focused paste and paste-to-focus. */ |
| 178 | export function importPastedComposerText( | 44 | export function importPastedComposerText( |
| 179 | clipboardData: Pick<DataTransfer, "getData">, | 45 | clipboardData: Pick<DataTransfer, "getData">, |
| 180 | importContextFragment?: ComposerInlineTokenPasteOptions["importContextFragment"], | 46 | importContextFragment?: ( |
| 47 | fragment: ComposerContextClipboardFragment, | ||
| 48 | ) => ReadonlyMap<string, string>, | ||
| 181 | ): string { | 49 | ): string { |
| 182 | const pastedText = clipboardData.getData("text/plain"); | 50 | const pastedText = clipboardData.getData("text/plain"); |
| 183 | const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; | 51 | const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; |
apps/web/src/components/settings/SettingsFontPreviews.tsx+1-1| ... | @@ -10,7 +10,7 @@ import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; | ... | @@ -10,7 +10,7 @@ import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; |
| 10 | import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; | 10 | import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; |
| 11 | 11 | ||
| 12 | // The font previews are the real surfaces, not lookalikes: the composer's | 12 | // The font previews are the real surfaces, not lookalikes: the composer's |
| 13 | // Lexical editor, the diff panel's file diff, and the Ghostty canvas | 13 | // Tiptap editor, the diff panel's file diff, and the Ghostty canvas |
| 14 | // renderer. Each already consumes the appearance font tokens (or, for the | 14 | // renderer. Each already consumes the appearance font tokens (or, for the |
| 15 | // terminal, the settings passed down as props), so what the row shows is | 15 | // terminal, the settings passed down as props), so what the row shows is |
| 16 | // exactly what the app renders. | 16 | // exactly what the app renders. |
apps/web/src/components/settings/SettingsPanels.tsx+32| ... | @@ -579,6 +579,9 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -579,6 +579,9 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 579 | ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll | 579 | ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll |
| 580 | ? ["Collapse composer on scroll"] | 580 | ? ["Collapse composer on scroll"] |
| 581 | : []), | 581 | : []), |
| 582 | ...(settings.composerRichTextEnabled !== DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled | ||
| 583 | ? ["Rich text composer"] | ||
| 584 | : []), | ||
| 582 | ...(settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ["Send shortcut"] : []), | 585 | ...(settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ["Send shortcut"] : []), |
| 583 | ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior | 586 | ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior |
| 584 | ? ["Follow-up behavior"] | 587 | ? ["Follow-up behavior"] |
| ... | @@ -641,6 +644,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -641,6 +644,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 641 | settings.confirmThreadDelete, | 644 | settings.confirmThreadDelete, |
| 642 | settings.confirmThreadUnpin, | 645 | settings.confirmThreadUnpin, |
| 643 | settings.composerCollapseOnScroll, | 646 | settings.composerCollapseOnScroll, |
| 647 | settings.composerRichTextEnabled, | ||
| 644 | settings.sendShortcut, | 648 | settings.sendShortcut, |
| 645 | settings.followUpBehavior, | 649 | settings.followUpBehavior, |
| 646 | settings.addProjectBaseDirectory, | 650 | settings.addProjectBaseDirectory, |
| ... | @@ -755,6 +759,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -755,6 +759,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 755 | proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, | 759 | proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, |
| 756 | showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, | 760 | showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, |
| 757 | composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, | 761 | composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, |
| 762 | composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, | ||
| 758 | sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, | 763 | sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, |
| 759 | followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, | 764 | followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, |
| 760 | contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, | 765 | contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, |
| ... | @@ -2577,6 +2582,33 @@ export function GeneralSettingsPanel() { | ... | @@ -2577,6 +2582,33 @@ export function GeneralSettingsPanel() { |
| 2577 | } | 2582 | } |
| 2578 | /> | 2583 | /> |
| 2579 | 2584 | ||
| 2585 | <SettingsRow | ||
| 2586 | {...searchableSetting("composer-rich-text")} | ||
| 2587 | description="Show formatted Markdown as you type." | ||
| 2588 | resetAction={ | ||
| 2589 | settings.composerRichTextEnabled !== | ||
| 2590 | DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled ? ( | ||
| 2591 | <SettingResetButton | ||
| 2592 | label="rich text composer" | ||
| 2593 | onClick={() => | ||
| 2594 | updateSettings({ | ||
| 2595 | composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, | ||
| 2596 | }) | ||
| 2597 | } | ||
| 2598 | /> | ||
| 2599 | ) : null | ||
| 2600 | } | ||
| 2601 | control={ | ||
| 2602 | <Switch | ||
| 2603 | checked={settings.composerRichTextEnabled} | ||
| 2604 | onCheckedChange={(checked) => | ||
| 2605 | updateSettings({ composerRichTextEnabled: Boolean(checked) }) | ||
| 2606 | } | ||
| 2607 | aria-label="Rich text composer" | ||
| 2608 | /> | ||
| 2609 | } | ||
| 2610 | /> | ||
| 2611 | |||
| 2580 | <SettingsRow | 2612 | <SettingsRow |
| 2581 | {...searchableSetting("composer-collapse")} | 2613 | {...searchableSetting("composer-collapse")} |
| 2582 | description="Rest the composer of an existing thread into a single line when you scroll the conversation. Focus the composer or start typing to expand it again." | 2614 | description="Rest the composer of an existing thread into a single line when you scroll the conversation. Focus the composer or start typing to expand it again." |
apps/web/src/components/settings/settingsSearch.ts+6| ... | @@ -292,6 +292,12 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -292,6 +292,12 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 292 | to: "/settings/general", | 292 | to: "/settings/general", |
| 293 | searchTerms: ["command menu dollar $ slash /"], | 293 | searchTerms: ["command menu dollar $ slash /"], |
| 294 | }, | 294 | }, |
| 295 | { | ||
| 296 | id: "composer-rich-text", | ||
| 297 | title: "Rich text composer", | ||
| 298 | to: "/settings/general", | ||
| 299 | searchTerms: ["composer rich text tiptap bold italic markdown styled wysiwyg"], | ||
| 300 | }, | ||
| 295 | { | 301 | { |
| 296 | id: "composer-collapse", | 302 | id: "composer-collapse", |
| 297 | title: "Collapse composer on scroll", | 303 | title: "Collapse composer on scroll", |
apps/web/src/composer-list-continuation.test.ts created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { listContinuationForEnter, listIndentForTab } from "./composer-list-continuation"; | ||
| 4 | |||
| 5 | function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) { | ||
| 6 | return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end); | ||
| 7 | } | ||
| 8 | |||
| 9 | describe("composer list continuation", () => { | ||
| 10 | it("continues ordered lists with the next number", () => { | ||
| 11 | const value = "1. foo"; | ||
| 12 | const edit = listContinuationForEnter(value, value.length); | ||
| 13 | expect(edit).not.toBeNull(); | ||
| 14 | expect(applyEdit(value, edit!)).toBe("1. foo\n2. "); | ||
| 15 | }); | ||
| 16 | |||
| 17 | it("increments multi-digit and paren markers", () => { | ||
| 18 | expect(applyEdit("12. foo", listContinuationForEnter("12. foo", 7)!)).toBe("12. foo\n13. "); | ||
| 19 | expect(applyEdit("3) foo", listContinuationForEnter("3) foo", 6)!)).toBe("3) foo\n4) "); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("continues bullets and keeps indentation", () => { | ||
| 23 | expect(applyEdit("- foo", listContinuationForEnter("- foo", 5)!)).toBe("- foo\n- "); | ||
| 24 | expect(applyEdit(" * foo", listContinuationForEnter(" * foo", 7)!)).toBe(" * foo\n * "); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("continues tasks unchecked", () => { | ||
| 28 | expect(applyEdit("- [x] done", listContinuationForEnter("- [x] done", 10)!)).toBe( | ||
| 29 | "- [x] done\n- [ ] ", | ||
| 30 | ); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("splits mid-line items", () => { | ||
| 34 | expect(applyEdit("1. foobar", listContinuationForEnter("1. foobar", 5)!)).toBe( | ||
| 35 | "1. fo\n2. obar", | ||
| 36 | ); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("exits the list on an empty item", () => { | ||
| 40 | expect(applyEdit("1. foo\n2. ", listContinuationForEnter("1. foo\n2. ", 10)!)).toBe("1. foo\n"); | ||
| 41 | expect(applyEdit("- ", listContinuationForEnter("- ", 2)!)).toBe(""); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("ignores non-list lines and carets inside the marker", () => { | ||
| 45 | expect(listContinuationForEnter("plain text", 5)).toBeNull(); | ||
| 46 | expect(listContinuationForEnter("1.foo no space", 3)).toBeNull(); | ||
| 47 | expect(listContinuationForEnter("-foo", 2)).toBeNull(); | ||
| 48 | expect(listContinuationForEnter("1. foo", 1)).toBeNull(); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("refuses to split an inline chip", () => { | ||
| 52 | const value = "1. @README.md go"; | ||
| 53 | const cursor = value.indexOf("README") + 2; | ||
| 54 | expect(listContinuationForEnter(value, cursor)).toBeNull(); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("indents list items on Tab", () => { | ||
| 58 | expect(applyEdit("- foo", listIndentForTab("- foo", 2, 2)!)).toBe(" - foo"); | ||
| 59 | expect(listIndentForTab("plain", 2, 2)).toBeNull(); | ||
| 60 | expect(listIndentForTab("- foo", 1, 3)).toBeNull(); | ||
| 61 | }); | ||
| 62 | }); | ||
apps/web/src/composer-list-continuation.ts created+137| ... | @@ -0,0 +1,137 @@ | ||
| 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * List continuation and indentation for the composer. | ||
| 5 | * | ||
| 6 | * Implemented once at the ChatComposer level (store replacement), so both | ||
| 7 | * composer modes behave identically and serialize identically: | ||
| 8 | * Shift+Enter on a list item continues it, Enter on an empty item exits the | ||
| 9 | * list, and Tab indents the item. Plain Markdown markers only — no real list | ||
| 10 | * nodes anywhere. | ||
| 11 | */ | ||
| 12 | |||
| 13 | export interface ComposerListEdit { | ||
| 14 | /** Expanded (plain string) offsets into the prompt. */ | ||
| 15 | start: number; | ||
| 16 | end: number; | ||
| 17 | replacement: string; | ||
| 18 | } | ||
| 19 | |||
| 20 | type ListMarker = | ||
| 21 | | { kind: "ordered"; indent: string; numberText: string; delimiter: "." | ")"; space: string } | ||
| 22 | | { kind: "task"; indent: string; space: string } | ||
| 23 | | { kind: "bullet"; indent: string; bullet: string; space: string }; | ||
| 24 | |||
| 25 | function parseListMarker(line: string): { marker: ListMarker; markerEnd: number } | null { | ||
| 26 | const indent = line.match(/^[ \t]*/)?.[0] ?? ""; | ||
| 27 | const rest = line.slice(indent.length); | ||
| 28 | const ordered = rest.match(/^(\d+)([.)])((?:[ \t]+)|\s*$)/); | ||
| 29 | if (ordered && ordered[1] !== undefined && ordered[2] !== undefined) { | ||
| 30 | return { | ||
| 31 | marker: { | ||
| 32 | kind: "ordered", | ||
| 33 | indent, | ||
| 34 | numberText: ordered[1], | ||
| 35 | delimiter: ordered[2] === ")" ? ")" : ".", | ||
| 36 | space: ordered[3] ?? "", | ||
| 37 | }, | ||
| 38 | markerEnd: indent.length + ordered[0].length, | ||
| 39 | }; | ||
| 40 | } | ||
| 41 | const task = rest.match(/^-\s\[[ xX]\]((?:[ \t]+)|\s*$)/); | ||
| 42 | if (task) { | ||
| 43 | return { | ||
| 44 | marker: { kind: "task", indent, space: task[1] ?? "" }, | ||
| 45 | markerEnd: indent.length + task[0].length, | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | const bullet = rest.match(/^([-*+])((?:[ \t]+)|\s*$)/); | ||
| 49 | if (bullet && bullet[1] !== undefined) { | ||
| 50 | return { | ||
| 51 | marker: { kind: "bullet", indent, bullet: bullet[1], space: bullet[2] ?? "" }, | ||
| 52 | markerEnd: indent.length + bullet[0].length, | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | return null; | ||
| 56 | } | ||
| 57 | |||
| 58 | function nextMarkerText(marker: ListMarker): string { | ||
| 59 | if (marker.kind === "ordered") { | ||
| 60 | const number = Number.parseInt(marker.numberText, 10); | ||
| 61 | const next = Number.isSafeInteger(number) | ||
| 62 | ? String(number + 1).padStart(marker.numberText.length, "0") | ||
| 63 | : marker.numberText; | ||
| 64 | return `${marker.indent}${next}${marker.delimiter} `; | ||
| 65 | } | ||
| 66 | if (marker.kind === "task") { | ||
| 67 | return `${marker.indent}- [ ] `; | ||
| 68 | } | ||
| 69 | return `${marker.indent}${marker.bullet} `; | ||
| 70 | } | ||
| 71 | |||
| 72 | function segmentSource( | ||
| 73 | segment: ReturnType<typeof splitPromptIntoComposerSegments>[number], | ||
| 74 | ): string { | ||
| 75 | if (segment.type === "text") return segment.text; | ||
| 76 | if (segment.type === "skill") return `$${segment.name}`; | ||
| 77 | return segment.source; | ||
| 78 | } | ||
| 79 | |||
| 80 | /** True when splitting at the caret would cut an inline chip in two. */ | ||
| 81 | function isInsideInlineToken(value: string, cursor: number): boolean { | ||
| 82 | let offset = 0; | ||
| 83 | for (const segment of splitPromptIntoComposerSegments(value)) { | ||
| 84 | const end = offset + segmentSource(segment).length; | ||
| 85 | if (segment.type !== "text" && cursor > offset && cursor < end) return true; | ||
| 86 | offset = end; | ||
| 87 | } | ||
| 88 | return false; | ||
| 89 | } | ||
| 90 | |||
| 91 | function currentLine(value: string, cursor: number): { start: number; end: number; text: string } { | ||
| 92 | const start = value.lastIndexOf("\n", cursor - 1) + 1; | ||
| 93 | const endIndex = value.indexOf("\n", cursor); | ||
| 94 | const end = endIndex === -1 ? value.length : endIndex; | ||
| 95 | return { start, end, text: value.slice(start, end) }; | ||
| 96 | } | ||
| 97 | |||
| 98 | /** | ||
| 99 | * Enter on a list item line: continue the list, or exit it when the item is | ||
| 100 | * empty. Returns null for non-list lines, carets inside the marker, and | ||
| 101 | * carets inside an inline chip — all fall through to a plain newline. | ||
| 102 | */ | ||
| 103 | export function listContinuationForEnter(value: string, cursor: number): ComposerListEdit | null { | ||
| 104 | if (!Number.isInteger(cursor) || cursor < 0 || cursor > value.length) return null; | ||
| 105 | const line = currentLine(value, cursor); | ||
| 106 | const parsed = parseListMarker(line.text); | ||
| 107 | if (!parsed) return null; | ||
| 108 | const markerEnd = line.start + parsed.markerEnd; | ||
| 109 | if (cursor < markerEnd) return null; | ||
| 110 | if (isInsideInlineToken(value, cursor)) return null; | ||
| 111 | if (value.slice(markerEnd, line.end).trim() === "") { | ||
| 112 | // Empty item: Enter exits the list by removing the marker. | ||
| 113 | return { start: line.start, end: Math.max(cursor, markerEnd), replacement: "" }; | ||
| 114 | } | ||
| 115 | return { | ||
| 116 | start: cursor, | ||
| 117 | end: cursor, | ||
| 118 | replacement: `\n${nextMarkerText(parsed.marker)}`, | ||
| 119 | }; | ||
| 120 | } | ||
| 121 | |||
| 122 | /** | ||
| 123 | * Tab on a list item line: indent by two spaces. Ranged selections, non-list | ||
| 124 | * lines, and carets inside an inline chip fall through (Shift+Tab stays the | ||
| 125 | * plan-mode toggle and is handled before this is consulted). | ||
| 126 | */ | ||
| 127 | export function listIndentForTab( | ||
| 128 | value: string, | ||
| 129 | start: number, | ||
| 130 | end: number, | ||
| 131 | ): ComposerListEdit | null { | ||
| 132 | if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null; | ||
| 133 | const line = currentLine(value, start); | ||
| 134 | if (!parseListMarker(line.text)) return null; | ||
| 135 | if (isInsideInlineToken(value, start)) return null; | ||
| 136 | return { start: line.start, end: line.start, replacement: " " }; | ||
| 137 | } | ||
apps/web/src/composer-rich-text-doc.test.ts created+344| ... | @@ -0,0 +1,344 @@ | ||
| 1 | import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/core"; | ||
| 2 | import StarterKit from "@tiptap/starter-kit"; | ||
| 3 | import { TaskList } from "@tiptap/extension-task-list"; | ||
| 4 | import { Node as ProseMirrorNode } from "@tiptap/pm/model"; | ||
| 5 | import { describe, expect, it } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | buildDocJson, | ||
| 9 | collapsedToFlat, | ||
| 10 | ComposerTaskItemExtension, | ||
| 11 | flatToCollapsed, | ||
| 12 | flatToMarkdown, | ||
| 13 | flatToPm, | ||
| 14 | pmToFlat, | ||
| 15 | serializeEditorDoc, | ||
| 16 | } from "./composer-rich-text-doc"; | ||
| 17 | |||
| 18 | function stubAtom(name: string, attrs: Record<string, { default: unknown }>) { | ||
| 19 | return Node.create({ | ||
| 20 | name, | ||
| 21 | group: "inline", | ||
| 22 | inline: true, | ||
| 23 | atom: true, | ||
| 24 | addAttributes: () => attrs, | ||
| 25 | }); | ||
| 26 | } | ||
| 27 | |||
| 28 | const schema = getSchemaByResolvedExtensions( | ||
| 29 | resolveExtensions([ | ||
| 30 | StarterKit.configure({ | ||
| 31 | blockquote: false, | ||
| 32 | bulletList: false, | ||
| 33 | codeBlock: false, | ||
| 34 | heading: false, | ||
| 35 | horizontalRule: false, | ||
| 36 | listItem: false, | ||
| 37 | orderedList: false, | ||
| 38 | dropcursor: false, | ||
| 39 | gapcursor: false, | ||
| 40 | trailingNode: false, | ||
| 41 | }), | ||
| 42 | stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }), | ||
| 43 | stubAtom("composer-skill", { | ||
| 44 | skillName: { default: "" }, | ||
| 45 | skillLabel: { default: "" }, | ||
| 46 | skillDescription: { default: null }, | ||
| 47 | }), | ||
| 48 | stubAtom("composer-citation", { | ||
| 49 | citation: { default: null }, | ||
| 50 | source: { default: "" }, | ||
| 51 | citeKey: { default: "" }, | ||
| 52 | }), | ||
| 53 | stubAtom("composer-context-reference", { | ||
| 54 | kind: { default: "" }, | ||
| 55 | contextId: { default: "" }, | ||
| 56 | label: { default: "" }, | ||
| 57 | source: { default: "" }, | ||
| 58 | }), | ||
| 59 | TaskList, | ||
| 60 | ComposerTaskItemExtension, | ||
| 61 | ]), | ||
| 62 | ); | ||
| 63 | |||
| 64 | function roundTrip(value: string) { | ||
| 65 | const json = buildDocJson(value, (name) => ({ label: name, description: null })); | ||
| 66 | const doc = ProseMirrorNode.fromJSON(schema, json); | ||
| 67 | return serializeEditorDoc(doc); | ||
| 68 | } | ||
| 69 | |||
| 70 | // Plain mode: the same engine with the mark extensions off. Markers stay | ||
| 71 | // literal characters and task lines stay paragraphs. | ||
| 72 | const plainSchema = getSchemaByResolvedExtensions( | ||
| 73 | resolveExtensions([ | ||
| 74 | StarterKit.configure({ | ||
| 75 | blockquote: false, | ||
| 76 | bulletList: false, | ||
| 77 | codeBlock: false, | ||
| 78 | heading: false, | ||
| 79 | horizontalRule: false, | ||
| 80 | listItem: false, | ||
| 81 | orderedList: false, | ||
| 82 | dropcursor: false, | ||
| 83 | gapcursor: false, | ||
| 84 | trailingNode: false, | ||
| 85 | bold: false, | ||
| 86 | italic: false, | ||
| 87 | strike: false, | ||
| 88 | code: false, | ||
| 89 | }), | ||
| 90 | stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }), | ||
| 91 | stubAtom("composer-skill", { | ||
| 92 | skillName: { default: "" }, | ||
| 93 | skillLabel: { default: "" }, | ||
| 94 | skillDescription: { default: null }, | ||
| 95 | }), | ||
| 96 | stubAtom("composer-citation", { | ||
| 97 | citation: { default: null }, | ||
| 98 | source: { default: "" }, | ||
| 99 | citeKey: { default: "" }, | ||
| 100 | }), | ||
| 101 | stubAtom("composer-context-reference", { | ||
| 102 | kind: { default: "" }, | ||
| 103 | contextId: { default: "" }, | ||
| 104 | label: { default: "" }, | ||
| 105 | source: { default: "" }, | ||
| 106 | }), | ||
| 107 | TaskList, | ||
| 108 | ComposerTaskItemExtension, | ||
| 109 | ]), | ||
| 110 | ); | ||
| 111 | |||
| 112 | function roundTripPlain(value: string) { | ||
| 113 | const json = buildDocJson(value, (name) => ({ label: name, description: null }), { | ||
| 114 | styling: false, | ||
| 115 | }); | ||
| 116 | const doc = ProseMirrorNode.fromJSON(plainSchema, json); | ||
| 117 | return serializeEditorDoc(doc); | ||
| 118 | } | ||
| 119 | |||
| 120 | describe("composer rich text document model", () => { | ||
| 121 | it.each([ | ||
| 122 | "", | ||
| 123 | "\n\n", | ||
| 124 | "plain text", | ||
| 125 | "hello **bold** world", | ||
| 126 | "a *italic* word and `code` here", | ||
| 127 | "struck ~~out~~ now", | ||
| 128 | "***bold italic*** keeps nesting", | ||
| 129 | "line one\nline two", | ||
| 130 | "trailing newline\n", | ||
| 131 | "1. foo\n2. asdf\n", | ||
| 132 | "- [ ] buy milk", | ||
| 133 | "- [ ] buy milk", | ||
| 134 | "\t-\t[x]\t\titem", | ||
| 135 | "- [ ] ", | ||
| 136 | "- [ ]\n - [ ] child", | ||
| 137 | " - [ ] first\n - [ ] second\n - [ ] child", | ||
| 138 | "**before @README.md after**", | ||
| 139 | "*a **b** c*", | ||
| 140 | "*a**b***", | ||
| 141 | "**a*b***", | ||
| 142 | "**a *b* c**", | ||
| 143 | "literal \uFFFC **before @README.md after**", | ||
| 144 | "- [x] done\n- [ ] next", | ||
| 145 | "- [ ] parent\n - [ ] child\n - [ ] sibling\n- [ ] uncle", | ||
| 146 | "- [ ] empty task follows\n- [ ]", | ||
| 147 | "- [ ] **bold** task with @README.md", | ||
| 148 | "para\n- [ ] task\npara", | ||
| 149 | "- [ ]No space stays literal", | ||
| 150 | "-[ ] also literal", | ||
| 151 | "@README.md explain this", | ||
| 152 | '@"docs/My File.md" and $my-skill please', | ||
| 153 | "snake_case stays literal", | ||
| 154 | "unmatched ** stays literal", | ||
| 155 | "**bold** then @README.md then *italic*", | ||
| 156 | ])("round-trips %s through a real ProseMirror document", (value) => { | ||
| 157 | expect(roundTrip(value).value).toBe(value); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it.each([ | ||
| 161 | "", | ||
| 162 | "\n", | ||
| 163 | "text\n\n", | ||
| 164 | "- [ ]\n- [ ] next\n", | ||
| 165 | "- [ ] parent\n - [ ] child\n- [ ]", | ||
| 166 | "para\n- [ ] task\npara", | ||
| 167 | "**before @README.md after**", | ||
| 168 | ])("maps editable positions in %s", (value) => { | ||
| 169 | const doc = ProseMirrorNode.fromJSON( | ||
| 170 | schema, | ||
| 171 | buildDocJson(value, (name) => ({ label: name, description: null })), | ||
| 172 | ); | ||
| 173 | const map = serializeEditorDoc(doc); | ||
| 174 | for (let flat = 0; flat <= map.docLength; flat += 1) { | ||
| 175 | const position = flatToPm(map, flat); | ||
| 176 | expect(doc.resolve(position).parent.isTextblock).toBe(true); | ||
| 177 | expect(pmToFlat(map, position)).toBe(flat); | ||
| 178 | expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); | ||
| 179 | } | ||
| 180 | }); | ||
| 181 | |||
| 182 | it("keeps the caret after a trailing hard break inside the same paragraph", () => { | ||
| 183 | const doc = schema.node("doc", null, [ | ||
| 184 | schema.node("paragraph", null, [schema.text("a"), schema.node("hardBreak")]), | ||
| 185 | ]); | ||
| 186 | const map = serializeEditorDoc(doc); | ||
| 187 | expect(flatToPm(map, map.docLength)).toBe(3); | ||
| 188 | expect(doc.resolve(flatToPm(map, map.docLength)).parent.isTextblock).toBe(true); | ||
| 189 | }); | ||
| 190 | |||
| 191 | it("renders a leading task dedent as siblings and nests under the new indent", () => { | ||
| 192 | const doc = ProseMirrorNode.fromJSON( | ||
| 193 | schema, | ||
| 194 | buildDocJson(" - [ ] first\n - [ ] second\n - [ ] child", (name) => ({ | ||
| 195 | label: name, | ||
| 196 | description: null, | ||
| 197 | })), | ||
| 198 | ); | ||
| 199 | const list = doc.firstChild!; | ||
| 200 | expect(list.childCount).toBe(2); | ||
| 201 | expect(list.child(0).childCount).toBe(1); | ||
| 202 | expect(list.child(1).child(1).firstChild!.textContent).toBe("child"); | ||
| 203 | }); | ||
| 204 | |||
| 205 | it("applies a shared mark to text on both sides of a chip", () => { | ||
| 206 | const doc = ProseMirrorNode.fromJSON( | ||
| 207 | schema, | ||
| 208 | buildDocJson("**before @README.md after**", (name) => ({ label: name, description: null })), | ||
| 209 | ); | ||
| 210 | expect(doc.firstChild!.childCount).toBe(3); | ||
| 211 | doc.firstChild!.forEach((child) => | ||
| 212 | expect(child.marks.map((mark) => mark.type.name)).toContain("bold"), | ||
| 213 | ); | ||
| 214 | expect(serializeEditorDoc(doc).value).toBe("**before @README.md after**"); | ||
| 215 | }); | ||
| 216 | |||
| 217 | it.each([ | ||
| 218 | [["bold"], ["bold", "italic"], ["italic"]], | ||
| 219 | [["italic"], ["bold", "italic"], ["bold"]], | ||
| 220 | [["bold"], ["bold", "strike"], ["strike"]], | ||
| 221 | [["strike"], ["bold", "strike"], ["bold"]], | ||
| 222 | ])("preserves crossing mark ranges %j through controlled rebuilds", (...marks) => { | ||
| 223 | const doc = schema.node("doc", null, [ | ||
| 224 | schema.node( | ||
| 225 | "paragraph", | ||
| 226 | null, | ||
| 227 | marks.map((names, index) => | ||
| 228 | schema.text( | ||
| 229 | String.fromCharCode(97 + index), | ||
| 230 | names.map((name) => schema.mark(name)), | ||
| 231 | ), | ||
| 232 | ), | ||
| 233 | ), | ||
| 234 | ]); | ||
| 235 | const serialized = serializeEditorDoc(doc).value; | ||
| 236 | const rebuilt = ProseMirrorNode.fromJSON( | ||
| 237 | schema, | ||
| 238 | buildDocJson(serialized, (name) => ({ label: name, description: null })), | ||
| 239 | ); | ||
| 240 | expect(rebuilt.eq(doc)).toBe(true); | ||
| 241 | expect(serializeEditorDoc(rebuilt).value).toBe(serialized); | ||
| 242 | }); | ||
| 243 | |||
| 244 | it.each([ | ||
| 245 | { parts: [{ text: "hello ", marks: ["bold"] }], expected: "**hello** " }, | ||
| 246 | { parts: [{ text: " ", marks: ["bold"] }], expected: " " }, | ||
| 247 | { parts: [{ text: " left ", marks: ["bold", "italic"] }], expected: " ***left*** " }, | ||
| 248 | { | ||
| 249 | parts: [ | ||
| 250 | { text: "one ", marks: ["bold"] }, | ||
| 251 | { text: " two ", marks: ["bold", "italic"] }, | ||
| 252 | { text: " three", marks: ["bold"] }, | ||
| 253 | ], | ||
| 254 | expected: "**one *two* three**", | ||
| 255 | }, | ||
| 256 | { | ||
| 257 | parts: [ | ||
| 258 | { text: "hello ", marks: ["bold"] }, | ||
| 259 | { text: "world ", marks: ["bold", "italic"] }, | ||
| 260 | ], | ||
| 261 | expected: "**hello *world*** ", | ||
| 262 | }, | ||
| 263 | { parts: [{ text: " hello ", marks: ["code"] }], expected: "` hello `" }, | ||
| 264 | { parts: [{ text: " hello ", marks: ["bold", "code"] }], expected: "**` hello `**" }, | ||
| 265 | { parts: [{ text: " ", marks: ["bold", "code"] }], expected: "**` `**" }, | ||
| 266 | ])("keeps boundary whitespace outside emphasis in $expected", ({ parts, expected }) => { | ||
| 267 | const doc = schema.node("doc", null, [ | ||
| 268 | schema.node( | ||
| 269 | "paragraph", | ||
| 270 | null, | ||
| 271 | parts.map(({ text, marks }) => | ||
| 272 | schema.text( | ||
| 273 | text, | ||
| 274 | marks.map((name) => schema.mark(name)), | ||
| 275 | ), | ||
| 276 | ), | ||
| 277 | ), | ||
| 278 | ]); | ||
| 279 | const map = serializeEditorDoc(doc); | ||
| 280 | expect(map.value).toBe(expected); | ||
| 281 | const rebuilt = ProseMirrorNode.fromJSON( | ||
| 282 | schema, | ||
| 283 | buildDocJson(map.value, (name) => ({ label: name, description: null })), | ||
| 284 | ); | ||
| 285 | expect(rebuilt.textContent).toBe(doc.textContent); | ||
| 286 | expect(serializeEditorDoc(rebuilt).value).toBe(map.value); | ||
| 287 | for (let flat = 0; flat <= map.docLength; flat += 1) { | ||
| 288 | expect(pmToFlat(map, flatToPm(map, flat))).toBe(flat); | ||
| 289 | expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); | ||
| 290 | if (flat < map.docLength && !/\s/.test(doc.textContent[flat]!)) { | ||
| 291 | expect(rebuilt.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name)).toEqual( | ||
| 292 | doc.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name), | ||
| 293 | ); | ||
| 294 | } | ||
| 295 | } | ||
| 296 | }); | ||
| 297 | |||
| 298 | it("keeps chip sources canonical through the document", () => { | ||
| 299 | const map = roundTrip("explain @README.md with **care**\nsecond line *here*"); | ||
| 300 | expect(map.value).toBe("explain @README.md with **care**\nsecond line *here*"); | ||
| 301 | expect( | ||
| 302 | map.runs.some((run) => run.kind === "token" && run.nodeName === "composer-mention"), | ||
| 303 | ).toBe(true); | ||
| 304 | }); | ||
| 305 | |||
| 306 | it("normalizes uppercase checkboxes to lowercase", () => { | ||
| 307 | expect(roundTrip("- [X] done").value).toBe("- [x] done"); | ||
| 308 | }); | ||
| 309 | |||
| 310 | it.each([ | ||
| 311 | "plain text", | ||
| 312 | "hello **bold** stays literal", | ||
| 313 | "a *italic* stays literal", | ||
| 314 | "some `code` stays literal", | ||
| 315 | "struck ~~out~~ stays literal", | ||
| 316 | "- [ ] stays a paragraph", | ||
| 317 | "- [x] stays a paragraph", | ||
| 318 | "line one\nline two", | ||
| 319 | "@README.md explain this", | ||
| 320 | "**bold** then @README.md then *italic*", | ||
| 321 | ])("round-trips %s byte-identically in plain mode", (value) => { | ||
| 322 | expect(roundTripPlain(value).value).toBe(value); | ||
| 323 | }); | ||
| 324 | |||
| 325 | it("maps every document offset through collapsed coordinates and back", () => { | ||
| 326 | const value = "hi **bold** @README.md bye"; | ||
| 327 | const map = roundTrip(value); | ||
| 328 | expect(map.value).toBe(value); | ||
| 329 | for (let flat = 0; flat <= map.docLength; flat += 1) { | ||
| 330 | expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); | ||
| 331 | } | ||
| 332 | }); | ||
| 333 | |||
| 334 | it("maps markdown offsets at styled edges onto document text", () => { | ||
| 335 | const value = "a **bold** c"; | ||
| 336 | const map = roundTrip(value); | ||
| 337 | expect(map.value).toBe(value); | ||
| 338 | // document text is "a bold c" (flat), markdown has the markers. | ||
| 339 | expect(flatToMarkdown(map, 2)).toBe(4); | ||
| 340 | expect(flatToMarkdown(map, 6)).toBe(10); | ||
| 341 | expect(collapsedToFlat(map, 3)).toBe(2); | ||
| 342 | expect(collapsedToFlat(map, 9)).toBe(6); | ||
| 343 | }); | ||
| 344 | }); | ||
apps/web/src/composer-rich-text-doc.ts created+644| ... | @@ -0,0 +1,644 @@ | ||
| 1 | import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; | ||
| 2 | import { TaskItem } from "@tiptap/extension-task-item"; | ||
| 3 | |||
| 4 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; | ||
| 5 | import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text"; | ||
| 6 | import { collectInlineContextIds } from "~/lib/composerContextReferences"; | ||
| 7 | |||
| 8 | /** | ||
| 9 | * Pure document model for the rich text (Tiptap) composer. | ||
| 10 | * | ||
| 11 | * The stored prompt stays markdown (`**bold**`, `@file` chips as canonical | ||
| 12 | * links). The Tiptap document holds styled text plus inline atom chips, so | ||
| 13 | * this module translates both ways and maps cursor offsets between the three | ||
| 14 | * coordinate spaces the composer speaks: | ||
| 15 | * | ||
| 16 | * - flat document offsets (styled markers excluded, chips count 1), | ||
| 17 | * - collapsed cursor offsets (markers literal, chips count 1 — the coordinate | ||
| 18 | * the draft store and mention detection use), | ||
| 19 | * - markdown offsets (markers literal, chips expand to their source). | ||
| 20 | * | ||
| 21 | * DOM-free on purpose: unit tests build a real ProseMirror document from the | ||
| 22 | * JSON this produces and assert the round trip without a browser. | ||
| 23 | */ | ||
| 24 | |||
| 25 | export type SkillMeta = { label: string; description: string | null }; | ||
| 26 | |||
| 27 | /** Outermost mark first, so closers mirror openers when nested. */ | ||
| 28 | const MARK_NESTING_ORDER: RichTextMark[] = ["strike", "bold", "italic", "code"]; | ||
| 29 | |||
| 30 | const MARK_TO_TIPTAP: Record<RichTextMark, string> = { | ||
| 31 | bold: "bold", | ||
| 32 | italic: "italic", | ||
| 33 | strike: "strike", | ||
| 34 | code: "code", | ||
| 35 | }; | ||
| 36 | |||
| 37 | const TIPTAP_TO_MARK: Record<string, RichTextMark> = { | ||
| 38 | bold: "bold", | ||
| 39 | italic: "italic", | ||
| 40 | strike: "strike", | ||
| 41 | code: "code", | ||
| 42 | }; | ||
| 43 | |||
| 44 | /** | ||
| 45 | * Task list items keep their exact source indent in an attribute so nesting | ||
| 46 | * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` — | ||
| 47 | * the same fixed-point deal as `__bold__` becoming `**bold**`. | ||
| 48 | */ | ||
| 49 | export const ComposerTaskItemExtension = TaskItem.extend({ | ||
| 50 | addAttributes() { | ||
| 51 | return { | ||
| 52 | ...this.parent?.(), | ||
| 53 | indent: { default: "" }, | ||
| 54 | markerSpace: { default: " " }, | ||
| 55 | contentSpace: { default: null }, | ||
| 56 | }; | ||
| 57 | }, | ||
| 58 | }).configure({ nested: true }); | ||
| 59 | |||
| 60 | function randomNodeKey(): string { | ||
| 61 | return `tiptap-${Math.random().toString(36).slice(2)}`; | ||
| 62 | } | ||
| 63 | |||
| 64 | interface TaskLinePrefix { | ||
| 65 | indent: string; | ||
| 66 | checked: boolean; | ||
| 67 | markerSpace: string; | ||
| 68 | contentSpace: string; | ||
| 69 | } | ||
| 70 | |||
| 71 | function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength: number } | null { | ||
| 72 | const match = head.match(/^([ \t]*)-([ \t]+)\[([ xX])\]([ \t]*)/); | ||
| 73 | if (!match) return null; | ||
| 74 | const after = head.slice(match[0].length); | ||
| 75 | if (after.length > 0 && !match[4]) return null; | ||
| 76 | return { | ||
| 77 | prefix: { | ||
| 78 | indent: match[1] ?? "", | ||
| 79 | checked: (match[3] ?? " ").toLowerCase() === "x", | ||
| 80 | markerSpace: match[2]!, | ||
| 81 | contentSpace: match[4]!, | ||
| 82 | }, | ||
| 83 | markerLength: match[0].length, | ||
| 84 | }; | ||
| 85 | } | ||
| 86 | |||
| 87 | type InlineJson = Record<string, unknown>; | ||
| 88 | |||
| 89 | interface DocLine { | ||
| 90 | task: TaskLinePrefix | null; | ||
| 91 | inline: InlineJson[]; | ||
| 92 | } | ||
| 93 | |||
| 94 | function atomJsonForSegment( | ||
| 95 | segment: Exclude<ReturnType<typeof splitPromptIntoComposerSegments>[number], { type: "text" }>, | ||
| 96 | skillLabelFor: (name: string) => SkillMeta, | ||
| 97 | ): InlineJson { | ||
| 98 | if (segment.type === "mention") { | ||
| 99 | return { | ||
| 100 | type: "composer-mention", | ||
| 101 | attrs: { path: segment.path, source: segment.source }, | ||
| 102 | }; | ||
| 103 | } | ||
| 104 | if (segment.type === "skill") { | ||
| 105 | const meta = skillLabelFor(segment.name); | ||
| 106 | return { | ||
| 107 | type: "composer-skill", | ||
| 108 | attrs: { | ||
| 109 | skillName: segment.name, | ||
| 110 | skillLabel: meta.label, | ||
| 111 | skillDescription: meta.description, | ||
| 112 | }, | ||
| 113 | }; | ||
| 114 | } | ||
| 115 | if (segment.type === "citation") { | ||
| 116 | return { | ||
| 117 | type: "composer-citation", | ||
| 118 | attrs: { citation: segment.citation, source: segment.source, citeKey: randomNodeKey() }, | ||
| 119 | }; | ||
| 120 | } | ||
| 121 | return { | ||
| 122 | type: "composer-context-reference", | ||
| 123 | attrs: { | ||
| 124 | kind: segment.kind, | ||
| 125 | contextId: segment.contextId, | ||
| 126 | label: segment.label, | ||
| 127 | source: segment.source, | ||
| 128 | }, | ||
| 129 | }; | ||
| 130 | } | ||
| 131 | |||
| 132 | interface PendingTaskItem extends TaskLinePrefix { | ||
| 133 | content: InlineJson[]; | ||
| 134 | children: PendingTaskItem[]; | ||
| 135 | } | ||
| 136 | |||
| 137 | function taskListJson(items: PendingTaskItem[]): InlineJson { | ||
| 138 | return { | ||
| 139 | type: "taskList", | ||
| 140 | content: items.map((item) => ({ | ||
| 141 | type: "taskItem", | ||
| 142 | attrs: { | ||
| 143 | checked: item.checked, | ||
| 144 | indent: item.indent, | ||
| 145 | markerSpace: item.markerSpace, | ||
| 146 | contentSpace: item.contentSpace, | ||
| 147 | }, | ||
| 148 | content: [ | ||
| 149 | { type: "paragraph", content: item.content }, | ||
| 150 | ...(item.children.length > 0 ? [taskListJson(item.children)] : []), | ||
| 151 | ], | ||
| 152 | })), | ||
| 153 | }; | ||
| 154 | } | ||
| 155 | |||
| 156 | function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, unknown> { | ||
| 157 | const json: Record<string, unknown> = { type: "text", text }; | ||
| 158 | if (marks.length > 0) { | ||
| 159 | json.marks = [...marks] | ||
| 160 | .sort((a, b) => MARK_NESTING_ORDER.indexOf(a) - MARK_NESTING_ORDER.indexOf(b)) | ||
| 161 | .map((mark) => ({ type: MARK_TO_TIPTAP[mark] })); | ||
| 162 | } | ||
| 163 | return json; | ||
| 164 | } | ||
| 165 | |||
| 166 | export function buildTiptapContent( | ||
| 167 | value: string, | ||
| 168 | skillLabelFor: (name: string) => SkillMeta, | ||
| 169 | options?: { styling?: boolean }, | ||
| 170 | ): Record<string, unknown>[] { | ||
| 171 | const styling = options?.styling ?? true; | ||
| 172 | // Hide token source from the markdown parser, then restore the atoms with | ||
| 173 | // the marks of their surrounding text. Choose a sentinel absent from input. | ||
| 174 | let sentinel = "\uFFFC"; | ||
| 175 | for (let codePoint = 0xe000; value.includes(sentinel); codePoint += 1) { | ||
| 176 | sentinel = String.fromCodePoint(codePoint); | ||
| 177 | } | ||
| 178 | const atoms: InlineJson[] = []; | ||
| 179 | const text = splitPromptIntoComposerSegments(value) | ||
| 180 | .map((segment) => { | ||
| 181 | if (segment.type === "text") return segment.text; | ||
| 182 | atoms.push(atomJsonForSegment(segment, skillLabelFor)); | ||
| 183 | return sentinel; | ||
| 184 | }) | ||
| 185 | .join(""); | ||
| 186 | let atomIndex = 0; | ||
| 187 | const lines: DocLine[] = text.split("\n").map((line) => { | ||
| 188 | const parsed = styling ? parseTaskPrefix(line) : null; | ||
| 189 | const content = parsed ? line.slice(parsed.markerLength) : line; | ||
| 190 | const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }]; | ||
| 191 | const inline: InlineJson[] = []; | ||
| 192 | for (const span of spans) { | ||
| 193 | span.text.split(sentinel).forEach((piece, index) => { | ||
| 194 | if (index > 0) { | ||
| 195 | const atom = atoms[atomIndex++]!; | ||
| 196 | inline.push({ ...atom, marks: textJsonForSpan("", span.marks).marks }); | ||
| 197 | } | ||
| 198 | if (piece) inline.push(textJsonForSpan(piece, span.marks)); | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | return { task: parsed?.prefix ?? null, inline }; | ||
| 202 | }); | ||
| 203 | |||
| 204 | // Pass 2: consecutive task lines group into (possibly nested) task lists | ||
| 205 | // by indent prefix; everything else stays a paragraph. | ||
| 206 | const blocks: Record<string, unknown>[] = []; | ||
| 207 | let stack: { indent: string; items: PendingTaskItem[] }[] = []; | ||
| 208 | const flushTasks = () => { | ||
| 209 | if (stack.length > 0) { | ||
| 210 | blocks.push(taskListJson(stack[0]!.items)); | ||
| 211 | stack = []; | ||
| 212 | } | ||
| 213 | }; | ||
| 214 | for (const line of lines) { | ||
| 215 | if (!line.task) { | ||
| 216 | flushTasks(); | ||
| 217 | blocks.push({ type: "paragraph", content: line.inline }); | ||
| 218 | continue; | ||
| 219 | } | ||
| 220 | const item: PendingTaskItem = { | ||
| 221 | ...line.task, | ||
| 222 | content: line.inline, | ||
| 223 | children: [], | ||
| 224 | }; | ||
| 225 | for (;;) { | ||
| 226 | const top = stack[stack.length - 1]; | ||
| 227 | if (!top) { | ||
| 228 | // A leading indented item with no parent flattens but keeps indent. | ||
| 229 | stack.push({ indent: item.indent, items: [] }); | ||
| 230 | continue; | ||
| 231 | } | ||
| 232 | if (top.indent === item.indent) { | ||
| 233 | top.items.push(item); | ||
| 234 | break; | ||
| 235 | } | ||
| 236 | if (top.indent !== "" && !item.indent.startsWith(top.indent)) { | ||
| 237 | if (stack.length > 1) { | ||
| 238 | stack.pop(); | ||
| 239 | continue; | ||
| 240 | } | ||
| 241 | top.indent = item.indent; | ||
| 242 | top.items.push(item); | ||
| 243 | break; | ||
| 244 | } | ||
| 245 | const parent = top.items[top.items.length - 1]; | ||
| 246 | if (!parent) { | ||
| 247 | top.items.push(item); | ||
| 248 | break; | ||
| 249 | } | ||
| 250 | parent.children.push(item); | ||
| 251 | stack.push({ indent: item.indent, items: parent.children }); | ||
| 252 | break; | ||
| 253 | } | ||
| 254 | } | ||
| 255 | flushTasks(); | ||
| 256 | return blocks; | ||
| 257 | } | ||
| 258 | |||
| 259 | export function buildDocJson( | ||
| 260 | value: string, | ||
| 261 | skillLabelFor: (name: string) => SkillMeta, | ||
| 262 | options?: { styling?: boolean }, | ||
| 263 | ) { | ||
| 264 | return { type: "doc", content: buildTiptapContent(value, skillLabelFor, options) }; | ||
| 265 | } | ||
| 266 | |||
| 267 | export interface RichRun { | ||
| 268 | kind: "text" | "token" | "break" | "prefix"; | ||
| 269 | /** Flat document offset (atoms count 1, markers excluded). */ | ||
| 270 | flatStart: number; | ||
| 271 | docLen: number; | ||
| 272 | /** Collapsed cursor length (markers literal, tokens count 1). */ | ||
| 273 | collapsedLen: number; | ||
| 274 | /** Markdown length (tokens expand to their source). */ | ||
| 275 | mdLen: number; | ||
| 276 | /** Marker layout inside text runs. */ | ||
| 277 | openLen: number; | ||
| 278 | closeLen: number; | ||
| 279 | /** ProseMirror position of the run start. */ | ||
| 280 | pmPos: number; | ||
| 281 | mdStart: number; | ||
| 282 | collapsedStart: number; | ||
| 283 | nodeName?: string; | ||
| 284 | } | ||
| 285 | |||
| 286 | export interface RichDocMap { | ||
| 287 | value: string; | ||
| 288 | runs: RichRun[]; | ||
| 289 | docLength: number; | ||
| 290 | contextIds: string[]; | ||
| 291 | } | ||
| 292 | |||
| 293 | function readAtomSource(node: ProseMirrorNode): string { | ||
| 294 | const attrs = node.attrs as Record<string, unknown>; | ||
| 295 | switch (node.type.name) { | ||
| 296 | case "composer-mention": | ||
| 297 | case "composer-citation": | ||
| 298 | case "composer-context-reference": | ||
| 299 | return typeof attrs.source === "string" ? attrs.source : ""; | ||
| 300 | case "composer-skill": { | ||
| 301 | const name = typeof attrs.skillName === "string" ? attrs.skillName : ""; | ||
| 302 | return name ? `$${name}` : ""; | ||
| 303 | } | ||
| 304 | default: | ||
| 305 | return ""; | ||
| 306 | } | ||
| 307 | } | ||
| 308 | |||
| 309 | interface RichAccumulator { | ||
| 310 | runs: RichRun[]; | ||
| 311 | value: string; | ||
| 312 | flat: number; | ||
| 313 | collapsed: number; | ||
| 314 | md: number; | ||
| 315 | } | ||
| 316 | |||
| 317 | function pushBreakRun(acc: RichAccumulator, position?: number): void { | ||
| 318 | const previous = acc.runs[acc.runs.length - 1]; | ||
| 319 | const pmPos = position ?? (previous ? previous.pmPos + previous.docLen : 1); | ||
| 320 | // Block boundary: one newline in every coordinate space. | ||
| 321 | acc.runs.push({ | ||
| 322 | kind: "break", | ||
| 323 | flatStart: acc.flat, | ||
| 324 | docLen: 1, | ||
| 325 | collapsedLen: 1, | ||
| 326 | mdLen: 1, | ||
| 327 | openLen: 0, | ||
| 328 | closeLen: 0, | ||
| 329 | pmPos, | ||
| 330 | mdStart: acc.md, | ||
| 331 | collapsedStart: acc.collapsed, | ||
| 332 | }); | ||
| 333 | acc.value += "\n"; | ||
| 334 | acc.flat += 1; | ||
| 335 | acc.collapsed += 1; | ||
| 336 | acc.md += 1; | ||
| 337 | } | ||
| 338 | |||
| 339 | function appendInlineRuns( | ||
| 340 | container: ProseMirrorNode, | ||
| 341 | contentStart: number, | ||
| 342 | acc: RichAccumulator, | ||
| 343 | ): void { | ||
| 344 | const children: ProseMirrorNode[] = []; | ||
| 345 | container.forEach((child) => { | ||
| 346 | if (!child.isText || child.marks.some((mark) => mark.type.name === "code")) { | ||
| 347 | children.push(child); | ||
| 348 | return; | ||
| 349 | } | ||
| 350 | // Separate boundary whitespace so delimiters can move past it without | ||
| 351 | // changing the document offsets or marks on the visible text. | ||
| 352 | const text = child.text!; | ||
| 353 | const start = text.length - text.trimStart().length; | ||
| 354 | const end = Math.max(start, text.trimEnd().length); | ||
| 355 | let offset = 0; | ||
| 356 | for (const boundary of [start, end, text.length]) { | ||
| 357 | if (boundary > offset) children.push(child.cut(offset, boundary)); | ||
| 358 | offset = boundary; | ||
| 359 | } | ||
| 360 | }); | ||
| 361 | // Emphasis cannot open or close next to whitespace. Retain a whitespace | ||
| 362 | // mark only when its range has visible content on both sides. | ||
| 363 | for (const mark of MARK_NESTING_ORDER) { | ||
| 364 | if (mark === "code") continue; | ||
| 365 | for (const direction of [1, -1]) { | ||
| 366 | let hasContent = false; | ||
| 367 | for ( | ||
| 368 | let index = direction === 1 ? 0 : children.length - 1; | ||
| 369 | index >= 0 && index < children.length; | ||
| 370 | index += direction | ||
| 371 | ) { | ||
| 372 | const child = children[index]!; | ||
| 373 | if ( | ||
| 374 | child.type.name === "hardBreak" || | ||
| 375 | !child.marks.some((item) => item.type.name === mark) | ||
| 376 | ) { | ||
| 377 | hasContent = false; | ||
| 378 | } else if ( | ||
| 379 | child.isText && | ||
| 380 | !child.marks.some((item) => item.type.name === "code") && | ||
| 381 | /^\s+$/.test(child.text!) | ||
| 382 | ) { | ||
| 383 | if (!hasContent) | ||
| 384 | children[index] = child.mark(child.marks.filter((item) => item.type.name !== mark)); | ||
| 385 | } else { | ||
| 386 | hasContent = true; | ||
| 387 | } | ||
| 388 | } | ||
| 389 | } | ||
| 390 | } | ||
| 391 | // Longer shared marks surround shorter ones. This keeps both nested | ||
| 392 | // formatting and formatting across chips inside a single delimiter pair. | ||
| 393 | const markEnds = new Map<RichTextMark, number>(); | ||
| 394 | const orderedMarks: RichTextMark[][] = []; | ||
| 395 | for (let index = children.length - 1; index >= 0; index -= 1) { | ||
| 396 | const child = children[index]!; | ||
| 397 | const marks = | ||
| 398 | child.type.name === "hardBreak" | ||
| 399 | ? [] | ||
| 400 | : child.marks | ||
| 401 | .map((mark) => TIPTAP_TO_MARK[mark.type.name]) | ||
| 402 | .filter((mark): mark is RichTextMark => Boolean(mark)); | ||
| 403 | for (const mark of MARK_NESTING_ORDER) { | ||
| 404 | if (!marks.includes(mark)) markEnds.delete(mark); | ||
| 405 | else if (!markEnds.has(mark)) markEnds.set(mark, index); | ||
| 406 | } | ||
| 407 | orderedMarks[index] = marks.sort( | ||
| 408 | (a, b) => | ||
| 409 | markEnds.get(b)! - markEnds.get(a)! || | ||
| 410 | MARK_NESTING_ORDER.indexOf(a) - MARK_NESTING_ORDER.indexOf(b), | ||
| 411 | ); | ||
| 412 | } | ||
| 413 | for (let index = 1; index < orderedMarks.length; index += 1) { | ||
| 414 | const marks = orderedMarks[index]!; | ||
| 415 | const retained: RichTextMark[] = []; | ||
| 416 | for (const mark of orderedMarks[index - 1]!) { | ||
| 417 | if (!marks.includes(mark)) break; | ||
| 418 | retained.push(mark); | ||
| 419 | } | ||
| 420 | orderedMarks[index] = [...retained, ...marks.filter((mark) => !retained.includes(mark))]; | ||
| 421 | } | ||
| 422 | const commonLength = (left: RichTextMark[], right: RichTextMark[]) => { | ||
| 423 | let index = 0; | ||
| 424 | while (index < left.length && left[index] === right[index]) index += 1; | ||
| 425 | return index; | ||
| 426 | }; | ||
| 427 | let inlineOffset = 0; | ||
| 428 | children.forEach((child, index) => { | ||
| 429 | const pmPos = contentStart + inlineOffset; | ||
| 430 | inlineOffset += child.nodeSize; | ||
| 431 | if (child.type.name === "hardBreak") { | ||
| 432 | pushBreakRun(acc, pmPos); | ||
| 433 | return; | ||
| 434 | } | ||
| 435 | const marks = orderedMarks[index]!; | ||
| 436 | const open = marks | ||
| 437 | .slice(commonLength(marks, orderedMarks[index - 1] ?? [])) | ||
| 438 | .map((mark) => RICH_TEXT_DELIMITERS[mark]) | ||
| 439 | .join(""); | ||
| 440 | const close = marks | ||
| 441 | .slice(commonLength(marks, orderedMarks[index + 1] ?? [])) | ||
| 442 | .toReversed() | ||
| 443 | .map((mark) => RICH_TEXT_DELIMITERS[mark]) | ||
| 444 | .join(""); | ||
| 445 | const source = child.isText ? child.text! : readAtomSource(child); | ||
| 446 | const docLen = child.isText ? source.length : 1; | ||
| 447 | const mdText = open + source + close; | ||
| 448 | const collapsedLen = open.length + docLen + close.length; | ||
| 449 | acc.runs.push({ | ||
| 450 | kind: child.isText ? "text" : "token", | ||
| 451 | flatStart: acc.flat, | ||
| 452 | docLen, | ||
| 453 | collapsedLen, | ||
| 454 | mdLen: mdText.length, | ||
| 455 | openLen: open.length, | ||
| 456 | closeLen: close.length, | ||
| 457 | pmPos, | ||
| 458 | mdStart: acc.md, | ||
| 459 | collapsedStart: acc.collapsed, | ||
| 460 | ...(child.isText ? {} : { nodeName: child.type.name }), | ||
| 461 | }); | ||
| 462 | acc.value += mdText; | ||
| 463 | acc.flat += docLen; | ||
| 464 | acc.collapsed += collapsedLen; | ||
| 465 | acc.md += mdText.length; | ||
| 466 | }); | ||
| 467 | // Empty paragraphs have an editable position even though they emit no text. | ||
| 468 | if (children.length === 0) { | ||
| 469 | acc.runs.push({ | ||
| 470 | kind: "text", | ||
| 471 | flatStart: acc.flat, | ||
| 472 | docLen: 0, | ||
| 473 | collapsedLen: 0, | ||
| 474 | mdLen: 0, | ||
| 475 | openLen: 0, | ||
| 476 | closeLen: 0, | ||
| 477 | pmPos: contentStart, | ||
| 478 | mdStart: acc.md, | ||
| 479 | collapsedStart: acc.collapsed, | ||
| 480 | }); | ||
| 481 | } | ||
| 482 | } | ||
| 483 | |||
| 484 | function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { | ||
| 485 | let itemPos = listStart + 1; | ||
| 486 | let firstItem = true; | ||
| 487 | list.content.forEach((item) => { | ||
| 488 | // Sibling items are separated by one newline in every coordinate space. | ||
| 489 | if (!firstItem) pushBreakRun(acc); | ||
| 490 | firstItem = false; | ||
| 491 | const itemContentStart = itemPos + 1; | ||
| 492 | const first = item.firstChild; | ||
| 493 | const empty = first?.type.name === "paragraph" && first.content.childCount === 0; | ||
| 494 | const attrs = item.attrs as Record<string, unknown>; | ||
| 495 | const indent = typeof attrs.indent === "string" ? attrs.indent : ""; | ||
| 496 | const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; | ||
| 497 | const contentSpace = | ||
| 498 | typeof attrs.contentSpace === "string" | ||
| 499 | ? attrs.contentSpace || (empty ? "" : " ") | ||
| 500 | : empty | ||
| 501 | ? "" | ||
| 502 | : " "; | ||
| 503 | const prefix = `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; | ||
| 504 | // The checkbox owns no document characters; every prefix offset clamps | ||
| 505 | // to the start of the item text, exactly like style markers. | ||
| 506 | acc.runs.push({ | ||
| 507 | kind: "prefix", | ||
| 508 | flatStart: acc.flat, | ||
| 509 | docLen: 0, | ||
| 510 | collapsedLen: prefix.length, | ||
| 511 | mdLen: prefix.length, | ||
| 512 | openLen: 0, | ||
| 513 | closeLen: 0, | ||
| 514 | pmPos: itemContentStart + 1, | ||
| 515 | mdStart: acc.md, | ||
| 516 | collapsedStart: acc.collapsed, | ||
| 517 | }); | ||
| 518 | acc.value += prefix; | ||
| 519 | acc.collapsed += prefix.length; | ||
| 520 | acc.md += prefix.length; | ||
| 521 | let childPos = itemContentStart; | ||
| 522 | let firstBlock = true; | ||
| 523 | item.content.forEach((child) => { | ||
| 524 | if (!firstBlock) pushBreakRun(acc); | ||
| 525 | firstBlock = false; | ||
| 526 | if (child.type.name === "taskList") { | ||
| 527 | walkTaskList(child, childPos, acc); | ||
| 528 | } else if (child.type.name === "paragraph") { | ||
| 529 | appendInlineRuns(child, childPos + 1, acc); | ||
| 530 | } | ||
| 531 | childPos += child.nodeSize; | ||
| 532 | }); | ||
| 533 | itemPos += item.nodeSize; | ||
| 534 | }); | ||
| 535 | } | ||
| 536 | |||
| 537 | export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { | ||
| 538 | const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 }; | ||
| 539 | const blocks: ProseMirrorNode[] = []; | ||
| 540 | doc.content.forEach((node) => { | ||
| 541 | blocks.push(node); | ||
| 542 | }); | ||
| 543 | |||
| 544 | let pmBlockStart = 0; | ||
| 545 | blocks.forEach((block, blockIndex) => { | ||
| 546 | if (blockIndex > 0) pushBreakRun(acc); | ||
| 547 | if (block.type.name === "taskList") { | ||
| 548 | walkTaskList(block, pmBlockStart, acc); | ||
| 549 | } else if (block.type.name === "paragraph") { | ||
| 550 | appendInlineRuns(block, pmBlockStart + 1, acc); | ||
| 551 | } | ||
| 552 | pmBlockStart += block.nodeSize; | ||
| 553 | }); | ||
| 554 | |||
| 555 | return { | ||
| 556 | value: acc.value, | ||
| 557 | runs: acc.runs, | ||
| 558 | docLength: acc.flat, | ||
| 559 | contextIds: Array.from(new Set(collectInlineContextIds(acc.value))), | ||
| 560 | }; | ||
| 561 | } | ||
| 562 | |||
| 563 | function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number { | ||
| 564 | const last = map.runs[map.runs.length - 1]; | ||
| 565 | if (!last) return 0; | ||
| 566 | return space === "collapsed" | ||
| 567 | ? last.collapsedStart + last.collapsedLen | ||
| 568 | : last.mdStart + last.mdLen; | ||
| 569 | } | ||
| 570 | |||
| 571 | export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { | ||
| 572 | const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); | ||
| 573 | for (const run of map.runs) { | ||
| 574 | if (bounded < run.flatStart + run.docLen) { | ||
| 575 | if (run.kind === "text" || run.kind === "token") { | ||
| 576 | return run.collapsedStart + run.openLen + (bounded - run.flatStart); | ||
| 577 | } | ||
| 578 | return run.collapsedStart + (bounded - run.flatStart); | ||
| 579 | } | ||
| 580 | } | ||
| 581 | return lastRunEnd(map, "collapsed"); | ||
| 582 | } | ||
| 583 | |||
| 584 | export function flatToMarkdown(map: RichDocMap, flatOffset: number): number { | ||
| 585 | const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); | ||
| 586 | for (const run of map.runs) { | ||
| 587 | if (bounded < run.flatStart + run.docLen) { | ||
| 588 | if (run.kind === "text" || run.kind === "token") { | ||
| 589 | return run.mdStart + run.openLen + (bounded - run.flatStart); | ||
| 590 | } | ||
| 591 | return run.mdStart + (bounded - run.flatStart); | ||
| 592 | } | ||
| 593 | } | ||
| 594 | return lastRunEnd(map, "md"); | ||
| 595 | } | ||
| 596 | |||
| 597 | export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): number { | ||
| 598 | for (const run of map.runs) { | ||
| 599 | if (collapsedOffset < run.collapsedStart + run.collapsedLen) { | ||
| 600 | // Checkbox prefixes and style markers are shown, never edited: every | ||
| 601 | // offset inside them clamps to the adjacent document position. | ||
| 602 | if (run.kind === "prefix") return run.flatStart; | ||
| 603 | if (run.kind === "text" || run.kind === "token") { | ||
| 604 | const within = collapsedOffset - run.collapsedStart; | ||
| 605 | // Marker characters clamp to the styled edge: they are shown, never edited. | ||
| 606 | if (within <= run.openLen) return run.flatStart; | ||
| 607 | if (within >= run.openLen + run.docLen) return run.flatStart + run.docLen; | ||
| 608 | return run.flatStart + (within - run.openLen); | ||
| 609 | } | ||
| 610 | return run.flatStart + (collapsedOffset - run.collapsedStart); | ||
| 611 | } | ||
| 612 | } | ||
| 613 | return map.docLength; | ||
| 614 | } | ||
| 615 | |||
| 616 | export function flatToPm(map: RichDocMap, flatOffset: number): number { | ||
| 617 | const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); | ||
| 618 | for (const run of map.runs) { | ||
| 619 | if (bounded < run.flatStart + run.docLen) { | ||
| 620 | return run.pmPos + (bounded - run.flatStart); | ||
| 621 | } | ||
| 622 | } | ||
| 623 | const last = map.runs[map.runs.length - 1]; | ||
| 624 | if (!last) return 1; | ||
| 625 | return last.pmPos + last.docLen; | ||
| 626 | } | ||
| 627 | |||
| 628 | export function pmToFlat(map: RichDocMap, pmPos: number): number { | ||
| 629 | for (const run of map.runs) { | ||
| 630 | if (pmPos >= run.pmPos && pmPos <= run.pmPos + run.docLen) { | ||
| 631 | // A position on a chip's trailing edge belongs after the chip. | ||
| 632 | if (run.kind === "token" && pmPos === run.pmPos + run.docLen) { | ||
| 633 | return run.flatStart + run.docLen; | ||
| 634 | } | ||
| 635 | return run.flatStart + Math.min(pmPos - run.pmPos, run.docLen); | ||
| 636 | } | ||
| 637 | } | ||
| 638 | // A paragraph boundary position belongs to the newline between paragraphs. | ||
| 639 | let best = 0; | ||
| 640 | for (const run of map.runs) { | ||
| 641 | if (run.pmPos <= pmPos) best = run.flatStart + run.docLen; | ||
| 642 | } | ||
| 643 | return Math.max(0, Math.min(best, map.docLength)); | ||
| 644 | } | ||
apps/web/src/composer-rich-text.test.ts created+67| ... | @@ -0,0 +1,67 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { parseInlineMarkdown } from "./composer-rich-text"; | ||
| 4 | |||
| 5 | describe("composer rich text markdown", () => { | ||
| 6 | it("parses bold markers into styled spans", () => { | ||
| 7 | expect(parseInlineMarkdown("hello **bold** world")).toEqual([ | ||
| 8 | { text: "hello ", marks: [] }, | ||
| 9 | { text: "bold", marks: ["bold"] }, | ||
| 10 | { text: " world", marks: [] }, | ||
| 11 | ]); | ||
| 12 | }); | ||
| 13 | |||
| 14 | it("preserves unmatched markers, escaped markers, and identifiers", () => { | ||
| 15 | for (const text of [ | ||
| 16 | "plain text", | ||
| 17 | "snake_case", | ||
| 18 | "unmatched **", | ||
| 19 | "** spaced **", | ||
| 20 | "\\*literal\\*", | ||
| 21 | ]) { | ||
| 22 | expect(parseInlineMarkdown(text)).toEqual([{ text, marks: [] }]); | ||
| 23 | } | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("renders triple markers and nested styles", () => { | ||
| 27 | expect(parseInlineMarkdown("***both***")).toEqual([ | ||
| 28 | { text: "both", marks: ["bold", "italic"] }, | ||
| 29 | ]); | ||
| 30 | expect(parseInlineMarkdown("*a **b** c*")).toEqual([ | ||
| 31 | { text: "a ", marks: ["italic"] }, | ||
| 32 | { text: "b", marks: ["italic", "bold"] }, | ||
| 33 | { text: " c", marks: ["italic"] }, | ||
| 34 | ]); | ||
| 35 | expect(parseInlineMarkdown("**a `code` c**")).toEqual([ | ||
| 36 | { text: "a ", marks: ["bold"] }, | ||
| 37 | { text: "code", marks: ["bold", "code"] }, | ||
| 38 | { text: " c", marks: ["bold"] }, | ||
| 39 | ]); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("preserves crossing italic and bold spans", () => { | ||
| 43 | expect(parseInlineMarkdown("*a**b*****c**")).toEqual([ | ||
| 44 | { text: "a", marks: ["italic"] }, | ||
| 45 | { text: "b", marks: ["italic", "bold"] }, | ||
| 46 | { text: "c", marks: ["bold"] }, | ||
| 47 | ]); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("keeps repeated and alternate delimiters literal inside an active mark", () => { | ||
| 51 | const repeated = "*".repeat(10_000); | ||
| 52 | expect(parseInlineMarkdown(repeated + "text" + repeated)).toEqual([ | ||
| 53 | { text: repeated.slice(2) + "text", marks: ["bold"] }, | ||
| 54 | { text: repeated.slice(2), marks: [] }, | ||
| 55 | ]); | ||
| 56 | const alternate = "__".repeat(5_000); | ||
| 57 | expect(parseInlineMarkdown("**~~" + alternate + "text" + alternate + "~~**")).toEqual([ | ||
| 58 | { text: alternate + "text" + alternate, marks: ["bold", "strike"] }, | ||
| 59 | ]); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("keeps code span contents literal", () => { | ||
| 63 | expect(parseInlineMarkdown("`**not bold**`")).toEqual([ | ||
| 64 | { text: "**not bold**", marks: ["code"] }, | ||
| 65 | ]); | ||
| 66 | }); | ||
| 67 | }); | ||
apps/web/src/composer-rich-text.ts created+131| ... | @@ -0,0 +1,131 @@ | ||
| 1 | /** | ||
| 2 | * Inline markdown styling for the rich text composer. | ||
| 3 | * | ||
| 4 | * The composer's stored prompt stays plain markdown (`**bold**`), while the | ||
| 5 | * Tiptap surface renders styled text. These helpers translate between the two: | ||
| 6 | * parsing markdown into marked spans for the document, serializing marked | ||
| 7 | * spans back to markdown in the document serializer. | ||
| 8 | * | ||
| 9 | * Deliberately small: bold, italic, strikethrough, and inline code only. | ||
| 10 | * Unmatched markers stay literal text so nothing the user typed is ever lost. | ||
| 11 | */ | ||
| 12 | |||
| 13 | export type RichTextMark = "bold" | "italic" | "strike" | "code"; | ||
| 14 | |||
| 15 | interface RichTextSpan { | ||
| 16 | text: string; | ||
| 17 | marks: RichTextMark[]; | ||
| 18 | } | ||
| 19 | |||
| 20 | export const RICH_TEXT_DELIMITERS: Record<RichTextMark, string> = { | ||
| 21 | bold: "**", | ||
| 22 | italic: "*", | ||
| 23 | strike: "~~", | ||
| 24 | code: "`", | ||
| 25 | }; | ||
| 26 | |||
| 27 | function pushSpan(spans: RichTextSpan[], text: string, marks: RichTextMark[]): void { | ||
| 28 | if (!text) return; | ||
| 29 | const last = spans[spans.length - 1]; | ||
| 30 | if ( | ||
| 31 | last && | ||
| 32 | last.marks.length === marks.length && | ||
| 33 | last.marks.every((mark, index) => mark === marks[index]) | ||
| 34 | ) { | ||
| 35 | last.text += text; | ||
| 36 | } else { | ||
| 37 | spans.push({ text, marks }); | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | /** Parse the supported inline styles, leaving unmatched and escaped markers literal. */ | ||
| 42 | export function parseInlineMarkdown(text: string): RichTextSpan[] { | ||
| 43 | const root: RichTextSpan[] = []; | ||
| 44 | const stack: { delimiter: string; mark: RichTextMark; spans: RichTextSpan[] }[] = []; | ||
| 45 | const current = () => stack.at(-1)?.spans ?? root; | ||
| 46 | let index = 0; | ||
| 47 | while (index < text.length) { | ||
| 48 | const char = text[index]!; | ||
| 49 | if (char === "\\") { | ||
| 50 | pushSpan(current(), text.slice(index, index + 2), []); | ||
| 51 | index += 2; | ||
| 52 | continue; | ||
| 53 | } | ||
| 54 | if (char === "`") { | ||
| 55 | // Multi-backtick code stays literal; a single-backtick span owns its contents. | ||
| 56 | const run = text.slice(index).match(/^`+/)![0]; | ||
| 57 | const close = text.indexOf(run, index + run.length); | ||
| 58 | if ( | ||
| 59 | run.length === 1 && | ||
| 60 | close > index + 1 && | ||
| 61 | !text.slice(index, close).includes("\n") && | ||
| 62 | text[close + 1] !== "`" | ||
| 63 | ) { | ||
| 64 | pushSpan(current(), text.slice(index + 1, close), ["code"]); | ||
| 65 | index = close + 1; | ||
| 66 | } else { | ||
| 67 | pushSpan(current(), run, []); | ||
| 68 | index += run.length; | ||
| 69 | } | ||
| 70 | continue; | ||
| 71 | } | ||
| 72 | if (char !== "*" && char !== "_" && char !== "~") { | ||
| 73 | pushSpan(current(), char, []); | ||
| 74 | index += 1; | ||
| 75 | continue; | ||
| 76 | } | ||
| 77 | let end = index; | ||
| 78 | while (text[end] === char) end += 1; | ||
| 79 | const before = text[index - 1] ?? ""; | ||
| 80 | const after = text[end] ?? ""; | ||
| 81 | const canClose = before !== "" && !/\s/.test(before) && (char !== "_" || !/\w/.test(after)); | ||
| 82 | const canOpen = after !== "" && !/\s/.test(after) && (char !== "_" || !/\w/.test(before)); | ||
| 83 | while (index < end) { | ||
| 84 | const top = stack.at(-1); | ||
| 85 | // Inside italic, a double marker opens bold before closing the single | ||
| 86 | // marker. Once bold is active, closing runs unwind both styles. | ||
| 87 | const opensNestedBold = | ||
| 88 | top?.mark === "italic" && | ||
| 89 | end - index === 2 && | ||
| 90 | canOpen && | ||
| 91 | (char === "*" || char === "_") && | ||
| 92 | !stack.some((frame) => frame.mark === "bold"); | ||
| 93 | if ( | ||
| 94 | !opensNestedBold && | ||
| 95 | canClose && | ||
| 96 | top && | ||
| 97 | text.startsWith(top.delimiter, index) && | ||
| 98 | index + top.delimiter.length <= end | ||
| 99 | ) { | ||
| 100 | stack.pop(); | ||
| 101 | for (const span of top.spans) pushSpan(current(), span.text, [top.mark, ...span.marks]); | ||
| 102 | index += top.delimiter.length; | ||
| 103 | } else if (canOpen && (char !== "~" || end - index >= 2)) { | ||
| 104 | const length = char === "~" || end - index >= 2 ? 2 : 1; | ||
| 105 | const mark = char === "~" ? "strike" : length === 2 ? "bold" : "italic"; | ||
| 106 | // A mark cannot nest inside itself, including alternate delimiters. | ||
| 107 | // This bounds the stack to the supported styles for arbitrary input. | ||
| 108 | if (stack.some((frame) => frame.mark === mark)) { | ||
| 109 | pushSpan(current(), text.slice(index, end), []); | ||
| 110 | index = end; | ||
| 111 | continue; | ||
| 112 | } | ||
| 113 | stack.push({ | ||
| 114 | delimiter: char.repeat(length), | ||
| 115 | mark, | ||
| 116 | spans: [], | ||
| 117 | }); | ||
| 118 | index += length; | ||
| 119 | } else { | ||
| 120 | pushSpan(current(), text.slice(index, end), []); | ||
| 121 | index = end; | ||
| 122 | } | ||
| 123 | } | ||
| 124 | } | ||
| 125 | while (stack.length > 0) { | ||
| 126 | const frame = stack.pop()!; | ||
| 127 | pushSpan(current(), frame.delimiter, []); | ||
| 128 | for (const span of frame.spans) pushSpan(current(), span.text, span.marks); | ||
| 129 | } | ||
| 130 | return root; | ||
| 131 | } | ||
apps/web/src/index.css+102| ... | @@ -2120,3 +2120,105 @@ code { | ... | @@ -2120,3 +2120,105 @@ code { |
| 2120 | visibility: visible; | 2120 | visibility: visible; |
| 2121 | } | 2121 | } |
| 2122 | } | 2122 | } |
| 2123 | |||
| 2124 | /* Rich text composer (Tiptap). The stored prompt stays markdown; only the | ||
| 2125 | surface renders styled text. Markers (** * ` ~~) appear as non-editable | ||
| 2126 | widgets while the caret is on styled text. */ | ||
| 2127 | .composer-tiptap { | ||
| 2128 | position: relative; | ||
| 2129 | } | ||
| 2130 | |||
| 2131 | .composer-tiptap p { | ||
| 2132 | margin: 0; | ||
| 2133 | } | ||
| 2134 | |||
| 2135 | .composer-tiptap:focus, | ||
| 2136 | .composer-tiptap .ProseMirror:focus { | ||
| 2137 | outline: none; | ||
| 2138 | } | ||
| 2139 | |||
| 2140 | .composer-tiptap code { | ||
| 2141 | font-family: var(--font-mono); | ||
| 2142 | font-size: 0.92em; | ||
| 2143 | background: color-mix(in oklab, var(--muted-foreground) 12%, transparent); | ||
| 2144 | border-radius: 0.3em; | ||
| 2145 | padding: 0.05em 0.25em; | ||
| 2146 | } | ||
| 2147 | |||
| 2148 | .composer-rich-marker { | ||
| 2149 | color: var(--muted-foreground); | ||
| 2150 | opacity: 0.75; | ||
| 2151 | font-weight: 400; | ||
| 2152 | font-style: normal; | ||
| 2153 | text-decoration: none; | ||
| 2154 | user-select: none; | ||
| 2155 | pointer-events: none; | ||
| 2156 | } | ||
| 2157 | |||
| 2158 | /* Selected chips (node selection from arrowing into or clicking a chip). */ | ||
| 2159 | .composer-tiptap .ProseMirror-selectednode { | ||
| 2160 | outline: 2px solid var(--ring); | ||
| 2161 | outline-offset: 1px; | ||
| 2162 | border-radius: 6px; | ||
| 2163 | } | ||
| 2164 | |||
| 2165 | .composer-tiptap .composer-chip-range-selected { | ||
| 2166 | position: relative; | ||
| 2167 | } | ||
| 2168 | |||
| 2169 | .composer-tiptap.ProseMirror-focused .composer-chip-range-selected::after { | ||
| 2170 | content: ""; | ||
| 2171 | position: absolute; | ||
| 2172 | inset: 0; | ||
| 2173 | border-radius: 6px; | ||
| 2174 | background: Highlight; | ||
| 2175 | opacity: 0.3; | ||
| 2176 | pointer-events: none; | ||
| 2177 | } | ||
| 2178 | |||
| 2179 | /* Rich-text task lists render as real checkboxes. Tiptap's TaskItem NodeView | ||
| 2180 | builds the <li> imperatively with only a data-checked attribute (no | ||
| 2181 | data-type), so items are targeted that way. Indentation comes from list | ||
| 2182 | nesting (two spaces per level from Tab), matching the stored Markdown. */ | ||
| 2183 | .composer-tiptap ul[data-type="taskList"] { | ||
| 2184 | list-style: none; | ||
| 2185 | margin: 0; | ||
| 2186 | padding: 0; | ||
| 2187 | } | ||
| 2188 | |||
| 2189 | .composer-tiptap ul[data-type="taskList"] ul[data-type="taskList"] { | ||
| 2190 | padding-left: 1.375rem; | ||
| 2191 | } | ||
| 2192 | |||
| 2193 | .composer-tiptap li[data-checked] { | ||
| 2194 | display: flex; | ||
| 2195 | align-items: flex-start; | ||
| 2196 | gap: 0.5rem; | ||
| 2197 | margin: 0; | ||
| 2198 | padding: 0; | ||
| 2199 | } | ||
| 2200 | |||
| 2201 | .composer-tiptap li[data-checked] > label { | ||
| 2202 | display: flex; | ||
| 2203 | align-items: center; | ||
| 2204 | flex: none; | ||
| 2205 | height: 1lh; | ||
| 2206 | cursor: pointer; | ||
| 2207 | } | ||
| 2208 | |||
| 2209 | .composer-tiptap li[data-checked] > label input[type="checkbox"] { | ||
| 2210 | display: block; | ||
| 2211 | margin: 0; | ||
| 2212 | width: 0.95rem; | ||
| 2213 | height: 0.95rem; | ||
| 2214 | cursor: pointer; | ||
| 2215 | } | ||
| 2216 | |||
| 2217 | .composer-tiptap li[data-checked] > div { | ||
| 2218 | flex: 1 1 auto; | ||
| 2219 | min-width: 0; | ||
| 2220 | } | ||
| 2221 | |||
| 2222 | .composer-tiptap li[data-checked] > div > p { | ||
| 2223 | margin: 0; | ||
| 2224 | } |
apps/web/src/keybindings.test.ts+16| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | import { | 14 | import { |
| 15 | formatShortcutLabel, | 15 | formatShortcutLabel, |
| 16 | isDiffToggleShortcut, | 16 | isDiffToggleShortcut, |
| 17 | isRichTextBoldShortcut, | ||
| 17 | modelPickerJumpCommandForIndex, | 18 | modelPickerJumpCommandForIndex, |
| 18 | modelPickerJumpIndexFromCommand, | 19 | modelPickerJumpIndexFromCommand, |
| 19 | isOpenFavoriteEditorShortcut, | 20 | isOpenFavoriteEditorShortcut, |
| ... | @@ -962,6 +963,21 @@ describe("isTerminalClearShortcut", () => { | ... | @@ -962,6 +963,21 @@ describe("isTerminalClearShortcut", () => { |
| 962 | }); | 963 | }); |
| 963 | }); | 964 | }); |
| 964 | 965 | ||
| 966 | describe("isRichTextBoldShortcut", () => { | ||
| 967 | it("matches Mod+B without extra modifiers", () => { | ||
| 968 | assert.isTrue(isRichTextBoldShortcut(event({ key: "b", metaKey: true }))); | ||
| 969 | assert.isTrue(isRichTextBoldShortcut(event({ key: "B", ctrlKey: true }))); | ||
| 970 | }); | ||
| 971 | |||
| 972 | it("ignores shifted, alted, bare, and non-keydown presses", () => { | ||
| 973 | assert.isFalse(isRichTextBoldShortcut(event({ key: "b", metaKey: true, shiftKey: true }))); | ||
| 974 | assert.isFalse(isRichTextBoldShortcut(event({ key: "b", metaKey: true, altKey: true }))); | ||
| 975 | assert.isFalse(isRichTextBoldShortcut(event({ key: "b" }))); | ||
| 976 | assert.isFalse(isRichTextBoldShortcut(event({ key: "i", metaKey: true }))); | ||
| 977 | assert.isFalse(isRichTextBoldShortcut(event({ type: "keyup", key: "b", metaKey: true }))); | ||
| 978 | }); | ||
| 979 | }); | ||
| 980 | |||
| 965 | describe("terminalDeleteShortcutData", () => { | 981 | describe("terminalDeleteShortcutData", () => { |
| 966 | it("maps Cmd+Backspace on macOS to delete-to-line-start", () => { | 982 | it("maps Cmd+Backspace on macOS to delete-to-line-start", () => { |
| 967 | assert.strictEqual( | 983 | assert.strictEqual( |
apps/web/src/keybindings.ts+17| ... | @@ -406,6 +406,23 @@ export function isOpenFavoriteEditorShortcut( | ... | @@ -406,6 +406,23 @@ export function isOpenFavoriteEditorShortcut( |
| 406 | return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options); | 406 | return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options); |
| 407 | } | 407 | } |
| 408 | 408 | ||
| 409 | /** | ||
| 410 | * Whether the keypress is the rich-text bold chord (Mod+B without extra | ||
| 411 | * modifiers). Tiptap binds the same chord, so app shortcuts captured ahead | ||
| 412 | * of the editor must yield when the rich-text composer is focused. | ||
| 413 | */ | ||
| 414 | export function isRichTextBoldShortcut(event: ShortcutEventLike): boolean { | ||
| 415 | if (event.type !== undefined && event.type !== "keydown") { | ||
| 416 | return false; | ||
| 417 | } | ||
| 418 | return ( | ||
| 419 | event.key.toLowerCase() === "b" && | ||
| 420 | (event.metaKey || event.ctrlKey) && | ||
| 421 | !event.altKey && | ||
| 422 | !event.shiftKey | ||
| 423 | ); | ||
| 424 | } | ||
| 425 | |||
| 409 | export function isTerminalClearShortcut( | 426 | export function isTerminalClearShortcut( |
| 410 | event: ShortcutEventLike, | 427 | event: ShortcutEventLike, |
| 411 | platform = navigator.platform, | 428 | platform = navigator.platform, |
docs/internals/composer-editors.md created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | # Composer coordinates and serialization | ||
| 2 | |||
| 3 | The draft store owns Markdown, while Tiptap owns the editing document. The rich-text | ||
| 4 | setting changes the installed extensions, so toggling it remounts the editor from the | ||
| 5 | stored draft. Plain mode must leave formatting markers literal. Rich mode preserves | ||
| 6 | whitespace and chip sources, but canonicalizes supported delimiters (`__` to `**`, | ||
| 7 | `_` to `*`) and checkbox case (`[X]` to `[x]`). | ||
| 8 | |||
| 9 | Store cursors are not ProseMirror positions: collapsed coordinates count a chip as one | ||
| 10 | character, expanded coordinates count its source text, and ProseMirror also counts | ||
| 11 | block boundaries. Keep conversion in [the document model](../../apps/web/src/composer-rich-text-doc.ts). | ||
| 12 | Empty paragraphs need real caret positions even though they contain no text. Markers | ||
| 13 | shown beside styled text are decorations, so offsets inside them clamp to the text edge. | ||
| 14 | |||
| 15 | Only replace editor content when the controlled text changes. Replacing it for a cursor | ||
| 16 | move creates undo entries and regenerates citation identities. Pending citation popovers | ||
| 17 | must wait until the requested draft has reached the editor before locating their chip. | ||
| 18 | |||
| 19 | Rich tasks split through native editor commands so marks and chips survive. Literal | ||
| 20 | lists use [store edits](../../apps/web/src/composer-list-continuation.ts). Newlines become | ||
| 21 | paragraph splits: trailing hard breaks otherwise appear to require two presses. | ||
| 22 | Programmatic moves must explicitly scroll the caret into view. | ||
| 23 | |||
| 24 | Clipboard text must come from the Markdown serializer, not DOM text: chip labels omit | ||
| 25 | the source and marker decorations are not content. Structured context records accompany | ||
| 26 | that text when available. Paste completes trailing chip delimiters and adds a leading | ||
| 27 | boundary when inserting a chip directly after text. | ||
packages/contracts/src/settings.test.ts+13| ... | @@ -20,6 +20,19 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); | ... | @@ -20,6 +20,19 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); |
| 20 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | 20 | const encodeServerSettings = Schema.encodeSync(ServerSettings); |
| 21 | const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); | 21 | const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); |
| 22 | 22 | ||
| 23 | describe("ClientSettings rich text composer", () => { | ||
| 24 | it("enables rich text for new and existing settings without a saved preference", () => { | ||
| 25 | expect(decodeClientSettings({}).composerRichTextEnabled).toBe(true); | ||
| 26 | expect(decodeClientSettings({ sendShortcut: "mod-enter" }).composerRichTextEnabled).toBe(true); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it("preserves an explicit opt-out through patches and persistence", () => { | ||
| 30 | const preference = { composerRichTextEnabled: false }; | ||
| 31 | expect(decodeClientSettingsPatch(preference)).toEqual(preference); | ||
| 32 | expect(encodeClientSettings(decodeClientSettings(preference))).toMatchObject(preference); | ||
| 33 | }); | ||
| 34 | }); | ||
| 35 | |||
| 23 | describe("ServerSettings default permissions", () => { | 36 | describe("ServerSettings default permissions", () => { |
| 24 | it("keeps full access for settings saved before a default was configured", () => { | 37 | it("keeps full access for settings saved before a default was configured", () => { |
| 25 | expect(decodeServerSettings({}).defaultRuntimeMode).toBe("full-access"); | 38 | expect(decodeServerSettings({}).defaultRuntimeMode).toBe("full-access"); |
packages/contracts/src/settings.ts+3| ... | @@ -430,6 +430,8 @@ export const ClientSettingsSchema = Schema.Struct({ | ... | @@ -430,6 +430,8 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 430 | // Desktop resting composer: scrolling an existing thread's conversation | 430 | // Desktop resting composer: scrolling an existing thread's conversation |
| 431 | // settles the composer into its single-line layout. Losing focus never does. | 431 | // settles the composer into its single-line layout. Losing focus never does. |
| 432 | composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 432 | composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 433 | // Rich text is the default; users can opt out for literal Markdown editing. | ||
| 434 | composerRichTextEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | ||
| 433 | sendShortcut: Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"]).pipe( | 435 | sendShortcut: Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"]).pipe( |
| 434 | Schema.withDecodingDefault(Effect.succeed("enter")), | 436 | Schema.withDecodingDefault(Effect.succeed("enter")), |
| 435 | ), | 437 | ), |
| ... | @@ -1505,6 +1507,7 @@ export const ClientSettingsPatch = Schema.Struct({ | ... | @@ -1505,6 +1507,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 1505 | planModeEnabled: Schema.optionalKey(Schema.Boolean), | 1507 | planModeEnabled: Schema.optionalKey(Schema.Boolean), |
| 1506 | contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), | 1508 | contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), |
| 1507 | composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), | 1509 | composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), |
| 1510 | composerRichTextEnabled: Schema.optionalKey(Schema.Boolean), | ||
| 1508 | sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), | 1511 | sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), |
| 1509 | followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), | 1512 | followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), |
| 1510 | proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), | 1513 | proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), |
pnpm-lock.yaml+505-301| ... | @@ -600,9 +600,6 @@ importers: | ... | @@ -600,9 +600,6 @@ importers: |
| 600 | '@legendapp/list': | 600 | '@legendapp/list': |
| 601 | specifier: 'catalog:' | 601 | specifier: 'catalog:' |
| 602 | version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 602 | version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 603 | '@lexical/react': | ||
| 604 | specifier: ^0.41.0 | ||
| 605 | version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) | ||
| 606 | '@noble/hashes': | 603 | '@noble/hashes': |
| 607 | specifier: 'catalog:' | 604 | specifier: 'catalog:' |
| 608 | version: 1.8.0 | 605 | version: 1.8.0 |
| ... | @@ -627,6 +624,24 @@ importers: | ... | @@ -627,6 +624,24 @@ importers: |
| 627 | '@tanstack/react-router': | 624 | '@tanstack/react-router': |
| 628 | specifier: ^1.160.2 | 625 | specifier: ^1.160.2 |
| 629 | version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 626 | version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 627 | '@tiptap/core': | ||
| 628 | specifier: ^3.31.3 | ||
| 629 | version: 3.31.3(@tiptap/pm@3.31.3) | ||
| 630 | '@tiptap/extension-task-item': | ||
| 631 | specifier: ^3.31.3 | ||
| 632 | version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) | ||
| 633 | '@tiptap/extension-task-list': | ||
| 634 | specifier: ^3.31.3 | ||
| 635 | version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) | ||
| 636 | '@tiptap/pm': | ||
| 637 | specifier: ^3.31.3 | ||
| 638 | version: 3.31.3 | ||
| 639 | '@tiptap/react': | ||
| 640 | specifier: ^3.31.3 | ||
| 641 | version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | ||
| 642 | '@tiptap/starter-kit': | ||
| 643 | specifier: ^3.31.3 | ||
| 644 | version: 3.31.3 | ||
| 630 | class-variance-authority: | 645 | class-variance-authority: |
| 631 | specifier: ^0.7.1 | 646 | specifier: ^0.7.1 |
| 632 | version: 0.7.1 | 647 | version: 0.7.1 |
| ... | @@ -654,9 +669,6 @@ importers: | ... | @@ -654,9 +669,6 @@ importers: |
| 654 | jszip: | 669 | jszip: |
| 655 | specifier: 3.10.1 | 670 | specifier: 3.10.1 |
| 656 | version: 3.10.1 | 671 | version: 3.10.1 |
| 657 | lexical: | ||
| 658 | specifier: ^0.41.0 | ||
| 659 | version: 0.41.0 | ||
| 660 | lucide-react: | 672 | lucide-react: |
| 661 | specifier: ^0.564.0 | 673 | specifier: ^0.564.0 |
| 662 | version: 0.564.0(react@19.2.6) | 674 | version: 0.564.0(react@19.2.6) |
| ... | @@ -777,7 +789,7 @@ importers: | ... | @@ -777,7 +789,7 @@ importers: |
| 777 | version: link:../../packages/shared | 789 | version: link:../../packages/shared |
| 778 | alchemy: | 790 | alchemy: |
| 779 | specifier: 2.0.0-beta.76 | 791 | specifier: 2.0.0-beta.76 |
| 780 | version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f) | 792 | version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e) |
| 781 | drizzle-orm: | 793 | drizzle-orm: |
| 782 | specifier: 1.0.0-rc.5-ab785fc | 794 | specifier: 1.0.0-rc.5-ab785fc |
| 783 | version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) | 795 | version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) |
| ... | @@ -2877,12 +2889,6 @@ packages: | ... | @@ -2877,12 +2889,6 @@ packages: |
| 2877 | react: '>=16.8.0' | 2889 | react: '>=16.8.0' |
| 2878 | react-dom: '>=16.8.0' | 2890 | react-dom: '>=16.8.0' |
| 2879 | 2891 | ||
| 2880 | '@floating-ui/react@0.27.19': | ||
| 2881 | resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} | ||
| 2882 | peerDependencies: | ||
| 2883 | react: '>=17.0.0' | ||
| 2884 | react-dom: '>=17.0.0' | ||
| 2885 | |||
| 2886 | '@floating-ui/utils@0.2.11': | 2892 | '@floating-ui/utils@0.2.11': |
| 2887 | resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} | 2893 | resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} |
| 2888 | 2894 | ||
| ... | @@ -3304,80 +3310,6 @@ packages: | ... | @@ -3304,80 +3310,6 @@ packages: |
| 3304 | react-native: | 3310 | react-native: |
| 3305 | optional: true | 3311 | optional: true |
| 3306 | 3312 | ||
| 3307 | '@lexical/clipboard@0.41.0': | ||
| 3308 | resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==} | ||
| 3309 | |||
| 3310 | '@lexical/code@0.41.0': | ||
| 3311 | resolution: {integrity: sha512-0hoNi1KC9/N3SBOGcOcFqnT0OpwmcRRAhfxTKMGqfCtCvAMzULVwZ8RWc9/NV9bKYESgBTW5D9xkDANP2mspHg==} | ||
| 3312 | |||
| 3313 | '@lexical/devtools-core@0.41.0': | ||
| 3314 | resolution: {integrity: sha512-FzJtluBhBc8bKS11TUZe72KoZN/hnzIyiiM0SPJAsPwGpoXuM01jqpXQGybWf/1bWB+bmmhOae7O4Nywi/Csuw==} | ||
| 3315 | peerDependencies: | ||
| 3316 | react: '>=17.x' | ||
| 3317 | react-dom: '>=17.x' | ||
| 3318 | |||
| 3319 | '@lexical/dragon@0.41.0': | ||
| 3320 | resolution: {integrity: sha512-gBEqkk8Q6ZPruvDaRcOdF1EK9suCVBODzOCcR+EnoJTaTjfDkCM7pkPAm4w90Wa1wCZEtFHvCfas+jU9MDSumg==} | ||
| 3321 | |||
| 3322 | '@lexical/extension@0.41.0': | ||
| 3323 | resolution: {integrity: sha512-sF4SPiP72yXvIGchmmIZ7Yg2XZTxNLOpFEIIzdqG7X/1fa1Ham9P/T7VbrblWpF6Ei5LJtK9JgNVB0hb4l3o1g==} | ||
| 3324 | |||
| 3325 | '@lexical/hashtag@0.41.0': | ||
| 3326 | resolution: {integrity: sha512-tFWM74RW4KU0E/sj2aowfWl26vmLUTp331CgVESnhQKcZBfT40KJYd57HEqBDTfQKn4MUhylQCCA0hbpw6EeFQ==} | ||
| 3327 | |||
| 3328 | '@lexical/history@0.41.0': | ||
| 3329 | resolution: {integrity: sha512-kGoVWsiOn62+RMjRolRa+NXZl8jFwxav6GNDiHH8yzivtoaH8n1SwUfLJELXCzeqzs81HySqD4q30VLJVTGoDg==} | ||
| 3330 | |||
| 3331 | '@lexical/html@0.41.0': | ||
| 3332 | resolution: {integrity: sha512-3RyZy+H/IDKz2D66rNN/NqYx87xVFrngfEbyu1OWtbY963RUFnopiVHCQvsge/8kT04QSZ7U/DzjVFqeNS6clg==} | ||
| 3333 | |||
| 3334 | '@lexical/link@0.41.0': | ||
| 3335 | resolution: {integrity: sha512-Rjtx5cGWAkKcnacncbVsZ1TqRnUB2Wm4eEVKpaAEG41+kHgqghzM2P+UGT15yROroxJu8KvAC9ISiYFiU4XE1w==} | ||
| 3336 | |||
| 3337 | '@lexical/list@0.41.0': | ||
| 3338 | resolution: {integrity: sha512-RXvB+xcbzVoQLGRDOBRCacztG7V+bI95tdoTwl8pz5xvgPtAaRnkZWMDP+yMNzMJZsqEChdtpxbf0NgtMkun6g==} | ||
| 3339 | |||
| 3340 | '@lexical/mark@0.41.0': | ||
| 3341 | resolution: {integrity: sha512-UO5WVs9uJAYIKHSlYh4Z1gHrBBchTOi21UCYBIZ7eAs4suK84hPzD+3/LAX5CB7ZltL6ke5Sly3FOwNXv/wfpA==} | ||
| 3342 | |||
| 3343 | '@lexical/markdown@0.41.0': | ||
| 3344 | resolution: {integrity: sha512-bzI73JMXpjGFhqUWNV6KqfjWcgAWzwFT+J3RHtbCF5rysC8HLldBYojOgAAtPfXqfxyv2mDzsY7SoJ75s9uHZA==} | ||
| 3345 | |||
| 3346 | '@lexical/offset@0.41.0': | ||
| 3347 | resolution: {integrity: sha512-2RHBXZqC8gm3X9C0AyRb0M8w7zJu5dKiasrif+jSKzsxPjAUeF1m95OtIOsWs1XLNUgASOSUqGovDZxKJslZfA==} | ||
| 3348 | |||
| 3349 | '@lexical/overflow@0.41.0': | ||
| 3350 | resolution: {integrity: sha512-Iy6ZiJip8X14EBYt1zKPOrXyQ4eG9JLBEoPoSVBTiSbVd+lYicdUvaOThT0k0/qeVTN9nqTaEltBjm56IrVKCQ==} | ||
| 3351 | |||
| 3352 | '@lexical/plain-text@0.41.0': | ||
| 3353 | resolution: {integrity: sha512-HIsGgmFUYRUNNyvckun33UQfU7LRzDlxymHUq67+Bxd5bXqdZOrStEKJXuDX+LuLh/GXZbaWNbDLqwLBObfbQg==} | ||
| 3354 | |||
| 3355 | '@lexical/react@0.41.0': | ||
| 3356 | resolution: {integrity: sha512-7+GUdZUm6sofWm+zdsWAs6cFBwKNsvsHezZTrf6k8jrZxL461ZQmbz/16b4DvjCGL9r5P1fR7md9/LCmk8TiCg==} | ||
| 3357 | peerDependencies: | ||
| 3358 | react: '>=17.x' | ||
| 3359 | react-dom: '>=17.x' | ||
| 3360 | |||
| 3361 | '@lexical/rich-text@0.41.0': | ||
| 3362 | resolution: {integrity: sha512-yUcr7ZaaVTZNi8bow4CK1M8jy2qyyls1Vr+5dVjwBclVShOL/F/nFyzBOSb6RtXXRbd3Ahuk9fEleppX/RNIdw==} | ||
| 3363 | |||
| 3364 | '@lexical/selection@0.41.0': | ||
| 3365 | resolution: {integrity: sha512-1s7/kNyRzcv5uaTwsUL28NpiisqTf5xZ1zNukLsCN1xY+TWbv9RE9OxIv+748wMm4pxNczQe/UbIBODkbeknLw==} | ||
| 3366 | |||
| 3367 | '@lexical/table@0.41.0': | ||
| 3368 | resolution: {integrity: sha512-d3SPThBAr+oZ8O74TXU0iXM3rLbrAVC7/HcOnSAq7/AhWQW8yMutT51JQGN+0fMLP9kqoWSAojNtkdvzXfU/+A==} | ||
| 3369 | |||
| 3370 | '@lexical/text@0.41.0': | ||
| 3371 | resolution: {integrity: sha512-gGA+Anc7ck110EXo4KVKtq6Ui3M7Vz3OpGJ4QE6zJHWW8nV5h273koUGSutAMeoZgRVb6t01Izh3ORoFt/j1CA==} | ||
| 3372 | |||
| 3373 | '@lexical/utils@0.41.0': | ||
| 3374 | resolution: {integrity: sha512-Wlsokr5NQCq83D+7kxZ9qs5yQ3dU3Qaf2M+uXxLRoPoDaXqW8xTWZq1+ZFoEzsHzx06QoPa4Vu/40BZR91uQPg==} | ||
| 3375 | |||
| 3376 | '@lexical/yjs@0.41.0': | ||
| 3377 | resolution: {integrity: sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w==} | ||
| 3378 | peerDependencies: | ||
| 3379 | yjs: '>=13.5.22' | ||
| 3380 | |||
| 3381 | '@libsql/client@0.17.3': | 3313 | '@libsql/client@0.17.3': |
| 3382 | resolution: {integrity: sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==} | 3314 | resolution: {integrity: sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==} |
| 3383 | 3315 | ||
| ... | @@ -4273,9 +4205,6 @@ packages: | ... | @@ -4273,9 +4205,6 @@ packages: |
| 4273 | '@polka/url@1.0.0-next.29': | 4205 | '@polka/url@1.0.0-next.29': |
| 4274 | resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} | 4206 | resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} |
| 4275 | 4207 | ||
| 4276 | '@preact/signals-core@1.14.2': | ||
| 4277 | resolution: {integrity: sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==} | ||
| 4278 | |||
| 4279 | '@prisma/debug@7.2.0': | 4208 | '@prisma/debug@7.2.0': |
| 4280 | resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} | 4209 | resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} |
| 4281 | 4210 | ||
| ... | @@ -5334,6 +5263,166 @@ packages: | ... | @@ -5334,6 +5263,166 @@ packages: |
| 5334 | peerDependencies: | 5263 | peerDependencies: |
| 5335 | '@testing-library/dom': '>=7.21.4' | 5264 | '@testing-library/dom': '>=7.21.4' |
| 5336 | 5265 | ||
| 5266 | '@tiptap/core@3.31.3': | ||
| 5267 | resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} | ||
| 5268 | peerDependencies: | ||
| 5269 | '@tiptap/pm': 3.31.3 | ||
| 5270 | |||
| 5271 | '@tiptap/extension-blockquote@3.31.3': | ||
| 5272 | resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} | ||
| 5273 | peerDependencies: | ||
| 5274 | '@tiptap/core': 3.31.3 | ||
| 5275 | '@tiptap/pm': 3.31.3 | ||
| 5276 | |||
| 5277 | '@tiptap/extension-bold@3.31.3': | ||
| 5278 | resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} | ||
| 5279 | peerDependencies: | ||
| 5280 | '@tiptap/core': 3.31.3 | ||
| 5281 | |||
| 5282 | '@tiptap/extension-bubble-menu@3.31.3': | ||
| 5283 | resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} | ||
| 5284 | peerDependencies: | ||
| 5285 | '@tiptap/core': 3.31.3 | ||
| 5286 | '@tiptap/pm': 3.31.3 | ||
| 5287 | |||
| 5288 | '@tiptap/extension-bullet-list@3.31.3': | ||
| 5289 | resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} | ||
| 5290 | peerDependencies: | ||
| 5291 | '@tiptap/extension-list': 3.31.3 | ||
| 5292 | |||
| 5293 | '@tiptap/extension-code-block@3.31.3': | ||
| 5294 | resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} | ||
| 5295 | peerDependencies: | ||
| 5296 | '@tiptap/core': 3.31.3 | ||
| 5297 | '@tiptap/pm': 3.31.3 | ||
| 5298 | |||
| 5299 | '@tiptap/extension-code@3.31.3': | ||
| 5300 | resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} | ||
| 5301 | peerDependencies: | ||
| 5302 | '@tiptap/core': 3.31.3 | ||
| 5303 | |||
| 5304 | '@tiptap/extension-document@3.31.3': | ||
| 5305 | resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} | ||
| 5306 | peerDependencies: | ||
| 5307 | '@tiptap/core': 3.31.3 | ||
| 5308 | |||
| 5309 | '@tiptap/extension-dropcursor@3.31.3': | ||
| 5310 | resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} | ||
| 5311 | peerDependencies: | ||
| 5312 | '@tiptap/extensions': 3.31.3 | ||
| 5313 | |||
| 5314 | '@tiptap/extension-floating-menu@3.31.3': | ||
| 5315 | resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} | ||
| 5316 | peerDependencies: | ||
| 5317 | '@floating-ui/dom': ^1.0.0 | ||
| 5318 | '@tiptap/core': 3.31.3 | ||
| 5319 | '@tiptap/pm': 3.31.3 | ||
| 5320 | |||
| 5321 | '@tiptap/extension-gapcursor@3.31.3': | ||
| 5322 | resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} | ||
| 5323 | peerDependencies: | ||
| 5324 | '@tiptap/extensions': 3.31.3 | ||
| 5325 | |||
| 5326 | '@tiptap/extension-hard-break@3.31.3': | ||
| 5327 | resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} | ||
| 5328 | peerDependencies: | ||
| 5329 | '@tiptap/core': 3.31.3 | ||
| 5330 | |||
| 5331 | '@tiptap/extension-heading@3.31.3': | ||
| 5332 | resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} | ||
| 5333 | peerDependencies: | ||
| 5334 | '@tiptap/core': 3.31.3 | ||
| 5335 | |||
| 5336 | '@tiptap/extension-horizontal-rule@3.31.3': | ||
| 5337 | resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} | ||
| 5338 | peerDependencies: | ||
| 5339 | '@tiptap/core': 3.31.3 | ||
| 5340 | '@tiptap/pm': 3.31.3 | ||
| 5341 | |||
| 5342 | '@tiptap/extension-italic@3.31.3': | ||
| 5343 | resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} | ||
| 5344 | peerDependencies: | ||
| 5345 | '@tiptap/core': 3.31.3 | ||
| 5346 | |||
| 5347 | '@tiptap/extension-link@3.31.3': | ||
| 5348 | resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} | ||
| 5349 | peerDependencies: | ||
| 5350 | '@tiptap/core': 3.31.3 | ||
| 5351 | '@tiptap/pm': 3.31.3 | ||
| 5352 | |||
| 5353 | '@tiptap/extension-list-item@3.31.3': | ||
| 5354 | resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} | ||
| 5355 | peerDependencies: | ||
| 5356 | '@tiptap/extension-list': 3.31.3 | ||
| 5357 | |||
| 5358 | '@tiptap/extension-list-keymap@3.31.3': | ||
| 5359 | resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} | ||
| 5360 | peerDependencies: | ||
| 5361 | '@tiptap/extension-list': 3.31.3 | ||
| 5362 | |||
| 5363 | '@tiptap/extension-list@3.31.3': | ||
| 5364 | resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} | ||
| 5365 | peerDependencies: | ||
| 5366 | '@tiptap/core': 3.31.3 | ||
| 5367 | '@tiptap/pm': 3.31.3 | ||
| 5368 | |||
| 5369 | '@tiptap/extension-ordered-list@3.31.3': | ||
| 5370 | resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} | ||
| 5371 | peerDependencies: | ||
| 5372 | '@tiptap/extension-list': 3.31.3 | ||
| 5373 | |||
| 5374 | '@tiptap/extension-paragraph@3.31.3': | ||
| 5375 | resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} | ||
| 5376 | peerDependencies: | ||
| 5377 | '@tiptap/core': 3.31.3 | ||
| 5378 | |||
| 5379 | '@tiptap/extension-strike@3.31.3': | ||
| 5380 | resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} | ||
| 5381 | peerDependencies: | ||
| 5382 | '@tiptap/core': 3.31.3 | ||
| 5383 | |||
| 5384 | '@tiptap/extension-task-item@3.31.3': | ||
| 5385 | resolution: {integrity: sha512-gCWvvXsCzi9tVFXPqKlkbU81XxtIdfjGy7FM9QFRKiWAvq5uv+Q5nvwrCyMVyP4bA7zRGWFaPzwjL68djgzoBw==} | ||
| 5386 | peerDependencies: | ||
| 5387 | '@tiptap/extension-list': 3.31.3 | ||
| 5388 | |||
| 5389 | '@tiptap/extension-task-list@3.31.3': | ||
| 5390 | resolution: {integrity: sha512-3WgVzmfEnDbmxbjUBlvTiKpT53KSBnnYhXUVQxM0nz1vpoWG8QifhsnCVsNI6TZmhgR0x/jY+UW9Hc/WLF8DkQ==} | ||
| 5391 | peerDependencies: | ||
| 5392 | '@tiptap/extension-list': 3.31.3 | ||
| 5393 | |||
| 5394 | '@tiptap/extension-text@3.31.3': | ||
| 5395 | resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} | ||
| 5396 | peerDependencies: | ||
| 5397 | '@tiptap/core': 3.31.3 | ||
| 5398 | |||
| 5399 | '@tiptap/extension-underline@3.31.3': | ||
| 5400 | resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} | ||
| 5401 | peerDependencies: | ||
| 5402 | '@tiptap/core': 3.31.3 | ||
| 5403 | |||
| 5404 | '@tiptap/extensions@3.31.3': | ||
| 5405 | resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} | ||
| 5406 | peerDependencies: | ||
| 5407 | '@tiptap/core': 3.31.3 | ||
| 5408 | '@tiptap/pm': 3.31.3 | ||
| 5409 | |||
| 5410 | '@tiptap/pm@3.31.3': | ||
| 5411 | resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} | ||
| 5412 | |||
| 5413 | '@tiptap/react@3.31.3': | ||
| 5414 | resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} | ||
| 5415 | peerDependencies: | ||
| 5416 | '@tiptap/core': 3.31.3 | ||
| 5417 | '@tiptap/pm': 3.31.3 | ||
| 5418 | '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 | ||
| 5419 | '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 | ||
| 5420 | react: ^17.0.0 || ^18.0.0 || ^19.0.0 | ||
| 5421 | react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 | ||
| 5422 | |||
| 5423 | '@tiptap/starter-kit@3.31.3': | ||
| 5424 | resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} | ||
| 5425 | |||
| 5337 | '@tybys/wasm-util@0.10.3': | 5426 | '@tybys/wasm-util@0.10.3': |
| 5338 | resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} | 5427 | resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} |
| 5339 | 5428 | ||
| ... | @@ -5474,6 +5563,9 @@ packages: | ... | @@ -5474,6 +5563,9 @@ packages: |
| 5474 | '@types/unist@3.0.3': | 5563 | '@types/unist@3.0.3': |
| 5475 | resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} | 5564 | resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} |
| 5476 | 5565 | ||
| 5566 | '@types/use-sync-external-store@0.0.6': | ||
| 5567 | resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} | ||
| 5568 | |||
| 5477 | '@types/webidl-conversions@7.0.3': | 5569 | '@types/webidl-conversions@7.0.3': |
| 5478 | resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} | 5570 | resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} |
| 5479 | 5571 | ||
| ... | @@ -7659,6 +7751,10 @@ packages: | ... | @@ -7659,6 +7751,10 @@ packages: |
| 7659 | fast-deep-equal@3.1.3: | 7751 | fast-deep-equal@3.1.3: |
| 7660 | resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} | 7752 | resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} |
| 7661 | 7753 | ||
| 7754 | fast-equals@5.4.2: | ||
| 7755 | resolution: {integrity: sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==} | ||
| 7756 | engines: {node: '>=6.0.0'} | ||
| 7757 | |||
| 7662 | fast-glob@3.3.3: | 7758 | fast-glob@3.3.3: |
| 7663 | resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} | 7759 | resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} |
| 7664 | engines: {node: '>=8.6.0'} | 7760 | engines: {node: '>=8.6.0'} |
| ... | @@ -8258,9 +8354,6 @@ packages: | ... | @@ -8258,9 +8354,6 @@ packages: |
| 8258 | resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} | 8354 | resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} |
| 8259 | engines: {node: '>=20'} | 8355 | engines: {node: '>=20'} |
| 8260 | 8356 | ||
| 8261 | isomorphic.js@0.2.5: | ||
| 8262 | resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} | ||
| 8263 | |||
| 8264 | jake@10.9.4: | 8357 | jake@10.9.4: |
| 8265 | resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} | 8358 | resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} |
| 8266 | engines: {node: '>=10'} | 8359 | engines: {node: '>=10'} |
| ... | @@ -8397,14 +8490,6 @@ packages: | ... | @@ -8397,14 +8490,6 @@ packages: |
| 8397 | resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} | 8490 | resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} |
| 8398 | engines: {node: '>=6'} | 8491 | engines: {node: '>=6'} |
| 8399 | 8492 | ||
| 8400 | lexical@0.41.0: | ||
| 8401 | resolution: {integrity: sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==} | ||
| 8402 | |||
| 8403 | lib0@0.2.117: | ||
| 8404 | resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} | ||
| 8405 | engines: {node: '>=16'} | ||
| 8406 | hasBin: true | ||
| 8407 | |||
| 8408 | libsodium-wrappers@0.8.4: | 8493 | libsodium-wrappers@0.8.4: |
| 8409 | resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==} | 8494 | resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==} |
| 8410 | 8495 | ||
| ... | @@ -8500,6 +8585,9 @@ packages: | ... | @@ -8500,6 +8585,9 @@ packages: |
| 8500 | resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} | 8585 | resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} |
| 8501 | engines: {node: '>=10'} | 8586 | engines: {node: '>=10'} |
| 8502 | 8587 | ||
| 8588 | linkifyjs@4.3.3: | ||
| 8589 | resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} | ||
| 8590 | |||
| 8503 | locate-path@3.0.0: | 8591 | locate-path@3.0.0: |
| 8504 | resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} | 8592 | resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} |
| 8505 | engines: {node: '>=6'} | 8593 | engines: {node: '>=6'} |
| ... | @@ -9249,6 +9337,9 @@ packages: | ... | @@ -9249,6 +9337,9 @@ packages: |
| 9249 | resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} | 9337 | resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} |
| 9250 | engines: {node: '>=20'} | 9338 | engines: {node: '>=20'} |
| 9251 | 9339 | ||
| 9340 | orderedmap@2.1.1: | ||
| 9341 | resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} | ||
| 9342 | |||
| 9252 | outvariant@1.4.3: | 9343 | outvariant@1.4.3: |
| 9253 | resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} | 9344 | resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} |
| 9254 | 9345 | ||
| ... | @@ -9609,6 +9700,45 @@ packages: | ... | @@ -9609,6 +9700,45 @@ packages: |
| 9609 | property-information@7.2.0: | 9700 | property-information@7.2.0: |
| 9610 | resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} | 9701 | resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} |
| 9611 | 9702 | ||
| 9703 | prosemirror-changeset@2.4.2: | ||
| 9704 | resolution: {integrity: sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==} | ||
| 9705 | |||
| 9706 | prosemirror-commands@1.7.2: | ||
| 9707 | resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==} | ||
| 9708 | |||
| 9709 | prosemirror-dropcursor@1.8.3: | ||
| 9710 | resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==} | ||
| 9711 | |||
| 9712 | prosemirror-gapcursor@1.4.1: | ||
| 9713 | resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} | ||
| 9714 | |||
| 9715 | prosemirror-history@1.5.0: | ||
| 9716 | resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} | ||
| 9717 | |||
| 9718 | prosemirror-inputrules@1.5.1: | ||
| 9719 | resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} | ||
| 9720 | |||
| 9721 | prosemirror-keymap@1.2.3: | ||
| 9722 | resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} | ||
| 9723 | |||
| 9724 | prosemirror-model@1.25.11: | ||
| 9725 | resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} | ||
| 9726 | |||
| 9727 | prosemirror-schema-list@1.5.1: | ||
| 9728 | resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} | ||
| 9729 | |||
| 9730 | prosemirror-state@1.4.4: | ||
| 9731 | resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} | ||
| 9732 | |||
| 9733 | prosemirror-tables@1.8.5: | ||
| 9734 | resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} | ||
| 9735 | |||
| 9736 | prosemirror-transform@1.12.1: | ||
| 9737 | resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==} | ||
| 9738 | |||
| 9739 | prosemirror-view@1.42.3: | ||
| 9740 | resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} | ||
| 9741 | |||
| 9612 | proxy-addr@2.0.7: | 9742 | proxy-addr@2.0.7: |
| 9613 | resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} | 9743 | resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} |
| 9614 | engines: {node: '>= 0.10'} | 9744 | engines: {node: '>= 0.10'} |
| ... | @@ -9682,11 +9812,6 @@ packages: | ... | @@ -9682,11 +9812,6 @@ packages: |
| 9682 | peerDependencies: | 9812 | peerDependencies: |
| 9683 | react: ^19.2.6 | 9813 | react: ^19.2.6 |
| 9684 | 9814 | ||
| 9685 | react-error-boundary@6.1.2: | ||
| 9686 | resolution: {integrity: sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==} | ||
| 9687 | peerDependencies: | ||
| 9688 | react: ^18.0.0 || ^19.0.0 | ||
| 9689 | |||
| 9690 | react-freeze@1.0.4: | 9815 | react-freeze@1.0.4: |
| 9691 | resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} | 9816 | resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} |
| 9692 | engines: {node: '>=10'} | 9817 | engines: {node: '>=10'} |
| ... | @@ -10091,6 +10216,9 @@ packages: | ... | @@ -10091,6 +10216,9 @@ packages: |
| 10091 | engines: {node: '>=18.0.0', npm: '>=8.0.0'} | 10216 | engines: {node: '>=18.0.0', npm: '>=8.0.0'} |
| 10092 | hasBin: true | 10217 | hasBin: true |
| 10093 | 10218 | ||
| 10219 | rope-sequence@1.3.4: | ||
| 10220 | resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} | ||
| 10221 | |||
| 10094 | router@2.2.0: | 10222 | router@2.2.0: |
| 10095 | resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} | 10223 | resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} |
| 10096 | engines: {node: '>= 18'} | 10224 | engines: {node: '>= 18'} |
| ... | @@ -10488,9 +10616,6 @@ packages: | ... | @@ -10488,9 +10616,6 @@ packages: |
| 10488 | resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} | 10616 | resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} |
| 10489 | hasBin: true | 10617 | hasBin: true |
| 10490 | 10618 | ||
| 10491 | tabbable@6.4.0: | ||
| 10492 | resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} | ||
| 10493 | |||
| 10494 | tagged-tag@1.0.0: | 10619 | tagged-tag@1.0.0: |
| 10495 | resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} | 10620 | resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} |
| 10496 | engines: {node: '>=20'} | 10621 | engines: {node: '>=20'} |
| ... | @@ -11113,6 +11238,9 @@ packages: | ... | @@ -11113,6 +11238,9 @@ packages: |
| 11113 | vscode-uri@3.1.0: | 11238 | vscode-uri@3.1.0: |
| 11114 | resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} | 11239 | resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} |
| 11115 | 11240 | ||
| 11241 | w3c-keyname@2.2.8: | ||
| 11242 | resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} | ||
| 11243 | |||
| 11116 | walk-up-path@4.0.0: | 11244 | walk-up-path@4.0.0: |
| 11117 | resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} | 11245 | resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} |
| 11118 | engines: {node: 20 || >=22} | 11246 | engines: {node: 20 || >=22} |
| ... | @@ -11313,10 +11441,6 @@ packages: | ... | @@ -11313,10 +11441,6 @@ packages: |
| 11313 | resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} | 11441 | resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} |
| 11314 | engines: {node: '>=12'} | 11442 | engines: {node: '>=12'} |
| 11315 | 11443 | ||
| 11316 | yjs@13.6.31: | ||
| 11317 | resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} | ||
| 11318 | engines: {node: '>=16.0.0', npm: '>=8.0.0'} | ||
| 11319 | |||
| 11320 | yocto-queue@0.1.0: | 11444 | yocto-queue@0.1.0: |
| 11321 | resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} | 11445 | resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} |
| 11322 | engines: {node: '>=10'} | 11446 | engines: {node: '>=10'} |
| ... | @@ -13727,14 +13851,6 @@ snapshots: | ... | @@ -13727,14 +13851,6 @@ snapshots: |
| 13727 | react: 19.2.6 | 13851 | react: 19.2.6 |
| 13728 | react-dom: 19.2.6(react@19.2.6) | 13852 | react-dom: 19.2.6(react@19.2.6) |
| 13729 | 13853 | ||
| 13730 | '@floating-ui/react@0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | ||
| 13731 | dependencies: | ||
| 13732 | '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | ||
| 13733 | '@floating-ui/utils': 0.2.11 | ||
| 13734 | react: 19.2.6 | ||
| 13735 | react-dom: 19.2.6(react@19.2.6) | ||
| 13736 | tabbable: 6.4.0 | ||
| 13737 | |||
| 13738 | '@floating-ui/utils@0.2.11': {} | 13854 | '@floating-ui/utils@0.2.11': {} |
| 13739 | 13855 | ||
| 13740 | '@formkit/auto-animate@0.9.0': {} | 13856 | '@formkit/auto-animate@0.9.0': {} |
| ... | @@ -14047,165 +14163,6 @@ snapshots: | ... | @@ -14047,165 +14163,6 @@ snapshots: |
| 14047 | optionalDependencies: | 14163 | optionalDependencies: |
| 14048 | react-dom: 19.2.6(react@19.2.6) | 14164 | react-dom: 19.2.6(react@19.2.6) |
| 14049 | 14165 | ||
| 14050 | '@lexical/clipboard@0.41.0': | ||
| 14051 | dependencies: | ||
| 14052 | '@lexical/html': 0.41.0 | ||
| 14053 | '@lexical/list': 0.41.0 | ||
| 14054 | '@lexical/selection': 0.41.0 | ||
| 14055 | '@lexical/utils': 0.41.0 | ||
| 14056 | lexical: 0.41.0 | ||
| 14057 | |||
| 14058 | '@lexical/code@0.41.0': | ||
| 14059 | dependencies: | ||
| 14060 | '@lexical/utils': 0.41.0 | ||
| 14061 | lexical: 0.41.0 | ||
| 14062 | prismjs: 1.30.0 | ||
| 14063 | |||
| 14064 | '@lexical/devtools-core@0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | ||
| 14065 | dependencies: | ||
| 14066 | '@lexical/html': 0.41.0 | ||
| 14067 | '@lexical/link': 0.41.0 | ||
| 14068 | '@lexical/mark': 0.41.0 | ||
| 14069 | '@lexical/table': 0.41.0 | ||
| 14070 | '@lexical/utils': 0.41.0 | ||
| 14071 | lexical: 0.41.0 | ||
| 14072 | react: 19.2.6 | ||
| 14073 | react-dom: 19.2.6(react@19.2.6) | ||
| 14074 | |||
| 14075 | '@lexical/dragon@0.41.0': | ||
| 14076 | dependencies: | ||
| 14077 | '@lexical/extension': 0.41.0 | ||
| 14078 | lexical: 0.41.0 | ||
| 14079 | |||
| 14080 | '@lexical/extension@0.41.0': | ||
| 14081 | dependencies: | ||
| 14082 | '@lexical/utils': 0.41.0 | ||
| 14083 | '@preact/signals-core': 1.14.2 | ||
| 14084 | lexical: 0.41.0 | ||
| 14085 | |||
| 14086 | '@lexical/hashtag@0.41.0': | ||
| 14087 | dependencies: | ||
| 14088 | '@lexical/text': 0.41.0 | ||
| 14089 | '@lexical/utils': 0.41.0 | ||
| 14090 | lexical: 0.41.0 | ||
| 14091 | |||
| 14092 | '@lexical/history@0.41.0': | ||
| 14093 | dependencies: | ||
| 14094 | '@lexical/extension': 0.41.0 | ||
| 14095 | '@lexical/utils': 0.41.0 | ||
| 14096 | lexical: 0.41.0 | ||
| 14097 | |||
| 14098 | '@lexical/html@0.41.0': | ||
| 14099 | dependencies: | ||
| 14100 | '@lexical/selection': 0.41.0 | ||
| 14101 | '@lexical/utils': 0.41.0 | ||
| 14102 | lexical: 0.41.0 | ||
| 14103 | |||
| 14104 | '@lexical/link@0.41.0': | ||
| 14105 | dependencies: | ||
| 14106 | '@lexical/extension': 0.41.0 | ||
| 14107 | '@lexical/utils': 0.41.0 | ||
| 14108 | lexical: 0.41.0 | ||
| 14109 | |||
| 14110 | '@lexical/list@0.41.0': | ||
| 14111 | dependencies: | ||
| 14112 | '@lexical/extension': 0.41.0 | ||
| 14113 | '@lexical/selection': 0.41.0 | ||
| 14114 | '@lexical/utils': 0.41.0 | ||
| 14115 | lexical: 0.41.0 | ||
| 14116 | |||
| 14117 | '@lexical/mark@0.41.0': | ||
| 14118 | dependencies: | ||
| 14119 | '@lexical/utils': 0.41.0 | ||
| 14120 | lexical: 0.41.0 | ||
| 14121 | |||
| 14122 | '@lexical/markdown@0.41.0': | ||
| 14123 | dependencies: | ||
| 14124 | '@lexical/code': 0.41.0 | ||
| 14125 | '@lexical/link': 0.41.0 | ||
| 14126 | '@lexical/list': 0.41.0 | ||
| 14127 | '@lexical/rich-text': 0.41.0 | ||
| 14128 | '@lexical/text': 0.41.0 | ||
| 14129 | '@lexical/utils': 0.41.0 | ||
| 14130 | lexical: 0.41.0 | ||
| 14131 | |||
| 14132 | '@lexical/offset@0.41.0': | ||
| 14133 | dependencies: | ||
| 14134 | lexical: 0.41.0 | ||
| 14135 | |||
| 14136 | '@lexical/overflow@0.41.0': | ||
| 14137 | dependencies: | ||
| 14138 | lexical: 0.41.0 | ||
| 14139 | |||
| 14140 | '@lexical/plain-text@0.41.0': | ||
| 14141 | dependencies: | ||
| 14142 | '@lexical/clipboard': 0.41.0 | ||
| 14143 | '@lexical/dragon': 0.41.0 | ||
| 14144 | '@lexical/selection': 0.41.0 | ||
| 14145 | '@lexical/utils': 0.41.0 | ||
| 14146 | lexical: 0.41.0 | ||
| 14147 | |||
| 14148 | '@lexical/react@0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)': | ||
| 14149 | dependencies: | ||
| 14150 | '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | ||
| 14151 | '@lexical/devtools-core': 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | ||
| 14152 | '@lexical/dragon': 0.41.0 | ||
| 14153 | '@lexical/extension': 0.41.0 | ||
| 14154 | '@lexical/hashtag': 0.41.0 | ||
| 14155 | '@lexical/history': 0.41.0 | ||
| 14156 | '@lexical/link': 0.41.0 | ||
| 14157 | '@lexical/list': 0.41.0 | ||
| 14158 | '@lexical/mark': 0.41.0 | ||
| 14159 | '@lexical/markdown': 0.41.0 | ||
| 14160 | '@lexical/overflow': 0.41.0 | ||
| 14161 | '@lexical/plain-text': 0.41.0 | ||
| 14162 | '@lexical/rich-text': 0.41.0 | ||
| 14163 | '@lexical/table': 0.41.0 | ||
| 14164 | '@lexical/text': 0.41.0 | ||
| 14165 | '@lexical/utils': 0.41.0 | ||
| 14166 | '@lexical/yjs': 0.41.0(yjs@13.6.31) | ||
| 14167 | lexical: 0.41.0 | ||
| 14168 | react: 19.2.6 | ||
| 14169 | react-dom: 19.2.6(react@19.2.6) | ||
| 14170 | react-error-boundary: 6.1.2(react@19.2.6) | ||
| 14171 | transitivePeerDependencies: | ||
| 14172 | - yjs | ||
| 14173 | |||
| 14174 | '@lexical/rich-text@0.41.0': | ||
| 14175 | dependencies: | ||
| 14176 | '@lexical/clipboard': 0.41.0 | ||
| 14177 | '@lexical/dragon': 0.41.0 | ||
| 14178 | '@lexical/selection': 0.41.0 | ||
| 14179 | '@lexical/utils': 0.41.0 | ||
| 14180 | lexical: 0.41.0 | ||
| 14181 | |||
| 14182 | '@lexical/selection@0.41.0': | ||
| 14183 | dependencies: | ||
| 14184 | lexical: 0.41.0 | ||
| 14185 | |||
| 14186 | '@lexical/table@0.41.0': | ||
| 14187 | dependencies: | ||
| 14188 | '@lexical/clipboard': 0.41.0 | ||
| 14189 | '@lexical/extension': 0.41.0 | ||
| 14190 | '@lexical/utils': 0.41.0 | ||
| 14191 | lexical: 0.41.0 | ||
| 14192 | |||
| 14193 | '@lexical/text@0.41.0': | ||
| 14194 | dependencies: | ||
| 14195 | lexical: 0.41.0 | ||
| 14196 | |||
| 14197 | '@lexical/utils@0.41.0': | ||
| 14198 | dependencies: | ||
| 14199 | '@lexical/selection': 0.41.0 | ||
| 14200 | lexical: 0.41.0 | ||
| 14201 | |||
| 14202 | '@lexical/yjs@0.41.0(yjs@13.6.31)': | ||
| 14203 | dependencies: | ||
| 14204 | '@lexical/offset': 0.41.0 | ||
| 14205 | '@lexical/selection': 0.41.0 | ||
| 14206 | lexical: 0.41.0 | ||
| 14207 | yjs: 13.6.31 | ||
| 14208 | |||
| 14209 | '@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': | 14166 | '@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': |
| 14210 | dependencies: | 14167 | dependencies: |
| 14211 | '@libsql/core': 0.17.3 | 14168 | '@libsql/core': 0.17.3 |
| ... | @@ -14877,8 +14834,6 @@ snapshots: | ... | @@ -14877,8 +14834,6 @@ snapshots: |
| 14877 | 14834 | ||
| 14878 | '@polka/url@1.0.0-next.29': {} | 14835 | '@polka/url@1.0.0-next.29': {} |
| 14879 | 14836 | ||
| 14880 | '@preact/signals-core@1.14.2': {} | ||
| 14881 | |||
| 14882 | '@prisma/debug@7.2.0': {} | 14837 | '@prisma/debug@7.2.0': {} |
| 14883 | 14838 | ||
| 14884 | '@prisma/dev@0.20.0(typescript@6.0.3)': | 14839 | '@prisma/dev@0.20.0(typescript@6.0.3)': |
| ... | @@ -15848,6 +15803,187 @@ snapshots: | ... | @@ -15848,6 +15803,187 @@ snapshots: |
| 15848 | dependencies: | 15803 | dependencies: |
| 15849 | '@testing-library/dom': 10.4.1 | 15804 | '@testing-library/dom': 10.4.1 |
| 15850 | 15805 | ||
| 15806 | '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': | ||
| 15807 | dependencies: | ||
| 15808 | '@tiptap/pm': 3.31.3 | ||
| 15809 | |||
| 15810 | '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': | ||
| 15811 | dependencies: | ||
| 15812 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15813 | '@tiptap/pm': 3.31.3 | ||
| 15814 | |||
| 15815 | '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': | ||
| 15816 | dependencies: | ||
| 15817 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15818 | |||
| 15819 | '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': | ||
| 15820 | dependencies: | ||
| 15821 | '@floating-ui/dom': 1.7.6 | ||
| 15822 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15823 | '@tiptap/pm': 3.31.3 | ||
| 15824 | optional: true | ||
| 15825 | |||
| 15826 | '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': | ||
| 15827 | dependencies: | ||
| 15828 | '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15829 | |||
| 15830 | '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': | ||
| 15831 | dependencies: | ||
| 15832 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15833 | '@tiptap/pm': 3.31.3 | ||
| 15834 | |||
| 15835 | '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': | ||
| 15836 | dependencies: | ||
| 15837 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15838 | |||
| 15839 | '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': | ||
| 15840 | dependencies: | ||
| 15841 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15842 | |||
| 15843 | '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': | ||
| 15844 | dependencies: | ||
| 15845 | '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15846 | |||
| 15847 | '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': | ||
| 15848 | dependencies: | ||
| 15849 | '@floating-ui/dom': 1.7.6 | ||
| 15850 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15851 | '@tiptap/pm': 3.31.3 | ||
| 15852 | optional: true | ||
| 15853 | |||
| 15854 | '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': | ||
| 15855 | dependencies: | ||
| 15856 | '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15857 | |||
| 15858 | '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': | ||
| 15859 | dependencies: | ||
| 15860 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15861 | |||
| 15862 | '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': | ||
| 15863 | dependencies: | ||
| 15864 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15865 | |||
| 15866 | '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': | ||
| 15867 | dependencies: | ||
| 15868 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15869 | '@tiptap/pm': 3.31.3 | ||
| 15870 | |||
| 15871 | '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': | ||
| 15872 | dependencies: | ||
| 15873 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15874 | |||
| 15875 | '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': | ||
| 15876 | dependencies: | ||
| 15877 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15878 | '@tiptap/pm': 3.31.3 | ||
| 15879 | linkifyjs: 4.3.3 | ||
| 15880 | |||
| 15881 | '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': | ||
| 15882 | dependencies: | ||
| 15883 | '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15884 | |||
| 15885 | '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': | ||
| 15886 | dependencies: | ||
| 15887 | '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15888 | |||
| 15889 | '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': | ||
| 15890 | dependencies: | ||
| 15891 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15892 | '@tiptap/pm': 3.31.3 | ||
| 15893 | |||
| 15894 | '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': | ||
| 15895 | dependencies: | ||
| 15896 | '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15897 | |||
| 15898 | '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': | ||
| 15899 | dependencies: | ||
| 15900 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15901 | |||
| 15902 | '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': | ||
| 15903 | dependencies: | ||
| 15904 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15905 | |||
| 15906 | '@tiptap/extension-task-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': | ||
| 15907 | dependencies: | ||
| 15908 | '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15909 | |||
| 15910 | '@tiptap/extension-task-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': | ||
| 15911 | dependencies: | ||
| 15912 | '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15913 | |||
| 15914 | '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': | ||
| 15915 | dependencies: | ||
| 15916 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15917 | |||
| 15918 | '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': | ||
| 15919 | dependencies: | ||
| 15920 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15921 | |||
| 15922 | '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': | ||
| 15923 | dependencies: | ||
| 15924 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15925 | '@tiptap/pm': 3.31.3 | ||
| 15926 | |||
| 15927 | '@tiptap/pm@3.31.3': | ||
| 15928 | dependencies: | ||
| 15929 | prosemirror-changeset: 2.4.2 | ||
| 15930 | prosemirror-commands: 1.7.2 | ||
| 15931 | prosemirror-dropcursor: 1.8.3 | ||
| 15932 | prosemirror-gapcursor: 1.4.1 | ||
| 15933 | prosemirror-history: 1.5.0 | ||
| 15934 | prosemirror-inputrules: 1.5.1 | ||
| 15935 | prosemirror-keymap: 1.2.3 | ||
| 15936 | prosemirror-model: 1.25.11 | ||
| 15937 | prosemirror-schema-list: 1.5.1 | ||
| 15938 | prosemirror-state: 1.4.4 | ||
| 15939 | prosemirror-tables: 1.8.5 | ||
| 15940 | prosemirror-transform: 1.12.1 | ||
| 15941 | prosemirror-view: 1.42.3 | ||
| 15942 | |||
| 15943 | '@tiptap/react@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | ||
| 15944 | dependencies: | ||
| 15945 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15946 | '@tiptap/pm': 3.31.3 | ||
| 15947 | '@types/react': 19.2.16 | ||
| 15948 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | ||
| 15949 | '@types/use-sync-external-store': 0.0.6 | ||
| 15950 | fast-equals: 5.4.2 | ||
| 15951 | react: 19.2.6 | ||
| 15952 | react-dom: 19.2.6(react@19.2.6) | ||
| 15953 | use-sync-external-store: 1.6.0(react@19.2.6) | ||
| 15954 | optionalDependencies: | ||
| 15955 | '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15956 | '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15957 | transitivePeerDependencies: | ||
| 15958 | - '@floating-ui/dom' | ||
| 15959 | |||
| 15960 | '@tiptap/starter-kit@3.31.3': | ||
| 15961 | dependencies: | ||
| 15962 | '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) | ||
| 15963 | '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15964 | '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) | ||
| 15965 | '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) | ||
| 15966 | '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) | ||
| 15967 | '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15968 | '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) | ||
| 15969 | '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) | ||
| 15970 | '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) | ||
| 15971 | '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) | ||
| 15972 | '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) | ||
| 15973 | '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15974 | '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) | ||
| 15975 | '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15976 | '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15977 | '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) | ||
| 15978 | '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) | ||
| 15979 | '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) | ||
| 15980 | '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) | ||
| 15981 | '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) | ||
| 15982 | '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) | ||
| 15983 | '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) | ||
| 15984 | '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | ||
| 15985 | '@tiptap/pm': 3.31.3 | ||
| 15986 | |||
| 15851 | '@tybys/wasm-util@0.10.3': | 15987 | '@tybys/wasm-util@0.10.3': |
| 15852 | dependencies: | 15988 | dependencies: |
| 15853 | tslib: 2.8.1 | 15989 | tslib: 2.8.1 |
| ... | @@ -16017,6 +16153,8 @@ snapshots: | ... | @@ -16017,6 +16153,8 @@ snapshots: |
| 16017 | 16153 | ||
| 16018 | '@types/unist@3.0.3': {} | 16154 | '@types/unist@3.0.3': {} |
| 16019 | 16155 | ||
| 16156 | '@types/use-sync-external-store@0.0.6': {} | ||
| 16157 | |||
| 16020 | '@types/webidl-conversions@7.0.3': | 16158 | '@types/webidl-conversions@7.0.3': |
| 16021 | optional: true | 16159 | optional: true |
| 16022 | 16160 | ||
| ... | @@ -16493,7 +16631,7 @@ snapshots: | ... | @@ -16493,7 +16631,7 @@ snapshots: |
| 16493 | json-schema-traverse: 1.0.0 | 16631 | json-schema-traverse: 1.0.0 |
| 16494 | require-from-string: 2.0.2 | 16632 | require-from-string: 2.0.2 |
| 16495 | 16633 | ||
| 16496 | alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f): | 16634 | alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e): |
| 16497 | dependencies: | 16635 | dependencies: |
| 16498 | '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2) | 16636 | '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2) |
| 16499 | '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) | 16637 | '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) |
| ... | @@ -18361,6 +18499,8 @@ snapshots: | ... | @@ -18361,6 +18499,8 @@ snapshots: |
| 18361 | 18499 | ||
| 18362 | fast-deep-equal@3.1.3: {} | 18500 | fast-deep-equal@3.1.3: {} |
| 18363 | 18501 | ||
| 18502 | fast-equals@5.4.2: {} | ||
| 18503 | |||
| 18364 | fast-glob@3.3.3: | 18504 | fast-glob@3.3.3: |
| 18365 | dependencies: | 18505 | dependencies: |
| 18366 | '@nodelib/fs.stat': 2.0.5 | 18506 | '@nodelib/fs.stat': 2.0.5 |
| ... | @@ -19072,8 +19212,6 @@ snapshots: | ... | @@ -19072,8 +19212,6 @@ snapshots: |
| 19072 | 19212 | ||
| 19073 | isexe@4.0.0: {} | 19213 | isexe@4.0.0: {} |
| 19074 | 19214 | ||
| 19075 | isomorphic.js@0.2.5: {} | ||
| 19076 | |||
| 19077 | jake@10.9.4: | 19215 | jake@10.9.4: |
| 19078 | dependencies: | 19216 | dependencies: |
| 19079 | async: 3.2.6 | 19217 | async: 3.2.6 |
| ... | @@ -19209,12 +19347,6 @@ snapshots: | ... | @@ -19209,12 +19347,6 @@ snapshots: |
| 19209 | 19347 | ||
| 19210 | leven@3.1.0: {} | 19348 | leven@3.1.0: {} |
| 19211 | 19349 | ||
| 19212 | lexical@0.41.0: {} | ||
| 19213 | |||
| 19214 | lib0@0.2.117: | ||
| 19215 | dependencies: | ||
| 19216 | isomorphic.js: 0.2.5 | ||
| 19217 | |||
| 19218 | libsodium-wrappers@0.8.4: | 19350 | libsodium-wrappers@0.8.4: |
| 19219 | dependencies: | 19351 | dependencies: |
| 19220 | libsodium: 0.8.4 | 19352 | libsodium: 0.8.4 |
| ... | @@ -19298,6 +19430,8 @@ snapshots: | ... | @@ -19298,6 +19430,8 @@ snapshots: |
| 19298 | 19430 | ||
| 19299 | lilconfig@2.1.0: {} | 19431 | lilconfig@2.1.0: {} |
| 19300 | 19432 | ||
| 19433 | linkifyjs@4.3.3: {} | ||
| 19434 | |||
| 19301 | locate-path@3.0.0: | 19435 | locate-path@3.0.0: |
| 19302 | dependencies: | 19436 | dependencies: |
| 19303 | p-locate: 3.0.0 | 19437 | p-locate: 3.0.0 |
| ... | @@ -20474,6 +20608,8 @@ snapshots: | ... | @@ -20474,6 +20608,8 @@ snapshots: |
| 20474 | stdin-discarder: 0.3.2 | 20608 | stdin-discarder: 0.3.2 |
| 20475 | string-width: 8.2.1 | 20609 | string-width: 8.2.1 |
| 20476 | 20610 | ||
| 20611 | orderedmap@2.1.1: {} | ||
| 20612 | |||
| 20477 | outvariant@1.4.3: | 20613 | outvariant@1.4.3: |
| 20478 | optional: true | 20614 | optional: true |
| 20479 | 20615 | ||
| ... | @@ -20866,6 +21002,80 @@ snapshots: | ... | @@ -20866,6 +21002,80 @@ snapshots: |
| 20866 | 21002 | ||
| 20867 | property-information@7.2.0: {} | 21003 | property-information@7.2.0: {} |
| 20868 | 21004 | ||
| 21005 | prosemirror-changeset@2.4.2: | ||
| 21006 | dependencies: | ||
| 21007 | prosemirror-transform: 1.12.1 | ||
| 21008 | |||
| 21009 | prosemirror-commands@1.7.2: | ||
| 21010 | dependencies: | ||
| 21011 | prosemirror-model: 1.25.11 | ||
| 21012 | prosemirror-state: 1.4.4 | ||
| 21013 | prosemirror-transform: 1.12.1 | ||
| 21014 | |||
| 21015 | prosemirror-dropcursor@1.8.3: | ||
| 21016 | dependencies: | ||
| 21017 | prosemirror-state: 1.4.4 | ||
| 21018 | prosemirror-transform: 1.12.1 | ||
| 21019 | prosemirror-view: 1.42.3 | ||
| 21020 | |||
| 21021 | prosemirror-gapcursor@1.4.1: | ||
| 21022 | dependencies: | ||
| 21023 | prosemirror-keymap: 1.2.3 | ||
| 21024 | prosemirror-model: 1.25.11 | ||
| 21025 | prosemirror-state: 1.4.4 | ||
| 21026 | prosemirror-view: 1.42.3 | ||
| 21027 | |||
| 21028 | prosemirror-history@1.5.0: | ||
| 21029 | dependencies: | ||
| 21030 | prosemirror-state: 1.4.4 | ||
| 21031 | prosemirror-transform: 1.12.1 | ||
| 21032 | prosemirror-view: 1.42.3 | ||
| 21033 | rope-sequence: 1.3.4 | ||
| 21034 | |||
| 21035 | prosemirror-inputrules@1.5.1: | ||
| 21036 | dependencies: | ||
| 21037 | prosemirror-state: 1.4.4 | ||
| 21038 | prosemirror-transform: 1.12.1 | ||
| 21039 | |||
| 21040 | prosemirror-keymap@1.2.3: | ||
| 21041 | dependencies: | ||
| 21042 | prosemirror-state: 1.4.4 | ||
| 21043 | w3c-keyname: 2.2.8 | ||
| 21044 | |||
| 21045 | prosemirror-model@1.25.11: | ||
| 21046 | dependencies: | ||
| 21047 | orderedmap: 2.1.1 | ||
| 21048 | |||
| 21049 | prosemirror-schema-list@1.5.1: | ||
| 21050 | dependencies: | ||
| 21051 | prosemirror-model: 1.25.11 | ||
| 21052 | prosemirror-state: 1.4.4 | ||
| 21053 | prosemirror-transform: 1.12.1 | ||
| 21054 | |||
| 21055 | prosemirror-state@1.4.4: | ||
| 21056 | dependencies: | ||
| 21057 | prosemirror-model: 1.25.11 | ||
| 21058 | prosemirror-transform: 1.12.1 | ||
| 21059 | prosemirror-view: 1.42.3 | ||
| 21060 | |||
| 21061 | prosemirror-tables@1.8.5: | ||
| 21062 | dependencies: | ||
| 21063 | prosemirror-keymap: 1.2.3 | ||
| 21064 | prosemirror-model: 1.25.11 | ||
| 21065 | prosemirror-state: 1.4.4 | ||
| 21066 | prosemirror-transform: 1.12.1 | ||
| 21067 | prosemirror-view: 1.42.3 | ||
| 21068 | |||
| 21069 | prosemirror-transform@1.12.1: | ||
| 21070 | dependencies: | ||
| 21071 | prosemirror-model: 1.25.11 | ||
| 21072 | |||
| 21073 | prosemirror-view@1.42.3: | ||
| 21074 | dependencies: | ||
| 21075 | prosemirror-model: 1.25.11 | ||
| 21076 | prosemirror-state: 1.4.4 | ||
| 21077 | prosemirror-transform: 1.12.1 | ||
| 21078 | |||
| 20869 | proxy-addr@2.0.7: | 21079 | proxy-addr@2.0.7: |
| 20870 | dependencies: | 21080 | dependencies: |
| 20871 | forwarded: 0.2.0 | 21081 | forwarded: 0.2.0 |
| ... | @@ -20944,10 +21154,6 @@ snapshots: | ... | @@ -20944,10 +21154,6 @@ snapshots: |
| 20944 | react: 19.2.6 | 21154 | react: 19.2.6 |
| 20945 | scheduler: 0.27.0 | 21155 | scheduler: 0.27.0 |
| 20946 | 21156 | ||
| 20947 | react-error-boundary@6.1.2(react@19.2.6): | ||
| 20948 | dependencies: | ||
| 20949 | react: 19.2.6 | ||
| 20950 | |||
| 20951 | react-freeze@1.0.4(react@19.2.3): | 21157 | react-freeze@1.0.4(react@19.2.3): |
| 20952 | dependencies: | 21158 | dependencies: |
| 20953 | react: 19.2.3 | 21159 | react: 19.2.3 |
| ... | @@ -21586,6 +21792,8 @@ snapshots: | ... | @@ -21586,6 +21792,8 @@ snapshots: |
| 21586 | fsevents: 2.3.3 | 21792 | fsevents: 2.3.3 |
| 21587 | optional: true | 21793 | optional: true |
| 21588 | 21794 | ||
| 21795 | rope-sequence@1.3.4: {} | ||
| 21796 | |||
| 21589 | router@2.2.0: | 21797 | router@2.2.0: |
| 21590 | dependencies: | 21798 | dependencies: |
| 21591 | debug: 4.4.3 | 21799 | debug: 4.4.3 |
| ... | @@ -22090,8 +22298,6 @@ snapshots: | ... | @@ -22090,8 +22298,6 @@ snapshots: |
| 22090 | transitivePeerDependencies: | 22298 | transitivePeerDependencies: |
| 22091 | - encoding | 22299 | - encoding |
| 22092 | 22300 | ||
| 22093 | tabbable@6.4.0: {} | ||
| 22094 | |||
| 22095 | tagged-tag@1.0.0: {} | 22301 | tagged-tag@1.0.0: {} |
| 22096 | 22302 | ||
| 22097 | tailwind-merge@3.6.0: {} | 22303 | tailwind-merge@3.6.0: {} |
| ... | @@ -22693,6 +22899,8 @@ snapshots: | ... | @@ -22693,6 +22899,8 @@ snapshots: |
| 22693 | 22899 | ||
| 22694 | vscode-uri@3.1.0: {} | 22900 | vscode-uri@3.1.0: {} |
| 22695 | 22901 | ||
| 22902 | w3c-keyname@2.2.8: {} | ||
| 22903 | |||
| 22696 | walk-up-path@4.0.0: {} | 22904 | walk-up-path@4.0.0: {} |
| 22697 | 22905 | ||
| 22698 | walker@1.0.8: | 22906 | walker@1.0.8: |
| ... | @@ -22880,10 +23088,6 @@ snapshots: | ... | @@ -22880,10 +23088,6 @@ snapshots: |
| 22880 | dependencies: | 23088 | dependencies: |
| 22881 | pend: 1.2.0 | 23089 | pend: 1.2.0 |
| 22882 | 23090 | ||
| 22883 | yjs@13.6.31: | ||
| 22884 | dependencies: | ||
| 22885 | lib0: 0.2.117 | ||
| 22886 | |||
| 22887 | yocto-queue@0.1.0: {} | 23091 | yocto-queue@0.1.0: {} |
| 22888 | 23092 | ||
| 22889 | yocto-queue@1.2.2: {} | 23093 | yocto-queue@1.2.2: {} |