Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 101 additions & 13 deletions docs/specs/markdown-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ Markdown applies to the **`content` of text notes** in full, and to **list-note
item text** in an inline-only subset (§2.1):

- **Note titles are plain.** They are rendered as text everywhere.
- **List-item Markdown renders on display surfaces only.** Note cards, mobile's
read-only editor and the collapsed-completed parent label render it; the
editable row still shows its source, because it is an always-live input with no
preview mode. Closing that gap is
[#824](https://github.com/hanzei/jot/issues/824).
- **List-item Markdown renders everywhere on the webapp, and on display surfaces
on mobile.** Note cards, mobile's read-only editor and the collapsed-completed
parent label render it on both clients. The webapp's *editable* row renders it
too, swapping to source while it holds the caret (§1.2). Mobile's editable row
still shows source; closing that gap is
[#867](https://github.com/hanzei/jot/issues/867).

### 1.1 Note cards render links as text

Expand All @@ -35,15 +36,96 @@ colour that only signals "link" fails anyone who cannot use it. So the label
renders exactly as the surrounding text.

The open note is unaffected: the webapp's modal preview and mobile's editor both
render live links, which is where a reader who wants the link already is. One
consequence worth naming: because the *editable* list-item row shows source
(above), a link typed into a list item now has no live surface on the webapp at
all until [#824](https://github.com/hanzei/jot/issues/824) gives that row a view
mode. Mobile still has one, in its read-only editor.
render live links, which is where a reader who wants the link already is. An
*editable* list-item row is the one other place a link stays inert, for a
different reason — §1.2.

Both clients render the same feature set from the same source string, so a note
written on a phone reads identically in a browser and the other way round.

### 1.2 The webapp's editable row swaps between rendered and source

A list-item row on the webapp shows its Markdown rendered until it holds the
caret, and its source for exactly as long as it does. Type `**Milk**`, move
away, and the row reads **Milk**; click back into it and it reads `**Milk**`
again, with the caret where you clicked.

**Focused and editing are the same state.** That is the decision the rest
follows from. Every keystroke a row handles — Tab to indent, Enter to split,
arrows to move between rows and through the completed-item suggestions — stays
on a real `<textarea>`, because a row that has the caret *is* a textarea. There
is no render-mode duplicate of any of it, and no focusable non-interactive
element in the markup for a screen reader to find or axe to flag.

Four consequences, each of which is a trap avoided rather than a preference:

- **The textarea is never unmounted**, only moved out of flow and faded to
`opacity-0`. Everything that reaches for a row imperatively — NoteModal's
Enter-to-split, its arrow navigation, its "add item" focus, all through the
`itemInputRefs` map — keeps working on a row that happens to be rendered, and
the height the field comes back at was measured while it was on screen rather
than at the moment of focus. `visibility: hidden` and `display: none` both
remove an element from the focus order, so neither can be used here.
- **A row only swaps when rendering changes something.** `buy milk` renders to
`buy milk`, so that row keeps the always-live input it has always had. So do
`# not a heading` and `![alt](url)`, which §2.1 shows as literal source. Only
a row whose author typed markup that actually renders pays for any of this,
which is what keeps a plain list exactly as it was.
- **An editable row's links are inert; a read-only row's are live.** One click
in an editable row already means "put the caret here", and a second meaning on
the same pixel has no way to resolve itself — so the label renders as ordinary
text and nothing looks followable, the same outcome as a card (§1.1) reached
by a different route. A read-only row has no caret to place, so its links work
and it drops the hidden textarea entirely rather than leaving a focusable copy
of the text behind the rendered one.
- **Completed rows render like any other**, `~~strike~~` and all, which §2.1
already accepted for display surfaces. A struck word inside an already-struck
row is indistinguishable; that is the cost of the subset staying a subset.

**A click has to place the caret itself.** The user points at character 4 of
`buy milk` and the field holds `buy **milk**`, where that character is at 6.
The browser maps the point to a position in the rendered DOM; `inlineSourceOffset`
maps that back through the source spans `normalizeInlineTokens` records. Without
it the caret lands at 0 on every click — which is what the *text-note* editor
does today, and is tolerable there only because it happens once per note instead
of once per row, and because the next click lands in a real textarea and
corrects it.

**Both forms must be the same height**, or every click shifts the rows below it.
They share one class list for width, padding and wrapping, and one property that
matters more than it looks: **both are `block`**.

That is there to remove the line box from the question rather than to match it.
A textarea is an `inline-block` by default, so it sits on a baseline and the line
box around it reserves descender space underneath — and how much is a property of
the platform's font and UA stylesheet. Reproducing that on a span is possible
(`overflow` moves a baseline to the bottom margin edge, CSS 2.1 §10.8.1) and was
the first attempt, but it only held on the font it was measured against: on
Windows the textarea did not reserve the space and the span did, so every row
grew about 7px the moment it lost focus. Blocks have no baseline to disagree
about, and each box is then `lines × line-height + padding` from the same
inherited metrics — equal on any platform.

The same trap has a second entrance, inside the rendered form. An inline box is
as tall as its `line-height` and sits around the shared baseline, so a child in a
*different font* is offset differently from the line's strut and can push the
line box past it. `.markdown-inline code` sets `font-mono`, which made a row
containing `` `code` `` taller rendered than in source — by 0.6px on one font and
who knows what on another. `leading-none` on it keeps its inline box under the
strut on any font, and does not change the chip, since an inline element's
background paints its content area rather than its line box.

Where the two forms genuinely differ — markers moving a wrap point, so the source
occupies more lines — the change is animated over 120ms, behind
`prefersReducedMotion`.

**A mouse drag does not collapse the row it starts on.** The grip prevents the
default mousedown, so grabbing it never moves focus off the field. Otherwise the
row would change height in the same tick the `PointerSensor` activates and
dnd-kit measures, and the drag would run against a rect for a size the row no
longer has. A keyboard drag needs no such guard: it arrives by Tab, so the row
has already collapsed and settled before Space starts it.

| | Webapp | Mobile |
|---|---|---|
| Lexer | `marked` (`gfm: true`, `breaks: true`) | `marked` (`gfm: true`, `breaks: true`) |
Expand Down Expand Up @@ -300,6 +382,9 @@ spec exists to prevent.
| Shared conformance corpora (both test suites) | `shared/src/markdownCases.ts` |
| Webapp node-to-HTML renderer + tag allowlist | `webapp/src/utils/markdown.ts` |
| Webapp item renderer | `webapp/src/components/InlineMarkdown.tsx` |
| Webapp editable-row swap (§1.2) | `webapp/src/components/SortableItem.tsx` |
| Shared rendered-offset → source-offset map | `inlineSourceOffset` in `shared/src/inlineMarkdown.ts` |
| Webapp click point → rendered offset | `webapp/src/utils/inlineCaret.ts` |
| Mobile block lexing entry point | `mobile/src/utils/markdown.ts` |
| Mobile block renderer (editor) | `mobile/src/components/Markdown.tsx` |
| Mobile card preview renderer | `mobile/src/components/MarkdownPreview.tsx` |
Expand Down Expand Up @@ -462,9 +547,12 @@ The heading button stops at `###`, matching §2: a fourth press would produce an

- **Interactive checkboxes.** Toggling a rendered ☐ would mean writing back into
`content` — a much larger feature than rendering.
- **Markdown in the *editable* list-item row.** The subset renders on display
surfaces (§1); giving the always-live input a view/edit swap is
[#824](https://github.com/hanzei/jot/issues/824).
- **Markdown in mobile's *editable* list-item row.** The webapp's row swaps
between rendered and source (§1.2); mobile's still shows source, because the
same swap there unmounts a focused `TextInput` on every row change and the
software keyboard goes with it. Tracked as
[#867](https://github.com/hanzei/jot/issues/867), which records the decisions
the webapp already made so mobile only has to solve the keyboard problem.
- **Block Markdown in list items.** Not a gap to be filled later — §2.1 explains
why an item cannot hold it.
- **Syntax highlighting** in code blocks. The webapp allowlist drops the
Expand Down
2 changes: 1 addition & 1 deletion mobile/__tests__/ListItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe('ListItem', () => {

// A read-only row is a display surface, so it renders the inline Markdown
// subset; an editable row is an input and keeps showing its source. Rendering
// in the editable row is #824.
// in the editable row is #867.
it('renders markdown in the read-only row', () => {
const { getByTestId, queryByTestId } = render(
<ListItem text="buy **milk**" completed={false} editable={false} />,
Expand Down
4 changes: 3 additions & 1 deletion mobile/src/components/ListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,9 @@ function ListItem({
{/* A read-only row is a display surface, so it renders the inline
Markdown subset — the same thing the note card shows for this
item. An editable row stays a plain TextInput showing source;
giving it a rendered mode is #824, not this. */}
giving it a rendered mode is #867, not this — the webapp already
swaps (docs/specs/markdown-rendering.md §1.2), and that ticket
records why the same swap here is a keyboard problem first. */}
{editable ? (
<TextInput
ref={inputRef}
Expand Down
2 changes: 1 addition & 1 deletion mobile/src/utils/inlineMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function inlineMarkdownNodes(text: string): InlineNode[] {
*
* A control's accessible name should identify the item the way the user knows it;
* spelling out `**` and backticks is never useful there. This also keeps the
* checkbox's name correct once #824 renders the editable row too.
* checkbox's name correct once #867 renders the editable row too.
*/
export function inlineMarkdownToText(text: string): string {
if (!text.trim()) return text;
Expand Down
119 changes: 119 additions & 0 deletions shared/src/__tests__/inlineMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Lexer } from 'marked';
import {
normalizeInlineTokens,
flattenInlineNodes,
inlineSourceOffset,
inlineRendersAsSource,
INLINE_LEXER_OPTIONS,
type InlineNode,
} from '../inlineMarkdown';
Expand All @@ -15,6 +17,11 @@ function lex(markdown: string): InlineNode[] {
return normalizeInlineTokens(Lexer.lexInline(markdown, INLINE_LEXER_OPTIONS));
}

/** The same nodes, annotated with where each one came from in `markdown`. */
function lexWithSource(markdown: string): InlineNode[] {
return normalizeInlineTokens(Lexer.lexInline(markdown, INLINE_LEXER_OPTIONS), 0);
}

/**
* Renders a node tree to a compact string so a case's expectation reads as one
* line. `text("buy ")strong(text("milk"))` is easier to review against the spec
Expand Down Expand Up @@ -142,4 +149,116 @@ describe('normalizeInlineTokens', () => {
it('turns a newline into a br node, not a text newline', () => {
expect(summarize(lex('a\nb'))).toBe('text("a")brtext("b")');
});

describe('source tracking', () => {
it('records no spans unless a source offset is passed', () => {
for (const node of lex('buy **milk**')) {
expect(node.src).toBeUndefined();
}
});

it('spans the content of a construct, not its delimiters', () => {
const nodes = lexWithSource('buy **milk**');
const text = nodes[0]!;
const strong = nodes[1]!;
expect(text.src).toEqual({ start: 0, end: 4 });
// The strong node covers `**milk**`; the text inside it covers `milk`.
expect(strong.src).toEqual({ start: 4, end: 12 });
expect(strong.type === 'strong' && strong.children[0]?.src).toEqual({ start: 6, end: 10 });
});

it('spans code content inside its backticks', () => {
const code = lexWithSource('run `npm ci`')[1]!;
expect(code.src).toEqual({ start: 5, end: 12 });
});
});

describe('inlineSourceOffset', () => {
/** The source offset a click at `renderedOffset` characters in should give. */
function offsetIn(markdown: string, renderedOffset: number): number {
return inlineSourceOffset(lexWithSource(markdown), renderedOffset, markdown.length);
}

it('maps a click inside bold text past the markers', () => {
// Rendered "buy milk"; the m of milk is at 4 on screen and 6 in source.
expect(offsetIn('buy **milk**', 0)).toBe(0);
expect(offsetIn('buy **milk**', 4)).toBe(6);
expect(offsetIn('buy **milk**', 6)).toBe(8);
});

it('maps a click inside code past the backtick', () => {
expect(offsetIn('run `npm ci`', 4)).toBe(5);
});

it('maps a click inside a link label past the bracket', () => {
expect(offsetIn('[docs](https://example.com)', 0)).toBe(1);
expect(offsetIn('[docs](https://example.com)', 2)).toBe(3);
});

it('puts a click past the end of the rendered text at the end of the source', () => {
expect(offsetIn('buy **milk**', 8)).toBe(12);
expect(offsetIn('buy **milk**', 99)).toBe(12);
expect(offsetIn('[docs](https://example.com)', 4)).toBe(27);
});

it('counts a line break as one position', () => {
expect(offsetIn('a\nb', 1)).toBe(1);
expect(offsetIn('a\nb', 2)).toBe(2);
});

it('keeps a click inside a construct that renders at a different length within it', () => {
// A literal image is rebuilt rather than echoed, so an offset inside it is
// approximate — but it must still land inside the construct.
const source = 'see ![alt](https://example.com/y.png)';
const offset = inlineSourceOffset(lexWithSource(source), 10, source.length);
expect(offset).toBeGreaterThanOrEqual(4);
expect(offset).toBeLessThanOrEqual(source.length);
});

it('falls back to the end of the source when nodes carry no spans', () => {
expect(inlineSourceOffset(lex('buy **milk**'), 4, 12)).toBe(12);
});
});

describe('inlineRendersAsSource', () => {
/** Every corpus case, split by whether rendering it changes what is shown. */
const RENDERS_AS_SOURCE = new Set([
'item-bare-domain',
'item-heading-literal',
'item-bullet-literal',
'item-ordered-literal',
'item-task-literal',
'item-hr-literal',
'item-blockquote-literal',
'item-table-literal',
'item-image',
'item-raw-html',
'item-raw-html-script',
'item-arithmetic',
'item-underscored-word',
'item-ampersand',
'item-plain',
]);

for (const testCase of MARKDOWN_ITEM_CASES) {
it(`${testCase.id}: ${RENDERS_AS_SOURCE.has(testCase.id) ? 'shows its source' : 'renders differently'}`, () => {
expect(inlineRendersAsSource(lex(testCase.markdown), testCase.markdown)).toBe(
RENDERS_AS_SOURCE.has(testCase.id),
);
});
}

it('treats a line break as showing its source', () => {
// The textarea and the rendered form both put "b" on the second line.
expect(inlineRendersAsSource(lex('a\nb'), 'a\nb')).toBe(true);
});

it('treats a dropped escape as a rendering change', () => {
expect(inlineRendersAsSource(lex('\\*x\\*'), '\\*x\\*')).toBe(false);
});

it('treats code as a rendering change even when the characters match', () => {
expect(inlineRendersAsSource(lex('`a`'), '`a`')).toBe(false);
});
});
});
Loading
Loading