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 @@
3232 "@tanstack/react-pacer": "^0.19.4",
3333 "@tanstack/react-router": "^1.160.2",
3434 "@tiptap/core": "^3.31.3",
35 "@tiptap/extension-blockquote": "^3.31.3",
3536 "@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",
3641 "@tiptap/extension-task-item": "^3.31.3",
3742 "@tiptap/extension-task-list": "^3.31.3",
3843 "@tiptap/pm": "^3.31.3",
apps/web/src/components/ChatMarkdown.tsx+66-15
......@@ -989,6 +989,63 @@ function MarkdownDetails({
989989 );
990990}
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
9921049/**
9931050 * Filename titles render icon + text; language-only titles render just the
9941051 * icon (redundant next to its own name) and fall back to the language text
......@@ -1141,19 +1198,12 @@ function MarkdownCodeBlock({
11411198 }
11421199
11431200 return (
1144 <div
1145 className="chat-markdown-codeblock my-[0.65rem] overflow-hidden rounded-lg border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32"
1146 data-language={language}
1147 data-wrap={wrapped ? "true" : "false"}
1148 >
1149 <div className="chat-markdown-codeblock-header flex items-center justify-between gap-2 pt-1.5 pr-1.5 pb-0 pl-3 select-none">
1150 <span className="inline-flex min-w-0 items-center gap-1.5 font-mono text-2xs">
1151 <MarkdownCodeBlockTitleContent
1152 fenceTitle={fenceTitle}
1153 language={language}
1154 theme={theme}
1155 />
1156 </span>
1201 <MarkdownCodeBlockFrame
1202 language={language}
1203 fenceTitle={fenceTitle}
1204 theme={theme}
1205 wrapped={wrapped}
1206 actions={
11571207 <span className="flex items-center gap-0.5" role="toolbar" aria-label="Code block actions">
11581208 {leadingActions}
11591209 {canWrap ? (
......@@ -1195,9 +1245,10 @@ function MarkdownCodeBlock({
11951245 ) : null}
11961246 {copyButton}
11971247 </span>
1198 </div>
1248 }
1249 >
11991250 {children}
1200 </div>
1251 </MarkdownCodeBlockFrame>
12011252 );
12021253}
12031254
apps/web/src/components/ComposerPromptEditorTiptap.tsx+374-48
......@@ -1,11 +1,17 @@
1import { Extension, Node, wrappingInputRule, type JSONContent } from "@tiptap/core";
2import { TaskList } from "@tiptap/extension-task-list";
1import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core";
32import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
43import StarterKit from "@tiptap/starter-kit";
54import { type Node as ProseMirrorNode } from "@tiptap/pm/model";
6import { splitBlockKeepMarks } from "@tiptap/pm/commands";
7import { type EditorState, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
8import { Decoration, DecorationSet } from "@tiptap/pm/view";
5import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands";
6import {
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";
915import type {
1016 AssistantCitation,
1117 ComposerContextClipboardFragment,
......@@ -45,19 +51,34 @@ import {
4551} from "~/composer-editor-mentions";
4652import {
4753 buildDocJson,
54 ComposerBlockExtensions,
55 ComposerCodeBlockExtension,
56 ComposerListExtensions,
4857 buildTiptapContent,
4958 collapsedToFlat,
5059 caretTakesMarksBefore,
60 convertBulletItemToTask,
5161 ComposerCodeExtension,
5262 ComposerTaskItemExtension,
63 ComposerTaskListExtension,
5364 flatToCollapsed,
5465 flatToMarkdown,
5566 flatToPm,
5667 pmToFlat,
5768 serializeEditorDoc,
69 serializeSelection,
70 splitOrLiftListItem,
5871 stepCaretAcrossStyledEdge,
5972 type SkillMeta,
6073} from "~/composer-rich-text-doc";
74import {
75 convertCodeFenceOnEnter,
76 exitCodeBlockOnClosingFence,
77 exitCodeBlockOnTrailingBlankLines,
78 indentCodeBlock,
79 indentedNewlineInCodeBlock,
80 selectionInOneCodeBlock,
81} from "~/composer-code-block";
6182import {
6283 COMPOSER_UNDO_GROUP_DELAY,
6384 type ComposerChangeKind,
......@@ -65,6 +86,7 @@ import {
6586 markAsClipboardEdit,
6687} from "~/composer-undo-grouping";
6788import { collectInlineContextIds } from "~/lib/composerContextReferences";
89import { resolveDiffThemeName } from "~/lib/diffRendering";
6890import { cn, isMacPlatform } from "~/lib/utils";
6991import { basenameOfPath } from "~/pierre-icons";
7092import { FileTagChipContent } from "./chat/FileTagChip";
......@@ -82,6 +104,8 @@ import {
82104import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
83105import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
84106import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
107import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";
108import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
85109import { importPastedComposerText } from "./composerInlineTokenPaste";
86110import { didComposerSelectionChangeVisibly } from "./composerSelection";
87111import type { ComposerDraftContextRecords } from "./composerContextPresentation";
......@@ -491,6 +515,9 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] {
491515 let range: StyledRange | null = null;
492516 let openLength = 0;
493517 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;
494521 if (run.openLen > 0) {
495522 range ??= { from: run.pmPos, to: run.pmPos, markers: [] };
496523 range.markers.push({
......@@ -518,6 +545,162 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] {
518545 return ranges;
519546}
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
521704const MarkerPluginKey = new PluginKey("composer-rich-markers");
522705
523706const ComposerMarkerPlugin = new Plugin({
......@@ -766,16 +949,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
766949 expandedCursor: nextExpandedCursor,
767950 contextIds: map.contextIds,
768951 };
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 ||
770959 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") ||
771960 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right");
772 onChangeRef.current(
773 nextValue,
774 nextCursor,
775 nextExpandedCursor,
776 cursorAdjacentToMention,
777 map.contextIds,
778 );
961 onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, map.contextIds);
779962 }, []);
780963
781964 const editorAttributes = useMemo(
......@@ -824,6 +1007,8 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
8241007 gapcursor: false,
8251008 trailingNode: false,
8261009 code: false,
1010 // The list extensions handle Backspace and Delete themselves.
1011 listKeymap: false,
8271012 undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY },
8281013 // Plain mode has no marks: typed markers stay literal characters.
8291014 ...(richText ? {} : { bold: false, italic: false, strike: false }),
......@@ -837,16 +1022,78 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
8371022 ...(richText
8381023 ? [
8391024 ComposerCodeExtension,
840 TaskList,
841 ComposerTaskItemExtension.extend({
1025 ComposerCodeBlockExtension.extend({
1026 addNodeView() {
1027 return ReactNodeViewRenderer(ComposerCodeBlockNodeView, {
1028 // The header holds the language picker. Tiptap only shields
1029 // events aimed at the button element itself, so a click on
1030 // its icon or label would reach ProseMirror and move the
1031 // selection; keep every header event away from the editor.
1032 stopEvent: ({ event }) =>
1033 event.target instanceof Element &&
1034 event.target.closest(".chat-markdown-codeblock-header") !== null,
1035 });
1036 },
1037 // Tiptap's own ``` + space rule would open a fence inside a
1038 // list item or quote, where the serializer has no line for
1039 // it. Enter on a fence line covers the gesture at top level.
8421040 addInputRules() {
843 return [
844 wrappingInputRule({
845 find: /^- \[([ xX])\] $/,
846 type: this.type,
847 getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }),
1041 return [];
1042 },
1043 }),
1044 composerCodeBlockHighlight({
1045 resolveTheme: () =>
1046 resolveDiffThemeName(
1047 document.documentElement.classList.contains("dark") ? "dark" : "light",
1048 ),
1049 }),
1050 ...ComposerBlockExtensions.map((extension) =>
1051 extension.name === "blockquote"
1052 ? extension.extend({
1053 addInputRules() {
1054 return [blockquoteInputRule];
1055 },
1056 })
1057 : extension.name === "horizontalRule"
1058 ? extension.extend({
1059 addInputRules() {
1060 return [horizontalRuleInputRule];
1061 },
1062 })
1063 : extension.name === "heading"
1064 ? extension.extend({
1065 addInputRules() {
1066 return [headingInputRule];
1067 },
1068 })
1069 : extension,
1070 ),
1071 ...ComposerListExtensions.map((extension) =>
1072 extension.name === "listItem"
1073 ? extension
1074 : extension.extend({
1075 addInputRules() {
1076 return this.name === "bulletList"
1077 ? [
1078 listMarkerInputRule(/^(?<marker>[*+])(?<space>\s)$/, "bulletList"),
1079 listMarkerInputRule(
1080 /^(?<marker>-)(?<space>[ \t]+)(?<carried>[^\s[])$/,
1081 "bulletList",
1082 ),
1083 ]
1084 : [
1085 listMarkerInputRule(
1086 /^(?<marker>\d+[.)])(?<space>\s)$/,
1087 "orderedList",
1088 ),
1089 ];
1090 },
8481091 }),
849 ];
1092 ),
1093 ComposerTaskListExtension,
1094 ComposerTaskItemExtension.extend({
1095 addInputRules() {
1096 return [taskInputRule(this.type), bulletToTaskInputRule];
8501097 },
8511098 }),
8521099 ]
......@@ -982,23 +1229,71 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
9821229 event.preventDefault();
9831230 return true;
9841231 }
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 }
9851276 const handler = onCommandKeyDownRef.current;
9861277 if (event.key === "Enter") {
9871278 const instance = editorHolder.current;
9881279 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;
9901282 if (handled) {
9911283 event.preventDefault();
9921284 event.stopPropagation();
9931285 return true;
9941286 }
9951287 event.preventDefault();
1288 if ((isTaskItem || isListItem) && instance && splitOrLiftListItem(instance)) {
1289 return true;
1290 }
9961291 if (
997 isTaskItem &&
1292 richText &&
9981293 instance &&
999 (instance.commands.splitListItem("taskItem", { checked: false }) ||
1000 (view.state.selection.$from.parent.content.size === 0 &&
1001 instance.commands.liftListItem("taskItem")))
1294 hasAncestor(view.state.selection.$from, "blockquote") &&
1295 view.state.selection.$from.parent.content.size === 0 &&
1296 instance.commands.lift("blockquote")
10021297 ) {
10031298 return true;
10041299 }
......@@ -1050,6 +1345,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
10501345 const pastedText = clipboardData.getData("text/plain");
10511346 if (!pastedText) return false;
10521347 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 }
10531361 const importFragment = importFragmentRef.current;
10541362 let text = importFragment
10551363 ? importPastedComposerText(clipboardData, importFragment)
......@@ -1073,18 +1381,44 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
10731381 }
10741382 const editorInstance = editorHolder.current;
10751383 if (editorInstance) {
1076 insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => {
1077 // Tagged on the same transaction insertContent builds, so the
1078 // paste is one undo step of its own.
1079 editorInstance
1080 .chain()
1081 .command(({ tr }) => {
1082 markAsClipboardEdit(tr, "paste");
1083 return true;
1084 })
1085 .insertContent(content)
1086 .run();
1087 });
1384 // Inside a list item or quote, pasted block markup has nowhere to
1385 // go: it stays literal lines the next rebuild reads back. Inline
1386 // marks still apply.
1387 const $paste = view.state.selection.$from;
1388 const nested = ["listItem", "taskItem", "blockquote"].some((name) =>
1389 hasAncestor($paste, name),
1390 );
1391 insertMarkdownParagraphs(
1392 text,
1393 skillLabelFor,
1394 { styling: richText, blocks: !nested },
1395 (content) => {
1396 // Tagged on the same transaction insertContent builds, so the
1397 // paste is one undo step of its own.
1398 editorInstance
1399 .chain()
1400 .command(({ tr }) => {
1401 markAsClipboardEdit(tr, "paste");
1402 return true;
1403 })
1404 .insertContent(content)
1405 .command(({ tr }) => {
1406 // A paste ending in a rule leaves the rule selected, and
1407 // the next keystroke would replace it. The caret goes to
1408 // the line after it, which is added when there is none.
1409 const { selection } = tr;
1410 if (!(selection instanceof NodeSelection) || !selection.node.isBlock) {
1411 return true;
1412 }
1413 if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) {
1414 tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create());
1415 }
1416 tr.setSelection(TextSelection.create(tr.doc, selection.to + 1));
1417 return true;
1418 })
1419 .run();
1420 },
1421 );
10881422 scrollTiptapCaretIntoView(editorInstance);
10891423 }
10901424 return true;
......@@ -1330,15 +1664,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
13301664 const clipboardData = event.clipboardData;
13311665 const { from, to } = editor.state.selection;
13321666 if (from === to) return;
1333 const { doc, schema } = editor.state;
1334 const slice = doc.slice(from, to);
1335 const first = slice.content.firstChild;
1336 const content = first?.isInline
1337 ? schema.nodes.paragraph!.create(null, slice.content)
1338 : first?.type.name === "taskItem"
1339 ? schema.nodes.taskList!.create(null, slice.content)
1340 : slice.content;
1341 const text = serializeEditorDoc(doc.type.create(null, content)).value;
1667 const text = serializeSelection(editor.state.doc, from, to);
13421668 const contextIds = Array.from(new Set(collectInlineContextIds(text)));
13431669 const fragment = contextIds.length > 0 ? build?.(contextIds) : null;
13441670 event.preventDefault();
......@@ -1442,7 +1768,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
14421768function insertMarkdownParagraphs(
14431769 value: string,
14441770 skillLabelFor: (name: string) => SkillMeta,
1445 options: { styling: boolean },
1771 options: { styling: boolean; blocks?: boolean },
14461772 insertContent: (content: JSONContent[] | JSONContent) => void,
14471773): void {
14481774 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 @@
11import { 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
59function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) {
610 return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end);
......@@ -66,3 +70,16 @@ describe("composer list continuation", () => {
6670 expect(listIndentForTab("- foo", 1, 3)).toBeNull();
6771 });
6872});
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
5555 return null;
5656}
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
5873function nextMarkerText(marker: ListMarker): string {
5974 if (marker.kind === "ordered") {
60 const number = Number.parseInt(marker.numberText, 10);
61 const next = Number.isSafeInteger(number)
62 ? String(number + 1).padStart(marker.numberText.length, "0")
63 : marker.numberText;
64 return `${marker.indent}${next}${marker.delimiter} `;
75 return `${marker.indent}${nextOrderedMarkerText(`${marker.numberText}${marker.delimiter}`)} `;
6576 }
6677 if (marker.kind === "task") {
6778 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";
55import { EditorState, TextSelection } from "@tiptap/pm/state";
66import { describe, expect, it } from "vite-plus/test";
77
8import { collapseExpandedComposerCursor } from "./composer-logic";
9
810import {
911 buildDocJson,
12 buildTiptapContent,
1013 caretTakesMarksBefore,
14 ComposerBlockExtensions,
15 ComposerCodeBlockExtension,
16 ComposerListExtensions,
1117 collapsedToFlat,
1218 ComposerCodeExtension,
1319 ComposerTaskItemExtension,
......@@ -16,6 +22,7 @@ import {
1622 flatToPm,
1723 pmToFlat,
1824 serializeEditorDoc,
25 serializeSelection,
1926 stepCaretAcrossStyledEdge,
2027} from "./composer-rich-text-doc";
2128
......@@ -64,6 +71,9 @@ const schema = getSchemaByResolvedExtensions(
6471 }),
6572 TaskList,
6673 ComposerTaskItemExtension,
74 ComposerCodeBlockExtension,
75 ...ComposerListExtensions,
76 ...ComposerBlockExtensions,
6777 ]),
6878);
6979
......@@ -344,6 +354,437 @@ describe("composer rich text document model", () => {
344354 expect(roundTripPlain(value).value).toBe(value);
345355 });
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
347788 it("maps every document offset through collapsed coordinates and back", () => {
348789 const value = "hi **bold** @README.md bye";
349790 const map = roundTrip(value);
......@@ -353,6 +794,37 @@ describe("composer rich text document model", () => {
353794 }
354795 });
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
356828 it("maps markdown offsets at styled edges onto document text", () => {
357829 const value = "a **bold** c";
358830 const map = roundTrip(value);
......@@ -365,6 +837,63 @@ describe("composer rich text document model", () => {
365837 });
366838});
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
368897describe("caret stops at styled edges", () => {
369898 function stateAt(value: string, pmPos: number) {
370899 const doc = ProseMirrorNode.fromJSON(
apps/web/src/composer-rich-text-doc.ts+925-77
......@@ -1,9 +1,24 @@
1import { Mark, type Node as ProseMirrorNode } from "@tiptap/pm/model";
2import type { EditorState, Transaction } from "@tiptap/pm/state";
1import { joinBackward, joinTextblockBackward, joinTextblockForward } from "@tiptap/pm/commands";
2import { Fragment, Mark, type Node as ProseMirrorNode, type ResolvedPos } from "@tiptap/pm/model";
33import { 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";
410import { 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
620import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
21import { nextOrderedMarkerText } from "~/composer-list-continuation";
722import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text";
823import { collectInlineContextIds } from "~/lib/composerContextReferences";
924
......@@ -55,12 +70,53 @@ export const ComposerCodeExtension = Code.extend({
5570 exitable: false,
5671});
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
58113/**
59114 * Task list items keep their exact source indent in an attribute so nesting
60115 * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` —
61116 * the same fixed-point deal as `__bold__` becoming `**bold**`.
62117 */
63118export const ComposerTaskItemExtension = TaskItem.extend({
119 content: LIST_ITEM_CONTENT,
64120 addAttributes() {
65121 return {
66122 ...this.parent?.(),
......@@ -69,8 +125,222 @@ export const ComposerTaskItemExtension = TaskItem.extend({
69125 contentSpace: { default: null },
70126 };
71127 },
128 addKeyboardShortcuts() {
129 return withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS);
130 },
72131}).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
74344function randomNodeKey(): string {
75345 return `tiptap-${Math.random().toString(36).slice(2)}`;
76346}
......@@ -98,10 +368,73 @@ function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength:
98368 };
99369}
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
101434type InlineJson = Record<string, unknown>;
102435
103436interface DocLine {
104 task: TaskLinePrefix | null;
437 list: ListLinePrefix | null;
105438 inline: InlineJson[];
106439}
107440
......@@ -143,28 +476,52 @@ function atomJsonForSegment(
143476 };
144477}
145478
146interface PendingTaskItem extends TaskLinePrefix {
479interface PendingItem {
480 prefix: ListLinePrefix;
147481 content: InlineJson[];
148 children: PendingTaskItem[];
482 /** Nested lists, in order; a parent can hold lists of different kinds. */
483 children: PendingList[];
149484}
150485
151function taskListJson(items: PendingTaskItem[]): InlineJson {
152 return {
153 type: "taskList",
154 content: items.map((item) => ({
155 type: "taskItem",
156 attrs: {
157 checked: item.checked,
158 indent: item.indent,
159 markerSpace: item.markerSpace,
160 contentSpace: item.contentSpace,
161 },
162 content: [
163 { type: "paragraph", content: item.content },
164 ...(item.children.length > 0 ? [taskListJson(item.children)] : []),
165 ],
166 })),
167 };
486interface PendingList {
487 key: string;
488 items: PendingItem[];
489}
490
491function listJson(list: PendingList): InlineJson {
492 const first = list.items[0]!.prefix;
493 const items = list.items.map((item) => {
494 const content = [
495 { type: "paragraph", content: item.content },
496 ...item.children.map((child) => listJson(child)),
497 ];
498 if (item.prefix.kind === "task") {
499 return {
500 type: "taskItem",
501 attrs: {
502 checked: item.prefix.checked,
503 indent: item.prefix.indent,
504 markerSpace: item.prefix.markerSpace,
505 contentSpace: item.prefix.contentSpace,
506 },
507 content,
508 };
509 }
510 return {
511 type: "listItem",
512 attrs: { indent: item.prefix.indent, marker: item.prefix.marker, space: item.prefix.space },
513 content,
514 };
515 });
516 if (first.kind === "task") return { type: "taskList", content: items };
517 if (first.kind === "ordered") {
518 return {
519 type: "orderedList",
520 attrs: { start: Number.parseInt(first.marker, 10) || 1 },
521 content: items,
522 };
523 }
524 return { type: "bulletList", content: items };
168525}
169526
170527function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, unknown> {
......@@ -180,9 +537,12 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, un
180537export function buildTiptapContent(
181538 value: string,
182539 skillLabelFor: (name: string) => SkillMeta,
183 options?: { styling?: boolean },
540 options?: { styling?: boolean; blocks?: boolean },
184541): Record<string, unknown>[] {
185542 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);
186546 // Hide token source from the markdown parser, then restore the atoms with
187547 // the marks of their surrounding text. Choose a sentinel absent from input.
188548 let sentinel = "\uFFFC";
......@@ -190,17 +550,19 @@ export function buildTiptapContent(
190550 sentinel = String.fromCodePoint(codePoint);
191551 }
192552 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[] = [];
193556 const text = splitPromptIntoComposerSegments(value)
194557 .map((segment) => {
195558 if (segment.type === "text") return segment.text;
196559 atoms.push(atomJsonForSegment(segment, skillLabelFor));
560 atomSources.push(segment.source);
197561 return sentinel;
198562 })
199563 .join("");
200564 let atomIndex = 0;
201 const lines: DocLine[] = text.split("\n").map((line) => {
202 const parsed = styling ? parseTaskPrefix(line) : null;
203 const content = parsed ? line.slice(parsed.markerLength) : line;
565 const buildInline = (content: string): InlineJson[] => {
204566 const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }];
205567 const inline: InlineJson[] = [];
206568 for (const span of spans) {
......@@ -212,61 +574,185 @@ export function buildTiptapContent(
212574 if (piece) inline.push(textJsonForSpan(piece, span.marks));
213575 });
214576 }
215 return { task: parsed?.prefix ?? null, inline };
216 });
577 return inline;
578 };
579 const buildDocLine = (line: string): DocLine => {
580 const parsed = blockSyntax ? parseListPrefix(line) : null;
581 const content = parsed ? line.slice(parsed.markerLength) : line;
582 return { list: parsed?.prefix ?? null, inline: buildInline(content) };
583 };
217584
218 // Pass 2: consecutive task lines group into (possibly nested) task lists
219 // by indent prefix; everything else stays a paragraph.
220 const blocks: Record<string, unknown>[] = [];
221 let stack: { indent: string; items: PendingTaskItem[] }[] = [];
222 const flushTasks = () => {
223 if (stack.length > 0) {
224 blocks.push(taskListJson(stack[0]!.items));
225 stack = [];
585 // Pass 1: fenced blocks claim their lines whole; everything else becomes an
586 // inline-parsed line. Fence bodies restore chip sources as literal text.
587 const sourceLines = text.split("\n");
588 const entries: (
589 | { code: Record<string, unknown> }
590 | { rule: string }
591 | { heading: { level: number; space: string; inline: InlineJson[] } }
592 | { quote: { prefix: string; inline: InlineJson[] } }
593 | { line: DocLine }
594 )[] = [];
595 const restoreSources = (line: string) =>
596 line.split(sentinel).reduce((joined, piece, index) => {
597 if (index === 0) return piece;
598 atomIndex += 1;
599 return joined + atomSources[atomIndex - 1]! + piece;
600 }, "");
601
602 for (let index = 0; index < sourceLines.length; index += 1) {
603 const line = sourceLines[index]!;
604 const opening = blockSyntax ? parseOpeningFence(line) : null;
605 if (!opening) {
606 // A thematic break outranks a list: `- - -` and `* * *` are rules, and
607 // `***` on its own line is a rule rather than an empty bold span.
608 if (blockSyntax && /^([-*_])(?:[ \t]*\1){2,}[ \t]*\r?$/.test(line)) {
609 entries.push({ rule: line });
610 continue;
611 }
612 const heading = blockSyntax ? /^(#{1,6})([ \t]+)([^\n]*)$/.exec(line) : null;
613 if (heading) {
614 entries.push({
615 heading: {
616 level: heading[1]!.length,
617 space: heading[2]!,
618 inline: buildInline(heading[3]!),
619 },
620 });
621 continue;
622 }
623 const quote = blockSyntax ? /^(>[ \t]*)([^\n]*)$/.exec(line) : null;
624 if (quote) entries.push({ quote: { prefix: quote[1]!, inline: buildInline(quote[2]!) } });
625 else entries.push({ line: buildDocLine(line) });
626 continue;
627 }
628 // The info string went through the sentinel pass like every line, so a
629 // token in it is put back as source here, in order, before the body.
630 const language = restoreSources(opening.language);
631 const body: string[] = [];
632 let cursor = index + 1;
633 let close = "";
634 while (cursor < sourceLines.length) {
635 const candidate = sourceLines[cursor]!;
636 if (isClosingFence(candidate, opening.fence)) {
637 close = `\n${candidate}`;
638 break;
639 }
640 body.push(restoreSources(candidate));
641 cursor += 1;
226642 }
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;
227683 };
228 for (const line of lines) {
229 if (!line.task) {
230 flushTasks();
684 for (const entry of entries) {
685 if ("quote" in entry) {
686 flushLists();
687 if (openQuote && openQuote.prefix !== entry.quote.prefix) flushQuote();
688 openQuote ??= { prefix: entry.quote.prefix, content: [] };
689 openQuote.content.push(entry.quote.inline);
690 continue;
691 }
692 flushQuote();
693 if ("code" in entry) {
694 flushLists();
695 blocks.push(entry.code);
696 continue;
697 }
698 if ("rule" in entry) {
699 flushLists();
700 blocks.push({ type: "horizontalRule", attrs: { source: entry.rule } });
701 continue;
702 }
703 if ("heading" in entry) {
704 flushLists();
705 blocks.push({
706 type: "heading",
707 attrs: { level: entry.heading.level, space: entry.heading.space },
708 content: entry.heading.inline,
709 });
710 continue;
711 }
712 const line = entry.line;
713 if (!line.list) {
714 flushLists();
231715 blocks.push({ type: "paragraph", content: line.inline });
232716 continue;
233717 }
234 const item: PendingTaskItem = {
235 ...line.task,
236 content: line.inline,
237 children: [],
238 };
718 const item: PendingItem = { prefix: line.list, content: line.inline, children: [] };
719 const key = listKey(line.list);
720 const indent = line.list.indent;
239721 for (;;) {
240722 const top = stack[stack.length - 1];
241723 if (!top) {
242724 // 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 });
244726 continue;
245727 }
246 if (top.indent === item.indent) {
247 top.items.push(item);
728 if (top.indent === indent) {
729 if (top.list.key !== key) top.list = openList(top.container, key);
730 top.list.items.push(item);
248731 break;
249732 }
250 if (top.indent !== "" && !item.indent.startsWith(top.indent)) {
733 if (top.indent !== "" && !indent.startsWith(top.indent)) {
251734 if (stack.length > 1) {
252735 stack.pop();
253736 continue;
254737 }
255 top.indent = item.indent;
256 top.items.push(item);
738 top.indent = indent;
739 if (top.list.key !== key) top.list = openList(top.container, key);
740 top.list.items.push(item);
257741 break;
258742 }
259 const parent = top.items[top.items.length - 1];
743 const parent = top.list.items[top.list.items.length - 1];
260744 if (!parent) {
261 top.items.push(item);
745 top.list.items.push(item);
262746 break;
263747 }
264 parent.children.push(item);
265 stack.push({ indent: item.indent, items: parent.children });
748 const list = openList(parent.children, key);
749 stack.push({ indent, list, container: parent.children });
750 list.items.push(item);
266751 break;
267752 }
268753 }
269 flushTasks();
754 flushQuote();
755 flushLists();
270756 return blocks;
271757}
272758
......@@ -275,7 +761,10 @@ export function buildDocJson(
275761 skillLabelFor: (name: string) => SkillMeta,
276762 options?: { styling?: boolean },
277763) {
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 };
279768}
280769
281770export interface RichRun {
......@@ -495,7 +984,210 @@ function appendInlineRuns(
495984 }
496985}
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 {
4991191 let itemPos = listStart + 1;
5001192 let firstItem = true;
5011193 list.content.forEach((item) => {
......@@ -505,17 +1197,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul
5051197 const itemContentStart = itemPos + 1;
5061198 const first = item.firstChild;
5071199 const empty = first?.type.name === "paragraph" && first.content.childCount === 0;
508 const attrs = item.attrs as Record<string, unknown>;
509 const indent = typeof attrs.indent === "string" ? attrs.indent : "";
510 const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " ";
511 const contentSpace =
512 typeof attrs.contentSpace === "string"
513 ? attrs.contentSpace || (empty ? "" : " ")
514 : empty
515 ? ""
516 : " ";
517 const prefix = `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`;
518 // The checkbox owns no document characters; every prefix offset clamps
1200 const prefix = listItemPrefix(item, empty);
1201 // The marker owns no document characters; every prefix offset clamps
5191202 // to the start of the item text, exactly like style markers.
5201203 acc.runs.push({
5211204 kind: "prefix",
......@@ -537,8 +1220,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul
5371220 item.content.forEach((child) => {
5381221 if (!firstBlock) pushBreakRun(acc);
5391222 firstBlock = false;
540 if (child.type.name === "taskList") {
541 walkTaskList(child, childPos, acc);
1223 if (LIST_NODE_NAMES.has(child.type.name)) {
1224 walkList(child, childPos, acc);
5421225 } else if (child.type.name === "paragraph") {
5431226 appendInlineRuns(child, childPos + 1, acc);
5441227 }
......@@ -548,6 +1231,111 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul
5481231 });
5491232}
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
5511339export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap {
5521340 const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 };
5531341 const blocks: ProseMirrorNode[] = [];
......@@ -557,9 +1345,52 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap {
5571345
5581346 let pmBlockStart = 0;
5591347 blocks.forEach((block, blockIndex) => {
560 if (blockIndex > 0) pushBreakRun(acc);
561 if (block.type.name === "taskList") {
562 walkTaskList(block, pmBlockStart, acc);
1348 if (blockIndex > 0 && !isTrailingLineAfterRule(doc, blockIndex)) pushBreakRun(acc);
1349 if (LIST_NODE_NAMES.has(block.type.name)) {
1350 walkList(block, pmBlockStart, acc);
1351 } else if (block.type.name === "codeBlock") {
1352 appendCodeBlockRun(block, pmBlockStart + 1, blockIndex < blocks.length - 1, acc);
1353 } else if (block.type.name === "blockquote") {
1354 walkBlockquote(block, pmBlockStart, acc);
1355 } else if (block.type.name === "heading") {
1356 const attrs = block.attrs as Record<string, unknown>;
1357 const level = typeof attrs.level === "number" ? attrs.level : 1;
1358 const space = typeof attrs.space === "string" ? attrs.space : " ";
1359 const prefix = `${"#".repeat(level)}${space}`;
1360 acc.runs.push({
1361 kind: "prefix",
1362 flatStart: acc.flat,
1363 docLen: 0,
1364 collapsedLen: prefix.length,
1365 mdLen: prefix.length,
1366 openLen: 0,
1367 closeLen: 0,
1368 pmPos: pmBlockStart + 1,
1369 mdStart: acc.md,
1370 collapsedStart: acc.collapsed,
1371 });
1372 acc.value += prefix;
1373 acc.collapsed += prefix.length;
1374 acc.md += prefix.length;
1375 appendInlineRuns(block, pmBlockStart + 1, acc);
1376 } else if (block.type.name === "horizontalRule") {
1377 const attrs = block.attrs as Record<string, unknown>;
1378 const source = typeof attrs.source === "string" && attrs.source ? attrs.source : "---";
1379 acc.runs.push({
1380 kind: "prefix",
1381 flatStart: acc.flat,
1382 docLen: 0,
1383 collapsedLen: source.length,
1384 mdLen: source.length,
1385 openLen: 0,
1386 closeLen: 0,
1387 pmPos: pmBlockStart + block.nodeSize,
1388 mdStart: acc.md,
1389 collapsedStart: acc.collapsed,
1390 });
1391 acc.value += source;
1392 acc.collapsed += source.length;
1393 acc.md += source.length;
5631394 } else if (block.type.name === "paragraph") {
5641395 appendInlineRuns(block, pmBlockStart + 1, acc);
5651396 }
......@@ -582,12 +1413,28 @@ function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number {
5821413 : last.mdStart + last.mdLen;
5831414}
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
5851427export function flatToCollapsed(map: RichDocMap, flatOffset: number): number {
5861428 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));
5871429 for (const run of map.runs) {
588 if (bounded < run.flatStart + run.docLen) {
1430 if (runOwnsOffset(run, bounded)) {
5891431 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;
5911438 }
5921439 return run.collapsedStart + (bounded - run.flatStart);
5931440 }
......@@ -598,7 +1445,7 @@ export function flatToCollapsed(map: RichDocMap, flatOffset: number): number {
5981445export function flatToMarkdown(map: RichDocMap, flatOffset: number): number {
5991446 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));
6001447 for (const run of map.runs) {
601 if (bounded < run.flatStart + run.docLen) {
1448 if (runOwnsOffset(run, bounded)) {
6021449 if (run.kind === "text" || run.kind === "token") {
6031450 return run.mdStart + run.openLen + (bounded - run.flatStart);
6041451 }
......@@ -616,9 +1463,10 @@ export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): numbe
6161463 if (run.kind === "prefix") return run.flatStart;
6171464 if (run.kind === "text" || run.kind === "token") {
6181465 const within = collapsedOffset - run.collapsedStart;
1466 const contentLen = run.collapsedLen - run.openLen - run.closeLen;
6191467 // Marker characters clamp to the styled edge: they are shown, never edited.
6201468 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;
6221470 return run.flatStart + (within - run.openLen);
6231471 }
6241472 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"] {
14221422/* Chat code blocks join the other code surfaces on the code tokens. The
14231423 default look keeps its secondary/input tints; themed palettes tint those
14241424 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 {
14261426 background-color: var(--code-background);
14271427 border-color: var(--contrast-border);
14281428 color: var(--code-foreground);
......@@ -1433,11 +1433,11 @@ html[data-theme-id] .chat-markdown pre {
14331433 color: var(--code-foreground);
14341434}
14351435
1436html[data-theme-id] .chat-markdown .chat-markdown-codeblock pre {
1436html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre {
14371437 background-color: transparent;
14381438}
14391439
1440html[data-theme-id] .chat-markdown .chat-markdown-codeblock-header {
1440html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header {
14411441 color: color-mix(in srgb, var(--code-foreground) 72%, transparent);
14421442}
14431443
......@@ -1685,7 +1685,7 @@ code {
16851685 Inline code stays relative to its sentence so it never towers over prose;
16861686 diffs and file previews take the size through --diffs-font-size. */
16871687.chat-markdown .chat-markdown-shiki .shiki,
1688.chat-markdown pre code {
1688:is(.chat-markdown, .composer-tiptap) pre code {
16891689 font-size: var(--font-size-code, inherit);
16901690}
16911691
......@@ -1755,45 +1755,47 @@ code {
17551755.chat-markdown ol,
17561756.chat-markdown blockquote,
17571757.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 {
17591761 margin: 0.65rem 0;
17601762}
17611763
1762.chat-markdown h1,
1763.chat-markdown h2,
1764.chat-markdown h3,
1765.chat-markdown h4,
1766.chat-markdown h5,
1767.chat-markdown h6 {
1764:is(.chat-markdown, .composer-tiptap) h1,
1765:is(.chat-markdown, .composer-tiptap) h2,
1766:is(.chat-markdown, .composer-tiptap) h3,
1767:is(.chat-markdown, .composer-tiptap) h4,
1768:is(.chat-markdown, .composer-tiptap) h5,
1769:is(.chat-markdown, .composer-tiptap) h6 {
17681770 margin: 1.25rem 0 0.5rem;
17691771 font-weight: 600;
17701772 line-height: 1.3;
17711773 color: var(--contrast-foreground);
17721774}
17731775
1774.chat-markdown h1 {
1776:is(.chat-markdown, .composer-tiptap) h1 {
17751777 font-size: 1.25rem;
17761778}
17771779
1778.chat-markdown h2 {
1780:is(.chat-markdown, .composer-tiptap) h2 {
17791781 font-size: 1.125rem;
17801782}
17811783
1782.chat-markdown h3 {
1784:is(.chat-markdown, .composer-tiptap) h3 {
17831785 font-size: 1rem;
17841786}
17851787
1786.chat-markdown h4,
1787.chat-markdown h5,
1788.chat-markdown h6 {
1788:is(.chat-markdown, .composer-tiptap) h4,
1789:is(.chat-markdown, .composer-tiptap) h5,
1790:is(.chat-markdown, .composer-tiptap) h6 {
17891791 font-size: 0.875rem;
17901792}
17911793
1792.chat-markdown h6 {
1794:is(.chat-markdown, .composer-tiptap) h6 {
17931795 color: var(--contrast-muted-foreground);
17941796}
17951797
1796.chat-markdown ul {
1798:is(.chat-markdown, .composer-tiptap) ul {
17971799 /* Reset for nested uls under a widened ol. --list-gutter is an inherited
17981800 custom property, so without this a task-list under a multi-digit ordered
17991801 list would inherit the outer gutter instead of its own default. */
......@@ -1807,21 +1809,27 @@ code {
18071809 widest marker has multiple characters so it isn't clipped by list-style-position:
18081810 outside painting the marker past the padding box. Reset it here too so a
18091811 nested ol without its own widened marker doesn't inherit the outer one. */
1810.chat-markdown ol {
1812:is(.chat-markdown, .composer-tiptap) ol {
18111813 --list-gutter: 1.25rem;
18121814 padding-left: var(--list-gutter, 1.25rem);
18131815 list-style-type: decimal;
18141816}
18151817
1816.chat-markdown ol > li::marker {
1818:is(.chat-markdown, .composer-tiptap) ol > li::marker {
18171819 font-variant-numeric: tabular-nums;
18181820}
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 {
18211829 list-style-type: circle;
18221830}
18231831
1824.chat-markdown ul ul ul {
1832:is(.chat-markdown, .composer-tiptap) ul ul ul {
18251833 list-style-type: square;
18261834}
18271835
......@@ -1870,12 +1878,30 @@ code {
18701878 background-size: 4px 2px;
18711879}
18721880
1873.chat-markdown blockquote {
1881:is(.chat-markdown, .composer-tiptap) blockquote {
18741882 border-left: 2px solid var(--contrast-border);
18751883 padding-left: 0.8rem;
18761884 color: var(--contrast-muted-foreground);
18771885}
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
18791905.chat-markdown section[data-footnotes] {
18801906 margin-top: 1.25rem;
18811907 border-top: 1px solid var(--contrast-border);
......@@ -1939,23 +1965,23 @@ code {
19391965 background: color-mix(in srgb, var(--contrast-border) 78%, transparent);
19401966}
19411967
1942.chat-markdown .chat-markdown-codeblock-header {
1968:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header {
19431969 color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent);
19441970}
19451971
1946.chat-markdown .chat-markdown-codeblock pre {
1972:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre {
19471973 margin: 0;
19481974 border: none;
19491975 border-radius: 0;
19501976 background: transparent;
19511977}
19521978
1953.chat-markdown .chat-markdown-codeblock[data-wrap="true"] pre {
1979:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock[data-wrap="true"] pre {
19541980 white-space: pre-wrap;
19551981 overflow-wrap: anywhere;
19561982}
19571983
1958.chat-markdown .chat-markdown-shiki .shiki {
1984:is(.chat-markdown, .composer-tiptap) .chat-markdown-shiki .shiki {
19591985 background: transparent !important;
19601986}
19611987
......@@ -2181,7 +2207,7 @@ code {
21812207 outline: none;
21822208}
21832209
2184.composer-tiptap code {
2210.composer-tiptap :not(pre) > code {
21852211 font-family: var(--font-mono);
21862212 font-size: 0.92em;
21872213 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
2525the source and marker decorations are not content. Structured context records accompany
2626that text when available. Paste completes trailing chip delimiters and adds a leading
2727boundary 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
1212hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere
1313to 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
1532## Attach files
1633
1734Attach 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:
687687 '@tiptap/core':
688688 specifier: ^3.31.3
689689 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)
690693 '@tiptap/extension-code':
691694 specifier: ^3.31.3
692695 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)
693708 '@tiptap/extension-task-item':
694709 specifier: ^3.31.3
695710 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))