Skip to content

Commit acabf81

Browse files
palmer-clclarencepalmer
andauthored
fix: add support for vrect nodes (#758)
* fix: add support for vrect nodes * test: add import test * test: export tests --------- Co-authored-by: clarencepalmer <cole@rollprogramcole.com>
1 parent e349fdc commit acabf81

7 files changed

Lines changed: 586 additions & 7 deletions

File tree

packages/super-editor/src/assets/styles/elements/prosemirror.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,11 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html
340340
z-index: -1;
341341
}
342342

343+
.ProseMirror div[data-horizontal-rule='true'] {
344+
margin-top: auto;
345+
align-self: flex-end;
346+
}
347+
343348
.sd-editor-dropcap {
344349
float: left;
345350
display: flex;

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

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2143,8 +2143,14 @@ function translateShapeTextbox(params) {
21432143

21442144
function translateContentBlock(params) {
21452145
const { node } = params;
2146-
const { drawingContent } = node.attrs;
2146+
const { drawingContent, vmlAttributes, horizontalRule } = node.attrs;
21472147

2148+
// Handle VML v:rect elements (like horizontal rules)
2149+
if (vmlAttributes || horizontalRule) {
2150+
return translateVRectContentBlock(params);
2151+
}
2152+
2153+
// Handle modern DrawingML content (existing logic)
21482154
const drawing = {
21492155
name: 'w:drawing',
21502156
elements: [...(drawingContent ? [...(drawingContent.elements || [])] : [])],
@@ -2161,12 +2167,56 @@ function translateContentBlock(params) {
21612167
elements: [choice],
21622168
};
21632169

2164-
const par = {
2165-
name: 'w:p',
2166-
elements: [wrapTextInRun(alternateContent)],
2170+
return wrapTextInRun(alternateContent);
2171+
}
2172+
2173+
function translateVRectContentBlock(params) {
2174+
const { node } = params;
2175+
const { vmlAttributes, background, attributes, style } = node.attrs;
2176+
2177+
const rectAttrs = {
2178+
id: attributes?.id || `_x0000_i${Math.floor(Math.random() * 10000)}`,
21672179
};
21682180

2169-
return par;
2181+
if (style) {
2182+
rectAttrs.style = style;
2183+
}
2184+
2185+
if (background) {
2186+
rectAttrs.fillcolor = background;
2187+
}
2188+
2189+
if (vmlAttributes) {
2190+
if (vmlAttributes.hralign) rectAttrs['o:hralign'] = vmlAttributes.hralign;
2191+
if (vmlAttributes.hrstd) rectAttrs['o:hrstd'] = vmlAttributes.hrstd;
2192+
if (vmlAttributes.hr) rectAttrs['o:hr'] = vmlAttributes.hr;
2193+
if (vmlAttributes.stroked) rectAttrs.stroked = vmlAttributes.stroked;
2194+
}
2195+
2196+
if (attributes) {
2197+
Object.entries(attributes).forEach(([key, value]) => {
2198+
if (!rectAttrs[key] && value !== undefined) {
2199+
rectAttrs[key] = value;
2200+
}
2201+
});
2202+
}
2203+
2204+
// Create the v:rect element
2205+
const rect = {
2206+
name: 'v:rect',
2207+
attributes: rectAttrs,
2208+
};
2209+
2210+
// Wrap in w:pict
2211+
const pict = {
2212+
name: 'w:pict',
2213+
attributes: {
2214+
'w14:anchorId': Math.floor(Math.random() * 0xffffffff).toString(),
2215+
},
2216+
elements: [rect],
2217+
};
2218+
2219+
return wrapTextInRun(pict);
21702220
}
21712221

21722222
export class DocxExporter {

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

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { handleParagraphNode } from './paragraphNodeImporter.js';
22
import { defaultNodeListHandler } from './docxImporter.js';
3+
import { twipsToPixels, twipsToLines } from '../../helpers.js';
34

45
export const handlePictNode = (params) => {
56
const { nodes } = params;
@@ -20,6 +21,18 @@ export const handlePictNode = (params) => {
2021
const node = pict;
2122
const shape = node.elements?.find((el) => el.name === 'v:shape');
2223
const group = node.elements?.find((el) => el.name === 'v:group');
24+
const rect = node.elements?.find((el) => el.name === 'v:rect');
25+
26+
// Handle v:rect elements (like horizontal rules)
27+
if (rect) {
28+
const result = handleVRectImport({
29+
pict,
30+
pNode,
31+
rect,
32+
params,
33+
});
34+
return { nodes: result ? [result] : [], consumed: 1 };
35+
}
2336

2437
// such a case probably shouldn't exist.
2538
if (!shape && !group) {
@@ -50,6 +63,89 @@ export const handlePictNode = (params) => {
5063
return { nodes: result ? [result] : [], consumed: 1 };
5164
};
5265

66+
// Handler for v:rect elements
67+
export function handleVRectImport({ rect, pNode }) {
68+
const schemaAttrs = {};
69+
const rectAttrs = rect.attributes || {};
70+
71+
// Store all the attributes you specified
72+
schemaAttrs.attributes = rectAttrs;
73+
74+
// Parse style attribute
75+
if (rectAttrs.style) {
76+
const parsedStyle = parseInlineStyles(rectAttrs.style);
77+
const rectStyle = buildVRectStyles(parsedStyle);
78+
79+
if (rectStyle) {
80+
schemaAttrs.style = rectStyle;
81+
}
82+
83+
// Extract dimensions for the size attribute
84+
const size = {};
85+
if (parsedStyle.width !== undefined) {
86+
size.width = parsePointsToPixels(parsedStyle.width);
87+
88+
// Check for full page width identifier and adjust width to be 100%
89+
if (rectAttrs['o:hr'] === 't') {
90+
size.width = '100%';
91+
}
92+
}
93+
if (parsedStyle.height !== undefined) {
94+
size.height = parsePointsToPixels(parsedStyle.height);
95+
}
96+
if (Object.keys(size).length > 0) {
97+
schemaAttrs.size = size;
98+
}
99+
}
100+
101+
// Handle fillcolor
102+
if (rectAttrs.fillcolor) {
103+
schemaAttrs.background = rectAttrs.fillcolor;
104+
}
105+
106+
// Store VML-specific attributes
107+
const vmlAttrs = {};
108+
if (rectAttrs['o:hralign']) vmlAttrs.hralign = rectAttrs['o:hralign'];
109+
if (rectAttrs['o:hrstd']) vmlAttrs.hrstd = rectAttrs['o:hrstd'];
110+
if (rectAttrs['o:hr']) vmlAttrs.hr = rectAttrs['o:hr'];
111+
if (rectAttrs.stroked) vmlAttrs.stroked = rectAttrs.stroked;
112+
113+
if (Object.keys(vmlAttrs).length > 0) {
114+
schemaAttrs.vmlAttributes = vmlAttrs;
115+
}
116+
117+
// Determine if this is a horizontal rule
118+
const isHorizontalRule = rectAttrs['o:hr'] === 't' || rectAttrs['o:hrstd'] === 't';
119+
if (isHorizontalRule) {
120+
schemaAttrs.horizontalRule = true;
121+
}
122+
123+
const pPr = pNode.elements?.find((el) => el.name === 'w:pPr');
124+
const spacingElement = pPr?.elements?.find((el) => el.name === 'w:spacing');
125+
const spacingAttrs = spacingElement?.attributes || {};
126+
127+
// Parse spacing using the same logic as paragraphNodeImporter
128+
const spacing = {};
129+
if (spacingAttrs['w:after']) spacing.lineSpaceAfter = twipsToPixels(spacingAttrs['w:after']);
130+
if (spacingAttrs['w:before']) spacing.lineSpaceBefore = twipsToPixels(spacingAttrs['w:before']);
131+
if (spacingAttrs['w:line']) spacing.line = twipsToLines(spacingAttrs['w:line']);
132+
if (spacingAttrs['w:lineRule']) spacing.lineRule = spacingAttrs['w:lineRule'];
133+
134+
return {
135+
type: 'paragraph',
136+
content: [
137+
{
138+
type: 'contentBlock',
139+
attrs: schemaAttrs,
140+
},
141+
],
142+
attrs: {
143+
spacing: Object.keys(spacing).length > 0 ? spacing : undefined,
144+
rsidRDefault: pNode.attributes?.['w:rsidRDefault'],
145+
},
146+
};
147+
}
148+
53149
export function handleShapTextboxImport({ shape, params }) {
54150
const schemaAttrs = {};
55151
const schemaTextboxAttrs = {};
@@ -146,6 +242,42 @@ function buildStyles(styleObject) {
146242
return style;
147243
}
148244

245+
function buildVRectStyles(styleObject) {
246+
let style = '';
247+
for (const [prop, value] of Object.entries(styleObject)) {
248+
style += `${prop}: ${value};`;
249+
}
250+
251+
return style;
252+
}
253+
254+
export function parsePointsToPixels(value) {
255+
if (typeof value !== 'string') return value;
256+
257+
// Convert points to pixels (1pt ≈ 1.33px)
258+
if (value.endsWith('pt')) {
259+
const val = value.replace('pt', '');
260+
if (isNaN(val)) {
261+
return 0;
262+
}
263+
const points = parseFloat(val);
264+
return Math.round(points * 1.33);
265+
}
266+
267+
// Handle pixel values
268+
if (value.endsWith('px')) {
269+
const val = value.replace('px', '');
270+
if (isNaN(val)) {
271+
return 0;
272+
}
273+
return parseInt(val);
274+
}
275+
276+
// Handle numeric values (assume pixels)
277+
const numValue = parseFloat(value);
278+
return isNaN(numValue) ? 0 : numValue;
279+
}
280+
149281
export const pictNodeHandlerEntity = {
150282
handlerName: 'handlePictNode',
151283
handler: handlePictNode,

packages/super-editor/src/extensions/content-block/content-block.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ export const ContentBlock = Node.create({
1919

2020
addAttributes() {
2121
return {
22+
horizontalRule: {
23+
default: false,
24+
renderDOM: ({ horizontalRule }) => {
25+
if (!horizontalRule) return {};
26+
return { 'data-horizontal-rule': 'true' };
27+
},
28+
},
2229
size: {
2330
default: null,
2431
renderDOM: ({ size }) => {
@@ -27,8 +34,9 @@ export const ContentBlock = Node.create({
2734
let style = '';
2835
if (size.top) style += `top: ${size.top}px; `;
2936
if (size.left) style += `left: ${size.left}px; `;
30-
if (size.width) style += `width: ${size.width}px; `;
31-
if (size.height) style += `height: ${size.height}px; `;
37+
if (size.width) style += `width: ${size.width.toString().endsWith('%') ? size.width : `${size.width}px`}; `;
38+
if (size.height)
39+
style += `height: ${size.height.toString().endsWith('%') ? size.height : `${size.height}px`}; `;
3240
return { style };
3341
},
3442
},
108 KB
Binary file not shown.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { expect, it, describe, beforeEach } from 'vitest';
2+
import { getExportedResult } from './export-helpers/index';
3+
4+
describe('RectNodeExporter', async () => {
5+
const fileName = 'vrect-node.docx';
6+
const result = await getExportedResult(fileName);
7+
const body = {};
8+
9+
beforeEach(() => {
10+
Object.assign(
11+
body,
12+
result.elements?.find((el) => el.name === 'w:body'),
13+
);
14+
});
15+
16+
it('should export v:rect with all attributes', () => {
17+
const rect = body.elements[3].elements[1].elements[0].elements[0];
18+
expect(rect.attributes.id).toBe('_x0000_i1079');
19+
// Reverts back to the word doc value of 0 width
20+
expect(rect.attributes.style).toBe('width: 0;height: 1.5pt;');
21+
expect(rect.attributes.fillcolor).toBe('#a0a0a0');
22+
expect(rect.attributes['o:hr']).toBe('t');
23+
});
24+
});

0 commit comments

Comments
 (0)