diff --git a/docs/13-editing.md b/docs/13-editing.md index d123042..6da3767 100644 --- a/docs/13-editing.md +++ b/docs/13-editing.md @@ -410,6 +410,34 @@ A lone `:` opens nothing. `:` is punctuation far more often than it is the start of an emoji, and the boundary guard also keeps the dropdown out of `https://` and out of `12:30`. +A shortcode typed out in full is replaced as well: the closing `:` of `:smile:` +turns the whole thing into πŸ˜„, without touching the dropdown +(`src/ui/editor-emoji-replace.ts`). Somebody who knows the name types straight +through instead of picking from a list. It is the typed colon that converts, +not the text: a line pasted out of a chat log keeps its `:tada:` until somebody +retypes that last colon. + +The risk this used to wait on is firing where a colon is not a shortcode, and +three guards answer it: + +- **The opening `:` has to sit on a word boundary** β€” start of line, or after + whitespace or an opening bracket. That is the dropdown's rule, extended to + `[` and `{`, and it is what leaves `a:b:c`, `12:30:45` and `host:8080/x:y:` + exactly as typed: in each of them a word character stands in front of the + colon that would open one. +- **The name has to be one we know.** An unknown `:foo:` stays text β€” here the + curated list is an advantage, because a small vocabulary reaches into less + prose than a complete one would. +- **Never inside code.** A shortcode in a code sample is a string literal, a + Ruby symbol or a YAML key. Closed code the parser has recognised is caught by + the syntax tree; a span the writer has only opened β€” `` `a :smile `` still + has no closing backtick, so there is no span to find β€” is caught by counting + the backticks on the line instead. + +It happens as a single change: one Ctrl+Z removes the emoji together with the +shortcode it replaced. It cannot put `:smile:` back whole, because the closing +colon never entered the document. + ### Insert menu β€” `/` `/` at the start of a line opens a menu of blocks: **Table**, **Image / @@ -592,9 +620,6 @@ there for the second question β€” "how do I get a quote?" β€” not the first one. - **Macros and layouts.** The insert menu (`/table`, `/image`, `/code`, `/quote`, `/divider`) holds the blocks the plain editor needs; the wiki-style macro and layout entries are still not built. -- **Auto-replacing a typed-out `:smile:`.** Only the dropdown converts a - shortcode today. Doing it on the text as well risks firing inside things like - `a:b:c`, so it waits for a reason. - **Real-time collaboration.** Unchanged from [05](05-versioning-history.md): two people editing at once are resolved by the optimistic lock and the three-way merge, not by a CRDT. diff --git a/src/ui/MarkdownEditor.tsx b/src/ui/MarkdownEditor.tsx index 1a34dee..d6f5713 100644 --- a/src/ui/MarkdownEditor.tsx +++ b/src/ui/MarkdownEditor.tsx @@ -22,6 +22,7 @@ import { useTheme } from '../theme/theme' import { liveMarkdown } from './markdown-live' import { needsLineUnderTable } from './editor-table' import { emojiCompletion, mentionCompletion } from './editor-complete' +import { emojiOnTyping } from './editor-emoji-replace' import { slashInsertCompletion } from './editor-slash' import { NO_SETEXT_HEADINGS } from './markdown-flavour' @@ -734,6 +735,7 @@ export function MarkdownEditor({ }), syntaxHighlighting(codeHighlight), normaliseTaskMarker, + emojiOnTyping, liveMarkdown, autocompletion({ override: [ diff --git a/src/ui/editor-emoji-replace.test.ts b/src/ui/editor-emoji-replace.test.ts new file mode 100644 index 0000000..c67b4a2 --- /dev/null +++ b/src/ui/editor-emoji-replace.test.ts @@ -0,0 +1,203 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { ensureSyntaxTree } from '@codemirror/language' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { history, undo } from '@codemirror/commands' +import { NO_SETEXT_HEADINGS } from './markdown-flavour' +import { emojiOnTyping, emojiReplacement } from './editor-emoji-replace' + +/** + * docs/13 left this open with one named risk β€” firing inside `a:b:c` β€” so most + * of what is pinned here is where the rule must *not* fire. A replacement that + * happens once too often eats somebody's timestamp, their YAML key or their + * Ruby symbol, and it does it silently. + */ +function mount(doc: string) { + const parent = document.createElement('div') + document.body.appendChild(parent) + const view = new EditorView({ + state: EditorState.create({ + doc, + extensions: [ + markdown({ base: markdownLanguage, extensions: NO_SETEXT_HEADINGS }), + emojiOnTyping, + ], + }), + parent, + }) + // The code guard reads the syntax tree, and a lazily parsed one would report + // no code block at all. + ensureSyntaxTree(view.state, view.state.doc.length, 5000) + return view +} + +/** What typing `:` at the end of `doc` would do. */ +function closing(doc: string, text = ':') { + const view = mount(doc) + const end = view.state.doc.length + const change = emojiReplacement(view.state, end, end, text) + view.destroy() + return change +} + +/** The same, but through the editor β€” the wiring, not just the rule. */ +function type(doc: string, text = ':'): string { + const view = mount(doc) + const end = view.state.doc.length + view.dispatch({ selection: { anchor: end } }) + const handled = view.state + .facet(EditorView.inputHandler) + .some((handler) => handler(view, end, end, text, () => view.state.update({}))) + const result = handled ? view.state.doc.toString() : `${doc}${text}` + view.destroy() + return result +} + +describe('emojiReplacement', () => { + it('turns a shortcode typed out in full into the character', () => { + expect(closing(':smile')).toEqual({ from: 0, to: 6, insert: 'πŸ˜„' }) + expect(closing('ship it :rocket')).toEqual({ from: 8, to: 15, insert: 'πŸš€' }) + }) + + it('leaves a:b:c alone β€” the colon has a word character in front of it', () => { + // Every name here is one we know, deliberately: with `a:b` or `12:30` the + // name guard answers first and the boundary rule β€” the one the ticket is + // actually about β€” is never reached, so the case would pass with the + // boundary check deleted. + expect(closing('a:smile')).toBeNull() + expect(closing('a:b:smile')).toBeNull() + expect(closing('12:30:cat')).toBeNull() + expect(closing('https://host:smile')).toBeNull() + expect(closing('host:8080/x:cat')).toBeNull() + }) + + it('reads the character in front of the colon even past the look-back window', () => { + // The 64-character window is a slice, not a line start. A word running + // past it must not come out looking like a boundary, or `a:b:c` would be + // replaced again on long lines. + const long = 'x'.repeat(80) + expect(closing(`${long}:smile`)).toBeNull() + expect(closing(`${long} :smile`)).toEqual({ from: 81, to: 87, insert: 'πŸ˜„' }) + }) + + it('replaces mid-line, with text still standing behind the caret', () => { + const view = mount('ship it :rocket today') + expect(emojiReplacement(view.state, 15, 15, ':')).toEqual({ from: 8, to: 15, insert: 'πŸš€' }) + view.destroy() + }) + + it('opens after a bracket as well, where a shortcode really can start', () => { + expect(closing('(:tada')).toEqual({ from: 1, to: 6, insert: 'πŸŽ‰' }) + }) + + it('leaves a shortcode nobody knows as it was typed', () => { + expect(closing(':notanemoji')).toBeNull() + expect(closing(':')).toBeNull() + }) + + it('accepts the name however it was capitalised', () => { + expect(closing(':SMILE')?.insert).toBe('πŸ˜„') + }) + + it('fires on the typed colon only, never on a paste or over a selection', () => { + expect(closing(':smile', ':smile:')).toBeNull() + expect(closing(':smile', 'x')).toBeNull() + + const view = mount('hello :smile') + // a selection, not a cursor: somebody is replacing text, not writing + expect(emojiReplacement(view.state, 6, 12, ':')).toBeNull() + view.destroy() + }) + + it('stays out of code, where a shortcode is a literal', () => { + expect(closing('```\nkey: :smile')).toBeNull() + expect(closing(' indented :smile')).toBeNull() + // an inline span that is already closed, with the caret typing inside it + const view = mount('`a :smile:`') + expect(emojiReplacement(view.state, 9, 9, ':')).toBeNull() + view.destroy() + + // …and it is the code around them that stops those three, not their text: + // the same words outside a code block are replaced as usual. + expect(closing('key: :smile')).toEqual({ from: 5, to: 11, insert: 'πŸ˜„' }) + expect(closing('indented :smile')).toEqual({ from: 9, to: 15, insert: 'πŸ˜„' }) + expect(closing('a :smile')).toEqual({ from: 2, to: 8, insert: 'πŸ˜„' }) + }) + + it('stays out of an inline code span that is still being typed', () => { + // The tree cannot answer this one: at the moment the closing colon is + // typed the line reads `` `a :smile `` and lezer sees a plain paragraph, + // so the backticks have to be counted. Before that, the replacement fired + // and swallowed a code literal somebody meant to keep. + expect(closing('`a :smile')).toBeNull() + expect(closing('write `:smile')).toBeNull() + + // A span that was opened and closed again leaves the rest of the line free… + expect(closing('`code` then :smile')).toEqual({ from: 12, to: 18, insert: 'πŸ˜„' }) + // …and a run of two backticks is closed by a run of two, not by the single + // one it encloses. + expect(closing('``a ` b`` then :smile')).toEqual({ from: 15, to: 21, insert: 'πŸ˜„' }) + }) +}) + +describe('emojiOnTyping', () => { + it('is wired into the editor: typing the closing colon replaces the shortcode', () => { + // jsdom does not drive CodeMirror's own text input, so the handler the + // editor would call is called directly β€” the registration is what matters. + expect(type('ship it :rocket')).toBe('ship it πŸš€') + }) + + it('undoes to the shortcode without its closing colon', () => { + // What undo can give back, pinned, because docs/13 used to promise more + // than it does: the typed colon never enters the document, so no Ctrl+Z + // can produce `:smile:`. `newGroupDelay: 0` keeps the steps apart here β€” + // with the default grouping the one undo takes the typing with it too. + const parent = document.createElement('div') + document.body.appendChild(parent) + const view = new EditorView({ + state: EditorState.create({ + doc: '', + extensions: [ + markdown({ base: markdownLanguage, extensions: NO_SETEXT_HEADINGS }), + history({ newGroupDelay: 0 }), + emojiOnTyping, + ], + }), + parent, + }) + for (const char of ':smile') { + const at = view.state.doc.length + view.dispatch({ + changes: { from: at, insert: char }, + selection: { anchor: at + 1 }, + userEvent: 'input.type', + }) + } + const end = view.state.doc.length + view.state + .facet(EditorView.inputHandler) + .some((handler) => handler(view, end, end, ':', () => view.state.update({}))) + expect(view.state.doc.toString()).toBe('πŸ˜„') + undo(view) + expect(view.state.doc.toString()).toBe(':smile') + view.destroy() + }) + + it('puts the caret behind the emoji, not where the colon would have gone', () => { + const view = mount(':smile') + const end = view.state.doc.length + view.dispatch({ selection: { anchor: end } }) + view.state + .facet(EditorView.inputHandler) + .some((handler) => handler(view, end, end, ':', () => view.state.update({}))) + expect(view.state.selection.main.head).toBe(view.state.doc.length) + view.destroy() + }) + + it('passes an ordinary colon through untouched', () => { + expect(type('12:30')).toBe('12:30:') + expect(type('note')).toBe('note:') + }) +}) diff --git a/src/ui/editor-emoji-replace.ts b/src/ui/editor-emoji-replace.ts new file mode 100644 index 0000000..18464b7 --- /dev/null +++ b/src/ui/editor-emoji-replace.ts @@ -0,0 +1,154 @@ +import { closeCompletion } from '@codemirror/autocomplete' +import { syntaxTree } from '@codemirror/language' +import { EditorSelection } from '@codemirror/state' +import type { EditorState, Extension } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import type { SyntaxNode } from '@lezer/common' +import { EMOJI } from './emoji' + +/** + * `:smile:` typed out in full, turned into πŸ˜„ by the closing colon. + * + * Until now only the `:` dropdown converted a shortcode (`src/ui/editor-complete.ts`); + * somebody who knows the name and types it through β€” or pastes a line from a + * chat log and retypes the last colon β€” kept the shortcode in the text, and + * the page then showed `:smile:` where every other client shows the emoji. + * + * docs/13 listed this as open with the reason to be careful spelled out: it + * "risks firing inside things like `a:b:c`". Three guards answer that, and + * they are the whole substance of this file: + * + * 1. **The opening colon must sit on a word boundary** β€” start of line, or + * after whitespace or an opening bracket. That is the `:` dropdown's rule + * (`(?:^|[\s(])`), extended to `[` and `{`, and it is what keeps `a:b:c`, + * `12:30:45` and `https://host:8080/x:y:` as they were: in each of them the + * colon that would open a shortcode has a word character in front of it. + * 2. **The name must be a shortcode we actually know.** An unknown `:foo:` + * stays text. A curated list is a small vocabulary, which here is a feature: + * the narrower it is, the less prose it can reach into. + * 3. **Never inside code.** A shortcode in a code sample is a string literal, + * a Ruby symbol or a YAML key, and replacing it would corrupt a code block + * rather than decorate it. + */ + +/** Written once, looked up on every keystroke. */ +const BY_NAME = new Map(EMOJI.map((emoji) => [emoji.name, emoji.char])) + +/** + * A shortcode in a code sample is text somebody meant literally. The same set + * as `NO_MENTION` in `markdown-live.ts`, minus `URL`: a colon inside a link + * target never has a boundary in front of it anyway, so the guard above + * already covers it. + */ +const CODE_NODES = new Set(['InlineCode', 'CodeText', 'FencedCode', 'CodeBlock', 'HTMLBlock']) + +function inCode(state: EditorState, pos: number): boolean { + for ( + let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1); + node; + node = node.parent + ) { + if (CODE_NODES.has(node.name)) return true + } + return false +} + +/** + * Is `pos` inside an inline code span the writer has opened but not yet closed? + * + * `inCode` above only knows the code the parser has recognised, and a span + * being typed has no closing backtick yet: while somebody writes `` `:smile: `` + * the document reads `` `:smile `` and lezer sees a plain paragraph. Without + * this the replacement fired inside it and silently ate a code literal they + * meant to keep β€” the one case the ticket names that the tree cannot answer. + * + * So the backticks are counted directly, by CommonMark's own pairing rule: a + * run of n backticks is closed by the next run of exactly n, and runs of other + * lengths in between are literal text. An unclosed run left over means the + * caret sits inside a span. + * + * Bounded to the line the caret is on, like the shortcode window itself. A + * span may run across lines in a paragraph, but then only the *opening* line + * is misread, and the direction of the error is the safe one: an un-replaced + * `:smile:` is an annoyance, a mangled code literal is a bug. + */ +function inUnclosedCodeSpan(state: EditorState, lineFrom: number, pos: number): boolean { + const text = state.sliceDoc(lineFrom, pos) + let open: number | null = null + for (let i = 0; i < text.length; ) { + if (text[i] !== '`') { + i += 1 + continue + } + let end = i + while (end < text.length && text[end] === '`') end += 1 + const run = end - i + if (open === null) open = run + else if (open === run) open = null + i = end + } + return open !== null +} + +/** + * The replacement a closing `:` typed at `from` should make, or `null` for the + * overwhelmingly common case where the colon is just a colon. + * + * Split out from the extension so the guards can be read β€” and tested β€” as + * plain text in, decision out. + */ +export function emojiReplacement( + state: EditorState, + from: number, + to: number, + text: string, +): { from: number; to: number; insert: string } | null { + // Only the plain typed colon. A pasted block and a replaced selection are + // both somebody moving text around, not writing a shortcode. + if (text !== ':' || from !== to) return null + + const line = state.doc.lineAt(from) + // A shortcode is at most a couple of dozen characters. Looking back further + // would only cost time on a long line, and the boundary below is checked + // against the document rather than against the start of this window, so the + // cap cannot invent a boundary that is not there. + const before = state.sliceDoc(Math.max(line.from, from - 64), from) + const match = /:([a-z0-9_+-]+)$/i.exec(before) + if (!match) return null + + const open = from - match[0].length + if (open > line.from && !/[\s([{]/.test(state.sliceDoc(open - 1, open))) return null + + const char = BY_NAME.get(match[1]!.toLowerCase()) + if (char === undefined) return null + if (inCode(state, open) || inUnclosedCodeSpan(state, line.from, open)) return null + + return { from: open, to: from, insert: char } +} + +/** + * The rule, wired to the text cursor. Registered as an `inputHandler` rather + * than as a transaction filter so the typed colon never reaches the document + * at all: the shortcode becomes the emoji in one change. + * + * What that costs is the literal shortcode on undo. The change is annotated + * `input.type` like the typing around it, so history groups them: one Ctrl+Z + * takes the emoji and the `:smile` that was typed before it away together. It + * cannot put `:smile:` back, because the closing colon was never in the + * document. Getting the literal text back would mean keeping the replacement + * out of that group β€” a decision about undo, not about this rule. + */ +export const emojiOnTyping: Extension = EditorView.inputHandler.of((view, from, to, text) => { + const change = emojiReplacement(view.state, from, to, text) + if (!change) return false + + view.dispatch({ + changes: change, + selection: EditorSelection.cursor(change.from + change.insert.length), + scrollIntoView: true, + userEvent: 'input.type', + }) + // The `:` dropdown is open on the shortcode that just stopped existing. + closeCompletion(view) + return true +})