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
75 changes: 58 additions & 17 deletions packages/super-editor/src/editors/v1/core/InputRule.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import {
SUPERDOC_MEDIA_MIME,
SUPERDOC_SLICE_ATTR,
SUPERDOC_BODY_SECT_PR_ATTR,
SUPERDOC_MEDIA_ATTR,
embedSliceInHtml,
extractSliceFromHtml,
stripSliceFromHtml,
extractBodySectPrFromHtml,
extractMediaFromHtml,
bodySectPrShouldEmbed,
collectReferencedImageMediaForClipboard,
applySuperdocClipboardMedia,
Expand All @@ -35,7 +37,11 @@ import { annotateFragmentDomWithClipboardData } from './helpers/clipboardFragmen
/** Heuristic: clipboard HTML from SuperDoc copy (slice attrs, list/section metadata). */
export function isSuperdocOriginClipboardHtml(html) {
if (!html || typeof html !== 'string') return false;
if (html.includes(SUPERDOC_SLICE_ATTR) || html.includes(SUPERDOC_BODY_SECT_PR_ATTR)) {
if (
html.includes(SUPERDOC_SLICE_ATTR) ||
html.includes(SUPERDOC_BODY_SECT_PR_ATTR) ||
html.includes(SUPERDOC_MEDIA_ATTR)
) {
return true;
}
if (/data-sd-sect-pr\s*=/i.test(html)) {
Expand Down Expand Up @@ -315,10 +321,11 @@ export const inputRulesPlugin = ({ editor, rules }) => {
const rawHtml = clipboard.getData('text/html');
const isSuperdocHtml = isSuperdocOriginClipboardHtml(rawHtml);
const embeddedBodySectPr = isSuperdocHtml ? extractBodySectPrFromHtml(rawHtml) : null;
const embeddedMedia = isSuperdocHtml ? extractMediaFromHtml(rawHtml) : '';

let superdocSliceData = clipboard.getData(SUPERDOC_SLICE_MIME) || extractSliceFromHtml(rawHtml);
if (isSuperdocHtml || superdocSliceData) {
superdocSliceData = applySuperdocClipboardMedia(editor, clipboard, superdocSliceData || null);
superdocSliceData = applySuperdocClipboardMedia(editor, clipboard, superdocSliceData || null, embeddedMedia);
}
if (superdocSliceData) {
try {
Expand Down Expand Up @@ -634,10 +641,8 @@ export function sanitizeHtml(html, forbiddenTags = ['meta', 'svg', 'script', 'st
}

/**
* Reusable paste-handling utility that replicates the logic formerly held only
* inside the `inputRulesPlugin` paste handler. This allows other components
* (e.g. context-menu items) to invoke the same paste logic without duplicating
* code.
* Handles clipboard content that was read outside the native paste event, such
* as the context-menu Paste action.
*
* @param {Object} params
* @param {Editor} params.editor The SuperEditor instance.
Expand All @@ -647,13 +652,33 @@ export function sanitizeHtml(html, forbiddenTags = ['meta', 'svg', 'script', 'st
* @returns {Boolean} Whether the paste was handled.
*/
export function handleClipboardPaste({ editor, view }, html, plainText) {
const rawHtml = html || '';
const isSuperdocHtml = isSuperdocOriginClipboardHtml(rawHtml);
const embeddedBodySectPr = isSuperdocHtml ? extractBodySectPrFromHtml(rawHtml) : null;
const embeddedMedia = isSuperdocHtml ? extractMediaFromHtml(rawHtml) : '';
let pasteHtml = rawHtml;

let superdocSliceData = extractSliceFromHtml(rawHtml);
if (superdocSliceData) {
superdocSliceData = applySuperdocClipboardMedia(editor, null, superdocSliceData, embeddedMedia);
try {
if (handleSuperdocSlicePaste(superdocSliceData, editor, view, embeddedBodySectPr)) return true;
} catch (err) {
console.warn('Failed to paste SuperDoc slice, falling back to HTML:', err);
}
}

if (isSuperdocHtml) {
pasteHtml = stripSliceFromHtml(rawHtml);
}

let source;

if (!html) {
if (!pasteHtml) {
source = 'plain-text';
} else if (isWordHtml(html)) {
} else if (isWordHtml(pasteHtml)) {
source = 'word-html';
} else if (isGoogleDocsHtml(html)) {
} else if (isGoogleDocsHtml(pasteHtml)) {
source = 'google-docs';
} else {
source = 'browser-html';
Expand All @@ -667,15 +692,31 @@ export function handleClipboardPaste({ editor, view }, html, plainText) {
return handlePlainTextUrlPaste(editor, view, plainText, detected);
}
case 'word-html':
if (editor.options.mode === 'docx' && !isSuperdocOriginClipboardHtml(html)) {
return handleDocxPaste(html, editor, view);
if (editor.options.mode === 'docx' && !isSuperdocHtml) {
return handleDocxPaste(pasteHtml, editor, view);
}
return handleHtmlPaste(html, editor);
case 'google-docs':
return handleGoogleDocsHtml(html, editor, view);
{
const ok = handleHtmlPaste(pasteHtml, editor);
if (ok && embeddedBodySectPr) {
tryApplyEmbeddedBodySectPr(editor, view, embeddedBodySectPr);
}
return ok;
}
case 'google-docs': {
const ok = handleGoogleDocsHtml(pasteHtml, editor, view);
if (ok && embeddedBodySectPr) {
tryApplyEmbeddedBodySectPr(editor, view, embeddedBodySectPr);
}
return ok;
}
// falls through to browser-html handling when not in DOCX mode
case 'browser-html':
return handleHtmlPaste(html, editor);
case 'browser-html': {
const ok = handleHtmlPaste(pasteHtml, editor);
if (ok && embeddedBodySectPr) {
tryApplyEmbeddedBodySectPr(editor, view, embeddedBodySectPr);
}
return ok;
}
}

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

event.preventDefault();
Expand Down
80 changes: 80 additions & 0 deletions packages/super-editor/src/editors/v1/core/InputRule.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
isWordHtml,
isSuperdocOriginClipboardHtml,
} from './InputRule.js';
import { embedSliceInHtml } from './helpers/superdocClipboardSlice.js';

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

const makeSliceJson = (contentNode) => JSON.stringify(contentNode.slice(0, contentNode.content.size).toJSON());

const makeMultiColumnSectPr = () => ({
name: 'w:sectPr',
elements: [{ name: 'w:cols', attributes: { 'w:num': '2' } }],
});

describe('InputRule helpers', () => {
beforeEach(() => {
handleDocxPasteMock.mockReset().mockReturnValue('docx-result');
Expand Down Expand Up @@ -179,6 +187,78 @@ describe('InputRule helpers', () => {
expect(handled).toBe(true);
});

it('pastes embedded SuperDoc slice data before falling back to hidden slice HTML', () => {
const { editor, view } = createEditorContext(doc(p('Base')));
const sourceDoc = doc(p('Slice content'));
const sliceJson = makeSliceJson(sourceDoc);
const html = embedSliceInHtml('<p>Visible fallback</p>', sliceJson);

const handled = handleClipboardPaste({ editor, view }, html, 'Slice content');

const text = view.state.doc.textContent;
expect(handled).toBe(true);
expect(text).toContain('Slice content');
expect(text).not.toContain('Visible fallback');
expect(text).not.toMatch(/eyJ[A-Za-z0-9+/=]{20,}/);
});

it('imports embedded SuperDoc media when context-menu paste can only read HTML', () => {
const { editor, view } = createEditorContext(doc(p('Base')));
editor.storage = {
image: {
media: { 'word/media/image1.png': 'data:image/png;base64,OLD' },
},
};

const sourceDoc = schema.nodes.doc.create(null, [
schema.nodes.paragraph.create(null, [schema.nodes.image.create({ src: 'word/media/image1.png' })]),
]);
const sliceJson = makeSliceJson(sourceDoc);
const mediaJson = JSON.stringify({ 'word/media/image1.png': 'data:image/png;base64,NEW' });
const html = embedSliceInHtml('<p>Visible fallback</p>', sliceJson, '', mediaJson);

const handled = handleClipboardPaste({ editor, view }, html, '');

const imageSrcs = [];
view.state.doc.descendants((node) => {
if (node.type.name === 'image') imageSrcs.push(node.attrs.src);
});
expect(handled).toBe(true);
expect(imageSrcs).toHaveLength(1);
expect(imageSrcs[0]).not.toBe('word/media/image1.png');
expect(editor.storage.image.media['word/media/image1.png']).toBe('data:image/png;base64,OLD');
expect(editor.storage.image.media[imageSrcs[0]]).toBe('data:image/png;base64,NEW');
});

it('applies embedded body section data when SuperDoc slice paste falls back to HTML', () => {
const { editor, view } = createEditorContext(doc(p('Base')));
editor.converter = {};
const emptySliceJson = JSON.stringify({ content: [], openStart: 0, openEnd: 0 });
const sectPr = makeMultiColumnSectPr();
const html = embedSliceInHtml('<p>Fallback content</p>', emptySliceJson, JSON.stringify(sectPr));

const handled = handleClipboardPaste({ editor, view }, html, 'Fallback content');

expect(handled).toBe(true);
expect(view.state.doc.textContent).toContain('Fallback content');
expect(editor.converter.bodySectPr).toEqual(sectPr);
});

it('handles SuperDoc-origin HTML with no slice div (block-id only) via stripped HTML paste', () => {
const { editor, view } = createEditorContext(doc(p('Base')));
editor.converter = {};
const sectPr = makeMultiColumnSectPr();
const visibleHtml = '<p data-sd-block-id="abc-123">Block content</p>';
const html = embedSliceInHtml(visibleHtml, '', JSON.stringify(sectPr));

const handled = handleClipboardPaste({ editor, view }, html, 'Block content');

expect(handled).toBe(true);
expect(view.state.doc.textContent).toContain('Block content');
expect(view.state.doc.textContent).not.toMatch(/eyJ[A-Za-z0-9+/=]{20,}/);
expect(editor.converter.bodySectPr).toEqual(sectPr);
});

it('falls back to browser HTML handling for Word HTML outside docx mode', () => {
const { editor } = createEditorContext(doc(p('Base')));
const html = '<meta name="Generator" content="Microsoft Word"><p>Content</p>';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const SUPERDOC_SLICE_MIME = 'application/x-superdoc-slice';
export const SUPERDOC_MEDIA_MIME = 'application/x-superdoc-media';
export const SUPERDOC_SLICE_ATTR = 'data-superdoc-slice';
export const SUPERDOC_BODY_SECT_PR_ATTR = 'data-sd-body-sect-pr';
export const SUPERDOC_MEDIA_ATTR = 'data-sd-superdoc-media';

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

/** Embeds PM slice (base64 in element text) and optional body sectPr for multi-column paste. */
export function embedSliceInHtml(html, sliceJson, bodySectPrJson = '') {
/** Embeds PM slice, media, and optional body sectPr as hidden base64 payloads. */
export function embedSliceInHtml(html, sliceJson, bodySectPrJson = '', mediaJson = '') {
let out = html;
if (bodySectPrJson) {
const body64 = encodeUtf8Base64(bodySectPrJson);
out = `<div ${SUPERDOC_BODY_SECT_PR_ATTR} style="display:none">${body64}</div>${out}`;
}
if (mediaJson) {
const media64 = encodeUtf8Base64(mediaJson);
out = `<div ${SUPERDOC_MEDIA_ATTR} style="display:none">${media64}</div>${out}`;
}
if (!sliceJson) return out;
const base64 = encodeUtf8Base64(sliceJson);
return `<div ${SUPERDOC_SLICE_ATTR} style="display:none">${base64}</div>${out}`;
Expand Down Expand Up @@ -294,9 +300,29 @@ export function stripSliceFromHtml(html) {
if (out.includes(SUPERDOC_BODY_SECT_PR_ATTR)) {
out = out.replace(/<div[^>]*data-sd-body-sect-pr[^>]*>[\s\S]*?<\/div>/gi, '');
}
if (out.includes(SUPERDOC_MEDIA_ATTR)) {
out = out.replace(/<div[^>]*data-sd-superdoc-media[^>]*>[\s\S]*?<\/div>/gi, '');
}
return out;
}

export function extractMediaFromHtml(html) {
if (!html || !html.includes(SUPERDOC_MEDIA_ATTR)) return null;
if (typeof DOMParser === 'undefined') return null;

try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const el = doc.querySelector(`[${SUPERDOC_MEDIA_ATTR}]`);
if (!el) return null;
const b64 = el.textContent?.trim() ?? '';
if (!b64) return null;
const decoded = decodeUtf8Base64(b64);
return decoded || null;
} catch {
return null;
}
}

export function extractBodySectPrFromHtml(html) {
if (!html || !html.includes(SUPERDOC_BODY_SECT_PR_ATTR)) return null;
if (typeof DOMParser === 'undefined') return null;
Expand Down
Loading
Loading