diff --git a/packages/super-editor/src/components/slash-menu/utils.js b/packages/super-editor/src/components/slash-menu/utils.js index 20d624934..d7683ba41 100644 --- a/packages/super-editor/src/components/slash-menu/utils.js +++ b/packages/super-editor/src/components/slash-menu/utils.js @@ -90,7 +90,7 @@ export async function getEditorContext(editor, event) { node = state.doc.nodeAt(pos); } - // We need to check if we have anything in the clipboard and request permission if needed + // We need to check if we have anything in the clipboard const clipboardContent = await readFromClipboard(state); return { diff --git a/packages/super-editor/src/core/Editor.js b/packages/super-editor/src/core/Editor.js index 5f55a7024..9131a5fa4 100644 --- a/packages/super-editor/src/core/Editor.js +++ b/packages/super-editor/src/core/Editor.js @@ -239,7 +239,6 @@ export class Editor extends EventEmitter { // async (file) => url; handleImageUpload: null, - // telemetry telemetry: null, // Docx xml updated by User diff --git a/packages/super-editor/src/core/commands/insertContentAt.js b/packages/super-editor/src/core/commands/insertContentAt.js index 5bdeeafe5..28f6deda6 100644 --- a/packages/super-editor/src/core/commands/insertContentAt.js +++ b/packages/super-editor/src/core/commands/insertContentAt.js @@ -17,118 +17,124 @@ const isFragment = (nodeOrFragment) => { /** * Inserts content at the specified position. - * @param {import("prosemirror-model").ResolvedPos} position - * @param {string|Array} value + * - Bare strings with newlines → insertText (keeps literal \n) + * - HTML-looking strings → parse and replaceWith + * - Arrays of strings / {text} objects → insertText + * + * @param {import("prosemirror-model").ResolvedPos|number|{from:number,to:number}} position + * @param {string|Array|ProseMirrorNode|ProseMirrorFragment} value * @param {Object} options - * @returns + * @returns {boolean} */ +// prettier-ignore export const insertContentAt = (position, value, options) => ({ tr, dispatch, editor }) => { - if (dispatch) { - options = { - parseOptions: {}, - updateSelection: true, - applyInputRules: false, - applyPasteRules: false, - ...options, - }; - - let content; - - try { - content = createNodeFromContent(value, editor.schema, { - parseOptions: { - preserveWhitespace: 'full', - ...options.parseOptions, - }, - errorOnInvalidContent: options.errorOnInvalidContent ?? editor.options.enableContentCheck, - }); - } catch (e) { - editor.emit('contentError', { - editor, - error: e, - disableCollaboration: () => { - console.error('[super-editor error]: Unable to disable collaboration at this point in time'); - }, - }); - return false; - } - - let { from, to } = - typeof position === 'number' ? { from: position, to: position } : { from: position.from, to: position.to }; - - // If the original input is plainly textual, prefer insertText regardless of how parsing represents it. - const forceTextInsert = - typeof value === 'string' || - (Array.isArray(value) && value.every((v) => typeof v === 'string' || (v && typeof v.text === 'string'))) || - (value && typeof value === 'object' && typeof value.text === 'string'); - - let isOnlyTextContent = forceTextInsert; // start true for plain text inputs - let isOnlyBlockContent = true; - const nodes = isFragment(content) ? content : [content]; - - nodes.forEach((node) => { - // check if added node is valid - node.check(); - - // only refine text heuristic if we are NOT forcing text insertion based on the original value - if (!forceTextInsert) { - isOnlyTextContent = isOnlyTextContent ? node.isText && node.marks.length === 0 : false; - } - - isOnlyBlockContent = isOnlyBlockContent ? node.isBlock : false; + if (!dispatch) return true; + + options = { + parseOptions: {}, + updateSelection: true, + applyInputRules: false, + applyPasteRules: false, + // optional escape hatch to force literal text insertion + asText: false, + ...options, + }; + + let content; + + try { + content = createNodeFromContent(value, editor.schema, { + parseOptions: { + preserveWhitespace: 'full', + ...options.parseOptions, + }, + errorOnInvalidContent: options.errorOnInvalidContent ?? editor.options.enableContentCheck, }); + } catch (e) { + editor.emit('contentError', { + editor, + error: e, + disableCollaboration: () => { + console.error('[super-editor error]: Unable to disable collaboration at this point in time'); + }, + }); + return false; + } - // check if we can replace the wrapping node by - // the newly inserted content - // example: - // replace an empty paragraph by an inserted image - // instead of inserting the image below the paragraph - if (from === to && isOnlyBlockContent) { - const { parent } = tr.doc.resolve(from); - const isEmptyTextBlock = parent.isTextblock && !parent.type.spec.code && !parent.childCount; - - if (isEmptyTextBlock) { - from -= 1; - to += 1; - } + let { from, to } = + typeof position === 'number' + ? { from: position, to: position } + : { from: position.from, to: position.to }; + + // Heuristic: + // - Bare strings that LOOK like HTML: let parser handle (replaceWith) + // - Bare strings with one or more newlines: force text insertion (insertText) + const isBareString = typeof value === 'string'; + const looksLikeHTML = isBareString && /^\s*<[a-zA-Z][^>]*>.*<\/[a-zA-Z][^>]*>\s*$/s.test(value); + const hasNewline = isBareString && /[\r\n]/.test(value); + const forceTextInsert = + !!options.asText || + (hasNewline && !looksLikeHTML) || + (Array.isArray(value) && value.every((v) => typeof v === 'string' || (v && typeof v.text === 'string'))) || + (!!value && typeof value === 'object' && typeof value.text === 'string'); + + // Inspect parsed nodes to decide text vs block replacement + let isOnlyTextContent = true; + let isOnlyBlockContent = true; + const nodes = isFragment(content) ? content : [content]; + + nodes.forEach((node) => { + // validate node + node.check(); + + // only-plain-text if every node is an unmarked text node + isOnlyTextContent = isOnlyTextContent ? (node.isText && node.marks.length === 0) : false; + + isOnlyBlockContent = isOnlyBlockContent ? node.isBlock : false; + }); + + // Replace empty textblock wrapper when inserting blocks at a cursor + if (from === to && isOnlyBlockContent) { + const { parent } = tr.doc.resolve(from); + const isEmptyTextBlock = parent.isTextblock && !parent.type.spec.code && !parent.childCount; + + if (isEmptyTextBlock) { + from -= 1; + to += 1; } + } - let newContent; - - // if there is only plain text we have to use `insertText` - // because this will keep the current marks - if (isOnlyTextContent) { - // if value is string, we can use it directly - // otherwise if it is an array, we have to join it - if (Array.isArray(value)) { - newContent = value.map((v) => (typeof v === 'string' ? v : (v && v.text) || '')).join(''); - } else if (typeof value === 'object' && !!value && !!value.text) { - newContent = value.text; - } else { - newContent = value; - } - - tr.insertText(newContent, from, to); - } else { - newContent = content; + let newContent; - tr.replaceWith(from, to, newContent); + // Use insertText for pure text OR when explicitly/heuristically forced + if (isOnlyTextContent || forceTextInsert) { + if (Array.isArray(value)) { + newContent = value.map((v) => (typeof v === 'string' ? v : (v && v.text) || '')).join(''); + } else if (typeof value === 'object' && !!value && !!value.text) { + newContent = value.text; + } else { + newContent = typeof value === 'string' ? value : ''; } - // set cursor at end of inserted content - if (options.updateSelection) { - selectionToInsertionEnd(tr, tr.steps.length - 1, -1); - } + tr.insertText(newContent, from, to); + } else { + newContent = content; + tr.replaceWith(from, to, newContent); + } - if (options.applyInputRules) { - tr.setMeta('applyInputRules', { from, text: newContent }); - } + // set cursor at end of inserted content + if (options.updateSelection) { + selectionToInsertionEnd(tr, tr.steps.length - 1, -1); + } - if (options.applyPasteRules) { - tr.setMeta('applyPasteRules', { from, text: newContent }); - } + if (options.applyInputRules) { + tr.setMeta('applyInputRules', { from, text: newContent }); + } + + if (options.applyPasteRules) { + tr.setMeta('applyPasteRules', { from, text: newContent }); } return true; diff --git a/packages/super-editor/src/core/commands/insertContentAt.test.js b/packages/super-editor/src/core/commands/insertContentAt.test.js index f24ac08e2..5276c2a48 100644 --- a/packages/super-editor/src/core/commands/insertContentAt.test.js +++ b/packages/super-editor/src/core/commands/insertContentAt.test.js @@ -147,4 +147,79 @@ describe('insertContentAt', () => { expect(tr.insertText).toHaveBeenCalledWith('Line 1\nLine 2', 3, 3); expect(tr.replaceWith).not.toHaveBeenCalled(); }); + + it('bare string with \\n\\n forces insertText (keeps literal newlines)', () => { + const value = 'Line 1\n\nLine 2'; + + // Parser would normally produce a Fragment with a hard break, but since we + // force text for newline strings, the parsed result is irrelevant. + createNodeFromContent.mockImplementation(() => [ + // simulate what parser might return; won't be used due to forceTextInsert + { isText: true, isBlock: false, marks: [], check: vi.fn() }, + { isText: false, isBlock: false, marks: [], check: vi.fn() }, // + { isText: true, isBlock: false, marks: [], check: vi.fn() }, + ]); + + const tr = makeTr(); + const editor = makeEditor(); + + const cmd = insertContentAt(7, value, { updateSelection: true }); + const result = cmd({ tr, dispatch: true, editor }); + + expect(result).toBe(true); + // With newline heuristic, we insert text literally: + expect(tr.insertText).toHaveBeenCalledWith('Line 1\n\nLine 2', 7, 7); + expect(tr.replaceWith).not.toHaveBeenCalled(); + }); + + it('bare string with single \\n also forces insertText', () => { + const value = 'A\nB'; + + createNodeFromContent.mockImplementation(() => [ + { isText: true, isBlock: false, marks: [], check: vi.fn() }, + { isText: false, isBlock: false, marks: [], check: vi.fn() }, + { isText: true, isBlock: false, marks: [], check: vi.fn() }, + ]); + + const tr = makeTr(); + const editor = makeEditor(); + + const cmd = insertContentAt(3, value, { updateSelection: true }); + const result = cmd({ tr, dispatch: true, editor }); + + expect(result).toBe(true); + expect(tr.insertText).toHaveBeenCalledWith('A\nB', 3, 3); + expect(tr.replaceWith).not.toHaveBeenCalled(); + }); + + // HTML still parses to nodes and uses replaceWith + it('HTML string parses and inserts via replaceWith (not insertText)', () => { + const value = '

Hello HTML

'; + + const blockNode = { + type: { name: 'paragraph' }, + isText: false, + isBlock: true, + marks: [], + check: vi.fn(), + }; + createNodeFromContent.mockImplementation(() => blockNode); + + const tr = makeTr({ + doc: { + resolve: vi.fn().mockReturnValue({ + parent: { isTextblock: true, type: { spec: {} }, childCount: 0 }, + }), + }, + }); + const editor = makeEditor(); + + const cmd = insertContentAt(10, value, { updateSelection: true }); + const result = cmd({ tr, dispatch: true, editor }); + + expect(result).toBe(true); + expect(tr.insertText).not.toHaveBeenCalled(); + // empty textblock wrapper replacement [from-1, to+1] + expect(tr.replaceWith).toHaveBeenCalledWith(9, 11, blockNode); + }); }); diff --git a/packages/super-editor/src/core/utilities/clipboardUtils.js b/packages/super-editor/src/core/utilities/clipboardUtils.js index 67be48e6d..483db510e 100644 --- a/packages/super-editor/src/core/utilities/clipboardUtils.js +++ b/packages/super-editor/src/core/utilities/clipboardUtils.js @@ -1,48 +1,41 @@ -// @ts-nocheck // clipboardUtils.js -import { DOMParser } from 'prosemirror-model'; +import { DOMSerializer, DOMParser } from 'prosemirror-model'; /** - * Checks if clipboard read permission is granted and handles permission prompts. - * Returns true if clipboard-read permission is granted. If state is "prompt" it will - * proactively trigger a readText() call which will surface the browser permission - * dialog to the user. Falls back gracefully in older browsers that lack the - * Permissions API. - * @returns {Promise} Whether clipboard read permission is granted + * Serializes the current selection in the editor state to HTML and plain text for clipboard use. + * @param {EditorState} state - The ProseMirror editor state containing the current selection. + * @returns {{ htmlString: string, text: string }} An object with the HTML string and plain text of the selection. */ -export async function ensureClipboardPermission() { - if (typeof navigator === 'undefined' || !navigator.clipboard) { - return false; - } - - // Some older browsers do not expose navigator.permissions – assume granted - if (!navigator.permissions || typeof navigator.permissions.query !== 'function') { - return true; - } +export function serializeSelectionToClipboard(state) { + const { from, to } = state.selection; + const slice = state.selection.content(); + const htmlContainer = document.createElement('div'); + htmlContainer.appendChild(DOMSerializer.fromSchema(state.schema).serializeFragment(slice.content)); + const htmlString = htmlContainer.innerHTML; + const text = state.doc.textBetween(from, to); + return { htmlString, text }; +} +/** + * Writes HTML and plain text data to the system clipboard. + * Uses the Clipboard API if available, otherwise falls back to plain text. + * @param {{ htmlString: string, text: string }} param0 - The HTML and plain text to write to the clipboard. + * @returns {Promise} A promise that resolves when the clipboard write is complete. + */ +export async function writeToClipboard({ htmlString, text }) { try { - // @ts-ignore – string literal is valid at runtime; TS lib DOM typing not available in .js file - const status = await navigator.permissions.query({ name: 'clipboard-read' }); - - if (status.state === 'granted') { - return true; - } - - if (status.state === 'prompt') { - // Trigger a readText() to make the browser show its permission prompt. - try { - await navigator.clipboard.readText(); - return true; - } catch { - return false; - } + if (navigator.clipboard && window.ClipboardItem) { + const clipboardItem = new window.ClipboardItem({ + 'text/html': new Blob([htmlString], { type: 'text/html' }), + 'text/plain': new Blob([text], { type: 'text/plain' }), + }); + await navigator.clipboard.write([clipboardItem]); + } else { + await navigator.clipboard.writeText(text); } - - // If we hit this area this is state === 'denied' - return false; - } catch { - return false; + } catch (e) { + console.error('Error writing to clipboard', e); } } @@ -55,9 +48,7 @@ export async function ensureClipboardPermission() { export async function readFromClipboard(state) { let html = ''; let text = ''; - const hasPermission = await ensureClipboardPermission(); - - if (hasPermission && navigator.clipboard && navigator.clipboard.read) { + if (navigator.clipboard && navigator.clipboard.read) { try { const items = await navigator.clipboard.read(); for (const item of items) { @@ -69,13 +60,10 @@ export async function readFromClipboard(state) { } } } catch { - // Fallback to plain text read; may still fail if permission denied - try { - text = await navigator.clipboard.readText(); - } catch {} + text = await navigator.clipboard.readText(); } } else { - // permissions denied or API unavailable; leave content empty + text = await navigator.clipboard.readText(); } let content = null; if (html) { diff --git a/packages/super-editor/src/core/utilities/tests/clipboardUtils.test.js b/packages/super-editor/src/core/utilities/tests/clipboardUtils.test.js deleted file mode 100644 index 095909ee6..000000000 --- a/packages/super-editor/src/core/utilities/tests/clipboardUtils.test.js +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; - -import { ensureClipboardPermission, readFromClipboard } from '../clipboardUtils.js'; - -// Helper to restore globals after each test -const originalNavigator = global.navigator; -const originalWindowClipboardItem = globalThis.ClipboardItem; - -function restoreGlobals() { - if (typeof originalNavigator !== 'undefined') { - global.navigator = originalNavigator; - } else { - delete global.navigator; - } - - if (typeof originalWindowClipboardItem !== 'undefined') { - globalThis.ClipboardItem = originalWindowClipboardItem; - } else { - delete globalThis.ClipboardItem; - } -} - -afterEach(() => { - restoreGlobals(); - vi.restoreAllMocks(); -}); - -describe('clipboardUtils', () => { - describe('ensureClipboardPermission', () => { - it('navigator undefined returns false', async () => { - // Remove navigator entirely - delete global.navigator; - const result = await ensureClipboardPermission(); - expect(result).toBe(false); - }); - it('permissions absent but clipboard present returns true', async () => { - global.navigator = { - clipboard: {}, - }; - const result = await ensureClipboardPermission(); - expect(result).toBe(true); - }); - }); - - describe('readFromClipboard', () => { - it('navigator.clipboard undefined returns null (no throw)', async () => { - global.navigator = {}; - const mockState = { schema: { text: (t) => t } }; - const res = await readFromClipboard(mockState); - expect(res).toBeNull(); - }); - - it('read() fails so fallback readText() is used', async () => { - const readTextMock = vi.fn().mockResolvedValue('plain'); - global.navigator = { - clipboard: { - read: vi.fn().mockRejectedValue(new Error('fail')), - readText: readTextMock, - }, - permissions: { - query: vi.fn().mockResolvedValue({ state: 'granted' }), - }, - }; - - const mockState = { schema: { text: (t) => t } }; - const res = await readFromClipboard(mockState); - - expect(readTextMock).toHaveBeenCalled(); - expect(res).toBe('plain'); - }); - }); -}); diff --git a/packages/super-editor/src/extensions/block-node/block-node.js b/packages/super-editor/src/extensions/block-node/block-node.js index a384f0dc4..0d54019b5 100644 --- a/packages/super-editor/src/extensions/block-node/block-node.js +++ b/packages/super-editor/src/extensions/block-node/block-node.js @@ -3,6 +3,7 @@ import { helpers } from '@core/index.js'; import { Plugin, PluginKey } from 'prosemirror-state'; import { ReplaceStep } from 'prosemirror-transform'; import { v4 as uuidv4 } from 'uuid'; +import { Transaction } from 'prosemirror-state'; const { findChildren } = helpers; const SD_BLOCK_ID_ATTRIBUTE_NAME = 'sdBlockId'; @@ -122,13 +123,12 @@ export const BlockNode = Extension.create({ // Check for new block nodes and if none found, we don't need to do anything if (hasInitialized && !checkForNewBlockNodesInTrs(transactions)) return null; - let tr = null; + const { tr } = newState; let changed = false; newState.doc.descendants((node, pos) => { // Only allow block nodes with a valid sdBlockId attribute if (!nodeAllowsSdBlockIdAttr(node) || !nodeNeedsSdBlockId(node)) return null; - tr = tr ?? newState.tr; tr.setNodeMarkup( pos, undefined, @@ -141,7 +141,14 @@ export const BlockNode = Extension.create({ changed = true; }); - if (changed && !hasInitialized) hasInitialized = true; + if (changed && !hasInitialized) { + hasInitialized = true; + } + + // Restore marks if they exist. + // `tr.setNodeMarkup` resets the stored marks. + tr.setStoredMarks(newState.tr.storedMarks); + return changed ? tr : null; }, }), @@ -171,7 +178,8 @@ export const nodeNeedsSdBlockId = (node) => { /** * Check for new block nodes in ProseMirror transactions. * Iterate through the list of transactions, and in each tr check if there are any new block nodes. - * @param {Array} transactions - The ProseMirror transactions to check. + * @readonly + * @param {readonly Transaction[]} transactions - The ProseMirror transactions to check. * @returns {boolean} - True if new block nodes are found, false otherwise. */ export const checkForNewBlockNodesInTrs = (transactions) => { diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 747b4ae8d..2bd5c7e0e 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -1,7 +1,7 @@ { "name": "@harbour-enterprises/superdoc", "type": "module", - "version": "0.16.0-next.9", + "version": "0.16.2", "license": "AGPL-3.0", "readme": "../../README.md", "files": [ diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index faf6cc0f4..b43e6c777 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -163,7 +163,6 @@ export class SuperDoc extends EventEmitter { isDev: false, - // telemetry config telemetry: null, // Events diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js index e34bd0ba1..866a3afc4 100644 --- a/packages/superdoc/src/stores/comments-store.js +++ b/packages/superdoc/src/stores/comments-store.js @@ -472,11 +472,12 @@ export const useCommentsStore = defineStore('comments', () => { // Create comments for tracked changes // that do not have a corresponding comment (created in Word). - const { tr } = editor.view.state; - const { dispatch } = editor.view; - groupedChanges.forEach(({ insertedMark, deletionMark, formatMark }, index) => { console.debug(`Create comment for track change: ${index}`); + + const { dispatch } = editor.view; + const { tr } = editor.view.state; + const foundComment = commentsList.value.find( (i) => i.commentId === insertedMark?.mark.attrs.id || @@ -488,6 +489,7 @@ export const useCommentsStore = defineStore('comments', () => { if (foundComment) { if (isLastIteration) { tr.setMeta(CommentsPluginKey, { type: 'force' }); + dispatch(tr); } return; } @@ -502,10 +504,9 @@ export const useCommentsStore = defineStore('comments', () => { if (isLastIteration) tr.setMeta(CommentsPluginKey, { type: 'force' }); tr.setMeta(CommentsPluginKey, { type: 'forceTrackChanges' }); tr.setMeta(TrackChangesBasePluginKey, trackChangesPayload); + dispatch(tr); } }); - - dispatch(tr); }; const translateCommentsForExport = () => {