From 8fbe4218c6a32d93322bcb2e640afe8bd830916a Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 1 Sep 2026 20:34:23 +0900 Subject: [PATCH] fix: delete complete Composer selections --- .../workspace/console/ComposerInput.svelte | 12 ++++----- .../workspace/console/composer-draft.test.ts | 25 +++++++++++++++++++ .../lib/workspace/console/composer-draft.ts | 20 +++++++++++++++ .../console/worker-console.ui.test.ts | 3 +++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.svelte b/web/workspace/src/lib/workspace/console/ComposerInput.svelte index 51a85c24..55aa74bd 100644 --- a/web/workspace/src/lib/workspace/console/ComposerInput.svelte +++ b/web/workspace/src/lib/workspace/console/ComposerInput.svelte @@ -28,6 +28,7 @@ type ComposerPasteMeasurement, } from "$lib/workspace/console/composer-paste.ts"; import { + composerDeletionRange, composerPasteAtoms, composerPasteToken, pasteChipLabel, @@ -255,18 +256,15 @@ direction: "backward" | "forward", ): boolean { const selection = currentView.state.selection.main; - if (!selection.empty) return false; const pastes = composerPasteAtoms( currentView.state.doc.toString(), currentView.state.field(pasteRegistry), ); - const paste = direction === "backward" - ? pastes.find((candidate) => candidate.to === selection.head) - : pastes.find((candidate) => candidate.from === selection.head); - if (!paste) return false; + const deletion = composerDeletionRange(selection, pastes, direction); + if (!deletion) return false; currentView.dispatch({ - changes: { from: paste.from, to: paste.to }, - selection: EditorSelection.cursor(paste.from), + changes: deletion, + selection: EditorSelection.cursor(deletion.from), annotations: isolateHistory.of("full"), userEvent: "delete", }); diff --git a/web/workspace/src/lib/workspace/console/composer-draft.test.ts b/web/workspace/src/lib/workspace/console/composer-draft.test.ts index 66bf303f..b4987b93 100644 --- a/web/workspace/src/lib/workspace/console/composer-draft.test.ts +++ b/web/workspace/src/lib/workspace/console/composer-draft.test.ts @@ -1,5 +1,6 @@ import type { Segment } from "$lib/generated/protocol.ts"; import { + composerDeletionRange, type ComposerPaste, composerPasteToken, pasteChipLabel, @@ -76,6 +77,30 @@ Deno.test("composer draft preserves mixed Text and Paste order exactly", () => { assertEquals(snapshot.pastes.map((entry) => entry.key), [11, 12]); }); +Deno.test("selection deletion covers mixed Text and every selected paste chip", () => { + const pastes = [ + { ...paste(1, "first"), key: 10, from: 2, to: 5 }, + { ...paste(2, "second"), key: 11, from: 8, to: 11 }, + ]; + + assertEquals( + composerDeletionRange({ from: 1, to: 12, head: 12 }, pastes, "backward"), + { from: 1, to: 12 }, + ); + assertEquals( + composerDeletionRange({ from: 2, to: 11, head: 2 }, pastes, "forward"), + { from: 2, to: 11 }, + ); + assertEquals( + composerDeletionRange({ from: 5, to: 5, head: 5 }, pastes, "backward"), + { from: 2, to: 5 }, + ); + assertEquals( + composerDeletionRange({ from: 8, to: 8, head: 8 }, pastes, "forward"), + { from: 8, to: 11 }, + ); +}); + Deno.test("composer paste chip label is compact and accessible", () => { assertEquals( pasteChipLabel({ id: 4, content: "payload", chars: 7, lines: 1 }), diff --git a/web/workspace/src/lib/workspace/console/composer-draft.ts b/web/workspace/src/lib/workspace/console/composer-draft.ts index b47de02d..ad0c399d 100644 --- a/web/workspace/src/lib/workspace/console/composer-draft.ts +++ b/web/workspace/src/lib/workspace/console/composer-draft.ts @@ -24,6 +24,26 @@ export interface ComposerTextPaste { content: string; } +export interface ComposerSelection { + from: number; + to: number; + head: number; +} + +export function composerDeletionRange( + selection: ComposerSelection, + pastes: readonly ComposerPasteAtom[], + direction: "backward" | "forward", +): { from: number; to: number } | null { + if (selection.from !== selection.to) { + return { from: selection.from, to: selection.to }; + } + const paste = direction === "backward" + ? pastes.find((candidate) => candidate.to === selection.head) + : pastes.find((candidate) => candidate.from === selection.head); + return paste ? { from: paste.from, to: paste.to } : null; +} + export interface ComposerDraftSnapshot { document: string; content: string; diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 572c9e15..86d8e875 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -604,6 +604,9 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority" composerInput.includes("EditorView.atomicRanges") && composerInput.includes('key: "Backspace"') && composerInput.includes('key: "Delete"') && + composerInput.includes( + "composerDeletionRange(selection, pastes, direction)", + ) && composerInput.includes('chip.setAttribute("aria-label", label)') && composerInput.includes("preserveExactText = false") && consolePage.includes("buildComposerSegmentsRequest(value.segments, {") &&