fix: list indent buttons

This commit is contained in:
Steffen Becker committed 2026-06-21 11:16:59 +02:00
1 parent 87b10b0059
commit 8e8b93bccf
5 files changed
+134 -58

No files matched your search

+6 -36
View File
@@ -12,6 +12,7 @@
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
import { listContext } from './indent';
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), hfActive = null, onEditZone, onDebugDump }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; hfDistances?: HfDistances; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void } = $props();
@@ -650,45 +651,14 @@
}
// --- 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).
// In a list, step the list point one level via indentListForward/Backward (the same
// commands the Tab keymap uses — see indent.ts); outside a list, step the paragraph's
// left indent.
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();
}
if (listContext(editor.state).inList) {
(dir === 1 ? c.indentListForward() : c.indentListBackward()).run();
} else {
(dir === 1 ? c.indentMore() : c.indentLess()).run();
}
+4 -4
View File
@@ -18,10 +18,10 @@ function buildDecorations(doc: PmNode): DecorationSet {
if (!node.isText) return;
const text = node.text ?? '';
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 32) {
const from = pos + i;
decos.push(Decoration.inline(from, from + 1, { class: 'pm-space-mark' }));
}
const code = text.charCodeAt(i);
if (code !== 32 && code !== 9) continue;
const from = pos + i;
decos.push(Decoration.inline(from, from + 1, { class: code === 9 ? 'pm-tab-mark' : 'pm-space-mark' }));
}
});
return decos.length ? DecorationSet.create(doc, decos) : DecorationSet.empty;
+72 -6
View File
@@ -1,16 +1,37 @@
import { Extension, type CommandProps } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import type { EditorState } from '@tiptap/pm/state';
// 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.
// Left indent in cm: maps to fo:margin-left on paragraphs/headings; on lists it shifts
// the whole list block (list items nest instead via sinkListItem/liftListItem).
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
export type ListIndentContext = { inList: boolean; wholeList: boolean; listIndent: number; nested: boolean };
// Shared by the Tab keymap and toolbar indent buttons so both apply identical rules.
export function listContext(state: EditorState): ListIndentContext {
const { empty } = state.selection;
const head = state.selection.$from;
const tail = 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, nested: false };
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);
const nested = listDepth > 0 && head.node(listDepth - 1).type.name === 'listItem';
return { inList: true, wholeList, listIndent, nested };
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
indent: {
@@ -18,6 +39,8 @@ declare module '@tiptap/core' {
indentLess: () => ReturnType;
indentListMore: () => ReturnType;
indentListLess: () => ReturnType;
indentListForward: () => ReturnType;
indentListBackward: () => ReturnType;
unsetIndent: () => ReturnType;
};
}
@@ -36,6 +59,10 @@ function parseIndent(value: string | null): number | null {
export const Indent = Extension.create({
name: 'indent',
// >100 so Tab/Shift-Tab beat ListItem's built-in sink/lift — TipTap priority-sorts
// after reversing extension order, so equal priority would let ListItem win.
priority: 1000,
addOptions() {
return {
types: ['paragraph', 'heading'] as string[],
@@ -113,8 +140,47 @@ export const Indent = Extension.create({
indentLess: () => step(-INDENT_STEP_CM),
indentListMore: () => stepList(INDENT_STEP_CM),
indentListLess: () => stepList(-INDENT_STEP_CM),
indentListForward: () => ({ state, commands }) => {
const ctx = listContext(state);
if (!ctx.inList) return false;
return ctx.wholeList ? commands.indentListMore() : commands.sinkListItem('listItem');
},
indentListBackward: () => ({ state, commands }) => {
const ctx = listContext(state);
if (!ctx.inList) return false;
if (ctx.listIndent > 0) return commands.indentListLess();
if (ctx.nested) return commands.liftListItem('listItem');
return false;
},
unsetIndent: () => ({ commands }) =>
[...types, ...listTypes].map((type) => commands.resetAttributes(type, 'indent')).some((r) => r),
};
},
// In a list: Tab/Shift-Tab call indentListForward/Backward.
// Outside a list: Tab inserts \t, Shift-Tab steps paragraph indent.
// In a table cell: returns false so Table's own Tab navigates between cells.
addKeyboardShortcuts() {
const inCell = () => this.editor.isActive('tableCell') || this.editor.isActive('tableHeader');
return {
Tab: () => {
if (inCell()) return false;
if (listContext(this.editor.state).inList) {
this.editor.commands.indentListForward();
return true;
}
this.editor.commands.insertContent('\t');
return true;
},
'Shift-Tab': () => {
if (inCell()) return false;
if (listContext(this.editor.state).inList) {
this.editor.commands.indentListBackward();
return true;
}
this.editor.commands.indentLess();
return true;
},
};
},
});
+28 -11
View File
@@ -41,14 +41,18 @@ const DEFAULT_LINE_HEIGHT = 1; // must match line-height multiplier default in
const SEG = '';
// Sentinel for hardBreak nodes: replaceHardBreaks turns them into text so every
// odf-kit path (plain paragraphs, list items, cells) carries them; applyLineBreaks
// odf-kit path (plain paragraphs, list items, cells) carries them; applyInlineSentinels
// swaps the char for <text:line-break/> in content.xml. U+E001 (SEG is U+E000).
const LBR = '';
// Sentinel wrapping the page-count digits in header/footer runs; applyHfPostProcess
// Sentinel wrapping page-count digits in header/footer runs; applyHfPostProcess
// rewrites it to <text:page-count> in styles.xml. U+E002 (SEG/LBR are E000/E001).
const PGC = '';
// Sentinel for tab chars: \t collapses to a space in ODF, so replaceTabs swaps
// each tab for this before odf-kit serializes; applyInlineSentinels → <text:tab/>. U+E003.
const TAB = '';
// Automatic list styles minted for in-cell lists (see applyCellBlocks). odf-kit's
// CellBuilder is run-based, so a list inside a cell can't reference a list style it
// generates for top-level lists — we inject our own, mirroring its buildListStyle.
@@ -112,7 +116,7 @@ function injectCustomTypes(node: TiptapNode, inContainer = false): TiptapNode {
}
// Turn hardBreak nodes into LBR-sentinel text nodes so they ride through every
// odf-kit serialization path as plain run text; applyLineBreaks rewrites the char
// odf-kit serialization path as plain run text; applyInlineSentinels rewrites the char
// to <text:line-break/> in content.xml afterwards.
function replaceHardBreaks(node: TiptapNode): TiptapNode {
if (!node.content?.length) return node;
@@ -122,6 +126,17 @@ function replaceHardBreaks(node: TiptapNode): TiptapNode {
};
}
// Swap each tab character in run text for the TAB sentinel so it survives odf-kit
// serialization (a literal \t would collapse to a space); applyInlineSentinels
// rewrites it to <text:tab/> in content.xml afterwards.
function replaceTabs(node: TiptapNode): TiptapNode {
if (node.type === 'text' && node.text?.includes('\t')) {
return { ...node, text: node.text.split('\t').join(TAB) };
}
if (!node.content?.length) return node;
return { ...node, content: node.content.map(replaceTabs) };
}
// Mirror odf-kit's normalizeLineHeight (content.js): a number is a multiplier
// (1.5 → "150%"); a string with a unit passes through ("18pt" → "18pt").
function normalizeLineHeight(lh: number | string): string {
@@ -909,17 +924,19 @@ function collapseRunWhitespace(odtBytes: Uint8Array): Uint8Array {
return rezipOdt(files);
}
// Rewrite the LBR sentinels replaceHardBreaks planted into real <text:line-break/>
// elements (valid both as bare paragraph text and inside <text:span>).
function applyLineBreaks(odtBytes: Uint8Array): Uint8Array {
// Rewrite the inline sentinels planted before serialization into real ODF elements:
// LBR (from replaceHardBreaks) → <text:line-break/>, TAB (from replaceTabs) →
// <text:tab/>. Both are valid as bare paragraph text and inside <text:span>.
function applyInlineSentinels(odtBytes: Uint8Array): Uint8Array {
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
const content = strFromU8(contentBytes);
if (!content.includes(LBR)) return odtBytes;
let content = strFromU8(contentBytes);
if (!content.includes(LBR) && !content.includes(TAB)) return odtBytes;
files['content.xml'] = strToU8(content.split(LBR).join('<text:line-break/>'));
content = content.split(LBR).join('<text:line-break/>').split(TAB).join('<text:tab/>');
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
@@ -936,7 +953,7 @@ export type HfExport = {
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null): Promise<Uint8Array> {
const raw = replaceHardBreaks(docJson);
const raw = replaceTabs(replaceHardBreaks(docJson));
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
// Distance from the page edge to the header (top) / footer (bottom). Becomes the
@@ -1048,7 +1065,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const styledRows = applyTableRowHeights(styledCells, rowHeights);
const cleaned = collapseRunWhitespace(styledRows);
const withBreaks = applyLineBreaks(cleaned);
const withBreaks = applyInlineSentinels(cleaned);
const withStyles = rewriteStylesXml(withBreaks, language ?? null);
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist);
}
+24 -1
View File
@@ -147,6 +147,11 @@
margin-bottom: 0.212cm;
}
/* Tab stops every 1.25cm, matching ODF's default tab interval. */
.paper .tiptap {
tab-size: 1.25cm;
}
.paper .tiptap h1,
.paper .tiptap h2,
.paper .tiptap h3 {
@@ -378,6 +383,23 @@
user-select: none;
}
/* Tab marks: a → arrow overlaid at the start of the tab's width (the tab char
itself supplies the gap), mirroring Word/LibreOffice. */
.paper.show-formatting-marks .tiptap .pm-tab-mark {
position: relative;
}
.paper.show-formatting-marks .tiptap .pm-tab-mark::before {
content: '→';
position: absolute;
left: 0.1em;
top: 0;
color: var(--color-primary);
opacity: 0.5;
pointer-events: none;
user-select: none;
}
/* Spell-check: red wavy underline under misspelled words (drawn by spellCheck.ts).
skip-ink:none keeps the wave continuous under descenders. */
.tiptap .pm-spell-error {
@@ -432,7 +454,8 @@
.paper.show-formatting-marks .tiptap h2::after,
.paper.show-formatting-marks .tiptap h3::after,
.paper.show-formatting-marks .tiptap li > p::after,
.paper.show-formatting-marks .tiptap .pm-space-mark::before {
.paper.show-formatting-marks .tiptap .pm-space-mark::before,
.paper.show-formatting-marks .tiptap .pm-tab-mark::before {
content: none;
}
}