Skip to content

Commit 62446a1

Browse files
authored
Merge pull request #3137 from superdoc-dev/caio/sd-2934-context-menu-paste-stable
fix: handle SuperDoc context-menu paste (SD-2934)
2 parents 40f66da + bbb62e5 commit 62446a1

6 files changed

Lines changed: 315 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: 80 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,78 @@ 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+
247+
it('handles SuperDoc-origin HTML with no slice div (block-id only) via stripped HTML paste', () => {
248+
const { editor, view } = createEditorContext(doc(p('Base')));
249+
editor.converter = {};
250+
const sectPr = makeMultiColumnSectPr();
251+
const visibleHtml = '<p data-sd-block-id="abc-123">Block content</p>';
252+
const html = embedSliceInHtml(visibleHtml, '', JSON.stringify(sectPr));
253+
254+
const handled = handleClipboardPaste({ editor, view }, html, 'Block content');
255+
256+
expect(handled).toBe(true);
257+
expect(view.state.doc.textContent).toContain('Block content');
258+
expect(view.state.doc.textContent).not.toMatch(/eyJ[A-Za-z0-9+/=]{20,}/);
259+
expect(editor.converter.bodySectPr).toEqual(sectPr);
260+
});
261+
182262
it('falls back to browser HTML handling for Word HTML outside docx mode', () => {
183263
const { editor } = createEditorContext(doc(p('Base')));
184264
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;

0 commit comments

Comments
 (0)