Every composer edits markdown source, on nldd-text-editor #279
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "nldd-text-editor-composer"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #168.
One commit on top of main.
Two changes, and the second does not need the first
1. NLDD is imported per component instead of through the barrel (WDR-0005).
import '@nldd/design-system'resolves to one pre-bundled chunk of all ~90 components; nothing tree-shakes out of it and Waggle renders 36.src/nlddImports.test.tsguards it by resolving each rendered tag through the package's own exports map - it caught a missing import fornldd-keyboard-shortcutwhen main's keyboard work merged in.2. Every composer edits markdown source, on
nldd-text-editor(WDR-0006). Tiptap is WYSIWYG over a node tree, and the markdown we send was reconstructed on the way out bytiptap-markdown. That reconstruction was where the bugs were:~town-squarecame out\~town-square, task lists gained a blank line, hard breaks needed a custom node. The new editor's document is the markdown, so there is nothing to reconstruct. The syntax would stay on screen; it is hidden here except on the construct the caret is in.Reply, new message and notes all use it. Notes stop being a second implementation of the same composer: they differ by one flag and one absent handler. Tiptap, ProseMirror and
tiptap-markdownleave the tree.What the subclass is for
<waggle-text-editor>extends the element over theview/buildExtensions()/reconfigure()seam it documents as protected, because four things are not in the public API:value =replaces the document and drops the caret to 0@and:typeaheads@-only and writes[@Label](user:id)where Mattermost wants@username; a secondautocompletion({override})throws a config merge conflict<u>/<mark>Plus task-list checkboxes (GFM parses
- [ ], nothing styles it) and a quote bar (the editor styles a quote italic with no bar, while NLDD's rendered blockquote has one).A rename of any protected member fails the build rather than failing silently - that is the reason to sit on those rather than on internals. The element is marked beta; that and
hideMarkers.test.tsare what to watch on an NLDD bump.Defects found and fixed
Each has a test that was first run against the broken behaviour.
aria-*to a prop, so a call site that omits it type-checks like one that passes it. The prop isaccessibleLabelnow, and required.defaultPreventedalready true, so the dialog's cancel never ran; CodeMirror does not. It now closes the link bar first, then leaves the field for the toolbar, then closes the window - the middle step is the way out now that Tab indents. Closing the link bar or emoji picker also used to drop focus on<body>.Mod-]. Tab now indents by two spaces on a fence content line and Shift+Tab outdents, never on the ``` line itself (four spaces there stop it being a fence). Both directions are written out rather than taken from CodeMirror, whoseindentLessrewrites a line's whole leading whitespace run and so converts pasted tabs to spaces - inside the one region shipped byte for byte.was the last marker still on screen** when the caret was elsewhere. Hiding it was blocked by the surface: NLDD tints a block's content lines and deliberately leaves the fence lines clean, so hiding the backticks left two blank rows around the tint, and an empty block - whose only lines are fence lines - vanished. The surface now covers the fence rows, in NLDD's own classes rather than a copy of its private tokens, with a test that fails if those class names go. Language name kept as a label; an unclosed fence keeps its.FileReaderand inserts the text, andrunHandlersonly callspreventDefault, so the event still reached the dropzone and uploaded it too. Measured: dropping anotes.txton a composer holdinghallogaveGEHEIM BESTANDSINHOUDhalloplus an attachment.doPaste(view, '')when the clipboard holds only files, which replaces the selection with nothing. Both are guarded now by adomEventHandlersextension that claims the event before the built-in and lets it bubble on to the uploader.:or@inside code opened the emoji or mention list, and Tab would have committed a pick into text Mattermost renders literally.>markers were hidden on the interior lines of a quoted code block, so the code read as unquoted while the source still carried the quote.defaultKeymapand indent any line by two spaces; two presses give four, which renders as<code>. macOS gives both keys to browser history, so they are swallowed rather than rebound. Tab nests a list item and Shift+Tab un-nests it, with toolbar buttons for both. On a line where indentation has no markdown meaning Tab keeps moving focus - one to three leading spaces are dropped by the renderer and the fourth makes a code block, so there is nothing useful for it to do.notesMarkswas applied once and never watched, andtoggleUnderline/toggleHighlightwrote a literal<u>whether or not the extension was loaded - only av-ifstood between that and a Mattermost message.rem, a unit that tracks the root font rather than the editor's line, at a size that ate the room the toolbar needed on a short window.Prop cleanup
A design round asked whether the options belong in a config object. They do not: after deleting what is dead there are three real feature switches, and two of them are dependencies whose presence is the switch. But getting to that count fixed things - five switches had no caller, and three features needed two props each to stay coherent (a paperclip and its handler, a mention fetcher and its account, marks and the buttons that toggle them). Those are one value each now. 27 declared props across the three layers become 21.
Tests the work exposed as missing
Bundle
Same tree, three builds,
gzip -9over every emitted.js:main(barrel + Tiptap)The 39% is change 1. The editor swap costs 27 kB: CodeMirror and
@lezer/markdownare larger than what they replace, and they share one copy with the element, which imports@codemirror/*as bare specifiers.Verification
typecheck,vitest1153,playwright113 (3 skipped),build,eslint0 errors,pre-commit. Driven by hand in the browser at each step.Two things worth knowing for the next person: a typo in an optional prop name produces no diagnostic (
strictTemplatesis off, and turning it on is 142 errors of its own), and HMR does not rebuild the adopted stylesheet inside a shadow root - a CSS change there needs a full reload before it means anything.`import '@nldd/design-system'` resolves to dist/components/components.js: one pre-bundled chunk holding every component, with CodeMirror inlined. Nothing tree-shakes out of it, and Waggle renders 36 of the ~90 elements. Replacing it with the 33 subpath imports that cover those 36 tags cuts the built JS from 3202 kB raw / 911 kB gzip to 1845 kB / 530 kB - 42% off both, and the entry chunk drops from 2726 kB to 1535 kB raw. The barrel also cannot coexist with a subpath import: both call customElements.define('nldd-icon', ...) and the second throws. That blocked loading any single component on its own. nlddImports.test.ts guards it. It scans the templates for nldd-* tags and resolves each import through the package's own exports map to the tags that module actually defines, so a rename upstream fails the test rather than leaving a stale constant behind. Resolution is deliberately non-transitive: ./icon-button happens to pull in nldd-icon, and relying on that would let a tag we render lose its registration when NLDD reshuffles an internal import. Note the subpaths are per source module, not per tag: ./menu also defines nldd-menu-item and nldd-menu-divider, ./form-field also defines nldd-form-field-help-text.e32d135d24f7d46c4ab6A second composer engine on nldd-text-editor, and NLDD imported per componentto Every composer edits markdown source, on nldd-text-editor`mentionFetch` and `accountId` were separate and independently optional, but neither is useful alone: the avatar proxy needs the account and answers 422 without it, so a fetcher on its own turns every candidate row into initials without saying anything. They become one `MentionSource { fetch, accountId }`, which the compiler will not let a caller half-fill - a literal missing `accountId` is TS2741 where two optional props were silently fine. `accountId` existed on MessageEditor only to reach the mention dropdown, so it leaves the prop list entirely rather than moving. Both composers build it in a computed. An inline object literal would mint a new identity on every render, which the wrapper's watcher would read as a changed source.`iconNames.test.ts` scans templates for literal `icon="…"` attributes and reads six named maps. The toolbar names its icons in neither: FORMAT_ITEMS and INSERT_ITEMS are object literals, and the heading icons are built as `heading-${l}`. Twenty names, none of them guarded, in the one component where `paperclip` now decides whether an attachment button exists at all - and a retired NLDD icon name renders an empty box with no warning anywhere. INSERT_ITEMS moves to composerCommands.ts, next to FORMAT_ITEMS, so the guard can import it - the module is already where the toolbar's data lives. Verified by renaming `paperclip` to `paper-clip`: "TOOLBAR_ICONS has names NLDD does not know".The editor already offers the open affordance. With the caret in a link NLDD renders `a.cm-link-badge` beside it - measured live while the bar was open: same href, same `target="_blank" rel="noopener noreferrer"`, and a better accessible name ("Open link in nieuw tabblad: <url>") than our unlabelled "Openen". Two buttons for one action, one of which says less. The height mismatch was a symptom, not a detail. The field measured 42px against 32px buttons because (a) its own `padding: 0.25rem 0.5rem` was dead - base.css styles every native `input[type=text]` and that selector outranks a bare class, which is why it silently lost - and (b) the bar was not using the compact single-line font the project reserves for exactly this kind of floating panel. It now takes `--semantics-controls-sm-min-size`, the token `nldd-button size="sm"` uses, as a `height` with no block padding. Not `min-height`: the border token is 2px, so line-height plus block padding plus border comes to 34 and pushes straight past a 32px floor. A native input centres its single line in whatever height it is given, so this needs no arithmetic over tokens and holds if either token moves. Measured: field and all three buttons 32px, tops aligned.A merge rather than the rebase that was asked for. The branch already carries one merge of main, and a rebase replays all 31 commits from the beginning: it stopped at commit 2 of 30, re-resolving conflicts against intermediate states that no longer exist (the dev switch this branch later deleted). The merge is nine conflicted files in one pass, all of them files this branch owns. Where main's work and this branch's touched the same thing, main's semantics win and this branch's implementation carries them: - NotesEditor. Main fixed two real bugs there that the rewrite would have dropped: `onSave` now names its card (`onSave(cardId, markdown)`, read from the component's own frozen prop) so a debounced save cannot land on the card the user just stepped to, and unmount *flushes* the pending save instead of dropping it, because the timer dies with the instance and losing typed text is worse. This branch's version did the opposite on unmount, with a test pinning it. Both are now the MessageEditor-based component's behaviour, and the test is inverted. - Main's `NotesEditor.stepping.test.ts` drove Tiptap directly; it now drives the same guarantees through the mocked MessageEditor. Verified by making unmount drop again - both flush tests fail. - MessageEditor gains main's `schedule` emit (Ctrl/Cmd+Shift+Enter, checked before plain Cmd+Enter) and its deferred `focus()`: Modal's `open` fires before the editor exists, so focus() waits for it once rather than guessing a delay. Here that is a watcher on the element from `onReady` instead of on the Tiptap instance. - `MessageEditor.focus.integration.test.ts` is deleted rather than ported. It mounts a real Tiptap to prove focus lands on the editable and not the wrapper; `<waggle-text-editor>` does not boot in happy-dom, so there is nothing to mount. The guarantee is asserted for real in tests/e2e/shortcuts.spec.ts ("r opens the card ready to reply, with real keyboard focus in the editor"), which passes against this engine. - Main's new `ReplyComposer.stepping.test.ts` imported the composer without mocking the editor, so the real element re-registered `nldd-icon`. Mocked at the same seam the sibling suites use. Two things the merge surfaced that are not conflicts: - `nldd-keyboard-shortcut` had no a-la-carte import. Main renders it and gets away with it because main still loads the NLDD barrel; on this branch the tag would have rendered an empty box. `src/nlddImports.test.ts` caught it, which is what it exists for. - The CHANGELOG was rebuilt from main's, which restructured everything into `## [v2026.8.11]`. Only this branch's own unreleased entries were put back; the rest is released now, reworded by that restructure. `types/api.ts` takes main's typed `source_data` (so `Card = CardRead`) and keeps this branch's `MentionSource`. `iconNames.test.ts` keeps both new maps. Gates: typecheck, 1066 vitest, 96 e2e (3 skipped), build, eslint 0 errors, pre-commit.763737f49d5b9f9bccff5b9f9bccffc5ea3cc7e0c5ea3cc7e0d52c7e4cb2d52c7e4cb26b88a4774d6b88a4774d2c33ac17bf2c33ac17bfd437b0f576d437b0f57621b3a8a84221b3a8a842ce0b36c254ce0b36c254f2ec9eec39f2ec9eec39e74c2169cfe74c2169cf2d4c3c4f842d4c3c4f84be3f5d94adbe3f5d94adde93ed721ede93ed721e5adca295a25adca295a2766a7f9f3d766a7f9f3d9557e63ea49557e63ea4bf91e11643bf91e116432451259a01