Skip to content

v2.2.2

Choose a tag to compare

@Samyssmile Samyssmile released this 17 Jun 20:19

Added

  • The formula plugin's structural palette now covers far more notation (#169). The on-screen math keyboard previously exposed only a thin slice of the bundled converter's capabilities (fractions, scripts, roots, a few operators, partial Greek, a handful of relations, arrows, and matrices). It now offers six new groups and several extended ones, all backed by snippets the converter already understood, so no engine change was needed. New groups: Accents (\vec, \hat, \overline, \dot, \tilde), Functions (\sin, \cos, \tan, \log, \ln, \exp), Sets (\in, \notin, \subset, \subseteq, \cup, \cap, \emptyset, and the number sets ℝ ℕ ℤ ℚ ℂ via \mathbb), Logic (\forall, \exists, \neg, \land, \lor, \implies, \iff), Brackets (auto-sized \left … \right parentheses, brackets, braces, absolute value, norm, floor, and ceiling), and Dots (\cdots, \ldots, \vdots, \ddots). Extended groups: Operators gains double and contour integrals (\iint, \oint) and the derivative symbols \partial and \nabla; Greek now spans the full lowercase and uppercase alphabet (adding ε, ζ, η, κ, ν, ξ, ρ, τ, χ, ψ and the capitals Γ Θ Λ Π Φ Ψ); Relations adds \sim, \cong, and \propto. Every new button carries an English accessible name, the palette keeps its ARIA toolbar and roving-tabindex keyboard model, and all six new group labels are translated across the nine bundled locales. Covered by a new MathPaletteData test that converts every palette snippet through the bundled LaTeX-to-MathML converter and asserts zero parse errors, plus the existing locale-completeness test extended to the new keys.

Fixed

  • Keyboard shortcuts now work on non-Latin keyboard layouts (#176). With a Russian (JCUKEN) layout, command shortcuts such as Ctrl+A, Ctrl+Z, and Ctrl+B did nothing, and Delete appeared broken too because nothing had been selected. The whole keyboard path keyed off event.key, which is layout-dependent: on a Russian layout the physical A key reports event.key = 'ф', the Z key reports 'я', so normalizeKeyDescriptor produced Mod-Ф instead of Mod-A and matched no keymap, and the separate hardcoded built-in fallback for undo, redo, and select-all compared 'я' against 'z' and never fired. The fix adds a physical-key fallback modeled on ProseMirror and CodeMirror: the layout-aware event.key descriptor is still tried first and always wins when it matches, then, only when a command modifier (Ctrl or Cmd) is held and the primary descriptor matched nothing, a second descriptor derived from the layout-independent event.code (the physical US-QWERTY position, for example KeyA to A) is tried. Normal typing, IME composition, and dead keys are never touched, and AltGr combinations (Ctrl+Alt) are explicitly excluded so layouts that compose characters with AltGr are not hijacked. Descriptor logic moved into a new DOM-free, fully unit-tested input/KeyDescriptor.ts module, and the two duplicated keymap dispatch loops in KeyboardHandler were unified behind a single helper that applies the fallback in both normal and read-only mode. This is fundamentally an internationalization and accessibility fix for keyboard operability (WCAG 2.1.1). The physical fallback also covers shifted bindings: when Shift is held it tries both the US-QWERTY base glyph and its shifted glyph, so digit shortcuts such as the heading bindings Mod-Shift-1 to Mod-Shift-6 (which a real browser reports as key = '!', not '1', and which therefore never matched on any layout before) and punctuation shortcuts such as Mod-Shift-> (blockquote) and Mod-Shift-+ / Mod-Shift-_ (font size) now resolve on every layout without changing a single binding descriptor, so tooltips stay correct. Covered by new KeyDescriptor unit tests (Cyrillic mapping, the no-modifier, AltGr, and named-key gates, and the base-plus-shifted digit and punctuation expansion), new KeyboardHandler tests (plugin keymap and built-in resolution on Cyrillic input, the heading Mod-Shift-1 and blockquote Mod-Shift-> and font-size Mod-Shift-+ shifted-glyph repairs, with Latin precedence preserved), and an e2e spec that simulates a Russian layout by dispatching synthetic events with Cyrillic key plus US-QWERTY code and confirms Ctrl+A then Delete clears the document and Ctrl+Z restores it, Ctrl+B bolds, and Ctrl+Shift+. toggles blockquote.

  • Buttons inside custom toolbar popups can now be activated with the keyboard (#171). A keyboard user could focus a button inside a custom popup, such as the formula plugin's structural math palette or its Insert and Cancel actions, by using Tab and the arrow keys, but pressing Enter or Space did nothing, so the on-screen math keyboard was unusable without a pointer (a WCAG 2.1.1 Keyboard gap). The root cause was a split of responsibility: the structural palette is a self-contained roving-tabindex toolbar, like the color grid, but unlike the color grid it did not handle Enter/Space itself and let its arrow keys bubble to the host popup, which then advanced focus a second time. The palette now fully owns its keyboard, matching the color grid: it activates the focused button on Enter/Space and stops every navigation and activation key it handles from bubbling, which also fixes a latent bug where Arrow Down/Up moved the roving focus by two instead of one. For the popup's standalone Insert and Cancel buttons, which run their action on click (with mousedown only guarding text-field focus), the toolbar's shared key handler now replays a complete pointer press (mousedown, then mouseup, then click) instead of dispatching mousedown alone, so it activates both the click convention and the mousedown convention used by the link and image popups, exactly once. Covered by new MathPalette unit tests (Enter/Space activation, single-step navigation, and that handled keys do not bubble while Tab and Escape still do), new ToolbarPopupController unit tests for the click-based and mousedown-based conventions, and a formula e2e test that walks the palette to the integral symbol using Tab and the arrow keys and inserts it with Enter, without a mouse.

  • A checklist item's checked state is now exposed to screen readers (#168) — The checked state was written as aria-checked on the <li>, which carries role="listitem", a role that does not support aria-checked, so assistive technology ignored it and never announced whether an item was checked (a WCAG 4.1.2 Name/Role/Value gap). The visible checkbox was drawn purely as a CSS ::before pseudo-element, so no real element carried the state either. The marker is now a real role="checkbox" element with aria-checked and an accessible label, rendered as the first child of the list item, so the state lives in the accessibility tree and is announced when the caret enters the item. The invalid aria-checked is removed from the <li> (the data-checked attribute that drives the CSS glyph stays). To keep the marker out of the editable text flow it is contenteditable="false" and flagged data-widget; the selection layer now treats data-widget elements as zero width, so the marker never shifts a caret offset or becomes a caret position, and an empty checklist item still places the caret on its editable line rather than before the checkbox. Because the marker is a real element, the toggle click handler now matches it directly and the previous bounding-box geometry and RTL math are gone. Toggling by mouse or by Mod+Enter also announces the new state through the editor's live region, so the action is spoken even though the marker is not focused (WCAG 4.1.3), complementing the keyboard-operability fix in #167. Covered by new unit tests for the marker markup and the announcement, by SelectionSync tests proving a data-widget marker is zero width in offset space (caret placement at text start, mid-text, and in an empty item), and by checklist and cursor e2e: a new live-region assertion confirms a keyboard toggle narrates the checked state (and is not clobbered by the block-type announcer), while the existing cursor suite confirms click, drag, double-click, and arrow navigation offsets are unchanged in a real browser.

  • Checklist items can now be toggled with the keyboard (#167) — A keyboard-only user could create a checklist item but had no way to toggle its checked state: the only runtime toggle was a mousedown handler on the marker click region, and the toggleChecklistItem command was registered but bound to no key, so the checked state was unreachable without a pointer (a WCAG 2.1.1 Keyboard gap). The command is now bound to Mod+Enter (Ctrl+Enter on Windows/Linux, Cmd+Enter on macOS) while the caret is inside a checklist item. The binding declines on non-checklist blocks, so it falls through cleanly to other Mod+Enter handlers (such as the code-block insert-after shortcut). It is registered at navigation priority so it stays reachable in read-only mode when interactiveCheckboxes is enabled, giving keyboard users the same toggle access as a mouse click. The screen-reader announcement of the checked state (aria-checked currently sits on the role="listitem" element, which does not expose it) is a separate concern, tracked apart from this keyboard-operability fix. Covered by new unit tests that drive the toggle through the registered key binding (not the command directly), assert the navigation-priority registration, confirm the read-only interactive path, and verify the binding is absent when the checklist type is disabled.

  • Pasting a table into a table cell no longer creates a nested table (#166) — Pasting table HTML (for example a table copied from a spreadsheet or a web page) while the caret sat inside a table cell inserted a full table into that cell, producing a schema-invalid document, because table_cell.content.allow does not list table. The table-aware paste path (PasteHTMLHandler.handleDocumentPaste) resolved the insertion parent as the caret's immediate container and inserted there with no check against the container's allowed content. It now validates the parsed blocks against the target container's NodeSpec.content.allow through the existing canContain rule, and when they do not fit (a table in a cell) it escapes the insertion to the document root, placing the pasted table right after the outer table. Containers keep their own content (the cell's paragraph is untouched), and block types the container does allow (an image pasted into a cell) still land inside it. The two new helpers canContainerHoldBlocks and resolveRootEscapeContext live in commands/BlockInsertion.ts beside the existing insertion-context utilities. Covered by a new paste regression test and unit tests for both helpers, with the existing image-into-cell e2e confirming allowed blocks are not escaped.

  • Pasting internally-copied content into a table cell now honors the cell's content rule too (#173). The #166 fix guarded only the external-HTML paste path. Content copied inside the editor takes two different routes that bypass that path and carried the same missing check, so they could still nest a schema-invalid block in a table cell. Copying a code block (a leaf block not in table_cell.content.allow) as a text selection and pasting it into a cell went through the rich-block path (PasteRichBlockHandler.handleRichPaste), which inserted the block straight into the cell. Copying a node-selected void such as a display formula and pasting it into a cell went through the internal block path (handleBlockPaste), which did the same. (A copied table never reached either path: composite selections skip the internal rich payload and round-trip as HTML, so #166 already covers them.) Both internal-copy cell branches now run the same canContainerHoldBlocks guard the HTML path uses, and when a block does not fit they escape the insertion to the document root after the outer table through the shared resolveRootEscapeContext, leaving the cell's own content untouched. Block types the cell does allow (an image, a paragraph) still land inside it. Covered by new paste regression tests for both internal-copy paths (escape on a disallowed block, nest on an allowed one).

  • Pasting multi-block clipboard content now splices at the caret instead of appending whole blocks (#165) — When the internal clipboard held more than one block (any cross-block copy or cut), pasting at a caret inside a non-empty block appended the clipboard's blocks as whole siblings after the caret block instead of splitting it at the offset and merging the boundary fragments. Cutting a selection that crossed a block boundary and pasting it straight back therefore did not restore the document: ["Hello world", "Foo bar"] cut from world..Foo and pasted back produced ["Hello bar", "world", "Foo"]. Select-all cut/paste and single-block (within one paragraph) paste were unaffected. The internal-clipboard path (PasteRichBlockHandler.handleRichPaste) had its own whole-block insertion that ignored the caret offset, bypassing the correct split-insert-merge logic in PasteCommand.pasteMultiBlock that the HTML/plain-text paste paths already use. A collapsed caret in a non-empty block at the document root now converts the rich blocks to a ContentSlice (via a new shared richBlocksToSlice) and routes through pasteSlice, so it splits the caret block and merges the first and last clipboard fragments back in. Empty anchors keep the whole-block path (which removes the empty anchor and drops the clipboard verbatim, a more faithful round-trip that preserves block types), and nested anchors (table cell, blockquote), node selections, and gap cursors keep the parent-path-aware whole-block insertion. A latent defect this exposed in pasteMultiBlock is fixed alongside it: the block was retyped to the first slice's type before the split, so the tail wrongly inherited that type (pasting a heading-first multi-block slice mid-paragraph left the tail a heading). The split now happens before the prefix is retyped, so the tail inherits the caret block's original type and a neutral paragraph boundary no longer strips a non-paragraph caret block (list item, heading) of its type. Covered by new unit tests (cross-block splice round-trip, middle-block insertion, container nesting, and the split-fragment type/attrs guard) and the existing cut/paste e2e regressions.

  • Deleting an inline node (formula or hard break) can now be undone (#162) — Removing an inline math formula or a hard break with Backspace, forward Delete, word delete, or a range selection deleted it correctly but undo did not bring it back, so the content (including a formula's LaTeX source) was lost permanently. A plain character delete and a node-selected display formula delete both undid fine; only the inline text-delete path was affected. The cause was the delete-inversion payload: all of these gestures route through TransactionBuilder.deleteTextAt, which captured the deleted content with a text-only walker (getBlockSegmentsInRange passed only an onText visitor), so inline nodes in the range were dropped and the inverse insertText had nothing to restore. The same omission sat in the undo/redo rebase path (StepMapping.mapDeleteText). The fix unifies the step payload on the existing ContentSegment type (text with marks, or an atomic inline node): DeleteTextStep.deletedSegments and InsertTextStep.segments now carry content segments, the delete paths capture them via getBlockContentSegmentsInRange, and insertSegmentsIntoInlineContent re-materializes inline nodes on undo. A second defect in the same area is fixed alongside it: an inserted segment run that contains an inline node is wider than its plain-text length (an inline node is width 1 but contributes nothing to the string), so redoing a mixed text+inline range delete removed only the inline node and left the text behind, and the restored cursor could land at the wrong offset. The inverse delete range (invertInsertText) and the forward position map (getMapInsertText) now both compute width from the segments through a shared insertTextStepWidth helper. The text-only delete/undo path and clipboard paste are unaffected (segment width equals text length when no inline nodes are present). The now-unused text-only getBlockSegmentsInRange helper was removed. Covered by new unit tests for Backspace, Delete, hard-break, and mixed-range undo/redo, plus focused guards on the insert-width accounting.

  • A formula's font size now survives HTML export and external paste (#160) — A formula's font size (set via the formula editor's Size control or the toolbar Font Size control) is stored as a node fontSize attribute, but it was never written into the HTML produced by getContentHTML(), and the parse side never read it back, so the size was lost on an HTML round-trip and on copy/paste to or from an external app (both of which go through HTML). It still survived undo/redo, the JSON model format, and copy/paste inside the editor (the internal block clipboard). The size is now serialized as the native MathML mathsize attribute on the <math> root and read back from there, in both the inline and display math node specs, so a sized formula round-trips through getContentHTML() / setContentHTML() and through the CSS-class export mode (mathsize is an attribute, not an inline style, so it is never stripped). Because foreign tools such as KaTeX, MathJax, and Word also express formula size as mathsize, pasting sized math from an external app now preserves the size too: the standalone-math paste interceptor lifts mathsize into the fontSize attribute. The size lives solely in fontSize; the canonical stored <math> carries no mathsize (single source of truth, mirroring how data-block-id is stripped). Covered by new unit tests for the withMathsize / stripMathsize helpers and the shared FormulaSerialization glue, plus e2e regressions for the export round-trip and external paste.

  • Typing right after inserting an image or display formula no longer adds a stray empty paragraph (#163) — After inserting a void block the selection rests on it as a node selection, and the void already owns a trailing empty paragraph as its escape line (#152/#158). Typing the first character then created a fresh paragraph for the text and left the original trailing paragraph orphaned and empty below it, so an empty editor went to [image, paragraph, paragraph] (and [math_display, paragraph, paragraph]) instead of [image, paragraph]. The same defect was reachable by clicking a void block (which also produces a node selection) and typing, and by pressing Enter while a void was selected. The node-selection text path (insertTextAfterNodeSelection) and Enter path (insertParagraphAfterNodeSelection) now reuse the void's existing trailing empty paragraph through a shared trailingEscapeParagraph helper: typing inserts into that paragraph and Enter places the cursor in it, so no second blank line stacks up. They fall back to creating a paragraph only when there is no reusable escape line (a non-empty trailing sibling, a void inside a table cell, or the void as the last block). The emptiness test reuses the inline-aware isEmptyParagraph from BlockInsertion (a paragraph holding only an inline formula has width > 0 and is never treated as blank), so the node selection that lets you immediately resize, edit, or delete a freshly inserted object is preserved. Covered by new unit tests (command-level typing and Enter for image and display-formula voids, a real image-insert-then-type path, plus controls for the non-empty-sibling and last-block fallbacks) and an e2e regression that inserts an image and types through real user gestures.

  • Pasting two or more standalone formulas now inserts all of them instead of silently dropping the rest (#159) — When the clipboard held several formulas separated only by whitespace (two adjacent equations copied from a KaTeX, MathJax, or native MathML page), the formula paste interceptor claimed the paste but inserted only the first <math>, so the remaining formulas were lost. The standalone guard isStandaloneMathHtml correctly fired for the multi-formula case (after removing every <math> and aria-hidden subtree, only whitespace remained), but the interceptor then read a single parseFragment(html).querySelector('math') and ignored the rest. The interceptor now collects every canonical <math> in document order through a new collectStandaloneMathElements helper that first drops aria-hidden="true" subtrees, so KaTeX's visual layer and MathJax's assistive <math> duplicate are excluded and each equation is counted exactly once (no double insertion). All extracted formulas are built into nodes and inserted in a single transaction: an all-inline run joins the text flow, and a run containing any display formula is laid out as blocks. The block path reuses a generalized insertBlockObjectsOnOwnLines primitive (the existing single-object insertBlockObjectOnOwnLine now delegates to it), so the empty-paragraph anchor consumption (#152) and void-anchor trailing reuse (#158) rules still apply to the whole run. Only external pastes are affected; copying within the editor uses the internal block clipboard and never ran this interceptor. Covered by new unit tests for the math-collection dedup and the multi-formula transaction builders, plus e2e paste regressions for inline, display, and MathJax payloads.

  • Inserting a block object right after an image or display formula no longer drops the insert or leaves a blank line (#158) — After inserting an image or display formula the selection rests on that void block as a node selection. Inserting the next object right away exposed two defects sharing that root cause. First, insertHorizontalRule and insertTable bailed out on any non-text selection (if (!isTextSelection(sel)) return false;), so clicking "Insert table" or "Insert horizontal rule" while a void block was selected silently discarded the action and the document stayed [image, paragraph]. Both commands now resolve the anchor through getSelectedBlockId, which handles node selections the same way the image and formula commands already do, so the object lands after the selected block. Second, a void object already owns a trailing empty paragraph as its escape line, but insertBlockObjectOnOwnLine always appended a fresh trailing paragraph and only consumed a blank-line anchor, so a second object stacked up a stray blank line ([image, image, paragraph, paragraph]). The primitive now detects a void anchor already followed by an empty paragraph and reuses that paragraph as the new object's trailing line instead of appending another, so the result is [image, image, paragraph]. Covered by new unit tests across image, table, horizontal rule, and display formula insertion.

  • Inserting a video right after an image, display formula, or another video no longer leaves a stray blank line — A video already owns a trailing empty paragraph as its escape line, but insertVideoAtRoot hand-rolled its own root insertion and always appended a fresh trailing paragraph, so inserting a video after a node-selected void block stacked a second one ([image, video, paragraph, paragraph]). Video insertion now routes through the same insertBlockObjectOnOwnLine primitive the image and formula plugins use, so it reuses an existing void anchor's trailing paragraph and consumes an empty-paragraph anchor instead of leaving a leading blank line, matching image behaviour (#152). This resolves the same node-selected-void root cause as #158 for the video plugin, which the original fix did not reach because video did not share the primitive. Covered by new unit tests.

Internal

  • Unit test suite cleanup. Removed 9 redundant unit tests, each a byte-identical or strict-subset duplicate of a surviving test in the same file (DocumentSerializer, CaretNavigation, Platform, CodeBlockPlugin, MathAlphabet, XmlDetector, FontSizePlugin, FontPlugin, TextDirectionPlugin). No behaviour coverage was lost and the suite stays green.
  • E2E test suite cleanup. Removed the redundant print-font e2e spec (3 tests). Its assertions only checked CSS property-name presence in the print HTML string and exercised no real-browser behaviour, so the same coverage already exists, more thoroughly, in the PrintServiceImpl, FullPreset, and PrintPlugin unit tests.