feat: add table frame menue
This commit is contained in:
1 parent
0aa9399f93
commit
15b9386eb7
14 files changed
+963
-6
No files matched your search
@@ -34,6 +34,7 @@
|
||||
|
||||
**Insert**
|
||||
- Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style), cell background shading, header-row toggle (bold + shading); a table splits cleanly across page boundaries
|
||||
- Table border control (Word/LibreOffice-style): per-side cell borders with presets (all / outside / inside / single edges / none), line width and color; buttons show active states matching the current pen and toggle borders off; round-trips to ODF `fo:border-*` and DOCX `w:tcBorders`
|
||||
- Images: inline or floating with text wrap (left / right / top-bottom), resize handles, rotation, live size badge; insert via toolbar, drag-and-drop, or paste
|
||||
- Special characters picker
|
||||
- Table of contents: generated from headings (H1–H3) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { activeBorderPresets, type BorderPreset } from '../editor/extensions/tableCellBorders';
|
||||
|
||||
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
|
||||
|
||||
let open = $state(false);
|
||||
// Pen settings (Word-style): presets apply with the currently chosen width/color.
|
||||
let widthPt = $state(0.5);
|
||||
let color = $state('#000000');
|
||||
|
||||
const WIDTHS = [0.5, 1, 1.5, 2.25, 3];
|
||||
// Greyscale + standard hues (the first two ColorPicker palette rows).
|
||||
const COLORS = [
|
||||
['#000000', '#1A1A1A', '#333333', '#4D4D4D', '#666666', '#808080', '#999999', '#B3B3B3', '#CCCCCC', '#FFFFFF'],
|
||||
['#C00000', '#FF0000', '#FFC000', '#FFFF00', '#92D050', '#00B050', '#00B0F0', '#0070C0', '#002060', '#7030A0'],
|
||||
];
|
||||
|
||||
// Preset icons: 6 segments (outer edges + the two inner grid lines); "on" segments
|
||||
// draw solid, the rest as faint guides.
|
||||
type Seg = 'top' | 'right' | 'bottom' | 'left' | 'h' | 'v';
|
||||
const SEG_PATH: Record<Seg, string> = {
|
||||
top: 'M2 2H16', right: 'M16 2V16', bottom: 'M2 16H16', left: 'M2 2V16',
|
||||
h: 'M2 9H16', v: 'M9 2V16',
|
||||
};
|
||||
const ALL_SEGS: Seg[] = ['top', 'right', 'bottom', 'left', 'h', 'v'];
|
||||
const PRESETS: { id: BorderPreset | 'none'; title: string; on: Seg[] }[] = [
|
||||
{ id: 'all', title: 'All borders', on: ['top', 'right', 'bottom', 'left', 'h', 'v'] },
|
||||
{ id: 'outer', title: 'Outside borders', on: ['top', 'right', 'bottom', 'left'] },
|
||||
{ id: 'inner', title: 'Inside borders', on: ['h', 'v'] },
|
||||
{ id: 'innerH', title: 'Inside horizontal border', on: ['h'] },
|
||||
{ id: 'innerV', title: 'Inside vertical border', on: ['v'] },
|
||||
{ id: 'top', title: 'Top border', on: ['top'] },
|
||||
{ id: 'bottom', title: 'Bottom border', on: ['bottom'] },
|
||||
{ id: 'left', title: 'Left border', on: ['left'] },
|
||||
{ id: 'right', title: 'Right border', on: ['right'] },
|
||||
{ id: 'none', title: 'No border', on: [] },
|
||||
];
|
||||
|
||||
// Word-like button states, re-read per transaction (tick) and pen change: a preset
|
||||
// is active when its boundaries all render the pen border; 'none' when borderless.
|
||||
const active = $derived(
|
||||
tick >= 0 && editor ? activeBorderPresets(editor.state, { widthPt, color }) : null,
|
||||
);
|
||||
|
||||
// Clicking an inactive preset applies the pen; clicking an active one toggles those
|
||||
// borders off (Word behavior). The panel stays open so the states visibly update.
|
||||
function apply(id: BorderPreset | 'none') {
|
||||
if (!editor) return;
|
||||
if (id === 'none') editor.chain().focus().setTableBorders('all', null).run();
|
||||
else if (active?.[id]) editor.chain().focus().setTableBorders(id, null).run();
|
||||
else editor.chain().focus().setTableBorders(id, { widthPt, color }).run();
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
function handler(e: MouseEvent) {
|
||||
if (!node.contains(e.target as Node)) open = false;
|
||||
}
|
||||
window.addEventListener('mousedown', handler);
|
||||
return { destroy() { window.removeEventListener('mousedown', handler); } };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-picker" use:clickOutside>
|
||||
<button
|
||||
class="bp-trigger"
|
||||
class:active={open}
|
||||
title="Borders"
|
||||
aria-label="Borders"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<rect x="2.5" y="2.5" width="13" height="13" stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d="M9 2.5V15.5M2.5 9H15.5" stroke="currentColor" stroke-width="1.1" stroke-dasharray="1.6 1.6"/>
|
||||
</svg>
|
||||
<svg class="bp-chevron" width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="bp-dropdown">
|
||||
<div class="bp-title">Borders</div>
|
||||
<div class="bp-presets">
|
||||
{#each PRESETS as preset}
|
||||
<button
|
||||
class="bp-preset"
|
||||
class:active={!!active?.[preset.id]}
|
||||
title={preset.title}
|
||||
aria-label={preset.title}
|
||||
aria-pressed={!!active?.[preset.id]}
|
||||
onclick={() => apply(preset.id)}
|
||||
>
|
||||
{#if preset.id === 'none'}
|
||||
<!-- Prohibition sign over a faint grid: unmistakably "remove borders". -->
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
{#each ALL_SEGS as seg}
|
||||
<path d={SEG_PATH[seg]} stroke="currentColor" stroke-width="1" opacity="0.22" stroke-dasharray="1.4 1.4"/>
|
||||
{/each}
|
||||
<circle cx="9" cy="9" r="4.6" fill="var(--color-surface)" stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d="M5.9 12.1L12.1 5.9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
{#each ALL_SEGS as seg}
|
||||
{#if !preset.on.includes(seg)}
|
||||
<path d={SEG_PATH[seg]} stroke="currentColor" stroke-width="1" opacity="0.22" stroke-dasharray="1.4 1.4"/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each preset.on as seg}
|
||||
<path d={SEG_PATH[seg]} stroke="currentColor" stroke-width="1.7"/>
|
||||
{/each}
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="bp-title">Line width</div>
|
||||
<div class="bp-widths">
|
||||
{#each WIDTHS as w}
|
||||
<button
|
||||
class="bp-width"
|
||||
class:active={widthPt === w}
|
||||
title="{w}pt"
|
||||
aria-label="{w}pt"
|
||||
aria-pressed={widthPt === w}
|
||||
onclick={() => (widthPt = w)}
|
||||
>
|
||||
<span class="bp-width-line" style="height: {Math.max(1, Math.round((w * 96) / 72))}px"></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="bp-title">Line color</div>
|
||||
<div class="bp-colors">
|
||||
{#each COLORS as row}
|
||||
<div class="bp-color-row">
|
||||
{#each row as c}
|
||||
<button
|
||||
class="bp-color"
|
||||
class:active={color === c}
|
||||
style="background: {c}"
|
||||
title={c}
|
||||
aria-label={c}
|
||||
onclick={() => (color = c)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.border-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bp-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
height: 1.7rem;
|
||||
min-width: unset;
|
||||
padding: 0 3px;
|
||||
border: none;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.bp-trigger:hover,
|
||||
.bp-trigger.active {
|
||||
background: var(--color-btn-hover);
|
||||
}
|
||||
|
||||
.bp-chevron {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bp-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 3px);
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.bp-title {
|
||||
padding: 0.1rem 0.2rem 0.2rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bp-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bp-preset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
min-width: unset;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.bp-preset:hover {
|
||||
background: var(--color-btn-hover);
|
||||
}
|
||||
|
||||
.bp-preset.active {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.bp-widths {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bp-width {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
height: 1.4rem;
|
||||
min-width: unset;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.bp-width:hover {
|
||||
background: var(--color-btn-hover);
|
||||
}
|
||||
|
||||
.bp-width.active {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.bp-width-line {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.bp-colors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bp-color-row {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bp-color {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-width: unset;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.05s;
|
||||
}
|
||||
|
||||
.bp-color:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.bp-color.active {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
import { CellSelection } from '@tiptap/pm/tables';
|
||||
import { isHeaderRowStyled } from '../editor/extensions/tableHeaderRow';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import TableBorderPicker from './TableBorderPicker.svelte';
|
||||
|
||||
let {
|
||||
editor,
|
||||
@@ -210,6 +211,8 @@
|
||||
{/snippet}
|
||||
</ColorPicker>
|
||||
|
||||
<TableBorderPicker {editor} {tick} />
|
||||
|
||||
<span class="tt-sep"></span>
|
||||
|
||||
<button
|
||||
|
||||
@@ -36,6 +36,7 @@ import { TableColumnResize } from './extensions/tableColumnResize';
|
||||
import { TableRowResize } from './extensions/tableRowResize';
|
||||
import { TableSplit } from './extensions/tableSplit';
|
||||
import { TableCellBackground } from './extensions/tableCellBackground';
|
||||
import { TableCellBorders } from './extensions/tableCellBorders';
|
||||
import { TableHeaderRow } from './extensions/tableHeaderRow';
|
||||
import { TrailingNode } from './extensions/trailingNode';
|
||||
import { Image } from './extensions/image';
|
||||
@@ -98,6 +99,8 @@ export const extensions = [
|
||||
TableSplit,
|
||||
// Cell shading: backgroundColor attr on tableCell/tableHeader (→ fo:background-color).
|
||||
TableCellBackground,
|
||||
// Word-style border presets: per-side border attrs on cells (→ fo:border-*).
|
||||
TableCellBorders,
|
||||
// "Header row" styling preset: bold + light shading on the first row (toggle).
|
||||
TableHeaderRow,
|
||||
// Guarantees a paragraph after a trailing table so the cursor isn't trapped
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import type { CommandProps } from '@tiptap/core';
|
||||
import type { EditorState } from '@tiptap/pm/state';
|
||||
import { selectedRect, isInTable } from '@tiptap/pm/tables';
|
||||
|
||||
// Word/LibreOffice table borders: per-side attrs (`borderTop/Right/Bottom/Left`) on
|
||||
// table cells. `null` = the table default (0.5pt solid black), `'none'` = no border,
|
||||
// else the canonical `'<W>pt solid #RRGGBB'`. Round-trips to ODF fo:border-* and DOCX
|
||||
// w:tcBorders (export/odt.ts exportTable, import/odt.ts convertTable, export/import
|
||||
// docx.ts). setTableBorders applies a Word-style preset to the selected cell region,
|
||||
// writing BOTH sides of every affected boundary (incl. the facing side of neighbour
|
||||
// cells outside the region) so collapsed borders never disagree between two cells.
|
||||
|
||||
export const DEFAULT_BORDER_WIDTH_PT = 0.5;
|
||||
export const DEFAULT_BORDER_COLOR = '#000000';
|
||||
|
||||
export type BorderPreset =
|
||||
| 'all' | 'outer' | 'inner' | 'innerH' | 'innerV'
|
||||
| 'top' | 'bottom' | 'left' | 'right';
|
||||
// null = no border ('none').
|
||||
export type BorderSpec = { widthPt: number; color: string } | null;
|
||||
|
||||
export const BORDER_SIDES = ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'] as const;
|
||||
export type BorderSide = (typeof BORDER_SIDES)[number];
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
tableCellBorders: {
|
||||
setTableBorders: (preset: BorderPreset, spec: BorderSpec) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical attr value for a spec; the editor default collapses to null.
|
||||
export function borderAttrValue(spec: BorderSpec): string | null {
|
||||
if (!spec) return 'none';
|
||||
const w = Math.round(spec.widthPt * 100) / 100;
|
||||
const color = spec.color.toUpperCase();
|
||||
if (w === DEFAULT_BORDER_WIDTH_PT && color === DEFAULT_BORDER_COLOR) return null;
|
||||
return `${w}pt solid ${color}`;
|
||||
}
|
||||
|
||||
// null = table default, 'none' = no border, else width/color of a custom border.
|
||||
export function parseBorderAttr(
|
||||
value: string | null | undefined,
|
||||
): { widthPt: number; color: string } | 'none' | null {
|
||||
if (value == null) return null;
|
||||
if (value === 'none') return 'none';
|
||||
const m = /^([\d.]+)pt solid (#[0-9A-Fa-f]{6})$/.exec(value);
|
||||
if (!m) return null;
|
||||
return { widthPt: parseFloat(m[1]), color: m[2].toUpperCase() };
|
||||
}
|
||||
|
||||
// CSS value for a side. The default 0.5pt renders as 1px (editor.css); custom widths
|
||||
// scale the same way (pt → px, min 1px so thin lines stay crisp).
|
||||
function cssBorder(value: string): string | null {
|
||||
const b = parseBorderAttr(value);
|
||||
if (b === null) return null;
|
||||
if (b === 'none') return 'none';
|
||||
const px = Math.max(1, Math.round((b.widthPt * 96) / 72));
|
||||
return `${px}px solid ${b.color}`;
|
||||
}
|
||||
|
||||
const SIDE_META: Record<BorderSide, { data: string; css: string }> = {
|
||||
borderTop: { data: 'data-border-top', css: 'border-top' },
|
||||
borderRight: { data: 'data-border-right', css: 'border-right' },
|
||||
borderBottom: { data: 'data-border-bottom', css: 'border-bottom' },
|
||||
borderLeft: { data: 'data-border-left', css: 'border-left' },
|
||||
};
|
||||
|
||||
// Walks every boundary a preset targets in the selected rect, reporting the two
|
||||
// facing (cellPos, side) pairs of each; a pos is null at the table edge. Boundaries
|
||||
// running inside a merged cell are skipped. Shared by the setTableBorders command
|
||||
// and the active-preset reader below.
|
||||
function forEachBoundary(
|
||||
rect: ReturnType<typeof selectedRect>,
|
||||
preset: BorderPreset,
|
||||
cb: (aPos: number | null, aSide: BorderSide, bPos: number | null, bSide: BorderSide) => void,
|
||||
): void {
|
||||
const { map } = rect;
|
||||
const wants = {
|
||||
topEdge: preset === 'all' || preset === 'outer' || preset === 'top',
|
||||
bottomEdge: preset === 'all' || preset === 'outer' || preset === 'bottom',
|
||||
leftEdge: preset === 'all' || preset === 'outer' || preset === 'left',
|
||||
rightEdge: preset === 'all' || preset === 'outer' || preset === 'right',
|
||||
innerH: preset === 'all' || preset === 'inner' || preset === 'innerH',
|
||||
innerV: preset === 'all' || preset === 'inner' || preset === 'innerV',
|
||||
};
|
||||
|
||||
// Horizontal boundaries: grid line y separates row y-1 (above) from row y (below).
|
||||
for (let y = rect.top; y <= rect.bottom; y++) {
|
||||
const isTop = y === rect.top;
|
||||
const isBottom = y === rect.bottom;
|
||||
if (isTop && !wants.topEdge) continue;
|
||||
if (isBottom && !wants.bottomEdge) continue;
|
||||
if (!isTop && !isBottom && !wants.innerH) continue;
|
||||
for (let x = rect.left; x < rect.right; x++) {
|
||||
const below = y < map.height ? map.map[y * map.width + x] : null;
|
||||
const above = y > 0 ? map.map[(y - 1) * map.width + x] : null;
|
||||
if (above === below) continue; // grid line runs inside a merged cell
|
||||
cb(above, 'borderBottom', below, 'borderTop');
|
||||
}
|
||||
}
|
||||
// Vertical boundaries: grid line x separates column x-1 (left) from column x (right).
|
||||
for (let x = rect.left; x <= rect.right; x++) {
|
||||
const isLeft = x === rect.left;
|
||||
const isRight = x === rect.right;
|
||||
if (isLeft && !wants.leftEdge) continue;
|
||||
if (isRight && !wants.rightEdge) continue;
|
||||
if (!isLeft && !isRight && !wants.innerV) continue;
|
||||
for (let y = rect.top; y < rect.bottom; y++) {
|
||||
const right = x < map.width ? map.map[y * map.width + x] : null;
|
||||
const left = x > 0 ? map.map[y * map.width + (x - 1)] : null;
|
||||
if (left === right) continue;
|
||||
cb(left, 'borderRight', right, 'borderLeft');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setTableBorders(preset: BorderPreset, spec: BorderSpec) {
|
||||
return ({ state, tr, dispatch }: CommandProps): boolean => {
|
||||
if (!isInTable(state)) return false;
|
||||
if (!dispatch) return true;
|
||||
|
||||
const rect = selectedRect(state);
|
||||
const value = borderAttrValue(spec);
|
||||
|
||||
// pos (relative to tableStart) → attr changes, deduped across grid slots of a span.
|
||||
const changes = new Map<number, Record<string, string | null>>();
|
||||
const set = (pos: number | null, side: BorderSide) => {
|
||||
if (pos == null) return;
|
||||
const c = changes.get(pos) ?? {};
|
||||
c[side] = value;
|
||||
changes.set(pos, c);
|
||||
};
|
||||
forEachBoundary(rect, preset, (aPos, aSide, bPos, bSide) => {
|
||||
set(aPos, aSide);
|
||||
set(bPos, bSide);
|
||||
});
|
||||
|
||||
// setNodeMarkup doesn't shift positions, so all cell positions stay valid.
|
||||
for (const [pos, attrs] of changes) {
|
||||
const cell = tr.doc.nodeAt(rect.tableStart + pos);
|
||||
if (!cell) continue;
|
||||
tr.setNodeMarkup(rect.tableStart + pos, undefined, { ...cell.attrs, ...attrs });
|
||||
}
|
||||
dispatch(tr);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export const BORDER_PRESETS: BorderPreset[] = [
|
||||
'all', 'outer', 'inner', 'innerH', 'innerV', 'top', 'bottom', 'left', 'right',
|
||||
];
|
||||
|
||||
// What a boundary renders: the collapse winner of its two facing cell sides
|
||||
// (a border beats 'none', wider beats narrower; attr null = the table default).
|
||||
type EffectiveBorder = { widthPt: number; color: string } | 'none';
|
||||
|
||||
// Word-like active states for the picker: a preset is active when every boundary it
|
||||
// targets renders exactly the pen border (width + color) — so with the default thin
|
||||
// pen a fresh table lights up every preset; picking a thicker pen turns them off.
|
||||
// 'none' is active when the whole region renders borderless. null outside a table.
|
||||
export function activeBorderPresets(
|
||||
state: EditorState,
|
||||
spec: Exclude<BorderSpec, null>,
|
||||
): Record<BorderPreset | 'none', boolean> | null {
|
||||
if (!isInTable(state)) return null;
|
||||
const rect = selectedRect(state);
|
||||
|
||||
const sideEff = (pos: number | null, side: BorderSide): EffectiveBorder | null => {
|
||||
if (pos == null) return null;
|
||||
const b = parseBorderAttr(state.doc.nodeAt(rect.tableStart + pos)?.attrs[side] as string | null);
|
||||
return b ?? { widthPt: DEFAULT_BORDER_WIDTH_PT, color: DEFAULT_BORDER_COLOR };
|
||||
};
|
||||
const boundaryEff = (aPos: number | null, aSide: BorderSide, bPos: number | null, bSide: BorderSide): EffectiveBorder => {
|
||||
let best: EffectiveBorder = 'none';
|
||||
for (const v of [sideEff(aPos, aSide), sideEff(bPos, bSide)]) {
|
||||
if (v == null || v === 'none') continue;
|
||||
if (best === 'none' || v.widthPt > best.widthPt) best = v;
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const matchesPen = (v: EffectiveBorder) =>
|
||||
v !== 'none' && Math.abs(v.widthPt - spec.widthPt) < 0.01 && v.color === spec.color.toUpperCase();
|
||||
|
||||
const out = {} as Record<BorderPreset | 'none', boolean>;
|
||||
for (const preset of BORDER_PRESETS) {
|
||||
let n = 0;
|
||||
let active = true;
|
||||
forEachBoundary(rect, preset, (aPos, aSide, bPos, bSide) => {
|
||||
n++;
|
||||
if (!matchesPen(boundaryEff(aPos, aSide, bPos, bSide))) active = false;
|
||||
});
|
||||
out[preset] = n > 0 && active;
|
||||
}
|
||||
let n = 0;
|
||||
let allNone = true;
|
||||
forEachBoundary(rect, 'all', (aPos, aSide, bPos, bSide) => {
|
||||
n++;
|
||||
if (boundaryEff(aPos, aSide, bPos, bSide) !== 'none') allNone = false;
|
||||
});
|
||||
out.none = n > 0 && allNone;
|
||||
return out;
|
||||
}
|
||||
|
||||
export const TableCellBorders = Extension.create({
|
||||
name: 'tableCellBorders',
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ['tableCell', 'tableHeader'],
|
||||
attributes: Object.fromEntries(
|
||||
BORDER_SIDES.map((side) => [
|
||||
side,
|
||||
{
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute(SIDE_META[side].data),
|
||||
renderHTML: (attributes: Record<string, unknown>) => {
|
||||
const v = attributes[side] as string | null;
|
||||
if (!v) return {};
|
||||
const css = cssBorder(v);
|
||||
if (!css) return {};
|
||||
return { [SIDE_META[side].data]: v, style: `${SIDE_META[side].css}: ${css}` };
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setTableBorders: (preset: BorderPreset, spec: BorderSpec) => setTableBorders(preset, spec),
|
||||
};
|
||||
},
|
||||
});
|
||||
+15
-1
@@ -18,6 +18,7 @@ import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
|
||||
import { orderedTypeDef } from '../utils/orderedListTypes';
|
||||
import { normalizeColor, HEADING_STYLE_OVERRIDES, type HfExport } from './odt';
|
||||
|
||||
@@ -608,6 +609,14 @@ function columnWidthsCm(node: TiptapNode, contentWidthCm: number): number[] | un
|
||||
|
||||
const cellBorder: IBorderOptions = { style: BorderStyle.SINGLE, size: 4, color: '000000' }; // 0.5pt
|
||||
|
||||
// A cell's per-side border attr → w:tcBorders options (size in eighth-points).
|
||||
function docxCellBorder(attrs: Record<string, unknown> | undefined, side: BorderSide): IBorderOptions {
|
||||
const b = parseBorderAttr(attrs?.[side] as string | null);
|
||||
if (b === 'none') return { style: BorderStyle.NONE, size: 0, color: 'auto' };
|
||||
if (b) return { style: BorderStyle.SINGLE, size: Math.max(2, Math.round(b.widthPt * 8)), color: hexColor(b.color) ?? '000000' };
|
||||
return cellBorder;
|
||||
}
|
||||
|
||||
function cellBlocksToDocx(content: TiptapNode[] = [], headerBold: boolean, num: Numbering, contentWidthCm: number): (Paragraph | Table)[] {
|
||||
const out: (Paragraph | Table)[] = [];
|
||||
for (const child of content) {
|
||||
@@ -646,7 +655,12 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
|
||||
rowSpan: rowspan > 1 ? rowspan : undefined,
|
||||
shading: fill ? { type: ShadingType.CLEAR, fill } : undefined,
|
||||
width: widthTwip ? { size: widthTwip, type: WidthType.DXA } : undefined,
|
||||
borders: { top: cellBorder, bottom: cellBorder, left: cellBorder, right: cellBorder },
|
||||
borders: {
|
||||
top: docxCellBorder(cell.attrs, 'borderTop'),
|
||||
bottom: docxCellBorder(cell.attrs, 'borderBottom'),
|
||||
left: docxCellBorder(cell.attrs, 'borderLeft'),
|
||||
right: docxCellBorder(cell.attrs, 'borderRight'),
|
||||
},
|
||||
children: cellBlocksToDocx(cell.content, bg === HEADER_SHADE, num, contentWidthCm),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
|
||||
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||||
import { DEFAULT_ORDERED_TYPE, orderedTypeDef } from '../utils/orderedListTypes';
|
||||
|
||||
@@ -1286,6 +1287,12 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number,
|
||||
const c = normalizeColor(bg);
|
||||
if (c) opts.backgroundColor = c;
|
||||
}
|
||||
// Per-side borders → fo:border-* (null = the table default border below).
|
||||
for (const side of BORDER_SIDES) {
|
||||
const b = parseBorderAttr(cell.attrs?.[side] as string | null);
|
||||
if (b === 'none') opts[side] = 'none';
|
||||
else if (b) opts[side] = `${b.widthPt}pt solid ${normalizeColor(b.color) ?? b.color}`;
|
||||
}
|
||||
// Header-row cells render bold via CSS (presentational), so bake bold into the
|
||||
// runs on export to keep Word/LibreOffice consistent (incl. freshly-typed text).
|
||||
const headerBold = bg === HEADER_SHADE;
|
||||
|
||||
+42
-1
@@ -722,14 +722,41 @@ function convertPict(pict: Element, ctx: Ctx): Node | null {
|
||||
}
|
||||
|
||||
// ---- tables -----------------------------------------------------------------
|
||||
|
||||
// A w:tcBorders/w:tblBorders side element → border attr value: null = the editor
|
||||
// default (0.5pt black), 'none', or '<W>pt solid #RRGGBB' (w:sz is eighth-points).
|
||||
// undefined when the element is absent (side not declared at this level).
|
||||
function docxBorderAttr(el: Element | null): string | null | undefined {
|
||||
if (!el) return undefined;
|
||||
const val = el.getAttributeNS(W, 'val');
|
||||
if (!val || val === 'none' || val === 'nil') return 'none';
|
||||
const sz = intAttr(el, W, 'sz');
|
||||
const w = Math.round(((sz != null ? sz / 8 : 0.5)) * 100) / 100;
|
||||
const c = (hexColor(el.getAttributeNS(W, 'color')) ?? '#000000').toUpperCase();
|
||||
if (Math.abs(w - 0.5) < 0.11 && c === '#000000') return null;
|
||||
return `${w}pt solid ${c}`;
|
||||
}
|
||||
|
||||
function convertTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
const grid = fc(tbl, 'tblGrid');
|
||||
const weights = grid ? fcAll(grid, 'gridCol').map((g) => Math.max(1, intAttr(g, W, 'w') ?? 1)) : null;
|
||||
const useWeights = weights && weights.length ? weights : null;
|
||||
|
||||
// Table-level border defaults; per-cell w:tcBorders override per side. Edge cells
|
||||
// fall back to the outer sides, interior cells to insideH/insideV.
|
||||
const tblBorders = fc(fc(tbl, 'tblPr'), 'tblBorders');
|
||||
const tblSide = (name: string) => docxBorderAttr(fc(tblBorders, name));
|
||||
const tblDef = {
|
||||
top: tblSide('top'), bottom: tblSide('bottom'), left: tblSide('left'),
|
||||
right: tblSide('right'), insideH: tblSide('insideH'), insideV: tblSide('insideV'),
|
||||
};
|
||||
const gridWidth = useWeights?.length ?? null;
|
||||
|
||||
const rows: Node[] = [];
|
||||
const pending: (Node | null)[] = []; // origin cell per grid column, for vMerge spans
|
||||
for (const tr of fcAll(tbl, 'tr')) {
|
||||
const trs = fcAll(tbl, 'tr');
|
||||
for (let ri = 0; ri < trs.length; ri++) {
|
||||
const tr = trs[ri];
|
||||
const cells: Node[] = [];
|
||||
let col = 0;
|
||||
for (const tc of fcAll(tr, 'tc')) {
|
||||
@@ -748,6 +775,20 @@ function convertTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
const blocks = convertBlocks(Array.from(tc.children), ctx, 'cell', bg === HEADER_SHADE);
|
||||
const attrs: Record<string, unknown> = { colspan, rowspan: 1 };
|
||||
if (bg) attrs.backgroundColor = bg;
|
||||
const tcBorders = fc(tcPr, 'tcBorders');
|
||||
const isRight = gridWidth != null && col + colspan >= gridWidth;
|
||||
const resolve = (name: string, tblVal: string | null | undefined) => {
|
||||
const own = docxBorderAttr(fc(tcBorders, name));
|
||||
return own !== undefined ? own : tblVal;
|
||||
};
|
||||
const sides: [string, string | null | undefined][] = [
|
||||
['borderTop', resolve('top', ri === 0 ? tblDef.top : tblDef.insideH)],
|
||||
['borderBottom', resolve('bottom', ri === trs.length - 1 ? tblDef.bottom : tblDef.insideH)],
|
||||
['borderLeft', resolve('left', col === 0 ? tblDef.left : tblDef.insideV)],
|
||||
['borderRight', resolve('right', isRight ? tblDef.right : tblDef.insideV)],
|
||||
];
|
||||
// undefined (nothing declared) and null (= editor default) both leave the attr off.
|
||||
for (const [attr, v] of sides) if (typeof v === 'string') attrs[attr] = v;
|
||||
if (useWeights) attrs.colwidth = useWeights.slice(col, col + colspan);
|
||||
const cell: Node = { type: 'tableCell', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
|
||||
cells.push(cell);
|
||||
|
||||
+35
-2
@@ -833,6 +833,28 @@ function listLevelDef(listStyle: Element | null, depth: number): Element | null
|
||||
|
||||
// ---- tables ------------------------------------------------------------------------
|
||||
|
||||
// ODF border ("0.5pt solid #000000" / "none" / absent) → border attr value: null for
|
||||
// the editor default (0.5pt black, tolerance for producer unit rounding), 'none' for
|
||||
// no border, else the canonical '<W>pt solid #RRGGBB'. Non-solid styles are coerced
|
||||
// to solid (the editor only renders solid lines).
|
||||
export function borderAttrFromOdf(raw: string | null | undefined): string | null {
|
||||
if (!raw || raw === 'none' || raw === 'hidden') return 'none';
|
||||
let widthPt: number | null = null;
|
||||
let color: string | null = null;
|
||||
let styleTok: string | null = null;
|
||||
for (const part of raw.trim().split(/\s+/)) {
|
||||
if (/^-?[\d.]+\s*(pt|cm|mm|in|px|pc)?$/.test(part)) widthPt = lengthToPt(part);
|
||||
else if (part.startsWith('#')) color = normalizeColor(part) ?? part;
|
||||
else styleTok = part;
|
||||
}
|
||||
if (styleTok === 'none' || styleTok === 'hidden') return 'none';
|
||||
if (widthPt != null && widthPt <= 0) return 'none';
|
||||
const w = Math.round((widthPt ?? 0.5) * 100) / 100;
|
||||
const c = (color ?? '#000000').toUpperCase();
|
||||
if (Math.abs(w - 0.5) < 0.11 && c === '#000000') return null;
|
||||
return `${w}pt solid ${c}`;
|
||||
}
|
||||
|
||||
function convertTable(el: Element, ctx: Ctx): Node | null {
|
||||
const weights = columnWeights(el, ctx.resolver);
|
||||
|
||||
@@ -851,13 +873,24 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
|
||||
|
||||
const colspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-columns-spanned') ?? '1', 10) || 1;
|
||||
const rowspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-rows-spanned') ?? '1', 10) || 1;
|
||||
const rawBg = ctx.resolver.cellBackgroundColor(cellEl.getAttributeNS(NS.table, 'style-name'));
|
||||
const cellStyleName = cellEl.getAttributeNS(NS.table, 'style-name');
|
||||
const rawBg = ctx.resolver.cellBackgroundColor(cellStyleName);
|
||||
const backgroundColor = rawBg ? normalizeColor(rawBg) ?? rawBg : null;
|
||||
// Per-side borders; an undeclared side means no border in ODF → 'none'.
|
||||
// Only null (= the editor's 0.5pt-black default) is left off the attrs.
|
||||
const rawBorders = ctx.resolver.cellBorders(cellStyleName);
|
||||
const borders: Record<string, string> = {};
|
||||
for (const [attr, side] of [
|
||||
['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left'],
|
||||
] as const) {
|
||||
const v = borderAttrFromOdf(rawBorders[side]);
|
||||
if (v !== null) borders[attr] = v;
|
||||
}
|
||||
// Header-shaded cells render bold by default (CSS); convert their runs like headings
|
||||
// so a baked-bold run needs no mark and only an explicitly normal run gets one.
|
||||
const blocks = convertBlocks(Array.from(cellEl.children), ctx, 'cell', backgroundColor === HEADER_SHADE);
|
||||
for (let r = 0; r < repeated; r++) {
|
||||
const attrs: Record<string, unknown> = { colspan, rowspan };
|
||||
const attrs: Record<string, unknown> = { colspan, rowspan, ...borders };
|
||||
if (backgroundColor) attrs.backgroundColor = backgroundColor;
|
||||
if (weights) attrs.colwidth = weights.slice(colIndex, colIndex + colspan);
|
||||
cells.push({
|
||||
|
||||
@@ -248,6 +248,21 @@ export class StyleResolver {
|
||||
return c && c !== 'transparent' && c !== 'none' ? c : null;
|
||||
}
|
||||
|
||||
// A cell style's raw fo:border-* values (per-side overriding the fo:border
|
||||
// shorthand). Sides the style doesn't declare stay undefined (= no border in ODF).
|
||||
cellBorders(styleName: string | null): Partial<Record<'top' | 'right' | 'bottom' | 'left', string>> {
|
||||
if (!styleName) return {};
|
||||
const misc = this.merged('table-cell', styleName).misc;
|
||||
const all = misc['fo:border'];
|
||||
const pick = (side: string) => misc[`fo:border-${side}`] ?? all;
|
||||
const out: Partial<Record<'top' | 'right' | 'bottom' | 'left', string>> = {};
|
||||
for (const side of ['top', 'right', 'bottom', 'left'] as const) {
|
||||
const v = pick(side);
|
||||
if (v != null) out[side] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
listStyle(name: string | null): Element | null {
|
||||
return name ? this.listStyles.get(name) ?? null : null;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('DOCX export → import round trip', () => {
|
||||
{ type: 'textBox', attrs: { width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] },
|
||||
{ type: 'table', content: [
|
||||
{ type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] },
|
||||
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1')] },
|
||||
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1', { borderTop: 'none', borderRight: '2.25pt solid #FF0000' })] },
|
||||
{ type: 'tableRow', content: [cell('2')] },
|
||||
] },
|
||||
],
|
||||
@@ -157,6 +157,12 @@ describe('DOCX export → import round trip', () => {
|
||||
expect(widget.attrs.backgroundColor).toBe('#FFFF00');
|
||||
expect(rows[2].content!.length).toBe(1);
|
||||
expect(rows[1].attrs.rowHeight).toBe(40);
|
||||
// per-side borders (w:tcBorders): hidden top + custom red right; defaults stay null
|
||||
const qty = rows[1].content![1];
|
||||
expect(qty.attrs.borderTop).toBe('none');
|
||||
expect(qty.attrs.borderRight).toBe('2.25pt solid #FF0000');
|
||||
expect(qty.attrs.borderBottom ?? null).toBe(null);
|
||||
expect(widget.attrs.borderTop ?? null).toBe(null);
|
||||
});
|
||||
|
||||
it('round-trips page geometry + header/footer with page fields', () => {
|
||||
|
||||
@@ -76,7 +76,7 @@ const fixture: N = {
|
||||
] },
|
||||
{ type: 'tableRow', content: [
|
||||
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [120] }, content: [P(null, T('A2'))] },
|
||||
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [240] }, content: [P(null, T('B2'))] },
|
||||
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [240], borderTop: 'none', borderRight: '2.25pt solid #FF0000' }, content: [P(null, T('B2'))] },
|
||||
] },
|
||||
] },
|
||||
TBX({ width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 },
|
||||
@@ -114,6 +114,11 @@ function normalize(node: N): N {
|
||||
if (k === 'rotation') { attrs.rotation = 'R'; continue; } // exact angle checked leniently below
|
||||
if (k === 'wrap') { attrs.wrap = 'W'; continue; } // float survives; exact mode checked leniently
|
||||
if (k === 'strokeWidthPt') { attrs.strokeWidthPt = Math.round((v as number) * 4) / 4; continue; } // pt↔in noise
|
||||
// Cell borders: LO re-saves widths with pt↔cm noise; quantize to 0.25pt steps.
|
||||
if (k.startsWith('border') && typeof v === 'string' && v !== 'none') {
|
||||
const bm = /^([\d.]+)pt solid (#[0-9A-F]{6})$/i.exec(v);
|
||||
if (bm) { attrs[k] = `${Math.round(parseFloat(bm[1]) * 4) / 4}pt solid ${bm[2].toUpperCase()}`; continue; }
|
||||
}
|
||||
if (k === 'fillColor' || k === 'strokeColor') { attrs[k] = typeof v === 'string' ? v.toUpperCase() : v; continue; }
|
||||
// LO re-parents text-box paragraphs onto its Frame-contents style (margin 0), so
|
||||
// an explicit spaceAfter 0 comes back where Standard's default was suppressed.
|
||||
|
||||
@@ -328,6 +328,41 @@ describe('Leg 1c: header-row cells (bold-by-default, editable)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 1d: table cell borders (per-side, fo:border-*)', () => {
|
||||
const bcell = (text: string, borders: Record<string, string> = {}): N =>
|
||||
({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [100], ...borders }, content: [P(null, T(text))] });
|
||||
|
||||
// A: default borders (null attrs) · B: top hidden + custom red right ·
|
||||
// C/D: fully borderless.
|
||||
const doc: N = { type: 'doc', content: [
|
||||
{ type: 'table', content: [
|
||||
ROW(bcell('A'), bcell('B', { borderTop: 'none', borderRight: '2.25pt solid #FF0000' })),
|
||||
ROW(bcell('C', { borderTop: 'none', borderRight: 'none', borderBottom: 'none', borderLeft: 'none' }),
|
||||
bcell('D', { borderTop: 'none', borderRight: 'none', borderBottom: 'none', borderLeft: 'none' })),
|
||||
] },
|
||||
] };
|
||||
|
||||
it('exports fo:border-* per side and re-imports the attrs', async () => {
|
||||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||||
check('export emits fo:border-top="none"', xml.includes('fo:border-top="none"'), xml.match(/fo:border-top="[^"]*"/g));
|
||||
check('export emits the custom red right border', xml.includes('fo:border-right="2.25pt solid #FF0000"'), xml.match(/fo:border-right="[^"]*"/g));
|
||||
|
||||
const res = importOdt(bytes);
|
||||
check('no warnings on own export', res.warnings.length === 0, res.warnings);
|
||||
const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
|
||||
const [row0, row1] = table?.content ?? [];
|
||||
const a = row0?.content?.[0]?.attrs ?? {};
|
||||
const b = row0?.content?.[1]?.attrs ?? {};
|
||||
const c = row1?.content?.[0]?.attrs ?? {};
|
||||
check('A keeps default borders (attrs null)', a.borderTop == null && a.borderRight == null && a.borderBottom == null && a.borderLeft == null, a);
|
||||
check('B top border stays hidden', b.borderTop === 'none', b);
|
||||
check('B custom right border round-trips', b.borderRight === '2.25pt solid #FF0000', b);
|
||||
check('B bottom/left stay default', b.borderBottom == null && b.borderLeft == null, b);
|
||||
check('C is fully borderless', c.borderTop === 'none' && c.borderRight === 'none' && c.borderBottom === 'none' && c.borderLeft === 'none', c);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
|
||||
it('resolves named/automatic styles, repeated cells, lists, and reports degradations', () => {
|
||||
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// Unit tests for the table border feature: the setTableBorders preset command
|
||||
// (driven against a ProseMirror EditorState, like table-split.test.ts) and the
|
||||
// attr-value parse/normalize helpers used by export and import.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import { EditorState, TextSelection } from '@tiptap/pm/state';
|
||||
import { CellSelection } from '@tiptap/pm/tables';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import Document from '@tiptap/extension-document';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Text from '@tiptap/extension-text';
|
||||
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
|
||||
import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
|
||||
import {
|
||||
TableCellBorders, setTableBorders, activeBorderPresets, borderAttrValue, parseBorderAttr,
|
||||
type BorderPreset, type BorderSpec,
|
||||
} from '../../src/lib/editor/extensions/tableCellBorders';
|
||||
import { borderAttrFromOdf } from '../../src/lib/import/odt';
|
||||
|
||||
type N = any;
|
||||
|
||||
const schema = getSchema([
|
||||
Document, Paragraph, Text, Table, ResizableTableRow, TableCell, TableHeader, TableCellBorders,
|
||||
]);
|
||||
|
||||
const cell = (text: string): N => ({
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||||
});
|
||||
const row = (...cells: N[]): N => ({ type: 'tableRow', content: cells });
|
||||
// 3×3 grid: [A B C] / [D E F] / [G H I]
|
||||
const grid3 = (): N => ({
|
||||
type: 'doc',
|
||||
content: [{ type: 'table', content: [
|
||||
row(cell('A'), cell('B'), cell('C')),
|
||||
row(cell('D'), cell('E'), cell('F')),
|
||||
row(cell('G'), cell('H'), cell('I')),
|
||||
] }],
|
||||
});
|
||||
|
||||
function textPos(doc: PMNode, text: string): number {
|
||||
let pos = -1;
|
||||
doc.descendants((node: PMNode, p: number) => {
|
||||
if (node.isText && node.text === text) pos = p;
|
||||
});
|
||||
if (pos < 0) throw new Error(`text ${text} not found`);
|
||||
return pos;
|
||||
}
|
||||
|
||||
function cellPos(doc: PMNode, text: string): number {
|
||||
const $pos = doc.resolve(textPos(doc, text));
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
if ($pos.node(d).type.spec.tableRole === 'cell') return $pos.before(d);
|
||||
}
|
||||
throw new Error(`no cell around ${text}`);
|
||||
}
|
||||
|
||||
// Run the command with the cursor in `target` (string) or a CellSelection over
|
||||
// [anchor, head] cells; returns cellText → border attrs for the whole table.
|
||||
function run(json: N, target: string | [string, string], preset: BorderPreset, spec: BorderSpec): Record<string, N> {
|
||||
const doc = schema.nodeFromJSON(json);
|
||||
const selection = typeof target === 'string'
|
||||
? TextSelection.create(doc, textPos(doc, target))
|
||||
: new CellSelection(doc.resolve(cellPos(doc, target[0])), doc.resolve(cellPos(doc, target[1])));
|
||||
const state = EditorState.create({ schema, doc, selection });
|
||||
let next: EditorState | null = null;
|
||||
const ok = setTableBorders(preset, spec)({
|
||||
state,
|
||||
tr: state.tr,
|
||||
dispatch: (tr: any) => { next = state.apply(tr); },
|
||||
} as any);
|
||||
expect(ok).toBe(true);
|
||||
const out: Record<string, N> = {};
|
||||
next!.doc.descendants((node: PMNode) => {
|
||||
if (node.type.spec.tableRole === 'cell') {
|
||||
const { borderTop, borderRight, borderBottom, borderLeft } = node.attrs;
|
||||
out[node.textContent] = { borderTop, borderRight, borderBottom, borderLeft };
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const RED = { widthPt: 1, color: '#FF0000' };
|
||||
const V = '1pt solid #FF0000';
|
||||
|
||||
describe('setTableBorders', () => {
|
||||
it('"all" on a single cell sets its four sides and mirrors the facing neighbour sides', () => {
|
||||
const t = run(grid3(), 'E', 'all', RED);
|
||||
expect(t.E).toEqual({ borderTop: V, borderRight: V, borderBottom: V, borderLeft: V });
|
||||
expect(t.B.borderBottom).toBe(V); // above
|
||||
expect(t.H.borderTop).toBe(V); // below
|
||||
expect(t.D.borderRight).toBe(V); // left
|
||||
expect(t.F.borderLeft).toBe(V); // right
|
||||
// Corners untouched.
|
||||
expect(t.A).toEqual({ borderTop: null, borderRight: null, borderBottom: null, borderLeft: null });
|
||||
// Neighbours' other sides untouched.
|
||||
expect(t.B.borderTop).toBe(null);
|
||||
expect(t.D.borderLeft).toBe(null);
|
||||
});
|
||||
|
||||
it('"all" with the default 0.5pt black spec normalizes to null (table default)', () => {
|
||||
const t = run(grid3(), 'E', 'all', { widthPt: 0.5, color: '#000000' });
|
||||
expect(t.E).toEqual({ borderTop: null, borderRight: null, borderBottom: null, borderLeft: null });
|
||||
});
|
||||
|
||||
it('a null spec removes borders ("no border"), both sides of each boundary', () => {
|
||||
const t = run(grid3(), 'E', 'all', null);
|
||||
expect(t.E).toEqual({ borderTop: 'none', borderRight: 'none', borderBottom: 'none', borderLeft: 'none' });
|
||||
expect(t.B.borderBottom).toBe('none');
|
||||
expect(t.F.borderLeft).toBe('none');
|
||||
});
|
||||
|
||||
it('"outer" on a 2×2 region sets only the region edges (+ outside mirrors)', () => {
|
||||
const t = run(grid3(), ['A', 'E'], 'outer', RED); // region = A B / D E
|
||||
expect(t.A.borderTop).toBe(V);
|
||||
expect(t.A.borderLeft).toBe(V);
|
||||
expect(t.B.borderTop).toBe(V);
|
||||
expect(t.B.borderRight).toBe(V);
|
||||
expect(t.E.borderRight).toBe(V);
|
||||
expect(t.E.borderBottom).toBe(V);
|
||||
// Inner boundaries untouched.
|
||||
expect(t.A.borderRight).toBe(null);
|
||||
expect(t.A.borderBottom).toBe(null);
|
||||
expect(t.E.borderTop).toBe(null);
|
||||
expect(t.E.borderLeft).toBe(null);
|
||||
// Outside neighbours mirror the shared edge.
|
||||
expect(t.C.borderLeft).toBe(V);
|
||||
expect(t.F.borderLeft).toBe(V);
|
||||
expect(t.G.borderTop).toBe(V);
|
||||
expect(t.H.borderTop).toBe(V);
|
||||
expect(t.I).toEqual({ borderTop: null, borderRight: null, borderBottom: null, borderLeft: null });
|
||||
});
|
||||
|
||||
it('"inner" on a 2×2 region sets only the boundaries between region cells', () => {
|
||||
const t = run(grid3(), ['A', 'E'], 'inner', RED);
|
||||
expect(t.A.borderRight).toBe(V);
|
||||
expect(t.B.borderLeft).toBe(V);
|
||||
expect(t.A.borderBottom).toBe(V);
|
||||
expect(t.D.borderTop).toBe(V);
|
||||
expect(t.E.borderTop).toBe(V);
|
||||
expect(t.E.borderLeft).toBe(V);
|
||||
// Region edges untouched.
|
||||
expect(t.A.borderTop).toBe(null);
|
||||
expect(t.A.borderLeft).toBe(null);
|
||||
expect(t.E.borderRight).toBe(null);
|
||||
expect(t.E.borderBottom).toBe(null);
|
||||
expect(t.C.borderLeft).toBe(null);
|
||||
});
|
||||
|
||||
it('"top" sets only the region top edge', () => {
|
||||
const t = run(grid3(), ['D', 'E'], 'top', RED); // region = D E (row 1)
|
||||
expect(t.D.borderTop).toBe(V);
|
||||
expect(t.E.borderTop).toBe(V);
|
||||
expect(t.A.borderBottom).toBe(V); // mirror above
|
||||
expect(t.B.borderBottom).toBe(V);
|
||||
expect(t.D.borderBottom).toBe(null);
|
||||
expect(t.D.borderLeft).toBe(null);
|
||||
expect(t.F.borderTop).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeBorderPresets', () => {
|
||||
function stateFor(json: N, target: string | [string, string]): EditorState {
|
||||
const doc = schema.nodeFromJSON(json);
|
||||
const selection = typeof target === 'string'
|
||||
? TextSelection.create(doc, textPos(doc, target))
|
||||
: new CellSelection(doc.resolve(cellPos(doc, target[0])), doc.resolve(cellPos(doc, target[1])));
|
||||
return EditorState.create({ schema, doc, selection });
|
||||
}
|
||||
const THIN = { widthPt: 0.5, color: '#000000' };
|
||||
|
||||
it('fresh table (default borders) + default pen → every preset active except none', () => {
|
||||
const a = activeBorderPresets(stateFor(grid3(), ['A', 'I']), THIN)!;
|
||||
expect(a.all && a.outer && a.inner && a.innerH && a.innerV && a.top && a.bottom && a.left && a.right).toBe(true);
|
||||
expect(a.none).toBe(false);
|
||||
});
|
||||
|
||||
it('active state follows the pen width: thicker pen → default borders no longer match', () => {
|
||||
const a = activeBorderPresets(stateFor(grid3(), ['A', 'I']), { widthPt: 2.25, color: '#000000' })!;
|
||||
expect(a.all).toBe(false);
|
||||
expect(a.outer).toBe(false);
|
||||
expect(a.none).toBe(false);
|
||||
});
|
||||
|
||||
it('borderless region → only none active', () => {
|
||||
// Remove all borders of the whole table first.
|
||||
const st = stateFor(grid3(), ['A', 'I']);
|
||||
let next: EditorState | null = null;
|
||||
setTableBorders('all', null)({ state: st, tr: st.tr, dispatch: (tr: any) => { next = st.apply(tr); } } as any);
|
||||
const a = activeBorderPresets(next!, THIN)!;
|
||||
expect(a.none).toBe(true);
|
||||
expect(a.all).toBe(false);
|
||||
expect(a.top).toBe(false);
|
||||
});
|
||||
|
||||
it('single cell: inner presets have no boundaries → inactive; edges active', () => {
|
||||
const a = activeBorderPresets(stateFor(grid3(), 'E'), THIN)!;
|
||||
expect(a.inner).toBe(false);
|
||||
expect(a.innerH).toBe(false);
|
||||
expect(a.all && a.outer && a.top && a.bottom && a.left && a.right).toBe(true);
|
||||
});
|
||||
|
||||
it('a boundary with a mismatched color breaks the match for presets that cover it', () => {
|
||||
const st = stateFor(grid3(), 'E');
|
||||
let next: EditorState | null = null;
|
||||
setTableBorders('top', { widthPt: 0.5, color: '#FF0000' })({ state: st, tr: st.tr, dispatch: (tr: any) => { next = st.apply(tr); } } as any);
|
||||
const withSel = EditorState.create({ schema, doc: next!.doc, selection: TextSelection.create(next!.doc, textPos(next!.doc, 'E')) });
|
||||
const a = activeBorderPresets(withSel, THIN)!;
|
||||
expect(a.top).toBe(false); // now red, pen is black
|
||||
expect(a.all).toBe(false);
|
||||
expect(a.bottom).toBe(true); // untouched sides still match
|
||||
const red = activeBorderPresets(withSel, { widthPt: 0.5, color: '#FF0000' })!;
|
||||
expect(red.top).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null outside a table', () => {
|
||||
const doc = schema.nodeFromJSON({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] });
|
||||
const st = EditorState.create({ schema, doc, selection: TextSelection.create(doc, 1) });
|
||||
expect(activeBorderPresets(st, THIN)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('border attr helpers', () => {
|
||||
it('borderAttrValue: default collapses to null, none to "none", custom to canonical', () => {
|
||||
expect(borderAttrValue({ widthPt: 0.5, color: '#000000' })).toBe(null);
|
||||
expect(borderAttrValue(null)).toBe('none');
|
||||
expect(borderAttrValue({ widthPt: 2.25, color: '#ff0000' })).toBe('2.25pt solid #FF0000');
|
||||
});
|
||||
|
||||
it('parseBorderAttr inverts borderAttrValue', () => {
|
||||
expect(parseBorderAttr(null)).toBe(null);
|
||||
expect(parseBorderAttr('none')).toBe('none');
|
||||
expect(parseBorderAttr('2.25pt solid #FF0000')).toEqual({ widthPt: 2.25, color: '#FF0000' });
|
||||
});
|
||||
|
||||
it('borderAttrFromOdf: absent/none → "none", the 0.5pt-black default → null (unit tolerance)', () => {
|
||||
expect(borderAttrFromOdf(undefined)).toBe('none');
|
||||
expect(borderAttrFromOdf('none')).toBe('none');
|
||||
expect(borderAttrFromOdf('0.5pt solid #000000')).toBe(null);
|
||||
expect(borderAttrFromOdf('0.018cm solid #000000')).toBe(null); // LibreOffice cm form
|
||||
expect(borderAttrFromOdf('2.25pt solid #ff0000')).toBe('2.25pt solid #FF0000');
|
||||
expect(borderAttrFromOdf('0.06pt double #808080')).toBe('0.06pt solid #808080'); // style coerced
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user