fix: preserve exact short paste text
This commit is contained in:
@@ -18,12 +18,13 @@
|
|||||||
defaultKeymap,
|
defaultKeymap,
|
||||||
history,
|
history,
|
||||||
historyKeymap,
|
historyKeymap,
|
||||||
|
invertedEffects,
|
||||||
isolateHistory,
|
isolateHistory,
|
||||||
} from "@codemirror/commands";
|
} from "@codemirror/commands";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import type { Segment } from "$lib/generated/protocol.ts";
|
import type { Segment } from "$lib/generated/protocol.ts";
|
||||||
import {
|
import {
|
||||||
handleComposerPaste,
|
measureComposerPaste,
|
||||||
type ComposerPasteMeasurement,
|
type ComposerPasteMeasurement,
|
||||||
} from "$lib/workspace/console/composer-paste.ts";
|
} from "$lib/workspace/console/composer-paste.ts";
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
snapshotComposerDraft,
|
snapshotComposerDraft,
|
||||||
type ComposerDraftSnapshot,
|
type ComposerDraftSnapshot,
|
||||||
type ComposerPaste,
|
type ComposerPaste,
|
||||||
|
type ComposerTextPaste,
|
||||||
} from "$lib/workspace/console/composer-draft.ts";
|
} from "$lib/workspace/console/composer-draft.ts";
|
||||||
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
|
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
|
||||||
|
|
||||||
@@ -74,6 +76,37 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const registerTextPaste = StateEffect.define<ComposerTextPaste>();
|
||||||
|
const restoreTextPastes = StateEffect.define<readonly ComposerTextPaste[]>();
|
||||||
|
const textPasteState = StateField.define<readonly ComposerTextPaste[]>({
|
||||||
|
create: () => [],
|
||||||
|
update(textPastes, transaction) {
|
||||||
|
const restored = transaction.effects.find((effect) =>
|
||||||
|
effect.is(restoreTextPastes)
|
||||||
|
);
|
||||||
|
if (restored) return restored.value;
|
||||||
|
const retained: ComposerTextPaste[] = [];
|
||||||
|
for (const paste of textPastes) {
|
||||||
|
let touched = false;
|
||||||
|
transaction.changes.iterChangedRanges((from, to) => {
|
||||||
|
const replacesContent = from < paste.to && to > paste.from;
|
||||||
|
const insertsInside = from === to && from > paste.from && from < paste.to;
|
||||||
|
if (replacesContent || insertsInside) touched = true;
|
||||||
|
});
|
||||||
|
if (touched) continue;
|
||||||
|
retained.push({
|
||||||
|
...paste,
|
||||||
|
from: transaction.changes.mapPos(paste.from, 1),
|
||||||
|
to: transaction.changes.mapPos(paste.to, -1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const effect of transaction.effects) {
|
||||||
|
if (effect.is(registerTextPaste)) retained.push(effect.value);
|
||||||
|
}
|
||||||
|
return retained;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
class PasteChipWidget extends WidgetType {
|
class PasteChipWidget extends WidgetType {
|
||||||
readonly paste: ComposerPaste;
|
readonly paste: ComposerPaste;
|
||||||
|
|
||||||
@@ -120,15 +153,25 @@
|
|||||||
|
|
||||||
const pasteChips = [
|
const pasteChips = [
|
||||||
pasteRegistry,
|
pasteRegistry,
|
||||||
|
textPasteState,
|
||||||
|
invertedEffects.of((transaction) =>
|
||||||
|
transaction.docChanged
|
||||||
|
? [restoreTextPastes.of(transaction.startState.field(textPasteState))]
|
||||||
|
: []
|
||||||
|
),
|
||||||
EditorView.decorations.of((currentView) => pasteDecorations(currentView.state)),
|
EditorView.decorations.of((currentView) => pasteDecorations(currentView.state)),
|
||||||
EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)),
|
EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)),
|
||||||
];
|
];
|
||||||
|
|
||||||
function currentSnapshot(state = view?.state): ComposerDraftSnapshot {
|
function currentSnapshot(state = view?.state): ComposerDraftSnapshot {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return { document: "", content: "", segments: [], pastes: [] };
|
return { document: "", content: "", segments: [], pastes: [], textPastes: [] };
|
||||||
}
|
}
|
||||||
return snapshotComposerDraft(state.doc.toString(), state.field(pasteRegistry));
|
return snapshotComposerDraft(
|
||||||
|
state.doc.toString(),
|
||||||
|
state.field(pasteRegistry),
|
||||||
|
state.field(textPasteState),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitChange(): void {
|
function emitChange(): void {
|
||||||
@@ -155,6 +198,37 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertTextPaste(content: string): void {
|
||||||
|
if (!view) return;
|
||||||
|
const selection = view.state.selection.main;
|
||||||
|
const rendered = view.state.toText(content).toString();
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: selection.from, to: selection.to, insert: rendered },
|
||||||
|
selection: EditorSelection.cursor(selection.from + rendered.length),
|
||||||
|
effects: registerTextPaste.of({
|
||||||
|
from: selection.from,
|
||||||
|
to: selection.from + rendered.length,
|
||||||
|
rendered,
|
||||||
|
content,
|
||||||
|
}),
|
||||||
|
annotations: isolateHistory.of("full"),
|
||||||
|
userEvent: "input.paste",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePasteEvent(event: ClipboardEvent): boolean {
|
||||||
|
const content = event.clipboardData?.getData("text/plain");
|
||||||
|
if (!content) return false;
|
||||||
|
const measurement = measureComposerPaste(content);
|
||||||
|
event.preventDefault();
|
||||||
|
if (measurement.presentation === "chip") {
|
||||||
|
insertPasteChip(content, measurement);
|
||||||
|
} else {
|
||||||
|
insertTextPaste(content);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function selectedClipboardContent(state: EditorState): string | null {
|
function selectedClipboardContent(state: EditorState): string | null {
|
||||||
const selection = state.selection.main;
|
const selection = state.selection.main;
|
||||||
if (selection.empty) return null;
|
if (selection.empty) return null;
|
||||||
@@ -166,7 +240,14 @@
|
|||||||
selectedRegistry.set(atom.key, atom);
|
selectedRegistry.set(atom.key, atom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return snapshotComposerDraft(document, selectedRegistry).content;
|
const selectedTextPastes = state.field(textPasteState)
|
||||||
|
.filter((paste) => paste.from >= selection.from && paste.to <= selection.to)
|
||||||
|
.map((paste) => ({
|
||||||
|
...paste,
|
||||||
|
from: paste.from - selection.from,
|
||||||
|
to: paste.to - selection.from,
|
||||||
|
}));
|
||||||
|
return snapshotComposerDraft(document, selectedRegistry, selectedTextPastes).content;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteAdjacentPasteFromView(
|
function deleteAdjacentPasteFromView(
|
||||||
@@ -229,7 +310,7 @@
|
|||||||
}),
|
}),
|
||||||
Prec.high(EditorView.domEventHandlers({
|
Prec.high(EditorView.domEventHandlers({
|
||||||
paste(event) {
|
paste(event) {
|
||||||
return handleComposerPaste(event, insertPasteChip);
|
return handlePasteEvent(event);
|
||||||
},
|
},
|
||||||
copy(event, currentView) {
|
copy(event, currentView) {
|
||||||
const content = selectedClipboardContent(currentView.state);
|
const content = selectedClipboardContent(currentView.state);
|
||||||
@@ -327,14 +408,28 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreSegments(segments: readonly Segment[]): void {
|
export function restoreSegments(
|
||||||
|
segments: readonly Segment[],
|
||||||
|
preserveExactText = false,
|
||||||
|
): void {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
let document = "";
|
let document = "";
|
||||||
const effects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
|
const pasteEffects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
|
||||||
|
const textEffects: StateEffect<ComposerTextPaste>[] = [];
|
||||||
let highestPasteId = nextPasteId - 1;
|
let highestPasteId = nextPasteId - 1;
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
if (segment.kind === "text") {
|
if (segment.kind === "text") {
|
||||||
document += segment.content;
|
const rendered = view.state.toText(segment.content).toString();
|
||||||
|
const from = document.length;
|
||||||
|
document += rendered;
|
||||||
|
if (preserveExactText) {
|
||||||
|
textEffects.push(registerTextPaste.of({
|
||||||
|
from,
|
||||||
|
to: from + rendered.length,
|
||||||
|
rendered,
|
||||||
|
content: segment.content,
|
||||||
|
}));
|
||||||
|
}
|
||||||
} else if (segment.kind === "paste") {
|
} else if (segment.kind === "paste") {
|
||||||
const key = nextPasteKey++;
|
const key = nextPasteKey++;
|
||||||
const paste: ComposerPaste = {
|
const paste: ComposerPaste = {
|
||||||
@@ -345,7 +440,7 @@
|
|||||||
};
|
};
|
||||||
highestPasteId = Math.max(highestPasteId, paste.id);
|
highestPasteId = Math.max(highestPasteId, paste.id);
|
||||||
document += composerPasteToken(key);
|
document += composerPasteToken(key);
|
||||||
effects.push(registerPaste.of({ key, paste }));
|
pasteEffects.push(registerPaste.of({ key, paste }));
|
||||||
} else if (segment.kind === "file_ref") {
|
} else if (segment.kind === "file_ref") {
|
||||||
document += `@${segment.path}`;
|
document += `@${segment.path}`;
|
||||||
}
|
}
|
||||||
@@ -354,7 +449,7 @@
|
|||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: document },
|
changes: { from: 0, to: view.state.doc.length, insert: document },
|
||||||
selection: EditorSelection.cursor(document.length),
|
selection: EditorSelection.cursor(document.length),
|
||||||
effects,
|
effects: [...pasteEffects, ...textEffects],
|
||||||
userEvent: "input.restore",
|
userEvent: "input.restore",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,20 +80,44 @@ export function buildComposerRequest(value: string): ComposerCommandResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComposerSegmentsRequestOptions {
|
||||||
|
preserveExactText?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildComposerSegmentsRequest(
|
export function buildComposerSegmentsRequest(
|
||||||
sourceSegments: readonly Segment[],
|
sourceSegments: readonly Segment[],
|
||||||
|
options: ComposerSegmentsRequestOptions = {},
|
||||||
): ComposerCommandResult {
|
): ComposerCommandResult {
|
||||||
const hasPaste = sourceSegments.some((segment) => segment.kind === "paste");
|
const hasPaste = sourceSegments.some((segment) => segment.kind === "paste");
|
||||||
if (!hasPaste) {
|
if (!hasPaste) {
|
||||||
const content = sourceSegments.map(segmentContent).join("");
|
const content = sourceSegments.map(segmentContent).join("");
|
||||||
|
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
|
||||||
return buildComposerRequest(content);
|
return buildComposerRequest(content);
|
||||||
}
|
}
|
||||||
|
if (!content.trim()) {
|
||||||
|
return { ok: false, message: "Input is empty." };
|
||||||
|
}
|
||||||
|
const segments = coalesceTextSegments(
|
||||||
|
sourceSegments.flatMap((segment) =>
|
||||||
|
segment.kind === "text"
|
||||||
|
? parseSigilSegments(segment.content)
|
||||||
|
: [segment]
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
request: { kind: "user", content, segments },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const content = sourceSegments.map(segmentContent).join("");
|
const content = sourceSegments.map(segmentContent).join("");
|
||||||
if (!content.trim()) {
|
if (!content.trim()) {
|
||||||
return { ok: false, message: "Input is empty." };
|
return { ok: false, message: "Input is empty." };
|
||||||
}
|
}
|
||||||
if (content.trimStart().startsWith(":")) {
|
const leadingText = sourceSegments[0]?.kind === "text"
|
||||||
|
? sourceSegments[0].content
|
||||||
|
: "";
|
||||||
|
if (leadingText.trimStart().startsWith(":")) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -134,6 +134,70 @@ Deno.test("mixed composer request preserves Paste and parsed file-ref boundaries
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("short-paste Text preserves CRLF, trailing newline, and surrounding whitespace", () => {
|
||||||
|
const original = " short\r\npaste\r\n ";
|
||||||
|
const rendered = " short\npaste\n ";
|
||||||
|
const snapshot = snapshotComposerDraft(rendered, new Map(), [{
|
||||||
|
from: 0,
|
||||||
|
to: rendered.length,
|
||||||
|
rendered,
|
||||||
|
content: original,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assertEquals(snapshot.content, original);
|
||||||
|
assertEquals(snapshot.segments, [{ kind: "text", content: original }]);
|
||||||
|
assertEquals(snapshot.textPastes.length, 1);
|
||||||
|
|
||||||
|
const result = buildComposerSegmentsRequest(snapshot.segments, {
|
||||||
|
preserveExactText: snapshot.textPastes.length > 0,
|
||||||
|
});
|
||||||
|
assert(result.ok);
|
||||||
|
assertEquals(result.request, {
|
||||||
|
kind: "user",
|
||||||
|
content: original,
|
||||||
|
segments: [{ kind: "text", content: original }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("edited short-paste provenance falls back to visible Text", () => {
|
||||||
|
const snapshot = snapshotComposerDraft("changed", new Map(), [{
|
||||||
|
from: 0,
|
||||||
|
to: 5,
|
||||||
|
rendered: "short",
|
||||||
|
content: "short\r\n",
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assertEquals(snapshot.content, "changed");
|
||||||
|
assertEquals(snapshot.segments, [{ kind: "text", content: "changed" }]);
|
||||||
|
assertEquals(snapshot.textPastes, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Paste content beginning with a colon remains opaque user input", () => {
|
||||||
|
const directPaste: Segment = {
|
||||||
|
kind: "paste",
|
||||||
|
id: 3,
|
||||||
|
content: ":not-a-command\r\n",
|
||||||
|
chars: 16,
|
||||||
|
lines: 2,
|
||||||
|
};
|
||||||
|
const direct = buildComposerSegmentsRequest([directPaste]);
|
||||||
|
assert(direct.ok);
|
||||||
|
assertEquals(direct.request, {
|
||||||
|
kind: "user",
|
||||||
|
content: ":not-a-command\r\n",
|
||||||
|
segments: [directPaste],
|
||||||
|
});
|
||||||
|
|
||||||
|
const afterWhitespace = buildComposerSegmentsRequest([
|
||||||
|
{ kind: "text", content: " " },
|
||||||
|
directPaste,
|
||||||
|
]);
|
||||||
|
assert(afterWhitespace.ok);
|
||||||
|
assert(afterWhitespace.request);
|
||||||
|
assertEquals(afterWhitespace.request.kind, "user");
|
||||||
|
assertEquals(afterWhitespace.request.content, " :not-a-command\r\n");
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("plain short-paste Text retains the existing composer request path", () => {
|
Deno.test("plain short-paste Text retains the existing composer request path", () => {
|
||||||
const result = buildComposerSegmentsRequest([
|
const result = buildComposerSegmentsRequest([
|
||||||
{ kind: "text", content: " short\r\npaste\r\n " },
|
{ kind: "text", content: " short\r\npaste\r\n " },
|
||||||
|
|||||||
@@ -17,11 +17,19 @@ export interface ComposerPasteAtom extends ComposerPaste {
|
|||||||
to: number;
|
to: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComposerTextPaste {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
rendered: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ComposerDraftSnapshot {
|
export interface ComposerDraftSnapshot {
|
||||||
document: string;
|
document: string;
|
||||||
content: string;
|
content: string;
|
||||||
segments: Segment[];
|
segments: Segment[];
|
||||||
pastes: ComposerPasteAtom[];
|
pastes: ComposerPasteAtom[];
|
||||||
|
textPastes: ComposerTextPaste[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function composerPasteToken(key: number): string {
|
export function composerPasteToken(key: number): string {
|
||||||
@@ -60,32 +68,60 @@ function appendTextSegment(segments: Segment[], content: string): void {
|
|||||||
export function snapshotComposerDraft(
|
export function snapshotComposerDraft(
|
||||||
document: string,
|
document: string,
|
||||||
registry: ReadonlyMap<number, ComposerPaste>,
|
registry: ReadonlyMap<number, ComposerPaste>,
|
||||||
|
candidateTextPastes: readonly ComposerTextPaste[] = [],
|
||||||
): ComposerDraftSnapshot {
|
): ComposerDraftSnapshot {
|
||||||
const pastes = composerPasteAtoms(document, registry);
|
const pastes = composerPasteAtoms(document, registry);
|
||||||
|
const textPastes = candidateTextPastes
|
||||||
|
.filter((paste) =>
|
||||||
|
paste.from >= 0 &&
|
||||||
|
paste.to <= document.length &&
|
||||||
|
document.slice(paste.from, paste.to) === paste.rendered
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.from - right.from);
|
||||||
|
const events = [
|
||||||
|
...pastes.map((paste) => ({
|
||||||
|
kind: "paste" as const,
|
||||||
|
from: paste.from,
|
||||||
|
to: paste.to,
|
||||||
|
paste,
|
||||||
|
})),
|
||||||
|
...textPastes.map((paste) => ({
|
||||||
|
kind: "text_paste" as const,
|
||||||
|
from: paste.from,
|
||||||
|
to: paste.to,
|
||||||
|
paste,
|
||||||
|
})),
|
||||||
|
].sort((left, right) => left.from - right.from);
|
||||||
const segments: Segment[] = [];
|
const segments: Segment[] = [];
|
||||||
let content = "";
|
let content = "";
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
for (const paste of pastes) {
|
for (const event of events) {
|
||||||
const text = document.slice(cursor, paste.from);
|
if (event.from < cursor) continue;
|
||||||
|
const text = document.slice(cursor, event.from);
|
||||||
appendTextSegment(segments, text);
|
appendTextSegment(segments, text);
|
||||||
content += text;
|
content += text;
|
||||||
|
|
||||||
|
if (event.kind === "paste") {
|
||||||
segments.push({
|
segments.push({
|
||||||
kind: "paste",
|
kind: "paste",
|
||||||
id: paste.id,
|
id: event.paste.id,
|
||||||
content: paste.content,
|
content: event.paste.content,
|
||||||
chars: paste.chars,
|
chars: event.paste.chars,
|
||||||
lines: paste.lines,
|
lines: event.paste.lines,
|
||||||
});
|
});
|
||||||
content += paste.content;
|
} else {
|
||||||
cursor = paste.to;
|
appendTextSegment(segments, event.paste.content);
|
||||||
|
}
|
||||||
|
content += event.paste.content;
|
||||||
|
cursor = event.to;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trailingText = document.slice(cursor);
|
const trailingText = document.slice(cursor);
|
||||||
appendTextSegment(segments, trailingText);
|
appendTextSegment(segments, trailingText);
|
||||||
content += trailingText;
|
content += trailingText;
|
||||||
|
|
||||||
return { document, content, segments, pastes };
|
return { document, content, segments, pastes, textPastes };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pasteChipLabel(paste: ComposerPaste): string {
|
export function pasteChipLabel(paste: ComposerPaste): string {
|
||||||
|
|||||||
@@ -599,13 +599,15 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
|
|||||||
new URL("./ComposerInput.svelte", import.meta.url),
|
new URL("./ComposerInput.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
composerInput.includes("handleComposerPaste(event, insertPasteChip)") &&
|
composerInput.includes('measurement.presentation === "chip"') &&
|
||||||
|
composerInput.includes("registerTextPaste") &&
|
||||||
composerInput.includes("EditorView.atomicRanges") &&
|
composerInput.includes("EditorView.atomicRanges") &&
|
||||||
composerInput.includes('key: "Backspace"') &&
|
composerInput.includes('key: "Backspace"') &&
|
||||||
composerInput.includes('key: "Delete"') &&
|
composerInput.includes('key: "Delete"') &&
|
||||||
composerInput.includes('chip.setAttribute("aria-label", label)') &&
|
composerInput.includes('chip.setAttribute("aria-label", label)') &&
|
||||||
composerInput.includes("restoreSegments(segments: readonly Segment[])") &&
|
composerInput.includes("preserveExactText = false") &&
|
||||||
consolePage.includes("buildComposerSegmentsRequest(value.segments)") &&
|
consolePage.includes("buildComposerSegmentsRequest(value.segments, {") &&
|
||||||
|
consolePage.includes("preserveExactText: value.textPastes.length > 0") &&
|
||||||
consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
|
consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
|
||||||
consolePage.includes("switchComposerTarget(target)") &&
|
consolePage.includes("switchComposerTarget(target)") &&
|
||||||
consolePage.includes('sendControl({ method: "cancel" }, "Stop")'),
|
consolePage.includes('sendControl({ method: "cancel" }, "Stop")'),
|
||||||
|
|||||||
+31
-7
@@ -109,7 +109,15 @@
|
|||||||
cursor(): number;
|
cursor(): number;
|
||||||
replaceRange(from: number, to: number, content: string): void;
|
replaceRange(from: number, to: number, content: string): void;
|
||||||
clear(): void;
|
clear(): void;
|
||||||
restoreSegments(segments: readonly Segment[]): void;
|
restoreSegments(
|
||||||
|
segments: readonly Segment[],
|
||||||
|
preserveExactText?: boolean,
|
||||||
|
): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComposerDraftCache = {
|
||||||
|
segments: Segment[];
|
||||||
|
preserveExactText: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_DRAFT: ComposerDraftSnapshot = {
|
const EMPTY_DRAFT: ComposerDraftSnapshot = {
|
||||||
@@ -117,6 +125,7 @@
|
|||||||
content: "",
|
content: "",
|
||||||
segments: [],
|
segments: [],
|
||||||
pastes: [],
|
pastes: [],
|
||||||
|
textPastes: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
|
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
|
||||||
@@ -149,7 +158,7 @@
|
|||||||
let composerInputElement = $state<
|
let composerInputElement = $state<
|
||||||
(SvelteComponent & ComposerInputHandle) | null
|
(SvelteComponent & ComposerInputHandle) | null
|
||||||
>(null);
|
>(null);
|
||||||
const composerDrafts = new Map<string, Segment[]>();
|
const composerDrafts = new Map<string, ComposerDraftCache>();
|
||||||
let activeComposerTargetKey = untrack(
|
let activeComposerTargetKey = untrack(
|
||||||
() => `${workspaceId}:${runtimeId}:${workerId}`,
|
() => `${workspaceId}:${runtimeId}:${workerId}`,
|
||||||
);
|
);
|
||||||
@@ -549,9 +558,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cachedComposerDraft(snapshot: ComposerDraftSnapshot): ComposerDraftCache {
|
||||||
|
return {
|
||||||
|
segments: [...snapshot.segments],
|
||||||
|
preserveExactText: snapshot.textPastes.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function handleComposerChange(snapshot: ComposerDraftSnapshot) {
|
function handleComposerChange(snapshot: ComposerDraftSnapshot) {
|
||||||
draft = snapshot;
|
draft = snapshot;
|
||||||
composerDrafts.set(activeComposerTargetKey, [...snapshot.segments]);
|
composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchComposerTarget(target: ConsoleTarget) {
|
function switchComposerTarget(target: ConsoleTarget) {
|
||||||
@@ -560,14 +576,20 @@
|
|||||||
if (composerInputElement) {
|
if (composerInputElement) {
|
||||||
composerDrafts.set(
|
composerDrafts.set(
|
||||||
activeComposerTargetKey,
|
activeComposerTargetKey,
|
||||||
[...composerInputElement.snapshot().segments],
|
cachedComposerDraft(composerInputElement.snapshot()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
activeComposerTargetKey = nextKey;
|
activeComposerTargetKey = nextKey;
|
||||||
const restored = composerDrafts.get(nextKey) ?? [];
|
const restored = composerDrafts.get(nextKey) ?? {
|
||||||
|
segments: [],
|
||||||
|
preserveExactText: false,
|
||||||
|
};
|
||||||
void tick().then(() => {
|
void tick().then(() => {
|
||||||
if (activeComposerTargetKey !== nextKey) return;
|
if (activeComposerTargetKey !== nextKey) return;
|
||||||
composerInputElement?.restoreSegments(restored);
|
composerInputElement?.restoreSegments(
|
||||||
|
restored.segments,
|
||||||
|
restored.preserveExactText,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +602,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitDraft(value: ComposerDraftSnapshot) {
|
async function submitDraft(value: ComposerDraftSnapshot) {
|
||||||
const command = buildComposerSegmentsRequest(value.segments);
|
const command = buildComposerSegmentsRequest(value.segments, {
|
||||||
|
preserveExactText: value.textPastes.length > 0,
|
||||||
|
});
|
||||||
if (!command.ok) {
|
if (!command.ok) {
|
||||||
composerNotice = null;
|
composerNotice = null;
|
||||||
sendError = command.message;
|
sendError = command.message;
|
||||||
|
|||||||
Reference in New Issue
Block a user