fix ellipse text field
This commit is contained in:
1 parent
0526579f2b
commit
a535738301
4 files changed
+99
-17
No files matched your search
@@ -18,6 +18,10 @@ export type ShapeKind = 'textbox' | 'roundRect' | 'ellipse';
|
||||
export const TEXTBOX_PADDING_CM = 0.15;
|
||||
const PADDING_PX = (TEXTBOX_PADDING_CM * 96) / 2.54;
|
||||
|
||||
// Extra per-axis inset that fits the text area into an ellipse's inscribed
|
||||
// rectangle (half-axis / √2), so text follows the oval instead of the bbox.
|
||||
const ELLIPSE_INSET_RATIO = (1 - 1 / Math.SQRT2) / 2;
|
||||
|
||||
const DEFAULT_WIDTH_PX = 280;
|
||||
const DEFAULT_HEIGHT_PX = 96;
|
||||
const PX_PER_PT = 96 / 72;
|
||||
@@ -224,6 +228,9 @@ class TextBoxView {
|
||||
private getPos: () => number;
|
||||
private observer: ResizeObserver | null = null;
|
||||
private resizing = false;
|
||||
// Last ellipse text-area inset applied; guards the ResizeObserver feedback loop.
|
||||
private lastInsetX = -1;
|
||||
private lastInsetY = -1;
|
||||
|
||||
constructor(node: PMNode, editor: Editor, getPos: () => number) {
|
||||
this.node = node;
|
||||
@@ -265,8 +272,12 @@ class TextBoxView {
|
||||
this.dom.addEventListener('mousedown', e => this.onFrameMouseDown(e));
|
||||
|
||||
// The rotor is out of flow and grows with its content; refit the wrapper to the
|
||||
// rotated bounding box whenever the rendered size changes (typing, resizing).
|
||||
this.observer = new ResizeObserver(() => this.fitWrapper());
|
||||
// rotated bounding box whenever the rendered size changes (typing, resizing), and
|
||||
// re-fit an ellipse's text area to its now-current height.
|
||||
this.observer = new ResizeObserver(() => {
|
||||
this.applyShapeInset();
|
||||
this.fitWrapper();
|
||||
});
|
||||
this.observer.observe(this.rotor);
|
||||
|
||||
this.applyAll();
|
||||
@@ -287,9 +298,30 @@ class TextBoxView {
|
||||
this.rotor.style.borderRadius = shapeRadius(a.shapeKind);
|
||||
this.rotor.style.transform = `translate(-50%, -50%) rotate(${a.rotation}deg)`;
|
||||
this.applyWrap();
|
||||
this.applyShapeInset();
|
||||
this.fitWrapper();
|
||||
}
|
||||
|
||||
// For an ellipse, pad the content into the inscribed rectangle so text stays inside
|
||||
// the oval. The vertical inset feeds back into the auto-grown height, so rewrite it
|
||||
// only past a threshold — the ResizeObserver then settles (ratio < 0.5 converges).
|
||||
private applyShapeInset(): void {
|
||||
if (this.attrs().shapeKind !== 'ellipse') {
|
||||
if (this.contentDOM.style.padding) this.contentDOM.style.padding = '';
|
||||
this.lastInsetX = this.lastInsetY = -1;
|
||||
return;
|
||||
}
|
||||
const w = this.rotor.offsetWidth;
|
||||
const h = this.rotor.offsetHeight;
|
||||
if (!w || !h) return;
|
||||
const insetX = w * ELLIPSE_INSET_RATIO;
|
||||
const insetY = h * ELLIPSE_INSET_RATIO;
|
||||
if (Math.abs(insetX - this.lastInsetX) < 0.5 && Math.abs(insetY - this.lastInsetY) < 0.5) return;
|
||||
this.lastInsetX = insetX;
|
||||
this.lastInsetY = insetY;
|
||||
this.contentDOM.style.padding = `${insetY.toFixed(2)}px ${insetX.toFixed(2)}px`;
|
||||
}
|
||||
|
||||
// Size the wrapper to the rotor's rotated bounding box so surrounding text
|
||||
// reserves the right space (same math as ImageView.applyLayout).
|
||||
private fitWrapper(): void {
|
||||
@@ -443,6 +475,7 @@ class TextBoxView {
|
||||
moved = true;
|
||||
this.rotor.style.width = `${lastW}px`;
|
||||
this.rotor.style.minHeight = `${lastH}px`;
|
||||
this.applyShapeInset();
|
||||
this.fitWrapper();
|
||||
this.showBadge(lastW, lastH);
|
||||
};
|
||||
@@ -508,6 +541,9 @@ class TextBoxView {
|
||||
}
|
||||
|
||||
ignoreMutation(m: MutationRecord | { type: 'selection'; target: globalThis.Node }): boolean {
|
||||
// The ellipse inset padding we write to contentDOM is our own styling, not a content
|
||||
// edit — without this PM rebuilds the node view mid-resize and the wrapper collapses.
|
||||
if (m.type === 'attributes' && m.target === this.contentDOM) return true;
|
||||
return !this.contentDOM.contains(m.target);
|
||||
}
|
||||
|
||||
|
||||
@@ -1787,6 +1787,7 @@ function applyDateTimeFields(odtBytes: Uint8Array, fields: DateTimeFieldExport[]
|
||||
const ENHANCED_GEOMETRY: Record<'roundRect' | 'ellipse', string> = {
|
||||
ellipse:
|
||||
'<draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="ellipse"' +
|
||||
' draw:text-areas="3163 3163 18437 18437"' +
|
||||
' draw:enhanced-path="U 10800 10800 10800 10800 0 360 Z N"/>',
|
||||
roundRect:
|
||||
'<draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="round-rectangle" draw:modifiers="3600"' +
|
||||
|
||||
+21
-15
@@ -138,17 +138,22 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null {
|
||||
}
|
||||
|
||||
// A shape's fill color. fo:background-color (LibreOffice's per-shape fill) wins over
|
||||
// draw:fill when set — the two coexist because the default graphic style carries a
|
||||
// draw:fill that a "no fill" shape overrides only via fo:background-color="transparent".
|
||||
function shapeFill(gp: PropMap): string | null {
|
||||
// draw:fill when set. A drawn shape's fill defaults to solid when the keyword is
|
||||
// absent — LibreOffice omits draw:fill and only writes "none" to turn it off, taking
|
||||
// the color from the (usually inherited) draw:fill-color. defaultSolid is false for
|
||||
// plain text frames, whose fill defaults to none.
|
||||
function shapeFill(gp: PropMap, defaultSolid: boolean): string | null {
|
||||
const bg = gp['fo:background-color'];
|
||||
if (bg !== undefined) return bg === 'transparent' || bg === 'none' ? null : normalizeColor(bg) ?? null;
|
||||
return gp['draw:fill'] === 'solid' ? normalizeColor(gp['draw:fill-color'] ?? '') ?? null : null;
|
||||
const fill = gp['draw:fill'];
|
||||
const solid = fill === 'solid' || (fill === undefined && defaultSolid);
|
||||
return solid ? normalizeColor(gp['draw:fill-color'] ?? '') ?? null : null;
|
||||
}
|
||||
|
||||
// A shape's stroke as { color, widthPt }. fo:border ("<w> <style> <color>", LibreOffice's
|
||||
// per-shape border) wins over draw:stroke for the same reason as shapeFill.
|
||||
function shapeStroke(gp: PropMap): { color: string | null; widthPt: number | null } {
|
||||
// per-shape border) wins over draw:stroke. Like the fill, a drawn shape's stroke defaults
|
||||
// to solid when draw:stroke is absent (color from svg:stroke-color); "none" turns it off.
|
||||
function shapeStroke(gp: PropMap, defaultSolid: boolean): { color: string | null; widthPt: number | null } {
|
||||
const border = gp['fo:border'];
|
||||
if (border !== undefined) {
|
||||
let widthPt: number | null = null, color: string | null = null, styleTok: string | null = null;
|
||||
@@ -162,19 +167,20 @@ function shapeStroke(gp: PropMap): { color: string | null; widthPt: number | nul
|
||||
}
|
||||
return { color: color ?? '#000000', widthPt };
|
||||
}
|
||||
const color = gp['draw:stroke'] && gp['draw:stroke'] !== 'none'
|
||||
? normalizeColor(gp['svg:stroke-color'] ?? '#000000') ?? '#000000'
|
||||
: null;
|
||||
const stroke = gp['draw:stroke'];
|
||||
const present = stroke === undefined ? defaultSolid && gp['svg:stroke-color'] !== undefined : stroke !== 'none';
|
||||
const color = present ? normalizeColor(gp['svg:stroke-color'] ?? '#000000') ?? '#000000' : null;
|
||||
return { color, widthPt: color ? lengthToPt(gp['svg:stroke-width']) : null };
|
||||
}
|
||||
|
||||
// Fill/stroke attrs from a shape's graphic style, suppressing the editor defaults
|
||||
// (white fill, 1pt black stroke). Absent/none → explicit null (transparent/no border),
|
||||
// since omitting the attr would re-apply the default.
|
||||
function shapeStyleAttrs(gp: PropMap, attrs: Record<string, unknown>): void {
|
||||
const fillColor = shapeFill(gp);
|
||||
// since omitting the attr would re-apply the default. defaultSolid marks drawn shapes,
|
||||
// whose fill/stroke default to solid (unlike a plain text frame's).
|
||||
function shapeStyleAttrs(gp: PropMap, attrs: Record<string, unknown>, defaultSolid: boolean): void {
|
||||
const fillColor = shapeFill(gp, defaultSolid);
|
||||
if (fillColor !== '#FFFFFF') attrs.fillColor = fillColor;
|
||||
const { color: strokeColor, widthPt } = shapeStroke(gp);
|
||||
const { color: strokeColor, widthPt } = shapeStroke(gp, defaultSolid);
|
||||
if (strokeColor !== '#000000') attrs.strokeColor = strokeColor;
|
||||
if (strokeColor && widthPt != null && Math.abs(widthPt - 1) > 0.1) {
|
||||
attrs.strokeWidthPt = Math.round(widthPt * 100) / 100;
|
||||
@@ -201,7 +207,7 @@ function convertTextBoxFrame(frame: Element, textBoxEl: Element, ctx: Ctx): Node
|
||||
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
|
||||
const gp = ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name'));
|
||||
applyFrameRotationAndWrap(frame, attrs, gp);
|
||||
shapeStyleAttrs(gp, attrs);
|
||||
shapeStyleAttrs(gp, attrs, false);
|
||||
return { type: 'textBox', attrs, content: textBoxContent(Array.from(textBoxEl.children), ctx) };
|
||||
}
|
||||
|
||||
@@ -231,7 +237,7 @@ function convertShape(el: Element, ctx: Ctx): Node | null {
|
||||
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
|
||||
const gp = ctx.resolver.graphicProps(el.getAttributeNS(NS.draw, 'style-name'));
|
||||
applyFrameRotationAndWrap(el, attrs, gp);
|
||||
shapeStyleAttrs(gp, attrs);
|
||||
shapeStyleAttrs(gp, attrs, true);
|
||||
return { type: 'textBox', attrs, content: textBoxContent(Array.from(el.children), ctx) };
|
||||
}
|
||||
|
||||
|
||||
@@ -588,6 +588,45 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
|
||||
check('foreign: no hyperlink warning (now round-tripped)', !f.warnings.includes('Hyperlinks were converted to plain text'), f.warnings);
|
||||
});
|
||||
|
||||
it('resolves a drawn shape\'s fill/stroke inherited from the default graphic style', () => {
|
||||
// LibreOffice omits draw:fill/draw:stroke on a shape that keeps the app default
|
||||
// (solid), taking the color from the default graphic style; only "none" turns it off.
|
||||
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0">
|
||||
<office:styles>
|
||||
<style:default-style style:family="graphic">
|
||||
<style:graphic-properties svg:stroke-color="#3465a4" draw:fill-color="#729fcf"/>
|
||||
</style:default-style>
|
||||
</office:styles>
|
||||
</office:document-styles>`;
|
||||
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0">
|
||||
<office:automatic-styles>
|
||||
<style:style style:name="grInherit" style:family="graphic"><style:graphic-properties draw:auto-grow-height="false"/></style:style>
|
||||
<style:style style:name="grNone" style:family="graphic"><style:graphic-properties draw:fill="none" draw:stroke="none"/></style:style>
|
||||
</office:automatic-styles>
|
||||
<office:body><office:text>
|
||||
<text:p><draw:custom-shape draw:style-name="grInherit" svg:width="4cm" svg:height="3cm"><text:p>inherit</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="ellipse"/></draw:custom-shape></text:p>
|
||||
<text:p><draw:custom-shape draw:style-name="grNone" svg:width="4cm" svg:height="3cm"><text:p>none</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="ellipse"/></draw:custom-shape></text:p>
|
||||
</office:text></office:body>
|
||||
</office:document-content>`;
|
||||
const foreign = zipSync({
|
||||
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
|
||||
'content.xml': [strToU8(contentXml), { level: 6 }],
|
||||
'styles.xml': [strToU8(stylesXml), { level: 6 }],
|
||||
} as any);
|
||||
|
||||
const boxes: N[] = [];
|
||||
const walk = (n: N): void => { if (n?.type === 'textBox') boxes.push(n); (n?.content || []).forEach(walk); };
|
||||
walk(importOdt(foreign).content);
|
||||
check('shape fill/stroke: two ellipses imported', boxes.length === 2, boxes.map((b: N) => b.attrs));
|
||||
const [inherit, none] = boxes;
|
||||
check('shape fill/stroke: inherited fill #729FCF', inherit.attrs.fillColor === '#729FCF', inherit.attrs);
|
||||
check('shape fill/stroke: inherited stroke #3465A4', inherit.attrs.strokeColor === '#3465A4', inherit.attrs);
|
||||
check('shape fill/stroke: explicit none → null fill', none.attrs.fillColor === null, none.attrs);
|
||||
check('shape fill/stroke: explicit none → null stroke', none.attrs.strokeColor === null, none.attrs);
|
||||
});
|
||||
|
||||
it('wraps the body in columns when the page layout declares them (Word-style)', () => {
|
||||
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0">
|
||||
|
||||
Reference in new issue
Block a user