diff --git a/packages/super-editor/src/editors/v1/extensions/link/link.js b/packages/super-editor/src/editors/v1/extensions/link/link.js index 1465878409..61da065961 100644 --- a/packages/super-editor/src/editors/v1/extensions/link/link.js +++ b/packages/super-editor/src/editors/v1/extensions/link/link.js @@ -4,6 +4,7 @@ import { Attribute } from '@core/Attribute.js'; import { getMarkRange } from '@core/helpers/getMarkRange.js'; import { findOrCreateRelationship } from '@core/parts/adapters/relationships-mutation.js'; import { sanitizeHref, encodeTooltip, UrlValidationConstants } from '@superdoc/url-validation'; +import { TRANSIENT_HYPERLINK_STYLE_IDS } from '@extensions/run/calculateInlineRunPropertiesPlugin.js'; /** * Target frame options @@ -227,9 +228,25 @@ export const Link = Mark.create({ } if (linkMarkType) tr = tr.removeMark(from, to, linkMarkType); - if (underlineMarkType) tr = tr.removeMark(from, to, underlineMarkType); - if (underlineMarkType) tr = tr.addMark(from, to, underlineMarkType.create()); + if (underlineMarkType) { + const rangesMissingUnderline = []; + tr.doc.nodesBetween(from, to, (node, pos) => { + if (!node.isText || node.nodeSize <= 0) return; + const hasUnderline = node.marks.some((mark) => mark.type === underlineMarkType); + if (hasUnderline) return; + + // Only apply while overlapping with current selection/link range + const rangeFrom = Math.max(pos, from); + const rangeTo = Math.min(pos + node.nodeSize, to); + if (rangeFrom >= rangeTo) return; + rangesMissingUnderline.push({ from: rangeFrom, to: rangeTo }); + }); + + rangesMissingUnderline.forEach((range) => { + tr = tr.addMark(range.from, range.to, underlineMarkType.create({ autoAdded: true })); + }); + } let rId = null; if (editor.options.mode === 'docx') { @@ -258,11 +275,78 @@ export const Link = Mark.create({ */ unsetLink: () => - ({ chain }) => { - return chain() - .unsetMark('underline', { extendEmptyMarkRange: true }) + ({ chain, state, editor }) => { + const { selection } = state; + const linkMarkType = editor.schema.marks.link; + const underlineMarkType = editor.schema.marks.underline; + + let { from, to } = selection; + + if (selection.empty && linkMarkType) { + const range = getMarkRange(selection.$from, linkMarkType); + if (range) { + from = range.from; + to = range.to; + } + } + + const commandChain = chain(); + + return commandChain .unsetColor() .unsetMark('link', { extendEmptyMarkRange: true }) + .command(({ tr }) => { + if (underlineMarkType) { + tr.doc.nodesBetween(from, to, (node, pos) => { + if (!node.isText) return; + node.marks.forEach((mark) => { + if (mark.type !== underlineMarkType) return; + if (mark.attrs?.autoAdded !== true) return; + tr.removeMark(pos, pos + node.nodeSize, mark); + }); + }); + } + + const textStyleMarkType = tr.doc.type.schema.marks.textStyle; + if (textStyleMarkType) { + tr.doc.nodesBetween(from, to, (node, pos) => { + if (!node.isText) return; + + node.marks.forEach((mark) => { + if (mark.type !== textStyleMarkType) return; + if (!TRANSIENT_HYPERLINK_STYLE_IDS.has(mark.attrs?.styleId)) return; + + const clearedAttrs = { ...mark.attrs, styleId: null }; + tr.removeMark(pos, pos + node.nodeSize, mark); + tr.addMark(pos, pos + node.nodeSize, textStyleMarkType.create(clearedAttrs)); + }); + }); + } + + const runNodesToUpdate = []; + tr.doc.nodesBetween(from, to, (node, pos) => { + if (node.type.name !== 'run') return; + if (!TRANSIENT_HYPERLINK_STYLE_IDS.has(node.attrs?.runProperties?.styleId)) return; + runNodesToUpdate.push({ node, pos }); + }); + + runNodesToUpdate + .sort((a, b) => b.pos - a.pos) + .forEach(({ node, pos }) => { + const mappedPos = tr.mapping.map(pos); + tr.setNodeMarkup( + mappedPos, + node.type, + { + ...node.attrs, + runProperties: { ...node.attrs.runProperties, styleId: null }, + }, + node.marks, + ); + }); + + return true; + }) .run(); }, diff --git a/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.js b/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.js index dbb7f32c69..0265493e4b 100644 --- a/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.js @@ -23,7 +23,7 @@ const RUN_PROPERTIES_DERIVED_FROM_MARKS = new Set([ 'position', ]); -const TRANSIENT_HYPERLINK_STYLE_IDS = new Set(['Hyperlink', 'FollowedHyperlink']); +export const TRANSIENT_HYPERLINK_STYLE_IDS = new Set(['Hyperlink', 'FollowedHyperlink']); const RUN_PROPERTY_PRESERVE_META_KEY = 'sdPreserveRunPropertiesKeys'; diff --git a/packages/super-editor/src/editors/v1/extensions/underline/underline.js b/packages/super-editor/src/editors/v1/extensions/underline/underline.js index 655d7350c4..b4ca70046d 100644 --- a/packages/super-editor/src/editors/v1/extensions/underline/underline.js +++ b/packages/super-editor/src/editors/v1/extensions/underline/underline.js @@ -84,6 +84,11 @@ export const Underline = Mark.create({ underlineThemeShade: { default: null, }, + // Internal flag to distinguish system-added underline. + autoAdded: { + default: false, + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/editors/v1/tests/editor/relationships.test.js b/packages/super-editor/src/editors/v1/tests/editor/relationships.test.js index 3917cc45cf..481f60ee91 100644 --- a/packages/super-editor/src/editors/v1/tests/editor/relationships.test.js +++ b/packages/super-editor/src/editors/v1/tests/editor/relationships.test.js @@ -41,6 +41,143 @@ describe('Relationships tests', () => { expect(found.attributes.Target).toBe('https://www.superdoc.dev'); }); + it.each(['Hyperlink', 'FollowedHyperlink'])( + 'clears transient textStyle and runProperties styleId "%s" when unsetting a link', + (styleId) => { + editor.commands.insertContent('link'); + editor.commands.selectAll(); + editor.commands.setLink({ href: 'https://www.superdoc.dev' }); + editor.commands.setMark('textStyle', { styleId }); + editor.commands.command(({ tr, dispatch }) => { + const runNodesToPatch = []; + + tr.doc.descendants((node, pos) => { + if (node.type.name !== 'run') return; + + runNodesToPatch.push({ node, pos }); + }); + + runNodesToPatch + .sort((a, b) => b.pos - a.pos) + .forEach(({ node, pos }) => { + tr.setNodeMarkup( + pos, + node.type, + { + ...node.attrs, + runProperties: { ...node.attrs.runProperties, styleId }, + }, + node.marks, + ); + }); + + dispatch(tr); + return true; + }); + + editor.commands.unsetLink(); + + const textStyleMarks = []; + const runNodes = []; + editor.state.doc.descendants((node) => { + if (!node.isText) return; + node.marks.forEach((mark) => { + if (mark.type.name === 'textStyle') { + textStyleMarks.push(mark); + } + }); + }); + editor.state.doc.descendants((node) => { + if (node.type.name !== 'run') return; + runNodes.push(node); + }); + + expect(textStyleMarks.length).toBeGreaterThan(0); + textStyleMarks.forEach((mark) => { + expect(mark.attrs.styleId).toBeNull(); + }); + expect(runNodes.length).toBeGreaterThan(0); + runNodes.forEach((runNode) => { + expect(runNode.attrs.runProperties?.styleId).toBeNull(); + }); + }, + ); + + it('preserves pre-existing underline after unsetLink', () => { + editor.commands.insertContent('link'); + editor.commands.selectAll(); + editor.commands.setUnderline(); + editor.commands.setLink({ href: 'https://www.superdoc.dev' }); + editor.commands.unsetLink(); + + let hasUnderline = false; + editor.state.doc.descendants((node) => { + if (!node.isText) return; + if (node.marks.some((mark) => mark.type.name === 'underline')) { + hasUnderline = true; + } + }); + + expect(hasUnderline).toBe(true); + }); + + it('removes underline on unsetLink when underline was not pre-existing', () => { + editor.commands.insertContent('link'); + editor.commands.selectAll(); + editor.commands.setLink({ href: 'https://www.superdoc.dev' }); + editor.commands.unsetLink(); + + let hasUnderline = false; + editor.state.doc.descendants((node) => { + if (!node.isText) return; + if (node.marks.some((mark) => mark.type.name === 'underline')) { + hasUnderline = true; + } + }); + + expect(hasUnderline).toBe(false); + }); + + it('keeps imported inline underline mark when removing link', async () => { + const imported = await loadTestDataForEditorTests('hyperlink_node.docx'); + const { editor: importedEditor } = initTestEditor({ + content: imported.docx, + media: imported.media, + mediaFiles: imported.mediaFiles, + fonts: imported.fonts, + }); + + importedEditor.commands.selectAll(); + + let importedUnderlineBefore = 0; + let linkCountBefore = 0; + importedEditor.state.doc.descendants((node) => { + if (!node.isText) return; + node.marks.forEach((mark) => { + if (mark.type.name === 'underline' && mark.attrs?.autoAdded !== true) importedUnderlineBefore += 1; + if (mark.type.name === 'link') linkCountBefore += 1; + }); + }); + + expect(linkCountBefore).toBeGreaterThan(0); + expect(importedUnderlineBefore).toBeGreaterThan(0); + + importedEditor.commands.unsetLink(); + + let importedUnderlineAfter = 0; + let linkCountAfter = 0; + importedEditor.state.doc.descendants((node) => { + if (!node.isText) return; + node.marks.forEach((mark) => { + if (mark.type.name === 'underline' && mark.attrs?.autoAdded !== true) importedUnderlineAfter += 1; + if (mark.type.name === 'link') linkCountAfter += 1; + }); + }); + + expect(linkCountAfter).toBe(0); + expect(importedUnderlineAfter).toBeGreaterThan(0); + }); + it('tests that the uploaded image has a rId and a relationship', async () => { const blob = await fetch(imageBase64).then((res) => res.blob()); const file = new File([blob], 'image.png', { type: 'image/png' });