feat(frontend): inline-preview markdown editor for encounter notes#161
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
This PR upgrades encounter notes in the frontend from plain text to a markdown-aware experience by introducing a CodeMirror 6 “inline preview” editor (Obsidian-style live rendering while storing raw markdown) and a sanitized, read-only markdown renderer for the encounter detail page.
Changes:
- Replaced the encounter notes
<textarea>with a newMarkdownEditor(CM6 + inline-preview extensions). - Added
MarkdownViewfor sanitized markdown rendering in encounter detail. - Vendored the inline-preview engine (from atomic-editor) and updated tooling/config (Biome ignore + Vitest/Svelte 5 testing plugin + deps).
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| biome.json | Excludes vendored inline-preview from Biome; adjusts test lint rule. |
| apps/frontend/vite.config.ts | Adds svelteTesting() Vite plugin to support Svelte 5 component tests. |
| apps/frontend/src/lib/editor/MarkdownView.svelte | New sanitized markdown renderer (markdown-it + DOMPurify + link hardening). |
| apps/frontend/src/lib/editor/MarkdownEditor.svelte | New CM6-based markdown editor wrapper using vendored inline-preview extensions. |
| apps/frontend/src/lib/editor/markdown-view.test.ts | Unit tests for markdown rendering + sanitization/link hardening. |
| apps/frontend/src/lib/editor/markdown-editor.test.ts | Unit tests for editor decoration + external value reconciliation. |
| apps/frontend/src/lib/editor/inline-preview/VENDORED.md | Documents upstream source commit and local modifications for vendored engine. |
| apps/frontend/src/lib/editor/inline-preview/tree-progress.ts | Vendored parse-progress plugin supporting incremental decoration rebuilds. |
| apps/frontend/src/lib/editor/inline-preview/LICENSE | MIT license for vendored atomic-editor code. |
| apps/frontend/src/lib/editor/inline-preview/inline-preview.ts | Vendored core inline-preview decorations + interaction behaviors. |
| apps/frontend/src/lib/editor/inline-preview/inline-preview.css | Vendored styles for inline-preview rendering. |
| apps/frontend/src/lib/editor/inline-preview/index.ts | Local barrel exporting the React-free CM6 extensions. |
| apps/frontend/src/lib/editor/inline-preview/edit-helpers.ts | Vendored input handlers (emphasis pairing, code-fence helper). |
| apps/frontend/src/lib/editor/inline-preview/atomic-theme.ts | Vendored theme/syntax highlighting; locally set dark:false. |
| apps/frontend/src/lib/components/encounters/encounter-form.svelte | Wires the new MarkdownEditor into the encounter form. |
| apps/frontend/src/lib/components/encounters/encounter-detail.svelte | Wires the new MarkdownView into the encounter detail view. |
| apps/frontend/package.json | Adds CodeMirror/Lezer + markdown-it deps and related typings. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+558
to
+575
| const imageLine = doc.lineAt(node.from); | ||
| const lineNum = imageLine.number; | ||
| if (!activeLines.has(lineNum)) { | ||
| // Hide the raw `` on inactive lines so only the | ||
| // rendered image block (emitted by the image-blocks state | ||
| // field below the line) shows. We deliberately keep the | ||
| // now-empty source `.cm-line` at its default line-height | ||
| // rather than collapsing it via `display: none`: on iOS | ||
| // Safari, toggling a line from its text-measured height | ||
| // to zero mid-scroll shifts every subsequent line up by | ||
| // that amount, which the scroll engine reads as an | ||
| // anchor conflict and halts kinetic momentum — visible | ||
| // as "scroll stops right before an image when you scroll | ||
| // back up." The tradeoff is one line of empty space | ||
| // above each rendered image, which actually reads a bit | ||
| // cleaner as visual separation anyway. | ||
| pushReplace(ranges, doc, node.from, node.to); | ||
| } |
Comment on lines
+5
to
+6
| const md = new MarkdownIt({ html: false, linkify: true, breaks: true }); | ||
|
|
Comment on lines
219
to
221
| <label for="description" class="block text-sm font-body font-medium text-gray-700 mb-1"> | ||
| {$i18n.t('encounters.form.notesLabel')} <span class="text-gray-400">{$i18n.t('encounters.form.optional')}</span> | ||
| </label> |
Comment on lines
+60
to
+64
| EditorView.lineWrapping, | ||
| ...(placeholder ? [cmPlaceholder(placeholder)] : []), | ||
| EditorView.editable.of(!disabled), | ||
| EditorView.contentAttributes.of({ 'aria-label': ariaLabel }), | ||
| EditorView.updateListener.of((u) => { |
Adds the CodeMirror 6 packages (state, view, commands, language, lang-markdown), lezer common/highlight, and markdown-it (+types) used by the new encounter-notes markdown editor and viewer. Exact-pinned per repo convention. No @atomic-editor/editor or react: the editor engine is vendored (React-free) instead, so React is not installed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vendors the React-free CodeMirror 6 extensions from atomic-editor (github.com/kenforthewin/atomic-editor @ 26f75e76, MIT) that power Obsidian-style inline markdown live-preview. We vendor rather than depend on @atomic-editor/editor because it is pre-1.0, single-maintainer, and declares react/react-dom as required peers — only its React component uses them, the engine does not. Includes inline-preview, tree-progress, atomic-theme (FB: dark->light), edit-helpers, the CSS, LICENSE, and a local barrel (no React re-exports). vendored.md records the source commit and local modifications. Image markdown is left as visible source (FB: the image-block widget is not vendored and remote images are blocked for privacy). The vendored dir is excluded from Biome to stay verbatim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
markdown-editor.svelte wraps the vendored CM6 engine in a Svelte 5 component: instantiates the EditorView in onMount (client-only), two-way binds the raw markdown via $bindable, and reconciles external value changes without clobbering the cursor. Editability is held in a Compartment so the disabled prop toggles reactively (blurs on disable), mirroring a disabled textarea. Labelled via aria-labelledby (or aria-label). Themed to the design system through the engine's --atomic-editor-* variables (forest accent, Merriweather body, Yanone headings) with a focus ring matching the surrounding form fields. markdown-view.svelte renders stored markdown read-only: markdown-it with html:false, sanitized through the existing isomorphic-dompurify, with link hardening (target=_blank, rel=noopener noreferrer nofollow). Images are disabled at the parser and forbidden at sanitize time so viewing a note never triggers an external request. Carries its own typography since Tailwind preflight strips defaults. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swaps the encounter notes textarea for the markdown editor (associated to its label via aria-labelledby) and the read-only notes div for the markdown viewer. The bound `description` string and the create/update path are unchanged — stored value stays byte-for-byte what the user typed; markdown is rendered, never stored as HTML. No backend or schema change: the column is already free-text TEXT. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Viewer: headings/lists/emphasis render, raw <script> stripped, javascript: links never produce a live anchor, external links hardened, images never render as <img>. Editor: a heading line gets the inline-preview decoration class, external value reconciles, empty mounts stay empty. Enables Svelte 5 component testing under jsdom via the svelteTesting() Vite plugin (browser resolve condition + auto-cleanup). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The repo migrated husky -> hk, but clones that previously ran `husky install` still carry `core.hooksPath=.husky/_` in their git config, which shadows the working hooks hk installs into `.git/hooks/`. Result: commit-msg (commitlint) and pre-push (danger) silently never run locally. The prepare script now unsets core.hooksPath before `hk install`, so any clone self-heals on the next install. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6047099 to
27f5238
Compare
Comment on lines
+219
to
+227
| <label for="description" class="block text-sm font-body font-medium text-gray-700 mb-1"> | ||
| {$i18n.t('encounters.form.notesLabel')} <span class="text-gray-400">{$i18n.t('encounters.form.optional')}</span> | ||
| </label> | ||
| <textarea | ||
| id="description" | ||
| <MarkdownEditor | ||
| bind:value={description} | ||
| rows="3" | ||
| ariaLabel={$i18n.t('encounters.form.notesLabel')} | ||
| placeholder={$i18n.t('encounters.form.notesPlaceholder')} | ||
| disabled={isSubmitting} | ||
| class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-forest focus:border-transparent font-body text-sm resize-none disabled:opacity-50" | ||
| ></textarea> | ||
| /> |
| </script> | ||
|
|
||
| <div class="fb-md-view"> | ||
| <!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized above --> |
The biome hook step invoked bare `biome`, which isn't on PATH under `mise x` (mise doesn't add node_modules/.bin), so pre-commit failed with "biome: command not found" once the hooks actually ran. Switch to `npx biome`, matching the danger and commitlint steps, so the pinned node_modules biome resolves. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the encounter notes label to the markdown editor with aria-labelledby (label id + labelledBy prop) instead of a dangling `for="description"` that pointed at no labelable control. Also drops a non-functional eslint-disable comment in the viewer (the repo uses Biome, not ESLint) in favor of a plain note that the html is sanitized. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| "docker:up": "docker-compose up -d", | ||
| "docker:down": "docker-compose down", | ||
| "prepare": "command -v hk >/dev/null && hk install --mise; ! command -v aube >/dev/null || aube --filter @freundebuch/frontend run sync --no-install || true", | ||
| "prepare": "git config --unset-all core.hooksPath 2>/dev/null || true; command -v hk >/dev/null && hk install --mise; ! command -v aube >/dev/null || aube --filter @freundebuch/frontend run sync --no-install || true", |
Comment on lines
+123
to
+125
| .fb-md-view :global(a) { | ||
| overflow-wrap: anywhere; | ||
| } |
Comment on lines
+866
to
+870
| if (!url) return false; | ||
|
|
||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| onLinkClick(url); |
| sveltekit(), | ||
| // Enables Svelte 5 component testing under jsdom (browser resolve | ||
| // condition + auto-cleanup between tests). | ||
| svelteTesting(), |
Comment on lines
+92
to
+96
| $effect(() => { | ||
| if (view && value !== view.state.doc.toString()) { | ||
| view.dispatch({ | ||
| changes: { from: 0, to: view.state.doc.length, insert: value }, | ||
| }); |
Comment on lines
219
to
222
| <!-- svelte-ignore a11y_label_has_associated_control --> | ||
| <label id="notes-label" class="block text-sm font-body font-medium text-gray-700 mb-1"> | ||
| {$i18n.t('encounters.form.notesLabel')} <span class="text-gray-400">{$i18n.t('encounters.form.optional')}</span> | ||
| </label> |
- Editor opens link-icon clicks through a scheme allowlist (http/https/mailto/tel), rejecting javascript:/data: URLs that come straight from note markdown. - External value swaps reconcile with addToHistory:false so Undo can't jump back into a previous document's content. - Notes field header is a <span> (matching the type field) referenced by aria-labelledby, dropping the label/svelte-ignore workaround. - Merge the duplicate .fb-md-view a rule in the viewer. - Gate svelteTesting() on VITEST so it never affects dev/preview/prod builds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Guards the prepare-script cleanup so it touches core.hooksPath only when it still points at the stale `.husky/_` path, leaving any intentional custom hooks path untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines
+11
to
+31
| // Harden every rendered link: open in a new tab and sever the opener so | ||
| // note content can't reach back into the app. Runs after sanitization, so | ||
| // it also covers autolinked bare URLs (linkify) and survives `html:false`. | ||
| let hooked = false; | ||
| function ensureHook() { | ||
| if (hooked) return; | ||
| DOMPurify.addHook('afterSanitizeAttributes', (node) => { | ||
| if (node.tagName === 'A') { | ||
| node.setAttribute('target', '_blank'); | ||
| node.setAttribute('rel', 'noopener noreferrer nofollow'); | ||
| } | ||
| }); | ||
| hooked = true; | ||
| } | ||
|
|
||
| export function renderMarkdown(source: string): string { | ||
| ensureHook(); | ||
| // html:false already neutralizes raw HTML; DOMPurify is the belt to that | ||
| // suspenders, and the only thing standing between note text and the DOM. | ||
| // FORBID_TAGS img backstops the parser-level image disable above. | ||
| return DOMPurify.sanitize(md.render(source ?? ''), { FORBID_TAGS: ['img'] }); |
|
|
||
| :global(.fb-md-editor--disabled) { | ||
| opacity: 0.5; | ||
| pointer-events: none; |
Comment on lines
+219
to
225
| <span id="notes-label" class="block text-sm font-body font-medium text-gray-700 mb-1"> | ||
| {$i18n.t('encounters.form.notesLabel')} <span class="text-gray-400">{$i18n.t('encounters.form.optional')}</span> | ||
| </label> | ||
| <textarea | ||
| id="description" | ||
| </span> | ||
| <MarkdownEditor | ||
| bind:value={description} | ||
| rows="3" | ||
| labelledBy="notes-label" | ||
| placeholder={$i18n.t('encounters.form.notesPlaceholder')} |
- Viewer hardens links via a module-local markdown-it renderer rule (target/rel on link_open) instead of a global DOMPurify.addHook, so it no longer mutates other sanitize() callers (e.g. search headlines). DOMPurify keeps the attributes via ADD_ATTR; images stay forbidden. - Disabled editor no longer sets pointer-events:none — editing is already blocked by EditorView.editable(false), and dropping it keeps long notes scrollable/selectable like a disabled textarea. - Notes field label stays a <span> tied via aria-labelledby; documented why there's no static-span click-to-focus handler (the editor box is itself click/tab focusable). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines
+74
to
+86
| markdown(), | ||
| inlinePreview({ onLinkClick: openSafeLink }), | ||
| extendEmphasisPair, | ||
| atomicMarkdownSyntax, | ||
| atomicEditorTheme, | ||
| inlineFieldTheme, | ||
| EditorView.lineWrapping, | ||
| ...(placeholder ? [cmPlaceholder(placeholder)] : []), | ||
| editableConf.of(EditorView.editable.of(!disabled)), | ||
| EditorView.contentAttributes.of( | ||
| labelledBy ? { 'aria-labelledby': labelledBy } : { 'aria-label': ariaLabel }, | ||
| ), | ||
| EditorView.updateListener.of((u) => { |
Comment on lines
+79
to
+80
| .fb-md-view :global(:first-child) { margin-top: 0; } | ||
| .fb-md-view :global(:last-child) { margin-bottom: 0; } |
- Editor holds placeholder and content attributes (aria-label/labelledby) in Compartments and reconfigures them in $effects, so a live language switch updates the translated placeholder/label instead of keeping the values frozen from mount. - Viewer trims leading/trailing margin only on the rendered block's direct children (`> :first-child` / `> :last-child`), so nested first/last items (e.g. a list's first <li>) keep their margins. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| // Enables Svelte 5 component testing under jsdom (browser resolve | ||
| // condition + auto-cleanup). Test-only — excluded from dev/preview/prod | ||
| // builds so it can't affect production module resolution. | ||
| ...(process.env.VITEST ? [svelteTesting()] : []), |
|
🎉 This PR is included in version 2.74.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Replaces the plain
<textarea>for encounter notes with a CodeMirror 6 editor that renders markdown inline, Obsidian-style — typing##makes the line a heading in place, while the stored value stays plain markdown. Adds a sanitized read-only viewer for the encounter detail page.Raw markdown is the source of truth (stored byte-for-byte); rendering is view-only and sanitized. No DB or backend change —
encounters.descriptionis already free-textTEXT, and the ArkType validator passes it through untouched.Editor engine: vendored, not a dependency
The inline-preview machinery comes from atomic-editor (MIT). Rather than depend on
@atomic-editor/editor, the React-free CodeMirror 6 extensions are vendored intoapps/frontend/src/lib/editor/inline-preview/at a pinned commit (26f75e76), because the package is pre-1.0, single-maintainer, and declaresreact/react-domas required peers (only its React component uses them; the engine does not).Result: React is not installed at all — it cannot end up in the bundle.
VENDORED.mdrecords the source commit + local modifications for re-syncing; the dir is excluded from Biome to stay verbatim.Changes
src/lib/editor/inline-preview/— vendored CM6 engine (+ LICENSE, VENDORED.md, local barrel)MarkdownEditor.svelte— Svelte 5 wrapper (onMount,$bindable, cursor-safe reconcile), themed to design tokens (forest accent, Merriweather body, Yanone headings), forest focus-ring, placeholder + disabled parityMarkdownView.svelte—markdown-it(html:false) + existingisomorphic-dompurify, link hardening (target=_blank rel=noopener noreferrer nofollow)encounter-form.svelte(editor) andencounter-detail.svelte(viewer — the only display surface)aube, exact-pinned;svelteTesting()enables Svelte 5 component testsVerification
javascript:links, link hardening, heading-decoration render, value reconcile)svelte-check: 0 errors (new files clean)Not covered
.cm-atomic-h2decoration, but a manual pass on the running stack is still wanted.#/-/>will now render as markdown (low blast radius; short free-text).🤖 Generated with Claude Code