Skip to content
Open
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
31 changes: 28 additions & 3 deletions docs/13-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down Expand Up @@ -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.
2 changes: 2 additions & 0 deletions src/ui/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -734,6 +735,7 @@ export function MarkdownEditor({
}),
syntaxHighlighting(codeHighlight),
normaliseTaskMarker,
emojiOnTyping,
liveMarkdown,
autocompletion({
override: [
Expand Down
203 changes: 203 additions & 0 deletions src/ui/editor-emoji-replace.test.ts
Original file line number Diff line number Diff line change
@@ -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:')
})
})
Loading
Loading