Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to Quoll are documented here.

## Unreleased

### Fixed

- Pasting text, tables, or rich content together with files now inserts the text and accepted image links together in one undoable step. Captions survive rejected files, and image links keep their clipboard order even when replies arrive out of order.
- Undoing while a paste is waiting for images preserves Redo and prevents late image replies from changing the document. Moving the caret while waiting keeps the paste at its original target without moving you back.

## 0.1.86 — 2026-11-03

### Added
Expand Down
535 changes: 125 additions & 410 deletions src/webview/cm/image/image-paste.ts

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion src/webview/cm/image/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export { imageBlockField } from "./image-field.js";
export { createImagePasteDrop } from "./image-paste.js";
export { ImageBlockWidget } from "./image-widget.js";
export { quollResourceBaseUri } from "./resource-base.js";
133 changes: 133 additions & 0 deletions src/webview/cm/paste/clipboard-paste.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { type EditorState, Prec } from "@codemirror/state";
import { EditorView } from "@codemirror/view";

import type { WebviewToHost } from "../../../shared/protocol.js";
import {
createImageFileTransport,
filesFromTransfer,
imageFileDrop,
} from "../image/image-paste.js";
import { decideTablePaste } from "./html-table-paste.js";
import { decideListPaste } from "./list-reindent.js";
import { createPasteOperationCoordinator, type PasteDecision } from "./paste-operation.js";
import { decideRichPaste } from "./rich-html-paste.js";
import { decideUrlPaste } from "./url-link-paste.js";

// Capture each clipboard flavour and File once, before the DOM event returns.
export function snapshotClipboard(data: DataTransfer | null) {
const types = Array.from(data?.types ?? []);
const flavour = (type: string) => ({
present: types.includes(type),
value: data?.getData(type) ?? "",
});
return {
plain: flavour("text/plain"),
html: flavour("text/html"),
uri: flavour("text/uri-list"),
files: filesFromTransfer(data),
};
}

/** First applicable representation owns its complete replacement range. */
export function decideClipboardPaste(
snapshot: ReturnType<typeof snapshotClipboard>,
state: EditorState
): PasteDecision | null {
const table = decideTablePaste(state, snapshot.html.value);
if (table) {
return table;
}
const url = decideUrlPaste(state, snapshot.plain.value);
if (url) {
return url;
}
const list = decideListPaste(state, snapshot.plain.value);
if (list) {
return list;
}
const rich = decideRichPaste(
state,
snapshot.html.value,
snapshot.plain.value || snapshot.uri.value,
snapshot.files.length > 0
);
if (rich?.emittedMarkdownSyntax) {
return rich;
}
if (rich) {
const text = snapshot.plain.value || snapshot.uri.value;
return text ? { from: rich.from, to: rich.to, render: () => text } : rich;
}
return null;
}

/** Single clipboard owner; File drops share its coordinator and transport. */
export function createClipboardPaste(options: {
canWrite: () => boolean;
post: (message: WebviewToHost) => void;
onFailure: () => void;
onUnlinkedPath?: (path: string) => void;
}) {
const reportUnlinked =
options.onUnlinkedPath ??
((relativePath: string) => {
console.warn("[quoll] pasted image written but not linked", { relativePath });
});
const transport = createImageFileTransport({ post: options.post });
let capturedFiles: readonly File[] = [];
const coordinator = createPasteOperationCoordinator({
canWrite: options.canWrite,
onFailure: options.onFailure,
onUnlinkedPath: reportUnlinked,
startResources: (id) =>
transport.start(capturedFiles, (slot, path) => coordinator.settle(id, slot, path)),
onReady: (id): void => {
coordinator.complete(id);
},
});
return {
extension: [
coordinator.extension,
imageFileDrop({
canWrite: options.canWrite,
capture(view, files, from) {
capturedFiles = files;
coordinator.capture(view, { from, to: from, render: () => "" }, files.length);
capturedFiles = [];
},
}),
Prec.high(
EditorView.domEventHandlers({
paste(event, view) {
const snapshot = snapshotClipboard(event.clipboardData);
const text = snapshot.plain.value || snapshot.uri.value;
const decided = decideClipboardPaste(snapshot, view.state);
if (!snapshot.files.length && text && !decided) {
return false;
}
event.preventDefault();
if (!options.canWrite() || view.state.readOnly) {
return true;
}
if (!snapshot.files.length && !text && !decided) {
options.onFailure();
return true;
}
const { from, to } = view.state.selection.main;
const decision: PasteDecision = decided ?? { from, to, render: () => text };
capturedFiles = snapshot.files;
coordinator.capture(view, decision, snapshot.files.length);
capturedFiles = [];
return true;
},
})
),
],
resolve(requestId: string, path: string | null) {
if (!transport.resolve(requestId, path) && path !== null) {
reportUnlinked(path);
}
},
dispose: coordinator.dispose,
};
}
68 changes: 14 additions & 54 deletions src/webview/cm/paste/html-table-paste.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,4 @@
// CodeMirror paste handler: when the clipboard carries a `text/html` flavour
// containing a `<table>`, insert an equivalent GFM Markdown table instead of the
// raw HTML/plain-text. Every other paste is left untouched — the handler returns
// `false` (without preventDefault) so `pasteUrlOverSelection` (url-link-paste.ts),
// the image paste pipeline (image-paste.ts), and CM's default plain-text paste
// still run.
//
// Mounted at Prec.high so it arbitrates before those handlers: on a convertible
// table it consumes the event; otherwise it defers. The insert rides the normal
// dispatch → updateListener → edit-sync → host write-lock → applyEdit settlement
// pipeline (no raw write path).

import { type Extension, Prec } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { EditorState } from "@codemirror/state";

import { htmlTableToGfm } from "./html-table-to-gfm.js";

Expand All @@ -36,44 +23,17 @@ function blockSuffix(after: string): string {
return after.startsWith("\n") ? "\n" : "\n\n";
}

export function htmlTablePaste(opts: { canWrite: () => boolean }): Extension {
return Prec.high(
EditorView.domEventHandlers({
paste: (event, view) => {
const html = event.clipboardData?.getData("text/html");
if (!html) {
return false; // no HTML flavour → let normal paste handlers run
}
const gfm = htmlTableToGfm(html);
if (gfm === null) {
return false; // not a convertible table → defer to the other handlers
}
// preventDefault ONLY here, AFTER the null-check. This is intentionally the
// OPPOSITE order from imagePaste (which preventDefaults before its
// capability check): moving it earlier would swallow non-table HTML pastes.
event.preventDefault();
// Read-only: swallow silently with NO fallback insert (mirrors imagePaste).
// canWrite() is the same source that drives EditorState.readOnly, so they
// cannot diverge — no redundant view.state.readOnly check.
if (!opts.canWrite()) {
return true;
}
// Insert at the MAIN selection range; multi-cursor collapses to it (a block
// table under multi-cursor is degenerate — acceptable). The handler runs
// synchronously inside the live DOM event, so the view is always alive at
// dispatch time (unlike imagePaste's async FileReader path).
const { from, to } = view.state.selection.main;
const before = view.state.doc.sliceString(0, from);
const after = view.state.doc.sliceString(to);
const insert = blockPrefix(before) + gfm + blockSuffix(after);
view.dispatch({
changes: { from, to, insert },
selection: { anchor: from + insert.length },
scrollIntoView: true,
userEvent: "input.paste",
});
return true;
},
})
);
/** Capture conversion once; frame it against the mapped replacement range. */
export function decideTablePaste(state: EditorState, html: string) {
const gfm = htmlTableToGfm(html);
if (gfm === null) {
return null;
}
const { from, to } = state.selection.main;
return {
from,
to,
render: (live: EditorState, start: number, end: number) =>
blockPrefix(live.doc.sliceString(0, start)) + gfm + blockSuffix(live.doc.sliceString(end)),
};
}
32 changes: 10 additions & 22 deletions src/webview/cm/paste/html-to-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,14 @@
// (happy-dom provides it under test), so this stays inside Quoll's supply-chain
// default-deny.
//
// TWO output channels, both of which INVITE the caller to prefer the clipboard's
// own bytes. Neither commands it — what the caller does depends on what else the
// clipboard carries, so do not assume either output string is unreachable:
// - `null` — nothing convertible (empty walk, cap breached, parse error). The
// caller normally defers, but when NOTHING can absorb the defer — no plain/uri
// fallback, no image file item for imagePaste — over a non-empty selection it
// SWALLOWS the event instead (deferring would let CM's `doPaste("")` delete the
// selection) — no paste of any kind happens.
// TWO output channels, interpreted by the clipboard coordinator:
// - `null` — nothing convertible. `decideRichPaste` returns null, and the
// clipboard's own text (if any) is pasted by `decideClipboardPaste`.
// - `emittedMarkdownSyntax === false` — the walk produced escaped text and line
// structure only. The conversion is valid Markdown, but the caller prefers the
// clipboard's own `text/plain` bytes over this module's escaped rendering.
// With no plain flavour — or with an image file riding along, where imagePaste
// keeps the plain flavour from ever landing — it inserts this module's output
// after all, so the escaped rendering is a live path, not dead code.
// This is the dominant path for clipboards that carry a merely presentational
// HTML flavour. See the `HtmlToMarkdownResult` docblock at the bottom.
// structure only. When `text/plain` or `text/uri-list` exists,
// `decideClipboardPaste` inserts those clipboard bytes. This module's escaped
// rendering is used only when neither flavour exists (an HTML-only clipboard).
// See the `HtmlToMarkdownResult` docblock at the bottom.
//
// Design notes (why each choice, so a future edit doesn't regress it):
// - Structure is read via an EXPLICIT direct-child walk (Array.from(childNodes)),
Expand All @@ -38,13 +30,9 @@
// construct. Pasted text therefore never activates a construct that the same
// text typed by hand would not. NOTE this escaping is precisely what makes the
// conversion unusable for a syntax-free fragment — Markdown the user typed by
// hand would come back as `\- \[ \]` — which is why the caller defers on
// `emittedMarkdownSyntax === false`. On an ordinary clipboard, one carrying a
// safe plain fallback, that defer inserts the clipboard's own bytes verbatim,
// exactly as typing them would. It is NOT unconditional: the caller inserts this
// escaped rendering after all whenever the plain flavour cannot land — there is
// none (an HTML-only clipboard), or an image file item rides along and
// imagePaste consumes the event before CM's plain paste can run.
// hand would come back as `\- \[ \]`. When `emittedMarkdownSyntax === false`,
// the coordinator uses the clipboard's plain or URI-list text when available;
// this escaped rendering is used for an HTML-only clipboard.
// - Whether a container is visually EMPTY is decided by ONE rule —
// `blankAfterInvisible` (full-strip of the `\p{DI} ∪ \p{Cf}` format+ignorable class +
// whitespace), measured over
Expand Down
7 changes: 3 additions & 4 deletions src/webview/cm/paste/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export { htmlTablePaste } from "./html-table-paste.js";
export { createClipboardPaste } from "./clipboard-paste.js";
export { htmlTableToGfm, tableElementToGfm } from "./html-table-to-gfm.js";
export { type HtmlToMarkdownResult, htmlToMarkdown } from "./html-to-markdown.js";
export { listReindentPaste, reindentPastedList } from "./list-reindent.js";
export { richHtmlPaste } from "./rich-html-paste.js";
export { detectPasteLinkUrl, pasteUrlOverSelection } from "./url-link-paste.js";
export { reindentPastedList } from "./list-reindent.js";
export { detectPasteLinkUrl } from "./url-link-paste.js";
Loading
Loading