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:
1 parent
78b732ac44
commit
a87e7a4ada
6 files changed
+92
-24
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user