feat: move table row bars by mouse
This commit is contained in:
1 parent
e28c399639
commit
bb281c60c1
7 files changed
+416
-15
No files matched your search
@@ -12,7 +12,8 @@ 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 { Table, TableHeader, TableCell } from '@tiptap/extension-table';
|
||||
import { ResizableTableRow } from './tableRow';
|
||||
import History from '@tiptap/extension-history';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
@@ -22,6 +23,7 @@ import { ParagraphSpacing } from './paragraphSpacing';
|
||||
import { FormattingMarks } from './formattingMarks';
|
||||
import { TableView } from './tableView';
|
||||
import { TableColumnResize } from './tableColumnResize';
|
||||
import { TableRowResize } from './tableRowResize';
|
||||
|
||||
export const extensions = [
|
||||
Document,
|
||||
@@ -46,17 +48,21 @@ export const extensions = [
|
||||
BulletList,
|
||||
OrderedList,
|
||||
ListItem,
|
||||
// Columns are flexible and drag-resizable (Word-style). We keep TipTap's own
|
||||
// resizable:false (so its columnResizing plugin isn't loaded) and instead supply
|
||||
// Columns and rows are flexible and drag-resizable (Word-style). We keep TipTap's
|
||||
// own resizable:false (so its columnResizing plugin isn't loaded) and instead supply
|
||||
// a custom node view that renders the <colgroup> with *percentage* widths from the
|
||||
// `colwidth` cell attrs — so the table is always full text width and stays in sync
|
||||
// with the ODT export (export/odt.ts emits per-column widths summing to the text
|
||||
// width). The TableColumnResize extension below adds the Word-style drag handles.
|
||||
// width). TableColumnResize adds the Word-style column drag handles; ResizableTableRow
|
||||
// carries a per-row `rowHeight` attr and TableRowResize adds the row drag handles
|
||||
// (exported as style:min-row-height). Table alone — unlike TableKit — does NOT
|
||||
// auto-add child extensions, so listing ResizableTableRow here is the only tableRow.
|
||||
Table.configure({ resizable: false, View: TableView }),
|
||||
TableRow,
|
||||
ResizableTableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableColumnResize,
|
||||
TableRowResize,
|
||||
History,
|
||||
Placeholder.configure({ placeholder: 'Start typing…' }),
|
||||
TextAlign.configure({
|
||||
|
||||
@@ -529,14 +529,19 @@ export const PageBreaks = Extension.create({
|
||||
cumulativeSpacerHeight += child.offsetHeight;
|
||||
continue;
|
||||
}
|
||||
// Skip the column-resize handle widget (tableColumnResize.ts), which
|
||||
// ProseMirror injects as a direct child of the active column's cells.
|
||||
// It's position:absolute and full-cell-height, so it's neither document
|
||||
// content nor part of the flow — measuring it as a leaf yields garbage
|
||||
// geometry (negative offsetTop, full-table height) that corrupts
|
||||
// pagination. Being out of flow it takes no vertical space, so unlike a
|
||||
// page-break spacer we skip it without touching cumulativeSpacerHeight.
|
||||
if (child.classList.contains('column-resize-handle')) continue;
|
||||
// Skip the column-/row-resize handle widgets (tableColumnResize.ts /
|
||||
// tableRowResize.ts), which ProseMirror injects as direct children of
|
||||
// the active cells. They're position:absolute, so they're neither
|
||||
// document content nor part of the flow — measuring one as a leaf yields
|
||||
// garbage geometry (negative offsetTop, full-table height) that corrupts
|
||||
// pagination. Being out of flow they take no vertical space, so unlike a
|
||||
// page-break spacer we skip them without touching cumulativeSpacerHeight.
|
||||
if (
|
||||
child.classList.contains('column-resize-handle') ||
|
||||
child.classList.contains('row-resize-handle')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const tag = child.tagName;
|
||||
// TipTap renders tables inside a <div class="tableWrapper"> node
|
||||
// view (see extensions.ts Table config), so the top-level child is
|
||||
|
||||
@@ -35,7 +35,7 @@ export const tableColumnResizeKey = new PluginKey<ResizeState>('tableColumnResiz
|
||||
|
||||
// ─── DOM / geometry helpers (mirrors prosemirror-tables) ───────────────────
|
||||
|
||||
function domCellAround(target: HTMLElement | null): HTMLElement | null {
|
||||
export function domCellAround(target: HTMLElement | null): HTMLElement | null {
|
||||
let el: HTMLElement | null = target;
|
||||
while (el && el.nodeName !== 'TD' && el.nodeName !== 'TH') {
|
||||
el = el.classList && el.classList.contains('ProseMirror') ? null : (el.parentNode as HTMLElement | null);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TableRow } from '@tiptap/extension-table';
|
||||
|
||||
// TableRow extended with a draggable row height.
|
||||
//
|
||||
// TipTap's stock TableRow has no height attribute. We add `rowHeight` (integer
|
||||
// CSS px, unscaled document pixels at 96 dpi) and render it as `style="height: Npx"`
|
||||
// on the <tr>. CSS treats a row's height as a *minimum* (the row grows if its
|
||||
// content is taller), which is exactly the semantics we export — odf-kit gets a
|
||||
// `style:min-row-height` (see export/odt.ts applyTableRowHeights). `null` means
|
||||
// "no explicit height" → the row auto-sizes to its content (back-compat for
|
||||
// documents saved before this feature).
|
||||
//
|
||||
// The actual drag interaction + live preview live in tableRowResize.ts; this file
|
||||
// only owns the persisted attribute and its DOM/serialization mapping.
|
||||
|
||||
export const ResizableTableRow = TableRow.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
rowHeight: {
|
||||
default: null as number | null,
|
||||
// Round-trip a pasted/imported <tr style="height: …px">. Non-px units are
|
||||
// ignored (null) — the editor only ever writes px.
|
||||
parseHTML: (element) => {
|
||||
const h = (element as HTMLElement).style?.height;
|
||||
if (!h) return null;
|
||||
const m = /^([\d.]+)px$/.exec(h.trim());
|
||||
return m ? Math.round(parseFloat(m[1])) : null;
|
||||
},
|
||||
renderHTML: (attributes) => {
|
||||
const h = attributes.rowHeight as number | null;
|
||||
return h ? { style: `height: ${h}px` } : {};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import type { Transaction } from '@tiptap/pm/state';
|
||||
import type { EditorState } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import { cellAround } from '@tiptap/pm/tables';
|
||||
import { domCellAround, tableColumnResizeKey } from './tableColumnResize';
|
||||
|
||||
// Word-style row-height dragging.
|
||||
//
|
||||
// Dragging a horizontal grid line changes the height of the row ABOVE it (Word
|
||||
// behaviour). The height is a *minimum*: the row never clips, it grows when its
|
||||
// content is taller (CSS `height` on a <tr>, exported as style:min-row-height —
|
||||
// see export/odt.ts). The committed value lives in the tableRow node's `rowHeight`
|
||||
// attribute (tableRow.ts).
|
||||
//
|
||||
// Live feedback during a drag is applied via a Decoration.node carrying a `style:
|
||||
// height` — ProseMirror owns that DOM write, so (unlike a direct tr.style poke
|
||||
// inside the content DOM) the DOM observer doesn't revert it. Each move dispatches
|
||||
// a meta-only transaction (no doc change → no pagination recompute, no history
|
||||
// entry); release commits the final height in one real transaction.
|
||||
//
|
||||
// Column resizing (tableColumnResize.ts) takes precedence at a corner: this plugin
|
||||
// yields whenever a column border is the active handle.
|
||||
|
||||
const HANDLE_WIDTH = 5; // px from a row border that activates the handle
|
||||
const MIN_ROW_PX = 16; // smallest a row may be dragged to (content still protects it)
|
||||
|
||||
interface RowResizeState {
|
||||
activeRow: number; // doc pos directly before the active row, or -1
|
||||
dragging: boolean;
|
||||
previewHeight: number | null; // live drag height in unscaled document px
|
||||
}
|
||||
|
||||
interface RowResizeAction {
|
||||
setActive?: number;
|
||||
setDragging?: boolean;
|
||||
setPreview?: number | null;
|
||||
}
|
||||
|
||||
export const tableRowResizeKey = new PluginKey<RowResizeState>('tableRowResize');
|
||||
|
||||
// ─── Geometry helpers ────────────────────────────────────────────────────────
|
||||
|
||||
// Position directly before the row whose lower border is near the pointer. We
|
||||
// probe just ABOVE the pointer so a border between row i and i+1 resolves to row i
|
||||
// (the row that gets resized). Returns -1 above the table's first row.
|
||||
function edgeRowPos(view: EditorView, event: MouseEvent): number {
|
||||
const found = view.posAtCoords({ left: event.clientX, top: event.clientY - HANDLE_WIDTH });
|
||||
if (!found) return -1;
|
||||
const $cell = cellAround(view.state.doc.resolve(found.pos));
|
||||
if (!$cell) return -1;
|
||||
return $cell.before(); // position before the row node
|
||||
}
|
||||
|
||||
// The <tr> element for the row at `rowPos` (a before-row position).
|
||||
function rowDOM(view: EditorView, rowPos: number): HTMLElement | null {
|
||||
let dom = view.domAtPos(rowPos + 1).node as HTMLElement | null;
|
||||
while (dom && dom.nodeName !== 'TR') dom = dom.parentNode as HTMLElement | null;
|
||||
return (dom as HTMLElement) ?? null;
|
||||
}
|
||||
|
||||
// ─── Event handlers ────────────────────────────────────────────────────────
|
||||
|
||||
function handleMouseMove(view: EditorView, event: MouseEvent): void {
|
||||
if (!view.editable) return;
|
||||
const state = tableRowResizeKey.getState(view.state);
|
||||
if (!state || state.dragging) return;
|
||||
|
||||
// Corner precedence: if a column border is active, don't claim a row border.
|
||||
const colState = tableColumnResizeKey.getState(view.state);
|
||||
if (colState && colState.activeHandle > -1) {
|
||||
if (state.activeRow !== -1) {
|
||||
view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setActive: -1 } as RowResizeAction));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const target = domCellAround(event.target as HTMLElement | null);
|
||||
let row = -1;
|
||||
if (target) {
|
||||
const { top, bottom } = target.getBoundingClientRect();
|
||||
if (bottom - event.clientY <= HANDLE_WIDTH || event.clientY - top <= HANDLE_WIDTH) {
|
||||
row = edgeRowPos(view, event);
|
||||
}
|
||||
}
|
||||
|
||||
if (row !== state.activeRow) {
|
||||
view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setActive: row } as RowResizeAction));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(view: EditorView, event: MouseEvent): boolean {
|
||||
if (!view.editable) return false;
|
||||
const state = tableRowResizeKey.getState(view.state);
|
||||
if (!state || state.activeRow === -1 || state.dragging) return false;
|
||||
|
||||
const activeRow = state.activeRow;
|
||||
const rowNode = view.state.doc.nodeAt(activeRow);
|
||||
if (!rowNode || rowNode.type.name !== 'tableRow') return false;
|
||||
|
||||
const tr = rowDOM(view, activeRow);
|
||||
if (!tr) return false;
|
||||
const startHeight = tr.offsetHeight; // unscaled document px (ignores CSS zoom)
|
||||
if (!startHeight) return false;
|
||||
// offsetHeight is unscaled; getBoundingClientRect is scaled by the .paper zoom.
|
||||
// Their ratio is the zoom factor, so the mouse delta (viewport px) converts back
|
||||
// to document px without walking the ancestor chain.
|
||||
const zoom = tr.getBoundingClientRect().height / startHeight || 1;
|
||||
|
||||
const startY = event.clientY;
|
||||
let lastHeight = startHeight;
|
||||
let pendingHeight = startHeight;
|
||||
let moved = false;
|
||||
let rafPending = false;
|
||||
const win = view.dom.ownerDocument.defaultView ?? window;
|
||||
|
||||
view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setDragging: true } as RowResizeAction));
|
||||
|
||||
function flush(): void {
|
||||
rafPending = false;
|
||||
const st = tableRowResizeKey.getState(view.state);
|
||||
if (!st?.dragging) return;
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.setMeta(tableRowResizeKey, { setPreview: pendingHeight } as RowResizeAction)
|
||||
.setMeta('addToHistory', false),
|
||||
);
|
||||
}
|
||||
|
||||
function move(e: MouseEvent): void {
|
||||
if (!e.buttons) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
pendingHeight = Math.max(MIN_ROW_PX, Math.round(startHeight + (e.clientY - startY) / zoom));
|
||||
lastHeight = pendingHeight;
|
||||
moved = true;
|
||||
if (!rafPending) {
|
||||
rafPending = true;
|
||||
win.requestAnimationFrame(flush);
|
||||
}
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
win.removeEventListener('mouseup', finish);
|
||||
win.removeEventListener('mousemove', move);
|
||||
const st = tableRowResizeKey.getState(view.state);
|
||||
if (!st?.dragging) return;
|
||||
const node = view.state.doc.nodeAt(activeRow);
|
||||
if (moved && node && node.type.name === 'tableRow') {
|
||||
const tx = view.state.tr.setNodeMarkup(activeRow, undefined, { ...node.attrs, rowHeight: lastHeight });
|
||||
tx.setMeta(tableRowResizeKey, { setDragging: false, setPreview: null } as RowResizeAction);
|
||||
view.dispatch(tx);
|
||||
} else {
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(tableRowResizeKey, { setDragging: false, setPreview: null } as RowResizeAction),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
win.addEventListener('mouseup', finish);
|
||||
win.addEventListener('mousemove', move);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Decorations: handle at the active row's bottom edge + live preview ───────
|
||||
|
||||
function rowDecorations(state: EditorState, activeRow: number, previewHeight: number | null): DecorationSet {
|
||||
const rowNode = state.doc.nodeAt(activeRow);
|
||||
if (!rowNode || rowNode.type.name !== 'tableRow') return DecorationSet.empty;
|
||||
const decos: Decoration[] = [];
|
||||
const dragging = tableRowResizeKey.getState(state)?.dragging;
|
||||
|
||||
// Live preview: apply the dragged height through ProseMirror so it isn't reverted.
|
||||
if (dragging && previewHeight != null) {
|
||||
decos.push(
|
||||
Decoration.node(activeRow, activeRow + rowNode.nodeSize, { style: `height: ${previewHeight}px` }),
|
||||
);
|
||||
}
|
||||
|
||||
// One handle per cell at the row's lower edge → a continuous line across the row.
|
||||
rowNode.forEach((cell, offset) => {
|
||||
const cellPos = activeRow + 1 + offset;
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'row-resize-handle';
|
||||
decos.push(Decoration.widget(cellPos + cell.nodeSize - 1, handle));
|
||||
});
|
||||
|
||||
return DecorationSet.create(state.doc, decos);
|
||||
}
|
||||
|
||||
// ─── Plugin / extension ──────────────────────────────────────────────────────
|
||||
|
||||
export const TableRowResize = Extension.create({
|
||||
name: 'tableRowResize',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin<RowResizeState>({
|
||||
key: tableRowResizeKey,
|
||||
state: {
|
||||
init: (): RowResizeState => ({ activeRow: -1, dragging: false, previewHeight: null }),
|
||||
apply(tr: Transaction, prev: RowResizeState): RowResizeState {
|
||||
const action = tr.getMeta(tableRowResizeKey) as RowResizeAction | undefined;
|
||||
if (action) {
|
||||
const next: RowResizeState = { ...prev };
|
||||
if ('setActive' in action) {
|
||||
next.activeRow = action.setActive ?? -1;
|
||||
next.dragging = false;
|
||||
next.previewHeight = null;
|
||||
}
|
||||
if ('setDragging' in action) next.dragging = !!action.setDragging;
|
||||
if ('setPreview' in action) next.previewHeight = action.setPreview ?? null;
|
||||
return next;
|
||||
}
|
||||
if (prev.activeRow > -1 && tr.docChanged) {
|
||||
const mapped = tr.mapping.map(prev.activeRow, -1);
|
||||
const node = tr.doc.nodeAt(mapped);
|
||||
if (!node || node.type.name !== 'tableRow') {
|
||||
return { activeRow: -1, dragging: false, previewHeight: null };
|
||||
}
|
||||
return { ...prev, activeRow: mapped };
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
attributes: (state): Record<string, string> => {
|
||||
const s = tableRowResizeKey.getState(state);
|
||||
return s && s.activeRow > -1 ? { class: 'row-resize-cursor' } : {};
|
||||
},
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
handleMouseMove(view, event as MouseEvent);
|
||||
return false;
|
||||
},
|
||||
mouseleave: (view) => {
|
||||
const s = tableRowResizeKey.getState(view.state);
|
||||
if (s && s.activeRow > -1 && !s.dragging) {
|
||||
view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setActive: -1 } as RowResizeAction));
|
||||
}
|
||||
return false;
|
||||
},
|
||||
mousedown: (view, event) => handleMouseDown(view, event as MouseEvent),
|
||||
},
|
||||
decorations: (state) => {
|
||||
const s = tableRowResizeKey.getState(state);
|
||||
if (s && s.activeRow > -1) return rowDecorations(state, s.activeRow, s.previewHeight);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
+77
-1
@@ -172,6 +172,78 @@ function collectTableCellStyles(node: TiptapNode, result: ParaStyle[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect each table row's explicit height (px → cm string), in DFS document
|
||||
// order — matching the order odf-kit emits <table:table-row> into content.xml.
|
||||
// rowHeight is stored as unscaled document px at 96 dpi (tableRow.ts); convert to
|
||||
// cm (px × 2.54 / 96). Rows without an explicit height yield null.
|
||||
function collectTableRowHeights(node: TiptapNode, result: (string | null)[]): void {
|
||||
if (node.type === 'table') {
|
||||
for (const row of node.content ?? []) {
|
||||
if (row.type !== 'tableRow') continue;
|
||||
const h = row.attrs?.rowHeight;
|
||||
if (typeof h === 'number' && h > 0) {
|
||||
const cm = Math.round(((h * 2.54) / 96) * 1000) / 1000;
|
||||
result.push(`${cm}cm`);
|
||||
} else {
|
||||
result.push(null);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const child of node.content ?? []) {
|
||||
collectTableRowHeights(child, result);
|
||||
}
|
||||
}
|
||||
|
||||
// odf-kit's TableBuilder has no row-height option (TableRowOptions only carries
|
||||
// backgroundColor → fo:background-color on style:table-row-properties). We add the
|
||||
// dragged row heights by post-processing content.xml: each <table:table-row> with a
|
||||
// height gets a table:style-name pointing at a new automatic table-row style with
|
||||
// style:min-row-height — a *minimum*, so the row grows with content and nothing is
|
||||
// clipped (round-trips with LibreOffice/Word). use-optimal-row-height="false" stops
|
||||
// LibreOffice from auto-shrinking back to the content height. The regex consumes one
|
||||
// heights[] entry per <table:table-row> so the DFS order from collectTableRowHeights
|
||||
// stays aligned; rows already carrying a table:style-name (not produced today — the
|
||||
// editor sets no row background) are skipped.
|
||||
function applyTableRowHeights(odtBytes: Uint8Array, heights: (string | null)[]): Uint8Array {
|
||||
if (heights.every(h => h === null)) return odtBytes;
|
||||
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
|
||||
let content = strFromU8(contentBytes);
|
||||
const styleDefs: { name: string; height: string }[] = [];
|
||||
const nameByHeight = new Map<string, string>();
|
||||
let counter = 0;
|
||||
let idx = 0;
|
||||
|
||||
content = content.replace(/<table:table-row\b([^>]*)>/g, (match, attrs: string) => {
|
||||
const height = heights[idx++];
|
||||
if (!height) return match;
|
||||
if (/\btable:style-name=/.test(attrs)) return match;
|
||||
let name = nameByHeight.get(height);
|
||||
if (!name) {
|
||||
counter++;
|
||||
name = `TRH${counter}`;
|
||||
nameByHeight.set(height, name);
|
||||
styleDefs.push({ name, height });
|
||||
}
|
||||
return `<table:table-row table:style-name="${name}"${attrs}>`;
|
||||
});
|
||||
|
||||
if (styleDefs.length === 0) return odtBytes;
|
||||
|
||||
const newStyles = styleDefs.map(({ name, height }) =>
|
||||
`<style:style style:name="${name}" style:family="table-row"><style:table-row-properties style:min-row-height="${height}" style:use-optimal-row-height="false"/></style:style>`,
|
||||
).join('\n');
|
||||
|
||||
content = content.replace('</office:automatic-styles>', `${newStyles}\n</office:automatic-styles>`);
|
||||
|
||||
files['content.xml'] = strToU8(content);
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// odf-kit's ListBuilder doesn't support per-item paragraph options, so list-item
|
||||
// paragraphs always emit with text:style-name="List_20_Bullet" or "List_20_Number".
|
||||
// We rewrite content.xml to point those at custom automatic styles that inherit
|
||||
@@ -612,7 +684,11 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT
|
||||
collectTableCellStyles(raw, cellStyles);
|
||||
const styledCells = applyTableCellStyles(styledLists, cellStyles);
|
||||
|
||||
const cleaned = collapseRunWhitespace(styledCells);
|
||||
const rowHeights: (string | null)[] = [];
|
||||
collectTableRowHeights(raw, rowHeights);
|
||||
const styledRows = applyTableRowHeights(styledCells, rowHeights);
|
||||
|
||||
const cleaned = collapseRunWhitespace(styledRows);
|
||||
const finalBytes = rewriteStylesXml(cleaned);
|
||||
|
||||
const blob = new Blob([finalBytes as Uint8Array<ArrayBuffer>], { type: 'application/vnd.oasis.opendocument.text' });
|
||||
|
||||
@@ -253,6 +253,25 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Row resize (tableRowResize.ts). Mirrors the column handle but sits at a cell's
|
||||
lower edge; one handle per cell forms a continuous line across the active row.
|
||||
`.row-resize-cursor` is toggled on the whole editor while a row border is active.
|
||||
Column resize takes precedence at a corner, so the two never fight. */
|
||||
.paper .tiptap.row-resize-cursor {
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.paper .tiptap .row-resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -2px;
|
||||
height: 4px;
|
||||
z-index: 20;
|
||||
background: var(--color-primary);
|
||||
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