| author | |
| committer | |
| log | 4b5a0105eece1c3aa7f02739122464260a9c5def |
| tree | 0b2dcf441f59832ad5bd1ae08911198209ab248b |
| parent | 06b388143eb05c2ed94bc04901560d243bb58823 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
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-sol98 files changed, 10485 insertions(+), 7883 deletions(-)
apps/desktop/src/ipc/DesktopIpc.ts+1| ... | @@ -6,6 +6,7 @@ import * as Scope from "effect/Scope"; | ... | @@ -6,6 +6,7 @@ import * as Scope from "effect/Scope"; |
| 6 | 6 | ||
| 7 | export interface DesktopIpcInvokeEvent { | 7 | export interface DesktopIpcInvokeEvent { |
| 8 | readonly sender: { readonly id: number }; | 8 | readonly sender: { readonly id: number }; |
| 9 | readonly senderFrame?: { readonly url: string; readonly parent: unknown } | null; | ||
| 9 | } | 10 | } |
| 10 | 11 | ||
| 11 | export interface DesktopIpcSyncEvent { | 12 | export interface DesktopIpcSyncEvent { |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { openVideo } from "./methods/media.ts"; | ||
| 1 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 2 | 3 | ||
| 3 | import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; | 4 | import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; |
| ... | @@ -145,6 +146,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -145,6 +146,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 145 | yield* ipc.handle(setTheme); | 146 | yield* ipc.handle(setTheme); |
| 146 | yield* ipc.handle(showContextMenu); | 147 | yield* ipc.handle(showContextMenu); |
| 147 | yield* ipc.handle(openExternal); | 148 | yield* ipc.handle(openExternal); |
| 149 | yield* ipc.handle(openVideo); | ||
| 148 | yield* ipc.handle(receiveProviderAuthCallback); | 150 | yield* ipc.handle(receiveProviderAuthCallback); |
| 149 | yield* ipc.handle(cancelProviderAuthCallback); | 151 | yield* ipc.handle(cancelProviderAuthCallback); |
| 150 | yield* ipc.handle(openSystemSettings); | 152 | 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 | ... | @@ -124,3 +124,5 @@ export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-au |
| 124 | export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; | 124 | export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; |
| 125 | export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-local-storage"; | 125 | export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-local-storage"; |
| 126 | export const DESKTOP_TERMINAL_CHANNEL = "desktop:terminal"; | 126 | export const DESKTOP_TERMINAL_CHANNEL = "desktop:terminal"; |
| 127 | |||
| 128 | export const OPEN_VIDEO_CHANNEL = "desktop:open-video"; |
apps/desktop/src/ipc/methods/media.test.ts created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import type * as Electron from "electron"; | ||
| 6 | import { vi } from "vite-plus/test"; | ||
| 7 | |||
| 8 | vi.mock("electron", () => ({})); | ||
| 9 | |||
| 10 | import * as DesktopConfig from "../../app/DesktopConfig.ts"; | ||
| 11 | import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; | ||
| 12 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | ||
| 13 | import * as DesktopVideo from "../../media/DesktopVideo.ts"; | ||
| 14 | import { getDesktopUrl } from "../../electron/ElectronProtocol.ts"; | ||
| 15 | import { openVideo } from "./media.ts"; | ||
| 16 | |||
| 17 | it.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 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; | ||
| 5 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | ||
| 6 | import { getDesktopUrl } from "../../electron/ElectronProtocol.ts"; | ||
| 7 | import * as DesktopVideo from "../../media/DesktopVideo.ts"; | ||
| 8 | import * as DesktopIpc from "../DesktopIpc.ts"; | ||
| 9 | import * as IpcChannels from "../channels.ts"; | ||
| 10 | |||
| 11 | export 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"; | ... | @@ -23,6 +23,7 @@ import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; |
| 23 | import serverPackageJson from "../../server/package.json" with { type: "json" }; | 23 | import serverPackageJson from "../../server/package.json" with { type: "json" }; |
| 24 | 24 | ||
| 25 | import * as DesktopIpc from "./ipc/DesktopIpc.ts"; | 25 | import * as DesktopIpc from "./ipc/DesktopIpc.ts"; |
| 26 | import * as DesktopVideo from "./media/DesktopVideo.ts"; | ||
| 26 | import * as ElectronApp from "./electron/ElectronApp.ts"; | 27 | import * as ElectronApp from "./electron/ElectronApp.ts"; |
| 27 | import * as ElectronDialog from "./electron/ElectronDialog.ts"; | 28 | import * as ElectronDialog from "./electron/ElectronDialog.ts"; |
| 28 | import * as ElectronMenu from "./electron/ElectronMenu.ts"; | 29 | import * as ElectronMenu from "./electron/ElectronMenu.ts"; |
| ... | @@ -216,6 +217,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( | ... | @@ -216,6 +217,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( |
| 216 | 217 | ||
| 217 | const layerDesktopApplication = Layer.mergeAll( | 218 | const layerDesktopApplication = Layer.mergeAll( |
| 218 | DesktopLifecycle.layer, | 219 | DesktopLifecycle.layer, |
| 220 | DesktopVideo.layer, | ||
| 219 | layerDesktopAppActivation, | 221 | layerDesktopAppActivation, |
| 220 | DesktopWebLinks.layer, | 222 | DesktopWebLinks.layer, |
| 221 | DesktopApplicationMenu.layer, | 223 | DesktopApplicationMenu.layer, |
apps/desktop/src/media/DesktopVideo.test.ts created+167| ... | @@ -0,0 +1,167 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 8 | import { beforeEach, vi } from "vite-plus/test"; | ||
| 9 | |||
| 10 | const { fetchVideo, open } = vi.hoisted(() => ({ fetchVideo: vi.fn(), open: vi.fn() })); | ||
| 11 | vi.mock("electron", () => ({ net: { fetch: fetchVideo } })); | ||
| 12 | |||
| 13 | import * as DesktopVideo from "./DesktopVideo.ts"; | ||
| 14 | |||
| 15 | const spawner = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { | ||
| 16 | exitCode: (command) => | ||
| 17 | Effect.sync(() => { | ||
| 18 | open(command); | ||
| 19 | return ChildProcessSpawner.ExitCode(0); | ||
| 20 | }), | ||
| 21 | }); | ||
| 22 | |||
| 23 | beforeEach(() => { | ||
| 24 | fetchVideo.mockReset().mockImplementation( | ||
| 25 | async () => | ||
| 26 | new Response("video bytes", { | ||
| 27 | headers: { "content-type": "video/mp4" }, | ||
| 28 | }), | ||
| 29 | ); | ||
| 30 | open.mockReset(); | ||
| 31 | }); | ||
| 32 | |||
| 33 | const layer = DesktopVideo.layer.pipe(Layer.provide(spawner)); | ||
| 34 | |||
| 35 | it.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 | |||
| 66 | it.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 | |||
| 84 | it.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 | |||
| 102 | it.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 | |||
| 125 | it.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 | |||
| 152 | it.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 @@ | ||
| 1 | import { VIDEO_FILE_EXTENSIONS, videoMimeType } from "@t3tools/shared/video"; | ||
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import * as Scope from "effect/Scope"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | import * as ChildProcess from "effect/process/ChildProcess"; | ||
| 12 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 13 | import * as Electron from "electron"; | ||
| 14 | |||
| 15 | export 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 | |||
| 36 | export 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 | |||
| 46 | const 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 | |||
| 122 | export const layer = Layer.effect(DesktopVideo, make); | ||
apps/desktop/src/preload.ts+6| ... | @@ -226,6 +226,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -226,6 +226,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 226 | cancelProviderAuthCallback: (url: string) => | 226 | cancelProviderAuthCallback: (url: string) => |
| 227 | ipcRenderer.invoke(IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL, url), | 227 | ipcRenderer.invoke(IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL, url), |
| 228 | openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), | 228 | 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 | : {}), | ||
| 229 | checkSystemPermission: (pane: string) => | 235 | checkSystemPermission: (pane: string) => |
| 230 | ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane), | 236 | ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane), |
| 231 | openSystemSettings: (pane: string) => | 237 | openSystemSettings: (pane: string) => |
apps/web/package.json+8-12| ... | @@ -14,7 +14,13 @@ | ... | @@ -14,7 +14,13 @@ |
| 14 | "@base-ui/react": "^1.4.1", | 14 | "@base-ui/react": "^1.4.1", |
| 15 | "@clerk/electron": "catalog:", | 15 | "@clerk/electron": "catalog:", |
| 16 | "@clerk/react": "catalog:", | 16 | "@clerk/react": "catalog:", |
| 17 | "@clo/markdown-editor": "workspace:*", | ||
| 17 | "@clo/terminal": "workspace:*", | 18 | "@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", | ||
| 18 | "@daypicker/react": "^10.0.1", | 24 | "@daypicker/react": "^10.0.1", |
| 19 | "@dnd-kit/core": "^6.3.1", | 25 | "@dnd-kit/core": "^6.3.1", |
| 20 | "@dnd-kit/modifiers": "^9.0.0", | 26 | "@dnd-kit/modifiers": "^9.0.0", |
| ... | @@ -23,6 +29,7 @@ | ... | @@ -23,6 +29,7 @@ |
| 23 | "@effect/atom-react": "catalog:", | 29 | "@effect/atom-react": "catalog:", |
| 24 | "@formkit/auto-animate": "^0.9.0", | 30 | "@formkit/auto-animate": "^0.9.0", |
| 25 | "@legendapp/list": "catalog:", | 31 | "@legendapp/list": "catalog:", |
| 32 | "@lezer/common": "^1.5.2", | ||
| 26 | "@noble/hashes": "catalog:", | 33 | "@noble/hashes": "catalog:", |
| 27 | "@pierre/diffs": "catalog:", | 34 | "@pierre/diffs": "catalog:", |
| 28 | "@pierre/trees": "1.0.0-beta.6", | 35 | "@pierre/trees": "1.0.0-beta.6", |
| ... | @@ -38,22 +45,11 @@ | ... | @@ -38,22 +45,11 @@ |
| 38 | "@t3tools/shared": "workspace:*", | 45 | "@t3tools/shared": "workspace:*", |
| 39 | "@tanstack/react-pacer": "^0.19.4", | 46 | "@tanstack/react-pacer": "^0.19.4", |
| 40 | "@tanstack/react-router": "^1.160.2", | 47 | "@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", | ||
| 53 | "class-variance-authority": "^0.7.1", | 48 | "class-variance-authority": "^0.7.1", |
| 54 | "culori": "^4.0.2", | 49 | "culori": "^4.0.2", |
| 55 | "dompurify": "^3.4.16", | 50 | "dompurify": "^3.4.16", |
| 56 | "effect": "catalog:", | 51 | "effect": "catalog:", |
| 52 | "gemoji": "^8.1.0", | ||
| 57 | "hast-util-to-html": "^9.0.5", | 53 | "hast-util-to-html": "^9.0.5", |
| 58 | "hast-util-to-jsx-runtime": "^2.3.6", | 54 | "hast-util-to-jsx-runtime": "^2.3.6", |
| 59 | "heic-to": "^1.5.2", | 55 | "heic-to": "^1.5.2", |
apps/web/src/appearanceFonts.ts+1| ... | @@ -103,6 +103,7 @@ export function applyAppearanceFontVariables( | ... | @@ -103,6 +103,7 @@ export function applyAppearanceFontVariables( |
| 103 | const families: ReadonlyArray<readonly [variable: string, custom: string, fallback: string]> = [ | 103 | const families: ReadonlyArray<readonly [variable: string, custom: string, fallback: string]> = [ |
| 104 | ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], | 104 | ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], |
| 105 | ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], | 105 | ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], |
| 106 | ["--chat-code-font", preferences.code, "var(--font-mono)"], | ||
| 106 | // The composer falls back to whatever the sans preference resolves to. | 107 | // The composer falls back to whatever the sans preference resolves to. |
| 107 | ["--font-composer", preferences.composer, "var(--font-sans)"], | 108 | ["--font-composer", preferences.composer, "var(--font-sans)"], |
| 108 | ]; | 109 | ]; |
apps/web/src/assets/fonts/Recursive-OFL.txt created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | Copyright 2020 The Recursive Project Authors (https://github.com/arrowtype/recursive) | ||
| 2 | |||
| 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. | ||
| 4 | This license is copied below, and is also available with a FAQ at: | ||
| 5 | http://scripts.sil.org/OFL | ||
| 6 | |||
| 7 | |||
| 8 | ----------------------------------------------------------- | ||
| 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 | ||
| 10 | ----------------------------------------------------------- | ||
| 11 | |||
| 12 | PREAMBLE | ||
| 13 | The goals of the Open Font License (OFL) are to stimulate worldwide | ||
| 14 | development of collaborative font projects, to support the font creation | ||
| 15 | efforts of academic and linguistic communities, and to provide a free and | ||
| 16 | open framework in which fonts may be shared and improved in partnership | ||
| 17 | with others. | ||
| 18 | |||
| 19 | The OFL allows the licensed fonts to be used, studied, modified and | ||
| 20 | redistributed freely as long as they are not sold by themselves. The | ||
| 21 | fonts, including any derivative works, can be bundled, embedded, | ||
| 22 | redistributed and/or sold with any software provided that any reserved | ||
| 23 | names are not used by derivative works. The fonts and derivatives, | ||
| 24 | however, cannot be released under any other type of license. The | ||
| 25 | requirement for fonts to remain under this license does not apply | ||
| 26 | to any document created using the fonts or their derivatives. | ||
| 27 | |||
| 28 | DEFINITIONS | ||
| 29 | "Font Software" refers to the set of files released by the Copyright | ||
| 30 | Holder(s) under this license and clearly marked as such. This may | ||
| 31 | include source files, build scripts and documentation. | ||
| 32 | |||
| 33 | "Reserved Font Name" refers to any names specified as such after the | ||
| 34 | copyright statement(s). | ||
| 35 | |||
| 36 | "Original Version" refers to the collection of Font Software components as | ||
| 37 | distributed by the Copyright Holder(s). | ||
| 38 | |||
| 39 | "Modified Version" refers to any derivative made by adding to, deleting, | ||
| 40 | or substituting -- in part or in whole -- any of the components of the | ||
| 41 | Original Version, by changing formats or by porting the Font Software to a | ||
| 42 | new environment. | ||
| 43 | |||
| 44 | "Author" refers to any designer, engineer, programmer, technical | ||
| 45 | writer or other person who contributed to the Font Software. | ||
| 46 | |||
| 47 | PERMISSION & CONDITIONS | ||
| 48 | Permission is hereby granted, free of charge, to any person obtaining | ||
| 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, | ||
| 50 | redistribute, and sell modified and unmodified copies of the Font | ||
| 51 | Software, subject to the following conditions: | ||
| 52 | |||
| 53 | 1) Neither the Font Software nor any of its individual components, | ||
| 54 | in Original or Modified Versions, may be sold by itself. | ||
| 55 | |||
| 56 | 2) Original or Modified Versions of the Font Software may be bundled, | ||
| 57 | redistributed and/or sold with any software, provided that each copy | ||
| 58 | contains the above copyright notice and this license. These can be | ||
| 59 | included either as stand-alone text files, human-readable headers or | ||
| 60 | in the appropriate machine-readable metadata fields within text or | ||
| 61 | binary files as long as those fields can be easily viewed by the user. | ||
| 62 | |||
| 63 | 3) No Modified Version of the Font Software may use the Reserved Font | ||
| 64 | Name(s) unless explicit written permission is granted by the corresponding | ||
| 65 | Copyright Holder. This restriction only applies to the primary font name as | ||
| 66 | presented to the users. | ||
| 67 | |||
| 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font | ||
| 69 | Software shall not be used to promote, endorse or advertise any | ||
| 70 | Modified Version, except to acknowledge the contribution(s) of the | ||
| 71 | Copyright Holder(s) and the Author(s) or with their explicit written | ||
| 72 | permission. | ||
| 73 | |||
| 74 | 5) The Font Software, modified or unmodified, in part or in whole, | ||
| 75 | must be distributed entirely under this license, and must not be | ||
| 76 | distributed under any other license. The requirement for fonts to | ||
| 77 | remain under this license does not apply to any document created | ||
| 78 | using the Font Software. | ||
| 79 | |||
| 80 | TERMINATION | ||
| 81 | This license becomes null and void if any of the above conditions are | ||
| 82 | not met. | ||
| 83 | |||
| 84 | DISCLAIMER | ||
| 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
| 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF | ||
| 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT | ||
| 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE | ||
| 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | ||
| 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL | ||
| 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | ||
| 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM | ||
| 93 | OTHER 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"; | ... | @@ -19,6 +19,9 @@ import { setMarkdownTaskChecked } from "./files/filePreviewMode"; |
| 19 | 19 | ||
| 20 | vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); | 20 | vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); |
| 21 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | 21 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 22 | vi.mock("../state/query", () => ({ | ||
| 23 | useEnvironmentQuery: () => ({ data: null, error: null }), | ||
| 24 | })); | ||
| 22 | vi.mock("./chat/MermaidDiagram", () => ({ | 25 | vi.mock("./chat/MermaidDiagram", () => ({ |
| 23 | // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG. | 26 | // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG. |
| 24 | MermaidDiagram: () => <svg aria-label="Diagram" />, | 27 | MermaidDiagram: () => <svg aria-label="Diagram" />, |
| ... | @@ -95,6 +98,286 @@ function codeButton(renderer: ReactTestRenderer, label: string) { | ... | @@ -95,6 +98,286 @@ function codeButton(renderer: ReactTestRenderer, label: string) { |
| 95 | return button.props as ComponentProps<typeof Button>; | 98 | return button.props as ComponentProps<typeof Button>; |
| 96 | } | 99 | } |
| 97 | 100 | ||
| 101 | describe("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 | |||
| 154 | describe("ChatMarkdown consecutive videos", () => { | ||
| 155 | it.each([ | ||
| 156 | [ | ||
| 157 | "separate paragraphs", | ||
| 158 | "\n\n", | ||
| 159 | ], | ||
| 160 | [ | ||
| 161 | "one paragraph", | ||
| 162 | " ", | ||
| 163 | ], | ||
| 164 | [ | ||
| 165 | "hard breaks", | ||
| 166 | " \n", | ||
| 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 | "", | ||
| 210 | "", | ||
| 211 | "Between videos.", | ||
| 212 | "", | ||
| 213 | "[Source](https://media.test/source)", | ||
| 214 | "", | ||
| 215 | "Caption ", | ||
| 216 | "", | ||
| 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 | '\n\n\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\n\n' | ||
| 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}`; | ||
| 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`} | ||
| 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\n\n`} | ||
| 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 | |||
| 98 | describe("ChatMarkdown bare anchor placeholders", () => { | 381 | describe("ChatMarkdown bare anchor placeholders", () => { |
| 99 | it.each(["<A>", "<a>", "<a >", "<a/>", "<A/>", "<a />"])( | 382 | it.each(["<A>", "<a>", "<a >", "<a/>", "<A/>", "<a />"])( |
| 100 | "preserves unmatched %s without linking later blocks", | 383 | "preserves unmatched %s without linking later blocks", |
| ... | @@ -1014,7 +1297,7 @@ describe("ChatMarkdown Windows file links", () => { | ... | @@ -1014,7 +1297,7 @@ describe("ChatMarkdown Windows file links", () => { |
| 1014 | <ChatMarkdown | 1297 | <ChatMarkdown |
| 1015 | cwd="C:/Users/shawn/project" | 1298 | cwd="C:/Users/shawn/project" |
| 1016 | environmentId={environmentId} | 1299 | 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)`} |
| 1018 | lineBreaks={!parseRawHtml} | 1301 | lineBreaks={!parseRawHtml} |
| 1019 | parseRawHtml={parseRawHtml} | 1302 | parseRawHtml={parseRawHtml} |
| 1020 | />, | 1303 | />, |
apps/web/src/components/ChatMarkdown.tsx+123-39| ... | @@ -107,7 +107,6 @@ import { parseComposerContextHref } from "@t3tools/shared/composerContextReferen | ... | @@ -107,7 +107,6 @@ import { parseComposerContextHref } from "@t3tools/shared/composerContextReferen |
| 107 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; | 107 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; |
| 108 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; | 108 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; |
| 109 | import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; | 109 | import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; |
| 110 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | ||
| 111 | import { | 110 | import { |
| 112 | artifactTemplateFromHastProperties, | 111 | artifactTemplateFromHastProperties, |
| 113 | renderCodexInlineDirectivesAsMarkdown, | 112 | renderCodexInlineDirectivesAsMarkdown, |
| ... | @@ -496,16 +495,6 @@ function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): | ... | @@ -496,16 +495,6 @@ function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): |
| 496 | ); | 495 | ); |
| 497 | } | 496 | } |
| 498 | 497 | ||
| 499 | type MarkdownAstNode = { | ||
| 500 | type?: string; | ||
| 501 | meta?: unknown; | ||
| 502 | url?: string; | ||
| 503 | data?: { | ||
| 504 | hProperties?: Record<string, unknown>; | ||
| 505 | }; | ||
| 506 | children?: MarkdownAstNode[]; | ||
| 507 | }; | ||
| 508 | |||
| 509 | function nodeToPlainText(node: ReactNode): string { | 498 | function nodeToPlainText(node: ReactNode): string { |
| 510 | if (typeof node === "string" || typeof node === "number") { | 499 | if (typeof node === "string" || typeof node === "number") { |
| 511 | return String(node); | 500 | return String(node); |
| ... | @@ -1207,7 +1196,7 @@ interface MarkdownFileLinkProps { | ... | @@ -1207,7 +1196,7 @@ interface MarkdownFileLinkProps { |
| 1207 | absolute host path outside it, null when the panel cannot show the file. */ | 1196 | absolute host path outside it, null when the panel cannot show the file. */ |
| 1208 | panelPath: string | null; | 1197 | panelPath: string | null; |
| 1209 | line?: number | undefined; | 1198 | line?: number | undefined; |
| 1210 | label: string; | 1199 | label: ReactNode; |
| 1211 | copyMarkdown: string; | 1200 | copyMarkdown: string; |
| 1212 | theme: "light" | "dark"; | 1201 | theme: "light" | "dark"; |
| 1213 | threadRef?: ScopedThreadRef | undefined; | 1202 | threadRef?: ScopedThreadRef | undefined; |
| ... | @@ -1560,7 +1549,7 @@ function ChatMarkdownVideo(props: { | ... | @@ -1560,7 +1549,7 @@ function ChatMarkdownVideo(props: { |
| 1560 | className={cn( | 1549 | className={cn( |
| 1561 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, | 1550 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, |
| 1562 | CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, | 1551 | CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, |
| 1563 | "w-full", | 1552 | "w-full max-w-[min(100%,30rem,calc(30rem*var(--media-video-ratio)))]", |
| 1564 | )} | 1553 | )} |
| 1565 | videoClassName={cn( | 1554 | videoClassName={cn( |
| 1566 | CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, | 1555 | CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, |
| ... | @@ -1718,8 +1707,8 @@ function leadingExternalLinkTextLength(text: string): number { | ... | @@ -1718,8 +1707,8 @@ function leadingExternalLinkTextLength(text: string): number { |
| 1718 | function breakableExternalLinkText(text: string): ReactNode[] { | 1707 | function breakableExternalLinkText(text: string): ReactNode[] { |
| 1719 | return Array.from(text, (character, index) => ( | 1708 | return Array.from(text, (character, index) => ( |
| 1720 | <React.Fragment key={`${index}:${character}`}> | 1709 | <React.Fragment key={`${index}:${character}`}> |
| 1710 | {index > 0 && <wbr />} | ||
| 1721 | {character} | 1711 | {character} |
| 1722 | <wbr /> | ||
| 1723 | </React.Fragment> | 1712 | </React.Fragment> |
| 1724 | )); | 1713 | )); |
| 1725 | } | 1714 | } |
| ... | @@ -1833,13 +1822,90 @@ function handleMarkdownFragmentClick(event: ReactMouseEvent<HTMLAnchorElement>, | ... | @@ -1833,13 +1822,90 @@ function handleMarkdownFragmentClick(event: ReactMouseEvent<HTMLAnchorElement>, |
| 1833 | findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); | 1822 | findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); |
| 1834 | } | 1823 | } |
| 1835 | 1824 | ||
| 1836 | type HeadingHastNode = { | 1825 | type MarkdownHastNode = { |
| 1837 | type?: string; | 1826 | type?: string; |
| 1827 | value?: string; | ||
| 1838 | tagName?: string; | 1828 | tagName?: string; |
| 1839 | properties?: Record<string, unknown>; | 1829 | properties?: Record<string, unknown>; |
| 1840 | children?: HeadingHastNode[]; | 1830 | children?: MarkdownHastNode[]; |
| 1841 | }; | 1831 | }; |
| 1842 | 1832 | ||
| 1833 | function 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 | |||
| 1843 | /** GitHub's heading anchor slug, so `[Setup](#setup)` table-of-contents links find their heading. */ | 1909 | /** GitHub's heading anchor slug, so `[Setup](#setup)` table-of-contents links find their heading. */ |
| 1844 | function githubHeadingSlug(text: string): string { | 1910 | function githubHeadingSlug(text: string): string { |
| 1845 | return text | 1911 | return text |
| ... | @@ -1855,18 +1921,18 @@ function githubHeadingSlug(text: string): string { | ... | @@ -1855,18 +1921,18 @@ function githubHeadingSlug(text: string): string { |
| 1855 | * fragment lookup strips it. | 1921 | * fragment lookup strips it. |
| 1856 | */ | 1922 | */ |
| 1857 | function rehypeHeadingIds() { | 1923 | function rehypeHeadingIds() { |
| 1858 | return (tree: HeadingHastNode) => { | 1924 | return (tree: MarkdownHastNode) => { |
| 1859 | // Every id already in the document, authored or assigned, so a suffix never | 1925 | // Every id already in the document, authored or assigned, so a suffix never |
| 1860 | // lands on one that exists: `Setup`, `Setup`, `Setup-1` get three distinct ids. | 1926 | // lands on one that exists: `Setup`, `Setup`, `Setup-1` get three distinct ids. |
| 1861 | const taken = new Set<string>(); | 1927 | const taken = new Set<string>(); |
| 1862 | const collect = (node: HeadingHastNode) => { | 1928 | const collect = (node: MarkdownHastNode) => { |
| 1863 | const id = node.properties?.id; | 1929 | const id = node.properties?.id; |
| 1864 | if (typeof id === "string") taken.add(id); | 1930 | if (typeof id === "string") taken.add(id); |
| 1865 | node.children?.forEach(collect); | 1931 | node.children?.forEach(collect); |
| 1866 | }; | 1932 | }; |
| 1867 | collect(tree); | 1933 | collect(tree); |
| 1868 | const nextSuffix = new Map<string, number>(); | 1934 | const nextSuffix = new Map<string, number>(); |
| 1869 | const visit = (node: HeadingHastNode) => { | 1935 | const visit = (node: MarkdownHastNode) => { |
| 1870 | if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { | 1936 | if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { |
| 1871 | const slug = githubHeadingSlug(hastPlainTextDeep(node)); | 1937 | const slug = githubHeadingSlug(hastPlainTextDeep(node)); |
| 1872 | if (node.properties?.id === undefined && slug) { | 1938 | if (node.properties?.id === undefined && slug) { |
| ... | @@ -1892,11 +1958,13 @@ function rehypeHeadingIds() { | ... | @@ -1892,11 +1958,13 @@ function rehypeHeadingIds() { |
| 1892 | const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ | 1958 | const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ |
| 1893 | ...CHAT_MARKDOWN_REHYPE_PLUGINS, | 1959 | ...CHAT_MARKDOWN_REHYPE_PLUGINS, |
| 1894 | rehypeHeadingIds, | 1960 | rehypeHeadingIds, |
| 1961 | rehypeVideoRows, | ||
| 1895 | ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; | 1962 | ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; |
| 1896 | 1963 | ||
| 1897 | const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< | 1964 | const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [ |
| 1898 | ReactMarkdownOptions["rehypePlugins"] | 1965 | rehypeHeadingIds, |
| 1899 | >; | 1966 | rehypeVideoRows, |
| 1967 | ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; | ||
| 1900 | 1968 | ||
| 1901 | function MarkdownExternalLinkContent({ | 1969 | function MarkdownExternalLinkContent({ |
| 1902 | host, | 1970 | host, |
| ... | @@ -2241,6 +2309,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -2241,6 +2309,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 2241 | render={<a href={href} />} | 2309 | render={<a href={href} />} |
| 2242 | className={MARKDOWN_FILE_LINK_CLASS_NAME} | 2310 | className={MARKDOWN_FILE_LINK_CLASS_NAME} |
| 2243 | data-markdown-copy={copyMarkdown} | 2311 | data-markdown-copy={copyMarkdown} |
| 2312 | aria-description={targetPath} | ||
| 2244 | onClick={(event) => { | 2313 | onClick={(event) => { |
| 2245 | event.preventDefault(); | 2314 | event.preventDefault(); |
| 2246 | event.stopPropagation(); | 2315 | event.stopPropagation(); |
| ... | @@ -2267,7 +2336,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -2267,7 +2336,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 2267 | <ContextChip | 2336 | <ContextChip |
| 2268 | kind="mention" | 2337 | kind="mention" |
| 2269 | render={<button type="button" />} | 2338 | render={<button type="button" />} |
| 2270 | aria-label={`File options for ${label}`} | 2339 | aria-label={`File options for ${nodeToPlainText(label)}`} |
| 2340 | aria-description={targetPath} | ||
| 2271 | aria-haspopup="menu" | 2341 | aria-haspopup="menu" |
| 2272 | className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} | 2342 | className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} |
| 2273 | data-markdown-copy={copyMarkdown} | 2343 | data-markdown-copy={copyMarkdown} |
| ... | @@ -2660,7 +2730,12 @@ function useChatMarkdownState({ | ... | @@ -2660,7 +2730,12 @@ function useChatMarkdownState({ |
| 2660 | [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], | 2730 | [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], |
| 2661 | ); | 2731 | ); |
| 2662 | const fileLinkChip = useCallback( | 2732 | const fileLinkChip = useCallback( |
| 2663 | (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => { | 2733 | ( |
| 2734 | fileLinkMeta: MarkdownFileLinkMeta, | ||
| 2735 | copyMarkdown: string, | ||
| 2736 | mediaSource?: string, | ||
| 2737 | label?: ReactNode, | ||
| 2738 | ) => { | ||
| 2664 | const mediaPath = mediaSource ?? fileLinkMeta.filePath; | 2739 | const mediaPath = mediaSource ?? fileLinkMeta.filePath; |
| 2665 | const canPreviewMedia = | 2740 | const canPreviewMedia = |
| 2666 | mediaMimeTypeFromExtension( | 2741 | mediaMimeTypeFromExtension( |
| ... | @@ -2681,14 +2756,17 @@ function useChatMarkdownState({ | ... | @@ -2681,14 +2756,17 @@ function useChatMarkdownState({ |
| 2681 | displayPath={fileLinkMeta.displayPath} | 2756 | displayPath={fileLinkMeta.displayPath} |
| 2682 | panelPath={panelPath} | 2757 | panelPath={panelPath} |
| 2683 | line={fileLinkMeta.line} | 2758 | line={fileLinkMeta.line} |
| 2684 | label={fileLinkLabel( | 2759 | label={ |
| 2685 | { | 2760 | label ?? |
| 2686 | path: fileLinkMeta.filePath, | 2761 | fileLinkLabel( |
| 2687 | ...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}), | 2762 | { |
| 2688 | ...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}), | 2763 | path: fileLinkMeta.filePath, |
| 2689 | }, | 2764 | ...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}), |
| 2690 | fileLinkParentSuffixByPath, | 2765 | ...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}), |
| 2691 | )} | 2766 | }, |
| 2767 | fileLinkParentSuffixByPath, | ||
| 2768 | ) | ||
| 2769 | } | ||
| 2692 | copyMarkdown={copyMarkdown} | 2770 | copyMarkdown={copyMarkdown} |
| 2693 | theme={resolvedTheme} | 2771 | theme={resolvedTheme} |
| 2694 | threadRef={threadRef} | 2772 | threadRef={threadRef} |
| ... | @@ -2851,6 +2929,14 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2851,6 +2929,14 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2851 | <CodexArtifactTemplateCard template={artifactTemplate} onUse={onUseArtifactTemplate} /> | 2929 | <CodexArtifactTemplateCard template={artifactTemplate} onUse={onUseArtifactTemplate} /> |
| 2852 | ); | 2930 | ); |
| 2853 | } | 2931 | } |
| 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 | } | ||
| 2854 | return <div {...props}>{children}</div>; | 2940 | return <div {...props}>{children}</div>; |
| 2855 | }, | 2941 | }, |
| 2856 | p: function MarkdownParagraph({ node: _node, children, ...props }) { | 2942 | p: function MarkdownParagraph({ node: _node, children, ...props }) { |
| ... | @@ -3196,13 +3282,11 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -3196,13 +3282,11 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3196 | source.startsWith("[") && source.includes("](") | 3282 | source.startsWith("[") && source.includes("](") |
| 3197 | ? source | 3283 | ? source |
| 3198 | : `[${(label || fileLinkMeta.basename).replace(/[\\[\]]/g, "\\$&")}](${normalizedHref})`; | 3284 | : `[${(label || fileLinkMeta.basename).replace(/[\\[\]]/g, "\\$&")}](${normalizedHref})`; |
| 3199 | const chip = fileLinkChip(fileLinkMeta, copyMarkdown, normalizedHref); | 3285 | return fileLinkChip( |
| 3200 | return isMarkdownFileLinkLabel(label, normalizedHref) ? ( | 3286 | fileLinkMeta, |
| 3201 | chip | 3287 | copyMarkdown, |
| 3202 | ) : ( | 3288 | normalizedHref, |
| 3203 | <span data-markdown-copy={copyMarkdown}> | 3289 | isMarkdownFileLinkLabel(label, normalizedHref) ? undefined : children, |
| 3204 | {children} {chip} | ||
| 3205 | </span> | ||
| 3206 | ); | 3290 | ); |
| 3207 | }, | 3291 | }, |
| 3208 | code: function MarkdownCode({ node, children, className, ...props }) { | 3292 | 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 | |||
| 3 | import { act, createRef, useLayoutEffect, useState } from "react"; | ||
| 4 | import { createRoot, type Root } from "react-dom/client"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | createMemoryHistory, | ||
| 9 | createRootRoute, | ||
| 10 | createRouter, | ||
| 11 | RouterProvider, | ||
| 12 | } from "@tanstack/react-router"; | ||
| 13 | import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; | ||
| 14 | import { collapseExpandedComposerCursor } from "~/composer-logic"; | ||
| 15 | import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; | ||
| 16 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | ||
| 17 | import { ComposerContextActionsContext } from "./composerContextPresentation"; | ||
| 18 | |||
| 19 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | ||
| 20 | |||
| 21 | let root: Root; | ||
| 22 | let container: HTMLDivElement; | ||
| 23 | const handle = createRef<ComposerPromptEditorHandle>(); | ||
| 24 | let replace: (value: string, cursor: number) => void; | ||
| 25 | let enableRichText: (enabled: boolean) => void; | ||
| 26 | const openMention = vi.fn(); | ||
| 27 | const commandKey = vi.fn((_key: string, _event: KeyboardEvent) => false); | ||
| 28 | const changes: Array<{ value: string; suppressed: boolean }> = []; | ||
| 29 | |||
| 30 | function 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 | |||
| 70 | beforeEach(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 | |||
| 90 | afterEach(async () => { | ||
| 91 | await act(() => root.unmount()); | ||
| 92 | container.remove(); | ||
| 93 | vi.unstubAllGlobals(); | ||
| 94 | }); | ||
| 95 | |||
| 96 | describe("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 | |||
| 157 | it("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 | |||
| 177 | it("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 | |||
| 189 | it("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 | |||
| 229 | it("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 @@ | ... | @@ -1,17 +1,628 @@ |
| 1 | import { ComposerPromptEditorTiptap } from "./ComposerPromptEditorTiptap"; | 1 | import type { ComposerContextClipboardFragment, ServerProviderSkill } from "@t3tools/contracts"; |
| 2 | import type { ComposerPromptEditorProps } from "./ComposerPromptEditorTiptap"; | 2 | import { |
| 3 | 3 | COMPOSER_CONTEXT_CLIPBOARD_MIME, | |
| 4 | export type { | 4 | encodeComposerContextClipboardHtml, |
| 5 | ComposerCitationCommentRequest, | 5 | } from "@t3tools/shared/composerContextClipboard"; |
| 6 | ComposerPromptEditorHandle, | 6 | import { |
| 7 | ComposerPromptEditorProps, | 7 | serializeAssistantCitation, |
| 8 | } from "./ComposerPromptEditorTiptap"; | 8 | withAssistantCitationComment, |
| 9 | 9 | } from "@t3tools/shared/assistantCitations"; | |
| 10 | /** | 10 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; |
| 11 | * The composer editor. Tiptap in both modes: the `richTextEnabled` setting | 11 | import { isolateHistory } from "@codemirror/commands"; |
| 12 | * toggles Markdown styling, never the engine. Plain mode keeps Markdown | 12 | import { Transaction } from "@codemirror/state"; |
| 13 | * markers literal; `literalText` also preserves context-token sources as text. | 13 | import { createPortal } from "react-dom"; |
| 14 | */ | 14 | import { use, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; |
| 15 | |||
| 16 | import { expandCollapsedComposerCursor } from "~/composer-logic"; | ||
| 17 | import { collectInlineContextIds } from "~/lib/composerContextReferences"; | ||
| 18 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | ||
| 19 | import { basenameOfPath } from "~/pierre-icons"; | ||
| 20 | import { cn } from "~/lib/utils"; | ||
| 21 | import { | ||
| 22 | ComposerContextActionsContext, | ||
| 23 | ComposerContextRecordsContext, | ||
| 24 | ComposerContextReferenceChip, | ||
| 25 | type ComposerDraftContextRecords, | ||
| 26 | } from "./composerContextPresentation"; | ||
| 27 | import { | ||
| 28 | createComposerMarkdownEditor, | ||
| 29 | composerSuggestionsSuppressed, | ||
| 30 | composerTokens, | ||
| 31 | type ComposerChip, | ||
| 32 | type ComposerToken, | ||
| 33 | } from "./composerMarkdownEditor"; | ||
| 34 | import { ContextChip } from "./ContextChip"; | ||
| 35 | import { ContextChipPopover } from "./contextChipParts"; | ||
| 36 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; | ||
| 37 | import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal"; | ||
| 38 | import { FileTagChipContent } from "./chat/FileTagChip"; | ||
| 39 | import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; | ||
| 40 | import { ComposerCodeBlockLanguagePicker } from "./chat/ComposerCodeBlockLanguagePicker"; | ||
| 41 | import { languageOfInfoString, withInfoStringLanguage } from "~/composer-code-languages"; | ||
| 42 | import { SkillChipIcon } from "./chat/SkillInlineText"; | ||
| 43 | import { Button } from "./ui/button"; | ||
| 44 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | ||
| 45 | |||
| 46 | export 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 | |||
| 68 | export 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 | |||
| 118 | export type ComposerCitationCommentRequest = { | ||
| 119 | value: string; | ||
| 120 | citationStart: number; | ||
| 121 | sourceAnchor: AssistantCitationSourceAnchor; | ||
| 122 | insertedSpaces: CitationInsertedSpaces; | ||
| 123 | }; | ||
| 124 | |||
| 125 | type CitationInsertedSpaces = { before: boolean; after: boolean }; | ||
| 126 | type CitationComment = { | ||
| 127 | start: number; | ||
| 128 | sourceAnchor?: AssistantCitationSourceAnchor | undefined; | ||
| 129 | insertedSpaces?: CitationInsertedSpaces | undefined; | ||
| 130 | }; | ||
| 131 | |||
| 15 | export function ComposerPromptEditor(props: ComposerPromptEditorProps) { | 132 | export 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 | |||
| 514 | function 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 | ); | ||
| 17 | } | 628 | } |
apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.test.tsx+9-1| ... | @@ -48,6 +48,14 @@ let container: HTMLDivElement; | ... | @@ -48,6 +48,14 @@ let container: HTMLDivElement; |
| 48 | 48 | ||
| 49 | beforeEach(() => { | 49 | beforeEach(() => { |
| 50 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | 50 | 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 | }); | ||
| 51 | container = document.createElement("div"); | 59 | container = document.createElement("div"); |
| 52 | document.body.append(container); | 60 | document.body.append(container); |
| 53 | root = createRoot(container); | 61 | root = createRoot(container); |
| ... | @@ -75,6 +83,7 @@ function Composer({ | ... | @@ -75,6 +83,7 @@ function Composer({ |
| 75 | <ComposerPromptEditor | 83 | <ComposerPromptEditor |
| 76 | editorRef={editorRef} | 84 | editorRef={editorRef} |
| 77 | value={question ? answer : prompt} | 85 | value={question ? answer : prompt} |
| 86 | literalText={question} | ||
| 78 | cursor={0} | 87 | cursor={0} |
| 79 | contextRecords={new Map()} | 88 | contextRecords={new Map()} |
| 80 | skills={[]} | 89 | skills={[]} |
| ... | @@ -97,7 +106,6 @@ it("keeps an unsaved citation comment when a question takes the composer", async | ... | @@ -97,7 +106,6 @@ it("keeps an unsaved citation comment when a question takes the composer", async |
| 97 | await act(async () => root.render(<Composer question={false} editorRef={editorRef} />)); | 106 | await act(async () => root.render(<Composer question={false} editorRef={editorRef} />)); |
| 98 | await act(async () => | 107 | await act(async () => |
| 99 | editorRef.current?.requestCitationComment({ | 108 | editorRef.current?.requestCitationComment({ |
| 100 | previousValue: "See ", | ||
| 101 | value: promptBefore, | 109 | value: promptBefore, |
| 102 | citationStart: "See ".length, | 110 | citationStart: "See ".length, |
| 103 | sourceAnchor: {} as AssistantCitationSourceAnchor, | 111 | sourceAnchor: {} as AssistantCitationSourceAnchor, |
apps/web/src/components/ComposerPromptEditorTiptap.navigation.test.tsx deleted-126| ... | @@ -1,126 +0,0 @@ | ||
| 1 | // @vitest-environment jsdom | ||
| 2 | |||
| 3 | import type { Editor } from "@tiptap/core"; | ||
| 4 | import { TextSelection } from "@tiptap/pm/state"; | ||
| 5 | import { act, createRef } from "react"; | ||
| 6 | import { createRoot, type Root } from "react-dom/client"; | ||
| 7 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 8 | |||
| 9 | import { serializeEditorDoc } from "~/composer-rich-text-doc"; | ||
| 10 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | ||
| 11 | |||
| 12 | const 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 | |||
| 15 | let root: Root; | ||
| 16 | let container: HTMLDivElement; | ||
| 17 | |||
| 18 | beforeEach(() => { | ||
| 19 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 20 | container = document.createElement("div"); | ||
| 21 | document.body.append(container); | ||
| 22 | root = createRoot(container); | ||
| 23 | }); | ||
| 24 | |||
| 25 | afterEach(async () => { | ||
| 26 | await act(async () => root.unmount()); | ||
| 27 | container.remove(); | ||
| 28 | vi.unstubAllGlobals(); | ||
| 29 | }); | ||
| 30 | |||
| 31 | async 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 | |||
| 53 | function 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 | |||
| 60 | it("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 | |||
| 81 | it("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 | |||
| 92 | it("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 | |||
| 103 | it.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 | |||
| 118 | it("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 @@ | ||
| 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; | ||
| 2 | import { | ||
| 3 | Extension, | ||
| 4 | findChildren, | ||
| 5 | InputRule, | ||
| 6 | Node, | ||
| 7 | wrappingInputRule, | ||
| 8 | type JSONContent, | ||
| 9 | } from "@tiptap/core"; | ||
| 10 | import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; | ||
| 11 | import StarterKit from "@tiptap/starter-kit"; | ||
| 12 | import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; | ||
| 13 | import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; | ||
| 14 | import { | ||
| 15 | type EditorState, | ||
| 16 | NodeSelection, | ||
| 17 | Plugin, | ||
| 18 | PluginKey, | ||
| 19 | TextSelection, | ||
| 20 | } from "@tiptap/pm/state"; | ||
| 21 | import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; | ||
| 22 | import type { NodeType, ResolvedPos } from "@tiptap/pm/model"; | ||
| 23 | import type { | ||
| 24 | AssistantCitation, | ||
| 25 | ComposerContextClipboardFragment, | ||
| 26 | ServerProviderSkill, | ||
| 27 | } from "@t3tools/contracts"; | ||
| 28 | import { | ||
| 29 | serializeAssistantCitation, | ||
| 30 | withAssistantCitationComment, | ||
| 31 | } from "@t3tools/shared/assistantCitations"; | ||
| 32 | import { | ||
| 33 | COMPOSER_CONTEXT_CLIPBOARD_MIME, | ||
| 34 | encodeComposerContextClipboardHtml, | ||
| 35 | } from "@t3tools/shared/composerContextClipboard"; | ||
| 36 | import { | ||
| 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"; | ||
| 49 | import { EditorContent, useEditor } from "@tiptap/react"; | ||
| 50 | |||
| 51 | import { | ||
| 52 | clampCollapsedComposerCursor, | ||
| 53 | collapseExpandedComposerCursor, | ||
| 54 | expandCollapsedComposerCursor, | ||
| 55 | isCollapsedCursorAdjacentToInlineToken, | ||
| 56 | pastedPathQueryLength, | ||
| 57 | } from "~/composer-logic"; | ||
| 58 | import { | ||
| 59 | collectComposerPromptInlineTokens, | ||
| 60 | selectionTouchesMentionBoundary, | ||
| 61 | } from "~/composer-editor-mentions"; | ||
| 62 | import { | ||
| 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"; | ||
| 84 | import { | ||
| 85 | convertCodeFenceOnEnter, | ||
| 86 | exitCodeBlockOnClosingFence, | ||
| 87 | exitCodeBlockOnTrailingBlankLines, | ||
| 88 | indentCodeBlock, | ||
| 89 | indentedNewlineInCodeBlock, | ||
| 90 | selectionInOneCodeBlock, | ||
| 91 | } from "~/composer-code-block"; | ||
| 92 | import { | ||
| 93 | COMPOSER_UNDO_GROUP_DELAY, | ||
| 94 | type ComposerChangeKind, | ||
| 95 | groupUndoByChangeKind, | ||
| 96 | markAsClipboardEdit, | ||
| 97 | } from "~/composer-undo-grouping"; | ||
| 98 | import { collectInlineContextIds } from "~/lib/composerContextReferences"; | ||
| 99 | import { resolveDiffThemeName } from "~/lib/diffRendering"; | ||
| 100 | import { cn, isMacPlatform } from "~/lib/utils"; | ||
| 101 | import { basenameOfPath } from "~/pierre-icons"; | ||
| 102 | import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; | ||
| 103 | import { FileTagChipContent } from "./chat/FileTagChip"; | ||
| 104 | import { SkillChipIcon } from "./chat/SkillInlineText"; | ||
| 105 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; | ||
| 106 | import { getTimelinePageScrollKey } from "./chat/pageScrollController"; | ||
| 107 | import { ContextChipPopover } from "./contextChipParts"; | ||
| 108 | import { Button } from "./ui/button"; | ||
| 109 | import { ContextChip } from "./ContextChip"; | ||
| 110 | import { | ||
| 111 | ComposerContextActionsContext, | ||
| 112 | ComposerContextReferenceChip, | ||
| 113 | ComposerContextRecordsContext, | ||
| 114 | } from "./composerContextPresentation"; | ||
| 115 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | ||
| 116 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | ||
| 117 | import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; | ||
| 118 | import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal"; | ||
| 119 | import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; | ||
| 120 | import { importPastedComposerText } from "./composerInlineTokenPaste"; | ||
| 121 | import { didComposerSelectionChangeVisibly } from "./composerSelection"; | ||
| 122 | import type { ComposerDraftContextRecords } from "./composerContextPresentation"; | ||
| 123 | |||
| 124 | export 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 | |||
| 145 | export 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 | |||
| 202 | export 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. | ||
| 211 | type CitationInsertedSpaces = { before: boolean; after: boolean }; | ||
| 212 | |||
| 213 | type OpenCitationComment = { | ||
| 214 | key: string; | ||
| 215 | sourceAnchor?: AssistantCitationSourceAnchor; | ||
| 216 | removeOnCancel?: CitationInsertedSpaces; | ||
| 217 | }; | ||
| 218 | |||
| 219 | const 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 | |||
| 231 | const RichComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]); | ||
| 232 | |||
| 233 | const SURROUND_CLOSE: Record<string, string> = { | ||
| 234 | "(": ")", | ||
| 235 | "[": "]", | ||
| 236 | "{": "}", | ||
| 237 | "'": "'", | ||
| 238 | '"': '"', | ||
| 239 | "“": "”", | ||
| 240 | "`": "`", | ||
| 241 | "<": ">", | ||
| 242 | "«": "»", | ||
| 243 | "*": "*", | ||
| 244 | _: "_", | ||
| 245 | }; | ||
| 246 | |||
| 247 | function 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 | */ | ||
| 257 | const 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 | |||
| 260 | const 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 | |||
| 283 | function 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 | |||
| 321 | const 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 | |||
| 345 | function 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 | |||
| 378 | const 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 | |||
| 402 | function 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 | |||
| 509 | const 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 | |||
| 534 | function 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 | |||
| 548 | type StyledRange = { | ||
| 549 | from: number; | ||
| 550 | to: number; | ||
| 551 | markers: { at: number; side: number; text: string }[]; | ||
| 552 | }; | ||
| 553 | |||
| 554 | function 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 | */ | ||
| 600 | function 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 | */ | ||
| 629 | const 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. */ | ||
| 650 | function 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 | |||
| 663 | function 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 | */ | ||
| 674 | const 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 | */ | ||
| 695 | const 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 | */ | ||
| 728 | const 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. */ | ||
| 742 | function isInCodeBlock(view: EditorView): boolean { | ||
| 743 | return view.state.selection.$from.parent.type.spec.code === true; | ||
| 744 | } | ||
| 745 | |||
| 746 | const MarkerPluginKey = new PluginKey("composer-rich-markers"); | ||
| 747 | |||
| 748 | const 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 | |||
| 762 | function 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 | |||
| 798 | const 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 | |||
| 809 | type TiptapEditor = NonNullable<ReturnType<typeof useEditor>>; | ||
| 810 | |||
| 811 | export 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 | */ | ||
| 828 | const 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 | |||
| 842 | function 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 | */ | ||
| 1912 | function 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 | */ | ||
| 1933 | function 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 | */ | ||
| 1941 | function 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 { | ... | @@ -8,7 +8,11 @@ import { |
| 8 | type CSSProperties, | 8 | type CSSProperties, |
| 9 | } from "react"; | 9 | } from "react"; |
| 10 | import { ChatCanvasContext } from "./ChatCanvasContext"; | 10 | import { ChatCanvasContext } from "./ChatCanvasContext"; |
| 11 | import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; | 11 | import { |
| 12 | DETAILS_CARD_CLEARANCE, | ||
| 13 | resolveChatCanvasLayout, | ||
| 14 | type ChatCanvasPreview, | ||
| 15 | } from "./chatCanvasLayout"; | ||
| 12 | import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; | 16 | import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; |
| 13 | import { observeResize } from "../../lib/observeResize"; | 17 | import { observeResize } from "../../lib/observeResize"; |
| 14 | 18 | ||
| ... | @@ -144,6 +148,15 @@ export function ChatCanvas({ | ... | @@ -144,6 +148,15 @@ export function ChatCanvas({ |
| 144 | detailsCardTopInset, | 148 | detailsCardTopInset, |
| 145 | ]); | 149 | ]); |
| 146 | const { layout } = context; | 150 | 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 | |||
| 147 | return ( | 160 | return ( |
| 148 | <ChatCanvasContext value={context}> | 161 | <ChatCanvasContext value={context}> |
| 149 | <div | 162 | <div |
| ... | @@ -157,6 +170,8 @@ export function ChatCanvas({ | ... | @@ -157,6 +170,8 @@ export function ChatCanvas({ |
| 157 | "--chat-timeline-gutter": `${measurements.timelineGutter}px`, | 170 | "--chat-timeline-gutter": `${measurements.timelineGutter}px`, |
| 158 | "--chat-lane-inset-start": `${layout.chat.insetStart}px`, | 171 | "--chat-lane-inset-start": `${layout.chat.insetStart}px`, |
| 159 | "--chat-lane-inset-end": `${layout.chat.insetEnd}px`, | 172 | "--chat-lane-inset-end": `${layout.chat.insetEnd}px`, |
| 173 | "--chat-block-outset-start": `${blockOutsetStart}px`, | ||
| 174 | "--chat-block-outset-end": `${blockOutsetEnd}px`, | ||
| 160 | } as CSSProperties | 175 | } as CSSProperties |
| 161 | } | 176 | } |
| 162 | > | 177 | > |
apps/web/src/components/chat/ChatComposer.tsx+87-55| ... | @@ -94,7 +94,6 @@ import { | ... | @@ -94,7 +94,6 @@ import { |
| 94 | replaceTextRange, | 94 | replaceTextRange, |
| 95 | } from "../../composer-logic"; | 95 | } from "../../composer-logic"; |
| 96 | import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; | 96 | import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; |
| 97 | import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation"; | ||
| 98 | import { | 97 | import { |
| 99 | deriveComposerSendState, | 98 | deriveComposerSendState, |
| 100 | getAntigravitySendBlockReason, | 99 | getAntigravitySendBlockReason, |
| ... | @@ -293,7 +292,7 @@ import { | ... | @@ -293,7 +292,7 @@ import { |
| 293 | ComposerControlSeparator, | 292 | ComposerControlSeparator, |
| 294 | ComposerSelectControl, | 293 | ComposerSelectControl, |
| 295 | } from "./ComposerControl"; | 294 | } from "./ComposerControl"; |
| 296 | import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; | 295 | import { composerEmojiKeyAction, resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; |
| 297 | import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDetail.logic"; | 296 | import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDetail.logic"; |
| 298 | import { | 297 | import { |
| 299 | matchesPullRequestQuery, | 298 | matchesPullRequestQuery, |
| ... | @@ -1707,7 +1706,7 @@ export interface ChatComposerProps { | ... | @@ -1707,7 +1706,7 @@ export interface ChatComposerProps { |
| 1707 | value: string, | 1706 | value: string, |
| 1708 | nextCursor: number, | 1707 | nextCursor: number, |
| 1709 | expandedCursor: number, | 1708 | expandedCursor: number, |
| 1710 | cursorAdjacentToMention: boolean, | 1709 | suppressSuggestions: boolean, |
| 1711 | ) => void; | 1710 | ) => void; |
| 1712 | 1711 | ||
| 1713 | onProviderModelSelect: ( | 1712 | onProviderModelSelect: ( |
| ... | @@ -2617,6 +2616,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2617,6 +2616,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2617 | // Derived: composer trigger / menu | 2616 | // Derived: composer trigger / menu |
| 2618 | // ------------------------------------------------------------------ | 2617 | // ------------------------------------------------------------------ |
| 2619 | const composerTriggerKind = composerTrigger?.kind ?? null; | 2618 | 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 | |||
| 2620 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; | 2640 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; |
| 2621 | const pullRequestTriggerQuery = | 2641 | const pullRequestTriggerQuery = |
| 2622 | composerTrigger?.kind === "pull-request" ? composerTrigger.query : ""; | 2642 | composerTrigger?.kind === "pull-request" ? composerTrigger.query : ""; |
| ... | @@ -2720,6 +2740,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2720,6 +2740,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2720 | 2740 | ||
| 2721 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { | 2741 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { |
| 2722 | if (!composerTrigger) return []; | 2742 | if (!composerTrigger) return []; |
| 2743 | if (composerTrigger.kind === "emoji") { | ||
| 2744 | return emojiSearch && emojiSearch !== "error" | ||
| 2745 | ? emojiSearch.searchComposerEmoji(composerTrigger.query) | ||
| 2746 | : []; | ||
| 2747 | } | ||
| 2723 | if (composerTrigger.kind === "path") { | 2748 | if (composerTrigger.kind === "path") { |
| 2724 | // Threads only surface for a typed query so `@` alone stays a file picker. A title match | 2749 | // Threads only surface for a typed query so `@` alone stays a file picker. A title match |
| 2725 | // is far more specific than a fuzzy path hit, so the few threads lead the list. | 2750 | // 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) | ... | @@ -2877,6 +2902,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2877 | activeThreadId, | 2902 | activeThreadId, |
| 2878 | compactSlashCommandAvailable, | 2903 | compactSlashCommandAvailable, |
| 2879 | composerTrigger, | 2904 | composerTrigger, |
| 2905 | emojiSearch, | ||
| 2880 | environmentId, | 2906 | environmentId, |
| 2881 | environmentThreadShells, | 2907 | environmentThreadShells, |
| 2882 | exactPullRequestLookup.data, | 2908 | exactPullRequestLookup.data, |
| ... | @@ -2964,6 +2990,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2964,6 +2990,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2964 | ]); | 2990 | ]); |
| 2965 | 2991 | ||
| 2966 | const isComposerMenuLoading = | 2992 | const isComposerMenuLoading = |
| 2993 | (isEmojiTrigger && emojiSearch === null) || | ||
| 2967 | (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || | 2994 | (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || |
| 2968 | (composerTriggerKind === "pull-request" && | 2995 | (composerTriggerKind === "pull-request" && |
| 2969 | pullRequestProjectId !== null && | 2996 | pullRequestProjectId !== null && |
| ... | @@ -2973,6 +3000,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2973,6 +3000,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2973 | pullRequestTriggerNumber !== debouncedPullRequestNumber || | 3000 | pullRequestTriggerNumber !== debouncedPullRequestNumber || |
| 2974 | exactPullRequestLookup.isPending)); | 3001 | exactPullRequestLookup.isPending)); |
| 2975 | const composerMenuEmptyState = useMemo(() => { | 3002 | 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 | } | ||
| 2976 | if (composerTriggerKind === "skill") { | 3008 | if (composerTriggerKind === "skill") { |
| 2977 | return "No skills found. Try / to browse provider commands."; | 3009 | return "No skills found. Try / to browse provider commands."; |
| 2978 | } | 3010 | } |
| ... | @@ -2996,6 +3028,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2996,6 +3028,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2996 | }, [ | 3028 | }, [ |
| 2997 | composerTrigger, | 3029 | composerTrigger, |
| 2998 | composerTriggerKind, | 3030 | composerTriggerKind, |
| 3031 | emojiSearch, | ||
| 2999 | pullRequestLookup.data?.errors, | 3032 | pullRequestLookup.data?.errors, |
| 3000 | pullRequestLookup.error, | 3033 | pullRequestLookup.error, |
| 3001 | pullRequestProjectId, | 3034 | pullRequestProjectId, |
| ... | @@ -3827,7 +3860,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3827,7 +3860,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3827 | nextPrompt: string, | 3860 | nextPrompt: string, |
| 3828 | nextCursor: number, | 3861 | nextCursor: number, |
| 3829 | expandedCursor: number, | 3862 | expandedCursor: number, |
| 3830 | cursorAdjacentToMention: boolean, | 3863 | suppressSuggestions: boolean, |
| 3831 | contextIds: string[], | 3864 | contextIds: string[], |
| 3832 | ) => { | 3865 | ) => { |
| 3833 | expandComposerForEditorChange(); | 3866 | expandComposerForEditorChange(); |
| ... | @@ -3835,14 +3868,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3835,14 +3868,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3835 | if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; | 3868 | if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; |
| 3836 | setComposerCursor(nextCursor); | 3869 | setComposerCursor(nextCursor); |
| 3837 | setComposerTrigger( | 3870 | setComposerTrigger( |
| 3838 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), | 3871 | suppressSuggestions ? null : detectComposerTrigger(nextPrompt, expandedCursor), |
| 3839 | ); | 3872 | ); |
| 3840 | onChangeActivePendingUserInputCustomAnswer( | 3873 | onChangeActivePendingUserInputCustomAnswer( |
| 3841 | activePendingProgress.activeQuestion.id, | 3874 | activePendingProgress.activeQuestion.id, |
| 3842 | nextPrompt, | 3875 | nextPrompt, |
| 3843 | nextCursor, | 3876 | nextCursor, |
| 3844 | expandedCursor, | 3877 | expandedCursor, |
| 3845 | cursorAdjacentToMention, | 3878 | suppressSuggestions, |
| 3846 | ); | 3879 | ); |
| 3847 | return; | 3880 | return; |
| 3848 | } | 3881 | } |
| ... | @@ -3947,7 +3980,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3947,7 +3980,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3947 | } | 3980 | } |
| 3948 | setComposerCursor(nextCursor); | 3981 | setComposerCursor(nextCursor); |
| 3949 | setComposerTrigger( | 3982 | setComposerTrigger( |
| 3950 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), | 3983 | suppressSuggestions ? null : detectComposerTrigger(nextPrompt, expandedCursor), |
| 3951 | ); | 3984 | ); |
| 3952 | }, | 3985 | }, |
| 3953 | [ | 3986 | [ |
| ... | @@ -4026,7 +4059,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4026,7 +4059,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4026 | ); | 4059 | ); |
| 4027 | if (options?.citationComment) { | 4060 | if (options?.citationComment) { |
| 4028 | composerEditorRef.current?.requestCitationComment({ | 4061 | composerEditorRef.current?.requestCitationComment({ |
| 4029 | previousValue: currentText, | ||
| 4030 | value: next.text, | 4062 | value: next.text, |
| 4031 | citationStart: options.citationComment.start, | 4063 | citationStart: options.citationComment.start, |
| 4032 | sourceAnchor: options.citationComment.sourceAnchor, | 4064 | sourceAnchor: options.citationComment.sourceAnchor, |
| ... | @@ -4104,9 +4136,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4104,9 +4136,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4104 | const snapshot = readComposerSnapshot(); | 4136 | const snapshot = readComposerSnapshot(); |
| 4105 | return { | 4137 | return { |
| 4106 | snapshot, | 4138 | snapshot, |
| 4107 | trigger: isLiteralPendingAnswer | 4139 | trigger: |
| 4108 | ? null | 4140 | isLiteralPendingAnswer || composerEditorRef.current?.suggestionsSuppressed() |
| 4109 | : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), | 4141 | ? null |
| 4142 | : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), | ||
| 4110 | }; | 4143 | }; |
| 4111 | }, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]); | 4144 | }, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]); |
| 4112 | 4145 | ||
| ... | @@ -4120,6 +4153,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4120,6 +4153,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4120 | }); | 4153 | }); |
| 4121 | const { snapshot, trigger } = resolveActiveComposerTrigger(); | 4154 | const { snapshot, trigger } = resolveActiveComposerTrigger(); |
| 4122 | if (!trigger) return; | 4155 | 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 | } | ||
| 4123 | if (item.type === "path") { | 4170 | if (item.type === "path") { |
| 4124 | const replacement = `${serializeComposerFileLink(item.path)} `; | 4171 | const replacement = `${serializeComposerFileLink(item.path)} `; |
| 4125 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | 4172 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( |
| ... | @@ -4596,7 +4643,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4596,7 +4643,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4596 | // ------------------------------------------------------------------ | 4643 | // ------------------------------------------------------------------ |
| 4597 | // Callbacks: command key | 4644 | // Callbacks: command key |
| 4598 | // ------------------------------------------------------------------ | 4645 | // ------------------------------------------------------------------ |
| 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 | } | ||
| 4600 | const submissionIntent = composerSubmissionIntentForKey({ | 4662 | const submissionIntent = composerSubmissionIntentForKey({ |
| 4601 | event, | 4663 | event, |
| 4602 | keybindings, | 4664 | keybindings, |
| ... | @@ -4611,16 +4673,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4611,16 +4673,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4611 | toggleInteractionMode(); | 4673 | toggleInteractionMode(); |
| 4612 | return true; | 4674 | return true; |
| 4613 | } | 4675 | } |
| 4614 | const { trigger } = resolveActiveComposerTrigger(); | ||
| 4615 | const menuIsActive = | 4676 | const menuIsActive = |
| 4616 | !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); | 4677 | !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); |
| 4617 | if (key === "Escape") { | 4678 | if (key === "Escape") { |
| 4618 | if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; | 4679 | if (!menuIsActive) return false; |
| 4619 | dismissComposerTrigger(trigger); | 4680 | dismissComposerTrigger(trigger); |
| 4620 | composerMenuOpenRef.current = false; | 4681 | composerMenuOpenRef.current = false; |
| 4621 | return true; | 4682 | return true; |
| 4622 | } | 4683 | } |
| 4623 | if (menuIsActive && (submissionIntent === null || submissionIntent === "foreground")) { | 4684 | if ( |
| 4685 | menuIsActive && | ||
| 4686 | trigger?.kind !== "emoji" && | ||
| 4687 | (submissionIntent === null || submissionIntent === "foreground") | ||
| 4688 | ) { | ||
| 4624 | const currentItems = composerMenuItemsRef.current; | 4689 | const currentItems = composerMenuItemsRef.current; |
| 4625 | const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; | 4690 | const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; |
| 4626 | if (key === "ArrowDown" && currentItems.length > 0) { | 4691 | if (key === "ArrowDown" && currentItems.length > 0) { |
| ... | @@ -4651,35 +4716,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4651,35 +4716,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4651 | ); | 4716 | ); |
| 4652 | return true; | 4717 | return true; |
| 4653 | } | 4718 | } |
| 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 | } | ||
| 4683 | return false; | 4719 | return false; |
| 4684 | }; | 4720 | }; |
| 4685 | 4721 | ||
| ... | @@ -6240,11 +6276,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -6240,11 +6276,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6240 | // composer is at its attachment limit: `addComposerAttachments` surfaces | 6276 | // composer is at its attachment limit: `addComposerAttachments` surfaces |
| 6241 | // those as a toast and a thread error. An early return here would swallow | 6277 | // those as a toast and a thread error. An early return here would swallow |
| 6242 | // the paste with no feedback. | 6278 | // the paste with no feedback. |
| 6243 | if ( | 6279 | if (files.length > 0 && shouldHandleComposerAttachmentPaste({ files, plainText })) { |
| 6244 | files.length > 0 && | ||
| 6245 | activeThreadId && | ||
| 6246 | shouldHandleComposerAttachmentPaste({ files, plainText }) | ||
| 6247 | ) { | ||
| 6248 | event.preventDefault(); | 6280 | event.preventDefault(); |
| 6249 | event.stopPropagation(); | 6281 | event.stopPropagation(); |
| 6250 | void addComposerAttachments(files, { skipImageInlineChip: bypassAutoAttachment }); | 6282 | void addComposerAttachments(files, { skipImageInlineChip: bypassAutoAttachment }); |
| ... | @@ -7225,9 +7257,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -7225,9 +7257,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7225 | <div role="status" aria-atomic="true" className="sr-only"> | 7257 | <div role="status" aria-atomic="true" className="sr-only"> |
| 7226 | {composerSuggestionsVisible && composerMenuItems.length === 0 | 7258 | {composerSuggestionsVisible && composerMenuItems.length === 0 |
| 7227 | ? isComposerMenuLoading | 7259 | ? isComposerMenuLoading |
| 7228 | ? composerTriggerKind === "pull-request" | 7260 | ? composerTriggerKind === "emoji" |
| 7229 | ? "Finding pull request..." | 7261 | ? "Loading emoji…" |
| 7230 | : "Searching workspace files..." | 7262 | : composerTriggerKind === "pull-request" |
| 7263 | ? "Finding pull request..." | ||
| 7264 | : "Searching workspace files..." | ||
| 7231 | : composerMenuEmptyState | 7265 | : composerMenuEmptyState |
| 7232 | : ""} | 7266 | : ""} |
| 7233 | </div> | 7267 | </div> |
| ... | @@ -7657,6 +7691,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -7657,6 +7691,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7657 | ) : null} | 7691 | ) : null} |
| 7658 | <ComposerContextActionsContext value={composerContextActions}> | 7692 | <ComposerContextActionsContext value={composerContextActions}> |
| 7659 | <ComposerPromptEditor | 7693 | <ComposerPromptEditor |
| 7694 | key={`${composerSuggestionListId}:${activePendingProgress?.activeQuestion?.id ?? "thread"}`} | ||
| 7660 | ariaLabel="Message" | 7695 | ariaLabel="Message" |
| 7661 | suggestionListId={composerSuggestionListId} | 7696 | suggestionListId={composerSuggestionListId} |
| 7662 | activeSuggestionId={ | 7697 | activeSuggestionId={ |
| ... | @@ -7670,9 +7705,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -7670,9 +7705,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7670 | editorRef={composerEditorRef} | 7705 | editorRef={composerEditorRef} |
| 7671 | richTextEnabled={settings.composerRichTextEnabled} | 7706 | richTextEnabled={settings.composerRichTextEnabled} |
| 7672 | literalText={isLiteralPendingAnswer} | 7707 | literalText={isLiteralPendingAnswer} |
| 7673 | isPathQueryActive={() => | ||
| 7674 | resolveActiveComposerTrigger().trigger?.kind === "path" | ||
| 7675 | } | ||
| 7676 | value={ | 7708 | value={ |
| 7677 | isComposerApprovalState | 7709 | isComposerApprovalState |
| 7678 | ? "" | 7710 | ? "" |
apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx deleted-67| ... | @@ -1,67 +0,0 @@ | ||
| 1 | import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; | ||
| 2 | |||
| 3 | import { languageOfInfoString, withInfoStringLanguage } from "~/composer-code-languages"; | ||
| 4 | |||
| 5 | import { useTheme } from "../../hooks/useTheme"; | ||
| 6 | import { MarkdownCodeBlockFrame } from "../ChatMarkdown"; | ||
| 7 | import { ComposerCodeBlockLanguagePicker } from "./ComposerCodeBlockLanguagePicker"; | ||
| 8 | |||
| 9 | /** | ||
| 10 | * Draws a composer fence in the chat view's own code block frame, so a draft | ||
| 11 | * looks like the message it is about to become, with the language title | ||
| 12 | * swapped for a picker that rewrites the fence's language in place. | ||
| 13 | * | ||
| 14 | * The chrome stops at the language. Chat's wrap and copy buttons act on text | ||
| 15 | * the reader cannot change; here the text is the draft, and both are already | ||
| 16 | * a selection away. | ||
| 17 | */ | ||
| 18 | export function ComposerCodeBlockNodeView({ | ||
| 19 | node, | ||
| 20 | editor, | ||
| 21 | getPos, | ||
| 22 | updateAttributes, | ||
| 23 | }: NodeViewProps) { | ||
| 24 | const { resolvedTheme } = useTheme(); | ||
| 25 | const info = typeof node.attrs.language === "string" ? node.attrs.language : ""; | ||
| 26 | const language = languageOfInfoString(info); | ||
| 27 | |||
| 28 | const changeLanguage = (next: string) => { | ||
| 29 | updateAttributes({ language: withInfoStringLanguage(info, next) }); | ||
| 30 | // Back into the code, at its end, the way a picker hands focus back. | ||
| 31 | const position = getPos(); | ||
| 32 | if (typeof position === "number") { | ||
| 33 | editor | ||
| 34 | .chain() | ||
| 35 | .focus(position + 1 + node.content.size) | ||
| 36 | .run(); | ||
| 37 | } | ||
| 38 | }; | ||
| 39 | |||
| 40 | return ( | ||
| 41 | <MarkdownCodeBlockFrame | ||
| 42 | as={NodeViewWrapper} | ||
| 43 | language={language || "text"} | ||
| 44 | fenceTitle={null} | ||
| 45 | theme={resolvedTheme} | ||
| 46 | headerProps={{ contentEditable: false }} | ||
| 47 | title={ | ||
| 48 | <ComposerCodeBlockLanguagePicker | ||
| 49 | language={language} | ||
| 50 | theme={resolvedTheme} | ||
| 51 | disabled={!editor.isEditable} | ||
| 52 | onChange={changeLanguage} | ||
| 53 | /> | ||
| 54 | } | ||
| 55 | > | ||
| 56 | <div className="chat-markdown-shiki"> | ||
| 57 | <pre className="max-w-full overflow-x-auto px-3 pt-1 pb-2"> | ||
| 58 | {/* A caret needs a line to sit on even before any code is typed. */} | ||
| 59 | <NodeViewContent<"code"> | ||
| 60 | as="code" | ||
| 61 | className="block min-h-[1lh] font-mono whitespace-pre-wrap text-inherit [overflow-wrap:anywhere]" | ||
| 62 | /> | ||
| 63 | </pre> | ||
| 64 | </div> | ||
| 65 | </MarkdownCodeBlockFrame> | ||
| 66 | ); | ||
| 67 | } | ||
apps/web/src/components/chat/ComposerCommandMenu.tsx+21-5| ... | @@ -31,6 +31,14 @@ import { ComposerBanner } from "./ComposerBanner"; | ... | @@ -31,6 +31,14 @@ import { ComposerBanner } from "./ComposerBanner"; |
| 31 | import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation"; | 31 | import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation"; |
| 32 | 32 | ||
| 33 | export type ComposerCommandItem = | 33 | export type ComposerCommandItem = |
| 34 | | { | ||
| 35 | id: string; | ||
| 36 | type: "emoji"; | ||
| 37 | emoji: string; | ||
| 38 | query: string; | ||
| 39 | label: string; | ||
| 40 | description: string; | ||
| 41 | } | ||
| 34 | | { | 42 | | { |
| 35 | id: string; | 43 | id: string; |
| 36 | type: "path"; | 44 | type: "path"; |
| ... | @@ -138,11 +146,13 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | ... | @@ -138,11 +146,13 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 138 | <div className="px-5 pt-3.5 pb-7"> | 146 | <div className="px-5 pt-3.5 pb-7"> |
| 139 | <p className="text-secondary-label text-xs"> | 147 | <p className="text-secondary-label text-xs"> |
| 140 | {props.isLoading | 148 | {props.isLoading |
| 141 | ? props.triggerKind === "skill" | 149 | ? props.triggerKind === "emoji" |
| 142 | ? "Searching workspace skills..." | 150 | ? "Loading emoji…" |
| 143 | : props.triggerKind === "pull-request" | 151 | : props.triggerKind === "skill" |
| 144 | ? "Finding pull request..." | 152 | ? "Searching workspace skills..." |
| 145 | : "Searching workspace files..." | 153 | : props.triggerKind === "pull-request" |
| 154 | ? "Finding pull request..." | ||
| 155 | : "Searching workspace files..." | ||
| 146 | : (props.emptyStateText ?? | 156 | : (props.emptyStateText ?? |
| 147 | (props.triggerKind === "skill" | 157 | (props.triggerKind === "skill" |
| 148 | ? "No skills found. Try / to browse provider commands." | 158 | ? "No skills found. Try / to browse provider commands." |
| ... | @@ -190,6 +200,11 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { | ... | @@ -190,6 +200,11 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 190 | props.onSelect(props.item); | 200 | props.onSelect(props.item); |
| 191 | }} | 201 | }} |
| 192 | > | 202 | > |
| 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} | ||
| 193 | {props.item.type === "path" ? ( | 208 | {props.item.type === "path" ? ( |
| 194 | <PierreEntryIcon | 209 | <PierreEntryIcon |
| 195 | pathValue={props.item.path} | 210 | pathValue={props.item.path} |
| ... | @@ -242,6 +257,7 @@ const LISTBOX_LABEL_BY_TRIGGER: Record<ComposerTriggerKind, string> = { | ... | @@ -242,6 +257,7 @@ const LISTBOX_LABEL_BY_TRIGGER: Record<ComposerTriggerKind, string> = { |
| 242 | "pull-request": "Pull requests", | 257 | "pull-request": "Pull requests", |
| 243 | "slash-command": "Commands", | 258 | "slash-command": "Commands", |
| 244 | skill: "Skills", | 259 | skill: "Skills", |
| 260 | emoji: "Emoji", | ||
| 245 | }; | 261 | }; |
| 246 | 262 | ||
| 247 | const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = { | 263 | const 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 }) { | ... | @@ -65,6 +65,7 @@ function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { |
| 65 | videoClassName="aspect-auto max-h-[var(--media-height)] w-auto max-w-[var(--media-width)] rounded-lg border border-border/70 shadow-2xl" | 65 | videoClassName="aspect-auto max-h-[var(--media-height)] w-auto max-w-[var(--media-width)] rounded-lg border border-border/70 shadow-2xl" |
| 66 | stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME} | 66 | stateClassName={EXPANDED_MEDIA_STATE_CLASS_NAME} |
| 67 | onRetry={asset ? refreshAssetUrl : undefined} | 67 | onRetry={asset ? refreshAssetUrl : undefined} |
| 68 | actionsSource={item.actionsSource} | ||
| 68 | /> | 69 | /> |
| 69 | ); | 70 | ); |
| 70 | } | 71 | } |
apps/web/src/components/chat/FileTagChip.tsx+2-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ReactNode } from "react"; | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { ContextChipLabel } from "../ContextChip"; | 3 | import { ContextChipLabel } from "../ContextChip"; |
| 3 | import { WorkspaceEntryIcon } from "./WorkspaceEntryIcon"; | 4 | import { WorkspaceEntryIcon } from "./WorkspaceEntryIcon"; |
| ... | @@ -5,7 +6,7 @@ import { WorkspaceEntryIcon } from "./WorkspaceEntryIcon"; | ... | @@ -5,7 +6,7 @@ import { WorkspaceEntryIcon } from "./WorkspaceEntryIcon"; |
| 5 | /** Icon and label for a file mention; render inside `<ContextChip kind="mention">`. */ | 6 | /** Icon and label for a file mention; render inside `<ContextChip kind="mention">`. */ |
| 6 | export function FileTagChipContent(props: { | 7 | export function FileTagChipContent(props: { |
| 7 | path: string; | 8 | path: string; |
| 8 | label: string; | 9 | label: ReactNode; |
| 9 | theme: "light" | "dark"; | 10 | theme: "light" | "dark"; |
| 10 | environmentId?: EnvironmentId | null | undefined; | 11 | environmentId?: EnvironmentId | null | undefined; |
| 11 | cwd?: string | undefined; | 12 | cwd?: string | undefined; |
apps/web/src/components/chat/HtmlRenderFrame.tsx+9-4| ... | @@ -3,6 +3,7 @@ import { | ... | @@ -3,6 +3,7 @@ import { |
| 3 | HTML_RENDER_COLUMN_WIDTH, | 3 | HTML_RENDER_COLUMN_WIDTH, |
| 4 | htmlRenderFileName, | 4 | htmlRenderFileName, |
| 5 | htmlRenderFrameHeight, | 5 | htmlRenderFrameHeight, |
| 6 | readHtmlRenderContentSize, | ||
| 6 | type HtmlRenderReference, | 7 | type HtmlRenderReference, |
| 7 | } from "@t3tools/shared/htmlRender"; | 8 | } from "@t3tools/shared/htmlRender"; |
| 8 | import { Maximize2Icon } from "lucide-react"; | 9 | import { Maximize2Icon } from "lucide-react"; |
| ... | @@ -47,8 +48,8 @@ export function HtmlRenderFrame(props: { | ... | @@ -47,8 +48,8 @@ export function HtmlRenderFrame(props: { |
| 47 | }, []); | 48 | }, []); |
| 48 | // Client fonts can wrap a page taller than the server measured it; a frame | 49 | // Client fonts can wrap a page taller than the server measured it; a frame |
| 49 | // left short would scroll inside the thread and take the reader's scroll. | 50 | // left short would scroll inside the thread and take the reader's scroll. |
| 50 | const [contentHeight, setContentHeight] = useState<number>(); | 51 | const [contentSize, setContentSize] = useState<ReturnType<typeof readHtmlRenderContentSize>>(); |
| 51 | const height = htmlRenderFrameHeight(props.htmlRender, width, contentHeight); | 52 | const height = htmlRenderFrameHeight(props.htmlRender, width, contentSize?.height); |
| 52 | const fileName = htmlRenderFileName(title); | 53 | const fileName = htmlRenderFileName(title); |
| 53 | const resource = useMemo( | 54 | const resource = useMemo( |
| 54 | () => ({ | 55 | () => ({ |
| ... | @@ -100,7 +101,11 @@ export function HtmlRenderFrame(props: { | ... | @@ -100,7 +101,11 @@ export function HtmlRenderFrame(props: { |
| 100 | }, [cacheFailed, cachedExpiresAt, cachedUrl, refresh, src]); | 101 | }, [cacheFailed, cachedExpiresAt, cachedUrl, refresh, src]); |
| 101 | 102 | ||
| 102 | return ( | 103 | 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 | > | ||
| 104 | {src !== null ? ( | 109 | {src !== null ? ( |
| 105 | <> | 110 | <> |
| 106 | <HtmlRenderDocument | 111 | <HtmlRenderDocument |
| ... | @@ -108,7 +113,7 @@ export function HtmlRenderFrame(props: { | ... | @@ -108,7 +113,7 @@ export function HtmlRenderFrame(props: { |
| 108 | title={title} | 113 | title={title} |
| 109 | threadRef={props.threadRef} | 114 | threadRef={props.threadRef} |
| 110 | className="block size-full" | 115 | className="block size-full" |
| 111 | onContentHeight={setContentHeight} | 116 | onContentSize={setContentSize} |
| 112 | /> | 117 | /> |
| 113 | <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"> | 118 | <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"> |
| 114 | <Tooltip> | 119 | <Tooltip> |
apps/web/src/components/chat/MermaidDiagram.tsx+8-6| ... | @@ -47,13 +47,20 @@ function sanitizeMermaidSvg(svg: string): string { | ... | @@ -47,13 +47,20 @@ function sanitizeMermaidSvg(svg: string): string { |
| 47 | data.attrValue = data.attrValue.replace(REMOTE_CSS_URL, "none"); | 47 | data.attrValue = data.attrValue.replace(REMOTE_CSS_URL, "none"); |
| 48 | }); | 48 | }); |
| 49 | } | 49 | } |
| 50 | return purifier.sanitize(svg, { | 50 | const sanitized = purifier.sanitize(svg, { |
| 51 | ADD_TAGS: ["foreignObject"], | 51 | ADD_TAGS: ["foreignObject"], |
| 52 | HTML_INTEGRATION_POINTS: { foreignobject: true }, | 52 | HTML_INTEGRATION_POINTS: { foreignobject: true }, |
| 53 | FORBID_ATTR: ["href", "xlink:href", "src", "srcset"], | 53 | FORBID_ATTR: ["href", "xlink:href", "src", "srcset"], |
| 54 | FORBID_TAGS: ["a", "img", "image", "script"], | 54 | FORBID_TAGS: ["a", "img", "image", "script"], |
| 55 | USE_PROFILES: { svg: true, svgFilters: true, html: true }, | 55 | USE_PROFILES: { svg: true, svgFilters: true, html: true }, |
| 56 | }); | 56 | }); |
| 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); | ||
| 57 | } | 64 | } |
| 58 | 65 | ||
| 59 | // Mermaid also lazy-loads diagram chunks inside render(); losing the network | 66 | // Mermaid also lazy-loads diagram chunks inside render(); losing the network |
| ... | @@ -145,11 +152,6 @@ let expandedImageUrl: string | null = null; | ... | @@ -145,11 +152,6 @@ let expandedImageUrl: string | null = null; |
| 145 | function mermaidImageUrl(svg: string): string { | 152 | function mermaidImageUrl(svg: string): string { |
| 146 | const svgDocument = new DOMParser().parseFromString(svg, "image/svg+xml"); | 153 | const svgDocument = new DOMParser().parseFromString(svg, "image/svg+xml"); |
| 147 | const element = svgDocument.documentElement; | 154 | 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 | } | ||
| 153 | element.setAttribute("xmlns", "http://www.w3.org/2000/svg"); | 155 | element.setAttribute("xmlns", "http://www.w3.org/2000/svg"); |
| 154 | element.style.maxWidth = "none"; | 156 | element.style.maxWidth = "none"; |
| 155 | element.style.backgroundColor = getComputedStyle(document.body).backgroundColor; | 157 | 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({ | ... | @@ -1582,7 +1582,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1582 | const renderItem = useCallback( | 1582 | const renderItem = useCallback( |
| 1583 | ({ item }: { item: MessagesTimelineRow }) => ( | 1583 | ({ item }: { item: MessagesTimelineRow }) => ( |
| 1584 | <div className="messages-timeline-row-frame"> | 1584 | <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"> |
| 1586 | <TimelineRowContent row={item} /> | 1586 | <TimelineRowContent row={item} /> |
| 1587 | </div> | 1587 | </div> |
| 1588 | </div> | 1588 | </div> |
| ... | @@ -2836,7 +2836,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess | ... | @@ -2836,7 +2836,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 2836 | 2836 | ||
| 2837 | return ( | 2837 | return ( |
| 2838 | <> | 2838 | <> |
| 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"> |
| 2840 | <MessageAuthorHeading>The Knife</MessageAuthorHeading> | 2840 | <MessageAuthorHeading>The Knife</MessageAuthorHeading> |
| 2841 | <div data-thread-find-text="true"> | 2841 | <div data-thread-find-text="true"> |
| 2842 | <AssistantCitationSource | 2842 | <AssistantCitationSource |
| ... | @@ -3051,7 +3051,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm | ... | @@ -3051,7 +3051,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm |
| 3051 | ); | 3051 | ); |
| 3052 | 3052 | ||
| 3053 | return ( | 3053 | return ( |
| 3054 | <div className="min-w-0 px-1"> | 3054 | <div className="chat-transcript-figure min-w-0"> |
| 3055 | <HtmlRenderFrame | 3055 | <HtmlRenderFrame |
| 3056 | // A recycled row must not keep another page's frozen frame. | 3056 | // A recycled row must not keep another page's frozen frame. |
| 3057 | key={row.htmlRender.attachmentId} | 3057 | key={row.htmlRender.attachmentId} |
| ... | @@ -3916,7 +3916,10 @@ function LiveActivityRow({ | ... | @@ -3916,7 +3916,10 @@ function LiveActivityRow({ |
| 3916 | return ( | 3916 | return ( |
| 3917 | <div | 3917 | <div |
| 3918 | ref={animated ? observeVisibleAnimation : undefined} | 3918 | 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 | )} | ||
| 3920 | > | 3923 | > |
| 3921 | <LiveActivityContent | 3924 | <LiveActivityContent |
| 3922 | label={label} | 3925 | label={label} |
| ... | @@ -3975,7 +3978,7 @@ function LiveActivityContent({ | ... | @@ -3975,7 +3978,7 @@ function LiveActivityContent({ |
| 3975 | icon={toolIcon} | 3978 | icon={toolIcon} |
| 3976 | fallbackName={iconName} | 3979 | fallbackName={iconName} |
| 3977 | className="block size-4 shrink-0 stroke-2" | 3980 | className="block size-4 shrink-0 stroke-2" |
| 3978 | muted={!highlighted} | 3981 | muted={!highlighted && !active} |
| 3979 | /> | 3982 | /> |
| 3980 | </span> | 3983 | </span> |
| 3981 | ) : null | 3984 | ) : null |
| ... | @@ -5488,6 +5491,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowP | ... | @@ -5488,6 +5491,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: WorkEntryRowP |
| 5488 | function WorkEntryLogRow(props: WorkEntryRowProps) { | 5491 | function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5489 | const { workEntry, workspaceRoot, displayLabel } = props; | 5492 | const { workEntry, workspaceRoot, displayLabel } = props; |
| 5490 | const ctx = use(TimelineRowCtx); | 5493 | const ctx = use(TimelineRowCtx); |
| 5494 | const { isWorking, latestRunId } = use(TimelineRowActivityCtx); | ||
| 5491 | const { threadRef, onImageExpand, timestampFormat } = ctx; | 5495 | const { threadRef, onImageExpand, timestampFormat } = ctx; |
| 5492 | const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx; | 5496 | const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx; |
| 5493 | const threadTarget = useThreadReadTarget(workEntry, ctx.activeThreadEnvironmentId); | 5497 | const threadTarget = useThreadReadTarget(workEntry, ctx.activeThreadEnvironmentId); |
| ... | @@ -5582,6 +5586,11 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { | ... | @@ -5582,6 +5586,11 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5582 | !showWarningIndicator && | 5586 | !showWarningIndicator && |
| 5583 | showFailedIndicator && | 5587 | showFailedIndicator && |
| 5584 | (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry)); | 5588 | (workEntrySignalsSevereFailure(workEntry) || !workLogEntryIsToolLike(workEntry)); |
| 5589 | const active = | ||
| 5590 | isWorking && | ||
| 5591 | workEntry.runId === latestRunId && | ||
| 5592 | workEntry.toolLifecycleStatus === "inProgress" && | ||
| 5593 | !showFailedIndicator; | ||
| 5585 | const entryIconName = | 5594 | const entryIconName = |
| 5586 | showWarningIndicator || showDestructiveRowStyle ? "circle-alert" : workEntryIconName(workEntry); | 5595 | showWarningIndicator || showDestructiveRowStyle ? "circle-alert" : workEntryIconName(workEntry); |
| 5587 | const entryToolIcon = | 5596 | const entryToolIcon = |
| ... | @@ -5675,17 +5684,21 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { | ... | @@ -5675,17 +5684,21 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5675 | ? "text-destructive" | 5684 | ? "text-destructive" |
| 5676 | : showFailedIndicator | 5685 | : showFailedIndicator |
| 5677 | ? failedToolIconClassName | 5686 | ? failedToolIconClassName |
| 5678 | : workEntry.tone === "tool" | 5687 | : active |
| 5679 | ? "text-icon-muted" | 5688 | ? "text-(--activity-foreground)" |
| 5680 | : iconConfig.className, | 5689 | : workEntry.tone === "tool" |
| 5690 | ? "text-icon-muted" | ||
| 5691 | : iconConfig.className, | ||
| 5681 | ); | 5692 | ); |
| 5682 | const headingClass = showWarningIndicator | 5693 | const headingClass = showWarningIndicator |
| 5683 | ? "font-medium text-warning" | 5694 | ? "font-medium text-warning" |
| 5684 | : showDestructiveRowStyle | 5695 | : showDestructiveRowStyle |
| 5685 | ? "font-medium text-destructive" | 5696 | ? "font-medium text-destructive" |
| 5686 | : workLogEntryIsToolLike(workEntry) | 5697 | : active |
| 5687 | ? "text-secondary-label" | 5698 | ? "text-(--activity-foreground)" |
| 5688 | : "text-foreground/80"; | 5699 | : workLogEntryIsToolLike(workEntry) |
| 5700 | ? "text-secondary-label" | ||
| 5701 | : "text-foreground/80"; | ||
| 5689 | const threadLabel = isReasoning ? null : threadReadLabel(previewText, threadTarget); | 5702 | const threadLabel = isReasoning ? null : threadReadLabel(previewText, threadTarget); |
| 5690 | const accessiblePreview = [threadLabel?.text ?? previewText, answerPreview] | 5703 | const accessiblePreview = [threadLabel?.text ?? previewText, answerPreview] |
| 5691 | .filter(Boolean) | 5704 | .filter(Boolean) |
| ... | @@ -5724,7 +5737,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { | ... | @@ -5724,7 +5737,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5724 | icon={entryToolIcon} | 5737 | icon={entryToolIcon} |
| 5725 | fallbackName={entryIconName} | 5738 | fallbackName={entryIconName} |
| 5726 | className="block size-4 shrink-0 stroke-2" | 5739 | className="block size-4 shrink-0 stroke-2" |
| 5727 | muted | 5740 | muted={!active} |
| 5728 | /> | 5741 | /> |
| 5729 | </span> | 5742 | </span> |
| 5730 | } | 5743 | } |
apps/web/src/components/chat/composerEmojiSearch.test.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { searchComposerEmoji } from "./composerEmojiSearch"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | import { gemoji } from "gemoji"; | ||
| 2 | |||
| 3 | import type { ComposerCommandItem } from "./ComposerCommandMenu"; | ||
| 4 | |||
| 5 | export 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 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; | 3 | import { composerEmojiKeyAction, resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; |
| 4 | 4 | ||
| 5 | describe("resolveComposerMenuActiveItemId", () => { | 5 | describe("resolveComposerMenuActiveItemId", () => { |
| 6 | const items = [{ id: "top" }, { id: "second" }, { id: "third" }] as const; | 6 | const items = [{ id: "top" }, { id: "second" }, { id: "third" }] as const; |
| ... | @@ -66,3 +66,41 @@ describe("resolveComposerMenuActiveItemId", () => { | ... | @@ -66,3 +66,41 @@ describe("resolveComposerMenuActiveItemId", () => { |
| 66 | ).toBe("new-result"); | 66 | ).toBe("new-result"); |
| 67 | }); | 67 | }); |
| 68 | }); | 68 | }); |
| 69 | |||
| 70 | describe("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: { | ... | @@ -18,3 +18,18 @@ export function resolveComposerMenuActiveItemId(input: { |
| 18 | 18 | ||
| 19 | return input.items[0]?.id ?? null; | 19 | return input.items[0]?.id ?? null; |
| 20 | } | 20 | } |
| 21 | |||
| 22 | export 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", () => { | ... | @@ -133,7 +133,7 @@ describe("composer suggestion dismissal", () => { |
| 133 | expect(composer.resolveTrigger(candidate)).toEqual(candidate); | 133 | expect(composer.resolveTrigger(candidate)).toEqual(candidate); |
| 134 | }); | 134 | }); |
| 135 | 135 | ||
| 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) => { |
| 137 | await updatePrompt(text); | 137 | await updatePrompt(text); |
| 138 | await act(() => composer.dismissTrigger(composer.trigger)); | 138 | await act(() => composer.dismissTrigger(composer.trigger)); |
| 139 | await updatePrompt(`${text}x`); | 139 | await updatePrompt(`${text}x`); |
apps/web/src/components/composerCodeBlockHighlight.test.ts+87-139| ... | @@ -1,152 +1,100 @@ | ... | @@ -1,152 +1,100 @@ |
| 1 | import { Editor } from "@tiptap/core"; | 1 | // @vitest-environment jsdom |
| 2 | import type { EditorView } from "@tiptap/pm/view"; | ||
| 3 | import StarterKit from "@tiptap/starter-kit"; | ||
| 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { ComposerCodeBlockExtension } from "~/composer-rich-text-doc"; | ||
| 7 | import type { DiffThemeName } from "~/lib/diffRendering"; | ||
| 8 | import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | composerCodeBlockHighlight, | ||
| 12 | MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH, | ||
| 13 | shouldHighlightCodeBlock, | ||
| 14 | tokenizeBlock, | ||
| 15 | } from "./composerCodeBlockHighlight"; | ||
| 16 | |||
| 17 | vi.mock("~/lib/syntaxHighlighting", () => ({ getSyntaxHighlighterPromise: vi.fn() })); | ||
| 18 | |||
| 19 | const THEME = "github-dark" as DiffThemeName; | ||
| 20 | |||
| 21 | /** | ||
| 22 | * Stands in for the Shiki highlighter so the offset arithmetic can be checked | ||
| 23 | * without loading a real grammar. Splits each line into whitespace-delimited | ||
| 24 | * tokens, which is enough shape for the flattening logic under test. | ||
| 25 | */ | ||
| 26 | function fakeHighlighter(colorFor: (content: string) => string | undefined) { | ||
| 27 | return { | ||
| 28 | codeToTokens(code: string) { | ||
| 29 | return { | ||
| 30 | tokens: code.split(/\r?\n/).map((line) => | ||
| 31 | line | ||
| 32 | .split(/(\s+)/) | ||
| 33 | .filter((part) => part.length > 0) | ||
| 34 | .map((content) => ({ content, color: colorFor(content) })), | ||
| 35 | ), | ||
| 36 | }; | ||
| 37 | }, | ||
| 38 | } as unknown as Parameters<typeof tokenizeBlock>[0]; | ||
| 39 | } | ||
| 40 | |||
| 41 | describe("composer code block highlighting", () => { | ||
| 42 | it("maps token offsets to positions within the block text", () => { | ||
| 43 | const highlighter = fakeHighlighter((content) => (content === "const" ? "#ff0000" : undefined)); | ||
| 44 | |||
| 45 | const decorations = tokenizeBlock(highlighter, "const answer = 42", "ts", THEME); | ||
| 46 | |||
| 47 | expect(decorations).toEqual([{ from: 0, to: 5, color: "#ff0000" }]); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("accounts for the newline between lines", () => { | ||
| 51 | const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); | ||
| 52 | |||
| 53 | const decorations = tokenizeBlock(highlighter, "one\ntwo", "ts", THEME); | ||
| 54 | |||
| 55 | // "one" is 3 characters, then the newline, so "two" starts at 4. | ||
| 56 | expect(decorations).toEqual([{ from: 4, to: 7, color: "#00ff00" }]); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("accounts for both characters of a CRLF line break", () => { | ||
| 60 | const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); | ||
| 61 | 2 | ||
| 62 | const decorations = tokenizeBlock(highlighter, "one\r\ntwo", "ts", THEME); | 3 | import { StateEffect } from "@codemirror/state"; |
| 63 | 4 | import { EditorView } from "@codemirror/view"; | |
| 64 | expect(decorations).toEqual([{ from: 5, to: 8, color: "#00ff00" }]); | 5 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 65 | }); | ||
| 66 | |||
| 67 | it("skips whitespace-only tokens so indentation is not decorated", () => { | ||
| 68 | const highlighter = fakeHighlighter(() => "#0000ff"); | ||
| 69 | |||
| 70 | const decorations = tokenizeBlock(highlighter, " indented", "ts", THEME); | ||
| 71 | |||
| 72 | expect(decorations).toEqual([{ from: 2, to: 10, color: "#0000ff" }]); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("falls back to plain text when the grammar throws", () => { | ||
| 76 | const throwing = { | ||
| 77 | codeToTokens() { | ||
| 78 | throw new Error("unsupported language"); | ||
| 79 | }, | ||
| 80 | } as unknown as Parameters<typeof tokenizeBlock>[0]; | ||
| 81 | 6 | ||
| 82 | expect(tokenizeBlock(throwing, "const answer = 42", "nope", THEME)).toEqual([]); | 7 | const 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; | ||
| 83 | }); | 15 | }); |
| 16 | return { ready, resolve: (value: Parameters<typeof resolve>[0]) => resolve(value) }; | ||
| 84 | }); | 17 | }); |
| 85 | 18 | vi.mock("~/lib/syntaxHighlighting", () => ({ | |
| 86 | describe("the highlighting size cap", () => { | 19 | getSyntaxHighlighterPromise: () => tokenization.ready, |
| 87 | // Each keystroke re-tokenizes the whole fence on the main thread; past a | 20 | })); |
| 88 | // few hundred lines that stalls typing, so large fences stay plain. | 21 | |
| 89 | it("highlights a fence up to the cap", () => { | 22 | import { createComposerMarkdownEditor } from "./composerMarkdownEditor"; |
| 90 | expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH))).toBe(true); | 23 | import { composerCodeBlocks, composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; |
| 24 | import type { ComposerPromptEditorProps } from "./ComposerPromptEditor"; | ||
| 25 | |||
| 26 | let editor: ReturnType<typeof createComposerMarkdownEditor>; | ||
| 27 | afterEach(() => { | ||
| 28 | editor?.destroy(); | ||
| 29 | editor?.dom.remove(); | ||
| 30 | }); | ||
| 31 | function 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: () => {}, | ||
| 91 | }); | 48 | }); |
| 49 | document.body.append(editor.dom); | ||
| 50 | return editor; | ||
| 51 | } | ||
| 92 | 52 | ||
| 93 | it("leaves a fence past the cap plain", () => { | 53 | it("lands an empty fence's language selection in its body rather than inside the info string", () => { |
| 94 | expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH + 1))).toBe(false); | 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 }, | ||
| 95 | }); | 60 | }); |
| 61 | expect(editor.getValue()).toBe("```python\n\n```"); | ||
| 62 | expect(editor.view.state.doc.lineAt(editor.view.state.selection.main.head).number).toBe(2); | ||
| 96 | }); | 63 | }); |
| 97 | 64 | ||
| 98 | describe("a highlighter that fails to load", () => { | 65 | it("keeps the trailing line of an unfinished fence as part of its code body", () => { |
| 99 | afterEach(() => { | 66 | mount("```ts\nunfinished\n"); |
| 100 | vi.unstubAllGlobals(); | 67 | const block = composerCodeBlocks(editor.view.state)[0]!; |
| 101 | }); | 68 | expect(block.code).toBe("unfinished\n"); |
| 69 | expect(block.codeTo).toBe(editor.view.state.doc.length); | ||
| 70 | }); | ||
| 102 | 71 | ||
| 103 | /** | 72 | describe("asynchronous code highlighting", () => { |
| 104 | * Shiki can fail to initialize at all. The blocks it could not reach stay | 73 | it("paints current text after initialization and never reintroduces an edited-away tokenization", async () => { |
| 105 | * plain, the ones it could are still painted, and nothing is left rejected. | 74 | mount("```ts\nold\n```"); |
| 106 | */ | 75 | editor.setValue("```ts\nnew\n```"); |
| 107 | it("still repaints the blocks whose highlighter loaded", async () => { | 76 | const settled = new Promise<void>((resolve) => { |
| 108 | vi.mocked(getSyntaxHighlighterPromise).mockImplementation((language) => | 77 | editor.view.dispatch({ |
| 109 | language === "ts" | 78 | effects: StateEffect.appendConfig.of( |
| 110 | ? Promise.resolve(fakeHighlighter(() => "#ff0000")) | 79 | EditorView.updateListener.of(() => { |
| 111 | : Promise.reject(new Error("Shiki failed to initialize")), | 80 | if ((editor.view.plugin(composerCodeBlockHighlight)?.decorations.size ?? 0) > 0) |
| 112 | ); | 81 | resolve(); |
| 113 | const editor = new Editor({ | 82 | }), |
| 114 | extensions: [ | 83 | ), |
| 115 | StarterKit.configure({ codeBlock: false, trailingNode: false }), | 84 | }); |
| 116 | ComposerCodeBlockExtension, | ||
| 117 | composerCodeBlockHighlight({ resolveTheme: () => THEME }), | ||
| 118 | ], | ||
| 119 | content: { | ||
| 120 | type: "doc", | ||
| 121 | content: [ | ||
| 122 | { type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "a" }] }, | ||
| 123 | { type: "codeBlock", attrs: { language: "py" }, content: [{ type: "text", text: "b" }] }, | ||
| 124 | ], | ||
| 125 | }, | ||
| 126 | }); | ||
| 127 | // The plugin watches <html> for theme changes; the unit suite has no DOM. | ||
| 128 | vi.stubGlobal("document", { documentElement: {} }); | ||
| 129 | vi.stubGlobal( | ||
| 130 | "MutationObserver", | ||
| 131 | class { | ||
| 132 | observe() {} | ||
| 133 | disconnect() {} | ||
| 134 | }, | ||
| 135 | ); | ||
| 136 | // ProseMirror names each plugin after its key, though it does not type it. | ||
| 137 | const plugin = editor.extensionManager.plugins.find((candidate) => | ||
| 138 | (candidate as unknown as { key: string }).key.startsWith("composerCodeBlockHighlight"), | ||
| 139 | ); | ||
| 140 | const repainted = new Promise<void>((resolve) => { | ||
| 141 | const view = { | ||
| 142 | get state() { | ||
| 143 | return editor.state; | ||
| 144 | }, | ||
| 145 | dispatch: () => resolve(), | ||
| 146 | } as unknown as EditorView; | ||
| 147 | plugin?.spec.view?.(view); | ||
| 148 | }); | 85 | }); |
| 149 | 86 | const codeToTokens = vi.fn((code: string) => ({ | |
| 150 | await expect(repainted).resolves.toBeUndefined(); | 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"); | ||
| 151 | }); | 99 | }); |
| 152 | }); | 100 | }); |
apps/web/src/components/composerCodeBlockHighlight.ts+143-219| ... | @@ -1,231 +1,155 @@ | ... | @@ -1,231 +1,155 @@ |
| 1 | import { Extension } from "@tiptap/core"; | 1 | import { syntaxTree } from "@codemirror/language"; |
| 2 | import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; | 2 | import { type EditorState, StateEffect } from "@codemirror/state"; |
| 3 | import { Decoration, DecorationSet } from "@tiptap/pm/view"; | 3 | import { |
| 4 | import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; | 4 | Decoration, |
| 5 | type DecorationSet, | ||
| 6 | EditorView, | ||
| 7 | ViewPlugin, | ||
| 8 | type ViewUpdate, | ||
| 9 | } from "@codemirror/view"; | ||
| 5 | 10 | ||
| 6 | import type { DiffThemeName } from "~/lib/diffRendering"; | ||
| 7 | import { languageOfInfoString } from "~/composer-code-languages"; | 11 | import { languageOfInfoString } from "~/composer-code-languages"; |
| 12 | import { resolveDiffThemeName } from "~/lib/diffRendering"; | ||
| 8 | import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; | 13 | import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; |
| 9 | 14 | ||
| 10 | interface HighlightedBlock { | 15 | export function composerCodeBlocks(state: EditorState) { |
| 11 | /** Guards against repainting a block whose text and language are unchanged. */ | 16 | const blocks: Array<{ |
| 12 | readonly signature: string; | 17 | from: number; |
| 13 | readonly decorations: ReadonlyArray<{ from: number; to: number; color: string }>; | 18 | infoFrom: number; |
| 14 | } | 19 | infoTo: number; |
| 15 | 20 | info: string; | |
| 16 | const composerCodeBlockHighlightKey = new PluginKey<DecorationSet>("composerCodeBlockHighlight"); | 21 | codeFrom: number; |
| 17 | 22 | codeTo: number; | |
| 18 | /** | 23 | code: string; |
| 19 | * Past this many characters a fence is left plain. Any edit changes a block's | 24 | }> = []; |
| 20 | * signature, so each keystroke re-tokenizes the whole block synchronously on | 25 | syntaxTree(state).iterate({ |
| 21 | * the main thread: a few milliseconds for a couple of hundred lines, but ~80ms | 26 | enter(node) { |
| 22 | * at a thousand, which stalls typing. 20k characters is roughly 400–500 lines | 27 | if (node.name !== "FencedCode") return undefined; |
| 23 | * of ordinary code; a fence that shrinks back under it is highlighted again. | 28 | const info = node.node.getChild("CodeInfo"); |
| 24 | */ | 29 | const mark = node.node.getChild("CodeMark"); |
| 25 | export const MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH = 20_000; | 30 | if (!mark) return false; |
| 26 | 31 | const code = node.node.getChild("CodeText"); | |
| 27 | export function shouldHighlightCodeBlock(code: string): boolean { | 32 | const openingLine = state.doc.lineAt(node.from); |
| 28 | return code.length <= MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH; | 33 | const bodyFrom = |
| 29 | } | 34 | openingLine.number < state.doc.lines |
| 30 | function blockSignature(node: ProseMirrorNode, theme: DiffThemeName): string { | 35 | ? state.doc.line(openingLine.number + 1).from |
| 31 | // A separator that cannot occur in a language name keeps the parts distinct. | 36 | : openingLine.to; |
| 32 | return [theme, String(node.attrs.language ?? ""), node.textContent].join("\u0000"); | 37 | const codeFrom = code?.from ?? bodyFrom; |
| 33 | } | 38 | const codeTo = |
| 34 | 39 | code && | |
| 35 | /** | 40 | node.node.getChildren("CodeMark").length > 1 && |
| 36 | * The composer's code blocks, which are always top-level: list items and | 41 | state.sliceDoc(code.to - 1, code.to) === "\n" |
| 37 | * quotes cannot hold one. Scanning only the top level keeps a keystroke in a | 42 | ? Math.max(codeFrom, code.to - 1) |
| 38 | * long draft from walking every node in it. | 43 | : (code?.to ?? bodyFrom); |
| 39 | */ | 44 | blocks.push({ |
| 40 | function collectCodeBlocks(state: EditorState): Array<{ node: ProseMirrorNode; pos: number }> { | 45 | from: node.from, |
| 41 | const blocks: Array<{ node: ProseMirrorNode; pos: number }> = []; | 46 | infoFrom: info?.from ?? mark.to, |
| 42 | state.doc.forEach((node, pos) => { | 47 | infoTo: info?.to ?? mark.to, |
| 43 | if (node.type.name === "codeBlock") blocks.push({ node, pos }); | 48 | info: info ? state.sliceDoc(info.from, info.to) : "", |
| 49 | codeFrom, | ||
| 50 | codeTo, | ||
| 51 | code: state.sliceDoc(codeFrom, codeTo), | ||
| 52 | }); | ||
| 53 | return false; | ||
| 54 | }, | ||
| 44 | }); | 55 | }); |
| 45 | return blocks; | 56 | return blocks; |
| 46 | } | 57 | } |
| 47 | 58 | ||
| 48 | /** | 59 | export const composerCodeBlockHighlight = ViewPlugin.fromClass( |
| 49 | * Highlights composer code blocks with the same Shiki instance the chat view | 60 | class { |
| 50 | * uses, painted as inline decorations so the text nodes stay editable. | 61 | decorations: DecorationSet = Decoration.none; |
| 51 | * | 62 | readonly cache = new Map<string, readonly { from: number; to: number; color: string }[]>(); |
| 52 | * Highlighting is asynchronous and per block: a keystroke re-tokenizes only the | 63 | readonly repaint = StateEffect.define<null>(); |
| 53 | * block that changed, and only after its highlighter has resolved. Blocks whose | 64 | readonly observer: MutationObserver; |
| 54 | * text and language are unchanged reuse their previous decorations. | 65 | disposed = false; |
| 55 | */ | 66 | live = new Set<string>(); |
| 56 | export function composerCodeBlockHighlight(options: { | 67 | doc: EditorState["doc"] | null = null; |
| 57 | resolveTheme: () => DiffThemeName; | 68 | tree: ReturnType<typeof syntaxTree> | null = null; |
| 58 | }): Extension { | 69 | theme = ""; |
| 59 | return Extension.create({ | 70 | |
| 60 | name: "composerCodeBlockHighlight", | 71 | constructor(readonly view: EditorView) { |
| 61 | 72 | this.observer = new MutationObserver(() => view.dispatch({ effects: this.repaint.of(null) })); | |
| 62 | addProseMirrorPlugins() { | 73 | this.observer.observe(document.documentElement, { |
| 63 | // Every keystroke in a code block mints a new signature, so each scan | 74 | attributeFilter: ["class", "data-theme-id"], |
| 64 | // drops the signatures the document no longer holds. The cache is then | 75 | }); |
| 65 | // bounded by the document itself, and no block it holds is ever evicted. | 76 | this.refresh(); |
| 66 | const cache = new Map<string, HighlightedBlock>(); | 77 | } |
| 67 | // The latest scan's signatures, so a tokenize that resolves after its | 78 | update(update: ViewUpdate) { |
| 68 | // block was edited away does not put it back. | 79 | if ( |
| 69 | let live = new Set<string>(); | 80 | update.docChanged || |
| 70 | 81 | syntaxTree(update.state) !== this.tree || | |
| 71 | return [ | 82 | update.transactions.some((transaction) => |
| 72 | new Plugin<DecorationSet>({ | 83 | transaction.effects.some((effect) => effect.is(this.repaint)), |
| 73 | key: composerCodeBlockHighlightKey, | 84 | ) |
| 74 | 85 | ) | |
| 75 | state: { | 86 | this.refresh(); |
| 76 | init: () => DecorationSet.empty, | 87 | } |
| 77 | apply(transaction, value, _oldState, newState) { | 88 | refresh() { |
| 78 | if (!transaction.docChanged && !transaction.getMeta(composerCodeBlockHighlightKey)) { | 89 | const state = this.view.state; |
| 79 | return value.map(transaction.mapping, transaction.doc); | 90 | const theme = resolveDiffThemeName( |
| 80 | } | 91 | document.documentElement.classList.contains("dark") ? "dark" : "light", |
| 81 | return buildDecorations(newState, cache, options.resolveTheme()); | 92 | ); |
| 82 | }, | 93 | const blocks = composerCodeBlocks(state); |
| 83 | }, | 94 | const signature = (block: (typeof blocks)[number]) => |
| 84 | 95 | `${theme}\u0000${block.info}\u0000${block.code}`; | |
| 85 | props: { | 96 | this.live = new Set(blocks.map(signature)); |
| 86 | decorations(state) { | 97 | for (const key of this.cache.keys()) if (!this.live.has(key)) this.cache.delete(key); |
| 87 | return composerCodeBlockHighlightKey.getState(state); | 98 | this.decorations = Decoration.set( |
| 88 | }, | 99 | blocks.flatMap((block) => |
| 89 | }, | 100 | (this.cache.get(signature(block)) ?? []).map((span) => |
| 90 | 101 | Decoration.mark({ attributes: { style: `color: ${span.color}` } }).range( | |
| 91 | view(view) { | 102 | block.codeFrom + span.from, |
| 92 | let disposed = false; | 103 | block.codeFrom + span.to, |
| 93 | let paintedTheme = options.resolveTheme(); | 104 | ), |
| 94 | let scannedDoc: ProseMirrorNode | null = null; | 105 | ), |
| 95 | 106 | ), | |
| 96 | /** | 107 | true, |
| 97 | * Tokenizes any block missing from the cache, then repaints once. | 108 | ); |
| 98 | * Returning early when nothing is pending is what stops the | 109 | if (this.doc === state.doc && this.tree === syntaxTree(state) && this.theme === theme) return; |
| 99 | * dispatch below from re-triggering this on its own update. | 110 | this.doc = state.doc; |
| 100 | */ | 111 | this.tree = syntaxTree(state); |
| 101 | const refresh = () => { | 112 | this.theme = theme; |
| 102 | const theme = options.resolveTheme(); | 113 | const pending = blocks.filter( |
| 103 | const themeChanged = theme !== paintedTheme; | 114 | (block) => block.code.length <= 20_000 && !this.cache.has(signature(block)), |
| 104 | paintedTheme = theme; | 115 | ); |
| 105 | // A selection change cannot alter what needs tokenizing, and | 116 | if (pending.length === 0) return; |
| 106 | // building a signature means concatenating every block's text. | 117 | void Promise.allSettled( |
| 107 | if (!themeChanged && view.state.doc === scannedDoc) return; | 118 | pending.map(async (block) => { |
| 108 | scannedDoc = view.state.doc; | 119 | const key = signature(block); |
| 109 | const blocks = collectCodeBlocks(view.state); | 120 | const language = languageOfInfoString(block.info) || "text"; |
| 110 | live = new Set(blocks.map(({ node }) => blockSignature(node, theme))); | 121 | const highlighter = await getSyntaxHighlighterPromise(language); |
| 111 | for (const signature of cache.keys()) { | 122 | if (this.disposed || this.cache.has(key) || !this.live.has(key)) return; |
| 112 | if (!live.has(signature)) cache.delete(signature); | 123 | const spans: Array<{ from: number; to: number; color: string }> = []; |
| 113 | } | 124 | try { |
| 114 | const pending = blocks.filter( | 125 | let offset = 0; |
| 115 | ({ node }) => | 126 | for (const [index, line] of highlighter |
| 116 | shouldHighlightCodeBlock(node.textContent) && | 127 | .codeToTokens(block.code, { lang: language, theme }) |
| 117 | !cache.has(blockSignature(node, theme)), | 128 | .tokens.entries()) { |
| 118 | ); | 129 | if (index > 0) offset += block.code.startsWith("\r\n", offset) ? 2 : 1; |
| 119 | if (pending.length === 0) { | 130 | for (const token of line) { |
| 120 | // A theme switch keeps every signature but changes which one | 131 | if (token.color && token.content.trim()) |
| 121 | // applies, so the painted decorations still have to be rebuilt. | 132 | spans.push({ |
| 122 | if (themeChanged) { | 133 | from: offset, |
| 123 | view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); | 134 | to: offset + token.content.length, |
| 124 | } | 135 | color: token.color, |
| 125 | return; | ||
| 126 | } | ||
| 127 | |||
| 128 | // Settled rather than all: a block whose highlighter fails to | ||
| 129 | // load stays plain, and the others are still painted. | ||
| 130 | void Promise.allSettled( | ||
| 131 | pending.map(async ({ node }) => { | ||
| 132 | // The stored info string keeps everything after the language | ||
| 133 | // (`js title=x`); Shiki wants only the name. | ||
| 134 | const language = | ||
| 135 | languageOfInfoString(String(node.attrs.language ?? "")) || "text"; | ||
| 136 | const signature = blockSignature(node, theme); | ||
| 137 | const highlighter = await getSyntaxHighlighterPromise(language); | ||
| 138 | if (disposed || cache.has(signature) || !live.has(signature)) return; | ||
| 139 | cache.set(signature, { | ||
| 140 | signature, | ||
| 141 | decorations: tokenizeBlock(highlighter, node.textContent, language, theme), | ||
| 142 | }); | 136 | }); |
| 143 | }), | 137 | offset += token.content.length; |
| 144 | ).then(() => { | 138 | } |
| 145 | if (disposed) return; | 139 | } |
| 146 | view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); | 140 | } catch { |
| 147 | }); | 141 | /* Unsupported languages retain source text. */ |
| 148 | }; | 142 | } |
| 149 | 143 | this.cache.set(key, spans); | |
| 150 | refresh(); | ||
| 151 | // The theme lives on <html>, outside the editor, so nothing would | ||
| 152 | // otherwise tell the view a repaint is due. | ||
| 153 | const themeObserver = new MutationObserver(refresh); | ||
| 154 | themeObserver.observe(document.documentElement, { | ||
| 155 | attributeFilter: ["class"], | ||
| 156 | }); | ||
| 157 | |||
| 158 | return { | ||
| 159 | update: refresh, | ||
| 160 | destroy() { | ||
| 161 | disposed = true; | ||
| 162 | themeObserver.disconnect(); | ||
| 163 | }, | ||
| 164 | }; | ||
| 165 | }, | ||
| 166 | }), | 144 | }), |
| 167 | ]; | 145 | ).then(() => { |
| 168 | }, | 146 | if (!this.disposed) this.view.dispatch({ effects: this.repaint.of(null) }); |
| 169 | }); | 147 | }); |
| 170 | } | ||
| 171 | |||
| 172 | type Highlighter = Awaited<ReturnType<typeof getSyntaxHighlighterPromise>>; | ||
| 173 | |||
| 174 | /** Flattens Shiki's line/token structure into offsets within the block's text. */ | ||
| 175 | export function tokenizeBlock( | ||
| 176 | highlighter: Highlighter, | ||
| 177 | code: string, | ||
| 178 | language: string, | ||
| 179 | theme: DiffThemeName, | ||
| 180 | ): ReadonlyArray<{ from: number; to: number; color: string }> { | ||
| 181 | let tokens; | ||
| 182 | try { | ||
| 183 | tokens = highlighter.codeToTokens(code, { lang: language, theme }).tokens; | ||
| 184 | } catch { | ||
| 185 | // An unsupported language should read as plain text, not break the editor. | ||
| 186 | return []; | ||
| 187 | } | ||
| 188 | |||
| 189 | const decorations: Array<{ from: number; to: number; color: string }> = []; | ||
| 190 | let offset = 0; | ||
| 191 | for (const [lineIndex, line] of tokens.entries()) { | ||
| 192 | // The separator between lines, which Shiki drops; CRLF is two characters. | ||
| 193 | if (lineIndex > 0) offset += code.startsWith("\r\n", offset) ? 2 : 1; | ||
| 194 | for (const token of line) { | ||
| 195 | const length = token.content.length; | ||
| 196 | if (token.color && token.content.trim()) { | ||
| 197 | decorations.push({ from: offset, to: offset + length, color: token.color }); | ||
| 198 | } | ||
| 199 | offset += length; | ||
| 200 | } | 148 | } |
| 201 | } | 149 | destroy() { |
| 202 | return decorations; | 150 | this.disposed = true; |
| 203 | } | 151 | this.observer.disconnect(); |
| 204 | |||
| 205 | function buildDecorations( | ||
| 206 | state: EditorState, | ||
| 207 | cache: Map<string, HighlightedBlock>, | ||
| 208 | theme: DiffThemeName, | ||
| 209 | ): DecorationSet { | ||
| 210 | const decorations: Decoration[] = []; | ||
| 211 | for (const { node, pos } of collectCodeBlocks(state)) { | ||
| 212 | if (!shouldHighlightCodeBlock(node.textContent)) continue; | ||
| 213 | const highlighted = cache.get(blockSignature(node, theme)); | ||
| 214 | if (!highlighted) continue; | ||
| 215 | // Token offsets index the block's text, which lines up with document | ||
| 216 | // positions only because `codeBlock` is `text*` with no marks — one | ||
| 217 | // unmarked text node, so `content.size === textContent.length`. Allowing | ||
| 218 | // marks or hard breaks in the code block would desync these silently. | ||
| 219 | if (node.content.size !== node.textContent.length) continue; | ||
| 220 | // +1 steps past the code block's opening token into its text. | ||
| 221 | const start = pos + 1; | ||
| 222 | for (const decoration of highlighted.decorations) { | ||
| 223 | decorations.push( | ||
| 224 | Decoration.inline(start + decoration.from, start + decoration.to, { | ||
| 225 | style: `color:${decoration.color}`, | ||
| 226 | }), | ||
| 227 | ); | ||
| 228 | } | 152 | } |
| 229 | } | 153 | }, |
| 230 | return DecorationSet.create(state.doc, decorations); | 154 | { decorations: (plugin) => plugin.decorations }, |
| 231 | } | 155 | ); |
apps/web/src/components/composerMarkdownEditor.test.ts created+412| ... | @@ -0,0 +1,412 @@ | ||
| 1 | // @vitest-environment jsdom | ||
| 2 | |||
| 3 | import { undo, redo } from "@codemirror/commands"; | ||
| 4 | import { Transaction } from "@codemirror/state"; | ||
| 5 | import { ComposerContextId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; | ||
| 6 | import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; | ||
| 7 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | ||
| 8 | import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; | ||
| 9 | import { | ||
| 10 | COMPOSER_CONTEXT_CLIPBOARD_MIME, | ||
| 11 | encodeComposerContextFragment, | ||
| 12 | } from "@t3tools/shared/composerContextClipboard"; | ||
| 13 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | ||
| 14 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 15 | |||
| 16 | import { detectComposerTrigger, composerSubmissionIntentForKey } from "~/composer-logic"; | ||
| 17 | import { composerEmojiKeyAction } from "./chat/composerMenuHighlight"; | ||
| 18 | import { | ||
| 19 | composerSuggestionsSuppressed, | ||
| 20 | composerTokens, | ||
| 21 | createComposerMarkdownEditor, | ||
| 22 | } from "./composerMarkdownEditor"; | ||
| 23 | import type { ComposerPromptEditorProps } from "./ComposerPromptEditor"; | ||
| 24 | |||
| 25 | let editor: ReturnType<typeof createComposerMarkdownEditor>; | ||
| 26 | let props: ComposerPromptEditorProps; | ||
| 27 | let sends: ReturnType<typeof vi.fn<(intent: string) => void>>; | ||
| 28 | |||
| 29 | beforeEach(() => { | ||
| 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 | }); | ||
| 50 | afterEach(() => { | ||
| 51 | editor?.destroy(); | ||
| 52 | editor?.dom.remove(); | ||
| 53 | }); | ||
| 54 | |||
| 55 | function 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 | } | ||
| 66 | function 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 | } | ||
| 71 | function 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 | |||
| 95 | describe("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 | |||
| 282 | it("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 | |||
| 337 | it("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 | |||
| 346 | it.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 | |||
| 363 | it.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 | |||
| 378 | it("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 | |||
| 390 | it("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 | |||
| 401 | it("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 @@ | ||
| 1 | import { | ||
| 2 | chipField, | ||
| 3 | createMarkdownEditor, | ||
| 4 | enclosingCodeBlock, | ||
| 5 | insertNewline, | ||
| 6 | livePreview, | ||
| 7 | type MarkdownEditor, | ||
| 8 | } from "@clo/markdown-editor"; | ||
| 9 | import { isolateHistory, indentLess, indentMore } from "@codemirror/commands"; | ||
| 10 | import { | ||
| 11 | defaultHighlightStyle, | ||
| 12 | HighlightStyle, | ||
| 13 | syntaxHighlighting, | ||
| 14 | syntaxTree, | ||
| 15 | } from "@codemirror/language"; | ||
| 16 | import { | ||
| 17 | Compartment, | ||
| 18 | EditorState, | ||
| 19 | Prec, | ||
| 20 | StateEffect, | ||
| 21 | StateField, | ||
| 22 | Transaction, | ||
| 23 | } from "@codemirror/state"; | ||
| 24 | import type { SyntaxNode } from "@lezer/common"; | ||
| 25 | import { Decoration, EditorView, WidgetType } from "@codemirror/view"; | ||
| 26 | |||
| 27 | import { cn } from "~/lib/utils"; | ||
| 28 | import { collectComposerPromptInlineTokens } from "~/composer-editor-mentions"; | ||
| 29 | import { collapseExpandedComposerCursor, expandCollapsedComposerCursor } from "~/composer-logic"; | ||
| 30 | import { getTimelinePageScrollKey } from "./chat/pageScrollController"; | ||
| 31 | import { importPastedComposerText } from "./composerInlineTokenPaste"; | ||
| 32 | import { composerCodeBlocks, composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; | ||
| 33 | import type { ComposerPromptEditorProps } from "./ComposerPromptEditor"; | ||
| 34 | |||
| 35 | export type ComposerToken = ReturnType<typeof collectComposerPromptInlineTokens>[number]; | ||
| 36 | export 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. | ||
| 42 | const composerSyntaxHighlight = syntaxHighlighting( | ||
| 43 | HighlightStyle.define( | ||
| 44 | defaultHighlightStyle.specs.map((style) => ({ ...style, color: "inherit" })), | ||
| 45 | ), | ||
| 46 | ); | ||
| 47 | |||
| 48 | export 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 | |||
| 65 | export 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 | |||
| 80 | class 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 | |||
| 119 | class 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 | |||
| 147 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { didComposerSelectionChangeVisibly } from "./composerSelection"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | export type ComposerSelectionRange = { | ||
| 2 | start: number; | ||
| 3 | end: number; | ||
| 4 | }; | ||
| 5 | |||
| 6 | export 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 { | ... | @@ -12,7 +12,7 @@ import { |
| 12 | htmlRenderThemeFragment, | 12 | htmlRenderThemeFragment, |
| 13 | htmlRenderThemeMessage, | 13 | htmlRenderThemeMessage, |
| 14 | htmlRenderResult, | 14 | htmlRenderResult, |
| 15 | readHtmlRenderContentHeight, | 15 | readHtmlRenderContentSize, |
| 16 | readHtmlRenderKeyDown, | 16 | readHtmlRenderKeyDown, |
| 17 | readHtmlRenderLinkRequest, | 17 | readHtmlRenderLinkRequest, |
| 18 | } from "@t3tools/shared/htmlRender"; | 18 | } from "@t3tools/shared/htmlRender"; |
| ... | @@ -122,8 +122,9 @@ export function HtmlRenderDocument(props: { | ... | @@ -122,8 +122,9 @@ export function HtmlRenderDocument(props: { |
| 122 | readonly className?: string; | 122 | readonly className?: string; |
| 123 | /** The thread the page's links open beside, as a chat link would. */ | 123 | /** The thread the page's links open beside, as a chat link would. */ |
| 124 | readonly threadRef?: ScopedThreadRef | undefined; | 124 | readonly threadRef?: ScopedThreadRef | undefined; |
| 125 | /** Receives the page's content height whenever it changes, so an inline frame can fit it. */ | 125 | readonly onContentSize?: ( |
| 126 | readonly onContentHeight?: (height: number) => void; | 126 | size: NonNullable<ReturnType<typeof readHtmlRenderContentSize>>, |
| 127 | ) => void; | ||
| 127 | }) { | 128 | }) { |
| 128 | const theme = useHtmlRenderTheme(); | 129 | const theme = useHtmlRenderTheme(); |
| 129 | const frameRef = useRef<HTMLIFrameElement>(null); | 130 | const frameRef = useRef<HTMLIFrameElement>(null); |
| ... | @@ -265,20 +266,20 @@ export function HtmlRenderDocument(props: { | ... | @@ -265,20 +266,20 @@ export function HtmlRenderDocument(props: { |
| 265 | window.addEventListener("message", replayKey); | 266 | window.addEventListener("message", replayKey); |
| 266 | return () => window.removeEventListener("message", replayKey); | 267 | return () => window.removeEventListener("message", replayKey); |
| 267 | }, []); | 268 | }, []); |
| 268 | const { onContentHeight } = props; | 269 | const { onContentSize } = props; |
| 269 | // A page posts its height once per change, so listen from the commit that | 270 | // A page posts its size once per change, so listen from the commit that |
| 270 | // inserts the frame; a passive effect could run after a fast page's first post. | 271 | // inserts the frame; a passive effect could run after a fast page's first post. |
| 271 | useLayoutEffect(() => { | 272 | useLayoutEffect(() => { |
| 272 | if (onContentHeight === undefined) return; | 273 | if (onContentSize === undefined) return; |
| 273 | const resize = (event: MessageEvent) => { | 274 | const resize = (event: MessageEvent) => { |
| 274 | const height = readHtmlRenderContentHeight(event.data); | 275 | const size = readHtmlRenderContentSize(event.data); |
| 275 | if (height !== undefined && event.source === frameRef.current?.contentWindow) { | 276 | if (size !== undefined && event.source === frameRef.current?.contentWindow) { |
| 276 | onContentHeight(height); | 277 | onContentSize(size); |
| 277 | } | 278 | } |
| 278 | }; | 279 | }; |
| 279 | window.addEventListener("message", resize); | 280 | window.addEventListener("message", resize); |
| 280 | return () => window.removeEventListener("message", resize); | 281 | return () => window.removeEventListener("message", resize); |
| 281 | }, [onContentHeight]); | 282 | }, [onContentSize]); |
| 282 | return ( | 283 | return ( |
| 283 | <iframe | 284 | <iframe |
| 284 | ref={frameRef} | 285 | ref={frameRef} |
apps/web/src/components/media/MediaActions.test.tsx+127| ... | @@ -24,6 +24,10 @@ const state = vi.hoisted(() => ({ | ... | @@ -24,6 +24,10 @@ const state = vi.hoisted(() => ({ |
| 24 | showMenu: vi.fn(), | 24 | showMenu: vi.fn(), |
| 25 | openFile: vi.fn(), | 25 | openFile: vi.fn(), |
| 26 | clipboard: vi.fn(), | 26 | clipboard: vi.fn(), |
| 27 | openVideo: vi.fn(), | ||
| 28 | reveal: vi.fn(), | ||
| 29 | local: false, | ||
| 30 | revealLabel: undefined as string | undefined, | ||
| 27 | menuFinished: null as (() => void) | null, | 31 | menuFinished: null as (() => void) | null, |
| 28 | })); | 32 | })); |
| 29 | 33 | ||
| ... | @@ -33,6 +37,12 @@ vi.mock("react", async (importOriginal) => ({ | ... | @@ -33,6 +37,12 @@ vi.mock("react", async (importOriginal) => ({ |
| 33 | useRef: <A,>(current: A) => ({ current }), | 37 | useRef: <A,>(current: A) => ({ current }), |
| 34 | useState: <A,>(initial: A) => [initial, () => {}], | 38 | useState: <A,>(initial: A) => [initial, () => {}], |
| 35 | })); | 39 | })); |
| 40 | vi.mock("../../fileContextMenu", () => ({ | ||
| 41 | useFileContextMenu: () => ({ | ||
| 42 | capabilities: { revealLabel: state.revealLabel }, | ||
| 43 | activate: state.reveal, | ||
| 44 | }), | ||
| 45 | })); | ||
| 36 | vi.mock("../../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: vi.fn() })); | 46 | vi.mock("../../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: vi.fn() })); |
| 37 | vi.mock("../../localApi", () => ({ | 47 | vi.mock("../../localApi", () => ({ |
| 38 | readLocalApi: () => ({ contextMenu: { show: state.showMenu } }), | 48 | readLocalApi: () => ({ contextMenu: { show: state.showMenu } }), |
| ... | @@ -71,6 +81,7 @@ vi.mock("../ui/toast", () => ({ | ... | @@ -71,6 +81,7 @@ vi.mock("../ui/toast", () => ({ |
| 71 | return "toast"; | 81 | return "toast"; |
| 72 | }, | 82 | }, |
| 73 | update: () => state.menuFinished?.(), | 83 | update: () => state.menuFinished?.(), |
| 84 | close: () => state.menuFinished?.(), | ||
| 74 | }, | 85 | }, |
| 75 | })); | 86 | })); |
| 76 | 87 | ||
| ... | @@ -119,6 +130,16 @@ function openMenu(source: MediaActionSource) { | ... | @@ -119,6 +130,16 @@ function openMenu(source: MediaActionSource) { |
| 119 | } | 130 | } |
| 120 | 131 | ||
| 121 | beforeEach(() => { | 132 | beforeEach(() => { |
| 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 | }); | ||
| 122 | state.sessions.clear(); | 143 | state.sessions.clear(); |
| 123 | state.sessions.set(environmentId, denied); | 144 | state.sessions.set(environmentId, denied); |
| 124 | state.mint | 145 | state.mint |
| ... | @@ -305,3 +326,109 @@ it.each(["https://cdn.test/image.png", "blob:local-image"])( | ... | @@ -305,3 +326,109 @@ it.each(["https://cdn.test/image.png", "blob:local-image"])( |
| 305 | expect(state.download).toHaveBeenCalledWith(src, "image.png"); | 326 | expect(state.download).toHaveBeenCalledWith(src, "image.png"); |
| 306 | }, | 327 | }, |
| 307 | ); | 328 | ); |
| 329 | |||
| 330 | function 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 | |||
| 341 | it("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 | |||
| 362 | it("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 | |||
| 375 | it("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 | |||
| 387 | it("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 | |||
| 394 | it("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 | |||
| 403 | it("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" | ... | @@ -8,7 +8,6 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" |
| 8 | import { | 8 | import { |
| 9 | AuthFilesystemReadScope, | 9 | AuthFilesystemReadScope, |
| 10 | type AssetResource, | 10 | type AssetResource, |
| 11 | type AuthSessionState, | ||
| 12 | type ContextMenuItem, | 11 | type ContextMenuItem, |
| 13 | type EnvironmentId, | 12 | type EnvironmentId, |
| 14 | sessionGrantsScope, | 13 | sessionGrantsScope, |
| ... | @@ -18,6 +17,7 @@ import * as Option from "effect/Option"; | ... | @@ -18,6 +17,7 @@ import * as Option from "effect/Option"; |
| 18 | import { AsyncResult } from "effect/reactivity"; | 17 | import { AsyncResult } from "effect/reactivity"; |
| 19 | import { useCallback, useRef, useState, type ReactElement } from "react"; | 18 | import { useCallback, useRef, useState, type ReactElement } from "react"; |
| 20 | 19 | ||
| 20 | import { useFileContextMenu } from "../../fileContextMenu"; | ||
| 21 | import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; | 21 | import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; |
| 22 | import { readLocalApi } from "../../localApi"; | 22 | import { readLocalApi } from "../../localApi"; |
| 23 | import { appAtomRegistry } from "../../rpc/atomRegistry"; | 23 | import { appAtomRegistry } from "../../rpc/atomRegistry"; |
| ... | @@ -56,11 +56,12 @@ function canReadHostMedia(environmentId: EnvironmentId | null): boolean { | ... | @@ -56,11 +56,12 @@ function canReadHostMedia(environmentId: EnvironmentId | null): boolean { |
| 56 | } | 56 | } |
| 57 | 57 | ||
| 58 | /** Explicit byte operations get fresh capabilities without replacing a player's active source. */ | 58 | /** 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. */ | ||
| 60 | export function useMediaActions(source: MediaActionSource) { | 59 | export function useMediaActions(source: MediaActionSource) { |
| 61 | const hostEnvironmentId = | 60 | const hostEnvironmentId = |
| 62 | source.asset && | 61 | 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") | ||
| 64 | ? source.asset.environmentId | 65 | ? source.asset.environmentId |
| 65 | : null; | 66 | : null; |
| 66 | const fileSession = useEnvironmentQuery( | 67 | const fileSession = useEnvironmentQuery( |
| ... | @@ -74,6 +75,22 @@ export function useMediaActions(source: MediaActionSource) { | ... | @@ -74,6 +75,22 @@ export function useMediaActions(source: MediaActionSource) { |
| 74 | throw new Error("This connection cannot read host files."); | 75 | throw new Error("This connection cannot read host files."); |
| 75 | } | 76 | } |
| 76 | }, [hostEnvironmentId]); | 77 | }, [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))); | ||
| 77 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | 94 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 78 | reportFailure: false, | 95 | reportFailure: false, |
| 79 | refresh: true, | 96 | refresh: true, |
| ... | @@ -109,28 +126,64 @@ export function useMediaActions(source: MediaActionSource) { | ... | @@ -109,28 +126,64 @@ export function useMediaActions(source: MediaActionSource) { |
| 109 | new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }), | 126 | new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }), |
| 110 | ]); | 127 | ]); |
| 111 | }, [actionUrl, assertCanReadMedia]); | 128 | }, [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 | }; | ||
| 113 | } | 166 | } |
| 114 | 167 | ||
| 115 | /** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ | 168 | export function useMediaActionMenu(source: MediaActionSource) { |
| 116 | export function MediaActions({ | 169 | const { |
| 117 | source, | 170 | save, |
| 118 | children, | 171 | copyImage, |
| 119 | }: { | 172 | reveal, |
| 120 | source: MediaActionSource; | 173 | openQuickTime, |
| 121 | children: ReactElement; | 174 | canReveal, |
| 122 | }) { | 175 | canOpenQuickTime, |
| 123 | const { save, copyImage, canReadMedia, assertCanReadMedia } = useMediaActions(source); | 176 | revealLabel, |
| 124 | const [tooltipOpen, setTooltipOpen] = useState(false); | 177 | canReadMedia, |
| 178 | assertCanReadMedia, | ||
| 179 | } = useMediaActions(source); | ||
| 125 | const menuOpen = useRef(false); | 180 | const menuOpen = useRef(false); |
| 126 | const reference = source.reference; | 181 | const reference = source.reference; |
| 127 | const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name); | ||
| 128 | 182 | ||
| 129 | const showMenu = async (position: { x: number; y: number }) => { | 183 | const showMenu = async (position: { x: number; y: number }) => { |
| 130 | const api = readLocalApi(); | 184 | const api = readLocalApi(); |
| 131 | if (!api || menuOpen.current) return; | 185 | if (!api || menuOpen.current) return; |
| 132 | menuOpen.current = true; | 186 | menuOpen.current = true; |
| 133 | setTooltipOpen(false); | ||
| 134 | let failureTitle = "Could not open media menu"; | 187 | let failureTitle = "Could not open media menu"; |
| 135 | let progressToast: ReturnType<typeof toastManager.add> | undefined; | 188 | let progressToast: ReturnType<typeof toastManager.add> | undefined; |
| 136 | try { | 189 | try { |
| ... | @@ -150,7 +203,16 @@ export function MediaActions({ | ... | @@ -150,7 +203,16 @@ export function MediaActions({ |
| 150 | } | 203 | } |
| 151 | if (source.onOpenFile) | 204 | if (source.onOpenFile) |
| 152 | items.push({ id: "open-file", label: "Open in file viewer", disabled: !canReadMedia }); | 205 | 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 }); | ||
| 154 | if (source.kind === "image") { | 216 | if (source.kind === "image") { |
| 155 | items.push({ | 217 | items.push({ |
| 156 | id: "copy-image", | 218 | id: "copy-image", |
| ... | @@ -179,6 +241,12 @@ export function MediaActions({ | ... | @@ -179,6 +241,12 @@ export function MediaActions({ |
| 179 | } else if (action === "open-file") { | 241 | } else if (action === "open-file") { |
| 180 | assertCanReadMedia(); | 242 | assertCanReadMedia(); |
| 181 | source.onOpenFile?.(); | 243 | 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); | ||
| 182 | } else if (action === "save" || action === "copy-image") { | 250 | } else if (action === "save" || action === "copy-image") { |
| 183 | progressToast = toastManager.add({ | 251 | progressToast = toastManager.add({ |
| 184 | type: "loading", | 252 | type: "loading", |
| ... | @@ -203,6 +271,22 @@ export function MediaActions({ | ... | @@ -203,6 +271,22 @@ export function MediaActions({ |
| 203 | } | 271 | } |
| 204 | }; | 272 | }; |
| 205 | 273 | ||
| 274 | return { showMenu }; | ||
| 275 | } | ||
| 276 | |||
| 277 | /** Adds source-aware actions and a tooltip without a layout wrapper. */ | ||
| 278 | export 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 | |||
| 206 | return ( | 290 | return ( |
| 207 | <Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}> | 291 | <Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}> |
| 208 | <TooltipTrigger | 292 | <TooltipTrigger |
| ... | @@ -213,6 +297,7 @@ export function MediaActions({ | ... | @@ -213,6 +297,7 @@ export function MediaActions({ |
| 213 | event.preventDefault(); | 297 | event.preventDefault(); |
| 214 | event.stopPropagation(); | 298 | event.stopPropagation(); |
| 215 | const bounds = event.currentTarget.getBoundingClientRect(); | 299 | const bounds = event.currentTarget.getBoundingClientRect(); |
| 300 | setTooltipOpen(false); | ||
| 216 | void showMenu( | 301 | void showMenu( |
| 217 | event.clientX === 0 && event.clientY === 0 | 302 | event.clientX === 0 && event.clientY === 0 |
| 218 | ? { x: bounds.left, y: bounds.bottom } | 303 | ? { x: bounds.left, y: bounds.bottom } |
| ... | @@ -228,6 +313,7 @@ export function MediaActions({ | ... | @@ -228,6 +313,7 @@ export function MediaActions({ |
| 228 | event.preventDefault(); | 313 | event.preventDefault(); |
| 229 | event.stopPropagation(); | 314 | event.stopPropagation(); |
| 230 | const bounds = event.currentTarget.getBoundingClientRect(); | 315 | const bounds = event.currentTarget.getBoundingClientRect(); |
| 316 | setTooltipOpen(false); | ||
| 231 | void showMenu({ x: bounds.left, y: bounds.bottom }); | 317 | void showMenu({ x: bounds.left, y: bounds.bottom }); |
| 232 | }} | 318 | }} |
| 233 | /> | 319 | /> |
apps/web/src/components/media/MediaPlaybackHost.test.tsx created+336| ... | @@ -0,0 +1,336 @@ | ||
| 1 | // @vitest-environment jsdom | ||
| 2 | |||
| 3 | import { act, StrictMode, useState } from "react"; | ||
| 4 | import { createRoot, type Root } from "react-dom/client"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { MediaPlaybackProvider, MediaPlaybackSlot } from "./MediaPlaybackHost"; | ||
| 8 | |||
| 9 | let root: Root; | ||
| 10 | let container: HTMLDivElement; | ||
| 11 | let playing: WeakSet<HTMLMediaElement>; | ||
| 12 | let pause: ReturnType<typeof vi.spyOn>; | ||
| 13 | let load: ReturnType<typeof vi.spyOn>; | ||
| 14 | let nextPlayer = 0; | ||
| 15 | |||
| 16 | function 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 | |||
| 26 | beforeEach(() => { | ||
| 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 | |||
| 54 | afterEach(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 | |||
| 64 | async 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 | |||
| 86 | function 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 | |||
| 92 | describe("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 @@ | ||
| 1 | import { XIcon } from "lucide-react"; | ||
| 2 | import { | ||
| 3 | createContext, | ||
| 4 | useContext, | ||
| 5 | useLayoutEffect, | ||
| 6 | useRef, | ||
| 7 | useState, | ||
| 8 | useSyncExternalStore, | ||
| 9 | type ReactNode, | ||
| 10 | } from "react"; | ||
| 11 | import { createPortal } from "react-dom"; | ||
| 12 | |||
| 13 | import { Button } from "../ui/button"; | ||
| 14 | |||
| 15 | interface PlaybackEntry { | ||
| 16 | readonly identity: string; | ||
| 17 | readonly container: HTMLSpanElement; | ||
| 18 | readonly key: number; | ||
| 19 | slot: HTMLSpanElement | null; | ||
| 20 | children: ReactNode; | ||
| 21 | } | ||
| 22 | |||
| 23 | function 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 | |||
| 32 | function hasRetainedPlayback(entry: PlaybackEntry) { | ||
| 33 | const video = entry.container.querySelector("video"); | ||
| 34 | return video && ((!video.paused && !video.ended) || document.pictureInPictureElement === video); | ||
| 35 | } | ||
| 36 | |||
| 37 | function 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 | |||
| 156 | const PlaybackContext = createContext<ReturnType<typeof createPlaybackHost> | null>(null); | ||
| 157 | |||
| 158 | export 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 | |||
| 221 | function 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 | |||
| 252 | export 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 | ||
| 2 | import { act } from "react"; | ||
| 3 | import { createRoot, type Root } from "react-dom/client"; | ||
| 4 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | vi.mock("./MediaActions", () => ({ useMediaActionMenu: () => ({ showMenu: vi.fn() }) })); | ||
| 7 | |||
| 8 | import { MediaVideoControls } from "./MediaVideoControls"; | ||
| 9 | |||
| 10 | let root: Root; | ||
| 11 | let container: HTMLDivElement; | ||
| 12 | let video: HTMLVideoElement; | ||
| 13 | |||
| 14 | beforeEach(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 | |||
| 40 | afterEach(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 | |||
| 49 | function button(name: string) { | ||
| 50 | const node = container.querySelector<HTMLButtonElement>(`button[aria-label="${name}"]`); | ||
| 51 | expect(node, name).not.toBeNull(); | ||
| 52 | return node!; | ||
| 53 | } | ||
| 54 | |||
| 55 | async 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 | |||
| 69 | it("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 | |||
| 80 | it("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 | |||
| 91 | it("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 | |||
| 107 | it("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 | |||
| 116 | it("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 | |||
| 124 | it("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. */ | ||
| 2 | import { | ||
| 3 | MaximizeIcon, | ||
| 4 | MinimizeIcon, | ||
| 5 | MoreHorizontalIcon, | ||
| 6 | PauseIcon, | ||
| 7 | PictureInPicture2Icon, | ||
| 8 | PlayIcon, | ||
| 9 | SlidersHorizontalIcon, | ||
| 10 | Volume2Icon, | ||
| 11 | VolumeXIcon, | ||
| 12 | } from "lucide-react"; | ||
| 13 | import { useEffect, useState } from "react"; | ||
| 14 | |||
| 15 | import { Button } from "../ui/button"; | ||
| 16 | import { useMediaActionMenu, type MediaActionSource } from "./MediaActions"; | ||
| 17 | |||
| 18 | function 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 | |||
| 26 | function 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 | |||
| 38 | function 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 | |||
| 57 | export 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 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { PlayIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; | 1 | import { PlayIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; |
| 2 | import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; | ||
| 2 | import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; | 3 | import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; |
| 3 | 4 | ||
| 4 | import { cn } from "../../lib/utils"; | 5 | import { cn } from "../../lib/utils"; |
| ... | @@ -6,6 +7,9 @@ import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | ... | @@ -6,6 +7,9 @@ import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; |
| 6 | import { Button } from "../ui/button"; | 7 | import { Button } from "../ui/button"; |
| 7 | import { OpenMediaLink } from "./OpenMediaLink"; | 8 | import { OpenMediaLink } from "./OpenMediaLink"; |
| 8 | import { MediaActions, type MediaActionSource } from "./MediaActions"; | 9 | import { MediaActions, type MediaActionSource } from "./MediaActions"; |
| 10 | import { MediaPlaybackSlot } from "./MediaPlaybackHost"; | ||
| 11 | import { MediaVideoControls } from "./MediaVideoControls"; | ||
| 12 | import "./MediaVideoPlayer.css"; | ||
| 9 | 13 | ||
| 10 | interface MediaVideoPlayerProps { | 14 | interface MediaVideoPlayerProps { |
| 11 | readonly src: string | null; | 15 | readonly src: string | null; |
| ... | @@ -27,8 +31,21 @@ interface MediaVideoPlayerProps { | ... | @@ -27,8 +31,21 @@ interface MediaVideoPlayerProps { |
| 27 | readonly actionsSource?: MediaActionSource | undefined; | 31 | readonly actionsSource?: MediaActionSource | undefined; |
| 28 | } | 32 | } |
| 29 | 33 | ||
| 30 | /** Keeps native range streaming and playback state consistent across inline and file previews. */ | 34 | export function MediaVideoPlayer(props: MediaVideoPlayerProps) { |
| 31 | export function MediaVideoPlayer({ | 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 | |||
| 48 | function MediaVideoPlayerContent({ | ||
| 32 | src: latestSrc, | 49 | src: latestSrc, |
| 33 | label, | 50 | label, |
| 34 | sourceFailed = false, | 51 | sourceFailed = false, |
| ... | @@ -46,6 +63,11 @@ export function MediaVideoPlayer({ | ... | @@ -46,6 +63,11 @@ export function MediaVideoPlayer({ |
| 46 | actionsSource, | 63 | actionsSource, |
| 47 | }: MediaVideoPlayerProps) { | 64 | }: MediaVideoPlayerProps) { |
| 48 | const videoRef = useRef<HTMLVideoElement>(null); | 65 | 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 | }, []); | ||
| 49 | const [playbackSource, setPlaybackSource] = useState<{ | 71 | const [playbackSource, setPlaybackSource] = useState<{ |
| 50 | src: string; | 72 | src: string; |
| 51 | revision: string | null; | 73 | revision: string | null; |
| ... | @@ -54,9 +76,11 @@ export function MediaVideoPlayer({ | ... | @@ -54,9 +76,11 @@ export function MediaVideoPlayer({ |
| 54 | const [retrying, setRetrying] = useState(false); | 76 | const [retrying, setRetrying] = useState(false); |
| 55 | const [loadAttempt, setLoadAttempt] = useState(0); | 77 | const [loadAttempt, setLoadAttempt] = useState(0); |
| 56 | const [preloadedSrc, setPreloadedSrc] = useState<string | null>(null); | 78 | const [preloadedSrc, setPreloadedSrc] = useState<string | null>(null); |
| 79 | const [videoSize, setVideoSize] = useState<{ src: string; ratio: number } | null>(null); | ||
| 57 | const src = playbackSource?.src ?? latestSrc; | 80 | const src = playbackSource?.src ?? latestSrc; |
| 58 | const sourceRevision = playbackSource === null ? revision : playbackSource.revision; | 81 | const sourceRevision = playbackSource === null ? revision : playbackSource.revision; |
| 59 | const failed = src !== null ? failedSrc === src : sourceFailed; | 82 | const failed = src !== null ? failedSrc === src : sourceFailed; |
| 83 | const fallbackReference = mediaUrlReference(originalUrl ?? src ?? ""); | ||
| 60 | 84 | ||
| 61 | // Re-signing must not reset the playhead. Changed files refresh once playback pauses. | 85 | // Re-signing must not reset the playhead. Changed files refresh once playback pauses. |
| 62 | const refreshPausedRevision = useCallback(() => { | 86 | const refreshPausedRevision = useCallback(() => { |
| ... | @@ -100,7 +124,8 @@ export function MediaVideoPlayer({ | ... | @@ -100,7 +124,8 @@ export function MediaVideoPlayer({ |
| 100 | const fullscreen = | 124 | const fullscreen = |
| 101 | document.fullscreenElement?.contains(video) || | 125 | document.fullscreenElement?.contains(video) || |
| 102 | ("webkitDisplayingFullscreen" in video && video.webkitDisplayingFullscreen === true); | 126 | ("webkitDisplayingFullscreen" in video && video.webkitDisplayingFullscreen === true); |
| 103 | if (document.hidden && !fullscreen) video.pause(); | 127 | if (document.hidden && !fullscreen && document.pictureInPictureElement !== video) |
| 128 | video.pause(); | ||
| 104 | }; | 129 | }; |
| 105 | document.addEventListener("visibilitychange", pauseWhenHidden); | 130 | document.addEventListener("visibilitychange", pauseWhenHidden); |
| 106 | document.addEventListener("fullscreenchange", pauseWhenHidden); | 131 | document.addEventListener("fullscreenchange", pauseWhenHidden); |
| ... | @@ -130,17 +155,21 @@ export function MediaVideoPlayer({ | ... | @@ -130,17 +155,21 @@ export function MediaVideoPlayer({ |
| 130 | 155 | ||
| 131 | const player = ( | 156 | const player = ( |
| 132 | <span | 157 | <span |
| 133 | className={cn("relative inline-block align-middle", className)} | 158 | data-media-player |
| 134 | style={style} | 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 | } | ||
| 135 | data-markdown-copy={copyMarkdown} | 166 | data-markdown-copy={copyMarkdown} |
| 136 | > | 167 | > |
| 137 | {failed && !onOpen ? ( | 168 | {failed && !onOpen ? ( |
| 138 | <span | 169 | <span |
| 139 | role="alert" | 170 | role="alert" |
| 140 | className={cn( | 171 | className={cn( |
| 141 | // Same 16:9 slot as the loading and playing states, so a failed or | 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", |
| 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", | ||
| 144 | stateClassName, | 173 | stateClassName, |
| 145 | )} | 174 | )} |
| 146 | > | 175 | > |
| ... | @@ -166,22 +195,28 @@ export function MediaVideoPlayer({ | ... | @@ -166,22 +195,28 @@ export function MediaVideoPlayer({ |
| 166 | ) : src !== null && !failed ? ( | 195 | ) : src !== null && !failed ? ( |
| 167 | <video | 196 | <video |
| 168 | key={loadAttempt} | 197 | key={loadAttempt} |
| 169 | ref={videoRef} | 198 | ref={attachVideo} |
| 170 | src={src} | 199 | src={src} |
| 171 | aria-label={label || "Video preview"} | 200 | aria-label={label || "Video preview"} |
| 172 | aria-hidden={onOpen ? true : undefined} | 201 | aria-hidden={onOpen ? true : undefined} |
| 173 | autoPlay={onOpen ? false : autoPlay} | 202 | autoPlay={onOpen ? false : autoPlay} |
| 174 | controls={!onOpen} | 203 | controls={false} |
| 175 | muted={onOpen ? true : undefined} | 204 | muted={onOpen ? true : undefined} |
| 176 | playsInline | 205 | playsInline |
| 177 | preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"} | 206 | preload={preload === "metadata" || preloadedSrc === src ? "metadata" : "none"} |
| 178 | className={cn( | 207 | 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", |
| 180 | onOpen && "pointer-events-none", | 209 | onOpen && "pointer-events-none", |
| 181 | videoClassName, | 210 | videoClassName, |
| 182 | )} | 211 | )} |
| 183 | style={style} | 212 | 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 | }} | ||
| 185 | onPlay={() => setPlaybackSource({ src, revision: sourceRevision })} | 220 | onPlay={() => setPlaybackSource({ src, revision: sourceRevision })} |
| 186 | onPause={refreshPausedRevision} | 221 | onPause={refreshPausedRevision} |
| 187 | onEnded={refreshPausedRevision} | 222 | onEnded={refreshPausedRevision} |
| ... | @@ -194,10 +229,26 @@ export function MediaVideoPlayer({ | ... | @@ -194,10 +229,26 @@ export function MediaVideoPlayer({ |
| 194 | <span | 229 | <span |
| 195 | role="status" | 230 | role="status" |
| 196 | aria-label={failed ? "Video preview unavailable" : "Loading video"} | 231 | 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 | )} | ||
| 198 | style={style} | 236 | style={style} |
| 199 | /> | 237 | /> |
| 200 | )} | 238 | )} |
| 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} | ||
| 201 | {onOpen ? ( | 252 | {onOpen ? ( |
| 202 | <button | 253 | <button |
| 203 | type="button" | 254 | type="button" |
apps/web/src/components/settings/settingsSearch.ts+1-1| ... | @@ -451,7 +451,7 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -451,7 +451,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 451 | id: "composer-rich-text", | 451 | id: "composer-rich-text", |
| 452 | title: "Rich text composer", | 452 | title: "Rich text composer", |
| 453 | to: "/settings/general", | 453 | to: "/settings/general", |
| 454 | searchTerms: ["composer rich text tiptap bold italic markdown styled wysiwyg"], | 454 | searchTerms: ["composer rich text markdown bold italic styled preview"], |
| 455 | }, | 455 | }, |
| 456 | { | 456 | { |
| 457 | id: "composer-collapse", | 457 | id: "composer-collapse", |
apps/web/src/composer-block-shortcuts.test.ts deleted-126| ... | @@ -1,126 +0,0 @@ | ||
| 1 | import { Editor } from "@tiptap/core"; | ||
| 2 | import { TextSelection } from "@tiptap/pm/state"; | ||
| 3 | import StarterKit from "@tiptap/starter-kit"; | ||
| 4 | import { describe, expect, it } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | buildDocJson, | ||
| 8 | ComposerBlockExtensions, | ||
| 9 | ComposerCodeBlockExtension, | ||
| 10 | ComposerListExtensions, | ||
| 11 | ComposerTaskItemExtension, | ||
| 12 | ComposerTaskListExtension, | ||
| 13 | serializeEditorDoc, | ||
| 14 | } from "./composer-rich-text-doc"; | ||
| 15 | |||
| 16 | // The same modifier ProseMirror's keymap reads "Mod" as. | ||
| 17 | const mac = typeof navigator !== "undefined" && /Mac|iP(hone|[oa]d)/.test(navigator.platform); | ||
| 18 | |||
| 19 | /** A key chord as the browser reports it: key, keyCode and modifiers. */ | ||
| 20 | function chord(spec: string) { | ||
| 21 | const parts = spec.split("-"); | ||
| 22 | const key = parts.pop()!; | ||
| 23 | const has = (name: string) => parts.includes(name); | ||
| 24 | const base = key.length === 1 ? key.toUpperCase() : key; | ||
| 25 | return { | ||
| 26 | key: has("Shift") && /^\d$/.test(key) ? "!@#$%^&*("[Number(key) - 1]! : key, | ||
| 27 | keyCode: base.length === 1 ? base.charCodeAt(0) : 0, | ||
| 28 | shiftKey: has("Shift"), | ||
| 29 | altKey: has("Alt"), | ||
| 30 | metaKey: has("Mod") && mac, | ||
| 31 | ctrlKey: has("Mod") && !mac, | ||
| 32 | preventDefault() {}, | ||
| 33 | stopPropagation() {}, | ||
| 34 | } as unknown as KeyboardEvent; | ||
| 35 | } | ||
| 36 | |||
| 37 | /** | ||
| 38 | * Presses `spec` with the caret inside `value`, the way the composer's | ||
| 39 | * editor would receive it, and returns the stored Markdown. | ||
| 40 | */ | ||
| 41 | function press(value: string, spec: string) { | ||
| 42 | const editor = new Editor({ | ||
| 43 | extensions: [ | ||
| 44 | StarterKit.configure({ | ||
| 45 | blockquote: false, | ||
| 46 | bulletList: false, | ||
| 47 | codeBlock: false, | ||
| 48 | heading: false, | ||
| 49 | horizontalRule: false, | ||
| 50 | listItem: false, | ||
| 51 | orderedList: false, | ||
| 52 | trailingNode: false, | ||
| 53 | }), | ||
| 54 | ComposerCodeBlockExtension, | ||
| 55 | ...ComposerBlockExtensions, | ||
| 56 | ...ComposerListExtensions, | ||
| 57 | ComposerTaskListExtension, | ||
| 58 | ComposerTaskItemExtension, | ||
| 59 | ], | ||
| 60 | content: buildDocJson(value, (name) => ({ label: name, description: null })), | ||
| 61 | }); | ||
| 62 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); | ||
| 63 | // What the view's `someProp("handleKeyDown")` does, without mounting one: | ||
| 64 | // offer the key to each extension plugin in order until one handles it. | ||
| 65 | // An unmounted editor's state does not carry them yet, so they come from | ||
| 66 | // the extension manager. | ||
| 67 | const event = chord(spec); | ||
| 68 | const before = editor.state.doc; | ||
| 69 | const handled = editor.extensionManager.plugins.some((plugin) => | ||
| 70 | plugin.props.handleKeyDown?.call(plugin, editor.view, event), | ||
| 71 | ); | ||
| 72 | return { | ||
| 73 | handled, | ||
| 74 | changed: !editor.state.doc.eq(before), | ||
| 75 | value: serializeEditorDoc(editor.state.doc).value, | ||
| 76 | }; | ||
| 77 | } | ||
| 78 | |||
| 79 | // Tiptap's own block shortcuts would nest a list, task list, code block or | ||
| 80 | // heading inside a quote, or restructure a list item. The serializers only | ||
| 81 | // write what the composer's grammar has, so the nested block, and the text | ||
| 82 | // in it, would vanish from the stored draft. None of these are the | ||
| 83 | // composer's shortcuts; they must do nothing. | ||
| 84 | const BLOCK_CHORDS = [ | ||
| 85 | "Mod-Shift-8", | ||
| 86 | "Mod-Shift-7", | ||
| 87 | "Mod-Shift-9", | ||
| 88 | "Mod-Shift-b", | ||
| 89 | "Mod-Alt-c", | ||
| 90 | "Mod-Alt-1", | ||
| 91 | "Mod-Alt-3", | ||
| 92 | ]; | ||
| 93 | |||
| 94 | describe("Tiptap's block shortcuts", () => { | ||
| 95 | it.each(BLOCK_CHORDS)("leave a quote's text alone on %s", (spec) => { | ||
| 96 | expect(press("> keep this text", spec).value).toBe("> keep this text"); | ||
| 97 | }); | ||
| 98 | |||
| 99 | it.each(BLOCK_CHORDS)("leave a list item alone on %s", (spec) => { | ||
| 100 | expect(press("- item", spec).value).toBe("- item"); | ||
| 101 | }); | ||
| 102 | |||
| 103 | it.each(BLOCK_CHORDS)("leave a paragraph alone on %s", (spec) => { | ||
| 104 | expect(press("plain text", spec).value).toBe("plain text"); | ||
| 105 | }); | ||
| 106 | |||
| 107 | // Guards the harness itself: a chord the composer keeps must still reach | ||
| 108 | // the keymap, or every case above would pass without pressing anything. | ||
| 109 | it("still delivers the composer's own keys", () => { | ||
| 110 | expect(press("**bold**", "Mod-b").handled).toBe(true); | ||
| 111 | }); | ||
| 112 | |||
| 113 | // Sinking or lifting an item moves it without touching its `indent`, so | ||
| 114 | // the stored draft keeps the old nesting and the next rebuild undoes the | ||
| 115 | // move. Nesting belongs to the composer's own Tab, which edits the source. | ||
| 116 | it.each([ | ||
| 117 | ["- p\n- a", "Tab"], | ||
| 118 | ["- p\n - a", "Shift-Tab"], | ||
| 119 | ["- [ ] p\n- [ ] a", "Tab"], | ||
| 120 | ["- [ ] p\n - [ ] a", "Shift-Tab"], | ||
| 121 | ])("leave the nesting of %j alone on %s", (value, spec) => { | ||
| 122 | const result = press(value, spec); | ||
| 123 | expect(result.changed).toBe(false); | ||
| 124 | expect(result.value).toBe(value); | ||
| 125 | }); | ||
| 126 | }); | ||
apps/web/src/composer-code-block.test.ts deleted-391| ... | @@ -1,391 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { Editor, Node } from "@tiptap/core"; | ||
| 3 | import { newlineInCode } from "@tiptap/pm/commands"; | ||
| 4 | import { TextSelection } from "@tiptap/pm/state"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | CODE_BLOCK_INDENT, | ||
| 8 | indentCodeBlock, | ||
| 9 | indentLines, | ||
| 10 | convertCodeFenceOnEnter, | ||
| 11 | exitCodeBlockOnClosingFence, | ||
| 12 | exitCodeBlockOnTrailingBlankLines, | ||
| 13 | indentedNewlineInCodeBlock, | ||
| 14 | leadingWhitespace, | ||
| 15 | outdentLine, | ||
| 16 | } from "./composer-code-block"; | ||
| 17 | import StarterKit from "@tiptap/starter-kit"; | ||
| 18 | |||
| 19 | import { | ||
| 20 | ComposerCodeBlockExtension, | ||
| 21 | ComposerListExtensions, | ||
| 22 | serializeEditorDoc, | ||
| 23 | } from "./composer-rich-text-doc"; | ||
| 24 | |||
| 25 | const extensions = [ | ||
| 26 | StarterKit.configure({ codeBlock: false, trailingNode: false }), | ||
| 27 | ComposerCodeBlockExtension, | ||
| 28 | ...ComposerListExtensions, | ||
| 29 | Node.create({ | ||
| 30 | name: "composer-mention", | ||
| 31 | group: "inline", | ||
| 32 | inline: true, | ||
| 33 | atom: true, | ||
| 34 | addAttributes: () => ({ path: { default: "" }, source: { default: "" } }), | ||
| 35 | }), | ||
| 36 | ]; | ||
| 37 | |||
| 38 | describe("leadingWhitespace", () => { | ||
| 39 | it("reads the indent a new line should inherit", () => { | ||
| 40 | expect(leadingWhitespace(" nested()")).toBe(" "); | ||
| 41 | expect(leadingWhitespace("\tnested()")).toBe("\t"); | ||
| 42 | expect(leadingWhitespace("flush()")).toBe(""); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("stops at the first non-space so code is never treated as indent", () => { | ||
| 46 | expect(leadingWhitespace(" a b")).toBe(" "); | ||
| 47 | }); | ||
| 48 | }); | ||
| 49 | |||
| 50 | describe("outdentLine", () => { | ||
| 51 | it("removes a full indent when there is one", () => { | ||
| 52 | expect(outdentLine(" deep")).toBe(" deep"); | ||
| 53 | }); | ||
| 54 | |||
| 55 | /** Hand-indented lines should still outdent rather than refuse to move. */ | ||
| 56 | it("falls back to a single space or tab", () => { | ||
| 57 | expect(outdentLine(" odd")).toBe("odd"); | ||
| 58 | expect(outdentLine("\ttabbed")).toBe("tabbed"); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("bottoms out instead of eating code", () => { | ||
| 62 | expect(outdentLine("flush()")).toBe("flush()"); | ||
| 63 | }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | describe("indentLines", () => { | ||
| 67 | it("indents every line it is given", () => { | ||
| 68 | expect(indentLines(["a", "b"], "in")).toEqual([ | ||
| 69 | `${CODE_BLOCK_INDENT}a`, | ||
| 70 | `${CODE_BLOCK_INDENT}b`, | ||
| 71 | ]); | ||
| 72 | }); | ||
| 73 | |||
| 74 | /** Indenting a blank line would leave whitespace the user cannot see. */ | ||
| 75 | it("leaves blank lines alone", () => { | ||
| 76 | expect(indentLines(["a", "", "b"], "in")).toEqual([ | ||
| 77 | `${CODE_BLOCK_INDENT}a`, | ||
| 78 | "", | ||
| 79 | `${CODE_BLOCK_INDENT}b`, | ||
| 80 | ]); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("outdents every line it is given", () => { | ||
| 84 | expect(indentLines([" a", " b", "c"], "out")).toEqual([" a", "b", "c"]); | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | /** Builds an editor holding a single fenced block, with the caret placed by offset. */ | ||
| 89 | function codeEditor(code: string, at?: { from: number; to?: number }) { | ||
| 90 | const editor = new Editor({ | ||
| 91 | extensions, | ||
| 92 | content: { | ||
| 93 | type: "doc", | ||
| 94 | content: [{ type: "codeBlock", content: [{ type: "text", text: code }] }], | ||
| 95 | }, | ||
| 96 | }); | ||
| 97 | const start = 1; | ||
| 98 | const from = start + (at?.from ?? code.length); | ||
| 99 | const to = start + (at?.to ?? at?.from ?? code.length); | ||
| 100 | editor.view.dispatch( | ||
| 101 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from, to)), | ||
| 102 | ); | ||
| 103 | return editor; | ||
| 104 | } | ||
| 105 | |||
| 106 | const textOf = (editor: Editor) => editor.state.doc.textContent; | ||
| 107 | |||
| 108 | describe("indentedNewlineInCodeBlock", () => { | ||
| 109 | it("carries the current indentation onto the new line", () => { | ||
| 110 | const editor = codeEditor("function f() {\n body()"); | ||
| 111 | const handled = indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); | ||
| 112 | expect(handled).toBe(true); | ||
| 113 | expect(textOf(editor)).toBe("function f() {\n body()\n "); | ||
| 114 | }); | ||
| 115 | |||
| 116 | /** | ||
| 117 | * With no indent to carry there is nothing to add, so the default newline | ||
| 118 | * should handle it and stay a single undo step. | ||
| 119 | */ | ||
| 120 | it("declines an unindented line", () => { | ||
| 121 | const editor = codeEditor("flush()"); | ||
| 122 | expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | ||
| 123 | expect(textOf(editor)).toBe("flush()"); | ||
| 124 | }); | ||
| 125 | |||
| 126 | it("indents from the line the caret is on, not the last line", () => { | ||
| 127 | const editor = codeEditor(" deep()\nflush()", { from: 10 }); | ||
| 128 | indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); | ||
| 129 | expect(textOf(editor)).toBe(" deep()\n \nflush()"); | ||
| 130 | }); | ||
| 131 | |||
| 132 | it("replaces the selected text rather than keeping it", () => { | ||
| 133 | const editor = codeEditor(" keep()DROP", { from: 8, to: 12 }); | ||
| 134 | indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); | ||
| 135 | expect(textOf(editor)).toBe(" keep()\n "); | ||
| 136 | }); | ||
| 137 | |||
| 138 | it("does nothing outside a code block", () => { | ||
| 139 | const editor = new Editor({ | ||
| 140 | extensions, | ||
| 141 | content: { | ||
| 142 | type: "doc", | ||
| 143 | content: [{ type: "paragraph", content: [{ type: "text", text: " hi" }] }], | ||
| 144 | }, | ||
| 145 | }); | ||
| 146 | expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | /** Enter inside a fence, in the order the composer's key handler tries it. */ | ||
| 151 | function pressEnter(editor: Editor) { | ||
| 152 | const dispatch = (tr: Parameters<typeof editor.view.dispatch>[0]) => editor.view.dispatch(tr); | ||
| 153 | return ( | ||
| 154 | exitCodeBlockOnTrailingBlankLines(editor.view) || | ||
| 155 | indentedNewlineInCodeBlock(editor.state, dispatch) || | ||
| 156 | newlineInCode(editor.state, dispatch) | ||
| 157 | ); | ||
| 158 | } | ||
| 159 | |||
| 160 | describe("exitCodeBlockOnTrailingBlankLines", () => { | ||
| 161 | it("leaves the block once two blank lines end it", () => { | ||
| 162 | const editor = codeEditor("flush()"); | ||
| 163 | pressEnter(editor); | ||
| 164 | pressEnter(editor); | ||
| 165 | expect(editor.state.doc.childCount).toBe(1); | ||
| 166 | pressEnter(editor); | ||
| 167 | expect(editor.state.doc.firstChild?.textContent).toBe("flush()"); | ||
| 168 | expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); | ||
| 169 | }); | ||
| 170 | |||
| 171 | /** Enter carries indentation, so the blank lines after nested code are not empty. */ | ||
| 172 | it("counts lines holding only carried indentation as blank", () => { | ||
| 173 | const editor = codeEditor(" nested()"); | ||
| 174 | pressEnter(editor); | ||
| 175 | pressEnter(editor); | ||
| 176 | expect(editor.state.doc.childCount).toBe(1); | ||
| 177 | pressEnter(editor); | ||
| 178 | expect(editor.state.doc.firstChild?.textContent).toBe(" nested()"); | ||
| 179 | expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); | ||
| 180 | }); | ||
| 181 | |||
| 182 | /** An unclosed fence runs to the end of the draft, so leaving it must close it. */ | ||
| 183 | it("closes an unclosed fence on the way out", () => { | ||
| 184 | const editor = codeEditor("flush()"); | ||
| 185 | editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "close", "")); | ||
| 186 | pressEnter(editor); | ||
| 187 | pressEnter(editor); | ||
| 188 | pressEnter(editor); | ||
| 189 | expect(editor.state.doc.firstChild?.attrs.close).toBe("\n```"); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it("stays in the block when the caret is not at its end", () => { | ||
| 193 | const editor = codeEditor("a\n\n\nb", { from: 3 }); | ||
| 194 | expect(exitCodeBlockOnTrailingBlankLines(editor.view)).toBe(false); | ||
| 195 | }); | ||
| 196 | }); | ||
| 197 | |||
| 198 | describe("exitCodeBlockOnClosingFence", () => { | ||
| 199 | const exit = (editor: Editor) => | ||
| 200 | exitCodeBlockOnClosingFence(editor.state, (tr) => editor.view.dispatch(tr)); | ||
| 201 | |||
| 202 | it("ends the block at a typed closing fence and starts a line after it", () => { | ||
| 203 | const editor = codeEditor("const a = 1\n```"); | ||
| 204 | editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "language", "ts")); | ||
| 205 | expect(exit(editor)).toBe(true); | ||
| 206 | editor.view.dispatch(editor.state.tr.insertText("explain this")); | ||
| 207 | expect(serializeEditorDoc(editor.state.doc).value).toBe( | ||
| 208 | "```ts\nconst a = 1\n```\nexplain this", | ||
| 209 | ); | ||
| 210 | }); | ||
| 211 | |||
| 212 | it("moves the lines after the fence out of the block", () => { | ||
| 213 | const editor = codeEditor("a\n```\nb", { from: "a\n```".length }); | ||
| 214 | expect(exit(editor)).toBe(true); | ||
| 215 | expect(serializeEditorDoc(editor.state.doc).value).toBe("```\na\n```\n\nb"); | ||
| 216 | expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); | ||
| 217 | expect(editor.state.selection.$from.parent.textContent).toBe(""); | ||
| 218 | }); | ||
| 219 | |||
| 220 | it.each([ | ||
| 221 | ["a line that cannot close the fence", "a\n``", undefined], | ||
| 222 | ["a caret before the end of the line", "a\n```", { from: "a\n``".length }], | ||
| 223 | ])("ignores %s", (_, code, at) => { | ||
| 224 | expect(exit(codeEditor(code, at))).toBe(false); | ||
| 225 | }); | ||
| 226 | }); | ||
| 227 | |||
| 228 | describe("indentCodeBlock", () => { | ||
| 229 | it("inserts an indent at a collapsed caret", () => { | ||
| 230 | const editor = codeEditor("ab", { from: 1 }); | ||
| 231 | expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(true); | ||
| 232 | expect(textOf(editor)).toBe(`a${CODE_BLOCK_INDENT}b`); | ||
| 233 | }); | ||
| 234 | |||
| 235 | it("indents every line a selection touches", () => { | ||
| 236 | const editor = codeEditor("one\ntwo\nthree", { from: 1, to: 9 }); | ||
| 237 | indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); | ||
| 238 | expect(textOf(editor)).toBe(" one\n two\n three"); | ||
| 239 | }); | ||
| 240 | |||
| 241 | /** A selection of whole lines ends at the start of the next one, which it does not touch. */ | ||
| 242 | it("leaves the line a selection ends at the start of", () => { | ||
| 243 | const editor = codeEditor("one\ntwo", { from: 0, to: 4 }); | ||
| 244 | indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); | ||
| 245 | expect(textOf(editor)).toBe(" one\ntwo"); | ||
| 246 | }); | ||
| 247 | |||
| 248 | it("outdents every line a selection touches", () => { | ||
| 249 | const editor = codeEditor(" one\n two", { from: 2, to: 9 }); | ||
| 250 | indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr)); | ||
| 251 | expect(textOf(editor)).toBe("one\ntwo"); | ||
| 252 | }); | ||
| 253 | |||
| 254 | /** Repeated Tab presses should keep working on the same block. */ | ||
| 255 | it("keeps the selection across the shifted lines", () => { | ||
| 256 | const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); | ||
| 257 | indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); | ||
| 258 | indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); | ||
| 259 | expect(textOf(editor)).toBe(" one\n two"); | ||
| 260 | }); | ||
| 261 | |||
| 262 | it("reports handled when outdenting can go no further, so Tab never escapes the editor", () => { | ||
| 263 | const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); | ||
| 264 | expect(indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr))).toBe(true); | ||
| 265 | expect(textOf(editor)).toBe("one\ntwo"); | ||
| 266 | }); | ||
| 267 | |||
| 268 | it("does nothing outside a code block", () => { | ||
| 269 | const editor = new Editor({ | ||
| 270 | extensions, | ||
| 271 | content: { | ||
| 272 | type: "doc", | ||
| 273 | content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }], | ||
| 274 | }, | ||
| 275 | }); | ||
| 276 | expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); | ||
| 277 | }); | ||
| 278 | }); | ||
| 279 | |||
| 280 | describe("selections across two identical fences", () => { | ||
| 281 | /** Two structurally equal code blocks; the selection spans from one into the other. */ | ||
| 282 | function twoBlockEditor() { | ||
| 283 | const block = { type: "codeBlock", content: [{ type: "text", text: " a" }] }; | ||
| 284 | const editor = new Editor({ extensions, content: { type: "doc", content: [block, block] } }); | ||
| 285 | // First block: 1..4 (text " a" occupies 1..4); second block starts at 5. | ||
| 286 | editor.view.dispatch( | ||
| 287 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2, 7)), | ||
| 288 | ); | ||
| 289 | return editor; | ||
| 290 | } | ||
| 291 | |||
| 292 | it("leaves Tab and Enter alone rather than editing through the boundary", () => { | ||
| 293 | const editor = twoBlockEditor(); | ||
| 294 | const before = editor.getJSON(); | ||
| 295 | expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); | ||
| 296 | expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | ||
| 297 | expect(editor.getJSON()).toEqual(before); | ||
| 298 | }); | ||
| 299 | }); | ||
| 300 | |||
| 301 | describe("convertCodeFenceOnEnter", () => { | ||
| 302 | /** Builds an editor holding one paragraph with the caret at its end. */ | ||
| 303 | function paragraphEditor(text: string) { | ||
| 304 | const editor = new Editor({ | ||
| 305 | extensions, | ||
| 306 | content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] }, | ||
| 307 | }); | ||
| 308 | editor.view.dispatch( | ||
| 309 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1 + text.length)), | ||
| 310 | ); | ||
| 311 | return editor; | ||
| 312 | } | ||
| 313 | |||
| 314 | it.each([ | ||
| 315 | ["```", "```", ""], | ||
| 316 | ["```ts", "```", "ts"], | ||
| 317 | ["~~~py", "~~~", "py"], | ||
| 318 | ["````", "````", ""], | ||
| 319 | ["```ts title=example", "```", "ts title=example"], | ||
| 320 | ["``` js", "```", " js"], | ||
| 321 | ])("turns %s into a code block", (text, fence, language) => { | ||
| 322 | const editor = paragraphEditor(text); | ||
| 323 | expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true); | ||
| 324 | const block = editor.state.doc.firstChild!; | ||
| 325 | expect(block.type.name).toBe("codeBlock"); | ||
| 326 | expect(block.attrs).toMatchObject({ fence, language, close: `\n${fence}` }); | ||
| 327 | expect(block.textContent).toBe(""); | ||
| 328 | // The caret belongs inside the new block, ready for the first line. | ||
| 329 | expect(editor.state.selection.from).toBe(1); | ||
| 330 | }); | ||
| 331 | |||
| 332 | it.each(["``", "text ```", "```a`b"])("leaves %s alone", (text) => { | ||
| 333 | const editor = paragraphEditor(text); | ||
| 334 | expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | ||
| 335 | }); | ||
| 336 | |||
| 337 | it("refuses to open a fence inside a list item", () => { | ||
| 338 | const text = "```ts"; | ||
| 339 | const editor = new Editor({ | ||
| 340 | extensions, | ||
| 341 | content: { | ||
| 342 | type: "doc", | ||
| 343 | content: [ | ||
| 344 | { | ||
| 345 | type: "bulletList", | ||
| 346 | content: [ | ||
| 347 | { | ||
| 348 | type: "listItem", | ||
| 349 | content: [{ type: "paragraph", content: [{ type: "text", text }] }], | ||
| 350 | }, | ||
| 351 | ], | ||
| 352 | }, | ||
| 353 | ], | ||
| 354 | }, | ||
| 355 | }); | ||
| 356 | // Inside listItem > paragraph: position 3 is the paragraph start. | ||
| 357 | editor.view.dispatch( | ||
| 358 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3 + text.length)), | ||
| 359 | ); | ||
| 360 | const before = editor.getJSON(); | ||
| 361 | expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | ||
| 362 | expect(editor.getJSON()).toEqual(before); | ||
| 363 | }); | ||
| 364 | |||
| 365 | it("keeps a chip's source in the info string", () => { | ||
| 366 | const editor = new Editor({ | ||
| 367 | extensions, | ||
| 368 | content: { | ||
| 369 | type: "doc", | ||
| 370 | content: [ | ||
| 371 | { | ||
| 372 | type: "paragraph", | ||
| 373 | content: [ | ||
| 374 | { type: "text", text: "```ts " }, | ||
| 375 | { type: "composer-mention", attrs: { path: "a.md", source: "@a.md" } }, | ||
| 376 | ], | ||
| 377 | }, | ||
| 378 | ], | ||
| 379 | }, | ||
| 380 | }); | ||
| 381 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); | ||
| 382 | expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true); | ||
| 383 | expect(editor.state.doc.firstChild?.attrs.language).toBe("ts @a.md"); | ||
| 384 | }); | ||
| 385 | |||
| 386 | it("ignores a fence with the caret before its end", () => { | ||
| 387 | const editor = paragraphEditor("```ts"); | ||
| 388 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3))); | ||
| 389 | expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); | ||
| 390 | }); | ||
| 391 | }); | ||
apps/web/src/composer-code-block.ts deleted-254| ... | @@ -1,254 +0,0 @@ | ||
| 1 | import { exitCode } from "@tiptap/pm/commands"; | ||
| 2 | import type { EditorState, Transaction } from "@tiptap/pm/state"; | ||
| 3 | import { TextSelection } from "@tiptap/pm/state"; | ||
| 4 | import type { EditorView } from "@tiptap/pm/view"; | ||
| 5 | |||
| 6 | import { isClosingFence, parseOpeningFence, serializeSelection } from "~/composer-rich-text-doc"; | ||
| 7 | |||
| 8 | /** | ||
| 9 | * Spaces rather than a tab: the fence round-trips through Markdown on its way | ||
| 10 | * to the agent, and a literal tab there renders at whatever width the reader | ||
| 11 | * happens to use. Two matches how this repo indents its own source. | ||
| 12 | */ | ||
| 13 | export const CODE_BLOCK_INDENT = " "; | ||
| 14 | |||
| 15 | /** The leading run of spaces and tabs on a line, which Enter carries forward. */ | ||
| 16 | export function leadingWhitespace(line: string): string { | ||
| 17 | return /^[ \t]*/.exec(line)?.[0] ?? ""; | ||
| 18 | } | ||
| 19 | |||
| 20 | /** | ||
| 21 | * Removes one indent's worth of leading whitespace, tolerating lines that were | ||
| 22 | * indented by hand with an odd number of spaces or with tabs. A line with no | ||
| 23 | * leading whitespace is returned unchanged, so outdenting bottoms out quietly | ||
| 24 | * rather than eating the first character of code. | ||
| 25 | */ | ||
| 26 | export function outdentLine(line: string): string { | ||
| 27 | if (line.startsWith(CODE_BLOCK_INDENT)) return line.slice(CODE_BLOCK_INDENT.length); | ||
| 28 | if (line.startsWith("\t")) return line.slice(1); | ||
| 29 | if (line.startsWith(" ")) return line.slice(1); | ||
| 30 | return line; | ||
| 31 | } | ||
| 32 | |||
| 33 | /** Applies Tab/Shift+Tab to every line a selection touches, whole lines at a time. */ | ||
| 34 | export function indentLines(lines: ReadonlyArray<string>, direction: "in" | "out"): string[] { | ||
| 35 | return lines.map((line) => { | ||
| 36 | // Indenting a blank line would leave trailing whitespace behind on a line | ||
| 37 | // the user cannot see the caret move on, so leave it be. | ||
| 38 | if (direction === "in") return line.length === 0 ? line : `${CODE_BLOCK_INDENT}${line}`; | ||
| 39 | return outdentLine(line); | ||
| 40 | }); | ||
| 41 | } | ||
| 42 | |||
| 43 | /** Whether every endpoint of the selection sits in the same code block. */ | ||
| 44 | export function selectionInOneCodeBlock(state: EditorState): boolean { | ||
| 45 | const { $from, $to } = state.selection; | ||
| 46 | return $from.parent.type.spec.code === true && $from.sameParent($to); | ||
| 47 | } | ||
| 48 | |||
| 49 | /** | ||
| 50 | * The code block containing the selection, or null when the selection is | ||
| 51 | * outside a fence or spans out of one. Both ends must sit in the same block: a | ||
| 52 | * selection that reaches past the fence belongs to the document, not to the | ||
| 53 | * code. | ||
| 54 | */ | ||
| 55 | function codeBlockRange( | ||
| 56 | state: EditorState, | ||
| 57 | ): { readonly from: number; readonly to: number; readonly text: string } | null { | ||
| 58 | const { $from, $to } = state.selection; | ||
| 59 | const parent = $from.parent; | ||
| 60 | if (parent.type.spec.code !== true) return null; | ||
| 61 | // Same occurrence, not merely equal structure: two identical fences would | ||
| 62 | // otherwise let a selection across them edit through the block boundary. | ||
| 63 | if (!$from.sameParent($to)) return null; | ||
| 64 | const from = $from.start(); | ||
| 65 | return { from, to: from + parent.content.size, text: parent.textContent }; | ||
| 66 | } | ||
| 67 | |||
| 68 | /** | ||
| 69 | * Enter inside a fence, carrying the current line's indentation onto the new | ||
| 70 | * line. ProseMirror's default splits the text but starts the new line at column | ||
| 71 | * zero, which means re-typing the indent on every line of a nested block. | ||
| 72 | */ | ||
| 73 | export function indentedNewlineInCodeBlock( | ||
| 74 | state: EditorState, | ||
| 75 | dispatch?: (transaction: Transaction) => void, | ||
| 76 | ): boolean { | ||
| 77 | const block = codeBlockRange(state); | ||
| 78 | if (!block) return false; | ||
| 79 | |||
| 80 | const { from, to } = state.selection; | ||
| 81 | const beforeCursor = block.text.slice(0, from - block.from); | ||
| 82 | const currentLine = beforeCursor.slice(beforeCursor.lastIndexOf("\n") + 1); | ||
| 83 | const indent = leadingWhitespace(currentLine); | ||
| 84 | |||
| 85 | // Nothing to carry, so let the default newline handle it and keep this path | ||
| 86 | // out of the undo history. | ||
| 87 | if (indent.length === 0) return false; | ||
| 88 | |||
| 89 | if (dispatch) { | ||
| 90 | const inserted = `\n${indent}`; | ||
| 91 | const transaction = state.tr.insertText(inserted, from, to); | ||
| 92 | const caret = from + inserted.length; | ||
| 93 | transaction.setSelection(TextSelection.create(transaction.doc, caret)); | ||
| 94 | dispatch(transaction.scrollIntoView()); | ||
| 95 | } | ||
| 96 | return true; | ||
| 97 | } | ||
| 98 | |||
| 99 | /** | ||
| 100 | * Two blank lines at the end of a fence leave it, the way every code editor | ||
| 101 | * does. Without this a fence at the end of the prompt is a trap: Enter only | ||
| 102 | * ever adds another line and there is no way back to prose. A line holding | ||
| 103 | * only the indentation Enter carried forward counts as blank, and is removed | ||
| 104 | * with the rest. | ||
| 105 | */ | ||
| 106 | export function exitCodeBlockOnTrailingBlankLines(view: EditorView): boolean { | ||
| 107 | const { $from, empty } = view.state.selection; | ||
| 108 | if (!empty || $from.parent.type.spec.code !== true) return false; | ||
| 109 | if ($from.parentOffset !== $from.parent.content.size) return false; | ||
| 110 | const trailing = /\n[ \t]*\n[ \t]*$/.exec($from.parent.textContent); | ||
| 111 | if (!trailing) return false; | ||
| 112 | const transaction = view.state.tr.delete($from.pos - trailing[0].length, $from.pos); | ||
| 113 | // An unclosed fence runs to the end of the draft; the prose after it would | ||
| 114 | // be read back as code, so leaving it writes the closing fence. | ||
| 115 | if (!$from.parent.attrs.close) { | ||
| 116 | transaction.setNodeAttribute($from.before(), "close", `\n${$from.parent.attrs.fence}`); | ||
| 117 | } | ||
| 118 | view.dispatch(transaction); | ||
| 119 | return exitCode(view.state, (tr) => view.dispatch(tr.scrollIntoView())); | ||
| 120 | } | ||
| 121 | |||
| 122 | /** | ||
| 123 | * Enter at the end of a line that would close the fence, such as ```` ``` ```` | ||
| 124 | * typed under the code, ends the block there. That line becomes the closing | ||
| 125 | * fence, and any lines after it continue as paragraphs after a new empty one, | ||
| 126 | * which is how the stored draft reads them. | ||
| 127 | */ | ||
| 128 | export function exitCodeBlockOnClosingFence( | ||
| 129 | state: EditorState, | ||
| 130 | dispatch?: (transaction: Transaction) => void, | ||
| 131 | ): boolean { | ||
| 132 | const block = codeBlockRange(state); | ||
| 133 | if (!block || !state.selection.empty) return false; | ||
| 134 | const caret = state.selection.from - block.from; | ||
| 135 | const lineStart = block.text.lastIndexOf("\n", caret - 1) + 1; | ||
| 136 | const newlineAfter = block.text.indexOf("\n", caret); | ||
| 137 | const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; | ||
| 138 | const line = block.text.slice(lineStart, lineEnd); | ||
| 139 | const node = state.selection.$from.parent; | ||
| 140 | if (caret !== lineEnd || !isClosingFence(line, String(node.attrs.fence))) return false; | ||
| 141 | |||
| 142 | if (dispatch) { | ||
| 143 | const { schema } = state; | ||
| 144 | const paragraph = (text: string) => | ||
| 145 | schema.nodes.paragraph!.create(null, text ? schema.text(text) : null); | ||
| 146 | const code = block.text.slice(0, Math.max(0, lineStart - 1)); | ||
| 147 | const rest = newlineAfter === -1 ? [] : block.text.slice(newlineAfter + 1).split("\n"); | ||
| 148 | const closed = node.type.create( | ||
| 149 | { ...node.attrs, close: `\n${line}` }, | ||
| 150 | code ? schema.text(code) : null, | ||
| 151 | ); | ||
| 152 | const start = state.selection.$from.before(); | ||
| 153 | const transaction = state.tr.replaceWith(start, start + node.nodeSize, [ | ||
| 154 | closed, | ||
| 155 | paragraph(""), | ||
| 156 | ...rest.map(paragraph), | ||
| 157 | ]); | ||
| 158 | transaction.setSelection(TextSelection.create(transaction.doc, start + closed.nodeSize + 1)); | ||
| 159 | dispatch(transaction.scrollIntoView()); | ||
| 160 | } | ||
| 161 | return true; | ||
| 162 | } | ||
| 163 | |||
| 164 | /** | ||
| 165 | * Tab and Shift+Tab inside a fence. A collapsed cursor indents at the caret so | ||
| 166 | * Tab works mid-line the way typing does; any selection spanning text indents | ||
| 167 | * the whole lines it touches, which is what makes re-indenting a block possible. | ||
| 168 | */ | ||
| 169 | export function indentCodeBlock( | ||
| 170 | state: EditorState, | ||
| 171 | direction: "in" | "out", | ||
| 172 | dispatch?: (transaction: Transaction) => void, | ||
| 173 | ): boolean { | ||
| 174 | const block = codeBlockRange(state); | ||
| 175 | if (!block) return false; | ||
| 176 | |||
| 177 | const { from, to } = state.selection; | ||
| 178 | const startOffset = from - block.from; | ||
| 179 | const endOffset = to - block.from; | ||
| 180 | |||
| 181 | if (from === to && direction === "in") { | ||
| 182 | if (dispatch) { | ||
| 183 | const transaction = state.tr.insertText(CODE_BLOCK_INDENT, from, to); | ||
| 184 | dispatch(transaction.scrollIntoView()); | ||
| 185 | } | ||
| 186 | return true; | ||
| 187 | } | ||
| 188 | |||
| 189 | // Grow the range to whole lines so indenting is stable no matter where in the | ||
| 190 | // first and last lines the selection happens to start and end. | ||
| 191 | const lineStart = block.text.lastIndexOf("\n", Math.max(0, startOffset - 1)) + 1; | ||
| 192 | // A selection ending at the start of a line does not touch that line. | ||
| 193 | const newlineAfter = block.text.indexOf("\n", Math.max(startOffset, endOffset - 1)); | ||
| 194 | const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; | ||
| 195 | |||
| 196 | const originalLines = block.text.slice(lineStart, lineEnd).split("\n"); | ||
| 197 | const nextLines = indentLines(originalLines, direction); | ||
| 198 | if (nextLines.join("\n") === originalLines.join("\n")) return true; | ||
| 199 | |||
| 200 | if (dispatch) { | ||
| 201 | const transaction = state.tr.insertText( | ||
| 202 | nextLines.join("\n"), | ||
| 203 | block.from + lineStart, | ||
| 204 | block.from + lineEnd, | ||
| 205 | ); | ||
| 206 | // Keep the same lines selected after the shift, so repeated Tab presses | ||
| 207 | // keep indenting the block rather than collapsing the selection. | ||
| 208 | // `split` always yields at least one entry, so these stand in only for the | ||
| 209 | // compiler's benefit. | ||
| 210 | const firstDelta = (nextLines[0]?.length ?? 0) - (originalLines[0]?.length ?? 0); | ||
| 211 | const totalDelta = nextLines.join("\n").length - originalLines.join("\n").length; | ||
| 212 | const nextFrom = Math.max(block.from + lineStart, from + firstDelta); | ||
| 213 | const nextTo = Math.max(nextFrom, to + totalDelta); | ||
| 214 | transaction.setSelection(TextSelection.create(transaction.doc, nextFrom, nextTo)); | ||
| 215 | dispatch(transaction.scrollIntoView()); | ||
| 216 | } | ||
| 217 | return true; | ||
| 218 | } | ||
| 219 | |||
| 220 | /** | ||
| 221 | * Enter on a line that is nothing but an opening fence turns it into a code | ||
| 222 | * block. Tiptap's own input rules only fire on a space, so without this the | ||
| 223 | * gesture every markdown editor has — ```` ```ts ```` then Enter — leaves the | ||
| 224 | * fence as literal text. | ||
| 225 | */ | ||
| 226 | export function convertCodeFenceOnEnter( | ||
| 227 | state: EditorState, | ||
| 228 | dispatch?: (transaction: Transaction) => void, | ||
| 229 | ): boolean { | ||
| 230 | const { $from, empty } = state.selection; | ||
| 231 | // Top-level paragraphs only: the list and quote serializers have no line | ||
| 232 | // to write a fence into, so one created inside them would vanish. | ||
| 233 | if (!empty || $from.parent.type.name !== "paragraph" || $from.depth !== 1) return false; | ||
| 234 | if ($from.parentOffset !== $from.parent.content.size) return false; | ||
| 235 | // The parser's own grammar, read from the line's stored source, so a line | ||
| 236 | // Enter turns into a fence is exactly one the stored draft reads back as a | ||
| 237 | // fence, info string and all, including any chip's source in it. | ||
| 238 | const opening = parseOpeningFence(serializeSelection(state.doc, $from.start(), $from.end())); | ||
| 239 | const codeBlock = state.schema.nodes.codeBlock; | ||
| 240 | if (!opening || !codeBlock) return false; | ||
| 241 | |||
| 242 | if (dispatch) { | ||
| 243 | const blockStart = $from.before(); | ||
| 244 | const { fence, language } = opening; | ||
| 245 | const transaction = state.tr.replaceWith( | ||
| 246 | blockStart, | ||
| 247 | blockStart + $from.parent.nodeSize, | ||
| 248 | codeBlock.create({ language, fence, close: `\n${fence}` }), | ||
| 249 | ); | ||
| 250 | transaction.setSelection(TextSelection.create(transaction.doc, blockStart + 1)); | ||
| 251 | dispatch(transaction.scrollIntoView()); | ||
| 252 | } | ||
| 253 | return true; | ||
| 254 | } | ||
apps/web/src/composer-list-continuation.test.ts deleted-85| ... | @@ -1,85 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | listContinuationForEnter, | ||
| 5 | listIndentForTab, | ||
| 6 | nextOrderedMarkerText, | ||
| 7 | } from "./composer-list-continuation"; | ||
| 8 | |||
| 9 | function 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 | |||
| 13 | describe("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 | |||
| 74 | describe("nextOrderedMarkerText", () => { | ||
| 75 | it.each([ | ||
| 76 | ["1.", "2."], | ||
| 77 | ["1)", "2)"], | ||
| 78 | ["09.", "10."], | ||
| 79 | ["001)", "002)"], | ||
| 80 | ["99.", "100."], | ||
| 81 | ["99999999999999999999.", "99999999999999999999."], | ||
| 82 | ])("counts %s up to %s", (marker, expected) => { | ||
| 83 | expect(nextOrderedMarkerText(marker)).toBe(expected); | ||
| 84 | }); | ||
| 85 | }); | ||
apps/web/src/composer-list-continuation.ts deleted-147| ... | @@ -1,147 +0,0 @@ | ||
| 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * List continuation and indentation for the composer. | ||
| 5 | * | ||
| 6 | * Implemented once at the ChatComposer level (store replacement), so both | ||
| 7 | * composer modes behave identically and serialize identically: | ||
| 8 | * Shift+Enter on a list item continues it, Enter on an empty item exits the | ||
| 9 | * list, and Tab indents the item. Plain Markdown markers only — no real list | ||
| 10 | * nodes anywhere. | ||
| 11 | */ | ||
| 12 | |||
| 13 | export interface ComposerListEdit { | ||
| 14 | /** Expanded (plain string) offsets into the prompt. */ | ||
| 15 | start: number; | ||
| 16 | end: number; | ||
| 17 | replacement: string; | ||
| 18 | } | ||
| 19 | |||
| 20 | type ListMarker = | ||
| 21 | | { kind: "ordered"; indent: string; numberText: string; delimiter: "." | ")"; space: string } | ||
| 22 | | { kind: "task"; indent: string; space: string } | ||
| 23 | | { kind: "bullet"; indent: string; bullet: string; space: string }; | ||
| 24 | |||
| 25 | function parseListMarker(line: string): { marker: ListMarker; markerEnd: number } | null { | ||
| 26 | const indent = line.match(/^[ \t]*/)?.[0] ?? ""; | ||
| 27 | const rest = line.slice(indent.length); | ||
| 28 | const ordered = rest.match(/^(\d+)([.)])((?:[ \t]+)|\s*$)/); | ||
| 29 | if (ordered && ordered[1] !== undefined && ordered[2] !== undefined) { | ||
| 30 | return { | ||
| 31 | marker: { | ||
| 32 | kind: "ordered", | ||
| 33 | indent, | ||
| 34 | numberText: ordered[1], | ||
| 35 | delimiter: ordered[2] === ")" ? ")" : ".", | ||
| 36 | space: ordered[3] ?? "", | ||
| 37 | }, | ||
| 38 | markerEnd: indent.length + ordered[0].length, | ||
| 39 | }; | ||
| 40 | } | ||
| 41 | const task = rest.match(/^-\s\[[ xX]\]((?:[ \t]+)|\s*$)/); | ||
| 42 | if (task) { | ||
| 43 | return { | ||
| 44 | marker: { kind: "task", indent, space: task[1] ?? "" }, | ||
| 45 | markerEnd: indent.length + task[0].length, | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | const bullet = rest.match(/^([-*+])((?:[ \t]+)|\s*$)/); | ||
| 49 | if (bullet && bullet[1] !== undefined) { | ||
| 50 | return { | ||
| 51 | marker: { kind: "bullet", indent, bullet: bullet[1], space: bullet[2] ?? "" }, | ||
| 52 | markerEnd: indent.length + bullet[0].length, | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | return null; | ||
| 56 | } | ||
| 57 | |||
| 58 | /** | ||
| 59 | * The ordered marker that follows `3.` or `09)`: one higher, same delimiter, | ||
| 60 | * zero padding kept, and a number too large to count left as typed. Shared | ||
| 61 | * with the rich editor's native split so both modes count the same way. | ||
| 62 | */ | ||
| 63 | export function nextOrderedMarkerText(marker: string): string { | ||
| 64 | const numberText = marker.slice(0, -1); | ||
| 65 | const delimiter = marker.slice(-1); | ||
| 66 | const number = Number.parseInt(numberText, 10); | ||
| 67 | const next = Number.isSafeInteger(number) | ||
| 68 | ? String(number + 1).padStart(numberText.length, "0") | ||
| 69 | : numberText; | ||
| 70 | return `${next}${delimiter}`; | ||
| 71 | } | ||
| 72 | |||
| 73 | function 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 | |||
| 83 | function 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. */ | ||
| 91 | function 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 | |||
| 101 | function 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 | */ | ||
| 113 | export 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 | */ | ||
| 137 | export 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 | |||
| 3 | import { Editor } from "@tiptap/core"; | ||
| 4 | import { TaskList } from "@tiptap/extension-task-list"; | ||
| 5 | import { TextSelection } from "@tiptap/pm/state"; | ||
| 6 | import StarterKit from "@tiptap/starter-kit"; | ||
| 7 | import { afterEach, describe, expect, it } from "vite-plus/test"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | backspaceAcrossList, | ||
| 11 | buildDocJson, | ||
| 12 | buildTiptapContent, | ||
| 13 | ComposerBlockExtensions, | ||
| 14 | ComposerCodeBlockExtension, | ||
| 15 | ComposerListExtensions, | ||
| 16 | ComposerTaskItemExtension, | ||
| 17 | convertBulletItemToTask, | ||
| 18 | deleteAcrossList, | ||
| 19 | serializeEditorDoc, | ||
| 20 | splitOrLiftListItem, | ||
| 21 | } from "./composer-rich-text-doc"; | ||
| 22 | |||
| 23 | // ProseMirror's DOM observer can flush on a timer after a test ends, which | ||
| 24 | // throws once jsdom is torn down, so every editor is destroyed after its test. | ||
| 25 | const editors: Editor[] = []; | ||
| 26 | afterEach(() => { | ||
| 27 | for (const editor of editors.splice(0)) editor.destroy(); | ||
| 28 | }); | ||
| 29 | |||
| 30 | /** | ||
| 31 | * Splits the last item of `value` the way Shift+Enter does, types `b`, and | ||
| 32 | * returns the stored Markdown. Tiptap carries every attribute left at the | ||
| 33 | * default `keepOnSplit: true` onto the new item and merges the overrides on | ||
| 34 | * top, so the new item must keep the source marker, indent and spacing. | ||
| 35 | */ | ||
| 36 | function makeEditor(value: string) { | ||
| 37 | const editor = new Editor({ | ||
| 38 | extensions: [ | ||
| 39 | StarterKit.configure({ | ||
| 40 | bulletList: false, | ||
| 41 | orderedList: false, | ||
| 42 | listItem: false, | ||
| 43 | codeBlock: false, | ||
| 44 | blockquote: false, | ||
| 45 | heading: false, | ||
| 46 | horizontalRule: false, | ||
| 47 | trailingNode: false, | ||
| 48 | listKeymap: false, | ||
| 49 | }), | ||
| 50 | ...ComposerListExtensions, | ||
| 51 | TaskList, | ||
| 52 | ComposerTaskItemExtension, | ||
| 53 | ComposerCodeBlockExtension, | ||
| 54 | ...ComposerBlockExtensions, | ||
| 55 | ], | ||
| 56 | content: buildDocJson(value, (name) => ({ label: name, description: null })), | ||
| 57 | }); | ||
| 58 | editors.push(editor); | ||
| 59 | return editor; | ||
| 60 | } | ||
| 61 | |||
| 62 | function splitLastItem(value: string, type: "listItem" | "taskItem", overrides: object) { | ||
| 63 | const editor = makeEditor(value); | ||
| 64 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); | ||
| 65 | editor.commands.splitListItem(type, overrides); | ||
| 66 | editor.view.dispatch(editor.state.tr.insertText("b")); | ||
| 67 | return serializeEditorDoc(editor.state.doc).value; | ||
| 68 | } | ||
| 69 | |||
| 70 | describe("splitting a list item", () => { | ||
| 71 | it.each([ | ||
| 72 | ["* a", { space: " " }, "* a\n* b"], | ||
| 73 | ["+ a", { space: " " }, "+ a\n+ b"], | ||
| 74 | ["- p\n - a", { space: " " }, "- p\n - a\n - b"], | ||
| 75 | ["3) a", { marker: "4)", space: " " }, "3) a\n4) b"], | ||
| 76 | ])("keeps the source marker and indent of %s", (value, overrides, expected) => { | ||
| 77 | expect(splitLastItem(value, "listItem", overrides)).toBe(expected); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("keeps the indent of a nested task", () => { | ||
| 81 | expect(splitLastItem("- [ ] p\n - [ ] a", "taskItem", { checked: false })).toBe( | ||
| 82 | "- [ ] p\n - [ ] a\n - [ ] b", | ||
| 83 | ); | ||
| 84 | }); | ||
| 85 | }); | ||
| 86 | |||
| 87 | /** Puts the caret `offset` characters into the first text node holding `text`. */ | ||
| 88 | function placeCaret(editor: Editor, text: string, offset = 0) { | ||
| 89 | let caret = -1; | ||
| 90 | editor.state.doc.descendants((node, pos) => { | ||
| 91 | if (caret < 0 && node.isText && node.text!.includes(text)) { | ||
| 92 | caret = pos + node.text!.indexOf(text) + offset; | ||
| 93 | } | ||
| 94 | }); | ||
| 95 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, caret))); | ||
| 96 | } | ||
| 97 | |||
| 98 | /** The stored draft, after checking a rebuild from it gives back the same document. */ | ||
| 99 | function storedDraft(editor: Editor) { | ||
| 100 | const stored = serializeEditorDoc(editor.state.doc).value; | ||
| 101 | expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); | ||
| 102 | return stored; | ||
| 103 | } | ||
| 104 | |||
| 105 | describe("Shift+Enter twice on an item", () => { | ||
| 106 | // The second Shift+Enter lifts the new empty item one level. The stored | ||
| 107 | // draft must write it at its new depth, so a rebuild from that draft gives | ||
| 108 | // back the document the user is looking at. | ||
| 109 | it.each([ | ||
| 110 | ["- a\n - x", "- a\n - x\n- b"], | ||
| 111 | ["* a\n * x", "* a\n * x\n* b"], | ||
| 112 | ["1. a\n 1. x", "1. a\n 1. x\n2. b"], | ||
| 113 | ["- a\n - x\n- c", "- a\n - x\n- b\n- c"], | ||
| 114 | ["- a\n - x\n - y", "- a\n - x\n- b\n - y"], | ||
| 115 | ["- [ ] a\n - [ ] x", "- [ ] a\n - [ ] x\n- [ ] b"], | ||
| 116 | ["- [x] a\n - [ ] x\n- [ ] c", "- [x] a\n - [ ] x\n- [ ] b\n- [ ] c"], | ||
| 117 | ])("after x in %j writes %j", (value, expected) => { | ||
| 118 | const editor = makeEditor(value); | ||
| 119 | let caret = -1; | ||
| 120 | editor.state.doc.descendants((node, pos) => { | ||
| 121 | if (node.isText && node.text === "x") caret = pos + 1; | ||
| 122 | }); | ||
| 123 | editor.view.dispatch( | ||
| 124 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, caret)), | ||
| 125 | ); | ||
| 126 | expect(splitOrLiftListItem(editor)).toBe(true); | ||
| 127 | expect(splitOrLiftListItem(editor)).toBe(true); | ||
| 128 | editor.view.dispatch(editor.state.tr.insertText("b")); | ||
| 129 | const stored = serializeEditorDoc(editor.state.doc).value; | ||
| 130 | expect(stored).toBe(expected); | ||
| 131 | // Same nesting after a rebuild; spacing defaults may differ but write the same. | ||
| 132 | expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); | ||
| 133 | }); | ||
| 134 | }); | ||
| 135 | |||
| 136 | describe("Shift+Enter in the middle of an ordered list", () => { | ||
| 137 | it.each([ | ||
| 138 | ["1. a\n2. x\n3. c\n4. d", "1. a\n2. x\n3. b\n4. c\n5. d"], | ||
| 139 | ["1) a\n2) x\n3) c", "1) a\n2) x\n3) b\n4) c"], | ||
| 140 | ["1. a\n 1. x\n 2. c\n2. d", "1. a\n 1. x\n 2. b\n 3. c\n2. d"], | ||
| 141 | ])("after x in %j writes %j", (value, expected) => { | ||
| 142 | const editor = makeEditor(value); | ||
| 143 | placeCaret(editor, "x", 1); | ||
| 144 | expect(splitOrLiftListItem(editor)).toBe(true); | ||
| 145 | editor.view.dispatch(editor.state.tr.insertText("b")); | ||
| 146 | expect(storedDraft(editor)).toBe(expected); | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | describe("Shift+Enter on an empty task under a bullet", () => { | ||
| 151 | // There is no task list one level up to move it into, and the item cannot | ||
| 152 | // hold a second line, so the draft stays what the editor shows. | ||
| 153 | it.each(["- a\n - [ ] x", "- [ ] a\n - x"])("in %j keeps the draft honest", (value) => { | ||
| 154 | const editor = makeEditor(value); | ||
| 155 | placeCaret(editor, "x", 1); | ||
| 156 | splitOrLiftListItem(editor); | ||
| 157 | splitOrLiftListItem(editor); | ||
| 158 | storedDraft(editor); | ||
| 159 | }); | ||
| 160 | }); | ||
| 161 | |||
| 162 | describe("Backspace at the start of a line", () => { | ||
| 163 | it.each([ | ||
| 164 | ["- a\n - x", "x", "- a\n- x"], | ||
| 165 | ["- a\n - x\n - y", "x", "- a\n- x\n - y"], | ||
| 166 | ["- [ ] a\n - [ ] x", "x", "- [ ] a\n- [ ] x"], | ||
| 167 | ["- a\n- b\n- c", "b", "- a\nb\n- c"], | ||
| 168 | // A task under a bullet has no task list to move into, so it joins up. | ||
| 169 | ["- a\n - [ ] x", "x", "- ax"], | ||
| 170 | ["- a\n - x\nb", "b", "- a\n - xb"], | ||
| 171 | ["1. a\nb", "b", "1. ab"], | ||
| 172 | ])("in %j at %s writes %j", (value, at, expected) => { | ||
| 173 | const editor = makeEditor(value); | ||
| 174 | placeCaret(editor, at); | ||
| 175 | expect(backspaceAcrossList(editor)).toBe(true); | ||
| 176 | expect(storedDraft(editor)).toBe(expected); | ||
| 177 | }); | ||
| 178 | |||
| 179 | it.each([ | ||
| 180 | ["- a\n```\nl1\nl2\n```", "- a\nl1\nl2"], | ||
| 181 | ["p\n```ts\nl1\n```", "p\nl1"], | ||
| 182 | ])("removes the fence of %j rather than joining its code up", (value, expected) => { | ||
| 183 | const editor = makeEditor(value); | ||
| 184 | placeCaret(editor, "l1"); | ||
| 185 | editor.commands.keyboardShortcut("Backspace"); | ||
| 186 | expect(storedDraft(editor)).toBe(expected); | ||
| 187 | }); | ||
| 188 | }); | ||
| 189 | |||
| 190 | describe("Delete at the end of a line", () => { | ||
| 191 | it.each([ | ||
| 192 | ["- a\n- b", "- ab"], | ||
| 193 | ["- a\n - x\n- c", "- ax\n- c"], | ||
| 194 | ["- a\n\nb", "- a\nb"], | ||
| 195 | ])("in %j writes %j", (value, expected) => { | ||
| 196 | const editor = makeEditor(value); | ||
| 197 | placeCaret(editor, "a", 1); | ||
| 198 | expect(deleteAcrossList(editor)).toBe(true); | ||
| 199 | expect(storedDraft(editor)).toBe(expected); | ||
| 200 | }); | ||
| 201 | |||
| 202 | it.each(["- a\n```\nl1\n```", "a\n```\nl1\n```"])("leaves the fence in %j alone", (value) => { | ||
| 203 | const editor = makeEditor(value); | ||
| 204 | placeCaret(editor, "a", 1); | ||
| 205 | editor.commands.keyboardShortcut("Delete"); | ||
| 206 | expect(storedDraft(editor)).toBe(value); | ||
| 207 | }); | ||
| 208 | }); | ||
| 209 | |||
| 210 | describe("blocks that a list item or quote cannot hold", () => { | ||
| 211 | it("puts lines pasted at the end of an item after the list", () => { | ||
| 212 | const editor = makeEditor("- a"); | ||
| 213 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); | ||
| 214 | const lines = buildTiptapContent("one\ntwo", (name) => ({ label: name, description: null })); | ||
| 215 | editor.commands.insertContent(lines); | ||
| 216 | expect(storedDraft(editor)).toBe("- a\none\ntwo"); | ||
| 217 | }); | ||
| 218 | |||
| 219 | it("will not make a task list inside a quote", () => { | ||
| 220 | const editor = makeEditor("> q"); | ||
| 221 | placeCaret(editor, "q"); | ||
| 222 | expect(editor.commands.wrapInList("taskList")).toBe(false); | ||
| 223 | expect(storedDraft(editor)).toBe("> q"); | ||
| 224 | }); | ||
| 225 | }); | ||
| 226 | |||
| 227 | describe("[ ] typed at the start of a bullet item", () => { | ||
| 228 | // The value holds `[ ]` already; the rule fires on the space after it. | ||
| 229 | it.each([ | ||
| 230 | ["* [ ]x", "- [ ] x"], | ||
| 231 | ["* a\n* [ ]x\n* c", "* a\n- [ ] x\n* c"], | ||
| 232 | ["- a\n * [ ]x", "- a\n - [ ] x"], | ||
| 233 | ["- a\n - [ ]x\n - y", "- a\n - [ ] x\n - y"], | ||
| 234 | ["- [ ]x\n - y", "- [ ] x\n - y"], | ||
| 235 | ["- [ ]x", "- [ ] x"], | ||
| 236 | ])("makes the item in %j a task where it stands", (value, expected) => { | ||
| 237 | const editor = makeEditor(value); | ||
| 238 | placeCaret(editor, "[ ]x"); | ||
| 239 | const from = editor.state.selection.from; | ||
| 240 | editor.view.dispatch( | ||
| 241 | editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from + 3)), | ||
| 242 | ); | ||
| 243 | const { tr } = editor.state; | ||
| 244 | convertBulletItemToTask(tr, from, from + 3, false); | ||
| 245 | editor.view.dispatch(tr); | ||
| 246 | expect(editor.state.selection.$from.parent.textContent).toBe("x"); | ||
| 247 | expect(editor.state.selection.$from.parentOffset).toBe(0); | ||
| 248 | expect(storedDraft(editor)).toBe(expected); | ||
| 249 | }); | ||
| 250 | }); | ||
| 251 | |||
| 252 | describe("a rule at the end of the draft", () => { | ||
| 253 | it("keeps an empty line after it to type on", () => { | ||
| 254 | const editor = makeEditor("a\n---"); | ||
| 255 | // Removing the line after the rule puts one straight back. | ||
| 256 | editor.view.dispatch( | ||
| 257 | editor.state.tr.delete(editor.state.doc.content.size - 2, editor.state.doc.content.size), | ||
| 258 | ); | ||
| 259 | expect(editor.state.doc.lastChild?.type.name).toBe("paragraph"); | ||
| 260 | expect(storedDraft(editor)).toBe("a\n---"); | ||
| 261 | editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); | ||
| 262 | editor.view.dispatch(editor.state.tr.insertText("b")); | ||
| 263 | expect(storedDraft(editor)).toBe("a\n---\nb"); | ||
| 264 | }); | ||
| 265 | }); | ||
apps/web/src/composer-logic.test.ts+56| ... | @@ -872,3 +872,59 @@ describe("literal editor answer cursor state", () => { | ... | @@ -872,3 +872,59 @@ describe("literal editor answer cursor state", () => { |
| 872 | expect(composerStateAtPromptEnd("Use $my").trigger?.kind).toBe("skill"); | 872 | expect(composerStateAtPromptEnd("Use $my").trigger?.kind).toBe("skill"); |
| 873 | }); | 873 | }); |
| 874 | }); | 874 | }); |
| 875 | |||
| 876 | describe("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 { | ... | @@ -11,7 +11,7 @@ import { |
| 11 | 11 | ||
| 12 | import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings"; | 12 | import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings"; |
| 13 | 13 | ||
| 14 | export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill"; | 14 | export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill" | "emoji"; |
| 15 | export type ComposerSlashCommand = "model" | "plan" | "default"; | 15 | export type ComposerSlashCommand = "model" | "plan" | "default"; |
| 16 | export type ComposerSubmissionIntent = "foreground" | "background" | "alternate"; | 16 | export type ComposerSubmissionIntent = "foreground" | "background" | "alternate"; |
| 17 | 17 | ||
| ... | @@ -256,8 +256,60 @@ export function isCollapsedCursorAdjacentToInlineToken( | ... | @@ -256,8 +256,60 @@ export function isCollapsedCursorAdjacentToInlineToken( |
| 256 | return false; | 256 | return false; |
| 257 | } | 257 | } |
| 258 | 258 | ||
| 259 | function 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 | |||
| 259 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { | 297 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { |
| 260 | const cursor = clampCursor(text, cursorInput); | 298 | 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 | |||
| 261 | const tokenStart = tokenStartForCursor(text, cursor); | 313 | const tokenStart = tokenStartForCursor(text, cursor); |
| 262 | const token = text.slice(tokenStart, cursor); | 314 | const token = text.slice(tokenStart, cursor); |
| 263 | if (token.startsWith("/")) { | 315 | if (token.startsWith("/")) { |
| ... | @@ -298,22 +350,6 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos | ... | @@ -298,22 +350,6 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos |
| 298 | }; | 350 | }; |
| 299 | } | 351 | } |
| 300 | 352 | ||
| 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 | */ | ||
| 307 | export 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 | |||
| 317 | /** Caret and trigger after replacing composer text and continuing at the end. */ | 353 | /** Caret and trigger after replacing composer text and continuing at the end. */ |
| 318 | export function composerStateAtPromptEnd( | 354 | export function composerStateAtPromptEnd( |
| 319 | text: string, | 355 | text: string, |
apps/web/src/composer-rich-text-doc.test.ts deleted-1122| ... | @@ -1,1122 +0,0 @@ | ||
| 1 | import { getSchemaByResolvedExtensions, Node, resolveExtensions } from "@tiptap/core"; | ||
| 2 | import StarterKit from "@tiptap/starter-kit"; | ||
| 3 | import { TaskList } from "@tiptap/extension-task-list"; | ||
| 4 | import { Node as ProseMirrorNode } from "@tiptap/pm/model"; | ||
| 5 | import { EditorState, TextSelection } from "@tiptap/pm/state"; | ||
| 6 | import { splitBlockKeepMarks } from "@tiptap/pm/commands"; | ||
| 7 | import { describe, expect, it } from "vite-plus/test"; | ||
| 8 | |||
| 9 | import { collapseExpandedComposerCursor, pastedPathQueryLength } from "./composer-logic"; | ||
| 10 | |||
| 11 | import { | ||
| 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 | |||
| 30 | function 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 | |||
| 40 | const 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 | |||
| 81 | function 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. | ||
| 91 | const 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 | |||
| 131 | function 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 | |||
| 139 | describe("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 | |||
| 186 | describe("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 | |||
| 888 | describe("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 | |||
| 930 | describe("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 | |||
| 945 | describe("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 | |||
| 1033 | describe("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 @@ | ||
| 1 | import { joinBackward, joinTextblockBackward, joinTextblockForward } from "@tiptap/pm/commands"; | ||
| 2 | import { Fragment, Mark, type Node as ProseMirrorNode, type ResolvedPos } from "@tiptap/pm/model"; | ||
| 3 | import { Code } from "@tiptap/extension-code"; | ||
| 4 | import { Blockquote } from "@tiptap/extension-blockquote"; | ||
| 5 | import { CodeBlock } from "@tiptap/extension-code-block"; | ||
| 6 | import { Heading } from "@tiptap/extension-heading"; | ||
| 7 | import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; | ||
| 8 | import { type Command, type Editor, mergeAttributes } from "@tiptap/core"; | ||
| 9 | import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; | ||
| 10 | import { TaskItem } from "@tiptap/extension-task-item"; | ||
| 11 | import { TaskList } from "@tiptap/extension-task-list"; | ||
| 12 | import { | ||
| 13 | type EditorState, | ||
| 14 | Plugin, | ||
| 15 | Selection, | ||
| 16 | TextSelection, | ||
| 17 | type Transaction, | ||
| 18 | } from "@tiptap/pm/state"; | ||
| 19 | |||
| 20 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; | ||
| 21 | import { nextOrderedMarkerText } from "~/composer-list-continuation"; | ||
| 22 | import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text"; | ||
| 23 | import { 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 | |||
| 42 | export type SkillMeta = { label: string; description: string | null }; | ||
| 43 | |||
| 44 | /** Outermost mark first, so closers mirror openers when nested. */ | ||
| 45 | const MARK_NESTING_ORDER: RichTextMark[] = ["strike", "bold", "italic", "code"]; | ||
| 46 | |||
| 47 | const MARK_TO_TIPTAP: Record<RichTextMark, string> = { | ||
| 48 | bold: "bold", | ||
| 49 | italic: "italic", | ||
| 50 | strike: "strike", | ||
| 51 | code: "code", | ||
| 52 | }; | ||
| 53 | |||
| 54 | const 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 | */ | ||
| 66 | export 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 | */ | ||
| 82 | function withoutBlockChords<Shortcuts extends Record<string, unknown>>( | ||
| 83 | shortcuts: Shortcuts | undefined, | ||
| 84 | drop: readonly string[], | ||
| 85 | ): Shortcuts { | ||
| 86 | return Object.fromEntries( | ||
| 87 | Object.entries(shortcuts ?? {}).filter(([key]) => !drop.includes(key)), | ||
| 88 | ) as Shortcuts; | ||
| 89 | } | ||
| 90 | |||
| 91 | /** | ||
| 92 | * What a list item may hold: its own line, then nested lists. That is all the | ||
| 93 | * Markdown can write, so ProseMirror refuses any join, wrap or paste that | ||
| 94 | * would put a second line or another block into an item. | ||
| 95 | */ | ||
| 96 | const LIST_ITEM_CONTENT = "paragraph list*"; | ||
| 97 | |||
| 98 | /** Task lists come from `- [ ]` alone; Mod-Shift-9 would nest one in a quote. */ | ||
| 99 | export const ComposerTaskListExtension = TaskList.extend({ | ||
| 100 | addKeyboardShortcuts() { | ||
| 101 | return withoutBlockChords(this.parent?.(), ["Mod-Shift-9"]); | ||
| 102 | }, | ||
| 103 | }); | ||
| 104 | |||
| 105 | /** | ||
| 106 | * Tiptap's Tab and Shift-Tab sink and lift an item without touching its | ||
| 107 | * `indent`, so the stored draft keeps the old nesting and the next rebuild | ||
| 108 | * undoes the move. The composer nests through its own Tab, which edits the | ||
| 109 | * source the way plain mode does. | ||
| 110 | */ | ||
| 111 | const LIST_NESTING_KEYS = ["Tab", "Shift-Tab"]; | ||
| 112 | |||
| 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 | */ | ||
| 118 | export 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 | */ | ||
| 146 | export const ComposerCodeBlockExtension = CodeBlock.extend({ | ||
| 147 | addAttributes() { | ||
| 148 | return { | ||
| 149 | ...this.parent?.(), | ||
| 150 | language: { default: "" }, | ||
| 151 | fence: { default: "```" }, | ||
| 152 | close: { default: "\n```" }, | ||
| 153 | }; | ||
| 154 | }, | ||
| 155 | addKeyboardShortcuts() { | ||
| 156 | return { | ||
| 157 | ...withoutBlockChords(this.parent?.(), ["Mod-Alt-c"]), | ||
| 158 | Backspace: () => unwrapCodeBlockAtStart(this.editor), | ||
| 159 | }; | ||
| 160 | }, | ||
| 161 | }); | ||
| 162 | |||
| 163 | /** | ||
| 164 | * Backspace at the start of a fence removes the fence and keeps its lines as | ||
| 165 | * paragraphs, the way it removes a list marker. Joining the code onto the line | ||
| 166 | * above instead would drop the fence from the draft and leave the rest of the | ||
| 167 | * code as stray lines. | ||
| 168 | */ | ||
| 169 | function unwrapCodeBlockAtStart(editor: Editor): boolean { | ||
| 170 | const { $from, empty } = editor.state.selection; | ||
| 171 | if (!empty || $from.parent.type.name !== "codeBlock" || $from.parentOffset !== 0) return false; | ||
| 172 | const { schema } = editor.state; | ||
| 173 | const paragraphs = $from.parent.textContent | ||
| 174 | .split("\n") | ||
| 175 | .map((line) => schema.nodes.paragraph!.create(null, line ? schema.text(line) : null)); | ||
| 176 | const start = $from.before(); | ||
| 177 | const tr = editor.state.tr.replaceWith(start, $from.after(), paragraphs); | ||
| 178 | editor.view.dispatch(tr.setSelection(TextSelection.create(tr.doc, start + 1))); | ||
| 179 | return true; | ||
| 180 | } | ||
| 181 | |||
| 182 | /** | ||
| 183 | * The markdown a code block node serializes to, delimiters included. `followed` | ||
| 184 | * says another block comes after it in the draft. | ||
| 185 | */ | ||
| 186 | function codeBlockSource( | ||
| 187 | node: ProseMirrorNode, | ||
| 188 | followed: boolean, | ||
| 189 | ): { | ||
| 190 | open: string; | ||
| 191 | content: string; | ||
| 192 | close: string; | ||
| 193 | } { | ||
| 194 | const attrs = node.attrs as Record<string, unknown>; | ||
| 195 | let fence = typeof attrs.fence === "string" && attrs.fence ? attrs.fence : "```"; | ||
| 196 | const language = typeof attrs.language === "string" ? attrs.language : ""; | ||
| 197 | let close = typeof attrs.close === "string" ? attrs.close : ""; | ||
| 198 | const content = node.textContent; | ||
| 199 | // Code holding a line that would close the fence, such as a pasted fence, | ||
| 200 | // is written inside a longer one, so the agent reads all of it as code. A | ||
| 201 | // parsed block never holds such a line, so its source stays byte-identical. | ||
| 202 | const longest = Math.max( | ||
| 203 | 0, | ||
| 204 | ...content | ||
| 205 | .split("\n") | ||
| 206 | .filter((line) => isClosingFence(line, fence)) | ||
| 207 | .map((line) => /^[`~]+/.exec(line)![0].length), | ||
| 208 | ); | ||
| 209 | if (longest > 0) { | ||
| 210 | fence = fence[0]!.repeat(longest + 1); | ||
| 211 | if (close) close = `\n${fence}`; | ||
| 212 | } | ||
| 213 | // An unclosed fence runs to the end of the draft, so with a block after it | ||
| 214 | // the fence is written closed, or the next rebuild would read that block as code. | ||
| 215 | if (!close && followed) close = `\n${fence}`; | ||
| 216 | return { open: `${fence}${language}${content ? "\n" : ""}`, content, close }; | ||
| 217 | } | ||
| 218 | |||
| 219 | /** | ||
| 220 | * Bullet and ordered items keep their exact source marker so a list | ||
| 221 | * round-trips byte-identically: `marker` is the literal `-`, `*`, `+`, `3.` | ||
| 222 | * or `3)`, `space` what followed it, and `indent` the leading whitespace. | ||
| 223 | * Numbering is not renumbered: what the user typed is what the agent gets. | ||
| 224 | */ | ||
| 225 | const ComposerListItemExtension = ListItem.extend({ | ||
| 226 | content: LIST_ITEM_CONTENT, | ||
| 227 | addAttributes() { | ||
| 228 | return { | ||
| 229 | ...this.parent?.(), | ||
| 230 | indent: { default: "" }, | ||
| 231 | marker: { default: "-" }, | ||
| 232 | space: { default: " " }, | ||
| 233 | }; | ||
| 234 | }, | ||
| 235 | addKeyboardShortcuts() { | ||
| 236 | return { | ||
| 237 | ...withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS), | ||
| 238 | // Task items share these: the list extensions are always loaded together. | ||
| 239 | Backspace: () => backspaceAcrossList(this.editor), | ||
| 240 | Delete: () => deleteAcrossList(this.editor), | ||
| 241 | }; | ||
| 242 | }, | ||
| 243 | // The source marker rides on the item so the composer draws `3)` and a | ||
| 244 | // nested `7.` as written, rather than the browser own numbering. | ||
| 245 | renderHTML({ node, HTMLAttributes }) { | ||
| 246 | return ["li", mergeAttributes(HTMLAttributes, { "data-marker": node.attrs.marker }), 0]; | ||
| 247 | }, | ||
| 248 | }); | ||
| 249 | |||
| 250 | export const ComposerListExtensions = [ | ||
| 251 | BulletList.extend({ | ||
| 252 | addKeyboardShortcuts() { | ||
| 253 | return withoutBlockChords(this.parent?.(), ["Mod-Shift-8"]); | ||
| 254 | }, | ||
| 255 | }), | ||
| 256 | OrderedList.extend({ | ||
| 257 | addKeyboardShortcuts() { | ||
| 258 | return withoutBlockChords(this.parent?.(), ["Mod-Shift-7"]); | ||
| 259 | }, | ||
| 260 | }), | ||
| 261 | ComposerListItemExtension, | ||
| 262 | ]; | ||
| 263 | |||
| 264 | /** | ||
| 265 | * A quote keeps the exact `>` prefix its lines were written with, applied to | ||
| 266 | * every line, so it round-trips byte-identically and a new line typed inside | ||
| 267 | * it gets the same prefix. One source line is one paragraph; a line whose | ||
| 268 | * prefix differs starts a sibling quote. Nested markers and list markers | ||
| 269 | * inside a quote stay literal text: the composer quotes prose, not documents. | ||
| 270 | */ | ||
| 271 | const ComposerBlockquoteExtension = Blockquote.extend({ | ||
| 272 | content: "paragraph+", | ||
| 273 | addAttributes() { | ||
| 274 | return { ...this.parent?.(), prefix: { default: "> " } }; | ||
| 275 | }, | ||
| 276 | addKeyboardShortcuts() { | ||
| 277 | return withoutBlockChords(this.parent?.(), ["Mod-Shift-b"]); | ||
| 278 | }, | ||
| 279 | }); | ||
| 280 | |||
| 281 | /** | ||
| 282 | * A rule keeps the exact line it was written as (`---`, `* * *`, `_____`), so | ||
| 283 | * it round-trips byte-identically. It owns no document characters: offsets | ||
| 284 | * inside its source clamp to the block after it. | ||
| 285 | * | ||
| 286 | * A rule cannot hold the caret, so a document never ends in one: an empty | ||
| 287 | * line follows it, which the draft does not write (see `isTrailingLineAfterRule`). | ||
| 288 | * A draft ending in `---\n` reads back the same way and so loses that newline, | ||
| 289 | * the same fixed-point deal as `__bold__` becoming `**bold**`. | ||
| 290 | */ | ||
| 291 | const ComposerHorizontalRuleExtension = HorizontalRule.extend({ | ||
| 292 | addAttributes() { | ||
| 293 | return { ...this.parent?.(), source: { default: "---" } }; | ||
| 294 | }, | ||
| 295 | addProseMirrorPlugins() { | ||
| 296 | return [ | ||
| 297 | ...(this.parent?.() ?? []), | ||
| 298 | new Plugin({ | ||
| 299 | appendTransaction: (transactions, _, state) => { | ||
| 300 | if (!transactions.some((tr) => tr.docChanged)) return null; | ||
| 301 | if (state.doc.lastChild?.type.name !== "horizontalRule") return null; | ||
| 302 | return state.tr.insert(state.doc.content.size, state.schema.nodes.paragraph!.create()); | ||
| 303 | }, | ||
| 304 | }), | ||
| 305 | ]; | ||
| 306 | }, | ||
| 307 | }); | ||
| 308 | |||
| 309 | /** The empty line after a rule that ends the document, which writes nothing. */ | ||
| 310 | function isTrailingLineAfterRule(doc: ProseMirrorNode, index: number): boolean { | ||
| 311 | const block = doc.child(index); | ||
| 312 | return ( | ||
| 313 | index === doc.childCount - 1 && | ||
| 314 | index > 0 && | ||
| 315 | block.type.name === "paragraph" && | ||
| 316 | block.content.size === 0 && | ||
| 317 | doc.child(index - 1).type.name === "horizontalRule" | ||
| 318 | ); | ||
| 319 | } | ||
| 320 | |||
| 321 | /** | ||
| 322 | * A heading keeps the exact whitespace between its `#`s and its text. The | ||
| 323 | * `#`s must be followed by whitespace to count, which is also what keeps a | ||
| 324 | * `#1234` pull request reference a reference: the marker owns no document | ||
| 325 | * characters, closing `#`s stay literal text, and nothing is ever stripped. | ||
| 326 | */ | ||
| 327 | const ComposerHeadingExtension = Heading.extend({ | ||
| 328 | addAttributes() { | ||
| 329 | return { ...this.parent?.(), space: { default: " " } }; | ||
| 330 | }, | ||
| 331 | // Its only shortcuts are the Mod-Alt-1…6 block chords. | ||
| 332 | addKeyboardShortcuts() { | ||
| 333 | return {}; | ||
| 334 | }, | ||
| 335 | }); | ||
| 336 | |||
| 337 | /** Block-level nodes beyond lists and fences, in the order the parser tries them. */ | ||
| 338 | export const ComposerBlockExtensions = [ | ||
| 339 | ComposerBlockquoteExtension, | ||
| 340 | ComposerHorizontalRuleExtension, | ||
| 341 | ComposerHeadingExtension, | ||
| 342 | ]; | ||
| 343 | |||
| 344 | function randomNodeKey(): string { | ||
| 345 | return `tiptap-${Math.random().toString(36).slice(2)}`; | ||
| 346 | } | ||
| 347 | |||
| 348 | interface TaskLinePrefix { | ||
| 349 | indent: string; | ||
| 350 | checked: boolean; | ||
| 351 | markerSpace: string; | ||
| 352 | contentSpace: string; | ||
| 353 | } | ||
| 354 | |||
| 355 | function 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 | */ | ||
| 379 | export function parseOpeningFence(line: string): { fence: string; language: string } | null { | ||
| 380 | // `[^\n]` rather than `.`, here and in the block rules: a CRLF draft's lines | ||
| 381 | // end in `\r`, which `.` refuses, and the `\r` stays in the stored text. | ||
| 382 | const match = /^(`{3,}|~{3,})([^\n]*)$/.exec(line); | ||
| 383 | if (!match) return null; | ||
| 384 | const fence = match[1]!; | ||
| 385 | const language = match[2]!; | ||
| 386 | if (fence.startsWith("`") && language.includes("`")) return null; | ||
| 387 | return { fence, language }; | ||
| 388 | } | ||
| 389 | |||
| 390 | /** A closing fence matches the opening run's character and is at least as long. */ | ||
| 391 | export function isClosingFence(line: string, fence: string): boolean { | ||
| 392 | const match = /^(`{3,}|~{3,})[ \t]*\r?$/.exec(line); | ||
| 393 | if (!match) return false; | ||
| 394 | const run = match[1]!; | ||
| 395 | return run[0] === fence[0] && run.length >= fence.length; | ||
| 396 | } | ||
| 397 | |||
| 398 | type ListLinePrefix = | ||
| 399 | | ({ kind: "task" } & TaskLinePrefix) | ||
| 400 | | { kind: "bullet" | "ordered"; indent: string; marker: string; space: string }; | ||
| 401 | |||
| 402 | /** | ||
| 403 | * The same grammar the literal continuation uses, so plain and rich mode agree | ||
| 404 | * on what a list line is: a task first (it also looks like a bullet), then an | ||
| 405 | * ordered marker, then a bullet. A marker followed by nothing is an empty item. | ||
| 406 | */ | ||
| 407 | function parseListPrefix(line: string): { prefix: ListLinePrefix; markerLength: number } | null { | ||
| 408 | const task = parseTaskPrefix(line); | ||
| 409 | if (task) return { prefix: { kind: "task", ...task.prefix }, markerLength: task.markerLength }; | ||
| 410 | const ordered = /^([ \t]*)(\d+[.)])((?:[ \t]+)|$)/.exec(line); | ||
| 411 | if (ordered) { | ||
| 412 | return { | ||
| 413 | prefix: { kind: "ordered", indent: ordered[1]!, marker: ordered[2]!, space: ordered[3]! }, | ||
| 414 | markerLength: ordered[0].length, | ||
| 415 | }; | ||
| 416 | } | ||
| 417 | const bullet = /^([ \t]*)([-*+])((?:[ \t]+)|$)/.exec(line); | ||
| 418 | if (bullet) { | ||
| 419 | return { | ||
| 420 | prefix: { kind: "bullet", indent: bullet[1]!, marker: bullet[2]!, space: bullet[3]! }, | ||
| 421 | markerLength: bullet[0].length, | ||
| 422 | }; | ||
| 423 | } | ||
| 424 | return null; | ||
| 425 | } | ||
| 426 | |||
| 427 | /** Items of one kind and marker family belong to one list; a change starts a sibling list. */ | ||
| 428 | function listKey(prefix: ListLinePrefix): string { | ||
| 429 | if (prefix.kind === "task") return "task"; | ||
| 430 | if (prefix.kind === "ordered") return `ordered:${prefix.marker.slice(-1)}`; | ||
| 431 | return `bullet:${prefix.marker}`; | ||
| 432 | } | ||
| 433 | |||
| 434 | type InlineJson = Record<string, unknown>; | ||
| 435 | |||
| 436 | interface DocLine { | ||
| 437 | list: ListLinePrefix | null; | ||
| 438 | inline: InlineJson[]; | ||
| 439 | } | ||
| 440 | |||
| 441 | function 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 | |||
| 479 | interface 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 | |||
| 486 | interface PendingList { | ||
| 487 | key: string; | ||
| 488 | items: PendingItem[]; | ||
| 489 | } | ||
| 490 | |||
| 491 | function listJson(list: PendingList): InlineJson { | ||
| 492 | const first = list.items[0]!.prefix; | ||
| 493 | const items = list.items.map((item) => { | ||
| 494 | const content = [ | ||
| 495 | { type: "paragraph", content: item.content }, | ||
| 496 | ...item.children.map((child) => listJson(child)), | ||
| 497 | ]; | ||
| 498 | if (item.prefix.kind === "task") { | ||
| 499 | return { | ||
| 500 | type: "taskItem", | ||
| 501 | attrs: { | ||
| 502 | checked: item.prefix.checked, | ||
| 503 | indent: item.prefix.indent, | ||
| 504 | markerSpace: item.prefix.markerSpace, | ||
| 505 | contentSpace: item.prefix.contentSpace, | ||
| 506 | }, | ||
| 507 | content, | ||
| 508 | }; | ||
| 509 | } | ||
| 510 | return { | ||
| 511 | type: "listItem", | ||
| 512 | attrs: { indent: item.prefix.indent, marker: item.prefix.marker, space: item.prefix.space }, | ||
| 513 | content, | ||
| 514 | }; | ||
| 515 | }); | ||
| 516 | if (first.kind === "task") return { type: "taskList", content: items }; | ||
| 517 | if (first.kind === "ordered") { | ||
| 518 | return { | ||
| 519 | type: "orderedList", | ||
| 520 | attrs: { start: Number.parseInt(first.marker, 10) || 1 }, | ||
| 521 | content: items, | ||
| 522 | }; | ||
| 523 | } | ||
| 524 | return { type: "bulletList", content: items }; | ||
| 525 | } | ||
| 526 | |||
| 527 | function 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 | */ | ||
| 542 | export 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 | |||
| 780 | export 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 | |||
| 791 | export 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 | |||
| 810 | export interface RichDocMap { | ||
| 811 | value: string; | ||
| 812 | runs: RichRun[]; | ||
| 813 | docLength: number; | ||
| 814 | contextIds: string[]; | ||
| 815 | } | ||
| 816 | |||
| 817 | function 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 | |||
| 833 | interface RichAccumulator { | ||
| 834 | runs: RichRun[]; | ||
| 835 | value: string; | ||
| 836 | flat: number; | ||
| 837 | collapsed: number; | ||
| 838 | md: number; | ||
| 839 | } | ||
| 840 | |||
| 841 | function 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 | |||
| 863 | function 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 | |||
| 1008 | const LIST_NODE_NAMES = new Set(["taskList", "bulletList", "orderedList"]); | ||
| 1009 | const 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 | */ | ||
| 1017 | function 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 | */ | ||
| 1046 | function 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 | */ | ||
| 1072 | export 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 | */ | ||
| 1098 | export 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 | |||
| 1135 | function 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 | */ | ||
| 1147 | export 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 | */ | ||
| 1177 | export 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. */ | ||
| 1190 | function 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 | |||
| 1211 | function 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 | */ | ||
| 1266 | function 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. */ | ||
| 1308 | function 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 | */ | ||
| 1342 | export 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 | |||
| 1360 | export 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 | |||
| 1429 | function 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 | */ | ||
| 1443 | function runOwnsOffset(run: RichRun, flatOffset: number): boolean { | ||
| 1444 | const end = run.flatStart + run.docLen; | ||
| 1445 | return flatOffset < end || (flatOffset === end && run.nodeName === "codeBlock"); | ||
| 1446 | } | ||
| 1447 | |||
| 1448 | export 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 | |||
| 1466 | export 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 | |||
| 1479 | export 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 | |||
| 1499 | export 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 | |||
| 1511 | export 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 | |||
| 1538 | function 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. */ | ||
| 1550 | export 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 | */ | ||
| 1560 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { parseInlineMarkdown } from "./composer-rich-text"; | ||
| 4 | |||
| 5 | describe("composer rich text markdown", () => { | ||
| 6 | it("parses bold markers into styled spans", () => { | ||
| 7 | expect(parseInlineMarkdown("hello **bold** world")).toEqual([ | ||
| 8 | { text: "hello ", marks: [] }, | ||
| 9 | { text: "bold", marks: ["bold"] }, | ||
| 10 | { text: " world", marks: [] }, | ||
| 11 | ]); | ||
| 12 | }); | ||
| 13 | |||
| 14 | it("preserves unmatched markers, escaped markers, and identifiers", () => { | ||
| 15 | for (const text of [ | ||
| 16 | "plain text", | ||
| 17 | "snake_case", | ||
| 18 | "unmatched **", | ||
| 19 | "** spaced **", | ||
| 20 | "\\*literal\\*", | ||
| 21 | ]) { | ||
| 22 | expect(parseInlineMarkdown(text)).toEqual([{ text, marks: [] }]); | ||
| 23 | } | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("renders triple markers and nested styles", () => { | ||
| 27 | expect(parseInlineMarkdown("***both***")).toEqual([ | ||
| 28 | { text: "both", marks: ["bold", "italic"] }, | ||
| 29 | ]); | ||
| 30 | expect(parseInlineMarkdown("*a **b** c*")).toEqual([ | ||
| 31 | { text: "a ", marks: ["italic"] }, | ||
| 32 | { text: "b", marks: ["italic", "bold"] }, | ||
| 33 | { text: " c", marks: ["italic"] }, | ||
| 34 | ]); | ||
| 35 | expect(parseInlineMarkdown("**a `code` c**")).toEqual([ | ||
| 36 | { text: "a ", marks: ["bold"] }, | ||
| 37 | { text: "code", marks: ["bold", "code"] }, | ||
| 38 | { text: " c", marks: ["bold"] }, | ||
| 39 | ]); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("preserves crossing italic and bold spans", () => { | ||
| 43 | expect(parseInlineMarkdown("*a**b*****c**")).toEqual([ | ||
| 44 | { text: "a", marks: ["italic"] }, | ||
| 45 | { text: "b", marks: ["italic", "bold"] }, | ||
| 46 | { text: "c", marks: ["bold"] }, | ||
| 47 | ]); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("keeps repeated and alternate delimiters literal inside an active mark", () => { | ||
| 51 | const repeated = "*".repeat(10_000); | ||
| 52 | expect(parseInlineMarkdown(repeated + "text" + repeated)).toEqual([ | ||
| 53 | { text: repeated.slice(2) + "text", marks: ["bold"] }, | ||
| 54 | { text: repeated.slice(2), marks: [] }, | ||
| 55 | ]); | ||
| 56 | const alternate = "__".repeat(5_000); | ||
| 57 | expect(parseInlineMarkdown("**~~" + alternate + "text" + alternate + "~~**")).toEqual([ | ||
| 58 | { text: alternate + "text" + alternate, marks: ["bold", "strike"] }, | ||
| 59 | ]); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("keeps code span contents literal", () => { | ||
| 63 | expect(parseInlineMarkdown("`**not bold**`")).toEqual([ | ||
| 64 | { text: "**not bold**", marks: ["code"] }, | ||
| 65 | ]); | ||
| 66 | }); | ||
| 67 | }); | ||
apps/web/src/composer-rich-text.ts 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 | |||
| 13 | export type RichTextMark = "bold" | "italic" | "strike" | "code"; | ||
| 14 | |||
| 15 | interface RichTextSpan { | ||
| 16 | text: string; | ||
| 17 | marks: RichTextMark[]; | ||
| 18 | } | ||
| 19 | |||
| 20 | export const RICH_TEXT_DELIMITERS: Record<RichTextMark, string> = { | ||
| 21 | bold: "**", | ||
| 22 | italic: "*", | ||
| 23 | strike: "~~", | ||
| 24 | code: "`", | ||
| 25 | }; | ||
| 26 | |||
| 27 | function pushSpan(spans: RichTextSpan[], text: string, marks: RichTextMark[]): void { | ||
| 28 | if (!text) return; | ||
| 29 | const last = spans[spans.length - 1]; | ||
| 30 | if ( | ||
| 31 | last && | ||
| 32 | last.marks.length === marks.length && | ||
| 33 | last.marks.every((mark, index) => mark === marks[index]) | ||
| 34 | ) { | ||
| 35 | last.text += text; | ||
| 36 | } else { | ||
| 37 | spans.push({ text, marks }); | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | /** Parse the supported inline styles, leaving unmatched and escaped markers literal. */ | ||
| 42 | export function parseInlineMarkdown(text: string): RichTextSpan[] { | ||
| 43 | const root: RichTextSpan[] = []; | ||
| 44 | const stack: { delimiter: string; mark: RichTextMark; spans: RichTextSpan[] }[] = []; | ||
| 45 | const current = () => stack.at(-1)?.spans ?? root; | ||
| 46 | let index = 0; | ||
| 47 | while (index < text.length) { | ||
| 48 | const char = text[index]!; | ||
| 49 | if (char === "\\") { | ||
| 50 | pushSpan(current(), text.slice(index, index + 2), []); | ||
| 51 | index += 2; | ||
| 52 | continue; | ||
| 53 | } | ||
| 54 | if (char === "`") { | ||
| 55 | // Multi-backtick code stays literal; a single-backtick span owns its contents. | ||
| 56 | const run = text.slice(index).match(/^`+/)![0]; | ||
| 57 | const close = text.indexOf(run, index + run.length); | ||
| 58 | if ( | ||
| 59 | run.length === 1 && | ||
| 60 | close > index + 1 && | ||
| 61 | !text.slice(index, close).includes("\n") && | ||
| 62 | text[close + 1] !== "`" | ||
| 63 | ) { | ||
| 64 | pushSpan(current(), text.slice(index + 1, close), ["code"]); | ||
| 65 | index = close + 1; | ||
| 66 | } else { | ||
| 67 | pushSpan(current(), run, []); | ||
| 68 | index += run.length; | ||
| 69 | } | ||
| 70 | continue; | ||
| 71 | } | ||
| 72 | if (char !== "*" && char !== "_" && char !== "~") { | ||
| 73 | pushSpan(current(), char, []); | ||
| 74 | index += 1; | ||
| 75 | continue; | ||
| 76 | } | ||
| 77 | let end = index; | ||
| 78 | while (text[end] === char) end += 1; | ||
| 79 | const before = text[index - 1] ?? ""; | ||
| 80 | const after = text[end] ?? ""; | ||
| 81 | const canClose = before !== "" && !/\s/.test(before) && (char !== "_" || !/\w/.test(after)); | ||
| 82 | const canOpen = after !== "" && !/\s/.test(after) && (char !== "_" || !/\w/.test(before)); | ||
| 83 | while (index < end) { | ||
| 84 | const top = stack.at(-1); | ||
| 85 | // Inside italic, a double marker opens bold before closing the single | ||
| 86 | // marker. Once bold is active, closing runs unwind both styles. | ||
| 87 | const opensNestedBold = | ||
| 88 | top?.mark === "italic" && | ||
| 89 | end - index === 2 && | ||
| 90 | canOpen && | ||
| 91 | (char === "*" || char === "_") && | ||
| 92 | !stack.some((frame) => frame.mark === "bold"); | ||
| 93 | if ( | ||
| 94 | !opensNestedBold && | ||
| 95 | canClose && | ||
| 96 | top && | ||
| 97 | text.startsWith(top.delimiter, index) && | ||
| 98 | index + top.delimiter.length <= end | ||
| 99 | ) { | ||
| 100 | stack.pop(); | ||
| 101 | for (const span of top.spans) pushSpan(current(), span.text, [top.mark, ...span.marks]); | ||
| 102 | index += top.delimiter.length; | ||
| 103 | } else if (canOpen && (char !== "~" || end - index >= 2)) { | ||
| 104 | const length = char === "~" || end - index >= 2 ? 2 : 1; | ||
| 105 | const mark = char === "~" ? "strike" : length === 2 ? "bold" : "italic"; | ||
| 106 | // A mark cannot nest inside itself, including alternate delimiters. | ||
| 107 | // This bounds the stack to the supported styles for arbitrary input. | ||
| 108 | if (stack.some((frame) => frame.mark === mark)) { | ||
| 109 | pushSpan(current(), text.slice(index, end), []); | ||
| 110 | index = end; | ||
| 111 | continue; | ||
| 112 | } | ||
| 113 | stack.push({ | ||
| 114 | delimiter: char.repeat(length), | ||
| 115 | mark, | ||
| 116 | spans: [], | ||
| 117 | }); | ||
| 118 | index += length; | ||
| 119 | } else { | ||
| 120 | pushSpan(current(), text.slice(index, end), []); | ||
| 121 | index = end; | ||
| 122 | } | ||
| 123 | } | ||
| 124 | } | ||
| 125 | while (stack.length > 0) { | ||
| 126 | const frame = stack.pop()!; | ||
| 127 | pushSpan(current(), frame.delimiter, []); | ||
| 128 | for (const span of frame.spans) pushSpan(current(), span.text, span.marks); | ||
| 129 | } | ||
| 130 | return root; | ||
| 131 | } | ||
apps/web/src/composer-undo-grouping.test.ts deleted-143| ... | @@ -1,143 +0,0 @@ | ||
| 1 | import { getSchema } from "@tiptap/core"; | ||
| 2 | import { history, undo } from "@tiptap/pm/history"; | ||
| 3 | import { EditorState, TextSelection, type Transaction } from "@tiptap/pm/state"; | ||
| 4 | import StarterKit from "@tiptap/starter-kit"; | ||
| 5 | import { describe, expect, it } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | COMPOSER_UNDO_GROUP_DELAY, | ||
| 9 | type ComposerChangeKind, | ||
| 10 | groupUndoByChangeKind, | ||
| 11 | markAsClipboardEdit, | ||
| 12 | } from "./composer-undo-grouping"; | ||
| 13 | |||
| 14 | const 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 | */ | ||
| 21 | function 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 | |||
| 101 | describe("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 @@ | ||
| 1 | import { closeHistory, isHistoryTransaction } from "@tiptap/pm/history"; | ||
| 2 | import type { Transaction } from "@tiptap/pm/state"; | ||
| 3 | import { 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 | */ | ||
| 15 | export const COMPOSER_UNDO_GROUP_DELAY = 1000; | ||
| 16 | |||
| 17 | export 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 | */ | ||
| 26 | export function markAsClipboardEdit(tr: Transaction, event: "paste" | "cut"): Transaction { | ||
| 27 | return tr.setMeta("uiEvent", event); | ||
| 28 | } | ||
| 29 | |||
| 30 | function 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 | */ | ||
| 55 | export 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 @@ | ... | @@ -32,6 +32,21 @@ |
| 32 | font-display: swap; | 32 | font-display: swap; |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 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 | |||
| 35 | @custom-variant dark (&:is(.dark, .dark *)); | 50 | @custom-variant dark (&:is(.dark, .dark *)); |
| 36 | @custom-variant light (&:not(.dark, .dark *)); | 51 | @custom-variant light (&:not(.dark, .dark *)); |
| 37 | /* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */ | 52 | /* 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 | ... | @@ -1133,6 +1148,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 1133 | --border: var(--color-zinc-200); | 1148 | --border: var(--color-zinc-200); |
| 1134 | --input: var(--color-zinc-300); | 1149 | --input: var(--color-zinc-300); |
| 1135 | --ring: var(--primary); | 1150 | --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 | ); | ||
| 1136 | --destructive-foreground: var(--error-foreground); | 1155 | --destructive-foreground: var(--error-foreground); |
| 1137 | --info: var(--color-blue-500); | 1156 | --info: var(--color-blue-500); |
| 1138 | --info-foreground: var(--color-blue-700); | 1157 | --info-foreground: var(--color-blue-700); |
| ... | @@ -1450,7 +1469,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] { | ... | @@ -1450,7 +1469,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] { |
| 1450 | /* Chat code blocks join the other code surfaces on the code tokens. The | 1469 | /* Chat code blocks join the other code surfaces on the code tokens. The |
| 1451 | default look keeps its secondary/input tints; themed palettes tint those | 1470 | default look keeps its secondary/input tints; themed palettes tint those |
| 1452 | surfaces with the accent, which fights syntax highlighting. */ | 1471 | surfaces with the accent, which fights syntax highlighting. */ |
| 1453 | html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock { | 1472 | html[data-theme-id] .chat-markdown .chat-markdown-codeblock { |
| 1454 | background-color: var(--code-background); | 1473 | background-color: var(--code-background); |
| 1455 | border-color: var(--contrast-border); | 1474 | border-color: var(--contrast-border); |
| 1456 | color: var(--code-foreground); | 1475 | color: var(--code-foreground); |
| ... | @@ -1461,11 +1480,11 @@ html[data-theme-id] .chat-markdown pre { | ... | @@ -1461,11 +1480,11 @@ html[data-theme-id] .chat-markdown pre { |
| 1461 | color: var(--code-foreground); | 1480 | color: var(--code-foreground); |
| 1462 | } | 1481 | } |
| 1463 | 1482 | ||
| 1464 | html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { | 1483 | html[data-theme-id] .chat-markdown .chat-markdown-codeblock pre { |
| 1465 | background-color: transparent; | 1484 | background-color: transparent; |
| 1466 | } | 1485 | } |
| 1467 | 1486 | ||
| 1468 | html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { | 1487 | html[data-theme-id] .chat-markdown .chat-markdown-codeblock-header { |
| 1469 | color: color-mix(in srgb, var(--code-foreground) 72%, transparent); | 1488 | color: color-mix(in srgb, var(--code-foreground) 72%, transparent); |
| 1470 | } | 1489 | } |
| 1471 | 1490 | ||
| ... | @@ -1486,7 +1505,7 @@ html[data-theme-id="t3-chat"] { | ... | @@ -1486,7 +1505,7 @@ html[data-theme-id="t3-chat"] { |
| 1486 | } | 1505 | } |
| 1487 | 1506 | ||
| 1488 | & .chat-markdown :not(pre) > code { | 1507 | & .chat-markdown :not(pre) > code { |
| 1489 | color: var(--contrast-message-foreground); | 1508 | color: inherit; |
| 1490 | } | 1509 | } |
| 1491 | } | 1510 | } |
| 1492 | } | 1511 | } |
| ... | @@ -1706,13 +1725,22 @@ code { | ... | @@ -1706,13 +1725,22 @@ code { |
| 1706 | font-family: var(--font-mono); | 1725 | font-family: var(--font-mono); |
| 1707 | } | 1726 | } |
| 1708 | 1727 | ||
| 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 | |||
| 1709 | /* Code blocks in chat carry the size preference. Scoped to chat markdown | 1736 | /* Code blocks in chat carry the size preference. Scoped to chat markdown |
| 1710 | rather than every pre: a global rule would beat text-size utilities and | 1737 | rather than every pre: a global rule would beat text-size utilities and |
| 1711 | inherited sizes on unrelated pre surfaces (terminal previews, approvals). | 1738 | inherited sizes on unrelated pre surfaces (terminal previews, approvals). |
| 1712 | Inline code stays relative to its sentence so it never towers over prose; | 1739 | Inline code stays relative to its sentence so it never towers over prose; |
| 1713 | diffs and file previews take the size through --diffs-font-size. */ | 1740 | diffs and file previews take the size through --diffs-font-size. */ |
| 1714 | .chat-markdown .chat-markdown-shiki .shiki, | 1741 | .chat-markdown .chat-markdown-shiki .shiki, |
| 1715 | :is(.chat-markdown, .composer-tiptap) pre code { | 1742 | .chat-markdown pre, |
| 1743 | .chat-markdown pre code { | ||
| 1716 | font-size: var(--font-size-code, inherit); | 1744 | font-size: var(--font-size-code, inherit); |
| 1717 | } | 1745 | } |
| 1718 | 1746 | ||
| ... | @@ -1777,86 +1805,157 @@ code { | ... | @@ -1777,86 +1805,157 @@ code { |
| 1777 | margin-bottom: 0; | 1805 | margin-bottom: 0; |
| 1778 | } | 1806 | } |
| 1779 | 1807 | ||
| 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 | |||
| 1780 | .chat-markdown p, | 1888 | .chat-markdown p, |
| 1781 | .chat-markdown ul, | 1889 | .chat-markdown ul, |
| 1782 | .chat-markdown ol, | 1890 | .chat-markdown ol, |
| 1783 | .chat-markdown blockquote, | 1891 | .chat-markdown blockquote, |
| 1784 | .chat-markdown pre, | 1892 | .chat-markdown pre, |
| 1785 | .chat-markdown .chat-markdown-table-container, | 1893 | .chat-markdown .chat-markdown-table-container, |
| 1786 | .composer-tiptap ul, | 1894 | .chat-markdown h1, |
| 1787 | .composer-tiptap ol { | 1895 | .chat-markdown h2, |
| 1788 | margin: 0.65rem 0; | 1896 | .chat-markdown h3, |
| 1897 | .chat-markdown h4, | ||
| 1898 | .chat-markdown h5, | ||
| 1899 | .chat-markdown h6 { | ||
| 1900 | margin: 1.25rem 0 0.5rem; | ||
| 1789 | } | 1901 | } |
| 1790 | 1902 | ||
| 1791 | :is(.chat-markdown, .composer-tiptap) h1, | 1903 | .chat-markdown h1, |
| 1792 | :is(.chat-markdown, .composer-tiptap) h2, | 1904 | .chat-markdown h2, |
| 1793 | :is(.chat-markdown, .composer-tiptap) h3, | 1905 | .chat-markdown h3, |
| 1794 | :is(.chat-markdown, .composer-tiptap) h4, | 1906 | .chat-markdown h4, |
| 1795 | :is(.chat-markdown, .composer-tiptap) h5, | 1907 | .chat-markdown h5, |
| 1796 | :is(.chat-markdown, .composer-tiptap) h6 { | 1908 | .chat-markdown h6 { |
| 1797 | margin: 1.25rem 0 0.5rem; | ||
| 1798 | font-weight: 600; | 1909 | font-weight: 600; |
| 1799 | line-height: 1.3; | 1910 | line-height: 1.3; |
| 1800 | color: var(--contrast-foreground); | 1911 | color: var(--contrast-foreground); |
| 1801 | } | 1912 | } |
| 1802 | 1913 | ||
| 1803 | :is(.chat-markdown, .composer-tiptap) h1 { | 1914 | .chat-markdown h1 { |
| 1804 | font-size: 1.25rem; | 1915 | font-size: 1.25rem; |
| 1805 | } | 1916 | } |
| 1806 | 1917 | ||
| 1807 | :is(.chat-markdown, .composer-tiptap) h2 { | 1918 | .chat-markdown h2 { |
| 1808 | font-size: 1.125rem; | 1919 | font-size: 1.125rem; |
| 1809 | } | 1920 | } |
| 1810 | 1921 | ||
| 1811 | :is(.chat-markdown, .composer-tiptap) h3 { | 1922 | .chat-markdown h3 { |
| 1812 | font-size: 1rem; | 1923 | font-size: 1rem; |
| 1813 | } | 1924 | } |
| 1814 | 1925 | ||
| 1815 | :is(.chat-markdown, .composer-tiptap) h4, | 1926 | .chat-markdown h4, |
| 1816 | :is(.chat-markdown, .composer-tiptap) h5, | 1927 | .chat-markdown h5, |
| 1817 | :is(.chat-markdown, .composer-tiptap) h6 { | 1928 | .chat-markdown h6 { |
| 1818 | font-size: 0.875rem; | 1929 | font-size: 0.875rem; |
| 1819 | } | 1930 | } |
| 1820 | 1931 | ||
| 1821 | :is(.chat-markdown, .composer-tiptap) h6 { | 1932 | .chat-markdown h6 { |
| 1822 | color: var(--contrast-muted-foreground); | 1933 | color: var(--contrast-muted-foreground); |
| 1823 | } | 1934 | } |
| 1824 | 1935 | ||
| 1825 | :is(.chat-markdown, .composer-tiptap) ul { | 1936 | .chat-markdown :is(ul, ol) { |
| 1826 | /* Reset for nested uls under a widened ol. --list-gutter is an inherited | 1937 | /* Nested lists must not inherit a wider ordered marker's gutter. */ |
| 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. */ | ||
| 1829 | --list-gutter: 1.25rem; | 1938 | --list-gutter: 1.25rem; |
| 1830 | padding-left: 1.25rem; | 1939 | padding-inline-start: var(--list-gutter); |
| 1940 | } | ||
| 1941 | |||
| 1942 | .chat-markdown ul { | ||
| 1831 | list-style-type: disc; | 1943 | list-style-type: disc; |
| 1832 | } | 1944 | } |
| 1833 | 1945 | ||
| 1834 | /* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but | 1946 | .chat-markdown ol { |
| 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); | ||
| 1842 | list-style-type: decimal; | 1947 | list-style-type: decimal; |
| 1843 | } | 1948 | } |
| 1844 | 1949 | ||
| 1845 | :is(.chat-markdown, .composer-tiptap) ol > li::marker { | 1950 | .chat-markdown ol > li::marker { |
| 1846 | font-variant-numeric: tabular-nums; | 1951 | font-variant-numeric: tabular-nums; |
| 1847 | } | 1952 | } |
| 1848 | 1953 | ||
| 1849 | /* The composer shows the marker the source has, `3)` or a nested `7.`, not the | 1954 | .chat-markdown ul ul { |
| 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 { | ||
| 1856 | list-style-type: circle; | 1955 | list-style-type: circle; |
| 1857 | } | 1956 | } |
| 1858 | 1957 | ||
| 1859 | :is(.chat-markdown, .composer-tiptap) ul ul ul { | 1958 | .chat-markdown ul ul ul { |
| 1860 | list-style-type: square; | 1959 | list-style-type: square; |
| 1861 | } | 1960 | } |
| 1862 | 1961 | ||
| ... | @@ -1872,16 +1971,48 @@ code { | ... | @@ -1872,16 +1971,48 @@ code { |
| 1872 | margin-top: 0.25rem; | 1971 | margin-top: 0.25rem; |
| 1873 | } | 1972 | } |
| 1874 | 1973 | ||
| 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 | |||
| 1875 | /* GitHub-style task lists: the checkbox replaces the list marker. */ | 1986 | /* GitHub-style task lists: the checkbox replaces the list marker. */ |
| 1876 | .chat-markdown li.task-list-item { | 1987 | .chat-markdown li.task-list-item { |
| 1877 | list-style-type: none; | 1988 | list-style-type: none; |
| 1878 | } | 1989 | } |
| 1879 | 1990 | ||
| 1880 | .chat-markdown li.task-list-item input[type="checkbox"] { | 1991 | .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; | ||
| 1882 | vertical-align: middle; | 1994 | vertical-align: middle; |
| 1883 | } | 1995 | } |
| 1884 | 1996 | ||
| 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 | |||
| 1885 | /* Markdown assumes inline images — GitHub comments lay button images out in a row — and the | 2016 | /* Markdown assumes inline images — GitHub comments lay button images out in a row — and the |
| 1886 | Tailwind preflight's block-level img would stack them one per line. */ | 2017 | Tailwind preflight's block-level img would stack them one per line. */ |
| 1887 | .chat-markdown img { | 2018 | .chat-markdown img { |
| ... | @@ -1905,30 +2036,12 @@ code { | ... | @@ -1905,30 +2036,12 @@ code { |
| 1905 | background-size: 4px 2px; | 2036 | background-size: 4px 2px; |
| 1906 | } | 2037 | } |
| 1907 | 2038 | ||
| 1908 | :is(.chat-markdown, .composer-tiptap) blockquote { | 2039 | .chat-markdown blockquote { |
| 1909 | border-left: 2px solid var(--contrast-border); | 2040 | border-left: 2px solid var(--contrast-border); |
| 1910 | padding-left: 0.8rem; | 2041 | padding-left: 0.8rem; |
| 1911 | color: var(--contrast-muted-foreground); | 2042 | color: var(--contrast-muted-foreground); |
| 1912 | } | 2043 | } |
| 1913 | 2044 | ||
| 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 | |||
| 1932 | .chat-markdown section[data-footnotes] { | 2045 | .chat-markdown section[data-footnotes] { |
| 1933 | margin-top: 1.25rem; | 2046 | margin-top: 1.25rem; |
| 1934 | border-top: 1px solid var(--contrast-border); | 2047 | border-top: 1px solid var(--contrast-border); |
| ... | @@ -1945,6 +2058,11 @@ code { | ... | @@ -1945,6 +2058,11 @@ code { |
| 1945 | margin-top: 0.35rem; | 2058 | margin-top: 0.35rem; |
| 1946 | } | 2059 | } |
| 1947 | 2060 | ||
| 2061 | .chat-markdown a[data-footnote-ref], | ||
| 2062 | .chat-markdown section[data-footnotes] li[id] { | ||
| 2063 | scroll-margin-block-start: 1rlh; | ||
| 2064 | } | ||
| 2065 | |||
| 1948 | .chat-markdown a[data-footnote-ref], | 2066 | .chat-markdown a[data-footnote-ref], |
| 1949 | .chat-markdown a[data-footnote-backref] { | 2067 | .chat-markdown a[data-footnote-backref] { |
| 1950 | display: inline-flex; | 2068 | display: inline-flex; |
| ... | @@ -1964,10 +2082,19 @@ code { | ... | @@ -1964,10 +2082,19 @@ code { |
| 1964 | -webkit-box-decoration-break: clone; | 2082 | -webkit-box-decoration-break: clone; |
| 1965 | background: var(--muted); | 2083 | background: var(--muted); |
| 1966 | padding: 0.1rem 0.35rem; | 2084 | padding: 0.1rem 0.35rem; |
| 1967 | color: var(--contrast-foreground); | 2085 | color: inherit; |
| 1968 | font-size: 0.75rem; | 2086 | font-size: 0.75rem; |
| 1969 | } | 2087 | } |
| 1970 | 2088 | ||
| 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 | |||
| 1971 | .chat-markdown pre { | 2098 | .chat-markdown pre { |
| 1972 | max-width: 100%; | 2099 | max-width: 100%; |
| 1973 | overflow-x: auto; | 2100 | overflow-x: auto; |
| ... | @@ -1975,6 +2102,8 @@ code { | ... | @@ -1975,6 +2102,8 @@ code { |
| 1975 | border-radius: 0.75rem; | 2102 | border-radius: 0.75rem; |
| 1976 | background: var(--muted); | 2103 | background: var(--muted); |
| 1977 | padding: 0.8rem 0.9rem; | 2104 | 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; | ||
| 1978 | scrollbar-width: thin; | 2107 | scrollbar-width: thin; |
| 1979 | scrollbar-color: color-mix(in srgb, var(--contrast-border) 78%, transparent) transparent; | 2108 | scrollbar-color: color-mix(in srgb, var(--contrast-border) 78%, transparent) transparent; |
| 1980 | } | 2109 | } |
| ... | @@ -1994,23 +2123,23 @@ code { | ... | @@ -1994,23 +2123,23 @@ code { |
| 1994 | background: color-mix(in srgb, var(--contrast-border) 78%, transparent); | 2123 | background: color-mix(in srgb, var(--contrast-border) 78%, transparent); |
| 1995 | } | 2124 | } |
| 1996 | 2125 | ||
| 1997 | :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { | 2126 | .chat-markdown .chat-markdown-codeblock-header { |
| 1998 | color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent); | 2127 | color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent); |
| 1999 | } | 2128 | } |
| 2000 | 2129 | ||
| 2001 | :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { | 2130 | .chat-markdown .chat-markdown-codeblock pre { |
| 2002 | margin: 0; | 2131 | margin: 0; |
| 2003 | border: none; | 2132 | border: none; |
| 2004 | border-radius: 0; | 2133 | border-radius: 0; |
| 2005 | background: transparent; | 2134 | background: transparent; |
| 2006 | } | 2135 | } |
| 2007 | 2136 | ||
| 2008 | :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock[data-wrap="true"] pre { | 2137 | .chat-markdown .chat-markdown-codeblock[data-wrap="true"] pre { |
| 2009 | white-space: pre-wrap; | 2138 | white-space: pre-wrap; |
| 2010 | overflow-wrap: anywhere; | 2139 | overflow-wrap: anywhere; |
| 2011 | } | 2140 | } |
| 2012 | 2141 | ||
| 2013 | :is(.chat-markdown, .composer-tiptap) .chat-markdown-shiki .shiki { | 2142 | .chat-markdown .chat-markdown-shiki .shiki { |
| 2014 | background: transparent !important; | 2143 | background: transparent !important; |
| 2015 | } | 2144 | } |
| 2016 | 2145 | ||
| ... | @@ -2228,108 +2357,6 @@ code { | ... | @@ -2228,108 +2357,6 @@ code { |
| 2228 | } | 2357 | } |
| 2229 | } | 2358 | } |
| 2230 | 2359 | ||
| 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 | |||
| 2333 | @layer components { | 2360 | @layer components { |
| 2334 | .chat-composer-lane { | 2361 | .chat-composer-lane { |
| 2335 | padding-inline-start: calc( | 2362 | padding-inline-start: calc( |
apps/web/src/lib/syntaxHighlighting.test.ts+34-2| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import type { DiffsHighlighter } from "@pierre/diffs"; | ||
| 2 | import { expect, it, vi } from "vite-plus/test"; | 1 | import { expect, it, vi } from "vite-plus/test"; |
| 3 | 2 | ||
| 4 | const { getSharedHighlighter } = vi.hoisted(() => ({ | 3 | const { getSharedHighlighter } = vi.hoisted(() => ({ |
| ... | @@ -12,7 +11,12 @@ vi.mock("@pierre/diffs", () => ({ | ... | @@ -12,7 +11,12 @@ vi.mock("@pierre/diffs", () => ({ |
| 12 | import { getSyntaxHighlighterPromise } from "./syntaxHighlighting"; | 11 | import { getSyntaxHighlighterPromise } from "./syntaxHighlighting"; |
| 13 | 12 | ||
| 14 | it("caches the recovered text highlighter for unsupported languages", async () => { | 13 | it("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 | }); | ||
| 16 | getSharedHighlighter.mockImplementation(({ langs }: { langs: string[] }) => | 20 | getSharedHighlighter.mockImplementation(({ langs }: { langs: string[] }) => |
| 17 | langs[0] === "text" | 21 | langs[0] === "text" |
| 18 | ? Promise.resolve(textHighlighter) | 22 | ? Promise.resolve(textHighlighter) |
| ... | @@ -26,3 +30,31 @@ it("caches the recovered text highlighter for unsupported languages", async () = | ... | @@ -26,3 +30,31 @@ it("caches the recovered text highlighter for unsupported languages", async () = |
| 26 | expect(second).toBe(first); | 30 | expect(second).toBe(first); |
| 27 | expect(getSharedHighlighter).toHaveBeenCalledTimes(2); | 31 | expect(getSharedHighlighter).toHaveBeenCalledTimes(2); |
| 28 | }); | 32 | }); |
| 33 | |||
| 34 | it("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 | ... | @@ -25,15 +25,50 @@ export function getSyntaxHighlighterPromise(language: string): Promise<DiffsHigh |
| 25 | themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], | 25 | themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], |
| 26 | langs: [language as SupportedLanguages], | 26 | langs: [language as SupportedLanguages], |
| 27 | preferredHighlighter: PREFERRED_HIGHLIGHTER, | 27 | preferredHighlighter: PREFERRED_HIGHLIGHTER, |
| 28 | }).catch((error) => { | 28 | }) |
| 29 | if (language === "text") { | 29 | .then(async (highlighter) => { |
| 30 | highlighterPromiseCache.delete(language); | 30 | for (const name of [resolveDiffThemeName("dark"), resolveDiffThemeName("light")]) { |
| 31 | // "text" itself failed — Shiki cannot initialize at all, surface the error | 31 | const theme = highlighter.getTheme(name); |
| 32 | throw error; | 32 | const neutralColors = Object.fromEntries( |
| 33 | } | 33 | theme.settings.flatMap(({ scope, settings }) => { |
| 34 | // Language not supported by Shiki — fall back to "text" | 34 | const scopes = new Set([scope].flat()); |
| 35 | return getSyntaxHighlighterPromise("text"); | 35 | if (!settings.foreground) return []; |
| 36 | }); | 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 | }); | ||
| 37 | highlighterPromiseCache.set(language, promise); | 72 | highlighterPromiseCache.set(language, promise); |
| 38 | return promise; | 73 | return promise; |
| 39 | } | 74 | } |
apps/web/src/routes/__root.tsx+8-5| ... | @@ -17,6 +17,7 @@ import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | ... | @@ -17,6 +17,7 @@ import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 17 | import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; | 17 | import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 18 | import { resolveServerBackedAppDisplayName } from "../branding.logic"; | 18 | import { resolveServerBackedAppDisplayName } from "../branding.logic"; |
| 19 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; | 19 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 20 | import { MediaPlaybackProvider } from "../components/media/MediaPlaybackHost"; | ||
| 20 | import { CommandPalette } from "../components/CommandPalette"; | 21 | import { CommandPalette } from "../components/CommandPalette"; |
| 21 | import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; | 22 | import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; |
| 22 | import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; | 23 | import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; |
| ... | @@ -219,11 +220,13 @@ function RootRouteView() { | ... | @@ -219,11 +220,13 @@ function RootRouteView() { |
| 219 | } | 220 | } |
| 220 | 221 | ||
| 221 | const appShell = ( | 222 | const appShell = ( |
| 222 | <CommandPalette> | 223 | <MediaPlaybackProvider> |
| 223 | <AppSidebarLayout> | 224 | <CommandPalette> |
| 224 | <Outlet /> | 225 | <AppSidebarLayout> |
| 225 | </AppSidebarLayout> | 226 | <Outlet /> |
| 226 | </CommandPalette> | 227 | </AppSidebarLayout> |
| 228 | </CommandPalette> | ||
| 229 | </MediaPlaybackProvider> | ||
| 227 | ); | 230 | ); |
| 228 | 231 | ||
| 229 | // FirstRunGate holds back everything below it — including EventRouter, | 232 | // FirstRunGate holds back everything below it — including EventRouter, |
packages/client-runtime/src/mediaActions.ts+3-1| ... | @@ -5,4 +5,6 @@ export type MediaActionId = | ... | @@ -5,4 +5,6 @@ export type MediaActionId = |
| 5 | | "copy-url" | 5 | | "copy-url" |
| 6 | | "open-file" | 6 | | "open-file" |
| 7 | | "save" | 7 | | "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 { | ... | @@ -1226,6 +1226,7 @@ export interface DesktopBridge { |
| 1226 | receiveProviderAuthCallback?: (authorizationUrl: string) => Promise<string>; | 1226 | receiveProviderAuthCallback?: (authorizationUrl: string) => Promise<string>; |
| 1227 | cancelProviderAuthCallback?: (authorizationUrl: string) => Promise<void>; | 1227 | cancelProviderAuthCallback?: (authorizationUrl: string) => Promise<void>; |
| 1228 | openExternal: (url: string) => Promise<boolean>; | 1228 | openExternal: (url: string) => Promise<boolean>; |
| 1229 | openVideo?: (input: { readonly url: string; readonly name: string }) => Promise<void>; | ||
| 1229 | /** | 1230 | /** |
| 1230 | * Open a System Settings pane by identifier. Optional: older desktop builds | 1231 | * Open a System Settings pane by identifier. Optional: older desktop builds |
| 1231 | * lack it, and callers no-op when it is missing. | 1232 | * lack it, and callers no-op when it is missing. |
packages/markdown-editor/LICENSE created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | ISC License | ||
| 2 | |||
| 3 | Copyright (c) 2026 paperclover | ||
| 4 | |||
| 5 | Permission to use, copy, modify, and/or distribute this software for any | ||
| 6 | purpose with or without fee is hereby granted, provided that the above | ||
| 7 | copyright notice and this permission notice appear in all copies. | ||
| 8 | |||
| 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES | ||
| 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF | ||
| 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR | ||
| 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES | ||
| 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN | ||
| 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF | ||
| 15 | OR 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). | ||
| 4 | import type { EditorState, Extension, Range, Transaction } from "@codemirror/state"; | ||
| 5 | import { StateField } from "@codemirror/state"; | ||
| 6 | import { Decoration, type DecorationSet, EditorView } from "@codemirror/view"; | ||
| 7 | |||
| 8 | export 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 | |||
| 19 | export 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. | ||
| 3 | import { syntaxTree } from "@codemirror/language"; | ||
| 4 | import { EditorSelection, type EditorState, type StateCommand } from "@codemirror/state"; | ||
| 5 | import 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 | */ | ||
| 12 | export 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. */ | ||
| 41 | export 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. */ | ||
| 47 | export 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. */ | ||
| 85 | export 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 | */ | ||
| 100 | export 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. | ||
| 134 | const LIST_LINE = /^(\s*)(?:[-*+]|\d{1,9}[.)])\s/; | ||
| 135 | const INDENT = " "; | ||
| 136 | |||
| 137 | function 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. */ | ||
| 150 | export 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. */ | ||
| 164 | export 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 | |||
| 177 | const LIST_MARKER = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(\[[ xX]\]\s)?/; | ||
| 178 | const 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 | */ | ||
| 186 | export 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 | |||
| 245 | export 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. */ | ||
| 253 | export 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. | ||
| 5 | import { history } from "@codemirror/commands"; | ||
| 6 | import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; | ||
| 7 | import type { Language, LanguageDescription } from "@codemirror/language"; | ||
| 8 | import { EditorState, type Extension, Prec } from "@codemirror/state"; | ||
| 9 | import { EditorView, keymap, placeholder as placeholderExt } from "@codemirror/view"; | ||
| 10 | |||
| 11 | import { codeBlockEnter, continueList, insertNewline } from "./commands.ts"; | ||
| 12 | import { baseKeymap, markdownKeymap } from "./keymap.ts"; | ||
| 13 | import { livePreview } from "./live-preview.ts"; | ||
| 14 | import { | ||
| 15 | createMentionRegistry, | ||
| 16 | hydrateMentions, | ||
| 17 | insertMention as insertMentionChip, | ||
| 18 | type MentionCandidate, | ||
| 19 | mentionsExtension, | ||
| 20 | mentionTriggerAt, | ||
| 21 | serializeMentions, | ||
| 22 | } from "./mentions.ts"; | ||
| 23 | import { | ||
| 24 | type EmojiTrigger, | ||
| 25 | emojiTriggerAt, | ||
| 26 | type MentionTrigger, | ||
| 27 | type SlashTrigger, | ||
| 28 | slashTriggerAt, | ||
| 29 | } from "./triggers.ts"; | ||
| 30 | |||
| 31 | export 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 | |||
| 48 | const LARGE_PASTE_THRESHOLD = 10_000; | ||
| 49 | |||
| 50 | /** The image files carried by a paste or drop, if any. */ | ||
| 51 | function imageFiles(data: DataTransfer | null): File[] { | ||
| 52 | if (!data) return []; | ||
| 53 | return [...data.files].filter((file) => file.type.startsWith("image/")); | ||
| 54 | } | ||
| 55 | |||
| 56 | const 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 | |||
| 71 | const 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 | |||
| 85 | export 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 | */ | ||
| 130 | export 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. */ | ||
| 2 | export 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. | ||
| 10 | const 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 | */ | ||
| 198 | export 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. | ||
| 18 | import { | ||
| 19 | EditorState, | ||
| 20 | type Extension, | ||
| 21 | type Range, | ||
| 22 | StateField, | ||
| 23 | Transaction, | ||
| 24 | } from "@codemirror/state"; | ||
| 25 | import { 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. */ | ||
| 28 | export const DEFAULT_WRAP_COLUMN = 80; | ||
| 29 | |||
| 30 | export 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. */ | ||
| 36 | export 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). */ | ||
| 42 | export 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. */ | ||
| 59 | export interface TextChange { | ||
| 60 | from: number; | ||
| 61 | to: number; | ||
| 62 | insert: string; | ||
| 63 | } | ||
| 64 | |||
| 65 | // --------------------------------------------------------------------------- | ||
| 66 | // Line scanning | ||
| 67 | // --------------------------------------------------------------------------- | ||
| 68 | |||
| 69 | interface 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 | |||
| 89 | const PREFIX = /^([ \t]*)((?:>[ \t]?)*)([ \t]*)/; | ||
| 90 | const ATX = /^#{1,6}(?:[ \t]|$)/; | ||
| 91 | const FENCE = /^(?:`{3,}|~{3,})/; | ||
| 92 | const THEMATIC = /^(?:\*[ \t]*){3,}$|^(?:-[ \t]*){3,}$|^(?:_[ \t]*){3,}$/; | ||
| 93 | const SETEXT = /^(?:=+|-+)[ \t]*$/; | ||
| 94 | const LIST = /^(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/; | ||
| 95 | const REF_DEF = /^\[[^\]]*\]:/; | ||
| 96 | const HTML_START = /^<[!/A-Za-z]/; | ||
| 97 | const TRAILING_BACKSLASHES = /\\*$/; | ||
| 98 | |||
| 99 | function 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. */ | ||
| 106 | function 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 | |||
| 132 | function 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 | */ | ||
| 156 | export 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. */ | ||
| 171 | function 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 | */ | ||
| 191 | function 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 | |||
| 258 | function contentStart(line: SourceLine, extra = 0): number { | ||
| 259 | return line.start + line.prefixLen + extra; | ||
| 260 | } | ||
| 261 | |||
| 262 | function 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. */ | ||
| 292 | export function scanBlocks(text: string): WrapBlock[] { | ||
| 293 | return scanBlocksFromLines(splitSourceLines(text)); | ||
| 294 | } | ||
| 295 | |||
| 296 | /** Every hidden break in `text`, in order. */ | ||
| 297 | export 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. */ | ||
| 306 | export 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. */ | ||
| 317 | export 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. */ | ||
| 327 | export 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 | |||
| 341 | interface 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 | |||
| 349 | interface 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 | */ | ||
| 360 | export 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 | */ | ||
| 408 | function 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 | |||
| 439 | function 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 | */ | ||
| 482 | export 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. */ | ||
| 495 | export 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 | */ | ||
| 504 | export 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 | |||
| 520 | export 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. */ | ||
| 532 | class 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 | |||
| 549 | const joinWidget = new JoinWidget(); | ||
| 550 | |||
| 551 | /** The replace decorations that hide a document's paragraph-internal line breaks. */ | ||
| 552 | export 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 | |||
| 562 | const 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 | */ | ||
| 569 | export 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 | |||
| 587 | function 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 | |||
| 15 | export interface HtmlOptions { | ||
| 16 | /** Maps relative image/source URLs to something fetchable; identity when omitted. */ | ||
| 17 | resolveUrl?: (url: string) => string; | ||
| 18 | } | ||
| 19 | |||
| 20 | const 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. */ | ||
| 75 | const 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 | |||
| 107 | const GLOBAL_ATTRS = new Set(["align", "title", "width", "height", "dir", "lang"]); | ||
| 108 | |||
| 109 | const 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 | |||
| 122 | const SCHEME = /^[a-z][a-z0-9+.-]*:/i; | ||
| 123 | |||
| 124 | function 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 | |||
| 131 | function 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. */ | ||
| 141 | function 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 | |||
| 155 | function 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 | |||
| 191 | function 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 | */ | ||
| 217 | export 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 | |||
| 227 | const 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. */ | ||
| 244 | const WRAPPER_TAGS = new Set(["div", "p", "center", "section", "header"]); | ||
| 245 | |||
| 246 | const TOKEN = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g; | ||
| 247 | |||
| 248 | export type HtmlTokenType = "comment" | "bracket" | "tag" | "attr" | "value"; | ||
| 249 | |||
| 250 | export interface HtmlToken { | ||
| 251 | type: HtmlTokenType; | ||
| 252 | from: number; | ||
| 253 | to: number; | ||
| 254 | } | ||
| 255 | |||
| 256 | const 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 | */ | ||
| 262 | export 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 | |||
| 293 | export type HtmlBlockShape = | ||
| 294 | | { kind: "comment" } | ||
| 295 | | { kind: "balanced" } | ||
| 296 | | { kind: "open"; tag: string; center: boolean } | ||
| 297 | | { kind: "close"; tag: string } | ||
| 298 | | { kind: "other" }; | ||
| 299 | |||
| 300 | const 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 | */ | ||
| 307 | export 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. | ||
| 3 | export { chipField, type ChipSpec } from "./chips.ts"; | ||
| 4 | export { | ||
| 5 | autoCloseFence, | ||
| 6 | codeBlockEnter, | ||
| 7 | continueList, | ||
| 8 | cycleEmphasis, | ||
| 9 | dedentListItem, | ||
| 10 | enclosingCodeBlock, | ||
| 11 | indentListItem, | ||
| 12 | insertNewline, | ||
| 13 | surroundSelection, | ||
| 14 | toggleInlineMark, | ||
| 15 | } from "./commands.ts"; | ||
| 16 | export { | ||
| 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"; | ||
| 35 | export { classifyHtmlBlock, sanitizeHtml } from "./html.ts"; | ||
| 36 | export type { HtmlBlockShape, HtmlOptions } from "./html.ts"; | ||
| 37 | export { baseKeymap, markdownKeymap } from "./keymap.ts"; | ||
| 38 | export { createMarkdownEditor, type MarkdownEditor, type MarkdownEditorOptions } from "./editor.ts"; | ||
| 39 | export { type Emoji, searchEmoji } from "./emoji.ts"; | ||
| 40 | export { blockDecorations, formattingDecorations, livePreview } from "./live-preview.ts"; | ||
| 41 | export type { LivePreviewOptions } from "./live-preview.ts"; | ||
| 42 | export type { VideoPreviewInput, VideoPreviewRenderer } from "./widgets.ts"; | ||
| 43 | export { | ||
| 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"; | ||
| 55 | export { | ||
| 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. | ||
| 4 | import { defaultKeymap, historyKeymap } from "@codemirror/commands"; | ||
| 5 | import { type Extension, Prec } from "@codemirror/state"; | ||
| 6 | import { keymap } from "@codemirror/view"; | ||
| 7 | |||
| 8 | import { | ||
| 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. */ | ||
| 19 | export 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 | */ | ||
| 37 | export 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. | ||
| 8 | import { defaultHighlightStyle, syntaxHighlighting, syntaxTree } from "@codemirror/language"; | ||
| 9 | import type { Extension, Range } from "@codemirror/state"; | ||
| 10 | import { EditorSelection, EditorState, Prec, StateEffect, StateField } from "@codemirror/state"; | ||
| 11 | import { | ||
| 12 | Decoration, | ||
| 13 | type DecorationSet, | ||
| 14 | EditorView, | ||
| 15 | keymap, | ||
| 16 | ViewPlugin, | ||
| 17 | type ViewUpdate, | ||
| 18 | } from "@codemirror/view"; | ||
| 19 | import type { SyntaxNode, Tree } from "@lezer/common"; | ||
| 20 | |||
| 21 | import { classifyHtmlBlock, htmlTokens } from "./html.ts"; | ||
| 22 | import { | ||
| 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 | |||
| 38 | export 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. | ||
| 63 | const setPreviewFocus = StateEffect.define<boolean>(); | ||
| 64 | |||
| 65 | const 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 | |||
| 73 | function 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. */ | ||
| 78 | function 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. */ | ||
| 84 | function lineRevealed(state: EditorState, pos: number): boolean { | ||
| 85 | const line = state.doc.lineAt(pos); | ||
| 86 | return revealed(state, line.from, line.to); | ||
| 87 | } | ||
| 88 | |||
| 89 | function 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 | |||
| 113 | const 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 | |||
| 120 | const 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 | |||
| 129 | function 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 | |||
| 151 | const CALLOUT_MARKER = /^\[!(\w+)\]\s?/; | ||
| 152 | |||
| 153 | /** The `[!type] ` marker right after this blockquote's first quote mark, if it is a callout. */ | ||
| 154 | function 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 | |||
| 169 | function 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 | |||
| 197 | function 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 | |||
| 210 | function 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 | |||
| 237 | function 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. */ | ||
| 257 | function 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 | |||
| 278 | function 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 | /** `` → inline media; falls back to link-style marks when revealed. */ | ||
| 298 | function 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 | |||
| 330 | function 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. */ | ||
| 355 | function 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 | |||
| 379 | function 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 | |||
| 402 | interface Span { | ||
| 403 | from: number; | ||
| 404 | to: number; | ||
| 405 | } | ||
| 406 | |||
| 407 | type HtmlBlockMode = "render" | "absorb" | "collapse" | "source"; | ||
| 408 | |||
| 409 | interface 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 | */ | ||
| 423 | function 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 | |||
| 467 | function 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. */ | ||
| 472 | const 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 | |||
| 495 | const TAG_HEAD = /^<(\/?)([a-zA-Z][\w-]*)/; | ||
| 496 | |||
| 497 | interface 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 | |||
| 507 | function 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. */ | ||
| 513 | function 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 | |||
| 523 | function 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 | |||
| 616 | const 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. */ | ||
| 625 | function 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 | |||
| 642 | function 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 | |||
| 655 | function 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 | */ | ||
| 665 | function 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 | */ | ||
| 756 | function 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. */ | ||
| 874 | export 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. */ | ||
| 884 | export 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 | |||
| 895 | const MONO = "ui-monospace, SFMono-Regular, Menlo, monospace"; | ||
| 896 | |||
| 897 | const 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 | */ | ||
| 1130 | function 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 | */ | ||
| 1153 | const INLINE_WHOLE_DOC_LIMIT = 32_000; | ||
| 1154 | |||
| 1155 | /** The live-preview layer: block field + viewport-scoped inline plugin plus class styles. */ | ||
| 1156 | export 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. | ||
| 6 | import type { Extension } from "@codemirror/state"; | ||
| 7 | import { Decoration, EditorView, WidgetType } from "@codemirror/view"; | ||
| 8 | |||
| 9 | import { chipField } from "./chips.ts"; | ||
| 10 | |||
| 11 | // Kept as two statements: kiln's type-stripping rejects inline `type` specifiers in re-exports. | ||
| 12 | export { mentionTriggerAt } from "./triggers.ts"; | ||
| 13 | export type { MentionTrigger } from "./triggers.ts"; | ||
| 14 | |||
| 15 | /** A mention target surfaced by a provider: a workspace file or a code symbol. */ | ||
| 16 | export 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 | */ | ||
| 35 | export 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. */ | ||
| 40 | export 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 | |||
| 52 | const 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 | */ | ||
| 59 | export interface MentionRegistry { | ||
| 60 | add(mention: MentionCandidate): number; | ||
| 61 | get(id: number): MentionCandidate | undefined; | ||
| 62 | } | ||
| 63 | |||
| 64 | export 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 | |||
| 77 | const 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>'; | ||
| 79 | const 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)). | ||
| 84 | class 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. */ | ||
| 127 | export 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 | */ | ||
| 150 | const 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 | |||
| 169 | export 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. */ | ||
| 185 | export 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. */ | ||
| 209 | export 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 | |||
| 216 | const SAVED_MENTION = /(^|\s)@([^\s@]+)/g; | ||
| 217 | const 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; | ||
| 219 | const RANGE_SUFFIX = /:(\d+)(?:-(\d+)|:(\d+))?$/; | ||
| 220 | |||
| 221 | /** Restore canonical draft `@path` tokens to atomic chips. */ | ||
| 222 | export 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. | ||
| 5 | import { EditorState, Compartment, type Extension } from "@codemirror/state"; | ||
| 6 | import { EditorView } from "@codemirror/view"; | ||
| 7 | import { type Ref, useEffect, useImperativeHandle, useLayoutEffect, useRef } from "react"; | ||
| 8 | |||
| 9 | import { createMarkdownEditor, type MarkdownEditor, type MarkdownEditorOptions } from "./editor.ts"; | ||
| 10 | |||
| 11 | export 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 | |||
| 25 | const EMPTY_EXTENSIONS: Extension[] = []; | ||
| 26 | |||
| 27 | /** A generic CodeMirror mount: one view per component instance, extensions hot-swappable. */ | ||
| 28 | export 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 | |||
| 85 | export 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 | */ | ||
| 96 | export 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. | ||
| 4 | import type { EditorState } from "@codemirror/state"; | ||
| 5 | |||
| 6 | /** Doc offsets of the trigger run and the query typed so far; the range an acceptance replaces. */ | ||
| 7 | export interface Trigger { | ||
| 8 | from: number; | ||
| 9 | to: number; | ||
| 10 | query: string; | ||
| 11 | } | ||
| 12 | |||
| 13 | export type MentionTrigger = Trigger; | ||
| 14 | export type SlashTrigger = Trigger; | ||
| 15 | export 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. | ||
| 19 | const MENTION_TRIGGER = /(?<=^|\s)@([^\s@]*)$/; | ||
| 20 | |||
| 21 | export 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. */ | ||
| 32 | export 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. | ||
| 44 | const EMOJI_TRIGGER = /(?:^|\s):([a-z0-9_+-]{2,})$/i; | ||
| 45 | |||
| 46 | export 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. | ||
| 4 | import type { EditorView } from "@codemirror/view"; | ||
| 5 | import { WidgetType } from "@codemirror/view"; | ||
| 6 | |||
| 7 | import { 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. */ | ||
| 13 | export 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. */ | ||
| 27 | export type TaskState = "unchecked" | "checked" | "indeterminate"; | ||
| 28 | |||
| 29 | export 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 | |||
| 75 | export 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. */ | ||
| 84 | export 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 (``), shown when the caret is elsewhere. */ | ||
| 123 | export 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 | |||
| 154 | function 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 | |||
| 168 | export interface VideoPreviewInput { | ||
| 169 | readonly source: string; | ||
| 170 | readonly url: string; | ||
| 171 | readonly alt: string; | ||
| 172 | } | ||
| 173 | |||
| 174 | export 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 | |||
| 183 | const 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. */ | ||
| 193 | export 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. */ | ||
| 283 | export 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. */ | ||
| 300 | export 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>`. */ | ||
| 318 | export 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 | */ | ||
| 336 | export 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 | |||
| 378 | export 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. */ | ||
| 386 | export 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 @@ | ||
| 1 | import { beforeEach } from "vite-plus/test"; | ||
| 2 | |||
| 3 | beforeEach(() => { | ||
| 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 | |||
| 3 | import "./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. | ||
| 7 | import { EditorSelection } from "@codemirror/state"; | ||
| 8 | import { syntaxTree } from "@codemirror/language"; | ||
| 9 | import { EditorView } from "@codemirror/view"; | ||
| 10 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 11 | |||
| 12 | import { createMarkdownEditor, type MarkdownEditorOptions } from "../src/editor.ts"; | ||
| 13 | |||
| 14 | let destroy: (() => void) | undefined; | ||
| 15 | afterEach(() => { | ||
| 16 | destroy?.(); | ||
| 17 | destroy = undefined; | ||
| 18 | }); | ||
| 19 | |||
| 20 | function 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 | |||
| 30 | function 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 | |||
| 36 | describe("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 | |||
| 77 | describe("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 | |||
| 128 | describe.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 | |||
| 207 | describe("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 | |||
| 229 | describe("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 | |||
| 246 | describe("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 | |||
| 3 | import "./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. | ||
| 7 | import { EditorSelection } from "@codemirror/state"; | ||
| 8 | import { afterEach, describe, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { createMarkdownEditor, type MarkdownEditor } from "../src/editor.ts"; | ||
| 11 | import { sanitizeHtml } from "../src/html.ts"; | ||
| 12 | |||
| 13 | const IMG = "data:image/svg+xml;base64,PHN2Zy8+"; | ||
| 14 | |||
| 15 | describe("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 | |||
| 70 | let destroy: (() => void) | undefined; | ||
| 71 | afterEach(() => { | ||
| 72 | destroy?.(); | ||
| 73 | destroy = undefined; | ||
| 74 | }); | ||
| 75 | |||
| 76 | function 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 | |||
| 88 | describe("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 | |||
| 166 | describe("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 | |||
| 3 | import "./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 | |||
| 7 | import { javascriptLanguage } from "@codemirror/lang-javascript"; | ||
| 8 | import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; | ||
| 9 | import { EditorSelection, EditorState } from "@codemirror/state"; | ||
| 10 | import { afterEach, describe, expect, it } from "vite-plus/test"; | ||
| 11 | |||
| 12 | import { createMarkdownEditor } from "../src/editor.ts"; | ||
| 13 | import { formattingDecorations } from "../src/live-preview.ts"; | ||
| 14 | |||
| 15 | let cleanup: (() => void) | undefined; | ||
| 16 | afterEach(() => { | ||
| 17 | cleanup?.(); | ||
| 18 | cleanup = undefined; | ||
| 19 | }); | ||
| 20 | |||
| 21 | function 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 | |||
| 30 | describe("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 | |||
| 67 | describe("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 | |||
| 83 | describe("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. | ||
| 91 | describe("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 | |||
| 99 | it("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 | |||
| 112 | it("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 | ||
| 2 | import "./dom"; | ||
| 3 | |||
| 4 | import { Compartment, EditorSelection } from "@codemirror/state"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { createMarkdownEditor, type MarkdownEditor } from "../src/editor.ts"; | ||
| 8 | import { livePreview } from "../src/live-preview.ts"; | ||
| 9 | import type { VideoPreviewInput, VideoPreviewRenderer } from "../src/widgets.ts"; | ||
| 10 | |||
| 11 | let editor: MarkdownEditor | undefined; | ||
| 12 | |||
| 13 | beforeEach(() => { | ||
| 14 | vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {}); | ||
| 15 | }); | ||
| 16 | |||
| 17 | afterEach(() => { | ||
| 18 | editor?.destroy(); | ||
| 19 | editor?.dom.remove(); | ||
| 20 | editor = undefined; | ||
| 21 | vi.restoreAllMocks(); | ||
| 22 | }); | ||
| 23 | |||
| 24 | function mount(source: string) { | ||
| 25 | editor = createMarkdownEditor({ preset: "document", doc: source }); | ||
| 26 | document.body.append(editor.dom); | ||
| 27 | return editor; | ||
| 28 | } | ||
| 29 | |||
| 30 | describe("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 = ``; | ||
| 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: "", | ||
| 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("\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(""); | ||
| 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(""); | ||
| 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: "", | ||
| 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 | |||
| 3 | import "./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. | ||
| 6 | import { EditorState } from "@codemirror/state"; | ||
| 7 | import { EditorView } from "@codemirror/view"; | ||
| 8 | import { describe, expect, it } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | createMentionRegistry, | ||
| 12 | hydrateMentions, | ||
| 13 | insertMention, | ||
| 14 | mentionsExtension, | ||
| 15 | serializeMentions, | ||
| 16 | staticMentionProvider, | ||
| 17 | } from "../src/mentions.ts"; | ||
| 18 | |||
| 19 | describe("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 | |||
| 125 | describe("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 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as NodeVM from "node:vm"; | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| ... | @@ -8,7 +9,7 @@ import { | ... | @@ -8,7 +9,7 @@ import { |
| 8 | htmlRenderThemeFragment, | 9 | htmlRenderThemeFragment, |
| 9 | injectHtmlRenderBootstrap, | 10 | injectHtmlRenderBootstrap, |
| 10 | htmlRenderThemeMessage, | 11 | htmlRenderThemeMessage, |
| 11 | readHtmlRenderContentHeight, | 12 | readHtmlRenderContentSize, |
| 12 | readHtmlRenderKeyDown, | 13 | readHtmlRenderKeyDown, |
| 13 | readHtmlRenderLinkRequest, | 14 | readHtmlRenderLinkRequest, |
| 14 | readHtmlRenderReference, | 15 | readHtmlRenderReference, |
| ... | @@ -19,6 +20,116 @@ import { htmlRenderFromToolItem } from "./toolOutput.ts"; | ... | @@ -19,6 +20,116 @@ import { htmlRenderFromToolItem } from "./toolOutput.ts"; |
| 19 | const reference = { attachmentId: "thread-abc-123.html", title: "Chart", height: 420 }; | 20 | const reference = { attachmentId: "thread-abc-123.html", title: "Chart", height: 420 }; |
| 20 | 21 | ||
| 21 | describe("injectHtmlRenderBootstrap", () => { | 22 | describe("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 | |||
| 22 | it("puts the theme ahead of the page's own head content", () => { | 133 | it("puts the theme ahead of the page's own head content", () => { |
| 23 | const html = | 134 | const html = |
| 24 | "<!doctype html><html><head><style>:root{--background:red}</style></head><body>x</body></html>"; | 135 | "<!doctype html><html><head><style>:root{--background:red}</style></head><body>x</body></html>"; |
| ... | @@ -130,17 +241,24 @@ describe("readHtmlRenderKeyDown", () => { | ... | @@ -130,17 +241,24 @@ describe("readHtmlRenderKeyDown", () => { |
| 130 | }); | 241 | }); |
| 131 | }); | 242 | }); |
| 132 | 243 | ||
| 133 | describe("readHtmlRenderContentHeight", () => { | 244 | describe("readHtmlRenderContentSize", () => { |
| 134 | it("reads only the height of an MCP Apps size-changed notification", () => { | 245 | it("reads positive dimensions and retains height-only notifications from existing renders", () => { |
| 135 | const notification = (params: unknown) => ({ | 246 | const notification = (params: unknown) => ({ |
| 136 | jsonrpc: "2.0", | 247 | jsonrpc: "2.0", |
| 137 | method: "ui/notifications/size-changed", | 248 | method: "ui/notifications/size-changed", |
| 138 | params, | 249 | params, |
| 139 | }); | 250 | }); |
| 140 | expect(readHtmlRenderContentHeight(notification({ height: 412 }))).toBe(412); | 251 | expect(readHtmlRenderContentSize(notification({ height: 412 }))).toEqual({ height: 412 }); |
| 141 | expect(readHtmlRenderContentHeight(notification({ height: "412" }))).toBe(undefined); | 252 | expect(readHtmlRenderContentSize(notification({ height: 412, width: 920 }))).toEqual({ |
| 142 | expect(readHtmlRenderContentHeight(notification({ height: 0 }))).toBe(undefined); | 253 | height: 412, |
| 143 | expect(readHtmlRenderContentHeight({ ...notification({ height: 412 }), method: "x" })).toBe( | 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( | ||
| 144 | undefined, | 262 | undefined, |
| 145 | ); | 263 | ); |
| 146 | }); | 264 | }); |
packages/shared/src/htmlRender.ts+10-8| ... | @@ -281,16 +281,18 @@ const SIZE_CHANGED_METHOD = "ui/notifications/size-changed"; | ... | @@ -281,16 +281,18 @@ const SIZE_CHANGED_METHOD = "ui/notifications/size-changed"; |
| 281 | const SHORTCUTS_METHOD = "t3/notifications/shortcuts"; | 281 | const SHORTCUTS_METHOD = "t3/notifications/shortcuts"; |
| 282 | const KEY_DOWN_METHOD = "t3/notifications/keydown"; | 282 | const KEY_DOWN_METHOD = "t3/notifications/keydown"; |
| 283 | 283 | ||
| 284 | /** The content height in a framed render's `ui/notifications/size-changed` notification. */ | 284 | /** A render's measured height and intrinsic width from its size-changed notification. */ |
| 285 | export function readHtmlRenderContentHeight(data: unknown): number | undefined { | 285 | export function readHtmlRenderContentSize(data: unknown) { |
| 286 | if (typeof data !== "object" || data === null) return undefined; | 286 | if (typeof data !== "object" || data === null) return undefined; |
| 287 | const { jsonrpc, method, params } = data as Record<string, unknown>; | 287 | const { jsonrpc, method, params } = data as Record<string, unknown>; |
| 288 | if (jsonrpc !== "2.0" || method !== SIZE_CHANGED_METHOD) return undefined; | 288 | if (jsonrpc !== "2.0" || method !== SIZE_CHANGED_METHOD) return undefined; |
| 289 | const height = | 289 | if (typeof params !== "object" || params === null) return undefined; |
| 290 | typeof params === "object" && params !== null | 290 | const { height, width } = params as { height?: unknown; width?: unknown }; |
| 291 | ? (params as { height?: unknown }).height | 291 | if (typeof height !== "number" || !Number.isFinite(height) || height <= 0) return undefined; |
| 292 | : undefined; | 292 | return { |
| 293 | return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined; | 293 | height, |
| 294 | ...(typeof width === "number" && Number.isFinite(width) && width > 0 ? { width } : {}), | ||
| 295 | }; | ||
| 294 | } | 296 | } |
| 295 | 297 | ||
| 296 | /** | 298 | /** |
| ... | @@ -420,7 +422,7 @@ function rootRule(theme: HtmlRenderTheme): string { | ... | @@ -420,7 +422,7 @@ function rootRule(theme: HtmlRenderTheme): string { |
| 420 | // field has focus or the page handled it: the deciding listener is added while | 422 | // field has focus or the page handled it: the deciding listener is added while |
| 421 | // the key dispatches, so it runs after every listener the page has. Keys | 423 | // the key dispatches, so it runs after every listener the page has. Keys |
| 422 | // match as resolveEventKeys reads them, so the client accepts what it claims. | 424 | // match as resolveEventKeys reads them, so the client accepts what it claims. |
| 423 | const 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);}})();`; | 425 | const 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);}})();`; |
| 424 | 426 | ||
| 425 | function bootstrapMarkup(markup: string): string { | 427 | function bootstrapMarkup(markup: string): string { |
| 426 | const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); | 428 | 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", () => | ... | @@ -27,3 +27,42 @@ it("preserves in-app thread links through the shared Markdown sanitizer", () => |
| 27 | }); | 27 | }); |
| 28 | expect(JSON.stringify(tree)).not.toContain("javascript:"); | 28 | expect(JSON.stringify(tree)).not.toContain("javascript:"); |
| 29 | }); | 29 | }); |
| 30 | |||
| 31 | it("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() { | ... | @@ -142,6 +142,7 @@ function rehypePreserveImageSourceMeta() { |
| 142 | 142 | ||
| 143 | const CHAT_MARKDOWN_SANITIZE_SCHEMA = { | 143 | const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 144 | ...defaultSchema, | 144 | ...defaultSchema, |
| 145 | tagNames: [...(defaultSchema.tagNames ?? []), "mark", "u"], | ||
| 145 | attributes: { | 146 | attributes: { |
| 146 | ...defaultSchema.attributes, | 147 | ...defaultSchema.attributes, |
| 147 | "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), | 148 | "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), |
pnpm-lock.yaml+249-522| ... | @@ -760,9 +760,27 @@ importers: | ... | @@ -760,9 +760,27 @@ importers: |
| 760 | '@clerk/react': | 760 | '@clerk/react': |
| 761 | specifier: 6.16.1 | 761 | specifier: 6.16.1 |
| 762 | version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 762 | 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 | ||
| 763 | '@clo/terminal': | 766 | '@clo/terminal': |
| 764 | specifier: workspace:* | 767 | specifier: workspace:* |
| 765 | version: link:../../packages/terminal | 768 | 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 | ||
| 766 | '@daypicker/react': | 784 | '@daypicker/react': |
| 767 | specifier: ^10.0.1 | 785 | specifier: ^10.0.1 |
| 768 | version: 10.0.1(@types/react@19.3.0)(react@19.2.6) | 786 | version: 10.0.1(@types/react@19.3.0)(react@19.2.6) |
| ... | @@ -787,6 +805,9 @@ importers: | ... | @@ -787,6 +805,9 @@ importers: |
| 787 | '@legendapp/list': | 805 | '@legendapp/list': |
| 788 | specifier: 'catalog:' | 806 | specifier: 'catalog:' |
| 789 | 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) | 807 | 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 | ||
| 790 | '@noble/hashes': | 811 | '@noble/hashes': |
| 791 | specifier: 'catalog:' | 812 | specifier: 'catalog:' |
| 792 | version: 1.8.0 | 813 | version: 1.8.0 |
| ... | @@ -832,42 +853,6 @@ importers: | ... | @@ -832,42 +853,6 @@ importers: |
| 832 | '@tanstack/react-router': | 853 | '@tanstack/react-router': |
| 833 | specifier: ^1.160.2 | 854 | specifier: ^1.160.2 |
| 834 | version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 855 | 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 | ||
| 871 | class-variance-authority: | 856 | class-variance-authority: |
| 872 | specifier: ^0.7.1 | 857 | specifier: ^0.7.1 |
| 873 | version: 0.7.1 | 858 | version: 0.7.1 |
| ... | @@ -880,6 +865,9 @@ importers: | ... | @@ -880,6 +865,9 @@ importers: |
| 880 | effect: | 865 | effect: |
| 881 | specifier: 4.0.4 | 866 | specifier: 4.0.4 |
| 882 | version: 4.0.4 | 867 | version: 4.0.4 |
| 868 | gemoji: | ||
| 869 | specifier: ^8.1.0 | ||
| 870 | version: 8.1.0 | ||
| 883 | hast-util-to-html: | 871 | hast-util-to-html: |
| 884 | specifier: ^9.0.5 | 872 | specifier: ^9.0.5 |
| 885 | version: 9.0.5 | 873 | version: 9.0.5 |
| ... | @@ -1185,6 +1173,39 @@ importers: | ... | @@ -1185,6 +1173,39 @@ importers: |
| 1185 | specifier: 1.0.0 | 1173 | specifier: 1.0.0 |
| 1186 | 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) | 1174 | 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) |
| 1187 | 1175 | ||
| 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 | |||
| 1188 | packages/provider-acp: | 1209 | packages/provider-acp: |
| 1189 | dependencies: | 1210 | dependencies: |
| 1190 | '@t3tools/contracts': | 1211 | '@t3tools/contracts': |
| ... | @@ -2848,6 +2869,39 @@ packages: | ... | @@ -2848,6 +2869,39 @@ packages: |
| 2848 | '@cloudflare/workers-types@5.20261004.1': | 2869 | '@cloudflare/workers-types@5.20261004.1': |
| 2849 | resolution: {integrity: sha512-K9D4/Y6bSIDNYFzjqzFMNoWK99pLgfgFxqSa76kfW++3VCkTSzJbzsQRffir366G3kmNyjoigC6ekp7DxIkxkw==} | 2870 | resolution: {integrity: sha512-K9D4/Y6bSIDNYFzjqzFMNoWK99pLgfgFxqSa76kfW++3VCkTSzJbzsQRffir366G3kmNyjoigC6ekp7DxIkxkw==} |
| 2850 | 2871 | ||
| 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 | |||
| 2851 | '@coderabbitai/config@1.1.0': | 2905 | '@coderabbitai/config@1.1.0': |
| 2852 | resolution: {integrity: sha512-NvQKaoOD7fWb7lMKhid57ABVlIBSczw/S+ePk5YvoPuWdrDkyY+PWdqVtq3TRq7VY8f3SNRlMFjmAtXVab66pw==} | 2906 | resolution: {integrity: sha512-NvQKaoOD7fWb7lMKhid57ABVlIBSczw/S+ePk5YvoPuWdrDkyY+PWdqVtq3TRq7VY8f3SNRlMFjmAtXVab66pw==} |
| 2853 | 2907 | ||
| ... | @@ -4190,6 +4244,27 @@ packages: | ... | @@ -4190,6 +4244,27 @@ packages: |
| 4190 | react-native: | 4244 | react-native: |
| 4191 | optional: true | 4245 | optional: true |
| 4192 | 4246 | ||
| 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 | |||
| 4193 | '@libsql/client@0.17.3': | 4268 | '@libsql/client@0.17.3': |
| 4194 | resolution: {integrity: sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==} | 4269 | resolution: {integrity: sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==} |
| 4195 | 4270 | ||
| ... | @@ -4255,6 +4330,9 @@ packages: | ... | @@ -4255,6 +4330,9 @@ packages: |
| 4255 | resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} | 4330 | resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} |
| 4256 | engines: {node: '>= 10.0.0'} | 4331 | engines: {node: '>= 10.0.0'} |
| 4257 | 4332 | ||
| 4333 | '@marijn/find-cluster-break@1.0.4': | ||
| 4334 | resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} | ||
| 4335 | |||
| 4258 | '@material/material-color-utilities@0.3.0': | 4336 | '@material/material-color-utilities@0.3.0': |
| 4259 | resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} | 4337 | resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} |
| 4260 | 4338 | ||
| ... | @@ -6045,166 +6123,6 @@ packages: | ... | @@ -6045,166 +6123,6 @@ packages: |
| 6045 | peerDependencies: | 6123 | peerDependencies: |
| 6046 | '@testing-library/dom': '>=7.21.4' | 6124 | '@testing-library/dom': '>=7.21.4' |
| 6047 | 6125 | ||
| 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 | |||
| 6208 | '@tsc-rs/darwin-arm64@0.7.0': | 6126 | '@tsc-rs/darwin-arm64@0.7.0': |
| 6209 | resolution: {integrity: sha512-3execGkNh7DXNZr03dhQP2wqmsjB1Q4OJnTMgKIibjeQMsjNK8HTalJZPIG6PkiXeusXUweHP3+4yJjbi5auNw==} | 6127 | resolution: {integrity: sha512-3execGkNh7DXNZr03dhQP2wqmsjB1Q4OJnTMgKIibjeQMsjNK8HTalJZPIG6PkiXeusXUweHP3+4yJjbi5auNw==} |
| 6210 | engines: {node: '>=16.20.0'} | 6128 | engines: {node: '>=16.20.0'} |
| ... | @@ -6501,9 +6419,6 @@ packages: | ... | @@ -6501,9 +6419,6 @@ packages: |
| 6501 | '@types/unist@3.0.3': | 6419 | '@types/unist@3.0.3': |
| 6502 | resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} | 6420 | resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} |
| 6503 | 6421 | ||
| 6504 | '@types/use-sync-external-store@0.0.6': | ||
| 6505 | resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} | ||
| 6506 | |||
| 6507 | '@types/webidl-conversions@7.0.3': | 6422 | '@types/webidl-conversions@7.0.3': |
| 6508 | resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} | 6423 | resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} |
| 6509 | 6424 | ||
| ... | @@ -7807,6 +7722,9 @@ packages: | ... | @@ -7807,6 +7722,9 @@ packages: |
| 7807 | cose-base@2.2.0: | 7722 | cose-base@2.2.0: |
| 7808 | resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} | 7723 | resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} |
| 7809 | 7724 | ||
| 7725 | crelt@1.0.7: | ||
| 7726 | resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} | ||
| 7727 | |||
| 7810 | cross-dirname@0.1.0: | 7728 | cross-dirname@0.1.0: |
| 7811 | resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} | 7729 | resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} |
| 7812 | 7730 | ||
| ... | @@ -8894,10 +8812,6 @@ packages: | ... | @@ -8894,10 +8812,6 @@ packages: |
| 8894 | fast-deep-equal@3.1.3: | 8812 | fast-deep-equal@3.1.3: |
| 8895 | resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} | 8813 | resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} |
| 8896 | 8814 | ||
| 8897 | fast-equals@5.4.2: | ||
| 8898 | resolution: {integrity: sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==} | ||
| 8899 | engines: {node: '>=6.0.0'} | ||
| 8900 | |||
| 8901 | fast-json-stable-stringify@2.1.0: | 8815 | fast-json-stable-stringify@2.1.0: |
| 8902 | resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} | 8816 | resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} |
| 8903 | 8817 | ||
| ... | @@ -9081,6 +8995,9 @@ packages: | ... | @@ -9081,6 +8995,9 @@ packages: |
| 9081 | function-bind@1.1.2: | 8995 | function-bind@1.1.2: |
| 9082 | resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} | 8996 | resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} |
| 9083 | 8997 | ||
| 8998 | gemoji@8.1.0: | ||
| 8999 | resolution: {integrity: sha512-HA4Gx59dw2+tn+UAa7XEV4ufUKI4fH1KgcbenVA9YKSj1QJTT0xh5Mwv5HMFNN3l2OtUe3ZIfuRwSyZS5pLIWw==} | ||
| 9000 | |||
| 9084 | gensync@1.0.0-beta.2: | 9001 | gensync@1.0.0-beta.2: |
| 9085 | resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} | 9002 | resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} |
| 9086 | engines: {node: '>=6.9.0'} | 9003 | engines: {node: '>=6.9.0'} |
| ... | @@ -9677,9 +9594,6 @@ packages: | ... | @@ -9677,9 +9594,6 @@ packages: |
| 9677 | resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} | 9594 | resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} |
| 9678 | engines: {node: '>= 12.0.0'} | 9595 | engines: {node: '>= 12.0.0'} |
| 9679 | 9596 | ||
| 9680 | linkifyjs@4.3.3: | ||
| 9681 | resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} | ||
| 9682 | |||
| 9683 | locate-path@3.0.0: | 9597 | locate-path@3.0.0: |
| 9684 | resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} | 9598 | resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} |
| 9685 | engines: {node: '>=6'} | 9599 | engines: {node: '>=6'} |
| ... | @@ -10391,9 +10305,6 @@ packages: | ... | @@ -10391,9 +10305,6 @@ packages: |
| 10391 | resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} | 10305 | resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} |
| 10392 | engines: {node: '>=20'} | 10306 | engines: {node: '>=20'} |
| 10393 | 10307 | ||
| 10394 | orderedmap@2.1.1: | ||
| 10395 | resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} | ||
| 10396 | |||
| 10397 | outvariant@1.4.3: | 10308 | outvariant@1.4.3: |
| 10398 | resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} | 10309 | resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} |
| 10399 | 10310 | ||
| ... | @@ -10731,45 +10642,6 @@ packages: | ... | @@ -10731,45 +10642,6 @@ packages: |
| 10731 | property-information@7.2.0: | 10642 | property-information@7.2.0: |
| 10732 | resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} | 10643 | resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} |
| 10733 | 10644 | ||
| 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 | |||
| 10773 | proxy-addr@2.0.7: | 10645 | proxy-addr@2.0.7: |
| 10774 | resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} | 10646 | resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} |
| 10775 | engines: {node: '>= 0.10'} | 10647 | engines: {node: '>= 0.10'} |
| ... | @@ -11142,9 +11014,6 @@ packages: | ... | @@ -11142,9 +11014,6 @@ packages: |
| 11142 | engines: {node: ^20.19.0 || >=22.12.0} | 11014 | engines: {node: ^20.19.0 || >=22.12.0} |
| 11143 | hasBin: true | 11015 | hasBin: true |
| 11144 | 11016 | ||
| 11145 | rope-sequence@1.3.4: | ||
| 11146 | resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} | ||
| 11147 | |||
| 11148 | roughjs@4.6.6: | 11017 | roughjs@4.6.6: |
| 11149 | resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} | 11018 | resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} |
| 11150 | 11019 | ||
| ... | @@ -11500,6 +11369,9 @@ packages: | ... | @@ -11500,6 +11369,9 @@ packages: |
| 11500 | structured-headers@0.4.1: | 11369 | structured-headers@0.4.1: |
| 11501 | resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} | 11370 | resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} |
| 11502 | 11371 | ||
| 11372 | style-mod@4.1.4: | ||
| 11373 | resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==} | ||
| 11374 | |||
| 11503 | style-to-js@1.1.21: | 11375 | style-to-js@1.1.21: |
| 11504 | resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} | 11376 | resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} |
| 11505 | 11377 | ||
| ... | @@ -13662,6 +13534,89 @@ snapshots: | ... | @@ -13662,6 +13534,89 @@ snapshots: |
| 13662 | 13534 | ||
| 13663 | '@cloudflare/workers-types@5.20261004.1': {} | 13535 | '@cloudflare/workers-types@5.20261004.1': {} |
| 13664 | 13536 | ||
| 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 | |||
| 13665 | '@coderabbitai/config@1.1.0': | 13620 | '@coderabbitai/config@1.1.0': |
| 13666 | dependencies: | 13621 | dependencies: |
| 13667 | zod: 4.5.4 | 13622 | zod: 4.5.4 |
| ... | @@ -15068,6 +15023,39 @@ snapshots: | ... | @@ -15068,6 +15023,39 @@ snapshots: |
| 15068 | react-dom: 19.3.0(react@19.3.0) | 15023 | react-dom: 19.3.0(react@19.3.0) |
| 15069 | 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) | 15024 | 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) |
| 15070 | 15025 | ||
| 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 | |||
| 15071 | '@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': | 15059 | '@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': |
| 15072 | dependencies: | 15060 | dependencies: |
| 15073 | '@libsql/core': 0.17.3 | 15061 | '@libsql/core': 0.17.3 |
| ... | @@ -15139,6 +15127,8 @@ snapshots: | ... | @@ -15139,6 +15127,8 @@ snapshots: |
| 15139 | transitivePeerDependencies: | 15127 | transitivePeerDependencies: |
| 15140 | - supports-color | 15128 | - supports-color |
| 15141 | 15129 | ||
| 15130 | '@marijn/find-cluster-break@1.0.4': {} | ||
| 15131 | |||
| 15142 | '@material/material-color-utilities@0.3.0': {} | 15132 | '@material/material-color-utilities@0.3.0': {} |
| 15143 | 15133 | ||
| 15144 | '@mermaid-js/parser@1.2.1': | 15134 | '@mermaid-js/parser@1.2.1': |
| ... | @@ -16623,187 +16613,6 @@ snapshots: | ... | @@ -16623,187 +16613,6 @@ snapshots: |
| 16623 | dependencies: | 16613 | dependencies: |
| 16624 | '@testing-library/dom': 10.4.1 | 16614 | '@testing-library/dom': 10.4.1 |
| 16625 | 16615 | ||
| 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 | |||
| 16807 | '@tsc-rs/darwin-arm64@0.7.0': | 16616 | '@tsc-rs/darwin-arm64@0.7.0': |
| 16808 | optional: true | 16617 | optional: true |
| 16809 | 16618 | ||
| ... | @@ -17145,8 +16954,6 @@ snapshots: | ... | @@ -17145,8 +16954,6 @@ snapshots: |
| 17145 | 16954 | ||
| 17146 | '@types/unist@3.0.3': {} | 16955 | '@types/unist@3.0.3': {} |
| 17147 | 16956 | ||
| 17148 | '@types/use-sync-external-store@0.0.6': {} | ||
| 17149 | |||
| 17150 | '@types/webidl-conversions@7.0.3': | 16957 | '@types/webidl-conversions@7.0.3': |
| 17151 | optional: true | 16958 | optional: true |
| 17152 | 16959 | ||
| ... | @@ -18420,6 +18227,8 @@ snapshots: | ... | @@ -18420,6 +18227,8 @@ snapshots: |
| 18420 | dependencies: | 18227 | dependencies: |
| 18421 | layout-base: 2.0.1 | 18228 | layout-base: 2.0.1 |
| 18422 | 18229 | ||
| 18230 | crelt@1.0.7: {} | ||
| 18231 | |||
| 18423 | cross-dirname@0.1.0: | 18232 | cross-dirname@0.1.0: |
| 18424 | optional: true | 18233 | optional: true |
| 18425 | 18234 | ||
| ... | @@ -19564,8 +19373,6 @@ snapshots: | ... | @@ -19564,8 +19373,6 @@ snapshots: |
| 19564 | 19373 | ||
| 19565 | fast-deep-equal@3.1.3: {} | 19374 | fast-deep-equal@3.1.3: {} |
| 19566 | 19375 | ||
| 19567 | fast-equals@5.4.2: {} | ||
| 19568 | |||
| 19569 | fast-json-stable-stringify@2.1.0: | 19376 | fast-json-stable-stringify@2.1.0: |
| 19570 | optional: true | 19377 | optional: true |
| 19571 | 19378 | ||
| ... | @@ -19615,10 +19422,6 @@ snapshots: | ... | @@ -19615,10 +19422,6 @@ snapshots: |
| 19615 | dependencies: | 19422 | dependencies: |
| 19616 | walk-up-path: 4.0.0 | 19423 | walk-up-path: 4.0.0 |
| 19617 | 19424 | ||
| 19618 | fdir@6.5.0(picomatch@4.0.4): | ||
| 19619 | optionalDependencies: | ||
| 19620 | picomatch: 4.0.4 | ||
| 19621 | |||
| 19622 | fdir@6.5.0(picomatch@4.0.7): | 19425 | fdir@6.5.0(picomatch@4.0.7): |
| 19623 | optionalDependencies: | 19426 | optionalDependencies: |
| 19624 | picomatch: 4.0.7 | 19427 | picomatch: 4.0.7 |
| ... | @@ -19771,6 +19574,8 @@ snapshots: | ... | @@ -19771,6 +19574,8 @@ snapshots: |
| 19771 | 19574 | ||
| 19772 | function-bind@1.1.2: {} | 19575 | function-bind@1.1.2: {} |
| 19773 | 19576 | ||
| 19577 | gemoji@8.1.0: {} | ||
| 19578 | |||
| 19774 | gensync@1.0.0-beta.2: {} | 19579 | gensync@1.0.0-beta.2: {} |
| 19775 | 19580 | ||
| 19776 | get-caller-file@2.0.5: {} | 19581 | get-caller-file@2.0.5: {} |
| ... | @@ -20418,8 +20223,6 @@ snapshots: | ... | @@ -20418,8 +20223,6 @@ snapshots: |
| 20418 | lightningcss-win32-arm64-msvc: 1.33.0 | 20223 | lightningcss-win32-arm64-msvc: 1.33.0 |
| 20419 | lightningcss-win32-x64-msvc: 1.33.0 | 20224 | lightningcss-win32-x64-msvc: 1.33.0 |
| 20420 | 20225 | ||
| 20421 | linkifyjs@4.3.3: {} | ||
| 20422 | |||
| 20423 | locate-path@3.0.0: | 20226 | locate-path@3.0.0: |
| 20424 | dependencies: | 20227 | dependencies: |
| 20425 | p-locate: 3.0.0 | 20228 | p-locate: 3.0.0 |
| ... | @@ -21448,8 +21251,6 @@ snapshots: | ... | @@ -21448,8 +21251,6 @@ snapshots: |
| 21448 | stdin-discarder: 0.3.2 | 21251 | stdin-discarder: 0.3.2 |
| 21449 | string-width: 8.2.1 | 21252 | string-width: 8.2.1 |
| 21450 | 21253 | ||
| 21451 | orderedmap@2.1.1: {} | ||
| 21452 | |||
| 21453 | outvariant@1.4.3: | 21254 | outvariant@1.4.3: |
| 21454 | optional: true | 21255 | optional: true |
| 21455 | 21256 | ||
| ... | @@ -21901,80 +21702,6 @@ snapshots: | ... | @@ -21901,80 +21702,6 @@ snapshots: |
| 21901 | 21702 | ||
| 21902 | property-information@7.2.0: {} | 21703 | property-information@7.2.0: {} |
| 21903 | 21704 | ||
| 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 | |||
| 21978 | proxy-addr@2.0.7: | 21705 | proxy-addr@2.0.7: |
| 21979 | dependencies: | 21706 | dependencies: |
| 21980 | forwarded: 0.2.0 | 21707 | forwarded: 0.2.0 |
| ... | @@ -22523,8 +22250,6 @@ snapshots: | ... | @@ -22523,8 +22250,6 @@ snapshots: |
| 22523 | '@rolldown/binding-win32-arm64-msvc': 1.2.5 | 22250 | '@rolldown/binding-win32-arm64-msvc': 1.2.5 |
| 22524 | '@rolldown/binding-win32-x64-msvc': 1.2.5 | 22251 | '@rolldown/binding-win32-x64-msvc': 1.2.5 |
| 22525 | 22252 | ||
| 22526 | rope-sequence@1.3.4: {} | ||
| 22527 | |||
| 22528 | roughjs@4.6.6: | 22253 | roughjs@4.6.6: |
| 22529 | dependencies: | 22254 | dependencies: |
| 22530 | hachure-fill: 0.5.2 | 22255 | hachure-fill: 0.5.2 |
| ... | @@ -22949,6 +22674,8 @@ snapshots: | ... | @@ -22949,6 +22674,8 @@ snapshots: |
| 22949 | 22674 | ||
| 22950 | structured-headers@0.4.1: {} | 22675 | structured-headers@0.4.1: {} |
| 22951 | 22676 | ||
| 22677 | style-mod@4.1.4: {} | ||
| 22678 | |||
| 22952 | style-to-js@1.1.21: | 22679 | style-to-js@1.1.21: |
| 22953 | dependencies: | 22680 | dependencies: |
| 22954 | style-to-object: 1.0.14 | 22681 | style-to-object: 1.0.14 |
| ... | @@ -23076,8 +22803,8 @@ snapshots: | ... | @@ -23076,8 +22803,8 @@ snapshots: |
| 23076 | 22803 | ||
| 23077 | tinyglobby@0.2.17: | 22804 | tinyglobby@0.2.17: |
| 23078 | dependencies: | 22805 | dependencies: |
| 23079 | fdir: 6.5.0(picomatch@4.0.4) | 22806 | fdir: 6.5.0(picomatch@4.0.7) |
| 23080 | picomatch: 4.0.4 | 22807 | picomatch: 4.0.7 |
| 23081 | 22808 | ||
| 23082 | tinypool@2.1.2: {} | 22809 | tinypool@2.1.2: {} |
| 23083 | 22810 |
third-party-licenses.config.json+8| ... | @@ -99,6 +99,14 @@ | ... | @@ -99,6 +99,14 @@ |
| 99 | "noticeFile": "native/libghostty-vt/LICENSE", | 99 | "noticeFile": "native/libghostty-vt/LICENSE", |
| 100 | "sourceUrl": "https://github.com/ghostty-org/ghostty" | 100 | "sourceUrl": "https://github.com/ghostty-org/ghostty" |
| 101 | }, | 101 | }, |
| 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 | }, | ||
| 102 | { | 110 | { |
| 103 | "bundles": ["assets", "web"], | 111 | "bundles": ["assets", "web"], |
| 104 | "license": "OFL-1.1", | 112 | "license": "OFL-1.1", |