feat: a shape can be a triangle, a star or a block arrow
Twelve presets beside the three rectangular ones. Each outline is one polygon in utils/shapes.ts, given once: the node view draws it as an SVG path, the ODF export scales the same points into draw:enhanced-path, and Word gets the preset's name and draws its own geometry. A gallery in the box toolbar and the ribbon renders its tiles from that same table, so a new shape needs no icon. Verified through LibreOffice from both formats; a preset we can't draw still imports as a warning.
This commit is contained in:
1 parent
5ee4eec5d7
commit
4cda289272
18 files changed
+566
-121
No files matched your search
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { SHAPES, shapePath, type ShapeKind } from '../utils/shapes';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
|
||||
// The shape gallery, shared by the floating box toolbar and the ribbon's Shape tab.
|
||||
// Every tile is drawn from the same preset the editor and both exports use, so a
|
||||
// shape added to `utils/shapes.ts` needs no icon of its own.
|
||||
let { value, onPick, compact = false }: {
|
||||
value: ShapeKind;
|
||||
onPick: (k: ShapeKind) => void;
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
|
||||
const KINDS = Object.keys(SHAPES) as ShapeKind[];
|
||||
let open = $state(false);
|
||||
let host = $state<HTMLElement | null>(null);
|
||||
|
||||
const label = (k: ShapeKind) => t().textBox.shapes[k];
|
||||
|
||||
function onWindowClick(e: MouseEvent) {
|
||||
if (open && host && !host.contains(e.target as HTMLElement)) open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={onWindowClick} />
|
||||
|
||||
<div class="shape-picker" bind:this={host}>
|
||||
<button
|
||||
class="shape-btn"
|
||||
class:compact
|
||||
title={t().textBox.chooseShape}
|
||||
aria-label={t().textBox.chooseShape}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
{@render tile(value)}
|
||||
<span class="chevron" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="shape-menu" role="menu">
|
||||
{#each KINDS as k}
|
||||
<button
|
||||
class="shape-tile"
|
||||
class:active={value === k}
|
||||
role="menuitemradio"
|
||||
aria-checked={value === k}
|
||||
title={label(k)}
|
||||
aria-label={label(k)}
|
||||
onclick={() => { onPick(k); open = false; }}
|
||||
>
|
||||
{@render tile(k)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet tile(k: ShapeKind)}
|
||||
<svg viewBox="0 0 100 100" fill="none" aria-hidden="true">
|
||||
{#if k === 'ellipse'}
|
||||
<ellipse cx="50" cy="50" rx="45" ry="35" stroke="currentColor" stroke-width="7" />
|
||||
{:else if k === 'roundRect'}
|
||||
<rect x="5" y="15" width="90" height="70" rx="18" stroke="currentColor" stroke-width="7" />
|
||||
{:else if k === 'textbox'}
|
||||
<rect x="5" y="15" width="90" height="70" stroke="currentColor" stroke-width="7" />
|
||||
{:else}
|
||||
<path d={shapePath(k)} stroke="currentColor" stroke-width="7" stroke-linejoin="round" />
|
||||
{/if}
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.shape-picker { position: relative; display: inline-flex; }
|
||||
|
||||
.shape-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px 4px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.shape-btn:hover { background: var(--w-hover, rgba(0, 0, 0, 0.08)); }
|
||||
.shape-btn svg { width: 22px; height: 22px; }
|
||||
.shape-btn.compact svg { width: 18px; height: 18px; }
|
||||
.chevron { font-size: 9px; line-height: 1; }
|
||||
|
||||
.shape-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 60;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--w-border, #d0d0d0);
|
||||
border-radius: 6px;
|
||||
background: var(--w-surface, #fff);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.shape-tile {
|
||||
display: inline-flex;
|
||||
padding: 5px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.shape-tile svg { width: 22px; height: 22px; }
|
||||
.shape-tile:hover { background: var(--w-hover, rgba(0, 0, 0, 0.08)); }
|
||||
.shape-tile.active { border-color: var(--accent, #3b82f6); }
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { WrapMode } from '../editor/extensions/image';
|
||||
import type { ShapeKind, TextBoxAttrs } from '../editor/extensions/textBox';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import ShapePicker from './ShapePicker.svelte';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
|
||||
let {
|
||||
@@ -40,13 +41,6 @@
|
||||
: t().textBox.wrapTopBottom;
|
||||
}
|
||||
|
||||
const shapes: ShapeKind[] = ['textbox', 'roundRect', 'ellipse'];
|
||||
function shapeTitle(k: ShapeKind): string {
|
||||
return k === 'textbox' ? t().textBox.shapeRect
|
||||
: k === 'roundRect' ? t().textBox.shapeRoundRect
|
||||
: t().textBox.shapeEllipse;
|
||||
}
|
||||
|
||||
const strokeWidths = [0.5, 1, 2.25];
|
||||
</script>
|
||||
|
||||
@@ -106,30 +100,7 @@
|
||||
|
||||
<span class="tb-sep"></span>
|
||||
|
||||
{#each shapes as s}
|
||||
<button
|
||||
class="tb-btn"
|
||||
class:active={shapeKind === s}
|
||||
title={shapeTitle(s)}
|
||||
aria-label={shapeTitle(s)}
|
||||
aria-pressed={shapeKind === s}
|
||||
onclick={() => set({ shapeKind: s })}
|
||||
>
|
||||
{#if s === 'textbox'}
|
||||
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<rect x="2.5" y="4.5" width="13" height="9" stroke="currentColor" stroke-width="1.3" />
|
||||
</svg>
|
||||
{:else if s === 'roundRect'}
|
||||
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<rect x="2.5" y="4.5" width="13" height="9" rx="3" stroke="currentColor" stroke-width="1.3" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<ellipse cx="9" cy="9" rx="6.5" ry="4.5" stroke="currentColor" stroke-width="1.3" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<ShapePicker value={shapeKind} onPick={(k) => set({ shapeKind: k })} compact />
|
||||
|
||||
<span class="tb-sep"></span>
|
||||
|
||||
|
||||
@@ -72,9 +72,6 @@ export const STROKED = {
|
||||
wrapLeft: ['M2 3.5h6v9H2z', 'M9.5 4.5h4.5M9.5 7h4.5M9.5 9.5h4.5M9.5 12h4.5'],
|
||||
wrapRight: ['M8 3.5h6v9H8z', 'M2 4.5h4.5M2 7h4.5M2 9.5h4.5M2 12h4.5'],
|
||||
wrapTopBottom: ['M4 6.5h8v3H4z', 'M2 3h12M2 13h12'],
|
||||
shapeRect: ['M2 4h12v8H2z'],
|
||||
shapeRound: ['M4 4h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z'],
|
||||
shapeEllipse: ['M8 12.5c3.6 0 6.5-2 6.5-4.5S11.6 3.5 8 3.5 1.5 5.5 1.5 8s2.9 4.5 6.5 4.5z'],
|
||||
toc: ['M2 3h6M2 6.5h5M2 10h6M2 13.5h4', 'M11 3h3M10 6.5h4M11 10h3M10 13.5h4'],
|
||||
tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'],
|
||||
caption: ['M2 2.5h12v7.5H2z', 'M2 13h7'],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import RibbonGroup from '../RibbonGroup.svelte';
|
||||
import RibbonButton from '../RibbonButton.svelte';
|
||||
import ColorPicker from '../../ColorPicker.svelte';
|
||||
import ShapePicker from '../../ShapePicker.svelte';
|
||||
import type { WrapMode } from '../../../editor/extensions/image';
|
||||
import type { ShapeKind } from '../../../editor/extensions/textBox';
|
||||
import { t } from '../../../i18n/i18n.svelte';
|
||||
@@ -28,12 +29,6 @@
|
||||
{ key: 'topBottom', icon: 'wrapTopBottom', label: () => t().image.wrapTopBottom },
|
||||
];
|
||||
|
||||
const SHAPES: { key: ShapeKind; label: () => string }[] = [
|
||||
{ key: 'textbox', label: () => t().textBox.shapeRect },
|
||||
{ key: 'roundRect', label: () => t().textBox.shapeRoundRect },
|
||||
{ key: 'ellipse', label: () => t().textBox.shapeEllipse },
|
||||
];
|
||||
|
||||
function setWrap(w: WrapMode) {
|
||||
if (which === 'picture') editor?.chain().focus().setImageWrap(w).run();
|
||||
else editor?.chain().focus().setTextBoxAttrs({ wrap: w }).run();
|
||||
@@ -68,9 +63,13 @@
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
<RibbonGroup label={t().ribbon.groups.shapeStyles}>
|
||||
{#each SHAPES as s}
|
||||
<RibbonButton variant="big" icon={s.key === 'ellipse' ? 'shapeEllipse' : s.key === 'roundRect' ? 'shapeRound' : 'shapeRect'} label={s.label()} title={s.label()} active={shapeKind === s.key} onclick={() => editor?.chain().focus().setTextBoxAttrs({ shapeKind: s.key }).run()} />
|
||||
{/each}
|
||||
<div class="rb-captioned" use:captionClicks>
|
||||
<ShapePicker
|
||||
value={shapeKind ?? 'textbox'}
|
||||
onPick={(k) => editor?.chain().focus().setTextBoxAttrs({ shapeKind: k }).run()}
|
||||
/>
|
||||
<span class="rb-caption">{t().textBox.shape}</span>
|
||||
</div>
|
||||
<div class="rb-captioned" use:captionClicks>
|
||||
<ColorPicker
|
||||
{editor}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { EditorState } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, type WrapMode } from './image';
|
||||
import { SHAPES, shapePath, isShapeKind, type ShapeKind } from '../../utils/shapes';
|
||||
|
||||
// cm attribute value → number, for the frame offsets (px ones use parsePx).
|
||||
const parseCmAttr = (v: string | null): number | null => {
|
||||
@@ -17,7 +18,7 @@ const parseCmAttr = (v: string | null): number | null => {
|
||||
// stroke, and the image's wrap model (inline = in-flow; left/right/topBottom float).
|
||||
// Round-trips to ODF <draw:frame>/<draw:text-box>/<draw:custom-shape> and DOCX <wps:wsp>.
|
||||
|
||||
export type ShapeKind = 'textbox' | 'roundRect' | 'ellipse';
|
||||
export type { ShapeKind };
|
||||
|
||||
// Fixed text inset inside the frame; exported as fo:padding / wps:bodyPr insets.
|
||||
export const TEXTBOX_PADDING_CM = 0.15;
|
||||
@@ -142,7 +143,10 @@ export const TextBox = Node.create({
|
||||
},
|
||||
shapeKind: {
|
||||
default: 'textbox',
|
||||
parseHTML: el => (el as HTMLElement).getAttribute('data-shape') ?? 'textbox',
|
||||
parseHTML: el => {
|
||||
const k = (el as HTMLElement).getAttribute('data-shape');
|
||||
return isShapeKind(k) ? k : 'textbox';
|
||||
},
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
fillColor: {
|
||||
@@ -175,8 +179,10 @@ export const TextBox = Node.create({
|
||||
const style = [
|
||||
a.width ? `width:${a.width}px` : '',
|
||||
a.height ? `min-height:${a.height}px` : '',
|
||||
a.fillColor ? `background:${a.fillColor}` : '',
|
||||
a.strokeColor ? `border:${a.strokeWidthPt * PX_PER_PT}px solid ${a.strokeColor}` : '',
|
||||
// A polygon shape paints itself, so the box behind it stays bare.
|
||||
a.fillColor && !SHAPES[a.shapeKind]?.points ? `background:${a.fillColor}` : '',
|
||||
a.strokeColor && !SHAPES[a.shapeKind]?.points
|
||||
? `border:${a.strokeWidthPt * PX_PER_PT}px solid ${a.strokeColor}` : '',
|
||||
a.shapeKind !== 'textbox' ? `border-radius:${shapeRadius(a.shapeKind)}` : '',
|
||||
a.rotation ? `transform:rotate(${a.rotation}deg)` : '',
|
||||
`padding:${paddingPx(a.paddingCm).toFixed(2)}px`,
|
||||
@@ -269,9 +275,10 @@ 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;
|
||||
// The polygon outline, for a shape CSS cannot draw; null for the three it can.
|
||||
private outline: SVGPathElement | null = null;
|
||||
// Last text-area inset applied; guards the ResizeObserver feedback loop.
|
||||
private lastInset = '';
|
||||
|
||||
constructor(node: PMNode, editor: Editor, getPos: () => number) {
|
||||
this.node = node;
|
||||
@@ -332,35 +339,76 @@ class TextBoxView {
|
||||
const a = this.attrs();
|
||||
this.rotor.style.width = a.width ? `${a.width}px` : `${DEFAULT_WIDTH_PX}px`;
|
||||
this.rotor.style.minHeight = a.height ? `${a.height}px` : '';
|
||||
this.rotor.style.background = a.fillColor ?? 'transparent';
|
||||
this.rotor.style.border = a.strokeColor
|
||||
// A polygon shape paints its own fill and stroke, so the box behind it stays bare.
|
||||
const poly = !!SHAPES[a.shapeKind]?.points;
|
||||
// The outline covers the padding box, so the frame's own ring moves to the text
|
||||
// where a polygon draws it — applyShapeInset adds it back in.
|
||||
this.rotor.style.padding = poly ? '0' : `${paddingPx(a.paddingCm).toFixed(2)}px`;
|
||||
this.rotor.style.background = !poly && a.fillColor ? a.fillColor : 'transparent';
|
||||
this.rotor.style.border = !poly && a.strokeColor
|
||||
? `${a.strokeWidthPt * PX_PER_PT}px solid ${a.strokeColor}`
|
||||
: 'none';
|
||||
this.rotor.style.borderRadius = shapeRadius(a.shapeKind);
|
||||
this.rotor.style.transform = `translate(-50%, -50%) rotate(${a.rotation}deg)`;
|
||||
this.applyOutline();
|
||||
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).
|
||||
// The outline of a shape CSS can't draw, behind the text. preserveAspectRatio="none"
|
||||
// stretches the 0…100 box to the frame, and non-scaling-stroke keeps the line even
|
||||
// width under that distortion.
|
||||
private applyOutline(): void {
|
||||
const a = this.attrs();
|
||||
const d = shapePath(a.shapeKind);
|
||||
if (!d) {
|
||||
this.outline?.parentElement?.remove();
|
||||
this.outline = null;
|
||||
return;
|
||||
}
|
||||
if (!this.outline) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'textbox-outline');
|
||||
svg.setAttribute('viewBox', '0 0 100 100');
|
||||
svg.setAttribute('preserveAspectRatio', 'none');
|
||||
this.outline = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
this.outline.setAttribute('vector-effect', 'non-scaling-stroke');
|
||||
svg.appendChild(this.outline);
|
||||
this.rotor.insertBefore(svg, this.rotor.firstChild);
|
||||
}
|
||||
this.outline.setAttribute('d', d);
|
||||
this.outline.setAttribute('fill', a.fillColor ?? 'none');
|
||||
this.outline.setAttribute('stroke', a.strokeColor ?? 'none');
|
||||
this.outline.setAttribute('stroke-width', String(a.strokeWidthPt * PX_PER_PT));
|
||||
}
|
||||
|
||||
// Pad the content into the shape's own text area — the ellipse's inscribed rectangle,
|
||||
// a polygon's `textArea` — so text stays inside the outline. The vertical inset feeds
|
||||
// back into the auto-grown height, so rewrite it only past a threshold; the
|
||||
// ResizeObserver then settles (every ratio below 0.5 converges).
|
||||
private applyShapeInset(): void {
|
||||
if (this.attrs().shapeKind !== 'ellipse') {
|
||||
const kind = this.attrs().shapeKind;
|
||||
const area = SHAPES[kind]?.textArea;
|
||||
if (kind !== 'ellipse' && !area) {
|
||||
if (this.contentDOM.style.padding) this.contentDOM.style.padding = '';
|
||||
this.lastInsetX = this.lastInsetY = -1;
|
||||
this.lastInset = '';
|
||||
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`;
|
||||
const [l, t, r, b] = area
|
||||
? [area[0] / 100, area[1] / 100, 1 - area[2] / 100, 1 - area[3] / 100]
|
||||
: [ELLIPSE_INSET_RATIO, ELLIPSE_INSET_RATIO, ELLIPSE_INSET_RATIO, ELLIPSE_INSET_RATIO];
|
||||
// Whole px: the inset feeds back into the height, and rounding is what settles it.
|
||||
// A polygon carries the frame's own ring here, its rotor having none.
|
||||
const ring = area ? paddingPx(this.attrs().paddingCm) : 0;
|
||||
const px = (v: number, base: number) => `${Math.round(v * base + ring)}px`;
|
||||
const inset = `${px(t, h)} ${px(r, w)} ${px(b, h)} ${px(l, w)}`;
|
||||
if (inset === this.lastInset) return;
|
||||
this.lastInset = inset;
|
||||
this.contentDOM.style.padding = inset;
|
||||
}
|
||||
|
||||
// Size the wrapper to the rotor's rotated bounding box so surrounding text
|
||||
|
||||
+4
-10
@@ -16,6 +16,7 @@ import type {
|
||||
} from 'docx';
|
||||
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
|
||||
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||||
import { SHAPES, isShapeKind, type ShapeKind } from '../utils/shapes';
|
||||
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
|
||||
@@ -610,7 +611,7 @@ type TextBoxDocx = {
|
||||
offsetCm: number | null;
|
||||
offsetYCm: number | null;
|
||||
distCm: number | null;
|
||||
shapeKind: 'textbox' | 'roundRect' | 'ellipse';
|
||||
shapeKind: ShapeKind;
|
||||
fill: string | null;
|
||||
stroke: string | null;
|
||||
strokeWidthPt: number;
|
||||
@@ -620,7 +621,6 @@ type TextBoxDocx = {
|
||||
function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx {
|
||||
const a = node.attrs ?? {};
|
||||
const wrapAttr = a.wrap;
|
||||
const kind = a.shapeKind;
|
||||
return {
|
||||
widthPx: typeof a.width === 'number' && a.width > 0 ? Math.round(a.width) : 280,
|
||||
heightPx: typeof a.height === 'number' && a.height > 0 ? Math.round(a.height) : 96,
|
||||
@@ -629,7 +629,7 @@ function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx {
|
||||
offsetCm: typeof a.wrapOffset === 'number' ? a.wrapOffset : null,
|
||||
offsetYCm: typeof a.wrapOffsetY === 'number' ? a.wrapOffsetY : null,
|
||||
distCm: typeof a.wrapDist === 'number' ? a.wrapDist : null,
|
||||
shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : 'textbox',
|
||||
shapeKind: isShapeKind(a.shapeKind) ? a.shapeKind : 'textbox',
|
||||
fill: typeof a.fillColor === 'string' && a.fillColor ? a.fillColor : null,
|
||||
stroke: typeof a.strokeColor === 'string' && a.strokeColor ? a.strokeColor : null,
|
||||
strokeWidthPt: typeof a.strokeWidthPt === 'number' && a.strokeWidthPt > 0 ? a.strokeWidthPt : 1,
|
||||
@@ -761,12 +761,6 @@ function txbxContentXml(blocks: TiptapNode[]): string {
|
||||
return out || '<w:p/>';
|
||||
}
|
||||
|
||||
const PRST_BY_KIND: Record<TextBoxDocx['shapeKind'], string> = {
|
||||
textbox: 'rect',
|
||||
roundRect: 'roundRect',
|
||||
ellipse: 'ellipse',
|
||||
};
|
||||
|
||||
// The full <w:drawing> for one text box. Namespaces are declared inline on the
|
||||
// wp/a/wps elements so the output never depends on what the library declares on
|
||||
// the document root.
|
||||
@@ -787,7 +781,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
|
||||
`<wps:wsp xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">` +
|
||||
`<wps:cNvSpPr txBox="1"/>` +
|
||||
`<wps:spPr><a:xfrm${rot}><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
|
||||
`<a:prstGeom prst="${PRST_BY_KIND[box.shapeKind]}"><a:avLst/></a:prstGeom>${fill}${ln}</wps:spPr>` +
|
||||
`<a:prstGeom prst="${SHAPES[box.shapeKind].prst}"><a:avLst/></a:prstGeom>${fill}${ln}</wps:spPr>` +
|
||||
`<wps:txbx><w:txbxContent>${txbxContentXml(box.content)}</w:txbxContent></wps:txbx>` +
|
||||
`<wps:bodyPr rot="0" vert="horz" wrap="square" lIns="${inset}" tIns="${inset}" rIns="${inset}" bIns="${inset}" anchor="t">${autofit}</wps:bodyPr>` +
|
||||
`</wps:wsp>`;
|
||||
|
||||
+4
-17
@@ -23,6 +23,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
|
||||
import { parseCellPadding, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
|
||||
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||||
import { isShapeKind, odfEnhancedGeometry, type ShapeKind } from '../utils/shapes';
|
||||
import { normalizeLeader, parseTabStops } from '../editor/extensions/tabStops';
|
||||
import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker';
|
||||
import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, formatOrdinal, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
@@ -786,7 +787,6 @@ function replaceBookmarks(node: TiptapNode, refs: CrossRefExport[]): TiptapNode
|
||||
}
|
||||
|
||||
// One text box / shape, collected by replaceTextBoxes and emitted by applyTextBoxes.
|
||||
type ShapeKind = 'textbox' | 'roundRect' | 'ellipse';
|
||||
type TextBoxExport = {
|
||||
widthCm: number;
|
||||
heightCm: number;
|
||||
@@ -811,7 +811,6 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport {
|
||||
const pxToCm = (px: number) => round3((px * 2.54) / 96);
|
||||
const a = node.attrs ?? {};
|
||||
const wrapAttr = a.wrap;
|
||||
const kind = a.shapeKind;
|
||||
return {
|
||||
widthCm: pxToCm(typeof a.width === 'number' && a.width > 0 ? a.width : 280),
|
||||
heightCm: pxToCm(typeof a.height === 'number' && a.height > 0 ? a.height : 96),
|
||||
@@ -822,7 +821,7 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport {
|
||||
wrapDistCm: typeof a.wrapDist === 'number' ? round3(a.wrapDist) : null,
|
||||
wrapAlign: a.wrapAlign === 'center' || a.wrapAlign === 'right' ? a.wrapAlign : null,
|
||||
paddingCm: typeof a.paddingCm === 'number' ? round3(a.paddingCm) : TEXTBOX_PADDING_CM,
|
||||
shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : 'textbox',
|
||||
shapeKind: isShapeKind(a.shapeKind) ? a.shapeKind : 'textbox',
|
||||
fill: typeof a.fillColor === 'string' && a.fillColor ? a.fillColor : null,
|
||||
stroke: typeof a.strokeColor === 'string' && a.strokeColor ? a.strokeColor : null,
|
||||
strokeWidthPt: typeof a.strokeWidthPt === 'number' && a.strokeWidthPt > 0 ? a.strokeWidthPt : 1,
|
||||
@@ -3546,18 +3545,6 @@ function applyDateTimeFields(odtBytes: Uint8Array, fields: DateTimeFieldExport[]
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// Static enhanced geometry for the two non-rectangular shape kinds, matching what
|
||||
// LibreOffice emits (round-rectangle path pre-evaluated for modifier 3600).
|
||||
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"' +
|
||||
' draw:enhanced-path="M 3600 0 X 0 3600 L 0 18000 Y 3600 21600 L 18000 21600 X 21600 18000 L 21600 3600 Y 18000 0 Z N"/>',
|
||||
};
|
||||
|
||||
// Graphic style for a text box / shape: fill, stroke, text padding, auto-grow, and —
|
||||
// for floating boxes — the same wrap/position props as floating images.
|
||||
function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
|
||||
@@ -3601,7 +3588,7 @@ function textBoxAnchorStyle(box: TextBoxExport, index: number): string {
|
||||
|
||||
// The drawing element wrapping a box's serialized blocks: a <draw:frame>/<draw:text-box>
|
||||
// for plain text boxes (height = fo:min-height, so it grows with content like the
|
||||
// editor), or a <draw:custom-shape> with preset geometry for roundRect/ellipse.
|
||||
// editor), or a <draw:custom-shape> with the preset geometry of `utils/shapes.ts`.
|
||||
function textBoxXml(box: TextBoxExport, inner: string, index: number): string {
|
||||
const n = index + 1;
|
||||
const anchor = box.wrap === 'inline' ? 'as-char' : 'paragraph';
|
||||
@@ -3622,7 +3609,7 @@ function textBoxXml(box: TextBoxExport, inner: string, index: number): string {
|
||||
}
|
||||
return (
|
||||
`<draw:custom-shape draw:name="Shape${n}"${common} svg:height="${box.heightCm}cm"${transform}>` +
|
||||
`${inner}${ENHANCED_GEOMETRY[box.shapeKind]}</draw:custom-shape>`
|
||||
`${inner}${odfEnhancedGeometry(box.shapeKind) ?? ''}</draw:custom-shape>`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -266,9 +266,25 @@ const de: Messages = {
|
||||
wrapLeft: 'Textumbruch — Feld links',
|
||||
wrapRight: 'Textumbruch — Feld rechts',
|
||||
wrapTopBottom: 'Oben und unten',
|
||||
shapeRect: 'Rechteck',
|
||||
shapeRoundRect: 'Abgerundetes Rechteck',
|
||||
shapeEllipse: 'Ellipse',
|
||||
shape: 'Form',
|
||||
chooseShape: 'Form auswählen',
|
||||
shapes: {
|
||||
textbox: 'Rechteck',
|
||||
roundRect: 'Abgerundetes Rechteck',
|
||||
ellipse: 'Ellipse',
|
||||
triangle: 'Gleichschenkliges Dreieck',
|
||||
rightTriangle: 'Rechtwinkliges Dreieck',
|
||||
diamond: 'Raute',
|
||||
pentagon: 'Fünfeck',
|
||||
hexagon: 'Sechseck',
|
||||
star5: 'Fünfzackiger Stern',
|
||||
trapezoid: 'Trapez',
|
||||
parallelogram: 'Parallelogramm',
|
||||
rightArrow: 'Pfeil nach rechts',
|
||||
leftArrow: 'Pfeil nach links',
|
||||
upArrow: 'Pfeil nach oben',
|
||||
downArrow: 'Pfeil nach unten',
|
||||
},
|
||||
fillColor: 'Füllfarbe',
|
||||
chooseFillColor: 'Füllfarbe wählen',
|
||||
noFill: 'Keine Füllung',
|
||||
|
||||
@@ -270,9 +270,25 @@ const en = {
|
||||
wrapLeft: 'Wrap text — box left',
|
||||
wrapRight: 'Wrap text — box right',
|
||||
wrapTopBottom: 'Top and bottom',
|
||||
shapeRect: 'Rectangle',
|
||||
shapeRoundRect: 'Rounded rectangle',
|
||||
shapeEllipse: 'Ellipse',
|
||||
shape: 'Shape',
|
||||
chooseShape: 'Choose a shape',
|
||||
shapes: {
|
||||
textbox: 'Rectangle',
|
||||
roundRect: 'Rounded rectangle',
|
||||
ellipse: 'Ellipse',
|
||||
triangle: 'Isosceles triangle',
|
||||
rightTriangle: 'Right triangle',
|
||||
diamond: 'Diamond',
|
||||
pentagon: 'Pentagon',
|
||||
hexagon: 'Hexagon',
|
||||
star5: 'Five-point star',
|
||||
trapezoid: 'Trapezoid',
|
||||
parallelogram: 'Parallelogram',
|
||||
rightArrow: 'Right arrow',
|
||||
leftArrow: 'Left arrow',
|
||||
upArrow: 'Up arrow',
|
||||
downArrow: 'Down arrow',
|
||||
},
|
||||
fillColor: 'Fill color',
|
||||
chooseFillColor: 'Choose fill color',
|
||||
noFill: 'No fill',
|
||||
|
||||
+4
-10
@@ -11,6 +11,7 @@ import { tableLookAttr } from '../styles/tableStyles';
|
||||
import { formatOrdinal, orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
|
||||
import { DATE_FORMATS, TIME_FORMATS, docxPicture, toDateValue } from '../utils/dateTime';
|
||||
import { shapeFromPrst } from '../utils/shapes';
|
||||
import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats';
|
||||
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
@@ -1712,12 +1713,6 @@ function nsChild(el: Element | null, ns: string, localName: string): Element | n
|
||||
return null;
|
||||
}
|
||||
|
||||
const KIND_BY_PRST: Record<string, 'textbox' | 'roundRect' | 'ellipse'> = {
|
||||
rect: 'textbox',
|
||||
roundRect: 'roundRect',
|
||||
ellipse: 'ellipse',
|
||||
};
|
||||
|
||||
// Fill/stroke attrs from a shape's spPr (or VML equivalents), suppressing the editor
|
||||
// defaults (white fill, 1pt black stroke); none → explicit null, since omitting the
|
||||
// attr would re-apply the default.
|
||||
@@ -1729,13 +1724,12 @@ function setShapeStyleAttrs(attrs: Record<string, unknown>, fill: string | null,
|
||||
}
|
||||
}
|
||||
|
||||
// A DrawingML <wps:wsp> (text box or preset shape) → a textBox node. Unsupported
|
||||
// preset geometries (stars, arrows, …) are dropped with a warning. All property
|
||||
// A DrawingML <wps:wsp> (text box or preset shape) → a textBox node. A preset
|
||||
// `utils/shapes.ts` can't draw (a connector, a freeform) is dropped with a warning. All property
|
||||
// lookups are scoped to spPr so a nested image's fill/xfrm can't leak in.
|
||||
function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ctx): Node | null {
|
||||
const spPr = nsChild(wsp, WPS, 'spPr');
|
||||
const prst = nsChild(spPr, A, 'prstGeom')?.getAttribute('prst') ?? 'rect';
|
||||
const kind = KIND_BY_PRST[prst];
|
||||
const kind = shapeFromPrst(nsChild(spPr, A, 'prstGeom')?.getAttribute('prst') ?? 'rect');
|
||||
if (!kind) { ctx.warnings.add('Unsupported shapes were removed'); return null; }
|
||||
|
||||
const attrs: Record<string, unknown> = {};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TABLE_REGIONS, tableLookAttr, type TableLook, type TableRegion } from '
|
||||
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
|
||||
import { matchFormat, toDateValue, type Token } from '../utils/dateTime';
|
||||
import { shapeFromOdfType, type ShapeKind } from '../utils/shapes';
|
||||
import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats';
|
||||
import { astToLatex } from '../math/latex';
|
||||
import { parseMathml } from '../math/mathml';
|
||||
@@ -484,19 +485,16 @@ function loadObjectDoc(href: string | null, ctx: Ctx): Document | null {
|
||||
return doc.documentElement && !doc.getElementsByTagName('parsererror').length ? doc : null;
|
||||
}
|
||||
|
||||
// draw:rect / draw:ellipse / draw:custom-shape (rect/round-rect/ellipse preset) → a
|
||||
// textBox with the matching shapeKind; the shape's text is its content. Other
|
||||
// custom-shape presets (stars, arrows, …) are dropped with a warning.
|
||||
// draw:rect / draw:ellipse / draw:custom-shape (any preset `utils/shapes.ts` knows) → a
|
||||
// textBox with the matching shapeKind; the shape's text is its content. A preset we
|
||||
// can't draw (a connector, a freeform) is dropped with a warning.
|
||||
function convertShape(el: Element, ctx: Ctx): Node | null {
|
||||
let kind: 'textbox' | 'roundRect' | 'ellipse' | null = null;
|
||||
let kind: ShapeKind | null = null;
|
||||
if (el.localName === 'rect') kind = 'textbox';
|
||||
else if (el.localName === 'ellipse') kind = 'ellipse';
|
||||
else {
|
||||
const geo = el.getElementsByTagNameNS(NS.draw, 'enhanced-geometry')[0];
|
||||
const type = geo?.getAttributeNS(NS.draw, 'type') ?? '';
|
||||
if (type === 'ellipse' || type === 'circle') kind = 'ellipse';
|
||||
else if (type === 'round-rectangle') kind = 'roundRect';
|
||||
else if (type === 'rectangle' || !type) kind = 'textbox';
|
||||
kind = shapeFromOdfType(geo?.getAttributeNS(NS.draw, 'type'));
|
||||
}
|
||||
if (!kind) {
|
||||
ctx.warnings.add('Unsupported shapes were removed');
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// The basic shapes a text box can take. Each non-rectangular one is a polygon given
|
||||
// once in a 0…100 box: the editor draws it as an SVG path, the ODF export scales the
|
||||
// same points into the 21600 viewBox <draw:enhanced-path> wants, and Word gets the
|
||||
// preset's name and draws its own geometry.
|
||||
|
||||
export type ShapeKind =
|
||||
| 'textbox' | 'roundRect' | 'ellipse'
|
||||
| 'triangle' | 'rightTriangle' | 'diamond' | 'pentagon' | 'hexagon' | 'star5'
|
||||
| 'trapezoid' | 'parallelogram'
|
||||
| 'rightArrow' | 'leftArrow' | 'upArrow' | 'downArrow';
|
||||
|
||||
type Point = readonly [number, number];
|
||||
|
||||
export type ShapePreset = {
|
||||
/** ODF <draw:enhanced-geometry draw:type>. */
|
||||
odf: string;
|
||||
/** DrawingML <a:prstGeom prst>. */
|
||||
prst: string;
|
||||
/** The outline, 0…100 both ways; absent for the three CSS-drawn kinds. */
|
||||
points?: readonly Point[];
|
||||
/** Where text goes inside the outline: x0 y0 x1 y1, same 0…100 box. */
|
||||
textArea?: readonly [number, number, number, number];
|
||||
};
|
||||
|
||||
// A regular n-gon's corners on the circle around the box, first one straight up.
|
||||
function ring(n: number, r: number, turn = 0): Point[] {
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const a = (-90 + turn + (360 / n) * i) * (Math.PI / 180);
|
||||
return [50 + r * Math.cos(a), 50 + r * Math.sin(a)] as Point;
|
||||
});
|
||||
}
|
||||
|
||||
// Stretch a ring onto the whole box, per axis: an odd-cornered shape leaves gaps at
|
||||
// its bounds, and the presets both word processors draw fill their frame.
|
||||
function fit(pts: Point[]): Point[] {
|
||||
const span = (i: 0 | 1) => {
|
||||
const vs = pts.map((p) => p[i]);
|
||||
const lo = Math.min(...vs);
|
||||
return { lo, k: 100 / (Math.max(...vs) - lo) };
|
||||
};
|
||||
const x = span(0);
|
||||
const y = span(1);
|
||||
return pts.map(([px, py]) => [(px - x.lo) * x.k, (py - y.lo) * y.k] as Point);
|
||||
}
|
||||
|
||||
// A star's corners alternate between the two circles; 0.382 is the ratio a regular
|
||||
// pentagram gives, which is the one both word processors draw.
|
||||
function star(n: number, outer: number, inner: number): Point[] {
|
||||
const o = ring(n, outer);
|
||||
const i = ring(n, inner, 360 / (2 * n));
|
||||
return fit(o.flatMap((p, k) => [p, i[k]]));
|
||||
}
|
||||
|
||||
export const SHAPES: Record<ShapeKind, ShapePreset> = {
|
||||
textbox: { odf: 'rectangle', prst: 'rect' },
|
||||
roundRect: { odf: 'round-rectangle', prst: 'roundRect' },
|
||||
ellipse: { odf: 'ellipse', prst: 'ellipse' },
|
||||
triangle: {
|
||||
odf: 'isosceles-triangle', prst: 'triangle',
|
||||
points: [[50, 0], [100, 100], [0, 100]], textArea: [25, 50, 75, 100],
|
||||
},
|
||||
rightTriangle: {
|
||||
odf: 'right-triangle', prst: 'rtTriangle',
|
||||
points: [[0, 0], [100, 100], [0, 100]], textArea: [5, 50, 50, 95],
|
||||
},
|
||||
diamond: {
|
||||
odf: 'diamond', prst: 'diamond',
|
||||
points: [[50, 0], [100, 50], [50, 100], [0, 50]], textArea: [25, 25, 75, 75],
|
||||
},
|
||||
pentagon: {
|
||||
odf: 'pentagon', prst: 'pentagon',
|
||||
points: fit(ring(5, 50)), textArea: [20, 35, 80, 85],
|
||||
},
|
||||
hexagon: {
|
||||
odf: 'hexagon', prst: 'hexagon',
|
||||
points: [[25, 0], [75, 0], [100, 50], [75, 100], [25, 100], [0, 50]], textArea: [15, 5, 85, 95],
|
||||
},
|
||||
star5: {
|
||||
odf: 'star5', prst: 'star5',
|
||||
points: star(5, 50, 19.1), textArea: [30, 35, 70, 70],
|
||||
},
|
||||
trapezoid: {
|
||||
odf: 'trapezoid', prst: 'trapezoid',
|
||||
points: [[25, 0], [75, 0], [100, 100], [0, 100]], textArea: [25, 5, 75, 95],
|
||||
},
|
||||
parallelogram: {
|
||||
odf: 'parallelogram', prst: 'parallelogram',
|
||||
points: [[25, 0], [100, 0], [75, 100], [0, 100]], textArea: [25, 5, 75, 95],
|
||||
},
|
||||
rightArrow: {
|
||||
odf: 'right-arrow', prst: 'rightArrow',
|
||||
points: [[0, 25], [50, 25], [50, 0], [100, 50], [50, 100], [50, 75], [0, 75]],
|
||||
textArea: [5, 30, 50, 70],
|
||||
},
|
||||
leftArrow: {
|
||||
odf: 'left-arrow', prst: 'leftArrow',
|
||||
points: [[100, 25], [50, 25], [50, 0], [0, 50], [50, 100], [50, 75], [100, 75]],
|
||||
textArea: [50, 30, 95, 70],
|
||||
},
|
||||
upArrow: {
|
||||
odf: 'up-arrow', prst: 'upArrow',
|
||||
points: [[25, 100], [25, 50], [0, 50], [50, 0], [100, 50], [75, 50], [75, 100]],
|
||||
textArea: [30, 50, 70, 95],
|
||||
},
|
||||
downArrow: {
|
||||
odf: 'down-arrow', prst: 'downArrow',
|
||||
points: [[25, 0], [25, 50], [0, 50], [50, 100], [100, 50], [75, 50], [75, 0]],
|
||||
textArea: [30, 5, 70, 50],
|
||||
},
|
||||
};
|
||||
|
||||
/** The kinds drawn as a polygon — everything past the three CSS ones. */
|
||||
export const POLYGON_KINDS = (Object.keys(SHAPES) as ShapeKind[]).filter((k) => SHAPES[k].points);
|
||||
|
||||
export function isShapeKind(v: unknown): v is ShapeKind {
|
||||
return typeof v === 'string' && v in SHAPES;
|
||||
}
|
||||
|
||||
const r2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** The outline as an SVG `d`, in the 0…100 box the node view's viewBox uses. */
|
||||
export function shapePath(kind: ShapeKind): string | null {
|
||||
const pts = SHAPES[kind].points;
|
||||
if (!pts) return null;
|
||||
return `M ${pts.map(([x, y]) => `${r2(x)},${r2(y)}`).join(' L ')} Z`;
|
||||
}
|
||||
|
||||
// ODF's viewBox for a custom shape. The two rectangular kinds keep the geometry
|
||||
// LibreOffice itself writes (round-rectangle pre-evaluated for modifier 3600); the
|
||||
// polygons derive theirs from the same points the editor draws.
|
||||
const VB = 21600;
|
||||
const s = (n: number) => Math.round((n * VB) / 100);
|
||||
|
||||
const STATIC_GEOMETRY: Partial<Record<ShapeKind, string>> = {
|
||||
ellipse:
|
||||
' draw:type="ellipse" draw:text-areas="3163 3163 18437 18437"' +
|
||||
' draw:enhanced-path="U 10800 10800 10800 10800 0 360 Z N"',
|
||||
roundRect:
|
||||
' draw:type="round-rectangle" draw:modifiers="3600"' +
|
||||
' draw:enhanced-path="M 3600 0 X 0 3600 L 0 18000 Y 3600 21600 L 18000 21600' +
|
||||
' X 21600 18000 L 21600 3600 Y 18000 0 Z N"',
|
||||
};
|
||||
|
||||
/** The whole `<draw:enhanced-geometry/>` element for a shape, or null for a plain box. */
|
||||
export function odfEnhancedGeometry(kind: ShapeKind): string | null {
|
||||
const preset = SHAPES[kind];
|
||||
const body = STATIC_GEOMETRY[kind] ?? (preset.points
|
||||
? ` draw:type="${preset.odf}"` +
|
||||
(preset.textArea ? ` draw:text-areas="${preset.textArea.map(s).join(' ')}"` : '') +
|
||||
// One M, one L, then bare pairs — the form LibreOffice writes itself.
|
||||
` draw:enhanced-path="M ${s(preset.points[0][0])} ${s(preset.points[0][1])} L ` +
|
||||
`${preset.points.slice(1).map(([x, y]) => `${s(x)} ${s(y)}`).join(' ')} Z N"`
|
||||
: null);
|
||||
return body === null ? null : `<draw:enhanced-geometry svg:viewBox="0 0 ${VB} ${VB}"${body}/>`;
|
||||
}
|
||||
|
||||
const BY_ODF = new Map((Object.keys(SHAPES) as ShapeKind[]).map((k) => [SHAPES[k].odf, k]));
|
||||
const BY_PRST = new Map((Object.keys(SHAPES) as ShapeKind[]).map((k) => [SHAPES[k].prst, k]));
|
||||
|
||||
// null = a preset we don't draw, which the importers report rather than flatten to a
|
||||
// rectangle. LibreOffice writes `circle` for an ellipse constrained to one.
|
||||
export function shapeFromOdfType(type: string | null | undefined): ShapeKind | null {
|
||||
if (!type) return 'textbox';
|
||||
if (type === 'circle') return 'ellipse';
|
||||
return BY_ODF.get(type) ?? null;
|
||||
}
|
||||
|
||||
export function shapeFromPrst(prst: string | null | undefined): ShapeKind | null {
|
||||
if (!prst) return 'textbox';
|
||||
return BY_PRST.get(prst) ?? null;
|
||||
}
|
||||
@@ -513,9 +513,22 @@
|
||||
.paper .tiptap .textbox-rotor {
|
||||
cursor: move;
|
||||
}
|
||||
/* The outline of a shape CSS can't draw, behind the text. Such a frame carries no
|
||||
padding of its own (the text inset moves to .textbox-content), so the padding box
|
||||
this covers is the frame. overflow stays visible or the stroke is clipped in half
|
||||
at the viewBox edge. */
|
||||
.paper .tiptap .textbox-outline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
.paper .tiptap .textbox-content {
|
||||
cursor: text;
|
||||
min-height: 1em;
|
||||
position: relative;
|
||||
}
|
||||
.paper .tiptap .textbox-content > :first-child {
|
||||
margin-top: 0;
|
||||
|
||||
Reference in new issue
Block a user