Skip to content

Commit 09aee5f

Browse files
committed
feat: extend linked styles api and reorganize folder
1 parent c45c32f commit 09aee5f

7 files changed

Lines changed: 405 additions & 338 deletions

File tree

packages/super-editor/src/components/toolbar/super-toolbar.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { startImageUpload, getFileOpener } from '../../extensions/image/imageHel
99
import { findParentNode } from '@helpers/index.js';
1010
import { toolbarIcons } from './toolbarIcons.js';
1111
import { toolbarTexts } from './toolbarTexts.js';
12-
import { getQuickFormatList } from '@extensions/linked-styles/linked-styles.js';
12+
import { getQuickFormatList } from '@extensions/linked-styles/index.js';
1313
import { getAvailableColorOptions, makeColorOption, renderColorOptions } from './color-dropdown-helpers.js';
1414
import { isInTable } from '@helpers/isInTable.js';
1515
import { useToolbarItem } from '@components/toolbar/use-toolbar-item';
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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+
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export * from './linked-styles';
2+
export * from './helpers.js';
3+
export * from './plugin.js';

0 commit comments

Comments
 (0)