diff --git a/packages/super-editor/src/components/slash-menu/utils.js b/packages/super-editor/src/components/slash-menu/utils.js index 20d6249348..d7683ba41b 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 5f55a7024f..9131a5fa40 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/utilities/clipboardUtils.js b/packages/super-editor/src/core/utilities/clipboardUtils.js index 67be48e6d0..483db510e0 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 095909ee6d..0000000000 --- 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 a384f0dc47..0d54019b52 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 747b4ae8d1..2bd5c7e0e8 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 faf6cc0f41..b43e6c777e 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 e34bd0ba1b..866a3afc41 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 = () => {