authorclover caruso <git@paperclover.net> 2026-10-13 10:14:33-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log4b5a0105eece1c3aa7f02739122464260a9c5def
tree0b2dcf441f59832ad5bd1ae08911198209ab248b
parent06b388143eb05c2ed94bc04901560d243bb58823
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat: replace T3's composer with @clo/markdown-editor

Clover wants predictable editing of Markdown drafts. Replace Tiptap with @clo/markdown-editor, using canonical Markdown in CodeMirror and React portals for T3 controls. Preserve attachments, structured clipboard records, citations, IME, draft boundaries, undo, and submission settings. Complete inline emoji shortcodes without sending the draft. Retain fence indentation and exits, citation keyboard focus, theme-aware syntax and links, and deliberate three-marker underline headings. Require an explicit > marker on each blockquote line so unmarked replies exit the quote, including nested quotes and callouts. Keep transcript prose at its normal weight; apply heading typography only to headings. Clover wants figures to borrow gutter space only when their content needs it. Size top-level code, tables, diagrams, and rendered HTML between the prose column and equal space from both gutters, using their natural width in between. Keep every expanded figure centered on the prose column even when the details card narrows one gutter. Re-measure changing HTML figures and keep copy controls clear of the details card; prose and nested blocks retain their reading width. Group consecutive video embeds into equal-width rows that wrap, preserving media controls, authored anchors, streamed player identity, and singleton presentation. Use each video's natural aspect ratio within the available bounds, retaining the known frame size on failure. Prose and non-video content separate the groups. Color bold and italic from theme roles, give combined emphasis a red wash, and distinguish running tools from completed ones in collapsed and expanded work logs. Preserve text contrast, wrapping controls, keyboard access, and existing activity motion. Normalize list spacing and gutters across tight, loose, nested, and task lists. Preserve authored file-link labels as one actionable chip, including inline formatting, and keep the destination accessible. Retain soft-break spacing after autolinks and theme the safe keyboard, highlight, and underline tags. Keep footnote reference sentences visible after following return links and use theme-derived neutral syntax colors for readable comments and punctuation. Clover wants code diagrams to retain their alignment and continuous strokes. Use the configured monospace font with matching-width Iosevka box glyphs and 1.25 line spacing. Retain Recursive as a custom choice with CASL 0.15 and MONO 1; the fork default uses locally supplied Name Mono. Clover wants videos to remain usable while moving between chats. Share themed playback, seek, volume, speed, fullscreen, picture-in-picture, and source-aware file actions across transcript videos and file previews. Retain the same playing element in a floating player when its view closes, restore it to the matching embed, and release it on close, completion, replacement, or leaving the authenticated shell. Local files offer Finder reveal; remote videos offer download. On macOS, QuickTime opens a private client-side temporary copy, using fresh signed access for environment files. Hosted Markdown widgets reuse this player; the standalone editor keeps its native fallback. Assisted-by: gpt-6.1-sol

98 files changed, 10485 insertions(+), 7883 deletions(-)

apps/desktop/src/ipc/DesktopIpc.ts+1
......@@ -6,6 +6,7 @@ import * as Scope from "effect/Scope";
66
77export interface DesktopIpcInvokeEvent {
88 readonly sender: { readonly id: number };
9 readonly senderFrame?: { readonly url: string; readonly parent: unknown } | null;
910}
1011
1112export interface DesktopIpcSyncEvent {
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
......@@ -1,3 +1,4 @@
1import { openVideo } from "./methods/media.ts";
12import * as Effect from "effect/Effect";
23
34import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts";
......@@ -145,6 +146,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
145146 yield* ipc.handle(setTheme);
146147 yield* ipc.handle(showContextMenu);
147148 yield* ipc.handle(openExternal);
149 yield* ipc.handle(openVideo);
148150 yield* ipc.handle(receiveProviderAuthCallback);
149151 yield* ipc.handle(cancelProviderAuthCallback);
150152 yield* ipc.handle(openSystemSettings);
apps/desktop/src/ipc/channels.ts+2
......@@ -124,3 +124,5 @@ export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-au
124124export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage";
125125export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-local-storage";
126126export const DESKTOP_TERMINAL_CHANNEL = "desktop:terminal";
127
128export const OPEN_VIDEO_CHANNEL = "desktop:open-video";
apps/desktop/src/ipc/methods/media.test.ts created+56
......@@ -0,0 +1,56 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import type * as Electron from "electron";
6import { vi } from "vite-plus/test";
7
8vi.mock("electron", () => ({}));
9
10import * as DesktopConfig from "../../app/DesktopConfig.ts";
11import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
12import * as ElectronWindow from "../../electron/ElectronWindow.ts";
13import * as DesktopVideo from "../../media/DesktopVideo.ts";
14import { getDesktopUrl } from "../../electron/ElectronProtocol.ts";
15import { openVideo } from "./media.ts";
16
17it.effect("allows only the top-level app frame to open a video", () => {
18 const calls: unknown[] = [];
19 // Electron owns this object; only the native identity checks are exercised here.
20 const window = { isDestroyed: () => false, webContents: { id: 42 } } as Electron.BrowserWindow;
21 const layer = Layer.mergeAll(
22 DesktopEnvironment.layer({
23 dirname: "/repo/apps/desktop/dist-electron",
24 homeDirectory: "/tmp/t3-media-test",
25 platform: "darwin",
26 processArch: "arm64",
27 appVersion: "0.0.45",
28 appPath: "/tmp/T3 Code.app",
29 isPackaged: true,
30 resourcesPath: "/tmp/resources",
31 runningUnderArm64Translation: false,
32 }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({})))),
33 Layer.mock(ElectronWindow.ElectronWindow, { main: Effect.succeedSome(window) }),
34 Layer.succeed(DesktopVideo.DesktopVideo, {
35 open: (input) =>
36 Effect.sync(() => {
37 calls.push(input);
38 }),
39 }),
40 );
41 const input = { url: "https://remote.test/clip.mp4", name: "clip.mp4" };
42 const trustedFrame = { parent: null, url: getDesktopUrl(false) };
43 return Effect.gen(function* () {
44 for (const event of [
45 undefined,
46 { sender: { id: 100 }, senderFrame: trustedFrame },
47 { sender: { id: 42 }, senderFrame: { parent: {}, url: trustedFrame.url } },
48 { sender: { id: 42 }, senderFrame: { parent: null, url: "https://preview.test" } },
49 { sender: { id: 42 } },
50 ])
51 assert.equal((yield* Effect.result(openVideo.handler(input, event)))._tag, "Failure");
52 assert.equal(calls.length, 0);
53 yield* openVideo.handler(input, { sender: { id: 42 }, senderFrame: trustedFrame });
54 assert.deepEqual(calls, [input]);
55 }).pipe(Effect.provide(layer));
56});
apps/desktop/src/ipc/methods/media.ts created+37
......@@ -0,0 +1,37 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
5import * as ElectronWindow from "../../electron/ElectronWindow.ts";
6import { getDesktopUrl } from "../../electron/ElectronProtocol.ts";
7import * as DesktopVideo from "../../media/DesktopVideo.ts";
8import * as DesktopIpc from "../DesktopIpc.ts";
9import * as IpcChannels from "../channels.ts";
10
11export const openVideo = DesktopIpc.makeIpcMethod({
12 channel: IpcChannels.OPEN_VIDEO_CHANNEL,
13 payload: Schema.Struct({
14 url: Schema.String.check(Schema.isMaxLength(16_384)),
15 name: Schema.String.check(Schema.isMaxLength(255)),
16 }),
17 result: Schema.Void,
18 handler: Effect.fn("desktop.ipc.media.openVideo")(function* (input, event) {
19 const environment = yield* DesktopEnvironment.DesktopEnvironment;
20 const main = yield* (yield* ElectronWindow.ElectronWindow).main;
21 const expected = new URL(getDesktopUrl(environment.isDevelopment));
22 const frame = event?.senderFrame;
23 const source = frame && URL.canParse(frame.url) ? new URL(frame.url) : null;
24 if (
25 event === undefined ||
26 Option.isNone(main) ||
27 main.value.isDestroyed() ||
28 main.value.webContents.id !== event.sender.id ||
29 frame?.parent !== null ||
30 source?.protocol !== expected.protocol ||
31 source.host !== expected.host
32 )
33 return yield* new DesktopVideo.DesktopVideoError({ stage: "source" });
34
35 return yield* (yield* DesktopVideo.DesktopVideo).open(input);
36 }),
37});
apps/desktop/src/main.ts+2
......@@ -23,6 +23,7 @@ import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
2323import serverPackageJson from "../../server/package.json" with { type: "json" };
2424
2525import * as DesktopIpc from "./ipc/DesktopIpc.ts";
26import * as DesktopVideo from "./media/DesktopVideo.ts";
2627import * as ElectronApp from "./electron/ElectronApp.ts";
2728import * as ElectronDialog from "./electron/ElectronDialog.ts";
2829import * as ElectronMenu from "./electron/ElectronMenu.ts";
......@@ -216,6 +217,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe(
216217
217218const layerDesktopApplication = Layer.mergeAll(
218219 DesktopLifecycle.layer,
220 DesktopVideo.layer,
219221 layerDesktopAppActivation,
220222 DesktopWebLinks.layer,
221223 DesktopApplicationMenu.layer,
apps/desktop/src/media/DesktopVideo.test.ts created+167
......@@ -0,0 +1,167 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
8import { beforeEach, vi } from "vite-plus/test";
9
10const { fetchVideo, open } = vi.hoisted(() => ({ fetchVideo: vi.fn(), open: vi.fn() }));
11vi.mock("electron", () => ({ net: { fetch: fetchVideo } }));
12
13import * as DesktopVideo from "./DesktopVideo.ts";
14
15const spawner = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {
16 exitCode: (command) =>
17 Effect.sync(() => {
18 open(command);
19 return ChildProcessSpawner.ExitCode(0);
20 }),
21});
22
23beforeEach(() => {
24 fetchVideo.mockReset().mockImplementation(
25 async () =>
26 new Response("video bytes", {
27 headers: { "content-type": "video/mp4" },
28 }),
29 );
30 open.mockReset();
31});
32
33const layer = DesktopVideo.layer.pipe(Layer.provide(spawner));
34
35it.effect(
36 "streams to a private client temp file, opens only QuickTime, and cleans up on app scope exit",
37 () => {
38 let openedPath = "";
39 return Effect.gen(function* () {
40 const fs = yield* FileSystem.FileSystem;
41 yield* Effect.gen(function* () {
42 const video = yield* DesktopVideo.DesktopVideo;
43 yield* video.open({
44 url: "https://remote.test/api/assets/signed/clip.mp4",
45 name: "../../clip.mp4",
46 });
47 const command = open.mock.lastCall![0];
48 assert.equal(command.command, "/usr/bin/open");
49 assert.deepEqual(command.args.slice(0, 2), [
50 "-a",
51 "/System/Applications/QuickTime Player.app",
52 ]);
53 openedPath = command.args[2];
54 assert.match(openedPath, /\/t3-video-[^/]+\/clip\.mp4$/);
55 assert.equal(yield* fs.readFileString(openedPath), "video bytes");
56 assert.equal((yield* fs.stat(openedPath)).mode & 0o777, 0o600);
57 }).pipe(Effect.provide(layer));
58 assert.isFalse(yield* fs.exists(openedPath));
59 }).pipe(
60 Effect.provideService(HostProcess.Platform, "darwin"),
61 Effect.provide(NodeServices.layer),
62 );
63 },
64);
65
66it.effect("rejects non-macOS before downloading", () =>
67 Effect.gen(function* () {
68 const result = yield* Effect.result(
69 (yield* DesktopVideo.DesktopVideo).open({
70 url: "https://remote.test/clip.mp4",
71 name: "clip.mp4",
72 }),
73 );
74 assert.equal(result._tag, "Failure");
75 assert.equal(fetchVideo.mock.calls.length, 0);
76 assert.equal(open.mock.calls.length, 0);
77 }).pipe(
78 Effect.provide(layer),
79 Effect.provideService(HostProcess.Platform, "linux"),
80 Effect.provide(NodeServices.layer),
81 ),
82);
83
84it.effect("rejects file URLs and nonvideo filenames without reading local files", () =>
85 Effect.gen(function* () {
86 const video = yield* DesktopVideo.DesktopVideo;
87 for (const input of [
88 { url: "file:///Users/private/clip.mp4", name: "clip.mp4" },
89 { url: "https://remote.test/run", name: "run.command" },
90 { url: "https://user:password@remote.test/clip.mp4", name: "clip.mp4" },
91 ])
92 assert.equal((yield* Effect.result(video.open(input)))._tag, "Failure");
93 assert.equal(fetchVideo.mock.calls.length, 0);
94 assert.equal(open.mock.calls.length, 0);
95 }).pipe(
96 Effect.provide(layer),
97 Effect.provideService(HostProcess.Platform, "darwin"),
98 Effect.provide(NodeServices.layer),
99 ),
100);
101
102it.effect("does not open an authorization error or HTML error page as a video", () =>
103 Effect.gen(function* () {
104 const video = yield* DesktopVideo.DesktopVideo;
105 for (const response of [
106 new Response("unauthorized", { status: 401 }),
107 new Response("<html>sign in</html>", { headers: { "content-type": "text/html" } }),
108 ]) {
109 fetchVideo.mockResolvedValueOnce(response);
110 assert.equal(
111 (yield* Effect.result(
112 video.open({ url: "https://remote.test/clip.mp4", name: "clip.mp4" }),
113 ))._tag,
114 "Failure",
115 );
116 }
117 assert.equal(open.mock.calls.length, 0);
118 }).pipe(
119 Effect.provide(layer),
120 Effect.provideService(HostProcess.Platform, "darwin"),
121 Effect.provide(NodeServices.layer),
122 ),
123);
124
125it.effect("does not launch a partial download when its stream fails", () =>
126 Effect.gen(function* () {
127 fetchVideo.mockResolvedValueOnce(
128 new Response(
129 new ReadableStream({
130 start(controller) {
131 controller.enqueue(new TextEncoder().encode("partial"));
132 controller.error(new Error("disconnected"));
133 },
134 }),
135 { headers: { "content-type": "video/mp4" } },
136 ),
137 );
138 const video = yield* DesktopVideo.DesktopVideo;
139 assert.equal(
140 (yield* Effect.result(video.open({ url: "https://remote.test/clip.mp4", name: "clip.mp4" })))
141 ._tag,
142 "Failure",
143 );
144 assert.equal(open.mock.calls.length, 0);
145 }).pipe(
146 Effect.provide(layer),
147 Effect.provideService(HostProcess.Platform, "darwin"),
148 Effect.provide(NodeServices.layer),
149 ),
150);
151
152it.effect("uses the video response type for an extensionless remote download", () =>
153 Effect.gen(function* () {
154 fetchVideo.mockResolvedValueOnce(
155 new Response("movie", { headers: { "content-type": "video/quicktime" } }),
156 );
157 yield* (yield* DesktopVideo.DesktopVideo).open({
158 url: "https://remote.test/download?id=clip",
159 name: "recording",
160 });
161 assert.match(open.mock.lastCall![0].args[2], /\/recording\.mov$/);
162 }).pipe(
163 Effect.provide(layer),
164 Effect.provideService(HostProcess.Platform, "darwin"),
165 Effect.provide(NodeServices.layer),
166 ),
167);
apps/desktop/src/media/DesktopVideo.ts created+122
......@@ -0,0 +1,122 @@
1import { VIDEO_FILE_EXTENSIONS, videoMimeType } from "@t3tools/shared/video";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import * as Scope from "effect/Scope";
10import * as Stream from "effect/Stream";
11import * as ChildProcess from "effect/process/ChildProcess";
12import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
13import * as Electron from "electron";
14
15export class DesktopVideoError extends Schema.TaggedError<DesktopVideoError>()(
16 "DesktopVideoError",
17 {
18 stage: Schema.Literals(["unsupported", "source", "download", "open"]),
19 cause: Schema.optionalKey(Schema.Defect()),
20 },
21) {
22 override get message() {
23 switch (this.stage) {
24 case "unsupported":
25 return "QuickTime is available only on macOS.";
26 case "source":
27 return "This video cannot be opened in QuickTime. Try downloading it.";
28 case "download":
29 return "The video could not be downloaded. Reconnect and try again.";
30 case "open":
31 return "QuickTime could not open the video. Try downloading it.";
32 }
33 }
34}
35
36export class DesktopVideo extends Context.Service<
37 DesktopVideo,
38 {
39 readonly open: (input: {
40 readonly url: string;
41 readonly name: string;
42 }) => Effect.Effect<void, DesktopVideoError>;
43 }
44>()("@t3tools/desktop/media/DesktopVideo") {}
45
46const make = Effect.gen(function* () {
47 const fs = yield* FileSystem.FileSystem;
48 const path = yield* Path.Path;
49 const platform = yield* HostProcess.Platform;
50 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
51 const scope = yield* Effect.scope;
52
53 return DesktopVideo.of({
54 open: Effect.fn("DesktopVideo.open")(function* (input) {
55 if (platform !== "darwin") return yield* new DesktopVideoError({ stage: "unsupported" });
56 const url = yield* Effect.try({
57 try: () => new URL(input.url),
58 catch: (cause) => new DesktopVideoError({ stage: "source", cause }),
59 });
60 const name = Array.from(path.basename(input.name.replaceAll("\\", "/")))
61 .filter((character) => character.charCodeAt(0) >= 32 && character !== "\x7f")
62 .join("");
63 if (
64 !["https:", "http:"].includes(url.protocol) ||
65 url.username ||
66 url.password ||
67 !name ||
68 name.length > 255 ||
69 (path.extname(name) !== "" && !videoMimeType({ name, mimeType: "" }))
70 )
71 return yield* new DesktopVideoError({ stage: "source" });
72
73 const response = yield* Effect.tryPromise({
74 try: (signal) => Electron.net.fetch(url.href, { signal }),
75 catch: (cause) => new DesktopVideoError({ stage: "download", cause }),
76 });
77 const body = response.body;
78 const mimeType = response.headers.get("content-type") ?? "";
79 if (!response.ok || !body || !videoMimeType({ name, mimeType })) {
80 yield* Effect.promise(() => body?.cancel().catch(() => undefined) ?? Promise.resolve());
81 return yield* new DesktopVideoError({ stage: "download" });
82 }
83
84 const extension =
85 path.extname(name) ||
86 `.${
87 VIDEO_FILE_EXTENSIONS.find(
88 (extension) =>
89 videoMimeType({ name: `video.${extension}`, mimeType: "" }) ===
90 mimeType.split(";", 1)[0]?.trim().toLowerCase(),
91 ) ?? "mp4"
92 }`;
93 const fileName = path.extname(name) ? name : `${name.slice(0, 240)}${extension}`;
94 // QuickTime keeps reading after this action returns.
95 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-" }).pipe(
96 Effect.provideService(Scope.Scope, scope),
97 Effect.mapError((cause) => new DesktopVideoError({ stage: "download", cause })),
98 );
99 const file = path.join(directory, fileName);
100 yield* Stream.fromReadableStream({
101 evaluate: () => body,
102 onError: (cause) => new DesktopVideoError({ stage: "download", cause }),
103 }).pipe(
104 Stream.run(fs.sink(file, { flag: "wx", mode: 0o600 })),
105 Effect.mapError((cause) => new DesktopVideoError({ stage: "download", cause })),
106 Effect.onError(() => fs.remove(file, { force: true }).pipe(Effect.ignore)),
107 );
108 const exitCode = yield* spawner
109 .exitCode(
110 ChildProcess.make(
111 "/usr/bin/open",
112 ["-a", "/System/Applications/QuickTime Player.app", file],
113 { stdin: "ignore", stdout: "ignore", stderr: "ignore" },
114 ),
115 )
116 .pipe(Effect.mapError((cause) => new DesktopVideoError({ stage: "open", cause })));
117 if (exitCode !== 0) return yield* new DesktopVideoError({ stage: "open" });
118 }),
119 });
120});
121
122export const layer = Layer.effect(DesktopVideo, make);
apps/desktop/src/preload.ts+6
......@@ -226,6 +226,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
226226 cancelProviderAuthCallback: (url: string) =>
227227 ipcRenderer.invoke(IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL, url),
228228 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
229 ...(clientPlatform === "darwin"
230 ? {
231 openVideo: (input: { url: string; name: string }) =>
232 ipcRenderer.invoke(IpcChannels.OPEN_VIDEO_CHANNEL, input),
233 }
234 : {}),
229235 checkSystemPermission: (pane: string) =>
230236 ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane),
231237 openSystemSettings: (pane: string) =>
apps/web/package.json+8-12
......@@ -14,7 +14,13 @@
1414 "@base-ui/react": "^1.4.1",
1515 "@clerk/electron": "catalog:",
1616 "@clerk/react": "catalog:",
17 "@clo/markdown-editor": "workspace:*",
1718 "@clo/terminal": "workspace:*",
19 "@codemirror/commands": "^6.8.1",
20 "@codemirror/lang-markdown": "^6.5.0",
21 "@codemirror/language": "^6.12.3",
22 "@codemirror/state": "^6.6.0",
23 "@codemirror/view": "^6.43.1",
1824 "@daypicker/react": "^10.0.1",
1925 "@dnd-kit/core": "^6.3.1",
2026 "@dnd-kit/modifiers": "^9.0.0",
......@@ -23,6 +29,7 @@
2329 "@effect/atom-react": "catalog:",
2430 "@formkit/auto-animate": "^0.9.0",
2531 "@legendapp/list": "catalog:",
32 "@lezer/common": "^1.5.2",
2633 "@noble/hashes": "catalog:",
2734 "@pierre/diffs": "catalog:",
2835 "@pierre/trees": "1.0.0-beta.6",
......@@ -38,22 +45,11 @@
3845 "@t3tools/shared": "workspace:*",
3946 "@tanstack/react-pacer": "^0.19.4",
4047 "@tanstack/react-router": "^1.160.2",
41 "@tiptap/core": "^3.31.3",
42 "@tiptap/extension-blockquote": "^3.31.3",
43 "@tiptap/extension-code": "^3.31.3",
44 "@tiptap/extension-code-block": "^3.31.3",
45 "@tiptap/extension-heading": "^3.31.3",
46 "@tiptap/extension-horizontal-rule": "^3.31.3",
47 "@tiptap/extension-list": "^3.31.3",
48 "@tiptap/extension-task-item": "^3.31.3",
49 "@tiptap/extension-task-list": "^3.31.3",
50 "@tiptap/pm": "^3.31.3",
51 "@tiptap/react": "^3.31.3",
52 "@tiptap/starter-kit": "^3.31.3",
5348 "class-variance-authority": "^0.7.1",
5449 "culori": "^4.0.2",
5550 "dompurify": "^3.4.16",
5651 "effect": "catalog:",
52 "gemoji": "^8.1.0",
5753 "hast-util-to-html": "^9.0.5",
5854 "hast-util-to-jsx-runtime": "^2.3.6",
5955 "heic-to": "^1.5.2",
apps/web/src/appearanceFonts.ts+1
......@@ -103,6 +103,7 @@ export function applyAppearanceFontVariables(
103103 const families: ReadonlyArray<readonly [variable: string, custom: string, fallback: string]> = [
104104 ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK],
105105 ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK],
106 ["--chat-code-font", preferences.code, "var(--font-mono)"],
106107 // The composer falls back to whatever the sans preference resolves to.
107108 ["--font-composer", preferences.composer, "var(--font-sans)"],
108109 ];
apps/web/src/assets/fonts/Recursive-OFL.txt created+93
......@@ -0,0 +1,93 @@
1Copyright 2020 The Recursive Project Authors (https://github.com/arrowtype/recursive)
2
3This Font Software is licensed under the SIL Open Font License, Version 1.1.
4This license is copied below, and is also available with a FAQ at:
5http://scripts.sil.org/OFL
6
7
8-----------------------------------------------------------
9SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10-----------------------------------------------------------
11
12PREAMBLE
13The goals of the Open Font License (OFL) are to stimulate worldwide
14development of collaborative font projects, to support the font creation
15efforts of academic and linguistic communities, and to provide a free and
16open framework in which fonts may be shared and improved in partnership
17with others.
18
19The OFL allows the licensed fonts to be used, studied, modified and
20redistributed freely as long as they are not sold by themselves. The
21fonts, including any derivative works, can be bundled, embedded,
22redistributed and/or sold with any software provided that any reserved
23names are not used by derivative works. The fonts and derivatives,
24however, cannot be released under any other type of license. The
25requirement for fonts to remain under this license does not apply
26to any document created using the fonts or their derivatives.
27
28DEFINITIONS
29"Font Software" refers to the set of files released by the Copyright
30Holder(s) under this license and clearly marked as such. This may
31include source files, build scripts and documentation.
32
33"Reserved Font Name" refers to any names specified as such after the
34copyright statement(s).
35
36"Original Version" refers to the collection of Font Software components as
37distributed by the Copyright Holder(s).
38
39"Modified Version" refers to any derivative made by adding to, deleting,
40or substituting -- in part or in whole -- any of the components of the
41Original Version, by changing formats or by porting the Font Software to a
42new environment.
43
44"Author" refers to any designer, engineer, programmer, technical
45writer or other person who contributed to the Font Software.
46
47PERMISSION & CONDITIONS
48Permission is hereby granted, free of charge, to any person obtaining
49a copy of the Font Software, to use, study, copy, merge, embed, modify,
50redistribute, and sell modified and unmodified copies of the Font
51Software, subject to the following conditions:
52
531) Neither the Font Software nor any of its individual components,
54in Original or Modified Versions, may be sold by itself.
55
562) Original or Modified Versions of the Font Software may be bundled,
57redistributed and/or sold with any software, provided that each copy
58contains the above copyright notice and this license. These can be
59included either as stand-alone text files, human-readable headers or
60in the appropriate machine-readable metadata fields within text or
61binary files as long as those fields can be easily viewed by the user.
62
633) No Modified Version of the Font Software may use the Reserved Font
64Name(s) unless explicit written permission is granted by the corresponding
65Copyright Holder. This restriction only applies to the primary font name as
66presented to the users.
67
684) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69Software shall not be used to promote, endorse or advertise any
70Modified Version, except to acknowledge the contribution(s) of the
71Copyright Holder(s) and the Author(s) or with their explicit written
72permission.
73
745) The Font Software, modified or unmodified, in part or in whole,
75must be distributed entirely under this license, and must not be
76distributed under any other license. The requirement for fonts to
77remain under this license does not apply to any document created
78using the Font Software.
79
80TERMINATION
81This license becomes null and void if any of the above conditions are
82not met.
83
84DISCLAIMER
85THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93OTHER DEALINGS IN THE FONT SOFTWARE.
apps/web/src/assets/fonts/Recursive_VF_1.085.woff2 created
Binary files /dev/null and b/apps/web/src/assets/fonts/Recursive_VF_1.085.woff2 differ
apps/web/src/components/ChatMarkdown.test.tsx+284-1
......@@ -19,6 +19,9 @@ import { setMarkdownTaskChecked } from "./files/filePreviewMode";
1919
2020vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null }));
2121vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
22vi.mock("../state/query", () => ({
23 useEnvironmentQuery: () => ({ data: null, error: null }),
24}));
2225vi.mock("./chat/MermaidDiagram", () => ({
2326 // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG.
2427 MermaidDiagram: () => <svg aria-label="Diagram" />,
......@@ -95,6 +98,286 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
9598 return button.props as ComponentProps<typeof Button>;
9699}
97100
101describe("ChatMarkdown inline rendering", () => {
102 it.each([true, false])(
103 "keeps authored file-link labels as one actionable chip with parseRawHtml=%s",
104 async (parseRawHtml) => {
105 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
106 const container = document.createElement("div");
107 document.body.append(container);
108 const root = createRoot(container);
109 const text =
110 "[relative](./foo.md), [**formatted** `source`](./src/main.ts), [ref][file] and [foo.md](./foo.md)\n\n[file]: ./bar.md";
111 try {
112 await act(() =>
113 root.render(
114 <ChatMarkdown
115 cwd="/workspace/project"
116 environmentId={EnvironmentId.make("env-links")}
117 parseRawHtml={parseRawHtml}
118 text={text}
119 />,
120 ),
121 );
122 const chips = [
123 ...container.querySelectorAll<HTMLAnchorElement>("a.chat-markdown-file-link"),
124 ];
125 expect(chips.map((chip) => chip.textContent)).toEqual([
126 "relative",
127 "formatted source",
128 "ref",
129 "foo.md",
130 ]);
131 expect(container.querySelector("p")!.textContent).toBe(
132 "relative, formatted source, ref and foo.md",
133 );
134 expect(chips.map((chip) => chip.getAttribute("href"))).toEqual([
135 "/workspace/project/./foo.md",
136 "/workspace/project/./src/main.ts",
137 "/workspace/project/./bar.md",
138 "/workspace/project/./foo.md",
139 ]);
140 expect(chips[1]!.querySelector("strong")!.textContent).toBe("formatted");
141 expect(chips[1]!.querySelector("code")!.textContent).toBe("source");
142 chips[0]!.focus();
143 expect(document.activeElement).toBe(chips[0]);
144 expect(chips[0]!.getAttribute("aria-description")).toBe("/workspace/project/./foo.md");
145 } finally {
146 await act(() => root.unmount());
147 container.remove();
148 vi.unstubAllGlobals();
149 }
150 },
151 );
152});
153
154describe("ChatMarkdown consecutive videos", () => {
155 it.each([
156 [
157 "separate paragraphs",
158 "![First](https://media.test/first.mp4)\n\n![Second](https://media.test/second.webm)",
159 ],
160 [
161 "one paragraph",
162 "![First](https://media.test/first.mp4) ![Second](https://media.test/second.webm)",
163 ],
164 [
165 "hard breaks",
166 "![First](https://media.test/first.mp4) \n![Second](https://media.test/second.webm)",
167 ],
168 [
169 "reference images",
170 "![First][first]\n\n![Second][second]\n\n[first]: https://media.test/first.mp4\n[second]: https://media.test/second.webm",
171 ],
172 [
173 "sanitized HTML images",
174 '<img src="https://media.test/first.mp4" alt="First" width="640" height="480">\n\n<img src="https://media.test/second.webm" alt="Second">',
175 ],
176 ])("groups %s without changing playback sources or accessible names", async (_case, text) => {
177 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
178 const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
179 const container = document.createElement("div");
180 const root = createRoot(container);
181 try {
182 await act(() => root.render(<ChatMarkdown cwd={undefined} text={text} />));
183 const gallery = container.querySelector(".chat-markdown-video-row");
184 expect(gallery).not.toBeNull();
185 const videos = [...gallery!.querySelectorAll("video")];
186 expect(videos.map((video) => video.src)).toEqual([
187 "https://media.test/first.mp4",
188 "https://media.test/second.webm",
189 ]);
190 expect(videos.map((video) => video.getAttribute("aria-label"))).toEqual(["First", "Second"]);
191 expect(videos.every((video) => !video.controls && video.playsInline)).toBe(true);
192 expect(gallery!.querySelectorAll('button[aria-label="Play video"]')).toHaveLength(2);
193 if (_case === "sanitized HTML images") {
194 expect(videos[0]!.style.aspectRatio).toBe("640 / 480");
195 }
196 } finally {
197 await act(() => root.unmount());
198 pause.mockRestore();
199 vi.unstubAllGlobals();
200 }
201 });
202
203 it("keeps prose, links, and images separate from grouped and lone videos", async () => {
204 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
205 const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
206 const container = document.createElement("div");
207 const root = createRoot(container);
208 const text = [
209 "![First](https://media.test/first.mp4)",
210 "![Second](https://media.test/second.webm)",
211 "Between videos.",
212 "![Third](https://media.test/third.mp4)",
213 "[Source](https://media.test/source)",
214 "![Still](https://media.test/still.png)",
215 "Caption ![Inline](https://media.test/inline.mp4)",
216 "![Last](https://media.test/last.mp4)",
217 ].join("\n\n");
218 try {
219 await act(() => root.render(<ChatMarkdown cwd={undefined} text={text} />));
220 expect(
221 [...container.querySelectorAll(".chat-markdown-video-row")].map((row) =>
222 row.getAttribute("data-video-count"),
223 ),
224 ).toEqual(["2", "1", "1"]);
225 expect(
226 container.querySelector(".chat-markdown-video-row")!.querySelectorAll("video"),
227 ).toHaveLength(2);
228 const paragraphs = [...container.querySelectorAll("p")];
229 expect(paragraphs.map((paragraph) => paragraph.textContent)).toContain("Between videos.");
230 expect(container.querySelector('video[aria-label="Third"]')?.getAttribute("src")).toBe(
231 "https://media.test/third.mp4",
232 );
233 expect(container.querySelector('video[aria-label="Last"]')?.getAttribute("src")).toBe(
234 "https://media.test/last.mp4",
235 );
236 expect(
237 container
238 .querySelector('a[href="https://media.test/source"]')!
239 .closest(".chat-markdown-video-row"),
240 ).toBeNull();
241 expect(
242 container.querySelector('img[alt="Still"]')!.closest(".chat-markdown-video-row"),
243 ).toBeNull();
244 const caption = paragraphs.find((paragraph) =>
245 paragraph.querySelector('[aria-label="Inline"]'),
246 )!;
247 const prose = [...caption.childNodes]
248 .filter((node) => node.nodeType === Node.TEXT_NODE)
249 .map((node) => node.textContent)
250 .join("");
251 expect(prose).toBe("Caption ");
252 } finally {
253 await act(() => root.unmount());
254 pause.mockRestore();
255 vi.unstubAllGlobals();
256 }
257 });
258
259 it("keeps literal HTML mode and raw-video sanitization unchanged", async () => {
260 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
261 const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
262 const container = document.createElement("div");
263 const root = createRoot(container);
264 try {
265 await act(() =>
266 root.render(
267 <ChatMarkdown
268 cwd={undefined}
269 parseRawHtml={false}
270 text={
271 '![First](https://media.test/first.mp4)\n\n![Second](https://media.test/second.webm)\n\n<video src="https://media.test/raw.mp4" controls></video>'
272 }
273 />,
274 ),
275 );
276 expect(container.querySelectorAll("video")).toHaveLength(2);
277 expect(container.querySelectorAll(".chat-markdown-video-row")).toHaveLength(1);
278 expect(container.textContent).toContain(
279 '<video src="https://media.test/raw.mp4" controls></video>',
280 );
281 await act(() =>
282 root.render(
283 <ChatMarkdown
284 cwd={undefined}
285 text='<video src="https://media.test/raw.mp4" controls></video>'
286 />,
287 ),
288 );
289 expect(container.querySelector("video")).toBeNull();
290 } finally {
291 await act(() => root.unmount());
292 pause.mockRestore();
293 vi.unstubAllGlobals();
294 }
295 });
296
297 it("preserves authored paragraph anchors beside grouped videos", async () => {
298 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
299 const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
300 const container = document.createElement("div");
301 const root = createRoot(container);
302 try {
303 await act(() =>
304 root.render(
305 <ChatMarkdown
306 cwd={undefined}
307 text={
308 '<p id="clip"><img src="https://media.test/anchored.mp4" alt="Anchored"></p>\n\n![First](https://media.test/first.mp4)\n\n![Second](https://media.test/second.webm)'
309 }
310 />,
311 ),
312 );
313 const anchored = container.querySelector("#user-content-clip");
314 expect(anchored?.tagName).toBe("P");
315 expect(anchored?.querySelector("video")?.src).toBe("https://media.test/anchored.mp4");
316 expect(anchored?.closest(".chat-markdown-video-row")).toBeNull();
317 expect(
318 container.querySelector(".chat-markdown-video-row")?.querySelectorAll("video"),
319 ).toHaveLength(2);
320 } finally {
321 await act(() => root.unmount());
322 pause.mockRestore();
323 vi.unstubAllGlobals();
324 }
325 });
326
327 it.each(["", "```text\ncompleted prefix\n```\n\n"])(
328 "preserves playing video nodes as a streamed singleton grows (prefix=%j)",
329 async (completedPrefix) => {
330 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
331 const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
332 const container = document.createElement("div");
333 const root = createRoot(container);
334 const prefix = `${completedPrefix}![First](https://media.test/first.mp4)`;
335 try {
336 await act(() => root.render(<ChatMarkdown cwd={undefined} isStreaming text={prefix} />));
337 const firstPlayer = container.querySelector("video");
338 expect(firstPlayer).not.toBeNull();
339 firstPlayer!.currentTime = 7;
340 await act(() => firstPlayer!.dispatchEvent(new Event("play")));
341 await act(() =>
342 root.render(
343 <ChatMarkdown
344 cwd={undefined}
345 isStreaming
346 text={`${prefix}\n\n![Second](https://media.test/second.webm)`}
347 />,
348 ),
349 );
350 expect(container.querySelector("video")).toBe(firstPlayer);
351 expect(container.querySelector("video")?.currentTime).toBe(7);
352 expect(
353 container.querySelector(".chat-markdown-video-row")?.getAttribute("data-video-count"),
354 ).toBe("2");
355 await act(() =>
356 root.render(
357 <ChatMarkdown
358 cwd={undefined}
359 isStreaming
360 text={`${prefix}\n\n![Second](https://media.test/second.webm)\n\n![Third](https://media.test/third.mp4)`}
361 />,
362 ),
363 );
364 expect(
365 container.querySelector(".chat-markdown-video-row")?.getAttribute("data-video-count"),
366 ).toBe("3");
367 expect(
368 [...container.querySelectorAll("video")].map((video) => video.getAttribute("aria-label")),
369 ).toEqual(["First", "Second", "Third"]);
370 expect(container.querySelector("video")).toBe(firstPlayer);
371 expect(container.querySelector("video")?.currentTime).toBe(7);
372 } finally {
373 await act(() => root.unmount());
374 pause.mockRestore();
375 vi.unstubAllGlobals();
376 }
377 },
378 );
379});
380
98381describe("ChatMarkdown bare anchor placeholders", () => {
99382 it.each(["<A>", "<a>", "<a >", "<a/>", "<A/>", "<a />"])(
100383 "preserves unmatched %s without linking later blocks",
......@@ -1014,7 +1297,7 @@ describe("ChatMarkdown Windows file links", () => {
10141297 <ChatMarkdown
10151298 cwd="C:/Users/shawn/project"
10161299 environmentId={environmentId}
1017 text={String.raw`[Source](C:\Users\shawn\project\src\index.ts) and [Test](C:\Users\shawn\project\test\index.ts)`}
1300 text={String.raw`[index.ts](C:\Users\shawn\project\src\index.ts) and [index.ts](C:\Users\shawn\project\test\index.ts)`}
10181301 lineBreaks={!parseRawHtml}
10191302 parseRawHtml={parseRawHtml}
10201303 />,
apps/web/src/components/ChatMarkdown.tsx+123-39
......@@ -107,7 +107,6 @@ import { parseComposerContextHref } from "@t3tools/shared/composerContextReferen
107107import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
108108import { AssistantCitationChip } from "./chat/AssistantCitationChip";
109109import { MarkdownThreadLink } from "./chat/MarkdownThreadLink";
110import { isWindowsAbsolutePath } from "@t3tools/shared/path";
111110import {
112111 artifactTemplateFromHastProperties,
113112 renderCodexInlineDirectivesAsMarkdown,
......@@ -496,16 +495,6 @@ function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string):
496495 );
497496}
498497
499type MarkdownAstNode = {
500 type?: string;
501 meta?: unknown;
502 url?: string;
503 data?: {
504 hProperties?: Record<string, unknown>;
505 };
506 children?: MarkdownAstNode[];
507};
508
509498function nodeToPlainText(node: ReactNode): string {
510499 if (typeof node === "string" || typeof node === "number") {
511500 return String(node);
......@@ -1207,7 +1196,7 @@ interface MarkdownFileLinkProps {
12071196 absolute host path outside it, null when the panel cannot show the file. */
12081197 panelPath: string | null;
12091198 line?: number | undefined;
1210 label: string;
1199 label: ReactNode;
12111200 copyMarkdown: string;
12121201 theme: "light" | "dark";
12131202 threadRef?: ScopedThreadRef | undefined;
......@@ -1560,7 +1549,7 @@ function ChatMarkdownVideo(props: {
15601549 className={cn(
15611550 CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME,
15621551 CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME,
1563 "w-full",
1552 "w-full max-w-[min(100%,30rem,calc(30rem*var(--media-video-ratio)))]",
15641553 )}
15651554 videoClassName={cn(
15661555 CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME,
......@@ -1718,8 +1707,8 @@ function leadingExternalLinkTextLength(text: string): number {
17181707function breakableExternalLinkText(text: string): ReactNode[] {
17191708 return Array.from(text, (character, index) => (
17201709 <React.Fragment key={`${index}:${character}`}>
1710 {index > 0 && <wbr />}
17211711 {character}
1722 <wbr />
17231712 </React.Fragment>
17241713 ));
17251714}
......@@ -1833,13 +1822,90 @@ function handleMarkdownFragmentClick(event: ReactMouseEvent<HTMLAnchorElement>,
18331822 findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" });
18341823}
18351824
1836type HeadingHastNode = {
1825type MarkdownHastNode = {
18371826 type?: string;
1827 value?: string;
18381828 tagName?: string;
18391829 properties?: Record<string, unknown>;
1840 children?: HeadingHastNode[];
1830 children?: MarkdownHastNode[];
18411831};
18421832
1833function rehypeVideoRows() {
1834 return (tree: MarkdownHastNode) => {
1835 const isVideoImage = (node: MarkdownHastNode) => {
1836 const src = node.properties?.dataLocalSrc ?? node.properties?.src;
1837 return (
1838 node.type === "element" &&
1839 node.tagName === "img" &&
1840 typeof src === "string" &&
1841 mediaKindFromPath(src) === "video"
1842 );
1843 };
1844 const videosInBlock = (node: MarkdownHastNode) => {
1845 if (isVideoImage(node)) return [node];
1846 if (
1847 node.type !== "element" ||
1848 node.tagName !== "p" ||
1849 Object.keys(node.properties ?? {}).length > 0
1850 ) {
1851 return null;
1852 }
1853 const videos = (node.children ?? []).filter(
1854 (child) =>
1855 !(child.type === "text" && !child.value?.trim()) &&
1856 !(child.type === "element" && child.tagName === "br"),
1857 );
1858 return videos.length > 0 && videos.every(isVideoImage) ? videos : null;
1859 };
1860 const visit = (node: MarkdownHastNode) => {
1861 if (!node.children) return;
1862 node.children.forEach(visit);
1863 if (
1864 node.type !== "root" &&
1865 !["div", "blockquote", "li", "td", "th", "figure", "details"].includes(node.tagName ?? "")
1866 ) {
1867 return;
1868 }
1869
1870 for (let index = 0; index < node.children.length; index++) {
1871 const first = node.children[index]!;
1872 const videos = videosInBlock(first);
1873 if (!videos) continue;
1874 let end = index + 1;
1875 while (end < node.children.length) {
1876 const next = node.children[end]!;
1877 if (next.type === "text" && !next.value?.trim()) {
1878 end++;
1879 continue;
1880 }
1881 const moreVideos = videosInBlock(next);
1882 if (!moreVideos) break;
1883 videos.push(...moreVideos);
1884 end++;
1885 }
1886 node.children.splice(index, end - index, {
1887 type: "element",
1888 tagName: "div",
1889 properties: {
1890 className: [
1891 "chat-markdown-video-row",
1892 ...(first.tagName === "img" ? ["chat-markdown-video-bare"] : []),
1893 ],
1894 dataVideoCount: videos.length,
1895 },
1896 children: videos.map((video) => ({
1897 type: "element",
1898 tagName: "div",
1899 properties: {},
1900 children: [video],
1901 })),
1902 });
1903 }
1904 };
1905 visit(tree);
1906 };
1907}
1908
18431909/** GitHub's heading anchor slug, so `[Setup](#setup)` table-of-contents links find their heading. */
18441910function githubHeadingSlug(text: string): string {
18451911 return text
......@@ -1855,18 +1921,18 @@ function githubHeadingSlug(text: string): string {
18551921 * fragment lookup strips it.
18561922 */
18571923function rehypeHeadingIds() {
1858 return (tree: HeadingHastNode) => {
1924 return (tree: MarkdownHastNode) => {
18591925 // Every id already in the document, authored or assigned, so a suffix never
18601926 // lands on one that exists: `Setup`, `Setup`, `Setup-1` get three distinct ids.
18611927 const taken = new Set<string>();
1862 const collect = (node: HeadingHastNode) => {
1928 const collect = (node: MarkdownHastNode) => {
18631929 const id = node.properties?.id;
18641930 if (typeof id === "string") taken.add(id);
18651931 node.children?.forEach(collect);
18661932 };
18671933 collect(tree);
18681934 const nextSuffix = new Map<string, number>();
1869 const visit = (node: HeadingHastNode) => {
1935 const visit = (node: MarkdownHastNode) => {
18701936 if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) {
18711937 const slug = githubHeadingSlug(hastPlainTextDeep(node));
18721938 if (node.properties?.id === undefined && slug) {
......@@ -1892,11 +1958,13 @@ function rehypeHeadingIds() {
18921958const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [
18931959 ...CHAT_MARKDOWN_REHYPE_PLUGINS,
18941960 rehypeHeadingIds,
1961 rehypeVideoRows,
18951962] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;
18961963
1897const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable<
1898 ReactMarkdownOptions["rehypePlugins"]
1899>;
1964const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [
1965 rehypeHeadingIds,
1966 rehypeVideoRows,
1967] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;
19001968
19011969function MarkdownExternalLinkContent({
19021970 host,
......@@ -2241,6 +2309,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
22412309 render={<a href={href} />}
22422310 className={MARKDOWN_FILE_LINK_CLASS_NAME}
22432311 data-markdown-copy={copyMarkdown}
2312 aria-description={targetPath}
22442313 onClick={(event) => {
22452314 event.preventDefault();
22462315 event.stopPropagation();
......@@ -2267,7 +2336,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
22672336 <ContextChip
22682337 kind="mention"
22692338 render={<button type="button" />}
2270 aria-label={`File options for ${label}`}
2339 aria-label={`File options for ${nodeToPlainText(label)}`}
2340 aria-description={targetPath}
22712341 aria-haspopup="menu"
22722342 className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")}
22732343 data-markdown-copy={copyMarkdown}
......@@ -2660,7 +2730,12 @@ function useChatMarkdownState({
26602730 [cwd, findWorkspaceBasenameMatch, revealFileInFileManager],
26612731 );
26622732 const fileLinkChip = useCallback(
2663 (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => {
2733 (
2734 fileLinkMeta: MarkdownFileLinkMeta,
2735 copyMarkdown: string,
2736 mediaSource?: string,
2737 label?: ReactNode,
2738 ) => {
26642739 const mediaPath = mediaSource ?? fileLinkMeta.filePath;
26652740 const canPreviewMedia =
26662741 mediaMimeTypeFromExtension(
......@@ -2681,14 +2756,17 @@ function useChatMarkdownState({
26812756 displayPath={fileLinkMeta.displayPath}
26822757 panelPath={panelPath}
26832758 line={fileLinkMeta.line}
2684 label={fileLinkLabel(
2685 {
2686 path: fileLinkMeta.filePath,
2687 ...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}),
2688 ...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}),
2689 },
2690 fileLinkParentSuffixByPath,
2691 )}
2759 label={
2760 label ??
2761 fileLinkLabel(
2762 {
2763 path: fileLinkMeta.filePath,
2764 ...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}),
2765 ...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}),
2766 },
2767 fileLinkParentSuffixByPath,
2768 )
2769 }
26922770 copyMarkdown={copyMarkdown}
26932771 theme={resolvedTheme}
26942772 threadRef={threadRef}
......@@ -2851,6 +2929,14 @@ const CHAT_MARKDOWN_COMPONENTS = {
28512929 <CodexArtifactTemplateCard template={artifactTemplate} onUse={onUseArtifactTemplate} />
28522930 );
28532931 }
2932 const videoCount = node?.properties.dataVideoCount;
2933 if (typeof videoCount === "number") {
2934 return (
2935 <div {...props} style={{ "--chat-video-count": videoCount } as CSSProperties}>
2936 {children}
2937 </div>
2938 );
2939 }
28542940 return <div {...props}>{children}</div>;
28552941 },
28562942 p: function MarkdownParagraph({ node: _node, children, ...props }) {
......@@ -3196,13 +3282,11 @@ const CHAT_MARKDOWN_COMPONENTS = {
31963282 source.startsWith("[") && source.includes("](")
31973283 ? source
31983284 : `[${(label || fileLinkMeta.basename).replace(/[\\[\]]/g, "\\$&")}](${normalizedHref})`;
3199 const chip = fileLinkChip(fileLinkMeta, copyMarkdown, normalizedHref);
3200 return isMarkdownFileLinkLabel(label, normalizedHref) ? (
3201 chip
3202 ) : (
3203 <span data-markdown-copy={copyMarkdown}>
3204 {children} {chip}
3205 </span>
3285 return fileLinkChip(
3286 fileLinkMeta,
3287 copyMarkdown,
3288 normalizedHref,
3289 isMarkdownFileLinkLabel(label, normalizedHref) ? undefined : children,
32063290 );
32073291 },
32083292 code: function MarkdownCode({ node, children, className, ...props }) {
apps/web/src/components/ComposerPromptEditor.test.tsx created+283
......@@ -0,0 +1,283 @@
1// @vitest-environment jsdom
2
3import { act, createRef, useLayoutEffect, useState } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import {
8 createMemoryHistory,
9 createRootRoute,
10 createRouter,
11 RouterProvider,
12} from "@tanstack/react-router";
13import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
14import { collapseExpandedComposerCursor } from "~/composer-logic";
15import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
16import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
17import { ComposerContextActionsContext } from "./composerContextPresentation";
18
19import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
20
21let root: Root;
22let container: HTMLDivElement;
23const handle = createRef<ComposerPromptEditorHandle>();
24let replace: (value: string, cursor: number) => void;
25let enableRichText: (enabled: boolean) => void;
26const openMention = vi.fn();
27const commandKey = vi.fn((_key: string, _event: KeyboardEvent) => false);
28const changes: Array<{ value: string; suppressed: boolean }> = [];
29
30function ComposerProbe() {
31 const [richText, setRichText] = useState(false);
32 const [draft, setDraft] = useState({
33 value: "before :dizzy after",
34 cursor: "before :dizzy".length,
35 });
36 useLayoutEffect(() => {
37 replace = (value, cursor) => setDraft({ value, cursor });
38 enableRichText = setRichText;
39 });
40 return (
41 <ComposerContextActionsContext
42 value={{
43 environmentId: null,
44 openMention,
45 expandImage: () => {},
46 expandVideo: () => {},
47 openFile: () => {},
48 openPullRequest: () => {},
49 }}
50 >
51 <ComposerPromptEditor
52 {...draft}
53 richTextEnabled={richText}
54 editorRef={handle}
55 contextRecords={new Map()}
56 skills={[]}
57 disabled={false}
58 placeholder="Message"
59 onPaste={() => {}}
60 onCommandKeyDown={commandKey}
61 onChange={(value, cursor, _expanded, suppressed) => {
62 changes.push({ value, suppressed });
63 setDraft({ value, cursor });
64 }}
65 />
66 </ComposerContextActionsContext>
67 );
68}
69
70beforeEach(async () => {
71 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
72 Object.defineProperty(Range.prototype, "getClientRects", {
73 configurable: true,
74 value: () => [],
75 });
76 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
77 configurable: true,
78 value: () => new DOMRect(),
79 });
80 container = document.createElement("div");
81 document.body.append(container);
82 root = createRoot(container);
83 changes.length = 0;
84 openMention.mockClear();
85 commandKey.mockReset();
86 commandKey.mockReturnValue(false);
87 await act(() => root.render(<ComposerProbe />));
88});
89
90afterEach(async () => {
91 await act(() => root.unmount());
92 container.remove();
93 vi.unstubAllGlobals();
94});
95
96describe("emoji completion in the real composer editor", () => {
97 it("keeps the caret and undo history when a controlled completion replaces the shortcode", async () => {
98 await act(() => handle.current!.focus());
99 expect(handle.current!.readSnapshot().expandedCursor).toBe("before :dizzy".length);
100 await act(() => replace("before 💫 after", "before 💫".length));
101 expect(handle.current!.readSnapshot()).toMatchObject({
102 value: "before 💫 after",
103 expandedCursor: "before 💫".length,
104 });
105 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
106 expect(document.activeElement).toBe(textbox);
107 await act(() =>
108 textbox.dispatchEvent(
109 new KeyboardEvent("keydown", {
110 key: "z",
111 code: "KeyZ",
112 ctrlKey: true,
113 bubbles: true,
114 cancelable: true,
115 }),
116 ),
117 );
118 expect(handle.current!.readSnapshot().value).toBe("before :dizzy after");
119 });
120
121 it("suppresses suggestions when a text selection keeps its anchor at the same caret", async () => {
122 await act(() => handle.current!.focus());
123 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
124 const selection = window.getSelection()!;
125 const range = selection.getRangeAt(0);
126 const selected = document.createRange();
127 selected.setStart(range.startContainer, range.startOffset);
128 selected.setEnd(range.startContainer, range.startOffset + 2);
129 await act(async () => {
130 selection.removeAllRanges();
131 selection.addRange(selected);
132 document.dispatchEvent(new Event("selectionchange"));
133 await Promise.resolve();
134 });
135 expect(textbox.contains(selection.anchorNode)).toBe(true);
136 expect(handle.current!.suggestionsSuppressed()).toBe(true);
137 expect(changes.at(-1)).toMatchObject({ suppressed: true });
138 });
139
140 it("closes suggestions during composition and rechecks the committed caret afterward", async () => {
141 await act(() => handle.current!.focus());
142 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
143 await act(() =>
144 textbox.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })),
145 );
146 expect(changes.at(-1)).toMatchObject({ suppressed: true });
147 expect(handle.current!.suggestionsSuppressed()).toBe(true);
148 await act(async () => {
149 textbox.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
150 await Promise.resolve();
151 });
152 expect(handle.current!.suggestionsSuppressed()).toBe(false);
153 expect(changes.at(-1)).toMatchObject({ value: "before :dizzy after", suppressed: false });
154 });
155});
156
157it("changes a real fence language picker while preserving metadata, code, and editor focus", async () => {
158 const draft = "```ts title=x\nconst x = 1\n```";
159 await act(() => {
160 enableRichText(true);
161 replace(draft, draft.length);
162 });
163 const trigger = container.querySelector<HTMLButtonElement>(
164 'button[aria-label="Code language: TypeScript"]',
165 )!;
166 expect(trigger).not.toBeNull();
167 await act(() => trigger.click());
168 const python = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find((option) =>
169 option.textContent?.includes("Python"),
170 )!;
171 expect(python).toBeDefined();
172 await act(() => python.click());
173 expect(handle.current!.readSnapshot().value).toBe("```python title=x\nconst x = 1\n```");
174 expect(document.activeElement).toBe(container.querySelector('[data-testid="composer-editor"]'));
175});
176
177it("opens a real file chip through its application action provider", async () => {
178 const source = serializeComposerFileLink("src/example.ts");
179 await act(() => replace(`${source} after`, `${source} `.length));
180 const button = container.querySelector<HTMLButtonElement>(
181 '[aria-label="Preview src/example.ts"]',
182 )!;
183 expect(button).not.toBeNull();
184 await act(() => button.click());
185 expect(openMention).toHaveBeenCalledWith("src/example.ts");
186 expect(handle.current!.readSnapshot().value).toBe(`${source} after`);
187});
188
189it("copies canonical chip Markdown, cuts once, and restores it with native undo", async () => {
190 const source = `${serializeComposerFileLink("src/example.ts")} after`;
191 await act(() => {
192 replace(source, source.length);
193 handle.current!.focus();
194 });
195 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
196 await act(() =>
197 textbox.dispatchEvent(
198 new KeyboardEvent("keydown", {
199 key: "a",
200 ctrlKey: true,
201 bubbles: true,
202 cancelable: true,
203 }),
204 ),
205 );
206 expect(handle.current!.readSelectionRange()).toEqual({ start: 0, end: source.length });
207 const data = new Map<string, string>();
208 const event = new Event("cut", { bubbles: true, cancelable: true });
209 Object.defineProperty(event, "clipboardData", {
210 value: { setData: (kind: string, text: string) => data.set(kind, text) },
211 });
212 await act(() => textbox.dispatchEvent(event));
213 expect(event.defaultPrevented).toBe(true);
214 expect(data.get("text/plain")).toBe(source);
215 expect(handle.current!.readSnapshot().value).toBe("");
216 await act(() =>
217 textbox.dispatchEvent(
218 new KeyboardEvent("keydown", {
219 key: "z",
220 ctrlKey: true,
221 bubbles: true,
222 cancelable: true,
223 }),
224 ),
225 );
226 expect(handle.current!.readSnapshot().value).toBe(source);
227});
228
229it("Shift+Tabs from a citation caret to its real comment trigger before plan-mode handling", async () => {
230 const router = createRouter({
231 routeTree: createRootRoute({ component: ComposerProbe }),
232 history: createMemoryHistory({ initialEntries: ["/"] }),
233 });
234 await router.load();
235 await act(() => root.render(<RouterProvider router={router} />));
236 const citation = serializeAssistantCitation({
237 version: 1,
238 environmentId: EnvironmentId.make("env"),
239 threadId: ThreadId.make("thread"),
240 messageId: MessageId.make("message"),
241 text: "Quoted text",
242 start: 0,
243 end: 11,
244 prefix: "",
245 suffix: "",
246 });
247 const source = `${citation} after`;
248 await act(() => {
249 replace(source, collapseExpandedComposerCursor(source, citation.length));
250 });
251 await act(() => handle.current!.focus());
252 expect(handle.current!.readSnapshot().expandedCursor).toBe(citation.length);
253 commandKey.mockImplementation((key, event) => key === "Tab" && event.shiftKey);
254 const textbox = container.querySelector<HTMLElement>('[data-testid="composer-editor"]')!;
255 const commentButton = container.querySelector<HTMLElement>("[data-citation-comment-trigger]")!;
256 expect(commentButton).not.toBeNull();
257 const shiftTab = new KeyboardEvent("keydown", {
258 key: "Tab",
259 shiftKey: true,
260 bubbles: true,
261 cancelable: true,
262 });
263 await act(() => textbox.dispatchEvent(shiftTab));
264 expect(shiftTab.defaultPrevented).toBe(true);
265 expect(document.activeElement).toBe(commentButton);
266 expect(commandKey).not.toHaveBeenCalled();
267 expect(handle.current!.readSnapshot()).toMatchObject({
268 value: source,
269 expandedCursor: citation.length,
270 });
271 const tab = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
272 await act(() => commentButton.dispatchEvent(tab));
273 expect(tab.defaultPrevented).toBe(true);
274 expect(document.activeElement).toBe(textbox);
275 expect(handle.current!.readSnapshot().expandedCursor).toBe(citation.length);
276 await act(() => handle.current!.focusAtEnd());
277 await act(() =>
278 textbox.dispatchEvent(
279 new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
280 ),
281 );
282 expect(commandKey).toHaveBeenCalledTimes(1);
283});
apps/web/src/components/ComposerPromptEditor.tsx+626-15
......@@ -1,17 +1,628 @@
1import { ComposerPromptEditorTiptap } from "./ComposerPromptEditorTiptap";
2import type { ComposerPromptEditorProps } from "./ComposerPromptEditorTiptap";
3
4export type {
5 ComposerCitationCommentRequest,
6 ComposerPromptEditorHandle,
7 ComposerPromptEditorProps,
8} from "./ComposerPromptEditorTiptap";
9
10/**
11 * The composer editor. Tiptap in both modes: the `richTextEnabled` setting
12 * toggles Markdown styling, never the engine. Plain mode keeps Markdown
13 * markers literal; `literalText` also preserves context-token sources as text.
14 */
1import type { ComposerContextClipboardFragment, ServerProviderSkill } from "@t3tools/contracts";
2import {
3 COMPOSER_CONTEXT_CLIPBOARD_MIME,
4 encodeComposerContextClipboardHtml,
5} from "@t3tools/shared/composerContextClipboard";
6import {
7 serializeAssistantCitation,
8 withAssistantCitationComment,
9} from "@t3tools/shared/assistantCitations";
10import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
11import { isolateHistory } from "@codemirror/commands";
12import { Transaction } from "@codemirror/state";
13import { createPortal } from "react-dom";
14import { use, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
15
16import { expandCollapsedComposerCursor } from "~/composer-logic";
17import { collectInlineContextIds } from "~/lib/composerContextReferences";
18import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
19import { basenameOfPath } from "~/pierre-icons";
20import { cn } from "~/lib/utils";
21import {
22 ComposerContextActionsContext,
23 ComposerContextRecordsContext,
24 ComposerContextReferenceChip,
25 type ComposerDraftContextRecords,
26} from "./composerContextPresentation";
27import {
28 createComposerMarkdownEditor,
29 composerSuggestionsSuppressed,
30 composerTokens,
31 type ComposerChip,
32 type ComposerToken,
33} from "./composerMarkdownEditor";
34import { ContextChip } from "./ContextChip";
35import { ContextChipPopover } from "./contextChipParts";
36import { AssistantCitationChip } from "./chat/AssistantCitationChip";
37import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal";
38import { FileTagChipContent } from "./chat/FileTagChip";
39import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon";
40import { ComposerCodeBlockLanguagePicker } from "./chat/ComposerCodeBlockLanguagePicker";
41import { languageOfInfoString, withInfoStringLanguage } from "~/composer-code-languages";
42import { SkillChipIcon } from "./chat/SkillInlineText";
43import { Button } from "./ui/button";
44import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
45
46export interface ComposerPromptEditorHandle {
47 focus: () => void;
48 focusAt: (cursor: number) => void;
49 focusAtEnd: () => void;
50 readSelectionRange: () => { start: number; end: number };
51 suggestionsSuppressed: () => boolean;
52 requestCitationComment: (request: ComposerCitationCommentRequest) => void;
53 readSnapshot: () => {
54 value: string;
55 cursor: number;
56 expandedCursor: number;
57 contextIds: string[];
58 };
59 /**
60 * True when a collapsed caret sits on the first ("start") or last ("end")
61 * visual line, counting soft wraps. Prompt history only claims ArrowUp and
62 * ArrowDown at these edges so arrows still move the caret inside multiline
63 * text.
64 */
65 isCaretOnVisualEdge: (edge: "start" | "end") => boolean;
66}
67
68export interface ComposerPromptEditorProps {
69 value: string;
70 cursor: number;
71 /**
72 * Render Markdown styling (bold, italic, code, strike, task checkboxes).
73 * Off renders the same CodeMirror engine as plain text: every marker stays a
74 * literal character.
75 */
76 richTextEnabled?: boolean;
77 /** Preserve editor answers verbatim, without Markdown or context chips. */
78 literalText?: boolean;
79 /** Draft records behind the prompt's context references, keyed by context id. */
80 contextRecords: ComposerDraftContextRecords;
81 /** Structured clipboard payload for the given referenced ids, or null to skip. */
82 buildContextClipboardFragment?:
83 | ((contextIds: ReadonlyArray<string>) => string | null)
84 | undefined;
85 /** Imports a structured paste's records; returns ids that changed. */
86 importContextFragment?:
87 | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap<string, string>)
88 | undefined;
89 skills: ReadonlyArray<ServerProviderSkill>;
90 disabled: boolean;
91 placeholder: string;
92 ariaLabel?: string | undefined;
93 /** Identifies an editor with suggestions, even while its list is closed. */
94 suggestionListId?: string | undefined;
95 /** References the highlighted option only while its list is rendered. */
96 activeSuggestionId?: string | undefined;
97 containerClassName?: string;
98 className?: string;
99 placeholderClassName?: string;
100 onChange: (
101 nextValue: string,
102 nextCursor: number,
103 expandedCursor: number,
104 suppressSuggestions: boolean,
105 contextIds: string[],
106 ) => void;
107 onVisibleSelectionChange?: () => void;
108 onCommandKeyDown?: (key: string, event: KeyboardEvent) => boolean;
109 onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void;
110 onPageScrollKeyUp?: (key: string) => void;
111 onPageScrollRelease?: () => void;
112 onCitationSubmitAndSend?: () => void;
113 onCitationDraftSave?: (replacedValue: string, savedValue: string) => void;
114 onPaste: React.ClipboardEventHandler<HTMLElement>;
115 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
116}
117
118export type ComposerCitationCommentRequest = {
119 value: string;
120 citationStart: number;
121 sourceAnchor: AssistantCitationSourceAnchor;
122 insertedSpaces: CitationInsertedSpaces;
123};
124
125type CitationInsertedSpaces = { before: boolean; after: boolean };
126type CitationComment = {
127 start: number;
128 sourceAnchor?: AssistantCitationSourceAnchor | undefined;
129 insertedSpaces?: CitationInsertedSpaces | undefined;
130};
131
15132export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
16 return <ComposerPromptEditorTiptap {...props} />;
133 const host = useRef<HTMLDivElement>(null);
134 const currentProps = useRef(props);
135 const editor = useRef<ReturnType<typeof createComposerMarkdownEditor> | null>(null);
136 const pendingCitation = useRef<ComposerCitationCommentRequest | null>(null);
137 const citationDraft = useRef<{ start: number; comment: string } | null>(null);
138 const [comment, setComment] = useState<CitationComment | null>(null);
139 const [chips, setChips] = useState<ComposerChip[]>([]);
140
141 useLayoutEffect(() => {
142 currentProps.current = props;
143 });
144 useLayoutEffect(() => {
145 let alive = true;
146 const instance = createComposerMarkdownEditor({
147 props: () => currentProps.current,
148 mountChip: (chip) => {
149 if (alive) setChips((chips) => [...chips, chip]);
150 },
151 unmountChip: (dom) => {
152 if (alive) setChips((chips) => chips.filter((chip) => chip.dom !== dom));
153 },
154 });
155 editor.current = instance;
156 host.current!.append(instance.dom);
157 return () => {
158 alive = false;
159 editor.current = null;
160 instance.destroy();
161 };
162 }, []);
163
164 // oxlint-disable react/exhaustive-effect-dependencies -- Editor callbacks read the ref; DOM configuration follows these props.
165 useLayoutEffect(() => {
166 editor.current?.configure();
167 }, [
168 props.disabled,
169 props.richTextEnabled,
170 props.literalText,
171 props.className,
172 props.ariaLabel,
173 props.suggestionListId,
174 props.activeSuggestionId,
175 props.placeholder,
176 ]);
177 // oxlint-enable react/exhaustive-effect-dependencies
178 useLayoutEffect(() => {
179 const instance = editor.current;
180 if (!instance) return;
181 const snapshot = instance.snapshot();
182 if (snapshot.value !== props.value) {
183 const draft = citationDraft.current;
184 citationDraft.current = null;
185 const saveDraft = currentProps.current.onCitationDraftSave;
186 if (draft && saveDraft) {
187 const token = composerTokens(instance.view.state, false).find(
188 (token) => token.type === "citation" && token.start === draft.start,
189 );
190 if (token?.type === "citation") {
191 const dismissal = resolveAssistantCitationCommentDismissal({
192 reason: "none",
193 draft: draft.comment,
194 savedComment: token.citation.comment,
195 });
196 if (dismissal.kind === "commit") {
197 const source = serializeAssistantCitation(
198 withAssistantCitationComment(token.citation, dismissal.comment),
199 );
200 saveDraft(
201 snapshot.value,
202 snapshot.value.slice(0, token.start) + source + snapshot.value.slice(token.end),
203 );
204 }
205 }
206 }
207 setComment(null);
208 let from = 0;
209 while (
210 from < snapshot.value.length &&
211 from < props.value.length &&
212 snapshot.value[from] === props.value[from]
213 )
214 from += 1;
215 let oldEnd = snapshot.value.length;
216 let newEnd = props.value.length;
217 while (
218 oldEnd > from &&
219 newEnd > from &&
220 snapshot.value[oldEnd - 1] === props.value[newEnd - 1]
221 ) {
222 oldEnd -= 1;
223 newEnd -= 1;
224 }
225 instance.view.dispatch({
226 changes: { from, to: oldEnd, insert: props.value.slice(from, newEnd) },
227 selection: {
228 anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText),
229 },
230 annotations: [isolateHistory.of("full"), Transaction.userEvent.of("t3.controlled")],
231 });
232 } else if (snapshot.cursor !== props.cursor && !instance.view.compositionStarted) {
233 instance.view.dispatch({
234 selection: {
235 anchor: expandCollapsedComposerCursor(props.value, props.cursor, props.literalText),
236 },
237 annotations: Transaction.userEvent.of("t3.controlled"),
238 });
239 }
240 const request = pendingCitation.current;
241 if (
242 request?.value === props.value &&
243 composerTokens(instance.view.state, props.literalText ?? false).some(
244 (token) => token.type === "citation" && token.start === request.citationStart,
245 )
246 ) {
247 pendingCitation.current = null;
248 setComment({
249 start: request.citationStart,
250 sourceAnchor: request.sourceAnchor,
251 insertedSpaces: request.insertedSpaces,
252 });
253 }
254 }, [props.value, props.cursor, props.literalText]);
255
256 useLayoutEffect(() => {
257 editor.current?.view.requestMeasure();
258 });
259
260 useImperativeHandle(
261 props.editorRef,
262 () => ({
263 focus: () => editor.current?.focus(),
264 focusAt: (cursor) => {
265 const instance = editor.current;
266 if (!instance) return;
267 const snapshot = instance.snapshot();
268 instance.view.dispatch({
269 selection: {
270 anchor: expandCollapsedComposerCursor(
271 snapshot.value,
272 cursor,
273 currentProps.current.literalText,
274 ),
275 },
276 scrollIntoView: true,
277 });
278 instance.focus();
279 },
280 focusAtEnd: () => {
281 const instance = editor.current;
282 if (!instance) return;
283 instance.view.dispatch({
284 selection: { anchor: instance.view.state.doc.length },
285 scrollIntoView: true,
286 });
287 instance.focus();
288 },
289 readSnapshot: () =>
290 editor.current?.snapshot() ?? {
291 value: props.value,
292 cursor: props.cursor,
293 expandedCursor: expandCollapsedComposerCursor(
294 props.value,
295 props.cursor,
296 props.literalText,
297 ),
298 contextIds: [],
299 },
300 readSelectionRange: () => {
301 const range = editor.current?.view.state.selection.main;
302 return { start: range?.from ?? 0, end: range?.to ?? 0 };
303 },
304 suggestionsSuppressed: () =>
305 !editor.current ||
306 composerSuggestionsSuppressed(
307 editor.current.view,
308 currentProps.current.literalText ?? false,
309 ),
310 requestCitationComment: (request) => {
311 pendingCitation.current = request;
312 if (editor.current?.snapshot().value === request.value) {
313 pendingCitation.current = null;
314 setComment({
315 start: request.citationStart,
316 sourceAnchor: request.sourceAnchor,
317 insertedSpaces: request.insertedSpaces,
318 });
319 }
320 },
321 isCaretOnVisualEdge: (edge) => {
322 const instance = editor.current;
323 if (!instance || !instance.view.state.selection.main.empty) return false;
324 const head = instance.view.state.selection.main.head;
325 if (instance.view.state.doc.length === 0) return true;
326 const line = instance.view.state.doc.lineAt(head);
327 if (edge === "start" ? line.number !== 1 : line.number !== instance.view.state.doc.lines)
328 return false;
329 const caret = instance.view.coordsAtPos(head);
330 const boundary = instance.view.coordsAtPos(
331 edge === "start" ? 0 : instance.view.state.doc.length,
332 );
333 return (
334 caret !== null &&
335 boundary !== null &&
336 Math.abs(caret.top - boundary.top) < Math.max(1, (caret.bottom - caret.top) / 2)
337 );
338 },
339 }),
340 [props.editorRef, props.value, props.cursor, props.literalText],
341 );
342
343 const copy = (event: React.ClipboardEvent<HTMLElement>, cut: boolean) => {
344 const instance = editor.current;
345 if (!instance || (cut && props.disabled)) return;
346 const range = instance.view.state.selection.main;
347 if (range.empty) return;
348 const text = instance.view.state.sliceDoc(range.from, range.to);
349 const ids = props.literalText ? [] : collectInlineContextIds(text);
350 const fragment = ids.length > 0 ? props.buildContextClipboardFragment?.(ids) : null;
351 event.preventDefault();
352 event.stopPropagation();
353 event.clipboardData.setData("text/plain", text);
354 if (fragment) {
355 event.clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment);
356 event.clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment));
357 }
358 if (cut)
359 instance.view.dispatch({
360 ...instance.view.state.replaceSelection(""),
361 annotations: isolateHistory.of("full"),
362 userEvent: "delete.cut",
363 scrollIntoView: true,
364 });
365 };
366
367 return (
368 <ComposerContextRecordsContext value={props.contextRecords}>
369 <div
370 className={cn(
371 "relative flow-root font-(family-name:--font-composer,var(--font-sans)) text-(length:--font-size-prompt,var(--text-sm)) max-sm:pointer-coarse:text-(length:--font-size-prompt-touch)",
372 props.containerClassName,
373 )}
374 onPasteCapture={props.onPaste}
375 onCopyCapture={(event) => copy(event, false)}
376 onCutCapture={(event) => copy(event, true)}
377 onKeyDown={(event) => {
378 if (["Control", "Meta", "Alt", "Shift"].includes(event.key))
379 props.onPageScrollRelease?.();
380 }}
381 onKeyUp={(event) => props.onPageScrollKeyUp?.(event.key)}
382 onBlur={props.onPageScrollRelease}
383 >
384 <div ref={host} />
385 {props.value.length === 0 && props.contextRecords.size === 0 && props.placeholder ? (
386 <div
387 className={cn(
388 "pointer-events-none absolute inset-0 leading-relaxed text-placeholder/75",
389 props.placeholderClassName,
390 )}
391 >
392 {props.placeholder}
393 </div>
394 ) : null}
395 </div>
396 {chips.map((chip) =>
397 chip.kind === "fence"
398 ? createPortal(
399 <ComposerCodeBlockLanguagePicker
400 language={languageOfInfoString(chip.block.info)}
401 theme={document.documentElement.classList.contains("dark") ? "dark" : "light"}
402 disabled={props.disabled}
403 onChange={(language) => {
404 const instance = editor.current;
405 if (
406 !instance ||
407 props.disabled ||
408 instance.view.state.sliceDoc(chip.block.infoFrom, chip.block.infoTo) !==
409 chip.block.info
410 )
411 return;
412 const info = withInfoStringLanguage(chip.block.info, language);
413 const offset = info.length - (chip.block.infoTo - chip.block.infoFrom);
414 instance.view.dispatch({
415 changes: { from: chip.block.infoFrom, to: chip.block.infoTo, insert: info },
416 selection: { anchor: chip.block.codeTo + offset },
417 annotations: isolateHistory.of("full"),
418 userEvent: "input.fence-language",
419 });
420 instance.focus();
421 }}
422 />,
423 chip.dom,
424 `fence:${chip.block.from}`,
425 )
426 : createPortal(
427 <ComposerTokenChip
428 token={chip.token}
429 skills={props.skills}
430 disabled={props.disabled}
431 comment={comment?.start === chip.token.start ? comment : null}
432 onCommentChange={(open) => {
433 citationDraft.current = null;
434 setComment(open ? { start: chip.token.start } : null);
435 }}
436 onDraftChange={(comment) => {
437 citationDraft.current = { start: chip.token.start, comment };
438 }}
439 onSaveComment={(text) => {
440 const instance = editor.current;
441 const token = chip.token;
442 if (
443 !instance ||
444 props.disabled ||
445 token.type !== "citation" ||
446 instance.view.state.sliceDoc(token.start, token.end) !== token.source
447 )
448 return false;
449 citationDraft.current = null;
450 const source = serializeAssistantCitation(
451 withAssistantCitationComment(token.citation, text),
452 );
453 instance.view.dispatch({
454 changes: { from: token.start, to: token.end, insert: source },
455 annotations: isolateHistory.of("full"),
456 userEvent: "input.citation",
457 });
458 return true;
459 }}
460 onCancel={() => {
461 const instance = editor.current;
462 if (
463 !instance ||
464 props.disabled ||
465 comment?.start !== chip.token.start ||
466 !comment.insertedSpaces
467 )
468 return;
469 const token = chip.token;
470 const from =
471 comment.insertedSpaces.before &&
472 instance.view.state.sliceDoc(token.start - 1, token.start) === " "
473 ? token.start - 1
474 : token.start;
475 const to =
476 comment.insertedSpaces.after &&
477 instance.view.state.sliceDoc(token.end, token.end + 1) === " "
478 ? token.end + 1
479 : token.end;
480 instance.view.dispatch({
481 changes: { from, to },
482 selection: { anchor: from },
483 annotations: isolateHistory.of("full"),
484 userEvent: "delete.citation",
485 });
486 instance.focus();
487 }}
488 onRestoreFocus={() => {
489 const instance = editor.current;
490 if (!instance || props.disabled) return;
491 const token = composerTokens(
492 instance.view.state,
493 props.literalText ?? false,
494 ).find((token) => token.start === chip.token.start);
495 instance.view.dispatch({
496 selection: {
497 anchor:
498 token?.end ?? Math.min(chip.token.start, instance.view.state.doc.length),
499 },
500 scrollIntoView: true,
501 });
502 instance.focus();
503 }}
504 onSubmitAndSend={props.onCitationSubmitAndSend}
505 />,
506 chip.dom,
507 `${chip.token.start}:${chip.token.type}`,
508 ),
509 )}
510 </ComposerContextRecordsContext>
511 );
512}
513
514function ComposerTokenChip(props: {
515 token: ComposerToken;
516 skills: readonly ServerProviderSkill[];
517 disabled: boolean;
518 comment: CitationComment | null;
519 onCommentChange: (open: boolean) => void;
520 onDraftChange: (comment: string) => void;
521 onSaveComment: (text: string) => boolean;
522 onCancel: () => void;
523 onRestoreFocus: () => void;
524 onSubmitAndSend: (() => void) | undefined;
525}) {
526 const actions = use(ComposerContextActionsContext);
527 const token = props.token;
528 if (token.type === "context-reference")
529 return (
530 <ComposerContextReferenceChip
531 kind={token.kind}
532 contextId={token.contextId}
533 label={token.label}
534 />
535 );
536 if (token.type === "citation")
537 return (
538 <span
539 data-composer-citation-chip="true"
540 onKeyDown={(event) => {
541 if (
542 event.key === "Tab" &&
543 !event.shiftKey &&
544 !event.altKey &&
545 !event.metaKey &&
546 !event.ctrlKey
547 ) {
548 event.preventDefault();
549 props.onRestoreFocus();
550 }
551 }}
552 >
553 <AssistantCitationChip
554 citation={token.citation}
555 composer
556 commentEditor={{
557 open: props.comment !== null,
558 sourceAnchor: props.comment?.sourceAnchor,
559 onOpenChange: (open) => {
560 if (!props.disabled || !open) props.onCommentChange(open);
561 },
562 ...(props.comment?.insertedSpaces ? { onCancel: props.onCancel } : {}),
563 onSave: props.onSaveComment,
564 onDraftChange: props.onDraftChange,
565 onSaveAndSend: (text) => {
566 if (!props.onSaveComment(text)) return false;
567 props.onSubmitAndSend?.();
568 return true;
569 },
570 onRestoreFocus: props.onRestoreFocus,
571 }}
572 />
573 </span>
574 );
575 if (token.type === "skill") {
576 const skill = props.skills.find((skill) => skill.name === token.value);
577 const label = formatProviderSkillDisplayName(skill ?? { name: token.value });
578 return (
579 <ContextChipPopover
580 kind="skill"
581 icon={<SkillChipIcon />}
582 label={label}
583 accessibleLabel={`Skill ${label}`}
584 >
585 <div className="space-y-3 p-2 text-sm">
586 <p className="font-medium">{label}</p>
587 <p>{skill?.description ?? "No description is available for this skill."}</p>
588 {skill?.path ? (
589 <Button variant="outline" size="sm" onClick={() => actions.openMention(skill.path)}>
590 View instructions
591 </Button>
592 ) : null}
593 </div>
594 </ContextChipPopover>
595 );
596 }
597 const path = token.value;
598 return (
599 <Tooltip>
600 <TooltipTrigger
601 render={
602 <ContextChip
603 kind="mention"
604 render={<button type="button" />}
605 onClick={() => actions.openMention(path)}
606 aria-label={`Preview ${path}`}
607 data-composer-mention-chip="true"
608 />
609 }
610 >
611 <FileTagChipContent
612 path={path}
613 environmentId={actions.environmentId}
614 cwd={actions.cwd}
615 label={basenameOfPath(path)}
616 theme={document.documentElement.classList.contains("dark") ? "dark" : "light"}
617 />
618 </TooltipTrigger>
619 <TooltipPopup side="top">
620 <WorkspaceEntryTooltip
621 path={path}
622 environmentId={actions.environmentId}
623 cwd={actions.cwd}
624 />
625 </TooltipPopup>
626 </Tooltip>
627 );
17628}
apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.test.tsx+9-1
......@@ -48,6 +48,14 @@ let container: HTMLDivElement;
4848
4949beforeEach(() => {
5050 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
51 Object.defineProperty(Range.prototype, "getClientRects", {
52 configurable: true,
53 value: () => [],
54 });
55 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
56 configurable: true,
57 value: () => new DOMRect(),
58 });
5159 container = document.createElement("div");
5260 document.body.append(container);
5361 root = createRoot(container);
......@@ -75,6 +83,7 @@ function Composer({
7583 <ComposerPromptEditor
7684 editorRef={editorRef}
7785 value={question ? answer : prompt}
86 literalText={question}
7887 cursor={0}
7988 contextRecords={new Map()}
8089 skills={[]}
......@@ -97,7 +106,6 @@ it("keeps an unsaved citation comment when a question takes the composer", async
97106 await act(async () => root.render(<Composer question={false} editorRef={editorRef} />));
98107 await act(async () =>
99108 editorRef.current?.requestCitationComment({
100 previousValue: "See ",
101109 value: promptBefore,
102110 citationStart: "See ".length,
103111 sourceAnchor: {} as AssistantCitationSourceAnchor,
apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx deleted-126
......@@ -1,126 +0,0 @@
1// @vitest-environment jsdom
2
3import type { Editor } from "@tiptap/core";
4import { TextSelection } from "@tiptap/pm/state";
5import { act, createRef } from "react";
6import { createRoot, type Root } from "react-dom/client";
7import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
8
9import { serializeEditorDoc } from "~/composer-rich-text-doc";
10import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
11
12const draft =
13 "> **Assistant and reasoning text still save intermediate full snapshots**, using the existing 50 ms batching. Compaction still preserves their `turn-item.updated` events. [Code](/Users/yashsingh/p/projects/t3code/apps/server/src/orchestration-v2/ProjectionMaintenance.ts:235)";
14
15let root: Root;
16let container: HTMLDivElement;
17
18beforeEach(() => {
19 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
20 container = document.createElement("div");
21 document.body.append(container);
22 root = createRoot(container);
23});
24
25afterEach(async () => {
26 await act(async () => root.unmount());
27 container.remove();
28 vi.unstubAllGlobals();
29});
30
31async function mountEditor(value: string, richTextEnabled = true) {
32 await act(async () =>
33 root.render(
34 <ComposerPromptEditor
35 editorRef={createRef<ComposerPromptEditorHandle>()}
36 value={value}
37 cursor={0}
38 richTextEnabled={richTextEnabled}
39 contextRecords={new Map()}
40 skills={[]}
41 disabled={false}
42 placeholder=""
43 onChange={() => {}}
44 onPaste={() => {}}
45 />,
46 ),
47 );
48 const element = container.querySelector<HTMLElement & { editor: Editor }>(".tiptap");
49 if (!element) throw new Error("Composer did not mount");
50 return element.editor;
51}
52
53function press(editor: Editor, key: string, options: KeyboardEventInit = {}) {
54 const event = new KeyboardEvent("keydown", { key, cancelable: true, ...options });
55 const handled = editor.view.props.handleKeyDown?.(editor.view, event);
56 expect(event.defaultPrevented).toBe(Boolean(handled));
57 return handled;
58}
59
60it("keeps Up before the opening bold marker in a quote, including while typing", async () => {
61 const editor = await mountEditor(draft);
62 const position = editor.state.selection.from;
63 await act(async () => {
64 editor.view.focus();
65 expect(press(editor, "ArrowUp")).toBe(true);
66 expect(editor.state.selection.from).toBe(position);
67 editor.view.updateState(editor.state);
68 const marker = editor.view.dom.querySelector(".composer-rich-marker");
69 const caret = window.getSelection()?.getRangeAt(0);
70 if (!marker || !caret) throw new Error("Caret or bold marker is missing");
71 const markerRange = document.createRange();
72 markerRange.selectNode(marker);
73 expect(caret.compareBoundaryPoints(Range.START_TO_START, markerRange)).toBeLessThanOrEqual(0);
74 // A repeated Up stays at the outside stop, ready for native navigation.
75 expect(press(editor, "ArrowUp")).toBe(false);
76 editor.commands.insertContent("x");
77 });
78 expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> x**"));
79});
80
81it("keeps Down after a closing marker, including while typing", async () => {
82 const editor = await mountEditor("> **bold**");
83 await act(async () => {
84 editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc)));
85 expect(press(editor, "ArrowDown")).toBe(true);
86 expect(press(editor, "ArrowDown")).toBe(false);
87 editor.commands.insertContent("x");
88 });
89 expect(serializeEditorDoc(editor.state.doc).value).toBe("> **bold**x");
90});
91
92it("leaves vertical navigation between paragraphs to the browser", async () => {
93 const editor = await mountEditor("**first**\n**second**");
94 await act(async () => {
95 const first = editor.state.doc.child(0);
96 editor.commands.setTextSelection(first.nodeSize + 1);
97 expect(press(editor, "ArrowUp")).toBe(false);
98 editor.commands.setTextSelection(first.nodeSize - 1);
99 expect(press(editor, "ArrowDown")).toBe(false);
100 });
101});
102
103it.each([
104 { shiftKey: true },
105 { altKey: true },
106 { metaKey: true },
107 { ctrlKey: true },
108 { isComposing: true },
109])("preserves modified Up behavior for %j", async (options) => {
110 const editor = await mountEditor(draft);
111 await act(async () => {
112 expect(press(editor, "ArrowUp", options)).toBe(false);
113 editor.commands.insertContent("x");
114 });
115 expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x"));
116});
117
118it("leaves literal Markdown navigation to the browser in plain mode", async () => {
119 const editor = await mountEditor(draft, false);
120 await act(async () => {
121 editor.commands.setTextSelection(5);
122 expect(press(editor, "ArrowUp")).toBe(false);
123 editor.commands.insertContent("x");
124 });
125 expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x"));
126});
apps/web/src/components/ComposerPromptEditorTiptap.tsx deleted-1985
......@@ -1,1985 +0,0 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
2import {
3 Extension,
4 findChildren,
5 InputRule,
6 Node,
7 wrappingInputRule,
8 type JSONContent,
9} from "@tiptap/core";
10import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
11import StarterKit from "@tiptap/starter-kit";
12import { type Node as ProseMirrorNode } from "@tiptap/pm/model";
13import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands";
14import {
15 type EditorState,
16 NodeSelection,
17 Plugin,
18 PluginKey,
19 TextSelection,
20} from "@tiptap/pm/state";
21import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view";
22import type { NodeType, ResolvedPos } from "@tiptap/pm/model";
23import type {
24 AssistantCitation,
25 ComposerContextClipboardFragment,
26 ServerProviderSkill,
27} from "@t3tools/contracts";
28import {
29 serializeAssistantCitation,
30 withAssistantCitationComment,
31} from "@t3tools/shared/assistantCitations";
32import {
33 COMPOSER_CONTEXT_CLIPBOARD_MIME,
34 encodeComposerContextClipboardHtml,
35} from "@t3tools/shared/composerContextClipboard";
36import {
37 createContext,
38 use,
39 useCallback,
40 useEffect,
41 useEffectEvent,
42 useImperativeHandle,
43 useLayoutEffect,
44 useMemo,
45 useRef,
46 useState,
47 type KeyboardEvent as ReactKeyboardEvent,
48} from "react";
49import { EditorContent, useEditor } from "@tiptap/react";
50
51import {
52 clampCollapsedComposerCursor,
53 collapseExpandedComposerCursor,
54 expandCollapsedComposerCursor,
55 isCollapsedCursorAdjacentToInlineToken,
56 pastedPathQueryLength,
57} from "~/composer-logic";
58import {
59 collectComposerPromptInlineTokens,
60 selectionTouchesMentionBoundary,
61} from "~/composer-editor-mentions";
62import {
63 buildDocJson,
64 ComposerBlockExtensions,
65 ComposerCodeBlockExtension,
66 ComposerListExtensions,
67 buildTiptapContent,
68 collapsedToFlat,
69 caretTakesMarksBefore,
70 convertBulletItemToTask,
71 ComposerCodeExtension,
72 ComposerTaskItemExtension,
73 ComposerTaskListExtension,
74 flatToCollapsed,
75 flatToMarkdown,
76 flatToPm,
77 pmToFlat,
78 serializeEditorDoc,
79 serializeSelection,
80 splitOrLiftListItem,
81 stepCaretAcrossStyledEdge,
82 type SkillMeta,
83} from "~/composer-rich-text-doc";
84import {
85 convertCodeFenceOnEnter,
86 exitCodeBlockOnClosingFence,
87 exitCodeBlockOnTrailingBlankLines,
88 indentCodeBlock,
89 indentedNewlineInCodeBlock,
90 selectionInOneCodeBlock,
91} from "~/composer-code-block";
92import {
93 COMPOSER_UNDO_GROUP_DELAY,
94 type ComposerChangeKind,
95 groupUndoByChangeKind,
96 markAsClipboardEdit,
97} from "~/composer-undo-grouping";
98import { collectInlineContextIds } from "~/lib/composerContextReferences";
99import { resolveDiffThemeName } from "~/lib/diffRendering";
100import { cn, isMacPlatform } from "~/lib/utils";
101import { basenameOfPath } from "~/pierre-icons";
102import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon";
103import { FileTagChipContent } from "./chat/FileTagChip";
104import { SkillChipIcon } from "./chat/SkillInlineText";
105import { AssistantCitationChip } from "./chat/AssistantCitationChip";
106import { getTimelinePageScrollKey } from "./chat/pageScrollController";
107import { ContextChipPopover } from "./contextChipParts";
108import { Button } from "./ui/button";
109import { ContextChip } from "./ContextChip";
110import {
111 ComposerContextActionsContext,
112 ComposerContextReferenceChip,
113 ComposerContextRecordsContext,
114} from "./composerContextPresentation";
115import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
116import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
117import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";
118import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal";
119import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
120import { importPastedComposerText } from "./composerInlineTokenPaste";
121import { didComposerSelectionChangeVisibly } from "./composerSelection";
122import type { ComposerDraftContextRecords } from "./composerContextPresentation";
123
124export interface ComposerPromptEditorHandle {
125 focus: () => void;
126 focusAt: (cursor: number) => void;
127 focusAtEnd: () => void;
128 readSelectionRange: () => { start: number; end: number };
129 requestCitationComment: (request: ComposerCitationCommentRequest) => void;
130 readSnapshot: () => {
131 value: string;
132 cursor: number;
133 expandedCursor: number;
134 contextIds: string[];
135 };
136 /**
137 * True when a collapsed caret sits on the first ("start") or last ("end")
138 * visual line, counting soft wraps. Prompt history only claims ArrowUp and
139 * ArrowDown at these edges so arrows still move the caret inside multiline
140 * text.
141 */
142 isCaretOnVisualEdge: (edge: "start" | "end") => boolean;
143}
144
145export interface ComposerPromptEditorProps {
146 value: string;
147 cursor: number;
148 /**
149 * Render Markdown styling (bold, italic, code, strike, task checkboxes).
150 * Off renders the same Tiptap engine as plain text: every marker stays a
151 * literal character.
152 */
153 richTextEnabled?: boolean;
154 /** Preserve editor answers verbatim, without Markdown or context chips. */
155 literalText?: boolean;
156 /** Draft records behind the prompt's context references, keyed by context id. */
157 contextRecords: ComposerDraftContextRecords;
158 /** Structured clipboard payload for the given referenced ids, or null to skip. */
159 buildContextClipboardFragment?:
160 | ((contextIds: ReadonlyArray<string>) => string | null)
161 | undefined;
162 /** Imports a structured paste's records; returns ids that changed. */
163 importContextFragment?:
164 | ((fragment: ComposerContextClipboardFragment) => ReadonlyMap<string, string>)
165 | undefined;
166 skills: ReadonlyArray<ServerProviderSkill>;
167 disabled: boolean;
168 placeholder: string;
169 ariaLabel?: string | undefined;
170 /** Identifies an editor with suggestions, even while its list is closed. */
171 suggestionListId?: string | undefined;
172 /** References the highlighted option only while its list is rendered. */
173 activeSuggestionId?: string | undefined;
174 /** Reads suggestion state at paste time, including Escape dismissal. */
175 isPathQueryActive?: () => boolean;
176 containerClassName?: string;
177 className?: string;
178 placeholderClassName?: string;
179 onChange: (
180 nextValue: string,
181 nextCursor: number,
182 expandedCursor: number,
183 cursorAdjacentToMention: boolean,
184 contextIds: string[],
185 ) => void;
186 onVisibleSelectionChange?: () => void;
187 onCommandKeyDown?: (key: string, event: KeyboardEvent, isTaskItem?: boolean) => boolean;
188 onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void;
189 onPageScrollKeyUp?: (key: string) => void;
190 onPageScrollRelease?: () => void;
191 onCitationSubmitAndSend?: () => void;
192 /**
193 * Called when a new controlled value replaces the document while a citation
194 * comment is unsaved. The comment is saved into `replacedValue`, giving
195 * `savedValue`, so the owner of `replacedValue` can keep it.
196 */
197 onCitationDraftSave?: (replacedValue: string, savedValue: string) => void;
198 onPaste: React.ClipboardEventHandler<HTMLElement>;
199 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
200}
201
202export type ComposerCitationCommentRequest = {
203 previousValue: string;
204 value: string;
205 citationStart: number;
206 sourceAnchor: AssistantCitationSourceAnchor;
207 insertedSpaces: CitationInsertedSpaces;
208};
209
210// Spaces added around a freshly inserted citation, removed with it if its comment is cancelled.
211type CitationInsertedSpaces = { before: boolean; after: boolean };
212
213type OpenCitationComment = {
214 key: string;
215 sourceAnchor?: AssistantCitationSourceAnchor;
216 removeOnCancel?: CitationInsertedSpaces;
217};
218
219const ComposerCitationCommentContext = createContext<{
220 openComment: OpenCitationComment | null;
221 onOpenChange: (citeKey: string, open: boolean) => void;
222 onDraftChange: (citeKey: string, comment: string) => void;
223 onSubmitAndSend: () => void;
224}>({
225 openComment: null,
226 onOpenChange: () => {},
227 onDraftChange: () => {},
228 onSubmitAndSend: () => {},
229});
230
231const RichComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]);
232
233const SURROUND_CLOSE: Record<string, string> = {
234 "(": ")",
235 "[": "]",
236 "{": "}",
237 "'": "'",
238 '"': '"',
239 "“": "”",
240 "`": "`",
241 "<": ">",
242 "«": "»",
243 "*": "*",
244 _: "_",
245};
246
247function resolvedThemeFromDocument(): "light" | "dark" {
248 return document.documentElement.classList.contains("dark") ? "dark" : "light";
249}
250
251// ── Inline atom nodes (chips) ─────────────────────────────────────────────
252
253/**
254 * Wraps an inline chip node view: keeps the caret and text selection out of the chip and
255 * paints the editor's node selection over it.
256 */
257const CHIP_NODE_SELECTION_CLASS_NAME =
258 "relative inline-flex select-none items-center align-middle leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-sm data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']";
259
260const ComposerMentionExtension = Node.create({
261 name: "composer-mention",
262 group: "inline",
263 inline: true,
264 atom: true,
265 selectable: true,
266 addAttributes() {
267 return {
268 path: { default: "" },
269 source: { default: "" },
270 };
271 },
272 parseHTML() {
273 return [{ tag: "span[data-composer-mention]" }];
274 },
275 renderHTML({ HTMLAttributes }) {
276 return ["span", { "data-composer-mention": "", ...HTMLAttributes }];
277 },
278 addNodeView() {
279 return ReactNodeViewRenderer(ComposerMentionNodeView);
280 },
281});
282
283function ComposerMentionNodeView({ node }: NodeViewProps) {
284 const actions = use(ComposerContextActionsContext);
285 const path = (node.attrs.path as string) ?? "";
286 const chip = (
287 <ContextChip
288 kind="mention"
289 render={<button type="button" />}
290 onClick={() => actions.openMention(path)}
291 aria-label={`Preview ${path}`}
292 contentEditable={false}
293 spellCheck={false}
294 data-composer-mention-chip="true"
295 >
296 <FileTagChipContent
297 path={path}
298 environmentId={actions.environmentId}
299 cwd={actions.cwd}
300 label={basenameOfPath(path)}
301 theme={resolvedThemeFromDocument()}
302 />
303 </ContextChip>
304 );
305 return (
306 <NodeViewWrapper as="span" className={CHIP_NODE_SELECTION_CLASS_NAME}>
307 <Tooltip>
308 <TooltipTrigger render={chip} />
309 <TooltipPopup side="top">
310 <WorkspaceEntryTooltip
311 path={path}
312 environmentId={actions.environmentId}
313 cwd={actions.cwd}
314 />
315 </TooltipPopup>
316 </Tooltip>
317 </NodeViewWrapper>
318 );
319}
320
321const ComposerSkillExtension = Node.create({
322 name: "composer-skill",
323 group: "inline",
324 inline: true,
325 atom: true,
326 selectable: true,
327 addAttributes() {
328 return {
329 skillName: { default: "" },
330 skillLabel: { default: "" },
331 skillDescription: { default: null },
332 };
333 },
334 parseHTML() {
335 return [{ tag: "span[data-composer-skill]" }];
336 },
337 renderHTML({ HTMLAttributes }) {
338 return ["span", { "data-composer-skill": "", ...HTMLAttributes }];
339 },
340 addNodeView() {
341 return ReactNodeViewRenderer(ComposerSkillNodeView);
342 },
343});
344
345function ComposerSkillNodeView({ node }: NodeViewProps) {
346 const actions = use(ComposerContextActionsContext);
347 const skills = use(RichComposerSkillsContext);
348 const skillName = (node.attrs.skillName as string) ?? "";
349 const skillLabel = (node.attrs.skillLabel as string) || skillName;
350 const skillDescription = (node.attrs.skillDescription as string | null) ?? null;
351 const skill = skills.find((candidate) => candidate.name === skillName);
352 return (
353 <NodeViewWrapper as="span" className={CHIP_NODE_SELECTION_CLASS_NAME}>
354 <ContextChipPopover
355 kind="skill"
356 icon={<SkillChipIcon />}
357 label={skillLabel}
358 accessibleLabel={`Skill ${skillLabel}`}
359 >
360 <div className="space-y-3 p-2 text-sm">
361 <p className="font-medium">{skillLabel}</p>
362 <p>
363 {skill?.description ??
364 skillDescription ??
365 "No description is available for this skill."}
366 </p>
367 {skill?.path ? (
368 <Button variant="outline" size="sm" onClick={() => actions.openMention(skill.path)}>
369 View instructions
370 </Button>
371 ) : null}
372 </div>
373 </ContextChipPopover>
374 </NodeViewWrapper>
375 );
376}
377
378const ComposerCitationExtension = Node.create({
379 name: "composer-citation",
380 group: "inline",
381 inline: true,
382 atom: true,
383 selectable: true,
384 addAttributes() {
385 return {
386 citation: { default: null },
387 source: { default: "" },
388 citeKey: { default: "" },
389 };
390 },
391 parseHTML() {
392 return [{ tag: "span[data-composer-citation]" }];
393 },
394 renderHTML({ HTMLAttributes }) {
395 return ["span", { "data-composer-citation": "", ...HTMLAttributes }];
396 },
397 addNodeView() {
398 return ReactNodeViewRenderer(ComposerCitationNodeView);
399 },
400});
401
402function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
403 const commentContext = use(ComposerCitationCommentContext);
404 const citation = node.attrs.citation as AssistantCitation;
405 const citeKey = node.attrs.citeKey as string;
406 const commentTarget =
407 commentContext.openComment?.key === citeKey ? commentContext.openComment : null;
408
409 const nodePos = useCallback(() => {
410 const pos = typeof getPos === "function" ? getPos() : null;
411 return typeof pos === "number" ? pos : null;
412 }, [getPos]);
413
414 const onSaveComment = useCallback(
415 (comment: string): boolean => {
416 if (!editor.isEditable) return false;
417 const pos = nodePos();
418 if (pos === null) return false;
419 const current = editor.state.doc.nodeAt(pos);
420 if (!current || current.type.name !== "composer-citation") return false;
421 const currentCitation = current.attrs.citation as AssistantCitation;
422 const next = withAssistantCitationComment(currentCitation, comment);
423 const tr = editor.state.tr.setNodeMarkup(pos, undefined, {
424 ...current.attrs,
425 citation: next,
426 source: serializeAssistantCitation(next),
427 });
428 editor.view.dispatch(tr);
429 return true;
430 },
431 [editor, nodePos],
432 );
433
434 // Undo a fresh insertion: drop the chip and the spaces inserted with it.
435 const removeOnCancel = commentTarget?.removeOnCancel;
436 const onRemove = useCallback(() => {
437 if (!editor.isEditable || !removeOnCancel) return;
438 const pos = nodePos();
439 if (pos === null) return;
440 const { doc } = editor.state;
441 const current = doc.nodeAt(pos);
442 if (!current) return;
443 const end = pos + current.nodeSize;
444 const from = removeOnCancel.before && doc.textBetween(pos - 1, pos) === " " ? pos - 1 : pos;
445 const to = removeOnCancel.after && doc.textBetween(end, end + 1) === " " ? end + 1 : end;
446 editor.chain().focus().deleteRange({ from, to }).run();
447 }, [editor, nodePos, removeOnCancel]);
448
449 // Put the caret right after the chip so Enter sends and typing continues the prompt.
450 const onRestoreFocus = useCallback(() => {
451 if (!editor.isEditable) return;
452 const pos = nodePos();
453 if (pos === null) return;
454 const current = editor.state.doc.nodeAt(pos);
455 if (!current || current.type.name !== "composer-citation") return;
456 editor.commands.focus(pos + current.nodeSize);
457 }, [editor, nodePos]);
458
459 return (
460 <NodeViewWrapper
461 as="span"
462 className="inline-flex min-w-0 max-w-full select-none"
463 contentEditable={false}
464 spellCheck={false}
465 data-composer-citation-chip="true"
466 onKeyDown={(event: ReactKeyboardEvent<HTMLElement>) => {
467 // Tab from the comment button returns to the caret after the chip.
468 if (
469 !editor.isEditable ||
470 event.key !== "Tab" ||
471 event.shiftKey ||
472 event.altKey ||
473 event.metaKey ||
474 event.ctrlKey ||
475 !(event.target instanceof HTMLElement) ||
476 event.target.dataset.citationCommentTrigger === undefined
477 ) {
478 return;
479 }
480 event.preventDefault();
481 onRestoreFocus();
482 }}
483 >
484 <AssistantCitationChip
485 citation={citation}
486 composer
487 commentEditor={{
488 open: commentTarget !== null,
489 sourceAnchor: commentTarget?.sourceAnchor,
490 onOpenChange: (open) => {
491 if (open && !editor.isEditable) return;
492 commentContext.onOpenChange(citeKey, open);
493 },
494 ...(removeOnCancel ? { onCancel: onRemove } : {}),
495 onSave: onSaveComment,
496 onDraftChange: (comment) => commentContext.onDraftChange(citeKey, comment),
497 onSaveAndSend: (comment) => {
498 if (!onSaveComment(comment)) return false;
499 commentContext.onSubmitAndSend();
500 return true;
501 },
502 onRestoreFocus,
503 }}
504 />
505 </NodeViewWrapper>
506 );
507}
508
509const ComposerContextReferenceExtension = Node.create({
510 name: "composer-context-reference",
511 group: "inline",
512 inline: true,
513 atom: true,
514 selectable: true,
515 addAttributes() {
516 return {
517 kind: { default: "" },
518 contextId: { default: "" },
519 label: { default: "" },
520 source: { default: "" },
521 };
522 },
523 parseHTML() {
524 return [{ tag: "span[data-composer-context-reference]" }];
525 },
526 renderHTML({ HTMLAttributes }) {
527 return ["span", { "data-composer-context-reference": "", ...HTMLAttributes }];
528 },
529 addNodeView() {
530 return ReactNodeViewRenderer(ComposerContextReferenceNodeView);
531 },
532});
533
534function ComposerContextReferenceNodeView({ node }: NodeViewProps) {
535 return (
536 <NodeViewWrapper as="span" className={CHIP_NODE_SELECTION_CLASS_NAME}>
537 <ComposerContextReferenceChip
538 kind={(node.attrs.kind as string) ?? ""}
539 contextId={(node.attrs.contextId as string) ?? ""}
540 label={(node.attrs.label as string) ?? ""}
541 />
542 </NodeViewWrapper>
543 );
544}
545
546// ── Marker reveal (show ** when the cursor is on styled text) ──────────────
547
548type StyledRange = {
549 from: number;
550 to: number;
551 markers: { at: number; side: number; text: string }[];
552};
553
554function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] {
555 const ranges: StyledRange[] = [];
556 const map = serializeEditorDoc(doc);
557 let range: StyledRange | null = null;
558 let openLength = 0;
559 for (const run of map.runs) {
560 // A fence is drawn as a block, not revealed a character at a time, so its
561 // delimiters never become marker widgets.
562 if (run.nodeName === "codeBlock") continue;
563 if (run.openLen > 0) {
564 range ??= { from: run.pmPos, to: run.pmPos, markers: [] };
565 range.markers.push({
566 at: run.pmPos,
567 side: -1,
568 text: map.value.slice(run.mdStart, run.mdStart + run.openLen),
569 });
570 }
571 if (range === null) continue;
572 range.to = run.pmPos + run.docLen;
573 if (run.closeLen > 0) {
574 const end = run.mdStart + run.mdLen;
575 range.markers.push({
576 at: range.to,
577 side: -2,
578 text: map.value.slice(end - run.closeLen, end),
579 });
580 }
581 openLength += run.openLen - run.closeLen;
582 if (openLength === 0) {
583 ranges.push(range);
584 range = null;
585 }
586 }
587 return ranges;
588}
589
590/**
591 * A typed marker becomes a list item that remembers the marker it was typed
592 * with, so the stored Markdown keeps `*` or `3)` rather than a canonical `-`.
593 *
594 * `- ` alone is not enough for a dash: it waits for the first character after
595 * the space, which it carries into the new item. That is what lets the GFM
596 * task gesture `- [ ] ` or `- [x] ` be typed whole and reach the task rule,
597 * instead of being cut off by an instant bullet. A `- ` left on its own is
598 * still a bullet the next time the draft is rebuilt.
599 */
600function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList"): InputRule {
601 return new InputRule({
602 find,
603 handler: ({ state, range, match, chain }) => {
604 const marker = match.groups?.marker ?? "-";
605 const space = match.groups?.space ?? " ";
606 const carried = match.groups?.carried ?? "";
607 // Top-level paragraphs only: inside an item or a quote the new list
608 // would nest under a line the stored draft writes flat.
609 const $from = state.doc.resolve(range.from);
610 if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null;
611 const command = chain()
612 .deleteRange(range)
613 .wrapInList(
614 listType,
615 listType === "orderedList" ? { start: Number.parseInt(marker, 10) || 1 } : {},
616 )
617 .updateAttributes("listItem", { marker, space });
618 (carried ? command.insertContent(carried) : command).run();
619 return undefined;
620 },
621 });
622}
623
624/**
625 * `[ ] ` at the start of an existing bullet item turns it into a task, for
626 * items that were already a list when the checkbox was wanted. New tasks are
627 * typed whole, `- [ ] `, and reach the task rule directly.
628 */
629const bulletToTaskInputRule = new InputRule({
630 find: /^\[([ xX])\] $/,
631 handler: ({ state, range, match, chain }) => {
632 const $from = state.doc.resolve(range.from);
633 const item = $from.node(-1);
634 if ($from.parent.type.name !== "paragraph" || item?.type.name !== "listItem") return null;
635 // Any bullet converts; the task grammar only knows `-`, so a `*` or `+`
636 // item comes back out as `- [ ]`.
637 if (!["-", "*", "+"].includes((item.attrs as { marker?: string }).marker ?? "")) return null;
638 const checked = (match[1] ?? " ").toLowerCase() === "x";
639 chain()
640 .command(({ tr }) => {
641 convertBulletItemToTask(tr, range.from, range.to, checked);
642 return true;
643 })
644 .run();
645 return undefined;
646 },
647});
648
649/** `- [ ] ` or `- [x] ` at a top-level paragraph, for the same reason as the list markers. */
650function taskInputRule(type: NodeType): InputRule {
651 const rule = wrappingInputRule({
652 find: /^- \[([ xX])\] $/,
653 type,
654 getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }),
655 });
656 return new InputRule({
657 find: rule.find,
658 handler: (props) =>
659 props.state.doc.resolve(props.range.from).depth === 1 ? rule.handler(props) : null,
660 });
661}
662
663function hasAncestor($pos: ResolvedPos, name: string): boolean {
664 for (let depth = $pos.depth; depth > 0; depth -= 1) {
665 if ($pos.node(depth).type.name === name) return true;
666 }
667 return false;
668}
669
670/**
671 * `> ` at the start of a top-level paragraph opens a quote. Not inside a list:
672 * a quote holds prose lines, and a list item is not one.
673 */
674const blockquoteInputRule = new InputRule({
675 find: /^>(\s)$/,
676 handler: ({ state, range, match, chain }) => {
677 const $from = state.doc.resolve(range.from);
678 if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null;
679 chain()
680 .deleteRange(range)
681 .wrapIn("blockquote", { prefix: `>${match[1] ?? " "}` })
682 .run();
683 return undefined;
684 },
685});
686
687/**
688 * `---` becomes a rule as the third dash lands; `***` and `___` need a space
689 * after them so typing bold or an underscore is not interrupted, matching
690 * Tiptap's own rule. Only at a top-level paragraph: the list and quote
691 * serializers have no line to write a rule into. The typed characters are
692 * kept as the rule's source, and `setHorizontalRule` adds a paragraph after a
693 * rule at the end so the caret has somewhere to go.
694 */
695const horizontalRuleInputRule = new InputRule({
696 find: /^(---|\*\*\*|___)\s?$/,
697 handler: ({ state, range, match, chain }) => {
698 const source = match[0] ?? "---";
699 if (!source.startsWith("---") && !/\s$/.test(source)) return null;
700 const $from = state.doc.resolve(range.from);
701 if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null;
702 chain()
703 .deleteRange(range)
704 .setHorizontalRule()
705 .command(({ tr }) => {
706 // The rule is the block before the caret's paragraph.
707 const $pos = tr.selection.$from;
708 const index = $pos.index(0) - 1;
709 if (index < 0) return true;
710 const rulePos = $pos.posAtIndex(index, 0);
711 const rule = tr.doc.nodeAt(rulePos);
712 if (rule?.type.name === "horizontalRule") {
713 tr.setNodeMarkup(rulePos, undefined, { ...rule.attrs, source });
714 }
715 return true;
716 })
717 .run();
718 return undefined;
719 },
720});
721
722/**
723 * `# ` through `###### ` at a top-level paragraph make a heading. The space
724 * is required, which is exactly what keeps `#1234` a pull request reference
725 * with its picker rather than a heading. Not inside lists or quotes, whose
726 * serializers have no line for one.
727 */
728const headingInputRule = new InputRule({
729 find: /^(#{1,6})(\s)$/,
730 handler: ({ state, range, match, chain }) => {
731 const $from = state.doc.resolve(range.from);
732 if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null;
733 chain()
734 .deleteRange(range)
735 .setNode("heading", { level: match[1]?.length ?? 1, space: match[2] ?? " " })
736 .run();
737 return undefined;
738 },
739});
740
741/** Whether the caret sits inside a fenced code block. */
742function isInCodeBlock(view: EditorView): boolean {
743 return view.state.selection.$from.parent.type.spec.code === true;
744}
745
746const MarkerPluginKey = new PluginKey("composer-rich-markers");
747
748const ComposerMarkerPlugin = new Plugin({
749 key: MarkerPluginKey,
750 state: {
751 init: (_, state) => decorationsForSelection(state),
752 apply: (tr, old, _, state) =>
753 tr.docChanged || tr.selectionSet || tr.storedMarksSet ? decorationsForSelection(state) : old,
754 },
755 props: {
756 decorations(state) {
757 return MarkerPluginKey.getState(state);
758 },
759 },
760});
761
762function decorationsForSelection(state: EditorState): DecorationSet {
763 const { doc, selection } = state;
764 // Markers at the caret render after it while it types with the marks before
765 // the edge. Shifting keeps closers ahead of openers at a shared position.
766 const caretSide = caretTakesMarksBefore(state) ? 3 : 0;
767 const decorations: Decoration[] = [];
768 if (!selection.empty) {
769 doc.nodesBetween(selection.from, selection.to, (node, pos) => {
770 if (node.type.name.startsWith("composer-")) {
771 decorations.push(
772 Decoration.node(pos, pos + node.nodeSize, { class: "composer-chip-range-selected" }),
773 );
774 return false;
775 }
776 return true;
777 });
778 }
779 for (const range of collectStyledRanges(doc)) {
780 const active = selection.empty
781 ? selection.from >= range.from && selection.from <= range.to
782 : selection.from < range.to && selection.to > range.from;
783 if (!active) continue;
784 for (const { at, side: baseSide, text } of range.markers) {
785 const side = selection.empty && at === selection.from ? baseSide + caretSide : baseSide;
786 const marker = document.createElement("span");
787 marker.className = "composer-rich-marker";
788 marker.textContent = text;
789 marker.setAttribute("aria-hidden", "true");
790 decorations.push(
791 Decoration.widget(at, marker, { side, key: `marker-${at}-${side}-${marker.textContent}` }),
792 );
793 }
794 }
795 return DecorationSet.create(doc, decorations);
796}
797
798const ComposerMarkersExtension = Extension.create({
799 name: "composer-rich-markers",
800 addProseMirrorPlugins() {
801 return [ComposerMarkerPlugin];
802 },
803});
804
805// Document model (markdown ⇄ ProseMirror) lives in ~/composer-rich-text-doc so
806// unit tests can round-trip it without a browser.
807// ── Editor component ───────────────────────────────────────────────────────
808
809type TiptapEditor = NonNullable<ReturnType<typeof useEditor>>;
810
811export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) {
812 // Extensions are creation-time, so changing text mode remounts the editor.
813 // Every mode initializes from the controlled value, preserving the draft.
814 return (
815 <ComposerPromptEditorTiptapInner
816 key={props.literalText ? "literal" : props.richTextEnabled ? "rich" : "plain"}
817 {...props}
818 />
819 );
820}
821
822/**
823 * Starts a new undo step when the kind of change switches (typing, deleting,
824 * a paste or a store rewrite), the way the Lexical composer grouped undo.
825 * Runs as dispatch middleware because the grouping has to be decided before
826 * the history plugin applies the transaction.
827 */
828const ComposerUndoGroupingExtension = Extension.create<
829 Record<string, never>,
830 { previous: ComposerChangeKind | null }
831>({
832 name: "composer-undo-grouping",
833 addStorage() {
834 return { previous: null };
835 },
836 dispatchTransaction({ transaction, next }) {
837 this.storage.previous = groupUndoByChangeKind(transaction, this.storage.previous);
838 next(transaction);
839 },
840});
841
842function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
843 const {
844 value,
845 cursor,
846 richTextEnabled,
847 literalText = false,
848 contextRecords,
849 buildContextClipboardFragment,
850 importContextFragment,
851 skills,
852 disabled,
853 placeholder,
854 ariaLabel,
855 suggestionListId,
856 activeSuggestionId,
857 isPathQueryActive,
858 containerClassName,
859 className,
860 placeholderClassName,
861 onChange,
862 onVisibleSelectionChange,
863 onCommandKeyDown,
864 onPageScrollKeyDown,
865 onPageScrollKeyUp,
866 onPageScrollRelease,
867 onCitationSubmitAndSend,
868 onCitationDraftSave,
869 onPaste,
870 editorRef,
871 } = props;
872 // The setting toggles styling, not the engine: both modes are Tiptap.
873 // Plain mode disables the mark extensions, so markers stay literal text.
874 const richText = !literalText && (richTextEnabled ?? false);
875 const clampEditorCursor = useCallback(
876 (text: string, position: number) =>
877 literalText
878 ? Math.max(
879 0,
880 Math.min(text.length, Number.isFinite(position) ? Math.floor(position) : text.length),
881 )
882 : clampCollapsedComposerCursor(text, position),
883 [literalText],
884 );
885 const expandEditorCursor = useCallback(
886 (text: string, position: number) =>
887 literalText ? position : expandCollapsedComposerCursor(text, position),
888 [literalText],
889 );
890
891 const onChangeRef = useRef(onChange);
892 const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange);
893 const onCommandKeyDownRef = useRef(onCommandKeyDown);
894 const isPathQueryActiveRef = useRef(isPathQueryActive);
895 const buildFragmentRef = useRef(buildContextClipboardFragment);
896 const importFragmentRef = useRef(importContextFragment);
897 const skillsRef = useRef(skills);
898 const latestValueRef = useRef(value);
899 // The editor instance for callbacks created before it exists (paste).
900 // Effects flush before any user interaction, so this is always set.
901 const editorHolder = useRef<TiptapEditor | null>(null);
902
903 useEffect(() => {
904 onChangeRef.current = onChange;
905 }, [onChange]);
906 useEffect(() => {
907 onVisibleSelectionChangeRef.current = onVisibleSelectionChange;
908 }, [onVisibleSelectionChange]);
909 useEffect(() => {
910 onCommandKeyDownRef.current = onCommandKeyDown;
911 }, [onCommandKeyDown]);
912 useEffect(() => {
913 isPathQueryActiveRef.current = isPathQueryActive;
914 }, [isPathQueryActive]);
915 useEffect(() => {
916 buildFragmentRef.current = buildContextClipboardFragment;
917 }, [buildContextClipboardFragment]);
918 useEffect(() => {
919 importFragmentRef.current = importContextFragment;
920 }, [importContextFragment]);
921 useEffect(() => {
922 skillsRef.current = skills;
923 }, [skills]);
924 useLayoutEffect(() => {
925 latestValueRef.current = value;
926 }, [value]);
927
928 const skillLabelFor = useCallback((name: string): SkillMeta => {
929 const normalized = name.startsWith("$") ? name.slice(1) : name;
930 const skill = skillsRef.current.find((candidate) => candidate.name === normalized);
931 if (!skill) {
932 return { label: formatProviderSkillDisplayName({ name: normalized }), description: null };
933 }
934 const shortDescription = skill.shortDescription?.trim();
935 return {
936 label: formatProviderSkillDisplayName(skill),
937 description: shortDescription || skill.description?.trim() || null,
938 };
939 }, []);
940
941 const initialCursor = clampEditorCursor(value, cursor);
942 const initialExpandedCursor = expandEditorCursor(value, initialCursor);
943 const snapshotRef = useRef({
944 value,
945 cursor: initialCursor,
946 expandedCursor: initialExpandedCursor,
947 contextIds: literalText ? [] : collectInlineContextIds(value),
948 });
949 const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor });
950 const isApplyingControlledUpdateRef = useRef(false);
951 const hasAppliedControlledSelectionRef = useRef(false);
952 const citationRequestRef = useRef<ComposerCitationCommentRequest | null>(null);
953 const [openCitation, setOpenCitation] = useState<OpenCitationComment | null>(null);
954 // The open citation's unsaved comment, saved before a new value replaces the document.
955 const citationDraftRef = useRef<{ key: string; comment: string } | null>(null);
956 const [isEmpty, setIsEmpty] = useState(value.length === 0);
957
958 const citationCommentActions = useMemo(
959 () => ({
960 openComment: openCitation,
961 onOpenChange: (nodeKey: string, open: boolean) => {
962 if (!open && citationDraftRef.current?.key === nodeKey) citationDraftRef.current = null;
963 setOpenCitation((current) => {
964 if (open) {
965 if (current?.key === nodeKey) return current;
966 return { key: nodeKey };
967 }
968 return current?.key === nodeKey ? null : current;
969 });
970 },
971 onDraftChange: (nodeKey: string, comment: string) => {
972 citationDraftRef.current = { key: nodeKey, comment };
973 },
974 onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}),
975 }),
976 [onCitationSubmitAndSend, openCitation],
977 );
978
979 const handleEditorChange = useCallback(
980 (updated: TiptapEditor) => {
981 const map = serializeEditorDoc(updated.state.doc);
982 // Literal answers hold no chips, so reference-shaped text is not a context.
983 const contextIds = literalText ? [] : map.contextIds;
984 const { from, to } = updated.state.selection;
985 const fromFlat = pmToFlat(map, from);
986 const toFlat = pmToFlat(map, to);
987 const nextValue = map.value;
988 const nextCursor = clampEditorCursor(map.value, flatToCollapsed(map, fromFlat));
989 const nextExpandedCursor = Math.max(
990 0,
991 Math.min(map.value.length, flatToMarkdown(map, fromFlat)),
992 );
993 const nextSelectionRange = {
994 start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)),
995 end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)),
996 };
997 const previousSelectionRange = selectionRangeRef.current;
998 selectionRangeRef.current = nextSelectionRange;
999 setIsEmpty(nextValue.length === 0);
1000 const previousSnapshot = snapshotRef.current;
1001 const snapshotChanged = !(
1002 previousSnapshot.value === nextValue &&
1003 previousSnapshot.cursor === nextCursor &&
1004 previousSnapshot.expandedCursor === nextExpandedCursor &&
1005 previousSnapshot.contextIds.length === contextIds.length &&
1006 previousSnapshot.contextIds.every((id, index) => id === contextIds[index])
1007 );
1008 if (isApplyingControlledUpdateRef.current) return;
1009 if (!snapshotChanged) {
1010 if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) {
1011 onVisibleSelectionChangeRef.current?.();
1012 }
1013 return;
1014 }
1015 // A selection-only update while a newer prompt waits to be applied (a chip
1016 // was just inserted through the store) would report stale text and clobber
1017 // the prompt. Let the controlled rewrite land instead.
1018 if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) {
1019 return;
1020 }
1021 snapshotRef.current = {
1022 value: nextValue,
1023 cursor: nextCursor,
1024 expandedCursor: nextExpandedCursor,
1025 contextIds,
1026 };
1027 // A fence holds no chips, so nothing in it should summon the mention or
1028 // command menu: `@` in code is a decorator, not a file. Suppressing the
1029 // trigger here also keeps the store from inserting a link the block can
1030 // only show as literal text, which the store would then count as a chip.
1031 const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true;
1032 const suppressTrigger =
1033 literalText ||
1034 inCodeBlock ||
1035 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") ||
1036 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right");
1037 onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, contextIds);
1038 },
1039 [clampEditorCursor, literalText],
1040 );
1041
1042 const editorAttributes = useMemo(
1043 () => ({
1044 class: cn(
1045 "composer-tiptap -m-1 block max-h-52 min-h-19.5 overflow-y-auto p-1 whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none",
1046 className,
1047 ),
1048 "data-testid": "composer-editor",
1049 "data-composer-rich-text": richText ? "true" : "false",
1050 role: "textbox",
1051 "aria-multiline": "true",
1052 ...(ariaLabel ? { "aria-label": ariaLabel } : {}),
1053 ...(disabled ? { "aria-readonly": "true" } : {}),
1054 ...(!disabled && suggestionListId
1055 ? {
1056 "aria-autocomplete": "list",
1057 "aria-haspopup": "listbox",
1058 ...(activeSuggestionId
1059 ? {
1060 "aria-controls": suggestionListId,
1061 "aria-activedescendant": activeSuggestionId,
1062 }
1063 : {}),
1064 }
1065 : {}),
1066 "aria-placeholder": placeholder,
1067 }),
1068 [activeSuggestionId, ariaLabel, className, disabled, placeholder, richText, suggestionListId],
1069 );
1070
1071 const editor = useEditor(
1072 {
1073 extensions: [
1074 StarterKit.configure({
1075 blockquote: false,
1076 bulletList: false,
1077 codeBlock: false,
1078 heading: false,
1079 horizontalRule: false,
1080 listItem: false,
1081 link: false,
1082 orderedList: false,
1083 underline: false,
1084 dropcursor: false,
1085 gapcursor: false,
1086 trailingNode: false,
1087 code: false,
1088 // The list extensions handle Backspace and Delete themselves.
1089 listKeymap: false,
1090 undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY },
1091 // Plain mode has no marks: typed markers stay literal characters.
1092 ...(richText ? {} : { bold: false, italic: false, strike: false }),
1093 }),
1094 ComposerUndoGroupingExtension,
1095 ComposerMentionExtension,
1096 ComposerSkillExtension,
1097 ComposerCitationExtension,
1098 ComposerContextReferenceExtension,
1099 ComposerMarkersExtension,
1100 ...(richText
1101 ? [
1102 ComposerCodeExtension,
1103 ComposerCodeBlockExtension.extend({
1104 addNodeView() {
1105 return ReactNodeViewRenderer(ComposerCodeBlockNodeView, {
1106 // The header holds the language picker. Tiptap only shields
1107 // events aimed at the button element itself, so a click on
1108 // its icon or label would reach ProseMirror and move the
1109 // selection; keep every header event away from the editor.
1110 stopEvent: ({ event }) =>
1111 event.target instanceof Element &&
1112 event.target.closest(".chat-markdown-codeblock-header") !== null,
1113 });
1114 },
1115 // Tiptap's own ``` + space rule would open a fence inside a
1116 // list item or quote, where the serializer has no line for
1117 // it. Enter on a fence line covers the gesture at top level.
1118 addInputRules() {
1119 return [];
1120 },
1121 }),
1122 composerCodeBlockHighlight({
1123 resolveTheme: () =>
1124 resolveDiffThemeName(
1125 document.documentElement.classList.contains("dark") ? "dark" : "light",
1126 ),
1127 }),
1128 ...ComposerBlockExtensions.map((extension) =>
1129 extension.name === "blockquote"
1130 ? extension.extend({
1131 addInputRules() {
1132 return [blockquoteInputRule];
1133 },
1134 })
1135 : extension.name === "horizontalRule"
1136 ? extension.extend({
1137 addInputRules() {
1138 return [horizontalRuleInputRule];
1139 },
1140 })
1141 : extension.name === "heading"
1142 ? extension.extend({
1143 addInputRules() {
1144 return [headingInputRule];
1145 },
1146 })
1147 : extension,
1148 ),
1149 ...ComposerListExtensions.map((extension) =>
1150 extension.name === "listItem"
1151 ? extension
1152 : extension.extend({
1153 addInputRules() {
1154 return this.name === "bulletList"
1155 ? [
1156 listMarkerInputRule(/^(?<marker>[*+])(?<space>\s)$/, "bulletList"),
1157 listMarkerInputRule(
1158 /^(?<marker>-)(?<space>[ \t]+)(?<carried>[^\s[])$/,
1159 "bulletList",
1160 ),
1161 ]
1162 : [
1163 listMarkerInputRule(
1164 /^(?<marker>\d+[.)])(?<space>\s)$/,
1165 "orderedList",
1166 ),
1167 ];
1168 },
1169 }),
1170 ),
1171 ComposerTaskListExtension,
1172 ComposerTaskItemExtension.extend({
1173 addInputRules() {
1174 return [taskInputRule(this.type), bulletToTaskInputRule];
1175 },
1176 }),
1177 ]
1178 : []),
1179 ],
1180 content: buildDocJson(
1181 value,
1182 (name) => {
1183 const normalized = name.startsWith("$") ? name.slice(1) : name;
1184 const found = skills.find((candidate) => candidate.name === normalized);
1185 if (!found) {
1186 return {
1187 label: formatProviderSkillDisplayName({ name: normalized }),
1188 description: null,
1189 };
1190 }
1191 const shortDescription = found.shortDescription?.trim();
1192 return {
1193 label: formatProviderSkillDisplayName(found),
1194 description: shortDescription || found.description?.trim() || null,
1195 };
1196 },
1197 { styling: richText, literalText },
1198 ),
1199 editable: !disabled,
1200 editorProps: {
1201 attributes: editorAttributes,
1202 handleKeyDown: (view, event) => {
1203 if (
1204 isMacPlatform(navigator.platform) &&
1205 (event.key === "Home" || event.key === "End") &&
1206 !event.altKey &&
1207 !event.metaKey &&
1208 !event.ctrlKey &&
1209 !event.isComposing
1210 ) {
1211 const selection = window.getSelection();
1212 if (
1213 selection?.anchorNode &&
1214 view.dom.contains(selection.anchorNode) &&
1215 typeof selection.modify === "function"
1216 ) {
1217 event.preventDefault();
1218 event.stopPropagation();
1219 selection.modify(
1220 event.shiftKey ? "extend" : "move",
1221 event.key === "Home" ? "backward" : "forward",
1222 "lineboundary",
1223 );
1224 if (selection.anchorNode && selection.focusNode) {
1225 view.dispatch(
1226 view.state.tr
1227 .setSelection(
1228 TextSelection.create(
1229 view.state.doc,
1230 view.posAtDOM(selection.anchorNode, selection.anchorOffset),
1231 view.posAtDOM(selection.focusNode, selection.focusOffset),
1232 ),
1233 )
1234 .scrollIntoView(),
1235 );
1236 }
1237 return true;
1238 }
1239 }
1240 if (
1241 ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key) &&
1242 !event.shiftKey &&
1243 !event.altKey &&
1244 !event.metaKey &&
1245 !event.ctrlKey &&
1246 !event.isComposing &&
1247 view.state.selection.empty
1248 ) {
1249 const { $from } = view.state.selection;
1250 const direction = event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1;
1251 const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight";
1252 // At the document's ends, native Up/Down can cross a marker without
1253 // changing the document position. Record the stop so DOM selection
1254 // synchronization does not put the caret back inside the styling.
1255 const atDocumentEdge =
1256 $from.pos ===
1257 (direction === -1
1258 ? TextSelection.atStart(view.state.doc).from
1259 : TextSelection.atEnd(view.state.doc).to);
1260 // Take the other stop of a styled edge before skipping a chip, so
1261 // the plain stop between styled text and a chip stays reachable.
1262 const step =
1263 horizontal || atDocumentEdge
1264 ? stepCaretAcrossStyledEdge(view.state, direction)
1265 : null;
1266 if (step) {
1267 event.preventDefault();
1268 event.stopPropagation();
1269 view.dispatch(step);
1270 return true;
1271 }
1272 const adjacent = direction === -1 ? $from.nodeBefore : $from.nodeAfter;
1273 if (horizontal && adjacent?.type.name.startsWith("composer-")) {
1274 event.preventDefault();
1275 event.stopPropagation();
1276 view.dispatch(
1277 view.state.tr
1278 .setSelection(
1279 TextSelection.create(view.state.doc, $from.pos + direction * adjacent.nodeSize),
1280 )
1281 .scrollIntoView(),
1282 );
1283 return true;
1284 }
1285 }
1286 // Shift+Tab from just after a citation reaches its comment button, which
1287 // native tab order skips because the chip lives inside the editor.
1288 if (
1289 event.key === "Tab" &&
1290 event.shiftKey &&
1291 !event.altKey &&
1292 !event.metaKey &&
1293 !event.ctrlKey &&
1294 view.state.selection.empty
1295 ) {
1296 const { $from } = view.state.selection;
1297 const citation = $from.nodeBefore;
1298 if (citation?.type.name === "composer-citation") {
1299 const chip = view.nodeDOM($from.pos - citation.nodeSize);
1300 const commentButton =
1301 chip instanceof HTMLElement
1302 ? chip.querySelector<HTMLElement>("[data-citation-comment-trigger]")
1303 : null;
1304 if (commentButton) {
1305 event.preventDefault();
1306 event.stopPropagation();
1307 commentButton.focus();
1308 return true;
1309 }
1310 }
1311 }
1312 if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) {
1313 event.stopPropagation();
1314 return true;
1315 }
1316 // Enter on a focused task checkbox must not send the prompt (or
1317 // split anything): Space toggles it, Enter does nothing.
1318 if (event.key === "Enter" && event.target instanceof HTMLInputElement) {
1319 event.preventDefault();
1320 return true;
1321 }
1322 // Inside a fence Tab belongs to the code, not to the composer's
1323 // focus order or its autocomplete.
1324 if (
1325 event.key === "Tab" &&
1326 !event.metaKey &&
1327 !event.ctrlKey &&
1328 selectionInOneCodeBlock(view.state)
1329 ) {
1330 event.preventDefault();
1331 event.stopPropagation();
1332 return indentCodeBlock(view.state, event.shiftKey ? "out" : "in", (tr) =>
1333 view.dispatch(tr),
1334 );
1335 }
1336 // A fence is multi-line by definition, so Enter and Shift+Enter
1337 // belong to the code rather than to sending or splitting: inside a
1338 // block they make a line (a generic split would cut the fence in
1339 // two), and on a line that is only an opening fence they open the
1340 // block. Sending from inside a fence is still Cmd/Ctrl+Enter, which
1341 // falls through.
1342 if (
1343 event.key === "Enter" &&
1344 richText &&
1345 !event.metaKey &&
1346 !event.ctrlKey &&
1347 !event.isComposing
1348 ) {
1349 if (selectionInOneCodeBlock(view.state)) {
1350 event.preventDefault();
1351 event.stopPropagation();
1352 const dispatch = (tr: typeof view.state.tr) => view.dispatch(tr.scrollIntoView());
1353 return (
1354 exitCodeBlockOnClosingFence(view.state, dispatch) ||
1355 exitCodeBlockOnTrailingBlankLines(view) ||
1356 indentedNewlineInCodeBlock(view.state, dispatch) ||
1357 newlineInCode(view.state, dispatch)
1358 );
1359 }
1360 if (convertCodeFenceOnEnter(view.state, (tr) => view.dispatch(tr))) {
1361 event.preventDefault();
1362 event.stopPropagation();
1363 return true;
1364 }
1365 }
1366 const handler = onCommandKeyDownRef.current;
1367 if (event.key === "Enter") {
1368 const instance = editorHolder.current;
1369 const isTaskItem = richText && (instance?.isActive("taskItem") ?? false);
1370 const isListItem = richText && (instance?.isActive("listItem") ?? false);
1371 const handled = handler?.("Enter", event, isTaskItem || isListItem) ?? false;
1372 if (handled) {
1373 event.preventDefault();
1374 event.stopPropagation();
1375 return true;
1376 }
1377 event.preventDefault();
1378 if ((isTaskItem || isListItem) && instance && splitOrLiftListItem(instance)) {
1379 return true;
1380 }
1381 if (
1382 richText &&
1383 instance &&
1384 hasAncestor(view.state.selection.$from, "blockquote") &&
1385 view.state.selection.$from.parent.content.size === 0 &&
1386 instance.commands.lift("blockquote")
1387 ) {
1388 return true;
1389 }
1390 // Split the paragraph so a single newline visibly advances the caret.
1391 return splitBlockKeepMarks(view.state, (tr) => {
1392 // The split is programmatic, so the browser won't follow the
1393 // caret into view on its own.
1394 view.dispatch(tr.scrollIntoView());
1395 });
1396 }
1397 if (!handler) return false;
1398 const handled = handler(event.key, event);
1399 if (handled) {
1400 event.preventDefault();
1401 event.stopPropagation();
1402 }
1403 return handled;
1404 },
1405 handleTextInput: (view, from, to, text) => {
1406 if (literalText || text.length !== 1) return false;
1407 const closer = SURROUND_CLOSE[text];
1408 if (!closer || from === to) return false;
1409 // Never wrap chips or other atoms, and never wrap styled text: the
1410 // default replace keeps marks intact, wrapping would drop them.
1411 let touchesSpecial = false;
1412 view.state.doc.nodesBetween(from, to, (node) => {
1413 if (
1414 (node.isAtom && node.isInline && !node.isText) ||
1415 (node.isText && node.marks.length > 0)
1416 ) {
1417 touchesSpecial = true;
1418 return false;
1419 }
1420 return true;
1421 });
1422 if (touchesSpecial) return false;
1423 const map = serializeEditorDoc(view.state.doc);
1424 const startMd = flatToMarkdown(map, pmToFlat(map, from));
1425 const endMd = flatToMarkdown(map, pmToFlat(map, to));
1426 if (selectionTouchesMentionBoundary(map.value, startMd, endMd)) return false;
1427 const tr = view.state.tr.insertText(closer, to).insertText(text, from);
1428 tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length));
1429 view.dispatch(tr);
1430 return true;
1431 },
1432 handlePaste: (view, event) => {
1433 const clipboardData = event.clipboardData;
1434 if (!clipboardData || clipboardData.files.length > 0) return false;
1435 const pastedText = clipboardData.getData("text/plain");
1436 if (!pastedText) return false;
1437 event.preventDefault();
1438 // A fence takes the clipboard verbatim. Running the markdown path
1439 // here would split the block on newlines, nest a pasted fence and
1440 // build chips the code block's schema cannot hold anyway.
1441 if (isInCodeBlock(view)) {
1442 const { from, to } = view.state.selection;
1443 view.dispatch(
1444 markAsClipboardEdit(
1445 view.state.tr.insertText(pastedText, from, to),
1446 "paste",
1447 ).scrollIntoView(),
1448 );
1449 return true;
1450 }
1451 const importFragment = importFragmentRef.current;
1452 let text =
1453 !literalText && importFragment
1454 ? importPastedComposerText(clipboardData, importFragment)
1455 : pastedText;
1456 // Complete chips at paste boundaries just as autocomplete does.
1457 const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`);
1458 const lastToken = tokens.at(-1);
1459 if (
1460 (lastToken?.type === "mention" || lastToken?.type === "skill") &&
1461 lastToken.end === text.length
1462 ) {
1463 text += " ";
1464 }
1465 if (
1466 (tokens[0]?.type === "mention" || tokens[0]?.type === "skill") &&
1467 tokens[0].start === 0
1468 ) {
1469 const map = serializeEditorDoc(view.state.doc);
1470 const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from));
1471 if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`;
1472 }
1473 const { $from } = view.state.selection;
1474 const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC");
1475 const literalLength = literalText
1476 ? 0
1477 : pastedPathQueryLength(lineBefore, text, isPathQueryActiveRef.current?.());
1478 const editorInstance = editorHolder.current;
1479 if (editorInstance) {
1480 // Inside a list item or quote, pasted block markup has nowhere to
1481 // go: it stays literal lines the next rebuild reads back. Inline
1482 // marks still apply.
1483 const $paste = view.state.selection.$from;
1484 const nested = ["listItem", "taskItem", "blockquote"].some((name) =>
1485 hasAncestor($paste, name),
1486 );
1487 insertMarkdownParagraphs(
1488 text,
1489 skillLabelFor,
1490 { styling: richText, blocks: !nested, literalText, literalLength },
1491 (content) => {
1492 // Tagged on the same transaction insertContent builds, so the
1493 // paste is one undo step of its own.
1494 editorInstance
1495 .chain()
1496 .command(({ tr }) => {
1497 markAsClipboardEdit(tr, "paste");
1498 return true;
1499 })
1500 .insertContent(content)
1501 .command(({ tr }) => {
1502 // A paste ending in a rule leaves the rule selected, and
1503 // the next keystroke would replace it. The caret goes to
1504 // the line after it, which is added when there is none.
1505 const { selection } = tr;
1506 if (!(selection instanceof NodeSelection) || !selection.node.isBlock) {
1507 return true;
1508 }
1509 if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) {
1510 tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create());
1511 }
1512 tr.setSelection(TextSelection.create(tr.doc, selection.to + 1));
1513 return true;
1514 })
1515 .run();
1516 },
1517 );
1518 scrollTiptapCaretIntoView(editorInstance);
1519 }
1520 return true;
1521 },
1522 },
1523 onUpdate: ({ editor: updated }) => {
1524 handleEditorChange(updated);
1525 },
1526 onSelectionUpdate: ({ editor: updated }) => {
1527 handleEditorChange(updated);
1528 },
1529 },
1530 [],
1531 );
1532
1533 useEffect(() => {
1534 editor?.setEditable(!disabled);
1535 }, [disabled, editor]);
1536
1537 useEffect(() => {
1538 editorHolder.current = editor;
1539 }, [editor]);
1540
1541 // Tiptap forwards option changes to the view from a passive effect, so a
1542 // class change here would reach the ProseMirror element one tick after
1543 // React commits. The chat composer measures its resting and expanded
1544 // geometry in layout effects that run first, and it clamps the prompt
1545 // through `className`, so the attributes are pushed to the view here for
1546 // those measurements to see the layout they are about to reserve for.
1547 useLayoutEffect(() => {
1548 if (!editor?.isInitialized) return;
1549 editor.view.setProps({ attributes: editorAttributes });
1550 }, [editor, editorAttributes]);
1551
1552 const readSnapshot = useCallback(() => {
1553 const snapshot = snapshotRef.current;
1554 if (!editor) return snapshot;
1555 const map = serializeEditorDoc(editor.state.doc);
1556 const { from, to } = editor.state.selection;
1557 const fromFlat = pmToFlat(map, from);
1558 const next: typeof snapshot = {
1559 value: map.value,
1560 cursor: clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)),
1561 expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))),
1562 contextIds: literalText ? [] : map.contextIds,
1563 };
1564 const toFlat = pmToFlat(map, to);
1565 selectionRangeRef.current = {
1566 start: Math.min(next.expandedCursor, flatToMarkdown(map, toFlat)),
1567 end: Math.max(next.expandedCursor, flatToMarkdown(map, toFlat)),
1568 };
1569 snapshotRef.current = next;
1570 return next;
1571 }, [clampEditorCursor, editor, literalText]);
1572
1573 // A question or approval swaps the controlled value while a citation comment
1574 // is open. Its node view unmounts with the old document, so save the comment
1575 // the way dismissing the popover would, and hand the saved value back.
1576 const saveCitationDraft = useEffectEvent((current: TiptapEditor, replacedValue: string) => {
1577 const draft = citationDraftRef.current;
1578 citationDraftRef.current = null;
1579 if (!draft || !onCitationDraftSave) return;
1580 const [target] = findChildren(
1581 current.state.doc,
1582 (node) => node.type.name === "composer-citation" && node.attrs.citeKey === draft.key,
1583 );
1584 if (!target) return;
1585 const { node, pos } = target;
1586 const citation = node.attrs.citation as AssistantCitation;
1587 const dismissal = resolveAssistantCitationCommentDismissal({
1588 reason: "none",
1589 draft: draft.comment,
1590 savedComment: citation.comment,
1591 });
1592 if (dismissal.kind !== "commit") return;
1593 const next = withAssistantCitationComment(citation, dismissal.comment);
1594 current.view.dispatch(
1595 current.state.tr
1596 .setNodeMarkup(pos, undefined, {
1597 ...node.attrs,
1598 citation: next,
1599 source: serializeAssistantCitation(next),
1600 })
1601 .setMeta("addToHistory", false),
1602 );
1603 onCitationDraftSave(replacedValue, serializeEditorDoc(current.state.doc).value);
1604 });
1605
1606 // Controlled value/cursor from the store (history recall, chip insertion…).
1607 useLayoutEffect(() => {
1608 if (!editor) return;
1609 const initialSelection = !hasAppliedControlledSelectionRef.current;
1610 hasAppliedControlledSelectionRef.current = true;
1611 const normalizedCursor = clampEditorCursor(value, cursor);
1612 const previousSnapshot = snapshotRef.current;
1613 if (
1614 !initialSelection &&
1615 previousSnapshot.value === value &&
1616 previousSnapshot.cursor === normalizedCursor
1617 ) {
1618 return;
1619 }
1620 const normalizedExpandedCursor = expandEditorCursor(value, normalizedCursor);
1621 snapshotRef.current = {
1622 value,
1623 cursor: normalizedCursor,
1624 expandedCursor: normalizedExpandedCursor,
1625 contextIds: collectInlineContextIds(value),
1626 };
1627 selectionRangeRef.current = {
1628 start: normalizedExpandedCursor,
1629 end: normalizedExpandedCursor,
1630 };
1631 setIsEmpty(value.length === 0);
1632 const rootElement = editor.view.dom;
1633 const isFocused = Boolean(rootElement && document.activeElement === rootElement);
1634 if (!initialSelection && previousSnapshot.value === value && !isFocused) return;
1635
1636 isApplyingControlledUpdateRef.current = true;
1637 const pendingCitation =
1638 citationRequestRef.current?.value === value ? citationRequestRef.current : null;
1639 if (previousSnapshot.value !== value) {
1640 saveCitationDraft(editor, previousSnapshot.value);
1641 editor.commands.setContent(
1642 buildDocJson(value, skillLabelFor, { styling: richText, literalText }),
1643 {
1644 emitUpdate: false,
1645 },
1646 );
1647 }
1648 const map = serializeEditorDoc(editor.state.doc);
1649 const flat = collapsedToFlat(map, normalizedCursor);
1650 editor.commands.setTextSelection(flatToPm(map, flat));
1651 if (isFocused) scrollTiptapCaretIntoView(editor);
1652 if (pendingCitation) {
1653 citationRequestRef.current = null;
1654 const target = map.runs.find(
1655 (run) =>
1656 run.kind === "token" &&
1657 run.nodeName === "composer-citation" &&
1658 run.mdStart === pendingCitation.citationStart,
1659 );
1660 if (target) {
1661 const node = editor.state.doc.nodeAt(target.pmPos);
1662 const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey;
1663 if (citeKey) {
1664 setOpenCitation({
1665 key: citeKey,
1666 sourceAnchor: pendingCitation.sourceAnchor,
1667 removeOnCancel: pendingCitation.insertedSpaces,
1668 });
1669 }
1670 }
1671 }
1672 queueMicrotask(() => {
1673 isApplyingControlledUpdateRef.current = false;
1674 });
1675 }, [
1676 clampEditorCursor,
1677 cursor,
1678 editor,
1679 expandEditorCursor,
1680 literalText,
1681 richText,
1682 skillLabelFor,
1683 value,
1684 ]);
1685
1686 const focusAt = useCallback(
1687 (nextCursor: number) => {
1688 if (!editor) return;
1689 // view.focus() writes the state selection to the DOM as it focuses. A
1690 // bare DOM focus leaves the native caret at the start until
1691 // ProseMirror resyncs it 20ms later, and dictation tools type fast
1692 // enough to land keys there, ahead of the text already inserted.
1693 editor.view.focus();
1694 // A newer prompt is waiting to be applied (a chip was just inserted
1695 // through the store). Reporting the editor's stale text now would
1696 // overwrite that prompt; the pending rewrite places the caret instead.
1697 if (snapshotRef.current.value !== latestValueRef.current) return;
1698 const boundedCursor = clampEditorCursor(snapshotRef.current.value, nextCursor);
1699 const map = serializeEditorDoc(editor.state.doc);
1700 const flat = collapsedToFlat(map, boundedCursor);
1701 editor.commands.setTextSelection(flatToPm(map, flat));
1702 scrollTiptapCaretIntoView(editor);
1703 if (boundedCursor === snapshotRef.current.cursor) return;
1704 snapshotRef.current = {
1705 value: snapshotRef.current.value,
1706 cursor: boundedCursor,
1707 expandedCursor: expandEditorCursor(snapshotRef.current.value, boundedCursor),
1708 contextIds: snapshotRef.current.contextIds,
1709 };
1710 selectionRangeRef.current = {
1711 start: snapshotRef.current.expandedCursor,
1712 end: snapshotRef.current.expandedCursor,
1713 };
1714 onChangeRef.current(
1715 snapshotRef.current.value,
1716 boundedCursor,
1717 snapshotRef.current.expandedCursor,
1718 literalText,
1719 snapshotRef.current.contextIds,
1720 );
1721 },
1722 [clampEditorCursor, editor, expandEditorCursor, literalText],
1723 );
1724
1725 useImperativeHandle(
1726 editorRef,
1727 () => ({
1728 focus: () => {
1729 focusAt(snapshotRef.current.cursor);
1730 },
1731 focusAt,
1732 focusAtEnd: () => {
1733 focusAt(
1734 literalText
1735 ? snapshotRef.current.value.length
1736 : collapseExpandedComposerCursor(
1737 snapshotRef.current.value,
1738 snapshotRef.current.value.length,
1739 ),
1740 );
1741 },
1742 readSelectionRange: () => {
1743 readSnapshot();
1744 return selectionRangeRef.current;
1745 },
1746 requestCitationComment: (request) => {
1747 citationRequestRef.current = request;
1748 if (!editor) return;
1749 const map = serializeEditorDoc(editor.state.doc);
1750 if (map.value !== request.value) return;
1751 const target = map.runs.find(
1752 (run) =>
1753 run.kind === "token" &&
1754 run.nodeName === "composer-citation" &&
1755 run.mdStart === request.citationStart,
1756 );
1757 if (!target) return;
1758 const node = editor.state.doc.nodeAt(target.pmPos);
1759 const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey;
1760 if (citeKey) {
1761 citationRequestRef.current = null;
1762 setOpenCitation({
1763 key: citeKey,
1764 sourceAnchor: request.sourceAnchor,
1765 removeOnCancel: request.insertedSpaces,
1766 });
1767 }
1768 },
1769 readSnapshot,
1770 isCaretOnVisualEdge: (edge) => {
1771 const snapshot = readSnapshot();
1772 if (snapshot.value.length === 0) return true;
1773 const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor);
1774 const afterCaret = snapshot.value.slice(snapshot.expandedCursor);
1775 if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) {
1776 return false;
1777 }
1778 const rootElement = editor?.view.dom;
1779 const selection = window.getSelection();
1780 if (
1781 !rootElement ||
1782 !selection ||
1783 !selection.isCollapsed ||
1784 selection.rangeCount === 0 ||
1785 !selection.anchorNode ||
1786 !rootElement.contains(selection.anchorNode)
1787 ) {
1788 return false;
1789 }
1790 const caretRect = caretLineRect(selection.getRangeAt(0), edge);
1791 if (!caretRect) return false;
1792 const edgeElement =
1793 edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild;
1794 const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect();
1795 const threshold = caretRect.height / 2;
1796 return edge === "start"
1797 ? caretRect.top - edgeRect.top < threshold
1798 : edgeRect.bottom - caretRect.bottom < threshold;
1799 },
1800 }),
1801 [editor, focusAt, literalText, readSnapshot],
1802 );
1803
1804 const handleCopyCut = useCallback(
1805 (event: React.ClipboardEvent, cut: boolean) => {
1806 const build = buildFragmentRef.current;
1807 if (!editor || (cut && !editor.isEditable)) return;
1808 const clipboardData = event.clipboardData;
1809 const { from, to } = editor.state.selection;
1810 if (from === to) return;
1811 const text = serializeSelection(editor.state.doc, from, to);
1812 const contextIds = literalText ? [] : Array.from(new Set(collectInlineContextIds(text)));
1813 const fragment = contextIds.length > 0 ? build?.(contextIds) : null;
1814 event.preventDefault();
1815 clipboardData.setData("text/plain", text);
1816 if (fragment) {
1817 clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment);
1818 clipboardData.setData("text/html", encodeComposerContextClipboardHtml(text, fragment));
1819 }
1820 if (cut) {
1821 editor
1822 .chain()
1823 .focus()
1824 .command(({ tr }) => {
1825 markAsClipboardEdit(tr, "cut");
1826 return true;
1827 })
1828 .deleteSelection()
1829 .run();
1830 }
1831 },
1832 [editor, literalText],
1833 );
1834
1835 return (
1836 <RichComposerSkillsContext value={skills}>
1837 <ComposerContextRecordsContext value={contextRecords}>
1838 <ComposerCitationCommentContext value={citationCommentActions}>
1839 <div
1840 className={cn(
1841 "relative flow-root font-(family-name:--font-composer,var(--font-sans)) text-(length:--font-size-prompt,var(--text-sm)) max-sm:pointer-coarse:text-(length:--font-size-prompt-touch)",
1842 containerClassName,
1843 )}
1844 >
1845 <EditorContent
1846 editor={editor}
1847 onKeyDown={(event) => {
1848 if (
1849 event.key === "Control" ||
1850 event.key === "Meta" ||
1851 event.key === "Alt" ||
1852 event.key === "Shift"
1853 ) {
1854 onPageScrollRelease?.();
1855 }
1856 if (event.key !== "PageUp" && event.key !== "PageDown") return;
1857 const target = event.currentTarget.querySelector(
1858 '[data-testid="composer-editor"]',
1859 ) as HTMLElement | null;
1860 if (!target) return;
1861 const pageScrollKey = getTimelinePageScrollKey({
1862 altKey: event.altKey,
1863 clientHeight: target.clientHeight,
1864 ctrlKey: event.ctrlKey,
1865 defaultPrevented: event.defaultPrevented,
1866 isComposing: event.nativeEvent.isComposing,
1867 key: event.key,
1868 keyCode: event.keyCode,
1869 metaKey: event.metaKey,
1870 scrollHeight: target.scrollHeight,
1871 scrollTop: target.scrollTop,
1872 shiftKey: event.shiftKey,
1873 });
1874 if (!pageScrollKey) {
1875 onPageScrollRelease?.();
1876 return;
1877 }
1878 if (!onPageScrollKeyDown) return;
1879 event.preventDefault();
1880 onPageScrollKeyDown(pageScrollKey);
1881 }}
1882 onKeyUp={(event) => onPageScrollKeyUp?.(event.key)}
1883 onBlur={onPageScrollRelease}
1884 onPasteCapture={onPaste}
1885 onCopyCapture={(event) => handleCopyCut(event, false)}
1886 onCutCapture={(event) => handleCopyCut(event, true)}
1887 />
1888 {isEmpty && contextRecords.size === 0 && placeholder ? (
1889 <div
1890 className={cn(
1891 "pointer-events-none absolute inset-0 leading-relaxed text-placeholder/75",
1892 placeholderClassName,
1893 )}
1894 >
1895 {placeholder}
1896 </div>
1897 ) : null}
1898 </div>
1899 </ComposerCitationCommentContext>
1900 </ComposerContextRecordsContext>
1901 </RichComposerSkillsContext>
1902 );
1903}
1904
1905/**
1906 * Insert pasted markdown at the selection, rebuilding inline tokens as chips
1907 * and styled spans as marks.
1908 *
1909 * Newlines always become paragraph splits — never trailing hard breaks, which
1910 * render no visible line — so pasted text lands exactly as typed.
1911 */
1912function insertMarkdownParagraphs(
1913 value: string,
1914 skillLabelFor: (name: string) => SkillMeta,
1915 options: { styling: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number },
1916 insertContent: (content: JSONContent[] | JSONContent) => void,
1917): void {
1918 const blocks = buildTiptapContent(value, skillLabelFor, options);
1919 if (blocks.length === 1 && blocks[0]?.type === "paragraph") {
1920 const inline = (blocks[0]?.content ?? []) as JSONContent[];
1921 if (inline.length === 0) return;
1922 insertContent(inline);
1923 return;
1924 }
1925 insertContent(blocks as JSONContent[]);
1926}
1927
1928/**
1929 * Follow a programmatically placed caret: native scrolling only happens for
1930 * real input, so controlled rewrites, pastes, and focus restores scroll the
1931 * composer to the caret explicitly.
1932 */
1933function scrollTiptapCaretIntoView(editor: TiptapEditor): void {
1934 editor.view.dispatch(editor.state.tr.scrollIntoView());
1935}
1936
1937/**
1938 * Client rect of the caret's visual line, so prompt history keeps claiming
1939 * ArrowUp/Down only at the first and last soft-wrapped lines.
1940 */
1941function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null {
1942 const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0);
1943 const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0];
1944 if (collapsedRect) return collapsedRect;
1945
1946 const container = range.startContainer;
1947 // TEXT_NODE without importing the DOM lib's Node (shadowed by Tiptap's).
1948 if (container.nodeType === 3) {
1949 const textNode = container as Text;
1950 if (textNode.data.length === 0) return null;
1951 const probeStart = Math.max(
1952 0,
1953 Math.min(
1954 edge === "start" ? range.startOffset : range.startOffset - 1,
1955 textNode.data.length - 1,
1956 ),
1957 );
1958 const probeRange = document.createRange();
1959 probeRange.setStart(textNode, probeStart);
1960 probeRange.setEnd(textNode, probeStart + 1);
1961 const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0);
1962 if (probeRect) return probeRect;
1963 const boundingRect = probeRange.getBoundingClientRect();
1964 return boundingRect.height > 0 ? boundingRect : null;
1965 }
1966
1967 if (!(container instanceof HTMLElement)) return null;
1968 const neighbour =
1969 container.childNodes[Math.max(0, range.startOffset - 1)] ??
1970 container.childNodes[range.startOffset];
1971 if (neighbour instanceof HTMLElement) {
1972 const neighbourRect = neighbour.getBoundingClientRect();
1973 if (neighbourRect.height > 0) return neighbourRect;
1974 } else if (neighbour instanceof Text && neighbour.data.length > 0) {
1975 const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1];
1976 const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0;
1977 const probeRange = document.createRange();
1978 probeRange.setStart(neighbour, probeStart);
1979 probeRange.setEnd(neighbour, probeStart + 1);
1980 const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0);
1981 if (probeRect) return probeRect;
1982 }
1983 const containerRect = container.getBoundingClientRect();
1984 return containerRect.height > 0 ? containerRect : null;
1985}
apps/web/src/components/chat/ChatCanvas.tsx+16-1
......@@ -8,7 +8,11 @@ import {
88 type CSSProperties,
99} from "react";
1010import { ChatCanvasContext } from "./ChatCanvasContext";
11import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout";
11import {
12 DETAILS_CARD_CLEARANCE,
13 resolveChatCanvasLayout,
14 type ChatCanvasPreview,
15} from "./chatCanvasLayout";
1216import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout";
1317import { observeResize } from "../../lib/observeResize";
1418
......@@ -144,6 +148,15 @@ export function ChatCanvas({
144148 detailsCardTopInset,
145149 ]);
146150 const { layout } = context;
151 const blockOutsetStart = Math.max(0, layout.chat.left - measurements.padding);
152 const blockRight = detailsCard
153 ? detailsCard.left - DETAILS_CARD_CLEARANCE
154 : measurements.width - measurements.padding;
155 const blockOutsetEnd =
156 layout.chat.insetEnd > 0
157 ? 0
158 : Math.max(0, Math.min(blockOutsetStart, blockRight - layout.chat.left - layout.chat.width));
159
147160 return (
148161 <ChatCanvasContext value={context}>
149162 <div
......@@ -157,6 +170,8 @@ export function ChatCanvas({
157170 "--chat-timeline-gutter": `${measurements.timelineGutter}px`,
158171 "--chat-lane-inset-start": `${layout.chat.insetStart}px`,
159172 "--chat-lane-inset-end": `${layout.chat.insetEnd}px`,
173 "--chat-block-outset-start": `${blockOutsetStart}px`,
174 "--chat-block-outset-end": `${blockOutsetEnd}px`,
160175 } as CSSProperties
161176 }
162177 >
apps/web/src/components/chat/ChatComposer.tsx+87-55
......@@ -94,7 +94,6 @@ import {
9494 replaceTextRange,
9595} from "../../composer-logic";
9696import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
97import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation";
9897import {
9998 deriveComposerSendState,
10099 getAntigravitySendBlockReason,
......@@ -293,7 +292,7 @@ import {
293292 ComposerControlSeparator,
294293 ComposerSelectControl,
295294} from "./ComposerControl";
296import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
295import { composerEmojiKeyAction, resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
297296import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDetail.logic";
298297import {
299298 matchesPullRequestQuery,
......@@ -1707,7 +1706,7 @@ export interface ChatComposerProps {
17071706 value: string,
17081707 nextCursor: number,
17091708 expandedCursor: number,
1710 cursorAdjacentToMention: boolean,
1709 suppressSuggestions: boolean,
17111710 ) => void;
17121711
17131712 onProviderModelSelect: (
......@@ -2617,6 +2616,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26172616 // Derived: composer trigger / menu
26182617 // ------------------------------------------------------------------
26192618 const composerTriggerKind = composerTrigger?.kind ?? null;
2619 const [emojiSearch, setEmojiSearch] = useState<
2620 typeof import("./composerEmojiSearch") | "error" | null
2621 >(null);
2622 const isEmojiTrigger = composerTriggerKind === "emoji";
2623
2624 useEffect(() => {
2625 if (!isEmojiTrigger || emojiSearch !== null) return;
2626 let active = true;
2627 void import("./composerEmojiSearch").then(
2628 (search) => {
2629 if (active) setEmojiSearch(search);
2630 },
2631 () => {
2632 if (active) setEmojiSearch("error");
2633 },
2634 );
2635 return () => {
2636 active = false;
2637 };
2638 }, [emojiSearch, isEmojiTrigger]);
2639
26202640 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
26212641 const pullRequestTriggerQuery =
26222642 composerTrigger?.kind === "pull-request" ? composerTrigger.query : "";
......@@ -2720,6 +2740,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27202740
27212741 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
27222742 if (!composerTrigger) return [];
2743 if (composerTrigger.kind === "emoji") {
2744 return emojiSearch && emojiSearch !== "error"
2745 ? emojiSearch.searchComposerEmoji(composerTrigger.query)
2746 : [];
2747 }
27232748 if (composerTrigger.kind === "path") {
27242749 // Threads only surface for a typed query so `@` alone stays a file picker. A title match
27252750 // is far more specific than a fuzzy path hit, so the few threads lead the list.
......@@ -2877,6 +2902,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28772902 activeThreadId,
28782903 compactSlashCommandAvailable,
28792904 composerTrigger,
2905 emojiSearch,
28802906 environmentId,
28812907 environmentThreadShells,
28822908 exactPullRequestLookup.data,
......@@ -2964,6 +2990,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29642990 ]);
29652991
29662992 const isComposerMenuLoading =
2993 (isEmojiTrigger && emojiSearch === null) ||
29672994 (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) ||
29682995 (composerTriggerKind === "pull-request" &&
29692996 pullRequestProjectId !== null &&
......@@ -2973,6 +3000,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29733000 pullRequestTriggerNumber !== debouncedPullRequestNumber ||
29743001 exactPullRequestLookup.isPending));
29753002 const composerMenuEmptyState = useMemo(() => {
3003 if (composerTriggerKind === "emoji") {
3004 return emojiSearch === "error"
3005 ? "Emoji could not load. Reload and try again."
3006 : "No matching emoji.";
3007 }
29763008 if (composerTriggerKind === "skill") {
29773009 return "No skills found. Try / to browse provider commands.";
29783010 }
......@@ -2996,6 +3028,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29963028 }, [
29973029 composerTrigger,
29983030 composerTriggerKind,
3031 emojiSearch,
29993032 pullRequestLookup.data?.errors,
30003033 pullRequestLookup.error,
30013034 pullRequestProjectId,
......@@ -3827,7 +3860,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38273860 nextPrompt: string,
38283861 nextCursor: number,
38293862 expandedCursor: number,
3830 cursorAdjacentToMention: boolean,
3863 suppressSuggestions: boolean,
38313864 contextIds: string[],
38323865 ) => {
38333866 expandComposerForEditorChange();
......@@ -3835,14 +3868,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38353868 if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return;
38363869 setComposerCursor(nextCursor);
38373870 setComposerTrigger(
3838 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
3871 suppressSuggestions ? null : detectComposerTrigger(nextPrompt, expandedCursor),
38393872 );
38403873 onChangeActivePendingUserInputCustomAnswer(
38413874 activePendingProgress.activeQuestion.id,
38423875 nextPrompt,
38433876 nextCursor,
38443877 expandedCursor,
3845 cursorAdjacentToMention,
3878 suppressSuggestions,
38463879 );
38473880 return;
38483881 }
......@@ -3947,7 +3980,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39473980 }
39483981 setComposerCursor(nextCursor);
39493982 setComposerTrigger(
3950 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
3983 suppressSuggestions ? null : detectComposerTrigger(nextPrompt, expandedCursor),
39513984 );
39523985 },
39533986 [
......@@ -4026,7 +4059,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
40264059 );
40274060 if (options?.citationComment) {
40284061 composerEditorRef.current?.requestCitationComment({
4029 previousValue: currentText,
40304062 value: next.text,
40314063 citationStart: options.citationComment.start,
40324064 sourceAnchor: options.citationComment.sourceAnchor,
......@@ -4104,9 +4136,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
41044136 const snapshot = readComposerSnapshot();
41054137 return {
41064138 snapshot,
4107 trigger: isLiteralPendingAnswer
4108 ? null
4109 : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)),
4139 trigger:
4140 isLiteralPendingAnswer || composerEditorRef.current?.suggestionsSuppressed()
4141 ? null
4142 : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)),
41104143 };
41114144 }, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]);
41124145
......@@ -4120,6 +4153,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
41204153 });
41214154 const { snapshot, trigger } = resolveActiveComposerTrigger();
41224155 if (!trigger) return;
4156 if (item.type === "emoji") {
4157 if (
4158 trigger.kind !== "emoji" ||
4159 trigger.query.toLowerCase() !== item.query ||
4160 !composerMenuItemsRef.current.some((candidate) => candidate.id === item.id)
4161 ) {
4162 return;
4163 }
4164 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, item.emoji, {
4165 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
4166 });
4167 if (applied) setComposerHighlightedItemId(null);
4168 return;
4169 }
41234170 if (item.type === "path") {
41244171 const replacement = `${serializeComposerFileLink(item.path)} `;
41254172 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
......@@ -4596,7 +4643,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
45964643 // ------------------------------------------------------------------
45974644 // Callbacks: command key
45984645 // ------------------------------------------------------------------
4599 const onComposerCommandKey = (key: string, event: KeyboardEvent, isTaskItem = false) => {
4646 const onComposerCommandKey = (key: string, event: KeyboardEvent) => {
4647 if (event.isComposing || event.keyCode === 229) return false;
4648 const { trigger } = resolveActiveComposerTrigger();
4649 if (trigger?.kind === "emoji") {
4650 const action = composerEmojiKeyAction(event);
4651 if (action === "select") {
4652 const selectedItem = activeComposerMenuItemRef.current;
4653 if (selectedItem?.type === "emoji") onSelectComposerItem(selectedItem);
4654 } else if (action === "next" || action === "previous") {
4655 nudgeComposerMenuHighlight(action === "next" ? "ArrowDown" : "ArrowUp");
4656 } else if (action === "dismiss") {
4657 dismissComposerTrigger(trigger);
4658 composerMenuOpenRef.current = false;
4659 }
4660 if (action !== null) return true;
4661 }
46004662 const submissionIntent = composerSubmissionIntentForKey({
46014663 event,
46024664 keybindings,
......@@ -4611,16 +4673,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
46114673 toggleInteractionMode();
46124674 return true;
46134675 }
4614 const { trigger } = resolveActiveComposerTrigger();
46154676 const menuIsActive =
46164677 !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null);
46174678 if (key === "Escape") {
4618 if (!menuIsActive || event.isComposing || event.keyCode === 229) return false;
4679 if (!menuIsActive) return false;
46194680 dismissComposerTrigger(trigger);
46204681 composerMenuOpenRef.current = false;
46214682 return true;
46224683 }
4623 if (menuIsActive && (submissionIntent === null || submissionIntent === "foreground")) {
4684 if (
4685 menuIsActive &&
4686 trigger?.kind !== "emoji" &&
4687 (submissionIntent === null || submissionIntent === "foreground")
4688 ) {
46244689 const currentItems = composerMenuItemsRef.current;
46254690 const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0];
46264691 if (key === "ArrowDown" && currentItems.length > 0) {
......@@ -4651,35 +4716,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
46514716 );
46524717 return true;
46534718 }
4654 // Native task splitting preserves marks and chips on both sides of the caret.
4655 if (key === "Enter" && isTaskItem) return false;
4656 if (
4657 !isLiteralPendingAnswer &&
4658 !event.isComposing &&
4659 (key === "Enter" || (key === "Tab" && !event.shiftKey))
4660 ) {
4661 const selection = composerEditorRef.current?.readSelectionRange();
4662 const snapshot = readComposerSnapshot();
4663 if (selection && selection.start === selection.end && snapshot.value === promptRef.current) {
4664 const edit =
4665 key === "Enter"
4666 ? listContinuationForEnter(snapshot.value, selection.start)
4667 : listIndentForTab(snapshot.value, selection.start, selection.end);
4668 if (
4669 edit &&
4670 applyPromptReplacement(
4671 edit.start,
4672 edit.end,
4673 edit.replacement,
4674 key === "Tab"
4675 ? { expandedCursorAfterReplace: selection.start + edit.replacement.length }
4676 : undefined,
4677 )
4678 ) {
4679 return true;
4680 }
4681 }
4682 }
46834719 return false;
46844720 };
46854721
......@@ -6240,11 +6276,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
62406276 // composer is at its attachment limit: `addComposerAttachments` surfaces
62416277 // those as a toast and a thread error. An early return here would swallow
62426278 // the paste with no feedback.
6243 if (
6244 files.length > 0 &&
6245 activeThreadId &&
6246 shouldHandleComposerAttachmentPaste({ files, plainText })
6247 ) {
6279 if (files.length > 0 && shouldHandleComposerAttachmentPaste({ files, plainText })) {
62486280 event.preventDefault();
62496281 event.stopPropagation();
62506282 void addComposerAttachments(files, { skipImageInlineChip: bypassAutoAttachment });
......@@ -7225,9 +7257,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
72257257 <div role="status" aria-atomic="true" className="sr-only">
72267258 {composerSuggestionsVisible && composerMenuItems.length === 0
72277259 ? isComposerMenuLoading
7228 ? composerTriggerKind === "pull-request"
7229 ? "Finding pull request..."
7230 : "Searching workspace files..."
7260 ? composerTriggerKind === "emoji"
7261 ? "Loading emoji…"
7262 : composerTriggerKind === "pull-request"
7263 ? "Finding pull request..."
7264 : "Searching workspace files..."
72317265 : composerMenuEmptyState
72327266 : ""}
72337267 </div>
......@@ -7657,6 +7691,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
76577691 ) : null}
76587692 <ComposerContextActionsContext value={composerContextActions}>
76597693 <ComposerPromptEditor
7694 key={`${composerSuggestionListId}:${activePendingProgress?.activeQuestion?.id ?? "thread"}`}
76607695 ariaLabel="Message"
76617696 suggestionListId={composerSuggestionListId}
76627697 activeSuggestionId={
......@@ -7670,9 +7705,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
76707705 editorRef={composerEditorRef}
76717706 richTextEnabled={settings.composerRichTextEnabled}
76727707 literalText={isLiteralPendingAnswer}
7673 isPathQueryActive={() =>
7674 resolveActiveComposerTrigger().trigger?.kind === "path"
7675 }
76767708 value={
76777709 isComposerApprovalState
76787710 ? ""
apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx deleted-67
......@@ -1,67 +0,0 @@
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/chat/ComposerCommandMenu.tsx+21-5
......@@ -31,6 +31,14 @@ import { ComposerBanner } from "./ComposerBanner";
3131import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation";
3232
3333export type ComposerCommandItem =
34 | {
35 id: string;
36 type: "emoji";
37 emoji: string;
38 query: string;
39 label: string;
40 description: string;
41 }
3442 | {
3543 id: string;
3644 type: "path";
......@@ -138,11 +146,13 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
138146 <div className="px-5 pt-3.5 pb-7">
139147 <p className="text-secondary-label text-xs">
140148 {props.isLoading
141 ? props.triggerKind === "skill"
142 ? "Searching workspace skills..."
143 : props.triggerKind === "pull-request"
144 ? "Finding pull request..."
145 : "Searching workspace files..."
149 ? props.triggerKind === "emoji"
150 ? "Loading emoji…"
151 : props.triggerKind === "skill"
152 ? "Searching workspace skills..."
153 : props.triggerKind === "pull-request"
154 ? "Finding pull request..."
155 : "Searching workspace files..."
146156 : (props.emptyStateText ??
147157 (props.triggerKind === "skill"
148158 ? "No skills found. Try / to browse provider commands."
......@@ -190,6 +200,11 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
190200 props.onSelect(props.item);
191201 }}
192202 >
203 {props.item.type === "emoji" ? (
204 <span aria-hidden="true" className="w-5 shrink-0 text-center text-lg leading-none">
205 {props.item.emoji}
206 </span>
207 ) : null}
193208 {props.item.type === "path" ? (
194209 <PierreEntryIcon
195210 pathValue={props.item.path}
......@@ -242,6 +257,7 @@ const LISTBOX_LABEL_BY_TRIGGER: Record<ComposerTriggerKind, string> = {
242257 "pull-request": "Pull requests",
243258 "slash-command": "Commands",
244259 skill: "Skills",
260 emoji: "Emoji",
245261};
246262
247263const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {
apps/web/src/components/chat/ExpandedImageDialog.tsx+1
......@@ -65,6 +65,7 @@ function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) {
6565 videoClassName="aspect-auto max-h-[var(--media-height)] w-auto max-w-[var(--media-width)] rounded-lg border border-border/70 shadow-2xl"
6666 stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME}
6767 onRetry={asset ? refreshAssetUrl : undefined}
68 actionsSource={item.actionsSource}
6869 />
6970 );
7071}
apps/web/src/components/chat/FileTagChip.tsx+2-1
......@@ -1,3 +1,4 @@
1import type { ReactNode } from "react";
12import type { EnvironmentId } from "@t3tools/contracts";
23import { ContextChipLabel } from "../ContextChip";
34import { WorkspaceEntryIcon } from "./WorkspaceEntryIcon";
......@@ -5,7 +6,7 @@ import { WorkspaceEntryIcon } from "./WorkspaceEntryIcon";
56/** Icon and label for a file mention; render inside `<ContextChip kind="mention">`. */
67export function FileTagChipContent(props: {
78 path: string;
8 label: string;
9 label: ReactNode;
910 theme: "light" | "dark";
1011 environmentId?: EnvironmentId | null | undefined;
1112 cwd?: string | undefined;
apps/web/src/components/chat/HtmlRenderFrame.tsx+9-4
......@@ -3,6 +3,7 @@ import {
33 HTML_RENDER_COLUMN_WIDTH,
44 htmlRenderFileName,
55 htmlRenderFrameHeight,
6 readHtmlRenderContentSize,
67 type HtmlRenderReference,
78} from "@t3tools/shared/htmlRender";
89import { Maximize2Icon } from "lucide-react";
......@@ -47,8 +48,8 @@ export function HtmlRenderFrame(props: {
4748 }, []);
4849 // Client fonts can wrap a page taller than the server measured it; a frame
4950 // left short would scroll inside the thread and take the reader's scroll.
50 const [contentHeight, setContentHeight] = useState<number>();
51 const height = htmlRenderFrameHeight(props.htmlRender, width, contentHeight);
51 const [contentSize, setContentSize] = useState<ReturnType<typeof readHtmlRenderContentSize>>();
52 const height = htmlRenderFrameHeight(props.htmlRender, width, contentSize?.height);
5253 const fileName = htmlRenderFileName(title);
5354 const resource = useMemo(
5455 () => ({
......@@ -100,7 +101,11 @@ export function HtmlRenderFrame(props: {
100101 }, [cacheFailed, cachedExpiresAt, cachedUrl, refresh, src]);
101102
102103 return (
103 <div ref={boxRef} className="group/html-render relative" style={{ height }}>
104 <div
105 ref={boxRef}
106 className="group/html-render relative"
107 style={{ height, width: contentSize?.width }}
108 >
104109 {src !== null ? (
105110 <>
106111 <HtmlRenderDocument
......@@ -108,7 +113,7 @@ export function HtmlRenderFrame(props: {
108113 title={title}
109114 threadRef={props.threadRef}
110115 className="block size-full"
111 onContentHeight={setContentHeight}
116 onContentSize={setContentSize}
112117 />
113118 <div className="absolute end-2 top-2 flex gap-1 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover/html-render:opacity-100 pointer-coarse:opacity-100">
114119 <Tooltip>
apps/web/src/components/chat/MermaidDiagram.tsx+8-6
......@@ -47,13 +47,20 @@ function sanitizeMermaidSvg(svg: string): string {
4747 data.attrValue = data.attrValue.replace(REMOTE_CSS_URL, "none");
4848 });
4949 }
50 return purifier.sanitize(svg, {
50 const sanitized = purifier.sanitize(svg, {
5151 ADD_TAGS: ["foreignObject"],
5252 HTML_INTEGRATION_POINTS: { foreignobject: true },
5353 FORBID_ATTR: ["href", "xlink:href", "src", "srcset"],
5454 FORBID_TAGS: ["a", "img", "image", "script"],
5555 USE_PROFILES: { svg: true, svgFilters: true, html: true },
5656 });
57 const element = new DOMParser().parseFromString(sanitized, "image/svg+xml").documentElement;
58 const viewBox = element.getAttribute("viewBox")?.trim().split(/\s+/).map(Number);
59 if (viewBox?.length === 4 && viewBox.every(Number.isFinite)) {
60 element.setAttribute("width", String(viewBox[2]));
61 element.setAttribute("height", String(viewBox[3]));
62 }
63 return new XMLSerializer().serializeToString(element);
5764}
5865
5966// Mermaid also lazy-loads diagram chunks inside render(); losing the network
......@@ -145,11 +152,6 @@ let expandedImageUrl: string | null = null;
145152function mermaidImageUrl(svg: string): string {
146153 const svgDocument = new DOMParser().parseFromString(svg, "image/svg+xml");
147154 const element = svgDocument.documentElement;
148 const viewBox = element.getAttribute("viewBox")?.trim().split(/\s+/).map(Number);
149 if (viewBox?.length === 4 && viewBox.every(Number.isFinite)) {
150 element.setAttribute("width", String(viewBox[2]));
151 element.setAttribute("height", String(viewBox[3]));
152 }
153155 element.setAttribute("xmlns", "http://www.w3.org/2000/svg");
154156 element.style.maxWidth = "none";
155157 element.style.backgroundColor = getComputedStyle(document.body).backgroundColor;
apps/web/src/components/chat/MessagesTimeline.tsx+25-12
......@@ -1582,7 +1582,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
15821582 const renderItem = useCallback(
15831583 ({ item }: { item: MessagesTimelineRow }) => (
15841584 <div className="messages-timeline-row-frame">
1585 <div className="chat-content-lane overflow-x-clip" data-timeline-root="true">
1585 <div className="chat-content-lane" data-timeline-root="true">
15861586 <TimelineRowContent row={item} />
15871587 </div>
15881588 </div>
......@@ -2836,7 +2836,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
28362836
28372837 return (
28382838 <>
2839 <div className="relative min-w-0 px-1 py-0.5">
2839 <div className="chat-transcript-blocks relative min-w-0 px-1 py-0.5">
28402840 <MessageAuthorHeading>The Knife</MessageAuthorHeading>
28412841 <div data-thread-find-text="true">
28422842 <AssistantCitationSource
......@@ -3051,7 +3051,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm
30513051 );
30523052
30533053 return (
3054 <div className="min-w-0 px-1">
3054 <div className="chat-transcript-figure min-w-0">
30553055 <HtmlRenderFrame
30563056 // A recycled row must not keep another page's frozen frame.
30573057 key={row.htmlRender.attachmentId}
......@@ -3916,7 +3916,10 @@ function LiveActivityRow({
39163916 return (
39173917 <div
39183918 ref={animated ? observeVisibleAnimation : undefined}
3919 className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed"
3919 className={cn(
3920 "relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed",
3921 animated && "live-activity-running",
3922 )}
39203923 >
39213924 <LiveActivityContent
39223925 label={label}
......@@ -3975,7 +3978,7 @@ function LiveActivityContent({
39753978 icon={toolIcon}
39763979 fallbackName={iconName}
39773980 className="block size-4 shrink-0 stroke-2"
3978 muted={!highlighted}
3981 muted={!highlighted && !active}
39793982 />
39803983 </span>
39813984 ) : null
......@@ -5488,6 +5491,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowP
54885491function WorkEntryLogRow(props: WorkEntryRowProps) {
54895492 const { workEntry, workspaceRoot, displayLabel } = props;
54905493 const ctx = use(TimelineRowCtx);
5494 const { isWorking, latestRunId } = use(TimelineRowActivityCtx);
54915495 const { threadRef, onImageExpand, timestampFormat } = ctx;
54925496 const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx;
54935497 const threadTarget = useThreadReadTarget(workEntry, ctx.activeThreadEnvironmentId);
......@@ -5582,6 +5586,11 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
55825586 !showWarningIndicator &&
55835587 showFailedIndicator &&
55845588 (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry));
5589 const active =
5590 isWorking &&
5591 workEntry.runId === latestRunId &&
5592 workEntry.toolLifecycleStatus === "inProgress" &&
5593 !showFailedIndicator;
55855594 const entryIconName =
55865595 showWarningIndicator || showDestructiveRowStyle ? "circle-alert" : workEntryIconName(workEntry);
55875596 const entryToolIcon =
......@@ -5675,17 +5684,21 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
56755684 ? "text-destructive"
56765685 : showFailedIndicator
56775686 ? failedToolIconClassName
5678 : workEntry.tone === "tool"
5679 ? "text-icon-muted"
5680 : iconConfig.className,
5687 : active
5688 ? "text-(--activity-foreground)"
5689 : workEntry.tone === "tool"
5690 ? "text-icon-muted"
5691 : iconConfig.className,
56815692 );
56825693 const headingClass = showWarningIndicator
56835694 ? "font-medium text-warning"
56845695 : showDestructiveRowStyle
56855696 ? "font-medium text-destructive"
5686 : workLogEntryIsToolLike(workEntry)
5687 ? "text-secondary-label"
5688 : "text-foreground/80";
5697 : active
5698 ? "text-(--activity-foreground)"
5699 : workLogEntryIsToolLike(workEntry)
5700 ? "text-secondary-label"
5701 : "text-foreground/80";
56895702 const threadLabel = isReasoning ? null : threadReadLabel(previewText, threadTarget);
56905703 const accessiblePreview = [threadLabel?.text ?? previewText, answerPreview]
56915704 .filter(Boolean)
......@@ -5724,7 +5737,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
57245737 icon={entryToolIcon}
57255738 fallbackName={entryIconName}
57265739 className="block size-4 shrink-0 stroke-2"
5727 muted
5740 muted={!active}
57285741 />
57295742 </span>
57305743 }
apps/web/src/components/chat/composerEmojiSearch.test.ts created+29
......@@ -0,0 +1,29 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { searchComposerEmoji } from "./composerEmojiSearch";
4
5describe("composer emoji search", () => {
6 it("puts the exact shortcode ahead of longer matching names", () => {
7 const matches = searchComposerEmoji("dizzy");
8 expect(matches[0]).toMatchObject({ type: "emoji", emoji: "💫", label: ":dizzy:" });
9 expect(matches.some((entry) => entry.label === ":dizzy_face:")).toBe(true);
10 });
11
12 it("finds canonical aliases, tags, descriptions, and multi-codepoint emoji", () => {
13 expect(searchComposerEmoji("+1")[0]).toMatchObject({ emoji: "👍", label: ":+1:" });
14 expect(searchComposerEmoji("happy").length).toBeGreaterThan(0);
15 expect(searchComposerEmoji("rainbow_flag")[0]).toMatchObject({ emoji: "🏳️‍🌈" });
16 expect(
17 searchComposerEmoji("scientist").some((entry) => entry.label === ":woman_scientist:"),
18 ).toBe(true);
19 });
20
21 it("normalizes uppercase, caps broad searches, and keeps emoji results unique", () => {
22 const matches = searchComposerEmoji("a");
23 expect(matches).toHaveLength(30);
24 expect(new Set(matches.map((entry) => entry.id)).size).toBe(matches.length);
25 expect(searchComposerEmoji("DIZZY")).toEqual(searchComposerEmoji("dizzy"));
26 expect(searchComposerEmoji("")).toEqual([]);
27 expect(searchComposerEmoji("no_such_emoji_999")).toEqual([]);
28 });
29});
apps/web/src/components/chat/composerEmojiSearch.ts created+40
......@@ -0,0 +1,40 @@
1import { gemoji } from "gemoji";
2
3import type { ComposerCommandItem } from "./ComposerCommandMenu";
4
5export function searchComposerEmoji(query: string): ComposerCommandItem[] {
6 const normalized = query.toLowerCase();
7 if (!normalized) return [];
8
9 return gemoji
10 .flatMap((entry) => {
11 const name =
12 entry.names.find((name) => name === normalized) ??
13 entry.names.find((name) => name.startsWith(normalized)) ??
14 entry.names.find((name) => name.includes(normalized));
15 const rank = name
16 ? name === normalized
17 ? 0
18 : name.startsWith(normalized)
19 ? 1
20 : 2
21 : entry.tags.some((tag) => tag.includes(normalized))
22 ? 3
23 : entry.description.toLowerCase().includes(normalized.replaceAll("_", " "))
24 ? 4
25 : null;
26 if (rank === null) return [];
27
28 return [{ entry, name: name ?? entry.names[0]!, rank }];
29 })
30 .toSorted((a, b) => a.rank - b.rank || a.name.localeCompare(b.name))
31 .slice(0, 30)
32 .map(({ entry, name }) => ({
33 id: `emoji:${entry.emoji}`,
34 type: "emoji",
35 emoji: entry.emoji,
36 query: normalized,
37 label: `:${name}:`,
38 description: entry.description,
39 }));
40}
apps/web/src/components/chat/composerMenuHighlight.test.ts+39-1
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
3import { composerEmojiKeyAction, resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
44
55describe("resolveComposerMenuActiveItemId", () => {
66 const items = [{ id: "top" }, { id: "second" }, { id: "third" }] as const;
......@@ -66,3 +66,41 @@ describe("resolveComposerMenuActiveItemId", () => {
6666 ).toBe("new-result");
6767 });
6868});
69
70describe("emoji autocomplete keys", () => {
71 const key = (key: string, overrides: Partial<KeyboardEvent> = {}) => ({
72 key,
73 shiftKey: false,
74 altKey: false,
75 metaKey: false,
76 ctrlKey: false,
77 isComposing: false,
78 keyCode: 0,
79 ...overrides,
80 });
81
82 it.each([
83 ["Enter", "select"],
84 ["Tab", "select"],
85 ["ArrowDown", "next"],
86 ["ArrowUp", "previous"],
87 ["Escape", "dismiss"],
88 ] as const)("claims %s without allowing submission or moving editor focus", (input, action) =>
89 expect(composerEmojiKeyAction(key(input))).toBe(action),
90 );
91
92 it("leaves newline, focus navigation, send chords, and ordinary typing alone", () => {
93 expect(composerEmojiKeyAction(key("Enter", { shiftKey: true }))).toBeNull();
94 expect(composerEmojiKeyAction(key("Tab", { shiftKey: true }))).toBeNull();
95 expect(composerEmojiKeyAction(key("Enter", { metaKey: true }))).toBeNull();
96 expect(composerEmojiKeyAction(key("Enter", { ctrlKey: true }))).toBeNull();
97 expect(composerEmojiKeyAction(key("z"))).toBeNull();
98 });
99
100 it("never selects or dismisses during IME composition", () => {
101 for (const input of ["Enter", "Tab", "ArrowDown", "ArrowUp", "Escape"]) {
102 expect(composerEmojiKeyAction(key(input, { isComposing: true }))).toBeNull();
103 expect(composerEmojiKeyAction(key(input, { keyCode: 229 }))).toBeNull();
104 }
105 });
106});
apps/web/src/components/chat/composerMenuHighlight.ts+15
......@@ -18,3 +18,18 @@ export function resolveComposerMenuActiveItemId(input: {
1818
1919 return input.items[0]?.id ?? null;
2020}
21
22export function composerEmojiKeyAction(
23 event: Pick<
24 KeyboardEvent,
25 "key" | "shiftKey" | "altKey" | "metaKey" | "ctrlKey" | "isComposing" | "keyCode"
26 >,
27): "select" | "next" | "previous" | "dismiss" | null {
28 if (event.isComposing || event.keyCode === 229) return null;
29 if (event.key === "Escape") return "dismiss";
30 if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) return null;
31 if (event.key === "Enter" || event.key === "Tab") return "select";
32 if (event.key === "ArrowDown") return "next";
33 if (event.key === "ArrowUp") return "previous";
34 return null;
35}
apps/web/src/components/chat/useComposerTriggerState.test.tsx+1-1
......@@ -133,7 +133,7 @@ describe("composer suggestion dismissal", () => {
133133 expect(composer.resolveTrigger(candidate)).toEqual(candidate);
134134 });
135135
136 it.each(["/plan", "$skill", "#123"])("also dismisses %s suggestions", async (text) => {
136 it.each(["/plan", "$skill", "#123", ":dizzy"])("also dismisses %s suggestions", async (text) => {
137137 await updatePrompt(text);
138138 await act(() => composer.dismissTrigger(composer.trigger));
139139 await updatePrompt(`${text}x`);
apps/web/src/components/composerCodeBlockHighlight.test.ts+87-139
......@@ -1,152 +1,100 @@
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));
1// @vitest-environment jsdom
612
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];
3import { StateEffect } from "@codemirror/state";
4import { EditorView } from "@codemirror/view";
5import { afterEach, describe, expect, it, vi } from "vite-plus/test";
816
82 expect(tokenizeBlock(throwing, "const answer = 42", "nope", THEME)).toEqual([]);
7const tokenization = vi.hoisted(() => {
8 let resolve: (value: {
9 codeToTokens: (code: string) => { tokens: Array<Array<{ content: string; color: string }>> };
10 }) => void;
11 const ready = new Promise<{
12 codeToTokens: (code: string) => { tokens: Array<Array<{ content: string; color: string }>> };
13 }>((done) => {
14 resolve = done;
8315 });
16 return { ready, resolve: (value: Parameters<typeof resolve>[0]) => resolve(value) };
8417});
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);
18vi.mock("~/lib/syntaxHighlighting", () => ({
19 getSyntaxHighlighterPromise: () => tokenization.ready,
20}));
21
22import { createComposerMarkdownEditor } from "./composerMarkdownEditor";
23import { composerCodeBlocks, composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
24import type { ComposerPromptEditorProps } from "./ComposerPromptEditor";
25
26let editor: ReturnType<typeof createComposerMarkdownEditor>;
27afterEach(() => {
28 editor?.destroy();
29 editor?.dom.remove();
30});
31function mount(value: string) {
32 const props: ComposerPromptEditorProps = {
33 value,
34 cursor: value.length,
35 contextRecords: new Map(),
36 skills: [],
37 disabled: false,
38 richTextEnabled: true,
39 placeholder: "Message",
40 editorRef: { current: null },
41 onChange: () => {},
42 onPaste: () => {},
43 };
44 editor = createComposerMarkdownEditor({
45 props: () => props,
46 mountChip: () => {},
47 unmountChip: () => {},
9148 });
49 document.body.append(editor.dom);
50 return editor;
51}
9252
93 it("leaves a fence past the cap plain", () => {
94 expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH + 1))).toBe(false);
53it("lands an empty fence's language selection in its body rather than inside the info string", () => {
54 mount("```ts\n\n```");
55 const block = composerCodeBlocks(editor.view.state)[0]!;
56 expect(editor.view.state.doc.lineAt(block.codeTo).number).toBe(2);
57 editor.view.dispatch({
58 changes: { from: block.infoFrom, to: block.infoTo, insert: "python" },
59 selection: { anchor: block.codeTo + 4 },
9560 });
61 expect(editor.getValue()).toBe("```python\n\n```");
62 expect(editor.view.state.doc.lineAt(editor.view.state.selection.main.head).number).toBe(2);
9663});
9764
98describe("a highlighter that fails to load", () => {
99 afterEach(() => {
100 vi.unstubAllGlobals();
101 });
65it("keeps the trailing line of an unfinished fence as part of its code body", () => {
66 mount("```ts\nunfinished\n");
67 const block = composerCodeBlocks(editor.view.state)[0]!;
68 expect(block.code).toBe("unfinished\n");
69 expect(block.codeTo).toBe(editor.view.state.doc.length);
70});
10271
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);
72describe("asynchronous code highlighting", () => {
73 it("paints current text after initialization and never reintroduces an edited-away tokenization", async () => {
74 mount("```ts\nold\n```");
75 editor.setValue("```ts\nnew\n```");
76 const settled = new Promise<void>((resolve) => {
77 editor.view.dispatch({
78 effects: StateEffect.appendConfig.of(
79 EditorView.updateListener.of(() => {
80 if ((editor.view.plugin(composerCodeBlockHighlight)?.decorations.size ?? 0) > 0)
81 resolve();
82 }),
83 ),
84 });
14885 });
149
150 await expect(repainted).resolves.toBeUndefined();
86 const codeToTokens = vi.fn((code: string) => ({
87 tokens: [[{ content: code, color: "#123456" }]],
88 }));
89 tokenization.resolve({ codeToTokens });
90 await settled;
91 expect(codeToTokens.mock.calls.map(([code]) => code)).not.toContain("old");
92 expect(codeToTokens.mock.calls.map(([code]) => code)).toContain("new");
93 expect(editor.getValue()).toBe("```ts\nnew\n```");
94 expect(
95 [...editor.dom.querySelectorAll<HTMLElement>("span")].find(
96 (span) => span.style.color === "rgb(18, 52, 86)",
97 )?.textContent,
98 ).toBe("new");
15199 });
152100});
apps/web/src/components/composerCodeBlockHighlight.ts+143-219
......@@ -1,231 +1,155 @@
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";
1import { syntaxTree } from "@codemirror/language";
2import { type EditorState, StateEffect } from "@codemirror/state";
3import {
4 Decoration,
5 type DecorationSet,
6 EditorView,
7 ViewPlugin,
8 type ViewUpdate,
9} from "@codemirror/view";
510
6import type { DiffThemeName } from "~/lib/diffRendering";
711import { languageOfInfoString } from "~/composer-code-languages";
12import { resolveDiffThemeName } from "~/lib/diffRendering";
813import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting";
914
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 });
15export function composerCodeBlocks(state: EditorState) {
16 const blocks: Array<{
17 from: number;
18 infoFrom: number;
19 infoTo: number;
20 info: string;
21 codeFrom: number;
22 codeTo: number;
23 code: string;
24 }> = [];
25 syntaxTree(state).iterate({
26 enter(node) {
27 if (node.name !== "FencedCode") return undefined;
28 const info = node.node.getChild("CodeInfo");
29 const mark = node.node.getChild("CodeMark");
30 if (!mark) return false;
31 const code = node.node.getChild("CodeText");
32 const openingLine = state.doc.lineAt(node.from);
33 const bodyFrom =
34 openingLine.number < state.doc.lines
35 ? state.doc.line(openingLine.number + 1).from
36 : openingLine.to;
37 const codeFrom = code?.from ?? bodyFrom;
38 const codeTo =
39 code &&
40 node.node.getChildren("CodeMark").length > 1 &&
41 state.sliceDoc(code.to - 1, code.to) === "\n"
42 ? Math.max(codeFrom, code.to - 1)
43 : (code?.to ?? bodyFrom);
44 blocks.push({
45 from: node.from,
46 infoFrom: info?.from ?? mark.to,
47 infoTo: info?.to ?? mark.to,
48 info: info ? state.sliceDoc(info.from, info.to) : "",
49 codeFrom,
50 codeTo,
51 code: state.sliceDoc(codeFrom, codeTo),
52 });
53 return false;
54 },
4455 });
4556 return blocks;
4657}
4758
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),
59export const composerCodeBlockHighlight = ViewPlugin.fromClass(
60 class {
61 decorations: DecorationSet = Decoration.none;
62 readonly cache = new Map<string, readonly { from: number; to: number; color: string }[]>();
63 readonly repaint = StateEffect.define<null>();
64 readonly observer: MutationObserver;
65 disposed = false;
66 live = new Set<string>();
67 doc: EditorState["doc"] | null = null;
68 tree: ReturnType<typeof syntaxTree> | null = null;
69 theme = "";
70
71 constructor(readonly view: EditorView) {
72 this.observer = new MutationObserver(() => view.dispatch({ effects: this.repaint.of(null) }));
73 this.observer.observe(document.documentElement, {
74 attributeFilter: ["class", "data-theme-id"],
75 });
76 this.refresh();
77 }
78 update(update: ViewUpdate) {
79 if (
80 update.docChanged ||
81 syntaxTree(update.state) !== this.tree ||
82 update.transactions.some((transaction) =>
83 transaction.effects.some((effect) => effect.is(this.repaint)),
84 )
85 )
86 this.refresh();
87 }
88 refresh() {
89 const state = this.view.state;
90 const theme = resolveDiffThemeName(
91 document.documentElement.classList.contains("dark") ? "dark" : "light",
92 );
93 const blocks = composerCodeBlocks(state);
94 const signature = (block: (typeof blocks)[number]) =>
95 `${theme}\u0000${block.info}\u0000${block.code}`;
96 this.live = new Set(blocks.map(signature));
97 for (const key of this.cache.keys()) if (!this.live.has(key)) this.cache.delete(key);
98 this.decorations = Decoration.set(
99 blocks.flatMap((block) =>
100 (this.cache.get(signature(block)) ?? []).map((span) =>
101 Decoration.mark({ attributes: { style: `color: ${span.color}` } }).range(
102 block.codeFrom + span.from,
103 block.codeFrom + span.to,
104 ),
105 ),
106 ),
107 true,
108 );
109 if (this.doc === state.doc && this.tree === syntaxTree(state) && this.theme === theme) return;
110 this.doc = state.doc;
111 this.tree = syntaxTree(state);
112 this.theme = theme;
113 const pending = blocks.filter(
114 (block) => block.code.length <= 20_000 && !this.cache.has(signature(block)),
115 );
116 if (pending.length === 0) return;
117 void Promise.allSettled(
118 pending.map(async (block) => {
119 const key = signature(block);
120 const language = languageOfInfoString(block.info) || "text";
121 const highlighter = await getSyntaxHighlighterPromise(language);
122 if (this.disposed || this.cache.has(key) || !this.live.has(key)) return;
123 const spans: Array<{ from: number; to: number; color: string }> = [];
124 try {
125 let offset = 0;
126 for (const [index, line] of highlighter
127 .codeToTokens(block.code, { lang: language, theme })
128 .tokens.entries()) {
129 if (index > 0) offset += block.code.startsWith("\r\n", offset) ? 2 : 1;
130 for (const token of line) {
131 if (token.color && token.content.trim())
132 spans.push({
133 from: offset,
134 to: offset + token.content.length,
135 color: token.color,
142136 });
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 },
137 offset += token.content.length;
138 }
139 }
140 } catch {
141 /* Unsupported languages retain source text. */
142 }
143 this.cache.set(key, spans);
166144 }),
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;
145 ).then(() => {
146 if (!this.disposed) this.view.dispatch({ effects: this.repaint.of(null) });
147 });
200148 }
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 );
149 destroy() {
150 this.disposed = true;
151 this.observer.disconnect();
228152 }
229 }
230 return DecorationSet.create(state.doc, decorations);
231}
153 },
154 { decorations: (plugin) => plugin.decorations },
155);
apps/web/src/components/composerMarkdownEditor.test.ts created+412
......@@ -0,0 +1,412 @@
1// @vitest-environment jsdom
2
3import { undo, redo } from "@codemirror/commands";
4import { Transaction } from "@codemirror/state";
5import { ComposerContextId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
6import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
7import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
8import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
9import {
10 COMPOSER_CONTEXT_CLIPBOARD_MIME,
11 encodeComposerContextFragment,
12} from "@t3tools/shared/composerContextClipboard";
13import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
14import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
15
16import { detectComposerTrigger, composerSubmissionIntentForKey } from "~/composer-logic";
17import { composerEmojiKeyAction } from "./chat/composerMenuHighlight";
18import {
19 composerSuggestionsSuppressed,
20 composerTokens,
21 createComposerMarkdownEditor,
22} from "./composerMarkdownEditor";
23import type { ComposerPromptEditorProps } from "./ComposerPromptEditor";
24
25let editor: ReturnType<typeof createComposerMarkdownEditor>;
26let props: ComposerPromptEditorProps;
27let sends: ReturnType<typeof vi.fn<(intent: string) => void>>;
28
29beforeEach(() => {
30 Object.defineProperty(Range.prototype, "getClientRects", { configurable: true, value: () => [] });
31 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
32 configurable: true,
33 value: () => new DOMRect(),
34 });
35 props = {
36 value: "",
37 cursor: 0,
38 contextRecords: new Map(),
39 skills: [],
40 disabled: false,
41 placeholder: "Message",
42 editorRef: { current: null },
43 onPaste: () => {},
44 onChange: (value, cursor) => {
45 props = { ...props, value, cursor };
46 },
47 };
48 sends = vi.fn<(intent: string) => void>();
49});
50afterEach(() => {
51 editor?.destroy();
52 editor?.dom.remove();
53});
54
55function mount(value = "", overrides: Partial<ComposerPromptEditorProps> = {}) {
56 props = { ...props, ...overrides, value, cursor: value.length };
57 editor = createComposerMarkdownEditor({
58 props: () => props,
59 mountChip: () => {},
60 unmountChip: () => {},
61 });
62 document.body.append(editor.dom);
63 editor.focus();
64 return editor;
65}
66function key(key: string, init: KeyboardEventInit = {}) {
67 const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
68 editor.view.contentDOM.dispatchEvent(event);
69 return event;
70}
71function submission(key: string, event: KeyboardEvent) {
72 const snapshot = editor.snapshot();
73 const trigger = composerSuggestionsSuppressed(editor.view, props.literalText ?? false)
74 ? null
75 : detectComposerTrigger(snapshot.value, snapshot.expandedCursor);
76 if (trigger?.kind === "emoji" && composerEmojiKeyAction(event) === "select") {
77 editor.replaceText("💫", { from: trigger.rangeStart, to: trigger.rangeEnd });
78 return true;
79 }
80 const intent = composerSubmissionIntentForKey({
81 event,
82 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
83 platform: "Linux",
84 isMobileViewport: false,
85 isDraftThread: false,
86 prompt: snapshot.value,
87 });
88 if (intent) {
89 sends(intent);
90 return true;
91 }
92 return false;
93}
94
95describe("CodeMirror composer behavior", () => {
96 it("keeps the preceding paragraph plain while starting a dash list", () => {
97 const intro = "tui up. this is better. my overall points";
98 mount(intro, { richTextEnabled: true });
99 key("Enter", { shiftKey: true });
100 editor.view.dispatch({ ...editor.view.state.replaceSelection("-"), userEvent: "input" });
101
102 const line = editor.dom.querySelector(".cm-line")!;
103 const text = line.firstElementChild ?? line;
104 expect(getComputedStyle(text).textDecoration).not.toContain("underline");
105 expect(["bold", "700"]).not.toContain(getComputedStyle(text).fontWeight);
106 expect(editor.getValue()).toBe(`${intro}\n-`);
107
108 editor.view.dispatch({
109 ...editor.view.state.replaceSelection(" first point"),
110 userEvent: "input",
111 });
112 key("Enter", { shiftKey: true });
113 expect(editor.getValue()).toBe(`${intro}\n- first point\n- `);
114 });
115
116 it("keeps explicit hash headings formatted", () => {
117 const value = "# An explicit heading\n\nPlain text";
118 mount(value, { richTextEnabled: true });
119 expect(editor.dom.querySelector(".cm-lp-h1")?.textContent).toBe("An explicit heading");
120 expect(editor.getValue()).toBe(value);
121 });
122
123 it.each(["-", "="])("formats an underline heading only after the third %s", (marker) => {
124 const intro = "A heading when the underline is deliberate";
125 mount(`${intro}\n`, { richTextEnabled: true });
126
127 for (let count = 1; count <= 4; count++) {
128 editor.view.dispatch({ ...editor.view.state.replaceSelection(marker), userEvent: "input" });
129
130 const line = editor.dom.querySelector(".cm-line")!;
131 const text = line.firstElementChild ?? line;
132 const style = getComputedStyle(text);
133 expect(style.textDecoration.includes("underline")).toBe(count >= 3);
134 expect(["bold", "700"].includes(style.fontWeight)).toBe(count >= 3);
135 expect(editor.getValue()).toBe(`${intro}\n${marker.repeat(count)}`);
136 }
137
138 editor.view.dispatch({ changes: { from: intro.length + 3, to: editor.view.state.doc.length } });
139 const line = editor.dom.querySelector(".cm-line")!;
140 expect(getComputedStyle(line.firstElementChild ?? line).textDecoration).not.toContain(
141 "underline",
142 );
143 expect(editor.getValue()).toBe(`${intro}\n${marker.repeat(2)}`);
144 });
145
146 it("claims emoji Enter and Tab, keeps a Unicode caret, and never submits", () => {
147 mount("before :dizzy", { onCommandKeyDown: submission });
148 expect(key("Enter").defaultPrevented).toBe(true);
149 expect(editor.snapshot()).toMatchObject({
150 value: "before 💫",
151 expandedCursor: "before 💫".length,
152 });
153 expect(sends).not.toHaveBeenCalled();
154 expect(undo(editor.view)).toBe(true);
155 expect(editor.getValue()).toBe("before :dizzy");
156 expect(redo(editor.view)).toBe(true);
157 expect(editor.getValue()).toBe("before 💫");
158 editor.setValue(":dizzy");
159 editor.view.dispatch({ selection: { anchor: 6 } });
160 expect(key("Tab").defaultPrevented).toBe(true);
161 expect(editor.getValue()).toBe("💫");
162 expect(sends).not.toHaveBeenCalled();
163 });
164
165 it("respects submission outside code and inserts newlines inside fences", () => {
166 mount("hello", { onCommandKeyDown: submission });
167 key("Enter");
168 expect(sends).toHaveBeenCalledTimes(1);
169 editor.setValue("```ts\nconst x = 1");
170 editor.view.dispatch({ selection: { anchor: editor.view.state.doc.length } });
171 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(true);
172 key("Enter");
173 expect(editor.getValue()).toContain("const x = 1\n");
174 expect(sends).toHaveBeenCalledTimes(1);
175 });
176
177 it("keeps context source bytes, skips code tokens, and deletes an atomic mention in one key", () => {
178 const mention = serializeComposerFileLink("src/example.ts");
179 mount(`${mention} after`);
180 const token = composerTokens(editor.view.state, false)[0]!;
181 expect(editor.getValue()).toBe(`${mention} after`);
182 expect(editor.dom.querySelectorAll('[data-composer-chip="true"]')).toHaveLength(1);
183 editor.view.dispatch({ selection: { anchor: token.end } });
184 key("Backspace");
185 expect(editor.getValue()).toBe(" after");
186 undo(editor.view);
187 expect(editor.getValue()).toBe(`${mention} after`);
188 editor.setValue(`\` ${mention} \`\n\`\`\`\n${mention}\n\`\`\``);
189 expect(composerTokens(editor.view.state, false)).toEqual([]);
190 });
191
192 it("round-trips citation and skill context as actual atomic source ranges", () => {
193 const citation = serializeAssistantCitation({
194 version: 1,
195 environmentId: EnvironmentId.make("env"),
196 threadId: ThreadId.make("thread"),
197 messageId: MessageId.make("message"),
198 text: "Quoted text",
199 start: 0,
200 end: 11,
201 prefix: "",
202 suffix: "",
203 });
204 mount(`${citation} $browser `);
205 expect(composerTokens(editor.view.state, false).map((token) => token.type)).toEqual([
206 "citation",
207 "skill",
208 ]);
209 expect(editor.getValue()).toBe(`${citation} $browser `);
210 props = { ...props, literalText: true };
211 editor.configure();
212 expect(composerTokens(editor.view.state, true)).toEqual([]);
213 expect(editor.getValue()).toBe(`${citation} $browser `);
214 expect(editor.dom.querySelector('[data-composer-chip="true"]')).toBeNull();
215 });
216
217 it("suppresses suggestions and submission during real composition events", async () => {
218 const onChange = vi.fn(props.onChange);
219 mount(":dizzy", { onCommandKeyDown: submission, onChange });
220 editor.view.contentDOM.dispatchEvent(
221 new CompositionEvent("compositionstart", { bubbles: true }),
222 );
223 expect(onChange.mock.calls.at(-1)?.[3]).toBe(true);
224 key("Enter", { isComposing: true });
225 expect(sends).not.toHaveBeenCalled();
226 expect(editor.getValue()).toBe(":dizzy");
227 editor.view.contentDOM.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
228 await Promise.resolve();
229 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false);
230 });
231
232 it("lets native selection and copy shortcuts pass through readonly input", () => {
233 mount("readonly", { disabled: true });
234 key("a", { ctrlKey: true });
235 expect(editor.view.state.selection.main).toMatchObject({ from: 0, to: 8 });
236 expect(key("c", { ctrlKey: true }).defaultPrevented).toBe(false);
237 });
238
239 it("routes PageDown to the timeline only at the editor's scroll boundary", () => {
240 const onPageScrollKeyDown = vi.fn();
241 mount("line", { onPageScrollKeyDown });
242 Object.defineProperties(editor.view.scrollDOM, {
243 clientHeight: { configurable: true, value: 100 },
244 scrollHeight: { configurable: true, value: 300 },
245 scrollTop: { configurable: true, writable: true, value: 0 },
246 });
247 key("PageDown");
248 expect(onPageScrollKeyDown).not.toHaveBeenCalled();
249 editor.view.scrollDOM.scrollTop = 200;
250 expect(key("PageDown").defaultPrevented).toBe(true);
251 expect(onPageScrollKeyDown).toHaveBeenCalledWith("PageDown");
252 });
253
254 it("keeps readonly drafts copyable and permits only controlled replacement", () => {
255 mount("readonly", { disabled: true, onCommandKeyDown: submission });
256 key("Enter");
257 key("b", { ctrlKey: true });
258 expect(editor.getValue()).toBe("readonly");
259 expect(sends).not.toHaveBeenCalled();
260 editor.view.dispatch({
261 changes: { from: 0, to: 8, insert: "streamed" },
262 annotations: Transaction.userEvent.of("t3.controlled"),
263 });
264 expect(editor.getValue()).toBe("streamed");
265 editor.view.dispatch({ selection: { anchor: 0, head: 8 } });
266 expect(editor.view.state.selection.main.empty).toBe(false);
267 });
268
269 it("continues tasks and keeps the same document and history through preview changes", () => {
270 mount("- [ ] task", { richTextEnabled: true });
271 key("Enter", { shiftKey: true });
272 expect(editor.getValue()).toContain("\n- [ ] ");
273 const draft = editor.getValue();
274 props = { ...props, richTextEnabled: false };
275 editor.configure();
276 expect(editor.getValue()).toBe(draft);
277 undo(editor.view);
278 expect(editor.getValue()).toBe("- [ ] task");
279 });
280});
281
282it("imports actual structured terminal clipboard records and rewrites their atomic references", () => {
283 const oldId = ComposerContextId.make("terminal-old");
284 const newId = ComposerContextId.make("terminal-new");
285 const text = formatComposerContextReference({
286 kind: "terminal",
287 contextId: oldId,
288 label: "Build",
289 });
290 const fragment = {
291 version: 1 as const,
292 source: { environmentId: EnvironmentId.make("env") },
293 records: [
294 {
295 version: 1 as const,
296 contextId: oldId,
297 kind: "terminal" as const,
298 label: "Build",
299 terminalId: "build",
300 terminalLabel: "Build",
301 lineStart: 1,
302 lineEnd: 2,
303 text: "build output",
304 },
305 ],
306 };
307 const records = new Map<string, unknown>();
308 mount("before ", {
309 importContextFragment: (incoming) => {
310 for (const record of incoming.records) records.set(newId, record);
311 return new Map([[oldId, newId]]);
312 },
313 });
314 const event = new Event("paste", { bubbles: true, cancelable: true });
315 Object.defineProperty(event, "clipboardData", {
316 value: {
317 files: [],
318 getData: (format: string) =>
319 format === "text/plain"
320 ? text
321 : format === COMPOSER_CONTEXT_CLIPBOARD_MIME
322 ? encodeComposerContextFragment(fragment)
323 : "",
324 },
325 });
326 editor.view.contentDOM.dispatchEvent(event);
327 expect(event.defaultPrevented).toBe(true);
328 expect(records.get(newId)).toEqual(fragment.records[0]);
329 expect(editor.snapshot().contextIds).toEqual([newId]);
330 expect(editor.getValue()).toBe(
331 `before ${formatComposerContextReference({ kind: "terminal", contextId: newId, label: "Build" })}`,
332 );
333 undo(editor.view);
334 expect(editor.getValue()).toBe("before ");
335});
336
337it("keeps literal answers verbatim through formatting shortcuts, lists, and fenced text", () => {
338 mount("- item", { literalText: true });
339 key("Enter", { shiftKey: true });
340 expect(editor.getValue()).toBe("- item\n");
341 editor.view.dispatch({ selection: { anchor: 0, head: 6 } });
342 key("b", { ctrlKey: true });
343 expect(editor.getValue()).toBe("- item\n");
344});
345
346it.each([false, true])(
347 "continues fence indentation with Shift=%s and restores it with undo",
348 (shiftKey) => {
349 const source = "```ts\n nested()\n```";
350 mount(source, { richTextEnabled: true, onCommandKeyDown: submission });
351 const cursor = source.indexOf("\n```");
352 editor.view.dispatch({ selection: { anchor: cursor } });
353 expect(key("Enter", { shiftKey }).defaultPrevented).toBe(true);
354 expect(editor.getValue()).toBe("```ts\n nested()\n \n```");
355 expect(editor.snapshot().expandedCursor).toBe(cursor + 3);
356 expect(sends).not.toHaveBeenCalled();
357 expect(undo(editor.view)).toBe(true);
358 expect(editor.getValue()).toBe(source);
359 expect(editor.view.hasFocus).toBe(true);
360 },
361);
362
363it.each(["```", "~~~~"])(
364 "closes an unfinished %s fence after two blank lines and leaves prose",
365 (fence) => {
366 const source = `${fence}ts\n nested()\n \n `;
367 mount(source, { richTextEnabled: true, onCommandKeyDown: submission });
368 expect(key("Enter").defaultPrevented).toBe(true);
369 expect(editor.getValue()).toBe(`${fence}ts\n nested()\n${fence}\n\n`);
370 expect(editor.snapshot().expandedCursor).toBe(editor.getValue().length);
371 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false);
372 expect(sends).not.toHaveBeenCalled();
373 expect(undo(editor.view)).toBe(true);
374 expect(editor.getValue()).toBe(source);
375 },
376);
377
378it("leaves an already closed fence from its trailing blank body without consuming following prose", () => {
379 const source = "```ts\nx\n\n\n```\nfollowing";
380 mount(source, { richTextEnabled: true, onCommandKeyDown: submission });
381 editor.view.dispatch({ selection: { anchor: source.indexOf("\n```") } });
382 key("Enter", { shiftKey: true });
383 expect(editor.getValue()).toBe("```ts\nx\n```\n\n\nfollowing");
384 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false);
385 expect(sends).not.toHaveBeenCalled();
386 undo(editor.view);
387 expect(editor.getValue()).toBe(source);
388});
389
390it("treats a completed closing fence as the way back to prose, without scaffolding a second fence", () => {
391 const source = "```ts\n nested()\n```";
392 mount(source, { richTextEnabled: true, onCommandKeyDown: submission });
393 key("Enter");
394 expect(editor.getValue()).toBe(`${source}\n`);
395 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false);
396 expect(sends).not.toHaveBeenCalled();
397 undo(editor.view);
398 expect(editor.getValue()).toBe(source);
399});
400
401it("continues two indented code lines before the third Enter returns to prose", () => {
402 mount("```ts\n nested()", { richTextEnabled: true, onCommandKeyDown: submission });
403 key("Enter");
404 expect(editor.getValue()).toBe("```ts\n nested()\n ");
405 key("Enter");
406 expect(editor.getValue()).toBe("```ts\n nested()\n \n ");
407 key("Enter");
408 expect(editor.getValue()).toBe("```ts\n nested()\n```\n\n");
409 expect(composerSuggestionsSuppressed(editor.view, false)).toBe(false);
410 expect(sends).not.toHaveBeenCalled();
411 expect(editor.view.hasFocus).toBe(true);
412});
apps/web/src/components/composerMarkdownEditor.ts created+486
......@@ -0,0 +1,486 @@
1import {
2 chipField,
3 createMarkdownEditor,
4 enclosingCodeBlock,
5 insertNewline,
6 livePreview,
7 type MarkdownEditor,
8} from "@clo/markdown-editor";
9import { isolateHistory, indentLess, indentMore } from "@codemirror/commands";
10import {
11 defaultHighlightStyle,
12 HighlightStyle,
13 syntaxHighlighting,
14 syntaxTree,
15} from "@codemirror/language";
16import {
17 Compartment,
18 EditorState,
19 Prec,
20 StateEffect,
21 StateField,
22 Transaction,
23} from "@codemirror/state";
24import type { SyntaxNode } from "@lezer/common";
25import { Decoration, EditorView, WidgetType } from "@codemirror/view";
26
27import { cn } from "~/lib/utils";
28import { collectComposerPromptInlineTokens } from "~/composer-editor-mentions";
29import { collapseExpandedComposerCursor, expandCollapsedComposerCursor } from "~/composer-logic";
30import { getTimelinePageScrollKey } from "./chat/pageScrollController";
31import { importPastedComposerText } from "./composerInlineTokenPaste";
32import { composerCodeBlocks, composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
33import type { ComposerPromptEditorProps } from "./ComposerPromptEditor";
34
35export type ComposerToken = ReturnType<typeof collectComposerPromptInlineTokens>[number];
36export type ComposerChip = { dom: HTMLElement } & (
37 | { kind: "chip"; token: ComposerToken }
38 | { kind: "fence"; block: ReturnType<typeof composerCodeBlocks>[number] }
39);
40
41// Live-preview decorations own prose colors; fenced code uses the Shiki extension.
42const composerSyntaxHighlight = syntaxHighlighting(
43 HighlightStyle.define(
44 defaultHighlightStyle.specs.map((style) => ({ ...style, color: "inherit" })),
45 ),
46);
47
48export function composerTokens(state: EditorState, literalText: boolean): ComposerToken[] {
49 if (literalText) return [];
50 const code: Array<{ from: number; to: number }> = [];
51 syntaxTree(state).iterate({
52 enter(node) {
53 if (["InlineCode", "FencedCode", "CodeBlock"].includes(node.name)) {
54 code.push({ from: node.from, to: node.to });
55 return false;
56 }
57 return undefined;
58 },
59 });
60 return collectComposerPromptInlineTokens(state.doc.toString()).filter(
61 (token) => !code.some((range) => token.start < range.to && token.end > range.from),
62 );
63}
64
65export function composerSuggestionsSuppressed(view: EditorView, literalText: boolean): boolean {
66 const range = view.state.selection.main;
67 if (literalText || view.compositionStarted || !range.empty) return true;
68 for (
69 let node: SyntaxNode | null = syntaxTree(view.state).resolveInner(range.head, -1);
70 node;
71 node = node.parent
72 ) {
73 if (["InlineCode", "FencedCode", "CodeBlock"].includes(node.name)) return true;
74 }
75 return composerTokens(view.state, literalText).some(
76 (token) => token.start <= range.head && token.end >= range.head,
77 );
78}
79
80class ComposerChipWidget extends WidgetType {
81 constructor(
82 readonly token: ComposerToken,
83 readonly mount: (chip: ComposerChip) => void,
84 readonly unmount: (dom: HTMLElement) => void,
85 ) {
86 super();
87 }
88 override eq(other: ComposerChipWidget) {
89 return (
90 this.token.start === other.token.start &&
91 this.token.end === other.token.end &&
92 this.token.source === other.token.source
93 );
94 }
95 override toDOM() {
96 const dom = document.createElement("span");
97 dom.contentEditable = "false";
98 dom.className = "relative inline-flex select-none items-center align-middle leading-none";
99 dom.dataset.composerChip = "true";
100 if (this.token.type === "citation") dom.dataset.citationStart = String(this.token.start);
101 dom.setAttribute(
102 "aria-label",
103 this.token.type === "mention"
104 ? this.token.value
105 : this.token.type === "skill"
106 ? this.token.value
107 : this.token.type === "context-reference"
108 ? this.token.label
109 : "Assistant citation",
110 );
111 this.mount({ dom, kind: "chip", token: this.token });
112 return dom;
113 }
114 override destroy(dom: HTMLElement) {
115 this.unmount(dom);
116 }
117}
118
119class ComposerFenceWidget extends WidgetType {
120 constructor(
121 readonly block: ReturnType<typeof composerCodeBlocks>[number],
122 readonly mount: (chip: ComposerChip) => void,
123 readonly unmount: (dom: HTMLElement) => void,
124 ) {
125 super();
126 }
127 override eq(other: ComposerFenceWidget) {
128 return (
129 this.block.from === other.block.from &&
130 this.block.info === other.block.info &&
131 this.block.codeTo === other.block.codeTo
132 );
133 }
134 override toDOM() {
135 const dom = document.createElement("span");
136 dom.contentEditable = "false";
137 dom.className = "ml-3 inline-flex items-center align-middle font-sans";
138 dom.dataset.composerFence = "true";
139 this.mount({ dom, kind: "fence", block: this.block });
140 return dom;
141 }
142 override destroy(dom: HTMLElement) {
143 this.unmount(dom);
144 }
145}
146
147export function createComposerMarkdownEditor(options: {
148 props: () => ComposerPromptEditorProps;
149 mountChip: (chip: ComposerChip) => void;
150 unmountChip: (dom: HTMLElement) => void;
151}): MarkdownEditor & {
152 configure: () => void;
153 snapshot: () => { value: string; cursor: number; expandedCursor: number; contextIds: string[] };
154} {
155 const presentation = new Compartment();
156 const attributes = new Compartment();
157 const editing = new Compartment();
158 const configuredLiteral = StateEffect.define<boolean>();
159 const literal = StateField.define<boolean>({
160 create: () => options.props().literalText ?? false,
161 update(value, transaction) {
162 for (const effect of transaction.effects)
163 if (effect.is(configuredLiteral)) return effect.value;
164 return value;
165 },
166 });
167 const snapshot = () => {
168 const view = editor.view;
169 const value = view.state.doc.toString();
170 const expandedCursor = view.state.selection.main.head;
171 const literalText = view.state.field(literal);
172 return {
173 value,
174 cursor: collapseExpandedComposerCursor(value, expandedCursor, literalText),
175 expandedCursor,
176 contextIds: composerTokens(view.state, literalText).flatMap((token) =>
177 token.type === "context-reference" ? [token.contextId] : [],
178 ),
179 };
180 };
181 const report = () => {
182 const props = options.props();
183 const current = snapshot();
184 props.onChange(
185 current.value,
186 current.cursor,
187 current.expandedCursor,
188 composerSuggestionsSuppressed(editor.view, props.literalText ?? false),
189 current.contextIds,
190 );
191 };
192 const editor = createMarkdownEditor({
193 preset: "composer",
194 mentions: false,
195 preview: false,
196 doc: options.props().value,
197 extensions: [
198 literal,
199 presentation.of([]),
200 attributes.of([]),
201 editing.of([]),
202 chipField({
203 atomic: true,
204 onSelection: true,
205 rebuild: (transaction) =>
206 syntaxTree(transaction.startState) !== syntaxTree(transaction.state),
207 chips: (state) =>
208 composerTokens(state, state.field(literal)).map((token) =>
209 Decoration.replace({
210 widget: new ComposerChipWidget(token, options.mountChip, options.unmountChip),
211 }).range(token.start, token.end),
212 ),
213 }),
214 chipField({
215 onSelection: true,
216 rebuild: (transaction) =>
217 syntaxTree(transaction.startState) !== syntaxTree(transaction.state),
218 chips: (state) =>
219 options.props().richTextEnabled && !state.field(literal)
220 ? composerCodeBlocks(state).map((block) =>
221 Decoration.widget({
222 widget: new ComposerFenceWidget(block, options.mountChip, options.unmountChip),
223 side: 1,
224 }).range(state.doc.lineAt(block.from).to),
225 )
226 : [],
227 }),
228 Prec.highest(
229 EditorView.domEventHandlers({
230 keydown(event, view) {
231 const props = options.props();
232 if (props.disabled) return false;
233 const pageKey = getTimelinePageScrollKey({
234 altKey: event.altKey,
235 ctrlKey: event.ctrlKey,
236 metaKey: event.metaKey,
237 shiftKey: event.shiftKey,
238 defaultPrevented: event.defaultPrevented,
239 isComposing: event.isComposing || view.compositionStarted,
240 keyCode: event.keyCode,
241 key: event.key,
242 clientHeight: view.scrollDOM.clientHeight,
243 scrollHeight: view.scrollDOM.scrollHeight,
244 scrollTop: view.scrollDOM.scrollTop,
245 });
246 if (pageKey && props.onPageScrollKeyDown) {
247 props.onPageScrollKeyDown(pageKey);
248 event.preventDefault();
249 event.stopPropagation();
250 return true;
251 }
252 if (event.isComposing || event.keyCode === 229 || view.compositionStarted) {
253 if (event.key === "Enter") {
254 event.preventDefault();
255 return true;
256 }
257 return false;
258 }
259 const range = view.state.selection.main;
260 if (
261 event.key === "Tab" &&
262 event.shiftKey &&
263 !event.altKey &&
264 !event.metaKey &&
265 !event.ctrlKey &&
266 range.empty &&
267 !props.literalText
268 ) {
269 const citation = composerTokens(view.state, false).find(
270 (token) => token.type === "citation" && token.end === range.head,
271 );
272 const button = citation
273 ? view.dom.querySelector<HTMLElement>(
274 `[data-citation-start="${citation.start}"] [data-citation-comment-trigger]`,
275 )
276 : null;
277 if (button) {
278 event.preventDefault();
279 event.stopPropagation();
280 button.focus();
281 return true;
282 }
283 }
284 const block = enclosingCodeBlock(view.state, range.head);
285 if (block && !props.literalText && !event.metaKey && !event.ctrlKey) {
286 if (event.key === "Enter") return false;
287 if (event.key === "Tab") {
288 const handled = (event.shiftKey ? indentLess : indentMore)(view);
289 if (handled) {
290 event.preventDefault();
291 event.stopPropagation();
292 }
293 return handled;
294 }
295 }
296 const handled = props.onCommandKeyDown?.(event.key, event) ?? false;
297 if (handled) {
298 event.preventDefault();
299 event.stopPropagation();
300 return true;
301 }
302 if (props.literalText) {
303 if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.altKey) {
304 insertNewline(view);
305 event.preventDefault();
306 return true;
307 }
308 if (
309 (event.metaKey || event.ctrlKey) &&
310 ["b", "i"].includes(event.key.toLowerCase())
311 ) {
312 event.preventDefault();
313 return true;
314 }
315 if (["Tab", "*", "`"].includes(event.key)) return true;
316 }
317 return false;
318 },
319 compositionstart() {
320 const props = options.props();
321 const current = snapshot();
322 props.onChange(
323 current.value,
324 current.cursor,
325 current.expandedCursor,
326 true,
327 current.contextIds,
328 );
329 return false;
330 },
331 compositionend(_event, view) {
332 queueMicrotask(() => {
333 if (view.dom.isConnected) report();
334 });
335 return false;
336 },
337 paste(event, view) {
338 const props = options.props();
339 if (props.disabled) return true;
340 const data = event.clipboardData;
341 if (!data || event.defaultPrevented) return false;
342 const text = props.literalText
343 ? data.getData("text/plain")
344 : importPastedComposerText(data, props.importContextFragment);
345 if (!text) return false;
346 event.preventDefault();
347 view.dispatch({
348 ...view.state.replaceSelection(text),
349 annotations: isolateHistory.of("full"),
350 userEvent: "input.paste",
351 scrollIntoView: true,
352 });
353 return true;
354 },
355 }),
356 ),
357 EditorView.updateListener.of((update) => {
358 if (update.docChanged || update.selectionSet) {
359 if (
360 update.transactions.some(
361 (transaction) => transaction.annotation(Transaction.userEvent) === "t3.controlled",
362 )
363 )
364 return;
365 if (!update.state.selection.main.empty) options.props().onVisibleSelectionChange?.();
366 report();
367 }
368 }),
369 Prec.highest(
370 EditorView.theme({
371 "&": {
372 color: "var(--contrast-foreground)",
373 fontSize: "inherit",
374 "--markdown-text": "var(--contrast-foreground)",
375 "--markdown-muted": "var(--contrast-muted-foreground)",
376 "--markdown-panel": "var(--muted)",
377 "--markdown-accent": "var(--info-foreground)",
378 },
379 ".cm-content": {
380 padding: "0",
381 caretColor: "var(--contrast-foreground)",
382 fontFamily: "inherit",
383 minHeight: "inherit",
384 },
385 ".cm-scroller": {
386 lineHeight: "inherit",
387 maxHeight: "inherit",
388 minHeight: "inherit",
389 overflowY: "auto",
390 overflowX: "hidden",
391 fontFamily: "inherit",
392 },
393 "&.cm-focused": { outline: "none" },
394 ".cm-lp-code, .cm-lp-codeblock": {
395 fontFamily: "var(--font-mono)",
396 fontSize: "var(--font-size-code, 13px)",
397 },
398 }),
399 ),
400 ],
401 });
402 const configure = () => {
403 const props = options.props();
404 const literalText = props.literalText ?? false;
405 const configuredPreview = presentation.get(editor.view.state);
406 const previewEnabled = Array.isArray(configuredPreview) && configuredPreview.length > 0;
407 const showPreview = Boolean(props.richTextEnabled && !literalText);
408 editor.view.dispatch({
409 selection: editor.view.state.selection,
410 annotations: Transaction.userEvent.of("t3.controlled"),
411 effects: [
412 configuredLiteral.of(literalText),
413 editing.reconfigure([
414 EditorView.editable.of(!props.disabled),
415 EditorState.readOnly.of(props.disabled),
416 EditorState.transactionFilter.of((transaction) =>
417 props.disabled &&
418 transaction.docChanged &&
419 transaction.annotation(Transaction.userEvent) !== "t3.controlled"
420 ? []
421 : transaction,
422 ),
423 ]),
424 attributes.reconfigure([
425 EditorView.editorAttributes.of({
426 class: cn(
427 "-m-1 block max-h-52 min-h-19.5 p-1 bg-transparent leading-relaxed text-foreground",
428 props.className,
429 ),
430 }),
431 EditorView.contentAttributes.of({
432 "data-testid": "composer-editor",
433 "data-composer-rich-text": props.richTextEnabled && !literalText ? "true" : "false",
434 role: "textbox",
435 "aria-multiline": "true",
436 "aria-label": props.ariaLabel ?? "Message",
437 "aria-readonly": String(props.disabled),
438 "aria-placeholder": props.placeholder,
439 ...(props.suggestionListId && !props.disabled
440 ? { "aria-autocomplete": "list", "aria-haspopup": "listbox" }
441 : {}),
442 ...(props.activeSuggestionId && props.suggestionListId
443 ? {
444 "aria-controls": props.suggestionListId,
445 "aria-activedescendant": props.activeSuggestionId,
446 }
447 : {}),
448 }),
449 ]),
450 ...(showPreview !== previewEnabled
451 ? [
452 presentation.reconfigure(
453 showPreview
454 ? [
455 composerSyntaxHighlight,
456 composerCodeBlockHighlight,
457 livePreview({
458 blockWidgets: false,
459 html: false,
460 exclude: (state) =>
461 composerTokens(state, false).map((token) => ({
462 from: token.start,
463 to: token.end,
464 })),
465 }),
466 ]
467 : [],
468 ),
469 ]
470 : []),
471 ],
472 });
473 };
474 configure();
475 editor.view.dispatch({
476 selection: {
477 anchor: expandCollapsedComposerCursor(
478 options.props().value,
479 options.props().cursor,
480 options.props().literalText,
481 ),
482 },
483 annotations: Transaction.userEvent.of("t3.controlled"),
484 });
485 return { ...editor, configure, snapshot };
486}
apps/web/src/components/composerSelection.test.ts deleted-27
......@@ -1,27 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { didComposerSelectionChangeVisibly } from "./composerSelection";
4
5describe("didComposerSelectionChangeVisibly", () => {
6 it("ignores selection-neutral editor updates", () => {
7 expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, null)).toBe(false);
8 expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, { start: 3, end: 3 })).toBe(
9 false,
10 );
11 });
12
13 it("detects entering and extending a visible range selection", () => {
14 expect(didComposerSelectionChangeVisibly({ start: 3, end: 3 }, { start: 0, end: 3 })).toBe(
15 true,
16 );
17 expect(didComposerSelectionChangeVisibly({ start: 1, end: 3 }, { start: 0, end: 3 })).toBe(
18 true,
19 );
20 });
21
22 it("does not repeatedly report the same visible selection", () => {
23 expect(didComposerSelectionChangeVisibly({ start: 0, end: 3 }, { start: 0, end: 3 })).toBe(
24 false,
25 );
26 });
27});
apps/web/src/components/composerSelection.ts deleted-15
......@@ -1,15 +0,0 @@
1export type ComposerSelectionRange = {
2 start: number;
3 end: number;
4};
5
6export function didComposerSelectionChangeVisibly(
7 previous: ComposerSelectionRange,
8 next: ComposerSelectionRange | null,
9): boolean {
10 return (
11 next !== null &&
12 next.start !== next.end &&
13 (next.start !== previous.start || next.end !== previous.end)
14 );
15}
apps/web/src/components/files/BrowserDocumentFrame.tsx+11-10
......@@ -12,7 +12,7 @@ import {
1212 htmlRenderThemeFragment,
1313 htmlRenderThemeMessage,
1414 htmlRenderResult,
15 readHtmlRenderContentHeight,
15 readHtmlRenderContentSize,
1616 readHtmlRenderKeyDown,
1717 readHtmlRenderLinkRequest,
1818} from "@t3tools/shared/htmlRender";
......@@ -122,8 +122,9 @@ export function HtmlRenderDocument(props: {
122122 readonly className?: string;
123123 /** The thread the page's links open beside, as a chat link would. */
124124 readonly threadRef?: ScopedThreadRef | undefined;
125 /** Receives the page's content height whenever it changes, so an inline frame can fit it. */
126 readonly onContentHeight?: (height: number) => void;
125 readonly onContentSize?: (
126 size: NonNullable<ReturnType<typeof readHtmlRenderContentSize>>,
127 ) => void;
127128}) {
128129 const theme = useHtmlRenderTheme();
129130 const frameRef = useRef<HTMLIFrameElement>(null);
......@@ -265,20 +266,20 @@ export function HtmlRenderDocument(props: {
265266 window.addEventListener("message", replayKey);
266267 return () => window.removeEventListener("message", replayKey);
267268 }, []);
268 const { onContentHeight } = props;
269 // A page posts its height once per change, so listen from the commit that
269 const { onContentSize } = props;
270 // A page posts its size once per change, so listen from the commit that
270271 // inserts the frame; a passive effect could run after a fast page's first post.
271272 useLayoutEffect(() => {
272 if (onContentHeight === undefined) return;
273 if (onContentSize === undefined) return;
273274 const resize = (event: MessageEvent) => {
274 const height = readHtmlRenderContentHeight(event.data);
275 if (height !== undefined && event.source === frameRef.current?.contentWindow) {
276 onContentHeight(height);
275 const size = readHtmlRenderContentSize(event.data);
276 if (size !== undefined && event.source === frameRef.current?.contentWindow) {
277 onContentSize(size);
277278 }
278279 };
279280 window.addEventListener("message", resize);
280281 return () => window.removeEventListener("message", resize);
281 }, [onContentHeight]);
282 }, [onContentSize]);
282283 return (
283284 <iframe
284285 ref={frameRef}
apps/web/src/components/media/MediaActions.test.tsx+127
......@@ -24,6 +24,10 @@ const state = vi.hoisted(() => ({
2424 showMenu: vi.fn(),
2525 openFile: vi.fn(),
2626 clipboard: vi.fn(),
27 openVideo: vi.fn(),
28 reveal: vi.fn(),
29 local: false,
30 revealLabel: undefined as string | undefined,
2731 menuFinished: null as (() => void) | null,
2832}));
2933
......@@ -33,6 +37,12 @@ vi.mock("react", async (importOriginal) => ({
3337 useRef: <A,>(current: A) => ({ current }),
3438 useState: <A,>(initial: A) => [initial, () => {}],
3539}));
40vi.mock("../../fileContextMenu", () => ({
41 useFileContextMenu: () => ({
42 capabilities: { revealLabel: state.revealLabel },
43 activate: state.reveal,
44 }),
45}));
3646vi.mock("../../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: vi.fn() }));
3747vi.mock("../../localApi", () => ({
3848 readLocalApi: () => ({ contextMenu: { show: state.showMenu } }),
......@@ -71,6 +81,7 @@ vi.mock("../ui/toast", () => ({
7181 return "toast";
7282 },
7383 update: () => state.menuFinished?.(),
84 close: () => state.menuFinished?.(),
7485 },
7586}));
7687
......@@ -119,6 +130,16 @@ function openMenu(source: MediaActionSource) {
119130}
120131
121132beforeEach(() => {
133 state.local = false;
134 state.revealLabel = undefined;
135 state.openVideo.mockReset().mockResolvedValue(undefined);
136 state.reveal.mockReset().mockResolvedValue(undefined);
137 vi.stubGlobal("window", {
138 desktopBridge: {
139 openVideo: state.openVideo,
140 supportsLocalTerminal: () => state.local,
141 },
142 });
122143 state.sessions.clear();
123144 state.sessions.set(environmentId, denied);
124145 state.mint
......@@ -305,3 +326,109 @@ it.each(["https://cdn.test/image.png", "blob:local-image"])(
305326 expect(state.download).toHaveBeenCalledWith(src, "image.png");
306327 },
307328);
329
330function videoSource(): MediaActionSource {
331 const source = hostSource();
332 return {
333 ...source,
334 kind: "video",
335 name: "clip.mp4",
336 reference: { kind: "file", path: "/repo/clip.mp4" },
337 asset: { environmentId, resource: { _tag: "media-file", threadId, path: "/repo/clip.mp4" } },
338 };
339}
340
341it("offers Finder only for a confirmed native-local file, and Download for a remote file", () => {
342 state.sessions.set(environmentId, granted);
343 state.revealLabel = "Reveal in Finder";
344 openMenu(videoSource());
345 const remoteItems = state.showMenu.mock.lastCall![0];
346 expect(remoteItems).toContainEqual(
347 expect.objectContaining({ id: "save", label: "Download video" }),
348 );
349 expect(remoteItems).not.toContainEqual(expect.objectContaining({ id: "reveal-in-folder" }));
350 state.local = true;
351 openMenu(videoSource());
352 const localItems = state.showMenu.mock.lastCall![0];
353 expect(localItems).toContainEqual(
354 expect.objectContaining({ id: "reveal-in-folder", label: "Reveal in Finder" }),
355 );
356 expect(localItems).not.toContainEqual(expect.objectContaining({ id: "save" }));
357 expect(localItems).toContainEqual(
358 expect.objectContaining({ id: "open-quicktime", disabled: false }),
359 );
360});
361
362it("opens a fresh signed URL on the client without forwarding an environment path", async () => {
363 state.sessions.set(environmentId, granted);
364 await useMediaActions(videoSource()).openQuickTime();
365 expect(state.mint).toHaveBeenCalledWith({
366 environmentId,
367 input: { resource: videoSource().asset!.resource },
368 });
369 expect(state.openVideo).toHaveBeenCalledWith({
370 url: "https://host.test/api/assets/image.png",
371 name: "clip.mp4",
372 });
373});
374
375it("rechecks file access before QuickTime or Finder actions retained from an old menu", async () => {
376 state.local = true;
377 state.revealLabel = "Reveal in Finder";
378 state.sessions.set(environmentId, granted);
379 const actions = useMediaActions(videoSource());
380 state.sessions.set(environmentId, denied);
381 await expect(actions.openQuickTime()).rejects.toThrow("cannot read host files");
382 await expect(actions.reveal()).rejects.toThrow("cannot read host files");
383 expect(state.openVideo).not.toHaveBeenCalled();
384 expect(state.reveal).not.toHaveBeenCalled();
385});
386
387it("does not reveal remote paths even if they exist on the client too", async () => {
388 state.sessions.set(environmentId, granted);
389 state.revealLabel = "Reveal in Finder";
390 await useMediaActions(videoSource()).reveal();
391 expect(state.reveal).not.toHaveBeenCalled();
392});
393
394it("hides QuickTime when the desktop platform does not expose it", () => {
395 vi.stubGlobal("window", {});
396 state.sessions.set(environmentId, granted);
397 openMenu(videoSource());
398 expect(state.showMenu.mock.lastCall![0]).not.toContainEqual(
399 expect.objectContaining({ id: "open-quicktime" }),
400 );
401});
402
403it("applies file grants and native-local reveal to draft document videos", async () => {
404 const source: MediaActionSource = {
405 ...videoSource(),
406 asset: {
407 environmentId,
408 resource: { _tag: "draft-workspace-file", cwd: "/repo", path: "/repo/clip.mp4" },
409 },
410 };
411 state.local = true;
412 state.revealLabel = "Reveal in Finder";
413 openMenu(source);
414 expect(state.showMenu.mock.lastCall![0]).toEqual(
415 expect.arrayContaining([
416 expect.objectContaining({ id: "reveal-in-folder", disabled: true }),
417 expect.objectContaining({ id: "open-quicktime", disabled: true }),
418 ]),
419 );
420 await expect(useMediaActions(source).openQuickTime()).rejects.toThrow("cannot read host files");
421 expect(state.mint).not.toHaveBeenCalled();
422 state.sessions.set(environmentId, granted);
423 await useMediaActions(source).reveal();
424 expect(state.reveal).toHaveBeenCalledWith("reveal-in-folder", {
425 environmentId,
426 filePath: "/repo/clip.mp4",
427 workspaceRoot: undefined,
428 });
429 await useMediaActions(source).openQuickTime();
430 expect(state.mint).toHaveBeenCalledWith({
431 environmentId,
432 input: { resource: source.asset!.resource },
433 });
434});
apps/web/src/components/media/MediaActions.tsx+103-17
......@@ -8,7 +8,6 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"
88import {
99 AuthFilesystemReadScope,
1010 type AssetResource,
11 type AuthSessionState,
1211 type ContextMenuItem,
1312 type EnvironmentId,
1413 sessionGrantsScope,
......@@ -18,6 +17,7 @@ import * as Option from "effect/Option";
1817import { AsyncResult } from "effect/reactivity";
1918import { useCallback, useRef, useState, type ReactElement } from "react";
2019
20import { useFileContextMenu } from "../../fileContextMenu";
2121import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
2222import { readLocalApi } from "../../localApi";
2323import { appAtomRegistry } from "../../rpc/atomRegistry";
......@@ -56,11 +56,12 @@ function canReadHostMedia(environmentId: EnvironmentId | null): boolean {
5656}
5757
5858/** Explicit byte operations get fresh capabilities without replacing a player's active source. */
59/** Exported for tests that drive the byte operations without the menu. */
6059export function useMediaActions(source: MediaActionSource) {
6160 const hostEnvironmentId =
6261 source.asset &&
63 (source.asset.resource._tag === "workspace-file" || source.asset.resource._tag === "media-file")
62 (source.asset.resource._tag === "workspace-file" ||
63 source.asset.resource._tag === "media-file" ||
64 source.asset.resource._tag === "draft-workspace-file")
6465 ? source.asset.environmentId
6566 : null;
6667 const fileSession = useEnvironmentQuery(
......@@ -74,6 +75,22 @@ export function useMediaActions(source: MediaActionSource) {
7475 throw new Error("This connection cannot read host files.");
7576 }
7677 }, [hostEnvironmentId]);
78 const fileMenu = useFileContextMenu(hostEnvironmentId);
79 const bridge = typeof window === "undefined" ? undefined : window.desktopBridge;
80 const connection = hostEnvironmentId === null ? null : readPreparedConnection(hostEnvironmentId);
81 const isLocalFile =
82 source.reference?.kind === "file" &&
83 connection !== null &&
84 hostEnvironmentId !== null &&
85 bridge?.supportsLocalTerminal?.({
86 httpBaseUrl: connection.httpBaseUrl,
87 environmentId: hostEnvironmentId,
88 }) === true;
89 const canReveal = isLocalFile && fileMenu.capabilities.revealLabel !== undefined;
90 const canOpenQuickTime =
91 source.kind === "video" &&
92 bridge?.openVideo !== undefined &&
93 (source.asset !== undefined || (source.src !== null && /^https?:/.test(source.src)));
7794 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
7895 reportFailure: false,
7996 refresh: true,
......@@ -109,28 +126,64 @@ export function useMediaActions(source: MediaActionSource) {
109126 new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }),
110127 ]);
111128 }, [actionUrl, assertCanReadMedia]);
112 return { save, copyImage, canReadMedia, assertCanReadMedia };
129 const reveal = useCallback(async () => {
130 assertCanReadMedia();
131 const currentConnection =
132 hostEnvironmentId === null ? null : readPreparedConnection(hostEnvironmentId);
133 if (
134 !canReveal ||
135 source.reference?.kind !== "file" ||
136 hostEnvironmentId === null ||
137 !currentConnection ||
138 bridge?.supportsLocalTerminal?.({
139 httpBaseUrl: currentConnection.httpBaseUrl,
140 environmentId: hostEnvironmentId,
141 }) !== true
142 )
143 return;
144 await fileMenu.activate("reveal-in-folder", {
145 environmentId: hostEnvironmentId,
146 filePath: source.reference.path,
147 workspaceRoot: undefined,
148 });
149 }, [assertCanReadMedia, canReveal, source.reference, hostEnvironmentId, bridge, fileMenu]);
150 const openQuickTime = useCallback(async () => {
151 if (!canOpenQuickTime || !bridge?.openVideo) return;
152 const url = await actionUrl();
153 await bridge.openVideo({ url, name: mediaFileName(source) });
154 }, [canOpenQuickTime, bridge, actionUrl, source]);
155 return {
156 save,
157 copyImage,
158 reveal,
159 openQuickTime,
160 canReveal,
161 revealLabel: fileMenu.capabilities.revealLabel,
162 canOpenQuickTime,
163 canReadMedia,
164 assertCanReadMedia,
165 };
113166}
114167
115/** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */
116export function MediaActions({
117 source,
118 children,
119}: {
120 source: MediaActionSource;
121 children: ReactElement;
122}) {
123 const { save, copyImage, canReadMedia, assertCanReadMedia } = useMediaActions(source);
124 const [tooltipOpen, setTooltipOpen] = useState(false);
168export function useMediaActionMenu(source: MediaActionSource) {
169 const {
170 save,
171 copyImage,
172 reveal,
173 openQuickTime,
174 canReveal,
175 canOpenQuickTime,
176 revealLabel,
177 canReadMedia,
178 assertCanReadMedia,
179 } = useMediaActions(source);
125180 const menuOpen = useRef(false);
126181 const reference = source.reference;
127 const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name);
128182
129183 const showMenu = async (position: { x: number; y: number }) => {
130184 const api = readLocalApi();
131185 if (!api || menuOpen.current) return;
132186 menuOpen.current = true;
133 setTooltipOpen(false);
134187 let failureTitle = "Could not open media menu";
135188 let progressToast: ReturnType<typeof toastManager.add> | undefined;
136189 try {
......@@ -150,7 +203,16 @@ export function MediaActions({
150203 }
151204 if (source.onOpenFile)
152205 items.push({ id: "open-file", label: "Open in file viewer", disabled: !canReadMedia });
153 items.push({ id: "save", label: `Save ${noun}`, disabled: unavailable });
206 if (source.kind === "video" && canReveal && revealLabel !== undefined)
207 items.push({ id: "reveal-in-folder", label: revealLabel, disabled: !canReadMedia });
208 if (source.kind !== "video" || !canReveal)
209 items.push({
210 id: "save",
211 label: source.kind === "video" ? "Download video" : `Save ${noun}`,
212 disabled: unavailable,
213 });
214 if (canOpenQuickTime)
215 items.push({ id: "open-quicktime", label: "Open in QuickTime", disabled: unavailable });
154216 if (source.kind === "image") {
155217 items.push({
156218 id: "copy-image",
......@@ -179,6 +241,12 @@ export function MediaActions({
179241 } else if (action === "open-file") {
180242 assertCanReadMedia();
181243 source.onOpenFile?.();
244 } else if (action === "reveal-in-folder") {
245 await reveal();
246 } else if (action === "open-quicktime") {
247 progressToast = toastManager.add({ type: "loading", title: "Opening in QuickTime…" });
248 await openQuickTime();
249 toastManager.close(progressToast);
182250 } else if (action === "save" || action === "copy-image") {
183251 progressToast = toastManager.add({
184252 type: "loading",
......@@ -203,6 +271,22 @@ export function MediaActions({
203271 }
204272 };
205273
274 return { showMenu };
275}
276
277/** Adds source-aware actions and a tooltip without a layout wrapper. */
278export function MediaActions({
279 source,
280 children,
281}: {
282 source: MediaActionSource;
283 children: ReactElement;
284}) {
285 const { showMenu } = useMediaActionMenu(source);
286 const [tooltipOpen, setTooltipOpen] = useState(false);
287 const reference = source.reference;
288 const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name);
289
206290 return (
207291 <Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
208292 <TooltipTrigger
......@@ -213,6 +297,7 @@ export function MediaActions({
213297 event.preventDefault();
214298 event.stopPropagation();
215299 const bounds = event.currentTarget.getBoundingClientRect();
300 setTooltipOpen(false);
216301 void showMenu(
217302 event.clientX === 0 && event.clientY === 0
218303 ? { x: bounds.left, y: bounds.bottom }
......@@ -228,6 +313,7 @@ export function MediaActions({
228313 event.preventDefault();
229314 event.stopPropagation();
230315 const bounds = event.currentTarget.getBoundingClientRect();
316 setTooltipOpen(false);
231317 void showMenu({ x: bounds.left, y: bounds.bottom });
232318 }}
233319 />
apps/web/src/components/media/MediaPlaybackHost.test.tsx created+336
......@@ -0,0 +1,336 @@
1// @vitest-environment jsdom
2
3import { act, StrictMode, useState } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import { MediaPlaybackProvider, MediaPlaybackSlot } from "./MediaPlaybackHost";
8
9let root: Root;
10let container: HTMLDivElement;
11let playing: WeakSet<HTMLMediaElement>;
12let pause: ReturnType<typeof vi.spyOn>;
13let load: ReturnType<typeof vi.spyOn>;
14let nextPlayer = 0;
15
16function Player({ label }: { readonly label: string }) {
17 const [instance] = useState(() => nextPlayer++);
18 return (
19 <span data-media-player>
20 <video src="/video.mp4" aria-label={label} controls data-instance={instance} />
21 <button type="button">Playback control</button>
22 </span>
23 );
24}
25
26beforeEach(() => {
27 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
28 playing = new WeakSet();
29 nextPlayer = 0;
30 vi.spyOn(HTMLMediaElement.prototype, "paused", "get").mockImplementation(function (
31 this: HTMLMediaElement,
32 ) {
33 return !playing.has(this);
34 });
35 vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (
36 this: HTMLMediaElement,
37 ) {
38 playing.add(this);
39 this.dispatchEvent(new Event("play"));
40 return Promise.resolve();
41 });
42 pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(function (
43 this: HTMLMediaElement,
44 ) {
45 playing.delete(this);
46 this.dispatchEvent(new Event("pause"));
47 });
48 load = vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => undefined);
49 container = document.createElement("div");
50 document.body.append(container);
51 root = createRoot(container);
52});
53
54afterEach(async () => {
55 await act(() => root.unmount());
56 container.remove();
57 Reflect.deleteProperty(document, "pictureInPictureElement");
58 Reflect.deleteProperty(document, "exitPictureInPicture");
59 Reflect.deleteProperty(document, "fullscreenElement");
60 vi.restoreAllMocks();
61 vi.unstubAllGlobals();
62});
63
64async function renderSlots(
65 slots: ReadonlyArray<{ key: string; identity?: string; label?: string }>,
66) {
67 await act(() => {
68 root.render(
69 <StrictMode>
70 <MediaPlaybackProvider>
71 <main>
72 {slots.map(({ key, identity = key, label = key }) => (
73 <div key={key} data-chat={key}>
74 <MediaPlaybackSlot identity={identity}>
75 <Player label={label} />
76 </MediaPlaybackSlot>
77 </div>
78 ))}
79 </main>
80 </MediaPlaybackProvider>
81 </StrictMode>,
82 );
83 });
84}
85
86function videoIn(selector: string) {
87 const video = container.querySelector<HTMLVideoElement>(`${selector} video`);
88 if (!video) throw new Error(`Missing video in ${selector}`);
89 return video;
90}
91
92describe("persistent media playback", () => {
93 it("keeps the same playing video, time, and component state across chats and return", async () => {
94 await renderSlots([{ key: "source", label: "Demo" }]);
95 const video = videoIn('[data-chat="source"]');
96 const instance = video.dataset.instance;
97 await act(() => video.play());
98 video.currentTime = 42.5;
99
100 await renderSlots([{ key: "other" }]);
101 expect(videoIn("[data-media-floating-player]")).toBe(video);
102 expect(video.currentTime).toBe(42.5);
103 expect(video.paused).toBe(false);
104 expect(container.querySelector('[aria-label="Picture in picture: Demo"]')).not.toBeNull();
105
106 await renderSlots([{ key: "source", label: "Updated demo" }]);
107 expect(videoIn('[data-chat="source"]')).toBe(video);
108 expect(video.currentTime).toBe(42.5);
109 expect(video.dataset.instance).toBe(instance);
110 expect(video.getAttribute("aria-label")).toBe("Updated demo");
111 expect(container.querySelector("[data-media-floating-player]")?.hasAttribute("hidden")).toBe(
112 true,
113 );
114 expect(pause.mock.contexts).not.toContain(video);
115 expect(load.mock.contexts).not.toContain(video);
116 });
117
118 it("releases a paused video when its inline slot disappears", async () => {
119 await renderSlots([{ key: "source" }]);
120 const original = videoIn('[data-chat="source"]');
121 await renderSlots([]);
122
123 expect(original.isConnected).toBe(false);
124 expect(original.hasAttribute("src")).toBe(false);
125 expect(load).toHaveBeenCalledOnce();
126 expect(container.querySelector("video")).toBeNull();
127
128 await renderSlots([{ key: "source" }]);
129 expect(videoIn('[data-chat="source"]')).not.toBe(original);
130 });
131
132 it("stops playback and releases the source through the keyboard-accessible close action", async () => {
133 await renderSlots([{ key: "source" }]);
134 const video = videoIn('[data-chat="source"]');
135 await act(() => video.play());
136 await renderSlots([]);
137
138 const close = container.querySelector<HTMLButtonElement>(
139 '[aria-label="Close picture in picture"]',
140 );
141 expect(close?.tagName).toBe("BUTTON");
142 close?.focus();
143 expect(document.activeElement).toBe(close);
144 await act(() => close?.click());
145
146 expect(video.paused).toBe(true);
147 expect(video.hasAttribute("src")).toBe(false);
148 expect(container.querySelector("video")).toBeNull();
149 });
150
151 it("retains only the latest detached player and releases the one it replaces", async () => {
152 await renderSlots([{ key: "first" }]);
153 const first = videoIn('[data-chat="first"]');
154 await act(() => first.play());
155 await renderSlots([{ key: "second" }]);
156 const second = videoIn('[data-chat="second"]');
157 await act(() => second.play());
158 await renderSlots([]);
159
160 expect(videoIn("[data-media-floating-player]")).toBe(second);
161 expect(first.isConnected).toBe(false);
162 expect(first.paused).toBe(true);
163 expect(first.hasAttribute("src")).toBe(false);
164 expect(container.querySelectorAll("video")).toHaveLength(1);
165 });
166
167 it("retains the most recently played video regardless of the DOM detach order", async () => {
168 await renderSlots([{ key: "first" }, { key: "second" }]);
169 const first = videoIn('[data-chat="first"]');
170 const second = videoIn('[data-chat="second"]');
171 await act(() => second.play());
172 await act(() => first.play());
173 await renderSlots([]);
174
175 expect(videoIn("[data-media-floating-player]")).toBe(first);
176 expect(first.paused).toBe(false);
177 expect(second.paused).toBe(true);
178 expect(second.hasAttribute("src")).toBe(false);
179 expect(second.isConnected).toBe(false);
180 expect(container.querySelectorAll("video")).toHaveLength(1);
181 });
182
183 it("keeps the prior floating video until the more recently played inline video detaches", async () => {
184 await renderSlots([{ key: "first" }, { key: "second" }]);
185 const first = videoIn('[data-chat="first"]');
186 const second = videoIn('[data-chat="second"]');
187 await act(() => second.play());
188 await act(() => first.play());
189 expect(second.paused).toBe(false);
190
191 await renderSlots([{ key: "first" }]);
192 expect(videoIn('[data-chat="first"]')).toBe(first);
193 expect(first.paused).toBe(false);
194 expect(videoIn("[data-media-floating-player]")).toBe(second);
195 expect(second.paused).toBe(false);
196
197 await renderSlots([]);
198 expect(videoIn("[data-media-floating-player]")).toBe(first);
199 expect(second.paused).toBe(true);
200 expect(second.hasAttribute("src")).toBe(false);
201 });
202
203 it("releases a floating player when playback ends", async () => {
204 await renderSlots([{ key: "source" }]);
205 const video = videoIn('[data-chat="source"]');
206 await act(() => video.play());
207 await renderSlots([]);
208 await act(() => video.dispatchEvent(new Event("ended")));
209
210 expect(video.isConnected).toBe(false);
211 expect(video.paused).toBe(true);
212 expect(video.hasAttribute("src")).toBe(false);
213 });
214
215 it("closes the floating player with Escape from its own Close button", async () => {
216 await renderSlots([{ key: "source" }]);
217 const video = videoIn('[data-chat="source"]');
218 await act(() => video.play());
219 await renderSlots([]);
220 const close = container.querySelector<HTMLButtonElement>(
221 '[aria-label="Close picture in picture"]',
222 );
223 await act(() =>
224 close?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })),
225 );
226 expect(video.paused).toBe(true);
227 expect(video.hasAttribute("src")).toBe(false);
228 expect(video.isConnected).toBe(false);
229 });
230
231 it("closes with Escape from a focused portaled player control", async () => {
232 await renderSlots([{ key: "source" }]);
233 const video = videoIn('[data-chat="source"]');
234 await act(() => video.play());
235 await renderSlots([]);
236 const control = container.querySelector<HTMLButtonElement>(
237 "[data-media-floating-player] [data-media-player] button",
238 );
239 if (!control) throw new Error("Missing portaled playback control");
240 control.focus();
241 expect(document.activeElement).toBe(control);
242
243 await act(() =>
244 control.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })),
245 );
246
247 expect(video.paused).toBe(true);
248 expect(video.hasAttribute("src")).toBe(false);
249 expect(video.isConnected).toBe(false);
250 expect(pause.mock.contexts.filter((context: unknown) => context === video)).toHaveLength(1);
251 expect(load.mock.contexts.filter((context: unknown) => context === video)).toHaveLength(1);
252 });
253
254 it("captures inline videos before provider teardown removes the portal children", async () => {
255 await renderSlots([{ key: "source" }]);
256 const video = videoIn('[data-chat="source"]');
257 await act(() => video.play());
258 await act(() => root.render(null));
259 expect(video.paused).toBe(true);
260 expect(video.hasAttribute("src")).toBe(false);
261 expect(load.mock.contexts).toContain(video);
262 });
263
264 it("leaves Escape to the browser while the floating video is fullscreen", async () => {
265 await renderSlots([{ key: "source" }]);
266 const video = videoIn('[data-chat="source"]');
267 await act(() => video.play());
268 await renderSlots([]);
269 Object.defineProperty(document, "fullscreenElement", {
270 configurable: true,
271 value: video.parentElement,
272 });
273 const control = container.querySelector<HTMLButtonElement>(
274 "[data-media-floating-player] [data-media-player] button",
275 )!;
276 const escape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
277 await act(() => control.dispatchEvent(escape));
278 expect(escape.defaultPrevented).toBe(false);
279 expect(video.paused).toBe(false);
280 expect(video.isConnected).toBe(true);
281 });
282
283 it("does not let another occurrence of the same source steal an attached video", async () => {
284 await renderSlots([
285 { key: "first", identity: "shared" },
286 { key: "second", identity: "shared" },
287 ]);
288 const first = videoIn('[data-chat="first"]');
289 const second = videoIn('[data-chat="second"]');
290 expect(first).not.toBe(second);
291 await act(() => first.play());
292 first.currentTime = 12;
293 await renderSlots([{ key: "second", identity: "shared", label: "Other occurrence" }]);
294
295 expect(videoIn('[data-chat="second"]')).toBe(second);
296 expect(videoIn("[data-media-floating-player]")).toBe(first);
297
298 await renderSlots([
299 { key: "first", identity: "shared" },
300 { key: "second", identity: "shared" },
301 ]);
302 expect(videoIn('[data-chat="first"]')).toBe(first);
303 expect(first.currentTime).toBe(12);
304 expect(videoIn('[data-chat="second"]')).toBe(second);
305 });
306
307 it("retains a paused system picture-in-picture video and releases it on provider teardown", async () => {
308 await renderSlots([{ key: "source" }]);
309 const video = videoIn('[data-chat="source"]');
310 Object.defineProperty(document, "pictureInPictureElement", {
311 configurable: true,
312 value: video,
313 });
314 const exit = vi.fn(() => Promise.resolve());
315 Object.defineProperty(document, "exitPictureInPicture", { configurable: true, value: exit });
316 await renderSlots([]);
317 expect(videoIn("[data-media-floating-player]")).toBe(video);
318
319 await act(() => root.render(null));
320 expect(video.paused).toBe(true);
321 expect(video.hasAttribute("src")).toBe(false);
322 expect(exit).toHaveBeenCalledOnce();
323 });
324
325 it("supports isolated players without an app provider", async () => {
326 await act(() =>
327 root.render(
328 <MediaPlaybackSlot identity="test">
329 <Player label="Standalone" />
330 </MediaPlaybackSlot>,
331 ),
332 );
333 expect(videoIn(":scope").getAttribute("aria-label")).toBe("Standalone");
334 expect(container.querySelector("[data-media-floating-player]")).toBeNull();
335 });
336});
apps/web/src/components/media/MediaPlaybackHost.tsx created+258
......@@ -0,0 +1,258 @@
1import { XIcon } from "lucide-react";
2import {
3 createContext,
4 useContext,
5 useLayoutEffect,
6 useRef,
7 useState,
8 useSyncExternalStore,
9 type ReactNode,
10} from "react";
11import { createPortal } from "react-dom";
12
13import { Button } from "../ui/button";
14
15interface PlaybackEntry {
16 readonly identity: string;
17 readonly container: HTMLSpanElement;
18 readonly key: number;
19 slot: HTMLSpanElement | null;
20 children: ReactNode;
21}
22
23function moveContainer(entry: PlaybackEntry, destination: HTMLSpanElement) {
24 // A state-preserving DOM move keeps the decoder and native picture-in-picture alive.
25 if (entry.container.isConnected && destination.isConnected && destination.moveBefore) {
26 destination.moveBefore(entry.container, null);
27 } else {
28 destination.append(entry.container);
29 }
30}
31
32function hasRetainedPlayback(entry: PlaybackEntry) {
33 const video = entry.container.querySelector("video");
34 return video && ((!video.paused && !video.ended) || document.pictureInPictureElement === video);
35}
36
37function createPlaybackHost() {
38 let entries: PlaybackEntry[] = [];
39 let nextKey = 0;
40 let mounted = false;
41 let lastPlayed: PlaybackEntry | null = null;
42 let floatingHost: HTMLSpanElement | null = null;
43 const listeners = new Set<() => void>();
44
45 const publish = () => {
46 entries = [...entries];
47 listeners.forEach((listener) => listener());
48 };
49
50 const release = (entry: PlaybackEntry, videos = entry.container.querySelectorAll("video")) => {
51 if (!entries.includes(entry)) return;
52
53 entries = entries.filter((candidate) => candidate !== entry);
54 if (lastPlayed === entry) lastPlayed = null;
55 for (const video of videos) {
56 if (document.pictureInPictureElement === video) {
57 void document.exitPictureInPicture?.().catch(() => undefined);
58 }
59 video.pause();
60 video.removeAttribute("src");
61 video.load();
62 }
63 entry.container.remove();
64 publish();
65 };
66
67 return {
68 subscribe(listener: () => void) {
69 listeners.add(listener);
70 return () => listeners.delete(listener);
71 },
72 getSnapshot: () => entries,
73 setFloatingHost(element: HTMLSpanElement | null) {
74 floatingHost = element;
75 },
76 mount() {
77 mounted = true;
78 return () => {
79 mounted = false;
80 const snapshots = entries.map((entry) => ({
81 entry,
82 videos: entry.container.querySelectorAll("video"),
83 }));
84 // StrictMode reconnects effects before this microtask can dispose the host.
85 queueMicrotask(() => {
86 if (!mounted) snapshots.forEach(({ entry, videos }) => release(entry, videos));
87 });
88 };
89 },
90 attach(identity: string, slot: HTMLSpanElement) {
91 let entry = entries.find((candidate) => candidate.identity === identity && !candidate.slot);
92 if (!entry) {
93 const container = document.createElement("span");
94 container.className = "contents";
95 entry = { identity, container, key: nextKey++, slot, children: null };
96 const createdEntry = entry;
97 container.addEventListener(
98 "play",
99 () => {
100 lastPlayed = createdEntry;
101 },
102 true,
103 );
104 container.addEventListener(
105 "ended",
106 () => {
107 if (!createdEntry.slot) release(createdEntry);
108 },
109 true,
110 );
111 entries = [...entries, entry];
112 }
113
114 entry.slot = slot;
115 moveContainer(entry, slot);
116 publish();
117 return entry;
118 },
119 update(entry: PlaybackEntry, children: ReactNode) {
120 if (entry.children === children || !entries.includes(entry)) return;
121 entry.children = children;
122 publish();
123 },
124 detach(entry: PlaybackEntry, slot: HTMLSpanElement) {
125 if (entry.slot !== slot || !entries.includes(entry)) return;
126 entry.slot = null;
127 const keepPlaying = hasRetainedPlayback(entry);
128
129 if (keepPlaying) {
130 if (
131 lastPlayed &&
132 lastPlayed !== entry &&
133 !lastPlayed.slot &&
134 hasRetainedPlayback(lastPlayed)
135 ) {
136 release(entry);
137 return;
138 }
139 entries
140 .filter((candidate) => candidate !== entry && !candidate.slot)
141 .forEach((candidate) => release(candidate));
142 }
143 if (floatingHost) moveContainer(entry, floatingHost);
144 publish();
145
146 if (!keepPlaying) {
147 queueMicrotask(() => {
148 if (!entry.slot) release(entry);
149 });
150 }
151 },
152 release,
153 };
154}
155
156const PlaybackContext = createContext<ReturnType<typeof createPlaybackHost> | null>(null);
157
158export function MediaPlaybackProvider({ children }: { readonly children: ReactNode }) {
159 const [host] = useState(createPlaybackHost);
160 const floatingRegionRef = useRef<HTMLElement>(null);
161 const entries = useSyncExternalStore(host.subscribe, host.getSnapshot, host.getSnapshot);
162 const floating = entries.find((entry) => !entry.slot);
163 const label = floating?.container.querySelector("video")?.getAttribute("aria-label") || "Video";
164
165 useLayoutEffect(() => host.mount(), [host]);
166 useLayoutEffect(() => {
167 const region = floatingRegionRef.current;
168 if (!region) return;
169 const closeOnEscape = (event: KeyboardEvent) => {
170 if (event.key !== "Escape" || region.contains(document.fullscreenElement)) return;
171 const entry = host.getSnapshot().find((candidate) => !candidate.slot);
172 if (!entry) return;
173 event.preventDefault();
174 event.stopPropagation();
175 host.release(entry);
176 };
177 // Portal controls bubble through React's owner tree, not this DOM region.
178 region.addEventListener("keydown", closeOnEscape, true);
179 return () => region.removeEventListener("keydown", closeOnEscape, true);
180 }, [host]);
181
182 return (
183 <PlaybackContext value={host}>
184 {children}
185 {entries.map((entry) => createPortal(entry.children, entry.container, String(entry.key)))}
186 <section
187 ref={floatingRegionRef}
188 role="region"
189 aria-label={`Picture in picture: ${label}`}
190 hidden={!floating}
191 data-media-floating-player
192 className="fixed right-4 z-50 overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
193 style={{
194 width: "min(22rem, calc(100vw - 2rem))",
195 bottom: "clamp(1rem, 24dvh, 12rem)",
196 maxHeight: "calc(100dvh - 2rem)",
197 }}
198 >
199 <div className="flex items-center gap-2 py-1 pr-1 pl-3">
200 <span className="min-w-0 flex-1 truncate text-xs">{label}</span>
201 <Button
202 variant="ghost"
203 size="icon-xs"
204 aria-label="Close picture in picture"
205 onClick={() => {
206 if (floating) host.release(floating);
207 }}
208 >
209 <XIcon aria-hidden />
210 </Button>
211 </div>
212 <span
213 ref={(element) => host.setFloatingHost(element)}
214 className="block [&_[data-media-player]]:block! [&_[data-media-player]]:w-full! [&_[data-media-player]]:max-w-full! [&_video]:max-h-[min(40dvh,20rem)]! [&_video]:w-full! [&_video]:object-contain!"
215 />
216 </section>
217 </PlaybackContext>
218 );
219}
220
221function HostedPlaybackSlot({
222 identity,
223 children,
224 host,
225}: {
226 readonly identity: string;
227 readonly children: ReactNode;
228 readonly host: ReturnType<typeof createPlaybackHost>;
229}) {
230 const slotRef = useRef<HTMLSpanElement>(null);
231 const entryRef = useRef<PlaybackEntry | null>(null);
232
233 useLayoutEffect(() => {
234 const slot = slotRef.current;
235 if (!slot) return;
236 const entry = host.attach(identity, slot);
237 entryRef.current = entry;
238 return () => {
239 entryRef.current = null;
240 host.detach(entry, slot);
241 };
242 }, [host, identity]);
243
244 useLayoutEffect(() => {
245 const entry = entryRef.current;
246 if (entry?.identity === identity) host.update(entry, children);
247 }, [host, identity, children]);
248
249 return <span ref={slotRef} className="contents" data-media-playback-slot={identity} />;
250}
251
252export function MediaPlaybackSlot(props: {
253 readonly identity: string;
254 readonly children: ReactNode;
255}) {
256 const host = useContext(PlaybackContext);
257 return host ? <HostedPlaybackSlot {...props} host={host} /> : props.children;
258}
apps/web/src/components/media/MediaVideoControls.test.tsx created+139
......@@ -0,0 +1,139 @@
1// @vitest-environment jsdom
2import { act } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5
6vi.mock("./MediaActions", () => ({ useMediaActionMenu: () => ({ showMenu: vi.fn() }) }));
7
8import { MediaVideoControls } from "./MediaVideoControls";
9
10let root: Root;
11let container: HTMLDivElement;
12let video: HTMLVideoElement;
13
14beforeEach(async () => {
15 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
16 container = document.createElement("div");
17 video = document.createElement("video");
18 document.body.append(video, container);
19 let paused = true;
20 Object.defineProperties(video, {
21 paused: { get: () => paused },
22 duration: { value: 125, configurable: true },
23 play: {
24 value: vi.fn(async () => {
25 paused = false;
26 video.dispatchEvent(new Event("play"));
27 }),
28 },
29 pause: {
30 value: vi.fn(() => {
31 paused = true;
32 video.dispatchEvent(new Event("pause"));
33 }),
34 },
35 });
36 root = createRoot(container);
37 await act(() => root.render(<MediaVideoControls video={video} actionsSource={undefined} />));
38});
39
40afterEach(async () => {
41 await act(() => root.unmount());
42 container.remove();
43 video.remove();
44 Reflect.deleteProperty(document, "fullscreenElement");
45 Reflect.deleteProperty(document, "exitFullscreen");
46 vi.unstubAllGlobals();
47});
48
49function button(name: string) {
50 const node = container.querySelector<HTMLButtonElement>(`button[aria-label="${name}"]`);
51 expect(node, name).not.toBeNull();
52 return node!;
53}
54
55async function change(label: string, value: string) {
56 const input = container.querySelector<HTMLInputElement | HTMLSelectElement>(
57 `[aria-label="${label}"]`,
58 )!;
59 const prototype =
60 input instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLSelectElement.prototype;
61 await act(() => {
62 Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(input, value);
63 input.dispatchEvent(
64 new Event(input instanceof HTMLInputElement ? "input" : "change", { bubbles: true }),
65 );
66 });
67}
68
69it("plays and pauses the actual media element and follows external playback changes", async () => {
70 await act(async () => button("Play video").click());
71 expect(video.play).toHaveBeenCalledOnce();
72 expect(video.paused).toBe(false);
73 await act(() => button("Pause video").click());
74 expect(video.pause).toHaveBeenCalledOnce();
75 expect(button("Play video")).toBeDefined();
76 await act(async () => video.play());
77 expect(button("Pause video")).toBeDefined();
78});
79
80it("seeks without replacing the source and publishes the current time accessibly", async () => {
81 video.src = "https://media.test/clip.mp4";
82 await change("Seek video", "42.5");
83 expect(video.currentTime).toBe(42.5);
84 expect(video.src).toBe("https://media.test/clip.mp4");
85 await act(() => video.dispatchEvent(new Event("timeupdate")));
86 expect(container.querySelector('[aria-label="Seek video"]')?.getAttribute("aria-valuetext")).toBe(
87 "0:42 of 2:05",
88 );
89});
90
91it("exposes volume and speed at every player width, and applies both to the video", async () => {
92 expect(button("Video settings").getAttribute("aria-expanded")).toBe("false");
93 await act(() => button("Video settings").click());
94 expect(button("Video settings").getAttribute("aria-expanded")).toBe("true");
95 await change("Video volume", "0.35");
96 expect(video.volume).toBe(0.35);
97 expect(video.muted).toBe(false);
98 await change("Playback speed", "1.5");
99 expect(video.playbackRate).toBe(1.5);
100 await act(() => button("Mute video").click());
101 expect(video.muted).toBe(true);
102 await act(() => video.dispatchEvent(new Event("volumechange")));
103 await act(() => button("Unmute video").click());
104 expect(video.muted).toBe(false);
105});
106
107it("keeps Play available after a rejected play request", async () => {
108 vi.mocked(video.play).mockRejectedValueOnce(new Error("gesture denied"));
109 await act(async () => button("Play video").click());
110 expect(container.querySelector('[role="alert"]')?.textContent).toContain("Try again");
111 await act(async () => button("Play video").click());
112 expect(container.querySelector('[role="alert"]')).toBeNull();
113 expect(button("Pause video")).toBeDefined();
114});
115
116it("disables seeking until the video has a finite duration", async () => {
117 Object.defineProperty(video, "duration", { value: Infinity });
118 await act(() => video.dispatchEvent(new Event("durationchange")));
119 expect(container.querySelector<HTMLInputElement>('[aria-label="Seek video"]')?.disabled).toBe(
120 true,
121 );
122});
123
124it("exits fullscreen with Escape from a focused playback control without stopping the video", async () => {
125 Object.defineProperty(document, "fullscreenElement", { configurable: true, value: video });
126 const exit = vi.fn(async () => {
127 Reflect.deleteProperty(document, "fullscreenElement");
128 document.dispatchEvent(new Event("fullscreenchange"));
129 });
130 Object.defineProperty(document, "exitFullscreen", { configurable: true, value: exit });
131 await act(async () => button("Play video").click());
132 const control = button("Pause video");
133 control.focus();
134 await act(async () =>
135 control.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })),
136 );
137 expect(exit).toHaveBeenCalledOnce();
138 expect(video.paused).toBe(false);
139});
apps/web/src/components/media/MediaVideoControls.tsx created+255
......@@ -0,0 +1,255 @@
1/* oxlint-disable react/immutability -- Media elements expose playback controls as mutable DOM properties. */
2import {
3 MaximizeIcon,
4 MinimizeIcon,
5 MoreHorizontalIcon,
6 PauseIcon,
7 PictureInPicture2Icon,
8 PlayIcon,
9 SlidersHorizontalIcon,
10 Volume2Icon,
11 VolumeXIcon,
12} from "lucide-react";
13import { useEffect, useState } from "react";
14
15import { Button } from "../ui/button";
16import { useMediaActionMenu, type MediaActionSource } from "./MediaActions";
17
18function mediaTime(seconds: number): string {
19 const time = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0;
20 const minutes = Math.floor(time / 60);
21 const hours = minutes >= 60 ? `${Math.floor(minutes / 60)}:` : "";
22 const minuteText = String(minutes % 60).padStart(hours ? 2 : 1, "0");
23 return `${hours}${minuteText}:${String(time % 60).padStart(2, "0")}`;
24}
25
26function readVideoState(video: HTMLVideoElement) {
27 return {
28 paused: video.paused || video.ended,
29 time: video.currentTime,
30 duration: Number.isFinite(video.duration) ? video.duration : 0,
31 volume: video.muted ? 0 : video.volume,
32 rate: video.playbackRate,
33 fullscreen: document.fullscreenElement?.contains(video) ?? false,
34 pip: document.pictureInPictureElement === video,
35 };
36}
37
38function VideoActionsButton({ source }: { readonly source: MediaActionSource }) {
39 const { showMenu } = useMediaActionMenu(source);
40 return (
41 <Button
42 size="icon-sm"
43 variant="ghost"
44 aria-label="Video actions"
45 aria-haspopup="menu"
46 title="Video actions"
47 onClick={(event) => {
48 const bounds = event.currentTarget.getBoundingClientRect();
49 void showMenu({ x: bounds.right, y: bounds.bottom });
50 }}
51 >
52 <MoreHorizontalIcon aria-hidden />
53 </Button>
54 );
55}
56
57export function MediaVideoControls({
58 video,
59 actionsSource,
60}: {
61 readonly video: HTMLVideoElement;
62 readonly actionsSource: MediaActionSource | undefined;
63}) {
64 const [state, setState] = useState(() => readVideoState(video));
65 const [error, setError] = useState<string | null>(null);
66 const [settingsOpen, setSettingsOpen] = useState(false);
67
68 useEffect(() => {
69 const update = () => setState(readVideoState(video));
70 const events = [
71 "play",
72 "pause",
73 "ended",
74 "timeupdate",
75 "loadedmetadata",
76 "durationchange",
77 "volumechange",
78 "ratechange",
79 "enterpictureinpicture",
80 "leavepictureinpicture",
81 ];
82 update();
83 for (const event of events) video.addEventListener(event, update);
84 document.addEventListener("fullscreenchange", update);
85 return () => {
86 for (const event of events) video.removeEventListener(event, update);
87 document.removeEventListener("fullscreenchange", update);
88 };
89 }, [video]);
90
91 const play = async () => {
92 setError(null);
93 try {
94 if (video.paused || video.ended) await video.play();
95 else video.pause();
96 } catch {
97 setError("Video could not play. Try again.");
98 }
99 };
100 const fullscreen = async () => {
101 setError(null);
102 try {
103 if (document.fullscreenElement?.contains(video)) await document.exitFullscreen();
104 else await video.closest("[data-media-player]")?.requestFullscreen();
105 } catch {
106 setError("Fullscreen is unavailable. Try opening the video separately.");
107 }
108 };
109 const pictureInPicture = async () => {
110 setError(null);
111 try {
112 if (document.pictureInPictureElement === video) await document.exitPictureInPicture();
113 else await video.requestPictureInPicture();
114 } catch {
115 setError("Picture in picture is unavailable. Keep playing here or switch chats to float it.");
116 }
117 };
118
119 return (
120 <span
121 data-media-video-controls
122 onKeyDown={(event) => {
123 if (event.key !== "Escape" || !document.fullscreenElement?.contains(video)) return;
124 event.preventDefault();
125 event.stopPropagation();
126 void fullscreen();
127 }}
128 >
129 <input
130 type="range"
131 min={0}
132 max={state.duration || 1}
133 step={0.1}
134 value={Math.min(state.time, state.duration)}
135 disabled={state.duration === 0}
136 aria-label="Seek video"
137 aria-valuetext={`${mediaTime(state.time)} of ${mediaTime(state.duration)}`}
138 onChange={(event) => {
139 video.currentTime = Number(event.currentTarget.value);
140 }}
141 data-media-video-seek
142 />
143 <span className="flex min-w-0 flex-wrap items-center gap-0.5">
144 <Button
145 size="icon-sm"
146 variant="ghost"
147 aria-label={state.paused ? "Play video" : "Pause video"}
148 title={state.paused ? "Play" : "Pause"}
149 onClick={() => void play()}
150 >
151 {state.paused ? <PlayIcon aria-hidden /> : <PauseIcon aria-hidden />}
152 </Button>
153 <span className="min-w-0 truncate px-1 text-xs tabular-nums" aria-hidden="true">
154 {mediaTime(state.time)} / {mediaTime(state.duration)}
155 </span>
156 <span className="flex-1" />
157 <Button
158 size="icon-sm"
159 variant="ghost"
160 aria-label={state.volume === 0 ? "Unmute video" : "Mute video"}
161 title={state.volume === 0 ? "Unmute" : "Mute"}
162 onClick={() => {
163 if (video.muted || video.volume === 0) {
164 video.muted = false;
165 if (video.volume === 0) video.volume = 1;
166 } else video.muted = true;
167 }}
168 >
169 {state.volume === 0 ? <VolumeXIcon aria-hidden /> : <Volume2Icon aria-hidden />}
170 </Button>
171 <Button
172 size="icon-sm"
173 variant="ghost"
174 aria-label="Video settings"
175 title="Video settings"
176 aria-expanded={settingsOpen}
177 onClick={() => setSettingsOpen((open) => !open)}
178 >
179 <SlidersHorizontalIcon aria-hidden />
180 </Button>
181 {document.pictureInPictureEnabled && typeof video.requestPictureInPicture === "function" ? (
182 <Button
183 size="icon-sm"
184 variant="ghost"
185 aria-label={state.pip ? "Exit picture in picture" : "Picture in picture"}
186 title="Picture in picture"
187 aria-pressed={state.pip}
188 onClick={() => void pictureInPicture()}
189 >
190 <PictureInPicture2Icon aria-hidden />
191 </Button>
192 ) : null}
193 {document.fullscreenEnabled ? (
194 <Button
195 size="icon-sm"
196 variant="ghost"
197 aria-label={state.fullscreen ? "Exit fullscreen" : "Fullscreen video"}
198 title={state.fullscreen ? "Exit fullscreen" : "Fullscreen"}
199 onClick={() => void fullscreen()}
200 >
201 {state.fullscreen ? <MinimizeIcon aria-hidden /> : <MaximizeIcon aria-hidden />}
202 </Button>
203 ) : null}
204 {actionsSource ? <VideoActionsButton source={actionsSource} /> : null}
205 </span>
206 {settingsOpen ? (
207 <span
208 role="group"
209 aria-label="Video settings"
210 className="flex flex-wrap items-center gap-2 px-1 py-1 text-xs"
211 >
212 <label className="flex min-w-0 flex-1 items-center gap-2 whitespace-nowrap">
213 Volume
214 <input
215 type="range"
216 min={0}
217 max={1}
218 step={0.05}
219 value={state.volume}
220 aria-label="Video volume"
221 aria-valuetext={`${Math.round(state.volume * 100)}%`}
222 data-media-video-volume
223 onChange={(event) => {
224 video.muted = false;
225 video.volume = Number(event.currentTarget.value);
226 }}
227 />
228 </label>
229 <label className="flex items-center gap-2">
230 Speed
231 <select
232 aria-label="Playback speed"
233 data-media-video-speed
234 value={state.rate}
235 onChange={(event) => {
236 video.playbackRate = Number(event.currentTarget.value);
237 }}
238 >
239 {[0.5, 0.75, 1, 1.25, 1.5, 2].map((rate) => (
240 <option key={rate} value={rate}>
241 {rate}×
242 </option>
243 ))}
244 </select>
245 </label>
246 </span>
247 ) : null}
248 {error ? (
249 <span role="alert" className="px-1 text-xs text-destructive-foreground">
250 {error}
251 </span>
252 ) : null}
253 </span>
254 );
255}
apps/web/src/components/media/MediaVideoPlayer.css created+76
......@@ -0,0 +1,76 @@
1[data-media-video-controls] {
2 display: flex;
3 width: 100%;
4 flex: none;
5 flex-direction: column;
6 gap: 0.125rem;
7 padding: 0.25rem 0.375rem;
8 border: 1px solid var(--border);
9 border-top: 0;
10 border-radius: 0 0 var(--radius-lg) var(--radius-lg);
11 background: var(--muted);
12 color: var(--foreground);
13}
14
15[data-media-player] > video {
16 min-height: 0;
17 border-bottom-right-radius: 0;
18 border-bottom-left-radius: 0;
19}
20
21[data-media-video-seek],
22[data-media-video-volume] {
23 min-width: 0;
24 height: 1.5rem;
25 cursor: pointer;
26 accent-color: var(--primary);
27}
28
29[data-media-video-seek] {
30 width: 100%;
31}
32
33[data-media-video-volume],
34[data-media-video-speed] {
35 display: block;
36}
37
38[data-media-video-volume] {
39 width: 100%;
40 min-width: 3rem;
41}
42
43[data-media-video-speed] {
44 max-width: 4rem;
45 height: 1.75rem;
46 border: 0;
47 border-radius: var(--radius-sm);
48 background: var(--muted);
49 color: inherit;
50 font: inherit;
51 font-size: 0.75rem;
52}
53
54[data-media-video-seek]:focus-visible,
55[data-media-video-volume]:focus-visible,
56[data-media-video-speed]:focus-visible {
57 outline: 2px solid var(--ring);
58 outline-offset: 2px;
59}
60
61[data-media-player]:fullscreen {
62 display: flex;
63 width: 100%;
64 max-width: none;
65 height: 100%;
66 max-height: none;
67 justify-content: center;
68 background: black;
69}
70
71[data-media-player]:fullscreen > video {
72 flex: 1;
73 width: 100%;
74 min-height: 0;
75 max-height: calc(100% - 4rem);
76}
apps/web/src/components/media/MediaVideoPlayer.tsx+64-13
......@@ -1,4 +1,5 @@
11import { PlayIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react";
2import { mediaUrlReference } from "@t3tools/client-runtime/media-reference";
23import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
34
45import { cn } from "../../lib/utils";
......@@ -6,6 +7,9 @@ import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
67import { Button } from "../ui/button";
78import { OpenMediaLink } from "./OpenMediaLink";
89import { MediaActions, type MediaActionSource } from "./MediaActions";
10import { MediaPlaybackSlot } from "./MediaPlaybackHost";
11import { MediaVideoControls } from "./MediaVideoControls";
12import "./MediaVideoPlayer.css";
913
1014interface MediaVideoPlayerProps {
1115 readonly src: string | null;
......@@ -27,8 +31,21 @@ interface MediaVideoPlayerProps {
2731 readonly actionsSource?: MediaActionSource | undefined;
2832}
2933
30/** Keeps native range streaming and playback state consistent across inline and file previews. */
31export function MediaVideoPlayer({
34export function MediaVideoPlayer(props: MediaVideoPlayerProps) {
35 const player = <MediaVideoPlayerContent {...props} />;
36 if (props.onOpen) return player;
37
38 const source = props.actionsSource;
39 const identity = JSON.stringify([
40 source?.asset?.environmentId,
41 source?.asset?.resource ?? source?.reference ?? props.originalUrl ?? props.src ?? props.label,
42 props.label,
43 props.copyMarkdown,
44 ]);
45 return <MediaPlaybackSlot identity={identity}>{player}</MediaPlaybackSlot>;
46}
47
48function MediaVideoPlayerContent({
3249 src: latestSrc,
3350 label,
3451 sourceFailed = false,
......@@ -46,6 +63,11 @@ export function MediaVideoPlayer({
4663 actionsSource,
4764}: MediaVideoPlayerProps) {
4865 const videoRef = useRef<HTMLVideoElement>(null);
66 const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null);
67 const attachVideo = useCallback((video: HTMLVideoElement | null) => {
68 videoRef.current = video;
69 setVideoElement(video);
70 }, []);
4971 const [playbackSource, setPlaybackSource] = useState<{
5072 src: string;
5173 revision: string | null;
......@@ -54,9 +76,11 @@ export function MediaVideoPlayer({
5476 const [retrying, setRetrying] = useState(false);
5577 const [loadAttempt, setLoadAttempt] = useState(0);
5678 const [preloadedSrc, setPreloadedSrc] = useState<string | null>(null);
79 const [videoSize, setVideoSize] = useState<{ src: string; ratio: number } | null>(null);
5780 const src = playbackSource?.src ?? latestSrc;
5881 const sourceRevision = playbackSource === null ? revision : playbackSource.revision;
5982 const failed = src !== null ? failedSrc === src : sourceFailed;
83 const fallbackReference = mediaUrlReference(originalUrl ?? src ?? "");
6084
6185 // Re-signing must not reset the playhead. Changed files refresh once playback pauses.
6286 const refreshPausedRevision = useCallback(() => {
......@@ -100,7 +124,8 @@ export function MediaVideoPlayer({
100124 const fullscreen =
101125 document.fullscreenElement?.contains(video) ||
102126 ("webkitDisplayingFullscreen" in video && video.webkitDisplayingFullscreen === true);
103 if (document.hidden && !fullscreen) video.pause();
127 if (document.hidden && !fullscreen && document.pictureInPictureElement !== video)
128 video.pause();
104129 };
105130 document.addEventListener("visibilitychange", pauseWhenHidden);
106131 document.addEventListener("fullscreenchange", pauseWhenHidden);
......@@ -130,17 +155,21 @@ export function MediaVideoPlayer({
130155
131156 const player = (
132157 <span
133 className={cn("relative inline-block align-middle", className)}
134 style={style}
158 data-media-player
159 className={cn("relative inline-flex flex-col align-middle", className)}
160 style={
161 {
162 ...style,
163 "--media-video-ratio": videoSize?.src === src ? videoSize.ratio : 16 / 9,
164 } as CSSProperties
165 }
135166 data-markdown-copy={copyMarkdown}
136167 >
137168 {failed && !onOpen ? (
138169 <span
139170 role="alert"
140171 className={cn(
141 // Same 16:9 slot as the loading and playing states, so a failed or
142 // retried video does not move the rows below it.
143 "flex aspect-video max-h-full min-h-28 w-full flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground",
172 "flex aspect-(--media-video-ratio) max-h-full min-h-28 w-full flex-col items-center justify-center gap-3 rounded-lg border border-border/40 bg-muted/40 p-4 text-center text-sm text-muted-foreground",
144173 stateClassName,
145174 )}
146175 >
......@@ -166,22 +195,28 @@ export function MediaVideoPlayer({
166195 ) : src !== null && !failed ? (
167196 <video
168197 key={loadAttempt}
169 ref={videoRef}
198 ref={attachVideo}
170199 src={src}
171200 aria-label={label || "Video preview"}
172201 aria-hidden={onOpen ? true : undefined}
173202 autoPlay={onOpen ? false : autoPlay}
174 controls={!onOpen}
203 controls={false}
175204 muted={onOpen ? true : undefined}
176205 playsInline
177206 preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"}
178207 className={cn(
179 "aspect-video max-h-full w-full bg-black object-contain",
208 "aspect-[auto_16/9] h-auto max-h-full w-full bg-black object-contain",
180209 onOpen && "pointer-events-none",
181210 videoClassName,
182211 )}
183212 style={style}
184 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}
213 onLoadedMetadata={(event) => {
214 const video = event.currentTarget;
215 if (video.videoWidth > 0 && video.videoHeight > 0) {
216 setVideoSize({ src, ratio: video.videoWidth / video.videoHeight });
217 }
218 prepareVideoFirstFrame(video);
219 }}
185220 onPlay={() => setPlaybackSource({ src, revision: sourceRevision })}
186221 onPause={refreshPausedRevision}
187222 onEnded={refreshPausedRevision}
......@@ -194,10 +229,26 @@ export function MediaVideoPlayer({
194229 <span
195230 role="status"
196231 aria-label={failed ? "Video preview unavailable" : "Loading video"}
197 className={cn("block aspect-video w-full rounded-lg bg-muted/60", stateClassName)}
232 className={cn(
233 "block aspect-(--media-video-ratio) w-full rounded-lg bg-muted/60",
234 stateClassName,
235 )}
198236 style={style}
199237 />
200238 )}
239 {!onOpen && videoElement && !failed ? (
240 <MediaVideoControls
241 video={videoElement}
242 actionsSource={
243 actionsSource ?? {
244 kind: "video",
245 name: label,
246 src,
247 ...(fallbackReference ? { reference: fallbackReference } : {}),
248 }
249 }
250 />
251 ) : null}
201252 {onOpen ? (
202253 <button
203254 type="button"
apps/web/src/components/settings/settingsSearch.ts+1-1
......@@ -451,7 +451,7 @@ export const SETTINGS_SEARCH_ITEMS = [
451451 id: "composer-rich-text",
452452 title: "Rich text composer",
453453 to: "/settings/general",
454 searchTerms: ["composer rich text tiptap bold italic markdown styled wysiwyg"],
454 searchTerms: ["composer rich text markdown bold italic styled preview"],
455455 },
456456 {
457457 id: "composer-collapse",
apps/web/src/composer-block-shortcuts.test.ts deleted-126
......@@ -1,126 +0,0 @@
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 deleted-391
......@@ -1,391 +0,0 @@
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 deleted-254
......@@ -1,254 +0,0 @@
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-list-continuation.test.ts deleted-85
......@@ -1,85 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 listContinuationForEnter,
5 listIndentForTab,
6 nextOrderedMarkerText,
7} from "./composer-list-continuation";
8
9function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) {
10 return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end);
11}
12
13describe("composer list continuation", () => {
14 it("continues ordered lists with the next number", () => {
15 const value = "1. foo";
16 const edit = listContinuationForEnter(value, value.length);
17 expect(edit).not.toBeNull();
18 expect(applyEdit(value, edit!)).toBe("1. foo\n2. ");
19 });
20
21 it("increments multi-digit and paren markers", () => {
22 expect(applyEdit("12. foo", listContinuationForEnter("12. foo", 7)!)).toBe("12. foo\n13. ");
23 expect(applyEdit("3) foo", listContinuationForEnter("3) foo", 6)!)).toBe("3) foo\n4) ");
24 });
25
26 it("continues bullets and keeps indentation", () => {
27 expect(applyEdit("- foo", listContinuationForEnter("- foo", 5)!)).toBe("- foo\n- ");
28 expect(applyEdit(" * foo", listContinuationForEnter(" * foo", 7)!)).toBe(" * foo\n * ");
29 });
30
31 it("continues tasks unchecked", () => {
32 expect(applyEdit("- [x] done", listContinuationForEnter("- [x] done", 10)!)).toBe(
33 "- [x] done\n- [ ] ",
34 );
35 });
36
37 it("splits mid-line items", () => {
38 expect(applyEdit("1. foobar", listContinuationForEnter("1. foobar", 5)!)).toBe(
39 "1. fo\n2. obar",
40 );
41 });
42
43 it("exits the list on an empty item", () => {
44 expect(applyEdit("1. foo\n2. ", listContinuationForEnter("1. foo\n2. ", 10)!)).toBe("1. foo\n");
45 expect(applyEdit("- ", listContinuationForEnter("- ", 2)!)).toBe("");
46 });
47
48 it("ignores non-list lines and carets inside the marker", () => {
49 expect(listContinuationForEnter("plain text", 5)).toBeNull();
50 expect(listContinuationForEnter("1.foo no space", 3)).toBeNull();
51 expect(listContinuationForEnter("-foo", 2)).toBeNull();
52 expect(listContinuationForEnter("1. foo", 1)).toBeNull();
53 });
54
55 it("refuses to split a supplementary currency skill chip", () => {
56 const value = "1. 𑿝review go";
57 const cursor = value.indexOf(" go") - 1;
58 expect(listContinuationForEnter(value, cursor)).toBeNull();
59 });
60
61 it("refuses to split an inline chip", () => {
62 const value = "1. @README.md go";
63 const cursor = value.indexOf("README") + 2;
64 expect(listContinuationForEnter(value, cursor)).toBeNull();
65 });
66
67 it("indents list items on Tab", () => {
68 expect(applyEdit("- foo", listIndentForTab("- foo", 2, 2)!)).toBe(" - foo");
69 expect(listIndentForTab("plain", 2, 2)).toBeNull();
70 expect(listIndentForTab("- foo", 1, 3)).toBeNull();
71 });
72});
73
74describe("nextOrderedMarkerText", () => {
75 it.each([
76 ["1.", "2."],
77 ["1)", "2)"],
78 ["09.", "10."],
79 ["001)", "002)"],
80 ["99.", "100."],
81 ["99999999999999999999.", "99999999999999999999."],
82 ])("counts %s up to %s", (marker, expected) => {
83 expect(nextOrderedMarkerText(marker)).toBe(expected);
84 });
85});
apps/web/src/composer-list-continuation.ts deleted-147
......@@ -1,147 +0,0 @@
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
2
3/**
4 * List continuation and indentation for the composer.
5 *
6 * Implemented once at the ChatComposer level (store replacement), so both
7 * composer modes behave identically and serialize identically:
8 * Shift+Enter on a list item continues it, Enter on an empty item exits the
9 * list, and Tab indents the item. Plain Markdown markers only — no real list
10 * nodes anywhere.
11 */
12
13export interface ComposerListEdit {
14 /** Expanded (plain string) offsets into the prompt. */
15 start: number;
16 end: number;
17 replacement: string;
18}
19
20type ListMarker =
21 | { kind: "ordered"; indent: string; numberText: string; delimiter: "." | ")"; space: string }
22 | { kind: "task"; indent: string; space: string }
23 | { kind: "bullet"; indent: string; bullet: string; space: string };
24
25function parseListMarker(line: string): { marker: ListMarker; markerEnd: number } | null {
26 const indent = line.match(/^[ \t]*/)?.[0] ?? "";
27 const rest = line.slice(indent.length);
28 const ordered = rest.match(/^(\d+)([.)])((?:[ \t]+)|\s*$)/);
29 if (ordered && ordered[1] !== undefined && ordered[2] !== undefined) {
30 return {
31 marker: {
32 kind: "ordered",
33 indent,
34 numberText: ordered[1],
35 delimiter: ordered[2] === ")" ? ")" : ".",
36 space: ordered[3] ?? "",
37 },
38 markerEnd: indent.length + ordered[0].length,
39 };
40 }
41 const task = rest.match(/^-\s\[[ xX]\]((?:[ \t]+)|\s*$)/);
42 if (task) {
43 return {
44 marker: { kind: "task", indent, space: task[1] ?? "" },
45 markerEnd: indent.length + task[0].length,
46 };
47 }
48 const bullet = rest.match(/^([-*+])((?:[ \t]+)|\s*$)/);
49 if (bullet && bullet[1] !== undefined) {
50 return {
51 marker: { kind: "bullet", indent, bullet: bullet[1], space: bullet[2] ?? "" },
52 markerEnd: indent.length + bullet[0].length,
53 };
54 }
55 return null;
56}
57
58/**
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
73function nextMarkerText(marker: ListMarker): string {
74 if (marker.kind === "ordered") {
75 return `${marker.indent}${nextOrderedMarkerText(`${marker.numberText}${marker.delimiter}`)} `;
76 }
77 if (marker.kind === "task") {
78 return `${marker.indent}- [ ] `;
79 }
80 return `${marker.indent}${marker.bullet} `;
81}
82
83function segmentSource(
84 segment: ReturnType<typeof splitPromptIntoComposerSegments>[number],
85): string {
86 if (segment.type === "text") return segment.text;
87 return segment.source;
88}
89
90/** True when splitting at the caret would cut an inline chip in two. */
91function isInsideInlineToken(value: string, cursor: number): boolean {
92 let offset = 0;
93 for (const segment of splitPromptIntoComposerSegments(value)) {
94 const end = offset + segmentSource(segment).length;
95 if (segment.type !== "text" && cursor > offset && cursor < end) return true;
96 offset = end;
97 }
98 return false;
99}
100
101function currentLine(value: string, cursor: number): { start: number; end: number; text: string } {
102 const start = value.lastIndexOf("\n", cursor - 1) + 1;
103 const endIndex = value.indexOf("\n", cursor);
104 const end = endIndex === -1 ? value.length : endIndex;
105 return { start, end, text: value.slice(start, end) };
106}
107
108/**
109 * Enter on a list item line: continue the list, or exit it when the item is
110 * empty. Returns null for non-list lines, carets inside the marker, and
111 * carets inside an inline chip — all fall through to a plain newline.
112 */
113export function listContinuationForEnter(value: string, cursor: number): ComposerListEdit | null {
114 if (!Number.isInteger(cursor) || cursor < 0 || cursor > value.length) return null;
115 const line = currentLine(value, cursor);
116 const parsed = parseListMarker(line.text);
117 if (!parsed) return null;
118 const markerEnd = line.start + parsed.markerEnd;
119 if (cursor < markerEnd) return null;
120 if (isInsideInlineToken(value, cursor)) return null;
121 if (value.slice(markerEnd, line.end).trim() === "") {
122 // Empty item: Enter exits the list by removing the marker.
123 return { start: line.start, end: Math.max(cursor, markerEnd), replacement: "" };
124 }
125 return {
126 start: cursor,
127 end: cursor,
128 replacement: `\n${nextMarkerText(parsed.marker)}`,
129 };
130}
131
132/**
133 * Tab on a list item line: indent by two spaces. Ranged selections, non-list
134 * lines, and carets inside an inline chip fall through (Shift+Tab stays the
135 * plan-mode toggle and is handled before this is consulted).
136 */
137export function listIndentForTab(
138 value: string,
139 start: number,
140 end: number,
141): ComposerListEdit | null {
142 if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null;
143 const line = currentLine(value, start);
144 if (!parseListMarker(line.text)) return null;
145 if (isInsideInlineToken(value, start)) return null;
146 return { start: line.start, end: line.start, replacement: " " };
147}
apps/web/src/composer-list-split.test.ts deleted-265
......@@ -1,265 +0,0 @@
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-logic.test.ts+56
......@@ -872,3 +872,59 @@ describe("literal editor answer cursor state", () => {
872872 expect(composerStateAtPromptEnd("Use $my").trigger?.kind).toBe("skill");
873873 });
874874});
875
876describe("emoji composer triggers", () => {
877 it.each([":dizzy", "feeling :dizzy", "(:dizzy", "first\n:dizzy", ":+1", ":-1"])(
878 "opens for %j",
879 (text) => {
880 const trigger = detectComposerTrigger(text, text.length);
881 expect(trigger?.kind).toBe("emoji");
882 expect(text.slice(trigger!.rangeStart, trigger!.rangeEnd)).toMatch(/^:[a-z_+-]/i);
883 },
884 );
885
886 it.each([
887 ":",
888 "12:30",
889 "https://example.com:dizzy",
890 "http://:dizzy",
891 "hello:dizzy",
892 ":dizzy:",
893 "\\:dizzy",
894 "` :dizzy",
895 "`` :dizzy",
896 "```ts\n:dizzy",
897 "~~~\n:dizzy",
898 "````\n```\n:dizzy",
899 "`code\n:dizzy",
900 ])("does not open for times, URLs, escapes, or code: %j", (text) => {
901 expect(detectComposerTrigger(text, text.length)).toBeNull();
902 });
903
904 it.each([
905 "`code` :dizzy",
906 "\\`literal :dizzy",
907 "```\ncode\n```\n:dizzy",
908 "~~~\ncode\n~~~\n:dizzy",
909 ])("opens after leaving code: %j", (text) =>
910 expect(detectComposerTrigger(text, text.length)?.kind).toBe("emoji"),
911 );
912
913 it("replaces a shortcode in the middle of a sentence and preserves the suffix", () => {
914 const text = "before :dizzy after";
915 const trigger = detectComposerTrigger(text, text.indexOf(" after"))!;
916 expect(replaceTextRange(text, trigger.rangeStart, trigger.rangeEnd, "💫")).toEqual({
917 text: "before 💫 after",
918 cursor: "before 💫".length,
919 });
920 });
921
922 it("replaces the entire shortcode when the caret is inside it", () => {
923 const text = "before :dizzy: after";
924 const trigger = detectComposerTrigger(text, text.indexOf("zzy"))!;
925 expect(trigger.query).toBe("di");
926 expect(replaceTextRange(text, trigger.rangeStart, trigger.rangeEnd, "💫").text).toBe(
927 "before 💫 after",
928 );
929 });
930});
apps/web/src/composer-logic.ts+53-17
......@@ -11,7 +11,7 @@ import {
1111
1212import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings";
1313
14export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill";
14export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill" | "emoji";
1515export type ComposerSlashCommand = "model" | "plan" | "default";
1616export type ComposerSubmissionIntent = "foreground" | "background" | "alternate";
1717
......@@ -256,8 +256,60 @@ export function isCollapsedCursorAdjacentToInlineToken(
256256 return false;
257257}
258258
259function isEmojiQueryInCode(text: string, cursor: number): boolean {
260 let fence: string | null = null;
261 let codeDelimiter: string | null = null;
262
263 for (const line of text.slice(0, cursor).split("\n")) {
264 const fenceMatch = /^[ \t]{0,3}(`{3,}|~{3,})(.*)$/.exec(line);
265 if (fenceMatch) {
266 const delimiter = fenceMatch[1]!;
267 if (fence === null) {
268 fence = delimiter;
269 } else if (
270 delimiter[0] === fence[0] &&
271 delimiter.length >= fence.length &&
272 fenceMatch[2]!.trim() === ""
273 ) {
274 fence = null;
275 }
276 codeDelimiter = null;
277 continue;
278 }
279 if (fence !== null) continue;
280
281 for (let index = 0; index < line.length; index += 1) {
282 if (line[index] === "\\" && codeDelimiter === null) {
283 index += 1;
284 continue;
285 }
286 if (line[index] !== "`") continue;
287 const delimiter = /^`+/.exec(line.slice(index))![0];
288 if (codeDelimiter === null) codeDelimiter = delimiter;
289 else if (codeDelimiter === delimiter) codeDelimiter = null;
290 index += delimiter.length - 1;
291 }
292 }
293
294 return fence !== null || codeDelimiter !== null;
295}
296
259297export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
260298 const cursor = clampCursor(text, cursorInput);
299 const emojiMatch = /(?:^|[\s([{])(:([a-z_+-][a-z0-9_+-]*))$/i.exec(text.slice(0, cursor));
300 if (emojiMatch && !isEmojiQueryInCode(text, cursor)) {
301 const shortcode = emojiMatch[1]!;
302 let rangeEnd = cursor;
303 while (rangeEnd < text.length && /[a-z0-9_+-]/i.test(text[rangeEnd]!)) rangeEnd += 1;
304 if (text[rangeEnd] === ":") rangeEnd += 1;
305 return {
306 kind: "emoji",
307 query: emojiMatch[2]!,
308 rangeStart: cursor - shortcode.length,
309 rangeEnd,
310 };
311 }
312
261313 const tokenStart = tokenStartForCursor(text, cursor);
262314 const token = text.slice(tokenStart, cursor);
263315 if (token.startsWith("/")) {
......@@ -298,22 +350,6 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
298350 };
299351}
300352
301/**
302 * Length of the leading part of `pasted` that continues an `@` path query
303 * typed at the end of `lineBefore`, the line's plain text up to the caret. A
304 * chip must read there as one non-space character, so it never passes for a
305 * typed `@path`.
306 */
307export function pastedPathQueryLength(
308 lineBefore: string,
309 pasted: string,
310 pathQueryActive = true,
311): number {
312 if (!pathQueryActive) return 0;
313 if (detectComposerTrigger(lineBefore, lineBefore.length)?.kind !== "path") return 0;
314 return /^\S*/.exec(pasted)![0].length;
315}
316
317353/** Caret and trigger after replacing composer text and continuing at the end. */
318354export function composerStateAtPromptEnd(
319355 text: string,
apps/web/src/composer-rich-text-doc.test.ts deleted-1122
......@@ -1,1122 +0,0 @@
1import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/core";
2import StarterKit from "@tiptap/starter-kit";
3import { TaskList } from "@tiptap/extension-task-list";
4import { Node as ProseMirrorNode } from "@tiptap/pm/model";
5import { EditorState, TextSelection } from "@tiptap/pm/state";
6import { splitBlockKeepMarks } from "@tiptap/pm/commands";
7import { describe, expect, it } from "vite-plus/test";
8
9import { collapseExpandedComposerCursor, pastedPathQueryLength } from "./composer-logic";
10
11import {
12 buildDocJson,
13 buildTiptapContent,
14 caretTakesMarksBefore,
15 ComposerBlockExtensions,
16 ComposerCodeBlockExtension,
17 ComposerListExtensions,
18 collapsedToFlat,
19 ComposerCodeExtension,
20 ComposerTaskItemExtension,
21 flatToCollapsed,
22 flatToMarkdown,
23 flatToPm,
24 pmToFlat,
25 serializeEditorDoc,
26 serializeSelection,
27 stepCaretAcrossStyledEdge,
28} from "./composer-rich-text-doc";
29
30function stubAtom(name: string, attrs: Record<string, { default: unknown }>) {
31 return Node.create({
32 name,
33 group: "inline",
34 inline: true,
35 atom: true,
36 addAttributes: () => attrs,
37 });
38}
39
40const schema = getSchemaByResolvedExtensions(
41 resolveExtensions([
42 StarterKit.configure({
43 blockquote: false,
44 bulletList: false,
45 codeBlock: false,
46 heading: false,
47 horizontalRule: false,
48 listItem: false,
49 orderedList: false,
50 dropcursor: false,
51 gapcursor: false,
52 trailingNode: false,
53 code: false,
54 }),
55 ComposerCodeExtension,
56 stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }),
57 stubAtom("composer-skill", {
58 skillName: { default: "" },
59 skillLabel: { default: "" },
60 skillDescription: { default: null },
61 }),
62 stubAtom("composer-citation", {
63 citation: { default: null },
64 source: { default: "" },
65 citeKey: { default: "" },
66 }),
67 stubAtom("composer-context-reference", {
68 kind: { default: "" },
69 contextId: { default: "" },
70 label: { default: "" },
71 source: { default: "" },
72 }),
73 TaskList,
74 ComposerTaskItemExtension,
75 ComposerCodeBlockExtension,
76 ...ComposerListExtensions,
77 ...ComposerBlockExtensions,
78 ]),
79);
80
81function roundTrip(value: string) {
82 const json = buildDocJson(value, (name) => ({ label: name, description: null }));
83 const doc = ProseMirrorNode.fromJSON(schema, json);
84 // `insertContent` validates every node against the schema; `fromJSON` does not.
85 doc.check();
86 return serializeEditorDoc(doc);
87}
88
89// Plain mode: the same engine with the mark extensions off. Markers stay
90// literal characters and task lines stay paragraphs.
91const plainSchema = getSchemaByResolvedExtensions(
92 resolveExtensions([
93 StarterKit.configure({
94 blockquote: false,
95 bulletList: false,
96 codeBlock: false,
97 heading: false,
98 horizontalRule: false,
99 listItem: false,
100 orderedList: false,
101 dropcursor: false,
102 gapcursor: false,
103 trailingNode: false,
104 bold: false,
105 italic: false,
106 strike: false,
107 code: false,
108 }),
109 stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }),
110 stubAtom("composer-skill", {
111 skillName: { default: "" },
112 skillLabel: { default: "" },
113 skillDescription: { default: null },
114 }),
115 stubAtom("composer-citation", {
116 citation: { default: null },
117 source: { default: "" },
118 citeKey: { default: "" },
119 }),
120 stubAtom("composer-context-reference", {
121 kind: { default: "" },
122 contextId: { default: "" },
123 label: { default: "" },
124 source: { default: "" },
125 }),
126 TaskList,
127 ComposerTaskItemExtension,
128 ]),
129);
130
131function roundTripPlain(value: string) {
132 const json = buildDocJson(value, (name) => ({ label: name, description: null }), {
133 styling: false,
134 });
135 const doc = ProseMirrorNode.fromJSON(plainSchema, json);
136 return serializeEditorDoc(doc);
137}
138
139describe("literal editor answers", () => {
140 const makeDoc = (value: string) =>
141 ProseMirrorNode.fromJSON(
142 plainSchema,
143 buildDocJson(value, (name) => ({ label: name, description: null }), {
144 literalText: true,
145 }),
146 );
147
148 it.each([
149 "",
150 "\n\n",
151 " - [X] done\n\n**keep** €my-skill @README.md \n",
152 "line one\r\nline two\r\n",
153 `- [X] done\n${" detail €my-skill @README.md\n".repeat(100)}`,
154 ])("preserves the complete answer through selection and serialization", (value) => {
155 const doc = makeDoc(value);
156 doc.check();
157 const serialized = serializeEditorDoc(doc);
158 expect(serialized.value).toBe(value);
159 expect(serialized.contextIds).toEqual([]);
160 expect(serializeSelection(doc, 1, doc.content.size - 1)).toBe(value);
161 for (let offset = 0; offset <= value.length; offset += 1) {
162 expect(flatToCollapsed(serialized, offset)).toBe(offset);
163 }
164 });
165
166 it("edits literal Markdown, inserts a newline, and preserves an explicitly cleared answer", () => {
167 const value = " - [X] done €my-skill @README.md ";
168 const doc = makeDoc(value);
169 let state = EditorState.create({
170 doc,
171 selection: TextSelection.create(doc, doc.content.size - 1),
172 });
173 state = state.apply(state.tr.insertText(" edited"));
174 expect(
175 splitBlockKeepMarks(state, (transaction) => {
176 state = state.apply(transaction);
177 }),
178 ).toBe(true);
179 state = state.apply(state.tr.insertText("second line "));
180 expect(serializeEditorDoc(state.doc).value).toBe(`${value} edited\nsecond line `);
181 state = state.apply(state.tr.delete(1, state.doc.content.size - 1));
182 expect(serializeEditorDoc(state.doc).value).toBe("");
183 });
184});
185
186describe("composer rich text document model", () => {
187 it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
188 "canonicalizes %s skill aliases while preserving amounts",
189 (prefix) => {
190 const value = `Use ${prefix}my-skill for ${prefix}20 please`;
191 const expected = `Use $my-skill for ${prefix}20 please`;
192 expect(roundTrip(value).value).toBe(expected);
193 expect(roundTripPlain(value).value).toBe(expected);
194 },
195 );
196
197 it.each([
198 "",
199 "\n\n",
200 "plain text",
201 "hello **bold** world",
202 "a *italic* word and `code` here",
203 "struck ~~out~~ now",
204 "**`x`**",
205 "*`x`*",
206 "~~`x`~~",
207 "**a `code` c**",
208 "***bold italic*** keeps nesting",
209 "line one\nline two",
210 "trailing newline\n",
211 "1. foo\n2. asdf\n",
212 "- [ ] buy milk",
213 "- [ ] buy milk",
214 "\t-\t[x]\t\titem",
215 "- [ ] ",
216 "- [ ]\n - [ ] child",
217 " - [ ] first\n - [ ] second\n - [ ] child",
218 "**before @README.md after**",
219 "*a **b** c*",
220 "*a**b***",
221 "**a*b***",
222 "**a *b* c**",
223 "literal \uFFFC **before @README.md after**",
224 "- [x] done\n- [ ] next",
225 "- [ ] parent\n - [ ] child\n - [ ] sibling\n- [ ] uncle",
226 "- [ ] empty task follows\n- [ ]",
227 "- [ ] **bold** task with @README.md",
228 "para\n- [ ] task\npara",
229 "- [ ]No space stays literal",
230 "-[ ] also literal",
231 "@README.md explain this",
232 '@"docs/My File.md" and $my-skill please',
233 "snake_case stays literal",
234 "unmatched ** stays literal",
235 "**bold** then @README.md then *italic*",
236 ])("round-trips %s through a real ProseMirror document", (value) => {
237 expect(roundTrip(value).value).toBe(value);
238 });
239
240 it.each([
241 "",
242 "\n",
243 "text\n\n",
244 "- [ ]\n- [ ] next\n",
245 "- [ ] parent\n - [ ] child\n- [ ]",
246 "para\n- [ ] task\npara",
247 "**before @README.md after**",
248 ])("maps editable positions in %s", (value) => {
249 const doc = ProseMirrorNode.fromJSON(
250 schema,
251 buildDocJson(value, (name) => ({ label: name, description: null })),
252 );
253 const map = serializeEditorDoc(doc);
254 for (let flat = 0; flat <= map.docLength; flat += 1) {
255 const position = flatToPm(map, flat);
256 expect(doc.resolve(position).parent.isTextblock).toBe(true);
257 expect(pmToFlat(map, position)).toBe(flat);
258 expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat);
259 }
260 });
261
262 it("keeps the caret after a trailing hard break inside the same paragraph", () => {
263 const doc = schema.node("doc", null, [
264 schema.node("paragraph", null, [schema.text("a"), schema.node("hardBreak")]),
265 ]);
266 const map = serializeEditorDoc(doc);
267 expect(flatToPm(map, map.docLength)).toBe(3);
268 expect(doc.resolve(flatToPm(map, map.docLength)).parent.isTextblock).toBe(true);
269 });
270
271 it("renders a leading task dedent as siblings and nests under the new indent", () => {
272 const doc = ProseMirrorNode.fromJSON(
273 schema,
274 buildDocJson(" - [ ] first\n - [ ] second\n - [ ] child", (name) => ({
275 label: name,
276 description: null,
277 })),
278 );
279 const list = doc.firstChild!;
280 expect(list.childCount).toBe(2);
281 expect(list.child(0).childCount).toBe(1);
282 expect(list.child(1).child(1).firstChild!.textContent).toBe("child");
283 });
284
285 it("applies a shared mark to text on both sides of a chip", () => {
286 const doc = ProseMirrorNode.fromJSON(
287 schema,
288 buildDocJson("**before @README.md after**", (name) => ({ label: name, description: null })),
289 );
290 expect(doc.firstChild!.childCount).toBe(3);
291 doc.firstChild!.forEach((child) =>
292 expect(child.marks.map((mark) => mark.type.name)).toContain("bold"),
293 );
294 expect(serializeEditorDoc(doc).value).toBe("**before @README.md after**");
295 });
296
297 it.each([
298 [["bold"], ["bold", "italic"], ["italic"]],
299 [["italic"], ["bold", "italic"], ["bold"]],
300 [["bold"], ["bold", "strike"], ["strike"]],
301 [["strike"], ["bold", "strike"], ["bold"]],
302 ])("preserves crossing mark ranges %j through controlled rebuilds", (...marks) => {
303 const doc = schema.node("doc", null, [
304 schema.node(
305 "paragraph",
306 null,
307 marks.map((names, index) =>
308 schema.text(
309 String.fromCharCode(97 + index),
310 names.map((name) => schema.mark(name)),
311 ),
312 ),
313 ),
314 ]);
315 const serialized = serializeEditorDoc(doc).value;
316 const rebuilt = ProseMirrorNode.fromJSON(
317 schema,
318 buildDocJson(serialized, (name) => ({ label: name, description: null })),
319 );
320 expect(rebuilt.eq(doc)).toBe(true);
321 expect(serializeEditorDoc(rebuilt).value).toBe(serialized);
322 });
323
324 it.each([
325 { parts: [{ text: "hello ", marks: ["bold"] }], expected: "**hello** " },
326 { parts: [{ text: " ", marks: ["bold"] }], expected: " " },
327 { parts: [{ text: " left ", marks: ["bold", "italic"] }], expected: " ***left*** " },
328 {
329 parts: [
330 { text: "one ", marks: ["bold"] },
331 { text: " two ", marks: ["bold", "italic"] },
332 { text: " three", marks: ["bold"] },
333 ],
334 expected: "**one *two* three**",
335 },
336 {
337 parts: [
338 { text: "hello ", marks: ["bold"] },
339 { text: "world ", marks: ["bold", "italic"] },
340 ],
341 expected: "**hello *world*** ",
342 },
343 { parts: [{ text: " hello ", marks: ["code"] }], expected: "` hello `" },
344 { parts: [{ text: " hello ", marks: ["bold", "code"] }], expected: "**` hello `**" },
345 { parts: [{ text: " ", marks: ["bold", "code"] }], expected: "**` `**" },
346 ])("keeps boundary whitespace outside emphasis in $expected", ({ parts, expected }) => {
347 const doc = schema.node("doc", null, [
348 schema.node(
349 "paragraph",
350 null,
351 parts.map(({ text, marks }) =>
352 schema.text(
353 text,
354 marks.map((name) => schema.mark(name)),
355 ),
356 ),
357 ),
358 ]);
359 const map = serializeEditorDoc(doc);
360 expect(map.value).toBe(expected);
361 const rebuilt = ProseMirrorNode.fromJSON(
362 schema,
363 buildDocJson(map.value, (name) => ({ label: name, description: null })),
364 );
365 expect(rebuilt.textContent).toBe(doc.textContent);
366 expect(serializeEditorDoc(rebuilt).value).toBe(map.value);
367 for (let flat = 0; flat <= map.docLength; flat += 1) {
368 expect(pmToFlat(map, flatToPm(map, flat))).toBe(flat);
369 expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat);
370 if (flat < map.docLength && !/\s/.test(doc.textContent[flat]!)) {
371 expect(rebuilt.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name)).toEqual(
372 doc.resolve(flat + 1).nodeAfter!.marks.map((mark) => mark.type.name),
373 );
374 }
375 }
376 });
377
378 it("keeps chip sources canonical through the document", () => {
379 const map = roundTrip("explain @README.md with **care**\nsecond line *here*");
380 expect(map.value).toBe("explain @README.md with **care**\nsecond line *here*");
381 expect(
382 map.runs.some((run) => run.kind === "token" && run.nodeName === "composer-mention"),
383 ).toBe(true);
384 });
385
386 it("normalizes uppercase checkboxes to lowercase", () => {
387 expect(roundTrip("- [X] done").value).toBe("- [x] done");
388 });
389
390 it.each([
391 "plain text",
392 "hello **bold** stays literal",
393 "a *italic* stays literal",
394 "some `code` stays literal",
395 "struck ~~out~~ stays literal",
396 "- [ ] stays a paragraph",
397 "- [x] stays a paragraph",
398 "line one\nline two",
399 "@README.md explain this",
400 "**bold** then @README.md then *italic*",
401 ])("round-trips %s byte-identically in plain mode", (value) => {
402 expect(roundTripPlain(value).value).toBe(value);
403 });
404
405 it.each([
406 "- one\n- two",
407 "* star\n* star two",
408 "+ plus",
409 "* star\n+ plus",
410 "1. first\n2. second",
411 "1) paren\n2) paren",
412 "3. starts at three\n4. four",
413 "01. zero padded\n02. two",
414 "- parent\n - child\n - sibling\n- uncle",
415 "1. ordered\n - bullet child\n2. next",
416 "- outer\n 1. inner ordered\n 2. more\n- outer again",
417 "- wide space item",
418 "-",
419 "1.",
420 "- \n- second",
421 "- **bold** item with @README.md",
422 "- [ ] task\n- bullet after",
423 "- bullet\n- [x] task after",
424 "para\n- item\npara",
425 "\t- tab indented\n\t- again",
426 " - leading indent only",
427 "- item\n\n- after a blank",
428 "- item one\n```ts\ncode\n```\n- item two",
429 "-no space stays literal",
430 "1.no space stays literal",
431 "10. ten\n11. eleven",
432 ])("round-trips the list %s through a real ProseMirror document", (value) => {
433 expect(roundTrip(value).value).toBe(value);
434 });
435
436 it.each([
437 "- one\n- two",
438 "1. first\n2. second",
439 "- parent\n - child",
440 "* star\n+ plus",
441 "-no space stays literal",
442 ])("keeps the list %s literal in plain mode", (value) => {
443 expect(roundTripPlain(value).value).toBe(value);
444 });
445
446 it.each(["- one\n- two", "1. a\n - b\n2. c", "- **bold** @README.md tail", "-"])(
447 "maps every document offset of the list %s through collapsed coordinates and back",
448 (value) => {
449 const map = roundTrip(value);
450 expect(map.value).toBe(value);
451 for (let flat = 0; flat <= map.docLength; flat += 1) {
452 expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat);
453 }
454 },
455 );
456
457 it("clamps offsets inside a list marker to the start of the item text", () => {
458 const value = "- item";
459 const map = roundTrip(value);
460 // The marker owns no document characters, like a checkbox.
461 for (let collapsed = 0; collapsed <= "- ".length; collapsed += 1) {
462 expect(collapsedToFlat(map, collapsed)).toBe(0);
463 }
464 expect(collapsedToFlat(map, "- it".length)).toBe(2);
465 expect(flatToCollapsed(map, 0)).toBe("- ".length);
466 });
467
468 it.each([
469 "> quoted",
470 "> line one\n> line two",
471 ">no space",
472 "> two spaces",
473 "> a\n>b",
474 ">",
475 "> **bold** and @README.md inside",
476 "> - looks like a list but stays quote text",
477 "> > nested stays literal inside the quote",
478 "before\n> quoted\nafter",
479 "> quote\n\n> another",
480 "- item\n> quote after list",
481 ])("round-trips the quote %s through a real ProseMirror document", (value) => {
482 expect(roundTrip(value).value).toBe(value);
483 });
484
485 it.each([
486 "---",
487 "***",
488 "___",
489 "- - -",
490 "* * *",
491 "-----",
492 "--- ",
493 "text\n---\nmore",
494 "- item\n---\n- item two",
495 "```\n---\n```",
496 "--",
497 "-- -",
498 "---text",
499 ])("round-trips the rule %s through a real ProseMirror document", (value) => {
500 expect(roundTrip(value).value).toBe(value);
501 });
502
503 it("parses rules ahead of lists and emphasis", () => {
504 const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({
505 label: n,
506 description: null,
507 }));
508 expect(json.content.map((block) => block.type)).toEqual([
509 "horizontalRule",
510 "horizontalRule",
511 "horizontalRule",
512 "paragraph",
513 "horizontalRule",
514 "bulletList",
515 ]);
516 });
517
518 it.each([
519 "# Heading",
520 "## Two",
521 "###### Six",
522 "####### seven hashes stays a paragraph",
523 "# two spaces",
524 "#\tTab",
525 "# Trailing hashes stay literal #",
526 "#1234",
527 "#1234 is a pull request, not a heading",
528 "# Heading with **bold** and @README.md",
529 "#",
530 "# ",
531 "text\n# Heading\ntext",
532 "# Heading\n- item\n> quote\n---",
533 ])("round-trips the heading %s through a real ProseMirror document", (value) => {
534 expect(roundTrip(value).value).toBe(value);
535 });
536
537 it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])(
538 "keeps the block %s literal in plain mode",
539 (value) => {
540 expect(roundTripPlain(value).value).toBe(value);
541 },
542 );
543
544 it("parses the blocks it renders as the right node types", () => {
545 const json = buildDocJson("# Title\n> quote\n---\n#1234 ref\n- - -", (n) => ({
546 label: n,
547 description: null,
548 }));
549 expect(json.content.map((block) => block.type)).toEqual([
550 "heading",
551 "blockquote",
552 "horizontalRule",
553 "paragraph",
554 "horizontalRule",
555 // The empty line a final rule keeps for the caret.
556 "paragraph",
557 ]);
558 });
559
560 it.each([
561 ["---", "---"],
562 ["a\n---", "a\n---"],
563 // That line and a trailing newline are the same document; the newline goes.
564 ["---\n", "---"],
565 ])("gives the caret a line after the final rule of %j, written %j", (value, expected) => {
566 const map = roundTrip(value);
567 expect(map.value).toBe(expected);
568 const json = buildDocJson(value, () => ({ label: "", description: null }));
569 const doc = ProseMirrorNode.fromJSON(schema, json);
570 expect(doc.resolve(flatToPm(map, map.docLength)).parent.isTextblock).toBe(true);
571 });
572
573 it("parses rules ahead of lists and emphasis", () => {
574 const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({
575 label: n,
576 description: null,
577 }));
578 expect(json.content.map((block) => block.type)).toEqual([
579 "horizontalRule",
580 "horizontalRule",
581 "horizontalRule",
582 "paragraph",
583 "horizontalRule",
584 "bulletList",
585 ]);
586 });
587
588 it.each([
589 "# Heading",
590 "## Two",
591 "###### Six",
592 "####### seven hashes stays a paragraph",
593 "# two spaces",
594 "#\tTab",
595 "# Trailing hashes stay literal #",
596 "#1234",
597 "#1234 is a pull request, not a heading",
598 "# Heading with **bold** and @README.md",
599 "#",
600 "# ",
601 "text\n# Heading\ntext",
602 "# Heading\n- item\n> quote\n---",
603 ])("round-trips the heading %s through a real ProseMirror document", (value) => {
604 expect(roundTrip(value).value).toBe(value);
605 });
606
607 it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])(
608 "keeps the block %s literal in plain mode",
609 (value) => {
610 expect(roundTripPlain(value).value).toBe(value);
611 },
612 );
613
614 it("parses a quote as a blockquote of one paragraph per line", () => {
615 const json = buildDocJson("> a\n> b\n>c", (n) => ({ label: n, description: null }));
616 expect(json.content.map((block) => block.type)).toEqual(["blockquote", "blockquote"]);
617 expect((json.content[0] as { content: unknown[] }).content).toHaveLength(2);
618 });
619
620 it.each([
621 "> a\n> b",
622 "> **q** @README.md",
623 "text\n---\nmore",
624 "---\ntext",
625 "# Heading text",
626 "## **b** @README.md",
627 ])(
628 "maps every document offset of the block %s through collapsed coordinates and back",
629 (value) => {
630 const map = roundTrip(value);
631 expect(map.value).toBe(value);
632 for (let flat = 0; flat <= map.docLength; flat += 1) {
633 expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat);
634 }
635 },
636 );
637
638 it("clamps offsets inside a quote or heading marker to the start of the text", () => {
639 for (const [value, prefix] of [
640 ["> quoted", "> "],
641 ["# Heading", "# "],
642 ] as const) {
643 const map = roundTrip(value);
644 for (let collapsed = 0; collapsed <= prefix.length; collapsed += 1) {
645 expect(collapsedToFlat(map, collapsed)).toBe(0);
646 }
647 expect(flatToCollapsed(map, 0)).toBe(prefix.length);
648 }
649 });
650
651 it.each([
652 "```\ncode\n```",
653 "```ts\nconst a = 1;\n```",
654 "```ts\nconst a = 1;\n```\n",
655 "before\n```ts\nconst a = 1;\n```\nafter",
656 "```\n```",
657 "```ts\nline one\nline two\nline three\n```",
658 "```ts\n indented\n deeper\n```",
659 "```js title=example\ncode\n```",
660 "~~~py\ncode\n~~~",
661 "````\n```\n````",
662 "```ts\ncode without a closing fence",
663 "```",
664 "```ts\n**not bold** and @README.md stay literal\n```",
665 "```ts\ncode\n````",
666 "- [ ] task\n```ts\ncode\n```\n- [ ] after",
667 " ```ts\nindented fence stays a paragraph\n ```",
668 ])("round-trips the fenced block %s", (value) => {
669 expect(roundTrip(value).value).toBe(value);
670 });
671
672 it.each(["```ts\nconst a = 1;\n```", "```\n```", "before\n```ts\ncode\n```\nafter"])(
673 "maps every document offset of %s through collapsed coordinates and back",
674 (value) => {
675 const map = roundTrip(value);
676 expect(map.value).toBe(value);
677 for (let flat = 0; flat <= map.docLength; flat += 1) {
678 expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat);
679 }
680 },
681 );
682
683 it("reads blocks written with CRLF line endings", () => {
684 const value = "# Title\r\n> quote\r\n---\r\n```ts\r\nconst a = 1;\r\n```\r\nafter";
685 const doc = buildDocJson(value, () => ({ label: "", description: null }));
686 expect(doc.content.map((block) => block.type)).toEqual([
687 "heading",
688 "blockquote",
689 "horizontalRule",
690 "codeBlock",
691 "paragraph",
692 ]);
693 expect(roundTrip(value).value).toBe(value);
694 });
695
696 it("keeps the end of the code inside the fence rather than after it", () => {
697 const value = "```ts\nfunc();\n```";
698 const map = roundTrip(value);
699 const endOfCode = "func();".length;
700 // Not the end of the string: the closing fence is a line of its own.
701 expect(flatToCollapsed(map, endOfCode)).toBe("```ts\nfunc();".length);
702 expect(flatToMarkdown(map, endOfCode)).toBe("```ts\nfunc();".length);
703 expect(collapsedToFlat(map, flatToCollapsed(map, endOfCode))).toBe(endOfCode);
704 });
705
706 it("keeps the caret inside an empty fence", () => {
707 const value = "before\n```\n```";
708 const map = roundTrip(value);
709 const inside = "before\n".length;
710 expect(flatToCollapsed(map, inside)).toBe("before\n```".length);
711 expect(collapsedToFlat(map, flatToCollapsed(map, inside))).toBe(inside);
712 });
713
714 it("still places the end of an inline mark after its markers", () => {
715 // The fence rule must not leak into inline marks, whose trailing edge is
716 // deliberately the position after the closing delimiter.
717 const map = roundTrip("a **bold** c");
718 expect(flatToMarkdown(map, 6)).toBe(10);
719 });
720
721 it("clamps offsets inside a fence to the edge of the code", () => {
722 const value = "```ts\nab\n```";
723 const map = roundTrip(value);
724 expect(map.value).toBe(value);
725 // The opening fence owns no document characters, so every offset in it
726 // lands on the first character of the code.
727 for (let collapsed = 0; collapsed <= "```ts\n".length; collapsed += 1) {
728 expect(collapsedToFlat(map, collapsed)).toBe(0);
729 }
730 expect(collapsedToFlat(map, "```ts\na".length)).toBe(1);
731 // Everything from the closing newline onwards clamps to the code's end.
732 for (let collapsed = "```ts\nab".length; collapsed <= value.length; collapsed += 1) {
733 expect(collapsedToFlat(map, collapsed)).toBe(2);
734 }
735 });
736
737 it.each([
738 ["```\n\n```", "```\n```"],
739 ["```\n", "```"],
740 ])("canonicalizes the empty fence %s", (value, expected) => {
741 expect(roundTrip(value).value).toBe(expected);
742 });
743
744 it("keeps a chip in a fence info string as source and keeps later chips aligned", () => {
745 const value = "```@README.md\ncode\n```\n$my-skill after";
746 expect(roundTrip(value).value).toBe(value);
747 const json = buildDocJson(value, (n) => ({ label: n, description: null }));
748 const after = json.content[1] as {
749 content: { type: string; attrs?: { skillName?: string } }[];
750 };
751 expect(after.content.map((n) => n.type)).toEqual(["composer-skill", "text"]);
752 expect(after.content[0]?.attrs?.skillName).toBe("my-skill");
753 });
754
755 it("keeps chips in a fence's info string and body each in their own place", () => {
756 const value = "``` @a.md\nsee @b.md\n```";
757 expect(roundTrip(value).value).toBe(value);
758 const json = buildDocJson(value, (n) => ({ label: n, description: null }));
759 const code = json.content[0] as { attrs: { language: string } };
760 expect(code.attrs.language).toBe(" @a.md");
761 });
762
763 it.each([
764 ["listItem", { marker: "-", space: "" }, "bulletList", "- text"],
765 ["listItem", { marker: "1.", space: "" }, "orderedList", "1. text"],
766 ])("gives a bare %s a space once it has text", (item, attrs, list, expected) => {
767 const doc = ProseMirrorNode.fromJSON(schema, {
768 type: "doc",
769 content: [
770 {
771 type: list,
772 content: [
773 {
774 type: item,
775 attrs,
776 content: [{ type: "paragraph", content: [{ type: "text", text: "text" }] }],
777 },
778 ],
779 },
780 ],
781 });
782 expect(serializeEditorDoc(doc).value).toBe(expected);
783 expect(roundTrip("-").value).toBe("-");
784 });
785
786 it("writes code holding a closing fence inside a longer fence", () => {
787 const doc = ProseMirrorNode.fromJSON(schema, {
788 type: "doc",
789 content: [
790 {
791 type: "codeBlock",
792 attrs: { fence: "```", close: "\n```" },
793 content: [{ type: "text", text: "initial\n```\nsecret" }],
794 },
795 ],
796 });
797 const stored = serializeEditorDoc(doc).value;
798 expect(stored).toBe("````\ninitial\n```\nsecret\n````");
799 expect(buildDocJson(stored, () => ({ label: "", description: null })).content).toHaveLength(1);
800 expect(roundTrip(stored).value).toBe(stored);
801 });
802
803 it("closes an unclosed fence that has a block after it", () => {
804 const doc = ProseMirrorNode.fromJSON(schema, {
805 type: "doc",
806 content: [
807 { type: "codeBlock", attrs: { close: "" }, content: [{ type: "text", text: "code" }] },
808 { type: "paragraph", content: [{ type: "text", text: "after" }] },
809 ],
810 });
811 expect(serializeEditorDoc(doc).value).toBe("```\ncode\n```\nafter");
812 // At the end of the draft it stays unclosed, as it was written.
813 expect(roundTrip("```\ncode").value).toBe("```\ncode");
814 });
815
816 it("counts a chip's source in a fence as one cursor position, as the draft store does", () => {
817 const value = "```\nsee @b.md now\n```\nafter";
818 const map = roundTrip(value);
819 expect(map.value).toBe(value);
820 for (let flat = 0; flat <= map.docLength; flat += 1) {
821 expect(flatToCollapsed(map, flat)).toBe(
822 collapseExpandedComposerCursor(value, flatToMarkdown(map, flat)),
823 );
824 }
825 const afterChip = "see @b.md".length;
826 expect(collapsedToFlat(map, flatToCollapsed(map, afterChip))).toBe(afterChip);
827 const end = map.docLength;
828 expect(collapsedToFlat(map, flatToCollapsed(map, end))).toBe(end);
829 });
830
831 it("keeps fences literal in plain mode", () => {
832 const value = "```ts\nconst a = 1;\n```";
833 expect(roundTripPlain(value).value).toBe(value);
834 });
835
836 it("maps every document offset through collapsed coordinates and back", () => {
837 const value = "hi **bold** @README.md bye";
838 const map = roundTrip(value);
839 expect(map.value).toBe(value);
840 for (let flat = 0; flat <= map.docLength; flat += 1) {
841 expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat);
842 }
843 });
844
845 // Flipping the rich text setting remounts the editor, and the caret is
846 // restored from the stored collapsed cursor. That only works because the
847 // coordinate means the same thing on both sides of the flip.
848 it.each([
849 "plain prose with no styling at all",
850 "a chip @README.md counts one character in both modes",
851 "$my-skill leads the line",
852 "trailing newline\n",
853 ])("resolves a collapsed cursor identically in both modes for %s", (value) => {
854 const rich = roundTrip(value);
855 const plain = roundTripPlain(value);
856 expect(rich.value).toBe(value);
857 expect(plain.value).toBe(value);
858 for (let collapsed = 0; collapsed <= value.length; collapsed += 1) {
859 expect(flatToMarkdown(rich, collapsedToFlat(rich, collapsed))).toBe(
860 flatToMarkdown(plain, collapsedToFlat(plain, collapsed)),
861 );
862 }
863 });
864
865 it("clamps a cursor that was sitting inside a marker onto the styled text", () => {
866 const value = "a **bold** c";
867 const plain = roundTripPlain(value);
868 const rich = roundTrip(value);
869 // Between the two asterisks: a real caret position in plain mode, and no
870 // position at all in rich mode, where it lands on the first styled
871 // character instead. The flip moves the caret by a marker's width at most.
872 expect(flatToMarkdown(plain, collapsedToFlat(plain, 3))).toBe(3);
873 expect(flatToMarkdown(rich, collapsedToFlat(rich, 3))).toBe(4);
874 });
875
876 it("maps markdown offsets at styled edges onto document text", () => {
877 const value = "a **bold** c";
878 const map = roundTrip(value);
879 expect(map.value).toBe(value);
880 // document text is "a bold c" (flat), markdown has the markers.
881 expect(flatToMarkdown(map, 2)).toBe(4);
882 expect(flatToMarkdown(map, 6)).toBe(10);
883 expect(collapsedToFlat(map, 3)).toBe(2);
884 expect(collapsedToFlat(map, 9)).toBe(6);
885 });
886});
887
888describe("serializeSelection", () => {
889 /** The stored Markdown of the selection from the start of `first` to the end of `last`. */
890 function copy(value: string, first: string, last: string) {
891 const doc = ProseMirrorNode.fromJSON(
892 schema,
893 buildDocJson(value, (name) => ({ label: name, description: null })),
894 );
895 let from = -1;
896 let to = -1;
897 doc.descendants((node, pos) => {
898 if (!node.isText) return true;
899 const text = node.text ?? "";
900 if (from < 0 && text.includes(first)) from = pos + text.indexOf(first);
901 if (text.includes(last)) to = pos + text.lastIndexOf(last) + last.length;
902 return false;
903 });
904 return serializeSelection(doc, from, to);
905 }
906
907 it("keeps the markers of the list items it spans", () => {
908 expect(copy("- one\n- two\n- three", "one", "two")).toBe("- one\n- two");
909 expect(copy("3) one\n4) two", "one", "two")).toBe("3) one\n4) two");
910 expect(copy("- [ ] one\n- [x] two", "one", "two")).toBe("- [ ] one\n- [x] two");
911 });
912
913 it("keeps the nesting of an item and its children", () => {
914 expect(copy("- p\n - a\n - b", "p", "a")).toBe("- p\n - a");
915 });
916
917 it("keeps the prefix of the quote lines it spans", () => {
918 expect(copy("> one\n> two", "one", "two")).toBe("> one\n> two");
919 });
920
921 it("copies text within one block without its marker", () => {
922 expect(copy("- one two", "one", "one")).toBe("one");
923 });
924
925 it("copies across blocks as they are written", () => {
926 expect(copy("intro\n- one", "intro", "one")).toBe("intro\n- one");
927 });
928});
929
930describe("pasting into a list item or quote", () => {
931 it("keeps inline marks but builds no blocks", () => {
932 const content = buildTiptapContent(
933 "**bold** text\n- item\n> quote",
934 () => ({ label: "", description: null }),
935 { styling: true, blocks: false },
936 );
937 expect(content.map((block) => block.type)).toEqual(["paragraph", "paragraph", "paragraph"]);
938 const first = content[0] as { content: { text: string; marks?: { type: string }[] }[] };
939 expect(first.content[0]).toMatchObject({ text: "bold", marks: [{ type: "bold" }] });
940 expect(JSON.stringify(content[1])).toContain("- item");
941 expect(JSON.stringify(content[2])).toContain("> quote");
942 });
943});
944
945describe("pasting into a path query", () => {
946 const skillLabelFor = (name: string) => ({ label: name, description: null });
947
948 // Inserts `pasted` at the end of a one-line prompt the way the composer's
949 // paste handler does. `prompt` may be a document, to place the caret after a chip.
950 function paste(prompt: string | ProseMirrorNode, pasted: string, pathQueryActive = true) {
951 const promptDoc =
952 typeof prompt === "string"
953 ? ProseMirrorNode.fromJSON(schema, buildDocJson(prompt, skillLabelFor))
954 : prompt;
955 const line = promptDoc.lastChild!;
956 const lineBefore = line.textBetween(0, line.content.size, undefined, "\uFFFC");
957 const literalLength = pastedPathQueryLength(lineBefore, pasted, pathQueryActive);
958 const blocks = buildTiptapContent(pasted, skillLabelFor, { literalLength });
959 const [first, ...rest] = blocks.map((block) => ProseMirrorNode.fromJSON(schema, block));
960 const head = promptDoc.lastChild!.content.append(first!.content);
961 const doc = schema.node("doc", null, [first!.copy(head), ...rest]);
962 doc.check();
963 return doc;
964 }
965
966 function boldText(doc: ProseMirrorNode) {
967 const bold: string[] = [];
968 doc.descendants((node) => {
969 if (node.isText && node.marks.some((mark) => mark.type.name === "bold")) {
970 bold.push(node.text!);
971 }
972 });
973 return bold;
974 }
975
976 it.each(["src/__test__.tsx", "src/__tests__/a.ts", "pkg/__init__.py", "a/**b**/c"])(
977 "keeps %s literal after @ so file search can match it",
978 (path) => {
979 const doc = paste("Open @", path);
980 expect(boldText(doc)).toEqual([]);
981 expect(serializeEditorDoc(doc).value).toBe(`Open @${path}`);
982 },
983 );
984
985 it("still styles the text after the path", () => {
986 const doc = paste("Open @", "src/__test__.tsx and **this**\n- [ ] then __that__");
987 expect(boldText(doc)).toEqual(["this", "that"]);
988 expect(doc.lastChild!.type.name).toBe("taskList");
989 expect(serializeEditorDoc(doc).value).toBe(
990 "Open @src/__test__.tsx and **this**\n- [ ] then **that**",
991 );
992 });
993
994 it("parses markdown after Escape dismisses the path query", () => {
995 const doc = paste("Open @", "__hello__ world", false);
996 expect(boldText(doc)).toEqual(["hello"]);
997 expect(serializeEditorDoc(doc).value).toBe("Open @**hello** world");
998 });
999
1000 it("keeps the rest of the path's line on that line", () => {
1001 const doc = paste("Open @", "src/a.ts - not a list item");
1002 expect(doc.childCount).toBe(1);
1003 expect(serializeEditorDoc(doc).value).toBe("Open @src/a.ts - not a list item");
1004 });
1005
1006 it("parses markdown when the paste does not continue a path query", () => {
1007 expect(boldText(paste("Open ", "src/__test__.tsx"))).toEqual(["test"]);
1008 expect(boldText(paste("Open @a ", "__b__"))).toEqual(["b"]);
1009 expect(boldText(paste("Open @", " __b__"))).toEqual(["b"]);
1010 });
1011
1012 it("keeps a path literal after a partly styled query", () => {
1013 const doc = paste("Open @**src/**", "__test__.tsx");
1014 expect(boldText(doc)).toEqual(["src/"]);
1015 expect(serializeEditorDoc(doc).value).toBe("Open @**src/**__test__.tsx");
1016 });
1017
1018 it("parses markdown pasted right after a mention chip", () => {
1019 const withSpace = ProseMirrorNode.fromJSON(
1020 schema,
1021 buildDocJson("Open @README.md ", skillLabelFor),
1022 );
1023 const paragraph = withSpace.firstChild!;
1024 expect(paragraph.lastChild!.text).toBe(" ");
1025 const chipLast = schema.node("doc", null, [
1026 paragraph.copy(paragraph.content.cut(0, paragraph.content.size - 1)),
1027 ]);
1028 expect(chipLast.firstChild!.lastChild!.type.name).toBe("composer-mention");
1029 expect(boldText(paste(chipLast, "__b__"))).toEqual(["b"]);
1030 });
1031});
1032
1033describe("caret stops at styled edges", () => {
1034 function stateAt(value: string, pmPos: number) {
1035 const doc = ProseMirrorNode.fromJSON(
1036 schema,
1037 buildDocJson(value, (name) => ({ label: name, description: null })),
1038 );
1039 return EditorState.create({ doc, selection: TextSelection.create(doc, pmPos) });
1040 }
1041
1042 function typed(state: EditorState, text: string) {
1043 return serializeEditorDoc(state.apply(state.tr.insertText(text)).doc).value;
1044 }
1045
1046 it("lets the caret step out in front of bold that starts the line", () => {
1047 const inside = stateAt("**bold** tail", 1);
1048 expect(caretTakesMarksBefore(inside)).toBe(false);
1049 expect(typed(inside, "x")).toBe("**xbold** tail");
1050
1051 const step = stepCaretAcrossStyledEdge(inside, -1);
1052 expect(step).not.toBeNull();
1053 const outside = inside.apply(step!);
1054 expect(caretTakesMarksBefore(outside)).toBe(true);
1055 expect(typed(outside, "x")).toBe("x**bold** tail");
1056 // Already outside: the next ArrowLeft moves the caret as usual.
1057 expect(stepCaretAcrossStyledEdge(outside, -1)).toBeNull();
1058
1059 const back = outside.apply(stepCaretAcrossStyledEdge(outside, 1)!);
1060 expect(typed(back, "x")).toBe("**xbold** tail");
1061 });
1062
1063 it("lets the caret step out after bold that ends the line", () => {
1064 const inside = stateAt("head **bold**", 10);
1065 expect(caretTakesMarksBefore(inside)).toBe(true);
1066 expect(typed(inside, "x")).toBe("head **boldx**");
1067 expect(stepCaretAcrossStyledEdge(inside, -1)).toBeNull();
1068
1069 const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!);
1070 expect(caretTakesMarksBefore(outside)).toBe(false);
1071 expect(typed(outside, "x")).toBe("head **bold**x");
1072 });
1073
1074 it("offers both stops where styled text meets plain text mid-line", () => {
1075 // Caret after "a ": types plain until ArrowRight steps into the bold.
1076 const plain = stateAt("a **b** c", 3);
1077 expect(caretTakesMarksBefore(plain)).toBe(true);
1078 expect(typed(plain, "x")).toBe("a x**b** c");
1079 const bold = plain.apply(stepCaretAcrossStyledEdge(plain, 1)!);
1080 expect(typed(bold, "x")).toBe("a **xb** c");
1081 });
1082
1083 it("offers the plain stop between bold text and a chip right after it", () => {
1084 // Markdown needs a space before a mention, but deleting it leaves them adjacent.
1085 const doc = schema.node("doc", null, [
1086 schema.node("paragraph", null, [
1087 schema.text("bold", [schema.marks.bold!.create()]),
1088 schema.nodes["composer-mention"]!.create({ path: "README.md", source: "@README.md" }),
1089 ]),
1090 ]);
1091 const inside = EditorState.create({ doc, selection: TextSelection.create(doc, 5) });
1092 expect(caretTakesMarksBefore(inside)).toBe(true);
1093 const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!);
1094 expect(caretTakesMarksBefore(outside)).toBe(false);
1095 const withText = outside.apply(outside.tr.insertText("x")).doc;
1096 expect(withText.child(0).child(1).text).toBe("x");
1097 expect(withText.child(0).child(1).marks).toEqual([]);
1098 });
1099
1100 it("steps out of inline code at the end of a line without inserting a space", () => {
1101 expect(ComposerCodeExtension.config.exitable).toBe(false);
1102 const inside = stateAt("`code`", 5);
1103 const outside = inside.apply(stepCaretAcrossStyledEdge(inside, 1)!);
1104 expect(typed(outside, "x")).toBe("`code`x");
1105 expect(stepCaretAcrossStyledEdge(outside, 1)).toBeNull();
1106 });
1107
1108 it("keeps marks the user toggled at an edge and lets the arrow move", () => {
1109 const inside = stateAt("a **b** c", 3);
1110 const toggled = inside.apply(
1111 inside.tr.setStoredMarks([schema.marks.bold!.create(), schema.marks.italic!.create()]),
1112 );
1113 expect(stepCaretAcrossStyledEdge(toggled, 1)).toBeNull();
1114 expect(stepCaretAcrossStyledEdge(toggled, -1)).toBeNull();
1115 });
1116
1117 it("leaves arrow keys alone away from styled edges", () => {
1118 expect(stepCaretAcrossStyledEdge(stateAt("**bold** tail", 3), -1)).toBeNull();
1119 expect(stepCaretAcrossStyledEdge(stateAt("plain text", 1), -1)).toBeNull();
1120 expect(caretTakesMarksBefore(stateAt("plain text", 1))).toBe(false);
1121 });
1122});
apps/web/src/composer-rich-text-doc.ts deleted-1573
......@@ -1,1573 +0,0 @@
1import { joinBackward, joinTextblockBackward, joinTextblockForward } from "@tiptap/pm/commands";
2import { Fragment, Mark, type Node as ProseMirrorNode, type ResolvedPos } from "@tiptap/pm/model";
3import { Code } from "@tiptap/extension-code";
4import { Blockquote } from "@tiptap/extension-blockquote";
5import { CodeBlock } from "@tiptap/extension-code-block";
6import { Heading } from "@tiptap/extension-heading";
7import { HorizontalRule } from "@tiptap/extension-horizontal-rule";
8import { type Command, type Editor, mergeAttributes } from "@tiptap/core";
9import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list";
10import { TaskItem } from "@tiptap/extension-task-item";
11import { TaskList } from "@tiptap/extension-task-list";
12import {
13 type EditorState,
14 Plugin,
15 Selection,
16 TextSelection,
17 type Transaction,
18} from "@tiptap/pm/state";
19
20import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
21import { nextOrderedMarkerText } from "~/composer-list-continuation";
22import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text";
23import { collectInlineContextIds } from "~/lib/composerContextReferences";
24
25/**
26 * Pure document model for the rich text (Tiptap) composer.
27 *
28 * The stored prompt stays markdown (`**bold**`, `@file` chips as canonical
29 * links). The Tiptap document holds styled text plus inline atom chips, so
30 * this module translates both ways and maps cursor offsets between the three
31 * coordinate spaces the composer speaks:
32 *
33 * - flat document offsets (styled markers excluded, chips count 1),
34 * - collapsed cursor offsets (markers literal, chips count 1 — the coordinate
35 * the draft store and mention detection use),
36 * - markdown offsets (markers literal, chips expand to their source).
37 *
38 * DOM-free on purpose: unit tests build a real ProseMirror document from the
39 * JSON this produces and assert the round trip without a browser.
40 */
41
42export type SkillMeta = { label: string; description: string | null };
43
44/** Outermost mark first, so closers mirror openers when nested. */
45const MARK_NESTING_ORDER: RichTextMark[] = ["strike", "bold", "italic", "code"];
46
47const MARK_TO_TIPTAP: Record<RichTextMark, string> = {
48 bold: "bold",
49 italic: "italic",
50 strike: "strike",
51 code: "code",
52};
53
54const TIPTAP_TO_MARK: Record<string, RichTextMark> = {
55 bold: "bold",
56 italic: "italic",
57 strike: "strike",
58 code: "code",
59};
60
61/**
62 * Tiptap's code mark excludes every other mark, which rejects the `bold+code`
63 * spans markdown like `**\`x\`**` parses into and drops the whole insert.
64 * Code nests inside emphasis here, so it only excludes itself like the rest.
65 */
66export const ComposerCodeExtension = Code.extend({
67 excludes: "code",
68 // ArrowRight leaves code through the caret stops at styled edges, so the
69 // stock exit (inserting a space at the end of a line) is not needed.
70 exitable: false,
71});
72
73/**
74 * Tiptap's block extensions each bind a chord that turns the current block
75 * into their node (Mod-Shift-8 for a list, Mod-Alt-c for a fence, and so on).
76 * The composer does not offer them, and they are not harmless: run inside a
77 * quote they nest a block the quote serializer cannot write, and the text in
78 * it drops out of the stored draft. `drop` names the chords to remove; the
79 * keys the composer relies on, such as Backspace and the arrows at a block's
80 * edge, stay.
81 */
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
113/**
114 * Task list items keep their exact source indent in an attribute so nesting
115 * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` —
116 * the same fixed-point deal as `__bold__` becoming `**bold**`.
117 */
118export const ComposerTaskItemExtension = TaskItem.extend({
119 content: LIST_ITEM_CONTENT,
120 addAttributes() {
121 return {
122 ...this.parent?.(),
123 indent: { default: "" },
124 markerSpace: { default: " " },
125 contentSpace: { default: null },
126 };
127 },
128 addKeyboardShortcuts() {
129 return withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS);
130 },
131}).configure({ nested: true });
132
133/**
134 * Fenced code blocks keep their exact source delimiters so a fence round-trips
135 * byte-identically: `fence` is the opening run of backticks or tildes,
136 * `language` its info string, and `close` the closing newline and fence, or
137 * the empty string when the fence was never closed.
138 *
139 * Neither delimiter owns a document character, so the caret can never land
140 * inside a fence — the same deal as a checkbox marker.
141 *
142 * An empty block written with a blank line (```` ```\n\n``` ````) canonicalizes
143 * to the blank-free form, the same fixed-point deal as `__bold__` becoming
144 * `**bold**`.
145 */
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
344function randomNodeKey(): string {
345 return `tiptap-${Math.random().toString(36).slice(2)}`;
346}
347
348interface TaskLinePrefix {
349 indent: string;
350 checked: boolean;
351 markerSpace: string;
352 contentSpace: string;
353}
354
355function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength: number } | null {
356 const match = head.match(/^([ \t]*)-([ \t]+)\[([ xX])\]([ \t]*)/);
357 if (!match) return null;
358 const after = head.slice(match[0].length);
359 if (after.length > 0 && !match[4]) return null;
360 return {
361 prefix: {
362 indent: match[1] ?? "",
363 checked: (match[3] ?? " ").toLowerCase() === "x",
364 markerSpace: match[2]!,
365 contentSpace: match[4]!,
366 },
367 markerLength: match[0].length,
368 };
369}
370
371/**
372 * An opening fence: three or more backticks or tildes at the start of a line,
373 * followed by an info string. A backtick fence cannot carry a backtick in its
374 * info string, which is what keeps `` `code` `` on its own line literal.
375 * Indented fences stay paragraphs — the composer is a prompt box, not a
376 * CommonMark renderer, and honoring indentation would cost another attribute
377 * for no case anyone writes.
378 */
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
434type InlineJson = Record<string, unknown>;
435
436interface DocLine {
437 list: ListLinePrefix | null;
438 inline: InlineJson[];
439}
440
441function atomJsonForSegment(
442 segment: Exclude<ReturnType<typeof splitPromptIntoComposerSegments>[number], { type: "text" }>,
443 skillLabelFor: (name: string) => SkillMeta,
444): InlineJson {
445 if (segment.type === "mention") {
446 return {
447 type: "composer-mention",
448 attrs: { path: segment.path, source: segment.source },
449 };
450 }
451 if (segment.type === "skill") {
452 const meta = skillLabelFor(segment.name);
453 return {
454 type: "composer-skill",
455 attrs: {
456 skillName: segment.name,
457 skillLabel: meta.label,
458 skillDescription: meta.description,
459 },
460 };
461 }
462 if (segment.type === "citation") {
463 return {
464 type: "composer-citation",
465 attrs: { citation: segment.citation, source: segment.source, citeKey: randomNodeKey() },
466 };
467 }
468 return {
469 type: "composer-context-reference",
470 attrs: {
471 kind: segment.kind,
472 contextId: segment.contextId,
473 label: segment.label,
474 source: segment.source,
475 },
476 };
477}
478
479interface PendingItem {
480 prefix: ListLinePrefix;
481 content: InlineJson[];
482 /** Nested lists, in order; a parent can hold lists of different kinds. */
483 children: PendingList[];
484}
485
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 };
525}
526
527function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, unknown> {
528 const json: Record<string, unknown> = { type: "text", text };
529 if (marks.length > 0) {
530 json.marks = [...marks]
531 .sort((a, b) => MARK_NESTING_ORDER.indexOf(a) - MARK_NESTING_ORDER.indexOf(b))
532 .map((mark) => ({ type: MARK_TO_TIPTAP[mark] }));
533 }
534 return json;
535}
536
537/**
538 * Document content for a prompt. `literalLength` keeps that many leading
539 * characters as plain text, for a paste that continues a path query: markers
540 * inside a path such as `src/__init__.py` must reach file search unstyled.
541 */
542export function buildTiptapContent(
543 value: string,
544 skillLabelFor: (name: string) => SkillMeta,
545 options?: { styling?: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number },
546): Record<string, unknown>[] {
547 // Editor answers are verbatim text, including Markdown and context-token sources.
548 if (options?.literalText) {
549 return value.split("\n").map((line) => ({
550 type: "paragraph",
551 ...(line ? { content: [{ type: "text", text: line }] } : {}),
552 }));
553 }
554 const styling = options?.styling ?? true;
555 // Inline marks without block structure: text pasted into a list item or
556 // quote, which has no line to write a nested block into.
557 const blockSyntax = styling && (options?.blocks ?? true);
558 // Hide token source from the markdown parser, then restore the atoms with
559 // the marks of their surrounding text. Choose a sentinel absent from input.
560 let sentinel = "\uFFFC";
561 for (let codePoint = 0xe000; value.includes(sentinel); codePoint += 1) {
562 sentinel = String.fromCodePoint(codePoint);
563 }
564 const atoms: InlineJson[] = [];
565 // Code fences hold no chips, so their lines put the original source back in
566 // place of the sentinel rather than building an atom for it.
567 const atomSources: string[] = [];
568 const literal = value.slice(0, options?.literalLength ?? 0);
569 const text = splitPromptIntoComposerSegments(value.slice(literal.length))
570 .map((segment) => {
571 if (segment.type === "text") return segment.text;
572 atoms.push(atomJsonForSegment(segment, skillLabelFor));
573 atomSources.push(segment.source);
574 return sentinel;
575 })
576 .join("");
577 let atomIndex = 0;
578 const buildInline = (content: string): InlineJson[] => {
579 const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }];
580 const inline: InlineJson[] = [];
581 for (const span of spans) {
582 span.text.split(sentinel).forEach((piece, index) => {
583 if (index > 0) {
584 const atom = atoms[atomIndex++]!;
585 inline.push({ ...atom, marks: textJsonForSpan("", span.marks).marks });
586 }
587 if (piece) inline.push(textJsonForSpan(piece, span.marks));
588 });
589 }
590 return inline;
591 };
592 const buildDocLine = (line: string): DocLine => {
593 const parsed = blockSyntax ? parseListPrefix(line) : null;
594 const content = parsed ? line.slice(parsed.markerLength) : line;
595 return { list: parsed?.prefix ?? null, inline: buildInline(content) };
596 };
597
598 // Pass 1: fenced blocks claim their lines whole; everything else becomes an
599 // inline-parsed line. Fence bodies restore chip sources as literal text.
600 const sourceLines = text.split("\n");
601 const entries: (
602 | { code: Record<string, unknown> }
603 | { rule: string }
604 | { heading: { level: number; space: string; inline: InlineJson[] } }
605 | { quote: { prefix: string; inline: InlineJson[] } }
606 | { line: DocLine }
607 )[] = [];
608 const restoreSources = (line: string) =>
609 line.split(sentinel).reduce((joined, piece, index) => {
610 if (index === 0) return piece;
611 atomIndex += 1;
612 return joined + atomSources[atomIndex - 1]! + piece;
613 }, "");
614
615 for (let index = 0; index < sourceLines.length; index += 1) {
616 const line = sourceLines[index]!;
617 // The rest of the literal's line continues the text before the caret, so
618 // it cannot open a block.
619 if (index === 0 && literal) {
620 entries.push({
621 line: { list: null, inline: [textJsonForSpan(literal, []), ...buildInline(line)] },
622 });
623 continue;
624 }
625 const opening = blockSyntax ? parseOpeningFence(line) : null;
626 if (!opening) {
627 // A thematic break outranks a list: `- - -` and `* * *` are rules, and
628 // `***` on its own line is a rule rather than an empty bold span.
629 if (blockSyntax && /^([-*_])(?:[ \t]*\1){2,}[ \t]*\r?$/.test(line)) {
630 entries.push({ rule: line });
631 continue;
632 }
633 const heading = blockSyntax ? /^(#{1,6})([ \t]+)([^\n]*)$/.exec(line) : null;
634 if (heading) {
635 entries.push({
636 heading: {
637 level: heading[1]!.length,
638 space: heading[2]!,
639 inline: buildInline(heading[3]!),
640 },
641 });
642 continue;
643 }
644 const quote = blockSyntax ? /^(>[ \t]*)([^\n]*)$/.exec(line) : null;
645 if (quote) entries.push({ quote: { prefix: quote[1]!, inline: buildInline(quote[2]!) } });
646 else entries.push({ line: buildDocLine(line) });
647 continue;
648 }
649 // The info string went through the sentinel pass like every line, so a
650 // token in it is put back as source here, in order, before the body.
651 const language = restoreSources(opening.language);
652 const body: string[] = [];
653 let cursor = index + 1;
654 let close = "";
655 while (cursor < sourceLines.length) {
656 const candidate = sourceLines[cursor]!;
657 if (isClosingFence(candidate, opening.fence)) {
658 close = `\n${candidate}`;
659 break;
660 }
661 body.push(restoreSources(candidate));
662 cursor += 1;
663 }
664 // An unclosed fence runs to the end of the prompt, which is what the user
665 // is looking at while they are still typing the block.
666 const closed = cursor < sourceLines.length;
667 const content = body.join("\n");
668 entries.push({
669 code: {
670 type: "codeBlock",
671 attrs: { language, fence: opening.fence, close },
672 ...(content ? { content: [{ type: "text", text: content }] } : {}),
673 },
674 });
675 index = closed ? cursor : sourceLines.length;
676 }
677
678 // Pass 2: consecutive list lines group into (possibly nested) lists by
679 // indent prefix; everything else stays a paragraph. Items of a different
680 // kind or marker at the same indent start a sibling list, so `* a` under
681 // `- b` keeps its star and a task list can follow a bullet list.
682 const blocks: Record<string, unknown>[] = [];
683 let rootLists: PendingList[] = [];
684 let stack: { indent: string; list: PendingList; container: PendingList[] }[] = [];
685 const flushLists = () => {
686 for (const list of rootLists) blocks.push(listJson(list));
687 rootLists = [];
688 stack = [];
689 };
690 const openList = (container: PendingList[], key: string): PendingList => {
691 const list = { key, items: [] };
692 container.push(list);
693 return list;
694 };
695 let openQuote: { prefix: string; content: InlineJson[][] } | null = null;
696 const flushQuote = () => {
697 if (!openQuote) return;
698 blocks.push({
699 type: "blockquote",
700 attrs: { prefix: openQuote.prefix },
701 content: openQuote.content.map((inline) => ({ type: "paragraph", content: inline })),
702 });
703 openQuote = null;
704 };
705 for (const entry of entries) {
706 if ("quote" in entry) {
707 flushLists();
708 if (openQuote && openQuote.prefix !== entry.quote.prefix) flushQuote();
709 openQuote ??= { prefix: entry.quote.prefix, content: [] };
710 openQuote.content.push(entry.quote.inline);
711 continue;
712 }
713 flushQuote();
714 if ("code" in entry) {
715 flushLists();
716 blocks.push(entry.code);
717 continue;
718 }
719 if ("rule" in entry) {
720 flushLists();
721 blocks.push({ type: "horizontalRule", attrs: { source: entry.rule } });
722 continue;
723 }
724 if ("heading" in entry) {
725 flushLists();
726 blocks.push({
727 type: "heading",
728 attrs: { level: entry.heading.level, space: entry.heading.space },
729 content: entry.heading.inline,
730 });
731 continue;
732 }
733 const line = entry.line;
734 if (!line.list) {
735 flushLists();
736 blocks.push({ type: "paragraph", content: line.inline });
737 continue;
738 }
739 const item: PendingItem = { prefix: line.list, content: line.inline, children: [] };
740 const key = listKey(line.list);
741 const indent = line.list.indent;
742 for (;;) {
743 const top = stack[stack.length - 1];
744 if (!top) {
745 // A leading indented item with no parent flattens but keeps indent.
746 stack.push({ indent, list: openList(rootLists, key), container: rootLists });
747 continue;
748 }
749 if (top.indent === indent) {
750 if (top.list.key !== key) top.list = openList(top.container, key);
751 top.list.items.push(item);
752 break;
753 }
754 if (top.indent !== "" && !indent.startsWith(top.indent)) {
755 if (stack.length > 1) {
756 stack.pop();
757 continue;
758 }
759 top.indent = indent;
760 if (top.list.key !== key) top.list = openList(top.container, key);
761 top.list.items.push(item);
762 break;
763 }
764 const parent = top.list.items[top.list.items.length - 1];
765 if (!parent) {
766 top.list.items.push(item);
767 break;
768 }
769 const list = openList(parent.children, key);
770 stack.push({ indent, list, container: parent.children });
771 list.items.push(item);
772 break;
773 }
774 }
775 flushQuote();
776 flushLists();
777 return blocks;
778}
779
780export function buildDocJson(
781 value: string,
782 skillLabelFor: (name: string) => SkillMeta,
783 options?: { styling?: boolean; literalText?: boolean },
784) {
785 const content = buildTiptapContent(value, skillLabelFor, options);
786 // The empty line the rule extension keeps after a final rule.
787 if (content.at(-1)?.type === "horizontalRule") content.push({ type: "paragraph" });
788 return { type: "doc", content };
789}
790
791export interface RichRun {
792 kind: "text" | "token" | "break" | "prefix";
793 /** Flat document offset (atoms count 1, markers excluded). */
794 flatStart: number;
795 docLen: number;
796 /** Collapsed cursor length (markers literal, tokens count 1). */
797 collapsedLen: number;
798 /** Markdown length (tokens expand to their source). */
799 mdLen: number;
800 /** Marker layout inside text runs. */
801 openLen: number;
802 closeLen: number;
803 /** ProseMirror position of the run start. */
804 pmPos: number;
805 mdStart: number;
806 collapsedStart: number;
807 nodeName?: string;
808}
809
810export interface RichDocMap {
811 value: string;
812 runs: RichRun[];
813 docLength: number;
814 contextIds: string[];
815}
816
817function readAtomSource(node: ProseMirrorNode): string {
818 const attrs = node.attrs as Record<string, unknown>;
819 switch (node.type.name) {
820 case "composer-mention":
821 case "composer-citation":
822 case "composer-context-reference":
823 return typeof attrs.source === "string" ? attrs.source : "";
824 case "composer-skill": {
825 const name = typeof attrs.skillName === "string" ? attrs.skillName : "";
826 return name ? `$${name}` : "";
827 }
828 default:
829 return "";
830 }
831}
832
833interface RichAccumulator {
834 runs: RichRun[];
835 value: string;
836 flat: number;
837 collapsed: number;
838 md: number;
839}
840
841function pushBreakRun(acc: RichAccumulator, position?: number): void {
842 const previous = acc.runs[acc.runs.length - 1];
843 const pmPos = position ?? (previous ? previous.pmPos + previous.docLen : 1);
844 // Block boundary: one newline in every coordinate space.
845 acc.runs.push({
846 kind: "break",
847 flatStart: acc.flat,
848 docLen: 1,
849 collapsedLen: 1,
850 mdLen: 1,
851 openLen: 0,
852 closeLen: 0,
853 pmPos,
854 mdStart: acc.md,
855 collapsedStart: acc.collapsed,
856 });
857 acc.value += "\n";
858 acc.flat += 1;
859 acc.collapsed += 1;
860 acc.md += 1;
861}
862
863function appendInlineRuns(
864 container: ProseMirrorNode,
865 contentStart: number,
866 acc: RichAccumulator,
867): void {
868 const children: ProseMirrorNode[] = [];
869 container.forEach((child) => {
870 if (!child.isText || child.marks.some((mark) => mark.type.name === "code")) {
871 children.push(child);
872 return;
873 }
874 // Separate boundary whitespace so delimiters can move past it without
875 // changing the document offsets or marks on the visible text.
876 const text = child.text!;
877 const start = text.length - text.trimStart().length;
878 const end = Math.max(start, text.trimEnd().length);
879 let offset = 0;
880 for (const boundary of [start, end, text.length]) {
881 if (boundary > offset) children.push(child.cut(offset, boundary));
882 offset = boundary;
883 }
884 });
885 // Emphasis cannot open or close next to whitespace. Retain a whitespace
886 // mark only when its range has visible content on both sides.
887 for (const mark of MARK_NESTING_ORDER) {
888 if (mark === "code") continue;
889 for (const direction of [1, -1]) {
890 let hasContent = false;
891 for (
892 let index = direction === 1 ? 0 : children.length - 1;
893 index >= 0 && index < children.length;
894 index += direction
895 ) {
896 const child = children[index]!;
897 if (
898 child.type.name === "hardBreak" ||
899 !child.marks.some((item) => item.type.name === mark)
900 ) {
901 hasContent = false;
902 } else if (
903 child.isText &&
904 !child.marks.some((item) => item.type.name === "code") &&
905 /^\s+$/.test(child.text!)
906 ) {
907 if (!hasContent)
908 children[index] = child.mark(child.marks.filter((item) => item.type.name !== mark));
909 } else {
910 hasContent = true;
911 }
912 }
913 }
914 }
915 // Longer shared marks surround shorter ones. This keeps both nested
916 // formatting and formatting across chips inside a single delimiter pair.
917 const markEnds = new Map<RichTextMark, number>();
918 const orderedMarks: RichTextMark[][] = [];
919 for (let index = children.length - 1; index >= 0; index -= 1) {
920 const child = children[index]!;
921 const marks =
922 child.type.name === "hardBreak"
923 ? []
924 : child.marks
925 .map((mark) => TIPTAP_TO_MARK[mark.type.name])
926 .filter((mark): mark is RichTextMark => Boolean(mark));
927 for (const mark of MARK_NESTING_ORDER) {
928 if (!marks.includes(mark)) markEnds.delete(mark);
929 else if (!markEnds.has(mark)) markEnds.set(mark, index);
930 }
931 orderedMarks[index] = marks.sort(
932 (a, b) =>
933 markEnds.get(b)! - markEnds.get(a)! ||
934 MARK_NESTING_ORDER.indexOf(a) - MARK_NESTING_ORDER.indexOf(b),
935 );
936 }
937 for (let index = 1; index < orderedMarks.length; index += 1) {
938 const marks = orderedMarks[index]!;
939 const retained: RichTextMark[] = [];
940 for (const mark of orderedMarks[index - 1]!) {
941 if (!marks.includes(mark)) break;
942 retained.push(mark);
943 }
944 orderedMarks[index] = [...retained, ...marks.filter((mark) => !retained.includes(mark))];
945 }
946 const commonLength = (left: RichTextMark[], right: RichTextMark[]) => {
947 let index = 0;
948 while (index < left.length && left[index] === right[index]) index += 1;
949 return index;
950 };
951 let inlineOffset = 0;
952 children.forEach((child, index) => {
953 const pmPos = contentStart + inlineOffset;
954 inlineOffset += child.nodeSize;
955 if (child.type.name === "hardBreak") {
956 pushBreakRun(acc, pmPos);
957 return;
958 }
959 const marks = orderedMarks[index]!;
960 const open = marks
961 .slice(commonLength(marks, orderedMarks[index - 1] ?? []))
962 .map((mark) => RICH_TEXT_DELIMITERS[mark])
963 .join("");
964 const close = marks
965 .slice(commonLength(marks, orderedMarks[index + 1] ?? []))
966 .toReversed()
967 .map((mark) => RICH_TEXT_DELIMITERS[mark])
968 .join("");
969 const source = child.isText ? child.text! : readAtomSource(child);
970 const docLen = child.isText ? source.length : 1;
971 const mdText = open + source + close;
972 const collapsedLen = open.length + docLen + close.length;
973 acc.runs.push({
974 kind: child.isText ? "text" : "token",
975 flatStart: acc.flat,
976 docLen,
977 collapsedLen,
978 mdLen: mdText.length,
979 openLen: open.length,
980 closeLen: close.length,
981 pmPos,
982 mdStart: acc.md,
983 collapsedStart: acc.collapsed,
984 ...(child.isText ? {} : { nodeName: child.type.name }),
985 });
986 acc.value += mdText;
987 acc.flat += docLen;
988 acc.collapsed += collapsedLen;
989 acc.md += mdText.length;
990 });
991 // Empty paragraphs have an editable position even though they emit no text.
992 if (children.length === 0) {
993 acc.runs.push({
994 kind: "text",
995 flatStart: acc.flat,
996 docLen: 0,
997 collapsedLen: 0,
998 mdLen: 0,
999 openLen: 0,
1000 closeLen: 0,
1001 pmPos: contentStart,
1002 mdStart: acc.md,
1003 collapsedStart: acc.collapsed,
1004 });
1005 }
1006}
1007
1008const LIST_NODE_NAMES = new Set(["taskList", "bulletList", "orderedList"]);
1009const LIST_ITEM_NODE_NAMES = new Set(["taskItem", "listItem"]);
1010
1011/**
1012 * Gives the list item at the caret the indent and marker of the sibling it
1013 * follows, or of the one after it when it is first. Tiptap moves items between
1014 * lists without touching these attributes, so without this the stored draft
1015 * keeps an outdented item at its old depth, and the next rebuild nests it again.
1016 */
1017function alignListItemWithSiblings(tr: Transaction, itemType: string): void {
1018 const $pos = tr.selection.$from;
1019 // A lift out of the list leaves the caret in a paragraph, possibly inside
1020 // an item of another kind, which did not move.
1021 if ($pos.depth < 3 || $pos.node(-1).type.name !== itemType) return;
1022 const item = $pos.node(-1);
1023 const list = $pos.node(-2);
1024 const index = $pos.index(-2);
1025 const sibling =
1026 index > 0 ? list.child(index - 1) : index + 1 < list.childCount ? list.child(index + 1) : null;
1027 if (!sibling) return;
1028 const attrs: Record<string, unknown> = { ...item.attrs, indent: sibling.attrs.indent };
1029 if (item.type.name === "taskItem") {
1030 attrs.markerSpace = sibling.attrs.markerSpace;
1031 } else {
1032 const marker = typeof sibling.attrs.marker === "string" ? sibling.attrs.marker : "-";
1033 attrs.marker = index > 0 && /^\d+[.)]$/.test(marker) ? nextOrderedMarkerText(marker) : marker;
1034 }
1035 tr.setNodeMarkup($pos.before(-1), undefined, attrs);
1036 if (typeof attrs.marker === "string" && /^\d+[.)]$/.test(attrs.marker)) {
1037 renumberFollowingItems(tr, $pos.before(-2), index, attrs.marker);
1038 }
1039}
1040
1041/**
1042 * Counts the items after `index` on from `marker`, so an item inserted in the
1043 * middle of an ordered list pushes the rest down instead of repeating a number.
1044 * Only marker attributes change, so positions stay valid while walking.
1045 */
1046function renumberFollowingItems(
1047 tr: Transaction,
1048 listPos: number,
1049 index: number,
1050 marker: string,
1051): void {
1052 const list = tr.doc.nodeAt(listPos);
1053 if (!list) return;
1054 let pos = listPos + 1;
1055 let previous = marker;
1056 list.forEach((child, _, childIndex) => {
1057 if (childIndex > index) {
1058 previous = nextOrderedMarkerText(previous);
1059 if (child.attrs.marker !== previous) {
1060 tr.setNodeMarkup(pos, undefined, { ...child.attrs, marker: previous });
1061 }
1062 }
1063 pos += child.nodeSize;
1064 });
1065}
1066
1067/**
1068 * Shift+Enter in a list or task item: split it, or on an empty item leave one
1069 * level of nesting. Either way the item at the caret ends up written with the
1070 * indent and marker of its new siblings.
1071 */
1072export function splitOrLiftListItem(editor: Editor): boolean {
1073 const $from = editor.state.selection.$from;
1074 const itemType = listItemTypeAt($from);
1075 if (!itemType) return false;
1076 const empty = $from.parent.content.size === 0;
1077 // One transaction, so the split or lift and the realignment undo together.
1078 const move: Command = ({ commands, tr }) => {
1079 const moved =
1080 commands.splitListItem(
1081 itemType,
1082 itemType === "taskItem" ? { checked: false } : { space: " " },
1083 ) ||
1084 (empty && commands.liftListItem(itemType));
1085 if (moved) alignListItemWithSiblings(tr, itemType);
1086 return moved;
1087 };
1088 return editor.can().command(move) && editor.chain().command(move).run();
1089}
1090
1091/**
1092 * Deletes `from`–`to`, the typed `[ ]` at the start of a bullet item's text,
1093 * and makes that item a task where it stands. A task list holds only tasks, so
1094 * the bullet list splits around it into sibling lists, which is also how the
1095 * parser reads a change of list kind. Nested items and the item's own nested
1096 * lists stay where they are.
1097 */
1098export function convertBulletItemToTask(
1099 tr: Transaction,
1100 from: number,
1101 to: number,
1102 checked: boolean,
1103): void {
1104 tr.delete(from, to);
1105 const $pos = tr.doc.resolve(from);
1106 const item = $pos.node(-1);
1107 const list = $pos.node(-2);
1108 const index = $pos.index(-2);
1109 const { schema } = tr.doc.type;
1110 const before: ProseMirrorNode[] = [];
1111 const after: ProseMirrorNode[] = [];
1112 list.forEach((child, _, childIndex) => {
1113 if (childIndex < index) before.push(child);
1114 else if (childIndex > index) after.push(child);
1115 });
1116 // The spacing after the bullet stays between the dash and the box, as typed.
1117 const markerSpace =
1118 typeof item.attrs.space === "string" && item.attrs.space ? item.attrs.space : " ";
1119 const task = schema.nodes.taskItem!.create(
1120 { checked, indent: item.attrs.indent, markerSpace },
1121 item.content,
1122 );
1123 const lists = [
1124 ...(before.length > 0 ? [list.copy(Fragment.from(before))] : []),
1125 schema.nodes.taskList!.create(null, task),
1126 ...(after.length > 0 ? [list.copy(Fragment.from(after))] : []),
1127 ];
1128 const listStart = $pos.before(-2);
1129 tr.replaceWith(listStart, listStart + list.nodeSize, lists);
1130 // Into the task list, its item and its paragraph.
1131 const caret = listStart + (before.length > 0 ? lists[0]!.nodeSize : 0) + 3;
1132 tr.setSelection(TextSelection.create(tr.doc, caret));
1133}
1134
1135function listItemTypeAt($pos: ResolvedPos): "listItem" | "taskItem" | null {
1136 const name = $pos.depth > 1 ? $pos.node(-1).type.name : null;
1137 return name === "listItem" || name === "taskItem" ? name : null;
1138}
1139
1140/**
1141 * Backspace at the start of an item's text leaves one level of nesting, or
1142 * the list itself at the top, written at its new depth. At the start of a
1143 * line right after a list it joins that line onto the list's last line.
1144 * Tiptap's list keymap, which StarterKit loads, does both by moving nodes
1145 * without touching their indent or marker, so the composer turns it off.
1146 */
1147export function backspaceAcrossList(editor: Editor): boolean {
1148 const { state } = editor;
1149 const { $from, empty } = state.selection;
1150 if (!empty || $from.parentOffset !== 0) return false;
1151 const itemType = listItemTypeAt($from);
1152 if (itemType && $from.index(-1) === 0) {
1153 const lift: Command = ({ commands, tr }) => {
1154 const moved = commands.liftListItem(itemType);
1155 if (moved) alignListItemWithSiblings(tr, itemType);
1156 return moved;
1157 };
1158 // Not `can()` first: a dry run of the lift skips the schema check, so it
1159 // says yes to a task under a bullet, which has no list to lift into. Its
1160 // text joins the line above instead.
1161 return editor.chain().command(lift).run() || joinBackward(state, editor.view.dispatch);
1162 }
1163 const before = $from.depth === 1 ? $from.node(0).maybeChild($from.index(0) - 1) : null;
1164 if (before && LIST_NODE_NAMES.has(before.type.name) && $from.parent.type.name !== "codeBlock") {
1165 return joinTextblockBackward(state, editor.view.dispatch);
1166 }
1167 return false;
1168}
1169
1170/**
1171 * Delete at the end of a line next to list structure pulls the next line's
1172 * text up onto it. Tiptap's default would instead nest the next item, or a
1173 * whole list, under this one at its old indent. Before a fence, anywhere, it
1174 * does nothing: pulling the code into the line would drop the fence from the
1175 * draft and leave the rest of the code as stray lines.
1176 */
1177export function deleteAcrossList(editor: Editor): boolean {
1178 const { state } = editor;
1179 const { $from, empty } = state.selection;
1180 if (!empty || !$from.parent.isTextblock || $from.depth === 0) return false;
1181 if ($from.parentOffset !== $from.parent.content.size) return false;
1182 const next = Selection.findFrom(state.doc.resolve($from.after()), 1, true);
1183 if (!next) return false;
1184 if (next.$from.parent.type.name === "codeBlock") return $from.parent.type.name !== "codeBlock";
1185 if (!listItemTypeAt($from) && !listItemTypeAt(next.$from)) return false;
1186 return joinTextblockForward(state, editor.view.dispatch);
1187}
1188
1189/** The literal prefix an item serializes to. Empty items keep their exact spacing. */
1190function listItemPrefix(item: ProseMirrorNode, empty: boolean): string {
1191 const attrs = item.attrs as Record<string, unknown>;
1192 const indent = typeof attrs.indent === "string" ? attrs.indent : "";
1193 if (item.type.name === "taskItem") {
1194 const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " ";
1195 const contentSpace =
1196 typeof attrs.contentSpace === "string"
1197 ? attrs.contentSpace || (empty ? "" : " ")
1198 : empty
1199 ? ""
1200 : " ";
1201 return `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`;
1202 }
1203 const marker = typeof attrs.marker === "string" && attrs.marker ? attrs.marker : "-";
1204 // A bare `-` keeps its missing space only while the item is empty: once it
1205 // has text, `-text` would not be a list line any more.
1206 const space =
1207 typeof attrs.space === "string" ? attrs.space || (empty ? "" : " ") : empty ? "" : " ";
1208 return `${indent}${marker}${space}`;
1209}
1210
1211function walkList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void {
1212 let itemPos = listStart + 1;
1213 let firstItem = true;
1214 list.content.forEach((item) => {
1215 // Sibling items are separated by one newline in every coordinate space.
1216 if (!firstItem) pushBreakRun(acc);
1217 firstItem = false;
1218 const itemContentStart = itemPos + 1;
1219 const first = item.firstChild;
1220 const empty = first?.type.name === "paragraph" && first.content.childCount === 0;
1221 const prefix = listItemPrefix(item, empty);
1222 // The marker owns no document characters; every prefix offset clamps
1223 // to the start of the item text, exactly like style markers.
1224 acc.runs.push({
1225 kind: "prefix",
1226 flatStart: acc.flat,
1227 docLen: 0,
1228 collapsedLen: prefix.length,
1229 mdLen: prefix.length,
1230 openLen: 0,
1231 closeLen: 0,
1232 pmPos: itemContentStart + 1,
1233 mdStart: acc.md,
1234 collapsedStart: acc.collapsed,
1235 });
1236 acc.value += prefix;
1237 acc.collapsed += prefix.length;
1238 acc.md += prefix.length;
1239 let childPos = itemContentStart;
1240 let firstBlock = true;
1241 item.content.forEach((child) => {
1242 if (!firstBlock) pushBreakRun(acc);
1243 firstBlock = false;
1244 if (LIST_NODE_NAMES.has(child.type.name)) {
1245 walkList(child, childPos, acc);
1246 } else if (child.type.name === "paragraph") {
1247 appendInlineRuns(child, childPos + 1, acc);
1248 }
1249 childPos += child.nodeSize;
1250 });
1251 itemPos += item.nodeSize;
1252 });
1253}
1254
1255/**
1256 * A fence becomes one run whose open and close lengths are the delimiters, so
1257 * every cursor rule that already clamps out of a style marker clamps out of a
1258 * fence too. `nodeName` keeps the marker decorations off it: a code block is
1259 * drawn as a block, not revealed a character at a time.
1260 *
1261 * The code is literal text in the editor, but the draft store still counts a
1262 * chip's source in it as one cursor position, as it does everywhere. So the
1263 * run splits at each chip source, which spans its characters in the document
1264 * and one position in the collapsed space.
1265 */
1266function appendCodeBlockRun(
1267 block: ProseMirrorNode,
1268 pmPos: number,
1269 followed: boolean,
1270 acc: RichAccumulator,
1271): void {
1272 const { open, content, close } = codeBlockSource(block, followed);
1273 const pieces = content
1274 ? splitPromptIntoComposerSegments(content).map((segment) =>
1275 segment.type === "text"
1276 ? { length: segment.text.length, collapsedLen: segment.text.length }
1277 : { length: segment.source.length, collapsedLen: 1 },
1278 )
1279 : [{ length: 0, collapsedLen: 0 }];
1280 let offset = 0;
1281 pieces.forEach((piece, index) => {
1282 const openLen = index === 0 ? open.length : 0;
1283 const closeLen = index === pieces.length - 1 ? close.length : 0;
1284 const collapsedLen = openLen + piece.collapsedLen + closeLen;
1285 const mdLen = openLen + piece.length + closeLen;
1286 acc.runs.push({
1287 kind: "text",
1288 flatStart: acc.flat,
1289 docLen: piece.length,
1290 collapsedLen,
1291 mdLen,
1292 openLen,
1293 closeLen,
1294 pmPos: pmPos + offset,
1295 mdStart: acc.md,
1296 collapsedStart: acc.collapsed,
1297 nodeName: "codeBlock",
1298 });
1299 offset += piece.length;
1300 acc.flat += piece.length;
1301 acc.collapsed += collapsedLen;
1302 acc.md += mdLen;
1303 });
1304 acc.value += open + content + close;
1305}
1306
1307/** Each paragraph of a quote is one source line behind the quote's prefix. */
1308function walkBlockquote(quote: ProseMirrorNode, quoteStart: number, acc: RichAccumulator): void {
1309 const attrs = quote.attrs as Record<string, unknown>;
1310 const prefix = typeof attrs.prefix === "string" ? attrs.prefix : "> ";
1311 let childPos = quoteStart + 1;
1312 let firstLine = true;
1313 quote.content.forEach((child) => {
1314 if (!firstLine) pushBreakRun(acc);
1315 firstLine = false;
1316 acc.runs.push({
1317 kind: "prefix",
1318 flatStart: acc.flat,
1319 docLen: 0,
1320 collapsedLen: prefix.length,
1321 mdLen: prefix.length,
1322 openLen: 0,
1323 closeLen: 0,
1324 pmPos: childPos + 1,
1325 mdStart: acc.md,
1326 collapsedStart: acc.collapsed,
1327 });
1328 acc.value += prefix;
1329 acc.collapsed += prefix.length;
1330 acc.md += prefix.length;
1331 if (child.type.name === "paragraph") appendInlineRuns(child, childPos + 1, acc);
1332 childPos += child.nodeSize;
1333 });
1334}
1335
1336/**
1337 * The stored Markdown of a selection, as copy and cut put it on the clipboard.
1338 * A slice holds only the content of the blocks the ends share, so items or
1339 * quote lines arrive without the list or quote that writes their markers. The
1340 * shared block is put back around them, and an item's list around it.
1341 */
1342export function serializeSelection(doc: ProseMirrorNode, from: number, to: number): string {
1343 const slice = doc.slice(from, to);
1344 const $from = doc.resolve(from);
1345 let depth = $from.sharedDepth(to);
1346 let content: Fragment | ProseMirrorNode = slice.content;
1347 if (slice.content.firstChild?.isInline) {
1348 content = doc.type.schema.nodes.paragraph!.create(null, slice.content);
1349 } else {
1350 while (depth > 0) {
1351 const shared = $from.node(depth);
1352 content = shared.copy(Fragment.from(content));
1353 if (!LIST_ITEM_NODE_NAMES.has(shared.type.name)) break;
1354 depth -= 1;
1355 }
1356 }
1357 return serializeEditorDoc(doc.type.create(null, content)).value;
1358}
1359
1360export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap {
1361 const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 };
1362 const blocks: ProseMirrorNode[] = [];
1363 doc.content.forEach((node) => {
1364 blocks.push(node);
1365 });
1366
1367 let pmBlockStart = 0;
1368 blocks.forEach((block, blockIndex) => {
1369 if (blockIndex > 0 && !isTrailingLineAfterRule(doc, blockIndex)) pushBreakRun(acc);
1370 if (LIST_NODE_NAMES.has(block.type.name)) {
1371 walkList(block, pmBlockStart, acc);
1372 } else if (block.type.name === "codeBlock") {
1373 appendCodeBlockRun(block, pmBlockStart + 1, blockIndex < blocks.length - 1, acc);
1374 } else if (block.type.name === "blockquote") {
1375 walkBlockquote(block, pmBlockStart, acc);
1376 } else if (block.type.name === "heading") {
1377 const attrs = block.attrs as Record<string, unknown>;
1378 const level = typeof attrs.level === "number" ? attrs.level : 1;
1379 const space = typeof attrs.space === "string" ? attrs.space : " ";
1380 const prefix = `${"#".repeat(level)}${space}`;
1381 acc.runs.push({
1382 kind: "prefix",
1383 flatStart: acc.flat,
1384 docLen: 0,
1385 collapsedLen: prefix.length,
1386 mdLen: prefix.length,
1387 openLen: 0,
1388 closeLen: 0,
1389 pmPos: pmBlockStart + 1,
1390 mdStart: acc.md,
1391 collapsedStart: acc.collapsed,
1392 });
1393 acc.value += prefix;
1394 acc.collapsed += prefix.length;
1395 acc.md += prefix.length;
1396 appendInlineRuns(block, pmBlockStart + 1, acc);
1397 } else if (block.type.name === "horizontalRule") {
1398 const attrs = block.attrs as Record<string, unknown>;
1399 const source = typeof attrs.source === "string" && attrs.source ? attrs.source : "---";
1400 acc.runs.push({
1401 kind: "prefix",
1402 flatStart: acc.flat,
1403 docLen: 0,
1404 collapsedLen: source.length,
1405 mdLen: source.length,
1406 openLen: 0,
1407 closeLen: 0,
1408 pmPos: pmBlockStart + block.nodeSize,
1409 mdStart: acc.md,
1410 collapsedStart: acc.collapsed,
1411 });
1412 acc.value += source;
1413 acc.collapsed += source.length;
1414 acc.md += source.length;
1415 } else if (block.type.name === "paragraph") {
1416 appendInlineRuns(block, pmBlockStart + 1, acc);
1417 }
1418 pmBlockStart += block.nodeSize;
1419 });
1420
1421 return {
1422 value: acc.value,
1423 runs: acc.runs,
1424 docLength: acc.flat,
1425 contextIds: Array.from(new Set(collectInlineContextIds(acc.value))),
1426 };
1427}
1428
1429function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number {
1430 const last = map.runs[map.runs.length - 1];
1431 if (!last) return 0;
1432 return space === "collapsed"
1433 ? last.collapsedStart + last.collapsedLen
1434 : last.mdStart + last.mdLen;
1435}
1436
1437/**
1438 * The end of a run belongs to the next run, which is how the end of `**bold**`
1439 * lands after its markers. A fence is the exception: its close is a line of
1440 * its own, so the end of the code stays inside the block rather than jumping
1441 * past the closing fence. Inline marks keep their trailing position.
1442 */
1443function runOwnsOffset(run: RichRun, flatOffset: number): boolean {
1444 const end = run.flatStart + run.docLen;
1445 return flatOffset < end || (flatOffset === end && run.nodeName === "codeBlock");
1446}
1447
1448export function flatToCollapsed(map: RichDocMap, flatOffset: number): number {
1449 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));
1450 for (const run of map.runs) {
1451 if (runOwnsOffset(run, bounded)) {
1452 if (run.kind === "text" || run.kind === "token") {
1453 // A chip's source in a fence spans its characters but one position.
1454 const within = Math.min(
1455 bounded - run.flatStart,
1456 run.collapsedLen - run.openLen - run.closeLen,
1457 );
1458 return run.collapsedStart + run.openLen + within;
1459 }
1460 return run.collapsedStart + (bounded - run.flatStart);
1461 }
1462 }
1463 return lastRunEnd(map, "collapsed");
1464}
1465
1466export function flatToMarkdown(map: RichDocMap, flatOffset: number): number {
1467 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));
1468 for (const run of map.runs) {
1469 if (runOwnsOffset(run, bounded)) {
1470 if (run.kind === "text" || run.kind === "token") {
1471 return run.mdStart + run.openLen + (bounded - run.flatStart);
1472 }
1473 return run.mdStart + (bounded - run.flatStart);
1474 }
1475 }
1476 return lastRunEnd(map, "md");
1477}
1478
1479export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): number {
1480 for (const run of map.runs) {
1481 if (collapsedOffset < run.collapsedStart + run.collapsedLen) {
1482 // Checkbox prefixes and style markers are shown, never edited: every
1483 // offset inside them clamps to the adjacent document position.
1484 if (run.kind === "prefix") return run.flatStart;
1485 if (run.kind === "text" || run.kind === "token") {
1486 const within = collapsedOffset - run.collapsedStart;
1487 const contentLen = run.collapsedLen - run.openLen - run.closeLen;
1488 // Marker characters clamp to the styled edge: they are shown, never edited.
1489 if (within <= run.openLen) return run.flatStart;
1490 if (within >= run.openLen + contentLen) return run.flatStart + run.docLen;
1491 return run.flatStart + (within - run.openLen);
1492 }
1493 return run.flatStart + (collapsedOffset - run.collapsedStart);
1494 }
1495 }
1496 return map.docLength;
1497}
1498
1499export function flatToPm(map: RichDocMap, flatOffset: number): number {
1500 const bounded = Math.max(0, Math.min(flatOffset, map.docLength));
1501 for (const run of map.runs) {
1502 if (bounded < run.flatStart + run.docLen) {
1503 return run.pmPos + (bounded - run.flatStart);
1504 }
1505 }
1506 const last = map.runs[map.runs.length - 1];
1507 if (!last) return 1;
1508 return last.pmPos + last.docLen;
1509}
1510
1511export function pmToFlat(map: RichDocMap, pmPos: number): number {
1512 for (const run of map.runs) {
1513 if (pmPos >= run.pmPos && pmPos <= run.pmPos + run.docLen) {
1514 // A position on a chip's trailing edge belongs after the chip.
1515 if (run.kind === "token" && pmPos === run.pmPos + run.docLen) {
1516 return run.flatStart + run.docLen;
1517 }
1518 return run.flatStart + Math.min(pmPos - run.pmPos, run.docLen);
1519 }
1520 }
1521 // A paragraph boundary position belongs to the newline between paragraphs.
1522 let best = 0;
1523 for (const run of map.runs) {
1524 if (run.pmPos <= pmPos) best = run.flatStart + run.docLen;
1525 }
1526 return Math.max(0, Math.min(best, map.docLength));
1527}
1528
1529// ── Caret stops at styled edges ────────────────────────────────────────────
1530//
1531// Markers are decorations, not text, so the position where styled text meets
1532// unstyled text (or a paragraph edge) is a single document position. The
1533// caret gets two stops there: one that types with the marks before the edge
1534// and one that types with the marks after it. Stored marks pick the stop, and
1535// the revealed markers render on the matching side of the caret, so a pasted
1536// `**bold**` at the start of a line can still be typed in front of.
1537
1538function styledEdge(state: EditorState) {
1539 const { selection } = state;
1540 if (!selection.empty) return null;
1541 const { $from } = selection;
1542 if (!$from.parent.inlineContent) return null;
1543 const before = $from.nodeBefore?.marks ?? Mark.none;
1544 const after = $from.nodeAfter?.marks ?? Mark.none;
1545 if (Mark.sameSet(before, after)) return null;
1546 return { before, after, current: state.storedMarks ?? $from.marks() };
1547}
1548
1549/** True when the caret sits on a styled edge and types with the marks before it. */
1550export function caretTakesMarksBefore(state: EditorState): boolean {
1551 const edge = styledEdge(state);
1552 return edge !== null && Mark.sameSet(edge.current, edge.before);
1553}
1554
1555/**
1556 * Moves the caret to the other stop of the styled edge it sits on, toward
1557 * `direction`. Returns null when there is no stop to take, so the arrow key
1558 * moves the caret as usual.
1559 */
1560export function stepCaretAcrossStyledEdge(
1561 state: EditorState,
1562 direction: -1 | 1,
1563): Transaction | null {
1564 const edge = styledEdge(state);
1565 if (!edge) return null;
1566 // Marks the user toggled by hand (neither stop) are theirs: move as usual.
1567 if (!Mark.sameSet(edge.current, edge.before) && !Mark.sameSet(edge.current, edge.after)) {
1568 return null;
1569 }
1570 const target = direction === -1 ? edge.before : edge.after;
1571 if (Mark.sameSet(edge.current, target)) return null;
1572 return state.tr.setStoredMarks(target);
1573}
apps/web/src/composer-rich-text.test.ts deleted-67
......@@ -1,67 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { parseInlineMarkdown } from "./composer-rich-text";
4
5describe("composer rich text markdown", () => {
6 it("parses bold markers into styled spans", () => {
7 expect(parseInlineMarkdown("hello **bold** world")).toEqual([
8 { text: "hello ", marks: [] },
9 { text: "bold", marks: ["bold"] },
10 { text: " world", marks: [] },
11 ]);
12 });
13
14 it("preserves unmatched markers, escaped markers, and identifiers", () => {
15 for (const text of [
16 "plain text",
17 "snake_case",
18 "unmatched **",
19 "** spaced **",
20 "\\*literal\\*",
21 ]) {
22 expect(parseInlineMarkdown(text)).toEqual([{ text, marks: [] }]);
23 }
24 });
25
26 it("renders triple markers and nested styles", () => {
27 expect(parseInlineMarkdown("***both***")).toEqual([
28 { text: "both", marks: ["bold", "italic"] },
29 ]);
30 expect(parseInlineMarkdown("*a **b** c*")).toEqual([
31 { text: "a ", marks: ["italic"] },
32 { text: "b", marks: ["italic", "bold"] },
33 { text: " c", marks: ["italic"] },
34 ]);
35 expect(parseInlineMarkdown("**a `code` c**")).toEqual([
36 { text: "a ", marks: ["bold"] },
37 { text: "code", marks: ["bold", "code"] },
38 { text: " c", marks: ["bold"] },
39 ]);
40 });
41
42 it("preserves crossing italic and bold spans", () => {
43 expect(parseInlineMarkdown("*a**b*****c**")).toEqual([
44 { text: "a", marks: ["italic"] },
45 { text: "b", marks: ["italic", "bold"] },
46 { text: "c", marks: ["bold"] },
47 ]);
48 });
49
50 it("keeps repeated and alternate delimiters literal inside an active mark", () => {
51 const repeated = "*".repeat(10_000);
52 expect(parseInlineMarkdown(repeated + "text" + repeated)).toEqual([
53 { text: repeated.slice(2) + "text", marks: ["bold"] },
54 { text: repeated.slice(2), marks: [] },
55 ]);
56 const alternate = "__".repeat(5_000);
57 expect(parseInlineMarkdown("**~~" + alternate + "text" + alternate + "~~**")).toEqual([
58 { text: alternate + "text" + alternate, marks: ["bold", "strike"] },
59 ]);
60 });
61
62 it("keeps code span contents literal", () => {
63 expect(parseInlineMarkdown("`**not bold**`")).toEqual([
64 { text: "**not bold**", marks: ["code"] },
65 ]);
66 });
67});
apps/web/src/composer-rich-text.ts deleted-131
......@@ -1,131 +0,0 @@
1/**
2 * Inline markdown styling for the rich text composer.
3 *
4 * The composer's stored prompt stays plain markdown (`**bold**`), while the
5 * Tiptap surface renders styled text. These helpers translate between the two:
6 * parsing markdown into marked spans for the document, serializing marked
7 * spans back to markdown in the document serializer.
8 *
9 * Deliberately small: bold, italic, strikethrough, and inline code only.
10 * Unmatched markers stay literal text so nothing the user typed is ever lost.
11 */
12
13export type RichTextMark = "bold" | "italic" | "strike" | "code";
14
15interface RichTextSpan {
16 text: string;
17 marks: RichTextMark[];
18}
19
20export const RICH_TEXT_DELIMITERS: Record<RichTextMark, string> = {
21 bold: "**",
22 italic: "*",
23 strike: "~~",
24 code: "`",
25};
26
27function pushSpan(spans: RichTextSpan[], text: string, marks: RichTextMark[]): void {
28 if (!text) return;
29 const last = spans[spans.length - 1];
30 if (
31 last &&
32 last.marks.length === marks.length &&
33 last.marks.every((mark, index) => mark === marks[index])
34 ) {
35 last.text += text;
36 } else {
37 spans.push({ text, marks });
38 }
39}
40
41/** Parse the supported inline styles, leaving unmatched and escaped markers literal. */
42export function parseInlineMarkdown(text: string): RichTextSpan[] {
43 const root: RichTextSpan[] = [];
44 const stack: { delimiter: string; mark: RichTextMark; spans: RichTextSpan[] }[] = [];
45 const current = () => stack.at(-1)?.spans ?? root;
46 let index = 0;
47 while (index < text.length) {
48 const char = text[index]!;
49 if (char === "\\") {
50 pushSpan(current(), text.slice(index, index + 2), []);
51 index += 2;
52 continue;
53 }
54 if (char === "`") {
55 // Multi-backtick code stays literal; a single-backtick span owns its contents.
56 const run = text.slice(index).match(/^`+/)![0];
57 const close = text.indexOf(run, index + run.length);
58 if (
59 run.length === 1 &&
60 close > index + 1 &&
61 !text.slice(index, close).includes("\n") &&
62 text[close + 1] !== "`"
63 ) {
64 pushSpan(current(), text.slice(index + 1, close), ["code"]);
65 index = close + 1;
66 } else {
67 pushSpan(current(), run, []);
68 index += run.length;
69 }
70 continue;
71 }
72 if (char !== "*" && char !== "_" && char !== "~") {
73 pushSpan(current(), char, []);
74 index += 1;
75 continue;
76 }
77 let end = index;
78 while (text[end] === char) end += 1;
79 const before = text[index - 1] ?? "";
80 const after = text[end] ?? "";
81 const canClose = before !== "" && !/\s/.test(before) && (char !== "_" || !/\w/.test(after));
82 const canOpen = after !== "" && !/\s/.test(after) && (char !== "_" || !/\w/.test(before));
83 while (index < end) {
84 const top = stack.at(-1);
85 // Inside italic, a double marker opens bold before closing the single
86 // marker. Once bold is active, closing runs unwind both styles.
87 const opensNestedBold =
88 top?.mark === "italic" &&
89 end - index === 2 &&
90 canOpen &&
91 (char === "*" || char === "_") &&
92 !stack.some((frame) => frame.mark === "bold");
93 if (
94 !opensNestedBold &&
95 canClose &&
96 top &&
97 text.startsWith(top.delimiter, index) &&
98 index + top.delimiter.length <= end
99 ) {
100 stack.pop();
101 for (const span of top.spans) pushSpan(current(), span.text, [top.mark, ...span.marks]);
102 index += top.delimiter.length;
103 } else if (canOpen && (char !== "~" || end - index >= 2)) {
104 const length = char === "~" || end - index >= 2 ? 2 : 1;
105 const mark = char === "~" ? "strike" : length === 2 ? "bold" : "italic";
106 // A mark cannot nest inside itself, including alternate delimiters.
107 // This bounds the stack to the supported styles for arbitrary input.
108 if (stack.some((frame) => frame.mark === mark)) {
109 pushSpan(current(), text.slice(index, end), []);
110 index = end;
111 continue;
112 }
113 stack.push({
114 delimiter: char.repeat(length),
115 mark,
116 spans: [],
117 });
118 index += length;
119 } else {
120 pushSpan(current(), text.slice(index, end), []);
121 index = end;
122 }
123 }
124 }
125 while (stack.length > 0) {
126 const frame = stack.pop()!;
127 pushSpan(current(), frame.delimiter, []);
128 for (const span of frame.spans) pushSpan(current(), span.text, span.marks);
129 }
130 return root;
131}
apps/web/src/composer-undo-grouping.test.ts deleted-143
......@@ -1,143 +0,0 @@
1import { getSchema } from "@tiptap/core";
2import { history, undo } from "@tiptap/pm/history";
3import { EditorState, TextSelection, type Transaction } from "@tiptap/pm/state";
4import StarterKit from "@tiptap/starter-kit";
5import { describe, expect, it } from "vite-plus/test";
6
7import {
8 COMPOSER_UNDO_GROUP_DELAY,
9 type ComposerChangeKind,
10 groupUndoByChangeKind,
11 markAsClipboardEdit,
12} from "./composer-undo-grouping";
13
14const schema = getSchema([StarterKit]);
15
16/**
17 * A composer-shaped editor state with the real history plugin, dispatching
18 * through the same grouping rule the composer's extension applies. Keystrokes
19 * land 80ms apart unless a gap says otherwise.
20 */
21function composer(text = "") {
22 let clock = 0;
23 let previous: ComposerChangeKind | null = null;
24 let state = EditorState.create({
25 schema,
26 doc: schema.node("doc", null, [
27 schema.node("paragraph", null, text ? [schema.text(text)] : []),
28 ]),
29 plugins: [history({ newGroupDelay: COMPOSER_UNDO_GROUP_DELAY })],
30 });
31 const dispatch = (tr: Transaction) => {
32 tr.setTime(clock);
33 previous = groupUndoByChangeKind(tr, previous);
34 state = state.apply(tr);
35 };
36 dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc)));
37 const api = {
38 text: () => state.doc.textContent,
39 wait: (ms: number) => ((clock += ms), api),
40 type: (s: string, gap = 80) => {
41 for (const ch of s) {
42 clock += gap;
43 dispatch(state.tr.insertText(ch));
44 }
45 return api;
46 },
47 backspace: (n: number) => {
48 for (let i = 0; i < n; i += 1) {
49 clock += 80;
50 const at = state.selection.from;
51 dispatch(state.tr.delete(at - 1, at));
52 }
53 return api;
54 },
55 /**
56 * The transaction the composer's `handlePaste` dispatches: a plain insert,
57 * tagged by `markAsClipboardEdit`. ProseMirror's own paste path would tag
58 * it, but the composer takes every text paste before that path runs.
59 */
60 paste: (s: string) => {
61 clock += 80;
62 dispatch(markAsClipboardEdit(state.tr.insertText(s), "paste"));
63 return api;
64 },
65 /** The composer's cut handler: a plain delete of the selection, tagged. */
66 cut: (n: number) => {
67 clock += 80;
68 const at = state.selection.from;
69 dispatch(markAsClipboardEdit(state.tr.delete(at - n, at), "cut"));
70 return api;
71 },
72 /** A store-driven rewrite: autocomplete inserting a chip, list continuation. */
73 rewrite: (next: string) => {
74 clock += 80;
75 dispatch(
76 state.tr.replaceWith(
77 0,
78 state.doc.content.size,
79 schema.node("paragraph", null, [schema.text(next)]),
80 ),
81 );
82 dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc)));
83 return api;
84 },
85 /** IME input replaces its own range on every keystroke. */
86 compose: (s: string) => {
87 const start = state.selection.from;
88 for (let i = 1; i <= s.length; i += 1) {
89 clock += 80;
90 dispatch(
91 state.tr.insertText(s.slice(0, i), start, start + i - 1).setMeta("composition", 1),
92 );
93 }
94 return api;
95 },
96 undo: () => (undo(state, dispatch), api),
97 };
98 return api;
99}
100
101describe("composer undo grouping", () => {
102 it("keeps a continuous run of typing as one step", () => {
103 expect(composer().type("hello world").undo().text()).toBe("");
104 });
105
106 it("starts a new step when typing turns into deleting, and back", () => {
107 const editor = composer().type("hello world").backspace(5).type("there");
108 expect(editor.text()).toBe("hello there");
109 expect(editor.undo().text()).toBe("hello ");
110 expect(editor.undo().text()).toBe("hello world");
111 expect(editor.undo().text()).toBe("");
112 });
113
114 it("gives a store rewrite its own step, and typing after it another", () => {
115 const editor = composer("draft ").type("one").rewrite("draft one @chip ").type("then");
116 expect(editor.undo().text()).toBe("draft one @chip ");
117 expect(editor.undo().text()).toBe("draft one");
118 expect(editor.undo().text()).toBe("draft ");
119 });
120
121 it("gives a paste its own step", () => {
122 const editor = composer().type("see ").paste("pasted text").type(" ok");
123 expect(editor.undo().text()).toBe("see pasted text");
124 expect(editor.undo().text()).toBe("see ");
125 });
126
127 it("gives a cut its own step between deletions", () => {
128 const editor = composer().type("hello world").backspace(1).cut(3).backspace(1);
129 expect(editor.text()).toBe("hello ");
130 expect(editor.undo().text()).toBe("hello w");
131 expect(editor.undo().text()).toBe("hello worl");
132 expect(editor.undo().text()).toBe("hello world");
133 });
134
135 it("keeps IME composition as one run of typing", () => {
136 expect(composer().type("go ").compose("にほん").undo().text()).toBe("");
137 });
138
139 it("merges typing across a short pause and splits it across a long one", () => {
140 expect(composer().type("hello").wait(700).type(" world").undo().text()).toBe("");
141 expect(composer().type("hello").wait(1100).type(" world").undo().text()).toBe("hello");
142 });
143});
apps/web/src/composer-undo-grouping.ts deleted-64
......@@ -1,64 +0,0 @@
1import { closeHistory, isHistoryTransaction } from "@tiptap/pm/history";
2import type { Transaction } from "@tiptap/pm/state";
3import { ReplaceStep } from "@tiptap/pm/transform";
4
5/**
6 * Undo grouping matching the Lexical composer it replaced.
7 *
8 * ProseMirror's history only starts a new undo step after a pause or when an
9 * edit is not next to the last one, so typing, backspacing and typing again
10 * without pausing is one step, and a store rewrite of the whole document
11 * (autocomplete inserting a chip, list continuation) makes every later edit
12 * "adjacent" and joins it. Lexical instead started a new step whenever the
13 * kind of change switched, and after one second without changes.
14 */
15export const COMPOSER_UNDO_GROUP_DELAY = 1000;
16
17export type ComposerChangeKind = "insert" | "delete" | "other";
18
19/**
20 * Tags a transaction as a paste or cut, so it becomes its own undo step.
21 * ProseMirror tags these itself only on its built-in clipboard paths; the
22 * composer's `handlePaste` and cut handler replace those paths, so each of
23 * their dispatches must call this, or the change merges with the typing or
24 * deleting around it.
25 */
26export function markAsClipboardEdit(tr: Transaction, event: "paste" | "cut"): Transaction {
27 return tr.setMeta("uiEvent", event);
28}
29
30function changeKindOf(tr: Transaction): ComposerChangeKind | null {
31 if (!tr.docChanged) return null;
32 const uiEvent = tr.getMeta("uiEvent");
33 if (uiEvent === "paste" || uiEvent === "drop" || uiEvent === "cut") return "other";
34 // IME input replaces its own range on each keystroke; it is still typing.
35 if (tr.getMeta("composition") != null) return "insert";
36 let kind: ComposerChangeKind | null = null;
37 for (const step of tr.steps) {
38 if (!(step instanceof ReplaceStep)) return "other";
39 const inserts = step.slice.size > 0;
40 const deletes = step.to > step.from;
41 const stepKind = inserts && !deletes ? "insert" : deletes && !inserts ? "delete" : "other";
42 if (kind !== null && kind !== stepKind) return "other";
43 kind = stepKind;
44 }
45 return kind;
46}
47
48/**
49 * Closes the current undo step before `tr` when its kind of change differs
50 * from the last one, or when it is a one-off change such as a paste or a
51 * store rewrite, and returns the kind to remember for the next transaction.
52 * Undo and redo themselves count as one-off, so typing after them starts a
53 * fresh step. Must run before the transaction is applied.
54 */
55export function groupUndoByChangeKind(
56 tr: Transaction,
57 previous: ComposerChangeKind | null,
58): ComposerChangeKind | null {
59 if (isHistoryTransaction(tr)) return "other";
60 const kind = changeKindOf(tr);
61 if (kind === null) return previous;
62 if (previous !== null && (kind !== previous || kind === "other")) closeHistory(tr);
63 return kind;
64}
apps/web/src/index.css+198-171
......@@ -32,6 +32,21 @@
3232 font-display: swap;
3333}
3434
35@font-face {
36 font-family: "Recursive";
37 src: url("./assets/fonts/Recursive_VF_1.085.woff2") format("woff2");
38 font-weight: 300 1000;
39 font-display: swap;
40}
41
42/* Box-drawing glyphs share the 0.6em cell width of the code font. */
43@font-face {
44 font-family: "T3 Markdown Symbols";
45 src: url("@clo/terminal/fonts/__clo_terminal_primary_34_8_1-Regular.woff2") format("woff2");
46 unicode-range: U+2500-259F;
47 font-display: swap;
48}
49
3550@custom-variant dark (&:is(.dark, .dark *));
3651@custom-variant light (&:not(.dark, .dark *));
3752/* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */
......@@ -1133,6 +1148,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
11331148 --border: var(--color-zinc-200);
11341149 --input: var(--color-zinc-300);
11351150 --ring: var(--primary);
1151 --activity-foreground: light-dark(
1152 oklch(from var(--ring) min(l, 0.55) c h),
1153 oklch(from var(--ring) max(l, 0.65) c h)
1154 );
11361155 --destructive-foreground: var(--error-foreground);
11371156 --info: var(--color-blue-500);
11381157 --info-foreground: var(--color-blue-700);
......@@ -1450,7 +1469,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] {
14501469/* Chat code blocks join the other code surfaces on the code tokens. The
14511470 default look keeps its secondary/input tints; themed palettes tint those
14521471 surfaces with the accent, which fights syntax highlighting. */
1453html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock {
1472html[data-theme-id] .chat-markdown .chat-markdown-codeblock {
14541473 background-color: var(--code-background);
14551474 border-color: var(--contrast-border);
14561475 color: var(--code-foreground);
......@@ -1461,11 +1480,11 @@ html[data-theme-id] .chat-markdown pre {
14611480 color: var(--code-foreground);
14621481}
14631482
1464html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre {
1483html[data-theme-id] .chat-markdown .chat-markdown-codeblock pre {
14651484 background-color: transparent;
14661485}
14671486
1468html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header {
1487html[data-theme-id] .chat-markdown .chat-markdown-codeblock-header {
14691488 color: color-mix(in srgb, var(--code-foreground) 72%, transparent);
14701489}
14711490
......@@ -1486,7 +1505,7 @@ html[data-theme-id="t3-chat"] {
14861505 }
14871506
14881507 & .chat-markdown :not(pre) > code {
1489 color: var(--contrast-message-foreground);
1508 color: inherit;
14901509 }
14911510 }
14921511}
......@@ -1706,13 +1725,22 @@ code {
17061725 font-family: var(--font-mono);
17071726}
17081727
1728.chat-markdown pre,
1729.chat-markdown code {
1730 font-family: var(--chat-code-font, "AT Name Mono"), "T3 Markdown Symbols", var(--font-mono);
1731 font-variation-settings:
1732 "CASL" 0.15,
1733 "MONO" 1;
1734}
1735
17091736/* Code blocks in chat carry the size preference. Scoped to chat markdown
17101737 rather than every pre: a global rule would beat text-size utilities and
17111738 inherited sizes on unrelated pre surfaces (terminal previews, approvals).
17121739 Inline code stays relative to its sentence so it never towers over prose;
17131740 diffs and file previews take the size through --diffs-font-size. */
17141741.chat-markdown .chat-markdown-shiki .shiki,
1715:is(.chat-markdown, .composer-tiptap) pre code {
1742.chat-markdown pre,
1743.chat-markdown pre code {
17161744 font-size: var(--font-size-code, inherit);
17171745}
17181746
......@@ -1777,86 +1805,157 @@ code {
17771805 margin-bottom: 0;
17781806}
17791807
1808.chat-transcript-blocks,
1809.chat-transcript-figure {
1810 container-type: inline-size;
1811}
1812
1813.chat-transcript-blocks
1814 .chat-markdown
1815 > :is(
1816 .chat-markdown-codeblock,
1817 .chat-markdown-table-container,
1818 .chat-markdown-video-row,
1819 [data-language],
1820 figure
1821 ),
1822.chat-transcript-figure > * {
1823 position: relative;
1824 left: 50%;
1825 width: max-content;
1826 min-width: 100cqw;
1827 max-width: calc(
1828 100cqw + 2 * min(var(--chat-block-outset-start, 0px), var(--chat-block-outset-end, 0px))
1829 );
1830 translate: -50%;
1831}
1832
1833.chat-markdown .chat-markdown-video-row {
1834 --chat-video-width: 20rem;
1835 --chat-video-gap: 0.75rem;
1836 display: grid;
1837 grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--chat-video-width)), 1fr));
1838 align-items: start;
1839 gap: var(--chat-video-gap);
1840 width: 100%;
1841 margin: 1.25rem 0 0.5rem;
1842}
1843
1844.chat-transcript-blocks .chat-markdown > .chat-markdown-video-row {
1845 width: calc(
1846 var(--chat-video-count) * var(--chat-video-width) + (var(--chat-video-count) - 1) *
1847 var(--chat-video-gap)
1848 );
1849}
1850
1851.chat-transcript-blocks .chat-markdown > .chat-markdown-video-row[data-video-count="1"] {
1852 width: 100%;
1853}
1854
1855.chat-markdown-video-row > div {
1856 min-width: 0;
1857}
1858
1859.chat-markdown .chat-markdown-video-bare[data-video-count="1"] {
1860 margin: 0;
1861}
1862
1863.chat-markdown strong {
1864 color: light-dark(
1865 oklch(from var(--message-action) min(l, 0.55) c h),
1866 oklch(from var(--message-action) max(l, 0.65) c h)
1867 );
1868}
1869
1870.chat-markdown em {
1871 color: var(--info-foreground);
1872}
1873
1874.chat-markdown :is(strong > em, em > strong) {
1875 color: var(--error-foreground);
1876 background: color-mix(in srgb, var(--error) 10%, transparent);
1877 border-radius: 0.2em;
1878 box-decoration-break: clone;
1879 -webkit-box-decoration-break: clone;
1880}
1881
1882.live-activity-running {
1883 --secondary-label: var(--activity-foreground);
1884 --contrast-secondary-label: var(--activity-foreground);
1885 --icon-muted: var(--activity-foreground);
1886}
1887
17801888.chat-markdown p,
17811889.chat-markdown ul,
17821890.chat-markdown ol,
17831891.chat-markdown blockquote,
17841892.chat-markdown pre,
17851893.chat-markdown .chat-markdown-table-container,
1786.composer-tiptap ul,
1787.composer-tiptap ol {
1788 margin: 0.65rem 0;
1894.chat-markdown h1,
1895.chat-markdown h2,
1896.chat-markdown h3,
1897.chat-markdown h4,
1898.chat-markdown h5,
1899.chat-markdown h6 {
1900 margin: 1.25rem 0 0.5rem;
17891901}
17901902
1791:is(.chat-markdown, .composer-tiptap) h1,
1792:is(.chat-markdown, .composer-tiptap) h2,
1793:is(.chat-markdown, .composer-tiptap) h3,
1794:is(.chat-markdown, .composer-tiptap) h4,
1795:is(.chat-markdown, .composer-tiptap) h5,
1796:is(.chat-markdown, .composer-tiptap) h6 {
1797 margin: 1.25rem 0 0.5rem;
1903.chat-markdown h1,
1904.chat-markdown h2,
1905.chat-markdown h3,
1906.chat-markdown h4,
1907.chat-markdown h5,
1908.chat-markdown h6 {
17981909 font-weight: 600;
17991910 line-height: 1.3;
18001911 color: var(--contrast-foreground);
18011912}
18021913
1803:is(.chat-markdown, .composer-tiptap) h1 {
1914.chat-markdown h1 {
18041915 font-size: 1.25rem;
18051916}
18061917
1807:is(.chat-markdown, .composer-tiptap) h2 {
1918.chat-markdown h2 {
18081919 font-size: 1.125rem;
18091920}
18101921
1811:is(.chat-markdown, .composer-tiptap) h3 {
1922.chat-markdown h3 {
18121923 font-size: 1rem;
18131924}
18141925
1815:is(.chat-markdown, .composer-tiptap) h4,
1816:is(.chat-markdown, .composer-tiptap) h5,
1817:is(.chat-markdown, .composer-tiptap) h6 {
1926.chat-markdown h4,
1927.chat-markdown h5,
1928.chat-markdown h6 {
18181929 font-size: 0.875rem;
18191930}
18201931
1821:is(.chat-markdown, .composer-tiptap) h6 {
1932.chat-markdown h6 {
18221933 color: var(--contrast-muted-foreground);
18231934}
18241935
1825:is(.chat-markdown, .composer-tiptap) ul {
1826 /* Reset for nested uls under a widened ol. --list-gutter is an inherited
1827 custom property, so without this a task-list under a multi-digit ordered
1828 list would inherit the outer gutter instead of its own default. */
1936.chat-markdown :is(ul, ol) {
1937 /* Nested lists must not inherit a wider ordered marker's gutter. */
18291938 --list-gutter: 1.25rem;
1830 padding-left: 1.25rem;
1939 padding-inline-start: var(--list-gutter);
1940}
1941
1942.chat-markdown ul {
18311943 list-style-type: disc;
18321944}
18331945
1834/* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but
1835 ChatMarkdown's `ol` renderer widens it (via inline style) for lists whose
1836 widest marker has multiple characters so it isn't clipped by list-style-position:
1837 outside painting the marker past the padding box. Reset it here too so a
1838 nested ol without its own widened marker doesn't inherit the outer one. */
1839:is(.chat-markdown, .composer-tiptap) ol {
1840 --list-gutter: 1.25rem;
1841 padding-left: var(--list-gutter, 1.25rem);
1946.chat-markdown ol {
18421947 list-style-type: decimal;
18431948}
18441949
1845:is(.chat-markdown, .composer-tiptap) ol > li::marker {
1950.chat-markdown ol > li::marker {
18461951 font-variant-numeric: tabular-nums;
18471952}
18481953
1849/* The composer shows the marker the source has, `3)` or a nested `7.`, not the
1850 browser counter. Browsers without ::marker content fall back to `start`. */
1851.composer-tiptap ol > li[data-marker]::marker {
1852 content: attr(data-marker) " ";
1853}
1854
1855:is(.chat-markdown, .composer-tiptap) ul ul {
1954.chat-markdown ul ul {
18561955 list-style-type: circle;
18571956}
18581957
1859:is(.chat-markdown, .composer-tiptap) ul ul ul {
1958.chat-markdown ul ul ul {
18601959 list-style-type: square;
18611960}
18621961
......@@ -1872,16 +1971,48 @@ code {
18721971 margin-top: 0.25rem;
18731972}
18741973
1974.chat-markdown li > :is(ul, ol) {
1975 margin-block: 0.25rem 0;
1976}
1977
1978.chat-markdown li > :first-child {
1979 margin-top: 0;
1980}
1981
1982.chat-markdown li > :last-child {
1983 margin-bottom: 0;
1984}
1985
18751986/* GitHub-style task lists: the checkbox replaces the list marker. */
18761987.chat-markdown li.task-list-item {
18771988 list-style-type: none;
18781989}
18791990
18801991.chat-markdown li.task-list-item input[type="checkbox"] {
1881 margin: 0 0.35em 0.15em calc(-1 * var(--list-gutter, 1.25rem));
1992 margin-block: 0 0.15em;
1993 margin-inline: calc(-1 * var(--list-gutter, 1.25rem)) 0.35em;
18821994 vertical-align: middle;
18831995}
18841996
1997.chat-markdown kbd {
1998 border: 1px solid var(--contrast-border);
1999 border-radius: 0.25rem;
2000 background: var(--muted);
2001 padding: 0.1rem 0.3rem;
2002 color: var(--contrast-foreground);
2003 font-size: 0.85em;
2004}
2005
2006.chat-markdown mark {
2007 background: var(--warning-surface);
2008 color: var(--warning-foreground);
2009}
2010
2011.chat-markdown u {
2012 text-decoration: underline;
2013 text-underline-offset: 0.15em;
2014}
2015
18852016/* Markdown assumes inline images — GitHub comments lay button images out in a row — and the
18862017 Tailwind preflight's block-level img would stack them one per line. */
18872018.chat-markdown img {
......@@ -1905,30 +2036,12 @@ code {
19052036 background-size: 4px 2px;
19062037}
19072038
1908:is(.chat-markdown, .composer-tiptap) blockquote {
2039.chat-markdown blockquote {
19092040 border-left: 2px solid var(--contrast-border);
19102041 padding-left: 0.8rem;
19112042 color: var(--contrast-muted-foreground);
19122043}
19132044
1914/* A quote in the composer is text the user is still writing; muting it reads
1915 as disabled in an input, so the border alone carries the quote. */
1916.composer-tiptap blockquote {
1917 margin: 0.65rem 0;
1918 color: var(--foreground);
1919}
1920
1921/* Preflight already draws the 1px rule, as it does in chat; only the block
1922 spacing needs to match. */
1923.composer-tiptap hr {
1924 margin: 0.65rem 0;
1925}
1926
1927/* A heading at the top of a draft should not push the first line down. */
1928.composer-tiptap :is(h1, h2, h3, h4, h5, h6):first-child {
1929 margin-top: 0;
1930}
1931
19322045.chat-markdown section[data-footnotes] {
19332046 margin-top: 1.25rem;
19342047 border-top: 1px solid var(--contrast-border);
......@@ -1945,6 +2058,11 @@ code {
19452058 margin-top: 0.35rem;
19462059}
19472060
2061.chat-markdown a[data-footnote-ref],
2062.chat-markdown section[data-footnotes] li[id] {
2063 scroll-margin-block-start: 1rlh;
2064}
2065
19482066.chat-markdown a[data-footnote-ref],
19492067.chat-markdown a[data-footnote-backref] {
19502068 display: inline-flex;
......@@ -1964,10 +2082,19 @@ code {
19642082 -webkit-box-decoration-break: clone;
19652083 background: var(--muted);
19662084 padding: 0.1rem 0.35rem;
1967 color: var(--contrast-foreground);
2085 color: inherit;
19682086 font-size: 0.75rem;
19692087}
19702088
2089.chat-markdown .chat-markdown-file-link code {
2090 border: none;
2091 background: transparent;
2092 padding: 0;
2093 color: inherit;
2094 font-size: inherit;
2095 line-height: inherit;
2096}
2097
19712098.chat-markdown pre {
19722099 max-width: 100%;
19732100 overflow-x: auto;
......@@ -1975,6 +2102,8 @@ code {
19752102 border-radius: 0.75rem;
19762103 background: var(--muted);
19772104 padding: 0.8rem 0.9rem;
2105 /* The fallback's vertical box strokes span 1.25em and meet on adjacent lines. */
2106 line-height: 1.25;
19782107 scrollbar-width: thin;
19792108 scrollbar-color: color-mix(in srgb, var(--contrast-border) 78%, transparent) transparent;
19802109}
......@@ -1994,23 +2123,23 @@ code {
19942123 background: color-mix(in srgb, var(--contrast-border) 78%, transparent);
19952124}
19962125
1997:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header {
2126.chat-markdown .chat-markdown-codeblock-header {
19982127 color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent);
19992128}
20002129
2001:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre {
2130.chat-markdown .chat-markdown-codeblock pre {
20022131 margin: 0;
20032132 border: none;
20042133 border-radius: 0;
20052134 background: transparent;
20062135}
20072136
2008:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock[data-wrap="true"] pre {
2137.chat-markdown .chat-markdown-codeblock[data-wrap="true"] pre {
20092138 white-space: pre-wrap;
20102139 overflow-wrap: anywhere;
20112140}
20122141
2013:is(.chat-markdown, .composer-tiptap) .chat-markdown-shiki .shiki {
2142.chat-markdown .chat-markdown-shiki .shiki {
20142143 background: transparent !important;
20152144}
20162145
......@@ -2228,108 +2357,6 @@ code {
22282357 }
22292358}
22302359
2231/* Rich text composer (Tiptap). The stored prompt stays markdown; only the
2232 surface renders styled text. Markers (** * ` ~~) appear as non-editable
2233 widgets while the caret is on styled text. */
2234.composer-tiptap {
2235 position: relative;
2236}
2237
2238.composer-tiptap p {
2239 margin: 0;
2240}
2241
2242.composer-tiptap:focus,
2243.composer-tiptap .ProseMirror:focus {
2244 outline: none;
2245}
2246
2247.composer-tiptap :not(pre) > code {
2248 font-family: var(--font-mono);
2249 font-size: 0.92em;
2250 background: color-mix(in oklab, var(--muted-foreground) 12%, transparent);
2251 border-radius: 0.3em;
2252 padding: 0.05em 0.25em;
2253}
2254
2255.composer-rich-marker {
2256 color: var(--muted-foreground);
2257 opacity: 0.75;
2258 font-weight: 400;
2259 font-style: normal;
2260 text-decoration: none;
2261 user-select: none;
2262 pointer-events: none;
2263}
2264
2265/* Selected chips (node selection from arrowing into or clicking a chip). */
2266.composer-tiptap .ProseMirror-selectednode {
2267 outline: 2px solid var(--ring);
2268 outline-offset: -2px;
2269 border-radius: 6px;
2270}
2271
2272.composer-tiptap .composer-chip-range-selected {
2273 position: relative;
2274}
2275
2276.composer-tiptap.ProseMirror-focused .composer-chip-range-selected::after {
2277 content: "";
2278 position: absolute;
2279 inset: 0;
2280 border-radius: 6px;
2281 background: Highlight;
2282 opacity: 0.3;
2283 pointer-events: none;
2284}
2285
2286/* Rich-text task lists render as real checkboxes. Tiptap's TaskItem NodeView
2287 builds the <li> imperatively with only a data-checked attribute (no
2288 data-type), so items are targeted that way. Indentation comes from list
2289 nesting (two spaces per level from Tab), matching the stored Markdown. */
2290.composer-tiptap ul[data-type="taskList"] {
2291 list-style: none;
2292 margin: 0;
2293 padding: 0;
2294}
2295
2296.composer-tiptap ul[data-type="taskList"] ul[data-type="taskList"] {
2297 padding-left: 1.375rem;
2298}
2299
2300.composer-tiptap li[data-checked] {
2301 display: flex;
2302 align-items: flex-start;
2303 gap: 0.5rem;
2304 margin: 0;
2305 padding: 0;
2306}
2307
2308.composer-tiptap li[data-checked] > label {
2309 display: flex;
2310 align-items: center;
2311 flex: none;
2312 height: 1lh;
2313 cursor: pointer;
2314}
2315
2316.composer-tiptap li[data-checked] > label input[type="checkbox"] {
2317 display: block;
2318 margin: 0;
2319 width: 0.95rem;
2320 height: 0.95rem;
2321 cursor: pointer;
2322}
2323
2324.composer-tiptap li[data-checked] > div {
2325 flex: 1 1 auto;
2326 min-width: 0;
2327}
2328
2329.composer-tiptap li[data-checked] > div > p {
2330 margin: 0;
2331}
2332
23332360@layer components {
23342361 .chat-composer-lane {
23352362 padding-inline-start: calc(
apps/web/src/lib/syntaxHighlighting.test.ts+34-2
......@@ -1,4 +1,3 @@
1import type { DiffsHighlighter } from "@pierre/diffs";
21import { expect, it, vi } from "vite-plus/test";
32
43const { getSharedHighlighter } = vi.hoisted(() => ({
......@@ -12,7 +11,12 @@ vi.mock("@pierre/diffs", () => ({
1211import { getSyntaxHighlighterPromise } from "./syntaxHighlighting";
1312
1413it("caches the recovered text highlighter for unsupported languages", async () => {
15 const textHighlighter = {} as DiffsHighlighter;
14 const actual = await vi.importActual<typeof import("@pierre/diffs")>("@pierre/diffs");
15 const textHighlighter = await actual.getSharedHighlighter({
16 themes: ["pierre-dark", "pierre-light"],
17 langs: ["text"],
18 preferredHighlighter: "shiki-wasm",
19 });
1620 getSharedHighlighter.mockImplementation(({ langs }: { langs: string[] }) =>
1721 langs[0] === "text"
1822 ? Promise.resolve(textHighlighter)
......@@ -26,3 +30,31 @@ it("caches the recovered text highlighter for unsupported languages", async () =
2630 expect(second).toBe(first);
2731 expect(getSharedHighlighter).toHaveBeenCalledTimes(2);
2832});
33
34it("keeps neutral syntax colors readable on the active code surface", async () => {
35 const actual = await vi.importActual<typeof import("@pierre/diffs")>("@pierre/diffs");
36 getSharedHighlighter.mockImplementation(actual.getSharedHighlighter);
37 const highlighter = await getSyntaxHighlighterPromise("typescript");
38
39 for (const theme of ["pierre-light", "pierre-dark"] as const) {
40 const { tokens } = highlighter.codeToTokens("// comment\nconst value: string = 'text';", {
41 lang: "typescript",
42 theme,
43 });
44 const comment = tokens.flat().find((token) => token.content === "// comment");
45 const punctuation = tokens.flat().find((token) => token.content === ":");
46 const keyword = tokens.flat().find((token) => token.content === "const");
47
48 expect(comment?.color).toBe(
49 "color-mix(in srgb, var(--code-foreground) 80%, var(--code-background))",
50 );
51 expect(punctuation?.color).toBe("var(--code-foreground)");
52 expect(keyword?.color).toMatch(/^#[\dA-F]{6}$/);
53 expect(
54 highlighter.codeToHtml("// comment\nconst value: string = 'text';", {
55 lang: "typescript",
56 theme,
57 }),
58 ).toContain("color:var(--code-foreground)");
59 }
60});
apps/web/src/lib/syntaxHighlighting.ts+44-9
......@@ -25,15 +25,50 @@ export function getSyntaxHighlighterPromise(language: string): Promise<DiffsHigh
2525 themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")],
2626 langs: [language as SupportedLanguages],
2727 preferredHighlighter: PREFERRED_HIGHLIGHTER,
28 }).catch((error) => {
29 if (language === "text") {
30 highlighterPromiseCache.delete(language);
31 // "text" itself failed — Shiki cannot initialize at all, surface the error
32 throw error;
33 }
34 // Language not supported by Shiki — fall back to "text"
35 return getSyntaxHighlighterPromise("text");
36 });
28 })
29 .then(async (highlighter) => {
30 for (const name of [resolveDiffThemeName("dark"), resolveDiffThemeName("light")]) {
31 const theme = highlighter.getTheme(name);
32 const neutralColors = Object.fromEntries(
33 theme.settings.flatMap(({ scope, settings }) => {
34 const scopes = new Set([scope].flat());
35 if (!settings.foreground) return [];
36 if (scopes.has("comment")) {
37 return [
38 [
39 settings.foreground,
40 "color-mix(in srgb, var(--code-foreground) 80%, var(--code-background))",
41 ],
42 ];
43 }
44 if (scopes.has("punctuation")) {
45 return [[settings.foreground, "var(--code-foreground)"]];
46 }
47 return [];
48 }),
49 );
50 if (
51 Object.entries(neutralColors).every(
52 ([color, replacement]) => theme.colorReplacements?.[color] === replacement,
53 )
54 )
55 continue;
56 await highlighter.loadTheme({
57 ...theme,
58 colorReplacements: { ...theme.colorReplacements, ...neutralColors },
59 });
60 }
61 return highlighter;
62 })
63 .catch((error) => {
64 if (language === "text") {
65 highlighterPromiseCache.delete(language);
66 // "text" itself failed — Shiki cannot initialize at all, surface the error
67 throw error;
68 }
69 // Language not supported by Shiki — fall back to "text"
70 return getSyntaxHighlighterPromise("text");
71 });
3772 highlighterPromiseCache.set(language, promise);
3873 return promise;
3974}
apps/web/src/routes/__root.tsx+8-5
......@@ -17,6 +17,7 @@ import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
1717import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding";
1818import { resolveServerBackedAppDisplayName } from "../branding.logic";
1919import { AppSidebarLayout } from "../components/AppSidebarLayout";
20import { MediaPlaybackProvider } from "../components/media/MediaPlaybackHost";
2021import { CommandPalette } from "../components/CommandPalette";
2122import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog";
2223import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
......@@ -219,11 +220,13 @@ function RootRouteView() {
219220 }
220221
221222 const appShell = (
222 <CommandPalette>
223 <AppSidebarLayout>
224 <Outlet />
225 </AppSidebarLayout>
226 </CommandPalette>
223 <MediaPlaybackProvider>
224 <CommandPalette>
225 <AppSidebarLayout>
226 <Outlet />
227 </AppSidebarLayout>
228 </CommandPalette>
229 </MediaPlaybackProvider>
227230 );
228231
229232 // FirstRunGate holds back everything below it — including EventRouter,
packages/client-runtime/src/mediaActions.ts+3-1
......@@ -5,4 +5,6 @@ export type MediaActionId =
55 | "copy-url"
66 | "open-file"
77 | "save"
8 | "copy-image";
8 | "copy-image"
9 | "reveal-in-folder"
10 | "open-quicktime";
packages/contracts/src/ipc.ts+1
......@@ -1226,6 +1226,7 @@ export interface DesktopBridge {
12261226 receiveProviderAuthCallback?: (authorizationUrl: string) => Promise<string>;
12271227 cancelProviderAuthCallback?: (authorizationUrl: string) => Promise<void>;
12281228 openExternal: (url: string) => Promise<boolean>;
1229 openVideo?: (input: { readonly url: string; readonly name: string }) => Promise<void>;
12291230 /**
12301231 * Open a System Settings pane by identifier. Optional: older desktop builds
12311232 * lack it, and callers no-op when it is missing.
packages/markdown-editor/LICENSE created+15
......@@ -0,0 +1,15 @@
1ISC License
2
3Copyright (c) 2026 paperclover
4
5Permission to use, copy, modify, and/or distribute this software for any
6purpose with or without fee is hereby granted, provided that the above
7copyright notice and this permission notice appear in all copies.
8
9THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
packages/markdown-editor/package.json created+48
......@@ -0,0 +1,48 @@
1{
2 "name": "@clo/markdown-editor",
3 "private": true,
4 "description": "headless CodeMirror 6 markdown editor with Obsidian-style live preview: composer preset, mention chips, and a thin React binding",
5 "license": "ISC",
6 "repository": {
7 "type": "git",
8 "url": "https://shale.paperclover.net/hexi.git",
9 "directory": "sys/lib/markdown-editor"
10 },
11 "type": "module",
12 "main": "src/index.ts",
13 "exports": {
14 ".": "./src/index.ts",
15 "./react": "./src/react.tsx"
16 },
17 "scripts": {
18 "typecheck": "tsc-rs --noEmit -p tsconfig.browser.json",
19 "test": "vp test run --config ../../vite.config.ts --dir ."
20 },
21 "devDependencies": {
22 "@codemirror/commands": "^6.8.1",
23 "@codemirror/lang-javascript": "^6.2.5",
24 "@codemirror/lang-markdown": "^6.5.0",
25 "@codemirror/language": "^6.12.3",
26 "@codemirror/state": "^6.6.0",
27 "@codemirror/view": "^6.43.1",
28 "@lezer/common": "^1.5.2",
29 "@types/react": "~19.3.0",
30 "react": "^19.2.0",
31 "vite-plus": "catalog:"
32 },
33 "peerDependencies": {
34 "@codemirror/commands": "^6.8.1",
35 "@codemirror/lang-markdown": "^6.5.0",
36 "@codemirror/language": "^6.12.3",
37 "@codemirror/state": "^6.6.0",
38 "@codemirror/view": "^6.43.1",
39 "@lezer/common": "^1.5.2",
40 "react": "^19.2.0"
41 },
42 "peerDependenciesMeta": {
43 "react": {
44 "optional": true
45 }
46 },
47 "packageManager": "pnpm@10.12.4"
48}
packages/markdown-editor/src/chips.ts created+37
......@@ -0,0 +1,37 @@
1// The atomic chip primitive: a StateField that recomputes replace-decorations from the document and
2// serves them as widget chips. Mentions build on it here; consumers build their own chip kinds on
3// the same seam (the acceptance test is that an app-specific paste decoration needs nothing else).
4import type { EditorState, Extension, Range, Transaction } from "@codemirror/state";
5import { StateField } from "@codemirror/state";
6import { Decoration, type DecorationSet, EditorView } from "@codemirror/view";
7
8export interface ChipSpec {
9 /** Recompute the chip decorations for a new state. Called on every doc change. */
10 chips(state: EditorState): Range<Decoration>[];
11 /** Also recompute on selection-only changes — for chips that reveal their source near the caret. */
12 onSelection?: boolean;
13 /** Consumer parser or configuration changes that affect the computed ranges. */
14 rebuild?: (transaction: Transaction) => boolean;
15 /** Register the set as atomic ranges: the caret skips a chip and Backspace deletes it whole. */
16 atomic?: boolean;
17}
18
19export function chipField(spec: ChipSpec): Extension {
20 const field = StateField.define<DecorationSet>({
21 create: (state) => Decoration.set(spec.chips(state), true),
22 update: (previous, transaction) =>
23 transaction.docChanged ||
24 (spec.onSelection && transaction.selection) ||
25 spec.rebuild?.(transaction)
26 ? Decoration.set(spec.chips(transaction.state), true)
27 : previous,
28 provide: (self) =>
29 spec.atomic
30 ? [
31 EditorView.decorations.from(self),
32 EditorView.atomicRanges.of((view) => view.state.field(self)),
33 ]
34 : EditorView.decorations.from(self),
35 });
36 return field;
37}
packages/markdown-editor/src/commands.ts created+297
......@@ -0,0 +1,297 @@
1// Markdown editing commands as StateCommands: they read EditorState and dispatch transactions, so
2// they run headless (property tests drive them with a capturing dispatch) and bind to any keymap.
3import { syntaxTree } from "@codemirror/language";
4import { EditorSelection, type EditorState, type StateCommand } from "@codemirror/state";
5import type { SyntaxNode } from "@lezer/common";
6
7/**
8 * Wrap each selected range in `mark`, or unwrap when the same delimiter already hugs it, so the
9 * shortcut toggles emphasis on a second press; an empty selection inserts the pair and lands the
10 * cursor between.
11 */
12export function toggleInlineMark(mark: string): StateCommand {
13 return ({ state, dispatch }) => {
14 const len = mark.length;
15 const transaction = state.changeByRange((range) => {
16 const before = state.sliceDoc(Math.max(0, range.from - len), range.from);
17 const after = state.sliceDoc(range.to, Math.min(state.doc.length, range.to + len));
18 if (before === mark && after === mark) {
19 return {
20 changes: [
21 { from: range.from - len, to: range.from },
22 { from: range.to, to: range.to + len },
23 ],
24 range: EditorSelection.range(range.from - len, range.to - len),
25 };
26 }
27 return {
28 changes: [
29 { from: range.from, insert: mark },
30 { from: range.to, insert: mark },
31 ],
32 range: EditorSelection.range(range.from + len, range.to + len),
33 };
34 });
35 dispatch(state.update(transaction, { scrollIntoView: true, userEvent: "input" }));
36 return true;
37 };
38}
39
40/** `toggleInlineMark` gated on a non-empty selection, so bare typing of the mark falls through. */
41export function surroundSelection(mark: string): StateCommand {
42 const toggle = toggleInlineMark(mark);
43 return (target) => (target.state.selection.main.empty ? false : toggle(target));
44}
45
46/** Bare `*` over a selection cycles plain → italic → bold → plain. */
47export const cycleEmphasis: StateCommand = ({ state, dispatch }) => {
48 if (state.selection.ranges.some((range) => range.empty)) return false;
49 const transaction = state.changeByRange((range) => {
50 const beforeTwo = state.sliceDoc(Math.max(0, range.from - 2), range.from);
51 const afterTwo = state.sliceDoc(range.to, Math.min(state.doc.length, range.to + 2));
52 if (beforeTwo === "**" && afterTwo === "**") {
53 return {
54 changes: [
55 { from: range.from - 2, to: range.from },
56 { from: range.to, to: range.to + 2 },
57 ],
58 range: EditorSelection.range(range.from - 2, range.to - 2),
59 };
60 }
61 const beforeOne = state.sliceDoc(Math.max(0, range.from - 1), range.from);
62 const afterOne = state.sliceDoc(range.to, Math.min(state.doc.length, range.to + 1));
63 if (beforeOne === "*" && afterOne === "*") {
64 return {
65 changes: [
66 { from: range.from - 1, insert: "*" },
67 { from: range.to + 1, insert: "*" },
68 ],
69 range: EditorSelection.range(range.from + 1, range.to + 1),
70 };
71 }
72 return {
73 changes: [
74 { from: range.from, insert: "*" },
75 { from: range.to, insert: "*" },
76 ],
77 range: EditorSelection.range(range.from + 1, range.to + 1),
78 };
79 });
80 dispatch(state.update(transaction, { scrollIntoView: true, userEvent: "input" }));
81 return true;
82};
83
84/** The fenced/indented code block enclosing `pos`, or null when outside code. */
85export function enclosingCodeBlock(state: EditorState, pos: number): SyntaxNode | null {
86 for (
87 let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1);
88 node;
89 node = node.parent
90 ) {
91 if (node.name === "FencedCode" || node.name === "CodeBlock") return node;
92 }
93 return null;
94}
95
96/**
97 * On the opening fence line of an unclosed fence with an empty body, scaffold the closing fence
98 * and land the cursor on a blank line between the fences.
99 */
100export const autoCloseFence: StateCommand = ({ state, dispatch }) => {
101 const cursor = state.selection.main;
102 if (!cursor.empty) return false;
103 const node = enclosingCodeBlock(state, cursor.head);
104 const line = state.doc.lineAt(cursor.head);
105 const closing = node?.getChildren("CodeMark")[1];
106 if (closing && state.doc.lineAt(closing.from).number === line.number) return false;
107 const parsedOpener =
108 node?.name === "FencedCode" &&
109 line.number === state.doc.lineAt(node.from).number &&
110 node.getChildren("CodeMark").length === 1 &&
111 state.sliceDoc(cursor.head, node.to).trim() === "";
112 // The incremental parser may not have incorporated a just-typed opener by the time Enter runs.
113 // Recognize only a complete opener ending at the caret; this cannot claim prose containing ```.
114 const rawOpener =
115 state.sliceDoc(cursor.head).trim() === ""
116 ? /^(\s*)(`{3,}|~{3,})([^`~]*)$/.exec(state.sliceDoc(line.from, cursor.head))
117 : null;
118 if (!parsedOpener && !rawOpener) return false;
119 const close = rawOpener ? `${rawOpener[1]}${rawOpener[2]}` : "```";
120 dispatch(
121 state.update({
122 changes: { from: cursor.head, insert: `\n\n${close}` },
123 selection: { anchor: cursor.head + 1 },
124 scrollIntoView: true,
125 userEvent: "input",
126 }),
127 );
128 return true;
129};
130
131// A list line: optional indent, then a bullet (`-`/`*`/`+`), an ordered marker (`1.`/`1)`), or a
132// continuation line indented under one. Four spaces make nesting visibly distinct and remain
133// unambiguous across CommonMark list-marker widths.
134const LIST_LINE = /^(\s*)(?:[-*+]|\d{1,9}[.)])\s/;
135const INDENT = " ";
136
137function listLines(state: EditorState): number[] {
138 const lines = new Set<number>();
139 for (const range of state.selection.ranges) {
140 const first = state.doc.lineAt(range.from).number;
141 const last = state.doc.lineAt(range.to).number;
142 for (let n = first; n <= last; n++) {
143 if (LIST_LINE.test(state.doc.line(n).text)) lines.add(n);
144 }
145 }
146 return [...lines].sort((a, b) => a - b);
147}
148
149/** Tab in a list: indent every selected list line by one unit. Falls through outside lists. */
150export const indentListItem: StateCommand = ({ state, dispatch }) => {
151 const lines = listLines(state);
152 if (lines.length === 0) return false;
153 dispatch(
154 state.update({
155 changes: lines.map((n) => ({ from: state.doc.line(n).from, insert: INDENT })),
156 scrollIntoView: true,
157 userEvent: "input.indent",
158 }),
159 );
160 return true;
161};
162
163/** Shift-Tab in a list: dedent every selected list line that has leading indent. */
164export const dedentListItem: StateCommand = ({ state, dispatch }) => {
165 const lines = listLines(state);
166 const changes = lines.flatMap((n) => {
167 const line = state.doc.line(n);
168 const indent = /^\s+/.exec(line.text)?.[0] ?? "";
169 if (indent === "") return [];
170 return [{ from: line.from, to: line.from + Math.min(INDENT.length, indent.length) }];
171 });
172 if (changes.length === 0) return false;
173 dispatch(state.update({ changes, scrollIntoView: true, userEvent: "delete.dedent" }));
174 return true;
175};
176
177const LIST_MARKER = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(\[[ xX]\]\s)?/;
178const QUOTE_PREFIX = /^\s*(?:>\s?)+/;
179
180/**
181 * Enter inside a list item or blockquote continues it: the next line gets the same marker (with
182 * ordered numbers incremented and task boxes reset to unchecked), and text after the caret moves
183 * to the new item. Enter on an *empty* item terminates the construct by clearing its marker.
184 * Falls through outside lists/quotes, or with the caret inside the marker itself.
185 */
186export const continueList: StateCommand = ({ state, dispatch }) => {
187 const range = state.selection.main;
188 if (!range.empty) return false;
189 const line = state.doc.lineAt(range.head);
190
191 const list = LIST_MARKER.exec(line.text);
192 if (list) {
193 const markerEnd = line.from + list[0].length;
194 if (range.head < markerEnd) return false;
195 if (line.text.slice(list[0].length).trim() === "") {
196 dispatch(
197 state.update({
198 changes: { from: line.from, to: line.to, insert: "" },
199 selection: { anchor: line.from },
200 scrollIntoView: true,
201 userEvent: "delete",
202 }),
203 );
204 return true;
205 }
206 const marker = list[2]!;
207 const next = /^\d/.test(marker)
208 ? `${Number.parseInt(marker, 10) + 1}${marker.slice(-1)}`
209 : marker;
210 const insert = `\n${list[1]}${next}${list[3]}${list[4] ? "[ ] " : ""}`;
211 dispatch(
212 state.update(state.replaceSelection(insert), {
213 scrollIntoView: true,
214 userEvent: "input",
215 }),
216 );
217 return true;
218 }
219
220 const quote = QUOTE_PREFIX.exec(line.text);
221 if (quote) {
222 if (line.text.slice(quote[0].length).trim() === "") {
223 dispatch(
224 state.update({
225 changes: { from: line.from, to: line.to, insert: "" },
226 selection: { anchor: line.from },
227 scrollIntoView: true,
228 userEvent: "delete",
229 }),
230 );
231 return true;
232 }
233 if (range.head < line.from + quote[0].length) return false;
234 dispatch(
235 state.update(state.replaceSelection(`\n${quote[0]}`), {
236 scrollIntoView: true,
237 userEvent: "input",
238 }),
239 );
240 return true;
241 }
242 return false;
243};
244
245export const insertNewline: StateCommand = ({ state, dispatch }) => {
246 dispatch(
247 state.update(state.replaceSelection("\n"), { scrollIntoView: true, userEvent: "input" }),
248 );
249 return true;
250};
251
252/** Enter continues indentation; two trailing blank lines leave a fence for prose. */
253export const codeBlockEnter: StateCommand = (target) => {
254 if (autoCloseFence(target)) return true;
255
256 const { state, dispatch } = target;
257 const range = state.selection.main;
258 const block = enclosingCodeBlock(state, range.from);
259 if (!block) return false;
260
261 const marks = block.getChildren("CodeMark");
262 const closing = marks[1];
263 if (closing && range.from >= closing.from) return insertNewline(target);
264
265 if (block.name === "FencedCode" && range.empty) {
266 const openingLine = state.doc.lineAt(block.from);
267 const bodyFrom = openingLine.to + 1;
268 const bodyTo = closing ? state.doc.lineAt(closing.from).from - 1 : block.to;
269 const trailing = /\n[ \t]*\n[ \t]*$/.exec(state.sliceDoc(bodyFrom, bodyTo));
270 if (range.head === bodyTo && trailing) {
271 const from = bodyTo - trailing[0].length;
272 const fence = closing
273 ? state.doc.lineAt(closing.from).text
274 : state.sliceDoc(marks[0]!.from, marks[0]!.to);
275 const insert = `\n${fence}\n\n`;
276 dispatch(
277 state.update({
278 changes: { from, to: block.to, insert },
279 selection: { anchor: from + insert.length },
280 scrollIntoView: true,
281 userEvent: "input",
282 }),
283 );
284 return true;
285 }
286 }
287
288 const line = state.doc.lineAt(range.from);
289 const indent = /^[ \t]*/.exec(state.sliceDoc(line.from, range.from))![0];
290 dispatch(
291 state.update(state.replaceSelection(`\n${indent}`), {
292 scrollIntoView: true,
293 userEvent: "input",
294 }),
295 );
296 return true;
297};
packages/markdown-editor/src/editor.ts created+339
......@@ -0,0 +1,339 @@
1// The composer-preset factory: a CodeMirror 6 markdown editor with live preview, mention chips,
2// and a submit-on-Enter keymap, handed back as a plain-TS handle the host mounts wherever it
3// likes. Consumer-specific behavior (extra decorations, paste chips, app keymaps) arrives through
4// `extensions` — the package never learns about it.
5import { history } from "@codemirror/commands";
6import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
7import type { Language, LanguageDescription } from "@codemirror/language";
8import { EditorState, type Extension, Prec } from "@codemirror/state";
9import { EditorView, keymap, placeholder as placeholderExt } from "@codemirror/view";
10
11import { codeBlockEnter, continueList, insertNewline } from "./commands.ts";
12import { baseKeymap, markdownKeymap } from "./keymap.ts";
13import { livePreview } from "./live-preview.ts";
14import {
15 createMentionRegistry,
16 hydrateMentions,
17 insertMention as insertMentionChip,
18 type MentionCandidate,
19 mentionsExtension,
20 mentionTriggerAt,
21 serializeMentions,
22} from "./mentions.ts";
23import {
24 type EmojiTrigger,
25 emojiTriggerAt,
26 type MentionTrigger,
27 type SlashTrigger,
28 slashTriggerAt,
29} from "./triggers.ts";
30
31export interface MarkdownEditor {
32 /** The CodeMirror root; the caller mounts it where the input used to sit. */
33 dom: HTMLElement;
34 /** The underlying view, for consumer extensions that dispatch their own effects. */
35 view: EditorView;
36 /** The serialized text: mention chips expanded to their `@token`. */
37 getValue(): string;
38 setValue(value: string): void;
39 clear(): void;
40 focus(): void;
41 insertText(text: string): void;
42 replaceText(text: string, range: { from: number; to: number }): void;
43 /** Insert an atomic mention chip, replacing `range` (the `@query` being completed) when given. */
44 insertMention(mention: MentionCandidate, range?: { from: number; to: number }): void;
45 destroy(): void;
46}
47
48const LARGE_PASTE_THRESHOLD = 10_000;
49
50/** The image files carried by a paste or drop, if any. */
51function imageFiles(data: DataTransfer | null): File[] {
52 if (!data) return [];
53 return [...data.files].filter((file) => file.type.startsWith("image/"));
54}
55
56const composerTheme = EditorView.theme({
57 "&": { fontSize: "14px", color: "var(--markdown-text, var(--text))" },
58 "&.cm-focused": { outline: "none" },
59 ".cm-content": { padding: "9px 12px", caretColor: "var(--markdown-text, var(--text))" },
60 ".cm-line": { padding: "0" },
61 ".cm-scroller": {
62 fontFamily: "inherit",
63 lineHeight: "1.55",
64 maxHeight: "var(--composer-max, 200px)",
65 overflowY: "auto",
66 scrollbarWidth: "thin",
67 },
68 ".cm-placeholder": { color: "var(--markdown-muted, var(--muted))" },
69});
70
71const documentTheme = EditorView.theme({
72 "&": { fontSize: "14px", color: "var(--markdown-text, var(--text))" },
73 "&.cm-focused": { outline: "none" },
74 ".cm-content": { padding: "12px 14px", caretColor: "var(--markdown-text, var(--text))" },
75 ".cm-line": { padding: "0" },
76 ".cm-scroller": {
77 fontFamily: "inherit",
78 lineHeight: "1.6",
79 overflowY: "auto",
80 scrollbarWidth: "thin",
81 },
82 ".cm-placeholder": { color: "var(--markdown-muted, var(--muted))" },
83});
84
85export interface MarkdownEditorOptions {
86 /**
87 * `composer` (default): chat-input behavior — Enter submits, block sources stay text-shaped.
88 * `document`: full-document behavior — Enter is a plain newline (fence-aware), and tables,
89 * images, and horizontal rules render as widgets while the caret is elsewhere.
90 */
91 preset?: "composer" | "document";
92 placeholder?: string;
93 /** Use host-owned chips instead of the built-in `@path` registry. */
94 mentions?: boolean;
95 /** Use a host-owned live-preview compartment when false. */
96 preview?: boolean;
97 /** Initial text; `@path` tokens hydrate to mention chips. */
98 doc?: string;
99 onChange?: (value: string) => void;
100 onSubmit?: () => void;
101 onMention?: (trigger: MentionTrigger | null) => void;
102 onSlash?: (trigger: SlashTrigger | null) => void;
103 onEmoji?: (trigger: EmojiTrigger | null) => void;
104 onArrow?: (direction: -1 | 1) => boolean;
105 onAccept?: () => boolean;
106 onEscape?: () => boolean;
107 /** Image files pasted or dropped onto the editor; returning true claims the event. */
108 onFiles?: (files: File[]) => boolean;
109 /** Maps relative image URLs to something fetchable (host seam); identity when omitted. */
110 resolveUrl?: (url: string) => string;
111 /** Consumer-owned fenced-code parsers; the package bundles no language data. */
112 codeLanguages?:
113 | readonly LanguageDescription[]
114 | ((info: string) => Language | LanguageDescription | null);
115 /** Consumer extensions: extra decorations, chips, keymaps. Appended after the built-ins. */
116 extensions?: Extension[];
117}
118
119/**
120 * A composer editor. Enter submits (a newline inside a code block instead); Shift-Enter always
121 * inserts a newline; Mod-b/Mod-i toggle bold/italic; a bare `` ` ``/`*` surrounds a selection.
122 * `onChange` fires on every edit so the caller can sync dependent state; `onSubmit` fires on a
123 * submitting Enter and reads the current value off the returned editor.
124 *
125 * The `onMention`/`onSlash`/`onEmoji`/`onArrow`/`onAccept`/`onEscape` hooks let a picker claim keys
126 * first: as the caret's trigger run changes the trigger hooks fire (with null when it closes);
127 * while the picker is open the arrows move its selection, Enter accepts instead of submitting, and
128 * Escape closes it — each returns true when it consumed the key.
129 */
130export function createMarkdownEditor(opts: MarkdownEditorOptions = {}): MarkdownEditor {
131 const registry = createMentionRegistry();
132 const documentPreset = opts.preset === "document";
133 const hydrate = (value: string) =>
134 opts.mentions === false ? value : hydrateMentions(registry, value);
135 const serialize = (value: string) =>
136 opts.mentions === false ? value : serializeMentions(registry, value);
137
138 const submitKeymap = Prec.highest(
139 keymap.of([
140 {
141 key: "Enter",
142 run: (view) => {
143 // Structural editing claims Enter before the composer submit action. An empty list or
144 // quote item terminates its structure; the following Enter can then submit normally.
145 if (opts.onAccept?.()) return true;
146 if (codeBlockEnter(view)) return true;
147 if (continueList(view)) return true;
148 if (!opts.onSubmit) return insertNewline(view);
149 opts.onSubmit();
150 return true;
151 },
152 },
153 { key: "ArrowDown", run: () => opts.onArrow?.(1) ?? false },
154 { key: "ArrowUp", run: () => opts.onArrow?.(-1) ?? false },
155 { key: "Escape", run: () => opts.onEscape?.() ?? false },
156 // The composer's newline key continues lists too, so multi-line drafts behave like a
157 // document even though plain Enter submits.
158 {
159 key: "Shift-Enter",
160 run: (view) => codeBlockEnter(view) || continueList(view) || insertNewline(view),
161 },
162 ]),
163 );
164
165 const extensions: Extension[] = [
166 history(),
167 baseKeymap(),
168 markdownKeymap(),
169 markdown({
170 base: markdownLanguage,
171 completeHTMLTags: false,
172 ...(opts.codeLanguages ? { codeLanguages: opts.codeLanguages } : {}),
173 extensions: [
174 {
175 parseBlock: [
176 {
177 name: "ExplicitBlockquotes",
178 before: "LinkReference",
179 leaf(context) {
180 let quoteDepth = 0;
181 for (let depth = 0; depth < context.depth; depth++) {
182 if (context.parentType(depth).name === "Blockquote") quoteDepth++;
183 }
184 if (quoteDepth === 0) return null;
185
186 return {
187 nextLine(context, line, block) {
188 const marks = line.markers.filter(
189 (mark) => context.parser.nodeSet.types[mark.type]?.name === "QuoteMark",
190 );
191 if (marks.length >= quoteDepth) return false;
192
193 // A missing > ends the quote, including CommonMark's lazy continuation lines.
194 if (!block.parsers.some((parser) => parser.finish(context, block))) {
195 context.addLeafElement(
196 block,
197 context.elt(
198 "Paragraph",
199 block.start,
200 block.start + block.content.length,
201 context.parser.parseInline(block.content, block.start),
202 ),
203 );
204 }
205 return true;
206 },
207 finish: () => false,
208 };
209 },
210 },
211 ],
212 },
213 // The composer stays HTML-free; the document preset previews sanitized readme HTML.
214 documentPreset
215 ? {}
216 : {
217 remove: ["HTMLBlock", "HTMLTag"],
218 parseBlock: [
219 {
220 name: "ComposerSetextHeading",
221 after: "SetextHeading",
222 leaf(_, leaf) {
223 // Preserve Lezer's Setext parser identity for precedence over horizontal rules.
224 const setext = leaf.parsers.at(-1)!;
225 const nextLine = setext.nextLine.bind(setext);
226 setext.nextLine = (context, line, block) => {
227 if (/^(?:-{1,2}|={1,2})[ \t]*$/.test(line.text.slice(line.pos))) {
228 return false;
229 }
230
231 return nextLine(context, line, block);
232 };
233 return null;
234 },
235 },
236 ],
237 },
238 ],
239 }),
240 EditorView.lineWrapping,
241 ...(opts.preview === false
242 ? []
243 : [
244 livePreview({
245 blockWidgets: documentPreset,
246 html: documentPreset,
247 ...(opts.resolveUrl ? { resolveUrl: opts.resolveUrl } : {}),
248 }),
249 ]),
250 ...(opts.mentions === false ? [] : [mentionsExtension(registry)]),
251 ...(opts.extensions ?? []),
252 EditorView.domEventHandlers({
253 paste: (event) => {
254 const files = imageFiles(event.clipboardData);
255 if (files.length !== 0) {
256 const handled = opts.onFiles?.(files) ?? false;
257 if (handled) event.preventDefault();
258 return handled;
259 }
260 const text = event.clipboardData?.getData("text/plain");
261 if (!text || text.length < LARGE_PASTE_THRESHOLD) return false;
262 event.preventDefault();
263 setTimeout(() => {
264 view.dispatch({
265 ...view.state.replaceSelection(hydrate(text)),
266 scrollIntoView: true,
267 userEvent: "input",
268 });
269 }, 0);
270 return true;
271 },
272 drop: (event) => {
273 const files = imageFiles(event.dataTransfer);
274 if (files.length === 0) return false;
275 const handled = opts.onFiles?.(files) ?? false;
276 if (handled) event.preventDefault();
277 return handled;
278 },
279 }),
280 submitKeymap,
281 ...(opts.placeholder === undefined ? [] : [placeholderExt(opts.placeholder)]),
282 documentPreset ? documentTheme : composerTheme,
283 EditorView.updateListener.of((update) => {
284 if (update.docChanged) opts.onChange?.(serialize(update.state.doc.toString()));
285 // A caret move (no doc edit) also opens/closes the picker, so track the trigger on either.
286 if (update.docChanged || update.selectionSet) {
287 opts.onMention?.(mentionTriggerAt(update.state));
288 opts.onSlash?.(slashTriggerAt(update.state));
289 opts.onEmoji?.(emojiTriggerAt(update.state));
290 }
291 }),
292 ];
293
294 const view = new EditorView({
295 state: EditorState.create({
296 doc: opts.doc === undefined ? "" : hydrate(opts.doc),
297 extensions,
298 }),
299 });
300
301 return {
302 dom: view.dom,
303 view,
304 getValue: () => serialize(view.state.doc.toString()),
305 setValue(value) {
306 view.dispatch({
307 changes: { from: 0, to: view.state.doc.length, insert: hydrate(value) },
308 });
309 },
310 clear() {
311 view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "" } });
312 },
313 focus: () => view.focus(),
314 insertText(text) {
315 const range = view.state.selection.main;
316 const needsLead =
317 range.from > 0 && !/\s/.test(view.state.sliceDoc(range.from - 1, range.from));
318 const insert = `${needsLead ? " " : ""}${text}`;
319 view.dispatch({
320 changes: { from: range.from, to: range.to, insert },
321 selection: { anchor: range.from + insert.length },
322 scrollIntoView: true,
323 userEvent: "input",
324 });
325 view.focus();
326 },
327 replaceText(text, range) {
328 view.dispatch({
329 changes: { from: range.from, to: range.to, insert: text },
330 selection: { anchor: range.from + text.length },
331 scrollIntoView: true,
332 userEvent: "input",
333 });
334 view.focus();
335 },
336 insertMention: (mention, range) => insertMentionChip(view, registry, mention, range),
337 destroy: () => view.destroy(),
338 };
339}
packages/markdown-editor/src/emoji.ts created+211
......@@ -0,0 +1,211 @@
1/** A `:shortcode:` emoji the editor can complete, matched by its code or its keywords. */
2export interface Emoji {
3 code: string;
4 char: string;
5 keywords?: readonly string[];
6}
7
8// A curated set skewed toward dev-chat use rather than the full Unicode table, so the whole
9// autocomplete stack stays a static in-bundle lookup with no dependency.
10const EMOJI: readonly Emoji[] = [
11 { code: "smile", char: "😄", keywords: ["happy", "joy"] },
12 { code: "smiley", char: "😃", keywords: ["happy"] },
13 { code: "grin", char: "😁" },
14 { code: "laughing", char: "😆", keywords: ["lol", "haha"] },
15 { code: "rofl", char: "🤣", keywords: ["lmao"] },
16 { code: "joy", char: "😂", keywords: ["tears", "cry", "laugh"] },
17 { code: "sweat_smile", char: "😅", keywords: ["phew"] },
18 { code: "wink", char: "😉" },
19 { code: "blush", char: "😊", keywords: ["happy"] },
20 { code: "slight_smile", char: "🙂" },
21 { code: "upside_down", char: "🙃" },
22 { code: "yum", char: "😋", keywords: ["tasty"] },
23 { code: "sunglasses", char: "😎", keywords: ["cool"] },
24 { code: "heart_eyes", char: "😍", keywords: ["love"] },
25 { code: "star_struck", char: "🤩", keywords: ["amazed"] },
26 { code: "thinking", char: "🤔", keywords: ["hmm"] },
27 { code: "smirk", char: "😏" },
28 { code: "neutral", char: "😐", keywords: ["meh"] },
29 { code: "expressionless", char: "😑" },
30 { code: "unamused", char: "😒" },
31 { code: "roll_eyes", char: "🙄" },
32 { code: "grimacing", char: "😬", keywords: ["yikes"] },
33 { code: "relieved", char: "😌" },
34 { code: "pensive", char: "😔" },
35 { code: "sleepy", char: "😪", keywords: ["tired"] },
36 { code: "sleeping", char: "😴", keywords: ["zzz"] },
37 { code: "mask", char: "😷", keywords: ["sick"] },
38 { code: "dizzy_face", char: "😵" },
39 { code: "exploding_head", char: "🤯", keywords: ["mind", "blown"] },
40 { code: "cowboy", char: "🤠" },
41 { code: "sob", char: "😭", keywords: ["cry", "crying"] },
42 { code: "cry", char: "😢", keywords: ["sad", "tear"] },
43 { code: "disappointed", char: "😞", keywords: ["sad"] },
44 { code: "worried", char: "😟" },
45 { code: "confused", char: "😕" },
46 { code: "frowning", char: "🙁" },
47 { code: "persevere", char: "😣" },
48 { code: "confounded", char: "😖" },
49 { code: "tired_face", char: "😫" },
50 { code: "weary", char: "😩" },
51 { code: "pleading", char: "🥺", keywords: ["puppy"] },
52 { code: "triumph", char: "😤" },
53 { code: "angry", char: "😠", keywords: ["mad"] },
54 { code: "rage", char: "😡", keywords: ["mad", "furious"] },
55 { code: "cursing", char: "🤬", keywords: ["swear"] },
56 { code: "scream", char: "😱", keywords: ["shock", "fear"] },
57 { code: "fearful", char: "😨" },
58 { code: "cold_sweat", char: "😰" },
59 { code: "flushed", char: "😳", keywords: ["blush", "embarrassed"] },
60 { code: "hot", char: "🥵", keywords: ["heat"] },
61 { code: "cold", char: "🥶", keywords: ["freezing"] },
62 { code: "woozy", char: "🥴" },
63 { code: "nauseated", char: "🤢", keywords: ["sick"] },
64 { code: "vomiting", char: "🤮", keywords: ["puke"] },
65 { code: "sneezing", char: "🤧" },
66 { code: "shush", char: "🤫", keywords: ["quiet"] },
67 { code: "zipper_mouth", char: "🤐" },
68 { code: "raised_eyebrow", char: "🤨", keywords: ["skeptical", "suspicious"] },
69 { code: "monocle", char: "🧐", keywords: ["inspect"] },
70 { code: "nerd", char: "🤓", keywords: ["geek"] },
71 { code: "shrug", char: "🤷", keywords: ["dunno", "idk"] },
72 { code: "facepalm", char: "🤦", keywords: ["ugh"] },
73 { code: "wave", char: "👋", keywords: ["hi", "hello", "bye"] },
74 { code: "thumbsup", char: "👍", keywords: ["+1", "yes", "approve", "lgtm"] },
75 { code: "thumbsdown", char: "👎", keywords: ["-1", "no"] },
76 { code: "ok_hand", char: "👌" },
77 { code: "pinch", char: "🤏", keywords: ["small", "tiny"] },
78 { code: "v", char: "✌️", keywords: ["peace"] },
79 { code: "crossed_fingers", char: "🤞", keywords: ["luck", "hope"] },
80 { code: "point_up", char: "☝️" },
81 { code: "point_down", char: "👇" },
82 { code: "point_left", char: "👈" },
83 { code: "point_right", char: "👉" },
84 { code: "raised_hands", char: "🙌", keywords: ["praise", "yay"] },
85 { code: "clap", char: "👏", keywords: ["applause"] },
86 { code: "pray", char: "🙏", keywords: ["please", "thanks", "thank you"] },
87 { code: "handshake", char: "🤝", keywords: ["deal"] },
88 { code: "muscle", char: "💪", keywords: ["strong", "flex"] },
89 { code: "fist", char: "✊" },
90 { code: "punch", char: "👊", keywords: ["bump"] },
91 { code: "eyes", char: "👀", keywords: ["look", "watching"] },
92 { code: "brain", char: "🧠" },
93 { code: "heart", char: "❤️", keywords: ["love"] },
94 { code: "orange_heart", char: "🧡" },
95 { code: "yellow_heart", char: "💛" },
96 { code: "green_heart", char: "💚" },
97 { code: "blue_heart", char: "💙" },
98 { code: "purple_heart", char: "💜" },
99 { code: "black_heart", char: "🖤" },
100 { code: "broken_heart", char: "💔" },
101 { code: "sparkling_heart", char: "💖" },
102 { code: "fire", char: "🔥", keywords: ["lit", "hot", "flame"] },
103 { code: "sparkles", char: "✨", keywords: ["shiny", "clean"] },
104 { code: "star", char: "⭐", keywords: ["favorite"] },
105 { code: "star2", char: "🌟", keywords: ["glowing"] },
106 { code: "zap", char: "⚡", keywords: ["lightning", "fast"] },
107 { code: "boom", char: "💥", keywords: ["explosion", "collision"] },
108 { code: "collision", char: "💢" },
109 { code: "dizzy", char: "💫" },
110 { code: "sweat_drops", char: "💦" },
111 { code: "dash", char: "💨", keywords: ["fast", "wind"] },
112 { code: "hundred", char: "💯", keywords: ["100", "perfect"] },
113 { code: "tada", char: "🎉", keywords: ["party", "celebrate", "yay"] },
114 { code: "confetti", char: "🎊", keywords: ["party"] },
115 { code: "balloon", char: "🎈" },
116 { code: "gift", char: "🎁", keywords: ["present"] },
117 { code: "trophy", char: "🏆", keywords: ["win", "award"] },
118 { code: "medal", char: "🏅" },
119 { code: "rocket", char: "🚀", keywords: ["ship", "launch", "deploy"] },
120 { code: "airplane", char: "✈️" },
121 { code: "bulb", char: "💡", keywords: ["idea"] },
122 { code: "wrench", char: "🔧", keywords: ["fix", "tool"] },
123 { code: "hammer", char: "🔨", keywords: ["build"] },
124 { code: "hammer_and_wrench", char: "🛠️", keywords: ["tools"] },
125 { code: "gear", char: "⚙️", keywords: ["settings", "config"] },
126 { code: "nut_and_bolt", char: "🔩" },
127 { code: "lock", char: "🔒", keywords: ["secure", "private"] },
128 { code: "unlock", char: "🔓" },
129 { code: "key", char: "🔑" },
130 { code: "mag", char: "🔍", keywords: ["search", "find", "zoom"] },
131 { code: "link", char: "🔗" },
132 { code: "paperclip", char: "📎" },
133 { code: "pushpin", char: "📌", keywords: ["pin"] },
134 { code: "memo", char: "📝", keywords: ["note", "write", "edit"] },
135 { code: "clipboard", char: "📋", keywords: ["copy", "paste"] },
136 { code: "books", char: "📚", keywords: ["docs", "read"] },
137 { code: "book", char: "📖" },
138 { code: "package", char: "📦", keywords: ["box", "npm", "module"] },
139 { code: "computer", char: "💻", keywords: ["laptop", "code"] },
140 { code: "keyboard", char: "⌨️" },
141 { code: "floppy_disk", char: "💾", keywords: ["save"] },
142 { code: "battery", char: "🔋" },
143 { code: "electric_plug", char: "🔌" },
144 { code: "hourglass", char: "⏳", keywords: ["wait", "loading", "time"] },
145 { code: "watch", char: "⌚", keywords: ["time"] },
146 { code: "alarm_clock", char: "⏰", keywords: ["time", "reminder"] },
147 { code: "calendar", char: "📅", keywords: ["date", "schedule"] },
148 { code: "chart", char: "📈", keywords: ["graph", "up", "growth"] },
149 { code: "chart_down", char: "📉", keywords: ["graph", "down"] },
150 { code: "bar_chart", char: "📊", keywords: ["graph", "stats"] },
151 { code: "bug", char: "🐛", keywords: ["issue", "defect"] },
152 { code: "skull", char: "💀", keywords: ["dead", "cooked"] },
153 { code: "ghost", char: "👻", keywords: ["boo"] },
154 { code: "alien", char: "👽" },
155 { code: "robot", char: "🤖", keywords: ["bot", "ai"] },
156 { code: "poop", char: "💩", keywords: ["crap"] },
157 { code: "clown", char: "🤡" },
158 { code: "check", char: "✅", keywords: ["done", "yes", "pass", "white_check_mark"] },
159 { code: "heavy_check", char: "✔️", keywords: ["done"] },
160 { code: "x", char: "❌", keywords: ["no", "fail", "cross"] },
161 { code: "warning", char: "⚠️", keywords: ["caution", "alert"] },
162 { code: "no_entry", char: "⛔", keywords: ["stop", "blocked"] },
163 { code: "question", char: "❓", keywords: ["help"] },
164 { code: "exclamation", char: "❗", keywords: ["important"] },
165 { code: "recycle", char: "♻️", keywords: ["refactor"] },
166 { code: "arrows_clockwise", char: "🔄", keywords: ["refresh", "sync", "retry"] },
167 { code: "back", char: "🔙" },
168 { code: "arrow_up", char: "⬆️" },
169 { code: "arrow_down", char: "⬇️" },
170 { code: "arrow_right", char: "➡️" },
171 { code: "arrow_left", char: "⬅️" },
172 { code: "hash", char: "#️⃣" },
173 { code: "red_circle", char: "🔴", keywords: ["record", "live"] },
174 { code: "green_circle", char: "🟢", keywords: ["online", "ok"] },
175 { code: "yellow_circle", char: "🟡" },
176 { code: "coffee", char: "☕", keywords: ["cafe"] },
177 { code: "beer", char: "🍺", keywords: ["drink", "cheers"] },
178 { code: "pizza", char: "🍕" },
179 { code: "cake", char: "🎂", keywords: ["birthday"] },
180 { code: "hamburger", char: "🍔" },
181 { code: "sun", char: "☀️", keywords: ["sunny"] },
182 { code: "moon", char: "🌙", keywords: ["night"] },
183 { code: "cloud", char: "☁️" },
184 { code: "rainbow", char: "🌈" },
185 { code: "snowflake", char: "❄️", keywords: ["cold", "snow"] },
186 { code: "cat", char: "🐱" },
187 { code: "dog", char: "🐶" },
188 { code: "unicorn", char: "🦄" },
189 { code: "snake", char: "🐍", keywords: ["python"] },
190 { code: "penguin", char: "🐧", keywords: ["linux"] },
191 { code: "four_leaf_clover", char: "🍀", keywords: ["luck", "clover"] },
192];
193
194/**
195 * Emoji matching `query` (the text after `:`), code-prefix matches first, then code substrings, then
196 * keyword hits. An empty query returns the leading slice so the menu can open on a bare `:`.
197 */
198export function searchEmoji(query: string, limit = 8): Emoji[] {
199 const q = query.toLowerCase();
200 if (q === "") return EMOJI.slice(0, limit);
201 const prefix: Emoji[] = [];
202 const infix: Emoji[] = [];
203 const keyword: Emoji[] = [];
204 for (const emoji of EMOJI) {
205 if (emoji.code.startsWith(q)) prefix.push(emoji);
206 else if (emoji.code.includes(q)) infix.push(emoji);
207 else if (emoji.keywords?.some((word) => word.includes(q))) keyword.push(emoji);
208 if (prefix.length >= limit) break;
209 }
210 return [...prefix, ...infix, ...keyword].slice(0, limit);
211}
packages/markdown-editor/src/hard-wrap.ts created+606
......@@ -0,0 +1,606 @@
1// Hard-wrapped markdown, edited as though it were soft-wrapped.
2//
3// A file whose paragraphs are wrapped at a column edits badly under live preview: every newline is
4// a real break, so the caret moves per source line, selection-by-paragraph is impossible, and
5// rewrapping after an edit is manual. This module makes such a paragraph *look* like one line
6// without changing a byte of the document: the line breaks inside a paragraph are hidden behind
7// replace decorations, and the file's own bytes stay in the CodeMirror document.
8//
9// That layering is load-bearing, not a style choice. The workbench builds `ChangeSet`s against the
10// host's text and pushes them into a shared version stream; a transform that rewrote the document
11// on load and un-rewrote it on save would make every offset in every ChangeSet a lie (the same
12// hazard that keeps `createMarkdownEditor`'s mention sentinels out of the file-editing path). So
13// the document is always the file, the join is a decoration, and the only writes are ordinary
14// transactions: `rewrapOnEdit` re-flows the one block the user just typed in, appended to their own
15// transaction, and nothing else in the file is touched.
16//
17// Everything above the CodeMirror section is pure string logic so the properties can run headless.
18import {
19 EditorState,
20 type Extension,
21 type Range,
22 StateField,
23 Transaction,
24} from "@codemirror/state";
25import { Decoration, type DecorationSet, EditorView, WidgetType } from "@codemirror/view";
26
27/** The column the rewrap fills to. No UI surface: consumers pass it, this is the code-level default. */
28export const DEFAULT_WRAP_COLUMN = 80;
29
30export interface HardWrapOptions {
31 /** Fill column for `rewrap*`. Defaults to {@link DEFAULT_WRAP_COLUMN}. Ignored by the scanner. */
32 column?: number;
33}
34
35/** A source range covering one hidden line break: trailing space, the break, and the next line's prefix. */
36export interface JoinRange {
37 from: number;
38 to: number;
39}
40
41/** A run of source lines that renders as a single markdown paragraph (prose, list item, or quoted). */
42export interface WrapBlock {
43 /** Offset of the first line's first character (its indent, not its content). */
44 from: number;
45 /** Offset of the end of the last line's text, excluding its line break. */
46 to: number;
47 lineCount: number;
48 /** The internal breaks, in order. Empty for a single-line block. */
49 joins: JoinRange[];
50 /** Everything before content on the first line: indent, quote markers, and any list marker. */
51 firstPrefix: string;
52 /** Everything before content on continuation lines, taken from the block's second line. */
53 contPrefix: string;
54 /** The block's own line terminator, so a CRLF file is rewrapped with CRLF. */
55 lineBreak: string;
56}
57
58/** A minimal edit produced by the rewrap: always a whitespace run, never a word. */
59export interface TextChange {
60 from: number;
61 to: number;
62 insert: string;
63}
64
65// ---------------------------------------------------------------------------
66// Line scanning
67// ---------------------------------------------------------------------------
68
69interface SourceLine {
70 start: number;
71 /** The line's text without its terminator. */
72 text: string;
73 /** The line's terminator: `\r\n`, `\n`, `\r`, or empty at end of input. */
74 breakText: string;
75 /** Characters consumed by indent plus `>` quote markers plus the indent after them. */
76 prefixLen: number;
77 quote: number;
78 /** Whitespace *before* any quote marker. Four or more of it is an indented code block. */
79 leadIndent: number;
80 /** Whitespace after the quote markers; the column the line's content starts at inside them. */
81 indent: number;
82 body: string;
83 /** True for an empty line and for a bare `>` — both terminate a paragraph. */
84 blank: boolean;
85 /** Two-or-more trailing spaces, or an odd run of trailing backslashes: a markdown line break. */
86 hardBreak: boolean;
87}
88
89const PREFIX = /^([ \t]*)((?:>[ \t]?)*)([ \t]*)/;
90const ATX = /^#{1,6}(?:[ \t]|$)/;
91const FENCE = /^(?:`{3,}|~{3,})/;
92const THEMATIC = /^(?:\*[ \t]*){3,}$|^(?:-[ \t]*){3,}$|^(?:_[ \t]*){3,}$/;
93const SETEXT = /^(?:=+|-+)[ \t]*$/;
94const LIST = /^(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/;
95const REF_DEF = /^\[[^\]]*\]:/;
96const HTML_START = /^<[!/A-Za-z]/;
97const TRAILING_BACKSLASHES = /\\*$/;
98
99function hasHardBreak(text: string): boolean {
100 if (/[ \t]{2,}$/.test(text)) return true;
101 const run = TRAILING_BACKSLASHES.exec(text);
102 return (run?.[0].length ?? 0) % 2 === 1;
103}
104
105/** Split into lines, keeping each terminator so CRLF and lone CR survive a round trip. */
106function splitSourceLines(text: string): SourceLine[] {
107 const lines: SourceLine[] = [];
108 let pos = 0;
109 while (pos <= text.length) {
110 let end = text.length;
111 let breakText = "";
112 for (let i = pos; i < text.length; i++) {
113 const ch = text[i];
114 if (ch === "\n") {
115 end = i;
116 breakText = "\n";
117 break;
118 }
119 if (ch === "\r") {
120 end = i;
121 breakText = text[i + 1] === "\n" ? "\r\n" : "\r";
122 break;
123 }
124 }
125 lines.push(describeLine(text.slice(pos, end), pos, breakText));
126 if (breakText === "") break;
127 pos = end + breakText.length;
128 }
129 return lines;
130}
131
132function describeLine(text: string, start: number, breakText: string): SourceLine {
133 const match = PREFIX.exec(text);
134 const prefix = match?.[0] ?? "";
135 const quote = (match?.[2] ?? "").split("").filter((ch) => ch === ">").length;
136 const body = text.slice(prefix.length);
137 return {
138 start,
139 text,
140 breakText,
141 prefixLen: prefix.length,
142 quote,
143 leadIndent: (match?.[1] ?? "").length,
144 indent: quote === 0 ? prefix.length : (match?.[3] ?? "").length,
145 body,
146 blank: body.trim() === "",
147 hardBreak: hasHardBreak(text),
148 };
149}
150
151/**
152 * True when this line's body opens a block of its own. Read in both directions: a paragraph must
153 * not swallow such a line, and a rewrap must never *produce* one — a break that leaves `- x`,
154 * `# x`, `1. x`, `===` or a fence at the head of a line silently turns prose into a new block.
155 */
156export function startsBlockLine(body: string): boolean {
157 return (
158 ATX.test(body) ||
159 FENCE.test(body) ||
160 THEMATIC.test(body) ||
161 SETEXT.test(body) ||
162 LIST.test(body) ||
163 REF_DEF.test(body) ||
164 HTML_START.test(body) ||
165 body.startsWith(">") ||
166 body.includes("|")
167 );
168}
169
170/** Content column of a list marker, or null when the body is not a list item. */
171function listContentOffset(body: string): number | null {
172 const marker = LIST.exec(body);
173 if (!marker) return null;
174 const rest = body.slice(marker[0].length);
175 const extra = /^[ \t]*/.exec(rest)?.[0].length ?? 0;
176 // An empty item ("-" with nothing after) starts no paragraph.
177 if (rest.trim() === "") return null;
178 return marker[0].length + extra;
179}
180
181// ---------------------------------------------------------------------------
182// Block scanning
183// ---------------------------------------------------------------------------
184
185/**
186 * The paragraph-shaped runs of `lines`, each with the internal breaks that may be displayed as a
187 * space. Deliberately conservative: anything the line-level classifier cannot prove is prose
188 * (fences, front matter, tables, setext headings, indented code, HTML, reference definitions, hard
189 * breaks) yields no joins rather than a guess.
190 */
191function scanBlocksFromLines(lines: SourceLine[]): WrapBlock[] {
192 const blocks: WrapBlock[] = [];
193 let i = 0;
194
195 // Front matter: only when the very first line is a `---` fence.
196 if (lines.length > 0 && /^---[ \t]*$/.test(lines[0]?.text ?? "")) {
197 i = 1;
198 while (i < lines.length && !/^(?:---|\.\.\.)[ \t]*$/.test(lines[i]?.text ?? "")) i++;
199 i = Math.min(i + 1, lines.length);
200 }
201
202 while (i < lines.length) {
203 const line = lines[i];
204 if (!line) break;
205 if (line.blank) {
206 i++;
207 continue;
208 }
209 if (FENCE.test(line.body)) {
210 const marker = FENCE.exec(line.body)?.[0] ?? "";
211 const char = marker[0] ?? "`";
212 i++;
213 while (i < lines.length) {
214 const close = lines[i];
215 i++;
216 if (close && new RegExp(`^\\${char}{${marker.length},}[ \t]*$`).test(close.body)) break;
217 }
218 continue;
219 }
220
221 const markerWidth = listContentOffset(line.body);
222 // Four columns of indent is the indented-code floor, and deciding whether a deeper line is code
223 // or a list item's paragraph needs real container parsing. Refuse instead of guessing: a nested
224 // item indented past column 3 simply gets no joins.
225 const startable =
226 line.indent < 4 && line.leadIndent < 4 && !startsBlockLine(line.body.slice(markerWidth ?? 0));
227 if (!startable) {
228 i++;
229 continue;
230 }
231
232 const contentIndent = line.indent + (markerWidth ?? 0);
233
234 let end = i;
235 while (end + 1 < lines.length) {
236 const next = lines[end + 1];
237 const prev = lines[end];
238 if (!next || !prev) break;
239 if (next.blank || prev.hardBreak) break;
240 if (next.quote !== line.quote) break;
241 if (startsBlockLine(next.body)) break;
242 if (next.indent - contentIndent >= 4) break;
243 if (next.leadIndent - line.leadIndent >= 4) break;
244 end++;
245 }
246
247 // A run underlined by `===`/`---` is a setext heading; refuse it rather than reason about it.
248 const after = lines[end + 1];
249 const underlined =
250 after !== undefined && !after.blank && after.quote === line.quote && SETEXT.test(after.body);
251
252 if (end > i && !underlined) blocks.push(buildBlock(lines, i, end, markerWidth ?? 0));
253 i = end + 1;
254 }
255 return blocks;
256}
257
258function contentStart(line: SourceLine, extra = 0): number {
259 return line.start + line.prefixLen + extra;
260}
261
262function buildBlock(
263 lines: SourceLine[],
264 first: number,
265 last: number,
266 markerWidth: number,
267): WrapBlock {
268 const head = lines[first] as SourceLine;
269 const second = lines[first + 1] as SourceLine;
270 const tail = lines[last] as SourceLine;
271 const joins: JoinRange[] = [];
272 for (let k = first; k < last; k++) {
273 const line = lines[k] as SourceLine;
274 const next = lines[k + 1] as SourceLine;
275 joins.push({
276 from: line.start + line.text.replace(/[ \t]+$/, "").length,
277 to: contentStart(next),
278 });
279 }
280 return {
281 from: head.start,
282 to: tail.start + tail.text.length,
283 lineCount: last - first + 1,
284 joins,
285 firstPrefix: head.text.slice(0, head.prefixLen + markerWidth),
286 contPrefix: second.text.slice(0, second.prefixLen),
287 lineBreak: head.breakText || "\n",
288 };
289}
290
291/** The paragraph-shaped runs of `text`. Pure; safe to call on a whole file. */
292export function scanBlocks(text: string): WrapBlock[] {
293 return scanBlocksFromLines(splitSourceLines(text));
294}
295
296/** Every hidden break in `text`, in order. */
297export function scanJoins(text: string): JoinRange[] {
298 return scanBlocks(text).flatMap((block) => block.joins);
299}
300
301// ---------------------------------------------------------------------------
302// The display transform (pure form; the CodeMirror layer below never applies it to a document)
303// ---------------------------------------------------------------------------
304
305/** `text` with every hidden break replaced by one space: what the editor shows. */
306export function unwrapText(text: string, joins = scanJoins(text)): string {
307 let out = "";
308 let pos = 0;
309 for (const join of joins) {
310 out += text.slice(pos, join.from) + " ";
311 pos = join.to;
312 }
313 return out + text.slice(pos);
314}
315
316/** Map a source offset onto the unwrapped view. Offsets inside a join land on its single space. */
317export function toDisplayPos(joins: readonly JoinRange[], pos: number): number {
318 let shift = 0;
319 for (const join of joins) {
320 if (pos <= join.from) break;
321 shift += Math.min(pos, join.to) - join.from - 1;
322 }
323 return pos - shift;
324}
325
326/** Map an offset in the unwrapped view back onto the source. */
327export function fromDisplayPos(joins: readonly JoinRange[], pos: number): number {
328 let shift = 0;
329 for (const join of joins) {
330 const displayFrom = join.from - shift;
331 if (pos <= displayFrom) break;
332 shift += join.to - join.from - 1;
333 }
334 return pos + shift;
335}
336
337// ---------------------------------------------------------------------------
338// The wrap direction
339// ---------------------------------------------------------------------------
340
341interface Gap {
342 from: number;
343 to: number;
344 text: string;
345 /** True when this gap is one of the block's line breaks in the current source. */
346 isBreak: boolean;
347}
348
349interface Token {
350 text: string;
351 from: number;
352}
353
354/**
355 * Rewrap one block to `column`, as the smallest set of whitespace-only edits that gets there.
356 * Words are never moved and never rewritten: every change replaces one inter-word gap. Returns an
357 * empty list when the block already fills correctly, which is what keeps an untouched file
358 * byte-identical.
359 */
360export function rewrapBlockChanges(
361 text: string,
362 block: WrapBlock,
363 column = DEFAULT_WRAP_COLUMN,
364): TextChange[] {
365 if (block.lineCount < 2) return [];
366 const { tokens, gaps } = tokenizeBlock(text, block);
367 if (tokens.length < 2) return [];
368
369 const firstWidth = block.firstPrefix.length;
370 const contWidth = block.contPrefix.length;
371 // Every inter-word gap inside a rewrapped block normalizes to one space. Keeping the original run
372 // is not an option: a gap that lands on a break loses its width, so the next rewrap would measure
373 // the line differently and the fill would never reach a fixed point. The cost is that editing a
374 // paragraph collapses double spaces and tabs between its words — markdown renders them the same.
375 const inlineText = (_gap: Gap) => " ";
376
377 const breakAfter = gaps.map(() => false);
378 let col = firstWidth + (tokens[0]?.text.length ?? 0);
379 for (let i = 0; i < gaps.length; i++) {
380 const gap = gaps[i] as Gap;
381 const next = tokens[i + 1] as Token;
382 const inline = inlineText(gap).length;
383 // Every line already holds at least one token, so breaking here can never leave one empty.
384 if (col + inline + next.text.length > column) {
385 breakAfter[i] = true;
386 col = contWidth + next.text.length;
387 } else {
388 col += inline + next.text.length;
389 }
390 }
391
392 avoidBlockStarts(tokens, gaps, breakAfter, inlineText);
393
394 const changes: TextChange[] = [];
395 for (let i = 0; i < gaps.length; i++) {
396 const gap = gaps[i] as Gap;
397 const insert = breakAfter[i] ? block.lineBreak + block.contPrefix : inlineText(gap);
398 if (insert !== gap.text) changes.push({ from: gap.from, to: gap.to, insert });
399 }
400 return changes;
401}
402
403/**
404 * A break that puts `- x`, `# x`, `> x`, `1. x`, backticks or a setext rule at the start of a
405 * continuation line would turn prose into a new block on the next parse. Pull such a word back onto
406 * the previous line — an over-long line is a cosmetic loss, a silently invented list is corruption.
407 */
408function avoidBlockStarts(
409 tokens: readonly Token[],
410 gaps: readonly Gap[],
411 breakAfter: boolean[],
412 inlineText: (gap: Gap) => string,
413): void {
414 const bodyFrom = (start: number, end: number) => {
415 let out = tokens[start]?.text ?? "";
416 for (let i = start; i < end; i++)
417 out += inlineText(gaps[i] as Gap) + (tokens[i + 1]?.text ?? "");
418 return out;
419 };
420 for (let guard = 0; guard <= tokens.length; guard++) {
421 const starts = [0];
422 for (let i = 0; i < gaps.length; i++) if (breakAfter[i]) starts.push(i + 1);
423 let fixed = false;
424 for (let l = 0; l < starts.length && !fixed; l++) {
425 const start = starts[l] as number;
426 const end = l + 1 < starts.length ? (starts[l + 1] as number) - 1 : tokens.length - 1;
427 if (!startsBlockLine(bodyFrom(start, end))) continue;
428 // A continuation line pulls its first word back; the first line has no line before it, so it
429 // pulls the next word forward instead (`===` alone is a setext rule, `=== word` is prose).
430 if (l > 0) breakAfter[start - 1] = false;
431 else if (end < tokens.length - 1) breakAfter[end] = false;
432 else break;
433 fixed = true;
434 }
435 if (!fixed) return;
436 }
437}
438
439function tokenizeBlock(text: string, block: WrapBlock): { tokens: Token[]; gaps: Gap[] } {
440 const tokens: Token[] = [];
441 const gaps: Gap[] = [];
442 const joins = block.joins;
443 let cursor = block.from + block.firstPrefix.length;
444 for (let k = 0; k < block.lineCount; k++) {
445 const lineEnd = k < joins.length ? (joins[k] as JoinRange).from : block.to;
446 const body = text.slice(cursor, lineEnd);
447 const word = /\S+/g;
448 let match = word.exec(body);
449 while (match) {
450 if (tokens.length > 0 && gaps.length < tokens.length) {
451 const previous = tokens[tokens.length - 1] as Token;
452 const from = previous.from + previous.text.length;
453 const to = cursor + match.index;
454 gaps.push({ from, to, text: text.slice(from, to), isBreak: false });
455 }
456 tokens.push({ text: match[0], from: cursor + match.index });
457 match = word.exec(body);
458 }
459 const join = joins[k];
460 if (join && tokens.length > 0) {
461 gaps.push({
462 from: join.from,
463 to: join.to,
464 text: text.slice(join.from, join.to),
465 isBreak: true,
466 });
467 cursor = join.to;
468 } else if (join) {
469 cursor = join.to;
470 }
471 }
472 // A trailing gap with no token after it cannot exist in a well-formed block; drop it defensively.
473 while (gaps.length >= tokens.length && gaps.length > 0) gaps.pop();
474 return { tokens, gaps };
475}
476
477/**
478 * Rewrap every hard-wrapped block in `text`. Opt-in and destructive by design — it rewrites blocks
479 * nobody edited, so it must never run on save. Exposed for explicit "reflow this file" actions and
480 * as the oracle the properties compare against.
481 */
482export function rewrapText(text: string, options: HardWrapOptions = {}): string {
483 const column = options.column ?? DEFAULT_WRAP_COLUMN;
484 const changes = scanBlocks(text).flatMap((block) => rewrapBlockChanges(text, block, column));
485 let out = "";
486 let pos = 0;
487 for (const change of changes) {
488 out += text.slice(pos, change.from) + change.insert;
489 pos = change.to;
490 }
491 return out + text.slice(pos);
492}
493
494/** The block containing `pos`, or null when `pos` is not inside hard-wrapped prose. */
495export function blockAt(blocks: readonly WrapBlock[], pos: number): WrapBlock | null {
496 return blocks.find((block) => pos >= block.from && pos <= block.to) ?? null;
497}
498
499/**
500 * The wrap column this file appears to already use: the longest line that a rewrap could have
501 * shortened. Null when nothing is hard-wrapped. Never applied automatically — a host that wants to
502 * follow the file instead of a fixed default calls this and passes the result back in.
503 */
504export function detectWrapColumn(text: string): number | null {
505 let widest = 0;
506 for (const block of scanBlocks(text)) {
507 let lineStart = block.from;
508 for (const join of block.joins) {
509 widest = Math.max(widest, join.from - lineStart);
510 lineStart = join.to - block.contPrefix.length;
511 }
512 }
513 return widest === 0 ? null : widest;
514}
515
516// ---------------------------------------------------------------------------
517// The CodeMirror layer
518// ---------------------------------------------------------------------------
519
520export interface HardWrapExtensionOptions extends HardWrapOptions {
521 /**
522 * Re-flow the one block the user just edited, appended to their own transaction (so it is one
523 * undo step and one ChangeSet). Only fires for `input`/`delete`/`move` user events: a snapshot
524 * load, an agent's edit, or any programmatic change is left exactly as it arrived. Default true.
525 */
526 rewrapOnEdit?: boolean;
527 /** Turn on `EditorView.lineWrapping`; without it a joined paragraph just scrolls. Default true. */
528 lineWrapping?: boolean;
529}
530
531/** The space shown in place of a hidden line break. A real space, so the browser may wrap there. */
532class JoinWidget extends WidgetType {
533 override eq(): boolean {
534 return true;
535 }
536
537 override toDOM(): HTMLElement {
538 const span = document.createElement("span");
539 span.className = "cm-hw-join";
540 span.textContent = " ";
541 return span;
542 }
543
544 override ignoreEvent(): boolean {
545 return false;
546 }
547}
548
549const joinWidget = new JoinWidget();
550
551/** The replace decorations that hide a document's paragraph-internal line breaks. */
552export function joinDecorations(state: EditorState): Range<Decoration>[] {
553 const ranges: Range<Decoration>[] = [];
554 for (const join of scanJoins(state.doc.toString())) {
555 if (join.to > join.from) {
556 ranges.push(Decoration.replace({ widget: joinWidget }).range(join.from, join.to));
557 }
558 }
559 return ranges;
560}
561
562const REWRAP_EVENTS = ["input", "delete", "move"] as const;
563
564/**
565 * Show hard-wrapped paragraphs as single soft-wrapped ones, and keep them wrapped as they are
566 * edited. The document is never rewritten behind the host's back: opening a file and saving it
567 * without typing produces the same bytes, because nothing ran.
568 */
569export function hardWrap(options: HardWrapExtensionOptions = {}): Extension {
570 const column = options.column ?? DEFAULT_WRAP_COLUMN;
571 const field = StateField.define<DecorationSet>({
572 create: (state) => Decoration.set(joinDecorations(state), true),
573 update: (previous, transaction) =>
574 transaction.docChanged ? Decoration.set(joinDecorations(transaction.state), true) : previous,
575 provide: (self) => [
576 EditorView.decorations.from(self),
577 EditorView.atomicRanges.of((view) => view.state.field(self)),
578 ],
579 });
580
581 const extensions: Extension[] = [field];
582 if (options.lineWrapping !== false) extensions.push(EditorView.lineWrapping);
583 if (options.rewrapOnEdit !== false) extensions.push(rewrapFilter(column));
584 return extensions;
585}
586
587function rewrapFilter(column: number): Extension {
588 return EditorState.transactionFilter.of((transaction) => {
589 if (!transaction.docChanged) return transaction;
590 if (transaction.annotation(Transaction.remote)) return transaction;
591 if (!REWRAP_EVENTS.some((event) => transaction.isUserEvent(event))) return transaction;
592
593 const text = transaction.newDoc.toString();
594 const blocks = scanBlocks(text);
595 const touched = new Set<WrapBlock>();
596 transaction.changes.iterChangedRanges((_fromA, _toA, fromB, toB) => {
597 for (const block of blocks) {
598 if (block.lineCount >= 2 && toB >= block.from && fromB <= block.to) touched.add(block);
599 }
600 });
601 const changes = [...touched].flatMap((block) => rewrapBlockChanges(text, block, column));
602 if (changes.length === 0) return transaction;
603 changes.sort((a, b) => a.from - b.from);
604 return [transaction, { changes, sequential: true, userEvent: "format.rewrap" }];
605 });
606}
packages/markdown-editor/src/html.ts created+358
......@@ -0,0 +1,358 @@
1// Sanitized readme-subset HTML for the document preset. Two independent pieces:
2//
3// - `sanitizeHtml`: a GitHub-style allowlist sanitizer over DOMParser. Disallowed-but-harmless
4// elements unwrap to their children; actively dangerous ones drop with their content; every
5// attribute and URL is allowlisted. This is the only path from document text to rendered DOM.
6// - `classifyHtmlBlock`: a tag-balance scan that decides how the live preview treats an HTMLBlock:
7// render it (balanced), absorb it (a centering wrapper split from its close by blank lines —
8// GFM's markdown-in-HTML rule), collapse it (comment), or leave it as visible source (anything
9// else).
10//
11// Security stance: the allowlist is absolute — no script/style/iframe, no on* attributes, no
12// javascript: URLs, ever. A cloned repo's README renders inside the workbench; treat every input
13// as hostile.
14
15export interface HtmlOptions {
16 /** Maps relative image/source URLs to something fetchable; identity when omitted. */
17 resolveUrl?: (url: string) => string;
18}
19
20const ALLOWED_TAGS = new Set([
21 "a",
22 "abbr",
23 "b",
24 "blockquote",
25 "br",
26 "center",
27 "code",
28 "dd",
29 "del",
30 "details",
31 "div",
32 "dl",
33 "dt",
34 "em",
35 "figcaption",
36 "figure",
37 "h1",
38 "h2",
39 "h3",
40 "h4",
41 "h5",
42 "h6",
43 "hr",
44 "i",
45 "img",
46 "ins",
47 "kbd",
48 "li",
49 "mark",
50 "ol",
51 "p",
52 "picture",
53 "pre",
54 "q",
55 "samp",
56 "small",
57 "source",
58 "span",
59 "strong",
60 "sub",
61 "summary",
62 "sup",
63 "table",
64 "tbody",
65 "td",
66 "tfoot",
67 "th",
68 "thead",
69 "tr",
70 "ul",
71 "var",
72]);
73
74/** Dropped with their entire content — unwrapping these would leak what they guard. */
75const DROP_WITH_CONTENT = new Set([
76 "script",
77 "style",
78 "iframe",
79 "object",
80 "embed",
81 "template",
82 "link",
83 "meta",
84 "base",
85 "form",
86 "input",
87 "button",
88 "textarea",
89 "select",
90 "option",
91 "dialog",
92 "slot",
93 "noscript",
94 "title",
95 "svg",
96 "math",
97 "audio",
98 "video",
99 "canvas",
100 "map",
101 "area",
102 "frame",
103 "frameset",
104 "portal",
105]);
106
107const GLOBAL_ATTRS = new Set(["align", "title", "width", "height", "dir", "lang"]);
108
109const TAG_ATTRS: Record<string, Set<string>> = {
110 a: new Set(["href"]),
111 img: new Set(["src", "srcset", "alt", "loading"]),
112 source: new Set(["src", "srcset", "media", "type"]),
113 details: new Set(["open"]),
114 ol: new Set(["start", "reversed", "type"]),
115 th: new Set(["colspan", "rowspan", "scope"]),
116 td: new Set(["colspan", "rowspan"]),
117 q: new Set(["cite"]),
118 del: new Set(["cite"]),
119 ins: new Set(["cite"]),
120};
121
122const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
123
124function safeLinkUrl(value: string): string | null {
125 const url = value.trim();
126 const scheme = SCHEME.exec(url)?.[0]?.toLowerCase();
127 if (!scheme) return url; // relative or fragment
128 return ["http:", "https:", "mailto:"].includes(scheme) ? url : null;
129}
130
131function safeMediaUrl(value: string, opts: HtmlOptions): string | null {
132 const url = value.trim();
133 const scheme = SCHEME.exec(url)?.[0]?.toLowerCase();
134 if (!scheme) return opts.resolveUrl ? opts.resolveUrl(url) : url;
135 if (scheme === "http:" || scheme === "https:") return url;
136 if (scheme === "data:" && /^data:image\//i.test(url)) return url;
137 return null;
138}
139
140/** Sanitize a srcset value entry by entry; entries with rejected URLs drop out. */
141function safeSrcset(value: string, opts: HtmlOptions): string | null {
142 // Split on comma-followed-by-whitespace: data: URLs carry internal commas with no space after.
143 const entries = value
144 .split(/,(?=\s)/)
145 .map((entry) => {
146 const [url, ...descriptor] = entry.trim().split(/\s+/);
147 if (!url) return null;
148 const safe = safeMediaUrl(url, opts);
149 return safe === null ? null : [safe, ...descriptor].join(" ");
150 })
151 .filter((entry): entry is string => entry !== null);
152 return entries.length === 0 ? null : entries.join(", ");
153}
154
155function sanitizeAttributes(source: Element, target: Element, opts: HtmlOptions): void {
156 const tag = source.tagName.toLowerCase();
157 const allowed = TAG_ATTRS[tag];
158 for (const attr of source.getAttributeNames()) {
159 const name = attr.toLowerCase();
160 if (!GLOBAL_ATTRS.has(name) && !allowed?.has(name)) continue;
161 const value = source.getAttribute(attr);
162 if (value === null) continue;
163 if (name === "href") {
164 const safe = safeLinkUrl(value);
165 if (safe === null) continue;
166 target.setAttribute("href", safe);
167 // Keep link clicks from navigating the embedding app away.
168 target.setAttribute("target", "_blank");
169 target.setAttribute("rel", "noopener noreferrer");
170 continue;
171 }
172 if (name === "src") {
173 const safe = safeMediaUrl(value, opts);
174 if (safe !== null) target.setAttribute("src", safe);
175 continue;
176 }
177 if (name === "srcset") {
178 const safe = safeSrcset(value, opts);
179 if (safe !== null) target.setAttribute("srcset", safe);
180 continue;
181 }
182 if (name === "cite") {
183 const safe = safeLinkUrl(value);
184 if (safe !== null) target.setAttribute("cite", safe);
185 continue;
186 }
187 target.setAttribute(name, value);
188 }
189}
190
191function sanitizeInto(parent: Node, children: NodeListOf<ChildNode>, opts: HtmlOptions): void {
192 for (const child of children) {
193 if (child.nodeType === 3) {
194 parent.appendChild(document.createTextNode(child.textContent ?? ""));
195 continue;
196 }
197 if (child.nodeType !== 1) continue; // comments, PIs, doctypes
198 const element = child as Element;
199 const tag = element.tagName.toLowerCase();
200 if (DROP_WITH_CONTENT.has(tag)) continue;
201 if (!ALLOWED_TAGS.has(tag)) {
202 // Harmless-unknown: unwrap, keeping the children.
203 sanitizeInto(parent, element.childNodes, opts);
204 continue;
205 }
206 const clean = document.createElement(tag);
207 sanitizeAttributes(element, clean, opts);
208 sanitizeInto(clean, element.childNodes, opts);
209 parent.appendChild(clean);
210 }
211}
212
213/**
214 * Parse and sanitize an HTML fragment. Returns a detached element ready to append, or null when
215 * nothing renderable survives.
216 */
217export function sanitizeHtml(source: string, opts: HtmlOptions = {}): HTMLElement | null {
218 const parsed = new DOMParser().parseFromString(source, "text/html");
219 const root = document.createElement("div");
220 sanitizeInto(root, parsed.body.childNodes, opts);
221 return root.childNodes.length === 0 ? null : root;
222}
223
224// ---------------------------------------------------------------------------
225// Block classification
226
227const VOID_TAGS = new Set([
228 "br",
229 "hr",
230 "img",
231 "source",
232 "input",
233 "meta",
234 "link",
235 "base",
236 "area",
237 "col",
238 "embed",
239 "track",
240 "wbr",
241]);
242
243/** Wrapper elements whose lone open/close blocks the preview absorbs instead of rendering. */
244const WRAPPER_TAGS = new Set(["div", "p", "center", "section", "header"]);
245
246const TOKEN = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
247
248export type HtmlTokenType = "comment" | "bracket" | "tag" | "attr" | "value";
249
250export interface HtmlToken {
251 type: HtmlTokenType;
252 from: number;
253 to: number;
254}
255
256const ATTR = /([\w-]+)(\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?/g;
257
258/**
259 * Tokenize an HTML source slice for the revealed-source highlighting: brackets, tag names,
260 * attribute names/values, comments. Pure over the string; offsets are slice-relative.
261 */
262export function htmlTokens(source: string): HtmlToken[] {
263 const tokens: HtmlToken[] = [];
264 TOKEN.lastIndex = 0;
265 for (let match = TOKEN.exec(source); match; match = TOKEN.exec(source)) {
266 const start = match.index;
267 if (match[0].startsWith("<!--")) {
268 tokens.push({ type: "comment", from: start, to: start + match[0].length });
269 continue;
270 }
271 const closing = match[1] === "/";
272 const tag = match[2]!;
273 const open = 1 + (closing ? 1 : 0);
274 tokens.push({ type: "bracket", from: start, to: start + open });
275 tokens.push({ type: "tag", from: start + open, to: start + open + tag.length });
276 const attrsFrom = start + open + tag.length;
277 const attrs = match[3] ?? "";
278 ATTR.lastIndex = 0;
279 for (let attr = ATTR.exec(attrs); attr; attr = ATTR.exec(attrs)) {
280 const nameFrom = attrsFrom + attr.index;
281 tokens.push({ type: "attr", from: nameFrom, to: nameFrom + attr[1]!.length });
282 if (attr[3] !== undefined) {
283 const valueFrom = attrsFrom + attr.index + attr[0].length - attr[3].length;
284 tokens.push({ type: "value", from: valueFrom, to: valueFrom + attr[3].length });
285 }
286 }
287 const end = start + match[0].length;
288 tokens.push({ type: "bracket", from: end - 1 - (match[4] === "/" ? 1 : 0), to: end });
289 }
290 return tokens;
291}
292
293export type HtmlBlockShape =
294 | { kind: "comment" }
295 | { kind: "balanced" }
296 | { kind: "open"; tag: string; center: boolean }
297 | { kind: "close"; tag: string }
298 | { kind: "other" };
299
300const CENTERED_ATTRS = /align\s*=\s*["']?center|text-align\s*:\s*center/i;
301
302/**
303 * Decide how the preview treats one HTMLBlock's source. "open"/"close" only fire for a block that
304 * is exactly one lone wrapper tag (plus comments/whitespace) — anything richer that does not
305 * balance is "other" and stays visible source.
306 */
307export function classifyHtmlBlock(source: string): HtmlBlockShape {
308 TOKEN.lastIndex = 0;
309 const stack: string[] = [];
310 let tags = 0;
311 let comments = 0;
312 let loneOpen: { tag: string; center: boolean } | null = null;
313 let loneClose: string | null = null;
314 let broken = false;
315 let last = 0;
316 let textOutside = false;
317
318 for (let match = TOKEN.exec(source); match; match = TOKEN.exec(source)) {
319 if (source.slice(last, match.index).trim() !== "") textOutside = true;
320 last = match.index + match[0].length;
321 if (match[0].startsWith("<!--")) {
322 comments++;
323 continue;
324 }
325 const closing = match[1] === "/";
326 const tag = match[2]!.toLowerCase();
327 const selfClosed = match[4] === "/" || VOID_TAGS.has(tag);
328 tags++;
329 if (closing) {
330 if (stack.length === 0) {
331 if (tags === 1 && WRAPPER_TAGS.has(tag)) loneClose = tag;
332 else broken = true;
333 } else if (stack[stack.length - 1] === tag) {
334 stack.pop();
335 } else {
336 broken = true;
337 }
338 } else if (!selfClosed) {
339 if (tags === 1 && WRAPPER_TAGS.has(tag)) {
340 loneOpen = { tag, center: tag === "center" || CENTERED_ATTRS.test(match[3] ?? "") };
341 }
342 stack.push(tag);
343 }
344 }
345 if (source.slice(last).trim() !== "") textOutside = true;
346
347 if (broken) return { kind: "other" };
348 // An unmatched close is only meaningful as the lone wrapper-closing half.
349 if (loneClose) {
350 return tags === 1 && !textOutside ? { kind: "close", tag: loneClose } : { kind: "other" };
351 }
352 if (tags === 0) return comments > 0 && !textOutside ? { kind: "comment" } : { kind: "other" };
353 if (stack.length === 0) return { kind: "balanced" };
354 if (stack.length === 1 && loneOpen && tags === 1 && !textOutside) {
355 return { kind: "open", tag: loneOpen.tag, center: loneOpen.center };
356 }
357 return { kind: "other" };
358}
packages/markdown-editor/src/index.ts created+63
......@@ -0,0 +1,63 @@
1// Headless surface only; the React binding lives behind `@clo/markdown-editor/react` so plain-TS
2// consumers never pull React into their graph.
3export { chipField, type ChipSpec } from "./chips.ts";
4export {
5 autoCloseFence,
6 codeBlockEnter,
7 continueList,
8 cycleEmphasis,
9 dedentListItem,
10 enclosingCodeBlock,
11 indentListItem,
12 insertNewline,
13 surroundSelection,
14 toggleInlineMark,
15} from "./commands.ts";
16export {
17 blockAt,
18 DEFAULT_WRAP_COLUMN,
19 detectWrapColumn,
20 fromDisplayPos,
21 hardWrap,
22 type HardWrapExtensionOptions,
23 type HardWrapOptions,
24 joinDecorations,
25 type JoinRange,
26 rewrapBlockChanges,
27 rewrapText,
28 scanBlocks,
29 scanJoins,
30 type TextChange,
31 toDisplayPos,
32 unwrapText,
33 type WrapBlock,
34} from "./hard-wrap.ts";
35export { classifyHtmlBlock, sanitizeHtml } from "./html.ts";
36export type { HtmlBlockShape, HtmlOptions } from "./html.ts";
37export { baseKeymap, markdownKeymap } from "./keymap.ts";
38export { createMarkdownEditor, type MarkdownEditor, type MarkdownEditorOptions } from "./editor.ts";
39export { type Emoji, searchEmoji } from "./emoji.ts";
40export { blockDecorations, formattingDecorations, livePreview } from "./live-preview.ts";
41export type { LivePreviewOptions } from "./live-preview.ts";
42export type { VideoPreviewInput, VideoPreviewRenderer } from "./widgets.ts";
43export {
44 createMentionRegistry,
45 hydrateMentions,
46 insertMention,
47 type MentionCandidate,
48 type MentionProvider,
49 type MentionRegistry,
50 mentionsExtension,
51 scanMentions,
52 serializeMentions,
53 staticMentionProvider,
54} from "./mentions.ts";
55export {
56 type EmojiTrigger,
57 emojiTriggerAt,
58 type MentionTrigger,
59 mentionTriggerAt,
60 type SlashTrigger,
61 slashTriggerAt,
62 type Trigger,
63} from "./triggers.ts";
packages/markdown-editor/src/keymap.ts created+39
......@@ -0,0 +1,39 @@
1// The markdown editing keymap shared by every preset: fence-aware Enter (auto-close, newline
2// inside code), Tab list indentation, and the inline-mark toggles. Hosts layer their own bindings
3// (submit, pickers) at higher precedence; the composer factory does exactly that.
4import { defaultKeymap, historyKeymap } from "@codemirror/commands";
5import { type Extension, Prec } from "@codemirror/state";
6import { keymap } from "@codemirror/view";
7
8import {
9 codeBlockEnter,
10 continueList,
11 cycleEmphasis,
12 dedentListItem,
13 indentListItem,
14 surroundSelection,
15 toggleInlineMark,
16} from "./commands.ts";
17
18/** Fence/list-aware Enter, Tab list indent/dedent, Mod-b/Mod-i, and selection surrounds. */
19export function markdownKeymap(): Extension {
20 return Prec.high(
21 keymap.of([
22 { key: "Enter", run: (target) => codeBlockEnter(target) || continueList(target) },
23 { key: "Tab", run: indentListItem },
24 { key: "Shift-Tab", run: dedentListItem },
25 { key: "Mod-b", run: toggleInlineMark("**") },
26 { key: "Mod-i", run: toggleInlineMark("*") },
27 { key: "`", run: surroundSelection("`") },
28 { key: "*", run: cycleEmphasis },
29 ]),
30 );
31}
32
33/**
34 * History plus the CodeMirror defaults, minus bindings that make no sense in markdown: Mod-/
35 * toggles an HTML comment (markdown's only comment syntax), which no markdown editor wants.
36 */
37export function baseKeymap(): Extension {
38 return keymap.of([...historyKeymap, ...defaultKeymap.filter((b) => b.key !== "Mod-/")]);
39}
packages/markdown-editor/src/live-preview.ts created+1242
......@@ -0,0 +1,1242 @@
1// Obsidian-style live preview: the markdown text stays the source of truth and a decoration layer
2// renders formatting over it. Delimiter marks hide unless the selection touches their construct,
3// where they mute rather than vanish so the raw syntax stays editable.
4//
5// Layout-shift rule: every style that changes line metrics (heading sizes, quote borders, callout
6// backgrounds) rides a *line* decoration applied in both the revealed and hidden state — toggling
7// the caret in and out of a construct may reflow horizontally (marks appear) but never vertically.
8import { defaultHighlightStyle, syntaxHighlighting, syntaxTree } from "@codemirror/language";
9import type { Extension, Range } from "@codemirror/state";
10import { EditorSelection, EditorState, Prec, StateEffect, StateField } from "@codemirror/state";
11import {
12 Decoration,
13 type DecorationSet,
14 EditorView,
15 keymap,
16 ViewPlugin,
17 type ViewUpdate,
18} from "@codemirror/view";
19import type { SyntaxNode, Tree } from "@lezer/common";
20
21import { classifyHtmlBlock, htmlTokens } from "./html.ts";
22import {
23 BrWidget,
24 CommentWidget,
25 BulletWidget,
26 CALLOUT_TYPES,
27 CalloutLabelWidget,
28 HrWidget,
29 HtmlWidget,
30 ImageWidget,
31 type TableData,
32 TableWidget,
33 TaskCheckboxWidget,
34 VideoWidget,
35 type VideoPreviewRenderer,
36} from "./widgets.ts";
37
38export interface LivePreviewOptions {
39 /**
40 * Replace whole blocks with rendered widgets when the caret is outside them: GFM tables, images,
41 * and horizontal rules. On in the document preset; off in the composer, where the input should
42 * stay text-shaped.
43 */
44 blockWidgets?: boolean;
45 /** Source ranges painted atomically by consumer decorations. */
46 exclude?: (state: EditorState) => readonly { from: number; to: number }[];
47 /**
48 * Preview readme-subset HTML (requires the grammar to include HTMLBlock/HTMLTag): balanced
49 * blocks render sanitized, centering wrappers absorb, inline tags style, and the caret entering
50 * any of it reveals the raw source. Document preset only.
51 */
52 html?: boolean;
53 /** Maps relative media URLs to something fetchable (host seam); identity when omitted. */
54 resolveUrl?: (url: string) => string;
55 /** Hosts video playback without importing a UI framework into the editor. */
56 renderVideo?: VideoPreviewRenderer;
57}
58
59// Reveals require the editor to be focused: an unfocused pane (the future document viewer) shows
60// pure preview no matter where its selection sits. The field defaults to unfocused; the DOM
61// focus/blur handlers in `livePreview` keep it current. Headless states without the field (plain
62// `formattingDecorations` calls) count as focused so selection-only tests exercise reveals.
63const setPreviewFocus = StateEffect.define<boolean>();
64
65const previewFocus = StateField.define<boolean>({
66 create: () => false,
67 update: (value, transaction) => {
68 for (const effect of transaction.effects) if (effect.is(setPreviewFocus)) value = effect.value;
69 return value;
70 },
71});
72
73function editorFocused(state: EditorState): boolean {
74 return state.field(previewFocus, false) ?? true;
75}
76
77/** A range of the focused editor's selection touches the span, so its raw syntax reveals. */
78function revealed(state: EditorState, from: number, to: number): boolean {
79 if (!editorFocused(state)) return false;
80 return state.selection.ranges.some((range) => range.from <= to && range.to >= from);
81}
82
83/** Reveal scope for line-anchored marks: the selection touches the mark's line. */
84function lineRevealed(state: EditorState, pos: number): boolean {
85 const line = state.doc.lineAt(pos);
86 return revealed(state, line.from, line.to);
87}
88
89function decorateDelimited(
90 state: EditorState,
91 node: SyntaxNode,
92 markName: string,
93 className: string,
94 ranges: Range<Decoration>[],
95): void {
96 const marks = node.getChildren(markName);
97 const open = marks[0];
98 const close = marks[marks.length - 1];
99 const innerFrom = open ? open.to : node.from;
100 const innerTo = close ? close.from : node.to;
101 if (innerTo > innerFrom)
102 ranges.push(Decoration.mark({ class: className }).range(innerFrom, innerTo));
103 if (revealed(state, node.from, node.to)) {
104 if (open) ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(open.from, open.to));
105 if (close && close !== open)
106 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(close.from, close.to));
107 return;
108 }
109 if (open) ranges.push(Decoration.replace({}).range(open.from, open.to));
110 if (close && close !== open) ranges.push(Decoration.replace({}).range(close.from, close.to));
111}
112
113const DELIMITED: Record<string, { mark: string; className: string }> = {
114 StrongEmphasis: { mark: "EmphasisMark", className: "cm-lp-strong" },
115 Emphasis: { mark: "EmphasisMark", className: "cm-lp-em" },
116 Strikethrough: { mark: "StrikethroughMark", className: "cm-lp-strike" },
117 InlineCode: { mark: "CodeMark", className: "cm-lp-code" },
118};
119
120const ATX_LEVEL: Record<string, number> = {
121 ATXHeading1: 1,
122 ATXHeading2: 2,
123 ATXHeading3: 3,
124 ATXHeading4: 4,
125 ATXHeading5: 5,
126 ATXHeading6: 6,
127};
128
129function decorateHeading(
130 state: EditorState,
131 node: SyntaxNode,
132 level: number,
133 ranges: Range<Decoration>[],
134): void {
135 const line = state.doc.lineAt(node.from);
136 ranges.push(Decoration.line({ class: `cm-lp-heading cm-lp-h${level}` }).range(line.from));
137 const show = revealed(state, node.from, node.to);
138 for (const mark of node.getChildren("HeaderMark")) {
139 if (show) {
140 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(mark.from, mark.to));
141 continue;
142 }
143 // Swallow the adjacent space with the mark so hidden `# ` leaves no stray gap.
144 const leading = mark.from === line.from;
145 const from = leading ? mark.from : Math.max(line.from, mark.from - 1);
146 const to = leading && state.sliceDoc(mark.to, mark.to + 1) === " " ? mark.to + 1 : mark.to;
147 ranges.push(Decoration.replace({}).range(from, to));
148 }
149}
150
151const CALLOUT_MARKER = /^\[!(\w+)\]\s?/;
152
153/** The `[!type] ` marker right after this blockquote's first quote mark, if it is a callout. */
154function calloutMarker(
155 state: EditorState,
156 node: SyntaxNode,
157): { type: string; from: number; to: number } | null {
158 const mark = node.getChild("QuoteMark");
159 if (!mark) return null;
160 const line = state.doc.lineAt(mark.from);
161 const contentFrom = mark.to + (state.sliceDoc(mark.to, mark.to + 1) === " " ? 1 : 0);
162 const match = CALLOUT_MARKER.exec(state.sliceDoc(contentFrom, line.to));
163 if (!match) return null;
164 const type = match[1]?.toLowerCase() ?? "";
165 if (!(type in CALLOUT_TYPES)) return null;
166 return { type, from: contentFrom, to: contentFrom + match[0].length };
167}
168
169function decorateBlockquote(
170 state: EditorState,
171 node: SyntaxNode,
172 ranges: Range<Decoration>[],
173): { from: number; to: number } | null {
174 const callout = calloutMarker(state, node);
175 const first = state.doc.lineAt(node.from).number;
176 const last = state.doc.lineAt(node.to).number;
177 for (let n = first; n <= last; n++) {
178 let cls = callout ? `cm-lp-quote cm-lp-callout cm-lp-callout-${callout.type}` : "cm-lp-quote";
179 if (n === first) cls += " cm-lp-quote-top";
180 if (n === last) cls += " cm-lp-quote-bottom";
181 ranges.push(Decoration.line({ class: cls }).range(state.doc.line(n).from));
182 }
183 if (!callout) return null;
184 if (lineRevealed(state, callout.from)) {
185 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(callout.from, callout.to));
186 } else {
187 ranges.push(
188 Decoration.replace({ widget: new CalloutLabelWidget(callout.type) }).range(
189 callout.from,
190 callout.to,
191 ),
192 );
193 }
194 return callout;
195}
196
197function decorateQuoteMark(
198 state: EditorState,
199 node: SyntaxNode,
200 ranges: Range<Decoration>[],
201): void {
202 if (lineRevealed(state, node.from)) {
203 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(node.from, node.to));
204 return;
205 }
206 const to = state.sliceDoc(node.to, node.to + 1) === " " ? node.to + 1 : node.to;
207 ranges.push(Decoration.replace({}).range(node.from, to));
208}
209
210function decorateListMark(state: EditorState, node: SyntaxNode, ranges: Range<Decoration>[]): void {
211 const text = state.sliceDoc(node.from, node.to);
212 const parsedTask = node.parent?.getChild("Task") ?? null;
213 const indeterminateTask = /^\s*\[~\](?=\s)/.test(
214 state.sliceDoc(node.to, state.doc.lineAt(node.to).to),
215 );
216 const task = parsedTask !== null || indeterminateTask;
217 const indent = /^\s*/.exec(state.doc.lineAt(node.from).text)?.[0].length ?? 0;
218 if (indent > 0)
219 ranges.push(
220 Decoration.line({ class: "cm-lp-list-nested" }).range(state.doc.lineAt(node.from).from),
221 );
222 if (/^[-*+]$/.test(text)) {
223 if (lineRevealed(state, node.from)) {
224 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(node.from, node.to));
225 } else if (task) {
226 // Task items render their checkbox; the bullet disappears with its trailing space.
227 const to = state.sliceDoc(node.to, node.to + 1) === " " ? node.to + 1 : node.to;
228 ranges.push(Decoration.replace({}).range(node.from, to));
229 } else {
230 ranges.push(Decoration.replace({ widget: new BulletWidget() }).range(node.from, node.to));
231 }
232 return;
233 }
234 ranges.push(Decoration.mark({ class: "cm-lp-list-num" }).range(node.from, node.to));
235}
236
237function decorateTask(state: EditorState, node: SyntaxNode, ranges: Range<Decoration>[]): void {
238 const marker = node.getChild("TaskMarker");
239 if (!marker) return;
240 const taskState = /x/i.test(state.sliceDoc(marker.from, marker.to)) ? "checked" : "unchecked";
241 if (taskState === "checked")
242 ranges.push(
243 Decoration.line({ class: "cm-lp-task-done" }).range(state.doc.lineAt(node.from).from),
244 );
245 if (lineRevealed(state, marker.from)) {
246 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(marker.from, marker.to));
247 return;
248 }
249 ranges.push(
250 Decoration.replace({
251 widget: new TaskCheckboxWidget(taskState, marker.from, marker.to, state.readOnly),
252 }).range(marker.from, marker.to),
253 );
254}
255
256/** Package extension to GFM tasks: `[~]` renders as a native indeterminate checkbox. */
257function decorateIndeterminateTask(
258 state: EditorState,
259 node: SyntaxNode,
260 ranges: Range<Decoration>[],
261): void {
262 const line = state.doc.lineAt(node.from);
263 const match = /^(\s*)\[~\](?=\s)/.exec(state.sliceDoc(node.from, line.to));
264 if (!match) return;
265 const from = node.from + (match[1]?.length ?? 0);
266 const to = from + 3;
267 if (lineRevealed(state, from)) {
268 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(from, to));
269 return;
270 }
271 ranges.push(
272 Decoration.replace({
273 widget: new TaskCheckboxWidget("indeterminate", from, to, state.readOnly),
274 }).range(from, to),
275 );
276}
277
278function decorateLink(state: EditorState, node: SyntaxNode, ranges: Range<Decoration>[]): void {
279 const marks = node.getChildren("LinkMark");
280 const url = node.getChild("URL");
281 const title = node.getChild("LinkTitle");
282 const open = marks[0];
283 const close = marks[1];
284 if (open && close && close.from > open.to)
285 ranges.push(Decoration.mark({ class: "cm-lp-link" }).range(open.to, close.from));
286 if (revealed(state, node.from, node.to)) {
287 for (const mark of marks)
288 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(mark.from, mark.to));
289 if (url) ranges.push(Decoration.mark({ class: "cm-lp-url" }).range(url.from, url.to));
290 return;
291 }
292 for (const mark of marks) ranges.push(Decoration.replace({}).range(mark.from, mark.to));
293 if (url) ranges.push(Decoration.replace({}).range(url.from, url.to));
294 if (title) ranges.push(Decoration.replace({}).range(title.from, title.to));
295}
296
297/** `![alt](url)` → inline media; falls back to link-style marks when revealed. */
298function decorateImage(
299 state: EditorState,
300 node: SyntaxNode,
301 ranges: Range<Decoration>[],
302 blockWidgets: boolean,
303 resolve: (url: string) => string,
304 renderVideo: VideoPreviewRenderer | undefined,
305): boolean {
306 const sameLine = state.doc.lineAt(node.from).number === state.doc.lineAt(node.to).number;
307 if (blockWidgets && sameLine && !revealed(state, node.from, node.to)) {
308 const url = node.getChild("URL");
309 const marks = node.getChildren("LinkMark");
310 const alt =
311 marks[0] && marks[1] && marks[1].from > marks[0].to
312 ? state.sliceDoc(marks[0].to, marks[1].from)
313 : "";
314 const source = url ? state.sliceDoc(url.from, url.to).replace(/^<|>$/g, "") : "";
315 const video = /\.(?:avi|m4v|mkv|mov|mp4|ogv|webm)$/i.test(source.split(/[?#]/, 1)[0] ?? "");
316 const resolved = resolve(source);
317 ranges.push(
318 Decoration.replace({
319 widget: video
320 ? new VideoWidget(source, resolved, alt, renderVideo)
321 : new ImageWidget(resolved, alt),
322 }).range(node.from, node.to),
323 );
324 return false;
325 }
326 decorateLink(state, node, ranges);
327 return false;
328}
329
330function tableData(state: EditorState, node: SyntaxNode): TableData {
331 const cellText = (row: SyntaxNode) =>
332 row.getChildren("TableCell").map((cell) => state.sliceDoc(cell.from, cell.to).trim());
333 const header = node.getChild("TableHeader");
334 const delimiter = node.getChild("TableDelimiter");
335 const align = (delimiter ? state.sliceDoc(delimiter.from, delimiter.to) : "")
336 .split("|")
337 .map((column) => column.trim())
338 .filter((column) => column !== "")
339 .map((column) => {
340 const left = column.startsWith(":");
341 const right = column.endsWith(":");
342 if (left && right) return "center";
343 if (right) return "right";
344 if (left) return "left";
345 return "";
346 });
347 return {
348 align,
349 header: header ? cellText(header) : [],
350 rows: node.getChildren("TableRow").map(cellText),
351 };
352}
353
354/** Rendered table when the caret is outside; mono source lines while editing. */
355function decorateTable(
356 state: EditorState,
357 node: SyntaxNode,
358 ranges: Range<Decoration>[],
359 blockWidgets: boolean,
360 centered: boolean,
361): boolean | undefined {
362 if (!blockWidgets) return undefined;
363 if (revealed(state, node.from, node.to)) {
364 const first = state.doc.lineAt(node.from).number;
365 const last = state.doc.lineAt(node.to).number;
366 for (let n = first; n <= last; n++)
367 ranges.push(Decoration.line({ class: "cm-lp-table-src" }).range(state.doc.line(n).from));
368 return true;
369 }
370 ranges.push(
371 Decoration.replace({
372 widget: new TableWidget(tableData(state, node), centered),
373 block: true,
374 }).range(node.from, node.to),
375 );
376 return false;
377}
378
379function decorateCodeBlock(
380 state: EditorState,
381 node: SyntaxNode,
382 ranges: Range<Decoration>[],
383): void {
384 const { doc } = state;
385 const first = doc.lineAt(node.from).number;
386 const last = doc.lineAt(node.to).number;
387 for (let n = first; n <= last; n++) {
388 let cls = "cm-lp-codeblock";
389 if (n === first) cls += " cm-lp-codeblock-top";
390 if (n === last) cls += " cm-lp-codeblock-bottom";
391 ranges.push(Decoration.line({ class: cls }).range(doc.line(n).from));
392 }
393 for (const mark of node.getChildren("CodeMark"))
394 ranges.push(Decoration.mark({ class: "cm-lp-mark" }).range(mark.from, mark.to));
395 const info = node.getChild("CodeInfo");
396 if (info) ranges.push(Decoration.mark({ class: "cm-lp-code-lang" }).range(info.from, info.to));
397}
398
399// ---------------------------------------------------------------------------
400// HTML preview (document preset)
401
402interface Span {
403 from: number;
404 to: number;
405}
406
407type HtmlBlockMode = "render" | "absorb" | "collapse" | "source";
408
409interface HtmlPlan {
410 /** Per HTMLBlock (keyed by node start): how the preview treats it. */
411 blocks: Map<number, HtmlBlockMode>;
412 /** Content spans between paired centering wrappers. */
413 centers: Span[];
414 /** For each absorbed wrapper half (by node start): its partner's span, so they reveal together. */
415 partners: Map<number, Span>;
416}
417
418/**
419 * Classify every HTMLBlock and pair split wrappers (GFM's markdown-in-HTML rule separates a
420 * wrapper's open and close into different blocks). Paired wrappers absorb; an unmatched lone
421 * open/close stays visible source rather than hiding a tag that never closes.
422 */
423function planHtml(state: EditorState, tree: Tree): HtmlPlan {
424 const blocks = new Map<number, HtmlBlockMode>();
425 const centers: Span[] = [];
426 const partners = new Map<number, Span>();
427 const stack: Array<{ tag: string; center: boolean; span: Span }> = [];
428 tree.iterate({
429 enter: (nodeRef) => {
430 if (nodeRef.name !== "HTMLBlock") return undefined;
431 const span = { from: nodeRef.from, to: nodeRef.to };
432 const shape = classifyHtmlBlock(state.sliceDoc(span.from, span.to));
433 switch (shape.kind) {
434 case "comment":
435 blocks.set(span.from, "collapse");
436 break;
437 case "balanced":
438 blocks.set(span.from, "render");
439 break;
440 case "open":
441 blocks.set(span.from, "source");
442 stack.push({ tag: shape.tag, center: shape.center, span });
443 break;
444 case "close": {
445 const top = stack[stack.length - 1];
446 if (top && top.tag === shape.tag) {
447 stack.pop();
448 blocks.set(top.span.from, "absorb");
449 blocks.set(span.from, "absorb");
450 partners.set(top.span.from, span);
451 partners.set(span.from, top.span);
452 if (top.center) centers.push({ from: top.span.to, to: span.from });
453 } else {
454 blocks.set(span.from, "source");
455 }
456 break;
457 }
458 default:
459 blocks.set(span.from, "source");
460 }
461 return false;
462 },
463 });
464 return { blocks, centers, partners };
465}
466
467function centeredAt(centers: Span[], pos: number): boolean {
468 return centers.some((span) => pos >= span.from && pos <= span.to);
469}
470
471/** Inline HTML pair tags the preview styles by hiding the tags and classing the content. */
472const INLINE_STYLE: Record<string, string> = {
473 b: "cm-lp-strong",
474 strong: "cm-lp-strong",
475 i: "cm-lp-em",
476 em: "cm-lp-em",
477 code: "cm-lp-code",
478 samp: "cm-lp-code",
479 var: "cm-lp-code",
480 kbd: "cm-html-kbd",
481 sub: "cm-html-sub",
482 sup: "cm-html-sup",
483 del: "cm-lp-strike",
484 s: "cm-lp-strike",
485 strike: "cm-lp-strike",
486 ins: "cm-html-ins",
487 u: "cm-html-ins",
488 mark: "cm-html-mark",
489 small: "cm-html-small",
490 a: "cm-lp-link",
491 span: "",
492 picture: "",
493};
494
495const TAG_HEAD = /^<(\/?)([a-zA-Z][\w-]*)/;
496
497interface InlineHtmlContext {
498 state: EditorState;
499 ranges: Range<Decoration>[];
500 htmlOptions: { resolveUrl?: (url: string) => string };
501 /** Close tags already claimed by their opener. */
502 consumedCloses: Set<number>;
503 /** End of a whole-range widget render; tags before this are already covered. */
504 consumedUntil: number;
505}
506
507function inlineSource(ctx: InlineHtmlContext, from: number, to: number): void {
508 ctx.ranges.push(Decoration.mark({ class: "cm-lp-html-inline" }).range(from, to));
509 highlightHtmlSource(ctx.state, ctx.ranges, from, to);
510}
511
512/** The named siblings strictly between two tags are all HTMLTags and the rest is whitespace. */
513function onlyHtmlBetween(state: EditorState, open: SyntaxNode, close: SyntaxNode): boolean {
514 let pos = open.to;
515 for (let sib = open.nextSibling; sib && sib.from < close.from; sib = sib.nextSibling) {
516 if (sib.name !== "HTMLTag") return false;
517 if (state.sliceDoc(pos, sib.from).trim() !== "") return false;
518 pos = sib.to;
519 }
520 return state.sliceDoc(pos, close.from).trim() === "";
521}
522
523function decorateInlineHtml(ctx: InlineHtmlContext, node: SyntaxNode): void {
524 const { state, ranges } = ctx;
525 if (node.from < ctx.consumedUntil || ctx.consumedCloses.has(node.from)) return;
526 const text = state.sliceDoc(node.from, node.to);
527 const show = lineRevealed(state, node.from);
528
529 if (text.startsWith("<!--")) {
530 if (show) inlineSource(ctx, node.from, node.to);
531 else ranges.push(Decoration.replace({ widget: new CommentWidget() }).range(node.from, node.to));
532 return;
533 }
534 const head = TAG_HEAD.exec(text);
535 if (!head) {
536 inlineSource(ctx, node.from, node.to);
537 return;
538 }
539 const tag = head[2]!.toLowerCase();
540 if (head[1] === "/") {
541 // An unconsumed close tag has no opener in this run: visible source.
542 inlineSource(ctx, node.from, node.to);
543 return;
544 }
545 if (tag === "br") {
546 if (show) inlineSource(ctx, node.from, node.to);
547 else ranges.push(Decoration.replace({ widget: new BrWidget() }).range(node.from, node.to));
548 return;
549 }
550 if (tag === "img") {
551 if (show) inlineSource(ctx, node.from, node.to);
552 else
553 ranges.push(
554 Decoration.replace({ widget: new HtmlWidget(text, true, false, ctx.htmlOptions) }).range(
555 node.from,
556 node.to,
557 ),
558 );
559 return;
560 }
561 const style = INLINE_STYLE[tag];
562 if (style === undefined) {
563 inlineSource(ctx, node.from, node.to);
564 return;
565 }
566
567 // Find the matching close among following siblings, tracking same-tag nesting.
568 let depth = 0;
569 let close: SyntaxNode | null = null;
570 for (let sib = node.nextSibling; sib; sib = sib.nextSibling) {
571 if (sib.name !== "HTMLTag") continue;
572 const match = TAG_HEAD.exec(state.sliceDoc(sib.from, sib.to));
573 if (!match || match[2]!.toLowerCase() !== tag) continue;
574 if (match[1] === "/") {
575 if (depth === 0) {
576 close = sib;
577 break;
578 }
579 depth--;
580 } else {
581 depth++;
582 }
583 }
584 if (!close) {
585 inlineSource(ctx, node.from, node.to);
586 return;
587 }
588 ctx.consumedCloses.add(close.from);
589
590 if (revealed(state, node.from, close.to)) {
591 inlineSource(ctx, node.from, node.to);
592 inlineSource(ctx, close.from, close.to);
593 if (style && close.from > node.to)
594 ranges.push(Decoration.mark({ class: style }).range(node.to, close.from));
595 return;
596 }
597 // A pair whose content is itself pure HTML (badge rows: `<a href><img></a>`, `<picture>…`)
598 // renders whole as one sanitized widget. Same-line only: the inline pass runs in a view plugin,
599 // which may not replace across line breaks; multi-line pairs fall through to tag styling.
600 const sameLine = state.doc.lineAt(node.from).number === state.doc.lineAt(close.to).number;
601 if (sameLine && (tag === "picture" || tag === "a") && onlyHtmlBetween(state, node, close)) {
602 ranges.push(
603 Decoration.replace({
604 widget: new HtmlWidget(state.sliceDoc(node.from, close.to), true, false, ctx.htmlOptions),
605 }).range(node.from, close.to),
606 );
607 ctx.consumedUntil = close.to;
608 return;
609 }
610 ranges.push(Decoration.replace({}).range(node.from, node.to));
611 ranges.push(Decoration.replace({}).range(close.from, close.to));
612 if (style && close.from > node.to)
613 ranges.push(Decoration.mark({ class: style }).range(node.to, close.from));
614}
615
616const TOKEN_CLASS: Record<string, string> = {
617 comment: "cm-html-tok-comment",
618 bracket: "cm-html-tok-bracket",
619 tag: "cm-html-tok-tag",
620 attr: "cm-html-tok-attr",
621 value: "cm-html-tok-value",
622};
623
624/** Mark decorations coloring revealed HTML source, from the pure tokenizer in html.ts. */
625function highlightHtmlSource(
626 state: EditorState,
627 ranges: Range<Decoration>[],
628 from: number,
629 to: number,
630): void {
631 for (const token of htmlTokens(state.sliceDoc(from, to))) {
632 if (token.to > token.from)
633 ranges.push(
634 Decoration.mark({ class: TOKEN_CLASS[token.type]! }).range(
635 from + token.from,
636 from + token.to,
637 ),
638 );
639 }
640}
641
642function htmlSourceLines(
643 state: EditorState,
644 ranges: Range<Decoration>[],
645 from: number,
646 to: number,
647): void {
648 const first = state.doc.lineAt(from).number;
649 const last = state.doc.lineAt(to).number;
650 for (let n = first; n <= last; n++)
651 ranges.push(Decoration.line({ class: "cm-lp-html-src" }).range(state.doc.line(n).from));
652 highlightHtmlSource(state, ranges, from, to);
653}
654
655function emptyPlan(): HtmlPlan {
656 return { blocks: new Map(), centers: [], partners: new Map() };
657}
658
659/**
660 * The whole-document pass: multi-line block replacements only (tables, HTML blocks, block
661 * comments). These decide which lines exist, so they must be stable regardless of the viewport —
662 * scoping them would make document height jump under scroll. The walk prunes leaf containers that
663 * cannot hold them, so its cost is the block skeleton, not the text.
664 */
665function collectBlockFormatting(
666 state: EditorState,
667 tree: Tree,
668 ranges: Range<Decoration>[],
669 options: LivePreviewOptions,
670): void {
671 const blockWidgets = options.blockWidgets ?? false;
672 const html = options.html ?? false;
673 const htmlPlan = html ? planHtml(state, tree) : emptyPlan();
674 const htmlOptions = options.resolveUrl ? { resolveUrl: options.resolveUrl } : {};
675 tree.iterate({
676 enter: (nodeRef) => {
677 if (ATX_LEVEL[nodeRef.name]) return false;
678 switch (nodeRef.name) {
679 case "Paragraph":
680 case "FencedCode":
681 case "CodeBlock":
682 case "SetextHeading1":
683 case "SetextHeading2":
684 return false;
685 case "Table":
686 return decorateTable(
687 state,
688 nodeRef.node,
689 ranges,
690 blockWidgets,
691 centeredAt(htmlPlan.centers, nodeRef.from),
692 );
693 case "HTMLBlock": {
694 const mode = htmlPlan.blocks.get(nodeRef.from);
695 if (!mode) return false; // html preview off: raw text stays visible
696 // Absorbed wrapper halves reveal together: touching either the open or the close block
697 // shows both as source.
698 const partner = htmlPlan.partners.get(nodeRef.from);
699 const show =
700 revealed(state, nodeRef.from, nodeRef.to) ||
701 (partner !== undefined && revealed(state, partner.from, partner.to));
702 if (mode === "source" || show) {
703 htmlSourceLines(state, ranges, nodeRef.from, nodeRef.to);
704 } else if (mode === "render") {
705 ranges.push(
706 Decoration.replace({
707 widget: new HtmlWidget(
708 state.sliceDoc(nodeRef.from, nodeRef.to),
709 false,
710 centeredAt(htmlPlan.centers, nodeRef.from),
711 htmlOptions,
712 ),
713 block: true,
714 }).range(nodeRef.from, nodeRef.to),
715 );
716 } else if (mode === "collapse") {
717 // Comment block: a collapsed `<!-- ⋯ -->` placeholder that expands on caret entry.
718 ranges.push(
719 Decoration.replace({ widget: new CommentWidget(), block: true }).range(
720 nodeRef.from,
721 nodeRef.to,
722 ),
723 );
724 } else {
725 // absorb (paired wrapper tag): the lines fold away entirely.
726 ranges.push(Decoration.replace({ block: true }).range(nodeRef.from, nodeRef.to));
727 }
728 return false;
729 }
730 case "CommentBlock": {
731 // Block-level `<!-- -->` gets its own node type; collapse it like a comment HTMLBlock.
732 if (!html) return false;
733 if (revealed(state, nodeRef.from, nodeRef.to))
734 htmlSourceLines(state, ranges, nodeRef.from, nodeRef.to);
735 else
736 ranges.push(
737 Decoration.replace({ widget: new CommentWidget(), block: true }).range(
738 nodeRef.from,
739 nodeRef.to,
740 ),
741 );
742 return false;
743 }
744 default:
745 return undefined;
746 }
747 },
748 });
749}
750
751/**
752 * The viewport pass: marks, line classes, and single-line widgets over `[from, to]`. Nothing here
753 * may change which lines exist (no block widgets, no cross-line replaces) — that is what lets the
754 * view plugin scope it to the visible ranges on large documents.
755 */
756function collectInlineFormatting(
757 state: EditorState,
758 tree: Tree,
759 ranges: Range<Decoration>[],
760 options: LivePreviewOptions,
761 from: number,
762 to: number,
763): void {
764 const blockWidgets = options.blockWidgets ?? false;
765 const html = options.html ?? false;
766 const resolve = options.resolveUrl ?? ((url: string) => url);
767 const htmlPlan = html ? planHtml(state, tree) : emptyPlan();
768 const inlineCtx: InlineHtmlContext = {
769 state,
770 ranges,
771 htmlOptions: options.resolveUrl ? { resolveUrl: options.resolveUrl } : {},
772 consumedCloses: new Set(),
773 consumedUntil: 0,
774 };
775 // `[!type]` callout markers parse as shortcut-reference Link nodes; remember their ranges so
776 // the link decorator leaves them to the callout label.
777 const calloutMarkers: Array<{ from: number; to: number }> = [];
778 const excluded = options.exclude?.(state) ?? [];
779 tree.iterate({
780 from,
781 to,
782 enter: (nodeRef) => {
783 if (excluded.some((range) => range.from <= nodeRef.from && range.to >= nodeRef.to))
784 return false;
785 const delimited = DELIMITED[nodeRef.name];
786 if (delimited) {
787 decorateDelimited(state, nodeRef.node, delimited.mark, delimited.className, ranges);
788 return undefined;
789 }
790 const level = ATX_LEVEL[nodeRef.name];
791 if (level) {
792 decorateHeading(state, nodeRef.node, level, ranges);
793 return undefined;
794 }
795 switch (nodeRef.name) {
796 case "FencedCode":
797 case "CodeBlock":
798 decorateCodeBlock(state, nodeRef.node, ranges);
799 return false;
800 case "Blockquote": {
801 const marker = decorateBlockquote(state, nodeRef.node, ranges);
802 if (marker) calloutMarkers.push(marker);
803 return undefined;
804 }
805 case "QuoteMark":
806 decorateQuoteMark(state, nodeRef.node, ranges);
807 return undefined;
808 case "ListMark":
809 decorateListMark(state, nodeRef.node, ranges);
810 return undefined;
811 case "Task":
812 decorateTask(state, nodeRef.node, ranges);
813 return undefined;
814 case "Paragraph":
815 decorateIndeterminateTask(state, nodeRef.node, ranges);
816 return undefined;
817 case "Link":
818 if (calloutMarkers.some((m) => nodeRef.from >= m.from && nodeRef.to <= m.to))
819 return false;
820 decorateLink(state, nodeRef.node, ranges);
821 return false;
822 case "Image":
823 return decorateImage(
824 state,
825 nodeRef.node,
826 ranges,
827 blockWidgets,
828 resolve,
829 options.renderVideo,
830 );
831 case "HorizontalRule":
832 if (blockWidgets && !lineRevealed(state, nodeRef.from)) {
833 ranges.push(
834 Decoration.replace({ widget: new HrWidget() }).range(nodeRef.from, nodeRef.to),
835 );
836 }
837 return undefined;
838 case "HTMLTag":
839 if (html) decorateInlineHtml(inlineCtx, nodeRef.node);
840 return false;
841 case "Comment":
842 if (!html) return false;
843 if (lineRevealed(state, nodeRef.from)) inlineSource(inlineCtx, nodeRef.from, nodeRef.to);
844 else
845 ranges.push(
846 Decoration.replace({ widget: new CommentWidget() }).range(nodeRef.from, nodeRef.to),
847 );
848 return false;
849 // Multi-line blocks belong to the whole-document pass.
850 case "Table":
851 return undefined; // inline formatting inside revealed table source still applies
852 case "HTMLBlock":
853 case "CommentBlock":
854 return false;
855 default:
856 return undefined;
857 }
858 },
859 });
860 // Centering wrappers absorb into a text-align on every content line between the pair.
861 for (const span of htmlPlan.centers) {
862 if (span.to < from || span.from > to) continue;
863 const first = state.doc.lineAt(Math.min(span.from + 1, state.doc.length)).number;
864 const last = state.doc.lineAt(span.to).number;
865 for (let n = first; n <= last; n++) {
866 const line = state.doc.line(n);
867 if (line.from >= span.from && line.to <= span.to)
868 ranges.push(Decoration.line({ class: "cm-lp-center" }).range(line.from));
869 }
870 }
871}
872
873/** The whole-document block decorations — exposed for headless tests. */
874export function blockDecorations(
875 state: EditorState,
876 options: LivePreviewOptions = {},
877): Range<Decoration>[] {
878 const ranges: Range<Decoration>[] = [];
879 collectBlockFormatting(state, syntaxTree(state), ranges, options);
880 return ranges;
881}
882
883/** The complete live-preview decoration set for a state — exposed for headless tests. */
884export function formattingDecorations(
885 state: EditorState,
886 options: LivePreviewOptions = {},
887): Range<Decoration>[] {
888 const ranges: Range<Decoration>[] = [];
889 const tree = syntaxTree(state);
890 collectBlockFormatting(state, tree, ranges, options);
891 collectInlineFormatting(state, tree, ranges, options, 0, state.doc.length);
892 return ranges;
893}
894
895const MONO = "ui-monospace, SFMono-Regular, Menlo, monospace";
896
897const livePreviewTheme = EditorView.theme({
898 ".cm-lp-mark": { color: "var(--markdown-muted, var(--muted))" },
899 ".cm-lp-strong": { fontWeight: "600" },
900 ".cm-lp-em": { fontStyle: "italic" },
901 ".cm-lp-strike": { textDecoration: "line-through" },
902 ".cm-lp-code": {
903 fontFamily: MONO,
904 fontSize: "0.9em",
905 backgroundColor: "var(--markdown-panel, var(--panel-2))",
906 borderRadius: "4px",
907 padding: "0.1em 0.3em",
908 margin: "0 -0.15em",
909 },
910 ".cm-lp-codeblock": {
911 fontFamily: MONO,
912 fontSize: "13px",
913 backgroundColor: "var(--markdown-panel, var(--panel-2))",
914 borderLeft: "1px solid var(--border)",
915 borderRight: "1px solid var(--border)",
916 padding: "0 0.6rem",
917 },
918 ".cm-lp-codeblock-top": {
919 borderTop: "1px solid var(--border)",
920 borderTopLeftRadius: "6px",
921 borderTopRightRadius: "6px",
922 paddingTop: "0.25rem",
923 },
924 ".cm-lp-codeblock-bottom": {
925 borderBottom: "1px solid var(--border)",
926 borderBottomLeftRadius: "6px",
927 borderBottomRightRadius: "6px",
928 paddingBottom: "0.25rem",
929 },
930 ".cm-lp-code-lang": { color: "var(--markdown-muted, var(--muted))", fontSize: "0.85em" },
931
932 // Headings size the whole line (marks included) so caret-in/caret-out never shifts vertically.
933 ".cm-lp-heading": { fontWeight: "650", lineHeight: "1.3" },
934 ".cm-lp-h1": { fontSize: "1.6em" },
935 ".cm-lp-h2": { fontSize: "1.4em" },
936 ".cm-lp-h3": { fontSize: "1.2em" },
937 ".cm-lp-h4": { fontSize: "1.05em" },
938 ".cm-lp-h5": { fontSize: "1em" },
939 ".cm-lp-h6": { fontSize: "0.95em", color: "var(--markdown-muted, var(--muted))" },
940
941 ".cm-lp-quote": {
942 borderLeft: "3px solid var(--border)",
943 paddingLeft: "0.7rem",
944 },
945 // GitHub alerts are deliberately spare: no card fill or radius, just a semantic bar and title.
946 ".cm-lp-callout": {
947 borderLeftWidth: "0.25rem",
948 paddingLeft: "1rem",
949 },
950 ".cm-lp-callout-note": { borderLeftColor: "#4c8dff" },
951 ".cm-lp-callout-tip": { borderLeftColor: "#2fbf71" },
952 ".cm-lp-callout-important": { borderLeftColor: "#8b5cf6" },
953 ".cm-lp-callout-warning": { borderLeftColor: "#f5a623" },
954 ".cm-lp-callout-caution": { borderLeftColor: "#ef4444" },
955 ".cm-lp-callout-label": {
956 display: "inline-flex",
957 alignItems: "center",
958 gap: "0.4em",
959 fontWeight: "600",
960 marginRight: "0.45em",
961 },
962 ".cm-lp-callout-icon": { width: "1em", height: "1em", fill: "currentColor" },
963 ".cm-lp-callout-label-note": { color: "#4c8dff" },
964 ".cm-lp-callout-label-tip": { color: "#2fbf71" },
965 ".cm-lp-callout-label-important": { color: "#8b5cf6" },
966 ".cm-lp-callout-label-warning": { color: "#f5a623" },
967 ".cm-lp-callout-label-caution": { color: "#ef4444" },
968
969 ".cm-lp-bullet": { color: "var(--markdown-muted, var(--muted))" },
970 ".cm-lp-list-num": { color: "var(--markdown-muted, var(--muted))" },
971 ".cm-lp-list-nested": {
972 paddingLeft: "1.5rem",
973 borderLeft:
974 "2px solid color-mix(in srgb, var(--markdown-accent, var(--accent)) 35%, var(--border))",
975 },
976 ".cm-lp-task": {
977 appearance: "none",
978 width: "14px",
979 height: "14px",
980 border: "1px solid var(--border)",
981 borderRadius: "4px",
982 backgroundColor: "var(--markdown-panel, var(--panel-2))",
983 verticalAlign: "-0.16em",
984 transform: "translateY(1px)",
985 margin: "0 0.15em 0 0",
986 },
987 ".cm-lp-task:checked, .cm-lp-task:indeterminate": {
988 borderColor: "var(--markdown-accent, var(--accent))",
989 backgroundColor: "var(--markdown-accent, var(--accent))",
990 backgroundRepeat: "no-repeat",
991 },
992 ".cm-lp-task:checked": {
993 backgroundImage:
994 "linear-gradient(135deg, transparent 44%, #fff 44%, #fff 56%, transparent 56%), linear-gradient(45deg, transparent 47%, #fff 47%, #fff 59%, transparent 59%)",
995 backgroundSize: "7px 5px, 4px 7px",
996 backgroundPosition: "5px 2px, 2px 4px",
997 },
998 ".cm-lp-task:indeterminate": {
999 backgroundImage: "linear-gradient(#fff, #fff)",
1000 backgroundSize: "8px 2px",
1001 backgroundPosition: "2px 5px",
1002 },
1003 ".cm-lp-task:focus-visible": {
1004 outline: "2px solid var(--markdown-accent, var(--accent))",
1005 outlineOffset: "2px",
1006 },
1007 ".cm-lp-task-done": { color: "var(--markdown-muted, var(--muted))" },
1008
1009 ".cm-lp-link": { color: "var(--markdown-accent, var(--accent))", textDecoration: "underline" },
1010 ".cm-lp-url": { color: "var(--markdown-muted, var(--muted))" },
1011
1012 ".cm-lp-image img": {
1013 maxWidth: "100%",
1014 maxHeight: "360px",
1015 borderRadius: "6px",
1016 verticalAlign: "text-bottom",
1017 },
1018 ".cm-lp-video": {
1019 width: "auto",
1020 height: "auto",
1021 maxWidth: "100%",
1022 maxHeight: "360px",
1023 borderRadius: "6px",
1024 verticalAlign: "text-bottom",
1025 },
1026 ".cm-lp-video:focus-visible": {
1027 outline: "2px solid var(--markdown-accent, var(--accent))",
1028 outlineOffset: "2px",
1029 },
1030
1031 ".cm-lp-hr": {
1032 display: "inline-block",
1033 width: "100%",
1034 borderTop: "2px solid var(--border)",
1035 verticalAlign: "middle",
1036 },
1037
1038 ".cm-lp-table-src": { fontFamily: MONO, fontSize: "13px" },
1039
1040 ".cm-lp-html-src": {
1041 fontFamily: MONO,
1042 fontSize: "13px",
1043 color: "var(--markdown-muted, var(--muted))",
1044 },
1045 ".cm-lp-html-inline": {
1046 fontFamily: MONO,
1047 fontSize: "0.85em",
1048 color: "var(--markdown-muted, var(--muted))",
1049 },
1050 ".cm-html-tok-bracket": { color: "var(--markdown-muted, var(--muted))" },
1051 ".cm-html-tok-tag": { color: "var(--markdown-accent, var(--accent))" },
1052 ".cm-html-tok-attr": {
1053 color:
1054 "color-mix(in srgb, var(--markdown-text, var(--text)) 75%, var(--markdown-muted, var(--muted)))",
1055 },
1056 ".cm-html-tok-value": {
1057 color:
1058 "color-mix(in srgb, var(--markdown-accent, var(--accent)) 55%, var(--markdown-text, var(--text)))",
1059 },
1060 ".cm-html-tok-comment": { color: "var(--markdown-muted, var(--muted))", fontStyle: "italic" },
1061 ".cm-lp-comment": {
1062 fontFamily: MONO,
1063 fontSize: "0.82em",
1064 color: "var(--markdown-muted, var(--muted))",
1065 opacity: "0.8",
1066 },
1067 ".cm-lp-center": { textAlign: "center" },
1068 ".cm-lp-html": { lineHeight: "1.5" },
1069 ".cm-lp-html.cm-lp-centered": { textAlign: "center" },
1070 ".cm-lp-table.cm-lp-centered": { marginLeft: "auto", marginRight: "auto" },
1071 ".cm-lp-html img": { maxWidth: "100%", maxHeight: "360px", verticalAlign: "middle" },
1072 ".cm-lp-html a": { color: "var(--markdown-accent, var(--accent))" },
1073 ".cm-lp-html h1, .cm-lp-html h2, .cm-lp-html h3": {
1074 margin: "0.2em 0",
1075 lineHeight: "1.3",
1076 },
1077 ".cm-lp-html p": { margin: "0.3em 0" },
1078 ".cm-lp-html details": {
1079 border: "1px solid var(--border)",
1080 borderRadius: "6px",
1081 padding: "0.3rem 0.6rem",
1082 },
1083 ".cm-lp-html summary": { cursor: "pointer", fontWeight: "600", marginBottom: "-0.15em" },
1084 ".cm-lp-html details > summary + *": { marginTop: "0.1em" },
1085 ".cm-lp-html details > :last-child": { marginBottom: "0" },
1086 ".cm-lp-html table": { borderCollapse: "collapse" },
1087 ".cm-lp-html th, .cm-lp-html td": {
1088 border: "1px solid var(--border)",
1089 padding: "3px 10px",
1090 },
1091 ".cm-html-kbd": {
1092 fontFamily: MONO,
1093 fontSize: "0.82em",
1094 border: "1px solid var(--border)",
1095 borderBottomWidth: "2px",
1096 borderRadius: "4px",
1097 padding: "0.05em 0.35em",
1098 backgroundColor: "var(--markdown-panel, var(--panel-2))",
1099 },
1100 ".cm-html-sub": { fontSize: "0.75em", verticalAlign: "sub" },
1101 ".cm-html-sup": { fontSize: "0.75em", verticalAlign: "super" },
1102 ".cm-html-ins": { textDecoration: "underline" },
1103 ".cm-html-mark": {
1104 backgroundColor: "color-mix(in srgb, var(--markdown-accent, var(--accent)) 30%, transparent)",
1105 borderRadius: "3px",
1106 padding: "0 0.15em",
1107 },
1108 ".cm-html-small": { fontSize: "0.85em" },
1109 ".cm-lp-table": {
1110 borderCollapse: "collapse",
1111 margin: "0.2rem 0",
1112 fontSize: "0.95em",
1113 },
1114 ".cm-lp-table th, .cm-lp-table td": {
1115 border: "1px solid var(--border)",
1116 padding: "3px 10px",
1117 textAlign: "left",
1118 },
1119 ".cm-lp-table th": {
1120 fontWeight: "600",
1121 backgroundColor: "var(--markdown-panel, var(--panel-2))",
1122 },
1123});
1124
1125/**
1126 * Vertical cursor movement that lands *inside* a replaced block (rendered table/html, absorbed
1127 * wrapper) instead of skipping it: when the default move would jump more than one document line,
1128 * the cursor is placed on the first hidden line, which reveals it.
1129 */
1130function moveVerticallyIntoBlocks(forward: boolean) {
1131 return (view: EditorView): boolean => {
1132 const { state } = view;
1133 const selection = state.selection.main;
1134 if (!selection.empty) return false;
1135 const target = view.moveVertically(selection, forward);
1136 const fromLine = state.doc.lineAt(selection.head).number;
1137 const targetLine = state.doc.lineAt(target.head).number;
1138 if (forward ? targetLine <= fromLine + 1 : targetLine >= fromLine - 1) return false;
1139 const landing = forward ? state.doc.line(fromLine + 1).from : state.doc.line(fromLine - 1).to;
1140 view.dispatch({
1141 selection: EditorSelection.cursor(landing),
1142 scrollIntoView: true,
1143 userEvent: "select",
1144 });
1145 return true;
1146 };
1147}
1148
1149/**
1150 * Below this document size the inline pass covers the whole document (stable decorations, simpler
1151 * tests); above it, only the visible ranges decorate and the pass re-runs as the viewport moves.
1152 */
1153const INLINE_WHOLE_DOC_LIMIT = 32_000;
1154
1155/** The live-preview layer: block field + viewport-scoped inline plugin plus class styles. */
1156export function livePreview(options: LivePreviewOptions = {}): Extension {
1157 // Block replacements (tables, HTML blocks) decide which lines exist, so they live in a state
1158 // field covering the whole document; CodeMirror also requires block decorations to come from a
1159 // field, not a plugin.
1160 const field = StateField.define<DecorationSet>({
1161 create: (state) => Decoration.set(blockDecorations(state, options), true),
1162 // The tree-identity check matters: the incremental parser finishes after the initial build in
1163 // transactions with no doc or selection change, and decorations built from the partial tree
1164 // would otherwise stay stale until the next caret move.
1165 update: (previous, transaction) =>
1166 transaction.docChanged ||
1167 transaction.selection ||
1168 transaction.effects.some((effect) => effect.is(setPreviewFocus)) ||
1169 syntaxTree(transaction.state) !== syntaxTree(transaction.startState)
1170 ? Decoration.set(blockDecorations(transaction.state, options), true)
1171 : previous,
1172 provide: (self) => EditorView.decorations.from(self),
1173 });
1174
1175 // Marks, line classes, and single-line widgets rebuild only over the visible ranges once the
1176 // document outgrows the whole-doc limit — the fix for whole-document decoration cost.
1177 const buildInline = (view: EditorView): DecorationSet => {
1178 const ranges: Range<Decoration>[] = [];
1179 const tree = syntaxTree(view.state);
1180 const spans =
1181 view.state.doc.length <= INLINE_WHOLE_DOC_LIMIT
1182 ? [{ from: 0, to: view.state.doc.length }]
1183 : view.visibleRanges;
1184 for (const span of spans)
1185 collectInlineFormatting(view.state, tree, ranges, options, span.from, span.to);
1186 return Decoration.set(ranges, true);
1187 };
1188 const inlinePlugin = ViewPlugin.fromClass(
1189 class {
1190 decorations: DecorationSet;
1191
1192 constructor(view: EditorView) {
1193 this.decorations = buildInline(view);
1194 }
1195
1196 update(update: ViewUpdate) {
1197 if (
1198 update.docChanged ||
1199 update.selectionSet ||
1200 update.viewportChanged ||
1201 update.focusChanged ||
1202 update.transactions.some((tr) => tr.effects.some((e) => e.is(setPreviewFocus))) ||
1203 syntaxTree(update.state) !== syntaxTree(update.startState)
1204 )
1205 this.decorations = buildInline(update.view);
1206 }
1207 },
1208 { decorations: (value) => value.decorations },
1209 );
1210 // DOM focus/blur dispatch synchronously (they run outside the update cycle), keeping the
1211 // reveal gate current without a scheduling dance.
1212 const focusWatcher = EditorView.domEventHandlers({
1213 focus: (_event, view) => {
1214 view.dispatch({ effects: setPreviewFocus.of(true) });
1215 return false;
1216 },
1217 blur: (_event, view) => {
1218 view.dispatch({ effects: setPreviewFocus.of(false) });
1219 return false;
1220 },
1221 });
1222 return [
1223 syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
1224 previewFocus,
1225 focusWatcher,
1226 field,
1227 inlinePlugin,
1228 livePreviewTheme,
1229 // High precedence so these beat the base cursor commands; a picker's Prec.highest keymap
1230 // still claims the arrows first in composer-style hosts.
1231 ...(options.blockWidgets
1232 ? [
1233 Prec.high(
1234 keymap.of([
1235 { key: "ArrowDown", run: moveVerticallyIntoBlocks(true) },
1236 { key: "ArrowUp", run: moveVerticallyIntoBlocks(false) },
1237 ]),
1238 ),
1239 ]
1240 : []),
1241 ];
1242}
packages/markdown-editor/src/mentions.ts created+246
......@@ -0,0 +1,246 @@
1// Atomic `@mention` chips. The document carries an opaque sentinel (`⟦mention:N⟧`) per mention; a
2// chip decoration paints it as an atomic widget (caret skips it, Backspace deletes it whole) and a
3// side registry keyed by the id holds the payload — so an edit elsewhere can't corrupt it. On
4// submit `serializeMentions` expands each sentinel to its `@token` (a path or source range), which
5// is exactly what the consumer reads.
6import type { Extension } from "@codemirror/state";
7import { Decoration, EditorView, WidgetType } from "@codemirror/view";
8
9import { chipField } from "./chips.ts";
10
11// Kept as two statements: kiln's type-stripping rejects inline `type` specifiers in re-exports.
12export { mentionTriggerAt } from "./triggers.ts";
13export type { MentionTrigger } from "./triggers.ts";
14
15/** A mention target surfaced by a provider: a workspace file or a code symbol. */
16export interface MentionCandidate {
17 kind: "file" | "symbol";
18 /** Primary text on the chip and picker row (a basename or symbol name). */
19 label: string;
20 /** Secondary muted text on the picker row (the directory, or a container). */
21 detail?: string;
22 /** The `@token` this mention expands to in the serialized text (a path or source range). */
23 token: string;
24 /** Optional image URL resolved by the host for file mentions. */
25 icon?: string;
26 /** Compact source range shown beside the basename, such as `L12-18`. */
27 lineLabel?: string;
28}
29
30/**
31 * The client/server seam. The editor asks a provider for candidates as the user types `@query`; a
32 * demo fakes it with a fixed list, a real host backs it with workspace file/symbol search. The UI
33 * depends only on this interface, never on how the results are found.
34 */
35export interface MentionProvider {
36 search(query: string): Promise<MentionCandidate[]>;
37}
38
39/** A fixed-list provider for demos and tests: substring-filters a static candidate set. */
40export function staticMentionProvider(all: MentionCandidate[]): MentionProvider {
41 return {
42 search(query) {
43 const needle = query.toLowerCase();
44 const hits = all.filter((c) =>
45 `${c.label} ${c.token} ${c.detail ?? ""}`.toLowerCase().includes(needle),
46 );
47 return Promise.resolve(hits.slice(0, 8));
48 },
49 };
50}
51
52const SENTINEL = /⟦mention:(\d+)⟧/g;
53
54/**
55 * Per-editor payload store keyed by the doc's `⟦mention:N⟧` sentinels. Scoped to one editor so the
56 * ids never collide across instances and the map dies with the editor instead of growing for the
57 * life of the module.
58 */
59export interface MentionRegistry {
60 add(mention: MentionCandidate): number;
61 get(id: number): MentionCandidate | undefined;
62}
63
64export function createMentionRegistry(): MentionRegistry {
65 const items = new Map<number, MentionCandidate>();
66 let nextId = 0;
67 return {
68 add(mention) {
69 const id = nextId++;
70 items.set(id, mention);
71 return id;
72 },
73 get: (id) => items.get(id),
74 };
75}
76
77const FILE_SVG =
78 '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"><path d="M4 2h4l3 3v9H4z"/><path d="M8 2v3h3"/></svg>';
79const SYMBOL_SVG =
80 '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 2.5c-1.2 0-1.5.8-1.5 2v1.3c0 .9-.4 1.7-1.5 1.7 1.1 0 1.5.8 1.5 1.7V11c0 1.2.3 2 1.5 2"/><path d="M9.5 2.5c1.2 0 1.5.8 1.5 2v1.3c0 .9.4 1.7 1.5 1.7-1.1 0-1.5.8-1.5 1.7V11c0 1.2-.3 2-1.5 2"/></svg>';
81
82// No TS parameter properties here: kiln imports this module and its type-stripping cannot
83// transform them ([`arc/verify/kiln-migration.md`](../../../../arc/verify/kiln-migration.md)).
84class MentionWidget extends WidgetType {
85 readonly id: number;
86 readonly mention: MentionCandidate;
87
88 constructor(id: number, mention: MentionCandidate) {
89 super();
90 this.id = id;
91 this.mention = mention;
92 }
93
94 override eq(other: MentionWidget): boolean {
95 return other.id === this.id;
96 }
97
98 override toDOM(): HTMLElement {
99 const chip = document.createElement("span");
100 chip.className = `chip is-${this.mention.kind}`;
101 chip.title = this.mention.token;
102 const icon = document.createElement("span");
103 icon.className = "chip-icon";
104 if (this.mention.icon) {
105 const image = document.createElement("img");
106 image.alt = "";
107 image.src = this.mention.icon;
108 icon.append(image);
109 } else {
110 icon.innerHTML = this.mention.kind === "symbol" ? SYMBOL_SVG : FILE_SVG;
111 }
112 const label = document.createElement("span");
113 label.className = "chip-label";
114 label.textContent = this.mention.label;
115 chip.append(icon, label);
116 if (this.mention.lineLabel) {
117 const lines = document.createElement("span");
118 lines.className = "chip-detail";
119 lines.textContent = this.mention.lineLabel;
120 chip.append(lines);
121 }
122 return chip;
123 }
124}
125
126/** The sentinel occurrences in `doc` that have a registered payload. */
127export function scanMentions(
128 registry: MentionRegistry,
129 doc: string,
130): Array<{ from: number; to: number; id: number; mention: MentionCandidate }> {
131 const out: Array<{ from: number; to: number; id: number; mention: MentionCandidate }> = [];
132 for (const match of doc.matchAll(SENTINEL)) {
133 const id = Number(match[1]);
134 const mention = registry.get(id);
135 if (mention && match.index !== undefined) {
136 out.push({ from: match.index, to: match.index + match[0].length, id, mention });
137 }
138 }
139 return out;
140}
141
142/**
143 * Structure only, and deliberately no paint. This package is the general-purpose editor, not the
144 * agent chat UI: it lays a chip out so the widget is legible standalone and leaves every border,
145 * surface, colour, and size to whoever themes the `chip`/`chip-icon`/`chip-label`/`chip-detail`
146 * contract this DOM emits. **A theme injected by CodeMirror carries a generated class and so
147 * outranks a plain product rule** — anything painted here would win inside the editor and lose
148 * everywhere else, which is one chip drawn two ways.
149 */
150const mentionTheme = EditorView.theme({
151 ".chip": {
152 display: "inline-flex",
153 alignItems: "center",
154 gap: "0.3rem",
155 maxWidth: "16rem",
156 verticalAlign: "baseline",
157 },
158 ".chip-icon": { display: "inline-flex", flex: "0 0 auto", alignSelf: "center" },
159 ".chip-icon svg, .chip-icon img": { display: "block", height: "1em", width: "1em" },
160 ".chip-label": {
161 minWidth: "0",
162 overflow: "hidden",
163 textOverflow: "ellipsis",
164 whiteSpace: "nowrap",
165 },
166 ".chip-detail": { flex: "0 0 auto" },
167});
168
169export function mentionsExtension(registry: MentionRegistry): Extension {
170 return [
171 chipField({
172 atomic: true,
173 chips: (state) =>
174 scanMentions(registry, state.doc.toString()).map(({ from, to, id, mention }) =>
175 Decoration.replace({ widget: new MentionWidget(id, mention) }).range(from, to),
176 ),
177 }),
178 mentionTheme,
179 EditorView.clipboardInputFilter.of((text) => hydrateMentions(registry, text)),
180 EditorView.clipboardOutputFilter.of((text) => serializeMentions(registry, text)),
181 ];
182}
183
184/** Insert an atomic mention chip, registering its payload; replaces `range` (the `@query`) when given. */
185export function insertMention(
186 view: EditorView,
187 registry: MentionRegistry,
188 mention: MentionCandidate,
189 range?: { from: number; to: number },
190): void {
191 const id = registry.add(mention);
192 const sentinel = `⟦mention:${id}⟧`;
193 const from = range?.from ?? view.state.selection.main.from;
194 const to = range?.to ?? view.state.selection.main.to;
195 // Pad a leading space when butting against prior text, and a trailing one so the caret lands clear
196 // of the atomic chip.
197 const needsLead = from > 0 && !/\s/.test(view.state.sliceDoc(from - 1, from));
198 const insert = `${needsLead ? " " : ""}${sentinel} `;
199 view.dispatch({
200 changes: { from, to, insert },
201 selection: { anchor: from + insert.length },
202 scrollIntoView: true,
203 userEvent: "input",
204 });
205 view.focus();
206}
207
208/** Expand every sentinel to its `@token`, dropping orphans — the text form the consumer receives. */
209export function serializeMentions(registry: MentionRegistry, doc: string): string {
210 return doc.replace(SENTINEL, (_token, id: string) => {
211 const mention = registry.get(Number(id));
212 return mention ? `@${mention.token}` : "";
213 });
214}
215
216const SAVED_MENTION = /(^|\s)@([^\s@]+)/g;
217const FILE_TOKEN =
218 /^(?:\.?\.?\/|[^/\s]+\/|[^/\s]+\.(?:[cm]?[jt]sx?|json|mdx?|css|scss|html|vue|svelte|py|rs|go|java|kt|swift|rb|php|sql|ya?ml|toml))(?:.*)$/i;
219const RANGE_SUFFIX = /:(\d+)(?:-(\d+)|:(\d+))?$/;
220
221/** Restore canonical draft `@path` tokens to atomic chips. */
222export function hydrateMentions(registry: MentionRegistry, value: string): string {
223 return value.replace(SAVED_MENTION, (whole, lead: string, savedToken: string) => {
224 const suffix = /[.,;!?]+$/.exec(savedToken)?.[0] ?? "";
225 const token = suffix ? savedToken.slice(0, -suffix.length) : savedToken;
226 if (/^[a-z][a-z\d+.-]*:\/\//i.test(token)) return whole;
227 if (!FILE_TOKEN.test(token)) return whole;
228 const range = RANGE_SUFFIX.exec(token);
229 const path = range ? token.slice(0, range.index) : token;
230 const id = registry.add({
231 kind: "file",
232 label: path.slice(path.lastIndexOf("/") + 1),
233 token,
234 ...(range
235 ? {
236 lineLabel: range[2]
237 ? `L${range[1]}-${range[2]}`
238 : range[3]
239 ? `L${range[1]}:${range[3]}`
240 : `L${range[1]}`,
241 }
242 : {}),
243 });
244 return `${lead}⟦mention:${id}⟧${suffix}`;
245 });
246}
packages/markdown-editor/src/react.tsx created+141
......@@ -0,0 +1,141 @@
1// The one React file: thin bindings over the headless package. `CodeMirror` mounts any extension
2// set; `MarkdownEditorView` mounts the composer factory and exposes its handle through `ref`.
3// Everything stateful lives in CodeMirror/the factory — these components only manage mount,
4// unmount, and prop sync.
5import { EditorState, Compartment, type Extension } from "@codemirror/state";
6import { EditorView } from "@codemirror/view";
7import { type Ref, useEffect, useImperativeHandle, useLayoutEffect, useRef } from "react";
8
9import { createMarkdownEditor, type MarkdownEditor, type MarkdownEditorOptions } from "./editor.ts";
10
11export interface CodeMirrorProps {
12 /** Controlled value: when it differs from the doc, the doc is replaced. Omit for uncontrolled. */
13 value?: string;
14 /** Initial doc for uncontrolled use. */
15 defaultValue?: string;
16 onChange?: (value: string) => void;
17 /** Reconfigured in place (via a compartment) when the array identity changes. */
18 extensions?: Extension[];
19 className?: string;
20 autoFocus?: boolean;
21 /** The underlying view, for imperative access. */
22 ref?: Ref<EditorView>;
23}
24
25const EMPTY_EXTENSIONS: Extension[] = [];
26
27/** A generic CodeMirror mount: one view per component instance, extensions hot-swappable. */
28export function CodeMirror({
29 value,
30 defaultValue,
31 onChange,
32 extensions = EMPTY_EXTENSIONS,
33 className,
34 autoFocus,
35 ref,
36}: CodeMirrorProps) {
37 const host = useRef<HTMLDivElement>(null);
38 const viewRef = useRef<EditorView>(null);
39 const compartment = useRef(new Compartment());
40 const onChangeRef = useRef(onChange);
41 useLayoutEffect(() => {
42 onChangeRef.current = onChange;
43 });
44
45 // oxlint-disable react/exhaustive-effect-dependencies -- The document is seeded once; controlled updates preserve the mounted view.
46 useLayoutEffect(() => {
47 const view = new EditorView({
48 state: EditorState.create({
49 doc: value ?? defaultValue ?? "",
50 extensions: [
51 compartment.current.of(extensions),
52 EditorView.updateListener.of((update) => {
53 if (update.docChanged) onChangeRef.current?.(update.state.doc.toString());
54 }),
55 ],
56 }),
57 parent: host.current!,
58 });
59 viewRef.current = view;
60 if (autoFocus) view.focus();
61 return () => {
62 viewRef.current = null;
63 view.destroy();
64 };
65 }, []);
66 // oxlint-enable react/exhaustive-effect-dependencies
67
68 useEffect(() => {
69 viewRef.current?.dispatch({ effects: compartment.current.reconfigure(extensions) });
70 }, [extensions]);
71
72 useEffect(() => {
73 const view = viewRef.current;
74 if (!view || value === undefined) return;
75 const current = view.state.doc.toString();
76 if (value !== current)
77 view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
78 }, [value]);
79
80 useImperativeHandle(ref, () => viewRef.current!, []);
81
82 return <div className={className} ref={host} />;
83}
84
85export interface MarkdownEditorViewProps extends MarkdownEditorOptions {
86 className?: string;
87 autoFocus?: boolean;
88 /** The editor handle (getValue/setValue/insertMention/…), for imperative access. */
89 ref?: Ref<MarkdownEditor>;
90}
91
92/**
93 * The composer preset as a component. Options are read once at mount (callbacks stay live through
94 * refs); the editor is uncontrolled — read and write text through the handle.
95 */
96export function MarkdownEditorView({
97 className,
98 autoFocus,
99 ref,
100 ...options
101}: MarkdownEditorViewProps) {
102 const host = useRef<HTMLDivElement>(null);
103 const editorRef = useRef<MarkdownEditor>(null);
104 const optionsRef = useRef(options);
105 useLayoutEffect(() => {
106 optionsRef.current = options;
107 });
108
109 // oxlint-disable react/exhaustive-effect-dependencies -- Factory options seed one editor; live callbacks read the ref.
110 useLayoutEffect(() => {
111 const current = optionsRef.current;
112 const editor = createMarkdownEditor({
113 ...current,
114 onChange: (value) => optionsRef.current.onChange?.(value),
115 onMention: (trigger) => optionsRef.current.onMention?.(trigger),
116 onSlash: (trigger) => optionsRef.current.onSlash?.(trigger),
117 onEmoji: (trigger) => optionsRef.current.onEmoji?.(trigger),
118 ...(current.onSubmit ? { onSubmit: () => optionsRef.current.onSubmit?.() } : {}),
119 ...(current.onArrow
120 ? { onArrow: (direction) => optionsRef.current.onArrow?.(direction) ?? false }
121 : {}),
122 ...(current.onAccept ? { onAccept: () => optionsRef.current.onAccept?.() ?? false } : {}),
123 ...(current.onEscape ? { onEscape: () => optionsRef.current.onEscape?.() ?? false } : {}),
124 ...(current.onFiles
125 ? { onFiles: (files) => optionsRef.current.onFiles?.(files) ?? false }
126 : {}),
127 });
128 editorRef.current = editor;
129 host.current!.append(editor.dom);
130 if (autoFocus) editor.focus();
131 return () => {
132 editorRef.current = null;
133 editor.destroy();
134 };
135 }, []);
136 // oxlint-enable react/exhaustive-effect-dependencies
137
138 useImperativeHandle(ref, () => editorRef.current!, []);
139
140 return <div className={className} ref={host} />;
141}
packages/markdown-editor/src/triggers.ts created+55
......@@ -0,0 +1,55 @@
1// Caret-anchored completion triggers: pure functions over EditorState, so a host can poll them from
2// an update listener and drive its own picker UI. Each returns the doc range an accepted completion
3// replaces, or null when the caret is not inside a trigger run.
4import type { EditorState } from "@codemirror/state";
5
6/** Doc offsets of the trigger run and the query typed so far; the range an acceptance replaces. */
7export interface Trigger {
8 from: number;
9 to: number;
10 query: string;
11}
12
13export type MentionTrigger = Trigger;
14export type SlashTrigger = Trigger;
15export type EmojiTrigger = Trigger;
16
17// `@` anchored to a word boundary (start of line or after whitespace); the query runs to the caret
18// over any non-space, non-`@` run, so paths with `/` and `.` work.
19const MENTION_TRIGGER = /(?<=^|\s)@([^\s@]*)$/;
20
21export function mentionTriggerAt(state: EditorState): MentionTrigger | null {
22 const range = state.selection.main;
23 if (!range.empty) return null;
24 const line = state.doc.lineAt(range.head);
25 const before = line.text.slice(0, range.head - line.from);
26 const match = MENTION_TRIGGER.exec(before);
27 if (!match || match.index === undefined) return null;
28 return { from: line.from + match.index, to: range.head, query: match[1] ?? "" };
29}
30
31/** A `/command` run anchored to the start of the line, as chat composers use for slash palettes. */
32export function slashTriggerAt(state: EditorState): SlashTrigger | null {
33 const range = state.selection.main;
34 if (!range.empty) return null;
35 const line = state.doc.lineAt(range.head);
36 const before = line.text.slice(0, range.head - line.from);
37 const match = /^\/([a-z-]*)$/i.exec(before);
38 if (!match) return null;
39 return { from: line.from, to: range.head, query: match[1] ?? "" };
40}
41
42// `:code` at a word boundary. The two-char floor keeps a bare `:` in prose (URLs, ratios) from
43// opening the picker; `mailto:`-style colons fail the boundary anyway.
44const EMOJI_TRIGGER = /(?:^|\s):([a-z0-9_+-]{2,})$/i;
45
46export function emojiTriggerAt(state: EditorState): EmojiTrigger | null {
47 const range = state.selection.main;
48 if (!range.empty) return null;
49 const line = state.doc.lineAt(range.head);
50 const before = line.text.slice(0, range.head - line.from);
51 const match = EMOJI_TRIGGER.exec(before);
52 if (!match) return null;
53 const query = match[1] ?? "";
54 return { from: range.head - query.length - 1, to: range.head, query };
55}
packages/markdown-editor/src/widgets.ts created+430
......@@ -0,0 +1,430 @@
1// Widget classes for the live-preview layer. Each renders from data captured at decoration-build
2// time; the field rebuilds on every doc change, so stored offsets are valid whenever a widget
3// handles an event.
4import type { EditorView } from "@codemirror/view";
5import { WidgetType } from "@codemirror/view";
6
7import { type HtmlOptions, sanitizeHtml } from "./html.ts";
8
9// No TS parameter properties anywhere here: kiln imports this module through the live-preview
10// graph and its type-stripping cannot transform them.
11
12/** The rendered bullet for an unrevealed `-`/`*`/`+` list mark. */
13export class BulletWidget extends WidgetType {
14 override eq(): boolean {
15 return true;
16 }
17
18 override toDOM(): HTMLElement {
19 const dot = document.createElement("span");
20 dot.className = "cm-lp-bullet";
21 dot.textContent = "•";
22 return dot;
23 }
24}
25
26/** A clickable checkbox replacing a `[ ]`/`[x]` task marker; toggling edits the document. */
27export type TaskState = "unchecked" | "checked" | "indeterminate";
28
29export class TaskCheckboxWidget extends WidgetType {
30 readonly state: TaskState;
31 readonly from: number;
32 readonly to: number;
33 readonly readOnly: boolean;
34
35 constructor(state: TaskState, from: number, to: number, readOnly = false) {
36 super();
37 this.state = state;
38 this.from = from;
39 this.to = to;
40 this.readOnly = readOnly;
41 }
42
43 override eq(other: TaskCheckboxWidget): boolean {
44 return (
45 other.state === this.state &&
46 other.from === this.from &&
47 other.to === this.to &&
48 other.readOnly === this.readOnly
49 );
50 }
51
52 override toDOM(view: EditorView): HTMLElement {
53 const box = document.createElement("input");
54 box.type = "checkbox";
55 box.className = "cm-lp-task";
56 box.checked = this.state === "checked";
57 box.indeterminate = this.state === "indeterminate";
58 box.addEventListener("mousedown", (event) => event.preventDefault());
59 box.disabled = this.readOnly;
60 box.addEventListener("change", () => {
61 if (view.state.readOnly) return;
62 view.dispatch({
63 changes: { from: this.from, to: this.to, insert: this.state === "checked" ? "[ ]" : "[x]" },
64 userEvent: "input",
65 });
66 });
67 return box;
68 }
69
70 override ignoreEvent(event: Event): boolean {
71 return event.type !== "mousedown";
72 }
73}
74
75export const CALLOUT_TYPES: Record<string, string> = {
76 note: "Note",
77 tip: "Tip",
78 important: "Important",
79 warning: "Warning",
80 caution: "Caution",
81};
82
83/** The styled label replacing an unrevealed `[!type]` callout marker. */
84export class CalloutLabelWidget extends WidgetType {
85 readonly type: string;
86
87 constructor(type: string) {
88 super();
89 this.type = type;
90 }
91
92 override eq(other: CalloutLabelWidget): boolean {
93 return other.type === this.type;
94 }
95
96 override toDOM(): HTMLElement {
97 const label = document.createElement("span");
98 label.className = `cm-lp-callout-label cm-lp-callout-label-${this.type}`;
99 const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
100 icon.setAttribute("class", "cm-lp-callout-icon");
101 icon.setAttribute("viewBox", "0 0 16 16");
102 icon.setAttribute("aria-hidden", "true");
103 const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
104 path.setAttribute(
105 "d",
106 this.type === "tip"
107 ? "M8 1.5a5 5 0 0 0-3 9v1h6v-1a5 5 0 0 0-3-9Zm-2 12h4v1H6Z"
108 : this.type === "warning"
109 ? "M8.87 2.1a1 1 0 0 0-1.74 0L1.2 12.5A1 1 0 0 0 2.07 14h11.86a1 1 0 0 0 .87-1.5ZM7.25 6h1.5v4h-1.5Zm0 5.5h1.5V13h-1.5Z"
110 : this.type === "caution"
111 ? "M5.2 1h5.6L15 5.2v5.6L10.8 15H5.2L1 10.8V5.2Zm2.05 3v6h1.5V4Zm0 7.5V13h1.5v-1.5Z"
112 : this.type === "important"
113 ? "M8 1a5 5 0 0 0-3.5 8.57V12h7V9.57A5 5 0 0 0 8 1Zm-.75 3h1.5v4h-1.5Zm0 5.5h1.5V11h-1.5ZM5 13.5h6V15H5Z"
114 : "M8 1.25a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM7.25 4h1.5v1.5h-1.5Zm0 3h1.5v5h-1.5Z",
115 );
116 icon.append(path);
117 label.append(icon, document.createTextNode(CALLOUT_TYPES[this.type] ?? this.type));
118 return label;
119 }
120}
121
122/** An inline-rendered markdown image (`![alt](url)`), shown when the caret is elsewhere. */
123export class ImageWidget extends WidgetType {
124 readonly url: string;
125 readonly alt: string;
126
127 constructor(url: string, alt: string) {
128 super();
129 this.url = url;
130 this.alt = alt;
131 }
132
133 override eq(other: ImageWidget): boolean {
134 return other.url === this.url && other.alt === this.alt;
135 }
136
137 override toDOM(): HTMLElement {
138 const frame = document.createElement("span");
139 frame.className = "cm-lp-image";
140 const image = document.createElement("img");
141 image.src = this.url;
142 image.alt = this.alt;
143 if (this.alt) image.title = this.alt;
144 frame.append(image);
145 return frame;
146 }
147
148 // Let CodeMirror handle clicks so they place the caret at the image's source.
149 override ignoreEvent(event: Event): boolean {
150 return event.type !== "mousedown";
151 }
152}
153
154function replaceVideoSource(video: HTMLVideoElement, url: string): void {
155 const position = video.currentTime;
156 if (position > 0) {
157 video.addEventListener(
158 "loadedmetadata",
159 () => {
160 if (Number.isFinite(video.duration)) video.currentTime = Math.min(position, video.duration);
161 },
162 { once: true },
163 );
164 }
165 video.src = url;
166}
167
168export interface VideoPreviewInput {
169 readonly source: string;
170 readonly url: string;
171 readonly alt: string;
172}
173
174export type VideoPreviewRenderer = (
175 input: VideoPreviewInput,
176 view: EditorView,
177) => {
178 readonly dom: HTMLElement;
179 readonly update: (input: VideoPreviewInput) => void;
180 readonly destroy: () => void;
181};
182
183const hostedVideos = new WeakMap<
184 HTMLElement,
185 {
186 readonly source: string;
187 readonly render: VideoPreviewRenderer;
188 readonly preview: ReturnType<VideoPreviewRenderer>;
189 }
190>();
191
192/** The authored destination stays stable while signed URLs refresh. */
193export class VideoWidget extends WidgetType {
194 readonly source: string;
195 readonly url: string;
196 readonly alt: string;
197 readonly renderVideo: VideoPreviewRenderer | undefined;
198
199 constructor(source: string, url: string, alt: string, renderVideo?: VideoPreviewRenderer) {
200 super();
201 this.source = source;
202 this.url = url;
203 this.alt = alt;
204 this.renderVideo = renderVideo;
205 }
206
207 override eq(other: VideoWidget): boolean {
208 return (
209 other.source === this.source &&
210 other.url === this.url &&
211 other.alt === this.alt &&
212 other.renderVideo === this.renderVideo
213 );
214 }
215
216 override toDOM(view: EditorView): HTMLElement {
217 if (this.renderVideo) {
218 const preview = this.renderVideo({ source: this.source, url: this.url, alt: this.alt }, view);
219 hostedVideos.set(preview.dom, { source: this.source, render: this.renderVideo, preview });
220 preview.dom.addEventListener("loadedmetadata", () => view.requestMeasure(), true);
221 return preview.dom;
222 }
223 const video = document.createElement("video");
224 video.className = "cm-lp-video";
225 video.dataset["source"] = this.source;
226 video.src = this.url;
227 video.controls = true;
228 video.playsInline = true;
229 video.preload = "metadata";
230 video.tabIndex = 0;
231 video.setAttribute("aria-label", this.alt || "Video preview");
232 video.addEventListener("loadedmetadata", () => view.requestMeasure());
233 const refreshPendingSource = () => {
234 const pending = video.dataset["pendingSrc"];
235 if (pending === undefined) return;
236 delete video.dataset["pendingSrc"];
237 replaceVideoSource(video, pending);
238 };
239 video.addEventListener("pause", refreshPendingSource);
240 video.addEventListener("error", refreshPendingSource);
241 return video;
242 }
243
244 override updateDOM(dom: HTMLElement): boolean {
245 const hosted = hostedVideos.get(dom);
246 if (hosted) {
247 if (hosted.source !== this.source || hosted.render !== this.renderVideo) return false;
248 hosted.preview.update({ source: this.source, url: this.url, alt: this.alt });
249 return true;
250 }
251 if (this.renderVideo) return false;
252 if (!(dom instanceof HTMLVideoElement) || dom.dataset["source"] !== this.source) return false;
253 dom.setAttribute("aria-label", this.alt || "Video preview");
254 if (dom.getAttribute("src") === this.url) {
255 delete dom.dataset["pendingSrc"];
256 } else if (dom.paused) {
257 delete dom.dataset["pendingSrc"];
258 replaceVideoSource(dom, this.url);
259 } else {
260 dom.dataset["pendingSrc"] = this.url;
261 }
262 return true;
263 }
264
265 override destroy(dom: HTMLElement): void {
266 const hosted = hostedVideos.get(dom);
267 if (hosted) {
268 hostedVideos.delete(dom);
269 hosted.preview.destroy();
270 return;
271 }
272 if (!(dom instanceof HTMLVideoElement)) return;
273 delete dom.dataset["pendingSrc"];
274 dom.pause();
275 }
276
277 override ignoreEvent(): boolean {
278 return true;
279 }
280}
281
282/** A thematic break replacing an unrevealed `---` line. */
283export class HrWidget extends WidgetType {
284 override eq(): boolean {
285 return true;
286 }
287
288 override toDOM(): HTMLElement {
289 const rule = document.createElement("span");
290 rule.className = "cm-lp-hr";
291 return rule;
292 }
293
294 override ignoreEvent(event: Event): boolean {
295 return event.type !== "mousedown";
296 }
297}
298
299/** The collapsed placeholder for an unrevealed HTML comment; the caret entering expands it. */
300export class CommentWidget extends WidgetType {
301 override eq(): boolean {
302 return true;
303 }
304
305 override toDOM(): HTMLElement {
306 const marker = document.createElement("span");
307 marker.className = "cm-lp-comment";
308 marker.textContent = "<!-- ⋯ -->";
309 return marker;
310 }
311
312 override ignoreEvent(event: Event): boolean {
313 return event.type !== "mousedown";
314 }
315}
316
317/** A hard line break replacing an unrevealed inline `<br>`. */
318export class BrWidget extends WidgetType {
319 override eq(): boolean {
320 return true;
321 }
322
323 override toDOM(): HTMLElement {
324 const span = document.createElement("span");
325 span.className = "cm-lp-br";
326 span.appendChild(document.createElement("br"));
327 return span;
328 }
329}
330
331/**
332 * A sanitized-HTML rendering of a source slice — the only widget that renders document-authored
333 * markup, and only ever through `sanitizeHtml`. Used for balanced HTML blocks and for
334 * self-contained inline runs (`<img>`, `<picture>…</picture>`, `<a><img></a>`).
335 */
336export class HtmlWidget extends WidgetType {
337 readonly source: string;
338 readonly inline: boolean;
339 readonly centered: boolean;
340 readonly options: HtmlOptions;
341
342 constructor(source: string, inline: boolean, centered: boolean, options: HtmlOptions) {
343 super();
344 this.source = source;
345 this.inline = inline;
346 this.centered = centered;
347 this.options = options;
348 }
349
350 override eq(other: HtmlWidget): boolean {
351 return (
352 other.source === this.source &&
353 other.inline === this.inline &&
354 other.centered === this.centered
355 );
356 }
357
358 override toDOM(): HTMLElement {
359 const frame = document.createElement(this.inline ? "span" : "div");
360 frame.className = this.centered ? "cm-lp-html cm-lp-centered" : "cm-lp-html";
361 const clean = sanitizeHtml(this.source, this.options);
362 if (clean) frame.append(...clean.childNodes);
363 // Nothing survived sanitization (e.g. a script block): render empty; the source is still
364 // reachable — the caret entering the range reveals it.
365 else frame.classList.add("cm-lp-html-empty");
366 return frame;
367 }
368
369 // Interactive descendants (links, details/summary) keep their events; anywhere else a mousedown
370 // goes to CodeMirror so the caret lands in the source and reveals it.
371 override ignoreEvent(event: Event): boolean {
372 const target = event.target as Element | null;
373 if (target?.closest("a, summary, details")) return true;
374 return event.type !== "mousedown";
375 }
376}
377
378export interface TableData {
379 /** Per-column alignment from the delimiter row: "left" | "center" | "right" | "". */
380 align: string[];
381 header: string[];
382 rows: string[][];
383}
384
385/** A GFM table rendered as a real table while the caret is outside the source block. */
386export class TableWidget extends WidgetType {
387 readonly data: TableData;
388 readonly centered: boolean;
389 readonly key: string;
390
391 constructor(data: TableData, centered = false) {
392 super();
393 this.data = data;
394 this.centered = centered;
395 this.key = `${centered}|${JSON.stringify(data)}`;
396 }
397
398 override eq(other: TableWidget): boolean {
399 return other.key === this.key;
400 }
401
402 override toDOM(): HTMLElement {
403 const table = document.createElement("table");
404 table.className = this.centered ? "cm-lp-table cm-lp-centered" : "cm-lp-table";
405 const head = table.createTHead().insertRow();
406 this.data.header.forEach((cell, column) => {
407 const th = document.createElement("th");
408 th.textContent = cell;
409 const align = this.data.align[column];
410 if (align) th.style.textAlign = align;
411 head.append(th);
412 });
413 const body = table.createTBody();
414 for (const row of this.data.rows) {
415 const tr = body.insertRow();
416 this.data.header.forEach((_, column) => {
417 const td = tr.insertCell();
418 td.textContent = row[column] ?? "";
419 const align = this.data.align[column];
420 if (align) td.style.textAlign = align;
421 });
422 }
423 return table;
424 }
425
426 // Let CodeMirror handle clicks so clicking the rendered table opens its source for editing.
427 override ignoreEvent(event: Event): boolean {
428 return event.type !== "mousedown";
429 }
430}
packages/markdown-editor/test/dom.ts created+9
......@@ -0,0 +1,9 @@
1import { beforeEach } from "vite-plus/test";
2
3beforeEach(() => {
4 Object.defineProperty(Range.prototype, "getClientRects", { configurable: true, value: () => [] });
5 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
6 configurable: true,
7 value: () => new DOMRect(),
8 });
9});
packages/markdown-editor/test/editor.test.ts created+254
......@@ -0,0 +1,254 @@
1// @vitest-environment jsdom
2
3import "./dom";
4// The composer factory end to end: real EditorView in happy-dom, keys delivered as KeyboardEvents
5// on contentDOM so the keymap stack (submit precedence, code-block Enter, picker claims) is what
6// runs — not the commands called directly.
7import { EditorSelection } from "@codemirror/state";
8import { syntaxTree } from "@codemirror/language";
9import { EditorView } from "@codemirror/view";
10import { afterEach, describe, expect, it, vi } from "vite-plus/test";
11
12import { createMarkdownEditor, type MarkdownEditorOptions } from "../src/editor.ts";
13
14let destroy: (() => void) | undefined;
15afterEach(() => {
16 destroy?.();
17 destroy = undefined;
18});
19
20function mount(opts: MarkdownEditorOptions = {}) {
21 const editor = createMarkdownEditor(opts);
22 document.body.append(editor.dom);
23 destroy = () => {
24 editor.destroy();
25 editor.dom.remove();
26 };
27 return editor;
28}
29
30function press(view: EditorView, key: string, init: KeyboardEventInit = {}): void {
31 view.contentDOM.dispatchEvent(
32 new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }),
33 );
34}
35
36describe("submit keymap", () => {
37 it("Enter submits; Shift-Enter inserts a newline instead", () => {
38 const onSubmit = vi.fn();
39 const editor = mount({ onSubmit });
40 editor.setValue("ship it");
41 press(editor.view, "Enter");
42 expect(onSubmit).toHaveBeenCalledTimes(1);
43 press(editor.view, "Enter", { shiftKey: true });
44 expect(onSubmit).toHaveBeenCalledTimes(1);
45 expect(editor.getValue()).toContain("\n");
46 });
47
48 it("Enter inside an unclosed fence scaffolds instead of submitting", () => {
49 const onSubmit = vi.fn();
50 const editor = mount({ onSubmit });
51 editor.setValue("```ts");
52 editor.view.dispatch({ selection: EditorSelection.single(5) });
53 press(editor.view, "Enter");
54 expect(onSubmit).not.toHaveBeenCalled();
55 expect(editor.getValue()).toBe("```ts\n\n```");
56 });
57
58 it("an open picker claims Enter before submit", () => {
59 const onSubmit = vi.fn();
60 const onAccept = vi.fn(() => true);
61 const editor = mount({ onSubmit, onAccept });
62 editor.setValue("hi");
63 press(editor.view, "Enter");
64 expect(onAccept).toHaveBeenCalled();
65 expect(onSubmit).not.toHaveBeenCalled();
66 });
67
68 it("without onSubmit, Enter is a plain newline (document use)", () => {
69 const editor = mount();
70 editor.setValue("line");
71 editor.view.dispatch({ selection: EditorSelection.single(4) });
72 press(editor.view, "Enter");
73 expect(editor.getValue()).toBe("line\n");
74 });
75});
76
77describe("value round-trip", () => {
78 it.each(["-", "--", "=", "=="])(
79 "retains short %s underline headings in the document preset",
80 (marker) => {
81 const doc = `Document heading\n${marker}`;
82 const editor = mount({ preset: "document", doc });
83 const heading = marker[0] === "=" ? "SetextHeading1" : "SetextHeading2";
84 expect(syntaxTree(editor.view.state).topNode.getChild(heading)).not.toBeNull();
85 expect(editor.getValue()).toBe(doc);
86 },
87 );
88
89 it.each(["Title\n---", "Title\n --- \t", "> Title\n> ---", "- Title\n ---", "Title\n--\n---"])(
90 "retains deliberate composer underline headings in %j",
91 (doc) => {
92 const editor = mount({ doc });
93 expect(syntaxTree(editor.view.state).toString()).toContain("SetextHeading2");
94 expect(syntaxTree(editor.view.state).toString()).not.toContain("HorizontalRule");
95 expect(editor.getValue()).toBe(doc);
96 },
97 );
98
99 it("setValue hydrates mention tokens and getValue serializes them back", () => {
100 const editor = mount();
101 editor.setValue("see @src/app.ts:3-5 ok");
102 expect(editor.view.state.doc.toString()).toMatch(/⟦mention:\d+⟧/);
103 expect(editor.getValue()).toBe("see @src/app.ts:3-5 ok");
104 });
105
106 it("insertMention replaces the @query and serializes to the token", () => {
107 const editor = mount();
108 editor.setValue("see @ap");
109 editor.insertMention(
110 { kind: "file", label: "app.ts", token: "src/app.ts" },
111 { from: 4, to: 7 },
112 );
113 expect(editor.getValue().trim()).toBe("see @src/app.ts");
114 });
115
116 it("onChange reports serialized text on every edit", () => {
117 const changes: string[] = [];
118 const editor = mount({ onChange: (value) => changes.push(value) });
119 editor.setValue("see @src/app.ts");
120 editor.view.dispatch({
121 selection: EditorSelection.single(editor.view.state.doc.length),
122 });
123 editor.insertText("now");
124 expect(changes.at(-1)).toBe("see @src/app.ts now");
125 });
126});
127
128describe.each(["composer", "document"] as const)("%s blockquotes", (preset) => {
129 it.each([
130 ["> quoted\nnormal", [true, false]],
131 ["> first\n> second\nnormal", [true, true, false]],
132 ["> quoted\n indented", [true, false]],
133 ["> quoted\n\tindented", [true, false]],
134 ["> outer\n>> inner\n> outer again\nnormal", [true, true, true, false]],
135 ["- > quoted\n normal\n- sibling", [true, false, false]],
136 ["> [!NOTE]\n> details\nnormal", [true, true, false]],
137 ["> first\n>\n> second\nnormal", [true, true, true, false]],
138 ["> ```\n> code\n> ```\nnormal", [true, true, true, false]],
139 ])("requires an explicit quote marker in %j", (doc, quoted) => {
140 const editor = mount({ preset, doc, mentions: false });
141
142 expect(
143 [...editor.dom.querySelectorAll(".cm-line")].map((line) =>
144 line.classList.contains("cm-lp-quote"),
145 ),
146 ).toEqual(quoted);
147 expect(editor.getValue()).toBe(doc);
148 expect(syntaxTree(editor.view.state).resolveInner(doc.length, -1).parent?.name).not.toBe(
149 "Blockquote",
150 );
151 });
152
153 it("reparses quote boundaries when a marker is added or removed", () => {
154 const editor = mount({ preset, doc: "> first\nsecond" });
155 const secondLine = editor.view.state.doc.line(2).from;
156
157 editor.view.dispatch({ changes: { from: secondLine, insert: "> " } });
158 expect(editor.dom.querySelectorAll(".cm-lp-quote")).toHaveLength(2);
159
160 editor.view.dispatch({ changes: { from: secondLine, to: secondLine + 2 } });
161 expect(editor.dom.querySelectorAll(".cm-lp-quote")).toHaveLength(1);
162 expect(editor.getValue()).toBe("> first\nsecond");
163 });
164
165 it("finishes quoted link definitions before unmarked text", () => {
166 const doc = '> [example]: https://example.test "Title"\nnormal';
167 const editor = mount({ preset, doc });
168 const root = syntaxTree(editor.view.state).topNode;
169
170 expect(root.getChild("Blockquote")?.getChild("LinkReference")).not.toBeNull();
171 const paragraph = root.getChild("Paragraph")!;
172 expect(editor.view.state.sliceDoc(paragraph.from, paragraph.to)).toBe("normal");
173 });
174
175 it("keeps quoted tables while excluding an unmarked row", () => {
176 const doc = "> | A | B |\n> | - | - |\n> | 1 | 2 |\n| 3 | 4 |";
177 const editor = mount({ preset, doc });
178 const root = syntaxTree(editor.view.state).topNode;
179
180 expect(root.getChild("Blockquote")?.getChild("Table")).not.toBeNull();
181 const paragraph = root.getChild("Paragraph")!;
182 expect(editor.view.state.sliceDoc(paragraph.from, paragraph.to)).toBe("| 3 | 4 |");
183 });
184
185 it("keeps inline formatting from crossing the end of a quote", () => {
186 const editor = mount({ preset, doc: "> **quoted\nnormal**" });
187
188 expect(editor.dom.querySelector(".cm-lp-strong")).toBeNull();
189 expect(syntaxTree(editor.view.state).topNode.getChild("Paragraph")?.from).toBe(11);
190 });
191
192 it("continues explicit quotes with Enter and exits an empty quote", () => {
193 const editor = mount({ preset, doc: "> quoted" });
194 editor.view.dispatch({ selection: EditorSelection.single(editor.view.state.doc.length) });
195 const modifiers = preset === "composer" ? { shiftKey: true } : {};
196
197 press(editor.view, "Enter", modifiers);
198 expect(editor.getValue()).toBe("> quoted\n> ");
199
200 press(editor.view, "Enter", modifiers);
201 editor.insertText("normal");
202 expect(editor.getValue()).toBe("> quoted\nnormal");
203 expect(editor.dom.querySelectorAll(".cm-lp-quote")).toHaveLength(1);
204 });
205});
206
207describe("trigger hooks", () => {
208 it("fires onMention/onSlash/onEmoji as the caret enters and leaves runs", () => {
209 const mentions: unknown[] = [];
210 const slashes: unknown[] = [];
211 const editor = mount({
212 onMention: (t) => mentions.push(t),
213 onSlash: (t) => slashes.push(t),
214 });
215 // setValue leaves the caret at 0; a trigger needs the caret at the end of its run.
216 editor.setValue("@ap");
217 editor.view.dispatch({ selection: EditorSelection.single(3) });
218 expect(mentions.at(-1)).toEqual({ from: 0, to: 3, query: "ap" });
219 editor.setValue("/go");
220 editor.view.dispatch({ selection: EditorSelection.single(3) });
221 expect(slashes.at(-1)).toEqual({ from: 0, to: 3, query: "go" });
222 editor.setValue("plain");
223 editor.view.dispatch({ selection: EditorSelection.single(5) });
224 expect(mentions.at(-1)).toBeNull();
225 expect(slashes.at(-1)).toBeNull();
226 });
227});
228
229describe("consumer extension seam", () => {
230 it("mounts consumer extensions into the same view", () => {
231 const editor = mount({
232 extensions: [EditorView.editorAttributes.of({ class: "consumer-marker" })],
233 });
234 expect(editor.dom.className).toContain("consumer-marker");
235 });
236
237 it("placeholder renders only when configured", () => {
238 const editor = mount({ placeholder: "Say something" });
239 expect(editor.dom.textContent).toContain("Say something");
240 const bare = createMarkdownEditor();
241 expect(bare.dom.querySelector(".cm-placeholder")).toBeNull();
242 bare.destroy();
243 });
244});
245
246describe("host-owned composer layers", () => {
247 it("preserves raw source instead of hydrating mentions when the host owns chips", () => {
248 const editor = mount({ mentions: false, preview: false, doc: "see @src/app.ts now" });
249 expect(editor.view.state.doc.toString()).toBe("see @src/app.ts now");
250 editor.setValue("next @src/other.ts now");
251 expect(editor.view.state.doc.toString()).toBe("next @src/other.ts now");
252 expect(editor.dom.querySelector(".chip")).toBeNull();
253 });
254});
packages/markdown-editor/test/html.test.ts created+211
......@@ -0,0 +1,211 @@
1// @vitest-environment jsdom
2
3import "./dom";
4// The sanitizer (DOM-bound: DOMParser) and the HTML live preview through a real document-preset
5// view. The security assertions here are the contract: nothing outside the allowlist may ever
6// reach the rendered DOM.
7import { EditorSelection } from "@codemirror/state";
8import { afterEach, describe, expect, it } from "vite-plus/test";
9
10import { createMarkdownEditor, type MarkdownEditor } from "../src/editor.ts";
11import { sanitizeHtml } from "../src/html.ts";
12
13const IMG = "data:image/svg+xml;base64,PHN2Zy8+";
14
15describe("sanitizeHtml", () => {
16 it("drops script/style/iframe with their content", () => {
17 expect(sanitizeHtml("<script>alert(1)</script>")).toBeNull();
18 expect(sanitizeHtml("<style>*{}</style><iframe src='x'></iframe>")).toBeNull();
19 });
20
21 it("strips event handlers and unknown attributes", () => {
22 const clean = sanitizeHtml(`<img src="${IMG}" onerror="alert(1)" data-x="1">`)!;
23 const img = clean.querySelector("img")!;
24 expect(img.getAttribute("onerror")).toBeNull();
25 expect(img.getAttribute("data-x")).toBeNull();
26 expect(img.getAttribute("src")).toBe(IMG);
27 });
28
29 it("rejects javascript: links and keeps https ones hardened", () => {
30 const clean = sanitizeHtml('<a href="javascript:alert(1)">x</a><a href="https://ok">y</a>')!;
31 const links = clean.querySelectorAll("a");
32 expect(links[0]?.getAttribute("href")).toBeNull();
33 expect(links[1]?.getAttribute("href")).toBe("https://ok");
34 expect(links[1]?.getAttribute("target")).toBe("_blank");
35 expect(links[1]?.getAttribute("rel")).toBe("noopener noreferrer");
36 });
37
38 it("rejects non-image data: urls", () => {
39 const clean = sanitizeHtml('<img src="data:text/html;base64,PHNjcmlwdC8+">')!;
40 expect(clean.querySelector("img")?.getAttribute("src")).toBeNull();
41 });
42
43 it("unwraps harmless unknown tags, keeping their children", () => {
44 const clean = sanitizeHtml("<blink>keep <b>me</b></blink>")!;
45 expect(clean.querySelector("blink")).toBeNull();
46 expect(clean.textContent).toBe("keep me");
47 expect(clean.querySelector("b")?.textContent).toBe("me");
48 });
49
50 it("routes relative srcs through resolveUrl and filters srcset entries", () => {
51 const clean = sanitizeHtml(
52 `<img src="./logo.png" srcset="./logo.png 1x, javascript:x 2x, ${IMG} 3x">`,
53 { resolveUrl: (url) => `resolved:${url}` },
54 )!;
55 const img = clean.querySelector("img")!;
56 expect(img.getAttribute("src")).toBe("resolved:./logo.png");
57 expect(img.getAttribute("srcset")).toBe(`resolved:./logo.png 1x, ${IMG} 3x`);
58 });
59
60 it("keeps picture/source media queries", () => {
61 const clean = sanitizeHtml(
62 `<picture><source media="(prefers-color-scheme: dark)" srcset="${IMG}"><img src="${IMG}"></picture>`,
63 )!;
64 expect(clean.querySelector("source")?.getAttribute("media")).toBe(
65 "(prefers-color-scheme: dark)",
66 );
67 });
68});
69
70let destroy: (() => void) | undefined;
71afterEach(() => {
72 destroy?.();
73 destroy = undefined;
74});
75
76function mount(doc: string, head = 0): MarkdownEditor {
77 const editor = createMarkdownEditor({ preset: "document", doc });
78 document.body.append(editor.dom);
79 editor.view.contentDOM.dispatchEvent(new FocusEvent("focus"));
80 editor.view.dispatch({ selection: EditorSelection.single(head) });
81 destroy = () => {
82 editor.destroy();
83 editor.dom.remove();
84 };
85 return editor;
86}
87
88describe("html blocks in the document preset", () => {
89 it("renders a balanced block sanitized, away from the caret", () => {
90 const doc = `<p align="center"><img src="${IMG}" alt="logo" width="120"></p>\n\nafter`;
91 const editor = mount(doc, doc.length);
92 const html = editor.view.dom.querySelector(".cm-lp-html");
93 expect(html?.querySelector("img")?.getAttribute("width")).toBe("120");
94 expect(editor.view.dom.textContent).not.toContain("<p");
95 });
96
97 it("shows raw html source when the caret enters the block", () => {
98 const doc = `<p align="center"><img src="${IMG}"></p>\n\nafter`;
99 const editor = mount(doc, 3);
100 expect(editor.view.dom.querySelector(".cm-lp-html")).toBeNull();
101 expect(editor.view.dom.querySelectorAll(".cm-lp-html-src").length).toBe(1);
102 expect(editor.view.dom.textContent).toContain('<p align="center">');
103 });
104
105 it("never renders script content, even though the block previews", () => {
106 const doc = "<script>alert(1)</script>\n\nafter";
107 const editor = mount(doc, doc.length);
108 expect(editor.view.dom.textContent).not.toContain("alert");
109 expect(editor.view.dom.querySelector("script")).toBeNull();
110 });
111
112 it("absorbs a split centering wrapper: tags hidden, content centered", () => {
113 const doc = `<div align="center">\n\n# Title\n\nsome badges\n\n</div>\n\nafter`;
114 const editor = mount(doc, doc.length);
115 expect(editor.view.dom.textContent).not.toContain("<div");
116 expect(editor.view.dom.textContent).not.toContain("</div>");
117 const centered = editor.view.dom.querySelectorAll(".cm-lp-center");
118 expect(centered.length).toBeGreaterThanOrEqual(2);
119 expect(editor.view.dom.querySelector(".cm-lp-h1.cm-lp-center")).not.toBeNull();
120 });
121
122 it("reveals a wrapper tag as source when the caret reaches its line", () => {
123 const doc = `<div align="center">\n\ncontent\n\n</div>\n\nafter`;
124 const editor = mount(doc, 4);
125 expect(editor.view.dom.textContent).toContain('<div align="center">');
126 expect(editor.view.dom.querySelector(".cm-lp-html-src")).not.toBeNull();
127 });
128
129 it("leaves an unmatched wrapper open visible as source", () => {
130 const doc = `<div align="center">\n\ncontent with no close\n\nafter`;
131 const editor = mount(doc, doc.length);
132 expect(editor.view.dom.textContent).toContain('<div align="center">');
133 });
134
135 it("collapses comment blocks to a placeholder that expands on caret entry", () => {
136 const doc = "<!-- secret plan -->\n\nafter";
137 const away = mount(doc, doc.length);
138 expect(away.view.dom.textContent).not.toContain("secret plan");
139 expect(away.view.dom.querySelector(".cm-lp-comment")?.textContent).toBe("<!-- ⋯ -->");
140 destroy?.();
141 destroy = undefined;
142 const inside = mount(doc, 4);
143 expect(inside.view.dom.textContent).toContain("secret plan");
144 expect(inside.view.dom.querySelector(".cm-lp-comment")).toBeNull();
145 });
146
147 it("reveals both halves of an absorbed wrapper together, with token highlighting", () => {
148 const doc = `<div align="center">\n\ncontent\n\n</div>\n\nafter`;
149 const editor = mount(doc, 4); // caret inside the opening <div>
150 expect(editor.view.dom.textContent).toContain('<div align="center">');
151 expect(editor.view.dom.textContent).toContain("</div>");
152 const tags = [...editor.view.dom.querySelectorAll(".cm-html-tok-tag")];
153 expect(tags.map((tag) => tag.textContent)).toEqual(["div", "div"]);
154 expect(editor.view.dom.querySelector(".cm-html-tok-attr")?.textContent).toBe("align");
155 expect(editor.view.dom.querySelector(".cm-html-tok-value")?.textContent).toBe('"center"');
156 });
157
158 it("reveals the opener from the closer too", () => {
159 const doc = `<div align="center">\n\ncontent\n\n</div>\n\nafter`;
160 const editor = mount(doc, doc.indexOf("</div>") + 2);
161 expect(editor.view.dom.textContent).toContain('<div align="center">');
162 expect(editor.view.dom.textContent).toContain("</div>");
163 });
164});
165
166describe("inline html in the document preset", () => {
167 it("styles paired tags and hides them away from the caret", () => {
168 const doc = "press <kbd>Cmd</kbd> and <sub>low</sub>\n\nafter";
169 const editor = mount(doc, doc.length);
170 expect(editor.view.dom.querySelector(".cm-html-kbd")?.textContent).toBe("Cmd");
171 expect(editor.view.dom.querySelector(".cm-html-sub")?.textContent).toBe("low");
172 expect(editor.view.dom.textContent).not.toContain("<kbd>");
173 });
174
175 it("reveals paired tags as muted source when the caret touches the pair", () => {
176 const editor = mount("press <kbd>Cmd</kbd> now\n\nafter", 8);
177 expect(editor.view.dom.textContent).toContain("<kbd>");
178 expect(editor.view.dom.querySelectorAll(".cm-lp-html-inline").length).toBe(2);
179 });
180
181 it("renders <br> as a line break widget", () => {
182 const doc = "a<br>b\n\nafter";
183 const editor = mount(doc, doc.length);
184 expect(editor.view.dom.querySelector(".cm-lp-br br")).not.toBeNull();
185 expect(editor.view.dom.textContent).not.toContain("<br>");
186 });
187
188 it("renders an inline <img> and a badge-style <a><img></a> as sanitized widgets", () => {
189 const doc = `logo <img src="${IMG}"> and <a href="https://x"><img src="${IMG}"></a>\n\nafter`;
190 const editor = mount(doc, doc.length);
191 const widgets = editor.view.dom.querySelectorAll(".cm-lp-html");
192 expect(widgets.length).toBe(2);
193 expect(widgets[1]?.querySelector("a[target='_blank'] img")).not.toBeNull();
194 });
195
196 it("leaves unknown or unpaired tags visible as muted source", () => {
197 const doc = "a <blink>x</blink> and <b>unclosed\n\nafter";
198 const editor = mount(doc, doc.length);
199 expect(editor.view.dom.textContent).toContain("<blink>");
200 expect(editor.view.dom.textContent).toContain("<b>");
201 });
202
203 it("keeps the composer preset html-free (raw text, no widgets)", () => {
204 const composer = createMarkdownEditor({ doc: `x <img src="${IMG}"> <b>hi</b>` });
205 document.body.append(composer.dom);
206 expect(composer.dom.querySelector(".cm-lp-html")).toBeNull();
207 expect(composer.dom.textContent).toContain("<b>hi</b>");
208 composer.destroy();
209 composer.dom.remove();
210 });
211});
packages/markdown-editor/test/live-preview.test.ts created+123
......@@ -0,0 +1,123 @@
1// @vitest-environment jsdom
2
3import "./dom";
4// The live-preview decoration layer, asserted through a real EditorView so the StateField,
5// provide-wiring, and class names are all exercised — the piece the kiln properties cannot reach.
6
7import { javascriptLanguage } from "@codemirror/lang-javascript";
8import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
9import { EditorSelection, EditorState } from "@codemirror/state";
10import { afterEach, describe, expect, it } from "vite-plus/test";
11
12import { createMarkdownEditor } from "../src/editor.ts";
13import { formattingDecorations } from "../src/live-preview.ts";
14
15let cleanup: (() => void) | undefined;
16afterEach(() => {
17 cleanup?.();
18 cleanup = undefined;
19});
20
21function mount(doc: string, head = 0) {
22 const editor = createMarkdownEditor({ doc });
23 document.body.append(editor.dom);
24 editor.view.contentDOM.dispatchEvent(new FocusEvent("focus"));
25 editor.view.dispatch({ selection: EditorSelection.single(head) });
26 cleanup = () => editor.destroy();
27 return editor;
28}
29
30describe("live preview in the view", () => {
31 it("hides emphasis marks away from the selection and styles the content", () => {
32 const editor = mount("plain **bold** tail", 0);
33 const strong = editor.view.dom.querySelector(".cm-lp-strong");
34 expect(strong?.textContent).toBe("bold");
35 expect(editor.view.dom.textContent).not.toContain("**");
36 });
37
38 it("reveals muted marks when the caret touches the node", () => {
39 const editor = mount("plain **bold** tail", 9);
40 expect(editor.view.dom.textContent).toContain("**bold**");
41 expect(editor.view.dom.querySelector(".cm-lp-mark")).not.toBeNull();
42 });
43
44 it("boxes fenced code line by line with rounded first and last lines", () => {
45 const editor = mount("```ts\ncode\n```\n", 0);
46 const lines = editor.view.dom.querySelectorAll(".cm-lp-codeblock");
47 expect(lines.length).toBe(3);
48 expect(lines[0]?.className).toContain("cm-lp-codeblock-top");
49 expect(lines[lines.length - 1]?.className).toContain("cm-lp-codeblock-bottom");
50 expect(editor.view.dom.querySelector(".cm-lp-code-lang")?.textContent).toBe("ts");
51 });
52
53 it("uses a consumer-supplied parser to highlight fenced code", () => {
54 const editor = createMarkdownEditor({
55 doc: "```ts\nexport const answer = 42\n```",
56 codeLanguages: (info: string) => (info === "ts" ? javascriptLanguage : null),
57 });
58 document.body.append(editor.dom);
59 cleanup = () => editor.destroy();
60 const token = [...editor.dom.querySelectorAll(".cm-line span")].find(
61 (element) => element.textContent === "export",
62 );
63 expect(token?.className).not.toBe("");
64 });
65});
66
67describe("formattingDecorations bounds", () => {
68 it("never emits a range outside the document", () => {
69 const docs = ["", "*a*", "``", "**x** `y` ~~z~~", "```\nunclosed", "- [ ] task *i*"];
70 for (const doc of docs) {
71 const state = EditorState.create({
72 doc,
73 extensions: [markdown({ base: markdownLanguage })],
74 });
75 for (const range of formattingDecorations(state)) {
76 expect(range.from).toBeGreaterThanOrEqual(0);
77 expect(range.to).toBeLessThanOrEqual(doc.length);
78 }
79 }
80 });
81});
82
83describe("html stays inert", () => {
84 it("keeps HTML out of the grammar (no tag nodes, rendered literally)", () => {
85 const editor = mount("<div>hi</div>", 0);
86 expect(editor.view.dom.textContent).toContain("<div>hi</div>");
87 });
88});
89
90// Sanity for the vitest/happy-dom substrate itself: a view mounts and measures without throwing.
91describe("view smoke", () => {
92 it("mounts, edits, and destroys", () => {
93 const editor = mount("hello", 0);
94 editor.view.dispatch({ changes: { from: 5, insert: " world" } });
95 expect(editor.getValue()).toBe("hello world");
96 });
97});
98
99it("leaves host-owned atomic source ranges untouched by link preview", () => {
100 const value = "before [file.ts](src/file.ts) after";
101 const state = EditorState.create({
102 doc: value,
103 extensions: [markdown({ base: markdownLanguage })],
104 });
105 const from = value.indexOf("[");
106 const to = value.indexOf(" after");
107 const ranges = formattingDecorations(state, { exclude: () => [{ from, to }] });
108 expect(ranges.some((range) => range.from >= from && range.to <= to)).toBe(false);
109 expect(state.doc.toString()).toBe(value);
110});
111
112it("renders readonly tasks as disabled controls and never rewrites their source", () => {
113 const editor = createMarkdownEditor({
114 doc: "- [ ] task",
115 extensions: [EditorState.readOnly.of(true)],
116 });
117 document.body.append(editor.dom);
118 cleanup = () => editor.destroy();
119 const checkbox = editor.dom.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
120 expect(checkbox.disabled).toBe(true);
121 checkbox.dispatchEvent(new Event("change", { bubbles: true }));
122 expect(editor.getValue()).toBe("- [ ] task");
123});
packages/markdown-editor/test/media.test.ts created+153
......@@ -0,0 +1,153 @@
1// @vitest-environment jsdom
2import "./dom";
3
4import { Compartment, EditorSelection } from "@codemirror/state";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import { createMarkdownEditor, type MarkdownEditor } from "../src/editor.ts";
8import { livePreview } from "../src/live-preview.ts";
9import type { VideoPreviewInput, VideoPreviewRenderer } from "../src/widgets.ts";
10
11let editor: MarkdownEditor | undefined;
12
13beforeEach(() => {
14 vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
15});
16
17afterEach(() => {
18 editor?.destroy();
19 editor?.dom.remove();
20 editor = undefined;
21 vi.restoreAllMocks();
22});
23
24function mount(source: string) {
25 editor = createMarkdownEditor({ preset: "document", doc: source });
26 document.body.append(editor.dom);
27 return editor;
28}
29
30describe("document video embeds", () => {
31 it.each(["./clip.mp4", "https://media.example/CLIP.WEBM?token=abc#t=2", "<./my clip.mov>"])(
32 "plays %s through native controls without editing the source",
33 (url) => {
34 const source = `![Demonstration](${url})`;
35 const instance = mount(source);
36 const video = instance.dom.querySelector("video")!;
37 expect(video).not.toBeNull();
38 expect(instance.dom.querySelector(".cm-lp-image img")).toBeNull();
39 expect(video.controls).toBe(true);
40 expect(video.autoplay).toBe(false);
41 expect(video.getAttribute("aria-label")).toBe("Demonstration");
42 video.focus();
43 expect(document.activeElement).toBe(video);
44 const key = new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true });
45 video.dispatchEvent(key);
46 expect(key.defaultPrevented).toBe(false);
47 expect(instance.getValue()).toBe(source);
48 expect(instance.dom.querySelector("video")).toBe(video);
49 },
50 );
51
52 it("recognizes the authored path before host signing and preserves playback through URL refreshes", () => {
53 const presentation = new Compartment();
54 const options = (url: string) => livePreview({ blockWidgets: true, resolveUrl: () => url });
55 editor = createMarkdownEditor({
56 preset: "document",
57 preview: false,
58 doc: "![Clip](clip.mp4)",
59 extensions: [presentation.of(options("https://host.example/asset?token=first"))],
60 });
61 document.body.append(editor.dom);
62 const video = editor.dom.querySelector("video")!;
63 let paused = false;
64 Object.defineProperty(video, "paused", { configurable: true, get: () => paused });
65 Object.defineProperty(video, "duration", { configurable: true, value: 100 });
66 video.currentTime = 12;
67
68 editor.view.dispatch({
69 effects: presentation.reconfigure(options("https://host.example/asset?token=second")),
70 });
71 expect(editor.dom.querySelector("video")).toBe(video);
72 expect(video.src).toBe("https://host.example/asset?token=first");
73 expect(video.currentTime).toBe(12);
74 expect(video.pause).not.toHaveBeenCalled();
75
76 paused = true;
77 video.dispatchEvent(new Event("pause"));
78 expect(video.src).toBe("https://host.example/asset?token=second");
79 video.currentTime = 0;
80 video.dispatchEvent(new Event("loadedmetadata"));
81 expect(video.currentTime).toBe(12);
82 expect(video.autoplay).toBe(false);
83
84 paused = false;
85 editor.view.dispatch({
86 effects: presentation.reconfigure(options("https://host.example/asset?token=third")),
87 });
88 expect(video.src).toBe("https://host.example/asset?token=second");
89 video.dispatchEvent(new Event("error"));
90 expect(video.src).toBe("https://host.example/asset?token=third");
91 expect(editor.dom.querySelector("video")).toBe(video);
92 });
93
94 it("stops playback when editing reveals the video source", () => {
95 const instance = mount("![Clip](clip.mp4)\n\nOther text");
96 const video = instance.dom.querySelector("video")!;
97 instance.view.contentDOM.dispatchEvent(new FocusEvent("focus"));
98 instance.view.dispatch({ selection: EditorSelection.single(3) });
99 expect(instance.dom.querySelector("video")).toBeNull();
100 expect(instance.dom.textContent).toContain("![Clip](clip.mp4)");
101 expect(video.pause).toHaveBeenCalledOnce();
102 });
103
104 it("does not mistake a video extension inside an image query for a video path", () => {
105 const instance = mount("![Figure](figure.png?label=clip.mp4)");
106 expect(instance.dom.querySelector("video")).toBeNull();
107 expect(instance.dom.querySelector(".cm-lp-image img")).not.toBeNull();
108 });
109
110 it("lets the host retain its player through signed URL refreshes and dispose it when removed", () => {
111 const presentation = new Compartment();
112 const update = vi.fn();
113 const destroy = vi.fn();
114 const inputs: VideoPreviewInput[] = [];
115 const renderVideo: VideoPreviewRenderer = (input) => {
116 inputs.push(input);
117 const dom = document.createElement("span");
118 dom.textContent = "Hosted player";
119 return { dom, update, destroy };
120 };
121 const options = (token: string) =>
122 livePreview({
123 blockWidgets: true,
124 renderVideo,
125 resolveUrl: () => `https://host.example/clip?token=${token}`,
126 });
127 editor = createMarkdownEditor({
128 preset: "document",
129 preview: false,
130 doc: "![Clip](clip.mp4)",
131 extensions: [presentation.of(options("first"))],
132 });
133 document.body.append(editor.dom);
134 const hosted = Array.from(editor.dom.querySelectorAll("span")).find(
135 (element) => element.textContent === "Hosted player",
136 )!;
137 expect(inputs).toEqual([
138 { source: "clip.mp4", url: "https://host.example/clip?token=first", alt: "Clip" },
139 ]);
140 expect(editor.dom.querySelector("video")).toBeNull();
141 editor.view.dispatch({ effects: presentation.reconfigure(options("second")) });
142 expect(hosted.isConnected).toBe(true);
143 expect(inputs).toHaveLength(1);
144 expect(update).toHaveBeenCalledWith({
145 source: "clip.mp4",
146 url: "https://host.example/clip?token=second",
147 alt: "Clip",
148 });
149 expect(destroy).not.toHaveBeenCalled();
150 editor.setValue("Video removed");
151 expect(destroy).toHaveBeenCalledOnce();
152 });
153});
packages/markdown-editor/test/mentions.test.ts created+141
......@@ -0,0 +1,141 @@
1// @vitest-environment jsdom
2
3import "./dom";
4// The DOM-bound half of the mention machinery: widget rendering, atomic chips in a live view, and
5// the clipboard filters. The pure serialization properties live in mentions.test.ts under kiln.
6import { EditorState } from "@codemirror/state";
7import { EditorView } from "@codemirror/view";
8import { describe, expect, it } from "vite-plus/test";
9
10import {
11 createMentionRegistry,
12 hydrateMentions,
13 insertMention,
14 mentionsExtension,
15 serializeMentions,
16 staticMentionProvider,
17} from "../src/mentions.ts";
18
19describe("mention widgets", () => {
20 it("rehydrates saved file ranges with their line count", () => {
21 const registry = createMentionRegistry();
22 const saved = "review @sys/ui/chat/src/editor.ts:12-18 please";
23 const view = new EditorView({
24 state: EditorState.create({
25 doc: hydrateMentions(registry, saved),
26 extensions: mentionsExtension(registry),
27 }),
28 parent: document.body,
29 });
30 expect(view.dom.querySelector(".chip-label")?.textContent).toBe("editor.ts");
31 expect(view.dom.querySelector(".chip-detail")?.textContent).toBe("L12-18");
32 expect(serializeMentions(registry, view.state.doc.toString())).toBe(saved);
33 view.destroy();
34 });
35
36 it("replaces the @query with an atomic chip that serializes back to @token", () => {
37 const registry = createMentionRegistry();
38 const view = new EditorView({
39 state: EditorState.create({ doc: "see brok", extensions: mentionsExtension(registry) }),
40 });
41 insertMention(
42 view,
43 registry,
44 {
45 kind: "file",
46 label: "session.ts",
47 token: "sys/agent-backend/src/providers/claude/session.ts",
48 },
49 { from: 4, to: 8 },
50 );
51 expect(serializeMentions(registry, view.state.doc.toString()).trim()).toBe(
52 "see @sys/agent-backend/src/providers/claude/session.ts",
53 );
54 view.destroy();
55 });
56
57 it("round-trips copied mention text through the clipboard filters", () => {
58 const registry = createMentionRegistry();
59 const view = new EditorView({
60 state: EditorState.create({
61 doc: "@src/app.ts:3-5",
62 extensions: mentionsExtension(registry),
63 }),
64 });
65 const input = view.state.facet(EditorView.clipboardInputFilter);
66 const output = view.state.facet(EditorView.clipboardOutputFilter);
67 const hydrated = input.reduce(
68 (text, filter) => filter(text, view.state),
69 view.state.doc.toString(),
70 );
71
72 expect(hydrated).toMatch(/⟦mention:\d+⟧/);
73 expect(output.reduce((text, filter) => filter(text, view.state), hydrated)).toBe(
74 "@src/app.ts:3-5",
75 );
76 view.destroy();
77 });
78
79 it("renders a provided file icon URL in the mention chip", () => {
80 const registry = createMentionRegistry();
81 const view = new EditorView({
82 state: EditorState.create({ doc: "@file", extensions: mentionsExtension(registry) }),
83 parent: document.body,
84 });
85 insertMention(
86 view,
87 registry,
88 {
89 kind: "file",
90 label: "app.ts",
91 token: "src/app.ts",
92 icon: "data:image/svg+xml;base64,PHN2Zy8+",
93 },
94 { from: 0, to: 5 },
95 );
96 expect(view.dom.querySelector<HTMLImageElement>(".chip-icon img")?.src).toContain(
97 "data:image/svg+xml;base64,PHN2Zy8+",
98 );
99 view.destroy();
100 });
101
102 it("serializes symbol mentions to their source range token", () => {
103 const registry = createMentionRegistry();
104 const view = new EditorView({
105 state: EditorState.create({ doc: "@acti", extensions: mentionsExtension(registry) }),
106 });
107 insertMention(
108 view,
109 registry,
110 {
111 kind: "symbol",
112 label: "activate",
113 detail: "extension · sys/extension/src/extension.ts",
114 token: "sys/extension/src/extension.ts:12:3",
115 },
116 { from: 0, to: 5 },
117 );
118 expect(serializeMentions(registry, view.state.doc.toString()).trim()).toBe(
119 "@sys/extension/src/extension.ts:12:3",
120 );
121 view.destroy();
122 });
123});
124
125describe("staticMentionProvider", () => {
126 it("substring-filters across label, token, and detail", async () => {
127 const provider = staticMentionProvider([
128 {
129 kind: "file",
130 label: "session.ts",
131 detail: "agent-backend/src/providers/claude",
132 token: "src/providers/claude/session.ts",
133 },
134 { kind: "symbol", label: "AgentSession", detail: "interface", token: "AgentSession" },
135 { kind: "file", label: "editor.ts", detail: "agent-ui/src", token: "src/editor.ts" },
136 ]);
137 const hits = await provider.search("session");
138 expect(hits.map((h) => h.label)).toEqual(["session.ts", "AgentSession"]);
139 expect(await provider.search("agent-ui")).toHaveLength(1);
140 });
141});
packages/markdown-editor/tsconfig.browser.json created+25
......@@ -0,0 +1,25 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "module": "Preserve",
5 "moduleResolution": "Bundler",
6 "jsx": "react-jsx",
7 "lib": ["ES2023", "DOM", "DOM.Iterable"],
8 "types": [],
9 "erasableSyntaxOnly": false,
10 "exactOptionalPropertyTypes": false,
11 "plugins": [
12 {
13 "name": "@effect/language-service",
14 "diagnosticSeverity": {
15 "globalTimers": "off",
16 "globalDate": "off",
17 "globalConsole": "off",
18 "globalFetch": "off",
19 "globalRandom": "off"
20 }
21 }
22 ]
23 },
24 "include": ["src"]
25}
packages/shared/src/htmlRender.test.ts+125-7
......@@ -1,3 +1,4 @@
1import * as NodeVM from "node:vm";
12import { describe, expect, it } from "vite-plus/test";
23
34import {
......@@ -8,7 +9,7 @@ import {
89 htmlRenderThemeFragment,
910 injectHtmlRenderBootstrap,
1011 htmlRenderThemeMessage,
11 readHtmlRenderContentHeight,
12 readHtmlRenderContentSize,
1213 readHtmlRenderKeyDown,
1314 readHtmlRenderLinkRequest,
1415 readHtmlRenderReference,
......@@ -19,6 +20,116 @@ import { htmlRenderFromToolItem } from "./toolOutput.ts";
1920const reference = { attachmentId: "thread-abc-123.html", title: "Chart", height: 420 };
2021
2122describe("injectHtmlRenderBootstrap", () => {
23 it("tracks descendant resizing and mutations while preserving the page's authored width", () => {
24 const listeners = new Map<string, () => void>();
25 const style = new Map([
26 ["width", "640px"],
27 ["priority", "important"],
28 ]);
29 let intrinsicWidth = 920;
30 const sizes: unknown[] = [];
31 const observed = new Set<unknown>();
32 let resize: (() => void) | undefined;
33 let mutate:
34 | ((records: { readonly addedNodes: unknown[]; readonly removedNodes: unknown[] }[]) => void)
35 | undefined;
36 const figure = { nodeType: 1, querySelectorAll: () => [] };
37 const descendants = [figure];
38 const body = {
39 nodeType: 1,
40 querySelectorAll: () => descendants,
41 get scrollWidth() {
42 return style.get("width") === "max-content" ? intrinsicWidth : 640;
43 },
44 };
45 class ResizeObserver {
46 constructor(callback: () => void) {
47 resize = callback;
48 }
49 observe(target: unknown) {
50 observed.add(target);
51 }
52 unobserve(target: unknown) {
53 observed.delete(target);
54 }
55 }
56 class MutationObserver {
57 constructor(callback: NonNullable<typeof mutate>) {
58 mutate = callback;
59 }
60 observe(target: unknown, options: unknown) {
61 expect(target).toBe(body);
62 expect(options).toEqual({
63 subtree: true,
64 childList: true,
65 attributes: true,
66 characterData: true,
67 });
68 }
69 }
70 const root = {
71 clientHeight: 100,
72 scrollHeight: 240,
73 getBoundingClientRect: () => ({
74 width: style.get("width") === "max-content" ? intrinsicWidth : 640,
75 height: 240,
76 }),
77 style: {
78 getPropertyValue: (property: string) => style.get(property),
79 getPropertyPriority: () => style.get("priority"),
80 setProperty: (property: string, value: string, priority: string) => {
81 style.set(property, value);
82 style.set("priority", priority);
83 },
84 removeProperty: (property: string) => style.delete(property),
85 },
86 };
87 const bootstrap =
88 injectHtmlRenderBootstrap("<p>Figure</p>").match(/<script>(.*?)<\/script>/s)![1];
89 NodeVM.runInNewContext(bootstrap!, {
90 document: {
91 getElementById: () => ({ textContent: "" }),
92 documentElement: root,
93 body,
94 addEventListener: (event: string, listener: () => void) => listeners.set(event, listener),
95 },
96 location: { hash: "" },
97 ResizeObserver,
98 MutationObserver,
99 window: {
100 ResizeObserver,
101 MutationObserver,
102 addEventListener: (event: string, listener: () => void) => listeners.set(event, listener),
103 parent: {
104 postMessage: (data: unknown) => {
105 expect(style.get("width")).toBe("640px");
106 expect(style.get("priority")).toBe("important");
107 sizes.push(readHtmlRenderContentSize(data));
108 },
109 },
110 },
111 });
112
113 listeners.get("DOMContentLoaded")!();
114 expect(observed).toEqual(new Set([root, body, figure]));
115 listeners.get("load")!();
116 intrinsicWidth = 1100;
117 resize!();
118 intrinsicWidth = 420;
119 resize!();
120 const replacement = { nodeType: 1, querySelectorAll: () => [] };
121 descendants.splice(0, 1, replacement);
122 intrinsicWidth = 880;
123 mutate!([{ addedNodes: [replacement], removedNodes: [figure] }]);
124 expect(observed).toEqual(new Set([root, body, replacement]));
125 expect(sizes).toEqual([
126 { width: 920, height: 240 },
127 { width: 1100, height: 240 },
128 { width: 420, height: 240 },
129 { width: 880, height: 240 },
130 ]);
131 });
132
22133 it("puts the theme ahead of the page's own head content", () => {
23134 const html =
24135 "<!doctype html><html><head><style>:root{--background:red}</style></head><body>x</body></html>";
......@@ -130,17 +241,24 @@ describe("readHtmlRenderKeyDown", () => {
130241 });
131242});
132243
133describe("readHtmlRenderContentHeight", () => {
134 it("reads only the height of an MCP Apps size-changed notification", () => {
244describe("readHtmlRenderContentSize", () => {
245 it("reads positive dimensions and retains height-only notifications from existing renders", () => {
135246 const notification = (params: unknown) => ({
136247 jsonrpc: "2.0",
137248 method: "ui/notifications/size-changed",
138249 params,
139250 });
140 expect(readHtmlRenderContentHeight(notification({ height: 412 }))).toBe(412);
141 expect(readHtmlRenderContentHeight(notification({ height: "412" }))).toBe(undefined);
142 expect(readHtmlRenderContentHeight(notification({ height: 0 }))).toBe(undefined);
143 expect(readHtmlRenderContentHeight({ ...notification({ height: 412 }), method: "x" })).toBe(
251 expect(readHtmlRenderContentSize(notification({ height: 412 }))).toEqual({ height: 412 });
252 expect(readHtmlRenderContentSize(notification({ height: 412, width: 920 }))).toEqual({
253 height: 412,
254 width: 920,
255 });
256 expect(readHtmlRenderContentSize(notification({ height: 412, width: Infinity }))).toEqual({
257 height: 412,
258 });
259 expect(readHtmlRenderContentSize(notification({ height: "412" }))).toBe(undefined);
260 expect(readHtmlRenderContentSize(notification({ height: 0 }))).toBe(undefined);
261 expect(readHtmlRenderContentSize({ ...notification({ height: 412 }), method: "x" })).toBe(
144262 undefined,
145263 );
146264 });
packages/shared/src/htmlRender.ts+10-8
......@@ -281,16 +281,18 @@ const SIZE_CHANGED_METHOD = "ui/notifications/size-changed";
281281const SHORTCUTS_METHOD = "t3/notifications/shortcuts";
282282const KEY_DOWN_METHOD = "t3/notifications/keydown";
283283
284/** The content height in a framed render's `ui/notifications/size-changed` notification. */
285export function readHtmlRenderContentHeight(data: unknown): number | undefined {
284/** A render's measured height and intrinsic width from its size-changed notification. */
285export function readHtmlRenderContentSize(data: unknown) {
286286 if (typeof data !== "object" || data === null) return undefined;
287287 const { jsonrpc, method, params } = data as Record<string, unknown>;
288288 if (jsonrpc !== "2.0" || method !== SIZE_CHANGED_METHOD) return undefined;
289 const height =
290 typeof params === "object" && params !== null
291 ? (params as { height?: unknown }).height
292 : undefined;
293 return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined;
289 if (typeof params !== "object" || params === null) return undefined;
290 const { height, width } = params as { height?: unknown; width?: unknown };
291 if (typeof height !== "number" || !Number.isFinite(height) || height <= 0) return undefined;
292 return {
293 height,
294 ...(typeof width === "number" && Number.isFinite(width) && width > 0 ? { width } : {}),
295 };
294296}
295297
296298/**
......@@ -420,7 +422,7 @@ function rootRule(theme: HtmlRenderTheme): string {
420422// field has focus or the page handled it: the deciding listener is added while
421423// the key dispatches, so it runs after every listener the page has. Keys
422424// match as resolveEventKeys reads them, so the client accepts what it claims.
423const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);var K=[],B=[],C=${JSON.stringify(EVENT_CODE_SHORTCUT_KEYS)};window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(e.source===window.parent&&d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(SHORTCUTS_METHOD)}&&p&&Array.isArray(p.shortcuts)&&Array.isArray(p.blocked)){K=p.shortcuts;B=p.blocked;}});window.addEventListener("keydown",function(e){var t=e.composedPath()[0],c=e.code||"",l=e.key.toLowerCase(),x=/^[a-z]$/.test(l)?[l]:[l,/^Key[A-Z]$/.test(c)?c.slice(3).toLowerCase():"",C.hasOwnProperty(c)?C[c]:""],f,m=function(s){return s&&s.metaKey===e.metaKey&&s.ctrlKey===e.ctrlKey&&s.shiftKey===e.shiftKey&&s.altKey===e.altKey&&x.indexOf(s.key)>-1;};if(!e.isTrusted||e.isComposing||e.getModifierState("AltGraph")&&!/^[a-z0-9]$/i.test(e.key)||t&&(t.isContentEditable||/^(?:INPUT|TEXTAREA|SELECT)$/.test(t.tagName))||!K.some(m)||B.some(m))return;f=function(v){window.removeEventListener("keydown",f);if(v!==e||e.defaultPrevented)return;e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(KEY_DOWN_METHOD)},params:{key:e.key,code:e.code,metaKey:e.metaKey,ctrlKey:e.ctrlKey,shiftKey:e.shiftKey,altKey:e.altKey,repeat:e.repeat}},"*");};window.addEventListener("keydown",f);setTimeout(function(){window.removeEventListener("keydown",f);});},true);}})();`;
425const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,w,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height),a=r.style.getPropertyValue("width"),p=r.style.getPropertyPriority("width");r.style.setProperty("width","max-content","important");var x=Math.ceil(Math.max(r.getBoundingClientRect().width,document.body?document.body.scrollWidth:0));if(a)r.style.setProperty("width",a,p);else r.style.removeProperty("width");if(v===h&&x===w)return;h=v;w=x;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v,width:x}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}function t(n,a){if(n.nodeType!==1)return;a(n);n.querySelectorAll("*").forEach(a);}document.addEventListener("DOMContentLoaded",function(){var b=document.body;if(b){if(o)t(b,function(n){o.observe(n);});if(window.MutationObserver){var m=new MutationObserver(function(es){if(o)es.forEach(function(e){e.removedNodes.forEach(function(n){t(n,function(v){o.unobserve(v);});});e.addedNodes.forEach(function(n){t(n,function(v){o.observe(v);});});});z();});m.observe(b,{subtree:true,childList:true,attributes:true,characterData:true});}}z();});window.addEventListener("load",z);var K=[],B=[],C=${JSON.stringify(EVENT_CODE_SHORTCUT_KEYS)};window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(e.source===window.parent&&d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(SHORTCUTS_METHOD)}&&p&&Array.isArray(p.shortcuts)&&Array.isArray(p.blocked)){K=p.shortcuts;B=p.blocked;}});window.addEventListener("keydown",function(e){var t=e.composedPath()[0],c=e.code||"",l=e.key.toLowerCase(),x=/^[a-z]$/.test(l)?[l]:[l,/^Key[A-Z]$/.test(c)?c.slice(3).toLowerCase():"",C.hasOwnProperty(c)?C[c]:""],f,m=function(s){return s&&s.metaKey===e.metaKey&&s.ctrlKey===e.ctrlKey&&s.shiftKey===e.shiftKey&&s.altKey===e.altKey&&x.indexOf(s.key)>-1;};if(!e.isTrusted||e.isComposing||e.getModifierState("AltGraph")&&!/^[a-z0-9]$/i.test(e.key)||t&&(t.isContentEditable||/^(?:INPUT|TEXTAREA|SELECT)$/.test(t.tagName))||!K.some(m)||B.some(m))return;f=function(v){window.removeEventListener("keydown",f);if(v!==e||e.defaultPrevented)return;e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(KEY_DOWN_METHOD)},params:{key:e.key,code:e.code,metaKey:e.metaKey,ctrlKey:e.ctrlKey,shiftKey:e.shiftKey,altKey:e.altKey,repeat:e.repeat}},"*");};window.addEventListener("keydown",f);setTimeout(function(){window.removeEventListener("keydown",f);});},true);}})();`;
424426
425427function bootstrapMarkup(markup: string): string {
426428 const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");
packages/shared/src/markdownPipeline.test.ts+39
......@@ -27,3 +27,42 @@ it("preserves in-app thread links through the shared Markdown sanitizer", () =>
2727 });
2828 expect(JSON.stringify(tree)).not.toContain("javascript:");
2929});
30
31it("preserves safe inline HTML semantics while rejecting executable markup and attributes", () => {
32 const processor = unified()
33 .use(remarkParse)
34 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
35 .use(remarkRehype, { allowDangerousHtml: true })
36 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
37 const tree = processor.runSync(
38 processor.parse(
39 '<kbd onclick="alert(1)">Cmd</kbd> <mark style="color:red" onmouseover="alert(1)">marked</mark> <u>underlined</u> <script>alert(1)</script> <a href="javascript:alert(1)">unsafe</a>',
40 ),
41 );
42 expect(tree.children[0]).toMatchObject({
43 tagName: "p",
44 children: expect.arrayContaining([
45 expect.objectContaining({
46 tagName: "kbd",
47 properties: {},
48 children: [expect.objectContaining({ type: "text", value: "Cmd" })],
49 }),
50 expect.objectContaining({
51 tagName: "mark",
52 properties: {},
53 children: [expect.objectContaining({ type: "text", value: "marked" })],
54 }),
55 expect.objectContaining({
56 tagName: "u",
57 properties: {},
58 children: [expect.objectContaining({ type: "text", value: "underlined" })],
59 }),
60 ]),
61 });
62 const rendered = JSON.stringify(tree);
63 expect(rendered).not.toContain("javascript:");
64 expect(rendered).not.toContain('"tagName":"script"');
65 expect(rendered).not.toContain("onclick");
66 expect(rendered).not.toContain("onmouseover");
67 expect(rendered).not.toContain('"style"');
68});
packages/shared/src/markdownPipeline.ts+1
......@@ -142,6 +142,7 @@ function rehypePreserveImageSourceMeta() {
142142
143143const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
144144 ...defaultSchema,
145 tagNames: [...(defaultSchema.tagNames ?? []), "mark", "u"],
145146 attributes: {
146147 ...defaultSchema.attributes,
147148 "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
pnpm-lock.yaml+249-522
......@@ -760,9 +760,27 @@ importers:
760760 '@clerk/react':
761761 specifier: 6.16.1
762762 version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
763 '@clo/markdown-editor':
764 specifier: workspace:*
765 version: link:../../packages/markdown-editor
763766 '@clo/terminal':
764767 specifier: workspace:*
765768 version: link:../../packages/terminal
769 '@codemirror/commands':
770 specifier: ^6.8.1
771 version: 6.11.1
772 '@codemirror/lang-markdown':
773 specifier: ^6.5.0
774 version: 6.5.2
775 '@codemirror/language':
776 specifier: ^6.12.3
777 version: 6.13.1
778 '@codemirror/state':
779 specifier: ^6.6.0
780 version: 6.7.6
781 '@codemirror/view':
782 specifier: ^6.43.1
783 version: 6.43.14
766784 '@daypicker/react':
767785 specifier: ^10.0.1
768786 version: 10.0.1(@types/react@19.3.0)(react@19.2.6)
......@@ -787,6 +805,9 @@ importers:
787805 '@legendapp/list':
788806 specifier: 'catalog:'
789807 version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
808 '@lezer/common':
809 specifier: ^1.5.2
810 version: 1.5.3
790811 '@noble/hashes':
791812 specifier: 'catalog:'
792813 version: 1.8.0
......@@ -832,42 +853,6 @@ importers:
832853 '@tanstack/react-router':
833854 specifier: ^1.160.2
834855 version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
835 '@tiptap/core':
836 specifier: ^3.31.3
837 version: 3.31.3(@tiptap/pm@3.31.3)
838 '@tiptap/extension-blockquote':
839 specifier: ^3.31.3
840 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
841 '@tiptap/extension-code':
842 specifier: ^3.31.3
843 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
844 '@tiptap/extension-code-block':
845 specifier: ^3.31.3
846 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
847 '@tiptap/extension-heading':
848 specifier: ^3.31.3
849 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
850 '@tiptap/extension-horizontal-rule':
851 specifier: ^3.31.3
852 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
853 '@tiptap/extension-list':
854 specifier: ^3.31.3
855 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
856 '@tiptap/extension-task-item':
857 specifier: ^3.31.3
858 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))
859 '@tiptap/extension-task-list':
860 specifier: ^3.31.3
861 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))
862 '@tiptap/pm':
863 specifier: ^3.31.3
864 version: 3.31.3
865 '@tiptap/react':
866 specifier: ^3.31.3
867 version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
868 '@tiptap/starter-kit':
869 specifier: ^3.31.3
870 version: 3.31.3
871856 class-variance-authority:
872857 specifier: ^0.7.1
873858 version: 0.7.1
......@@ -880,6 +865,9 @@ importers:
880865 effect:
881866 specifier: 4.0.4
882867 version: 4.0.4
868 gemoji:
869 specifier: ^8.1.0
870 version: 8.1.0
883871 hast-util-to-html:
884872 specifier: ^9.0.5
885873 version: 9.0.5
......@@ -1185,6 +1173,39 @@ importers:
11851173 specifier: 1.0.0
11861174 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
11871175
1176 packages/markdown-editor:
1177 devDependencies:
1178 '@codemirror/commands':
1179 specifier: ^6.8.1
1180 version: 6.11.1
1181 '@codemirror/lang-javascript':
1182 specifier: ^6.2.5
1183 version: 6.2.5
1184 '@codemirror/lang-markdown':
1185 specifier: ^6.5.0
1186 version: 6.5.2
1187 '@codemirror/language':
1188 specifier: ^6.12.3
1189 version: 6.13.1
1190 '@codemirror/state':
1191 specifier: ^6.6.0
1192 version: 6.7.6
1193 '@codemirror/view':
1194 specifier: ^6.43.1
1195 version: 6.43.14
1196 '@lezer/common':
1197 specifier: ^1.5.2
1198 version: 1.5.3
1199 '@types/react':
1200 specifier: ~19.3.0
1201 version: 19.3.0
1202 react:
1203 specifier: ^19.2.0
1204 version: 19.3.0
1205 vite-plus:
1206 specifier: 1.0.0
1207 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1208
11881209 packages/provider-acp:
11891210 dependencies:
11901211 '@t3tools/contracts':
......@@ -2848,6 +2869,39 @@ packages:
28482869 '@cloudflare/workers-types@5.20261004.1':
28492870 resolution: {integrity: sha512-K9D4/Y6bSIDNYFzjqzFMNoWK99pLgfgFxqSa76kfW++3VCkTSzJbzsQRffir366G3kmNyjoigC6ekp7DxIkxkw==}
28502871
2872 '@codemirror/autocomplete@6.20.3':
2873 resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
2874
2875 '@codemirror/commands@6.11.1':
2876 resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==}
2877
2878 '@codemirror/lang-css@6.3.1':
2879 resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
2880
2881 '@codemirror/lang-html@6.4.12':
2882 resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==}
2883
2884 '@codemirror/lang-javascript@6.2.5':
2885 resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
2886
2887 '@codemirror/lang-markdown@6.5.2':
2888 resolution: {integrity: sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==}
2889
2890 '@codemirror/language@6.13.1':
2891 resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==}
2892
2893 '@codemirror/lint@6.9.7':
2894 resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
2895
2896 '@codemirror/state@6.7.6':
2897 resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
2898
2899 '@codemirror/streamparser@6.0.0':
2900 resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==}
2901
2902 '@codemirror/view@6.43.14':
2903 resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==}
2904
28512905 '@coderabbitai/config@1.1.0':
28522906 resolution: {integrity: sha512-NvQKaoOD7fWb7lMKhid57ABVlIBSczw/S+ePk5YvoPuWdrDkyY+PWdqVtq3TRq7VY8f3SNRlMFjmAtXVab66pw==}
28532907
......@@ -4190,6 +4244,27 @@ packages:
41904244 react-native:
41914245 optional: true
41924246
4247 '@lezer/common@1.5.3':
4248 resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==}
4249
4250 '@lezer/css@1.3.9':
4251 resolution: {integrity: sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==}
4252
4253 '@lezer/highlight@1.2.5':
4254 resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
4255
4256 '@lezer/html@1.3.13':
4257 resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
4258
4259 '@lezer/javascript@1.5.6':
4260 resolution: {integrity: sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==}
4261
4262 '@lezer/lr@1.4.11':
4263 resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==}
4264
4265 '@lezer/markdown@1.8.0':
4266 resolution: {integrity: sha512-X6w3A2NpyhdtGqTWRJB8gR4QDXBaFMbTOs780ZdZ15KKCfBXOQTKcB3Vr+W6tqJHVlealt53tLmHRvOqPTQgYA==}
4267
41934268 '@libsql/client@0.17.3':
41944269 resolution: {integrity: sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==}
41954270
......@@ -4255,6 +4330,9 @@ packages:
42554330 resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
42564331 engines: {node: '>= 10.0.0'}
42574332
4333 '@marijn/find-cluster-break@1.0.4':
4334 resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
4335
42584336 '@material/material-color-utilities@0.3.0':
42594337 resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==}
42604338
......@@ -6045,166 +6123,6 @@ packages:
60456123 peerDependencies:
60466124 '@testing-library/dom': '>=7.21.4'
60476125
6048 '@tiptap/core@3.31.3':
6049 resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==}
6050 peerDependencies:
6051 '@tiptap/pm': 3.31.3
6052
6053 '@tiptap/extension-blockquote@3.31.3':
6054 resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==}
6055 peerDependencies:
6056 '@tiptap/core': 3.31.3
6057 '@tiptap/pm': 3.31.3
6058
6059 '@tiptap/extension-bold@3.31.3':
6060 resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==}
6061 peerDependencies:
6062 '@tiptap/core': 3.31.3
6063
6064 '@tiptap/extension-bubble-menu@3.31.3':
6065 resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==}
6066 peerDependencies:
6067 '@tiptap/core': 3.31.3
6068 '@tiptap/pm': 3.31.3
6069
6070 '@tiptap/extension-bullet-list@3.31.3':
6071 resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==}
6072 peerDependencies:
6073 '@tiptap/extension-list': 3.31.3
6074
6075 '@tiptap/extension-code-block@3.31.3':
6076 resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==}
6077 peerDependencies:
6078 '@tiptap/core': 3.31.3
6079 '@tiptap/pm': 3.31.3
6080
6081 '@tiptap/extension-code@3.31.3':
6082 resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==}
6083 peerDependencies:
6084 '@tiptap/core': 3.31.3
6085
6086 '@tiptap/extension-document@3.31.3':
6087 resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==}
6088 peerDependencies:
6089 '@tiptap/core': 3.31.3
6090
6091 '@tiptap/extension-dropcursor@3.31.3':
6092 resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==}
6093 peerDependencies:
6094 '@tiptap/extensions': 3.31.3
6095
6096 '@tiptap/extension-floating-menu@3.31.3':
6097 resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==}
6098 peerDependencies:
6099 '@floating-ui/dom': ^1.0.0
6100 '@tiptap/core': 3.31.3
6101 '@tiptap/pm': 3.31.3
6102
6103 '@tiptap/extension-gapcursor@3.31.3':
6104 resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==}
6105 peerDependencies:
6106 '@tiptap/extensions': 3.31.3
6107
6108 '@tiptap/extension-hard-break@3.31.3':
6109 resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==}
6110 peerDependencies:
6111 '@tiptap/core': 3.31.3
6112
6113 '@tiptap/extension-heading@3.31.3':
6114 resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==}
6115 peerDependencies:
6116 '@tiptap/core': 3.31.3
6117
6118 '@tiptap/extension-horizontal-rule@3.31.3':
6119 resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==}
6120 peerDependencies:
6121 '@tiptap/core': 3.31.3
6122 '@tiptap/pm': 3.31.3
6123
6124 '@tiptap/extension-italic@3.31.3':
6125 resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==}
6126 peerDependencies:
6127 '@tiptap/core': 3.31.3
6128
6129 '@tiptap/extension-link@3.31.3':
6130 resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==}
6131 peerDependencies:
6132 '@tiptap/core': 3.31.3
6133 '@tiptap/pm': 3.31.3
6134
6135 '@tiptap/extension-list-item@3.31.3':
6136 resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==}
6137 peerDependencies:
6138 '@tiptap/extension-list': 3.31.3
6139
6140 '@tiptap/extension-list-keymap@3.31.3':
6141 resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==}
6142 peerDependencies:
6143 '@tiptap/extension-list': 3.31.3
6144
6145 '@tiptap/extension-list@3.31.3':
6146 resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==}
6147 peerDependencies:
6148 '@tiptap/core': 3.31.3
6149 '@tiptap/pm': 3.31.3
6150
6151 '@tiptap/extension-ordered-list@3.31.3':
6152 resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==}
6153 peerDependencies:
6154 '@tiptap/extension-list': 3.31.3
6155
6156 '@tiptap/extension-paragraph@3.31.3':
6157 resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==}
6158 peerDependencies:
6159 '@tiptap/core': 3.31.3
6160
6161 '@tiptap/extension-strike@3.31.3':
6162 resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==}
6163 peerDependencies:
6164 '@tiptap/core': 3.31.3
6165
6166 '@tiptap/extension-task-item@3.31.3':
6167 resolution: {integrity: sha512-gCWvvXsCzi9tVFXPqKlkbU81XxtIdfjGy7FM9QFRKiWAvq5uv+Q5nvwrCyMVyP4bA7zRGWFaPzwjL68djgzoBw==}
6168 peerDependencies:
6169 '@tiptap/extension-list': 3.31.3
6170
6171 '@tiptap/extension-task-list@3.31.3':
6172 resolution: {integrity: sha512-3WgVzmfEnDbmxbjUBlvTiKpT53KSBnnYhXUVQxM0nz1vpoWG8QifhsnCVsNI6TZmhgR0x/jY+UW9Hc/WLF8DkQ==}
6173 peerDependencies:
6174 '@tiptap/extension-list': 3.31.3
6175
6176 '@tiptap/extension-text@3.31.3':
6177 resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==}
6178 peerDependencies:
6179 '@tiptap/core': 3.31.3
6180
6181 '@tiptap/extension-underline@3.31.3':
6182 resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==}
6183 peerDependencies:
6184 '@tiptap/core': 3.31.3
6185
6186 '@tiptap/extensions@3.31.3':
6187 resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==}
6188 peerDependencies:
6189 '@tiptap/core': 3.31.3
6190 '@tiptap/pm': 3.31.3
6191
6192 '@tiptap/pm@3.31.3':
6193 resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==}
6194
6195 '@tiptap/react@3.31.3':
6196 resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==}
6197 peerDependencies:
6198 '@tiptap/core': 3.31.3
6199 '@tiptap/pm': 3.31.3
6200 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
6201 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
6202 react: ^17.0.0 || ^18.0.0 || ^19.0.0
6203 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
6204
6205 '@tiptap/starter-kit@3.31.3':
6206 resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==}
6207
62086126 '@tsc-rs/darwin-arm64@0.7.0':
62096127 resolution: {integrity: sha512-3execGkNh7DXNZr03dhQP2wqmsjB1Q4OJnTMgKIibjeQMsjNK8HTalJZPIG6PkiXeusXUweHP3+4yJjbi5auNw==}
62106128 engines: {node: '>=16.20.0'}
......@@ -6501,9 +6419,6 @@ packages:
65016419 '@types/unist@3.0.3':
65026420 resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
65036421
6504 '@types/use-sync-external-store@0.0.6':
6505 resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
6506
65076422 '@types/webidl-conversions@7.0.3':
65086423 resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
65096424
......@@ -7807,6 +7722,9 @@ packages:
78077722 cose-base@2.2.0:
78087723 resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
78097724
7725 crelt@1.0.7:
7726 resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
7727
78107728 cross-dirname@0.1.0:
78117729 resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==}
78127730
......@@ -8894,10 +8812,6 @@ packages:
88948812 fast-deep-equal@3.1.3:
88958813 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
88968814
8897 fast-equals@5.4.2:
8898 resolution: {integrity: sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==}
8899 engines: {node: '>=6.0.0'}
8900
89018815 fast-json-stable-stringify@2.1.0:
89028816 resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
89038817
......@@ -9081,6 +8995,9 @@ packages:
90818995 function-bind@1.1.2:
90828996 resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
90838997
8998 gemoji@8.1.0:
8999 resolution: {integrity: sha512-HA4Gx59dw2+tn+UAa7XEV4ufUKI4fH1KgcbenVA9YKSj1QJTT0xh5Mwv5HMFNN3l2OtUe3ZIfuRwSyZS5pLIWw==}
9000
90849001 gensync@1.0.0-beta.2:
90859002 resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
90869003 engines: {node: '>=6.9.0'}
......@@ -9677,9 +9594,6 @@ packages:
96779594 resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
96789595 engines: {node: '>= 12.0.0'}
96799596
9680 linkifyjs@4.3.3:
9681 resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
9682
96839597 locate-path@3.0.0:
96849598 resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
96859599 engines: {node: '>=6'}
......@@ -10391,9 +10305,6 @@ packages:
1039110305 resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==}
1039210306 engines: {node: '>=20'}
1039310307
10394 orderedmap@2.1.1:
10395 resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
10396
1039710308 outvariant@1.4.3:
1039810309 resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
1039910310
......@@ -10731,45 +10642,6 @@ packages:
1073110642 property-information@7.2.0:
1073210643 resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
1073310644
10734 prosemirror-changeset@2.4.2:
10735 resolution: {integrity: sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==}
10736
10737 prosemirror-commands@1.7.2:
10738 resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==}
10739
10740 prosemirror-dropcursor@1.8.3:
10741 resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==}
10742
10743 prosemirror-gapcursor@1.4.1:
10744 resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
10745
10746 prosemirror-history@1.5.0:
10747 resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
10748
10749 prosemirror-inputrules@1.5.1:
10750 resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
10751
10752 prosemirror-keymap@1.2.3:
10753 resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
10754
10755 prosemirror-model@1.25.11:
10756 resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==}
10757
10758 prosemirror-schema-list@1.5.1:
10759 resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
10760
10761 prosemirror-state@1.4.4:
10762 resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
10763
10764 prosemirror-tables@1.8.5:
10765 resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
10766
10767 prosemirror-transform@1.12.1:
10768 resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==}
10769
10770 prosemirror-view@1.42.3:
10771 resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==}
10772
1077310645 proxy-addr@2.0.7:
1077410646 resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
1077510647 engines: {node: '>= 0.10'}
......@@ -11142,9 +11014,6 @@ packages:
1114211014 engines: {node: ^20.19.0 || >=22.12.0}
1114311015 hasBin: true
1114411016
11145 rope-sequence@1.3.4:
11146 resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
11147
1114811017 roughjs@4.6.6:
1114911018 resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
1115011019
......@@ -11500,6 +11369,9 @@ packages:
1150011369 structured-headers@0.4.1:
1150111370 resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==}
1150211371
11372 style-mod@4.1.4:
11373 resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
11374
1150311375 style-to-js@1.1.21:
1150411376 resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
1150511377
......@@ -13662,6 +13534,89 @@ snapshots:
1366213534
1366313535 '@cloudflare/workers-types@5.20261004.1': {}
1366413536
13537 '@codemirror/autocomplete@6.20.3':
13538 dependencies:
13539 '@codemirror/language': 6.13.1
13540 '@codemirror/state': 6.7.6
13541 '@codemirror/view': 6.43.14
13542 '@lezer/common': 1.5.3
13543
13544 '@codemirror/commands@6.11.1':
13545 dependencies:
13546 '@codemirror/language': 6.13.1
13547 '@codemirror/state': 6.7.6
13548 '@codemirror/view': 6.43.14
13549 '@lezer/common': 1.5.3
13550
13551 '@codemirror/lang-css@6.3.1':
13552 dependencies:
13553 '@codemirror/autocomplete': 6.20.3
13554 '@codemirror/language': 6.13.1
13555 '@codemirror/state': 6.7.6
13556 '@lezer/common': 1.5.3
13557 '@lezer/css': 1.3.9
13558
13559 '@codemirror/lang-html@6.4.12':
13560 dependencies:
13561 '@codemirror/autocomplete': 6.20.3
13562 '@codemirror/lang-css': 6.3.1
13563 '@codemirror/lang-javascript': 6.2.5
13564 '@codemirror/language': 6.13.1
13565 '@codemirror/state': 6.7.6
13566 '@codemirror/view': 6.43.14
13567 '@lezer/common': 1.5.3
13568 '@lezer/css': 1.3.9
13569 '@lezer/html': 1.3.13
13570
13571 '@codemirror/lang-javascript@6.2.5':
13572 dependencies:
13573 '@codemirror/autocomplete': 6.20.3
13574 '@codemirror/language': 6.13.1
13575 '@codemirror/lint': 6.9.7
13576 '@codemirror/state': 6.7.6
13577 '@codemirror/view': 6.43.14
13578 '@lezer/common': 1.5.3
13579 '@lezer/javascript': 1.5.6
13580
13581 '@codemirror/lang-markdown@6.5.2':
13582 dependencies:
13583 '@codemirror/autocomplete': 6.20.3
13584 '@codemirror/lang-html': 6.4.12
13585 '@codemirror/language': 6.13.1
13586 '@codemirror/state': 6.7.6
13587 '@codemirror/view': 6.43.14
13588 '@lezer/common': 1.5.3
13589 '@lezer/markdown': 1.8.0
13590
13591 '@codemirror/language@6.13.1':
13592 dependencies:
13593 '@codemirror/state': 6.7.6
13594 '@codemirror/streamparser': 6.0.0
13595 '@codemirror/view': 6.43.14
13596 '@lezer/common': 1.5.3
13597 '@lezer/highlight': 1.2.5
13598 '@lezer/lr': 1.4.11
13599 style-mod: 4.1.4
13600
13601 '@codemirror/lint@6.9.7':
13602 dependencies:
13603 '@codemirror/state': 6.7.6
13604 '@codemirror/view': 6.43.14
13605 crelt: 1.0.7
13606
13607 '@codemirror/state@6.7.6':
13608 dependencies:
13609 '@marijn/find-cluster-break': 1.0.4
13610
13611 '@codemirror/streamparser@6.0.0': {}
13612
13613 '@codemirror/view@6.43.14':
13614 dependencies:
13615 '@codemirror/state': 6.7.6
13616 crelt: 1.0.7
13617 style-mod: 4.1.4
13618 w3c-keyname: 2.2.8
13619
1366513620 '@coderabbitai/config@1.1.0':
1366613621 dependencies:
1366713622 zod: 4.5.4
......@@ -15068,6 +15023,39 @@ snapshots:
1506815023 react-dom: 19.3.0(react@19.3.0)
1506915024 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
1507015025
15026 '@lezer/common@1.5.3': {}
15027
15028 '@lezer/css@1.3.9':
15029 dependencies:
15030 '@lezer/common': 1.5.3
15031 '@lezer/highlight': 1.2.5
15032 '@lezer/lr': 1.4.11
15033
15034 '@lezer/highlight@1.2.5':
15035 dependencies:
15036 '@lezer/common': 1.5.3
15037
15038 '@lezer/html@1.3.13':
15039 dependencies:
15040 '@lezer/common': 1.5.3
15041 '@lezer/highlight': 1.2.5
15042 '@lezer/lr': 1.4.11
15043
15044 '@lezer/javascript@1.5.6':
15045 dependencies:
15046 '@lezer/common': 1.5.3
15047 '@lezer/highlight': 1.2.5
15048 '@lezer/lr': 1.4.11
15049
15050 '@lezer/lr@1.4.11':
15051 dependencies:
15052 '@lezer/common': 1.5.3
15053
15054 '@lezer/markdown@1.8.0':
15055 dependencies:
15056 '@lezer/common': 1.5.3
15057 '@lezer/highlight': 1.2.5
15058
1507115059 '@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)':
1507215060 dependencies:
1507315061 '@libsql/core': 0.17.3
......@@ -15139,6 +15127,8 @@ snapshots:
1513915127 transitivePeerDependencies:
1514015128 - supports-color
1514115129
15130 '@marijn/find-cluster-break@1.0.4': {}
15131
1514215132 '@material/material-color-utilities@0.3.0': {}
1514315133
1514415134 '@mermaid-js/parser@1.2.1':
......@@ -16623,187 +16613,6 @@ snapshots:
1662316613 dependencies:
1662416614 '@testing-library/dom': 10.4.1
1662516615
16626 '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
16627 dependencies:
16628 '@tiptap/pm': 3.31.3
16629
16630 '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
16631 dependencies:
16632 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16633 '@tiptap/pm': 3.31.3
16634
16635 '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
16636 dependencies:
16637 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16638
16639 '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
16640 dependencies:
16641 '@floating-ui/dom': 1.7.6
16642 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16643 '@tiptap/pm': 3.31.3
16644 optional: true
16645
16646 '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
16647 dependencies:
16648 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16649
16650 '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
16651 dependencies:
16652 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16653 '@tiptap/pm': 3.31.3
16654
16655 '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
16656 dependencies:
16657 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16658
16659 '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
16660 dependencies:
16661 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16662
16663 '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
16664 dependencies:
16665 '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16666
16667 '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
16668 dependencies:
16669 '@floating-ui/dom': 1.7.6
16670 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16671 '@tiptap/pm': 3.31.3
16672 optional: true
16673
16674 '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
16675 dependencies:
16676 '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16677
16678 '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
16679 dependencies:
16680 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16681
16682 '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
16683 dependencies:
16684 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16685
16686 '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
16687 dependencies:
16688 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16689 '@tiptap/pm': 3.31.3
16690
16691 '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
16692 dependencies:
16693 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16694
16695 '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
16696 dependencies:
16697 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16698 '@tiptap/pm': 3.31.3
16699 linkifyjs: 4.3.3
16700
16701 '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
16702 dependencies:
16703 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16704
16705 '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
16706 dependencies:
16707 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16708
16709 '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
16710 dependencies:
16711 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16712 '@tiptap/pm': 3.31.3
16713
16714 '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
16715 dependencies:
16716 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16717
16718 '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
16719 dependencies:
16720 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16721
16722 '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
16723 dependencies:
16724 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16725
16726 '@tiptap/extension-task-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
16727 dependencies:
16728 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16729
16730 '@tiptap/extension-task-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
16731 dependencies:
16732 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16733
16734 '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
16735 dependencies:
16736 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16737
16738 '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
16739 dependencies:
16740 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16741
16742 '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
16743 dependencies:
16744 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16745 '@tiptap/pm': 3.31.3
16746
16747 '@tiptap/pm@3.31.3':
16748 dependencies:
16749 prosemirror-changeset: 2.4.2
16750 prosemirror-commands: 1.7.2
16751 prosemirror-dropcursor: 1.8.3
16752 prosemirror-gapcursor: 1.4.1
16753 prosemirror-history: 1.5.0
16754 prosemirror-inputrules: 1.5.1
16755 prosemirror-keymap: 1.2.3
16756 prosemirror-model: 1.25.11
16757 prosemirror-schema-list: 1.5.1
16758 prosemirror-state: 1.4.4
16759 prosemirror-tables: 1.8.5
16760 prosemirror-transform: 1.12.1
16761 prosemirror-view: 1.42.3
16762
16763 '@tiptap/react@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
16764 dependencies:
16765 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16766 '@tiptap/pm': 3.31.3
16767 '@types/react': 19.3.0
16768 '@types/react-dom': 19.2.3(@types/react@19.3.0)
16769 '@types/use-sync-external-store': 0.0.6
16770 fast-equals: 5.4.2
16771 react: 19.2.6
16772 react-dom: 19.2.6(react@19.2.6)
16773 use-sync-external-store: 1.6.0(react@19.2.6)
16774 optionalDependencies:
16775 '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16776 '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16777 transitivePeerDependencies:
16778 - '@floating-ui/dom'
16779
16780 '@tiptap/starter-kit@3.31.3':
16781 dependencies:
16782 '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
16783 '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16784 '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
16785 '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
16786 '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
16787 '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16788 '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
16789 '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
16790 '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
16791 '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
16792 '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
16793 '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16794 '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
16795 '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16796 '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16797 '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
16798 '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
16799 '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
16800 '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
16801 '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
16802 '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
16803 '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
16804 '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
16805 '@tiptap/pm': 3.31.3
16806
1680716616 '@tsc-rs/darwin-arm64@0.7.0':
1680816617 optional: true
1680916618
......@@ -17145,8 +16954,6 @@ snapshots:
1714516954
1714616955 '@types/unist@3.0.3': {}
1714716956
17148 '@types/use-sync-external-store@0.0.6': {}
17149
1715016957 '@types/webidl-conversions@7.0.3':
1715116958 optional: true
1715216959
......@@ -18420,6 +18227,8 @@ snapshots:
1842018227 dependencies:
1842118228 layout-base: 2.0.1
1842218229
18230 crelt@1.0.7: {}
18231
1842318232 cross-dirname@0.1.0:
1842418233 optional: true
1842518234
......@@ -19564,8 +19373,6 @@ snapshots:
1956419373
1956519374 fast-deep-equal@3.1.3: {}
1956619375
19567 fast-equals@5.4.2: {}
19568
1956919376 fast-json-stable-stringify@2.1.0:
1957019377 optional: true
1957119378
......@@ -19615,10 +19422,6 @@ snapshots:
1961519422 dependencies:
1961619423 walk-up-path: 4.0.0
1961719424
19618 fdir@6.5.0(picomatch@4.0.4):
19619 optionalDependencies:
19620 picomatch: 4.0.4
19621
1962219425 fdir@6.5.0(picomatch@4.0.7):
1962319426 optionalDependencies:
1962419427 picomatch: 4.0.7
......@@ -19771,6 +19574,8 @@ snapshots:
1977119574
1977219575 function-bind@1.1.2: {}
1977319576
19577 gemoji@8.1.0: {}
19578
1977419579 gensync@1.0.0-beta.2: {}
1977519580
1977619581 get-caller-file@2.0.5: {}
......@@ -20418,8 +20223,6 @@ snapshots:
2041820223 lightningcss-win32-arm64-msvc: 1.33.0
2041920224 lightningcss-win32-x64-msvc: 1.33.0
2042020225
20421 linkifyjs@4.3.3: {}
20422
2042320226 locate-path@3.0.0:
2042420227 dependencies:
2042520228 p-locate: 3.0.0
......@@ -21448,8 +21251,6 @@ snapshots:
2144821251 stdin-discarder: 0.3.2
2144921252 string-width: 8.2.1
2145021253
21451 orderedmap@2.1.1: {}
21452
2145321254 outvariant@1.4.3:
2145421255 optional: true
2145521256
......@@ -21901,80 +21702,6 @@ snapshots:
2190121702
2190221703 property-information@7.2.0: {}
2190321704
21904 prosemirror-changeset@2.4.2:
21905 dependencies:
21906 prosemirror-transform: 1.12.1
21907
21908 prosemirror-commands@1.7.2:
21909 dependencies:
21910 prosemirror-model: 1.25.11
21911 prosemirror-state: 1.4.4
21912 prosemirror-transform: 1.12.1
21913
21914 prosemirror-dropcursor@1.8.3:
21915 dependencies:
21916 prosemirror-state: 1.4.4
21917 prosemirror-transform: 1.12.1
21918 prosemirror-view: 1.42.3
21919
21920 prosemirror-gapcursor@1.4.1:
21921 dependencies:
21922 prosemirror-keymap: 1.2.3
21923 prosemirror-model: 1.25.11
21924 prosemirror-state: 1.4.4
21925 prosemirror-view: 1.42.3
21926
21927 prosemirror-history@1.5.0:
21928 dependencies:
21929 prosemirror-state: 1.4.4
21930 prosemirror-transform: 1.12.1
21931 prosemirror-view: 1.42.3
21932 rope-sequence: 1.3.4
21933
21934 prosemirror-inputrules@1.5.1:
21935 dependencies:
21936 prosemirror-state: 1.4.4
21937 prosemirror-transform: 1.12.1
21938
21939 prosemirror-keymap@1.2.3:
21940 dependencies:
21941 prosemirror-state: 1.4.4
21942 w3c-keyname: 2.2.8
21943
21944 prosemirror-model@1.25.11:
21945 dependencies:
21946 orderedmap: 2.1.1
21947
21948 prosemirror-schema-list@1.5.1:
21949 dependencies:
21950 prosemirror-model: 1.25.11
21951 prosemirror-state: 1.4.4
21952 prosemirror-transform: 1.12.1
21953
21954 prosemirror-state@1.4.4:
21955 dependencies:
21956 prosemirror-model: 1.25.11
21957 prosemirror-transform: 1.12.1
21958 prosemirror-view: 1.42.3
21959
21960 prosemirror-tables@1.8.5:
21961 dependencies:
21962 prosemirror-keymap: 1.2.3
21963 prosemirror-model: 1.25.11
21964 prosemirror-state: 1.4.4
21965 prosemirror-transform: 1.12.1
21966 prosemirror-view: 1.42.3
21967
21968 prosemirror-transform@1.12.1:
21969 dependencies:
21970 prosemirror-model: 1.25.11
21971
21972 prosemirror-view@1.42.3:
21973 dependencies:
21974 prosemirror-model: 1.25.11
21975 prosemirror-state: 1.4.4
21976 prosemirror-transform: 1.12.1
21977
2197821705 proxy-addr@2.0.7:
2197921706 dependencies:
2198021707 forwarded: 0.2.0
......@@ -22523,8 +22250,6 @@ snapshots:
2252322250 '@rolldown/binding-win32-arm64-msvc': 1.2.5
2252422251 '@rolldown/binding-win32-x64-msvc': 1.2.5
2252522252
22526 rope-sequence@1.3.4: {}
22527
2252822253 roughjs@4.6.6:
2252922254 dependencies:
2253022255 hachure-fill: 0.5.2
......@@ -22949,6 +22674,8 @@ snapshots:
2294922674
2295022675 structured-headers@0.4.1: {}
2295122676
22677 style-mod@4.1.4: {}
22678
2295222679 style-to-js@1.1.21:
2295322680 dependencies:
2295422681 style-to-object: 1.0.14
......@@ -23076,8 +22803,8 @@ snapshots:
2307622803
2307722804 tinyglobby@0.2.17:
2307822805 dependencies:
23079 fdir: 6.5.0(picomatch@4.0.4)
23080 picomatch: 4.0.4
22806 fdir: 6.5.0(picomatch@4.0.7)
22807 picomatch: 4.0.7
2308122808
2308222809 tinypool@2.1.2: {}
2308322810
third-party-licenses.config.json+8
......@@ -99,6 +99,14 @@
9999 "noticeFile": "native/libghostty-vt/LICENSE",
100100 "sourceUrl": "https://github.com/ghostty-org/ghostty"
101101 },
102 {
103 "bundles": ["assets", "web"],
104 "license": "OFL-1.1",
105 "name": "Recursive",
106 "noticeFile": "apps/web/src/assets/fonts/Recursive-OFL.txt",
107 "sourceUrl": "https://github.com/arrowtype/recursive",
108 "version": "1.085"
109 },
102110 {
103111 "bundles": ["assets", "web"],
104112 "license": "OFL-1.1",