From ac7fe3c48642045c38e3b59dd2b0566cf770b54f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 3 Sep 2025 01:01:35 +0000 Subject: [PATCH 01/11] chore(release): 0.16.0 [skip ci] # [0.16.0](https://github.com/Harbour-Enterprises/SuperDoc/compare/v0.15.18...v0.16.0) (2025-09-03) ### Bug Fixes * additional fixes to list indent/outdent, split list, toggle list, types and more tests ([02e6cd9](https://github.com/Harbour-Enterprises/SuperDoc/commit/02e6cd971b672adc7a27ee6f4c3e491ea6582927)) * backspaceNextToList, toggleList and tests ([8b33258](https://github.com/Harbour-Enterprises/SuperDoc/commit/8b33258aa9a09cd566191083de2095377f532de5)) * closing dropdown after clicking again ([#835](https://github.com/Harbour-Enterprises/SuperDoc/issues/835)) ([88ff88d](https://github.com/Harbour-Enterprises/SuperDoc/commit/88ff88d06568716d78be4fcdc311cbba0e6ba3fd)) * definition possibly missing name key, add jsdoc ([bb714f1](https://github.com/Harbour-Enterprises/SuperDoc/commit/bb714f14635239301ed6931bb06259b299b11fa8)) * images are missing for the document in edit mode ([#831](https://github.com/Harbour-Enterprises/SuperDoc/issues/831)) ([a9af47e](https://github.com/Harbour-Enterprises/SuperDoc/commit/a9af47ed4def516900b14460218e476374c69a80)) * include package lock on tests folder ([#845](https://github.com/Harbour-Enterprises/SuperDoc/issues/845)) ([1409d02](https://github.com/Harbour-Enterprises/SuperDoc/commit/1409d02ce457db963a5696ec78be30a3f349ffca)) * insertContentAt fails if new line characters (\n) inserted ([dd60d91](https://github.com/Harbour-Enterprises/SuperDoc/commit/dd60d91711e63741e2d6ca2ced02251f2a4e0465)) * install http server ([#846](https://github.com/Harbour-Enterprises/SuperDoc/issues/846)) ([1a6e684](https://github.com/Harbour-Enterprises/SuperDoc/commit/1a6e684f809ac96e00e370bb324f0317ec6917ef)) * **internal:** remove pdfjs from build ([#843](https://github.com/Harbour-Enterprises/SuperDoc/issues/843)) ([021b2c1](https://github.com/Harbour-Enterprises/SuperDoc/commit/021b2c123052215ba8f52ee103034ebaaa72e1e4)) * remove footer line length breaking deployments ([04766cd](https://github.com/Harbour-Enterprises/SuperDoc/commit/04766cdb1f085419730212b70eacf4072ef6eeeb)) * toggle list ([770998a](https://github.com/Harbour-Enterprises/SuperDoc/commit/770998a9e9b5097d1efa031dc12e6bf12920fa8b)) * update condition checks for screenshot updates in CI workflow ([e17fdf0](https://github.com/Harbour-Enterprises/SuperDoc/commit/e17fdf0b939e8caef65f60207611a71343e4cfde)) ### Features * enable dispatching example apps tests ([#844](https://github.com/Harbour-Enterprises/SuperDoc/issues/844)) ([8b2bc73](https://github.com/Harbour-Enterprises/SuperDoc/commit/8b2bc73bb909c2ce93a93e6266f18c17af0b46e2)) * filter out ooxml tags cli to highest priority namespaces ([23b1efa](https://github.com/Harbour-Enterprises/SuperDoc/commit/23b1efabc63f999f1b297ac046e8c178ff345e49)) --- packages/superdoc/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index faa535428..8d0221b72 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.6", + "version": "0.16.0", "license": "AGPL-3.0", "readme": "../../README.md", "files": [ From 9bc488d40430b61240d05bacc541cae51ea84ebb Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 2 Sep 2025 21:39:48 -0700 Subject: [PATCH 02/11] fix: imports encoded in utf-16 break DocxZipper --- packages/super-editor/src/core/DocxZipper.js | 48 +++--- .../super-editor/src/core/DocxZipper.test.js | 60 ++++++++ .../super-editor/src/core/encoding-helpers.js | 80 ++++++++++ .../src/core/encoding-helpers.test.js | 142 ++++++++++++++++++ 4 files changed, 304 insertions(+), 26 deletions(-) create mode 100644 packages/super-editor/src/core/encoding-helpers.js create mode 100644 packages/super-editor/src/core/encoding-helpers.test.js diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 4ba50a94c..e50b0c3c5 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -1,6 +1,7 @@ import xmljs from 'xml-js'; import JSZip from 'jszip'; import { getContentTypesFromXml } from './super-converter/helpers.js'; +import { ensureXmlString, isXmlLike } from './encoding-helpers.js'; /** * Class to handle unzipping and zipping of docx files @@ -37,42 +38,37 @@ class DocxZipper { const extractedFiles = await this.unzip(file); const files = Object.entries(extractedFiles.files); - const mediaObjects = {}; - const validTypes = ['xml', 'rels']; - for (const file of files) { - const [, zipEntry] = file; - - if (validTypes.some((validType) => zipEntry.name.endsWith(validType))) { - const content = await zipEntry.async('string'); - this.files.push({ - name: zipEntry.name, - content, - }); + for (const [, zipEntry] of files) { + const name = zipEntry.name; + + if (isXmlLike(name)) { + // Read raw bytes and decode (handles UTF-8 & UTF-16) + const u8 = await zipEntry.async('uint8array'); + const content = ensureXmlString(u8); + this.files.push({ name, content }); } else if ( - (zipEntry.name.startsWith('word/media') && zipEntry.name !== 'word/media/') || - (zipEntry.name.startsWith('media') && zipEntry.name !== 'media/') + (name.startsWith('word/media') && name !== 'word/media/') || + (name.startsWith('media') && name !== 'media/') ) { - // If we are in node, we need to convert the buffer to base64 + // Media files if (isNode) { const buffer = await zipEntry.async('nodebuffer'); const fileBase64 = buffer.toString('base64'); - this.mediaFiles[zipEntry.name] = fileBase64; - } - - // If we are in the browser, we can use the base64 directly - else { + this.mediaFiles[name] = fileBase64; + } else { const blob = await zipEntry.async('blob'); - const extension = this.getFileExtension(zipEntry.name); + const extension = this.getFileExtension(name); const fileBase64 = await zipEntry.async('base64'); - this.mediaFiles[zipEntry.name] = `data:image/${extension};base64,${fileBase64}`; + this.mediaFiles[name] = `data:image/${extension};base64,${fileBase64}`; - const file = new File([blob], zipEntry.name, { type: blob.type }); - const imageUrl = URL.createObjectURL(file); - this.media[zipEntry.name] = imageUrl; + const fileObj = new File([blob], name, { type: blob.type }); + const imageUrl = URL.createObjectURL(fileObj); + this.media[name] = imageUrl; } - } else if (zipEntry.name.startsWith('word/fonts') && zipEntry.name !== 'word/fonts/') { + } else if (name.startsWith('word/fonts') && name !== 'word/fonts/') { + // Font files const uint8array = await zipEntry.async('uint8array'); - this.fonts[zipEntry.name] = uint8array; + this.fonts[name] = uint8array; } } diff --git a/packages/super-editor/src/core/DocxZipper.test.js b/packages/super-editor/src/core/DocxZipper.test.js index 654e34066..28755f280 100644 --- a/packages/super-editor/src/core/DocxZipper.test.js +++ b/packages/super-editor/src/core/DocxZipper.test.js @@ -2,6 +2,7 @@ import path from 'path'; import fs from 'fs'; import { describe, it, expect, beforeEach } from 'vitest'; import DocxZipper from './DocxZipper'; +import JSZip from 'jszip'; async function readFileAsBuffer(filePath) { const resolvedPath = path.resolve(__dirname, filePath); @@ -48,3 +49,62 @@ describe('DocxZipper - file extraction', () => { expect(documentXml).toBeTruthy(); }); }); + +// Helper to build a UTF-16LE Buffer with BOM +function utf16leWithBOM(str) { + const bom = Buffer.from([0xff, 0xfe]); + const body = Buffer.from(str, 'utf16le'); + return Buffer.concat([bom, body]); +} + +describe('DocxZipper - UTF-16 XML handling', () => { + let zipper; + beforeEach(() => { + zipper = new DocxZipper(); + }); + + it('decodes a UTF-16LE customXml part correctly (was failing before fix)', async () => { + const zip = new JSZip(); + + // Minimal [Content_Types].xml to look like a docx + const contentTypes = ` + + + + + `; + zip.file('[Content_Types].xml', contentTypes); + + // A basic UTF-8 document.xml so there's at least one normal XML entry + const documentXml = ` + + Hello + `; + zip.file('word/document.xml', documentXml); + + // The problematic UTF-16LE customXml item + const customXmlUtf16 = ` + + TELEKOM!4176814.1 + A675398 + GUDRUN.JORDAN@TELEKOM.DE + 2023-07-06T15:09:00.0000000+02:00 + TELEKOM +`; + zip.file('customXml/item2.xml', utf16leWithBOM(customXmlUtf16)); + + // Generate the zip as a Node buffer and feed it to the zipper + const buf = await zip.generateAsync({ type: 'nodebuffer' }); + const files = await zipper.getDocxData(buf /* isNode not needed for XML */); + + // Find the customXml item + const item2 = files.find((f) => f.name === 'customXml/item2.xml'); + expect(item2).toBeTruthy(); + + // ✅ With the fix, content is a clean JS string: + expect(item2.content).toContain(' /\.xml$|\.rels$/i.test(name); + +/** + * Hex dump for optional debugging + * @param {Uint8Array|ArrayBuffer} bytes + * @param {number} n + * @returns {string} Hex dump + */ +export function hex(bytes, n = 32) { + const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + return Array.from(u8.slice(0, n)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(' '); +} + +/** + * Try to detect encoding by BOM / null density + * @param {Uint8Array} u8 + * @returns {string} Detected encoding + */ +export function sniffEncoding(u8) { + if (u8.length >= 2) { + const b0 = u8[0], + b1 = u8[1]; + if (b0 === 0xff && b1 === 0xfe) return 'utf-16le'; + if (b0 === 0xfe && b1 === 0xff) return 'utf-16be'; + } + // Heuristic: lots of NULs near the start → likely UTF-16 + let nul = 0; + for (let i = 0; i < Math.min(64, u8.length); i++) if (u8[i] === 0) nul++; + if (nul > 16) return 'utf-16le'; + return 'utf-8'; +} + +/** + * Remove leading BOM from already-decoded JS string + * @param {string} str + * @returns {string} Cleaned string without BOM + */ +export function stripBOM(str) { + return str && str.charCodeAt(0) === 0xfeff ? str.slice(1) : str; +} + +/** + * Decode XML/RELS content to a clean JS string. + * Accepts: string | Uint8Array | ArrayBuffer + * @param {string|Uint8Array|ArrayBuffer} content + * @returns {string} Clean XML string + */ +export function ensureXmlString(content) { + if (typeof content === 'string') return stripBOM(content); + + // Accept: Buffer, Uint8Array, DataView, any TypedArray, or ArrayBuffer + let u8 = null; + + if (content && typeof content === 'object') { + if (content instanceof Uint8Array) { + u8 = content; + } else if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(content)) { + // Node Buffer + u8 = new Uint8Array(content.buffer, content.byteOffset, content.byteLength); + } else if (ArrayBuffer.isView && ArrayBuffer.isView(content)) { + // Any ArrayBufferView: DataView or other TypedArray + u8 = new Uint8Array(content.buffer, content.byteOffset, content.byteLength); + } else if (content.constructor && (content instanceof ArrayBuffer || content.constructor.name === 'ArrayBuffer')) { + u8 = new Uint8Array(content); + } + } + + if (!u8) throw new Error('Unsupported content type for XML'); + + const enc = sniffEncoding(u8); + let xml = new TextDecoder(enc).decode(u8); + return stripBOM(xml); +} diff --git a/packages/super-editor/src/core/encoding-helpers.test.js b/packages/super-editor/src/core/encoding-helpers.test.js new file mode 100644 index 000000000..7a0a154bf --- /dev/null +++ b/packages/super-editor/src/core/encoding-helpers.test.js @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { isXmlLike, hex, sniffEncoding, stripBOM, ensureXmlString } from './encoding-helpers.js'; + +function utf16leWithBOM(str) { + const bom = Buffer.from([0xff, 0xfe]); + const body = Buffer.from(str, 'utf16le'); + return Buffer.concat([bom, body]); +} + +function utf16beWithBOM(str) { + const le = Buffer.from(str, 'utf16le'); + const swapped = Buffer.alloc(le.length); + for (let i = 0; i < le.length; i += 2) { + swapped[i] = le[i + 1]; + swapped[i + 1] = le[i]; + } + const bom = Buffer.from([0xfe, 0xff]); + return Buffer.concat([bom, swapped]); +} + +function noBOMUtf16leBytes(str) { + // UTF-16LE WITHOUT a BOM (to trigger the NUL-heuristic) + return Buffer.from(str, 'utf16le'); +} + +describe('isXmlLike', () => { + it('matches .xml and .rels', () => { + expect(isXmlLike('word/document.xml')).toBe(true); + expect(isXmlLike('word/_rels/document.xml.rels')).toBe(true); + expect(isXmlLike('docProps/core.xml')).toBe(true); + }); + it('rejects non-xml', () => { + expect(isXmlLike('word/media/image1.png')).toBe(false); + expect(isXmlLike('customXml/item1.xml.bin')).toBe(false); + expect(isXmlLike('word/fonts/font1.odttf')).toBe(false); + }); +}); + +describe('hex', () => { + it('renders hex dump of first N bytes', () => { + const u8 = new Uint8Array([0xff, 0xfe, 0x3c, 0x00, 0x3f, 0x00]); + expect(hex(u8, 6)).toBe('ff fe 3c 00 3f 00'); + }); +}); + +describe('sniffEncoding', () => { + it('detects UTF-16LE by BOM', () => { + const u8 = utf16leWithBOM(''); + expect(sniffEncoding(u8)).toBe('utf-16le'); + }); + it('detects UTF-16BE by BOM', () => { + const u8 = utf16beWithBOM(''); + expect(sniffEncoding(u8)).toBe('utf-16be'); + }); + it('defaults to utf-8 for plain ASCII/UTF-8', () => { + const u8 = new TextEncoder().encode(''); + expect(sniffEncoding(u8)).toBe('utf-8'); + }); + it('heuristically detects UTF-16 (no BOM) via NUL density', () => { + const u8 = noBOMUtf16leBytes(''); + // Our heuristic returns 'utf-16le' for lots of NULs + expect(sniffEncoding(u8)).toBe('utf-16le'); + }); +}); + +describe('stripBOM', () => { + it('removes U+FEFF if present', () => { + const s = '\uFEFF'; + expect(stripBOM(s)).toBe(''); + }); + it('no-ops when no BOM present', () => { + const s = ''; + expect(stripBOM(s)).toBe(s); + }); +}); + +describe('ensureXmlString', () => { + it('returns same string when given a plain XML string', () => { + const s = ''; + expect(ensureXmlString(s)).toBe(s); + }); + + it('strips leading BOM from a decoded string', () => { + const s = '\uFEFF'; + expect(ensureXmlString(s)).toBe(''); + }); + + it('decodes UTF-8 bytes', () => { + const u8 = new TextEncoder().encode('héllo'); + const out = ensureXmlString(u8); + expect(out).toContain(' { + const u8 = utf16leWithBOM('v'); + const out = ensureXmlString(u8); + expect(out.toLowerCase()).toContain('encoding="utf-16"'); + expect(out).toContain(''); + expect(out).not.toMatch(/\u0000/); + }); + + it('decodes UTF-16BE with BOM bytes', () => { + const u8 = utf16beWithBOM('v'); + const out = ensureXmlString(u8); + expect(out.toLowerCase()).toContain('encoding="utf-16"'); + expect(out).toContain(''); + expect(out).not.toMatch(/\u0000/); + }); + + it('decodes UTF-16 (no BOM) via heuristic', () => { + const u8 = noBOMUtf16leBytes('NOBOM'); + const out = ensureXmlString(u8); + expect(out).toContain(''); + expect(out).toContain('NOBOM'); + expect(out).not.toMatch(/\u0000/); + }); + + it('accepts ArrayBuffer input', () => { + const u8 = new TextEncoder().encode(''); + const out = ensureXmlString(u8.buffer); + expect(out).toContain(''); + }); + + it('throws on unsupported content types', () => { + expect(() => ensureXmlString(12345)).toThrow(/Unsupported content type/); + }); + + it('decodes from Node Buffer (utf-8)', () => { + const buf = Buffer.from('', 'utf8'); + const out = ensureXmlString(buf); + expect(out).toContain(''); + }); +}); + +describe('ensureXmlString cross-env', () => { + it('decodes from Node Buffer (utf-8)', () => { + const buf = Buffer.from('', 'utf8'); + const out = ensureXmlString(buf); + expect(out).toContain(''); + }); +}); From 6d09115f2bea86dc11d84a7e637d7ef897119116 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 2 Sep 2025 21:50:17 -0700 Subject: [PATCH 03/11] fix: imports encoded in utf-16 break DocxZipper From 850eac3c8feeb20dda5c6cfd065232a38db10734 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Sep 2025 08:29:38 -0300 Subject: [PATCH 04/11] chore: test patching --- packages/superdoc/src/core/SuperDoc.js | 1 - 1 file changed, 1 deletion(-) 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 From 505e27b55308cf397ef09fde6ab2f68ff31f5bde Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Sep 2025 08:30:52 -0300 Subject: [PATCH 05/11] fix: semantic release range --- .releaserc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.releaserc.json b/.releaserc.json index 5ee7b47ee..49980b70a 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -7,7 +7,8 @@ }, { "name": "release/v+([0-9])?(.{+([0-9]),x})", - "channel": "latest" + "channel": "latest", + "range": "${name.replace(/^release\\/v/, '')}.x" } ], "plugins": [ From 5a4f309f75a7437258a3a157702a861d3136f686 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Sep 2025 09:48:12 -0300 Subject: [PATCH 06/11] chore: test patching --- packages/super-editor/src/core/Editor.js | 1 - 1 file changed, 1 deletion(-) 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 From 1fda65576f879ad3e625aa6b9b94208271946853 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Sep 2025 10:04:54 -0300 Subject: [PATCH 07/11] fix: update release naming pattern in .releaserc.json for better version matching --- .releaserc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.releaserc.json b/.releaserc.json index 49980b70a..b1acd4bb6 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -6,7 +6,7 @@ "channel": "next" }, { - "name": "release/v+([0-9])?(.{+([0-9]),x})", + "name": "release/v+([0-9]).+([0-9])", "channel": "latest", "range": "${name.replace(/^release\\/v/, '')}.x" } From e9c331c1f4d95940d807498a162236ba0ade73f6 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 3 Sep 2025 17:13:36 +0000 Subject: [PATCH 08/11] chore(release): 0.16.1 [skip ci] ## [0.16.1](https://github.com/Harbour-Enterprises/SuperDoc/compare/v0.16.0...v0.16.1) (2025-09-03) ### Bug Fixes * add safety check for clipboard usage ([#859](https://github.com/Harbour-Enterprises/SuperDoc/issues/859)) ([bfca96e](https://github.com/Harbour-Enterprises/SuperDoc/commit/bfca96ea30f60d68229a6648152fb1d49c8de277)) * correct syntax in release workflow for semantic-release command ([3e6376e](https://github.com/Harbour-Enterprises/SuperDoc/commit/3e6376e600d6bf87b0fb20159452cfddadab3657)) * dispatch tracked changes transaction only once at import ([31ecec7](https://github.com/Harbour-Enterprises/SuperDoc/commit/31ecec70ba08d9668f45e9a33b724d7e60cc4b66)) * imports encoded in utf-16 break DocxZipper ([6d09115](https://github.com/Harbour-Enterprises/SuperDoc/commit/6d09115f2bea86dc11d84a7e637d7ef897119116)) * imports encoded in utf-16 break DocxZipper ([9bc488d](https://github.com/Harbour-Enterprises/SuperDoc/commit/9bc488d40430b61240d05bacc541cae51ea84ebb)) * imports encoded in utf-16 break DocxZipper ([#860](https://github.com/Harbour-Enterprises/SuperDoc/issues/860)) ([3a1be24](https://github.com/Harbour-Enterprises/SuperDoc/commit/3a1be24798490147dad3d39fc66e1bcac86d7875)) * semantic release range ([505e27b](https://github.com/Harbour-Enterprises/SuperDoc/commit/505e27b55308cf397ef09fde6ab2f68ff31f5bde)) * update release naming pattern in .releaserc.json for better version matching ([1fda655](https://github.com/Harbour-Enterprises/SuperDoc/commit/1fda65576f879ad3e625aa6b9b94208271946853)) --- packages/superdoc/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 8d0221b72..691423cb6 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", + "version": "0.16.1", "license": "AGPL-3.0", "readme": "../../README.md", "files": [ From 53c07c5a6adc7c674933bce2fa80f9f7d12ea24d Mon Sep 17 00:00:00 2001 From: Artem Nistuley <101666502+artem-harbour@users.noreply.github.com> Date: Wed, 3 Sep 2025 20:19:11 +0300 Subject: [PATCH 09/11] fix: restore stored marks if they exist (#863) --- .../src/extensions/block-node/block-node.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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) => { From 87b4e20fde21d6cc100d14be6cd8132c4d8a913b Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 3 Sep 2025 18:18:28 +0000 Subject: [PATCH 10/11] chore(release): 0.16.2 [skip ci] ## [0.16.2](https://github.com/Harbour-Enterprises/SuperDoc/compare/v0.16.1...v0.16.2) (2025-09-03) ### Bug Fixes * restore stored marks if they exist ([#863](https://github.com/Harbour-Enterprises/SuperDoc/issues/863)) ([53c07c5](https://github.com/Harbour-Enterprises/SuperDoc/commit/53c07c5a6adc7c674933bce2fa80f9f7d12ea24d)) --- packages/superdoc/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 691423cb6..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.1", + "version": "0.16.2", "license": "AGPL-3.0", "readme": "../../README.md", "files": [ From c273865484b1a1731507622887f56138d1d18752 Mon Sep 17 00:00:00 2001 From: Nick Bernal <117235294+harbournick@users.noreply.github.com> Date: Fri, 5 Sep 2025 22:03:47 -0700 Subject: [PATCH 11/11] fix: insertContentAt for html (#888) * fix: insertContentAt for html * fix: regex improvements --- .../src/core/commands/insertContentAt.js | 202 +++++++++--------- .../src/core/commands/insertContentAt.test.js | 75 +++++++ 2 files changed, 179 insertions(+), 98 deletions(-) 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); + }); });