v2.2.2
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 … \rightparentheses, 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\partialand\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 newMathPaletteDatatest 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 reportsevent.key = 'ф', the Z key reports'я', sonormalizeKeyDescriptorproducedMod-Фinstead ofMod-Aand 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-awareevent.keydescriptor 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-independentevent.code(the physical US-QWERTY position, for exampleKeyAtoA) 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-testedinput/KeyDescriptor.tsmodule, and the two duplicated keymap dispatch loops inKeyboardHandlerwere 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 bindingsMod-Shift-1toMod-Shift-6(which a real browser reports askey = '!', not'1', and which therefore never matched on any layout before) and punctuation shortcuts such asMod-Shift->(blockquote) andMod-Shift-+/Mod-Shift-_(font size) now resolve on every layout without changing a single binding descriptor, so tooltips stay correct. Covered by newKeyDescriptorunit tests (Cyrillic mapping, the no-modifier, AltGr, and named-key gates, and the base-plus-shifted digit and punctuation expansion), newKeyboardHandlertests (plugin keymap and built-in resolution on Cyrillic input, the headingMod-Shift-1and blockquoteMod-Shift->and font-sizeMod-Shift-+shifted-glyph repairs, with Latin precedence preserved), and an e2e spec that simulates a Russian layout by dispatching synthetic events with Cyrillickeyplus US-QWERTYcodeand 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(withmousedownonly guarding text-field focus), the toolbar's shared key handler now replays a complete pointer press (mousedown, thenmouseup, thenclick) instead of dispatchingmousedownalone, so it activates both theclickconvention and themousedownconvention used by the link and image popups, exactly once. Covered by newMathPaletteunit tests (Enter/Space activation, single-step navigation, and that handled keys do not bubble while Tab and Escape still do), newToolbarPopupControllerunit 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-checkedon the<li>, which carriesrole="listitem", a role that does not supportaria-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::beforepseudo-element, so no real element carried the state either. The marker is now a realrole="checkbox"element witharia-checkedand 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 invalidaria-checkedis removed from the<li>(thedata-checkedattribute that drives the CSS glyph stays). To keep the marker out of the editable text flow it iscontenteditable="false"and flaggeddata-widget; the selection layer now treatsdata-widgetelements 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 byMod+Enteralso 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 adata-widgetmarker 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
mousedownhandler on the marker click region, and thetoggleChecklistItemcommand 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 toMod+Enter(Ctrl+Enteron Windows/Linux,Cmd+Enteron macOS) while the caret is inside a checklist item. The binding declines on non-checklist blocks, so it falls through cleanly to otherMod+Enterhandlers (such as the code-block insert-after shortcut). It is registered at navigation priority so it stays reachable in read-only mode wheninteractiveCheckboxesis enabled, giving keyboard users the same toggle access as a mouse click. The screen-reader announcement of the checked state (aria-checkedcurrently sits on therole="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.allowdoes not listtable. 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'sNodeSpec.content.allowthrough the existingcanContainrule, 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 helperscanContainerHoldBlocksandresolveRootEscapeContextlive incommands/BlockInsertion.tsbeside 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 samecanContainerHoldBlocksguard 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 sharedresolveRootEscapeContext, 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 fromworld..Fooand 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 inPasteCommand.pasteMultiBlockthat 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 aContentSlice(via a new sharedrichBlocksToSlice) and routes throughpasteSlice, 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 inpasteMultiBlockis 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 (getBlockSegmentsInRangepassed only anonTextvisitor), so inline nodes in the range were dropped and the inverseinsertTexthad nothing to restore. The same omission sat in the undo/redo rebase path (StepMapping.mapDeleteText). The fix unifies the step payload on the existingContentSegmenttype (text with marks, or an atomic inline node):DeleteTextStep.deletedSegmentsandInsertTextStep.segmentsnow carry content segments, the delete paths capture them viagetBlockContentSegmentsInRange, andinsertSegmentsIntoInlineContentre-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 sharedinsertTextStepWidthhelper. 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-onlygetBlockSegmentsInRangehelper 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
fontSizeattribute, but it was never written into the HTML produced bygetContentHTML(), 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 MathMLmathsizeattribute on the<math>root and read back from there, in both the inline and display math node specs, so a sized formula round-trips throughgetContentHTML()/setContentHTML()and through the CSS-class export mode (mathsizeis an attribute, not an inlinestyle, so it is never stripped). Because foreign tools such as KaTeX, MathJax, and Word also express formula size asmathsize, pasting sized math from an external app now preserves the size too: the standalone-math paste interceptor liftsmathsizeinto thefontSizeattribute. The size lives solely infontSize; the canonical stored<math>carries nomathsize(single source of truth, mirroring howdata-block-idis stripped). Covered by new unit tests for thewithMathsize/stripMathsizehelpers and the sharedFormulaSerializationglue, 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 sharedtrailingEscapeParagraphhelper: 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-awareisEmptyParagraphfromBlockInsertion(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 guardisStandaloneMathHtmlcorrectly fired for the multi-formula case (after removing every<math>andaria-hiddensubtree, only whitespace remained), but the interceptor then read a singleparseFragment(html).querySelector('math')and ignored the rest. The interceptor now collects every canonical<math>in document order through a newcollectStandaloneMathElementshelper that first dropsaria-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 generalizedinsertBlockObjectsOnOwnLinesprimitive (the existing single-objectinsertBlockObjectOnOwnLinenow 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,
insertHorizontalRuleandinsertTablebailed 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 throughgetSelectedBlockId, 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, butinsertBlockObjectOnOwnLinealways 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
insertVideoAtRoothand-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 sameinsertBlockObjectOnOwnLineprimitive 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-fonte2e 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 thePrintServiceImpl,FullPreset, andPrintPluginunit tests.