feat: add increase and decrease indent

This commit is contained in:
Steffen Becker committed 2026-06-16 23:16:28 +02:00
1 parent a8456ba42f
commit b8ed2aa14e
5 files changed
+300 -13

No files matched your search

+80 -10
View File
@@ -648,6 +648,51 @@
if (!editor) return;
editor.chain().focus().setTextSelection(range).insertTable({ rows, cols, withHeaderRow: false }).run();
}
// --- Indent (Einzug) ---
// Resolve the selection's list context: whether the cursor is in a list, whether
// the whole list is targeted (cursor in the first item, or the selection spans
// every item), and the innermost list's current indent (cm).
function listContext(): { inList: boolean; wholeList: boolean; listIndent: number } {
if (!editor) return { inList: false, wholeList: false, listIndent: 0 };
const { empty } = editor.state.selection;
const head = editor.state.selection.$from;
const tail = editor.state.selection.$to;
let liDepth = -1;
for (let d = head.depth; d > 0; d--) {
if (head.node(d).type.name === 'listItem') { liDepth = d; break; }
}
if (liDepth < 0) return { inList: false, wholeList: false, listIndent: 0 };
const listDepth = liDepth - 1;
const list = head.node(listDepth);
const listIndent = typeof list.attrs.indent === 'number' ? list.attrs.indent : 0;
const fromFirst = head.index(listDepth) === 0;
// Guard: a selection ending outside the list has no index at listDepth.
const toLast = tail.depth >= listDepth && tail.node(listDepth) === list
&& tail.index(listDepth) === list.childCount - 1;
const wholeList = fromFirst && (empty || toLast);
return { inList: true, wholeList, listIndent };
}
// Word/LibreOffice indent: with the whole list targeted, shift the list as a
// block; in a non-first item, change that item's nesting level; outside a list,
// step the paragraph's left indent (indent.ts).
function changeIndent(dir: 1 | -1) {
if (!editor) return;
const c = editor.chain().focus();
const ctx = listContext();
if (ctx.inList) {
if (ctx.wholeList) {
if (dir === 1) c.indentListMore().run();
else if (ctx.listIndent > 0) c.indentListLess().run();
else c.liftListItem('listItem').run();
} else {
(dir === 1 ? c.sinkListItem('listItem') : c.liftListItem('listItem')).run();
}
} else {
(dir === 1 ? c.indentMore() : c.indentLess()).run();
}
}
</script>
<div class="toolbar-expanded">
@@ -704,8 +749,6 @@
{/if}
</div>
<div class="toolbar-separator"></div>
<div class="size-picker" use:sizePickerClickOutside>
<div class="size-trigger-wrap">
<input
@@ -808,14 +851,35 @@
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button onclick={() => changeIndent(-1)} title="Decrease indent">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="5" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="8" x2="14" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5" y1="13" x2="14" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M5.5 8H1.5M3.5 5.5L1.5 8L3.5 10.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button onclick={() => changeIndent(1)} title="Increase indent">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="5" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="8" x2="14" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5" y1="13" x2="14" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M1.5 8H5.5M3.5 5.5L5.5 8L3.5 10.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="toolbar-separator"></div>
<div class="lh-picker" use:lineHeightPickerClickOutside>
<button class="lh-trigger" onclick={openLineHeightPicker} title="Line &amp; paragraph spacing">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="7" y1="3" x2="16" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="7" y1="8" x2="16" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="7" y1="13" x2="16" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M2 5.5V1.5M2 1.5L0.3 3.4M2 1.5L3.7 3.4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 10.5V14.5M2 14.5L0.3 12.6M2 14.5L3.7 12.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="8" y1="3" x2="16" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="8" y1="8" x2="16" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="8" y1="13" x2="16" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M2.8 6.5V1.6M2.8 1.6L0.7 4.1M2.8 1.6L4.9 4.1" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.8 9.5V14.4M2.8 14.4L0.7 11.9M2.8 14.4L4.9 11.9" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg 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"/>
@@ -901,9 +965,7 @@
{/if}
</div>
<div class="toolbar-separator"></div>
<div class="layout-picker" use:layoutClickOutside>
<div class="layout-picker right-group" use:layoutClickOutside>
<button class="layout-trigger" onclick={openLayout} title="Page layout &amp; margins" aria-pressed={layoutOpen}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2.5" y="1.5" width="11" height="13" rx="1" stroke="currentColor" stroke-width="1.3"/>
@@ -1062,6 +1124,7 @@
.unsetTextAlign()
.setSpaceBefore(null)
.setSpaceAfter(null)
.unsetIndent()
.run()}
title="Clear formatting"
>
@@ -1105,6 +1168,13 @@
border-bottom: 1px solid var(--color-border);
}
/* Layout sits at the far right (logical separation from the rest). order:1
renders it after the left cluster; margin-left:auto opens the gap. Its
dropdown anchors right so it stays inside the viewport. */
.right-group { order: 1; }
.layout-picker.right-group { margin-left: auto; }
.layout-picker.right-group .layout-dropdown { left: auto; right: 0; }
.toolbar-group {
display: flex;
gap: 2px;
+2
View File
@@ -24,6 +24,7 @@ import TextAlign from '@tiptap/extension-text-align';
import { PageBreaks } from './pageBreaks';
import { LineHeight } from './lineHeight';
import { ParagraphSpacing } from './paragraphSpacing';
import { Indent } from './indent';
import { FormattingMarks } from './formattingMarks';
import { TableView } from './tableView';
import { TableColumnResize } from './tableColumnResize';
@@ -54,6 +55,7 @@ export const extensions = [
HardBreak,
LineHeight,
ParagraphSpacing,
Indent,
Heading.configure({ levels: [1, 2, 3] }),
BulletList,
OrderedList,
+120
View File
@@ -0,0 +1,120 @@
import { Extension, type CommandProps } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
// Left indent stored in cm so it round-trips 1:1 to ODF's fo:margin-left.
// On a paragraph/heading it maps to odf-kit's ParagraphOptions.indentLeft; on a
// bulletList/orderedList it shifts the whole list (the indent is added to the
// list style's level margins on export — see export/odt.ts applyListIndents).
// Renders as an inline margin-left. List *items* indent by nesting
// (sinkListItem/liftListItem); this list attr shifts the list as a block.
export const INDENT_STEP_CM = 1.25; // LibreOffice's default indent increment
export const INDENT_MAX_CM = 12; // keep the indent inside the text column
declare module '@tiptap/core' {
interface Commands<ReturnType> {
indent: {
indentMore: () => ReturnType;
indentLess: () => ReturnType;
indentListMore: () => ReturnType;
indentListLess: () => ReturnType;
unsetIndent: () => ReturnType;
};
}
}
function clampIndent(cm: number): number {
return Math.round(Math.max(0, Math.min(INDENT_MAX_CM, cm)) * 100) / 100;
}
function parseIndent(value: string | null): number | null {
if (value == null || value === '') return null;
const n = parseFloat(value);
return Number.isFinite(n) && n > 0 ? n : null;
}
export const Indent = Extension.create({
name: 'indent',
addOptions() {
return {
types: ['paragraph', 'heading'] as string[],
listTypes: ['bulletList', 'orderedList'] as string[],
};
},
addGlobalAttributes() {
return [
{
types: [...this.options.types, ...this.options.listTypes],
attributes: {
indent: {
default: null,
parseHTML: (element: HTMLElement) => parseIndent(element.getAttribute('data-indent')),
renderHTML: (attributes: Record<string, unknown>) => {
if (attributes.indent == null) return {};
const cm = Number(attributes.indent);
return {
'data-indent': String(cm),
style: `margin-left: ${cm}cm`,
};
},
},
},
},
];
},
addCommands() {
const types = this.options.types as string[];
const listTypes = this.options.listTypes as string[];
// Step every paragraph/heading in the selection by delta cm, clamped to
// [0, MAX]. Per-node (relative) change, so a mixed selection keeps each
// block's offset; list-item paragraphs are skipped (lists indent by nesting).
const step = (delta: number) => ({ state, tr, dispatch }: CommandProps) => {
const { from, to } = state.selection;
let changed = false;
state.doc.nodesBetween(from, to, (node: PMNode, pos: number, parent: PMNode | null) => {
if (!types.includes(node.type.name)) return;
if (parent?.type.name === 'listItem') return;
const current = typeof node.attrs.indent === 'number' ? node.attrs.indent : 0;
const next = clampIndent(current + delta);
const value = next > 0 ? next : null;
if (value === (node.attrs.indent ?? null)) return;
tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: value });
changed = true;
});
if (changed && dispatch) dispatch(tr);
return changed;
};
// Step the innermost list ancestor's indent (shifts the whole list as a block).
const stepList = (delta: number) => ({ state, tr, dispatch }: CommandProps) => {
const { $from } = state.selection;
for (let d = $from.depth; d > 0; d--) {
const node = $from.node(d);
if (!listTypes.includes(node.type.name)) continue;
const current = typeof node.attrs.indent === 'number' ? node.attrs.indent : 0;
const next = clampIndent(current + delta);
const value = next > 0 ? next : null;
if (value === (node.attrs.indent ?? null)) return false;
if (dispatch) {
tr.setNodeMarkup($from.before(d), undefined, { ...node.attrs, indent: value });
dispatch(tr);
}
return true;
}
return false;
};
return {
indentMore: () => step(INDENT_STEP_CM),
indentLess: () => step(-INDENT_STEP_CM),
indentListMore: () => stepList(INDENT_STEP_CM),
indentListLess: () => stepList(-INDENT_STEP_CM),
unsetIndent: () => ({ commands }) =>
[...types, ...listTypes].map((type) => commands.resetAttributes(type, 'indent')).some((r) => r),
};
},
});
+53 -1
View File
@@ -70,6 +70,7 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
if (attrs.lineHeight) return true;
if (attrs.spaceBefore != null) return true;
if (attrs.spaceAfter != null) return true;
if (typeof attrs.indent === 'number' && attrs.indent > 0) return true;
const ta = attrs.textAlign;
return ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify';
}
@@ -357,6 +358,47 @@ function applyOrderedListFormats(odtBytes: Uint8Array, formats: (OrderedFmt | nu
return rezipOdt(files);
}
// Whole-list indent: the `indent` attr on a top-level bulletList/orderedList (cm)
// shifts the entire list. odf-kit emits each level in label-alignment mode (the
// paragraph margin is ignored), so the shift must be added to the L# list-style's
// per-level fo:margin-left and text:list-tab-stop-position. One entry per top-level
// list in odf-kit's L# order (0 = no shift).
function collectListIndents(node: TiptapNode, result: number[]): void {
for (const child of node.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
const ind = child.attrs?.indent;
result.push(typeof ind === 'number' && ind > 0 ? ind : 0);
}
}
}
function applyListIndents(odtBytes: Uint8Array, indents: number[]): Uint8Array {
if (indents.every(v => !v)) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
const bump = (cm: number) => (_m: string, attr: string, v: string) =>
`${attr}="${(parseFloat(v) + cm).toFixed(3)}cm"`;
indents.forEach((cm, i) => {
if (!cm) return;
const re = new RegExp(`(<text:list-style style:name="L${i + 1}">)([\\s\\S]*?)(</text:list-style>)`);
content = content.replace(re, (_m, open: string, body: string, close: string) =>
open +
body
.replace(/(fo:margin-left)="([\d.]+)cm"/g, bump(cm))
.replace(/(text:list-tab-stop-position)="([\d.]+)cm"/g, bump(cm)) +
close,
);
});
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// odf-kit emits nested lists as bare <text:list> sharing the top-level L# style,
// so a nested list of a *different* kind/format (e.g. ordered inside bullets)
// loses its type. Give those nested lists their own minted 6-level list style —
@@ -933,6 +975,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
align?: AlignValue;
spaceBefore?: string;
spaceAfter?: string;
indentLeft?: string;
} = {};
if (node.attrs?.lineHeight != null) {
const lhRaw = String(node.attrs.lineHeight);
@@ -947,6 +990,10 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
}
if (node.attrs?.spaceBefore != null) opts.spaceBefore = `${node.attrs.spaceBefore}pt`;
if (node.attrs?.spaceAfter != null) opts.spaceAfter = `${node.attrs.spaceAfter}pt`;
// Left indent → fo:margin-left (odf-kit emits it natively from indentLeft).
if (typeof node.attrs?.indent === 'number' && node.attrs.indent > 0) {
opts.indentLeft = `${node.attrs.indent}cm`;
}
const content = node.content ?? [];
if (node.type === CUST_P) {
@@ -977,9 +1024,14 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
collectListItemStyles(raw, listStyles);
const styledLists = applyListItemStyles(numberedOdt, listStyles);
// Whole-list indent → added to each L# list-style's level margins.
const listIndents: number[] = [];
collectListIndents(raw, listIndents);
const indentedLists = applyListIndents(styledLists, listIndents);
// Rebuild real headings/lists/paragraphs inside table cells. Must run after
// applyListItemStyles (cell lists don't exist yet) and before collapseRunWhitespace.
const styledCells = applyCellBlocks(styledLists, cellBlocks);
const styledCells = applyCellBlocks(indentedLists, cellBlocks);
const rowHeights: (string | null)[] = [];
collectTableRowHeights(raw, rowHeights);
+45 -2
View File
@@ -1,5 +1,5 @@
import { unzipSync, strFromU8 } from 'fflate';
import { StyleResolver, NS, lengthToPt, layerTextProps, type PropMap } from './styleResolver';
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { DEFAULT_ORDERED_TYPE, orderedTypeFromFormat } from '../editor/orderedListTypes';
import { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
@@ -60,6 +60,11 @@ const DEFAULT_FONTS = new Set(['times new roman', 'liberation serif']);
// ODF line spacing multiplies the font's natural line height; see lineHeight.ts.
const LINE_HEIGHT_RATIO = 1.15;
// odf-kit emits each list level at margin-left = level × 1.27cm (label-alignment).
// A top-level list's margin beyond this level-1 base is its whole-list indent.
const LIST_BASE_MARGIN_CM = 1.27;
const LIST_INDENT_EPS_CM = 0.05;
// ---- entry --------------------------------------------------------------------
export function importOdt(bytes: Uint8Array): OdtImportResult {
@@ -240,6 +245,13 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number
if (mt != null && Math.abs(mt - defTop) > EPS_PT) attrs.spaceBefore = snapPt(mt);
if (mb != null && Math.abs(mb - defBottom) > EPS_PT) attrs.spaceAfter = snapPt(mb);
// Left indent → fo:margin-left (cm). Skip lists: their indent lives in the
// list-style level properties, not paraProps. Default 0 is suppressed.
if (kind !== 'list') {
const ml = lengthToPt(paraProps['fo:margin-left']);
if (ml != null && ml > EPS_PT) attrs.indent = Math.round((ml / 72) * 2.54 * 100) / 100;
}
return attrs;
}
@@ -455,7 +467,22 @@ function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, d
}
if (items.length === 0) return null;
if (!ordered) return { type: 'bulletList', content: items };
// Whole-list indent (top level only): margin beyond the level-1 base. Nested
// lists inherit it visually through DOM nesting, so only depth 1 carries it.
let indent: number | null = null;
if (depth === 1) {
const mlCm = listLevelMarginLeftCm(levelDef);
if (mlCm != null) {
const extra = Math.round((mlCm - LIST_BASE_MARGIN_CM) * 100) / 100;
if (extra > LIST_INDENT_EPS_CM) indent = extra;
}
}
if (!ordered) {
const node: Node = { type: 'bulletList', content: items };
if (indent != null) node.attrs = { indent };
return node;
}
const listStyleType = orderedTypeFromFormat(
levelDef!.getAttributeNS(NS.style, 'num-format'),
@@ -464,11 +491,27 @@ function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, d
const attrs: Record<string, unknown> = {};
if (start != null) attrs.start = start;
if (listStyleType !== DEFAULT_ORDERED_TYPE) attrs.listStyleType = listStyleType;
if (indent != null) attrs.indent = indent;
const node: Node = { type: 'orderedList', content: items };
if (Object.keys(attrs).length) node.attrs = attrs;
return node;
}
// Level's fo:margin-left (cm) from its <style:list-level-label-alignment> (the
// label-alignment mode odf-kit/LibreOffice use). null when absent.
function listLevelMarginLeftCm(levelDef: Element | null): number | null {
if (!levelDef) return null;
for (const props of Array.from(levelDef.children)) {
if (props.namespaceURI !== NS.style || props.localName !== 'list-level-properties') continue;
for (const la of Array.from(props.children)) {
if (la.namespaceURI === NS.style && la.localName === 'list-level-label-alignment') {
return lengthToCm(la.getAttributeNS(NS.fo, 'margin-left'));
}
}
}
return null;
}
function listLevelDef(listStyle: Element | null, depth: number): Element | null {
if (!listStyle) return null;
for (const child of Array.from(listStyle.children)) {