Skip to content

Commit fb1f0c1

Browse files
authored
feat: add tab stop support (#730)
* feat: add tab stop support * chore: remove commented code * chore: fix tests * feat: cache tabWidth * chore: fix width cache handling for tabs * feat: add reamining ab stop types, more correct handling of defautl tab stops + generate tab stops after loading fonts
1 parent 049f7d5 commit fb1f0c1

9 files changed

Lines changed: 956 additions & 35 deletions

File tree

packages/super-editor/src/core/super-converter/exporter.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,31 @@ function generateParagraphProperties(node) {
344344
pPrElements.push(sectPr);
345345
}
346346

347+
// Add tab stops
348+
const { tabStops } = attrs;
349+
if (tabStops && tabStops.length > 0) {
350+
const tabElements = tabStops.map((tab) => {
351+
const tabAttributes = {
352+
'w:val': tab.val || 'start',
353+
'w:pos': pixelsToTwips(tab.pos).toString(),
354+
};
355+
356+
if (tab.leader) {
357+
tabAttributes['w:leader'] = tab.leader;
358+
}
359+
360+
return {
361+
name: 'w:tab',
362+
attributes: tabAttributes,
363+
};
364+
});
365+
366+
pPrElements.push({
367+
name: 'w:tabs',
368+
elements: tabElements,
369+
});
370+
}
371+
347372
const numPr = node.attrs?.paragraphProperties?.elements?.find((n) => n.name === 'w:numPr');
348373
const hasNumPr = pPrElements.some((n) => n.name === 'w:numPr');
349374
if (numPr && !hasNumPr) pPrElements.push(numPr);

packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,37 @@ export const handleParagraphNode = (params) => {
131131

132132
schemaNode.attrs['filename'] = filename;
133133

134+
// Parse tab stops
135+
const tabs = pPr?.elements?.find((el) => el.name === 'w:tabs');
136+
if (tabs && tabs.elements) {
137+
const tabStops = tabs.elements
138+
.filter((el) => el.name === 'w:tab')
139+
.map((tab) => {
140+
let val = tab.attributes['w:val'] || 'start';
141+
// Test files continue to contain "left" and "right" rather than "start" and "end"
142+
if (val == 'left') {
143+
val = 'start';
144+
} else if (val == 'right') {
145+
val = 'end';
146+
}
147+
const tabStop = {
148+
val,
149+
pos: twipsToPixels(tab.attributes['w:pos']),
150+
};
151+
152+
// Add leader if present
153+
if (tab.attributes['w:leader']) {
154+
tabStop.leader = tab.attributes['w:leader'];
155+
}
156+
157+
return tabStop;
158+
});
159+
160+
if (tabStops.length > 0) {
161+
schemaNode.attrs.tabStops = tabStops;
162+
}
163+
}
164+
134165
// Normalize text nodes.
135166
if (schemaNode && schemaNode.content) {
136167
schemaNode = {

packages/super-editor/src/extensions/heading/heading.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const Heading = Node.create({
2424
default: 1,
2525
rendered: false,
2626
},
27+
tabStops: { rendered: false },
2728
};
2829
},
2930

packages/super-editor/src/extensions/paragraph/paragraph.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export const Paragraph = Node.create({
110110
return { style };
111111
},
112112
},
113+
tabStops: { rendered: false },
113114
};
114115
},
115116

packages/super-editor/src/extensions/tab/tab.js

Lines changed: 213 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Node, Attribute } from '@core/index.js';
22
import { Plugin, PluginKey } from 'prosemirror-state';
33
import { Decoration, DecorationSet } from 'prosemirror-view';
4+
import { ReplaceStep, ReplaceAroundStep, StepMap } from 'prosemirror-transform';
5+
import { DOMSerializer } from 'prosemirror-model';
46

57
export const TabNode = Node.create({
68
name: 'tab',
@@ -44,73 +46,250 @@ export const TabNode = Node.create({
4446
},
4547

4648
addPmPlugins() {
47-
const { view } = this.editor;
49+
const { view, schema } = this.editor;
50+
const domSerializer = DOMSerializer.fromSchema(schema);
51+
4852
const tabPlugin = new Plugin({
4953
name: 'tabPlugin',
5054
key: new PluginKey('tabPlugin'),
5155
state: {
52-
init(_, state) {
53-
let decorations = getTabDecorations(state, view);
54-
return DecorationSet.create(state.doc, decorations);
56+
init() {
57+
return { decorations: false };
5558
},
59+
apply(tr, { decorations }, _oldState, newState) {
60+
if (!decorations) {
61+
decorations = DecorationSet.create(
62+
newState.doc,
63+
getTabDecorations(newState.doc, StepMap.empty, view, domSerializer),
64+
);
65+
}
5666

57-
apply(tr, oldDecorationSet, oldState, newState) {
58-
if (!tr.docChanged) return oldDecorationSet;
59-
const decorations = getTabDecorations(newState, view);
60-
return DecorationSet.create(newState.doc, decorations);
67+
if (!tr.docChanged) {
68+
return { decorations };
69+
}
70+
decorations = decorations.map(tr.mapping, tr.doc);
71+
72+
let rangesToRecalculate = [];
73+
tr.steps.forEach((step, index) => {
74+
const stepMap = step.getMap();
75+
if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) {
76+
const $from = tr.docs[index].resolve(step.from);
77+
const $to = tr.docs[index].resolve(step.to);
78+
const start = $from.start(Math.min($from.depth, 1)); // start of node at level 1
79+
const end = $to.end(Math.min($to.depth, 1)); // end of node at level 1
80+
let addRange = false;
81+
tr.docs[index].nodesBetween(start, end, (node) => {
82+
if (node.type.name === 'tab') {
83+
// Node contains or contained a tab
84+
addRange = true;
85+
}
86+
});
87+
if (!addRange && step.slice?.content) {
88+
step.slice.content.descendants((node) => {
89+
if (node.type.name === 'tab') {
90+
// A tab was added.
91+
addRange = true;
92+
}
93+
});
94+
}
95+
if (addRange) {
96+
rangesToRecalculate.push([start, end]);
97+
}
98+
}
99+
rangesToRecalculate = rangesToRecalculate.map(([from, to]) => {
100+
const mappedFrom = stepMap.map(from, -1);
101+
const mappedTo = stepMap.map(to, 1);
102+
return [mappedFrom, mappedTo];
103+
});
104+
});
105+
rangesToRecalculate.forEach(([start, end]) => {
106+
const oldDecorations = decorations.find(start, end);
107+
decorations = decorations.remove(oldDecorations);
108+
const invertMapping = tr.mapping.invert();
109+
const newDecorations = getTabDecorations(newState.doc, invertMapping, view, domSerializer, start, end);
110+
decorations = decorations.add(newState.doc, newDecorations);
111+
});
112+
return { decorations };
61113
},
62114
},
63115
props: {
64116
decorations(state) {
65-
return this.getState(state);
117+
return this.getState(state).decorations;
66118
},
67119
},
68120
});
69121
return [tabPlugin];
70122
},
71123
});
72124

73-
const tabWidthPx = 48;
125+
const defaultTabDistance = 48;
126+
const defaultLineLength = 816;
74127

75-
const getTabDecorations = (state, view) => {
128+
const getTabDecorations = (doc, invertMapping, view, domSerializer, from = 0, to = null) => {
129+
// TODO: Render "bar".
130+
if (!to) {
131+
to = doc.content.size;
132+
}
133+
const nodeWidthCache = {};
76134
let decorations = [];
77-
state.doc.descendants((node, pos) => {
135+
doc.nodesBetween(from, to, (node, pos, parent) => {
78136
if (node.type.name === 'tab') {
79-
let $pos = state.doc.resolve(pos);
80-
const prevNodeSize = $pos.nodeBefore?.nodeSize || 0;
81-
82-
let textWidth = 0;
83-
84-
try {
85-
state.doc.nodesBetween(pos - prevNodeSize - 1, pos - 1, (node, nodePos) => {
86-
if (node.isText && node.textContent !== ' ') {
87-
const textWidthForNode = calcTextWidth(view, nodePos);
88-
textWidth += textWidthForNode;
137+
let extraStyles = '';
138+
const $pos = doc.resolve(pos);
139+
const tabIndex = $pos.index($pos.depth);
140+
const fistlineIndent = parent.attrs?.indent?.firstLine || 0;
141+
const currentWidth =
142+
calcChildNodesWidth(
143+
parent,
144+
pos - $pos.parentOffset,
145+
0,
146+
tabIndex,
147+
domSerializer,
148+
view,
149+
invertMapping,
150+
nodeWidthCache,
151+
) + fistlineIndent;
152+
let tabWidth;
153+
if ($pos.depth === 1 && parent.attrs.tabStops && parent.attrs.tabStops.length > 0) {
154+
const tabStop = parent.attrs.tabStops.find((tabStop) => tabStop.pos > currentWidth && tabStop.val !== 'clear');
155+
if (tabStop) {
156+
tabWidth = tabStop.pos - currentWidth;
157+
if (['end', 'center'].includes(tabStop.val)) {
158+
let nextTabIndex = tabIndex + 1;
159+
while (nextTabIndex < parent.childCount && parent.child(nextTabIndex).type.name !== 'tab') {
160+
nextTabIndex++;
161+
}
162+
const tabSectionWidth = calcChildNodesWidth(
163+
parent,
164+
pos - $pos.parentOffset,
165+
tabIndex,
166+
nextTabIndex,
167+
domSerializer,
168+
view,
169+
invertMapping,
170+
nodeWidthCache,
171+
);
172+
tabWidth -= tabStop.val === 'end' ? tabSectionWidth : tabSectionWidth / 2;
173+
} else if (['decimal', 'num'].includes(tabStop.val)) {
174+
const breakChar = '.'; // TODO: The break character should likely be document language dependent.
175+
let nodeIndex = tabIndex + 1;
176+
let integralWidth = 0;
177+
let nodePos = pos - $pos.parentOffset;
178+
while (nodeIndex < parent.childCount) {
179+
const node = parent.child(nodeIndex);
180+
if (node.type.name === 'tab') {
181+
break;
182+
}
183+
const oldPos = invertMapping.map(nodePos);
184+
if (node.type.name === 'text' && node.text.includes(breakChar)) {
185+
// Only include text before the break character
186+
const modifiedNode = node.cut(0, node.text.indexOf(breakChar));
187+
integralWidth += calcNodeWidth(domSerializer, modifiedNode, view, oldPos);
188+
break;
189+
}
190+
integralWidth += calcNodeWidth(domSerializer, node, view, oldPos);
191+
nodeWidthCache[nodePos] = integralWidth;
192+
nodePos += node.nodeSize;
193+
nodeIndex += 1;
194+
}
195+
tabWidth -= integralWidth;
196+
}
197+
if (tabStop.leader) {
198+
// TODO: The following styles will likely not correspond 1:1 to the original. Adjust as needed.
199+
if (tabStop.leader === 'dot') {
200+
extraStyles += `border-bottom: 1px dotted black;`;
201+
} else if (tabStop.leader === 'heavy') {
202+
extraStyles += `border-bottom: 2px solid black;`;
203+
} else if (tabStop.leader === 'hyphen') {
204+
extraStyles += `border-bottom: 1px solid black;`;
205+
} else if (tabStop.leader === 'middleDot') {
206+
extraStyles += `border-bottom: 1px dotted black; margin-bottom: 2px;`;
207+
} else if (tabStop.leader === 'underscore') {
208+
extraStyles += `border-bottom: 1px solid black;`;
209+
}
89210
}
90-
});
91-
} catch {
92-
return;
211+
}
212+
}
213+
214+
if (!tabWidth || tabWidth < 1) {
215+
tabWidth = defaultTabDistance - ((currentWidth % defaultLineLength) % defaultTabDistance);
216+
if (tabWidth === 0) {
217+
tabWidth = defaultTabDistance;
218+
}
93219
}
94220

95-
const tabWidth = $pos.nodeBefore?.type.name === 'tab' ? tabWidthPx : tabWidthPx - (textWidth % tabWidthPx);
221+
nodeWidthCache[pos] = tabWidth; // Update width with final tab width - important for subsequent tabs.
222+
96223
const tabHeight = calcTabHeight($pos);
97224

98225
decorations.push(
99-
Decoration.node(pos, pos + node.nodeSize, { style: `width: ${tabWidth}px; height: ${tabHeight};` }),
226+
Decoration.node(pos, pos + node.nodeSize, {
227+
style: `width: ${tabWidth}px; height: ${tabHeight};${extraStyles}`,
228+
}),
100229
);
101230
}
102231
});
103232
return decorations;
104233
};
105234

106-
function calcTextWidth(view, pos) {
107-
const domNode = view.nodeDOM(pos);
108-
if (domNode) {
109-
const range = document.createRange();
110-
range.selectNodeContents(domNode);
111-
return range.getBoundingClientRect().width;
235+
function calcNodeWidth(domSerializer, node, view, oldPos) {
236+
// Create dom node of node. Then calculate width.
237+
const oldDomNode = view.nodeDOM(oldPos);
238+
const styleReference = oldDomNode ? (oldDomNode.nodeName === '#text' ? oldDomNode.parentNode : oldDomNode) : view.dom;
239+
const temp = document.createElement('div');
240+
const style = window.getComputedStyle(styleReference);
241+
// Copy relevant styles
242+
temp.style.cssText = `
243+
position: absolute;
244+
top: -9999px;
245+
left: -9999px;
246+
white-space: nowrap;
247+
font-family: ${style.fontFamily};
248+
font-size: ${style.fontSize};
249+
font-weight: ${style.fontWeight};
250+
font-style: ${style.fontStyle};
251+
letter-spacing: ${style.letterSpacing};
252+
word-spacing: ${style.wordSpacing};
253+
text-transform: ${style.textTransform};
254+
display: inline-block;
255+
`;
256+
257+
const domNode = domSerializer.serializeNode(node);
258+
259+
temp.appendChild(domNode);
260+
document.body.appendChild(temp);
261+
262+
const width = temp.offsetWidth;
263+
document.body.removeChild(temp);
264+
265+
return width;
266+
}
267+
268+
function calcChildNodesWidth(
269+
parent,
270+
parentPos,
271+
startIndex,
272+
endIndex,
273+
domSerializer,
274+
view,
275+
invertMapping,
276+
nodeWidthCache,
277+
) {
278+
let pos = parentPos;
279+
let width = 0;
280+
for (let i = 0; i < endIndex; i++) {
281+
const node = parent.child(i);
282+
if (i >= startIndex) {
283+
if (!nodeWidthCache[pos]) {
284+
nodeWidthCache[pos] = calcNodeWidth(domSerializer, node, view, invertMapping.map(pos));
285+
}
286+
width += nodeWidthCache[pos];
287+
}
288+
pos += node.nodeSize;
289+
290+
// TODO: This assumes no space between inline sibling nodes.
112291
}
113-
return 0;
292+
return width;
114293
}
115294

116295
function calcTabHeight(pos) {

packages/super-editor/src/tests/export/lists/miscOrderedListExport.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe('[custom_list1.docx] interrupted ordered list tests', async () => {
6565
const firstList = body.elements[0];
6666
const firstListPprList = firstList.elements.filter((n) => (n.name = 'w:pPr' && n.elements.length));
6767
const firstListPpr = firstListPprList[0];
68-
expect(firstListPpr.elements.length).toBe(4);
68+
expect(firstListPpr.elements.length).toBe(5);
6969

7070
const numPr = firstListPpr.elements.find((n) => n.name === 'w:numPr');
7171
const numIdTag = numPr.elements.find((n) => n.name === 'w:numId');

0 commit comments

Comments
 (0)