authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-10-07 23:29:43+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 15:29:43-07:00
log300f7f9d45ff19c01bc987dbf1c3bc3e9a3a59ee
treee546c936e48119f6890538662d5698d1293699bb
parent95ccbf406626a8a80fa545777a6388bdf5153086
signature Signed by PGP key B5690EEEBB952194

feat(web): block-level Markdown in the rich text composer (#14677)

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,7 +32,12 @@
32 "@tanstack/react-pacer": "^0.19.4",32 "@tanstack/react-pacer": "^0.19.4",
33 "@tanstack/react-router": "^1.160.2",33 "@tanstack/react-router": "^1.160.2",
34 "@tiptap/core": "^3.31.3",34 "@tiptap/core": "^3.31.3",
35 "@tiptap/extension-blockquote": "^3.31.3",
35 "@tiptap/extension-code": "^3.31.3",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 "@tiptap/extension-task-item": "^3.31.3",41 "@tiptap/extension-task-item": "^3.31.3",
37 "@tiptap/extension-task-list": "^3.31.3",42 "@tiptap/extension-task-list": "^3.31.3",
38 "@tiptap/pm": "^3.31.3",43 "@tiptap/pm": "^3.31.3",
apps/web/src/components/ChatMarkdown.tsx+66-15
...@@ -989,6 +989,63 @@ function MarkdownDetails({...@@ -989,6 +989,63 @@ function MarkdownDetails({
989 );989 );
990}990}
991991
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 */
998export 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 * Filename titles render icon + text; language-only titles render just the1050 * Filename titles render icon + text; language-only titles render just the
994 * icon (redundant next to its own name) and fall back to the language text1051 * icon (redundant next to its own name) and fall back to the language text
...@@ -1141,19 +1198,12 @@ function MarkdownCodeBlock({...@@ -1141,19 +1198,12 @@ function MarkdownCodeBlock({
1141 }1198 }
11421199
1143 return (1200 return (
1144 <div1201 <MarkdownCodeBlockFrame
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"1202 language={language}
1146 data-language={language}1203 fenceTitle={fenceTitle}
1147 data-wrap={wrapped ? "true" : "false"}1204 theme={theme}
1148 >1205 wrapped={wrapped}
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">1206 actions={
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>
1157 <span className="flex items-center gap-0.5" role="toolbar" aria-label="Code block actions">1207 <span className="flex items-center gap-0.5" role="toolbar" aria-label="Code block actions">
1158 {leadingActions}1208 {leadingActions}
1159 {canWrap ? (1209 {canWrap ? (
...@@ -1195,9 +1245,10 @@ function MarkdownCodeBlock({...@@ -1195,9 +1245,10 @@ function MarkdownCodeBlock({
1195 ) : null}1245 ) : null}
1196 {copyButton}1246 {copyButton}
1197 </span>1247 </span>
1198 </div>1248 }
1249 >
1199 {children}1250 {children}
1200 </div>1251 </MarkdownCodeBlockFrame>
1201 );1252 );
1202}1253}
12031254
apps/web/src/components/ComposerPromptEditorTiptap.tsx+374-48
...@@ -1,11 +1,17 @@...@@ -1,11 +1,17 @@
1import { Extension, Node, wrappingInputRule, type JSONContent } from "@tiptap/core";1import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core";
2import { TaskList } from "@tiptap/extension-task-list";
3import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";2import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
4import StarterKit from "@tiptap/starter-kit";3import StarterKit from "@tiptap/starter-kit";
5import { type Node as ProseMirrorNode } from "@tiptap/pm/model";4import { type Node as ProseMirrorNode } from "@tiptap/pm/model";
6import { splitBlockKeepMarks } from "@tiptap/pm/commands";5import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands";
7import { type EditorState, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";6import {
8import { Decoration, DecorationSet } from "@tiptap/pm/view";7 type EditorState,
8 NodeSelection,
9 Plugin,
10 PluginKey,
11 TextSelection,
12} from "@tiptap/pm/state";
13import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view";
14import type { NodeType, ResolvedPos } from "@tiptap/pm/model";
9import type {15import type {
10 AssistantCitation,16 AssistantCitation,
11 ComposerContextClipboardFragment,17 ComposerContextClipboardFragment,
...@@ -45,19 +51,34 @@ import {...@@ -45,19 +51,34 @@ import {
45} from "~/composer-editor-mentions";51} from "~/composer-editor-mentions";
46import {52import {
47 buildDocJson,53 buildDocJson,
54 ComposerBlockExtensions,
55 ComposerCodeBlockExtension,
56 ComposerListExtensions,
48 buildTiptapContent,57 buildTiptapContent,
49 collapsedToFlat,58 collapsedToFlat,
50 caretTakesMarksBefore,59 caretTakesMarksBefore,
60 convertBulletItemToTask,
51 ComposerCodeExtension,61 ComposerCodeExtension,
52 ComposerTaskItemExtension,62 ComposerTaskItemExtension,
63 ComposerTaskListExtension,
53 flatToCollapsed,64 flatToCollapsed,
54 flatToMarkdown,65 flatToMarkdown,
55 flatToPm,66 flatToPm,
56 pmToFlat,67 pmToFlat,
57 serializeEditorDoc,68 serializeEditorDoc,
69 serializeSelection,
70 splitOrLiftListItem,
58 stepCaretAcrossStyledEdge,71 stepCaretAcrossStyledEdge,
59 type SkillMeta,72 type SkillMeta,
60} from "~/composer-rich-text-doc";73} from "~/composer-rich-text-doc";
74import {
75 convertCodeFenceOnEnter,
76 exitCodeBlockOnClosingFence,
77 exitCodeBlockOnTrailingBlankLines,
78 indentCodeBlock,
79 indentedNewlineInCodeBlock,
80 selectionInOneCodeBlock,
81} from "~/composer-code-block";
61import {82import {
62 COMPOSER_UNDO_GROUP_DELAY,83 COMPOSER_UNDO_GROUP_DELAY,
63 type ComposerChangeKind,84 type ComposerChangeKind,
...@@ -65,6 +86,7 @@ import {...@@ -65,6 +86,7 @@ import {
65 markAsClipboardEdit,86 markAsClipboardEdit,
66} from "~/composer-undo-grouping";87} from "~/composer-undo-grouping";
67import { collectInlineContextIds } from "~/lib/composerContextReferences";88import { collectInlineContextIds } from "~/lib/composerContextReferences";
89import { resolveDiffThemeName } from "~/lib/diffRendering";
68import { cn, isMacPlatform } from "~/lib/utils";90import { cn, isMacPlatform } from "~/lib/utils";
69import { basenameOfPath } from "~/pierre-icons";91import { basenameOfPath } from "~/pierre-icons";
70import { FileTagChipContent } from "./chat/FileTagChip";92import { FileTagChipContent } from "./chat/FileTagChip";
...@@ -82,6 +104,8 @@ import {...@@ -82,6 +104,8 @@ import {
82import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";104import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
83import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";105import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
84import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";106import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
107import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";
108import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
85import { importPastedComposerText } from "./composerInlineTokenPaste";109import { importPastedComposerText } from "./composerInlineTokenPaste";
86import { didComposerSelectionChangeVisibly } from "./composerSelection";110import { didComposerSelectionChangeVisibly } from "./composerSelection";
87import type { ComposerDraftContextRecords } from "./composerContextPresentation";111import type { ComposerDraftContextRecords } from "./composerContextPresentation";
...@@ -491,6 +515,9 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] {...@@ -491,6 +515,9 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] {
491 let range: StyledRange | null = null;515 let range: StyledRange | null = null;
492 let openLength = 0;516 let openLength = 0;
493 for (const run of map.runs) {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 if (run.openLen > 0) {521 if (run.openLen > 0) {
495 range ??= { from: run.pmPos, to: run.pmPos, markers: [] };522 range ??= { from: run.pmPos, to: run.pmPos, markers: [] };
496 range.markers.push({523 range.markers.push({
...@@ -518,6 +545,162 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] {...@@ -518,6 +545,162 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] {
518 return ranges;545 return ranges;
519}546}
520547
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 */
558function 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 */
587const 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. */
608function 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
621function 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 */
632const 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 */
653const 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 */
686const 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. */
700function isInCodeBlock(view: EditorView): boolean {
701 return view.state.selection.$from.parent.type.spec.code === true;
702}
703
521const MarkerPluginKey = new PluginKey("composer-rich-markers");704const MarkerPluginKey = new PluginKey("composer-rich-markers");
522705
523const ComposerMarkerPlugin = new Plugin({706const ComposerMarkerPlugin = new Plugin({
...@@ -766,16 +949,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -766,16 +949,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
766 expandedCursor: nextExpandedCursor,949 expandedCursor: nextExpandedCursor,
767 contextIds: map.contextIds,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 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") ||959 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") ||
771 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right");960 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right");
772 onChangeRef.current(961 onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, map.contextIds);
773 nextValue,
774 nextCursor,
775 nextExpandedCursor,
776 cursorAdjacentToMention,
777 map.contextIds,
778 );
779 }, []);962 }, []);
780963
781 const editorAttributes = useMemo(964 const editorAttributes = useMemo(
...@@ -824,6 +1007,8 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -824,6 +1007,8 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
824 gapcursor: false,1007 gapcursor: false,
825 trailingNode: false,1008 trailingNode: false,
826 code: false,1009 code: false,
1010 // The list extensions handle Backspace and Delete themselves.
1011 listKeymap: false,
827 undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY },1012 undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY },
828 // Plain mode has no marks: typed markers stay literal characters.1013 // Plain mode has no marks: typed markers stay literal characters.
829 ...(richText ? {} : { bold: false, italic: false, strike: false }),1014 ...(richText ? {} : { bold: false, italic: false, strike: false }),
...@@ -837,16 +1022,78 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -837,16 +1022,78 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
837 ...(richText1022 ...(richText
838 ? [1023 ? [
839 ComposerCodeExtension,1024 ComposerCodeExtension,
840 TaskList,1025 ComposerCodeBlockExtension.extend({
841 ComposerTaskItemExtension.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 addInputRules() {1040 addInputRules() {
843 return [1041 return [];
844 wrappingInputRule({1042 },
845 find: /^- \[([ xX])\] $/,1043 }),
846 type: this.type,1044 composerCodeBlockHighlight({
847 getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }),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,23 +1229,71 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
982 event.preventDefault();1229 event.preventDefault();
983 return true;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 const handler = onCommandKeyDownRef.current;1276 const handler = onCommandKeyDownRef.current;
986 if (event.key === "Enter") {1277 if (event.key === "Enter") {
987 const instance = editorHolder.current;1278 const instance = editorHolder.current;
988 const isTaskItem = richText && (instance?.isActive("taskItem") ?? false);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 if (handled) {1282 if (handled) {
991 event.preventDefault();1283 event.preventDefault();
992 event.stopPropagation();1284 event.stopPropagation();
993 return true;1285 return true;
994 }1286 }
995 event.preventDefault();1287 event.preventDefault();
1288 if ((isTaskItem || isListItem) && instance && splitOrLiftListItem(instance)) {
1289 return true;
1290 }
996 if (1291 if (
997 isTaskItem &&1292 richText &&
998 instance &&1293 instance &&
999 (instance.commands.splitListItem("taskItem", { checked: false }) ||1294 hasAncestor(view.state.selection.$from, "blockquote") &&
1000 (view.state.selection.$from.parent.content.size === 0 &&1295 view.state.selection.$from.parent.content.size === 0 &&
1001 instance.commands.liftListItem("taskItem")))1296 instance.commands.lift("blockquote")
1002 ) {1297 ) {
1003 return true;1298 return true;
1004 }1299 }
...@@ -1050,6 +1345,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -1050,6 +1345,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
1050 const pastedText = clipboardData.getData("text/plain");1345 const pastedText = clipboardData.getData("text/plain");
1051 if (!pastedText) return false;1346 if (!pastedText) return false;
1052 event.preventDefault();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 const importFragment = importFragmentRef.current;1361 const importFragment = importFragmentRef.current;
1054 let text = importFragment1362 let text = importFragment
1055 ? importPastedComposerText(clipboardData, importFragment)1363 ? importPastedComposerText(clipboardData, importFragment)
...@@ -1073,18 +1381,44 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -1073,18 +1381,44 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
1073 }1381 }
1074 const editorInstance = editorHolder.current;1382 const editorInstance = editorHolder.current;
1075 if (editorInstance) {1383 if (editorInstance) {
1076 insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => {1384 // Inside a list item or quote, pasted block markup has nowhere to
1077 // Tagged on the same transaction insertContent builds, so the1385 // go: it stays literal lines the next rebuild reads back. Inline
1078 // paste is one undo step of its own.1386 // marks still apply.
1079 editorInstance1387 const $paste = view.state.selection.$from;
1080 .chain()1388 const nested = ["listItem", "taskItem", "blockquote"].some((name) =>
1081 .command(({ tr }) => {1389 hasAncestor($paste, name),
1082 markAsClipboardEdit(tr, "paste");1390 );
1083 return true;1391 insertMarkdownParagraphs(
1084 })1392 text,
1085 .insertContent(content)1393 skillLabelFor,
1086 .run();1394 { styling: richText, blocks: !nested },
1087 });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 scrollTiptapCaretIntoView(editorInstance);1422 scrollTiptapCaretIntoView(editorInstance);
1089 }1423 }
1090 return true;1424 return true;
...@@ -1330,15 +1664,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -1330,15 +1664,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
1330 const clipboardData = event.clipboardData;1664 const clipboardData = event.clipboardData;
1331 const { from, to } = editor.state.selection;1665 const { from, to } = editor.state.selection;
1332 if (from === to) return;1666 if (from === to) return;
1333 const { doc, schema } = editor.state;1667 const text = serializeSelection(editor.state.doc, from, to);
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;
1342 const contextIds = Array.from(new Set(collectInlineContextIds(text)));1668 const contextIds = Array.from(new Set(collectInlineContextIds(text)));
1343 const fragment = contextIds.length > 0 ? build?.(contextIds) : null;1669 const fragment = contextIds.length > 0 ? build?.(contextIds) : null;
1344 event.preventDefault();1670 event.preventDefault();
...@@ -1442,7 +1768,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -1442,7 +1768,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
1442function insertMarkdownParagraphs(1768function insertMarkdownParagraphs(
1443 value: string,1769 value: string,
1444 skillLabelFor: (name: string) => SkillMeta,1770 skillLabelFor: (name: string) => SkillMeta,
1445 options: { styling: boolean },1771 options: { styling: boolean; blocks?: boolean },
1446 insertContent: (content: JSONContent[] | JSONContent) => void,1772 insertContent: (content: JSONContent[] | JSONContent) => void,
1447): void {1773): void {
1448 const blocks = buildTiptapContent(value, skillLabelFor, options);1774 const blocks = buildTiptapContent(value, skillLabelFor, options);
apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx created+121
...@@ -0,0 +1,121 @@
1import { CheckIcon, ChevronDownIcon } from "lucide-react";
2import { useMemo, useState } from "react";
3
4import {
5 CODE_BLOCK_LANGUAGES,
6 type CodeBlockLanguage,
7 codeLanguageEntry,
8 codeLanguageLabel,
9} from "~/composer-code-languages";
10import { syntheticFileNameForLanguageId } from "~/pierre-icons";
11
12import { Button } from "../ui/button";
13import {
14 Combobox,
15 ComboboxEmpty,
16 ComboboxItem,
17 ComboboxList,
18 ComboboxPopup,
19 ComboboxSearchInput,
20 ComboboxTrigger,
21} from "../ui/combobox";
22import { 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 */
29function 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 */
42export 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 @@
1import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
2
3import { languageOfInfoString, withInfoStringLanguage } from "~/composer-code-languages";
4
5import { useTheme } from "../../hooks/useTheme";
6import { MarkdownCodeBlockFrame } from "../ChatMarkdown";
7import { 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 */
18export 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 @@
1import { Editor } from "@tiptap/core";
2import type { EditorView } from "@tiptap/pm/view";
3import StarterKit from "@tiptap/starter-kit";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5
6import { ComposerCodeBlockExtension } from "~/composer-rich-text-doc";
7import type { DiffThemeName } from "~/lib/diffRendering";
8import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting";
9
10import {
11 composerCodeBlockHighlight,
12 MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH,
13 shouldHighlightCodeBlock,
14 tokenizeBlock,
15} from "./composerCodeBlockHighlight";
16
17vi.mock("~/lib/syntaxHighlighting", () => ({ getSyntaxHighlighterPromise: vi.fn() }));
18
19const 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 */
26function 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
41describe("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
86describe("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
98describe("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 @@
1import { Extension } from "@tiptap/core";
2import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
3import { Decoration, DecorationSet } from "@tiptap/pm/view";
4import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
5
6import type { DiffThemeName } from "~/lib/diffRendering";
7import { languageOfInfoString } from "~/composer-code-languages";
8import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting";
9
10interface 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
16const 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 */
25export const MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH = 20_000;
26
27export function shouldHighlightCodeBlock(code: string): boolean {
28 return code.length <= MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH;
29}
30function 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 */
40function 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 */
56export 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
172type Highlighter = Awaited<ReturnType<typeof getSyntaxHighlighterPromise>>;
173
174/** Flattens Shiki's line/token structure into offsets within the block's text. */
175export 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
205function 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 @@
1import { Editor } from "@tiptap/core";
2import { TextSelection } from "@tiptap/pm/state";
3import StarterKit from "@tiptap/starter-kit";
4import { describe, expect, it } from "vite-plus/test";
5
6import {
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.
17const 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. */
20function 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 */
41function 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.
84const 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
94describe("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 @@
1import { describe, expect, it } from "vite-plus/test";
2import { Editor, Node } from "@tiptap/core";
3import { newlineInCode } from "@tiptap/pm/commands";
4import { TextSelection } from "@tiptap/pm/state";
5
6import {
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";
17import StarterKit from "@tiptap/starter-kit";
18
19import {
20 ComposerCodeBlockExtension,
21 ComposerListExtensions,
22 serializeEditorDoc,
23} from "./composer-rich-text-doc";
24
25const 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
38describe("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
50describe("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
66describe("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. */
89function 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
106const textOf = (editor: Editor) => editor.state.doc.textContent;
107
108describe("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. */
151function 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
160describe("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
198describe("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
228describe("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
280describe("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
301describe("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 @@
1import { exitCode } from "@tiptap/pm/commands";
2import type { EditorState, Transaction } from "@tiptap/pm/state";
3import { TextSelection } from "@tiptap/pm/state";
4import type { EditorView } from "@tiptap/pm/view";
5
6import { 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 */
13export const CODE_BLOCK_INDENT = " ";
14
15/** The leading run of spaces and tabs on a line, which Enter carries forward. */
16export 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 */
26export 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. */
34export 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. */
44export 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 */
55function 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 */
73export 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 */
106export 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 */
128export 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 */
169export 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 */
226export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 CODE_BLOCK_LANGUAGES,
5 codeLanguageLabel,
6 languageOfInfoString,
7 withInfoStringLanguage,
8} from "./composer-code-languages";
9
10describe("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
22describe("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
43describe("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 */
10export 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
52export type CodeBlockLanguage = { readonly id: string; readonly label: string };
53
54/** Short names people type on the fence line, labelled like the entry they mean. */
55const 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. */
70export 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 */
80export 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. */
90export 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
95export 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,6 +1,10 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { listContinuationForEnter, listIndentForTab } from "./composer-list-continuation";3import {
4 listContinuationForEnter,
5 listIndentForTab,
6 nextOrderedMarkerText,
7} from "./composer-list-continuation";
48
5function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) {9function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) {
6 return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end);10 return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end);
...@@ -66,3 +70,16 @@ describe("composer list continuation", () => {...@@ -66,3 +70,16 @@ describe("composer list continuation", () => {
66 expect(listIndentForTab("- foo", 1, 3)).toBeNull();70 expect(listIndentForTab("- foo", 1, 3)).toBeNull();
67 });71 });
68});72});
73
74describe("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,13 +55,24 @@ function parseListMarker(line: string): { marker: ListMarker; markerEnd: number
55 return null;55 return null;
56}56}
5757
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 */
63export 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
58function nextMarkerText(marker: ListMarker): string {73function nextMarkerText(marker: ListMarker): string {
59 if (marker.kind === "ordered") {74 if (marker.kind === "ordered") {
60 const number = Number.parseInt(marker.numberText, 10);75 return `${marker.indent}${nextOrderedMarkerText(`${marker.numberText}${marker.delimiter}`)} `;
61 const next = Number.isSafeInteger(number)
62 ? String(number + 1).padStart(marker.numberText.length, "0")
63 : marker.numberText;
64 return `${marker.indent}${next}${marker.delimiter} `;
65 }76 }
66 if (marker.kind === "task") {77 if (marker.kind === "task") {
67 return `${marker.indent}- [ ] `;78 return `${marker.indent}- [ ] `;
apps/web/src/composer-list-split.test.ts created+265
...@@ -0,0 +1,265 @@
1// @vitest-environment jsdom
2
3import { Editor } from "@tiptap/core";
4import { TaskList } from "@tiptap/extension-task-list";
5import { TextSelection } from "@tiptap/pm/state";
6import StarterKit from "@tiptap/starter-kit";
7import { afterEach, describe, expect, it } from "vite-plus/test";
8
9import {
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.
25const editors: Editor[] = [];
26afterEach(() => {
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 */
36function 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
62function 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
70describe("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`. */
88function 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. */
99function 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
105describe("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
136describe("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
150describe("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
162describe("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
190describe("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
210describe("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
227describe("[ ] 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
252describe("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,9 +5,15 @@ import { Node as ProseMirrorNode } from "@tiptap/pm/model";
5import { EditorState, TextSelection } from "@tiptap/pm/state";5import { EditorState, TextSelection } from "@tiptap/pm/state";
6import { describe, expect, it } from "vite-plus/test";6import { describe, expect, it } from "vite-plus/test";
77
8import { collapseExpandedComposerCursor } from "./composer-logic";
9
8import {10import {
9 buildDocJson,11 buildDocJson,
12 buildTiptapContent,
10 caretTakesMarksBefore,13 caretTakesMarksBefore,
14 ComposerBlockExtensions,
15 ComposerCodeBlockExtension,
16 ComposerListExtensions,
11 collapsedToFlat,17 collapsedToFlat,
12 ComposerCodeExtension,18 ComposerCodeExtension,
13 ComposerTaskItemExtension,19 ComposerTaskItemExtension,
...@@ -16,6 +22,7 @@ import {...@@ -16,6 +22,7 @@ import {
16 flatToPm,22 flatToPm,
17 pmToFlat,23 pmToFlat,
18 serializeEditorDoc,24 serializeEditorDoc,
25 serializeSelection,
19 stepCaretAcrossStyledEdge,26 stepCaretAcrossStyledEdge,
20} from "./composer-rich-text-doc";27} from "./composer-rich-text-doc";
2128
...@@ -64,6 +71,9 @@ const schema = getSchemaByResolvedExtensions(...@@ -64,6 +71,9 @@ const schema = getSchemaByResolvedExtensions(
64 }),71 }),
65 TaskList,72 TaskList,
66 ComposerTaskItemExtension,73 ComposerTaskItemExtension,
74 ComposerCodeBlockExtension,
75 ...ComposerListExtensions,
76 ...ComposerBlockExtensions,
67 ]),77 ]),
68);78);
6979
...@@ -344,6 +354,437 @@ describe("composer rich text document model", () => {...@@ -344,6 +354,437 @@ describe("composer rich text document model", () => {
344 expect(roundTripPlain(value).value).toBe(value);354 expect(roundTripPlain(value).value).toBe(value);
345 });355 });
346356
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 it("maps every document offset through collapsed coordinates and back", () => {788 it("maps every document offset through collapsed coordinates and back", () => {
348 const value = "hi **bold** @README.md bye";789 const value = "hi **bold** @README.md bye";
349 const map = roundTrip(value);790 const map = roundTrip(value);
...@@ -353,6 +794,37 @@ describe("composer rich text document model", () => {...@@ -353,6 +794,37 @@ describe("composer rich text document model", () => {
353 }794 }
354 });795 });
355796
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 it("maps markdown offsets at styled edges onto document text", () => {828 it("maps markdown offsets at styled edges onto document text", () => {
357 const value = "a **bold** c";829 const value = "a **bold** c";
358 const map = roundTrip(value);830 const map = roundTrip(value);
...@@ -365,6 +837,63 @@ describe("composer rich text document model", () => {...@@ -365,6 +837,63 @@ describe("composer rich text document model", () => {
365 });837 });
366});838});
367839
840describe("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
882describe("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
368describe("caret stops at styled edges", () => {897describe("caret stops at styled edges", () => {
369 function stateAt(value: string, pmPos: number) {898 function stateAt(value: string, pmPos: number) {
370 const doc = ProseMirrorNode.fromJSON(899 const doc = ProseMirrorNode.fromJSON(
apps/web/src/composer-rich-text-doc.ts+925-77
...@@ -1,9 +1,24 @@...@@ -1,9 +1,24 @@
1import { Mark, type Node as ProseMirrorNode } from "@tiptap/pm/model";1import { joinBackward, joinTextblockBackward, joinTextblockForward } from "@tiptap/pm/commands";
2import type { EditorState, Transaction } from "@tiptap/pm/state";2import { Fragment, Mark, type Node as ProseMirrorNode, type ResolvedPos } from "@tiptap/pm/model";
3import { Code } from "@tiptap/extension-code";3import { Code } from "@tiptap/extension-code";
4import { Blockquote } from "@tiptap/extension-blockquote";
5import { CodeBlock } from "@tiptap/extension-code-block";
6import { Heading } from "@tiptap/extension-heading";
7import { HorizontalRule } from "@tiptap/extension-horizontal-rule";
8import { type Command, type Editor, mergeAttributes } from "@tiptap/core";
9import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list";
4import { TaskItem } from "@tiptap/extension-task-item";10import { TaskItem } from "@tiptap/extension-task-item";
11import { TaskList } from "@tiptap/extension-task-list";
12import {
13 type EditorState,
14 Plugin,
15 Selection,
16 TextSelection,
17 type Transaction,
18} from "@tiptap/pm/state";
519
6import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";20import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
21import { nextOrderedMarkerText } from "~/composer-list-continuation";
7import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text";22import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text";
8import { collectInlineContextIds } from "~/lib/composerContextReferences";23import { collectInlineContextIds } from "~/lib/composerContextReferences";
924
...@@ -55,12 +70,53 @@ export const ComposerCodeExtension = Code.extend({...@@ -55,12 +70,53 @@ export const ComposerCodeExtension = Code.extend({
55 exitable: false,70 exitable: false,
56});71});
5772
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 */
82function 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 */
96const LIST_ITEM_CONTENT = "paragraph list*";
97
98/** Task lists come from `- [ ]` alone; Mod-Shift-9 would nest one in a quote. */
99export 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 */
111const LIST_NESTING_KEYS = ["Tab", "Shift-Tab"];
112
58/**113/**
59 * Task list items keep their exact source indent in an attribute so nesting114 * Task list items keep their exact source indent in an attribute so nesting
60 * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` —115 * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` —
61 * the same fixed-point deal as `__bold__` becoming `**bold**`.116 * the same fixed-point deal as `__bold__` becoming `**bold**`.
62 */117 */
63export const ComposerTaskItemExtension = TaskItem.extend({118export const ComposerTaskItemExtension = TaskItem.extend({
119 content: LIST_ITEM_CONTENT,
64 addAttributes() {120 addAttributes() {
65 return {121 return {
66 ...this.parent?.(),122 ...this.parent?.(),
...@@ -69,8 +125,222 @@ export const ComposerTaskItemExtension = TaskItem.extend({...@@ -69,8 +125,222 @@ export const ComposerTaskItemExtension = TaskItem.extend({
69 contentSpace: { default: null },125 contentSpace: { default: null },
70 };126 };
71 },127 },
128 addKeyboardShortcuts() {
129 return withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS);
130 },
72}).configure({ nested: true });131}).configure({ nested: true });
73132
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 */
146export 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 */
169function 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 */
186function 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 */
225const 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
250export 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 */
271const 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 */
291const 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. */
310function 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 */
327const 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. */
338export const ComposerBlockExtensions = [
339 ComposerBlockquoteExtension,
340 ComposerHorizontalRuleExtension,
341 ComposerHeadingExtension,
342];
343
74function randomNodeKey(): string {344function randomNodeKey(): string {
75 return `tiptap-${Math.random().toString(36).slice(2)}`;345 return `tiptap-${Math.random().toString(36).slice(2)}`;
76}346}
...@@ -98,10 +368,73 @@ function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength:...@@ -98,10 +368,73 @@ function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength:
98 };368 };
99}369}
100370
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 */
379export 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. */
391export 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
398type 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 */
407function 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. */
428function 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
101type InlineJson = Record<string, unknown>;434type InlineJson = Record<string, unknown>;
102435
103interface DocLine {436interface DocLine {
104 task: TaskLinePrefix | null;437 list: ListLinePrefix | null;
105 inline: InlineJson[];438 inline: InlineJson[];
106}439}
107440
...@@ -143,28 +476,52 @@ function atomJsonForSegment(...@@ -143,28 +476,52 @@ function atomJsonForSegment(
143 };476 };
144}477}
145478
146interface PendingTaskItem extends TaskLinePrefix {479interface PendingItem {
480 prefix: ListLinePrefix;
147 content: InlineJson[];481 content: InlineJson[];
148 children: PendingTaskItem[];482 /** Nested lists, in order; a parent can hold lists of different kinds. */
483 children: PendingList[];
149}484}
150485
151function taskListJson(items: PendingTaskItem[]): InlineJson {486interface PendingList {
152 return {487 key: string;
153 type: "taskList",488 items: PendingItem[];
154 content: items.map((item) => ({489}
155 type: "taskItem",490
156 attrs: {491function listJson(list: PendingList): InlineJson {
157 checked: item.checked,492 const first = list.items[0]!.prefix;
158 indent: item.indent,493 const items = list.items.map((item) => {
159 markerSpace: item.markerSpace,494 const content = [
160 contentSpace: item.contentSpace,495 { type: "paragraph", content: item.content },
161 },496 ...item.children.map((child) => listJson(child)),
162 content: [497 ];
163 { type: "paragraph", content: item.content },498 if (item.prefix.kind === "task") {
164 ...(item.children.length > 0 ? [taskListJson(item.children)] : []),499 return {
165 ],500 type: "taskItem",
166 })),501 attrs: {
167 };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}
169526
170function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, unknown> {527function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, unknown> {
...@@ -180,9 +537,12 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, un...@@ -180,9 +537,12 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, un
180export function buildTiptapContent(537export function buildTiptapContent(
181 value: string,538 value: string,
182 skillLabelFor: (name: string) => SkillMeta,539 skillLabelFor: (name: string) => SkillMeta,
183 options?: { styling?: boolean },540 options?: { styling?: boolean; blocks?: boolean },
184): Record<string, unknown>[] {541): Record<string, unknown>[] {
185 const styling = options?.styling ?? true;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 // Hide token source from the markdown parser, then restore the atoms with546 // Hide token source from the markdown parser, then restore the atoms with
187 // the marks of their surrounding text. Choose a sentinel absent from input.547 // the marks of their surrounding text. Choose a sentinel absent from input.
188 let sentinel = "\uFFFC";548 let sentinel = "\uFFFC";
...@@ -190,17 +550,19 @@ export function buildTiptapContent(...@@ -190,17 +550,19 @@ export function buildTiptapContent(
190 sentinel = String.fromCodePoint(codePoint);550 sentinel = String.fromCodePoint(codePoint);
191 }551 }
192 const atoms: InlineJson[] = [];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 const text = splitPromptIntoComposerSegments(value)556 const text = splitPromptIntoComposerSegments(value)
194 .map((segment) => {557 .map((segment) => {
195 if (segment.type === "text") return segment.text;558 if (segment.type === "text") return segment.text;
196 atoms.push(atomJsonForSegment(segment, skillLabelFor));559 atoms.push(atomJsonForSegment(segment, skillLabelFor));
560 atomSources.push(segment.source);
197 return sentinel;561 return sentinel;
198 })562 })
199 .join("");563 .join("");
200 let atomIndex = 0;564 let atomIndex = 0;
201 const lines: DocLine[] = text.split("\n").map((line) => {565 const buildInline = (content: string): InlineJson[] => {
202 const parsed = styling ? parseTaskPrefix(line) : null;
203 const content = parsed ? line.slice(parsed.markerLength) : line;
204 const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }];566 const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }];
205 const inline: InlineJson[] = [];567 const inline: InlineJson[] = [];
206 for (const span of spans) {568 for (const span of spans) {
...@@ -212,61 +574,185 @@ export function buildTiptapContent(...@@ -212,61 +574,185 @@ export function buildTiptapContent(
212 if (piece) inline.push(textJsonForSpan(piece, span.marks));574 if (piece) inline.push(textJsonForSpan(piece, span.marks));
213 });575 });
214 }576 }
215 return { task: parsed?.prefix ?? null, inline };577 return inline;
216 });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 };
217584
218 // Pass 2: consecutive task lines group into (possibly nested) task lists585 // Pass 1: fenced blocks claim their lines whole; everything else becomes an
219 // by indent prefix; everything else stays a paragraph.586 // inline-parsed line. Fence bodies restore chip sources as literal text.
220 const blocks: Record<string, unknown>[] = [];587 const sourceLines = text.split("\n");
221 let stack: { indent: string; items: PendingTaskItem[] }[] = [];588 const entries: (
222 const flushTasks = () => {589 | { code: Record<string, unknown> }
223 if (stack.length > 0) {590 | { rule: string }
224 blocks.push(taskListJson(stack[0]!.items));591 | { heading: { level: number; space: string; inline: InlineJson[] } }
225 stack = [];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) {684 for (const entry of entries) {
229 if (!line.task) {685 if ("quote" in entry) {
230 flushTasks();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 blocks.push({ type: "paragraph", content: line.inline });715 blocks.push({ type: "paragraph", content: line.inline });
232 continue;716 continue;
233 }717 }
234 const item: PendingTaskItem = {718 const item: PendingItem = { prefix: line.list, content: line.inline, children: [] };
235 ...line.task,719 const key = listKey(line.list);
236 content: line.inline,720 const indent = line.list.indent;
237 children: [],
238 };
239 for (;;) {721 for (;;) {
240 const top = stack[stack.length - 1];722 const top = stack[stack.length - 1];
241 if (!top) {723 if (!top) {
242 // A leading indented item with no parent flattens but keeps indent.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 continue;726 continue;
245 }727 }
246 if (top.indent === item.indent) {728 if (top.indent === indent) {
247 top.items.push(item);729 if (top.list.key !== key) top.list = openList(top.container, key);
730 top.list.items.push(item);
248 break;731 break;
249 }732 }
250 if (top.indent !== "" && !item.indent.startsWith(top.indent)) {733 if (top.indent !== "" && !indent.startsWith(top.indent)) {
251 if (stack.length > 1) {734 if (stack.length > 1) {
252 stack.pop();735 stack.pop();
253 continue;736 continue;
254 }737 }
255 top.indent = item.indent;738 top.indent = indent;
256 top.items.push(item);739 if (top.list.key !== key) top.list = openList(top.container, key);
740 top.list.items.push(item);
257 break;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 if (!parent) {744 if (!parent) {
261 top.items.push(item);745 top.list.items.push(item);
262 break;746 break;
263 }747 }
264 parent.children.push(item);748 const list = openList(parent.children, key);
265 stack.push({ indent: item.indent, items: parent.children });749 stack.push({ indent, list, container: parent.children });
750 list.items.push(item);
266 break;751 break;
267 }752 }
268 }753 }
269 flushTasks();754 flushQuote();
755 flushLists();
270 return blocks;756 return blocks;
271}757}
272758
...@@ -275,7 +761,10 @@ export function buildDocJson(...@@ -275,7 +761,10 @@ export function buildDocJson(
275 skillLabelFor: (name: string) => SkillMeta,761 skillLabelFor: (name: string) => SkillMeta,
276 options?: { styling?: boolean },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}
280769
281export interface RichRun {770export interface RichRun {
...@@ -495,7 +984,210 @@ function appendInlineRuns(...@@ -495,7 +984,210 @@ function appendInlineRuns(
495 }984 }
496}985}
497986
498function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void {987const LIST_NODE_NAMES = new Set(["taskList", "bulletList", "orderedList"]);
988const 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 */
996function 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 */
1025function 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 */
1051export 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 */
1077export 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
1114function 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 */
1126export 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 */
1156export 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. */
1169function 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
1190function walkList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void {
499 let itemPos = listStart + 1;1191 let itemPos = listStart + 1;
500 let firstItem = true;1192 let firstItem = true;
501 list.content.forEach((item) => {1193 list.content.forEach((item) => {
...@@ -505,17 +1197,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul...@@ -505,17 +1197,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul
505 const itemContentStart = itemPos + 1;1197 const itemContentStart = itemPos + 1;
506 const first = item.firstChild;1198 const first = item.firstChild;
507 const empty = first?.type.name === "paragraph" && first.content.childCount === 0;1199 const empty = first?.type.name === "paragraph" && first.content.childCount === 0;
508 const attrs = item.attrs as Record<string, unknown>;1200 const prefix = listItemPrefix(item, empty);
509 const indent = typeof attrs.indent === "string" ? attrs.indent : "";1201 // The marker owns no document characters; every prefix offset clamps
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
519 // to the start of the item text, exactly like style markers.1202 // to the start of the item text, exactly like style markers.
520 acc.runs.push({1203 acc.runs.push({
521 kind: "prefix",1204 kind: "prefix",
...@@ -537,8 +1220,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul...@@ -537,8 +1220,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul
537 item.content.forEach((child) => {1220 item.content.forEach((child) => {
538 if (!firstBlock) pushBreakRun(acc);1221 if (!firstBlock) pushBreakRun(acc);
539 firstBlock = false;1222 firstBlock = false;
540 if (child.type.name === "taskList") {1223 if (LIST_NODE_NAMES.has(child.type.name)) {
541 walkTaskList(child, childPos, acc);1224 walkList(child, childPos, acc);
542 } else if (child.type.name === "paragraph") {1225 } else if (child.type.name === "paragraph") {
543 appendInlineRuns(child, childPos + 1, acc);1226 appendInlineRuns(child, childPos + 1, acc);
544 }1227 }
...@@ -548,6 +1231,111 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul...@@ -548,6 +1231,111 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul
548 });1231 });
549}1232}
5501233
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 */
1245function 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. */
1287function 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 */
1321export 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
551export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap {1339export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap {
552 const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 };1340 const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 };
553 const blocks: ProseMirrorNode[] = [];1341 const blocks: ProseMirrorNode[] = [];
...@@ -557,9 +1345,52 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap {...@@ -557,9 +1345,52 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap {
5571345
558 let pmBlockStart = 0;1346 let pmBlockStart = 0;
559 blocks.forEach((block, blockIndex) => {1347 blocks.forEach((block, blockIndex) => {
560 if (blockIndex > 0) pushBreakRun(acc);1348 if (blockIndex > 0 && !isTrailingLineAfterRule(doc, blockIndex)) pushBreakRun(acc);
561 if (block.type.name === "taskList") {1349 if (LIST_NODE_NAMES.has(block.type.name)) {
562 walkTaskList(block, pmBlockStart, acc);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 } else if (block.type.name === "paragraph") {1394 } else if (block.type.name === "paragraph") {
564 appendInlineRuns(block, pmBlockStart + 1, acc);1395 appendInlineRuns(block, pmBlockStart + 1, acc);
565 }1396 }
...@@ -582,12 +1413,28 @@ function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number {...@@ -582,12 +1413,28 @@ function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number {
582 : last.mdStart + last.mdLen;1413 : last.mdStart + last.mdLen;
583}1414}
5841415
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 */
1422function runOwnsOffset(run: RichRun, flatOffset: number): boolean {
1423 const end = run.flatStart + run.docLen;
1424 return flatOffset < end || (flatOffset === end && run.nodeName === "codeBlock");
1425}
1426
585export function flatToCollapsed(map: RichDocMap, flatOffset: number): number {1427export function flatToCollapsed(map: RichDocMap, flatOffset: number): number {
586 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));1428 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));
587 for (const run of map.runs) {1429 for (const run of map.runs) {
588 if (bounded < run.flatStart + run.docLen) {1430 if (runOwnsOffset(run, bounded)) {
589 if (run.kind === "text" || run.kind === "token") {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 return run.collapsedStart + (bounded - run.flatStart);1439 return run.collapsedStart + (bounded - run.flatStart);
593 }1440 }
...@@ -598,7 +1445,7 @@ export function flatToCollapsed(map: RichDocMap, flatOffset: number): number {...@@ -598,7 +1445,7 @@ export function flatToCollapsed(map: RichDocMap, flatOffset: number): number {
598export function flatToMarkdown(map: RichDocMap, flatOffset: number): number {1445export function flatToMarkdown(map: RichDocMap, flatOffset: number): number {
599 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));1446 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));
600 for (const run of map.runs) {1447 for (const run of map.runs) {
601 if (bounded < run.flatStart + run.docLen) {1448 if (runOwnsOffset(run, bounded)) {
602 if (run.kind === "text" || run.kind === "token") {1449 if (run.kind === "text" || run.kind === "token") {
603 return run.mdStart + run.openLen + (bounded - run.flatStart);1450 return run.mdStart + run.openLen + (bounded - run.flatStart);
604 }1451 }
...@@ -616,9 +1463,10 @@ export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): numbe...@@ -616,9 +1463,10 @@ export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): numbe
616 if (run.kind === "prefix") return run.flatStart;1463 if (run.kind === "prefix") return run.flatStart;
617 if (run.kind === "text" || run.kind === "token") {1464 if (run.kind === "text" || run.kind === "token") {
618 const within = collapsedOffset - run.collapsedStart;1465 const within = collapsedOffset - run.collapsedStart;
1466 const contentLen = run.collapsedLen - run.openLen - run.closeLen;
619 // Marker characters clamp to the styled edge: they are shown, never edited.1467 // Marker characters clamp to the styled edge: they are shown, never edited.
620 if (within <= run.openLen) return run.flatStart;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 return run.flatStart + (within - run.openLen);1470 return run.flatStart + (within - run.openLen);
623 }1471 }
624 return run.flatStart + (collapsedOffset - run.collapsedStart);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,7 +1422,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] {
1422/* Chat code blocks join the other code surfaces on the code tokens. The1422/* Chat code blocks join the other code surfaces on the code tokens. The
1423 default look keeps its secondary/input tints; themed palettes tint those1423 default look keeps its secondary/input tints; themed palettes tint those
1424 surfaces with the accent, which fights syntax highlighting. */1424 surfaces with the accent, which fights syntax highlighting. */
1425html[data-theme-id] .chat-markdown .chat-markdown-codeblock {1425html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock {
1426 background-color: var(--code-background);1426 background-color: var(--code-background);
1427 border-color: var(--contrast-border);1427 border-color: var(--contrast-border);
1428 color: var(--code-foreground);1428 color: var(--code-foreground);
...@@ -1433,11 +1433,11 @@ html[data-theme-id] .chat-markdown pre {...@@ -1433,11 +1433,11 @@ html[data-theme-id] .chat-markdown pre {
1433 color: var(--code-foreground);1433 color: var(--code-foreground);
1434}1434}
14351435
1436html[data-theme-id] .chat-markdown .chat-markdown-codeblock pre {1436html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre {
1437 background-color: transparent;1437 background-color: transparent;
1438}1438}
14391439
1440html[data-theme-id] .chat-markdown .chat-markdown-codeblock-header {1440html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header {
1441 color: color-mix(in srgb, var(--code-foreground) 72%, transparent);1441 color: color-mix(in srgb, var(--code-foreground) 72%, transparent);
1442}1442}
14431443
...@@ -1685,7 +1685,7 @@ code {...@@ -1685,7 +1685,7 @@ code {
1685 Inline code stays relative to its sentence so it never towers over prose;1685 Inline code stays relative to its sentence so it never towers over prose;
1686 diffs and file previews take the size through --diffs-font-size. */1686 diffs and file previews take the size through --diffs-font-size. */
1687.chat-markdown .chat-markdown-shiki .shiki,1687.chat-markdown .chat-markdown-shiki .shiki,
1688.chat-markdown pre code {1688:is(.chat-markdown, .composer-tiptap) pre code {
1689 font-size: var(--font-size-code, inherit);1689 font-size: var(--font-size-code, inherit);
1690}1690}
16911691
...@@ -1755,45 +1755,47 @@ code {...@@ -1755,45 +1755,47 @@ code {
1755.chat-markdown ol,1755.chat-markdown ol,
1756.chat-markdown blockquote,1756.chat-markdown blockquote,
1757.chat-markdown pre,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 margin: 0.65rem 0;1761 margin: 0.65rem 0;
1760}1762}
17611763
1762.chat-markdown h1,1764:is(.chat-markdown, .composer-tiptap) h1,
1763.chat-markdown h2,1765:is(.chat-markdown, .composer-tiptap) h2,
1764.chat-markdown h3,1766:is(.chat-markdown, .composer-tiptap) h3,
1765.chat-markdown h4,1767:is(.chat-markdown, .composer-tiptap) h4,
1766.chat-markdown h5,1768:is(.chat-markdown, .composer-tiptap) h5,
1767.chat-markdown h6 {1769:is(.chat-markdown, .composer-tiptap) h6 {
1768 margin: 1.25rem 0 0.5rem;1770 margin: 1.25rem 0 0.5rem;
1769 font-weight: 600;1771 font-weight: 600;
1770 line-height: 1.3;1772 line-height: 1.3;
1771 color: var(--contrast-foreground);1773 color: var(--contrast-foreground);
1772}1774}
17731775
1774.chat-markdown h1 {1776:is(.chat-markdown, .composer-tiptap) h1 {
1775 font-size: 1.25rem;1777 font-size: 1.25rem;
1776}1778}
17771779
1778.chat-markdown h2 {1780:is(.chat-markdown, .composer-tiptap) h2 {
1779 font-size: 1.125rem;1781 font-size: 1.125rem;
1780}1782}
17811783
1782.chat-markdown h3 {1784:is(.chat-markdown, .composer-tiptap) h3 {
1783 font-size: 1rem;1785 font-size: 1rem;
1784}1786}
17851787
1786.chat-markdown h4,1788:is(.chat-markdown, .composer-tiptap) h4,
1787.chat-markdown h5,1789:is(.chat-markdown, .composer-tiptap) h5,
1788.chat-markdown h6 {1790:is(.chat-markdown, .composer-tiptap) h6 {
1789 font-size: 0.875rem;1791 font-size: 0.875rem;
1790}1792}
17911793
1792.chat-markdown h6 {1794:is(.chat-markdown, .composer-tiptap) h6 {
1793 color: var(--contrast-muted-foreground);1795 color: var(--contrast-muted-foreground);
1794}1796}
17951797
1796.chat-markdown ul {1798:is(.chat-markdown, .composer-tiptap) ul {
1797 /* Reset for nested uls under a widened ol. --list-gutter is an inherited1799 /* Reset for nested uls under a widened ol. --list-gutter is an inherited
1798 custom property, so without this a task-list under a multi-digit ordered1800 custom property, so without this a task-list under a multi-digit ordered
1799 list would inherit the outer gutter instead of its own default. */1801 list would inherit the outer gutter instead of its own default. */
...@@ -1807,21 +1809,27 @@ code {...@@ -1807,21 +1809,27 @@ code {
1807 widest marker has multiple characters so it isn't clipped by list-style-position:1809 widest marker has multiple characters so it isn't clipped by list-style-position:
1808 outside painting the marker past the padding box. Reset it here too so a1810 outside painting the marker past the padding box. Reset it here too so a
1809 nested ol without its own widened marker doesn't inherit the outer one. */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 --list-gutter: 1.25rem;1813 --list-gutter: 1.25rem;
1812 padding-left: var(--list-gutter, 1.25rem);1814 padding-left: var(--list-gutter, 1.25rem);
1813 list-style-type: decimal;1815 list-style-type: decimal;
1814}1816}
18151817
1816.chat-markdown ol > li::marker {1818:is(.chat-markdown, .composer-tiptap) ol > li::marker {
1817 font-variant-numeric: tabular-nums;1819 font-variant-numeric: tabular-nums;
1818}1820}
18191821
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 list-style-type: circle;1829 list-style-type: circle;
1822}1830}
18231831
1824.chat-markdown ul ul ul {1832:is(.chat-markdown, .composer-tiptap) ul ul ul {
1825 list-style-type: square;1833 list-style-type: square;
1826}1834}
18271835
...@@ -1870,12 +1878,30 @@ code {...@@ -1870,12 +1878,30 @@ code {
1870 background-size: 4px 2px;1878 background-size: 4px 2px;
1871}1879}
18721880
1873.chat-markdown blockquote {1881:is(.chat-markdown, .composer-tiptap) blockquote {
1874 border-left: 2px solid var(--contrast-border);1882 border-left: 2px solid var(--contrast-border);
1875 padding-left: 0.8rem;1883 padding-left: 0.8rem;
1876 color: var(--contrast-muted-foreground);1884 color: var(--contrast-muted-foreground);
1877}1885}
18781886
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.chat-markdown section[data-footnotes] {1905.chat-markdown section[data-footnotes] {
1880 margin-top: 1.25rem;1906 margin-top: 1.25rem;
1881 border-top: 1px solid var(--contrast-border);1907 border-top: 1px solid var(--contrast-border);
...@@ -1939,23 +1965,23 @@ code {...@@ -1939,23 +1965,23 @@ code {
1939 background: color-mix(in srgb, var(--contrast-border) 78%, transparent);1965 background: color-mix(in srgb, var(--contrast-border) 78%, transparent);
1940}1966}
19411967
1942.chat-markdown .chat-markdown-codeblock-header {1968:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header {
1943 color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent);1969 color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent);
1944}1970}
19451971
1946.chat-markdown .chat-markdown-codeblock pre {1972:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre {
1947 margin: 0;1973 margin: 0;
1948 border: none;1974 border: none;
1949 border-radius: 0;1975 border-radius: 0;
1950 background: transparent;1976 background: transparent;
1951}1977}
19521978
1953.chat-markdown .chat-markdown-codeblock[data-wrap="true"] pre {1979:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock[data-wrap="true"] pre {
1954 white-space: pre-wrap;1980 white-space: pre-wrap;
1955 overflow-wrap: anywhere;1981 overflow-wrap: anywhere;
1956}1982}
19571983
1958.chat-markdown .chat-markdown-shiki .shiki {1984:is(.chat-markdown, .composer-tiptap) .chat-markdown-shiki .shiki {
1959 background: transparent !important;1985 background: transparent !important;
1960}1986}
19611987
...@@ -2181,7 +2207,7 @@ code {...@@ -2181,7 +2207,7 @@ code {
2181 outline: none;2207 outline: none;
2182}2208}
21832209
2184.composer-tiptap code {2210.composer-tiptap :not(pre) > code {
2185 font-family: var(--font-mono);2211 font-family: var(--font-mono);
2186 font-size: 0.92em;2212 font-size: 0.92em;
2187 background: color-mix(in oklab, var(--muted-foreground) 12%, transparent);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,3 +25,18 @@ Clipboard text must come from the Markdown serializer, not DOM text: chip labels
25the source and marker decorations are not content. Structured context records accompany25the source and marker decorations are not content. Structured context records accompany
26that text when available. Paste completes trailing chip delimiters and adds a leading26that text when available. Paste completes trailing chip delimiters and adds a leading
27boundary when inserting a chip directly after text.27boundary when inserting a chip directly after text.
28
29Block nodes (fences, lists, quotes, rules, headings) keep their exact Markdown
30delimiters as node attributes, so the round trip is byte-identical and offsets
31inside a delimiter clamp to the block's text like task and style markers. The
32serializers write only what the grammar has: a quote holds paragraphs, a list
33holds paragraphs and lists. So blocks may only form at a top-level paragraph.
34Input rules check this, and Tiptap's own block chords (Mod-Shift-8, Mod-Alt-c,
35and so on) are removed, because nesting a block the serializer cannot write
36drops its text from the stored draft. The list grammar is the plain-mode
37continuation's in [composer-list-continuation](../../apps/web/src/composer-list-continuation.ts);
38keep the two in step.
39
40Enter sends, so the fence handling in `handleKeyDown` must run before the send
41handler: checked afterwards, a fence never opens and a newline inside one sends
42the draft.
docs/user/composer.md+17
...@@ -12,6 +12,23 @@ becomes an attachment when inserting it would exceed the message limit. On a...@@ -12,6 +12,23 @@ becomes an attachment when inserting it would exceed the message limit. On a
12hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere12hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere
13to keep a large paste editable in the composer instead.13to keep a large paste editable in the composer instead.
1414
15## Formatting
16
17The 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
19them. Lines starting with `- `, `1. `, `- [ ] `, `> `, `# ` or `---` become lists,
20task lists, quotes, headings and rules; ` ``` ` followed by Enter opens a code
21block. What you typed is what the agent receives, markers and numbering
22included, and `#1234` without a space still looks up a pull request.
23
24Enter sends. In a list or quote, **Shift+Enter** continues it, and Shift+Enter
25on an empty line leaves it; **Tab** nests a list item. In a code block, Enter
26starts a new line at the current indentation, **Tab** and **Shift+Tab** indent
27the selected lines, and a closing ` ``` ` followed by Enter, or two blank lines
28at the end, leave the block. **Backspace** at the start of a code block turns it
29back into plain lines. Choose the language in a code block's corner to change
30it. Very large code blocks are shown without syntax highlighting.
31
15## Attach files32## Attach files
1633
17Attach up to 100 files per message. Each image can be up to 10 MiB, with at most34Attach 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,9 +687,24 @@ importers:
687 '@tiptap/core':687 '@tiptap/core':
688 specifier: ^3.31.3688 specifier: ^3.31.3
689 version: 3.31.3(@tiptap/pm@3.31.3)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 '@tiptap/extension-code':693 '@tiptap/extension-code':
691 specifier: ^3.31.3694 specifier: ^3.31.3
692 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))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 '@tiptap/extension-task-item':708 '@tiptap/extension-task-item':
694 specifier: ^3.31.3709 specifier: ^3.31.3
695 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))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))