From 2085dd33357b101639d90f2162729e2f05278efc Mon Sep 17 00:00:00 2001 From: M <> Date: Mon, 14 Sep 2026 14:10:49 +0200 Subject: [PATCH 1/2] CON-20: replace a typed-out :smile: on its closing colon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the `:` dropdown converted a shortcode. Somebody who knows the name and types it through β€” or pastes a line out of a chat log β€” kept `:smile:` in the text, and the page then showed the shortcode where every other client shows the character. docs/13 left this open with the reason to be careful named: it "risks firing inside things like a:b:c". Three guards answer that, and they are the whole substance of the new module: - The opening colon must sit on a word boundary β€” start of line, whitespace or an opening bracket. That is the dropdown's own rule, and it is what leaves a:b:c, 12:30:45 and host:8080/x:y: as typed: in each of them a word character stands in front of the colon that would open a shortcode. - The name must be one of ours. An unknown :foo: stays text; the curated list being small is the feature here, not the limitation. - Never inside code, where a shortcode is a string literal, a Ruby symbol or a YAML key. Known limit, written down: an inline span still being typed has no closing backtick, so the parser sees no span and the replacement does fire inside it. Closed code is caught. It runs as an inputHandler, so the typed colon never reaches the document and the replacement is one change β€” one Ctrl+Z brings :smile: back whole instead of peeling off a colon. Co-Authored-By: Claude Opus 5 --- docs/13-editing.md | 28 +++++- src/ui/MarkdownEditor.tsx | 2 + src/ui/editor-emoji-replace.test.ts | 130 ++++++++++++++++++++++++++++ src/ui/editor-emoji-replace.ts | 115 ++++++++++++++++++++++++ 4 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 src/ui/editor-emoji-replace.test.ts create mode 100644 src/ui/editor-emoji-replace.ts diff --git a/docs/13-editing.md b/docs/13-editing.md index d123042..b5106b6 100644 --- a/docs/13-editing.md +++ b/docs/13-editing.md @@ -410,6 +410,31 @@ 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, and a line pasted from a chat log stops being the one place in the +wiki that still shows `:tada:` where every other client shows the character. + +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 own rule, 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. One limit worth knowing: an inline span still being + typed has no closing backtick yet, so the parser sees no span and the + replacement does fire inside it. Closed code β€” all code that has been written, + pasted or reopened β€” is caught. + +It happens as a single change, so one Ctrl+Z brings `:smile:` back whole rather +than peeling off a colon. + ### Insert menu β€” `/` `/` at the start of a line opens a menu of blocks: **Table**, **Image / @@ -592,9 +617,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..da50399 --- /dev/null +++ b/src/ui/editor-emoji-replace.test.ts @@ -0,0 +1,130 @@ +// @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 { 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', () => { + expect(closing('a:b')).toBeNull() + expect(closing('12:30')).toBeNull() + expect(closing('https://host:8080')).toBeNull() + }) + + 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: 'πŸ˜„' }) + }) +}) + +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('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..742fc38 --- /dev/null +++ b/src/ui/editor-emoji-replace.ts @@ -0,0 +1,115 @@ +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 same rule the `:` + * dropdown already uses, 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 `editor-slash.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. + * + * The limit worth knowing: an inline code span the writer is still typing has + * no closing backtick yet, so the parser does not see a span at all and the + * replacement does fire inside it. Closed code β€” which is all code that has + * been written, pasted or reopened β€” is what this catches. + */ +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 +} + +/** + * 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)) 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, which is also one + * step for undo β€” Ctrl+Z brings the whole `:smile:` back, not a colon. + */ +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 +}) From ace72e1c0a3b4fd5edee15cf178e97ae42dbedb3 Mon Sep 17 00:00:00 2001 From: M <> Date: Mon, 14 Sep 2026 21:37:50 +0200 Subject: [PATCH 2/2] CON-20: count the backticks the parser cannot see yet, and read past the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inline code span that is still being typed has no closing backtick, so lezer sees a plain paragraph and the replacement fired inside it β€” swallowing a code literal somebody meant to keep. Closed code is still caught by the syntax tree; an open span is caught by counting the backticks on the line, which is the only thing that can answer while the span is incomplete. The look-back is a 64-character slice, not a line start, so a word running past it came out looking like a word boundary and `a:b:c` was replaced again on a long line. The character in front of the colon is read from the line now. The boundary rule also admits `[` and `{`, and the tests no longer lean on unknown names: with `a:b` the name guard answers first, so every one of those rows would have passed with the boundary check deleted. docs/13-editing.md said a pasted chat log stops showing `:tada:` and that one Ctrl+Z brings `:smile:` back whole. Neither is true β€” it is the typed colon that converts, and that colon never enters the document. --- docs/13-editing.md | 27 +++++----- src/ui/editor-emoji-replace.test.ts | 79 +++++++++++++++++++++++++++-- src/ui/editor-emoji-replace.ts | 67 +++++++++++++++++++----- 3 files changed, 144 insertions(+), 29 deletions(-) diff --git a/docs/13-editing.md b/docs/13-editing.md index b5106b6..6da3767 100644 --- a/docs/13-editing.md +++ b/docs/13-editing.md @@ -413,27 +413,30 @@ 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, and a line pasted from a chat log stops being the one place in the -wiki that still shows `:tada:` where every other client shows the character. +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 own rule, 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. + 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. One limit worth knowing: an inline span still being - typed has no closing backtick yet, so the parser sees no span and the - replacement does fire inside it. Closed code β€” all code that has been written, - pasted or reopened β€” is caught. - -It happens as a single change, so one Ctrl+Z brings `:smile:` back whole rather -than peeling off a colon. + 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 β€” `/` diff --git a/src/ui/editor-emoji-replace.test.ts b/src/ui/editor-emoji-replace.test.ts index da50399..c67b4a2 100644 --- a/src/ui/editor-emoji-replace.test.ts +++ b/src/ui/editor-emoji-replace.test.ts @@ -4,6 +4,7 @@ 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' @@ -61,9 +62,30 @@ describe('emojiReplacement', () => { }) it('leaves a:b:c alone β€” the colon has a word character in front of it', () => { - expect(closing('a:b')).toBeNull() - expect(closing('12:30')).toBeNull() - expect(closing('https://host:8080')).toBeNull() + // 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', () => { @@ -103,6 +125,21 @@ describe('emojiReplacement', () => { 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', () => { @@ -112,6 +149,42 @@ describe('emojiOnTyping', () => { 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 diff --git a/src/ui/editor-emoji-replace.ts b/src/ui/editor-emoji-replace.ts index 742fc38..18464b7 100644 --- a/src/ui/editor-emoji-replace.ts +++ b/src/ui/editor-emoji-replace.ts @@ -19,10 +19,10 @@ import { EMOJI } from './emoji' * 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 same rule the `:` - * dropdown already uses, 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. + * 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. @@ -36,13 +36,9 @@ 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 `editor-slash.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. - * - * The limit worth knowing: an inline code span the writer is still typing has - * no closing backtick yet, so the parser does not see a span at all and the - * replacement does fire inside it. Closed code β€” which is all code that has - * been written, pasted or reopened β€” is what this catches. + * 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']) @@ -57,6 +53,43 @@ function inCode(state: EditorState, pos: number): boolean { 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. @@ -88,7 +121,7 @@ export function emojiReplacement( const char = BY_NAME.get(match[1]!.toLowerCase()) if (char === undefined) return null - if (inCode(state, open)) return null + if (inCode(state, open) || inUnclosedCodeSpan(state, line.from, open)) return null return { from: open, to: from, insert: char } } @@ -96,8 +129,14 @@ export function emojiReplacement( /** * 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, which is also one - * step for undo β€” Ctrl+Z brings the whole `:smile:` back, not a colon. + * 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)