fix: a heading's style name never lands on a plain paragraph

styleName was kept on split, so Enter at the end of a heading left a paragraph
carrying it: drawn like a heading, absent from the navigator's outline, exported
as text:p. It is no longer kept there. setParagraphStyle also missed the name
whenever it switched the node type — a following updateAttributes looks for the
new type in the pre-chain state — so it now rides along with setNode. Levels 2–6
never reached that command at all: the digit class built from MAX_HEADING_LEVEL
(10) read as [0-1], so Heading's own Mod-Alt-N took the key and set a level with
no style; that keymap is dropped, running ahead of every plugin binding.
This commit is contained in:
Steffen Becker committed 2026-08-19 18:41:21 +02:00
1 parent 78b732ac44
commit a87e7a4ada
6 files changed
+92 -24

No files matched your search

+3 -1
View File
@@ -24,7 +24,9 @@ the tooltips (via `withShortcut`, which localizes Ctrl/Shift/Alt and swaps in
- **`Mod-Alt-<digit>` can't go through the keymap**: Windows reads Ctrl+Alt as AltGr, so
`event.key` is layout-dependent (German AltGr+2 = `²`) and prosemirror-keymap skips its
keyCode fallback for exactly that modifier pair. Those seven run in `addProseMirrorPlugins`
off `event.code` (`Digit0`–`Digit6`) instead.
off `event.code` (`Digit0`–`Digit6`) instead — and **Heading's own `Mod-Alt-N` is dropped**
(`extensions.ts`): every keymap plugin runs ahead of every `addProseMirrorPlugins` one, whatever
the extension priority, and `toggleHeading` sets the level without the heading's named style.
- **F3 is shared**: AutoText expands a shortcut at the caret (both word processors' key), and the app's find-next runs only where it did not — the window handler bails on `defaultPrevented`, which the editor's keymap sets when a binding took the key.
- Deliberate resolutions: `Mod-m` stays LibreOffice's *clear formatting* (Word's
increase-indent is Tab); `Ctrl+1/2/5` are Word's **line spacing**, so headings are
+4 -1
View File
@@ -175,7 +175,10 @@ export const extensions = [
TextDirection,
Indent,
TabStops,
Heading.configure({ levels: HEADING_LEVELS as Level[] }),
// Its own Mod-Alt-N is dropped: a keymap plugin runs ahead of every plugin from
// addProseMirrorPlugins, so it would beat the code-based binding in shortcuts.ts
// and set the level without the heading's named style.
Heading.extend({ addKeyboardShortcuts: () => ({}) }).configure({ levels: HEADING_LEVELS as Level[] }),
BulletList,
OrderedList,
ListItem,
+20 -17
View File
@@ -35,18 +35,18 @@ export const ParagraphStyle = Extension.create<{ types: string[]; sheet: () => S
},
addGlobalAttributes() {
const styleName = {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-style') || null,
renderHTML: (attributes: Record<string, unknown>) =>
attributes.styleName ? { 'data-style': String(attributes.styleName) } : {},
};
return [
{
types: this.options.types,
attributes: {
styleName: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-style') || null,
renderHTML: (attributes: Record<string, unknown>) =>
attributes.styleName ? { 'data-style': String(attributes.styleName) } : {},
},
},
},
{ types: this.options.types.filter((t) => t !== 'heading'), attributes: { styleName } },
// Enter at the end of a heading starts a body paragraph, as in both word
// processors — carrying the name over would leave a paragraph that looks like a
// heading and is none (no outline entry, no text:h on export).
{ types: ['heading'], attributes: { styleName: { ...styleName, keepOnSplit: false } } },
];
},
@@ -54,13 +54,16 @@ export const ParagraphStyle = Extension.create<{ types: string[]; sheet: () => S
return {
// A heading style also switches the node type (its outline level); any other
// style turns a heading back into a paragraph.
// The name rides along with the type switch: setNode copies the block's own attrs,
// and a later updateAttributes looks for the *new* type in the pre-chain state,
// where the block still has the old one — leaving the previous name in place.
setParagraphStyle: (name: string) => ({ editor, chain }) => {
const style = this.options.sheet().paragraph[name];
const level = style?.outlineLevel;
const run = chain().focus();
if (level && editor.schema.nodes.heading) run.setNode('heading', { level });
else if (!level) run.setNode('paragraph');
return run.updateAttributes(level ? 'heading' : 'paragraph', { styleName: name }).run();
const level = this.options.sheet().paragraph[name]?.outlineLevel;
const heading = !!level && !!editor.schema.nodes.heading;
return chain()
.focus()
.setNode(heading ? 'heading' : 'paragraph', heading ? { level, styleName: name } : { styleName: name })
.run();
},
// Word/LibreOffice Ctrl+M: drop hard formatting, keep the style assignment.
+3 -4
View File
@@ -4,12 +4,10 @@ import type { Mark } from '@tiptap/pm/model';
import { DEFAULT_SHORTCUTS, type ShortcutId } from '../shortcuts';
import { FONT_SIZES, blockFontSize, coversWholeBlock } from '../../utils/fontSize';
import { headingStyleName } from '../../styles/styleSheet';
import { MAX_HEADING_LEVEL } from '../../export/odt';
import { DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../../utils/dateTime';
// The Word/LibreOffice shortcuts that aren't already a TipTap default, bound from
// the central table. priority 1000 so Mod-Alt-N beats Heading's toggleHeading and
// Mod-Shift-b beats Bold's Mod-B alias.
// the central table. priority 1000 so Mod-Shift-b beats Bold's Mod-B alias.
type Binding = (editor: Editor) => boolean;
@@ -105,7 +103,8 @@ export const Shortcuts = Extension.create<{ body: boolean }>({
// skips its keyCode fallback for exactly this combination.
handleKeyDown(_view, event) {
if (!(event.ctrlKey || event.metaKey) || !event.altKey || event.shiftKey) return false;
const digit = new RegExp(`^Digit([0-${MAX_HEADING_LEVEL}])$`).exec(event.code);
// Digit0–Digit6: 0 is Standard, 1–6 the levels both word processors bind.
const digit = /^Digit([0-6])$/.exec(event.code);
if (!digit) return false;
const level = Number(digit[1]);
event.preventDefault();
+6 -1
View File
@@ -25,7 +25,12 @@ holds it as a reactive singleton persisted to `edentext-styles` (same shape as `
- **Assignment**: the `ParagraphStyle` extension adds a global `styleName` attr on paragraph/heading,
rendered as `data-style`. `setParagraphStyle(name)` switches the node type when the style has an
`outlineLevel` (heading) and **keeps hard formatting**, as in Word/LibreOffice;
`outlineLevel` (heading) and **keeps hard formatting**, as in Word/LibreOffice. The name rides
along with that type switch: `setNode` copies the block's own attrs, and a following
`updateAttributes` would look for the new type in the pre-chain state and set nothing — a
paragraph left carrying a heading's name looks like a heading and is none (no outline entry).
The heading's `styleName` is the one attr **not** kept on split, so Enter at the end of one
starts a body paragraph;
`clearDirectFormatting()` (Ctrl+M, also the ToolbarExpanded eraser button) drops marks and the
style-governed block attrs but keeps the style and hyperlinks. `blockStyleName(node)` resolves a
block's style (own → heading level → Standard).
+56
View File
@@ -0,0 +1,56 @@
// A heading is a named style plus the node type, and the two must never drift apart:
// a paragraph carrying "Heading 2" looks like a heading, is none, and drops out of the
// navigator's outline. Drives a real body editor.
import { describe, it, expect } from 'vitest';
import { Editor } from '@tiptap/core';
import { extensions } from '../../src/lib/editor/extensions';
import { outline } from '../../src/lib/editor/extensions/outline';
type N = any;
function makeEditor(content: N) {
const el = document.createElement('div');
document.body.appendChild(el);
return new Editor({ element: el, extensions, content });
}
const p = (text: string) => ({ type: 'paragraph', content: [{ type: 'text', text }] });
const blocks = (editor: Editor) =>
(editor.getJSON().content as N[]).map((n) => `${n.type}:${n.attrs?.level ?? ''}:${n.attrs?.styleName ?? ''}`);
// Ctrl+Alt+N runs off event.code, not the keymap — so does this.
const press = (editor: Editor, code: string, init: KeyboardEventInit = {}) =>
editor.view.dom.dispatchEvent(new KeyboardEvent('keydown', { code, bubbles: true, cancelable: true, ...init }));
describe('heading style name', () => {
it('Ctrl+Alt+N applies the level and its style, 1 through 6', () => {
const editor = makeEditor({ type: 'doc', content: [p('x')] });
for (const level of [1, 2, 3, 4, 5, 6]) {
editor.commands.focus('end');
press(editor, `Digit${level}`, { ctrlKey: true, altKey: true });
expect(blocks(editor)).toEqual([`heading:${level}:Heading ${level}`]);
}
press(editor, 'Digit0', { ctrlKey: true, altKey: true });
expect(blocks(editor)).toEqual(['paragraph::Standard']);
editor.destroy();
});
it('Enter at the end of a heading starts a body paragraph', () => {
const editor = makeEditor({ type: 'doc', content: [{ type: 'heading', attrs: { level: 2, styleName: 'Heading 2' }, content: [{ type: 'text', text: 'A' }] }] });
editor.commands.focus('end');
editor.commands.splitBlock();
expect(blocks(editor)).toEqual(['heading:2:Heading 2', 'paragraph::']);
editor.destroy();
});
it('lists two consecutive headings of one level', () => {
const editor = makeEditor({ type: 'doc', content: [p('x')] });
editor.commands.focus('end');
press(editor, 'Digit2', { ctrlKey: true, altKey: true });
editor.commands.insertContent('A');
editor.commands.splitBlock();
press(editor, 'Digit2', { ctrlKey: true, altKey: true });
editor.commands.insertContent('B');
expect(outline(editor.state.doc).map((e) => `${e.level}:${e.text}`)).toEqual(['2:xA', '2:B']);
editor.destroy();
});
});