Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions packages/super-editor/src/core/super-converter/exporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,31 @@ function generateParagraphProperties(node) {
pPrElements.push(sectPr);
}

// Add tab stops
const { tabStops } = attrs;
if (tabStops && tabStops.length > 0) {
const tabElements = tabStops.map((tab) => {
const tabAttributes = {
'w:val': tab.val || 'start',
'w:pos': pixelsToTwips(tab.pos).toString(),
};

if (tab.leader) {
tabAttributes['w:leader'] = tab.leader;
}

return {
name: 'w:tab',
attributes: tabAttributes,
};
});

pPrElements.push({
name: 'w:tabs',
elements: tabElements,
});
}

const numPr = node.attrs?.paragraphProperties?.elements?.find((n) => n.name === 'w:numPr');
const hasNumPr = pPrElements.some((n) => n.name === 'w:numPr');
if (numPr && !hasNumPr) pPrElements.push(numPr);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,37 @@ export const handleParagraphNode = (params) => {

schemaNode.attrs['filename'] = filename;

// Parse tab stops
const tabs = pPr?.elements?.find((el) => el.name === 'w:tabs');
if (tabs && tabs.elements) {
const tabStops = tabs.elements
.filter((el) => el.name === 'w:tab')
.map((tab) => {
let val = tab.attributes['w:val'] || 'start';
// Test files continue to contain "left" and "right" rather than "start" and "end"
if (val == 'left') {
val = 'start';
} else if (val == 'right') {
val = 'end';
}
const tabStop = {
val,
pos: twipsToPixels(tab.attributes['w:pos']),
};

// Add leader if present
if (tab.attributes['w:leader']) {
tabStop.leader = tab.attributes['w:leader'];
}

return tabStop;
});

if (tabStops.length > 0) {
schemaNode.attrs.tabStops = tabStops;
}
}

// Normalize text nodes.
if (schemaNode && schemaNode.content) {
schemaNode = {
Expand Down
1 change: 1 addition & 0 deletions packages/super-editor/src/extensions/heading/heading.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const Heading = Node.create({
default: 1,
rendered: false,
},
tabStops: { rendered: false },
};
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export const Paragraph = Node.create({
return { style };
},
},
tabStops: { rendered: false },
};
},

Expand Down
247 changes: 213 additions & 34 deletions packages/super-editor/src/extensions/tab/tab.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Node, Attribute } from '@core/index.js';
import { Plugin, PluginKey } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { ReplaceStep, ReplaceAroundStep, StepMap } from 'prosemirror-transform';
import { DOMSerializer } from 'prosemirror-model';

export const TabNode = Node.create({
name: 'tab',
Expand Down Expand Up @@ -44,73 +46,250 @@ export const TabNode = Node.create({
},

addPmPlugins() {
const { view } = this.editor;
const { view, schema } = this.editor;
const domSerializer = DOMSerializer.fromSchema(schema);

const tabPlugin = new Plugin({
name: 'tabPlugin',
key: new PluginKey('tabPlugin'),
state: {
init(_, state) {
let decorations = getTabDecorations(state, view);
return DecorationSet.create(state.doc, decorations);
init() {
return { decorations: false };
},
apply(tr, { decorations }, _oldState, newState) {
if (!decorations) {
decorations = DecorationSet.create(
newState.doc,
getTabDecorations(newState.doc, StepMap.empty, view, domSerializer),
);
}

apply(tr, oldDecorationSet, oldState, newState) {
if (!tr.docChanged) return oldDecorationSet;
const decorations = getTabDecorations(newState, view);
return DecorationSet.create(newState.doc, decorations);
if (!tr.docChanged) {
return { decorations };
}
decorations = decorations.map(tr.mapping, tr.doc);

let rangesToRecalculate = [];
tr.steps.forEach((step, index) => {
const stepMap = step.getMap();
if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) {
const $from = tr.docs[index].resolve(step.from);
const $to = tr.docs[index].resolve(step.to);
const start = $from.start(Math.min($from.depth, 1)); // start of node at level 1
const end = $to.end(Math.min($to.depth, 1)); // end of node at level 1
let addRange = false;
tr.docs[index].nodesBetween(start, end, (node) => {
if (node.type.name === 'tab') {
// Node contains or contained a tab
addRange = true;
}
});
if (!addRange && step.slice?.content) {
step.slice.content.descendants((node) => {
if (node.type.name === 'tab') {
// A tab was added.
addRange = true;
}
});
}
if (addRange) {
rangesToRecalculate.push([start, end]);
}
}
rangesToRecalculate = rangesToRecalculate.map(([from, to]) => {
const mappedFrom = stepMap.map(from, -1);
const mappedTo = stepMap.map(to, 1);
return [mappedFrom, mappedTo];
});
});
rangesToRecalculate.forEach(([start, end]) => {
const oldDecorations = decorations.find(start, end);
decorations = decorations.remove(oldDecorations);
const invertMapping = tr.mapping.invert();
const newDecorations = getTabDecorations(newState.doc, invertMapping, view, domSerializer, start, end);
decorations = decorations.add(newState.doc, newDecorations);
});
return { decorations };
},
},
props: {
decorations(state) {
return this.getState(state);
return this.getState(state).decorations;
},
},
});
return [tabPlugin];
},
});

const tabWidthPx = 48;
const defaultTabDistance = 48;
const defaultLineLength = 816;

const getTabDecorations = (state, view) => {
const getTabDecorations = (doc, invertMapping, view, domSerializer, from = 0, to = null) => {
// TODO: Render "bar".
if (!to) {
to = doc.content.size;
}
const nodeWidthCache = {};
let decorations = [];
state.doc.descendants((node, pos) => {
doc.nodesBetween(from, to, (node, pos, parent) => {
if (node.type.name === 'tab') {
let $pos = state.doc.resolve(pos);
const prevNodeSize = $pos.nodeBefore?.nodeSize || 0;

let textWidth = 0;

try {
state.doc.nodesBetween(pos - prevNodeSize - 1, pos - 1, (node, nodePos) => {
if (node.isText && node.textContent !== ' ') {
const textWidthForNode = calcTextWidth(view, nodePos);
textWidth += textWidthForNode;
let extraStyles = '';
const $pos = doc.resolve(pos);
const tabIndex = $pos.index($pos.depth);
const fistlineIndent = parent.attrs?.indent?.firstLine || 0;
const currentWidth =
calcChildNodesWidth(
parent,
pos - $pos.parentOffset,
0,
tabIndex,
domSerializer,
view,
invertMapping,
nodeWidthCache,
) + fistlineIndent;
let tabWidth;
if ($pos.depth === 1 && parent.attrs.tabStops && parent.attrs.tabStops.length > 0) {
const tabStop = parent.attrs.tabStops.find((tabStop) => tabStop.pos > currentWidth && tabStop.val !== 'clear');
if (tabStop) {
tabWidth = tabStop.pos - currentWidth;
if (['end', 'center'].includes(tabStop.val)) {
let nextTabIndex = tabIndex + 1;
while (nextTabIndex < parent.childCount && parent.child(nextTabIndex).type.name !== 'tab') {
nextTabIndex++;
}
const tabSectionWidth = calcChildNodesWidth(
parent,
pos - $pos.parentOffset,
tabIndex,
nextTabIndex,
domSerializer,
view,
invertMapping,
nodeWidthCache,
);
tabWidth -= tabStop.val === 'end' ? tabSectionWidth : tabSectionWidth / 2;
} else if (['decimal', 'num'].includes(tabStop.val)) {
const breakChar = '.'; // TODO: The break character should likely be document language dependent.
let nodeIndex = tabIndex + 1;
let integralWidth = 0;
let nodePos = pos - $pos.parentOffset;
while (nodeIndex < parent.childCount) {
const node = parent.child(nodeIndex);
if (node.type.name === 'tab') {
break;
}
const oldPos = invertMapping.map(nodePos);
if (node.type.name === 'text' && node.text.includes(breakChar)) {
// Only include text before the break character
const modifiedNode = node.cut(0, node.text.indexOf(breakChar));
integralWidth += calcNodeWidth(domSerializer, modifiedNode, view, oldPos);
break;
}
integralWidth += calcNodeWidth(domSerializer, node, view, oldPos);
nodeWidthCache[nodePos] = integralWidth;
nodePos += node.nodeSize;
nodeIndex += 1;
}
tabWidth -= integralWidth;
}
if (tabStop.leader) {
// TODO: The following styles will likely not correspond 1:1 to the original. Adjust as needed.
if (tabStop.leader === 'dot') {
extraStyles += `border-bottom: 1px dotted black;`;
} else if (tabStop.leader === 'heavy') {
extraStyles += `border-bottom: 2px solid black;`;
} else if (tabStop.leader === 'hyphen') {
extraStyles += `border-bottom: 1px solid black;`;
} else if (tabStop.leader === 'middleDot') {
extraStyles += `border-bottom: 1px dotted black; margin-bottom: 2px;`;
} else if (tabStop.leader === 'underscore') {
extraStyles += `border-bottom: 1px solid black;`;
}
}
});
} catch {
return;
}
}

if (!tabWidth || tabWidth < 1) {
tabWidth = defaultTabDistance - ((currentWidth % defaultLineLength) % defaultTabDistance);
if (tabWidth === 0) {
tabWidth = defaultTabDistance;
}
}

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

const tabHeight = calcTabHeight($pos);

decorations.push(
Decoration.node(pos, pos + node.nodeSize, { style: `width: ${tabWidth}px; height: ${tabHeight};` }),
Decoration.node(pos, pos + node.nodeSize, {
style: `width: ${tabWidth}px; height: ${tabHeight};${extraStyles}`,
}),
);
}
});
return decorations;
};

function calcTextWidth(view, pos) {
const domNode = view.nodeDOM(pos);
if (domNode) {
const range = document.createRange();
range.selectNodeContents(domNode);
return range.getBoundingClientRect().width;
function calcNodeWidth(domSerializer, node, view, oldPos) {
// Create dom node of node. Then calculate width.
const oldDomNode = view.nodeDOM(oldPos);
const styleReference = oldDomNode ? (oldDomNode.nodeName === '#text' ? oldDomNode.parentNode : oldDomNode) : view.dom;
const temp = document.createElement('div');
const style = window.getComputedStyle(styleReference);
// Copy relevant styles
temp.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
white-space: nowrap;
font-family: ${style.fontFamily};
font-size: ${style.fontSize};
font-weight: ${style.fontWeight};
font-style: ${style.fontStyle};
letter-spacing: ${style.letterSpacing};
word-spacing: ${style.wordSpacing};
text-transform: ${style.textTransform};
display: inline-block;
`;

const domNode = domSerializer.serializeNode(node);

temp.appendChild(domNode);
document.body.appendChild(temp);

const width = temp.offsetWidth;
document.body.removeChild(temp);

return width;
}

function calcChildNodesWidth(
parent,
parentPos,
startIndex,
endIndex,
domSerializer,
view,
invertMapping,
nodeWidthCache,
) {
let pos = parentPos;
let width = 0;
for (let i = 0; i < endIndex; i++) {
const node = parent.child(i);
if (i >= startIndex) {
if (!nodeWidthCache[pos]) {
nodeWidthCache[pos] = calcNodeWidth(domSerializer, node, view, invertMapping.map(pos));
}
width += nodeWidthCache[pos];
}
pos += node.nodeSize;

// TODO: This assumes no space between inline sibling nodes.
}
return 0;
return width;
}

function calcTabHeight(pos) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe('[custom_list1.docx] interrupted ordered list tests', async () => {
const firstList = body.elements[0];
const firstListPprList = firstList.elements.filter((n) => (n.name = 'w:pPr' && n.elements.length));
const firstListPpr = firstListPprList[0];
expect(firstListPpr.elements.length).toBe(4);
expect(firstListPpr.elements.length).toBe(5);

const numPr = firstListPpr.elements.find((n) => n.name === 'w:numPr');
const numIdTag = numPr.elements.find((n) => n.name === 'w:numId');
Expand Down
Loading
Loading