|
| 1 | +import { CustomSelectionPluginKey } from '../custom-selection/custom-selection.js'; |
| 2 | +import { getLineHeightValueString } from '@core/super-converter/helpers.js'; |
| 3 | +import { findParentNode } from '@helpers/index.js'; |
| 4 | +import { kebabCase } from '@harbour-enterprises/common'; |
| 5 | + |
| 6 | +/** |
| 7 | + * Get the (parsed) linked style from the styles.xml |
| 8 | + * |
| 9 | + * @param {String} styleId The styleId of the linked style |
| 10 | + * @param {Array[Object]} styles The styles array |
| 11 | + * @returns {Object} The linked style |
| 12 | + */ |
| 13 | +export const getLinkedStyle = (styleId, styles = []) => { |
| 14 | + const linkedStyle = styles.find((style) => style.id === styleId); |
| 15 | + const basedOn = linkedStyle?.definition?.attrs?.basedOn; |
| 16 | + const basedOnStyle = styles.find((style) => style.id === basedOn); |
| 17 | + return { linkedStyle, basedOnStyle }; |
| 18 | +}; |
| 19 | + |
| 20 | +export const getSpacingStyle = (spacing) => { |
| 21 | + const { lineSpaceBefore, lineSpaceAfter, line, lineRule } = spacing; |
| 22 | + return { |
| 23 | + 'margin-top': lineSpaceBefore + 'px', |
| 24 | + 'margin-bottom': lineSpaceAfter + 'px', |
| 25 | + ...getLineHeightValueString(line, '', lineRule, true), |
| 26 | + }; |
| 27 | +}; |
| 28 | + |
| 29 | +/** |
| 30 | + * Convert spacing object to a style string |
| 31 | + * |
| 32 | + * @param {Object} spacing The spacing object |
| 33 | + * @returns {String} The style string |
| 34 | + */ |
| 35 | +export const getSpacingStyleString = (spacing) => { |
| 36 | + const { lineSpaceBefore, lineSpaceAfter, line } = spacing; |
| 37 | + return ` |
| 38 | + ${lineSpaceBefore ? `margin-top: ${lineSpaceBefore}px;` : ''} |
| 39 | + ${lineSpaceAfter ? `margin-bottom: ${lineSpaceAfter}px;` : ''} |
| 40 | + ${line ? getLineHeightValueString(line, '') : ''} |
| 41 | + `.trim(); |
| 42 | +}; |
| 43 | + |
| 44 | +export const getMarksStyle = (attrs) => { |
| 45 | + let styles = ''; |
| 46 | + for (const attr of attrs) { |
| 47 | + switch (attr.type) { |
| 48 | + case 'bold': |
| 49 | + styles += `font-weight: bold; `; |
| 50 | + break; |
| 51 | + case 'italic': |
| 52 | + styles += `font-style: italic; `; |
| 53 | + break; |
| 54 | + case 'underline': |
| 55 | + styles += `text-decoration: underline; `; |
| 56 | + break; |
| 57 | + case 'highlight': |
| 58 | + styles += `background-color: ${attr.attrs.color}; `; |
| 59 | + break; |
| 60 | + case 'textStyle': |
| 61 | + const { fontFamily, fontSize } = attr.attrs; |
| 62 | + styles += `${fontFamily ? `font-family: ${fontFamily};` : ''} ${fontSize ? `font-size: ${fontSize};` : ''}`; |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + return styles.trim(); |
| 67 | +}; |
| 68 | + |
| 69 | +export const getQuickFormatList = (editor) => { |
| 70 | + if (!editor?.converter) return []; |
| 71 | + const styles = editor.converter.linkedStyles || []; |
| 72 | + return styles |
| 73 | + .filter((style) => { |
| 74 | + return style.type === 'paragraph' && style.definition.attrs; |
| 75 | + }) |
| 76 | + .sort((a, b) => { |
| 77 | + return a.definition.attrs?.name.localeCompare(b.definition.attrs?.name); |
| 78 | + }); |
| 79 | +}; |
| 80 | + |
| 81 | +/** |
| 82 | + * Convert the linked styles and current node marks into a decoration string |
| 83 | + * If the node contains a given mark, we don't override it with the linked style per MS Word behavior |
| 84 | + * |
| 85 | + * @param {Object} linkedStyle The linked style object |
| 86 | + * @param {Object} basedOnStyle The basedOn style object |
| 87 | + * @param {Object} node The current node |
| 88 | + * @param {Object} parent The parent of current |
| 89 | + * @returns {String} The style string |
| 90 | + */ |
| 91 | +export const generateLinkedStyleString = (linkedStyle, basedOnStyle, node, parent, includeSpacing = true) => { |
| 92 | + if (!linkedStyle?.definition?.styles) return ''; |
| 93 | + const markValue = {}; |
| 94 | + |
| 95 | + const linkedDefinitionStyles = { ...linkedStyle.definition.styles }; |
| 96 | + const basedOnDefinitionStyles = { ...basedOnStyle?.definition?.styles }; |
| 97 | + const resultStyles = { ...linkedDefinitionStyles }; |
| 98 | + |
| 99 | + if (!linkedDefinitionStyles['font-size'] && basedOnDefinitionStyles['font-size']) { |
| 100 | + resultStyles['font-size'] = basedOnDefinitionStyles['font-size']; |
| 101 | + } |
| 102 | + if (!linkedDefinitionStyles['text-transform'] && basedOnDefinitionStyles['text-transform']) { |
| 103 | + resultStyles['text-transform'] = basedOnDefinitionStyles['text-transform']; |
| 104 | + } |
| 105 | + |
| 106 | + Object.entries(resultStyles).forEach(([k, value]) => { |
| 107 | + const key = kebabCase(k); |
| 108 | + const flattenedMarks = []; |
| 109 | + |
| 110 | + // Flatten node marks (including text styles) for comparison |
| 111 | + node?.marks?.forEach((n) => { |
| 112 | + if (n.type.name === 'textStyle') { |
| 113 | + Object.entries(n.attrs).forEach(([styleKey, value]) => { |
| 114 | + const parsedKey = kebabCase(styleKey); |
| 115 | + if (!value) return; |
| 116 | + flattenedMarks.push({ key: parsedKey, value }); |
| 117 | + }); |
| 118 | + return; |
| 119 | + } |
| 120 | + |
| 121 | + flattenedMarks.push({ key: n.type.name, value: n.attrs[key] }); |
| 122 | + }); |
| 123 | + |
| 124 | + // Check if this node has the expected mark. If yes, we are not overriding it |
| 125 | + const mark = flattenedMarks.find((n) => n.key === key); |
| 126 | + const hasParentIndent = Object.keys(parent?.attrs?.indent || {}); |
| 127 | + const hasParentSpacing = Object.keys(parent?.attrs?.spacing || {}); |
| 128 | + |
| 129 | + const listTypes = ['orderedList', 'listItem']; |
| 130 | + |
| 131 | + // If no mark already in the node, we override the style |
| 132 | + if (!mark) { |
| 133 | + if (key === 'spacing' && includeSpacing && !hasParentSpacing) { |
| 134 | + const space = getSpacingStyle(value); |
| 135 | + Object.entries(space).forEach(([k, v]) => { |
| 136 | + markValue[k] = v; |
| 137 | + }); |
| 138 | + } else if (key === 'indent' && includeSpacing && !hasParentIndent) { |
| 139 | + const { leftIndent, rightIndent, firstLine } = value; |
| 140 | + |
| 141 | + if (leftIndent) markValue['margin-left'] = leftIndent + 'px'; |
| 142 | + if (rightIndent) markValue['margin-right'] = rightIndent + 'px'; |
| 143 | + if (firstLine) markValue['text-indent'] = firstLine + 'px'; |
| 144 | + } else if (key === 'bold' && node) { |
| 145 | + const val = value?.value; |
| 146 | + if (!listTypes.includes(node.type.name) && val !== '0') { |
| 147 | + markValue['font-weight'] = 'bold'; |
| 148 | + } |
| 149 | + } else if (key === 'text-transform' && node) { |
| 150 | + if (!listTypes.includes(node.type.name)) { |
| 151 | + markValue[key] = value; |
| 152 | + } |
| 153 | + } else if (key === 'font-size' && node) { |
| 154 | + if (!listTypes.includes(node.type.name)) { |
| 155 | + markValue[key] = value; |
| 156 | + } |
| 157 | + } else if (typeof value === 'string') { |
| 158 | + markValue[key] = value; |
| 159 | + } |
| 160 | + } |
| 161 | + }); |
| 162 | + |
| 163 | + const final = Object.entries(markValue) |
| 164 | + .map(([key, value]) => `${key}: ${value}`) |
| 165 | + .join(';'); |
| 166 | + return final; |
| 167 | +}; |
| 168 | + |
| 169 | +/** |
| 170 | + * Helper function to apply a linked style to a transaction |
| 171 | + * |
| 172 | + * @param {Transaction} tr The transaction to mutate |
| 173 | + * @param {Editor} editor The editor instance |
| 174 | + * @param {object} style The linked style to apply |
| 175 | + * @returns {boolean} Whether the transaction was modified |
| 176 | + */ |
| 177 | +export const applyLinkedStyleToTransaction = (tr, editor, style) => { |
| 178 | + if (!style) return false; |
| 179 | + |
| 180 | + let selection = tr.selection; |
| 181 | + const state = editor.state; |
| 182 | + |
| 183 | + // Check for preserved selection from custom selection plugin |
| 184 | + const focusState = CustomSelectionPluginKey.getState(state); |
| 185 | + if (selection.empty && focusState?.preservedSelection) { |
| 186 | + selection = focusState.preservedSelection; |
| 187 | + tr.setSelection(selection); |
| 188 | + } |
| 189 | + // Fallback to lastSelection if no preserved selection |
| 190 | + else if (selection.empty && editor.options.lastSelection) { |
| 191 | + selection = editor.options.lastSelection; |
| 192 | + tr.setSelection(selection); |
| 193 | + } |
| 194 | + |
| 195 | + const { from, to } = selection; |
| 196 | + |
| 197 | + // Function to get clean paragraph attributes (strips existing styles) |
| 198 | + const getCleanParagraphAttrs = (node) => { |
| 199 | + const cleanAttrs = {}; |
| 200 | + const preservedAttrs = ['id', 'class']; |
| 201 | + |
| 202 | + preservedAttrs.forEach((attr) => { |
| 203 | + if (node.attrs[attr] !== undefined) { |
| 204 | + cleanAttrs[attr] = node.attrs[attr]; |
| 205 | + } |
| 206 | + }); |
| 207 | + |
| 208 | + // Apply the new style |
| 209 | + cleanAttrs.styleId = style.id; |
| 210 | + |
| 211 | + return cleanAttrs; |
| 212 | + }; |
| 213 | + |
| 214 | + // Function to clear formatting marks from text content |
| 215 | + const clearFormattingMarks = (startPos, endPos) => { |
| 216 | + tr.doc.nodesBetween(startPos, endPos, (node, pos) => { |
| 217 | + if (node.isText && node.marks.length > 0) { |
| 218 | + const marksToRemove = [ |
| 219 | + 'textStyle', |
| 220 | + 'bold', |
| 221 | + 'italic', |
| 222 | + 'underline', |
| 223 | + 'strike', |
| 224 | + 'subscript', |
| 225 | + 'superscript', |
| 226 | + 'highlight', |
| 227 | + ]; |
| 228 | + |
| 229 | + node.marks.forEach((mark) => { |
| 230 | + if (marksToRemove.includes(mark.type.name)) { |
| 231 | + tr.removeMark(pos, pos + node.nodeSize, mark); |
| 232 | + } |
| 233 | + }); |
| 234 | + } |
| 235 | + return true; |
| 236 | + }); |
| 237 | + }; |
| 238 | + |
| 239 | + // Handle cursor position (no selection) |
| 240 | + if (from === to) { |
| 241 | + let pos = from; |
| 242 | + let paragraphNode = tr.doc.nodeAt(from); |
| 243 | + |
| 244 | + if (paragraphNode?.type.name !== 'paragraph') { |
| 245 | + const parentNode = findParentNode((node) => node.type.name === 'paragraph')(selection); |
| 246 | + if (!parentNode) return false; |
| 247 | + pos = parentNode.pos; |
| 248 | + paragraphNode = parentNode.node; |
| 249 | + } |
| 250 | + |
| 251 | + // Clear formatting marks within the paragraph |
| 252 | + clearFormattingMarks(pos + 1, pos + paragraphNode.nodeSize - 1); |
| 253 | + |
| 254 | + // Apply clean paragraph attributes |
| 255 | + tr.setNodeMarkup(pos, undefined, getCleanParagraphAttrs(paragraphNode)); |
| 256 | + return true; |
| 257 | + } |
| 258 | + |
| 259 | + // Handle selection spanning multiple nodes |
| 260 | + const paragraphPositions = []; |
| 261 | + |
| 262 | + tr.doc.nodesBetween(from, to, (node, pos) => { |
| 263 | + if (node.type.name === 'paragraph') { |
| 264 | + paragraphPositions.push({ node, pos }); |
| 265 | + } |
| 266 | + return true; |
| 267 | + }); |
| 268 | + |
| 269 | + // Apply style to all paragraphs in selection (with clean attributes and cleared marks) |
| 270 | + paragraphPositions.forEach(({ node, pos }) => { |
| 271 | + // Clear formatting marks within this paragraph |
| 272 | + clearFormattingMarks(pos + 1, pos + node.nodeSize - 1); |
| 273 | + |
| 274 | + // Apply clean paragraph attributes |
| 275 | + tr.setNodeMarkup(pos, undefined, getCleanParagraphAttrs(node)); |
| 276 | + }); |
| 277 | + |
| 278 | + return true; |
| 279 | +}; |
0 commit comments