diff --git a/src/lib/editor/CLAUDE.md b/src/lib/editor/CLAUDE.md index ab5a904..e86cb0b 100644 --- a/src/lib/editor/CLAUDE.md +++ b/src/lib/editor/CLAUDE.md @@ -24,7 +24,9 @@ the tooltips (via `withShortcut`, which localizes Ctrl/Shift/Alt and swaps in - **`Mod-Alt-` 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 diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 06b5f50..b4d4edc 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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, diff --git a/src/lib/editor/extensions/paragraphStyle.ts b/src/lib/editor/extensions/paragraphStyle.ts index 3683c3b..8c75844 100644 --- a/src/lib/editor/extensions/paragraphStyle.ts +++ b/src/lib/editor/extensions/paragraphStyle.ts @@ -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) => + 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) => - 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. diff --git a/src/lib/editor/extensions/shortcuts.ts b/src/lib/editor/extensions/shortcuts.ts index 2f1371a..fcaa17f 100644 --- a/src/lib/editor/extensions/shortcuts.ts +++ b/src/lib/editor/extensions/shortcuts.ts @@ -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(); diff --git a/src/lib/styles/CLAUDE.md b/src/lib/styles/CLAUDE.md index 731e109..4711bf6 100644 --- a/src/lib/styles/CLAUDE.md +++ b/src/lib/styles/CLAUDE.md @@ -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). diff --git a/tests/unit/heading-style-name.test.ts b/tests/unit/heading-style-name.test.ts new file mode 100644 index 0000000..cab0ca4 --- /dev/null +++ b/tests/unit/heading-style-name.test.ts @@ -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(); + }); +});