From 42af1fada28d984ba2853730245a278bbaa793d7 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 3 Jul 2025 14:35:59 +0200 Subject: [PATCH 1/9] Make baseline-alignment work for empty, readonly rich-text fields --- .../lcm-rich-text-editor/lcm-rich-text-editor.svelte | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte index dadd1e8255..bbec0ecf82 100644 --- a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte +++ b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte @@ -238,6 +238,11 @@ :global(.customized ~ .customized) { margin-left: 2px; } + + &::before { + /* Ensure baseline-alignment even works when empty */ + content: '\200B'; + } } From cfc0a35b604bb5d26f595027452f09efbf36527e Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 3 Jul 2025 16:11:24 +0200 Subject: [PATCH 2/9] Add story for comparing inputs in different states --- .../editor/fields/1_overview.stories.svelte | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte diff --git a/frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte b/frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte new file mode 100644 index 0000000000..db40c51647 --- /dev/null +++ b/frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte @@ -0,0 +1,249 @@ + + + + {#snippet template()} + + +
Single WS Input
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Rich WS Input
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Multi WS Input
+ + + + + + + + + + + + + +
Rich Multi WS Input
+ + + + + + + + + + + + + +
Single Select
+ + + + + + + + + + + + + +
Multi Select
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ {/snippet} +
From 289043417e5a65d8fbb7798240d05fae361cbfbf Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 3 Jul 2025 16:12:06 +0200 Subject: [PATCH 3/9] Make storybook accessible on local network --- frontend/viewer/package.json | 2 +- frontend/viewer/src/project/NewEntryButton.svelte | 2 +- .../entity-primitives/entry-editor-primitive.stories.svelte | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index 304f670c07..2f358a2df1 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -35,7 +35,7 @@ "lint:report": "eslint . --output-file eslint_report.json --format json", "i18n:extract": "lingui extract --clean && lingui extract", "generate-icon-types": "node ./generate-icon-types.js", - "storybook": "storybook dev -p 6006", + "storybook": "storybook dev -p 6006 --host 0.0.0.0", "build-storybook": "storybook build" }, "devDependencies": { diff --git a/frontend/viewer/src/project/NewEntryButton.svelte b/frontend/viewer/src/project/NewEntryButton.svelte index 741223be86..1d3e8ef962 100644 --- a/frontend/viewer/src/project/NewEntryButton.svelte +++ b/frontend/viewer/src/project/NewEntryButton.svelte @@ -33,7 +33,7 @@ } = $props(); const features = useFeatures(); - const id = crypto.randomUUID(); + const id = $props.id(); const isActive = $derived( // explicitly active instances[id] === true || diff --git a/frontend/viewer/src/stories/editor/entity-primitives/entry-editor-primitive.stories.svelte b/frontend/viewer/src/stories/editor/entity-primitives/entry-editor-primitive.stories.svelte index 1e6eaca41a..69280e1622 100644 --- a/frontend/viewer/src/stories/editor/entity-primitives/entry-editor-primitive.stories.svelte +++ b/frontend/viewer/src/stories/editor/entity-primitives/entry-editor-primitive.stories.svelte @@ -8,7 +8,7 @@ import {tick} from 'svelte'; let entry: IEntry = $state({ - id: crypto.randomUUID(), + id: '36b8f84d-df4e-4d49-b662-bcde71a8764f', lexemeForm: { 'seh': 'Lexeme form', }, From 94b7e500ff90f46f06cbf0d050b3c514b392d942 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 3 Jul 2025 16:47:16 +0200 Subject: [PATCH 4/9] Further conform prose-mirror into the image of input --- frontend/viewer/package.json | 1 + .../lcm-rich-text-editor.svelte | 144 ++++++++++-- .../components/ui/input/input-shell.svelte | 2 +- .../viewer/src/lib/utils/tabbable.test.ts | 207 ++++++++++++++++++ frontend/viewer/src/lib/utils/tabbable.ts | 37 +++- .../editor/fields/1_overview.stories.svelte | 11 + frontend/viewer/vitest.config.ts | 2 +- 7 files changed, 379 insertions(+), 25 deletions(-) create mode 100644 frontend/viewer/src/lib/utils/tabbable.test.ts diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index 2f358a2df1..398906917e 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -30,6 +30,7 @@ "test:watch": "vitest", "test:storybook": "vitest --project=storybook", "test:unit": "vitest --project=unit", + "test:browser": "vitest --project=browser", "check": "svelte-check", "lint": "eslint .", "lint:report": "eslint . --output-file eslint_report.json --format json", diff --git a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte index bbec0ecf82..50db569ab2 100644 --- a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte +++ b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte @@ -3,9 +3,14 @@ import {gt} from 'svelte-i18n-lingui'; import {cn} from '$lib/utils'; + /* + whitespace: pre tells prose-mirror how to parse the dom, not how to render it, that's our job + */ const textSchema = new Schema({ nodes: { - text: {}, + text: { + whitespace: 'pre', + }, /** * Note: it seems that our spans likely should have been modeled as "marks" rather than "nodes". * Conceptually our users "mark" up a text. @@ -15,6 +20,7 @@ span: { selectable: false, content: 'text*', + inline: true, whitespace: 'pre', toDOM: (node) => { return ['span', { @@ -27,7 +33,12 @@ //this allows us to update the text property without having to map all the span properties into the schema attrs: {richSpan: {default: {}}, className: {default: ''}} }, - doc: {content: 'span*', attrs: {}} + doc: { + whitespace: 'pre', + inline: true, + content: 'span*', + attrs: {} + } } }); @@ -41,7 +52,7 @@ import type {IRichString} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichString'; import {Label} from '$lib/components/ui/label'; import {EditorView} from 'prosemirror-view'; - import {AllSelection, EditorState} from 'prosemirror-state'; + import {AllSelection, EditorState, Selection} from 'prosemirror-state'; import {keymap} from 'prosemirror-keymap'; import {baseKeymap} from 'prosemirror-commands'; import {undo, redo, history} from 'prosemirror-history'; @@ -50,8 +61,10 @@ import type {HTMLAttributes} from 'svelte/elements'; import {IsUsingKeyboard} from 'bits-ui'; import type {IRichSpan} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichSpan'; - import {inputVariants} from '../ui/input/input.svelte'; import {on} from 'svelte/events'; + import InputShell from '../ui/input/input-shell.svelte'; + import {IsMobile} from '$lib/hooks/is-mobile.svelte'; + import {findNextTabbable} from '$lib/utils/tabbable'; let { value = $bindable(), @@ -64,6 +77,8 @@ readonly = false, onchange = () => {}, autofocus, + class: className, + enterkeyhint = 'next', ...rest }: { @@ -82,6 +97,8 @@ let editor: EditorView | null = null; const isUsingKeyboard = new IsUsingKeyboard(); + // isUsingKeyboard.current isn't entirely reliable on mobile due to virtual keyboards + let pointerDown = false; onMount(() => { // docs https://prosemirror.net/docs/ @@ -100,7 +117,6 @@ editor.updateState(newState); }, attributes: { - class: inputVariants({class: 'min-h-10 h-auto block'}), // todo: the distribution of props between the editor and the elementRef is not good // there should probably be a wrapper component that provides the elementRef to this one ...(id ? {id} : {}), @@ -109,13 +125,25 @@ role: 'textbox', ...(autocapitalize ? {autocapitalize} : {}), spellcheck: 'false', + ...(enterkeyhint ? {enterkeyhint} : {}), }, editable() { return !readonly; }, handleDOMEvents: { - 'focus': onfocus, + pointerdown() { + pointerDown = true; + setTimeout(() => pointerDown = false, 100); // yes, apparently we need a decently high timeout value + }, + 'focus'(editor) { + onfocus(editor, !pointerDown); + }, 'blur': onblur, + keydown(_view, event) { + if (event.key === 'Enter') { + onEnterKey(); + } + }, } }); editor.dom.setAttribute('tabindex', '0'); @@ -125,9 +153,22 @@ if (relatedLabel) return on(relatedLabel, 'click', onFocusTargetClick); }); - function onfocus(editor: EditorView) { - if (isUsingKeyboard.current) { // tabbed in - selectAll(editor); + let prevSelection: Selection | undefined; + function onfocus(editor: EditorView, viaKeyboard: boolean) { + const usingKeyboard = isUsingKeyboard.current || + IsMobile.value && viaKeyboard; + if (usingKeyboard) { // tabbed in + if (IsMobile.value) { + if (prevSelection) { + const prevSelectionForCurrentDoc = Selection.fromJSON(editor.state.doc, prevSelection.toJSON()); + setSelection(prevSelectionForCurrentDoc); + prevSelection = undefined; + } else { + setSelection(Selection.atEnd(editor.state.doc)); + } + } else { + selectAll(editor); + } } } @@ -136,6 +177,7 @@ onchange(value); dirty = false; } + prevSelection = editor.state.selection; clearSelection(editor); } @@ -167,6 +209,15 @@ keymap({ 'Mod-z': undo, 'Mod-y': redo, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Enter': () => { + // This handler is not triggered reliably on mobile, so we're using handleDOMEvents.keydown instead + // if (IsMobile.value) onEnterKey(); + + // and we never want to do anything on desktop + // (partially because it causes range errors - on mobile too) + return true; + }, 'Shift-Enter': (state, dispatch) => { if (dispatch) dispatch(state.tr.insertText(newLine)); return true; @@ -177,6 +228,14 @@ }); } + function onEnterKey(): void { + if (IsMobile.value && enterkeyhint === 'next' && editor?.dom) { + // mimic 'next' behaviour + const nextTabbable = findNextTabbable(editor.dom); + nextTabbable?.focus(); + } + } + function valueToDoc(): Node { return textSchema.node('doc', {}, value?.spans .filter(s => s.text) @@ -197,7 +256,16 @@ } function selectAll(editor: EditorView) { - editor.dispatch(editor.state.tr.setSelection(new AllSelection(editor.state.doc))); + setSelection(new AllSelection(editor.state.doc)); + + if (!readonly) return; // happens automatically if not readonly + + const selection = window.getSelection(); + if (!selection) return; + const range = document.createRange(); + range.selectNodeContents(editor.dom); + selection.removeAllRanges(); + selection.addRange(range); } function clearSelection(editor: EditorView) { @@ -209,6 +277,19 @@ } } + function setSelection(selection: Selection): void { + if (!editor) return; + editor.dispatch(editor.state.tr.setSelection(selection)); + setTimeout(() => { + if (!editor) return; + if (editor.state.selection.eq(selection) || selection.$anchor.doc !== editor.state.doc) return; + // when tabbing back and forth between 2 rich text editors, the previous setSelection is not sufficient 🤷 + // (It doesn't have anything to do with the clearSelection() below) + // The first call is kept, because it avoid a visible delay when possible + editor.dispatch(editor.state.tr.setSelection(selection)); + }, 1); // 0 is not enough on Firefox + } + //lcm expects line separators, but html does not render them, so we replace them with \n function replaceNewLineWithLineSeparator(text: string) { return text.replaceAll(newLine, lineSeparator); @@ -218,18 +299,31 @@ } function onFocusTargetClick(event: MouseEvent) { - if (!editor) return; - if (event.target === editor?.dom) return; // the editor will handle focus itself - editor.focus(); + if (!editor || !event.target) return; + if (event.target === editor.dom || event.target instanceof globalThis.Node && editor.dom.contains(event.target)) + return; // the editor will handle focus itself + editor?.focus(); + // Minorly dissatisfying is that when you click on the padding to the right of prose-mirror, + // the caret is not intelligently placed at the end of the text (and on the correct line if multi-line) + // Everything else seems good } + + const wrapperClasses = 'px-3 min-h-10 h-max block overflow-hidden cursor-text place-content-center focus:ring-2'; - + {#if label} -
- -
+
+ +
{:else} -
+ {/if} diff --git a/frontend/viewer/src/lib/components/ui/input/input-shell.svelte b/frontend/viewer/src/lib/components/ui/input/input-shell.svelte index 5ed7731285..1d9f84570b 100644 --- a/frontend/viewer/src/lib/components/ui/input/input-shell.svelte +++ b/frontend/viewer/src/lib/components/ui/input/input-shell.svelte @@ -8,7 +8,7 @@ focusRingClass?: string; } - const anyChildHasFocusRing = 'has-[:focus-visible]:ring-ring has-[:focus-visible]:outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-2'; + const anyChildHasFocusRing = 'ring-ring outline-none ring-offset-2 has-[:focus-visible]:ring-2'; let { ref = $bindable(null), diff --git a/frontend/viewer/src/lib/utils/tabbable.test.ts b/frontend/viewer/src/lib/utils/tabbable.test.ts new file mode 100644 index 0000000000..a26871a423 --- /dev/null +++ b/frontend/viewer/src/lib/utils/tabbable.test.ts @@ -0,0 +1,207 @@ +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {findFirstTabbable, findNextTabbable} from './tabbable'; + +describe('tabbable utilities', () => { + let container: HTMLDivElement; + + beforeEach(() => { + // Create a fresh container for each test + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + // Clean up after each test + document.body.removeChild(container); + }); + + describe('findFirstTabbable', () => { + it('should return undefined for null container', () => { + expect(findFirstTabbable(null)).toBeUndefined(); + }); + + it('should return undefined for undefined container', () => { + expect(findFirstTabbable(undefined)).toBeUndefined(); + }); + + it('should return null when no tabbable elements exist', () => { + container.innerHTML = '
No tabbable elements
'; + expect(findFirstTabbable(container)).toBeNull(); + }); + + it('should find the first tabbable element', () => { + container.innerHTML = ` +
+ Not tabbable + + + +
+ `; + const result = findFirstTabbable(container); + expect(result?.id).toBe('first'); + }); + + it('should find tabbable element with explicit tabindex', () => { + container.innerHTML = ` +
+ Tabbable span + +
+ `; + const result = findFirstTabbable(container); + expect(result?.id).toBe('tabbable-span'); + }); + }); + + describe('findNextTabbable', () => { + it('should return undefined when current is null', () => { + expect(findNextTabbable(null)).toBeUndefined(); + }); + + it('should return undefined when current is undefined', () => { + expect(findNextTabbable(undefined)).toBeUndefined(); + }); + + it('should return current element when it has no parent', () => { + const isolated = document.createElement('button'); + expect(findNextTabbable(isolated)).toBe(isolated); + }); + + it('should throw error when current element is not tabbable', () => { + container.innerHTML = '
Not tabbable
'; + const notTabbable = container.querySelector('#not-tabbable') as HTMLElement; + + expect(() => findNextTabbable(notTabbable)).toThrow( + 'Current element is not tabbable, so can\'t find relative tabbable element' + ); + }); + + it('should find the next tabbable element in same container', () => { + container.innerHTML = ` +
+ + + +
+ `; + const first = container.querySelector('#first') as HTMLElement; + const result = findNextTabbable(first); + expect(result?.id).toBe('second'); + }); + + it('should wrap to first element when at the end', () => { + container.innerHTML = ` +
+ + + +
+ `; + const third = container.querySelector('#third') as HTMLElement; + const result = findNextTabbable(third); + expect(result?.id).toBe('first'); + }); + + it('should traverse up the DOM when no next element in current container', () => { + container.innerHTML = ` +
+ +
+ +
+ +
+ `; + const current = container.querySelector('#current') as HTMLElement; + const result = findNextTabbable(current); + expect(result?.id).toBe('after'); + }); + + it('should handle nested containers correctly', () => { + container.innerHTML = ` +
+ +
+
+ +
+
+ +
+ `; + const nested = container.querySelector('#nested') as HTMLElement; + const result = findNextTabbable(nested); + expect(result?.id).toBe('last'); + }); + + it('should work with mixed tabbable elements', () => { + container.innerHTML = ` +
+ + + Link + + +
+ `; + const button = container.querySelector('#button') as HTMLElement; + const input = findNextTabbable(button); + expect(input?.id).toBe('input'); + const link = findNextTabbable(input); + expect(link?.id).toBe('link'); + const select = findNextTabbable(link); + expect(select?.id).toBe('select'); + const textarea = findNextTabbable(select); + expect(textarea?.id).toBe('textarea'); + const buttonAgain = findNextTabbable(textarea); + expect(buttonAgain?.id).toBe('button'); // wraps back to first element + }); + + it('should handle elements with custom tabindex', () => { + container.innerHTML = ` +
+ +
Div
+ Span +
+ `; + // Note: tabbable library handles tabindex ordering + const button = container.querySelector('#button') as HTMLElement; + const span = findNextTabbable(button); + expect(span?.id).toBe('span'); + }); + + it('should handle single tabbable element by wrapping to itself', () => { + container.innerHTML = ` +
+ +
+ `; + const only = container.querySelector('#only') as HTMLElement; + const result = findNextTabbable(only); + expect(result?.id).toBe('only'); + }); + + it('should work when starting from deeply nested element', () => { + container.innerHTML = ` +
+ +
+
+
+
+ +
+
+
+
+ +
+ `; + const deep = container.querySelector('#deep') as HTMLElement; + const result = findNextTabbable(deep); + expect(result?.id).toBe('last'); + }); + }); +}); diff --git a/frontend/viewer/src/lib/utils/tabbable.ts b/frontend/viewer/src/lib/utils/tabbable.ts index beab17883c..e859477c7d 100644 --- a/frontend/viewer/src/lib/utils/tabbable.ts +++ b/frontend/viewer/src/lib/utils/tabbable.ts @@ -1,4 +1,7 @@ -import {isTabbable} from 'tabbable'; +import {isTabbable, tabbable} from 'tabbable'; + +// Use 'none' for tests (jsdom) and 'full' for production +const displayCheck: 'none' | 'full' = typeof process !== 'undefined' && process.env?.NODE_ENV === 'test' ? 'none' : 'full'; export function findFirstTabbable(container: Element | null | undefined): HTMLElement | undefined { if (!container) { @@ -8,10 +11,40 @@ export function findFirstTabbable(container: Element | null | undefined): HTMLEl container, NodeFilter.SHOW_ELEMENT, { - acceptNode: node => isTabbable(node as HTMLElement, {displayCheck: 'full'}) + acceptNode: node => isTabbable(node as HTMLElement, {displayCheck}) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP } ); return walker.nextNode() as HTMLElement; } + +export function findNextTabbable( + current: HTMLElement | SVGElement | null | undefined +): HTMLElement | SVGElement | undefined { + if (!current?.parentElement) { + return current ?? undefined; + } + + if (!isTabbable(current, {displayCheck})) { + throw new Error('Current element is not tabbable, so can\'t find relative tabbable element'); + } + + let container = current.parentElement; + let tabbables = tabbable(container, {displayCheck}); + while (tabbables.length === 0 // should never happen, but whatever + || tabbables.at(-1) === current // we're still last, so haven't found a "next" yet + ) { + if (!container.parentElement) return tabbables[0]; // loop back to first + container = container.parentElement; + tabbables = tabbable(container, {displayCheck}); + } + + const currentIndex = tabbables.indexOf(current); + if (currentIndex === -1) { + throw new Error('Current tabbable element should always be in an ancestor\'s list of tabbables'); + } + + return tabbables[currentIndex + 1] ?? + tabbables[0]; // loop back to first +} diff --git a/frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte b/frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte index db40c51647..b058e3c739 100644 --- a/frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte +++ b/frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte @@ -247,3 +247,14 @@ {/snippet} + + + {#snippet template()} +
+ + + + +
+ {/snippet} +
diff --git a/frontend/viewer/vitest.config.ts b/frontend/viewer/vitest.config.ts index cbbfe6656b..155ebde1a5 100644 --- a/frontend/viewer/vitest.config.ts +++ b/frontend/viewer/vitest.config.ts @@ -34,7 +34,7 @@ export default defineConfig({ svelte(), ], test: { - name: 'browser (non storybook)', + name: 'browser', browser: { enabled: true, headless: true, From 9d7537762f033a8e1b096a38a12ca51d70925013 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 7 Jul 2025 15:19:40 +0200 Subject: [PATCH 5/9] Revert making more like input and less like textarea Wrapping is good --- .../lcm-rich-text-editor.svelte | 25 +++++-------------- .../editor/fields/1_overview.stories.svelte | 17 +++++++++---- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte index 50db569ab2..b4671ebffe 100644 --- a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte +++ b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte @@ -62,9 +62,9 @@ import {IsUsingKeyboard} from 'bits-ui'; import type {IRichSpan} from '$lib/dotnet-types/generated-types/MiniLcm/Models/IRichSpan'; import {on} from 'svelte/events'; - import InputShell from '../ui/input/input-shell.svelte'; import {IsMobile} from '$lib/hooks/is-mobile.svelte'; import {findNextTabbable} from '$lib/utils/tabbable'; + import {inputVariants} from '../ui/input/input.svelte'; let { value = $bindable(), @@ -117,6 +117,7 @@ editor.updateState(newState); }, attributes: { + class: inputVariants({class: 'min-h-10 h-auto'}), // todo: the distribution of props between the editor and the elementRef is not good // there should probably be a wrapper component that provides the elementRef to this one ...(id ? {id} : {}), @@ -307,23 +308,17 @@ // the caret is not intelligently placed at the end of the text (and on the correct line if multi-line) // Everything else seems good } - - const wrapperClasses = 'px-3 min-h-10 h-max block overflow-hidden cursor-text place-content-center focus:ring-2'; - {#if label}
- +
{:else} - +
{/if} diff --git a/frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte b/frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte index b058e3c739..e57337f293 100644 --- a/frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte +++ b/frontend/viewer/src/stories/editor/fields/1_overview.stories.svelte @@ -3,6 +3,7 @@ import {defineMeta} from '@storybook/addon-svelte-csf'; import * as Editor from '$lib/components/editor'; import {writingSystems as demoWritingSystems} from '$lib/demo-entry-data'; + import {inputVariants} from '$lib/components/ui/input/input.svelte'; const { Story } = defineMeta({ title: 'editor/fields/overview', @@ -250,11 +251,17 @@ {#snippet template()} -
- - - - +
Editable
+
+ + + +
+
Readonly
+
+ + +
{/snippet} From fd5583580c0cfa4877d3b514d4f61e369e38f00c Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 7 Jul 2025 15:29:21 +0200 Subject: [PATCH 6/9] Add inline clarifying comments --- .../components/lcm-rich-text-editor/lcm-rich-text-editor.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte index b4671ebffe..af9028833a 100644 --- a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte +++ b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte @@ -20,6 +20,7 @@ span: { selectable: false, content: 'text*', + // If we remove this Backspace + Delete start removing whole spans inline: true, whitespace: 'pre', toDOM: (node) => { @@ -35,6 +36,7 @@ }, doc: { whitespace: 'pre', + // if we remove this Shift + Enter creates new spans and then Backspace starts removing whole spans inline: true, content: 'span*', attrs: {} From 0de25952af42ea76c56d28e2e3ba32b9188ab0bb Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 8 Jul 2025 09:31:51 +0200 Subject: [PATCH 7/9] Add
so trailing \n is rendered. --- .../lcm-rich-text-editor.svelte | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte index af9028833a..3590feaec1 100644 --- a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte +++ b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte @@ -19,7 +19,7 @@ * */ span: { selectable: false, - content: 'text*', + content: 'text* br?', // If we remove this Backspace + Delete start removing whole spans inline: true, whitespace: 'pre', @@ -34,6 +34,14 @@ //this allows us to update the text property without having to map all the span properties into the schema attrs: {richSpan: {default: {}}, className: {default: ''}} }, + br: { + inline: true, + group: 'inline', + selectable: false, + linebreakReplacement: true, + toDOM: () => ['br'], + parseDOM: [{tag: 'br'}] + }, doc: { whitespace: 'pre', // if we remove this Shift + Enter creates new spans and then Backspace starts removing whole spans @@ -242,16 +250,21 @@ function valueToDoc(): Node { return textSchema.node('doc', {}, value?.spans .filter(s => s.text) - .map(s => richSpanToNode(s)) ?? []); + .map((s, i, all) => richSpanToNode(s, i === all.length - 1)) ?? []); } - function richSpanToNode(s: IRichSpan) { + function richSpanToNode(s: IRichSpan, isLast: boolean): Node { //we must pull text out of what is stored on the node attrs //ProseMirror will keep the text up to date itself, if we store it on the richSpan attr then it will become out of date let {text, ...rest} = s; //if the ws doesn't match expected, or there's more than just the ws key in props const isCustomized = (!!s.ws && !!normalWs && normalWs !== s.ws) || Object.keys(rest).length > 1; - return textSchema.node('span', {richSpan: rest, className: cn(isCustomized && 'customized')}, [textSchema.text(replaceLineSeparatorWithNewLine(text))]); + return textSchema.node('span', {richSpan: rest, className: cn(isCustomized && 'customized')}, [ + textSchema.text(replaceLineSeparatorWithNewLine(text)), + // a
seems to be the only thing that will cause a trailing \n to be rendered + // this is what Prose-Mirror does if inline: false, which we can't use + ...(isLast ? [textSchema.node('br')] : []) + ]); } function richSpanFromNode(node: Node) { From 70274b68ff28de75ea8f9c884dda76847ed9b23d Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 8 Jul 2025 09:45:33 +0200 Subject: [PATCH 8/9] Slight mobile height standardization --- .../components/lcm-rich-text-editor/lcm-rich-text-editor.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte index 3590feaec1..da4c5f6f60 100644 --- a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte +++ b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte @@ -127,7 +127,7 @@ editor.updateState(newState); }, attributes: { - class: inputVariants({class: 'min-h-10 h-auto'}), + class: inputVariants({class: 'min-h-10 h-auto pb-1.5'}), // todo: the distribution of props between the editor and the elementRef is not good // there should probably be a wrapper component that provides the elementRef to this one ...(id ? {id} : {}), From 6d0307d3b92fc6256a21acd615774429532df6ca Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 9 Jul 2025 10:45:01 +0700 Subject: [PATCH 9/9] resolve merge issues --- .../lcm-rich-text-editor/editor-schema.ts | 28 +++++++++-- .../lcm-rich-text-editor.svelte | 50 +------------------ 2 files changed, 25 insertions(+), 53 deletions(-) diff --git a/frontend/viewer/src/lib/components/lcm-rich-text-editor/editor-schema.ts b/frontend/viewer/src/lib/components/lcm-rich-text-editor/editor-schema.ts index c8f4e671d9..6a9856070a 100644 --- a/frontend/viewer/src/lib/components/lcm-rich-text-editor/editor-schema.ts +++ b/frontend/viewer/src/lib/components/lcm-rich-text-editor/editor-schema.ts @@ -1,10 +1,14 @@ import {Schema} from 'prosemirror-model'; import {gt} from 'svelte-i18n-lingui'; import {cn} from '$lib/utils'; - +/* + whitespace: pre tells prose-mirror how to parse the dom, not how to render it, that's our job + */ export const textSchema = new Schema({ nodes: { - text: {}, + text: { + whitespace: 'pre', + }, /** * Note: it seems that our spans likely should have been modeled as "marks" rather than "nodes". * Conceptually our users "mark" up a text. @@ -13,7 +17,9 @@ export const textSchema = new Schema({ * */ span: { selectable: false, - content: 'text*', + content: 'text* br?', + // If we remove this Backspace + Delete start removing whole spans + inline: true, whitespace: 'pre', toDOM: (node) => { return ['span', { @@ -28,6 +34,20 @@ export const textSchema = new Schema({ //this allows us to update the text property without having to map all the span properties into the schema attrs: {richSpan: {default: {}}, className: {default: ''}} }, - doc: {content: 'span*', attrs: {}} + br: { + inline: true, + group: 'inline', + selectable: false, + linebreakReplacement: true, + toDOM: () => ['br'], + parseDOM: [{tag: 'br'}] + }, + doc: { + whitespace: 'pre', + // if we remove this Shift + Enter creates new spans and then Backspace starts removing whole spans + inline: true, + content: 'span*', + attrs: {} + } } }); diff --git a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte index 9ec510cbaf..b6d8bf38f9 100644 --- a/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte +++ b/frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte @@ -1,55 +1,7 @@