feat: tables
This commit is contained in:
1 parent
355f12afd7
commit
e2f522854f
9 files changed
+465
-20
No files matched your search
+2
-1
@@ -34,11 +34,12 @@
|
||||
- show formatting marks
|
||||
- margins
|
||||
- page orientation
|
||||
- tables
|
||||
|
||||
### Not yet implemented
|
||||
- ODT import / open existing file
|
||||
- undo history
|
||||
- Images, tables
|
||||
- Images
|
||||
- Custom right-click context menu (formatting, cut/copy)
|
||||
- Multi-document management
|
||||
- PWA / offline support
|
||||
|
||||
Generated
+15
@@ -21,6 +21,7 @@
|
||||
"@tiptap/extension-ordered-list": "^3.23.6",
|
||||
"@tiptap/extension-paragraph": "^3.23.6",
|
||||
"@tiptap/extension-placeholder": "^3.23.6",
|
||||
"@tiptap/extension-table": "3.23.6",
|
||||
"@tiptap/extension-text": "^3.23.6",
|
||||
"@tiptap/extension-text-align": "^3.23.6",
|
||||
"@tiptap/extension-text-style": "^3.23.6",
|
||||
@@ -627,6 +628,20 @@
|
||||
"@tiptap/extensions": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.23.6.tgz",
|
||||
"integrity": "sha512-XbhZXjhsS6AP7ThoZxjAnNs+NiR81YRori25l6E+ORqB7quiPkIXOAi5h4AIpkn/CYIqze6ere11lWsYpDjtaQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.6.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@tiptap/extension-ordered-list": "^3.23.6",
|
||||
"@tiptap/extension-paragraph": "^3.23.6",
|
||||
"@tiptap/extension-placeholder": "^3.23.6",
|
||||
"@tiptap/extension-table": "3.23.6",
|
||||
"@tiptap/extension-text": "^3.23.6",
|
||||
"@tiptap/extension-text-align": "^3.23.6",
|
||||
"@tiptap/extension-text-style": "^3.23.6",
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
|
||||
let {
|
||||
editor,
|
||||
open = $bindable(false),
|
||||
onOpen,
|
||||
onInsert,
|
||||
}: {
|
||||
editor: Editor | null;
|
||||
open?: boolean;
|
||||
onOpen?: () => void;
|
||||
// Mirrors ColorPicker's onApply: the parent runs the actual editor command
|
||||
// against the saved selection range (focus is stolen by the popover).
|
||||
onInsert: (rows: number, cols: number, range: { from: number; to: number }) => void;
|
||||
} = $props();
|
||||
|
||||
// Quick-pick grid size (like Word/LibreOffice). Hovering selects the top-left
|
||||
// rows×cols block; clicking inserts it.
|
||||
const GRID_ROWS = 8;
|
||||
const GRID_COLS = 10;
|
||||
const ROW_INDICES = Array.from({ length: GRID_ROWS }, (_, i) => i + 1);
|
||||
const COL_INDICES = Array.from({ length: GRID_COLS }, (_, i) => i + 1);
|
||||
|
||||
// Manual-entry bounds for the row/column number inputs.
|
||||
const MAX_ROWS = 50;
|
||||
const MAX_COLS = 20;
|
||||
|
||||
let hoverRows = $state(0);
|
||||
let hoverCols = $state(0);
|
||||
let rowsInput = $state('3');
|
||||
let colsInput = $state('3');
|
||||
|
||||
let savedFrom: number | null = null;
|
||||
let savedTo: number | null = null;
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(n)) return min;
|
||||
return Math.max(min, Math.min(max, Math.round(n)));
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
if (!editor) return;
|
||||
onOpen?.();
|
||||
savedFrom = editor.state.selection.from;
|
||||
savedTo = editor.state.selection.to;
|
||||
if (open) {
|
||||
open = false;
|
||||
} else {
|
||||
hoverRows = 0;
|
||||
hoverCols = 0;
|
||||
open = true;
|
||||
}
|
||||
}
|
||||
|
||||
function insert(rows: number, cols: number) {
|
||||
if (!editor) return;
|
||||
open = false;
|
||||
const from = savedFrom ?? editor.state.selection.from;
|
||||
const to = savedTo ?? editor.state.selection.to;
|
||||
savedFrom = null;
|
||||
savedTo = null;
|
||||
onInsert(rows, cols, { from, to });
|
||||
}
|
||||
|
||||
function insertFromInputs() {
|
||||
insert(clamp(parseInt(rowsInput, 10), 1, MAX_ROWS), clamp(parseInt(colsInput, 10), 1, MAX_COLS));
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
insertFromInputs();
|
||||
}
|
||||
}
|
||||
|
||||
function tablePickerClickOutside(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="table-picker" use:tablePickerClickOutside>
|
||||
<button class="table-trigger" onclick={openPicker} title="Insert table" aria-pressed={open}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="1" stroke="currentColor" stroke-width="1.5"/>
|
||||
<line x1="1.5" y1="5.5" x2="14.5" y2="5.5" stroke="currentColor" stroke-width="1"/>
|
||||
<line x1="1.5" y1="9.5" x2="14.5" y2="9.5" stroke="currentColor" stroke-width="1"/>
|
||||
<line x1="5.5" y1="5.5" x2="5.5" y2="14.5" stroke="currentColor" stroke-width="1"/>
|
||||
<line x1="10.5" y1="5.5" x2="10.5" y2="14.5" stroke="currentColor" stroke-width="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="table-dropdown">
|
||||
<div class="table-dim-label">
|
||||
{#if hoverRows > 0 && hoverCols > 0}
|
||||
{hoverCols} × {hoverRows}
|
||||
{:else}
|
||||
Insert table
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||
<div
|
||||
class="table-grid"
|
||||
role="grid"
|
||||
tabindex="-1"
|
||||
aria-label="Select table size"
|
||||
onmouseleave={() => { hoverRows = 0; hoverCols = 0; }}
|
||||
>
|
||||
{#each ROW_INDICES as r}
|
||||
<div class="table-grid-row" role="row">
|
||||
{#each COL_INDICES as c}
|
||||
<button
|
||||
class="table-grid-cell"
|
||||
class:hot={r <= hoverRows && c <= hoverCols}
|
||||
role="gridcell"
|
||||
aria-label={`${c} by ${r}`}
|
||||
onmouseover={() => { hoverRows = r; hoverCols = c; }}
|
||||
onfocus={() => { hoverRows = r; hoverCols = c; }}
|
||||
onclick={() => insert(r, c)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="table-manual">
|
||||
<label class="table-manual-field">
|
||||
<span>Rows</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
bind:value={rowsInput}
|
||||
onkeydown={onInputKeydown}
|
||||
aria-label="Number of rows"
|
||||
/>
|
||||
</label>
|
||||
<label class="table-manual-field">
|
||||
<span>Cols</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
bind:value={colsInput}
|
||||
onkeydown={onInputKeydown}
|
||||
aria-label="Number of columns"
|
||||
/>
|
||||
</label>
|
||||
<button class="table-insert-btn" onclick={insertFromInputs}>Insert</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.table-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.table-trigger:hover {
|
||||
background: var(--color-btn-hover);
|
||||
}
|
||||
|
||||
.table-trigger[aria-pressed='true'] {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.table-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 3px);
|
||||
left: 0;
|
||||
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);
|
||||
z-index: 200;
|
||||
padding: 8px;
|
||||
/* Anchor near the right edge of the toolbar — keep it from spilling off-screen. */
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.table-dim-label {
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.table-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.table-grid-row {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.table-grid-cell {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-width: unset;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 2px;
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-grid-cell.hot {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.table-manual {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.table-manual-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.table-manual-field input {
|
||||
width: 44px;
|
||||
height: 1.8rem;
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.table-manual-field input:hover,
|
||||
.table-manual-field input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.table-insert-btn {
|
||||
height: 1.8rem;
|
||||
padding: 0 0.7rem;
|
||||
min-width: unset;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-insert-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { onMount } from 'svelte';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import TablePicker from './TablePicker.svelte';
|
||||
import {
|
||||
CANDIDATE_FONTS,
|
||||
detectAvailableFonts,
|
||||
@@ -624,6 +625,26 @@
|
||||
if (which === 'font') highlightColorOpen = false;
|
||||
else fontColorOpen = false;
|
||||
}
|
||||
|
||||
// --- Table insertion (TablePicker.svelte) ---
|
||||
let tableOpen = $state(false);
|
||||
|
||||
// Close the sibling dropdowns when the table picker opens (mirrors openLayout).
|
||||
function onTablePickerOpen() {
|
||||
fontOpen = false;
|
||||
sizeOpen = false;
|
||||
sizeInputFocused = false;
|
||||
lineHeightOpen = false;
|
||||
alignOpen = false;
|
||||
fontColorOpen = false;
|
||||
highlightColorOpen = false;
|
||||
layoutOpen = false;
|
||||
}
|
||||
|
||||
function insertTable(rows: number, cols: number, range: { from: number; to: number }) {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().setTextSelection(range).insertTable({ rows, cols, withHeaderRow: false }).run();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toolbar-expanded">
|
||||
@@ -1017,15 +1038,12 @@
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<button disabled title="Insert table (coming soon)">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="1" stroke="currentColor" stroke-width="1.5"/>
|
||||
<line x1="1.5" y1="5.5" x2="14.5" y2="5.5" stroke="currentColor" stroke-width="1"/>
|
||||
<line x1="1.5" y1="9.5" x2="14.5" y2="9.5" stroke="currentColor" stroke-width="1"/>
|
||||
<line x1="5.5" y1="5.5" x2="5.5" y2="14.5" stroke="currentColor" stroke-width="1"/>
|
||||
<line x1="10.5" y1="5.5" x2="10.5" y2="14.5" stroke="currentColor" stroke-width="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<TablePicker
|
||||
{editor}
|
||||
bind:open={tableOpen}
|
||||
onOpen={onTablePickerOpen}
|
||||
onInsert={insertTable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import Heading from '@tiptap/extension-heading';
|
||||
import BulletList from '@tiptap/extension-bullet-list';
|
||||
import OrderedList from '@tiptap/extension-ordered-list';
|
||||
import ListItem from '@tiptap/extension-list-item';
|
||||
import { Table, TableRow, TableHeader, TableCell } from '@tiptap/extension-table';
|
||||
import History from '@tiptap/extension-history';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
@@ -43,6 +44,13 @@ export const extensions = [
|
||||
BulletList,
|
||||
OrderedList,
|
||||
ListItem,
|
||||
// resizable:false → equal-width, full-width columns. Kept non-resizable so the
|
||||
// editor's column layout matches the ODT export (odf-kit distributes columns
|
||||
// evenly when no column widths are emitted — see export/odt.ts CUST_TABLE path).
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
History,
|
||||
Placeholder.configure({ placeholder: 'Start typing…' }),
|
||||
TextAlign.configure({
|
||||
|
||||
@@ -114,7 +114,10 @@ type Leaf = {
|
||||
naturalHeight: number;
|
||||
};
|
||||
|
||||
const ATOMIC_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
|
||||
// TABLE is atomic: a table that overflows the page is pushed whole to the next
|
||||
// page (we don't split a table across pages). A table taller than one page will
|
||||
// overflow visually — an accepted limitation of this version.
|
||||
const ATOMIC_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TABLE']);
|
||||
const SPLITTABLE_TAGS = new Set(['P']);
|
||||
const CONTAINER_TAGS = new Set(['UL', 'OL', 'LI', 'BLOCKQUOTE']);
|
||||
|
||||
@@ -376,7 +379,13 @@ export const PageBreaks = Extension.create({
|
||||
continue;
|
||||
}
|
||||
const tag = child.tagName;
|
||||
const isAtomic = ATOMIC_TAGS.has(tag);
|
||||
// TipTap renders tables inside a <div class="tableWrapper"> node
|
||||
// view (see extensions.ts Table config), so the top-level child is
|
||||
// the wrapper DIV, not the TABLE. Treat either as an atomic table.
|
||||
const isTable =
|
||||
tag === 'TABLE' ||
|
||||
(tag === 'DIV' && child.classList.contains('tableWrapper'));
|
||||
const isAtomic = ATOMIC_TAGS.has(tag) || isTable;
|
||||
const isSplittable = SPLITTABLE_TAGS.has(tag);
|
||||
if (isAtomic || isSplittable) {
|
||||
const rect = child.getBoundingClientRect();
|
||||
|
||||
+61
-8
@@ -1,5 +1,5 @@
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder } from 'odf-kit';
|
||||
import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder, type TableBuilder, type RowBuilder, type CellBuilder } from 'odf-kit';
|
||||
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
|
||||
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
@@ -12,6 +12,16 @@ type AlignValue = 'left' | 'center' | 'right' | 'justify';
|
||||
|
||||
const CUST_P = '__cust_p__';
|
||||
const CUST_H = '__cust_h__';
|
||||
// Tables are renamed to this so odf-kit routes them to unknownNodeHandler. Its
|
||||
// native table path (walkTable) calls addTable with no options, emitting cells
|
||||
// with no border — invisible in LibreOffice/Word. We build the table ourselves
|
||||
// and pass an explicit border instead.
|
||||
const CUST_TABLE = '__cust_table__';
|
||||
|
||||
// Table export styling. Values mirror the editor's table CSS (src/styles/editor.css)
|
||||
// so the .odt matches the on-screen preview.
|
||||
const TABLE_BORDER = '0.5pt solid #000000';
|
||||
const CELL_PADDING = '0.1cm'; // must match td/th padding in editor.css
|
||||
|
||||
// odf-kit emits this as the document's default font (Standard style). The editor
|
||||
// renders the bundled, metric-identical Liberation Serif on screen.
|
||||
@@ -41,19 +51,30 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
|
||||
return ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify';
|
||||
}
|
||||
|
||||
function injectCustomTypes(node: TiptapNode, inList = false): TiptapNode {
|
||||
// Don't rename paragraphs inside list items — tiptapToOdt walks them by
|
||||
// type === "paragraph" to build list content; renaming breaks that.
|
||||
if (!inList && hasCustomAttrs(node.attrs)) {
|
||||
function injectCustomTypes(node: TiptapNode, inContainer = false): TiptapNode {
|
||||
// Rename tables so odf-kit routes them to our unknownNodeHandler (see
|
||||
// CUST_TABLE). Recurse with inContainer=true so the cell paragraphs are NOT
|
||||
// renamed — our table handler walks them by type === "paragraph".
|
||||
if (node.type === 'table') {
|
||||
return {
|
||||
...node,
|
||||
type: CUST_TABLE,
|
||||
content: node.content?.map(c => injectCustomTypes(c, true)),
|
||||
};
|
||||
}
|
||||
// Don't rename paragraphs inside list items or table cells — tiptapToOdt's list
|
||||
// builder (and our table handler) walk them by type === "paragraph"; renaming
|
||||
// breaks that.
|
||||
if (!inContainer && hasCustomAttrs(node.attrs)) {
|
||||
if (node.type === 'paragraph') return { ...node, type: CUST_P };
|
||||
if (node.type === 'heading') return { ...node, type: CUST_H };
|
||||
}
|
||||
if (node.content?.length) {
|
||||
const childInList = inList
|
||||
const childInContainer = inContainer
|
||||
|| node.type === 'bulletList'
|
||||
|| node.type === 'orderedList'
|
||||
|| node.type === 'listItem';
|
||||
return { ...node, content: node.content.map(c => injectCustomTypes(c, childInList)) };
|
||||
return { ...node, content: node.content.map(c => injectCustomTypes(c, childInContainer)) };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
@@ -260,7 +281,7 @@ function normalizeColor(input: string): string | undefined {
|
||||
return s;
|
||||
}
|
||||
|
||||
function applyRuns(p: ParagraphBuilder, content: TiptapNode[] = []) {
|
||||
function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = []) {
|
||||
for (const node of content) {
|
||||
if (node.type !== 'text' || !node.text) continue;
|
||||
const marks = node.marks ?? [];
|
||||
@@ -292,6 +313,34 @@ function applyRuns(p: ParagraphBuilder, content: TiptapNode[] = []) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build an ODF table from a CUST_TABLE node (renamed from "table" in
|
||||
// injectCustomTypes). We bypass odf-kit's native walkTable so we can pass an
|
||||
// explicit cell border — the native path emits none, so the table would be
|
||||
// invisible in LibreOffice/Word. No columnWidths are passed, so odf-kit
|
||||
// distributes columns evenly, matching the editor's equal-width fixed layout.
|
||||
function exportTable(node: TiptapNode, doc: OdtDocument): void {
|
||||
const rows = (node.content ?? []).filter(r => r.type === 'tableRow');
|
||||
if (rows.length === 0) return;
|
||||
doc.addTable((t: TableBuilder) => {
|
||||
for (const row of rows) {
|
||||
t.addRow((r: RowBuilder) => {
|
||||
for (const cell of row.content ?? []) {
|
||||
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
|
||||
const paras = (cell.content ?? []).filter(c => c.type === 'paragraph');
|
||||
r.addCell((c: CellBuilder) => {
|
||||
// Multiple cell paragraphs are joined with a space, matching
|
||||
// odf-kit's own walkTable behavior.
|
||||
paras.forEach((para, i) => {
|
||||
if (i > 0) c.addText(' ');
|
||||
applyRuns(c, para.content);
|
||||
});
|
||||
}, { padding: CELL_PADDING });
|
||||
}
|
||||
});
|
||||
}
|
||||
}, { border: TABLE_BORDER });
|
||||
}
|
||||
|
||||
// odf-kit's XML builder serializes a multi-child element's children on separate
|
||||
// lines, joined by "\n" (core/xml.js). Inside a <text:p>/<text:h> that newline
|
||||
// is significant character data, so LibreOffice/Word collapse it to a space —
|
||||
@@ -333,6 +382,10 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT
|
||||
marginLeft: `${margins.left}cm`,
|
||||
marginRight: `${margins.right}cm`,
|
||||
unknownNodeHandler(node: TiptapNode, doc: OdtDocument) {
|
||||
if (node.type === CUST_TABLE) {
|
||||
exportTable(node, doc);
|
||||
return;
|
||||
}
|
||||
const opts: {
|
||||
lineHeight?: number | string;
|
||||
align?: AlignValue;
|
||||
|
||||
@@ -154,6 +154,54 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Tables. The visual border/padding must match the ODT export so the editor
|
||||
and the exported .odt look identical (see export/odt.ts CUST_TABLE handler):
|
||||
• border → export emits 0.5pt solid #000000; 1px solid renders crisply
|
||||
on screen and is the visual equivalent of a thin 0.5pt line.
|
||||
• padding → export emits fo:padding 0.1cm per cell (CELL_PADDING).
|
||||
Tables are wrapped in <div class="tableWrapper"> by TipTap's node view. */
|
||||
.paper .tiptap .tableWrapper {
|
||||
margin: 0 0 0.212cm;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.paper .tiptap table {
|
||||
width: 100%;
|
||||
/* fixed layout + width:100% → equal-width columns regardless of content,
|
||||
matching odf-kit's even column distribution on export. */
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.paper .tiptap td,
|
||||
.paper .tiptap th {
|
||||
border: 1px solid #000;
|
||||
padding: 0.1cm;
|
||||
vertical-align: top;
|
||||
/* Cells hold paragraphs; suppress their bottom margin so a single-line cell
|
||||
isn't taller than its text (matches the list-item paragraph treatment). */
|
||||
}
|
||||
|
||||
.paper .tiptap td > p,
|
||||
.paper .tiptap th > p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Selected-cell highlight while editing (ProseMirror tables add .selectedCell).
|
||||
A translucent overlay via the ::after avoids disturbing the cell border. */
|
||||
.paper .tiptap .selectedCell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.paper .tiptap .selectedCell::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-primary);
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.paper .tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
|
||||
Reference in new issue
Block a user