| author | |
| committer | |
| log | 300f7f9d45ff19c01bc987dbf1c3bc3e9a3a59ee |
| tree | e546c936e48119f6890538662d5698d1293699bb |
| parent | 95ccbf406626a8a80fa545777a6388bdf5153086 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>21 files changed, 3798 insertions(+), 175 deletions(-)
apps/web/package.json+5| ... | ... | @@ -32,7 +32,12 @@ |
| 32 | 32 | "@tanstack/react-pacer": "^0.19.4", |
| 33 | 33 | "@tanstack/react-router": "^1.160.2", |
| 34 | 34 | "@tiptap/core": "^3.31.3", |
| 35 | "@tiptap/extension-blockquote": "^3.31.3", | |
| 35 | 36 | "@tiptap/extension-code": "^3.31.3", |
| 37 | "@tiptap/extension-code-block": "^3.31.3", | |
| 38 | "@tiptap/extension-heading": "^3.31.3", | |
| 39 | "@tiptap/extension-horizontal-rule": "^3.31.3", | |
| 40 | "@tiptap/extension-list": "^3.31.3", | |
| 36 | 41 | "@tiptap/extension-task-item": "^3.31.3", |
| 37 | 42 | "@tiptap/extension-task-list": "^3.31.3", |
| 38 | 43 | "@tiptap/pm": "^3.31.3", |
apps/web/src/components/ChatMarkdown.tsx+66-15| ... | ... | @@ -989,6 +989,63 @@ function MarkdownDetails({ |
| 989 | 989 | ); |
| 990 | 990 | } |
| 991 | 991 | |
| 992 | /** | |
| 993 | * The code block frame, shared with the composer so an editable fence and a | |
| 994 | * rendered one cannot drift apart: the wrapper, its header row with the | |
| 995 | * language title, an optional actions slot, and the body as children. The | |
| 996 | * composer passes Tiptap's node view wrapper as the outer element. | |
| 997 | */ | |
| 998 | export function MarkdownCodeBlockFrame({ | |
| 999 | as: Wrapper = "div", | |
| 1000 | language, | |
| 1001 | fenceTitle, | |
| 1002 | theme, | |
| 1003 | wrapped = true, | |
| 1004 | title, | |
| 1005 | actions, | |
| 1006 | headerProps, | |
| 1007 | children, | |
| 1008 | }: { | |
| 1009 | as?: React.ElementType; | |
| 1010 | language: string; | |
| 1011 | fenceTitle: string | null; | |
| 1012 | theme: "light" | "dark"; | |
| 1013 | wrapped?: boolean; | |
| 1014 | /** Replaces the language title, as the composer does with its language picker. */ | |
| 1015 | title?: React.ReactNode; | |
| 1016 | actions?: React.ReactNode; | |
| 1017 | headerProps?: React.HTMLAttributes<HTMLDivElement>; | |
| 1018 | children: React.ReactNode; | |
| 1019 | }) { | |
| 1020 | return ( | |
| 1021 | <Wrapper | |
| 1022 | className="chat-markdown-codeblock my-[0.65rem] overflow-hidden rounded-lg border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32" | |
| 1023 | data-language={language} | |
| 1024 | data-wrap={wrapped ? "true" : "false"} | |
| 1025 | > | |
| 1026 | <div | |
| 1027 | {...headerProps} | |
| 1028 | className={cn( | |
| 1029 | "chat-markdown-codeblock-header flex items-center justify-between gap-2 pt-1.5 pr-1.5 pb-0 pl-3 select-none", | |
| 1030 | headerProps?.className, | |
| 1031 | )} | |
| 1032 | > | |
| 1033 | {title ?? ( | |
| 1034 | <span className="inline-flex min-w-0 items-center gap-1.5 font-mono text-2xs"> | |
| 1035 | <MarkdownCodeBlockTitleContent | |
| 1036 | fenceTitle={fenceTitle} | |
| 1037 | language={language} | |
| 1038 | theme={theme} | |
| 1039 | /> | |
| 1040 | </span> | |
| 1041 | )} | |
| 1042 | {actions} | |
| 1043 | </div> | |
| 1044 | {children} | |
| 1045 | </Wrapper> | |
| 1046 | ); | |
| 1047 | } | |
| 1048 | ||
| 992 | 1049 | /** |
| 993 | 1050 | * Filename titles render icon + text; language-only titles render just the |
| 994 | 1051 | * icon (redundant next to its own name) and fall back to the language text |
| ... | ... | @@ -1141,19 +1198,12 @@ function MarkdownCodeBlock({ |
| 1141 | 1198 | } |
| 1142 | 1199 | |
| 1143 | 1200 | return ( |
| 1144 | <div | |
| 1145 | className="chat-markdown-codeblock my-[0.65rem] overflow-hidden rounded-lg border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32" | |
| 1146 | data-language={language} | |
| 1147 | data-wrap={wrapped ? "true" : "false"} | |
| 1148 | > | |
| 1149 | <div className="chat-markdown-codeblock-header flex items-center justify-between gap-2 pt-1.5 pr-1.5 pb-0 pl-3 select-none"> | |
| 1150 | <span className="inline-flex min-w-0 items-center gap-1.5 font-mono text-2xs"> | |
| 1151 | <MarkdownCodeBlockTitleContent | |
| 1152 | fenceTitle={fenceTitle} | |
| 1153 | language={language} | |
| 1154 | theme={theme} | |
| 1155 | /> | |
| 1156 | </span> | |
| 1201 | <MarkdownCodeBlockFrame | |
| 1202 | language={language} | |
| 1203 | fenceTitle={fenceTitle} | |
| 1204 | theme={theme} | |
| 1205 | wrapped={wrapped} | |
| 1206 | actions={ | |
| 1157 | 1207 | <span className="flex items-center gap-0.5" role="toolbar" aria-label="Code block actions"> |
| 1158 | 1208 | {leadingActions} |
| 1159 | 1209 | {canWrap ? ( |
| ... | ... | @@ -1195,9 +1245,10 @@ function MarkdownCodeBlock({ |
| 1195 | 1245 | ) : null} |
| 1196 | 1246 | {copyButton} |
| 1197 | 1247 | </span> |
| 1198 | </div> | |
| 1248 | } | |
| 1249 | > | |
| 1199 | 1250 | {children} |
| 1200 | </div> | |
| 1251 | </MarkdownCodeBlockFrame> | |
| 1201 | 1252 | ); |
| 1202 | 1253 | } |
| 1203 | 1254 |
apps/web/src/components/ComposerPromptEditorTiptap.tsx+374-48| ... | ... | @@ -1,11 +1,17 @@ |
| 1 | import { Extension, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; | |
| 2 | import { TaskList } from "@tiptap/extension-task-list"; | |
| 1 | import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; | |
| 3 | 2 | import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; |
| 4 | 3 | import StarterKit from "@tiptap/starter-kit"; |
| 5 | 4 | import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; |
| 6 | import { splitBlockKeepMarks } from "@tiptap/pm/commands"; | |
| 7 | import { type EditorState, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; | |
| 8 | import { Decoration, DecorationSet } from "@tiptap/pm/view"; | |
| 5 | import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; | |
| 6 | import { | |
| 7 | type EditorState, | |
| 8 | NodeSelection, | |
| 9 | Plugin, | |
| 10 | PluginKey, | |
| 11 | TextSelection, | |
| 12 | } from "@tiptap/pm/state"; | |
| 13 | import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; | |
| 14 | import type { NodeType, ResolvedPos } from "@tiptap/pm/model"; | |
| 9 | 15 | import type { |
| 10 | 16 | AssistantCitation, |
| 11 | 17 | ComposerContextClipboardFragment, |
| ... | ... | @@ -45,19 +51,34 @@ import { |
| 45 | 51 | } from "~/composer-editor-mentions"; |
| 46 | 52 | import { |
| 47 | 53 | buildDocJson, |
| 54 | ComposerBlockExtensions, | |
| 55 | ComposerCodeBlockExtension, | |
| 56 | ComposerListExtensions, | |
| 48 | 57 | buildTiptapContent, |
| 49 | 58 | collapsedToFlat, |
| 50 | 59 | caretTakesMarksBefore, |
| 60 | convertBulletItemToTask, | |
| 51 | 61 | ComposerCodeExtension, |
| 52 | 62 | ComposerTaskItemExtension, |
| 63 | ComposerTaskListExtension, | |
| 53 | 64 | flatToCollapsed, |
| 54 | 65 | flatToMarkdown, |
| 55 | 66 | flatToPm, |
| 56 | 67 | pmToFlat, |
| 57 | 68 | serializeEditorDoc, |
| 69 | serializeSelection, | |
| 70 | splitOrLiftListItem, | |
| 58 | 71 | stepCaretAcrossStyledEdge, |
| 59 | 72 | type SkillMeta, |
| 60 | 73 | } from "~/composer-rich-text-doc"; |
| 74 | import { | |
| 75 | convertCodeFenceOnEnter, | |
| 76 | exitCodeBlockOnClosingFence, | |
| 77 | exitCodeBlockOnTrailingBlankLines, | |
| 78 | indentCodeBlock, | |
| 79 | indentedNewlineInCodeBlock, | |
| 80 | selectionInOneCodeBlock, | |
| 81 | } from "~/composer-code-block"; | |
| 61 | 82 | import { |
| 62 | 83 | COMPOSER_UNDO_GROUP_DELAY, |
| 63 | 84 | type ComposerChangeKind, |
| ... | ... | @@ -65,6 +86,7 @@ import { |
| 65 | 86 | markAsClipboardEdit, |
| 66 | 87 | } from "~/composer-undo-grouping"; |
| 67 | 88 | import { collectInlineContextIds } from "~/lib/composerContextReferences"; |
| 89 | import { resolveDiffThemeName } from "~/lib/diffRendering"; | |
| 68 | 90 | import { cn, isMacPlatform } from "~/lib/utils"; |
| 69 | 91 | import { basenameOfPath } from "~/pierre-icons"; |
| 70 | 92 | import { FileTagChipContent } from "./chat/FileTagChip"; |
| ... | ... | @@ -82,6 +104,8 @@ import { |
| 82 | 104 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; |
| 83 | 105 | import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; |
| 84 | 106 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 107 | import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; | |
| 108 | import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; | |
| 85 | 109 | import { importPastedComposerText } from "./composerInlineTokenPaste"; |
| 86 | 110 | import { didComposerSelectionChangeVisibly } from "./composerSelection"; |
| 87 | 111 | import type { ComposerDraftContextRecords } from "./composerContextPresentation"; |
| ... | ... | @@ -491,6 +515,9 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { |
| 491 | 515 | let range: StyledRange | null = null; |
| 492 | 516 | let openLength = 0; |
| 493 | 517 | for (const run of map.runs) { |
| 518 | // A fence is drawn as a block, not revealed a character at a time, so its | |
| 519 | // delimiters never become marker widgets. | |
| 520 | if (run.nodeName === "codeBlock") continue; | |
| 494 | 521 | if (run.openLen > 0) { |
| 495 | 522 | range ??= { from: run.pmPos, to: run.pmPos, markers: [] }; |
| 496 | 523 | range.markers.push({ |
| ... | ... | @@ -518,6 +545,162 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { |
| 518 | 545 | return ranges; |
| 519 | 546 | } |
| 520 | 547 | |
| 548 | /** | |
| 549 | * A typed marker becomes a list item that remembers the marker it was typed | |
| 550 | * with, so the stored Markdown keeps `*` or `3)` rather than a canonical `-`. | |
| 551 | * | |
| 552 | * `- ` alone is not enough for a dash: it waits for the first character after | |
| 553 | * the space, which it carries into the new item. That is what lets the GFM | |
| 554 | * task gesture `- [ ] ` or `- [x] ` be typed whole and reach the task rule, | |
| 555 | * instead of being cut off by an instant bullet. A `- ` left on its own is | |
| 556 | * still a bullet the next time the draft is rebuilt. | |
| 557 | */ | |
| 558 | function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList"): InputRule { | |
| 559 | return new InputRule({ | |
| 560 | find, | |
| 561 | handler: ({ state, range, match, chain }) => { | |
| 562 | const marker = match.groups?.marker ?? "-"; | |
| 563 | const space = match.groups?.space ?? " "; | |
| 564 | const carried = match.groups?.carried ?? ""; | |
| 565 | // Top-level paragraphs only: inside an item or a quote the new list | |
| 566 | // would nest under a line the stored draft writes flat. | |
| 567 | const $from = state.doc.resolve(range.from); | |
| 568 | if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; | |
| 569 | const command = chain() | |
| 570 | .deleteRange(range) | |
| 571 | .wrapInList( | |
| 572 | listType, | |
| 573 | listType === "orderedList" ? { start: Number.parseInt(marker, 10) || 1 } : {}, | |
| 574 | ) | |
| 575 | .updateAttributes("listItem", { marker, space }); | |
| 576 | (carried ? command.insertContent(carried) : command).run(); | |
| 577 | return undefined; | |
| 578 | }, | |
| 579 | }); | |
| 580 | } | |
| 581 | ||
| 582 | /** | |
| 583 | * `[ ] ` at the start of an existing bullet item turns it into a task, for | |
| 584 | * items that were already a list when the checkbox was wanted. New tasks are | |
| 585 | * typed whole, `- [ ] `, and reach the task rule directly. | |
| 586 | */ | |
| 587 | const bulletToTaskInputRule = new InputRule({ | |
| 588 | find: /^\[([ xX])\] $/, | |
| 589 | handler: ({ state, range, match, chain }) => { | |
| 590 | const $from = state.doc.resolve(range.from); | |
| 591 | const item = $from.node(-1); | |
| 592 | if ($from.parent.type.name !== "paragraph" || item?.type.name !== "listItem") return null; | |
| 593 | // Any bullet converts; the task grammar only knows `-`, so a `*` or `+` | |
| 594 | // item comes back out as `- [ ]`. | |
| 595 | if (!["-", "*", "+"].includes((item.attrs as { marker?: string }).marker ?? "")) return null; | |
| 596 | const checked = (match[1] ?? " ").toLowerCase() === "x"; | |
| 597 | chain() | |
| 598 | .command(({ tr }) => { | |
| 599 | convertBulletItemToTask(tr, range.from, range.to, checked); | |
| 600 | return true; | |
| 601 | }) | |
| 602 | .run(); | |
| 603 | return undefined; | |
| 604 | }, | |
| 605 | }); | |
| 606 | ||
| 607 | /** `- [ ] ` or `- [x] ` at a top-level paragraph, for the same reason as the list markers. */ | |
| 608 | function taskInputRule(type: NodeType): InputRule { | |
| 609 | const rule = wrappingInputRule({ | |
| 610 | find: /^- \[([ xX])\] $/, | |
| 611 | type, | |
| 612 | getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), | |
| 613 | }); | |
| 614 | return new InputRule({ | |
| 615 | find: rule.find, | |
| 616 | handler: (props) => | |
| 617 | props.state.doc.resolve(props.range.from).depth === 1 ? rule.handler(props) : null, | |
| 618 | }); | |
| 619 | } | |
| 620 | ||
| 621 | function hasAncestor($pos: ResolvedPos, name: string): boolean { | |
| 622 | for (let depth = $pos.depth; depth > 0; depth -= 1) { | |
| 623 | if ($pos.node(depth).type.name === name) return true; | |
| 624 | } | |
| 625 | return false; | |
| 626 | } | |
| 627 | ||
| 628 | /** | |
| 629 | * `> ` at the start of a top-level paragraph opens a quote. Not inside a list: | |
| 630 | * a quote holds prose lines, and a list item is not one. | |
| 631 | */ | |
| 632 | const blockquoteInputRule = new InputRule({ | |
| 633 | find: /^>(\s)$/, | |
| 634 | handler: ({ state, range, match, chain }) => { | |
| 635 | const $from = state.doc.resolve(range.from); | |
| 636 | if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; | |
| 637 | chain() | |
| 638 | .deleteRange(range) | |
| 639 | .wrapIn("blockquote", { prefix: `>${match[1] ?? " "}` }) | |
| 640 | .run(); | |
| 641 | return undefined; | |
| 642 | }, | |
| 643 | }); | |
| 644 | ||
| 645 | /** | |
| 646 | * `---` becomes a rule as the third dash lands; `***` and `___` need a space | |
| 647 | * after them so typing bold or an underscore is not interrupted, matching | |
| 648 | * Tiptap's own rule. Only at a top-level paragraph: the list and quote | |
| 649 | * serializers have no line to write a rule into. The typed characters are | |
| 650 | * kept as the rule's source, and `setHorizontalRule` adds a paragraph after a | |
| 651 | * rule at the end so the caret has somewhere to go. | |
| 652 | */ | |
| 653 | const horizontalRuleInputRule = new InputRule({ | |
| 654 | find: /^(---|\*\*\*|___)\s?$/, | |
| 655 | handler: ({ state, range, match, chain }) => { | |
| 656 | const source = match[0] ?? "---"; | |
| 657 | if (!source.startsWith("---") && !/\s$/.test(source)) return null; | |
| 658 | const $from = state.doc.resolve(range.from); | |
| 659 | if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; | |
| 660 | chain() | |
| 661 | .deleteRange(range) | |
| 662 | .setHorizontalRule() | |
| 663 | .command(({ tr }) => { | |
| 664 | // The rule is the block before the caret's paragraph. | |
| 665 | const $pos = tr.selection.$from; | |
| 666 | const index = $pos.index(0) - 1; | |
| 667 | if (index < 0) return true; | |
| 668 | const rulePos = $pos.posAtIndex(index, 0); | |
| 669 | const rule = tr.doc.nodeAt(rulePos); | |
| 670 | if (rule?.type.name === "horizontalRule") { | |
| 671 | tr.setNodeMarkup(rulePos, undefined, { ...rule.attrs, source }); | |
| 672 | } | |
| 673 | return true; | |
| 674 | }) | |
| 675 | .run(); | |
| 676 | return undefined; | |
| 677 | }, | |
| 678 | }); | |
| 679 | ||
| 680 | /** | |
| 681 | * `# ` through `###### ` at a top-level paragraph make a heading. The space | |
| 682 | * is required, which is exactly what keeps `#1234` a pull request reference | |
| 683 | * with its picker rather than a heading. Not inside lists or quotes, whose | |
| 684 | * serializers have no line for one. | |
| 685 | */ | |
| 686 | const headingInputRule = new InputRule({ | |
| 687 | find: /^(#{1,6})(\s)$/, | |
| 688 | handler: ({ state, range, match, chain }) => { | |
| 689 | const $from = state.doc.resolve(range.from); | |
| 690 | if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; | |
| 691 | chain() | |
| 692 | .deleteRange(range) | |
| 693 | .setNode("heading", { level: match[1]?.length ?? 1, space: match[2] ?? " " }) | |
| 694 | .run(); | |
| 695 | return undefined; | |
| 696 | }, | |
| 697 | }); | |
| 698 | ||
| 699 | /** Whether the caret sits inside a fenced code block. */ | |
| 700 | function isInCodeBlock(view: EditorView): boolean { | |
| 701 | return view.state.selection.$from.parent.type.spec.code === true; | |
| 702 | } | |
| 703 | ||
| 521 | 704 | const MarkerPluginKey = new PluginKey("composer-rich-markers"); |
| 522 | 705 | |
| 523 | 706 | const ComposerMarkerPlugin = new Plugin({ |
| ... | ... | @@ -766,16 +949,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 766 | 949 | expandedCursor: nextExpandedCursor, |
| 767 | 950 | contextIds: map.contextIds, |
| 768 | 951 | }; |
| 769 | const cursorAdjacentToMention = | |
| 952 | // A fence holds no chips, so nothing in it should summon the mention or | |
| 953 | // command menu: `@` in code is a decorator, not a file. Suppressing the | |
| 954 | // trigger here also keeps the store from inserting a link the block can | |
| 955 | // only show as literal text, which the store would then count as a chip. | |
| 956 | const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; | |
| 957 | const suppressTrigger = | |
| 958 | inCodeBlock || | |
| 770 | 959 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || |
| 771 | 960 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); |
| 772 | onChangeRef.current( | |
| 773 | nextValue, | |
| 774 | nextCursor, | |
| 775 | nextExpandedCursor, | |
| 776 | cursorAdjacentToMention, | |
| 777 | map.contextIds, | |
| 778 | ); | |
| 961 | onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, map.contextIds); | |
| 779 | 962 | }, []); |
| 780 | 963 | |
| 781 | 964 | const editorAttributes = useMemo( |
| ... | ... | @@ -824,6 +1007,8 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 824 | 1007 | gapcursor: false, |
| 825 | 1008 | trailingNode: false, |
| 826 | 1009 | code: false, |
| 1010 | // The list extensions handle Backspace and Delete themselves. | |
| 1011 | listKeymap: false, | |
| 827 | 1012 | undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY }, |
| 828 | 1013 | // Plain mode has no marks: typed markers stay literal characters. |
| 829 | 1014 | ...(richText ? {} : { bold: false, italic: false, strike: false }), |
| ... | ... | @@ -837,16 +1022,78 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 837 | 1022 | ...(richText |
| 838 | 1023 | ? [ |
| 839 | 1024 | ComposerCodeExtension, |
| 840 | TaskList, | |
| 841 | ComposerTaskItemExtension.extend({ | |
| 1025 | ComposerCodeBlockExtension.extend({ | |
| 1026 | addNodeView() { | |
| 1027 | return ReactNodeViewRenderer(ComposerCodeBlockNodeView, { | |
| 1028 | // The header holds the language picker. Tiptap only shields | |
| 1029 | // events aimed at the button element itself, so a click on | |
| 1030 | // its icon or label would reach ProseMirror and move the | |
| 1031 | // selection; keep every header event away from the editor. | |
| 1032 | stopEvent: ({ event }) => | |
| 1033 | event.target instanceof Element && | |
| 1034 | event.target.closest(".chat-markdown-codeblock-header") !== null, | |
| 1035 | }); | |
| 1036 | }, | |
| 1037 | // Tiptap's own ``` + space rule would open a fence inside a | |
| 1038 | // list item or quote, where the serializer has no line for | |
| 1039 | // it. Enter on a fence line covers the gesture at top level. | |
| 842 | 1040 | addInputRules() { |
| 843 | return [ | |
| 844 | wrappingInputRule({ | |
| 845 | find: /^- \[([ xX])\] $/, | |
| 846 | type: this.type, | |
| 847 | getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), | |
| 1041 | return []; | |
| 1042 | }, | |
| 1043 | }), | |
| 1044 | composerCodeBlockHighlight({ | |
| 1045 | resolveTheme: () => | |
| 1046 | resolveDiffThemeName( | |
| 1047 | document.documentElement.classList.contains("dark") ? "dark" : "light", | |
| 1048 | ), | |
| 1049 | }), | |
| 1050 | ...ComposerBlockExtensions.map((extension) => | |
| 1051 | extension.name === "blockquote" | |
| 1052 | ? extension.extend({ | |
| 1053 | addInputRules() { | |
| 1054 | return [blockquoteInputRule]; | |
| 1055 | }, | |
| 1056 | }) | |
| 1057 | : extension.name === "horizontalRule" | |
| 1058 | ? extension.extend({ | |
| 1059 | addInputRules() { | |
| 1060 | return [horizontalRuleInputRule]; | |
| 1061 | }, | |
| 1062 | }) | |
| 1063 | : extension.name === "heading" | |
| 1064 | ? extension.extend({ | |
| 1065 | addInputRules() { | |
| 1066 | return [headingInputRule]; | |
| 1067 | }, | |
| 1068 | }) | |
| 1069 | : extension, | |
| 1070 | ), | |
| 1071 | ...ComposerListExtensions.map((extension) => | |
| 1072 | extension.name === "listItem" | |
| 1073 | ? extension | |
| 1074 | : extension.extend({ | |
| 1075 | addInputRules() { | |
| 1076 | return this.name === "bulletList" | |
| 1077 | ? [ | |
| 1078 | listMarkerInputRule(/^(?<marker>[*+])(?<space>\s)$/, "bulletList"), | |
| 1079 | listMarkerInputRule( | |
| 1080 | /^(?<marker>-)(?<space>[ \t]+)(?<carried>[^\s[])$/, | |
| 1081 | "bulletList", | |
| 1082 | ), | |
| 1083 | ] | |
| 1084 | : [ | |
| 1085 | listMarkerInputRule( | |
| 1086 | /^(?<marker>\d+[.)])(?<space>\s)$/, | |
| 1087 | "orderedList", | |
| 1088 | ), | |
| 1089 | ]; | |
| 1090 | }, | |
| 848 | 1091 | }), |
| 849 | ]; | |
| 1092 | ), | |
| 1093 | ComposerTaskListExtension, | |
| 1094 | ComposerTaskItemExtension.extend({ | |
| 1095 | addInputRules() { | |
| 1096 | return [taskInputRule(this.type), bulletToTaskInputRule]; | |
| 850 | 1097 | }, |
| 851 | 1098 | }), |
| 852 | 1099 | ] |
| ... | ... | @@ -982,23 +1229,71 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 982 | 1229 | event.preventDefault(); |
| 983 | 1230 | return true; |
| 984 | 1231 | } |
| 1232 | // Inside a fence Tab belongs to the code, not to the composer's | |
| 1233 | // focus order or its autocomplete. | |
| 1234 | if ( | |
| 1235 | event.key === "Tab" && | |
| 1236 | !event.metaKey && | |
| 1237 | !event.ctrlKey && | |
| 1238 | selectionInOneCodeBlock(view.state) | |
| 1239 | ) { | |
| 1240 | event.preventDefault(); | |
| 1241 | event.stopPropagation(); | |
| 1242 | return indentCodeBlock(view.state, event.shiftKey ? "out" : "in", (tr) => | |
| 1243 | view.dispatch(tr), | |
| 1244 | ); | |
| 1245 | } | |
| 1246 | // A fence is multi-line by definition, so Enter and Shift+Enter | |
| 1247 | // belong to the code rather than to sending or splitting: inside a | |
| 1248 | // block they make a line (a generic split would cut the fence in | |
| 1249 | // two), and on a line that is only an opening fence they open the | |
| 1250 | // block. Sending from inside a fence is still Cmd/Ctrl+Enter, which | |
| 1251 | // falls through. | |
| 1252 | if ( | |
| 1253 | event.key === "Enter" && | |
| 1254 | richText && | |
| 1255 | !event.metaKey && | |
| 1256 | !event.ctrlKey && | |
| 1257 | !event.isComposing | |
| 1258 | ) { | |
| 1259 | if (selectionInOneCodeBlock(view.state)) { | |
| 1260 | event.preventDefault(); | |
| 1261 | event.stopPropagation(); | |
| 1262 | const dispatch = (tr: typeof view.state.tr) => view.dispatch(tr.scrollIntoView()); | |
| 1263 | return ( | |
| 1264 | exitCodeBlockOnClosingFence(view.state, dispatch) || | |
| 1265 | exitCodeBlockOnTrailingBlankLines(view) || | |
| 1266 | indentedNewlineInCodeBlock(view.state, dispatch) || | |
| 1267 | newlineInCode(view.state, dispatch) | |
| 1268 | ); | |
| 1269 | } | |
| 1270 | if (convertCodeFenceOnEnter(view.state, (tr) => view.dispatch(tr))) { | |
| 1271 | event.preventDefault(); | |
| 1272 | event.stopPropagation(); | |
| 1273 | return true; | |
| 1274 | } | |
| 1275 | } | |
| 985 | 1276 | const handler = onCommandKeyDownRef.current; |
| 986 | 1277 | if (event.key === "Enter") { |
| 987 | 1278 | const instance = editorHolder.current; |
| 988 | 1279 | const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); |
| 989 | const handled = handler?.("Enter", event, isTaskItem) ?? false; | |
| 1280 | const isListItem = richText && (instance?.isActive("listItem") ?? false); | |
| 1281 | const handled = handler?.("Enter", event, isTaskItem || isListItem) ?? false; | |
| 990 | 1282 | if (handled) { |
| 991 | 1283 | event.preventDefault(); |
| 992 | 1284 | event.stopPropagation(); |
| 993 | 1285 | return true; |
| 994 | 1286 | } |
| 995 | 1287 | event.preventDefault(); |
| 1288 | if ((isTaskItem || isListItem) && instance && splitOrLiftListItem(instance)) { | |
| 1289 | return true; | |
| 1290 | } | |
| 996 | 1291 | if ( |
| 997 | isTaskItem && | |
| 1292 | richText && | |
| 998 | 1293 | instance && |
| 999 | (instance.commands.splitListItem("taskItem", { checked: false }) || | |
| 1000 | (view.state.selection.$from.parent.content.size === 0 && | |
| 1001 | instance.commands.liftListItem("taskItem"))) | |
| 1294 | hasAncestor(view.state.selection.$from, "blockquote") && | |
| 1295 | view.state.selection.$from.parent.content.size === 0 && | |
| 1296 | instance.commands.lift("blockquote") | |
| 1002 | 1297 | ) { |
| 1003 | 1298 | return true; |
| 1004 | 1299 | } |
| ... | ... | @@ -1050,6 +1345,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1050 | 1345 | const pastedText = clipboardData.getData("text/plain"); |
| 1051 | 1346 | if (!pastedText) return false; |
| 1052 | 1347 | event.preventDefault(); |
| 1348 | // A fence takes the clipboard verbatim. Running the markdown path | |
| 1349 | // here would split the block on newlines, nest a pasted fence and | |
| 1350 | // build chips the code block's schema cannot hold anyway. | |
| 1351 | if (isInCodeBlock(view)) { | |
| 1352 | const { from, to } = view.state.selection; | |
| 1353 | view.dispatch( | |
| 1354 | markAsClipboardEdit( | |
| 1355 | view.state.tr.insertText(pastedText, from, to), | |
| 1356 | "paste", | |
| 1357 | ).scrollIntoView(), | |
| 1358 | ); | |
| 1359 | return true; | |
| 1360 | } | |
| 1053 | 1361 | const importFragment = importFragmentRef.current; |
| 1054 | 1362 | let text = importFragment |
| 1055 | 1363 | ? importPastedComposerText(clipboardData, importFragment) |
| ... | ... | @@ -1073,18 +1381,44 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1073 | 1381 | } |
| 1074 | 1382 | const editorInstance = editorHolder.current; |
| 1075 | 1383 | if (editorInstance) { |
| 1076 | insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => { | |
| 1077 | // Tagged on the same transaction insertContent builds, so the | |
| 1078 | // paste is one undo step of its own. | |
| 1079 | editorInstance | |
| 1080 | .chain() | |
| 1081 | .command(({ tr }) => { | |
| 1082 | markAsClipboardEdit(tr, "paste"); | |
| 1083 | return true; | |
| 1084 | }) | |
| 1085 | .insertContent(content) | |
| 1086 | .run(); | |
| 1087 | }); | |
| 1384 | // Inside a list item or quote, pasted block markup has nowhere to | |
| 1385 | // go: it stays literal lines the next rebuild reads back. Inline | |
| 1386 | // marks still apply. | |
| 1387 | const $paste = view.state.selection.$from; | |
| 1388 | const nested = ["listItem", "taskItem", "blockquote"].some((name) => | |
| 1389 | hasAncestor($paste, name), | |
| 1390 | ); | |
| 1391 | insertMarkdownParagraphs( | |
| 1392 | text, | |
| 1393 | skillLabelFor, | |
| 1394 | { styling: richText, blocks: !nested }, | |
| 1395 | (content) => { | |
| 1396 | // Tagged on the same transaction insertContent builds, so the | |
| 1397 | // paste is one undo step of its own. | |
| 1398 | editorInstance | |
| 1399 | .chain() | |
| 1400 | .command(({ tr }) => { | |
| 1401 | markAsClipboardEdit(tr, "paste"); | |
| 1402 | return true; | |
| 1403 | }) | |
| 1404 | .insertContent(content) | |
| 1405 | .command(({ tr }) => { | |
| 1406 | // A paste ending in a rule leaves the rule selected, and | |
| 1407 | // the next keystroke would replace it. The caret goes to | |
| 1408 | // the line after it, which is added when there is none. | |
| 1409 | const { selection } = tr; | |
| 1410 | if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { | |
| 1411 | return true; | |
| 1412 | } | |
| 1413 | if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { | |
| 1414 | tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); | |
| 1415 | } | |
| 1416 | tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); | |
| 1417 | return true; | |
| 1418 | }) | |
| 1419 | .run(); | |
| 1420 | }, | |
| 1421 | ); | |
| 1088 | 1422 | scrollTiptapCaretIntoView(editorInstance); |
| 1089 | 1423 | } |
| 1090 | 1424 | return true; |
| ... | ... | @@ -1330,15 +1664,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1330 | 1664 | const clipboardData = event.clipboardData; |
| 1331 | 1665 | const { from, to } = editor.state.selection; |
| 1332 | 1666 | if (from === to) return; |
| 1333 | const { doc, schema } = editor.state; | |
| 1334 | const slice = doc.slice(from, to); | |
| 1335 | const first = slice.content.firstChild; | |
| 1336 | const content = first?.isInline | |
| 1337 | ? schema.nodes.paragraph!.create(null, slice.content) | |
| 1338 | : first?.type.name === "taskItem" | |
| 1339 | ? schema.nodes.taskList!.create(null, slice.content) | |
| 1340 | : slice.content; | |
| 1341 | const text = serializeEditorDoc(doc.type.create(null, content)).value; | |
| 1667 | const text = serializeSelection(editor.state.doc, from, to); | |
| 1342 | 1668 | const contextIds = Array.from(new Set(collectInlineContextIds(text))); |
| 1343 | 1669 | const fragment = contextIds.length > 0 ? build?.(contextIds) : null; |
| 1344 | 1670 | event.preventDefault(); |
| ... | ... | @@ -1442,7 +1768,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1442 | 1768 | function insertMarkdownParagraphs( |
| 1443 | 1769 | value: string, |
| 1444 | 1770 | skillLabelFor: (name: string) => SkillMeta, |
| 1445 | options: { styling: boolean }, | |
| 1771 | options: { styling: boolean; blocks?: boolean }, | |
| 1446 | 1772 | insertContent: (content: JSONContent[] | JSONContent) => void, |
| 1447 | 1773 | ): void { |
| 1448 | 1774 | const blocks = buildTiptapContent(value, skillLabelFor, options); |
apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | import { CheckIcon, ChevronDownIcon } from "lucide-react"; | |
| 2 | import { useMemo, useState } from "react"; | |
| 3 | ||
| 4 | import { | |
| 5 | CODE_BLOCK_LANGUAGES, | |
| 6 | type CodeBlockLanguage, | |
| 7 | codeLanguageEntry, | |
| 8 | codeLanguageLabel, | |
| 9 | } from "~/composer-code-languages"; | |
| 10 | import { syntheticFileNameForLanguageId } from "~/pierre-icons"; | |
| 11 | ||
| 12 | import { Button } from "../ui/button"; | |
| 13 | import { | |
| 14 | Combobox, | |
| 15 | ComboboxEmpty, | |
| 16 | ComboboxItem, | |
| 17 | ComboboxList, | |
| 18 | ComboboxPopup, | |
| 19 | ComboboxSearchInput, | |
| 20 | ComboboxTrigger, | |
| 21 | } from "../ui/combobox"; | |
| 22 | import { PierreEntryIcon } from "./PierreEntryIcon"; | |
| 23 | ||
| 24 | /** | |
| 25 | * The language's file icon: plain text gets the text-file icon, and a | |
| 26 | * language the icon set does not cover gets the default file icon, so every | |
| 27 | * entry has one and the labels line up. | |
| 28 | */ | |
| 29 | function LanguageIcon(props: { language: string; theme: "light" | "dark" }) { | |
| 30 | const fileName = props.language ? syntheticFileNameForLanguageId(props.language) : "file.txt"; | |
| 31 | return ( | |
| 32 | <PierreEntryIcon pathValue={fileName} kind="file" theme={props.theme} className="size-3.5" /> | |
| 33 | ); | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * The language control in a composer fence's header: shows what the fence | |
| 38 | * declares and switches it from a searchable list. A language typed on the | |
| 39 | * fence line that the list does not carry is offered as its own entry, so | |
| 40 | * the current choice is always visible and checked. | |
| 41 | */ | |
| 42 | export function ComposerCodeBlockLanguagePicker(props: { | |
| 43 | language: string; | |
| 44 | theme: "light" | "dark"; | |
| 45 | disabled: boolean; | |
| 46 | onChange: (language: string) => void; | |
| 47 | }) { | |
| 48 | const [query, setQuery] = useState(""); | |
| 49 | const current = codeLanguageEntry(props.language)?.id ?? props.language; | |
| 50 | const items = useMemo<CodeBlockLanguage[]>( | |
| 51 | () => | |
| 52 | CODE_BLOCK_LANGUAGES.some((entry) => entry.id === current) | |
| 53 | ? [...CODE_BLOCK_LANGUAGES] | |
| 54 | : [{ id: current, label: current }, ...CODE_BLOCK_LANGUAGES], | |
| 55 | [current], | |
| 56 | ); | |
| 57 | const filteredItems = useMemo(() => { | |
| 58 | const needle = query.trim().toLowerCase(); | |
| 59 | if (!needle) return items; | |
| 60 | return items.filter( | |
| 61 | (entry) => entry.label.toLowerCase().includes(needle) || entry.id.includes(needle), | |
| 62 | ); | |
| 63 | }, [items, query]); | |
| 64 | const selected = items.find((entry) => entry.id === current) ?? null; | |
| 65 | ||
| 66 | return ( | |
| 67 | // The trigger's pill pads its content by 8px (padding plus border) and the | |
| 68 | // button pulls a leading icon back 2px; offsetting the difference lines | |
| 69 | // the icon up with the code below while keeping the hover pill. | |
| 70 | <span className="-ml-1.5 flex min-w-0"> | |
| 71 | <Combobox | |
| 72 | items={items} | |
| 73 | filteredItems={filteredItems} | |
| 74 | autoHighlight | |
| 75 | itemToStringLabel={(entry) => entry.label} | |
| 76 | isItemEqualToValue={(a, b) => a.id === b.id} | |
| 77 | value={selected} | |
| 78 | onOpenChange={(open) => { | |
| 79 | if (!open) setQuery(""); | |
| 80 | }} | |
| 81 | onValueChange={(entry) => { | |
| 82 | if (entry && entry.id !== current) props.onChange(entry.id); | |
| 83 | }} | |
| 84 | > | |
| 85 | <ComboboxTrigger | |
| 86 | render={ | |
| 87 | <Button | |
| 88 | type="button" | |
| 89 | variant="ghost-muted" | |
| 90 | size="xs" | |
| 91 | disabled={props.disabled} | |
| 92 | aria-label={`Code language: ${codeLanguageLabel(props.language)}`} | |
| 93 | /> | |
| 94 | } | |
| 95 | > | |
| 96 | <LanguageIcon language={current} theme={props.theme} /> | |
| 97 | <span className="max-w-40 truncate">{codeLanguageLabel(props.language)}</span> | |
| 98 | <ChevronDownIcon className="opacity-60" /> | |
| 99 | </ComboboxTrigger> | |
| 100 | <ComboboxPopup align="start" className="w-56"> | |
| 101 | <ComboboxSearchInput | |
| 102 | aria-label="Search languages" | |
| 103 | placeholder="Search languages" | |
| 104 | value={query} | |
| 105 | onChange={(event) => setQuery(event.target.value)} | |
| 106 | /> | |
| 107 | <ComboboxEmpty>No matching languages.</ComboboxEmpty> | |
| 108 | <ComboboxList> | |
| 109 | {(entry: CodeBlockLanguage) => ( | |
| 110 | <ComboboxItem key={entry.id || "plain"} value={entry}> | |
| 111 | <LanguageIcon language={entry.id} theme={props.theme} /> | |
| 112 | <span className="min-w-0 flex-1 truncate">{entry.label}</span> | |
| 113 | {entry.id === current ? <CheckIcon className="ml-auto" /> : null} | |
| 114 | </ComboboxItem> | |
| 115 | )} | |
| 116 | </ComboboxList> | |
| 117 | </ComboboxPopup> | |
| 118 | </Combobox> | |
| 119 | </span> | |
| 120 | ); | |
| 121 | } |
apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; | |
| 2 | ||
| 3 | import { languageOfInfoString, withInfoStringLanguage } from "~/composer-code-languages"; | |
| 4 | ||
| 5 | import { useTheme } from "../../hooks/useTheme"; | |
| 6 | import { MarkdownCodeBlockFrame } from "../ChatMarkdown"; | |
| 7 | import { ComposerCodeBlockLanguagePicker } from "./ComposerCodeBlockLanguagePicker"; | |
| 8 | ||
| 9 | /** | |
| 10 | * Draws a composer fence in the chat view's own code block frame, so a draft | |
| 11 | * looks like the message it is about to become, with the language title | |
| 12 | * swapped for a picker that rewrites the fence's language in place. | |
| 13 | * | |
| 14 | * The chrome stops at the language. Chat's wrap and copy buttons act on text | |
| 15 | * the reader cannot change; here the text is the draft, and both are already | |
| 16 | * a selection away. | |
| 17 | */ | |
| 18 | export function ComposerCodeBlockNodeView({ | |
| 19 | node, | |
| 20 | editor, | |
| 21 | getPos, | |
| 22 | updateAttributes, | |
| 23 | }: NodeViewProps) { | |
| 24 | const { resolvedTheme } = useTheme(); | |
| 25 | const info = typeof node.attrs.language === "string" ? node.attrs.language : ""; | |
| 26 | const language = languageOfInfoString(info); | |
| 27 | ||
| 28 | const changeLanguage = (next: string) => { | |
| 29 | updateAttributes({ language: withInfoStringLanguage(info, next) }); | |
| 30 | // Back into the code, at its end, the way a picker hands focus back. | |
| 31 | const position = getPos(); | |
| 32 | if (typeof position === "number") { | |
| 33 | editor | |
| 34 | .chain() | |
| 35 | .focus(position + 1 + node.content.size) | |
| 36 | .run(); | |
| 37 | } | |
| 38 | }; | |
| 39 | ||
| 40 | return ( | |
| 41 | <MarkdownCodeBlockFrame | |
| 42 | as={NodeViewWrapper} | |
| 43 | language={language || "text"} | |
| 44 | fenceTitle={null} | |
| 45 | theme={resolvedTheme} | |
| 46 | headerProps={{ contentEditable: false }} | |
| 47 | title={ | |
| 48 | <ComposerCodeBlockLanguagePicker | |
| 49 | language={language} | |
| 50 | theme={resolvedTheme} | |
| 51 | disabled={!editor.isEditable} | |
| 52 | onChange={changeLanguage} | |
| 53 | /> | |
| 54 | } | |
| 55 | > | |
| 56 | <div className="chat-markdown-shiki"> | |
| 57 | <pre className="max-w-full overflow-x-auto px-3 pt-1 pb-2"> | |
| 58 | {/* A caret needs a line to sit on even before any code is typed. */} | |
| 59 | <NodeViewContent<"code"> | |
| 60 | as="code" | |
| 61 | className="block min-h-[1lh] font-mono whitespace-pre-wrap text-inherit [overflow-wrap:anywhere]" | |
| 62 | /> | |
| 63 | </pre> | |
| 64 | </div> | |
| 65 | </MarkdownCodeBlockFrame> | |
| 66 | ); | |
| 67 | } |
apps/web/src/components/composerCodeBlockHighlight.test.ts created+152| ... | ... | @@ -0,0 +1,152 @@ |
| 1 | import { Editor } from "@tiptap/core"; | |
| 2 | import type { EditorView } from "@tiptap/pm/view"; | |
| 3 | import StarterKit from "@tiptap/starter-kit"; | |
| 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | import { ComposerCodeBlockExtension } from "~/composer-rich-text-doc"; | |
| 7 | import type { DiffThemeName } from "~/lib/diffRendering"; | |
| 8 | import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; | |
| 9 | ||
| 10 | import { | |
| 11 | composerCodeBlockHighlight, | |
| 12 | MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH, | |
| 13 | shouldHighlightCodeBlock, | |
| 14 | tokenizeBlock, | |
| 15 | } from "./composerCodeBlockHighlight"; | |
| 16 | ||
| 17 | vi.mock("~/lib/syntaxHighlighting", () => ({ getSyntaxHighlighterPromise: vi.fn() })); | |
| 18 | ||
| 19 | const THEME = "github-dark" as DiffThemeName; | |
| 20 | ||
| 21 | /** | |
| 22 | * Stands in for the Shiki highlighter so the offset arithmetic can be checked | |
| 23 | * without loading a real grammar. Splits each line into whitespace-delimited | |
| 24 | * tokens, which is enough shape for the flattening logic under test. | |
| 25 | */ | |
| 26 | function fakeHighlighter(colorFor: (content: string) => string | undefined) { | |
| 27 | return { | |
| 28 | codeToTokens(code: string) { | |
| 29 | return { | |
| 30 | tokens: code.split(/\r?\n/).map((line) => | |
| 31 | line | |
| 32 | .split(/(\s+)/) | |
| 33 | .filter((part) => part.length > 0) | |
| 34 | .map((content) => ({ content, color: colorFor(content) })), | |
| 35 | ), | |
| 36 | }; | |
| 37 | }, | |
| 38 | } as unknown as Parameters<typeof tokenizeBlock>[0]; | |
| 39 | } | |
| 40 | ||
| 41 | describe("composer code block highlighting", () => { | |
| 42 | it("maps token offsets to positions within the block text", () => { | |
| 43 | const highlighter = fakeHighlighter((content) => (content === "const" ? "#ff0000" : undefined)); | |
| 44 | ||
| 45 | const decorations = tokenizeBlock(highlighter, "const answer = 42", "ts", THEME); | |
| 46 | ||
| 47 | expect(decorations).toEqual([{ from: 0, to: 5, color: "#ff0000" }]); | |
| 48 | }); | |
| 49 | ||
| 50 | it("accounts for the newline between lines", () => { | |
| 51 | const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); | |
| 52 | ||
| 53 | const decorations = tokenizeBlock(highlighter, "one\ntwo", "ts", THEME); | |
| 54 | ||
| 55 | // "one" is 3 characters, then the newline, so "two" starts at 4. | |
| 56 | expect(decorations).toEqual([{ from: 4, to: 7, color: "#00ff00" }]); | |
| 57 | }); | |
| 58 | ||
| 59 | it("accounts for both characters of a CRLF line break", () => { | |
| 60 | const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); | |
| 61 | ||
| 62 | const decorations = tokenizeBlock(highlighter, "one\r\ntwo", "ts", THEME); | |
| 63 | ||
| 64 | expect(decorations).toEqual([{ from: 5, to: 8, color: "#00ff00" }]); | |
| 65 | }); | |
| 66 | ||
| 67 | it("skips whitespace-only tokens so indentation is not decorated", () => { | |
| 68 | const highlighter = fakeHighlighter(() => "#0000ff"); | |
| 69 | ||
| 70 | const decorations = tokenizeBlock(highlighter, " indented", "ts", THEME); | |
| 71 | ||
| 72 | expect(decorations).toEqual([{ from: 2, to: 10, color: "#0000ff" }]); | |
| 73 | }); | |
| 74 | ||
| 75 | it("falls back to plain text when the grammar throws", () => { | |
| 76 | const throwing = { | |
| 77 | codeToTokens() { | |
| 78 | throw new Error("unsupported language"); | |
| 79 | }, | |
| 80 | } as unknown as Parameters<typeof tokenizeBlock>[0]; | |
| 81 | ||
| 82 | expect(tokenizeBlock(throwing, "const answer = 42", "nope", THEME)).toEqual([]); | |
| 83 | }); | |
| 84 | }); | |
| 85 | ||
| 86 | describe("the highlighting size cap", () => { | |
| 87 | // Each keystroke re-tokenizes the whole fence on the main thread; past a | |
| 88 | // few hundred lines that stalls typing, so large fences stay plain. | |
| 89 | it("highlights a fence up to the cap", () => { | |
| 90 | expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH))).toBe(true); | |
| 91 | }); | |
| 92 | ||
| 93 | it("leaves a fence past the cap plain", () => { | |
| 94 | expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH + 1))).toBe(false); | |
| 95 | }); | |
| 96 | }); | |
| 97 | ||
| 98 | describe("a highlighter that fails to load", () => { | |
| 99 | afterEach(() => { | |
| 100 | vi.unstubAllGlobals(); | |
| 101 | }); | |
| 102 | ||
| 103 | /** | |
| 104 | * Shiki can fail to initialize at all. The blocks it could not reach stay | |
| 105 | * plain, the ones it could are still painted, and nothing is left rejected. | |
| 106 | */ | |
| 107 | it("still repaints the blocks whose highlighter loaded", async () => { | |
| 108 | vi.mocked(getSyntaxHighlighterPromise).mockImplementation((language) => | |
| 109 | language === "ts" | |
| 110 | ? Promise.resolve(fakeHighlighter(() => "#ff0000")) | |
| 111 | : Promise.reject(new Error("Shiki failed to initialize")), | |
| 112 | ); | |
| 113 | const editor = new Editor({ | |
| 114 | extensions: [ | |
| 115 | StarterKit.configure({ codeBlock: false, trailingNode: false }), | |
| 116 | ComposerCodeBlockExtension, | |
| 117 | composerCodeBlockHighlight({ resolveTheme: () => THEME }), | |
| 118 | ], | |
| 119 | content: { | |
| 120 | type: "doc", | |
| 121 | content: [ | |
| 122 | { type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "a" }] }, | |
| 123 | { type: "codeBlock", attrs: { language: "py" }, content: [{ type: "text", text: "b" }] }, | |
| 124 | ], | |
| 125 | }, | |
| 126 | }); | |
| 127 | // The plugin watches <html> for theme changes; the unit suite has no DOM. | |
| 128 | vi.stubGlobal("document", { documentElement: {} }); | |
| 129 | vi.stubGlobal( | |
| 130 | "MutationObserver", | |
| 131 | class { | |
| 132 | observe() {} | |
| 133 | disconnect() {} | |
| 134 | }, | |
| 135 | ); | |
| 136 | // ProseMirror names each plugin after its key, though it does not type it. | |
| 137 | const plugin = editor.extensionManager.plugins.find((candidate) => | |
| 138 | (candidate as unknown as { key: string }).key.startsWith("composerCodeBlockHighlight"), | |
| 139 | ); | |
| 140 | const repainted = new Promise<void>((resolve) => { | |
| 141 | const view = { | |
| 142 | get state() { | |
| 143 | return editor.state; | |
| 144 | }, | |
| 145 | dispatch: () => resolve(), | |
| 146 | } as unknown as EditorView; | |
| 147 | plugin?.spec.view?.(view); | |
| 148 | }); | |
| 149 | ||
| 150 | await expect(repainted).resolves.toBeUndefined(); | |
| 151 | }); | |
| 152 | }); |
apps/web/src/components/composerCodeBlockHighlight.ts created+231| ... | ... | @@ -0,0 +1,231 @@ |
| 1 | import { Extension } from "@tiptap/core"; | |
| 2 | import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; | |
| 3 | import { Decoration, DecorationSet } from "@tiptap/pm/view"; | |
| 4 | import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; | |
| 5 | ||
| 6 | import type { DiffThemeName } from "~/lib/diffRendering"; | |
| 7 | import { languageOfInfoString } from "~/composer-code-languages"; | |
| 8 | import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; | |
| 9 | ||
| 10 | interface HighlightedBlock { | |
| 11 | /** Guards against repainting a block whose text and language are unchanged. */ | |
| 12 | readonly signature: string; | |
| 13 | readonly decorations: ReadonlyArray<{ from: number; to: number; color: string }>; | |
| 14 | } | |
| 15 | ||
| 16 | const composerCodeBlockHighlightKey = new PluginKey<DecorationSet>("composerCodeBlockHighlight"); | |
| 17 | ||
| 18 | /** | |
| 19 | * Past this many characters a fence is left plain. Any edit changes a block's | |
| 20 | * signature, so each keystroke re-tokenizes the whole block synchronously on | |
| 21 | * the main thread: a few milliseconds for a couple of hundred lines, but ~80ms | |
| 22 | * at a thousand, which stalls typing. 20k characters is roughly 400–500 lines | |
| 23 | * of ordinary code; a fence that shrinks back under it is highlighted again. | |
| 24 | */ | |
| 25 | export const MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH = 20_000; | |
| 26 | ||
| 27 | export function shouldHighlightCodeBlock(code: string): boolean { | |
| 28 | return code.length <= MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH; | |
| 29 | } | |
| 30 | function blockSignature(node: ProseMirrorNode, theme: DiffThemeName): string { | |
| 31 | // A separator that cannot occur in a language name keeps the parts distinct. | |
| 32 | return [theme, String(node.attrs.language ?? ""), node.textContent].join("\u0000"); | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 36 | * The composer's code blocks, which are always top-level: list items and | |
| 37 | * quotes cannot hold one. Scanning only the top level keeps a keystroke in a | |
| 38 | * long draft from walking every node in it. | |
| 39 | */ | |
| 40 | function collectCodeBlocks(state: EditorState): Array<{ node: ProseMirrorNode; pos: number }> { | |
| 41 | const blocks: Array<{ node: ProseMirrorNode; pos: number }> = []; | |
| 42 | state.doc.forEach((node, pos) => { | |
| 43 | if (node.type.name === "codeBlock") blocks.push({ node, pos }); | |
| 44 | }); | |
| 45 | return blocks; | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Highlights composer code blocks with the same Shiki instance the chat view | |
| 50 | * uses, painted as inline decorations so the text nodes stay editable. | |
| 51 | * | |
| 52 | * Highlighting is asynchronous and per block: a keystroke re-tokenizes only the | |
| 53 | * block that changed, and only after its highlighter has resolved. Blocks whose | |
| 54 | * text and language are unchanged reuse their previous decorations. | |
| 55 | */ | |
| 56 | export function composerCodeBlockHighlight(options: { | |
| 57 | resolveTheme: () => DiffThemeName; | |
| 58 | }): Extension { | |
| 59 | return Extension.create({ | |
| 60 | name: "composerCodeBlockHighlight", | |
| 61 | ||
| 62 | addProseMirrorPlugins() { | |
| 63 | // Every keystroke in a code block mints a new signature, so each scan | |
| 64 | // drops the signatures the document no longer holds. The cache is then | |
| 65 | // bounded by the document itself, and no block it holds is ever evicted. | |
| 66 | const cache = new Map<string, HighlightedBlock>(); | |
| 67 | // The latest scan's signatures, so a tokenize that resolves after its | |
| 68 | // block was edited away does not put it back. | |
| 69 | let live = new Set<string>(); | |
| 70 | ||
| 71 | return [ | |
| 72 | new Plugin<DecorationSet>({ | |
| 73 | key: composerCodeBlockHighlightKey, | |
| 74 | ||
| 75 | state: { | |
| 76 | init: () => DecorationSet.empty, | |
| 77 | apply(transaction, value, _oldState, newState) { | |
| 78 | if (!transaction.docChanged && !transaction.getMeta(composerCodeBlockHighlightKey)) { | |
| 79 | return value.map(transaction.mapping, transaction.doc); | |
| 80 | } | |
| 81 | return buildDecorations(newState, cache, options.resolveTheme()); | |
| 82 | }, | |
| 83 | }, | |
| 84 | ||
| 85 | props: { | |
| 86 | decorations(state) { | |
| 87 | return composerCodeBlockHighlightKey.getState(state); | |
| 88 | }, | |
| 89 | }, | |
| 90 | ||
| 91 | view(view) { | |
| 92 | let disposed = false; | |
| 93 | let paintedTheme = options.resolveTheme(); | |
| 94 | let scannedDoc: ProseMirrorNode | null = null; | |
| 95 | ||
| 96 | /** | |
| 97 | * Tokenizes any block missing from the cache, then repaints once. | |
| 98 | * Returning early when nothing is pending is what stops the | |
| 99 | * dispatch below from re-triggering this on its own update. | |
| 100 | */ | |
| 101 | const refresh = () => { | |
| 102 | const theme = options.resolveTheme(); | |
| 103 | const themeChanged = theme !== paintedTheme; | |
| 104 | paintedTheme = theme; | |
| 105 | // A selection change cannot alter what needs tokenizing, and | |
| 106 | // building a signature means concatenating every block's text. | |
| 107 | if (!themeChanged && view.state.doc === scannedDoc) return; | |
| 108 | scannedDoc = view.state.doc; | |
| 109 | const blocks = collectCodeBlocks(view.state); | |
| 110 | live = new Set(blocks.map(({ node }) => blockSignature(node, theme))); | |
| 111 | for (const signature of cache.keys()) { | |
| 112 | if (!live.has(signature)) cache.delete(signature); | |
| 113 | } | |
| 114 | const pending = blocks.filter( | |
| 115 | ({ node }) => | |
| 116 | shouldHighlightCodeBlock(node.textContent) && | |
| 117 | !cache.has(blockSignature(node, theme)), | |
| 118 | ); | |
| 119 | if (pending.length === 0) { | |
| 120 | // A theme switch keeps every signature but changes which one | |
| 121 | // applies, so the painted decorations still have to be rebuilt. | |
| 122 | if (themeChanged) { | |
| 123 | view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); | |
| 124 | } | |
| 125 | return; | |
| 126 | } | |
| 127 | ||
| 128 | // Settled rather than all: a block whose highlighter fails to | |
| 129 | // load stays plain, and the others are still painted. | |
| 130 | void Promise.allSettled( | |
| 131 | pending.map(async ({ node }) => { | |
| 132 | // The stored info string keeps everything after the language | |
| 133 | // (`js title=x`); Shiki wants only the name. | |
| 134 | const language = | |
| 135 | languageOfInfoString(String(node.attrs.language ?? "")) || "text"; | |
| 136 | const signature = blockSignature(node, theme); | |
| 137 | const highlighter = await getSyntaxHighlighterPromise(language); | |
| 138 | if (disposed || cache.has(signature) || !live.has(signature)) return; | |
| 139 | cache.set(signature, { | |
| 140 | signature, | |
| 141 | decorations: tokenizeBlock(highlighter, node.textContent, language, theme), | |
| 142 | }); | |
| 143 | }), | |
| 144 | ).then(() => { | |
| 145 | if (disposed) return; | |
| 146 | view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); | |
| 147 | }); | |
| 148 | }; | |
| 149 | ||
| 150 | refresh(); | |
| 151 | // The theme lives on <html>, outside the editor, so nothing would | |
| 152 | // otherwise tell the view a repaint is due. | |
| 153 | const themeObserver = new MutationObserver(refresh); | |
| 154 | themeObserver.observe(document.documentElement, { | |
| 155 | attributeFilter: ["class"], | |
| 156 | }); | |
| 157 | ||
| 158 | return { | |
| 159 | update: refresh, | |
| 160 | destroy() { | |
| 161 | disposed = true; | |
| 162 | themeObserver.disconnect(); | |
| 163 | }, | |
| 164 | }; | |
| 165 | }, | |
| 166 | }), | |
| 167 | ]; | |
| 168 | }, | |
| 169 | }); | |
| 170 | } | |
| 171 | ||
| 172 | type Highlighter = Awaited<ReturnType<typeof getSyntaxHighlighterPromise>>; | |
| 173 | ||
| 174 | /** Flattens Shiki's line/token structure into offsets within the block's text. */ | |
| 175 | export function tokenizeBlock( | |
| 176 | highlighter: Highlighter, | |
| 177 | code: string, | |
| 178 | language: string, | |
| 179 | theme: DiffThemeName, | |
| 180 | ): ReadonlyArray<{ from: number; to: number; color: string }> { | |
| 181 | let tokens; | |
| 182 | try { | |
| 183 | tokens = highlighter.codeToTokens(code, { lang: language, theme }).tokens; | |
| 184 | } catch { | |
| 185 | // An unsupported language should read as plain text, not break the editor. | |
| 186 | return []; | |
| 187 | } | |
| 188 | ||
| 189 | const decorations: Array<{ from: number; to: number; color: string }> = []; | |
| 190 | let offset = 0; | |
| 191 | for (const [lineIndex, line] of tokens.entries()) { | |
| 192 | // The separator between lines, which Shiki drops; CRLF is two characters. | |
| 193 | if (lineIndex > 0) offset += code.startsWith("\r\n", offset) ? 2 : 1; | |
| 194 | for (const token of line) { | |
| 195 | const length = token.content.length; | |
| 196 | if (token.color && token.content.trim()) { | |
| 197 | decorations.push({ from: offset, to: offset + length, color: token.color }); | |
| 198 | } | |
| 199 | offset += length; | |
| 200 | } | |
| 201 | } | |
| 202 | return decorations; | |
| 203 | } | |
| 204 | ||
| 205 | function buildDecorations( | |
| 206 | state: EditorState, | |
| 207 | cache: Map<string, HighlightedBlock>, | |
| 208 | theme: DiffThemeName, | |
| 209 | ): DecorationSet { | |
| 210 | const decorations: Decoration[] = []; | |
| 211 | for (const { node, pos } of collectCodeBlocks(state)) { | |
| 212 | if (!shouldHighlightCodeBlock(node.textContent)) continue; | |
| 213 | const highlighted = cache.get(blockSignature(node, theme)); | |
| 214 | if (!highlighted) continue; | |
| 215 | // Token offsets index the block's text, which lines up with document | |
| 216 | // positions only because `codeBlock` is `text*` with no marks — one | |
| 217 | // unmarked text node, so `content.size === textContent.length`. Allowing | |
| 218 | // marks or hard breaks in the code block would desync these silently. | |
| 219 | if (node.content.size !== node.textContent.length) continue; | |
| 220 | // +1 steps past the code block's opening token into its text. | |
| 221 | const start = pos + 1; | |
| 222 | for (const decoration of highlighted.decorations) { | |
| 223 | decorations.push( | |
| 224 | Decoration.inline(start + decoration.from, start + decoration.to, { | |
| 225 | style: `color:${decoration.color}`, | |
| 226 | }), | |
| 227 | ); | |
| 228 | } | |
| 229 | } | |
| 230 | return DecorationSet.create(state.doc, decorations); | |
| 231 | } |
apps/web/src/composer-block-shortcuts.test.ts created+126| ... | ... | @@ -0,0 +1,126 @@ |
| 1 | import { Editor } from "@tiptap/core"; | |
| 2 | import { TextSelection } from "@tiptap/pm/state"; | |
| 3 | import StarterKit from "@tiptap/starter-kit"; | |
| 4 | import { describe, expect, it } from "vite-plus/test"; | |
| 5 | ||
| 6 | import { | |
| 7 | buildDocJson, | |
| 8 | ComposerBlockExtensions, | |
| 9 | ComposerCodeBlockExtension, | |
| 10 | ComposerListExtensions, | |
| 11 | ComposerTaskItemExtension, | |
| 12 | ComposerTaskListExtension, | |
| 13 | serializeEditorDoc, | |
| 14 | } from "./composer-rich-text-doc"; | |
| 15 | ||
| 16 | // The same modifier ProseMirror's keymap reads "Mod" as. | |
| 17 | const mac = typeof navigator !== "undefined" && /Mac|iP(hone|[oa]d)/.test(navigator.platform); | |
| 18 | ||
| 19 | /** A key chord as the browser reports it: key, keyCode and modifiers. */ | |
| 20 | function chord(spec: string) { | |
| 21 | const parts = spec.split("-"); | |
| 22 | const key = parts.pop()!; | |
| 23 | const has = (name: string) => parts.includes(name); | |
| 24 | const base = key.length === 1 ? key.toUpperCase() : key; | |
| 25 | return { | |
| 26 | key: has("Shift") && /^\d$/.test(key) ? "!@#$%^&*("[Number(key) - 1]! : key, | |
| 27 | keyCode: base.length === 1 ? base.charCodeAt(0) : 0, | |
| 28 | shiftKey: has("Shift"), | |
| 29 | altKey: has("Alt"), | |
| 30 | metaKey: has("Mod") && mac, | |
| 31 | ctrlKey: has("Mod") && !mac, | |
| 32 | preventDefault() {}, | |
| 33 | stopPropagation() {}, | |
| 34 | } as unknown as KeyboardEvent; | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * Presses `spec` with the caret inside `value`, the way the composer's | |
| 39 | * editor would receive it, and returns the stored Markdown. | |
| 40 | */ | |
| 41 | function press(value: string, spec: string) { | |
| 42 | const editor = new Editor({ | |
| 43 | extensions: [ | |
| 44 | StarterKit.configure({ | |
| 45 | blockquote: false, | |
| 46 | bulletList: false, | |
| 47 | codeBlock: false, | |
| 48 | heading: false, | |
| 49 | horizontalRule: false, | |
| 50 | listItem: false, | |
| 51 | orderedList: false, | |
| 52 | trailingNode: false, | |
| 53 | }), | |
| 54 | ComposerCodeBlockExtension, | |
| 55 | ...ComposerBlockExtensions, | |
| 56 | ...ComposerListExtensions, | |
| 57 | ComposerTaskListExtension, | |
| 58 | ComposerTaskItemExtension, | |
| 59 | ], | |
| 60 | content: buildDocJson(value, (name) => ({ label: name, description: null })), | |
| 61 | }); | |
| 62 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); | |
| 63 | // What the view's `someProp("handleKeyDown")` does, without mounting one: | |
| 64 | // offer the key to each extension plugin in order until one handles it. | |
| 65 | // An unmounted editor's state does not carry them yet, so they come from | |
| 66 | // the extension manager. | |
| 67 | const event = chord(spec); | |
| 68 | const before = editor.state.doc; | |
| 69 | const handled = editor.extensionManager.plugins.some((plugin) => | |
| 70 | plugin.props.handleKeyDown?.call(plugin, editor.view, event), | |
| 71 | ); | |
| 72 | return { | |
| 73 | handled, | |
| 74 | changed: !editor.state.doc.eq(before), | |
| 75 | value: serializeEditorDoc(editor.state.doc).value, | |
| 76 | }; | |
| 77 | } | |
| 78 | ||
| 79 | // Tiptap's own block shortcuts would nest a list, task list, code block or | |
| 80 | // heading inside a quote, or restructure a list item. The serializers only | |
| 81 | // write what the composer's grammar has, so the nested block, and the text | |
| 82 | // in it, would vanish from the stored draft. None of these are the | |
| 83 | // composer's shortcuts; they must do nothing. | |
| 84 | const BLOCK_CHORDS = [ | |
| 85 | "Mod-Shift-8", | |
| 86 | "Mod-Shift-7", | |
| 87 | "Mod-Shift-9", | |
| 88 | "Mod-Shift-b", | |
| 89 | "Mod-Alt-c", | |
| 90 | "Mod-Alt-1", | |
| 91 | "Mod-Alt-3", | |
| 92 | ]; | |
| 93 | ||
| 94 | describe("Tiptap's block shortcuts", () => { | |
| 95 | it.each(BLOCK_CHORDS)("leave a quote's text alone on %s", (spec) => { | |
| 96 | expect(press("> keep this text", spec).value).toBe("> keep this text"); | |
| 97 | }); | |
| 98 | ||
| 99 | it.each(BLOCK_CHORDS)("leave a list item alone on %s", (spec) => { | |
| 100 | expect(press("- item", spec).value).toBe("- item"); | |
| 101 | }); | |
| 102 | ||
| 103 | it.each(BLOCK_CHORDS)("leave a paragraph alone on %s", (spec) => { | |
| 104 | expect(press("plain text", spec).value).toBe("plain text"); | |
| 105 | }); | |
| 106 | ||
| 107 | // Guards the harness itself: a chord the composer keeps must still reach | |
| 108 | // the keymap, or every case above would pass without pressing anything. | |
| 109 | it("still delivers the composer's own keys", () => { | |
| 110 | expect(press("**bold**", "Mod-b").handled).toBe(true); | |
| 111 | }); | |
| 112 | ||
| 113 | // Sinking or lifting an item moves it without touching its `indent`, so | |
| 114 | // the stored draft keeps the old nesting and the next rebuild undoes the | |
| 115 | // move. Nesting belongs to the composer's own Tab, which edits the source. | |
| 116 | it.each([ | |
| 117 | ["- p\n- a", "Tab"], | |
| 118 | ["- p\n - a", "Shift-Tab"], | |
| 119 | ["- [ ] p\n- [ ] a", "Tab"], | |
| 120 | ["- [ ] p\n - [ ] a", "Shift-Tab"], | |
| 121 | ])("leave the nesting of %j alone on %s", (value, spec) => { | |
| 122 | const result = press(value, spec); | |
| 123 | expect(result.changed).toBe(false); | |
| 124 | expect(result.value).toBe(value); | |
| 125 | }); | |
| 126 | }); |
apps/web/src/composer-code-block.test.ts created+391| ... | ... | @@ -0,0 +1,391 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { Editor, Node } from "@tiptap/core"; | |
| 3 | import { newlineInCode } from "@tiptap/pm/commands"; | |
| 4 | import { TextSelection } from "@tiptap/pm/state"; | |
| 5 | ||
| 6 | import { | |
| 7 | CODE_BLOCK_INDENT, | |
| 8 | indentCodeBlock, | |
| 9 | indentLines, | |
| 10 | convertCodeFenceOnEnter, | |
| 11 | exitCodeBlockOnClosingFence, | |
| 12 | exitCodeBlockOnTrailingBlankLines, | |
| 13 | indentedNewlineInCodeBlock, | |
| 14 | leadingWhitespace, | |
| 15 | outdentLine, | |
| 16 | } from "./composer-code-block"; | |
| 17 | import StarterKit from "@tiptap/starter-kit"; | |
| 18 | ||
| 19 | import { | |
| 20 | ComposerCodeBlockExtension, | |
| 21 | ComposerListExtensions, | |
| 22 | serializeEditorDoc, | |
| 23 | } from "./composer-rich-text-doc"; | |
| 24 | ||
| 25 | const extensions = [ | |
| 26 | StarterKit.configure({ codeBlock: false, trailingNode: false }), | |
| 27 | ComposerCodeBlockExtension, | |
| 28 | ...ComposerListExtensions, | |
| 29 | Node.create({ | |
| 30 | name: "composer-mention", | |
| 31 | group: "inline", | |
| 32 | inline: true, | |
| 33 | atom: true, | |
| 34 | addAttributes: () => ({ path: { default: "" }, source: { default: "" } }), | |
| 35 | }), | |
| 36 | ]; | |
| 37 | ||
| 38 | describe("leadingWhitespace", () => { | |
| 39 | it("reads the indent a new line should inherit", () => { | |
| 40 | expect(leadingWhitespace(" nested()")).toBe(" "); | |
| 41 | expect(leadingWhitespace("\tnested()")).toBe("\t"); | |
| 42 | expect(leadingWhitespace("flush()")).toBe(""); | |
| 43 | }); | |
| 44 | ||
| 45 | it("stops at the first non-space so code is never treated as indent", () => { | |
| 46 | expect(leadingWhitespace(" a b")).toBe(" "); | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | describe("outdentLine", () => { | |
| 51 | it("removes a full indent when there is one", () => { | |
| 52 | expect(outdentLine(" deep")).toBe(" deep"); | |
| 53 | }); | |
| 54 | ||
| 55 | /** Hand-indented lines should still outdent rather than refuse to move. */ | |
| 56 | it("falls back to a single space or tab", () => { | |
| 57 | expect(outdentLine(" odd")).toBe("odd"); | |
| 58 | expect(outdentLine("\ttabbed")).toBe("tabbed"); | |
| 59 | }); | |
| 60 | ||
| 61 | it("bottoms out instead of eating code", () => { | |
| 62 | expect(outdentLine("flush()")).toBe("flush()"); | |
| 63 | }); | |
| 64 | }); | |
| 65 | ||
| 66 | describe("indentLines", () => { | |
| 67 | it("indents every line it is given", () => { | |
| 68 | expect(indentLines(["a", "b"], "in")).toEqual([ | |
| 69 | `${CODE_BLOCK_INDENT}a`, | |
| 70 | `${CODE_BLOCK_INDENT}b`, | |
| 71 | ]); | |
| 72 | }); | |
| 73 | ||
| 74 | /** Indenting a blank line would leave whitespace the user cannot see. */ | |
| 75 | it("leaves blank lines alone", () => { | |
| 76 | expect(indentLines(["a", "", "b"], "in")).toEqual([ | |
| 77 | `${CODE_BLOCK_INDENT}a`, | |
| 78 | "", | |
| 79 | `${CODE_BLOCK_INDENT}b`, | |
| 80 | ]); | |
| 81 | }); | |
| 82 | ||
| 83 | it("outdents every line it is given", () => { | |
| 84 | expect(indentLines([" a", " b", "c"], "out")).toEqual([" a", "b", "c"]); | |
| 85 | }); | |
| 86 | }); | |
| 87 | ||
| 88 | /** Builds an editor holding a single fenced block, with the caret placed by offset. */ | |
| 89 | function codeEditor(code: string, at?: { from: number; to?: number }) { | |
| 90 | const editor = new Editor({ | |
| 91 | extensions, | |
| 92 | content: { | |
| 93 | type: "doc", | |
| 94 | content: [{ type: "codeBlock", content: [{ type: "text", text: code }] }], | |
| 95 | }, | |
| 96 | }); | |
| 97 | const start = 1; | |
| 98 | const from = start + (at?.from ?? code.length); | |
| 99 | const to = start + (at?.to ?? at?.from ?? code.length); | |
| 100 | editor.view.dispatch( | |
| 101 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from, to)), | |
| 102 | ); | |
| 103 | return editor; | |
| 104 | } | |
| 105 | ||
| 106 | const textOf = (editor: Editor) => editor.state.doc.textContent; | |
| 107 | ||
| 108 | describe("indentedNewlineInCodeBlock", () => { | |
| 109 | it("carries the current indentation onto the new line", () => { | |
| 110 | const editor = codeEditor("function f() {\n body()"); | |
| 111 | const handled = indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); | |
| 112 | expect(handled).toBe(true); | |
| 113 | expect(textOf(editor)).toBe("function f() {\n body()\n "); | |
| 114 | }); | |
| 115 | ||
| 116 | /** | |
| 117 | * With no indent to carry there is nothing to add, so the default newline | |
| 118 | * should handle it and stay a single undo step. | |
| 119 | */ | |
| 120 | it("declines an unindented line", () => { | |
| 121 | const editor = codeEditor("flush()"); | |
| 122 | expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | |
| 123 | expect(textOf(editor)).toBe("flush()"); | |
| 124 | }); | |
| 125 | ||
| 126 | it("indents from the line the caret is on, not the last line", () => { | |
| 127 | const editor = codeEditor(" deep()\nflush()", { from: 10 }); | |
| 128 | indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); | |
| 129 | expect(textOf(editor)).toBe(" deep()\n \nflush()"); | |
| 130 | }); | |
| 131 | ||
| 132 | it("replaces the selected text rather than keeping it", () => { | |
| 133 | const editor = codeEditor(" keep()DROP", { from: 8, to: 12 }); | |
| 134 | indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); | |
| 135 | expect(textOf(editor)).toBe(" keep()\n "); | |
| 136 | }); | |
| 137 | ||
| 138 | it("does nothing outside a code block", () => { | |
| 139 | const editor = new Editor({ | |
| 140 | extensions, | |
| 141 | content: { | |
| 142 | type: "doc", | |
| 143 | content: [{ type: "paragraph", content: [{ type: "text", text: " hi" }] }], | |
| 144 | }, | |
| 145 | }); | |
| 146 | expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | |
| 147 | }); | |
| 148 | }); | |
| 149 | ||
| 150 | /** Enter inside a fence, in the order the composer's key handler tries it. */ | |
| 151 | function pressEnter(editor: Editor) { | |
| 152 | const dispatch = (tr: Parameters<typeof editor.view.dispatch>[0]) => editor.view.dispatch(tr); | |
| 153 | return ( | |
| 154 | exitCodeBlockOnTrailingBlankLines(editor.view) || | |
| 155 | indentedNewlineInCodeBlock(editor.state, dispatch) || | |
| 156 | newlineInCode(editor.state, dispatch) | |
| 157 | ); | |
| 158 | } | |
| 159 | ||
| 160 | describe("exitCodeBlockOnTrailingBlankLines", () => { | |
| 161 | it("leaves the block once two blank lines end it", () => { | |
| 162 | const editor = codeEditor("flush()"); | |
| 163 | pressEnter(editor); | |
| 164 | pressEnter(editor); | |
| 165 | expect(editor.state.doc.childCount).toBe(1); | |
| 166 | pressEnter(editor); | |
| 167 | expect(editor.state.doc.firstChild?.textContent).toBe("flush()"); | |
| 168 | expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); | |
| 169 | }); | |
| 170 | ||
| 171 | /** Enter carries indentation, so the blank lines after nested code are not empty. */ | |
| 172 | it("counts lines holding only carried indentation as blank", () => { | |
| 173 | const editor = codeEditor(" nested()"); | |
| 174 | pressEnter(editor); | |
| 175 | pressEnter(editor); | |
| 176 | expect(editor.state.doc.childCount).toBe(1); | |
| 177 | pressEnter(editor); | |
| 178 | expect(editor.state.doc.firstChild?.textContent).toBe(" nested()"); | |
| 179 | expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); | |
| 180 | }); | |
| 181 | ||
| 182 | /** An unclosed fence runs to the end of the draft, so leaving it must close it. */ | |
| 183 | it("closes an unclosed fence on the way out", () => { | |
| 184 | const editor = codeEditor("flush()"); | |
| 185 | editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "close", "")); | |
| 186 | pressEnter(editor); | |
| 187 | pressEnter(editor); | |
| 188 | pressEnter(editor); | |
| 189 | expect(editor.state.doc.firstChild?.attrs.close).toBe("\n```"); | |
| 190 | }); | |
| 191 | ||
| 192 | it("stays in the block when the caret is not at its end", () => { | |
| 193 | const editor = codeEditor("a\n\n\nb", { from: 3 }); | |
| 194 | expect(exitCodeBlockOnTrailingBlankLines(editor.view)).toBe(false); | |
| 195 | }); | |
| 196 | }); | |
| 197 | ||
| 198 | describe("exitCodeBlockOnClosingFence", () => { | |
| 199 | const exit = (editor: Editor) => | |
| 200 | exitCodeBlockOnClosingFence(editor.state, (tr) => editor.view.dispatch(tr)); | |
| 201 | ||
| 202 | it("ends the block at a typed closing fence and starts a line after it", () => { | |
| 203 | const editor = codeEditor("const a = 1\n```"); | |
| 204 | editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "language", "ts")); | |
| 205 | expect(exit(editor)).toBe(true); | |
| 206 | editor.view.dispatch(editor.state.tr.insertText("explain this")); | |
| 207 | expect(serializeEditorDoc(editor.state.doc).value).toBe( | |
| 208 | "```ts\nconst a = 1\n```\nexplain this", | |
| 209 | ); | |
| 210 | }); | |
| 211 | ||
| 212 | it("moves the lines after the fence out of the block", () => { | |
| 213 | const editor = codeEditor("a\n```\nb", { from: "a\n```".length }); | |
| 214 | expect(exit(editor)).toBe(true); | |
| 215 | expect(serializeEditorDoc(editor.state.doc).value).toBe("```\na\n```\n\nb"); | |
| 216 | expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); | |
| 217 | expect(editor.state.selection.$from.parent.textContent).toBe(""); | |
| 218 | }); | |
| 219 | ||
| 220 | it.each([ | |
| 221 | ["a line that cannot close the fence", "a\n``", undefined], | |
| 222 | ["a caret before the end of the line", "a\n```", { from: "a\n``".length }], | |
| 223 | ])("ignores %s", (_, code, at) => { | |
| 224 | expect(exit(codeEditor(code, at))).toBe(false); | |
| 225 | }); | |
| 226 | }); | |
| 227 | ||
| 228 | describe("indentCodeBlock", () => { | |
| 229 | it("inserts an indent at a collapsed caret", () => { | |
| 230 | const editor = codeEditor("ab", { from: 1 }); | |
| 231 | expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(true); | |
| 232 | expect(textOf(editor)).toBe(`a${CODE_BLOCK_INDENT}b`); | |
| 233 | }); | |
| 234 | ||
| 235 | it("indents every line a selection touches", () => { | |
| 236 | const editor = codeEditor("one\ntwo\nthree", { from: 1, to: 9 }); | |
| 237 | indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); | |
| 238 | expect(textOf(editor)).toBe(" one\n two\n three"); | |
| 239 | }); | |
| 240 | ||
| 241 | /** A selection of whole lines ends at the start of the next one, which it does not touch. */ | |
| 242 | it("leaves the line a selection ends at the start of", () => { | |
| 243 | const editor = codeEditor("one\ntwo", { from: 0, to: 4 }); | |
| 244 | indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); | |
| 245 | expect(textOf(editor)).toBe(" one\ntwo"); | |
| 246 | }); | |
| 247 | ||
| 248 | it("outdents every line a selection touches", () => { | |
| 249 | const editor = codeEditor(" one\n two", { from: 2, to: 9 }); | |
| 250 | indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr)); | |
| 251 | expect(textOf(editor)).toBe("one\ntwo"); | |
| 252 | }); | |
| 253 | ||
| 254 | /** Repeated Tab presses should keep working on the same block. */ | |
| 255 | it("keeps the selection across the shifted lines", () => { | |
| 256 | const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); | |
| 257 | indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); | |
| 258 | indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); | |
| 259 | expect(textOf(editor)).toBe(" one\n two"); | |
| 260 | }); | |
| 261 | ||
| 262 | it("reports handled when outdenting can go no further, so Tab never escapes the editor", () => { | |
| 263 | const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); | |
| 264 | expect(indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr))).toBe(true); | |
| 265 | expect(textOf(editor)).toBe("one\ntwo"); | |
| 266 | }); | |
| 267 | ||
| 268 | it("does nothing outside a code block", () => { | |
| 269 | const editor = new Editor({ | |
| 270 | extensions, | |
| 271 | content: { | |
| 272 | type: "doc", | |
| 273 | content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }], | |
| 274 | }, | |
| 275 | }); | |
| 276 | expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); | |
| 277 | }); | |
| 278 | }); | |
| 279 | ||
| 280 | describe("selections across two identical fences", () => { | |
| 281 | /** Two structurally equal code blocks; the selection spans from one into the other. */ | |
| 282 | function twoBlockEditor() { | |
| 283 | const block = { type: "codeBlock", content: [{ type: "text", text: " a" }] }; | |
| 284 | const editor = new Editor({ extensions, content: { type: "doc", content: [block, block] } }); | |
| 285 | // First block: 1..4 (text " a" occupies 1..4); second block starts at 5. | |
| 286 | editor.view.dispatch( | |
| 287 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2, 7)), | |
| 288 | ); | |
| 289 | return editor; | |
| 290 | } | |
| 291 | ||
| 292 | it("leaves Tab and Enter alone rather than editing through the boundary", () => { | |
| 293 | const editor = twoBlockEditor(); | |
| 294 | const before = editor.getJSON(); | |
| 295 | expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); | |
| 296 | expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | |
| 297 | expect(editor.getJSON()).toEqual(before); | |
| 298 | }); | |
| 299 | }); | |
| 300 | ||
| 301 | describe("convertCodeFenceOnEnter", () => { | |
| 302 | /** Builds an editor holding one paragraph with the caret at its end. */ | |
| 303 | function paragraphEditor(text: string) { | |
| 304 | const editor = new Editor({ | |
| 305 | extensions, | |
| 306 | content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] }, | |
| 307 | }); | |
| 308 | editor.view.dispatch( | |
| 309 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1 + text.length)), | |
| 310 | ); | |
| 311 | return editor; | |
| 312 | } | |
| 313 | ||
| 314 | it.each([ | |
| 315 | ["```", "```", ""], | |
| 316 | ["```ts", "```", "ts"], | |
| 317 | ["~~~py", "~~~", "py"], | |
| 318 | ["````", "````", ""], | |
| 319 | ["```ts title=example", "```", "ts title=example"], | |
| 320 | ["``` js", "```", " js"], | |
| 321 | ])("turns %s into a code block", (text, fence, language) => { | |
| 322 | const editor = paragraphEditor(text); | |
| 323 | expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true); | |
| 324 | const block = editor.state.doc.firstChild!; | |
| 325 | expect(block.type.name).toBe("codeBlock"); | |
| 326 | expect(block.attrs).toMatchObject({ fence, language, close: `\n${fence}` }); | |
| 327 | expect(block.textContent).toBe(""); | |
| 328 | // The caret belongs inside the new block, ready for the first line. | |
| 329 | expect(editor.state.selection.from).toBe(1); | |
| 330 | }); | |
| 331 | ||
| 332 | it.each(["``", "text ```", "```a`b"])("leaves %s alone", (text) => { | |
| 333 | const editor = paragraphEditor(text); | |
| 334 | expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | |
| 335 | }); | |
| 336 | ||
| 337 | it("refuses to open a fence inside a list item", () => { | |
| 338 | const text = "```ts"; | |
| 339 | const editor = new Editor({ | |
| 340 | extensions, | |
| 341 | content: { | |
| 342 | type: "doc", | |
| 343 | content: [ | |
| 344 | { | |
| 345 | type: "bulletList", | |
| 346 | content: [ | |
| 347 | { | |
| 348 | type: "listItem", | |
| 349 | content: [{ type: "paragraph", content: [{ type: "text", text }] }], | |
| 350 | }, | |
| 351 | ], | |
| 352 | }, | |
| 353 | ], | |
| 354 | }, | |
| 355 | }); | |
| 356 | // Inside listItem > paragraph: position 3 is the paragraph start. | |
| 357 | editor.view.dispatch( | |
| 358 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3 + text.length)), | |
| 359 | ); | |
| 360 | const before = editor.getJSON(); | |
| 361 | expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | |
| 362 | expect(editor.getJSON()).toEqual(before); | |
| 363 | }); | |
| 364 | ||
| 365 | it("keeps a chip's source in the info string", () => { | |
| 366 | const editor = new Editor({ | |
| 367 | extensions, | |
| 368 | content: { | |
| 369 | type: "doc", | |
| 370 | content: [ | |
| 371 | { | |
| 372 | type: "paragraph", | |
| 373 | content: [ | |
| 374 | { type: "text", text: "```ts " }, | |
| 375 | { type: "composer-mention", attrs: { path: "a.md", source: "@a.md" } }, | |
| 376 | ], | |
| 377 | }, | |
| 378 | ], | |
| 379 | }, | |
| 380 | }); | |
| 381 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); | |
| 382 | expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true); | |
| 383 | expect(editor.state.doc.firstChild?.attrs.language).toBe("ts @a.md"); | |
| 384 | }); | |
| 385 | ||
| 386 | it("ignores a fence with the caret before its end", () => { | |
| 387 | const editor = paragraphEditor("```ts"); | |
| 388 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3))); | |
| 389 | expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | |
| 390 | }); | |
| 391 | }); |
apps/web/src/composer-code-block.ts created+254| ... | ... | @@ -0,0 +1,254 @@ |
| 1 | import { exitCode } from "@tiptap/pm/commands"; | |
| 2 | import type { EditorState, Transaction } from "@tiptap/pm/state"; | |
| 3 | import { TextSelection } from "@tiptap/pm/state"; | |
| 4 | import type { EditorView } from "@tiptap/pm/view"; | |
| 5 | ||
| 6 | import { isClosingFence, parseOpeningFence, serializeSelection } from "~/composer-rich-text-doc"; | |
| 7 | ||
| 8 | /** | |
| 9 | * Spaces rather than a tab: the fence round-trips through Markdown on its way | |
| 10 | * to the agent, and a literal tab there renders at whatever width the reader | |
| 11 | * happens to use. Two matches how this repo indents its own source. | |
| 12 | */ | |
| 13 | export const CODE_BLOCK_INDENT = " "; | |
| 14 | ||
| 15 | /** The leading run of spaces and tabs on a line, which Enter carries forward. */ | |
| 16 | export function leadingWhitespace(line: string): string { | |
| 17 | return /^[ \t]*/.exec(line)?.[0] ?? ""; | |
| 18 | } | |
| 19 | ||
| 20 | /** | |
| 21 | * Removes one indent's worth of leading whitespace, tolerating lines that were | |
| 22 | * indented by hand with an odd number of spaces or with tabs. A line with no | |
| 23 | * leading whitespace is returned unchanged, so outdenting bottoms out quietly | |
| 24 | * rather than eating the first character of code. | |
| 25 | */ | |
| 26 | export function outdentLine(line: string): string { | |
| 27 | if (line.startsWith(CODE_BLOCK_INDENT)) return line.slice(CODE_BLOCK_INDENT.length); | |
| 28 | if (line.startsWith("\t")) return line.slice(1); | |
| 29 | if (line.startsWith(" ")) return line.slice(1); | |
| 30 | return line; | |
| 31 | } | |
| 32 | ||
| 33 | /** Applies Tab/Shift+Tab to every line a selection touches, whole lines at a time. */ | |
| 34 | export function indentLines(lines: ReadonlyArray<string>, direction: "in" | "out"): string[] { | |
| 35 | return lines.map((line) => { | |
| 36 | // Indenting a blank line would leave trailing whitespace behind on a line | |
| 37 | // the user cannot see the caret move on, so leave it be. | |
| 38 | if (direction === "in") return line.length === 0 ? line : `${CODE_BLOCK_INDENT}${line}`; | |
| 39 | return outdentLine(line); | |
| 40 | }); | |
| 41 | } | |
| 42 | ||
| 43 | /** Whether every endpoint of the selection sits in the same code block. */ | |
| 44 | export function selectionInOneCodeBlock(state: EditorState): boolean { | |
| 45 | const { $from, $to } = state.selection; | |
| 46 | return $from.parent.type.spec.code === true && $from.sameParent($to); | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * The code block containing the selection, or null when the selection is | |
| 51 | * outside a fence or spans out of one. Both ends must sit in the same block: a | |
| 52 | * selection that reaches past the fence belongs to the document, not to the | |
| 53 | * code. | |
| 54 | */ | |
| 55 | function codeBlockRange( | |
| 56 | state: EditorState, | |
| 57 | ): { readonly from: number; readonly to: number; readonly text: string } | null { | |
| 58 | const { $from, $to } = state.selection; | |
| 59 | const parent = $from.parent; | |
| 60 | if (parent.type.spec.code !== true) return null; | |
| 61 | // Same occurrence, not merely equal structure: two identical fences would | |
| 62 | // otherwise let a selection across them edit through the block boundary. | |
| 63 | if (!$from.sameParent($to)) return null; | |
| 64 | const from = $from.start(); | |
| 65 | return { from, to: from + parent.content.size, text: parent.textContent }; | |
| 66 | } | |
| 67 | ||
| 68 | /** | |
| 69 | * Enter inside a fence, carrying the current line's indentation onto the new | |
| 70 | * line. ProseMirror's default splits the text but starts the new line at column | |
| 71 | * zero, which means re-typing the indent on every line of a nested block. | |
| 72 | */ | |
| 73 | export function indentedNewlineInCodeBlock( | |
| 74 | state: EditorState, | |
| 75 | dispatch?: (transaction: Transaction) => void, | |
| 76 | ): boolean { | |
| 77 | const block = codeBlockRange(state); | |
| 78 | if (!block) return false; | |
| 79 | ||
| 80 | const { from, to } = state.selection; | |
| 81 | const beforeCursor = block.text.slice(0, from - block.from); | |
| 82 | const currentLine = beforeCursor.slice(beforeCursor.lastIndexOf("\n") + 1); | |
| 83 | const indent = leadingWhitespace(currentLine); | |
| 84 | ||
| 85 | // Nothing to carry, so let the default newline handle it and keep this path | |
| 86 | // out of the undo history. | |
| 87 | if (indent.length === 0) return false; | |
| 88 | ||
| 89 | if (dispatch) { | |
| 90 | const inserted = `\n${indent}`; | |
| 91 | const transaction = state.tr.insertText(inserted, from, to); | |
| 92 | const caret = from + inserted.length; | |
| 93 | transaction.setSelection(TextSelection.create(transaction.doc, caret)); | |
| 94 | dispatch(transaction.scrollIntoView()); | |
| 95 | } | |
| 96 | return true; | |
| 97 | } | |
| 98 | ||
| 99 | /** | |
| 100 | * Two blank lines at the end of a fence leave it, the way every code editor | |
| 101 | * does. Without this a fence at the end of the prompt is a trap: Enter only | |
| 102 | * ever adds another line and there is no way back to prose. A line holding | |
| 103 | * only the indentation Enter carried forward counts as blank, and is removed | |
| 104 | * with the rest. | |
| 105 | */ | |
| 106 | export function exitCodeBlockOnTrailingBlankLines(view: EditorView): boolean { | |
| 107 | const { $from, empty } = view.state.selection; | |
| 108 | if (!empty || $from.parent.type.spec.code !== true) return false; | |
| 109 | if ($from.parentOffset !== $from.parent.content.size) return false; | |
| 110 | const trailing = /\n[ \t]*\n[ \t]*$/.exec($from.parent.textContent); | |
| 111 | if (!trailing) return false; | |
| 112 | const transaction = view.state.tr.delete($from.pos - trailing[0].length, $from.pos); | |
| 113 | // An unclosed fence runs to the end of the draft; the prose after it would | |
| 114 | // be read back as code, so leaving it writes the closing fence. | |
| 115 | if (!$from.parent.attrs.close) { | |
| 116 | transaction.setNodeAttribute($from.before(), "close", `\n${$from.parent.attrs.fence}`); | |
| 117 | } | |
| 118 | view.dispatch(transaction); | |
| 119 | return exitCode(view.state, (tr) => view.dispatch(tr.scrollIntoView())); | |
| 120 | } | |
| 121 | ||
| 122 | /** | |
| 123 | * Enter at the end of a line that would close the fence, such as ```` ``` ```` | |
| 124 | * typed under the code, ends the block there. That line becomes the closing | |
| 125 | * fence, and any lines after it continue as paragraphs after a new empty one, | |
| 126 | * which is how the stored draft reads them. | |
| 127 | */ | |
| 128 | export function exitCodeBlockOnClosingFence( | |
| 129 | state: EditorState, | |
| 130 | dispatch?: (transaction: Transaction) => void, | |
| 131 | ): boolean { | |
| 132 | const block = codeBlockRange(state); | |
| 133 | if (!block || !state.selection.empty) return false; | |
| 134 | const caret = state.selection.from - block.from; | |
| 135 | const lineStart = block.text.lastIndexOf("\n", caret - 1) + 1; | |
| 136 | const newlineAfter = block.text.indexOf("\n", caret); | |
| 137 | const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; | |
| 138 | const line = block.text.slice(lineStart, lineEnd); | |
| 139 | const node = state.selection.$from.parent; | |
| 140 | if (caret !== lineEnd || !isClosingFence(line, String(node.attrs.fence))) return false; | |
| 141 | ||
| 142 | if (dispatch) { | |
| 143 | const { schema } = state; | |
| 144 | const paragraph = (text: string) => | |
| 145 | schema.nodes.paragraph!.create(null, text ? schema.text(text) : null); | |
| 146 | const code = block.text.slice(0, Math.max(0, lineStart - 1)); | |
| 147 | const rest = newlineAfter === -1 ? [] : block.text.slice(newlineAfter + 1).split("\n"); | |
| 148 | const closed = node.type.create( | |
| 149 | { ...node.attrs, close: `\n${line}` }, | |
| 150 | code ? schema.text(code) : null, | |
| 151 | ); | |
| 152 | const start = state.selection.$from.before(); | |
| 153 | const transaction = state.tr.replaceWith(start, start + node.nodeSize, [ | |
| 154 | closed, | |
| 155 | paragraph(""), | |
| 156 | ...rest.map(paragraph), | |
| 157 | ]); | |
| 158 | transaction.setSelection(TextSelection.create(transaction.doc, start + closed.nodeSize + 1)); | |
| 159 | dispatch(transaction.scrollIntoView()); | |
| 160 | } | |
| 161 | return true; | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Tab and Shift+Tab inside a fence. A collapsed cursor indents at the caret so | |
| 166 | * Tab works mid-line the way typing does; any selection spanning text indents | |
| 167 | * the whole lines it touches, which is what makes re-indenting a block possible. | |
| 168 | */ | |
| 169 | export function indentCodeBlock( | |
| 170 | state: EditorState, | |
| 171 | direction: "in" | "out", | |
| 172 | dispatch?: (transaction: Transaction) => void, | |
| 173 | ): boolean { | |
| 174 | const block = codeBlockRange(state); | |
| 175 | if (!block) return false; | |
| 176 | ||
| 177 | const { from, to } = state.selection; | |
| 178 | const startOffset = from - block.from; | |
| 179 | const endOffset = to - block.from; | |
| 180 | ||
| 181 | if (from === to && direction === "in") { | |
| 182 | if (dispatch) { | |
| 183 | const transaction = state.tr.insertText(CODE_BLOCK_INDENT, from, to); | |
| 184 | dispatch(transaction.scrollIntoView()); | |
| 185 | } | |
| 186 | return true; | |
| 187 | } | |
| 188 | ||
| 189 | // Grow the range to whole lines so indenting is stable no matter where in the | |
| 190 | // first and last lines the selection happens to start and end. | |
| 191 | const lineStart = block.text.lastIndexOf("\n", Math.max(0, startOffset - 1)) + 1; | |
| 192 | // A selection ending at the start of a line does not touch that line. | |
| 193 | const newlineAfter = block.text.indexOf("\n", Math.max(startOffset, endOffset - 1)); | |
| 194 | const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; | |
| 195 | ||
| 196 | const originalLines = block.text.slice(lineStart, lineEnd).split("\n"); | |
| 197 | const nextLines = indentLines(originalLines, direction); | |
| 198 | if (nextLines.join("\n") === originalLines.join("\n")) return true; | |
| 199 | ||
| 200 | if (dispatch) { | |
| 201 | const transaction = state.tr.insertText( | |
| 202 | nextLines.join("\n"), | |
| 203 | block.from + lineStart, | |
| 204 | block.from + lineEnd, | |
| 205 | ); | |
| 206 | // Keep the same lines selected after the shift, so repeated Tab presses | |
| 207 | // keep indenting the block rather than collapsing the selection. | |
| 208 | // `split` always yields at least one entry, so these stand in only for the | |
| 209 | // compiler's benefit. | |
| 210 | const firstDelta = (nextLines[0]?.length ?? 0) - (originalLines[0]?.length ?? 0); | |
| 211 | const totalDelta = nextLines.join("\n").length - originalLines.join("\n").length; | |
| 212 | const nextFrom = Math.max(block.from + lineStart, from + firstDelta); | |
| 213 | const nextTo = Math.max(nextFrom, to + totalDelta); | |
| 214 | transaction.setSelection(TextSelection.create(transaction.doc, nextFrom, nextTo)); | |
| 215 | dispatch(transaction.scrollIntoView()); | |
| 216 | } | |
| 217 | return true; | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * Enter on a line that is nothing but an opening fence turns it into a code | |
| 222 | * block. Tiptap's own input rules only fire on a space, so without this the | |
| 223 | * gesture every markdown editor has — ```` ```ts ```` then Enter — leaves the | |
| 224 | * fence as literal text. | |
| 225 | */ | |
| 226 | export function convertCodeFenceOnEnter( | |
| 227 | state: EditorState, | |
| 228 | dispatch?: (transaction: Transaction) => void, | |
| 229 | ): boolean { | |
| 230 | const { $from, empty } = state.selection; | |
| 231 | // Top-level paragraphs only: the list and quote serializers have no line | |
| 232 | // to write a fence into, so one created inside them would vanish. | |
| 233 | if (!empty || $from.parent.type.name !== "paragraph" || $from.depth !== 1) return false; | |
| 234 | if ($from.parentOffset !== $from.parent.content.size) return false; | |
| 235 | // The parser's own grammar, read from the line's stored source, so a line | |
| 236 | // Enter turns into a fence is exactly one the stored draft reads back as a | |
| 237 | // fence, info string and all, including any chip's source in it. | |
| 238 | const opening = parseOpeningFence(serializeSelection(state.doc, $from.start(), $from.end())); | |
| 239 | const codeBlock = state.schema.nodes.codeBlock; | |
| 240 | if (!opening || !codeBlock) return false; | |
| 241 | ||
| 242 | if (dispatch) { | |
| 243 | const blockStart = $from.before(); | |
| 244 | const { fence, language } = opening; | |
| 245 | const transaction = state.tr.replaceWith( | |
| 246 | blockStart, | |
| 247 | blockStart + $from.parent.nodeSize, | |
| 248 | codeBlock.create({ language, fence, close: `\n${fence}` }), | |
| 249 | ); | |
| 250 | transaction.setSelection(TextSelection.create(transaction.doc, blockStart + 1)); | |
| 251 | dispatch(transaction.scrollIntoView()); | |
| 252 | } | |
| 253 | return true; | |
| 254 | } |
apps/web/src/composer-code-languages.test.ts created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | CODE_BLOCK_LANGUAGES, | |
| 5 | codeLanguageLabel, | |
| 6 | languageOfInfoString, | |
| 7 | withInfoStringLanguage, | |
| 8 | } from "./composer-code-languages"; | |
| 9 | ||
| 10 | describe("languageOfInfoString", () => { | |
| 11 | it.each([ | |
| 12 | ["", ""], | |
| 13 | ["ts", "ts"], | |
| 14 | [" ts ", "ts"], | |
| 15 | ["js title=example", "js"], | |
| 16 | ["typescript ", "typescript"], | |
| 17 | ])("reads %j as %j", (info, language) => { | |
| 18 | expect(languageOfInfoString(info)).toBe(language); | |
| 19 | }); | |
| 20 | }); | |
| 21 | ||
| 22 | describe("withInfoStringLanguage", () => { | |
| 23 | it.each([ | |
| 24 | ["", "ts", "ts"], | |
| 25 | ["js", "python", "python"], | |
| 26 | // Anything after the language belongs to the fence and stays put. | |
| 27 | ["js title=example", "ts", "ts title=example"], | |
| 28 | ["js {1,3}", "ts", "ts {1,3}"], | |
| 29 | // Plain text clears the language... | |
| 30 | ["js", "", ""], | |
| 31 | // ...but a bare `title=x` would read as the language, so it keeps `text`. | |
| 32 | ["js title=example", "", "text title=example"], | |
| 33 | // Whitespace between the fence and the language is the user's formatting. | |
| 34 | [" js title=x", "python", " python title=x"], | |
| 35 | [" js title=x", "", " text title=x"], | |
| 36 | [" js", "go", " go"], | |
| 37 | [" ", "go", " go"], | |
| 38 | ])("sets %j to %j as %j", (info, language, expected) => { | |
| 39 | expect(withInfoStringLanguage(info, language)).toBe(expected); | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 43 | describe("codeLanguageLabel", () => { | |
| 44 | it.each([ | |
| 45 | ["", "Plain text"], | |
| 46 | ["text", "Plain text"], | |
| 47 | ["cpp", "C++"], | |
| 48 | ["ts", "TypeScript"], | |
| 49 | ["arduino", "arduino"], | |
| 50 | ])("labels %j as %j", (language, label) => { | |
| 51 | expect(codeLanguageLabel(language)).toBe(label); | |
| 52 | }); | |
| 53 | ||
| 54 | it("lists every language once, plain text first", () => { | |
| 55 | const ids = CODE_BLOCK_LANGUAGES.map((language) => language.id); | |
| 56 | expect(ids[0]).toBe(""); | |
| 57 | expect(new Set(ids).size).toBe(ids.length); | |
| 58 | }); | |
| 59 | }); |
apps/web/src/composer-code-languages.ts created+97| ... | ... | @@ -0,0 +1,97 @@ |
| 1 | /** | |
| 2 | * The languages a composer fence can be switched to from its header, and how | |
| 3 | * a choice is written back into the fence's info string. | |
| 4 | * | |
| 5 | * Ids are the names the highlighter and the file icons both know. The list | |
| 6 | * is curated rather than every grammar the highlighter bundles: a picker is | |
| 7 | * for the common case, and any other language can still be typed on the | |
| 8 | * fence line, where it shows as its own entry. | |
| 9 | */ | |
| 10 | export const CODE_BLOCK_LANGUAGES = [ | |
| 11 | { id: "", label: "Plain text" }, | |
| 12 | { id: "bash", label: "Bash" }, | |
| 13 | { id: "c", label: "C" }, | |
| 14 | { id: "cpp", label: "C++" }, | |
| 15 | { id: "csharp", label: "C#" }, | |
| 16 | { id: "css", label: "CSS" }, | |
| 17 | { id: "dart", label: "Dart" }, | |
| 18 | { id: "diff", label: "Diff" }, | |
| 19 | { id: "dockerfile", label: "Dockerfile" }, | |
| 20 | { id: "elixir", label: "Elixir" }, | |
| 21 | { id: "go", label: "Go" }, | |
| 22 | { id: "graphql", label: "GraphQL" }, | |
| 23 | { id: "haskell", label: "Haskell" }, | |
| 24 | { id: "html", label: "HTML" }, | |
| 25 | { id: "java", label: "Java" }, | |
| 26 | { id: "javascript", label: "JavaScript" }, | |
| 27 | { id: "json", label: "JSON" }, | |
| 28 | { id: "jsx", label: "JSX" }, | |
| 29 | { id: "kotlin", label: "Kotlin" }, | |
| 30 | { id: "lua", label: "Lua" }, | |
| 31 | { id: "markdown", label: "Markdown" }, | |
| 32 | { id: "php", label: "PHP" }, | |
| 33 | { id: "powershell", label: "PowerShell" }, | |
| 34 | { id: "python", label: "Python" }, | |
| 35 | { id: "r", label: "R" }, | |
| 36 | { id: "ruby", label: "Ruby" }, | |
| 37 | { id: "rust", label: "Rust" }, | |
| 38 | { id: "scala", label: "Scala" }, | |
| 39 | { id: "scss", label: "SCSS" }, | |
| 40 | { id: "sql", label: "SQL" }, | |
| 41 | { id: "svelte", label: "Svelte" }, | |
| 42 | { id: "swift", label: "Swift" }, | |
| 43 | { id: "toml", label: "TOML" }, | |
| 44 | { id: "tsx", label: "TSX" }, | |
| 45 | { id: "typescript", label: "TypeScript" }, | |
| 46 | { id: "vue", label: "Vue" }, | |
| 47 | { id: "xml", label: "XML" }, | |
| 48 | { id: "yaml", label: "YAML" }, | |
| 49 | { id: "zig", label: "Zig" }, | |
| 50 | ] as const; | |
| 51 | ||
| 52 | export type CodeBlockLanguage = { readonly id: string; readonly label: string }; | |
| 53 | ||
| 54 | /** Short names people type on the fence line, labelled like the entry they mean. */ | |
| 55 | const LANGUAGE_ALIASES: Record<string, string> = { | |
| 56 | cs: "csharp", | |
| 57 | "c++": "cpp", | |
| 58 | js: "javascript", | |
| 59 | md: "markdown", | |
| 60 | py: "python", | |
| 61 | rb: "ruby", | |
| 62 | rs: "rust", | |
| 63 | sh: "bash", | |
| 64 | shell: "bash", | |
| 65 | ts: "typescript", | |
| 66 | yml: "yaml", | |
| 67 | }; | |
| 68 | ||
| 69 | /** The language is the info string's first word; the rest belongs to the fence. */ | |
| 70 | export function languageOfInfoString(info: string): string { | |
| 71 | return info.trim().split(/\s+/, 1)[0] ?? ""; | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Replaces the language and keeps everything around it: the whitespace the | |
| 76 | * fence had before it and whatever follows it. Plain text clears it, unless | |
| 77 | * something follows: a bare `title=x` would read as the language, so that | |
| 78 | * keeps `text` in front. | |
| 79 | */ | |
| 80 | export function withInfoStringLanguage(info: string, language: string): string { | |
| 81 | const leading = /^\s*/.exec(info)![0]; | |
| 82 | // The language is the first word, so what follows it is empty or starts | |
| 83 | // with whitespace. | |
| 84 | const rest = info.slice(leading.length + languageOfInfoString(info).length); | |
| 85 | if (language) return `${leading}${language}${rest}`; | |
| 86 | return rest.trim() ? `${leading}text${rest}` : ""; | |
| 87 | } | |
| 88 | ||
| 89 | /** The canonical entry a language names, if the picker lists it. */ | |
| 90 | export function codeLanguageEntry(language: string): CodeBlockLanguage | null { | |
| 91 | const id = language === "text" ? "" : (LANGUAGE_ALIASES[language.toLowerCase()] ?? language); | |
| 92 | return CODE_BLOCK_LANGUAGES.find((entry) => entry.id === id.toLowerCase()) ?? null; | |
| 93 | } | |
| 94 | ||
| 95 | export function codeLanguageLabel(language: string): string { | |
| 96 | return codeLanguageEntry(language)?.label ?? language; | |
| 97 | } |
apps/web/src/composer-list-continuation.test.ts+18-1| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { listContinuationForEnter, listIndentForTab } from "./composer-list-continuation"; | |
| 3 | import { | |
| 4 | listContinuationForEnter, | |
| 5 | listIndentForTab, | |
| 6 | nextOrderedMarkerText, | |
| 7 | } from "./composer-list-continuation"; | |
| 4 | 8 | |
| 5 | 9 | function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) { |
| 6 | 10 | return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end); |
| ... | ... | @@ -66,3 +70,16 @@ describe("composer list continuation", () => { |
| 66 | 70 | expect(listIndentForTab("- foo", 1, 3)).toBeNull(); |
| 67 | 71 | }); |
| 68 | 72 | }); |
| 73 | ||
| 74 | describe("nextOrderedMarkerText", () => { | |
| 75 | it.each([ | |
| 76 | ["1.", "2."], | |
| 77 | ["1)", "2)"], | |
| 78 | ["09.", "10."], | |
| 79 | ["001)", "002)"], | |
| 80 | ["99.", "100."], | |
| 81 | ["99999999999999999999.", "99999999999999999999."], | |
| 82 | ])("counts %s up to %s", (marker, expected) => { | |
| 83 | expect(nextOrderedMarkerText(marker)).toBe(expected); | |
| 84 | }); | |
| 85 | }); |
apps/web/src/composer-list-continuation.ts+16-5| ... | ... | @@ -55,13 +55,24 @@ function parseListMarker(line: string): { marker: ListMarker; markerEnd: number |
| 55 | 55 | return null; |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | /** | |
| 59 | * The ordered marker that follows `3.` or `09)`: one higher, same delimiter, | |
| 60 | * zero padding kept, and a number too large to count left as typed. Shared | |
| 61 | * with the rich editor's native split so both modes count the same way. | |
| 62 | */ | |
| 63 | export function nextOrderedMarkerText(marker: string): string { | |
| 64 | const numberText = marker.slice(0, -1); | |
| 65 | const delimiter = marker.slice(-1); | |
| 66 | const number = Number.parseInt(numberText, 10); | |
| 67 | const next = Number.isSafeInteger(number) | |
| 68 | ? String(number + 1).padStart(numberText.length, "0") | |
| 69 | : numberText; | |
| 70 | return `${next}${delimiter}`; | |
| 71 | } | |
| 72 | ||
| 58 | 73 | function nextMarkerText(marker: ListMarker): string { |
| 59 | 74 | 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} `; | |
| 75 | return `${marker.indent}${nextOrderedMarkerText(`${marker.numberText}${marker.delimiter}`)} `; | |
| 65 | 76 | } |
| 66 | 77 | if (marker.kind === "task") { |
| 67 | 78 | return `${marker.indent}- [ ] `; |
apps/web/src/composer-list-split.test.ts created+265| ... | ... | @@ -0,0 +1,265 @@ |
| 1 | // @vitest-environment jsdom | |
| 2 | ||
| 3 | import { Editor } from "@tiptap/core"; | |
| 4 | import { TaskList } from "@tiptap/extension-task-list"; | |
| 5 | import { TextSelection } from "@tiptap/pm/state"; | |
| 6 | import StarterKit from "@tiptap/starter-kit"; | |
| 7 | import { afterEach, describe, expect, it } from "vite-plus/test"; | |
| 8 | ||
| 9 | import { | |
| 10 | backspaceAcrossList, | |
| 11 | buildDocJson, | |
| 12 | buildTiptapContent, | |
| 13 | ComposerBlockExtensions, | |
| 14 | ComposerCodeBlockExtension, | |
| 15 | ComposerListExtensions, | |
| 16 | ComposerTaskItemExtension, | |
| 17 | convertBulletItemToTask, | |
| 18 | deleteAcrossList, | |
| 19 | serializeEditorDoc, | |
| 20 | splitOrLiftListItem, | |
| 21 | } from "./composer-rich-text-doc"; | |
| 22 | ||
| 23 | // ProseMirror's DOM observer can flush on a timer after a test ends, which | |
| 24 | // throws once jsdom is torn down, so every editor is destroyed after its test. | |
| 25 | const editors: Editor[] = []; | |
| 26 | afterEach(() => { | |
| 27 | for (const editor of editors.splice(0)) editor.destroy(); | |
| 28 | }); | |
| 29 | ||
| 30 | /** | |
| 31 | * Splits the last item of `value` the way Shift+Enter does, types `b`, and | |
| 32 | * returns the stored Markdown. Tiptap carries every attribute left at the | |
| 33 | * default `keepOnSplit: true` onto the new item and merges the overrides on | |
| 34 | * top, so the new item must keep the source marker, indent and spacing. | |
| 35 | */ | |
| 36 | function makeEditor(value: string) { | |
| 37 | const editor = new Editor({ | |
| 38 | extensions: [ | |
| 39 | StarterKit.configure({ | |
| 40 | bulletList: false, | |
| 41 | orderedList: false, | |
| 42 | listItem: false, | |
| 43 | codeBlock: false, | |
| 44 | blockquote: false, | |
| 45 | heading: false, | |
| 46 | horizontalRule: false, | |
| 47 | trailingNode: false, | |
| 48 | listKeymap: false, | |
| 49 | }), | |
| 50 | ...ComposerListExtensions, | |
| 51 | TaskList, | |
| 52 | ComposerTaskItemExtension, | |
| 53 | ComposerCodeBlockExtension, | |
| 54 | ...ComposerBlockExtensions, | |
| 55 | ], | |
| 56 | content: buildDocJson(value, (name) => ({ label: name, description: null })), | |
| 57 | }); | |
| 58 | editors.push(editor); | |
| 59 | return editor; | |
| 60 | } | |
| 61 | ||
| 62 | function splitLastItem(value: string, type: "listItem" | "taskItem", overrides: object) { | |
| 63 | const editor = makeEditor(value); | |
| 64 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); | |
| 65 | editor.commands.splitListItem(type, overrides); | |
| 66 | editor.view.dispatch(editor.state.tr.insertText("b")); | |
| 67 | return serializeEditorDoc(editor.state.doc).value; | |
| 68 | } | |
| 69 | ||
| 70 | describe("splitting a list item", () => { | |
| 71 | it.each([ | |
| 72 | ["* a", { space: " " }, "* a\n* b"], | |
| 73 | ["+ a", { space: " " }, "+ a\n+ b"], | |
| 74 | ["- p\n - a", { space: " " }, "- p\n - a\n - b"], | |
| 75 | ["3) a", { marker: "4)", space: " " }, "3) a\n4) b"], | |
| 76 | ])("keeps the source marker and indent of %s", (value, overrides, expected) => { | |
| 77 | expect(splitLastItem(value, "listItem", overrides)).toBe(expected); | |
| 78 | }); | |
| 79 | ||
| 80 | it("keeps the indent of a nested task", () => { | |
| 81 | expect(splitLastItem("- [ ] p\n - [ ] a", "taskItem", { checked: false })).toBe( | |
| 82 | "- [ ] p\n - [ ] a\n - [ ] b", | |
| 83 | ); | |
| 84 | }); | |
| 85 | }); | |
| 86 | ||
| 87 | /** Puts the caret `offset` characters into the first text node holding `text`. */ | |
| 88 | function placeCaret(editor: Editor, text: string, offset = 0) { | |
| 89 | let caret = -1; | |
| 90 | editor.state.doc.descendants((node, pos) => { | |
| 91 | if (caret < 0 && node.isText && node.text!.includes(text)) { | |
| 92 | caret = pos + node.text!.indexOf(text) + offset; | |
| 93 | } | |
| 94 | }); | |
| 95 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, caret))); | |
| 96 | } | |
| 97 | ||
| 98 | /** The stored draft, after checking a rebuild from it gives back the same document. */ | |
| 99 | function storedDraft(editor: Editor) { | |
| 100 | const stored = serializeEditorDoc(editor.state.doc).value; | |
| 101 | expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); | |
| 102 | return stored; | |
| 103 | } | |
| 104 | ||
| 105 | describe("Shift+Enter twice on an item", () => { | |
| 106 | // The second Shift+Enter lifts the new empty item one level. The stored | |
| 107 | // draft must write it at its new depth, so a rebuild from that draft gives | |
| 108 | // back the document the user is looking at. | |
| 109 | it.each([ | |
| 110 | ["- a\n - x", "- a\n - x\n- b"], | |
| 111 | ["* a\n * x", "* a\n * x\n* b"], | |
| 112 | ["1. a\n 1. x", "1. a\n 1. x\n2. b"], | |
| 113 | ["- a\n - x\n- c", "- a\n - x\n- b\n- c"], | |
| 114 | ["- a\n - x\n - y", "- a\n - x\n- b\n - y"], | |
| 115 | ["- [ ] a\n - [ ] x", "- [ ] a\n - [ ] x\n- [ ] b"], | |
| 116 | ["- [x] a\n - [ ] x\n- [ ] c", "- [x] a\n - [ ] x\n- [ ] b\n- [ ] c"], | |
| 117 | ])("after x in %j writes %j", (value, expected) => { | |
| 118 | const editor = makeEditor(value); | |
| 119 | let caret = -1; | |
| 120 | editor.state.doc.descendants((node, pos) => { | |
| 121 | if (node.isText && node.text === "x") caret = pos + 1; | |
| 122 | }); | |
| 123 | editor.view.dispatch( | |
| 124 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, caret)), | |
| 125 | ); | |
| 126 | expect(splitOrLiftListItem(editor)).toBe(true); | |
| 127 | expect(splitOrLiftListItem(editor)).toBe(true); | |
| 128 | editor.view.dispatch(editor.state.tr.insertText("b")); | |
| 129 | const stored = serializeEditorDoc(editor.state.doc).value; | |
| 130 | expect(stored).toBe(expected); | |
| 131 | // Same nesting after a rebuild; spacing defaults may differ but write the same. | |
| 132 | expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); | |
| 133 | }); | |
| 134 | }); | |
| 135 | ||
| 136 | describe("Shift+Enter in the middle of an ordered list", () => { | |
| 137 | it.each([ | |
| 138 | ["1. a\n2. x\n3. c\n4. d", "1. a\n2. x\n3. b\n4. c\n5. d"], | |
| 139 | ["1) a\n2) x\n3) c", "1) a\n2) x\n3) b\n4) c"], | |
| 140 | ["1. a\n 1. x\n 2. c\n2. d", "1. a\n 1. x\n 2. b\n 3. c\n2. d"], | |
| 141 | ])("after x in %j writes %j", (value, expected) => { | |
| 142 | const editor = makeEditor(value); | |
| 143 | placeCaret(editor, "x", 1); | |
| 144 | expect(splitOrLiftListItem(editor)).toBe(true); | |
| 145 | editor.view.dispatch(editor.state.tr.insertText("b")); | |
| 146 | expect(storedDraft(editor)).toBe(expected); | |
| 147 | }); | |
| 148 | }); | |
| 149 | ||
| 150 | describe("Shift+Enter on an empty task under a bullet", () => { | |
| 151 | // There is no task list one level up to move it into, and the item cannot | |
| 152 | // hold a second line, so the draft stays what the editor shows. | |
| 153 | it.each(["- a\n - [ ] x", "- [ ] a\n - x"])("in %j keeps the draft honest", (value) => { | |
| 154 | const editor = makeEditor(value); | |
| 155 | placeCaret(editor, "x", 1); | |
| 156 | splitOrLiftListItem(editor); | |
| 157 | splitOrLiftListItem(editor); | |
| 158 | storedDraft(editor); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("Backspace at the start of a line", () => { | |
| 163 | it.each([ | |
| 164 | ["- a\n - x", "x", "- a\n- x"], | |
| 165 | ["- a\n - x\n - y", "x", "- a\n- x\n - y"], | |
| 166 | ["- [ ] a\n - [ ] x", "x", "- [ ] a\n- [ ] x"], | |
| 167 | ["- a\n- b\n- c", "b", "- a\nb\n- c"], | |
| 168 | // A task under a bullet has no task list to move into, so it joins up. | |
| 169 | ["- a\n - [ ] x", "x", "- ax"], | |
| 170 | ["- a\n - x\nb", "b", "- a\n - xb"], | |
| 171 | ["1. a\nb", "b", "1. ab"], | |
| 172 | ])("in %j at %s writes %j", (value, at, expected) => { | |
| 173 | const editor = makeEditor(value); | |
| 174 | placeCaret(editor, at); | |
| 175 | expect(backspaceAcrossList(editor)).toBe(true); | |
| 176 | expect(storedDraft(editor)).toBe(expected); | |
| 177 | }); | |
| 178 | ||
| 179 | it.each([ | |
| 180 | ["- a\n```\nl1\nl2\n```", "- a\nl1\nl2"], | |
| 181 | ["p\n```ts\nl1\n```", "p\nl1"], | |
| 182 | ])("removes the fence of %j rather than joining its code up", (value, expected) => { | |
| 183 | const editor = makeEditor(value); | |
| 184 | placeCaret(editor, "l1"); | |
| 185 | editor.commands.keyboardShortcut("Backspace"); | |
| 186 | expect(storedDraft(editor)).toBe(expected); | |
| 187 | }); | |
| 188 | }); | |
| 189 | ||
| 190 | describe("Delete at the end of a line", () => { | |
| 191 | it.each([ | |
| 192 | ["- a\n- b", "- ab"], | |
| 193 | ["- a\n - x\n- c", "- ax\n- c"], | |
| 194 | ["- a\n\nb", "- a\nb"], | |
| 195 | ])("in %j writes %j", (value, expected) => { | |
| 196 | const editor = makeEditor(value); | |
| 197 | placeCaret(editor, "a", 1); | |
| 198 | expect(deleteAcrossList(editor)).toBe(true); | |
| 199 | expect(storedDraft(editor)).toBe(expected); | |
| 200 | }); | |
| 201 | ||
| 202 | it.each(["- a\n```\nl1\n```", "a\n```\nl1\n```"])("leaves the fence in %j alone", (value) => { | |
| 203 | const editor = makeEditor(value); | |
| 204 | placeCaret(editor, "a", 1); | |
| 205 | editor.commands.keyboardShortcut("Delete"); | |
| 206 | expect(storedDraft(editor)).toBe(value); | |
| 207 | }); | |
| 208 | }); | |
| 209 | ||
| 210 | describe("blocks that a list item or quote cannot hold", () => { | |
| 211 | it("puts lines pasted at the end of an item after the list", () => { | |
| 212 | const editor = makeEditor("- a"); | |
| 213 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); | |
| 214 | const lines = buildTiptapContent("one\ntwo", (name) => ({ label: name, description: null })); | |
| 215 | editor.commands.insertContent(lines); | |
| 216 | expect(storedDraft(editor)).toBe("- a\none\ntwo"); | |
| 217 | }); | |
| 218 | ||
| 219 | it("will not make a task list inside a quote", () => { | |
| 220 | const editor = makeEditor("> q"); | |
| 221 | placeCaret(editor, "q"); | |
| 222 | expect(editor.commands.wrapInList("taskList")).toBe(false); | |
| 223 | expect(storedDraft(editor)).toBe("> q"); | |
| 224 | }); | |
| 225 | }); | |
| 226 | ||
| 227 | describe("[ ] typed at the start of a bullet item", () => { | |
| 228 | // The value holds `[ ]` already; the rule fires on the space after it. | |
| 229 | it.each([ | |
| 230 | ["* [ ]x", "- [ ] x"], | |
| 231 | ["* a\n* [ ]x\n* c", "* a\n- [ ] x\n* c"], | |
| 232 | ["- a\n * [ ]x", "- a\n - [ ] x"], | |
| 233 | ["- a\n - [ ]x\n - y", "- a\n - [ ] x\n - y"], | |
| 234 | ["- [ ]x\n - y", "- [ ] x\n - y"], | |
| 235 | ["- [ ]x", "- [ ] x"], | |
| 236 | ])("makes the item in %j a task where it stands", (value, expected) => { | |
| 237 | const editor = makeEditor(value); | |
| 238 | placeCaret(editor, "[ ]x"); | |
| 239 | const from = editor.state.selection.from; | |
| 240 | editor.view.dispatch( | |
| 241 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from + 3)), | |
| 242 | ); | |
| 243 | const { tr } = editor.state; | |
| 244 | convertBulletItemToTask(tr, from, from + 3, false); | |
| 245 | editor.view.dispatch(tr); | |
| 246 | expect(editor.state.selection.$from.parent.textContent).toBe("x"); | |
| 247 | expect(editor.state.selection.$from.parentOffset).toBe(0); | |
| 248 | expect(storedDraft(editor)).toBe(expected); | |
| 249 | }); | |
| 250 | }); | |
| 251 | ||
| 252 | describe("a rule at the end of the draft", () => { | |
| 253 | it("keeps an empty line after it to type on", () => { | |
| 254 | const editor = makeEditor("a\n---"); | |
| 255 | // Removing the line after the rule puts one straight back. | |
| 256 | editor.view.dispatch( | |
| 257 | editor.state.tr.delete(editor.state.doc.content.size - 2, editor.state.doc.content.size), | |
| 258 | ); | |
| 259 | expect(editor.state.doc.lastChild?.type.name).toBe("paragraph"); | |
| 260 | expect(storedDraft(editor)).toBe("a\n---"); | |
| 261 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); | |
| 262 | editor.view.dispatch(editor.state.tr.insertText("b")); | |
| 263 | expect(storedDraft(editor)).toBe("a\n---\nb"); | |
| 264 | }); | |
| 265 | }); |
apps/web/src/composer-rich-text-doc.test.ts+529| ... | ... | @@ -5,9 +5,15 @@ import { Node as ProseMirrorNode } from "@tiptap/pm/model"; |
| 5 | 5 | import { EditorState, TextSelection } from "@tiptap/pm/state"; |
| 6 | 6 | import { describe, expect, it } from "vite-plus/test"; |
| 7 | 7 | |
| 8 | import { collapseExpandedComposerCursor } from "./composer-logic"; | |
| 9 | ||
| 8 | 10 | import { |
| 9 | 11 | buildDocJson, |
| 12 | buildTiptapContent, | |
| 10 | 13 | caretTakesMarksBefore, |
| 14 | ComposerBlockExtensions, | |
| 15 | ComposerCodeBlockExtension, | |
| 16 | ComposerListExtensions, | |
| 11 | 17 | collapsedToFlat, |
| 12 | 18 | ComposerCodeExtension, |
| 13 | 19 | ComposerTaskItemExtension, |
| ... | ... | @@ -16,6 +22,7 @@ import { |
| 16 | 22 | flatToPm, |
| 17 | 23 | pmToFlat, |
| 18 | 24 | serializeEditorDoc, |
| 25 | serializeSelection, | |
| 19 | 26 | stepCaretAcrossStyledEdge, |
| 20 | 27 | } from "./composer-rich-text-doc"; |
| 21 | 28 | |
| ... | ... | @@ -64,6 +71,9 @@ const schema = getSchemaByResolvedExtensions( |
| 64 | 71 | }), |
| 65 | 72 | TaskList, |
| 66 | 73 | ComposerTaskItemExtension, |
| 74 | ComposerCodeBlockExtension, | |
| 75 | ...ComposerListExtensions, | |
| 76 | ...ComposerBlockExtensions, | |
| 67 | 77 | ]), |
| 68 | 78 | ); |
| 69 | 79 | |
| ... | ... | @@ -344,6 +354,437 @@ describe("composer rich text document model", () => { |
| 344 | 354 | expect(roundTripPlain(value).value).toBe(value); |
| 345 | 355 | }); |
| 346 | 356 | |
| 357 | it.each([ | |
| 358 | "- one\n- two", | |
| 359 | "* star\n* star two", | |
| 360 | "+ plus", | |
| 361 | "* star\n+ plus", | |
| 362 | "1. first\n2. second", | |
| 363 | "1) paren\n2) paren", | |
| 364 | "3. starts at three\n4. four", | |
| 365 | "01. zero padded\n02. two", | |
| 366 | "- parent\n - child\n - sibling\n- uncle", | |
| 367 | "1. ordered\n - bullet child\n2. next", | |
| 368 | "- outer\n 1. inner ordered\n 2. more\n- outer again", | |
| 369 | "- wide space item", | |
| 370 | "-", | |
| 371 | "1.", | |
| 372 | "- \n- second", | |
| 373 | "- **bold** item with @README.md", | |
| 374 | "- [ ] task\n- bullet after", | |
| 375 | "- bullet\n- [x] task after", | |
| 376 | "para\n- item\npara", | |
| 377 | "\t- tab indented\n\t- again", | |
| 378 | " - leading indent only", | |
| 379 | "- item\n\n- after a blank", | |
| 380 | "- item one\n```ts\ncode\n```\n- item two", | |
| 381 | "-no space stays literal", | |
| 382 | "1.no space stays literal", | |
| 383 | "10. ten\n11. eleven", | |
| 384 | ])("round-trips the list %s through a real ProseMirror document", (value) => { | |
| 385 | expect(roundTrip(value).value).toBe(value); | |
| 386 | }); | |
| 387 | ||
| 388 | it.each([ | |
| 389 | "- one\n- two", | |
| 390 | "1. first\n2. second", | |
| 391 | "- parent\n - child", | |
| 392 | "* star\n+ plus", | |
| 393 | "-no space stays literal", | |
| 394 | ])("keeps the list %s literal in plain mode", (value) => { | |
| 395 | expect(roundTripPlain(value).value).toBe(value); | |
| 396 | }); | |
| 397 | ||
| 398 | it.each(["- one\n- two", "1. a\n - b\n2. c", "- **bold** @README.md tail", "-"])( | |
| 399 | "maps every document offset of the list %s through collapsed coordinates and back", | |
| 400 | (value) => { | |
| 401 | const map = roundTrip(value); | |
| 402 | expect(map.value).toBe(value); | |
| 403 | for (let flat = 0; flat <= map.docLength; flat += 1) { | |
| 404 | expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); | |
| 405 | } | |
| 406 | }, | |
| 407 | ); | |
| 408 | ||
| 409 | it("clamps offsets inside a list marker to the start of the item text", () => { | |
| 410 | const value = "- item"; | |
| 411 | const map = roundTrip(value); | |
| 412 | // The marker owns no document characters, like a checkbox. | |
| 413 | for (let collapsed = 0; collapsed <= "- ".length; collapsed += 1) { | |
| 414 | expect(collapsedToFlat(map, collapsed)).toBe(0); | |
| 415 | } | |
| 416 | expect(collapsedToFlat(map, "- it".length)).toBe(2); | |
| 417 | expect(flatToCollapsed(map, 0)).toBe("- ".length); | |
| 418 | }); | |
| 419 | ||
| 420 | it.each([ | |
| 421 | "> quoted", | |
| 422 | "> line one\n> line two", | |
| 423 | ">no space", | |
| 424 | "> two spaces", | |
| 425 | "> a\n>b", | |
| 426 | ">", | |
| 427 | "> **bold** and @README.md inside", | |
| 428 | "> - looks like a list but stays quote text", | |
| 429 | "> > nested stays literal inside the quote", | |
| 430 | "before\n> quoted\nafter", | |
| 431 | "> quote\n\n> another", | |
| 432 | "- item\n> quote after list", | |
| 433 | ])("round-trips the quote %s through a real ProseMirror document", (value) => { | |
| 434 | expect(roundTrip(value).value).toBe(value); | |
| 435 | }); | |
| 436 | ||
| 437 | it.each([ | |
| 438 | "---", | |
| 439 | "***", | |
| 440 | "___", | |
| 441 | "- - -", | |
| 442 | "* * *", | |
| 443 | "-----", | |
| 444 | "--- ", | |
| 445 | "text\n---\nmore", | |
| 446 | "- item\n---\n- item two", | |
| 447 | "```\n---\n```", | |
| 448 | "--", | |
| 449 | "-- -", | |
| 450 | "---text", | |
| 451 | ])("round-trips the rule %s through a real ProseMirror document", (value) => { | |
| 452 | expect(roundTrip(value).value).toBe(value); | |
| 453 | }); | |
| 454 | ||
| 455 | it("parses rules ahead of lists and emphasis", () => { | |
| 456 | const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({ | |
| 457 | label: n, | |
| 458 | description: null, | |
| 459 | })); | |
| 460 | expect(json.content.map((block) => block.type)).toEqual([ | |
| 461 | "horizontalRule", | |
| 462 | "horizontalRule", | |
| 463 | "horizontalRule", | |
| 464 | "paragraph", | |
| 465 | "horizontalRule", | |
| 466 | "bulletList", | |
| 467 | ]); | |
| 468 | }); | |
| 469 | ||
| 470 | it.each([ | |
| 471 | "# Heading", | |
| 472 | "## Two", | |
| 473 | "###### Six", | |
| 474 | "####### seven hashes stays a paragraph", | |
| 475 | "# two spaces", | |
| 476 | "#\tTab", | |
| 477 | "# Trailing hashes stay literal #", | |
| 478 | "#1234", | |
| 479 | "#1234 is a pull request, not a heading", | |
| 480 | "# Heading with **bold** and @README.md", | |
| 481 | "#", | |
| 482 | "# ", | |
| 483 | "text\n# Heading\ntext", | |
| 484 | "# Heading\n- item\n> quote\n---", | |
| 485 | ])("round-trips the heading %s through a real ProseMirror document", (value) => { | |
| 486 | expect(roundTrip(value).value).toBe(value); | |
| 487 | }); | |
| 488 | ||
| 489 | it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])( | |
| 490 | "keeps the block %s literal in plain mode", | |
| 491 | (value) => { | |
| 492 | expect(roundTripPlain(value).value).toBe(value); | |
| 493 | }, | |
| 494 | ); | |
| 495 | ||
| 496 | it("parses the blocks it renders as the right node types", () => { | |
| 497 | const json = buildDocJson("# Title\n> quote\n---\n#1234 ref\n- - -", (n) => ({ | |
| 498 | label: n, | |
| 499 | description: null, | |
| 500 | })); | |
| 501 | expect(json.content.map((block) => block.type)).toEqual([ | |
| 502 | "heading", | |
| 503 | "blockquote", | |
| 504 | "horizontalRule", | |
| 505 | "paragraph", | |
| 506 | "horizontalRule", | |
| 507 | // The empty line a final rule keeps for the caret. | |
| 508 | "paragraph", | |
| 509 | ]); | |
| 510 | }); | |
| 511 | ||
| 512 | it.each([ | |
| 513 | ["---", "---"], | |
| 514 | ["a\n---", "a\n---"], | |
| 515 | // That line and a trailing newline are the same document; the newline goes. | |
| 516 | ["---\n", "---"], | |
| 517 | ])("gives the caret a line after the final rule of %j, written %j", (value, expected) => { | |
| 518 | const map = roundTrip(value); | |
| 519 | expect(map.value).toBe(expected); | |
| 520 | const json = buildDocJson(value, () => ({ label: "", description: null })); | |
| 521 | const doc = ProseMirrorNode.fromJSON(schema, json); | |
| 522 | expect(doc.resolve(flatToPm(map, map.docLength)).parent.isTextblock).toBe(true); | |
| 523 | }); | |
| 524 | ||
| 525 | it("parses rules ahead of lists and emphasis", () => { | |
| 526 | const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({ | |
| 527 | label: n, | |
| 528 | description: null, | |
| 529 | })); | |
| 530 | expect(json.content.map((block) => block.type)).toEqual([ | |
| 531 | "horizontalRule", | |
| 532 | "horizontalRule", | |
| 533 | "horizontalRule", | |
| 534 | "paragraph", | |
| 535 | "horizontalRule", | |
| 536 | "bulletList", | |
| 537 | ]); | |
| 538 | }); | |
| 539 | ||
| 540 | it.each([ | |
| 541 | "# Heading", | |
| 542 | "## Two", | |
| 543 | "###### Six", | |
| 544 | "####### seven hashes stays a paragraph", | |
| 545 | "# two spaces", | |
| 546 | "#\tTab", | |
| 547 | "# Trailing hashes stay literal #", | |
| 548 | "#1234", | |
| 549 | "#1234 is a pull request, not a heading", | |
| 550 | "# Heading with **bold** and @README.md", | |
| 551 | "#", | |
| 552 | "# ", | |
| 553 | "text\n# Heading\ntext", | |
| 554 | "# Heading\n- item\n> quote\n---", | |
| 555 | ])("round-trips the heading %s through a real ProseMirror document", (value) => { | |
| 556 | expect(roundTrip(value).value).toBe(value); | |
| 557 | }); | |
| 558 | ||
| 559 | it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])( | |
| 560 | "keeps the block %s literal in plain mode", | |
| 561 | (value) => { | |
| 562 | expect(roundTripPlain(value).value).toBe(value); | |
| 563 | }, | |
| 564 | ); | |
| 565 | ||
| 566 | it("parses a quote as a blockquote of one paragraph per line", () => { | |
| 567 | const json = buildDocJson("> a\n> b\n>c", (n) => ({ label: n, description: null })); | |
| 568 | expect(json.content.map((block) => block.type)).toEqual(["blockquote", "blockquote"]); | |
| 569 | expect((json.content[0] as { content: unknown[] }).content).toHaveLength(2); | |
| 570 | }); | |
| 571 | ||
| 572 | it.each([ | |
| 573 | "> a\n> b", | |
| 574 | "> **q** @README.md", | |
| 575 | "text\n---\nmore", | |
| 576 | "---\ntext", | |
| 577 | "# Heading text", | |
| 578 | "## **b** @README.md", | |
| 579 | ])( | |
| 580 | "maps every document offset of the block %s through collapsed coordinates and back", | |
| 581 | (value) => { | |
| 582 | const map = roundTrip(value); | |
| 583 | expect(map.value).toBe(value); | |
| 584 | for (let flat = 0; flat <= map.docLength; flat += 1) { | |
| 585 | expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); | |
| 586 | } | |
| 587 | }, | |
| 588 | ); | |
| 589 | ||
| 590 | it("clamps offsets inside a quote or heading marker to the start of the text", () => { | |
| 591 | for (const [value, prefix] of [ | |
| 592 | ["> quoted", "> "], | |
| 593 | ["# Heading", "# "], | |
| 594 | ] as const) { | |
| 595 | const map = roundTrip(value); | |
| 596 | for (let collapsed = 0; collapsed <= prefix.length; collapsed += 1) { | |
| 597 | expect(collapsedToFlat(map, collapsed)).toBe(0); | |
| 598 | } | |
| 599 | expect(flatToCollapsed(map, 0)).toBe(prefix.length); | |
| 600 | } | |
| 601 | }); | |
| 602 | ||
| 603 | it.each([ | |
| 604 | "```\ncode\n```", | |
| 605 | "```ts\nconst a = 1;\n```", | |
| 606 | "```ts\nconst a = 1;\n```\n", | |
| 607 | "before\n```ts\nconst a = 1;\n```\nafter", | |
| 608 | "```\n```", | |
| 609 | "```ts\nline one\nline two\nline three\n```", | |
| 610 | "```ts\n indented\n deeper\n```", | |
| 611 | "```js title=example\ncode\n```", | |
| 612 | "~~~py\ncode\n~~~", | |
| 613 | "````\n```\n````", | |
| 614 | "```ts\ncode without a closing fence", | |
| 615 | "```", | |
| 616 | "```ts\n**not bold** and @README.md stay literal\n```", | |
| 617 | "```ts\ncode\n````", | |
| 618 | "- [ ] task\n```ts\ncode\n```\n- [ ] after", | |
| 619 | " ```ts\nindented fence stays a paragraph\n ```", | |
| 620 | ])("round-trips the fenced block %s", (value) => { | |
| 621 | expect(roundTrip(value).value).toBe(value); | |
| 622 | }); | |
| 623 | ||
| 624 | it.each(["```ts\nconst a = 1;\n```", "```\n```", "before\n```ts\ncode\n```\nafter"])( | |
| 625 | "maps every document offset of %s through collapsed coordinates and back", | |
| 626 | (value) => { | |
| 627 | const map = roundTrip(value); | |
| 628 | expect(map.value).toBe(value); | |
| 629 | for (let flat = 0; flat <= map.docLength; flat += 1) { | |
| 630 | expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); | |
| 631 | } | |
| 632 | }, | |
| 633 | ); | |
| 634 | ||
| 635 | it("reads blocks written with CRLF line endings", () => { | |
| 636 | const value = "# Title\r\n> quote\r\n---\r\n```ts\r\nconst a = 1;\r\n```\r\nafter"; | |
| 637 | const doc = buildDocJson(value, () => ({ label: "", description: null })); | |
| 638 | expect(doc.content.map((block) => block.type)).toEqual([ | |
| 639 | "heading", | |
| 640 | "blockquote", | |
| 641 | "horizontalRule", | |
| 642 | "codeBlock", | |
| 643 | "paragraph", | |
| 644 | ]); | |
| 645 | expect(roundTrip(value).value).toBe(value); | |
| 646 | }); | |
| 647 | ||
| 648 | it("keeps the end of the code inside the fence rather than after it", () => { | |
| 649 | const value = "```ts\nfunc();\n```"; | |
| 650 | const map = roundTrip(value); | |
| 651 | const endOfCode = "func();".length; | |
| 652 | // Not the end of the string: the closing fence is a line of its own. | |
| 653 | expect(flatToCollapsed(map, endOfCode)).toBe("```ts\nfunc();".length); | |
| 654 | expect(flatToMarkdown(map, endOfCode)).toBe("```ts\nfunc();".length); | |
| 655 | expect(collapsedToFlat(map, flatToCollapsed(map, endOfCode))).toBe(endOfCode); | |
| 656 | }); | |
| 657 | ||
| 658 | it("keeps the caret inside an empty fence", () => { | |
| 659 | const value = "before\n```\n```"; | |
| 660 | const map = roundTrip(value); | |
| 661 | const inside = "before\n".length; | |
| 662 | expect(flatToCollapsed(map, inside)).toBe("before\n```".length); | |
| 663 | expect(collapsedToFlat(map, flatToCollapsed(map, inside))).toBe(inside); | |
| 664 | }); | |
| 665 | ||
| 666 | it("still places the end of an inline mark after its markers", () => { | |
| 667 | // The fence rule must not leak into inline marks, whose trailing edge is | |
| 668 | // deliberately the position after the closing delimiter. | |
| 669 | const map = roundTrip("a **bold** c"); | |
| 670 | expect(flatToMarkdown(map, 6)).toBe(10); | |
| 671 | }); | |
| 672 | ||
| 673 | it("clamps offsets inside a fence to the edge of the code", () => { | |
| 674 | const value = "```ts\nab\n```"; | |
| 675 | const map = roundTrip(value); | |
| 676 | expect(map.value).toBe(value); | |
| 677 | // The opening fence owns no document characters, so every offset in it | |
| 678 | // lands on the first character of the code. | |
| 679 | for (let collapsed = 0; collapsed <= "```ts\n".length; collapsed += 1) { | |
| 680 | expect(collapsedToFlat(map, collapsed)).toBe(0); | |
| 681 | } | |
| 682 | expect(collapsedToFlat(map, "```ts\na".length)).toBe(1); | |
| 683 | // Everything from the closing newline onwards clamps to the code's end. | |
| 684 | for (let collapsed = "```ts\nab".length; collapsed <= value.length; collapsed += 1) { | |
| 685 | expect(collapsedToFlat(map, collapsed)).toBe(2); | |
| 686 | } | |
| 687 | }); | |
| 688 | ||
| 689 | it.each([ | |
| 690 | ["```\n\n```", "```\n```"], | |
| 691 | ["```\n", "```"], | |
| 692 | ])("canonicalizes the empty fence %s", (value, expected) => { | |
| 693 | expect(roundTrip(value).value).toBe(expected); | |
| 694 | }); | |
| 695 | ||
| 696 | it("keeps a chip in a fence info string as source and keeps later chips aligned", () => { | |
| 697 | const value = "```@README.md\ncode\n```\n$my-skill after"; | |
| 698 | expect(roundTrip(value).value).toBe(value); | |
| 699 | const json = buildDocJson(value, (n) => ({ label: n, description: null })); | |
| 700 | const after = json.content[1] as { | |
| 701 | content: { type: string; attrs?: { skillName?: string } }[]; | |
| 702 | }; | |
| 703 | expect(after.content.map((n) => n.type)).toEqual(["composer-skill", "text"]); | |
| 704 | expect(after.content[0]?.attrs?.skillName).toBe("my-skill"); | |
| 705 | }); | |
| 706 | ||
| 707 | it("keeps chips in a fence's info string and body each in their own place", () => { | |
| 708 | const value = "``` @a.md\nsee @b.md\n```"; | |
| 709 | expect(roundTrip(value).value).toBe(value); | |
| 710 | const json = buildDocJson(value, (n) => ({ label: n, description: null })); | |
| 711 | const code = json.content[0] as { attrs: { language: string } }; | |
| 712 | expect(code.attrs.language).toBe(" @a.md"); | |
| 713 | }); | |
| 714 | ||
| 715 | it.each([ | |
| 716 | ["listItem", { marker: "-", space: "" }, "bulletList", "- text"], | |
| 717 | ["listItem", { marker: "1.", space: "" }, "orderedList", "1. text"], | |
| 718 | ])("gives a bare %s a space once it has text", (item, attrs, list, expected) => { | |
| 719 | const doc = ProseMirrorNode.fromJSON(schema, { | |
| 720 | type: "doc", | |
| 721 | content: [ | |
| 722 | { | |
| 723 | type: list, | |
| 724 | content: [ | |
| 725 | { | |
| 726 | type: item, | |
| 727 | attrs, | |
| 728 | content: [{ type: "paragraph", content: [{ type: "text", text: "text" }] }], | |
| 729 | }, | |
| 730 | ], | |
| 731 | }, | |
| 732 | ], | |
| 733 | }); | |
| 734 | expect(serializeEditorDoc(doc).value).toBe(expected); | |
| 735 | expect(roundTrip("-").value).toBe("-"); | |
| 736 | }); | |
| 737 | ||
| 738 | it("writes code holding a closing fence inside a longer fence", () => { | |
| 739 | const doc = ProseMirrorNode.fromJSON(schema, { | |
| 740 | type: "doc", | |
| 741 | content: [ | |
| 742 | { | |
| 743 | type: "codeBlock", | |
| 744 | attrs: { fence: "```", close: "\n```" }, | |
| 745 | content: [{ type: "text", text: "initial\n```\nsecret" }], | |
| 746 | }, | |
| 747 | ], | |
| 748 | }); | |
| 749 | const stored = serializeEditorDoc(doc).value; | |
| 750 | expect(stored).toBe("````\ninitial\n```\nsecret\n````"); | |
| 751 | expect(buildDocJson(stored, () => ({ label: "", description: null })).content).toHaveLength(1); | |
| 752 | expect(roundTrip(stored).value).toBe(stored); | |
| 753 | }); | |
| 754 | ||
| 755 | it("closes an unclosed fence that has a block after it", () => { | |
| 756 | const doc = ProseMirrorNode.fromJSON(schema, { | |
| 757 | type: "doc", | |
| 758 | content: [ | |
| 759 | { type: "codeBlock", attrs: { close: "" }, content: [{ type: "text", text: "code" }] }, | |
| 760 | { type: "paragraph", content: [{ type: "text", text: "after" }] }, | |
| 761 | ], | |
| 762 | }); | |
| 763 | expect(serializeEditorDoc(doc).value).toBe("```\ncode\n```\nafter"); | |
| 764 | // At the end of the draft it stays unclosed, as it was written. | |
| 765 | expect(roundTrip("```\ncode").value).toBe("```\ncode"); | |
| 766 | }); | |
| 767 | ||
| 768 | it("counts a chip's source in a fence as one cursor position, as the draft store does", () => { | |
| 769 | const value = "```\nsee @b.md now\n```\nafter"; | |
| 770 | const map = roundTrip(value); | |
| 771 | expect(map.value).toBe(value); | |
| 772 | for (let flat = 0; flat <= map.docLength; flat += 1) { | |
| 773 | expect(flatToCollapsed(map, flat)).toBe( | |
| 774 | collapseExpandedComposerCursor(value, flatToMarkdown(map, flat)), | |
| 775 | ); | |
| 776 | } | |
| 777 | const afterChip = "see @b.md".length; | |
| 778 | expect(collapsedToFlat(map, flatToCollapsed(map, afterChip))).toBe(afterChip); | |
| 779 | const end = map.docLength; | |
| 780 | expect(collapsedToFlat(map, flatToCollapsed(map, end))).toBe(end); | |
| 781 | }); | |
| 782 | ||
| 783 | it("keeps fences literal in plain mode", () => { | |
| 784 | const value = "```ts\nconst a = 1;\n```"; | |
| 785 | expect(roundTripPlain(value).value).toBe(value); | |
| 786 | }); | |
| 787 | ||
| 347 | 788 | it("maps every document offset through collapsed coordinates and back", () => { |
| 348 | 789 | const value = "hi **bold** @README.md bye"; |
| 349 | 790 | const map = roundTrip(value); |
| ... | ... | @@ -353,6 +794,37 @@ describe("composer rich text document model", () => { |
| 353 | 794 | } |
| 354 | 795 | }); |
| 355 | 796 | |
| 797 | // Flipping the rich text setting remounts the editor, and the caret is | |
| 798 | // restored from the stored collapsed cursor. That only works because the | |
| 799 | // coordinate means the same thing on both sides of the flip. | |
| 800 | it.each([ | |
| 801 | "plain prose with no styling at all", | |
| 802 | "a chip @README.md counts one character in both modes", | |
| 803 | "$my-skill leads the line", | |
| 804 | "trailing newline\n", | |
| 805 | ])("resolves a collapsed cursor identically in both modes for %s", (value) => { | |
| 806 | const rich = roundTrip(value); | |
| 807 | const plain = roundTripPlain(value); | |
| 808 | expect(rich.value).toBe(value); | |
| 809 | expect(plain.value).toBe(value); | |
| 810 | for (let collapsed = 0; collapsed <= value.length; collapsed += 1) { | |
| 811 | expect(flatToMarkdown(rich, collapsedToFlat(rich, collapsed))).toBe( | |
| 812 | flatToMarkdown(plain, collapsedToFlat(plain, collapsed)), | |
| 813 | ); | |
| 814 | } | |
| 815 | }); | |
| 816 | ||
| 817 | it("clamps a cursor that was sitting inside a marker onto the styled text", () => { | |
| 818 | const value = "a **bold** c"; | |
| 819 | const plain = roundTripPlain(value); | |
| 820 | const rich = roundTrip(value); | |
| 821 | // Between the two asterisks: a real caret position in plain mode, and no | |
| 822 | // position at all in rich mode, where it lands on the first styled | |
| 823 | // character instead. The flip moves the caret by a marker's width at most. | |
| 824 | expect(flatToMarkdown(plain, collapsedToFlat(plain, 3))).toBe(3); | |
| 825 | expect(flatToMarkdown(rich, collapsedToFlat(rich, 3))).toBe(4); | |
| 826 | }); | |
| 827 | ||
| 356 | 828 | it("maps markdown offsets at styled edges onto document text", () => { |
| 357 | 829 | const value = "a **bold** c"; |
| 358 | 830 | const map = roundTrip(value); |
| ... | ... | @@ -365,6 +837,63 @@ describe("composer rich text document model", () => { |
| 365 | 837 | }); |
| 366 | 838 | }); |
| 367 | 839 | |
| 840 | describe("serializeSelection", () => { | |
| 841 | /** The stored Markdown of the selection from the start of `first` to the end of `last`. */ | |
| 842 | function copy(value: string, first: string, last: string) { | |
| 843 | const doc = ProseMirrorNode.fromJSON( | |
| 844 | schema, | |
| 845 | buildDocJson(value, (name) => ({ label: name, description: null })), | |
| 846 | ); | |
| 847 | let from = -1; | |
| 848 | let to = -1; | |
| 849 | doc.descendants((node, pos) => { | |
| 850 | if (!node.isText) return true; | |
| 851 | const text = node.text ?? ""; | |
| 852 | if (from < 0 && text.includes(first)) from = pos + text.indexOf(first); | |
| 853 | if (text.includes(last)) to = pos + text.lastIndexOf(last) + last.length; | |
| 854 | return false; | |
| 855 | }); | |
| 856 | return serializeSelection(doc, from, to); | |
| 857 | } | |
| 858 | ||
| 859 | it("keeps the markers of the list items it spans", () => { | |
| 860 | expect(copy("- one\n- two\n- three", "one", "two")).toBe("- one\n- two"); | |
| 861 | expect(copy("3) one\n4) two", "one", "two")).toBe("3) one\n4) two"); | |
| 862 | expect(copy("- [ ] one\n- [x] two", "one", "two")).toBe("- [ ] one\n- [x] two"); | |
| 863 | }); | |
| 864 | ||
| 865 | it("keeps the nesting of an item and its children", () => { | |
| 866 | expect(copy("- p\n - a\n - b", "p", "a")).toBe("- p\n - a"); | |
| 867 | }); | |
| 868 | ||
| 869 | it("keeps the prefix of the quote lines it spans", () => { | |
| 870 | expect(copy("> one\n> two", "one", "two")).toBe("> one\n> two"); | |
| 871 | }); | |
| 872 | ||
| 873 | it("copies text within one block without its marker", () => { | |
| 874 | expect(copy("- one two", "one", "one")).toBe("one"); | |
| 875 | }); | |
| 876 | ||
| 877 | it("copies across blocks as they are written", () => { | |
| 878 | expect(copy("intro\n- one", "intro", "one")).toBe("intro\n- one"); | |
| 879 | }); | |
| 880 | }); | |
| 881 | ||
| 882 | describe("pasting into a list item or quote", () => { | |
| 883 | it("keeps inline marks but builds no blocks", () => { | |
| 884 | const content = buildTiptapContent( | |
| 885 | "**bold** text\n- item\n> quote", | |
| 886 | () => ({ label: "", description: null }), | |
| 887 | { styling: true, blocks: false }, | |
| 888 | ); | |
| 889 | expect(content.map((block) => block.type)).toEqual(["paragraph", "paragraph", "paragraph"]); | |
| 890 | const first = content[0] as { content: { text: string; marks?: { type: string }[] }[] }; | |
| 891 | expect(first.content[0]).toMatchObject({ text: "bold", marks: [{ type: "bold" }] }); | |
| 892 | expect(JSON.stringify(content[1])).toContain("- item"); | |
| 893 | expect(JSON.stringify(content[2])).toContain("> quote"); | |
| 894 | }); | |
| 895 | }); | |
| 896 | ||
| 368 | 897 | describe("caret stops at styled edges", () => { |
| 369 | 898 | function stateAt(value: string, pmPos: number) { |
| 370 | 899 | const doc = ProseMirrorNode.fromJSON( |
apps/web/src/composer-rich-text-doc.ts+925-77| ... | ... | @@ -1,9 +1,24 @@ |
| 1 | import { Mark, type Node as ProseMirrorNode } from "@tiptap/pm/model"; | |
| 2 | import type { EditorState, Transaction } from "@tiptap/pm/state"; | |
| 1 | import { joinBackward, joinTextblockBackward, joinTextblockForward } from "@tiptap/pm/commands"; | |
| 2 | import { Fragment, Mark, type Node as ProseMirrorNode, type ResolvedPos } from "@tiptap/pm/model"; | |
| 3 | 3 | import { Code } from "@tiptap/extension-code"; |
| 4 | import { Blockquote } from "@tiptap/extension-blockquote"; | |
| 5 | import { CodeBlock } from "@tiptap/extension-code-block"; | |
| 6 | import { Heading } from "@tiptap/extension-heading"; | |
| 7 | import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; | |
| 8 | import { type Command, type Editor, mergeAttributes } from "@tiptap/core"; | |
| 9 | import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; | |
| 4 | 10 | import { TaskItem } from "@tiptap/extension-task-item"; |
| 11 | import { TaskList } from "@tiptap/extension-task-list"; | |
| 12 | import { | |
| 13 | type EditorState, | |
| 14 | Plugin, | |
| 15 | Selection, | |
| 16 | TextSelection, | |
| 17 | type Transaction, | |
| 18 | } from "@tiptap/pm/state"; | |
| 5 | 19 | |
| 6 | 20 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; |
| 21 | import { nextOrderedMarkerText } from "~/composer-list-continuation"; | |
| 7 | 22 | import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text"; |
| 8 | 23 | import { collectInlineContextIds } from "~/lib/composerContextReferences"; |
| 9 | 24 | |
| ... | ... | @@ -55,12 +70,53 @@ export const ComposerCodeExtension = Code.extend({ |
| 55 | 70 | exitable: false, |
| 56 | 71 | }); |
| 57 | 72 | |
| 73 | /** | |
| 74 | * Tiptap's block extensions each bind a chord that turns the current block | |
| 75 | * into their node (Mod-Shift-8 for a list, Mod-Alt-c for a fence, and so on). | |
| 76 | * The composer does not offer them, and they are not harmless: run inside a | |
| 77 | * quote they nest a block the quote serializer cannot write, and the text in | |
| 78 | * it drops out of the stored draft. `drop` names the chords to remove; the | |
| 79 | * keys the composer relies on, such as Backspace and the arrows at a block's | |
| 80 | * edge, stay. | |
| 81 | */ | |
| 82 | function withoutBlockChords<Shortcuts extends Record<string, unknown>>( | |
| 83 | shortcuts: Shortcuts | undefined, | |
| 84 | drop: readonly string[], | |
| 85 | ): Shortcuts { | |
| 86 | return Object.fromEntries( | |
| 87 | Object.entries(shortcuts ?? {}).filter(([key]) => !drop.includes(key)), | |
| 88 | ) as Shortcuts; | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * What a list item may hold: its own line, then nested lists. That is all the | |
| 93 | * Markdown can write, so ProseMirror refuses any join, wrap or paste that | |
| 94 | * would put a second line or another block into an item. | |
| 95 | */ | |
| 96 | const LIST_ITEM_CONTENT = "paragraph list*"; | |
| 97 | ||
| 98 | /** Task lists come from `- [ ]` alone; Mod-Shift-9 would nest one in a quote. */ | |
| 99 | export const ComposerTaskListExtension = TaskList.extend({ | |
| 100 | addKeyboardShortcuts() { | |
| 101 | return withoutBlockChords(this.parent?.(), ["Mod-Shift-9"]); | |
| 102 | }, | |
| 103 | }); | |
| 104 | ||
| 105 | /** | |
| 106 | * Tiptap's Tab and Shift-Tab sink and lift an item without touching its | |
| 107 | * `indent`, so the stored draft keeps the old nesting and the next rebuild | |
| 108 | * undoes the move. The composer nests through its own Tab, which edits the | |
| 109 | * source the way plain mode does. | |
| 110 | */ | |
| 111 | const LIST_NESTING_KEYS = ["Tab", "Shift-Tab"]; | |
| 112 | ||
| 58 | 113 | /** |
| 59 | 114 | * Task list items keep their exact source indent in an attribute so nesting |
| 60 | 115 | * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` — |
| 61 | 116 | * the same fixed-point deal as `__bold__` becoming `**bold**`. |
| 62 | 117 | */ |
| 63 | 118 | export const ComposerTaskItemExtension = TaskItem.extend({ |
| 119 | content: LIST_ITEM_CONTENT, | |
| 64 | 120 | addAttributes() { |
| 65 | 121 | return { |
| 66 | 122 | ...this.parent?.(), |
| ... | ... | @@ -69,8 +125,222 @@ export const ComposerTaskItemExtension = TaskItem.extend({ |
| 69 | 125 | contentSpace: { default: null }, |
| 70 | 126 | }; |
| 71 | 127 | }, |
| 128 | addKeyboardShortcuts() { | |
| 129 | return withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS); | |
| 130 | }, | |
| 72 | 131 | }).configure({ nested: true }); |
| 73 | 132 | |
| 133 | /** | |
| 134 | * Fenced code blocks keep their exact source delimiters so a fence round-trips | |
| 135 | * byte-identically: `fence` is the opening run of backticks or tildes, | |
| 136 | * `language` its info string, and `close` the closing newline and fence, or | |
| 137 | * the empty string when the fence was never closed. | |
| 138 | * | |
| 139 | * Neither delimiter owns a document character, so the caret can never land | |
| 140 | * inside a fence — the same deal as a checkbox marker. | |
| 141 | * | |
| 142 | * An empty block written with a blank line (```` ```\n\n``` ````) canonicalizes | |
| 143 | * to the blank-free form, the same fixed-point deal as `__bold__` becoming | |
| 144 | * `**bold**`. | |
| 145 | */ | |
| 146 | export const ComposerCodeBlockExtension = CodeBlock.extend({ | |
| 147 | addAttributes() { | |
| 148 | return { | |
| 149 | ...this.parent?.(), | |
| 150 | language: { default: "" }, | |
| 151 | fence: { default: "```" }, | |
| 152 | close: { default: "\n```" }, | |
| 153 | }; | |
| 154 | }, | |
| 155 | addKeyboardShortcuts() { | |
| 156 | return { | |
| 157 | ...withoutBlockChords(this.parent?.(), ["Mod-Alt-c"]), | |
| 158 | Backspace: () => unwrapCodeBlockAtStart(this.editor), | |
| 159 | }; | |
| 160 | }, | |
| 161 | }); | |
| 162 | ||
| 163 | /** | |
| 164 | * Backspace at the start of a fence removes the fence and keeps its lines as | |
| 165 | * paragraphs, the way it removes a list marker. Joining the code onto the line | |
| 166 | * above instead would drop the fence from the draft and leave the rest of the | |
| 167 | * code as stray lines. | |
| 168 | */ | |
| 169 | function unwrapCodeBlockAtStart(editor: Editor): boolean { | |
| 170 | const { $from, empty } = editor.state.selection; | |
| 171 | if (!empty || $from.parent.type.name !== "codeBlock" || $from.parentOffset !== 0) return false; | |
| 172 | const { schema } = editor.state; | |
| 173 | const paragraphs = $from.parent.textContent | |
| 174 | .split("\n") | |
| 175 | .map((line) => schema.nodes.paragraph!.create(null, line ? schema.text(line) : null)); | |
| 176 | const start = $from.before(); | |
| 177 | const tr = editor.state.tr.replaceWith(start, $from.after(), paragraphs); | |
| 178 | editor.view.dispatch(tr.setSelection(TextSelection.create(tr.doc, start + 1))); | |
| 179 | return true; | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * The markdown a code block node serializes to, delimiters included. `followed` | |
| 184 | * says another block comes after it in the draft. | |
| 185 | */ | |
| 186 | function codeBlockSource( | |
| 187 | node: ProseMirrorNode, | |
| 188 | followed: boolean, | |
| 189 | ): { | |
| 190 | open: string; | |
| 191 | content: string; | |
| 192 | close: string; | |
| 193 | } { | |
| 194 | const attrs = node.attrs as Record<string, unknown>; | |
| 195 | let fence = typeof attrs.fence === "string" && attrs.fence ? attrs.fence : "```"; | |
| 196 | const language = typeof attrs.language === "string" ? attrs.language : ""; | |
| 197 | let close = typeof attrs.close === "string" ? attrs.close : ""; | |
| 198 | const content = node.textContent; | |
| 199 | // Code holding a line that would close the fence, such as a pasted fence, | |
| 200 | // is written inside a longer one, so the agent reads all of it as code. A | |
| 201 | // parsed block never holds such a line, so its source stays byte-identical. | |
| 202 | const longest = Math.max( | |
| 203 | 0, | |
| 204 | ...content | |
| 205 | .split("\n") | |
| 206 | .filter((line) => isClosingFence(line, fence)) | |
| 207 | .map((line) => /^[`~]+/.exec(line)![0].length), | |
| 208 | ); | |
| 209 | if (longest > 0) { | |
| 210 | fence = fence[0]!.repeat(longest + 1); | |
| 211 | if (close) close = `\n${fence}`; | |
| 212 | } | |
| 213 | // An unclosed fence runs to the end of the draft, so with a block after it | |
| 214 | // the fence is written closed, or the next rebuild would read that block as code. | |
| 215 | if (!close && followed) close = `\n${fence}`; | |
| 216 | return { open: `${fence}${language}${content ? "\n" : ""}`, content, close }; | |
| 217 | } | |
| 218 | ||
| 219 | /** | |
| 220 | * Bullet and ordered items keep their exact source marker so a list | |
| 221 | * round-trips byte-identically: `marker` is the literal `-`, `*`, `+`, `3.` | |
| 222 | * or `3)`, `space` what followed it, and `indent` the leading whitespace. | |
| 223 | * Numbering is not renumbered: what the user typed is what the agent gets. | |
| 224 | */ | |
| 225 | const ComposerListItemExtension = ListItem.extend({ | |
| 226 | content: LIST_ITEM_CONTENT, | |
| 227 | addAttributes() { | |
| 228 | return { | |
| 229 | ...this.parent?.(), | |
| 230 | indent: { default: "" }, | |
| 231 | marker: { default: "-" }, | |
| 232 | space: { default: " " }, | |
| 233 | }; | |
| 234 | }, | |
| 235 | addKeyboardShortcuts() { | |
| 236 | return { | |
| 237 | ...withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS), | |
| 238 | // Task items share these: the list extensions are always loaded together. | |
| 239 | Backspace: () => backspaceAcrossList(this.editor), | |
| 240 | Delete: () => deleteAcrossList(this.editor), | |
| 241 | }; | |
| 242 | }, | |
| 243 | // The source marker rides on the item so the composer draws `3)` and a | |
| 244 | // nested `7.` as written, rather than the browser own numbering. | |
| 245 | renderHTML({ node, HTMLAttributes }) { | |
| 246 | return ["li", mergeAttributes(HTMLAttributes, { "data-marker": node.attrs.marker }), 0]; | |
| 247 | }, | |
| 248 | }); | |
| 249 | ||
| 250 | export const ComposerListExtensions = [ | |
| 251 | BulletList.extend({ | |
| 252 | addKeyboardShortcuts() { | |
| 253 | return withoutBlockChords(this.parent?.(), ["Mod-Shift-8"]); | |
| 254 | }, | |
| 255 | }), | |
| 256 | OrderedList.extend({ | |
| 257 | addKeyboardShortcuts() { | |
| 258 | return withoutBlockChords(this.parent?.(), ["Mod-Shift-7"]); | |
| 259 | }, | |
| 260 | }), | |
| 261 | ComposerListItemExtension, | |
| 262 | ]; | |
| 263 | ||
| 264 | /** | |
| 265 | * A quote keeps the exact `>` prefix its lines were written with, applied to | |
| 266 | * every line, so it round-trips byte-identically and a new line typed inside | |
| 267 | * it gets the same prefix. One source line is one paragraph; a line whose | |
| 268 | * prefix differs starts a sibling quote. Nested markers and list markers | |
| 269 | * inside a quote stay literal text: the composer quotes prose, not documents. | |
| 270 | */ | |
| 271 | const ComposerBlockquoteExtension = Blockquote.extend({ | |
| 272 | content: "paragraph+", | |
| 273 | addAttributes() { | |
| 274 | return { ...this.parent?.(), prefix: { default: "> " } }; | |
| 275 | }, | |
| 276 | addKeyboardShortcuts() { | |
| 277 | return withoutBlockChords(this.parent?.(), ["Mod-Shift-b"]); | |
| 278 | }, | |
| 279 | }); | |
| 280 | ||
| 281 | /** | |
| 282 | * A rule keeps the exact line it was written as (`---`, `* * *`, `_____`), so | |
| 283 | * it round-trips byte-identically. It owns no document characters: offsets | |
| 284 | * inside its source clamp to the block after it. | |
| 285 | * | |
| 286 | * A rule cannot hold the caret, so a document never ends in one: an empty | |
| 287 | * line follows it, which the draft does not write (see `isTrailingLineAfterRule`). | |
| 288 | * A draft ending in `---\n` reads back the same way and so loses that newline, | |
| 289 | * the same fixed-point deal as `__bold__` becoming `**bold**`. | |
| 290 | */ | |
| 291 | const ComposerHorizontalRuleExtension = HorizontalRule.extend({ | |
| 292 | addAttributes() { | |
| 293 | return { ...this.parent?.(), source: { default: "---" } }; | |
| 294 | }, | |
| 295 | addProseMirrorPlugins() { | |
| 296 | return [ | |
| 297 | ...(this.parent?.() ?? []), | |
| 298 | new Plugin({ | |
| 299 | appendTransaction: (transactions, _, state) => { | |
| 300 | if (!transactions.some((tr) => tr.docChanged)) return null; | |
| 301 | if (state.doc.lastChild?.type.name !== "horizontalRule") return null; | |
| 302 | return state.tr.insert(state.doc.content.size, state.schema.nodes.paragraph!.create()); | |
| 303 | }, | |
| 304 | }), | |
| 305 | ]; | |
| 306 | }, | |
| 307 | }); | |
| 308 | ||
| 309 | /** The empty line after a rule that ends the document, which writes nothing. */ | |
| 310 | function isTrailingLineAfterRule(doc: ProseMirrorNode, index: number): boolean { | |
| 311 | const block = doc.child(index); | |
| 312 | return ( | |
| 313 | index === doc.childCount - 1 && | |
| 314 | index > 0 && | |
| 315 | block.type.name === "paragraph" && | |
| 316 | block.content.size === 0 && | |
| 317 | doc.child(index - 1).type.name === "horizontalRule" | |
| 318 | ); | |
| 319 | } | |
| 320 | ||
| 321 | /** | |
| 322 | * A heading keeps the exact whitespace between its `#`s and its text. The | |
| 323 | * `#`s must be followed by whitespace to count, which is also what keeps a | |
| 324 | * `#1234` pull request reference a reference: the marker owns no document | |
| 325 | * characters, closing `#`s stay literal text, and nothing is ever stripped. | |
| 326 | */ | |
| 327 | const ComposerHeadingExtension = Heading.extend({ | |
| 328 | addAttributes() { | |
| 329 | return { ...this.parent?.(), space: { default: " " } }; | |
| 330 | }, | |
| 331 | // Its only shortcuts are the Mod-Alt-1…6 block chords. | |
| 332 | addKeyboardShortcuts() { | |
| 333 | return {}; | |
| 334 | }, | |
| 335 | }); | |
| 336 | ||
| 337 | /** Block-level nodes beyond lists and fences, in the order the parser tries them. */ | |
| 338 | export const ComposerBlockExtensions = [ | |
| 339 | ComposerBlockquoteExtension, | |
| 340 | ComposerHorizontalRuleExtension, | |
| 341 | ComposerHeadingExtension, | |
| 342 | ]; | |
| 343 | ||
| 74 | 344 | function randomNodeKey(): string { |
| 75 | 345 | return `tiptap-${Math.random().toString(36).slice(2)}`; |
| 76 | 346 | } |
| ... | ... | @@ -98,10 +368,73 @@ function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength: |
| 98 | 368 | }; |
| 99 | 369 | } |
| 100 | 370 | |
| 371 | /** | |
| 372 | * An opening fence: three or more backticks or tildes at the start of a line, | |
| 373 | * followed by an info string. A backtick fence cannot carry a backtick in its | |
| 374 | * info string, which is what keeps `` `code` `` on its own line literal. | |
| 375 | * Indented fences stay paragraphs — the composer is a prompt box, not a | |
| 376 | * CommonMark renderer, and honoring indentation would cost another attribute | |
| 377 | * for no case anyone writes. | |
| 378 | */ | |
| 379 | export function parseOpeningFence(line: string): { fence: string; language: string } | null { | |
| 380 | // `[^\n]` rather than `.`, here and in the block rules: a CRLF draft's lines | |
| 381 | // end in `\r`, which `.` refuses, and the `\r` stays in the stored text. | |
| 382 | const match = /^(`{3,}|~{3,})([^\n]*)$/.exec(line); | |
| 383 | if (!match) return null; | |
| 384 | const fence = match[1]!; | |
| 385 | const language = match[2]!; | |
| 386 | if (fence.startsWith("`") && language.includes("`")) return null; | |
| 387 | return { fence, language }; | |
| 388 | } | |
| 389 | ||
| 390 | /** A closing fence matches the opening run's character and is at least as long. */ | |
| 391 | export function isClosingFence(line: string, fence: string): boolean { | |
| 392 | const match = /^(`{3,}|~{3,})[ \t]*\r?$/.exec(line); | |
| 393 | if (!match) return false; | |
| 394 | const run = match[1]!; | |
| 395 | return run[0] === fence[0] && run.length >= fence.length; | |
| 396 | } | |
| 397 | ||
| 398 | type ListLinePrefix = | |
| 399 | | ({ kind: "task" } & TaskLinePrefix) | |
| 400 | | { kind: "bullet" | "ordered"; indent: string; marker: string; space: string }; | |
| 401 | ||
| 402 | /** | |
| 403 | * The same grammar the literal continuation uses, so plain and rich mode agree | |
| 404 | * on what a list line is: a task first (it also looks like a bullet), then an | |
| 405 | * ordered marker, then a bullet. A marker followed by nothing is an empty item. | |
| 406 | */ | |
| 407 | function parseListPrefix(line: string): { prefix: ListLinePrefix; markerLength: number } | null { | |
| 408 | const task = parseTaskPrefix(line); | |
| 409 | if (task) return { prefix: { kind: "task", ...task.prefix }, markerLength: task.markerLength }; | |
| 410 | const ordered = /^([ \t]*)(\d+[.)])((?:[ \t]+)|$)/.exec(line); | |
| 411 | if (ordered) { | |
| 412 | return { | |
| 413 | prefix: { kind: "ordered", indent: ordered[1]!, marker: ordered[2]!, space: ordered[3]! }, | |
| 414 | markerLength: ordered[0].length, | |
| 415 | }; | |
| 416 | } | |
| 417 | const bullet = /^([ \t]*)([-*+])((?:[ \t]+)|$)/.exec(line); | |
| 418 | if (bullet) { | |
| 419 | return { | |
| 420 | prefix: { kind: "bullet", indent: bullet[1]!, marker: bullet[2]!, space: bullet[3]! }, | |
| 421 | markerLength: bullet[0].length, | |
| 422 | }; | |
| 423 | } | |
| 424 | return null; | |
| 425 | } | |
| 426 | ||
| 427 | /** Items of one kind and marker family belong to one list; a change starts a sibling list. */ | |
| 428 | function listKey(prefix: ListLinePrefix): string { | |
| 429 | if (prefix.kind === "task") return "task"; | |
| 430 | if (prefix.kind === "ordered") return `ordered:${prefix.marker.slice(-1)}`; | |
| 431 | return `bullet:${prefix.marker}`; | |
| 432 | } | |
| 433 | ||
| 101 | 434 | type InlineJson = Record<string, unknown>; |
| 102 | 435 | |
| 103 | 436 | interface DocLine { |
| 104 | task: TaskLinePrefix | null; | |
| 437 | list: ListLinePrefix | null; | |
| 105 | 438 | inline: InlineJson[]; |
| 106 | 439 | } |
| 107 | 440 | |
| ... | ... | @@ -143,28 +476,52 @@ function atomJsonForSegment( |
| 143 | 476 | }; |
| 144 | 477 | } |
| 145 | 478 | |
| 146 | interface PendingTaskItem extends TaskLinePrefix { | |
| 479 | interface PendingItem { | |
| 480 | prefix: ListLinePrefix; | |
| 147 | 481 | content: InlineJson[]; |
| 148 | children: PendingTaskItem[]; | |
| 482 | /** Nested lists, in order; a parent can hold lists of different kinds. */ | |
| 483 | children: PendingList[]; | |
| 149 | 484 | } |
| 150 | 485 | |
| 151 | function taskListJson(items: PendingTaskItem[]): InlineJson { | |
| 152 | return { | |
| 153 | type: "taskList", | |
| 154 | content: items.map((item) => ({ | |
| 155 | type: "taskItem", | |
| 156 | attrs: { | |
| 157 | checked: item.checked, | |
| 158 | indent: item.indent, | |
| 159 | markerSpace: item.markerSpace, | |
| 160 | contentSpace: item.contentSpace, | |
| 161 | }, | |
| 162 | content: [ | |
| 163 | { type: "paragraph", content: item.content }, | |
| 164 | ...(item.children.length > 0 ? [taskListJson(item.children)] : []), | |
| 165 | ], | |
| 166 | })), | |
| 167 | }; | |
| 486 | interface PendingList { | |
| 487 | key: string; | |
| 488 | items: PendingItem[]; | |
| 489 | } | |
| 490 | ||
| 491 | function listJson(list: PendingList): InlineJson { | |
| 492 | const first = list.items[0]!.prefix; | |
| 493 | const items = list.items.map((item) => { | |
| 494 | const content = [ | |
| 495 | { type: "paragraph", content: item.content }, | |
| 496 | ...item.children.map((child) => listJson(child)), | |
| 497 | ]; | |
| 498 | if (item.prefix.kind === "task") { | |
| 499 | return { | |
| 500 | type: "taskItem", | |
| 501 | attrs: { | |
| 502 | checked: item.prefix.checked, | |
| 503 | indent: item.prefix.indent, | |
| 504 | markerSpace: item.prefix.markerSpace, | |
| 505 | contentSpace: item.prefix.contentSpace, | |
| 506 | }, | |
| 507 | content, | |
| 508 | }; | |
| 509 | } | |
| 510 | return { | |
| 511 | type: "listItem", | |
| 512 | attrs: { indent: item.prefix.indent, marker: item.prefix.marker, space: item.prefix.space }, | |
| 513 | content, | |
| 514 | }; | |
| 515 | }); | |
| 516 | if (first.kind === "task") return { type: "taskList", content: items }; | |
| 517 | if (first.kind === "ordered") { | |
| 518 | return { | |
| 519 | type: "orderedList", | |
| 520 | attrs: { start: Number.parseInt(first.marker, 10) || 1 }, | |
| 521 | content: items, | |
| 522 | }; | |
| 523 | } | |
| 524 | return { type: "bulletList", content: items }; | |
| 168 | 525 | } |
| 169 | 526 | |
| 170 | 527 | function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, unknown> { |
| ... | ... | @@ -180,9 +537,12 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, un |
| 180 | 537 | export function buildTiptapContent( |
| 181 | 538 | value: string, |
| 182 | 539 | skillLabelFor: (name: string) => SkillMeta, |
| 183 | options?: { styling?: boolean }, | |
| 540 | options?: { styling?: boolean; blocks?: boolean }, | |
| 184 | 541 | ): Record<string, unknown>[] { |
| 185 | 542 | const styling = options?.styling ?? true; |
| 543 | // Inline marks without block structure: text pasted into a list item or | |
| 544 | // quote, which has no line to write a nested block into. | |
| 545 | const blockSyntax = styling && (options?.blocks ?? true); | |
| 186 | 546 | // Hide token source from the markdown parser, then restore the atoms with |
| 187 | 547 | // the marks of their surrounding text. Choose a sentinel absent from input. |
| 188 | 548 | let sentinel = "\uFFFC"; |
| ... | ... | @@ -190,17 +550,19 @@ export function buildTiptapContent( |
| 190 | 550 | sentinel = String.fromCodePoint(codePoint); |
| 191 | 551 | } |
| 192 | 552 | const atoms: InlineJson[] = []; |
| 553 | // Code fences hold no chips, so their lines put the original source back in | |
| 554 | // place of the sentinel rather than building an atom for it. | |
| 555 | const atomSources: string[] = []; | |
| 193 | 556 | const text = splitPromptIntoComposerSegments(value) |
| 194 | 557 | .map((segment) => { |
| 195 | 558 | if (segment.type === "text") return segment.text; |
| 196 | 559 | atoms.push(atomJsonForSegment(segment, skillLabelFor)); |
| 560 | atomSources.push(segment.source); | |
| 197 | 561 | return sentinel; |
| 198 | 562 | }) |
| 199 | 563 | .join(""); |
| 200 | 564 | let atomIndex = 0; |
| 201 | const lines: DocLine[] = text.split("\n").map((line) => { | |
| 202 | const parsed = styling ? parseTaskPrefix(line) : null; | |
| 203 | const content = parsed ? line.slice(parsed.markerLength) : line; | |
| 565 | const buildInline = (content: string): InlineJson[] => { | |
| 204 | 566 | const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }]; |
| 205 | 567 | const inline: InlineJson[] = []; |
| 206 | 568 | for (const span of spans) { |
| ... | ... | @@ -212,61 +574,185 @@ export function buildTiptapContent( |
| 212 | 574 | if (piece) inline.push(textJsonForSpan(piece, span.marks)); |
| 213 | 575 | }); |
| 214 | 576 | } |
| 215 | return { task: parsed?.prefix ?? null, inline }; | |
| 216 | }); | |
| 577 | return inline; | |
| 578 | }; | |
| 579 | const buildDocLine = (line: string): DocLine => { | |
| 580 | const parsed = blockSyntax ? parseListPrefix(line) : null; | |
| 581 | const content = parsed ? line.slice(parsed.markerLength) : line; | |
| 582 | return { list: parsed?.prefix ?? null, inline: buildInline(content) }; | |
| 583 | }; | |
| 217 | 584 | |
| 218 | // Pass 2: consecutive task lines group into (possibly nested) task lists | |
| 219 | // by indent prefix; everything else stays a paragraph. | |
| 220 | const blocks: Record<string, unknown>[] = []; | |
| 221 | let stack: { indent: string; items: PendingTaskItem[] }[] = []; | |
| 222 | const flushTasks = () => { | |
| 223 | if (stack.length > 0) { | |
| 224 | blocks.push(taskListJson(stack[0]!.items)); | |
| 225 | stack = []; | |
| 585 | // Pass 1: fenced blocks claim their lines whole; everything else becomes an | |
| 586 | // inline-parsed line. Fence bodies restore chip sources as literal text. | |
| 587 | const sourceLines = text.split("\n"); | |
| 588 | const entries: ( | |
| 589 | | { code: Record<string, unknown> } | |
| 590 | | { rule: string } | |
| 591 | | { heading: { level: number; space: string; inline: InlineJson[] } } | |
| 592 | | { quote: { prefix: string; inline: InlineJson[] } } | |
| 593 | | { line: DocLine } | |
| 594 | )[] = []; | |
| 595 | const restoreSources = (line: string) => | |
| 596 | line.split(sentinel).reduce((joined, piece, index) => { | |
| 597 | if (index === 0) return piece; | |
| 598 | atomIndex += 1; | |
| 599 | return joined + atomSources[atomIndex - 1]! + piece; | |
| 600 | }, ""); | |
| 601 | ||
| 602 | for (let index = 0; index < sourceLines.length; index += 1) { | |
| 603 | const line = sourceLines[index]!; | |
| 604 | const opening = blockSyntax ? parseOpeningFence(line) : null; | |
| 605 | if (!opening) { | |
| 606 | // A thematic break outranks a list: `- - -` and `* * *` are rules, and | |
| 607 | // `***` on its own line is a rule rather than an empty bold span. | |
| 608 | if (blockSyntax && /^([-*_])(?:[ \t]*\1){2,}[ \t]*\r?$/.test(line)) { | |
| 609 | entries.push({ rule: line }); | |
| 610 | continue; | |
| 611 | } | |
| 612 | const heading = blockSyntax ? /^(#{1,6})([ \t]+)([^\n]*)$/.exec(line) : null; | |
| 613 | if (heading) { | |
| 614 | entries.push({ | |
| 615 | heading: { | |
| 616 | level: heading[1]!.length, | |
| 617 | space: heading[2]!, | |
| 618 | inline: buildInline(heading[3]!), | |
| 619 | }, | |
| 620 | }); | |
| 621 | continue; | |
| 622 | } | |
| 623 | const quote = blockSyntax ? /^(>[ \t]*)([^\n]*)$/.exec(line) : null; | |
| 624 | if (quote) entries.push({ quote: { prefix: quote[1]!, inline: buildInline(quote[2]!) } }); | |
| 625 | else entries.push({ line: buildDocLine(line) }); | |
| 626 | continue; | |
| 627 | } | |
| 628 | // The info string went through the sentinel pass like every line, so a | |
| 629 | // token in it is put back as source here, in order, before the body. | |
| 630 | const language = restoreSources(opening.language); | |
| 631 | const body: string[] = []; | |
| 632 | let cursor = index + 1; | |
| 633 | let close = ""; | |
| 634 | while (cursor < sourceLines.length) { | |
| 635 | const candidate = sourceLines[cursor]!; | |
| 636 | if (isClosingFence(candidate, opening.fence)) { | |
| 637 | close = `\n${candidate}`; | |
| 638 | break; | |
| 639 | } | |
| 640 | body.push(restoreSources(candidate)); | |
| 641 | cursor += 1; | |
| 226 | 642 | } |
| 643 | // An unclosed fence runs to the end of the prompt, which is what the user | |
| 644 | // is looking at while they are still typing the block. | |
| 645 | const closed = cursor < sourceLines.length; | |
| 646 | const content = body.join("\n"); | |
| 647 | entries.push({ | |
| 648 | code: { | |
| 649 | type: "codeBlock", | |
| 650 | attrs: { language, fence: opening.fence, close }, | |
| 651 | ...(content ? { content: [{ type: "text", text: content }] } : {}), | |
| 652 | }, | |
| 653 | }); | |
| 654 | index = closed ? cursor : sourceLines.length; | |
| 655 | } | |
| 656 | ||
| 657 | // Pass 2: consecutive list lines group into (possibly nested) lists by | |
| 658 | // indent prefix; everything else stays a paragraph. Items of a different | |
| 659 | // kind or marker at the same indent start a sibling list, so `* a` under | |
| 660 | // `- b` keeps its star and a task list can follow a bullet list. | |
| 661 | const blocks: Record<string, unknown>[] = []; | |
| 662 | let rootLists: PendingList[] = []; | |
| 663 | let stack: { indent: string; list: PendingList; container: PendingList[] }[] = []; | |
| 664 | const flushLists = () => { | |
| 665 | for (const list of rootLists) blocks.push(listJson(list)); | |
| 666 | rootLists = []; | |
| 667 | stack = []; | |
| 668 | }; | |
| 669 | const openList = (container: PendingList[], key: string): PendingList => { | |
| 670 | const list = { key, items: [] }; | |
| 671 | container.push(list); | |
| 672 | return list; | |
| 673 | }; | |
| 674 | let openQuote: { prefix: string; content: InlineJson[][] } | null = null; | |
| 675 | const flushQuote = () => { | |
| 676 | if (!openQuote) return; | |
| 677 | blocks.push({ | |
| 678 | type: "blockquote", | |
| 679 | attrs: { prefix: openQuote.prefix }, | |
| 680 | content: openQuote.content.map((inline) => ({ type: "paragraph", content: inline })), | |
| 681 | }); | |
| 682 | openQuote = null; | |
| 227 | 683 | }; |
| 228 | for (const line of lines) { | |
| 229 | if (!line.task) { | |
| 230 | flushTasks(); | |
| 684 | for (const entry of entries) { | |
| 685 | if ("quote" in entry) { | |
| 686 | flushLists(); | |
| 687 | if (openQuote && openQuote.prefix !== entry.quote.prefix) flushQuote(); | |
| 688 | openQuote ??= { prefix: entry.quote.prefix, content: [] }; | |
| 689 | openQuote.content.push(entry.quote.inline); | |
| 690 | continue; | |
| 691 | } | |
| 692 | flushQuote(); | |
| 693 | if ("code" in entry) { | |
| 694 | flushLists(); | |
| 695 | blocks.push(entry.code); | |
| 696 | continue; | |
| 697 | } | |
| 698 | if ("rule" in entry) { | |
| 699 | flushLists(); | |
| 700 | blocks.push({ type: "horizontalRule", attrs: { source: entry.rule } }); | |
| 701 | continue; | |
| 702 | } | |
| 703 | if ("heading" in entry) { | |
| 704 | flushLists(); | |
| 705 | blocks.push({ | |
| 706 | type: "heading", | |
| 707 | attrs: { level: entry.heading.level, space: entry.heading.space }, | |
| 708 | content: entry.heading.inline, | |
| 709 | }); | |
| 710 | continue; | |
| 711 | } | |
| 712 | const line = entry.line; | |
| 713 | if (!line.list) { | |
| 714 | flushLists(); | |
| 231 | 715 | blocks.push({ type: "paragraph", content: line.inline }); |
| 232 | 716 | continue; |
| 233 | 717 | } |
| 234 | const item: PendingTaskItem = { | |
| 235 | ...line.task, | |
| 236 | content: line.inline, | |
| 237 | children: [], | |
| 238 | }; | |
| 718 | const item: PendingItem = { prefix: line.list, content: line.inline, children: [] }; | |
| 719 | const key = listKey(line.list); | |
| 720 | const indent = line.list.indent; | |
| 239 | 721 | for (;;) { |
| 240 | 722 | const top = stack[stack.length - 1]; |
| 241 | 723 | if (!top) { |
| 242 | 724 | // A leading indented item with no parent flattens but keeps indent. |
| 243 | stack.push({ indent: item.indent, items: [] }); | |
| 725 | stack.push({ indent, list: openList(rootLists, key), container: rootLists }); | |
| 244 | 726 | continue; |
| 245 | 727 | } |
| 246 | if (top.indent === item.indent) { | |
| 247 | top.items.push(item); | |
| 728 | if (top.indent === indent) { | |
| 729 | if (top.list.key !== key) top.list = openList(top.container, key); | |
| 730 | top.list.items.push(item); | |
| 248 | 731 | break; |
| 249 | 732 | } |
| 250 | if (top.indent !== "" && !item.indent.startsWith(top.indent)) { | |
| 733 | if (top.indent !== "" && !indent.startsWith(top.indent)) { | |
| 251 | 734 | if (stack.length > 1) { |
| 252 | 735 | stack.pop(); |
| 253 | 736 | continue; |
| 254 | 737 | } |
| 255 | top.indent = item.indent; | |
| 256 | top.items.push(item); | |
| 738 | top.indent = indent; | |
| 739 | if (top.list.key !== key) top.list = openList(top.container, key); | |
| 740 | top.list.items.push(item); | |
| 257 | 741 | break; |
| 258 | 742 | } |
| 259 | const parent = top.items[top.items.length - 1]; | |
| 743 | const parent = top.list.items[top.list.items.length - 1]; | |
| 260 | 744 | if (!parent) { |
| 261 | top.items.push(item); | |
| 745 | top.list.items.push(item); | |
| 262 | 746 | break; |
| 263 | 747 | } |
| 264 | parent.children.push(item); | |
| 265 | stack.push({ indent: item.indent, items: parent.children }); | |
| 748 | const list = openList(parent.children, key); | |
| 749 | stack.push({ indent, list, container: parent.children }); | |
| 750 | list.items.push(item); | |
| 266 | 751 | break; |
| 267 | 752 | } |
| 268 | 753 | } |
| 269 | flushTasks(); | |
| 754 | flushQuote(); | |
| 755 | flushLists(); | |
| 270 | 756 | return blocks; |
| 271 | 757 | } |
| 272 | 758 | |
| ... | ... | @@ -275,7 +761,10 @@ export function buildDocJson( |
| 275 | 761 | skillLabelFor: (name: string) => SkillMeta, |
| 276 | 762 | options?: { styling?: boolean }, |
| 277 | 763 | ) { |
| 278 | return { type: "doc", content: buildTiptapContent(value, skillLabelFor, options) }; | |
| 764 | const content = buildTiptapContent(value, skillLabelFor, options); | |
| 765 | // The empty line the rule extension keeps after a final rule. | |
| 766 | if (content.at(-1)?.type === "horizontalRule") content.push({ type: "paragraph" }); | |
| 767 | return { type: "doc", content }; | |
| 279 | 768 | } |
| 280 | 769 | |
| 281 | 770 | export interface RichRun { |
| ... | ... | @@ -495,7 +984,210 @@ function appendInlineRuns( |
| 495 | 984 | } |
| 496 | 985 | } |
| 497 | 986 | |
| 498 | function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { | |
| 987 | const LIST_NODE_NAMES = new Set(["taskList", "bulletList", "orderedList"]); | |
| 988 | const LIST_ITEM_NODE_NAMES = new Set(["taskItem", "listItem"]); | |
| 989 | ||
| 990 | /** | |
| 991 | * Gives the list item at the caret the indent and marker of the sibling it | |
| 992 | * follows, or of the one after it when it is first. Tiptap moves items between | |
| 993 | * lists without touching these attributes, so without this the stored draft | |
| 994 | * keeps an outdented item at its old depth, and the next rebuild nests it again. | |
| 995 | */ | |
| 996 | function alignListItemWithSiblings(tr: Transaction, itemType: string): void { | |
| 997 | const $pos = tr.selection.$from; | |
| 998 | // A lift out of the list leaves the caret in a paragraph, possibly inside | |
| 999 | // an item of another kind, which did not move. | |
| 1000 | if ($pos.depth < 3 || $pos.node(-1).type.name !== itemType) return; | |
| 1001 | const item = $pos.node(-1); | |
| 1002 | const list = $pos.node(-2); | |
| 1003 | const index = $pos.index(-2); | |
| 1004 | const sibling = | |
| 1005 | index > 0 ? list.child(index - 1) : index + 1 < list.childCount ? list.child(index + 1) : null; | |
| 1006 | if (!sibling) return; | |
| 1007 | const attrs: Record<string, unknown> = { ...item.attrs, indent: sibling.attrs.indent }; | |
| 1008 | if (item.type.name === "taskItem") { | |
| 1009 | attrs.markerSpace = sibling.attrs.markerSpace; | |
| 1010 | } else { | |
| 1011 | const marker = typeof sibling.attrs.marker === "string" ? sibling.attrs.marker : "-"; | |
| 1012 | attrs.marker = index > 0 && /^\d+[.)]$/.test(marker) ? nextOrderedMarkerText(marker) : marker; | |
| 1013 | } | |
| 1014 | tr.setNodeMarkup($pos.before(-1), undefined, attrs); | |
| 1015 | if (typeof attrs.marker === "string" && /^\d+[.)]$/.test(attrs.marker)) { | |
| 1016 | renumberFollowingItems(tr, $pos.before(-2), index, attrs.marker); | |
| 1017 | } | |
| 1018 | } | |
| 1019 | ||
| 1020 | /** | |
| 1021 | * Counts the items after `index` on from `marker`, so an item inserted in the | |
| 1022 | * middle of an ordered list pushes the rest down instead of repeating a number. | |
| 1023 | * Only marker attributes change, so positions stay valid while walking. | |
| 1024 | */ | |
| 1025 | function renumberFollowingItems( | |
| 1026 | tr: Transaction, | |
| 1027 | listPos: number, | |
| 1028 | index: number, | |
| 1029 | marker: string, | |
| 1030 | ): void { | |
| 1031 | const list = tr.doc.nodeAt(listPos); | |
| 1032 | if (!list) return; | |
| 1033 | let pos = listPos + 1; | |
| 1034 | let previous = marker; | |
| 1035 | list.forEach((child, _, childIndex) => { | |
| 1036 | if (childIndex > index) { | |
| 1037 | previous = nextOrderedMarkerText(previous); | |
| 1038 | if (child.attrs.marker !== previous) { | |
| 1039 | tr.setNodeMarkup(pos, undefined, { ...child.attrs, marker: previous }); | |
| 1040 | } | |
| 1041 | } | |
| 1042 | pos += child.nodeSize; | |
| 1043 | }); | |
| 1044 | } | |
| 1045 | ||
| 1046 | /** | |
| 1047 | * Shift+Enter in a list or task item: split it, or on an empty item leave one | |
| 1048 | * level of nesting. Either way the item at the caret ends up written with the | |
| 1049 | * indent and marker of its new siblings. | |
| 1050 | */ | |
| 1051 | export function splitOrLiftListItem(editor: Editor): boolean { | |
| 1052 | const $from = editor.state.selection.$from; | |
| 1053 | const itemType = listItemTypeAt($from); | |
| 1054 | if (!itemType) return false; | |
| 1055 | const empty = $from.parent.content.size === 0; | |
| 1056 | // One transaction, so the split or lift and the realignment undo together. | |
| 1057 | const move: Command = ({ commands, tr }) => { | |
| 1058 | const moved = | |
| 1059 | commands.splitListItem( | |
| 1060 | itemType, | |
| 1061 | itemType === "taskItem" ? { checked: false } : { space: " " }, | |
| 1062 | ) || | |
| 1063 | (empty && commands.liftListItem(itemType)); | |
| 1064 | if (moved) alignListItemWithSiblings(tr, itemType); | |
| 1065 | return moved; | |
| 1066 | }; | |
| 1067 | return editor.can().command(move) && editor.chain().command(move).run(); | |
| 1068 | } | |
| 1069 | ||
| 1070 | /** | |
| 1071 | * Deletes `from`–`to`, the typed `[ ]` at the start of a bullet item's text, | |
| 1072 | * and makes that item a task where it stands. A task list holds only tasks, so | |
| 1073 | * the bullet list splits around it into sibling lists, which is also how the | |
| 1074 | * parser reads a change of list kind. Nested items and the item's own nested | |
| 1075 | * lists stay where they are. | |
| 1076 | */ | |
| 1077 | export function convertBulletItemToTask( | |
| 1078 | tr: Transaction, | |
| 1079 | from: number, | |
| 1080 | to: number, | |
| 1081 | checked: boolean, | |
| 1082 | ): void { | |
| 1083 | tr.delete(from, to); | |
| 1084 | const $pos = tr.doc.resolve(from); | |
| 1085 | const item = $pos.node(-1); | |
| 1086 | const list = $pos.node(-2); | |
| 1087 | const index = $pos.index(-2); | |
| 1088 | const { schema } = tr.doc.type; | |
| 1089 | const before: ProseMirrorNode[] = []; | |
| 1090 | const after: ProseMirrorNode[] = []; | |
| 1091 | list.forEach((child, _, childIndex) => { | |
| 1092 | if (childIndex < index) before.push(child); | |
| 1093 | else if (childIndex > index) after.push(child); | |
| 1094 | }); | |
| 1095 | // The spacing after the bullet stays between the dash and the box, as typed. | |
| 1096 | const markerSpace = | |
| 1097 | typeof item.attrs.space === "string" && item.attrs.space ? item.attrs.space : " "; | |
| 1098 | const task = schema.nodes.taskItem!.create( | |
| 1099 | { checked, indent: item.attrs.indent, markerSpace }, | |
| 1100 | item.content, | |
| 1101 | ); | |
| 1102 | const lists = [ | |
| 1103 | ...(before.length > 0 ? [list.copy(Fragment.from(before))] : []), | |
| 1104 | schema.nodes.taskList!.create(null, task), | |
| 1105 | ...(after.length > 0 ? [list.copy(Fragment.from(after))] : []), | |
| 1106 | ]; | |
| 1107 | const listStart = $pos.before(-2); | |
| 1108 | tr.replaceWith(listStart, listStart + list.nodeSize, lists); | |
| 1109 | // Into the task list, its item and its paragraph. | |
| 1110 | const caret = listStart + (before.length > 0 ? lists[0]!.nodeSize : 0) + 3; | |
| 1111 | tr.setSelection(TextSelection.create(tr.doc, caret)); | |
| 1112 | } | |
| 1113 | ||
| 1114 | function listItemTypeAt($pos: ResolvedPos): "listItem" | "taskItem" | null { | |
| 1115 | const name = $pos.depth > 1 ? $pos.node(-1).type.name : null; | |
| 1116 | return name === "listItem" || name === "taskItem" ? name : null; | |
| 1117 | } | |
| 1118 | ||
| 1119 | /** | |
| 1120 | * Backspace at the start of an item's text leaves one level of nesting, or | |
| 1121 | * the list itself at the top, written at its new depth. At the start of a | |
| 1122 | * line right after a list it joins that line onto the list's last line. | |
| 1123 | * Tiptap's list keymap, which StarterKit loads, does both by moving nodes | |
| 1124 | * without touching their indent or marker, so the composer turns it off. | |
| 1125 | */ | |
| 1126 | export function backspaceAcrossList(editor: Editor): boolean { | |
| 1127 | const { state } = editor; | |
| 1128 | const { $from, empty } = state.selection; | |
| 1129 | if (!empty || $from.parentOffset !== 0) return false; | |
| 1130 | const itemType = listItemTypeAt($from); | |
| 1131 | if (itemType && $from.index(-1) === 0) { | |
| 1132 | const lift: Command = ({ commands, tr }) => { | |
| 1133 | const moved = commands.liftListItem(itemType); | |
| 1134 | if (moved) alignListItemWithSiblings(tr, itemType); | |
| 1135 | return moved; | |
| 1136 | }; | |
| 1137 | // Not `can()` first: a dry run of the lift skips the schema check, so it | |
| 1138 | // says yes to a task under a bullet, which has no list to lift into. Its | |
| 1139 | // text joins the line above instead. | |
| 1140 | return editor.chain().command(lift).run() || joinBackward(state, editor.view.dispatch); | |
| 1141 | } | |
| 1142 | const before = $from.depth === 1 ? $from.node(0).maybeChild($from.index(0) - 1) : null; | |
| 1143 | if (before && LIST_NODE_NAMES.has(before.type.name) && $from.parent.type.name !== "codeBlock") { | |
| 1144 | return joinTextblockBackward(state, editor.view.dispatch); | |
| 1145 | } | |
| 1146 | return false; | |
| 1147 | } | |
| 1148 | ||
| 1149 | /** | |
| 1150 | * Delete at the end of a line next to list structure pulls the next line's | |
| 1151 | * text up onto it. Tiptap's default would instead nest the next item, or a | |
| 1152 | * whole list, under this one at its old indent. Before a fence, anywhere, it | |
| 1153 | * does nothing: pulling the code into the line would drop the fence from the | |
| 1154 | * draft and leave the rest of the code as stray lines. | |
| 1155 | */ | |
| 1156 | export function deleteAcrossList(editor: Editor): boolean { | |
| 1157 | const { state } = editor; | |
| 1158 | const { $from, empty } = state.selection; | |
| 1159 | if (!empty || !$from.parent.isTextblock || $from.depth === 0) return false; | |
| 1160 | if ($from.parentOffset !== $from.parent.content.size) return false; | |
| 1161 | const next = Selection.findFrom(state.doc.resolve($from.after()), 1, true); | |
| 1162 | if (!next) return false; | |
| 1163 | if (next.$from.parent.type.name === "codeBlock") return $from.parent.type.name !== "codeBlock"; | |
| 1164 | if (!listItemTypeAt($from) && !listItemTypeAt(next.$from)) return false; | |
| 1165 | return joinTextblockForward(state, editor.view.dispatch); | |
| 1166 | } | |
| 1167 | ||
| 1168 | /** The literal prefix an item serializes to. Empty items keep their exact spacing. */ | |
| 1169 | function listItemPrefix(item: ProseMirrorNode, empty: boolean): string { | |
| 1170 | const attrs = item.attrs as Record<string, unknown>; | |
| 1171 | const indent = typeof attrs.indent === "string" ? attrs.indent : ""; | |
| 1172 | if (item.type.name === "taskItem") { | |
| 1173 | const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; | |
| 1174 | const contentSpace = | |
| 1175 | typeof attrs.contentSpace === "string" | |
| 1176 | ? attrs.contentSpace || (empty ? "" : " ") | |
| 1177 | : empty | |
| 1178 | ? "" | |
| 1179 | : " "; | |
| 1180 | return `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; | |
| 1181 | } | |
| 1182 | const marker = typeof attrs.marker === "string" && attrs.marker ? attrs.marker : "-"; | |
| 1183 | // A bare `-` keeps its missing space only while the item is empty: once it | |
| 1184 | // has text, `-text` would not be a list line any more. | |
| 1185 | const space = | |
| 1186 | typeof attrs.space === "string" ? attrs.space || (empty ? "" : " ") : empty ? "" : " "; | |
| 1187 | return `${indent}${marker}${space}`; | |
| 1188 | } | |
| 1189 | ||
| 1190 | function walkList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { | |
| 499 | 1191 | let itemPos = listStart + 1; |
| 500 | 1192 | let firstItem = true; |
| 501 | 1193 | list.content.forEach((item) => { |
| ... | ... | @@ -505,17 +1197,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul |
| 505 | 1197 | const itemContentStart = itemPos + 1; |
| 506 | 1198 | const first = item.firstChild; |
| 507 | 1199 | const empty = first?.type.name === "paragraph" && first.content.childCount === 0; |
| 508 | const attrs = item.attrs as Record<string, unknown>; | |
| 509 | const indent = typeof attrs.indent === "string" ? attrs.indent : ""; | |
| 510 | const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; | |
| 511 | const contentSpace = | |
| 512 | typeof attrs.contentSpace === "string" | |
| 513 | ? attrs.contentSpace || (empty ? "" : " ") | |
| 514 | : empty | |
| 515 | ? "" | |
| 516 | : " "; | |
| 517 | const prefix = `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; | |
| 518 | // The checkbox owns no document characters; every prefix offset clamps | |
| 1200 | const prefix = listItemPrefix(item, empty); | |
| 1201 | // The marker owns no document characters; every prefix offset clamps | |
| 519 | 1202 | // to the start of the item text, exactly like style markers. |
| 520 | 1203 | acc.runs.push({ |
| 521 | 1204 | kind: "prefix", |
| ... | ... | @@ -537,8 +1220,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul |
| 537 | 1220 | item.content.forEach((child) => { |
| 538 | 1221 | if (!firstBlock) pushBreakRun(acc); |
| 539 | 1222 | firstBlock = false; |
| 540 | if (child.type.name === "taskList") { | |
| 541 | walkTaskList(child, childPos, acc); | |
| 1223 | if (LIST_NODE_NAMES.has(child.type.name)) { | |
| 1224 | walkList(child, childPos, acc); | |
| 542 | 1225 | } else if (child.type.name === "paragraph") { |
| 543 | 1226 | appendInlineRuns(child, childPos + 1, acc); |
| 544 | 1227 | } |
| ... | ... | @@ -548,6 +1231,111 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul |
| 548 | 1231 | }); |
| 549 | 1232 | } |
| 550 | 1233 | |
| 1234 | /** | |
| 1235 | * A fence becomes one run whose open and close lengths are the delimiters, so | |
| 1236 | * every cursor rule that already clamps out of a style marker clamps out of a | |
| 1237 | * fence too. `nodeName` keeps the marker decorations off it: a code block is | |
| 1238 | * drawn as a block, not revealed a character at a time. | |
| 1239 | * | |
| 1240 | * The code is literal text in the editor, but the draft store still counts a | |
| 1241 | * chip's source in it as one cursor position, as it does everywhere. So the | |
| 1242 | * run splits at each chip source, which spans its characters in the document | |
| 1243 | * and one position in the collapsed space. | |
| 1244 | */ | |
| 1245 | function appendCodeBlockRun( | |
| 1246 | block: ProseMirrorNode, | |
| 1247 | pmPos: number, | |
| 1248 | followed: boolean, | |
| 1249 | acc: RichAccumulator, | |
| 1250 | ): void { | |
| 1251 | const { open, content, close } = codeBlockSource(block, followed); | |
| 1252 | const pieces = content | |
| 1253 | ? splitPromptIntoComposerSegments(content).map((segment) => | |
| 1254 | segment.type === "text" | |
| 1255 | ? { length: segment.text.length, collapsedLen: segment.text.length } | |
| 1256 | : { length: segment.source.length, collapsedLen: 1 }, | |
| 1257 | ) | |
| 1258 | : [{ length: 0, collapsedLen: 0 }]; | |
| 1259 | let offset = 0; | |
| 1260 | pieces.forEach((piece, index) => { | |
| 1261 | const openLen = index === 0 ? open.length : 0; | |
| 1262 | const closeLen = index === pieces.length - 1 ? close.length : 0; | |
| 1263 | const collapsedLen = openLen + piece.collapsedLen + closeLen; | |
| 1264 | const mdLen = openLen + piece.length + closeLen; | |
| 1265 | acc.runs.push({ | |
| 1266 | kind: "text", | |
| 1267 | flatStart: acc.flat, | |
| 1268 | docLen: piece.length, | |
| 1269 | collapsedLen, | |
| 1270 | mdLen, | |
| 1271 | openLen, | |
| 1272 | closeLen, | |
| 1273 | pmPos: pmPos + offset, | |
| 1274 | mdStart: acc.md, | |
| 1275 | collapsedStart: acc.collapsed, | |
| 1276 | nodeName: "codeBlock", | |
| 1277 | }); | |
| 1278 | offset += piece.length; | |
| 1279 | acc.flat += piece.length; | |
| 1280 | acc.collapsed += collapsedLen; | |
| 1281 | acc.md += mdLen; | |
| 1282 | }); | |
| 1283 | acc.value += open + content + close; | |
| 1284 | } | |
| 1285 | ||
| 1286 | /** Each paragraph of a quote is one source line behind the quote's prefix. */ | |
| 1287 | function walkBlockquote(quote: ProseMirrorNode, quoteStart: number, acc: RichAccumulator): void { | |
| 1288 | const attrs = quote.attrs as Record<string, unknown>; | |
| 1289 | const prefix = typeof attrs.prefix === "string" ? attrs.prefix : "> "; | |
| 1290 | let childPos = quoteStart + 1; | |
| 1291 | let firstLine = true; | |
| 1292 | quote.content.forEach((child) => { | |
| 1293 | if (!firstLine) pushBreakRun(acc); | |
| 1294 | firstLine = false; | |
| 1295 | acc.runs.push({ | |
| 1296 | kind: "prefix", | |
| 1297 | flatStart: acc.flat, | |
| 1298 | docLen: 0, | |
| 1299 | collapsedLen: prefix.length, | |
| 1300 | mdLen: prefix.length, | |
| 1301 | openLen: 0, | |
| 1302 | closeLen: 0, | |
| 1303 | pmPos: childPos + 1, | |
| 1304 | mdStart: acc.md, | |
| 1305 | collapsedStart: acc.collapsed, | |
| 1306 | }); | |
| 1307 | acc.value += prefix; | |
| 1308 | acc.collapsed += prefix.length; | |
| 1309 | acc.md += prefix.length; | |
| 1310 | if (child.type.name === "paragraph") appendInlineRuns(child, childPos + 1, acc); | |
| 1311 | childPos += child.nodeSize; | |
| 1312 | }); | |
| 1313 | } | |
| 1314 | ||
| 1315 | /** | |
| 1316 | * The stored Markdown of a selection, as copy and cut put it on the clipboard. | |
| 1317 | * A slice holds only the content of the blocks the ends share, so items or | |
| 1318 | * quote lines arrive without the list or quote that writes their markers. The | |
| 1319 | * shared block is put back around them, and an item's list around it. | |
| 1320 | */ | |
| 1321 | export function serializeSelection(doc: ProseMirrorNode, from: number, to: number): string { | |
| 1322 | const slice = doc.slice(from, to); | |
| 1323 | const $from = doc.resolve(from); | |
| 1324 | let depth = $from.sharedDepth(to); | |
| 1325 | let content: Fragment | ProseMirrorNode = slice.content; | |
| 1326 | if (slice.content.firstChild?.isInline) { | |
| 1327 | content = doc.type.schema.nodes.paragraph!.create(null, slice.content); | |
| 1328 | } else { | |
| 1329 | while (depth > 0) { | |
| 1330 | const shared = $from.node(depth); | |
| 1331 | content = shared.copy(Fragment.from(content)); | |
| 1332 | if (!LIST_ITEM_NODE_NAMES.has(shared.type.name)) break; | |
| 1333 | depth -= 1; | |
| 1334 | } | |
| 1335 | } | |
| 1336 | return serializeEditorDoc(doc.type.create(null, content)).value; | |
| 1337 | } | |
| 1338 | ||
| 551 | 1339 | export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { |
| 552 | 1340 | const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 }; |
| 553 | 1341 | const blocks: ProseMirrorNode[] = []; |
| ... | ... | @@ -557,9 +1345,52 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { |
| 557 | 1345 | |
| 558 | 1346 | let pmBlockStart = 0; |
| 559 | 1347 | blocks.forEach((block, blockIndex) => { |
| 560 | if (blockIndex > 0) pushBreakRun(acc); | |
| 561 | if (block.type.name === "taskList") { | |
| 562 | walkTaskList(block, pmBlockStart, acc); | |
| 1348 | if (blockIndex > 0 && !isTrailingLineAfterRule(doc, blockIndex)) pushBreakRun(acc); | |
| 1349 | if (LIST_NODE_NAMES.has(block.type.name)) { | |
| 1350 | walkList(block, pmBlockStart, acc); | |
| 1351 | } else if (block.type.name === "codeBlock") { | |
| 1352 | appendCodeBlockRun(block, pmBlockStart + 1, blockIndex < blocks.length - 1, acc); | |
| 1353 | } else if (block.type.name === "blockquote") { | |
| 1354 | walkBlockquote(block, pmBlockStart, acc); | |
| 1355 | } else if (block.type.name === "heading") { | |
| 1356 | const attrs = block.attrs as Record<string, unknown>; | |
| 1357 | const level = typeof attrs.level === "number" ? attrs.level : 1; | |
| 1358 | const space = typeof attrs.space === "string" ? attrs.space : " "; | |
| 1359 | const prefix = `${"#".repeat(level)}${space}`; | |
| 1360 | acc.runs.push({ | |
| 1361 | kind: "prefix", | |
| 1362 | flatStart: acc.flat, | |
| 1363 | docLen: 0, | |
| 1364 | collapsedLen: prefix.length, | |
| 1365 | mdLen: prefix.length, | |
| 1366 | openLen: 0, | |
| 1367 | closeLen: 0, | |
| 1368 | pmPos: pmBlockStart + 1, | |
| 1369 | mdStart: acc.md, | |
| 1370 | collapsedStart: acc.collapsed, | |
| 1371 | }); | |
| 1372 | acc.value += prefix; | |
| 1373 | acc.collapsed += prefix.length; | |
| 1374 | acc.md += prefix.length; | |
| 1375 | appendInlineRuns(block, pmBlockStart + 1, acc); | |
| 1376 | } else if (block.type.name === "horizontalRule") { | |
| 1377 | const attrs = block.attrs as Record<string, unknown>; | |
| 1378 | const source = typeof attrs.source === "string" && attrs.source ? attrs.source : "---"; | |
| 1379 | acc.runs.push({ | |
| 1380 | kind: "prefix", | |
| 1381 | flatStart: acc.flat, | |
| 1382 | docLen: 0, | |
| 1383 | collapsedLen: source.length, | |
| 1384 | mdLen: source.length, | |
| 1385 | openLen: 0, | |
| 1386 | closeLen: 0, | |
| 1387 | pmPos: pmBlockStart + block.nodeSize, | |
| 1388 | mdStart: acc.md, | |
| 1389 | collapsedStart: acc.collapsed, | |
| 1390 | }); | |
| 1391 | acc.value += source; | |
| 1392 | acc.collapsed += source.length; | |
| 1393 | acc.md += source.length; | |
| 563 | 1394 | } else if (block.type.name === "paragraph") { |
| 564 | 1395 | appendInlineRuns(block, pmBlockStart + 1, acc); |
| 565 | 1396 | } |
| ... | ... | @@ -582,12 +1413,28 @@ function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number { |
| 582 | 1413 | : last.mdStart + last.mdLen; |
| 583 | 1414 | } |
| 584 | 1415 | |
| 1416 | /** | |
| 1417 | * The end of a run belongs to the next run, which is how the end of `**bold**` | |
| 1418 | * lands after its markers. A fence is the exception: its close is a line of | |
| 1419 | * its own, so the end of the code stays inside the block rather than jumping | |
| 1420 | * past the closing fence. Inline marks keep their trailing position. | |
| 1421 | */ | |
| 1422 | function runOwnsOffset(run: RichRun, flatOffset: number): boolean { | |
| 1423 | const end = run.flatStart + run.docLen; | |
| 1424 | return flatOffset < end || (flatOffset === end && run.nodeName === "codeBlock"); | |
| 1425 | } | |
| 1426 | ||
| 585 | 1427 | export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { |
| 586 | 1428 | const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); |
| 587 | 1429 | for (const run of map.runs) { |
| 588 | if (bounded < run.flatStart + run.docLen) { | |
| 1430 | if (runOwnsOffset(run, bounded)) { | |
| 589 | 1431 | if (run.kind === "text" || run.kind === "token") { |
| 590 | return run.collapsedStart + run.openLen + (bounded - run.flatStart); | |
| 1432 | // A chip's source in a fence spans its characters but one position. | |
| 1433 | const within = Math.min( | |
| 1434 | bounded - run.flatStart, | |
| 1435 | run.collapsedLen - run.openLen - run.closeLen, | |
| 1436 | ); | |
| 1437 | return run.collapsedStart + run.openLen + within; | |
| 591 | 1438 | } |
| 592 | 1439 | return run.collapsedStart + (bounded - run.flatStart); |
| 593 | 1440 | } |
| ... | ... | @@ -598,7 +1445,7 @@ export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { |
| 598 | 1445 | export function flatToMarkdown(map: RichDocMap, flatOffset: number): number { |
| 599 | 1446 | const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); |
| 600 | 1447 | for (const run of map.runs) { |
| 601 | if (bounded < run.flatStart + run.docLen) { | |
| 1448 | if (runOwnsOffset(run, bounded)) { | |
| 602 | 1449 | if (run.kind === "text" || run.kind === "token") { |
| 603 | 1450 | return run.mdStart + run.openLen + (bounded - run.flatStart); |
| 604 | 1451 | } |
| ... | ... | @@ -616,9 +1463,10 @@ export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): numbe |
| 616 | 1463 | if (run.kind === "prefix") return run.flatStart; |
| 617 | 1464 | if (run.kind === "text" || run.kind === "token") { |
| 618 | 1465 | const within = collapsedOffset - run.collapsedStart; |
| 1466 | const contentLen = run.collapsedLen - run.openLen - run.closeLen; | |
| 619 | 1467 | // Marker characters clamp to the styled edge: they are shown, never edited. |
| 620 | 1468 | if (within <= run.openLen) return run.flatStart; |
| 621 | if (within >= run.openLen + run.docLen) return run.flatStart + run.docLen; | |
| 1469 | if (within >= run.openLen + contentLen) return run.flatStart + run.docLen; | |
| 622 | 1470 | return run.flatStart + (within - run.openLen); |
| 623 | 1471 | } |
| 624 | 1472 | return run.flatStart + (collapsedOffset - run.collapsedStart); |
apps/web/src/index.css+55-29| ... | ... | @@ -1422,7 +1422,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] { |
| 1422 | 1422 | /* Chat code blocks join the other code surfaces on the code tokens. The |
| 1423 | 1423 | default look keeps its secondary/input tints; themed palettes tint those |
| 1424 | 1424 | surfaces with the accent, which fights syntax highlighting. */ |
| 1425 | html[data-theme-id] .chat-markdown .chat-markdown-codeblock { | |
| 1425 | html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock { | |
| 1426 | 1426 | background-color: var(--code-background); |
| 1427 | 1427 | border-color: var(--contrast-border); |
| 1428 | 1428 | color: var(--code-foreground); |
| ... | ... | @@ -1433,11 +1433,11 @@ html[data-theme-id] .chat-markdown pre { |
| 1433 | 1433 | color: var(--code-foreground); |
| 1434 | 1434 | } |
| 1435 | 1435 | |
| 1436 | html[data-theme-id] .chat-markdown .chat-markdown-codeblock pre { | |
| 1436 | html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { | |
| 1437 | 1437 | background-color: transparent; |
| 1438 | 1438 | } |
| 1439 | 1439 | |
| 1440 | html[data-theme-id] .chat-markdown .chat-markdown-codeblock-header { | |
| 1440 | html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { | |
| 1441 | 1441 | color: color-mix(in srgb, var(--code-foreground) 72%, transparent); |
| 1442 | 1442 | } |
| 1443 | 1443 | |
| ... | ... | @@ -1685,7 +1685,7 @@ code { |
| 1685 | 1685 | Inline code stays relative to its sentence so it never towers over prose; |
| 1686 | 1686 | diffs and file previews take the size through --diffs-font-size. */ |
| 1687 | 1687 | .chat-markdown .chat-markdown-shiki .shiki, |
| 1688 | .chat-markdown pre code { | |
| 1688 | :is(.chat-markdown, .composer-tiptap) pre code { | |
| 1689 | 1689 | font-size: var(--font-size-code, inherit); |
| 1690 | 1690 | } |
| 1691 | 1691 | |
| ... | ... | @@ -1755,45 +1755,47 @@ code { |
| 1755 | 1755 | .chat-markdown ol, |
| 1756 | 1756 | .chat-markdown blockquote, |
| 1757 | 1757 | .chat-markdown pre, |
| 1758 | .chat-markdown .chat-markdown-table-container { | |
| 1758 | .chat-markdown .chat-markdown-table-container, | |
| 1759 | .composer-tiptap ul, | |
| 1760 | .composer-tiptap ol { | |
| 1759 | 1761 | margin: 0.65rem 0; |
| 1760 | 1762 | } |
| 1761 | 1763 | |
| 1762 | .chat-markdown h1, | |
| 1763 | .chat-markdown h2, | |
| 1764 | .chat-markdown h3, | |
| 1765 | .chat-markdown h4, | |
| 1766 | .chat-markdown h5, | |
| 1767 | .chat-markdown h6 { | |
| 1764 | :is(.chat-markdown, .composer-tiptap) h1, | |
| 1765 | :is(.chat-markdown, .composer-tiptap) h2, | |
| 1766 | :is(.chat-markdown, .composer-tiptap) h3, | |
| 1767 | :is(.chat-markdown, .composer-tiptap) h4, | |
| 1768 | :is(.chat-markdown, .composer-tiptap) h5, | |
| 1769 | :is(.chat-markdown, .composer-tiptap) h6 { | |
| 1768 | 1770 | margin: 1.25rem 0 0.5rem; |
| 1769 | 1771 | font-weight: 600; |
| 1770 | 1772 | line-height: 1.3; |
| 1771 | 1773 | color: var(--contrast-foreground); |
| 1772 | 1774 | } |
| 1773 | 1775 | |
| 1774 | .chat-markdown h1 { | |
| 1776 | :is(.chat-markdown, .composer-tiptap) h1 { | |
| 1775 | 1777 | font-size: 1.25rem; |
| 1776 | 1778 | } |
| 1777 | 1779 | |
| 1778 | .chat-markdown h2 { | |
| 1780 | :is(.chat-markdown, .composer-tiptap) h2 { | |
| 1779 | 1781 | font-size: 1.125rem; |
| 1780 | 1782 | } |
| 1781 | 1783 | |
| 1782 | .chat-markdown h3 { | |
| 1784 | :is(.chat-markdown, .composer-tiptap) h3 { | |
| 1783 | 1785 | font-size: 1rem; |
| 1784 | 1786 | } |
| 1785 | 1787 | |
| 1786 | .chat-markdown h4, | |
| 1787 | .chat-markdown h5, | |
| 1788 | .chat-markdown h6 { | |
| 1788 | :is(.chat-markdown, .composer-tiptap) h4, | |
| 1789 | :is(.chat-markdown, .composer-tiptap) h5, | |
| 1790 | :is(.chat-markdown, .composer-tiptap) h6 { | |
| 1789 | 1791 | font-size: 0.875rem; |
| 1790 | 1792 | } |
| 1791 | 1793 | |
| 1792 | .chat-markdown h6 { | |
| 1794 | :is(.chat-markdown, .composer-tiptap) h6 { | |
| 1793 | 1795 | color: var(--contrast-muted-foreground); |
| 1794 | 1796 | } |
| 1795 | 1797 | |
| 1796 | .chat-markdown ul { | |
| 1798 | :is(.chat-markdown, .composer-tiptap) ul { | |
| 1797 | 1799 | /* Reset for nested uls under a widened ol. --list-gutter is an inherited |
| 1798 | 1800 | custom property, so without this a task-list under a multi-digit ordered |
| 1799 | 1801 | list would inherit the outer gutter instead of its own default. */ |
| ... | ... | @@ -1807,21 +1809,27 @@ code { |
| 1807 | 1809 | widest marker has multiple characters so it isn't clipped by list-style-position: |
| 1808 | 1810 | outside painting the marker past the padding box. Reset it here too so a |
| 1809 | 1811 | nested ol without its own widened marker doesn't inherit the outer one. */ |
| 1810 | .chat-markdown ol { | |
| 1812 | :is(.chat-markdown, .composer-tiptap) ol { | |
| 1811 | 1813 | --list-gutter: 1.25rem; |
| 1812 | 1814 | padding-left: var(--list-gutter, 1.25rem); |
| 1813 | 1815 | list-style-type: decimal; |
| 1814 | 1816 | } |
| 1815 | 1817 | |
| 1816 | .chat-markdown ol > li::marker { | |
| 1818 | :is(.chat-markdown, .composer-tiptap) ol > li::marker { | |
| 1817 | 1819 | font-variant-numeric: tabular-nums; |
| 1818 | 1820 | } |
| 1819 | 1821 | |
| 1820 | .chat-markdown ul ul { | |
| 1822 | /* The composer shows the marker the source has, `3)` or a nested `7.`, not the | |
| 1823 | browser counter. Browsers without ::marker content fall back to `start`. */ | |
| 1824 | .composer-tiptap ol > li[data-marker]::marker { | |
| 1825 | content: attr(data-marker) " "; | |
| 1826 | } | |
| 1827 | ||
| 1828 | :is(.chat-markdown, .composer-tiptap) ul ul { | |
| 1821 | 1829 | list-style-type: circle; |
| 1822 | 1830 | } |
| 1823 | 1831 | |
| 1824 | .chat-markdown ul ul ul { | |
| 1832 | :is(.chat-markdown, .composer-tiptap) ul ul ul { | |
| 1825 | 1833 | list-style-type: square; |
| 1826 | 1834 | } |
| 1827 | 1835 | |
| ... | ... | @@ -1870,12 +1878,30 @@ code { |
| 1870 | 1878 | background-size: 4px 2px; |
| 1871 | 1879 | } |
| 1872 | 1880 | |
| 1873 | .chat-markdown blockquote { | |
| 1881 | :is(.chat-markdown, .composer-tiptap) blockquote { | |
| 1874 | 1882 | border-left: 2px solid var(--contrast-border); |
| 1875 | 1883 | padding-left: 0.8rem; |
| 1876 | 1884 | color: var(--contrast-muted-foreground); |
| 1877 | 1885 | } |
| 1878 | 1886 | |
| 1887 | /* A quote in the composer is text the user is still writing; muting it reads | |
| 1888 | as disabled in an input, so the border alone carries the quote. */ | |
| 1889 | .composer-tiptap blockquote { | |
| 1890 | margin: 0.65rem 0; | |
| 1891 | color: var(--foreground); | |
| 1892 | } | |
| 1893 | ||
| 1894 | /* Preflight already draws the 1px rule, as it does in chat; only the block | |
| 1895 | spacing needs to match. */ | |
| 1896 | .composer-tiptap hr { | |
| 1897 | margin: 0.65rem 0; | |
| 1898 | } | |
| 1899 | ||
| 1900 | /* A heading at the top of a draft should not push the first line down. */ | |
| 1901 | .composer-tiptap :is(h1, h2, h3, h4, h5, h6):first-child { | |
| 1902 | margin-top: 0; | |
| 1903 | } | |
| 1904 | ||
| 1879 | 1905 | .chat-markdown section[data-footnotes] { |
| 1880 | 1906 | margin-top: 1.25rem; |
| 1881 | 1907 | border-top: 1px solid var(--contrast-border); |
| ... | ... | @@ -1939,23 +1965,23 @@ code { |
| 1939 | 1965 | background: color-mix(in srgb, var(--contrast-border) 78%, transparent); |
| 1940 | 1966 | } |
| 1941 | 1967 | |
| 1942 | .chat-markdown .chat-markdown-codeblock-header { | |
| 1968 | :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { | |
| 1943 | 1969 | color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent); |
| 1944 | 1970 | } |
| 1945 | 1971 | |
| 1946 | .chat-markdown .chat-markdown-codeblock pre { | |
| 1972 | :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { | |
| 1947 | 1973 | margin: 0; |
| 1948 | 1974 | border: none; |
| 1949 | 1975 | border-radius: 0; |
| 1950 | 1976 | background: transparent; |
| 1951 | 1977 | } |
| 1952 | 1978 | |
| 1953 | .chat-markdown .chat-markdown-codeblock[data-wrap="true"] pre { | |
| 1979 | :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock[data-wrap="true"] pre { | |
| 1954 | 1980 | white-space: pre-wrap; |
| 1955 | 1981 | overflow-wrap: anywhere; |
| 1956 | 1982 | } |
| 1957 | 1983 | |
| 1958 | .chat-markdown .chat-markdown-shiki .shiki { | |
| 1984 | :is(.chat-markdown, .composer-tiptap) .chat-markdown-shiki .shiki { | |
| 1959 | 1985 | background: transparent !important; |
| 1960 | 1986 | } |
| 1961 | 1987 | |
| ... | ... | @@ -2181,7 +2207,7 @@ code { |
| 2181 | 2207 | outline: none; |
| 2182 | 2208 | } |
| 2183 | 2209 | |
| 2184 | .composer-tiptap code { | |
| 2210 | .composer-tiptap :not(pre) > code { | |
| 2185 | 2211 | font-family: var(--font-mono); |
| 2186 | 2212 | font-size: 0.92em; |
| 2187 | 2213 | background: color-mix(in oklab, var(--muted-foreground) 12%, transparent); |
docs/internals/composer-editors.md+15| ... | ... | @@ -25,3 +25,18 @@ Clipboard text must come from the Markdown serializer, not DOM text: chip labels |
| 25 | 25 | the source and marker decorations are not content. Structured context records accompany |
| 26 | 26 | that text when available. Paste completes trailing chip delimiters and adds a leading |
| 27 | 27 | boundary when inserting a chip directly after text. |
| 28 | ||
| 29 | Block nodes (fences, lists, quotes, rules, headings) keep their exact Markdown | |
| 30 | delimiters as node attributes, so the round trip is byte-identical and offsets | |
| 31 | inside a delimiter clamp to the block's text like task and style markers. The | |
| 32 | serializers write only what the grammar has: a quote holds paragraphs, a list | |
| 33 | holds paragraphs and lists. So blocks may only form at a top-level paragraph. | |
| 34 | Input rules check this, and Tiptap's own block chords (Mod-Shift-8, Mod-Alt-c, | |
| 35 | and so on) are removed, because nesting a block the serializer cannot write | |
| 36 | drops its text from the stored draft. The list grammar is the plain-mode | |
| 37 | continuation's in [composer-list-continuation](../../apps/web/src/composer-list-continuation.ts); | |
| 38 | keep the two in step. | |
| 39 | ||
| 40 | Enter sends, so the fence handling in `handleKeyDown` must run before the send | |
| 41 | handler: checked afterwards, a fence never opens and a newline inside one sends | |
| 42 | the draft. |
docs/user/composer.md+17| ... | ... | @@ -12,6 +12,23 @@ becomes an attachment when inserting it would exceed the message limit. On a |
| 12 | 12 | hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere |
| 13 | 13 | to keep a large paste editable in the composer instead. |
| 14 | 14 | |
| 15 | ## Formatting | |
| 16 | ||
| 17 | The composer writes Markdown and shows it styled as you type. Markers such as | |
| 18 | `**` stay in the text and show beside the styled words when your cursor is on | |
| 19 | them. Lines starting with `- `, `1. `, `- [ ] `, `> `, `# ` or `---` become lists, | |
| 20 | task lists, quotes, headings and rules; ` ``` ` followed by Enter opens a code | |
| 21 | block. What you typed is what the agent receives, markers and numbering | |
| 22 | included, and `#1234` without a space still looks up a pull request. | |
| 23 | ||
| 24 | Enter sends. In a list or quote, **Shift+Enter** continues it, and Shift+Enter | |
| 25 | on an empty line leaves it; **Tab** nests a list item. In a code block, Enter | |
| 26 | starts a new line at the current indentation, **Tab** and **Shift+Tab** indent | |
| 27 | the selected lines, and a closing ` ``` ` followed by Enter, or two blank lines | |
| 28 | at the end, leave the block. **Backspace** at the start of a code block turns it | |
| 29 | back into plain lines. Choose the language in a code block's corner to change | |
| 30 | it. Very large code blocks are shown without syntax highlighting. | |
| 31 | ||
| 15 | 32 | ## Attach files |
| 16 | 33 | |
| 17 | 34 | Attach up to 100 files per message. Each image can be up to 10 MiB, with at most |
pnpm-lock.yaml+15| ... | ... | @@ -687,9 +687,24 @@ importers: |
| 687 | 687 | '@tiptap/core': |
| 688 | 688 | specifier: ^3.31.3 |
| 689 | 689 | version: 3.31.3(@tiptap/pm@3.31.3) |
| 690 | '@tiptap/extension-blockquote': | |
| 691 | specifier: ^3.31.3 | |
| 692 | version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | |
| 690 | 693 | '@tiptap/extension-code': |
| 691 | 694 | specifier: ^3.31.3 |
| 692 | 695 | version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) |
| 696 | '@tiptap/extension-code-block': | |
| 697 | specifier: ^3.31.3 | |
| 698 | version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | |
| 699 | '@tiptap/extension-heading': | |
| 700 | specifier: ^3.31.3 | |
| 701 | version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) | |
| 702 | '@tiptap/extension-horizontal-rule': | |
| 703 | specifier: ^3.31.3 | |
| 704 | version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | |
| 705 | '@tiptap/extension-list': | |
| 706 | specifier: ^3.31.3 | |
| 707 | version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) | |
| 693 | 708 | '@tiptap/extension-task-item': |
| 694 | 709 | specifier: ^3.31.3 |
| 695 | 710 | 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)) |