From 758af6f0bcfeee76aae3d2bc6cde0b531e61b75f Mon Sep 17 00:00:00 2001 From: eti Date: Mon, 21 Sep 2026 18:08:13 +0200 Subject: [PATCH] web: replace markdown forms editor with a textarea toolbar, mentions and references Signed-off-by: eti --- web/package.json | 4 +- web/pnpm-lock.yaml | 16 + web/src/lib/api/records.ts | 15 + .../components/comment/CommentEditor.svelte | 6 +- .../repo/pulls/PullReviewCommentEditor.svelte | 4 +- .../repo/tickets/TicketStatePill.svelte | 4 +- .../ui/MarkdownEditor.stories.svelte | 85 ++++- .../lib/components/ui/MarkdownEditor.svelte | 348 ++++++++++++++---- .../components/ui/MarkdownTypeahead.svelte | 268 ++++++++++++++ .../lib/components/ui/markdownFormat.test.ts | 133 +++++++ web/src/lib/components/ui/markdownFormat.ts | 203 ++++++++++ .../lib/components/ui/markdownScroll.test.ts | 45 +++ web/src/lib/components/ui/markdownScroll.ts | 84 +++++ .../lib/components/ui/markdownSuggestions.ts | 99 +++++ web/src/lib/markup/markdown.test.ts | 14 + web/src/lib/markup/markdown.ts | 24 +- web/src/lib/markup/paths.ts | 1 + web/src/lib/markup/render.ts | 11 +- web/src/lib/markup/sanitize.ts | 4 +- web/src/routes/+error.svelte | 2 +- web/vite.config.ts | 2 +- 21 files changed, 1276 insertions(+), 96 deletions(-) create mode 100644 web/src/lib/components/ui/MarkdownTypeahead.svelte create mode 100644 web/src/lib/components/ui/markdownFormat.test.ts create mode 100644 web/src/lib/components/ui/markdownFormat.ts create mode 100644 web/src/lib/components/ui/markdownScroll.test.ts create mode 100644 web/src/lib/components/ui/markdownScroll.ts create mode 100644 web/src/lib/components/ui/markdownSuggestions.ts diff --git a/web/package.json b/web/package.json index f625b5867..31c141bc4 100644 --- a/web/package.json +++ b/web/package.json @@ -40,6 +40,7 @@ "@types/markdown-it-emoji": "^3.0.1", "@types/node": "^24.13.3", "@types/sanitize-html": "^2.16.1", + "@types/textarea-caret": "^3.0.4", "@vitest/browser-playwright": "4.1.10", "@vitest/coverage-v8": "4.1.10", "eslint": "^10.7.0", @@ -87,6 +88,7 @@ "markdown-it-emoji": "^3.1.0", "sanitize-html": "^2.17.6", "tailwind-merge": "^3.6.0", - "tailwind-variants": "^3.2.2" + "tailwind-variants": "^3.2.2", + "textarea-caret": "^3.1.0" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index b04268c44..2a7f448b7 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: tailwind-variants: specifier: ^3.2.2 version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.2) + textarea-caret: + specifier: ^3.1.0 + version: 3.1.0 devDependencies: '@atcute/lex-cli': specifier: 3.2.1 @@ -133,6 +136,9 @@ importers: '@types/sanitize-html': specifier: ^2.16.1 version: 2.16.1 + '@types/textarea-caret': + specifier: ^3.0.4 + version: 3.0.4 '@vitest/browser-playwright': specifier: 4.1.10 version: 4.1.10(playwright@1.61.1)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) @@ -1861,6 +1867,9 @@ packages: '@types/sanitize-html@2.16.1': resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + '@types/textarea-caret@3.0.4': + resolution: {integrity: sha512-epJGYB37/sNrTDbhfyRjHkXsQSAcO6zby0JBDS0QMt6HQ1f1W2E4YpSc7TQkNmWaWmYXv92zOIfN5PHA8CmThg==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -3280,6 +3289,9 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + textarea-caret@3.1.0: + resolution: {integrity: sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -5046,6 +5058,8 @@ snapshots: dependencies: htmlparser2: 10.1.0 + '@types/textarea-caret@3.0.4': {} + '@types/trusted-types@2.0.7': {} '@types/unist@3.0.3': {} @@ -6526,6 +6540,8 @@ snapshots: tapable@2.3.3: {} + textarea-caret@3.1.0: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} diff --git a/web/src/lib/api/records.ts b/web/src/lib/api/records.ts index 93a74690d..b717ea391 100644 --- a/web/src/lib/api/records.ts +++ b/web/src/lib/api/records.ts @@ -89,6 +89,21 @@ export interface PullListPage { items: PullListItem[]; } +export interface ListPullsFilter { + status?: PullState; + author?: string; + limit?: number; + cursor?: string; + order?: "asc" | "desc"; +} + +export const listPulls = ( + ctx: BobbinContext, + subject: string, + filter: ListPullsFilter = {}, + init?: XrpcRequestInit +) => jsonGet(ctx, "sh.tangled.repo.listPulls", { subject, ...filter }, init); + export const getRepos = (ctx: BobbinContext, repos: readonly string[], init?: XrpcRequestInit) => jsonGet>(ctx, "sh.tangled.repo.getRepos", { repos }, init); diff --git a/web/src/lib/components/comment/CommentEditor.svelte b/web/src/lib/components/comment/CommentEditor.svelte index 1c7ad6cea..07a81e24e 100644 --- a/web/src/lib/components/comment/CommentEditor.svelte +++ b/web/src/lib/components/comment/CommentEditor.svelte @@ -80,7 +80,7 @@ ); let body = $state(untrack(() => initialBody)); - let tab = $state<"write" | "preview">("write"); + let preview = $state(false); const publish = createAction(async (event: SubmitEvent) => { event.preventDefault(); @@ -130,7 +130,7 @@ // keep the prefilled body when editing; reset when composing a fresh comment if (!rkey) { body = ""; - tab = "write"; + preview = false; } }); const canSubmit = $derived(body.trim() !== "" && !publish.loading); @@ -145,7 +145,7 @@ {autofocus} transparent bind:value={body} - bind:tab + bind:preview disabled={publish.loading} /> diff --git a/web/src/lib/components/repo/pulls/PullReviewCommentEditor.svelte b/web/src/lib/components/repo/pulls/PullReviewCommentEditor.svelte index e30123f87..611fba5e3 100644 --- a/web/src/lib/components/repo/pulls/PullReviewCommentEditor.svelte +++ b/web/src/lib/components/repo/pulls/PullReviewCommentEditor.svelte @@ -32,7 +32,7 @@ }: Props = $props(); let body = $state(untrack(() => initialBody)); - let tab = $state<"write" | "preview">("write"); + let preview = $state(false); const publish = createAction(async (event: SubmitEvent) => { event.preventDefault(); @@ -51,7 +51,7 @@ {markup} transparent bind:value={body} - bind:tab + bind:preview disabled={publish.loading} /> diff --git a/web/src/lib/components/repo/tickets/TicketStatePill.svelte b/web/src/lib/components/repo/tickets/TicketStatePill.svelte index dac748226..fc806944b 100644 --- a/web/src/lib/components/repo/tickets/TicketStatePill.svelte +++ b/web/src/lib/components/repo/tickets/TicketStatePill.svelte @@ -35,7 +35,7 @@ export type StatePillVariants = VariantProps; export type TicketState = NonNullable; - const ICONS = { + export const TICKET_STATE_ICONS = { issue: { open: CircleDot, closed: CircleX, @@ -66,7 +66,7 @@ let { isPull = false, state = "open", class: className, size }: Props = $props(); const kind = $derived(isPull ? "pull" : "issue"); - const Icon = $derived(ICONS[kind][state]); + const Icon = $derived(TICKET_STATE_ICONS[kind][state]); const label = $derived(LABELS[state]); const style = $derived(statePill({ state, size })); diff --git a/web/src/lib/components/ui/MarkdownEditor.stories.svelte b/web/src/lib/components/ui/MarkdownEditor.stories.svelte index 74b38a276..052a00ad5 100644 --- a/web/src/lib/components/ui/MarkdownEditor.stories.svelte +++ b/web/src/lib/components/ui/MarkdownEditor.stories.svelte @@ -1,6 +1,8 @@ + + + + { + const canvas = within(canvasElement); + const textarea = canvas.getByPlaceholderText(/describe your issue/i); + await userEvent.type(textarea, "hello"); + textarea.setSelectionRange(0, 5); + await userEvent.click(canvas.getByRole("button", { name: "Bold" })); + await expect(textarea).toHaveValue("**hello**"); + await userEvent.keyboard("{Control>}i{/Control}"); + await expect(textarea).toHaveValue("**_hello_**"); + }} +/> { + const canvas = within(canvasElement); + const textarea = canvas.getByPlaceholderText(/describe your issue/i); + textarea.style.height = "240px"; + const field = textarea.parentElement!; + const height = field.offsetHeight; + const toggle = canvas.getByRole("switch", { name: "Preview" }); + await userEvent.click(toggle); + await expect(textarea).not.toBeVisible(); + const pane = field.nextElementSibling as HTMLElement; + await expect(pane.offsetHeight).toBe(height); + await expect(field.parentElement!.offsetHeight).toBe(height); + await waitFor(() => expect(pane.scrollHeight).toBeGreaterThan(pane.clientHeight)); + await userEvent.click(toggle); + await expect(textarea).toBeVisible(); + await expect(field.offsetHeight).toBe(height); + }} +/> + { + const canvas = within(canvasElement); + const textarea = canvas.getByPlaceholderText(/describe your issue/i); + const toggle = canvas.getByRole("switch", { name: "Preview" }); + scrollEditorToLine(textarea, 80); + const editorScroll = textarea.scrollTop; + + const paneOf = () => textarea.parentElement!.nextElementSibling as HTMLElement; + await userEvent.click(toggle); + const pane = paneOf(); + await waitFor(() => expect(pane.querySelector('[data-line="80"]')).not.toBeNull()); + await waitFor(() => + expect(Math.abs(offsetIn(pane, pane.querySelector('[data-line="80"]')!))).toBeLessThan( + 24 + ) + ); + await userEvent.click(toggle); + await expect(textarea.scrollTop).toBe(editorScroll); + + await userEvent.click(toggle); + const again = paneOf(); + await waitFor(() => + expect( + Math.abs(offsetIn(again, again.querySelector('[data-line="80"]')!)) + ).toBeLessThan(24) + ); + again.scrollTop += offsetIn(again, again.querySelector('[data-line="160"]')!); + await userEvent.click(toggle); + await expect(Math.abs(editorLine(textarea) - 160)).toBeLessThan(1); }} /> diff --git a/web/src/lib/components/ui/MarkdownEditor.svelte b/web/src/lib/components/ui/MarkdownEditor.svelte index 20bd5827c..aa68d988a 100644 --- a/web/src/lib/components/ui/MarkdownEditor.svelte +++ b/web/src/lib/components/ui/MarkdownEditor.svelte @@ -1,33 +1,105 @@ -
- - +{#snippet toolButton(tool: Tool)} + {@const hint = shortcut(tool)} + - + {#each INLINE_TOOLS as tool (tool.label)} + {@render toolButton(tool)} + {/each} + + {#snippet trigger()} + + {#each BLOCK_TOOLS as tool (tool.label)} + {@render toolButton(tool)} + {/each} +
+
+ + +
+ - {#if tab === "write"} +