Skip to content

Commit d083235

Browse files
committed
fix: handle SuperDoc context-menu paste
1 parent ed9a9f6 commit d083235

6 files changed

Lines changed: 264 additions & 25 deletions

File tree

packages/super-editor/src/editors/v1/core/InputRule.js

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ import {
2222
SUPERDOC_MEDIA_MIME,
2323
SUPERDOC_SLICE_ATTR,
2424
SUPERDOC_BODY_SECT_PR_ATTR,
25+
SUPERDOC_MEDIA_ATTR,
2526
embedSliceInHtml,
2627
extractSliceFromHtml,
2728
stripSliceFromHtml,
2829
extractBodySectPrFromHtml,
30+
extractMediaFromHtml,
2931
bodySectPrShouldEmbed,
3032
collectReferencedImageMediaForClipboard,
3133
applySuperdocClipboardMedia,
@@ -35,7 +37,11 @@ import { annotateFragmentDomWithClipboardData } from './helpers/clipboardFragmen
3537
/** Heuristic: clipboard HTML from SuperDoc copy (slice attrs, list/section metadata). */
3638
export function isSuperdocOriginClipboardHtml(html) {
3739
if (!html || typeof html !== 'string') return false;
38-
if (html.includes(SUPERDOC_SLICE_ATTR) || html.includes(SUPERDOC_BODY_SECT_PR_ATTR)) {
40+
if (
41+
html.includes(SUPERDOC_SLICE_ATTR) ||
42+
html.includes(SUPERDOC_BODY_SECT_PR_ATTR) ||
43+
html.includes(SUPERDOC_MEDIA_ATTR)
44+
) {
3945
return true;
4046
}
4147
if (/data-sd-sect-pr\s*=/i.test(html)) {
@@ -315,10 +321,11 @@ export const inputRulesPlugin = ({ editor, rules }) => {
315321
const rawHtml = clipboard.getData('text/html');
316322
const isSuperdocHtml = isSuperdocOriginClipboardHtml(rawHtml);
317323
const embeddedBodySectPr = isSuperdocHtml ? extractBodySectPrFromHtml(rawHtml) : null;
324+
const embeddedMedia = isSuperdocHtml ? extractMediaFromHtml(rawHtml) : '';
318325

319326
let superdocSliceData = clipboard.getData(SUPERDOC_SLICE_MIME) || extractSliceFromHtml(rawHtml);
320327
if (isSuperdocHtml || superdocSliceData) {
321-
superdocSliceData = applySuperdocClipboardMedia(editor, clipboard, superdocSliceData || null);
328+
superdocSliceData = applySuperdocClipboardMedia(editor, clipboard, superdocSliceData || null, embeddedMedia);
322329
}
323330
if (superdocSliceData) {
324331
try {
@@ -634,10 +641,8 @@ export function sanitizeHtml(html, forbiddenTags = ['meta', 'svg', 'script', 'st
634641
}
635642

636643
/**
637-
* Reusable paste-handling utility that replicates the logic formerly held only
638-
* inside the `inputRulesPlugin` paste handler. This allows other components
639-
* (e.g. context-menu items) to invoke the same paste logic without duplicating
640-
* code.
644+
* Handles clipboard content that was read outside the native paste event, such
645+
* as the context-menu Paste action.
641646
*
642647
* @param {Object} params
643648
* @param {Editor} params.editor The SuperEditor instance.
@@ -647,13 +652,33 @@ export function sanitizeHtml(html, forbiddenTags = ['meta', 'svg', 'script', 'st
647652
* @returns {Boolean} Whether the paste was handled.
648653
*/
649654
export function handleClipboardPaste({ editor, view }, html, plainText) {
655+
const rawHtml = html || '';
656+
const isSuperdocHtml = isSuperdocOriginClipboardHtml(rawHtml);
657+
const embeddedBodySectPr = isSuperdocHtml ? extractBodySectPrFromHtml(rawHtml) : null;
658+
const embeddedMedia = isSuperdocHtml ? extractMediaFromHtml(rawHtml) : '';
659+
let pasteHtml = rawHtml;
660+
661+
let superdocSliceData = extractSliceFromHtml(rawHtml);
662+
if (superdocSliceData) {
663+
superdocSliceData = applySuperdocClipboardMedia(editor, null, superdocSliceData, embeddedMedia);
664+
try {
665+
if (handleSuperdocSlicePaste(superdocSliceData, editor, view, embeddedBodySectPr)) return true;
666+
} catch (err) {
667+
console.warn('Failed to paste SuperDoc slice, falling back to HTML:', err);
668+
}
669+
}
670+
671+
if (isSuperdocHtml) {
672+
pasteHtml = stripSliceFromHtml(rawHtml);
673+
}
674+
650675
let source;
651676

652-
if (!html) {
677+
if (!pasteHtml) {
653678
source = 'plain-text';
654-
} else if (isWordHtml(html)) {
679+
} else if (isWordHtml(pasteHtml)) {
655680
source = 'word-html';
656-
} else if (isGoogleDocsHtml(html)) {
681+
} else if (isGoogleDocsHtml(pasteHtml)) {
657682
source = 'google-docs';
658683
} else {
659684
source = 'browser-html';
@@ -667,15 +692,31 @@ export function handleClipboardPaste({ editor, view }, html, plainText) {
667692
return handlePlainTextUrlPaste(editor, view, plainText, detected);
668693
}
669694
case 'word-html':
670-
if (editor.options.mode === 'docx' && !isSuperdocOriginClipboardHtml(html)) {
671-
return handleDocxPaste(html, editor, view);
695+
if (editor.options.mode === 'docx' && !isSuperdocHtml) {
696+
return handleDocxPaste(pasteHtml, editor, view);
672697
}
673-
return handleHtmlPaste(html, editor);
674-
case 'google-docs':
675-
return handleGoogleDocsHtml(html, editor, view);
698+
{
699+
const ok = handleHtmlPaste(pasteHtml, editor);
700+
if (ok && embeddedBodySectPr) {
701+
tryApplyEmbeddedBodySectPr(editor, view, embeddedBodySectPr);
702+
}
703+
return ok;
704+
}
705+
case 'google-docs': {
706+
const ok = handleGoogleDocsHtml(pasteHtml, editor, view);
707+
if (ok && embeddedBodySectPr) {
708+
tryApplyEmbeddedBodySectPr(editor, view, embeddedBodySectPr);
709+
}
710+
return ok;
711+
}
676712
// falls through to browser-html handling when not in DOCX mode
677-
case 'browser-html':
678-
return handleHtmlPaste(html, editor);
713+
case 'browser-html': {
714+
const ok = handleHtmlPaste(pasteHtml, editor);
715+
if (ok && embeddedBodySectPr) {
716+
tryApplyEmbeddedBodySectPr(editor, view, embeddedBodySectPr);
717+
}
718+
return ok;
719+
}
679720
}
680721

681722
return false;
@@ -709,7 +750,7 @@ function handleCutEvent(view, event, editor) {
709750
const html = unflattenListsInHtml(div.innerHTML);
710751
const bodySectPr = view.state.doc.attrs?.bodySectPr;
711752
const bodySectPrJson = bodySectPr && bodySectPrShouldEmbed(bodySectPr) ? JSON.stringify(bodySectPr) : '';
712-
clipboardData.setData('text/html', embedSliceInHtml(html, sliceJson, bodySectPrJson));
753+
clipboardData.setData('text/html', embedSliceInHtml(html, sliceJson, bodySectPrJson, mediaJson));
713754
clipboardData.setData('text/plain', fragment.textBetween(0, fragment.size, '\n\n'));
714755

715756
event.preventDefault();

packages/super-editor/src/editors/v1/core/InputRule.test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
isWordHtml,
3333
isSuperdocOriginClipboardHtml,
3434
} from './InputRule.js';
35+
import { embedSliceInHtml } from './helpers/superdocClipboardSlice.js';
3536

3637
const createEditorContext = (initialDoc) => {
3738
const baseState = EditorState.create({ schema, doc: initialDoc });
@@ -49,6 +50,13 @@ const createEditorContext = (initialDoc) => {
4950
return { editor, view };
5051
};
5152

53+
const makeSliceJson = (contentNode) => JSON.stringify(contentNode.slice(0, contentNode.content.size).toJSON());
54+
55+
const makeMultiColumnSectPr = () => ({
56+
name: 'w:sectPr',
57+
elements: [{ name: 'w:cols', attributes: { 'w:num': '2' } }],
58+
});
59+
5260
describe('InputRule helpers', () => {
5361
beforeEach(() => {
5462
handleDocxPasteMock.mockReset().mockReturnValue('docx-result');
@@ -179,6 +187,63 @@ describe('InputRule helpers', () => {
179187
expect(handled).toBe(true);
180188
});
181189

190+
it('pastes embedded SuperDoc slice data before falling back to hidden slice HTML', () => {
191+
const { editor, view } = createEditorContext(doc(p('Base')));
192+
const sourceDoc = doc(p('Slice content'));
193+
const sliceJson = makeSliceJson(sourceDoc);
194+
const html = embedSliceInHtml('<p>Visible fallback</p>', sliceJson);
195+
196+
const handled = handleClipboardPaste({ editor, view }, html, 'Slice content');
197+
198+
const text = view.state.doc.textContent;
199+
expect(handled).toBe(true);
200+
expect(text).toContain('Slice content');
201+
expect(text).not.toContain('Visible fallback');
202+
expect(text).not.toMatch(/eyJ[A-Za-z0-9+/=]{20,}/);
203+
});
204+
205+
it('imports embedded SuperDoc media when context-menu paste can only read HTML', () => {
206+
const { editor, view } = createEditorContext(doc(p('Base')));
207+
editor.storage = {
208+
image: {
209+
media: { 'word/media/image1.png': 'data:image/png;base64,OLD' },
210+
},
211+
};
212+
213+
const sourceDoc = schema.nodes.doc.create(null, [
214+
schema.nodes.paragraph.create(null, [schema.nodes.image.create({ src: 'word/media/image1.png' })]),
215+
]);
216+
const sliceJson = makeSliceJson(sourceDoc);
217+
const mediaJson = JSON.stringify({ 'word/media/image1.png': 'data:image/png;base64,NEW' });
218+
const html = embedSliceInHtml('<p>Visible fallback</p>', sliceJson, '', mediaJson);
219+
220+
const handled = handleClipboardPaste({ editor, view }, html, '');
221+
222+
const imageSrcs = [];
223+
view.state.doc.descendants((node) => {
224+
if (node.type.name === 'image') imageSrcs.push(node.attrs.src);
225+
});
226+
expect(handled).toBe(true);
227+
expect(imageSrcs).toHaveLength(1);
228+
expect(imageSrcs[0]).not.toBe('word/media/image1.png');
229+
expect(editor.storage.image.media['word/media/image1.png']).toBe('data:image/png;base64,OLD');
230+
expect(editor.storage.image.media[imageSrcs[0]]).toBe('data:image/png;base64,NEW');
231+
});
232+
233+
it('applies embedded body section data when SuperDoc slice paste falls back to HTML', () => {
234+
const { editor, view } = createEditorContext(doc(p('Base')));
235+
editor.converter = {};
236+
const emptySliceJson = JSON.stringify({ content: [], openStart: 0, openEnd: 0 });
237+
const sectPr = makeMultiColumnSectPr();
238+
const html = embedSliceInHtml('<p>Fallback content</p>', emptySliceJson, JSON.stringify(sectPr));
239+
240+
const handled = handleClipboardPaste({ editor, view }, html, 'Fallback content');
241+
242+
expect(handled).toBe(true);
243+
expect(view.state.doc.textContent).toContain('Fallback content');
244+
expect(editor.converter.bodySectPr).toEqual(sectPr);
245+
});
246+
182247
it('falls back to browser HTML handling for Word HTML outside docx mode', () => {
183248
const { editor } = createEditorContext(doc(p('Base')));
184249
const html = '<meta name="Generator" content="Microsoft Word"><p>Content</p>';

packages/super-editor/src/editors/v1/core/helpers/superdocClipboardSlice.js

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const SUPERDOC_SLICE_MIME = 'application/x-superdoc-slice';
99
export const SUPERDOC_MEDIA_MIME = 'application/x-superdoc-media';
1010
export const SUPERDOC_SLICE_ATTR = 'data-superdoc-slice';
1111
export const SUPERDOC_BODY_SECT_PR_ATTR = 'data-sd-body-sect-pr';
12+
export const SUPERDOC_MEDIA_ATTR = 'data-sd-superdoc-media';
1213

1314
/**
1415
* Walk a ProseMirror Slice JSON object and collect `editor.storage.image.media`
@@ -129,10 +130,11 @@ function rewriteImageSrcsInSliceJsonTree(node, pathRemap) {
129130
* @param {object} editor
130131
* @param {DataTransfer | null | undefined} clipboardData
131132
* @param {string | null} [sliceJson] SuperDoc slice JSON string, if any
133+
* @param {string} [mediaJson] media JSON from HTML, if custom clipboard MIME is unavailable
132134
* @returns {string | null} slice JSON to paste (updated when paths were remapped), or `sliceJson` unchanged
133135
*/
134-
export function applySuperdocClipboardMedia(editor, clipboardData, sliceJson = null) {
135-
const raw = clipboardData?.getData?.(SUPERDOC_MEDIA_MIME);
136+
export function applySuperdocClipboardMedia(editor, clipboardData, sliceJson = null, mediaJson = '') {
137+
const raw = clipboardData?.getData?.(SUPERDOC_MEDIA_MIME) || mediaJson;
136138
if (!editor?.storage?.image || !raw || typeof raw !== 'string') {
137139
return sliceJson;
138140
}
@@ -248,13 +250,17 @@ export function bodySectPrShouldEmbed(bodySectPr) {
248250
return !!(cols?.count && cols.count > 1);
249251
}
250252

251-
/** Embeds PM slice (base64 in element text) and optional body sectPr for multi-column paste. */
252-
export function embedSliceInHtml(html, sliceJson, bodySectPrJson = '') {
253+
/** Embeds PM slice, media, and optional body sectPr as hidden base64 payloads. */
254+
export function embedSliceInHtml(html, sliceJson, bodySectPrJson = '', mediaJson = '') {
253255
let out = html;
254256
if (bodySectPrJson) {
255257
const body64 = encodeUtf8Base64(bodySectPrJson);
256258
out = `<div ${SUPERDOC_BODY_SECT_PR_ATTR} style="display:none">${body64}</div>${out}`;
257259
}
260+
if (mediaJson) {
261+
const media64 = encodeUtf8Base64(mediaJson);
262+
out = `<div ${SUPERDOC_MEDIA_ATTR} style="display:none">${media64}</div>${out}`;
263+
}
258264
if (!sliceJson) return out;
259265
const base64 = encodeUtf8Base64(sliceJson);
260266
return `<div ${SUPERDOC_SLICE_ATTR} style="display:none">${base64}</div>${out}`;
@@ -294,9 +300,29 @@ export function stripSliceFromHtml(html) {
294300
if (out.includes(SUPERDOC_BODY_SECT_PR_ATTR)) {
295301
out = out.replace(/<div[^>]*data-sd-body-sect-pr[^>]*>[\s\S]*?<\/div>/gi, '');
296302
}
303+
if (out.includes(SUPERDOC_MEDIA_ATTR)) {
304+
out = out.replace(/<div[^>]*data-sd-superdoc-media[^>]*>[\s\S]*?<\/div>/gi, '');
305+
}
297306
return out;
298307
}
299308

309+
export function extractMediaFromHtml(html) {
310+
if (!html || !html.includes(SUPERDOC_MEDIA_ATTR)) return null;
311+
if (typeof DOMParser === 'undefined') return null;
312+
313+
try {
314+
const doc = new DOMParser().parseFromString(html, 'text/html');
315+
const el = doc.querySelector(`[${SUPERDOC_MEDIA_ATTR}]`);
316+
if (!el) return null;
317+
const b64 = el.textContent?.trim() ?? '';
318+
if (!b64) return null;
319+
const decoded = decodeUtf8Base64(b64);
320+
return decoded || null;
321+
} catch {
322+
return null;
323+
}
324+
}
325+
300326
export function extractBodySectPrFromHtml(html) {
301327
if (!html || !html.includes(SUPERDOC_BODY_SECT_PR_ATTR)) return null;
302328
if (typeof DOMParser === 'undefined') return null;

packages/super-editor/src/editors/v1/core/helpers/superdocClipboardSlice.test.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import {
55
applySuperdocClipboardMedia,
66
embedSliceInHtml,
77
extractSliceFromHtml,
8+
extractMediaFromHtml,
89
stripSliceFromHtml,
910
extractBodySectPrFromHtml,
1011
bodySectPrShouldEmbed,
12+
SUPERDOC_MEDIA_ATTR,
1113
SUPERDOC_MEDIA_MIME,
1214
} from './superdocClipboardSlice.js';
1315

@@ -138,6 +140,34 @@ describe('superdocClipboardSlice image media', () => {
138140
expect(JSON.parse(outSlice).content[0].content[0].attrs.src).toBe('word/media/image1.png');
139141
});
140142

143+
it('applySuperdocClipboardMedia accepts embedded media JSON when custom MIME data is unavailable', () => {
144+
const editor = {
145+
storage: {
146+
image: {
147+
media: { 'word/media/image1.png': 'data:image/png;base64,OLD' },
148+
},
149+
},
150+
};
151+
const sliceJson = JSON.stringify({
152+
content: [
153+
{
154+
type: 'paragraph',
155+
content: [{ type: 'image', attrs: { src: 'word/media/image1.png' } }],
156+
},
157+
],
158+
openStart: 0,
159+
openEnd: 0,
160+
});
161+
const mediaJson = JSON.stringify({ 'word/media/image1.png': 'data:image/png;base64,NEW' });
162+
163+
const outSlice = applySuperdocClipboardMedia(editor, null, sliceJson, mediaJson);
164+
165+
const img = JSON.parse(outSlice).content[0].content[0];
166+
expect(img.attrs.src).not.toBe('word/media/image1.png');
167+
expect(editor.storage.image.media['word/media/image1.png']).toBe('data:image/png;base64,OLD');
168+
expect(editor.storage.image.media[img.attrs.src]).toBe('data:image/png;base64,NEW');
169+
});
170+
141171
it('applySuperdocClipboardMedia rewrites shapeGroup nested image src on collision', () => {
142172
const editor = {
143173
storage: {
@@ -183,12 +213,23 @@ describe('HTML slice embed/extract round-trip', () => {
183213
expect(extracted).toBe(sampleSlice);
184214
});
185215

216+
it('extractMediaFromHtml recovers embedded media JSON', () => {
217+
const mediaJson = JSON.stringify({ 'word/media/image1.png': 'data:image/png;base64,AAA' });
218+
const embedded = embedSliceInHtml(sampleHtml, sampleSlice, '', mediaJson);
219+
220+
expect(embedded).toContain(SUPERDOC_MEDIA_ATTR);
221+
expect(extractMediaFromHtml(embedded)).toBe(mediaJson);
222+
});
223+
186224
it('stripSliceFromHtml removes embedded divs and preserves the original HTML', () => {
187-
const embedded = embedSliceInHtml(sampleHtml, sampleSlice);
225+
const mediaJson = JSON.stringify({ 'word/media/image1.png': 'data:image/png;base64,AAA' });
226+
const embedded = embedSliceInHtml(sampleHtml, sampleSlice, '', mediaJson);
188227
expect(embedded).toContain('data-superdoc-slice');
228+
expect(embedded).toContain(SUPERDOC_MEDIA_ATTR);
189229
const stripped = stripSliceFromHtml(embedded);
190230
expect(stripped).toBe(sampleHtml);
191231
expect(stripped).not.toContain('data-superdoc-slice');
232+
expect(stripped).not.toContain(SUPERDOC_MEDIA_ATTR);
192233
});
193234

194235
it('round-trips Unicode content (CJK, emoji)', () => {

packages/super-editor/src/editors/v1/core/renderers/ProseMirrorRenderer.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -842,11 +842,12 @@ export class ProseMirrorRenderer implements EditorRenderer {
842842

843843
const { from, to } = this.view.state.selection;
844844
let sliceJson = '';
845+
let mediaJson = '';
845846
if (from !== to) {
846847
const slice = this.view.state.doc.slice(from, to);
847848
sliceJson = JSON.stringify(slice.toJSON());
848849
clipboardData.setData('application/x-superdoc-slice', sliceJson);
849-
const mediaJson = collectReferencedImageMediaForClipboard(sliceJson, editor);
850+
mediaJson = collectReferencedImageMediaForClipboard(sliceJson, editor);
850851
if (mediaJson) {
851852
clipboardData.setData(SUPERDOC_MEDIA_MIME, mediaJson);
852853
}
@@ -857,7 +858,7 @@ export class ProseMirrorRenderer implements EditorRenderer {
857858
const bodySectPrJson = bodySectPr && bodySectPrShouldEmbed(bodySectPr) ? JSON.stringify(bodySectPr) : '';
858859

859860
if (richHtml) {
860-
clipboardData.setData('text/html', embedSliceInHtml(richHtml, sliceJson, bodySectPrJson));
861+
clipboardData.setData('text/html', embedSliceInHtml(richHtml, sliceJson, bodySectPrJson, mediaJson));
861862
clipboardData.setData('text/plain', getSelectionFromViewRoot(this.view.root)?.toString() ?? '');
862863
return;
863864
}
@@ -873,7 +874,7 @@ export class ProseMirrorRenderer implements EditorRenderer {
873874

874875
const html = transformListsInCopiedContent(div.innerHTML);
875876

876-
clipboardData.setData('text/html', embedSliceInHtml(html, sliceJson, bodySectPrJson));
877+
clipboardData.setData('text/html', embedSliceInHtml(html, sliceJson, bodySectPrJson, mediaJson));
877878
clipboardData.setData('text/plain', this.view.state.doc.textBetween(from, to, '\n'));
878879
} catch (error) {
879880
console.warn('Failed to transform copied content:', error);

0 commit comments

Comments
 (0)