Skip to content

Commit fef97ba

Browse files
fix(editor): preserve copied note images
Add note clipboard text serialization that keeps selected image nodes as markdown image references.
1 parent ae72d71 commit fef97ba

4 files changed

Lines changed: 119 additions & 0 deletions

File tree

packages/editor/src/note/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
type PlaceholderFunction,
4949
SearchQuery,
5050
clearMarksOnEnterPlugin,
51+
clipboardPlugin,
5152
clipPastePlugin,
5253
ensureImageTrailingParagraphs,
5354
fileHandlerPlugin,
@@ -445,6 +446,7 @@ export const NoteEditor = forwardRef<NoteEditorRef, NoteEditorProps>(
445446
history(),
446447
dropCursor(),
447448
gapCursor(),
449+
clipboardPlugin(),
448450
hashtagPlugin(),
449451
imageTrailingParagraphPlugin(),
450452
searchPlugin(),
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Fragment, Slice } from "prosemirror-model";
2+
import { describe, expect, test } from "vitest";
3+
4+
import { schema } from "../note/schema";
5+
import { serializeClipboardText } from "./clipboard";
6+
7+
describe("serializeClipboardText", () => {
8+
test("serializes selected images as markdown", () => {
9+
const slice = new Slice(
10+
Fragment.fromArray([
11+
schema.nodes.paragraph.create(null, schema.text("Before")),
12+
schema.nodes.image.create({
13+
src: "asset://localhost/session/image.png",
14+
alt: "diagram",
15+
title: "Architecture",
16+
editorWidth: 64,
17+
}),
18+
schema.nodes.paragraph.create(null, schema.text("After")),
19+
]),
20+
0,
21+
0,
22+
);
23+
24+
expect(serializeClipboardText(slice)).toBe(
25+
'Before\n\n![diagram](asset://localhost/session/image.png "char-editor-width=64|Architecture")\n\nAfter',
26+
);
27+
});
28+
29+
test("escapes markdown image fields", () => {
30+
const slice = new Slice(
31+
Fragment.from(
32+
schema.nodes.image.create({
33+
src: "https://example.com/screenshots/a(b).png",
34+
alt: "diagram] detail",
35+
title: 'Quote "title"',
36+
editorWidth: 42,
37+
}),
38+
),
39+
0,
40+
0,
41+
);
42+
43+
expect(serializeClipboardText(slice)).toBe(
44+
'![diagram\\] detail](https://example.com/screenshots/a\\(b\\).png "char-editor-width=42|Quote \\"title\\"")',
45+
);
46+
});
47+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Node as PMNode, Slice } from "prosemirror-model";
2+
import { Plugin, PluginKey } from "prosemirror-state";
3+
4+
const EDITOR_WIDTH_PREFIX = "char-editor-width=";
5+
const MIN_EDITOR_WIDTH = 15;
6+
const MAX_EDITOR_WIDTH = 100;
7+
8+
function normalizeEditorWidth(value: unknown) {
9+
if (typeof value !== "number" || Number.isNaN(value)) {
10+
return null;
11+
}
12+
13+
return Math.min(
14+
MAX_EDITOR_WIDTH,
15+
Math.max(MIN_EDITOR_WIDTH, Math.round(value)),
16+
);
17+
}
18+
19+
function serializeImageTitleMetadata({
20+
editorWidth,
21+
title,
22+
}: {
23+
editorWidth?: number | null;
24+
title?: string | null;
25+
}) {
26+
const normalizedTitle = title || null;
27+
const normalizedWidth = normalizeEditorWidth(editorWidth);
28+
29+
if (!normalizedWidth) {
30+
return normalizedTitle;
31+
}
32+
33+
return normalizedTitle
34+
? `${EDITOR_WIDTH_PREFIX}${normalizedWidth}|${normalizedTitle}`
35+
: `${EDITOR_WIDTH_PREFIX}${normalizedWidth}`;
36+
}
37+
38+
function imageLeafText(node: PMNode) {
39+
const alt = (node.attrs.alt || "").replace(/]/g, "\\]");
40+
const src = node.attrs.src ? node.attrs.src.replace(/[()]/g, "\\$&") : "";
41+
const title = serializeImageTitleMetadata({
42+
editorWidth: node.attrs.editorWidth,
43+
title: node.attrs.title,
44+
});
45+
const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : "";
46+
47+
return `![${alt}](${src}${titlePart})`;
48+
}
49+
50+
export function serializeClipboardText(slice: Slice) {
51+
return slice.content.textBetween(0, slice.content.size, "\n\n", (node) => {
52+
if (node.type.name === "image") {
53+
return imageLeafText(node);
54+
}
55+
56+
return node.textContent;
57+
});
58+
}
59+
60+
export function clipboardPlugin() {
61+
return new Plugin({
62+
key: new PluginKey("clipboard"),
63+
props: {
64+
clipboardTextSerializer(slice) {
65+
return serializeClipboardText(slice);
66+
},
67+
},
68+
});
69+
}

packages/editor/src/plugins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { appLinkPastePlugin } from "./app-link-paste";
2+
export { clipboardPlugin, serializeClipboardText } from "./clipboard";
23
export { clearMarksOnEnterPlugin } from "./clear-marks-on-enter";
34
export {
45
clipNodeSpec,

0 commit comments

Comments
 (0)