Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ac7fe3c
chore(release): 0.16.0 [skip ci]
semantic-release-bot Sep 3, 2025
9bc488d
fix: imports encoded in utf-16 break DocxZipper
harbournick Sep 3, 2025
6d09115
fix: imports encoded in utf-16 break DocxZipper
harbournick Sep 3, 2025
850eac3
chore: test patching
caio-pizzol Sep 3, 2025
505e27b
fix: semantic release range
caio-pizzol Sep 3, 2025
5a4f309
chore: test patching
caio-pizzol Sep 3, 2025
8753421
Merge branch 'main' of https://github.com/Harbour-Enterprises/SuperDo…
caio-pizzol Sep 3, 2025
1fda655
fix: update release naming pattern in .releaserc.json for better vers…
caio-pizzol Sep 3, 2025
afc6414
Merge branch 'main' of https://github.com/Harbour-Enterprises/SuperDo…
caio-pizzol Sep 3, 2025
905df1f
Merge branch 'main' of https://github.com/Harbour-Enterprises/SuperDo…
caio-pizzol Sep 3, 2025
2af6097
Merge branch 'main' of https://github.com/Harbour-Enterprises/SuperDo…
caio-pizzol Sep 3, 2025
34c7189
Merge branch 'main' of https://github.com/Harbour-Enterprises/SuperDo…
caio-pizzol Sep 3, 2025
0f5546b
Merge branch 'main' of https://github.com/Harbour-Enterprises/SuperDo…
caio-pizzol Sep 3, 2025
7381930
Merge branch 'main' of https://github.com/Harbour-Enterprises/SuperDo…
caio-pizzol Sep 3, 2025
0abf060
Merge branch 'main' of https://github.com/Harbour-Enterprises/SuperDo…
caio-pizzol Sep 3, 2025
d617907
Merge branch 'main' of https://github.com/Harbour-Enterprises/SuperDo…
caio-pizzol Sep 3, 2025
c49bfb0
Merge branch 'main' of https://github.com/Harbour-Enterprises/SuperDo…
caio-pizzol Sep 3, 2025
e9c331c
chore(release): 0.16.1 [skip ci]
semantic-release-bot Sep 3, 2025
53c07c5
fix: restore stored marks if they exist (#863)
artem-harbour Sep 3, 2025
87b4e20
chore(release): 0.16.2 [skip ci]
semantic-release-bot Sep 3, 2025
c273865
fix: insertContentAt for html (#888)
harbournick Sep 6, 2025
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
2 changes: 1 addition & 1 deletion packages/super-editor/src/components/slash-menu/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion packages/super-editor/src/core/Editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,6 @@ export class Editor extends EventEmitter {
// async (file) => url;
handleImageUpload: null,

// telemetry
telemetry: null,

// Docx xml updated by User
Expand Down
202 changes: 104 additions & 98 deletions packages/super-editor/src/core/commands/insertContentAt.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,118 +17,124 @@ const isFragment = (nodeOrFragment) => {

/**
* Inserts content at the specified position.
* @param {import("prosemirror-model").ResolvedPos} position
* @param {string|Array<string|ProseMirrorNode>} 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<string|{text?:string}>|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;
Expand Down
75 changes: 75 additions & 0 deletions packages/super-editor/src/core/commands/insertContentAt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() }, // <hardBreak>
{ 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 = '<p>Hello HTML</p>';

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);
});
});
Loading