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
2 changes: 2 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"jszip": "^3.10.1",
"lucide-react": "^0.462.0",
"mermaid": "^11.16.0",
"prosemirror-model": "^1.25.9",
"prosemirror-state": "^1.4.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
Expand Down
148 changes: 148 additions & 0 deletions frontend/src/utils/dictationInsert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Schema, type Node as PMNode } from 'prosemirror-model'
import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'
import { describe, expect, it } from 'vitest'

import { insertDictationAtSelection, padDictatedText } from '@/utils/dictationInsert'

const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*' },
image: { group: 'block', atom: true },
text: { group: 'inline' },
hard_break: { group: 'inline', inline: true, leafText: () => '\n' },
mention: { group: 'inline', inline: true, atom: true },
},
marks: { strong: {} },
})

const p = (...content: (string | PMNode)[]) =>
schema.node('paragraph', null, content.map((c) => (typeof c === 'string' ? schema.text(c) : c)))
const bold = (text: string) => schema.text(text, [schema.mark('strong')])

// Builds a one-paragraph-per-arg doc and puts a text cursor at `|` (or a
// range between two `|`s) in the first paragraph that has one.
function stateWithCursor(...paragraphs: string[]) {
let anchor = -1
let head = -1
let pos = 0
const nodes = paragraphs.map((src) => {
const parts = src.split('|')
let text = ''
parts.forEach((part, i) => {
if (i > 0) {
const at = pos + 1 + text.length
if (anchor < 0) anchor = at
else head = at
}
text += part
})
const node = text ? p(text) : p()
pos += node.nodeSize
return node
})
const doc = schema.node('doc', null, nodes)
return EditorState.create({ doc, selection: TextSelection.create(doc, anchor, head < 0 ? anchor : head) })
}

// Dictates each chunk in turn, the way consecutive speech results arrive.
function dictate(state: EditorState, ...chunks: string[]) {
for (const chunk of chunks) {
const tr = state.tr
expect(insertDictationAtSelection(tr, chunk)).toBe(true)
state = state.apply(tr)
}
return state
}

// Paragraph texts with the caret marked as `|`.
function show(state: EditorState) {
const { from } = state.selection
const out: string[] = []
state.doc.forEach((node, offset) => {
const start = offset + 1
const text = node.textBetween(0, node.content.size, undefined, (n) => n.type.spec.leafText?.(n) ?? '@')
out.push(from >= start && from <= start + node.content.size
? text.slice(0, from - start) + '|' + text.slice(from - start)
: text)
})
return out
}

describe('insertDictationAtSelection', () => {
it('types at the cursor in the middle of a note, not at the bottom', () => {
const state = dictate(stateWithCursor('First line', 'Hello |world', 'Last line'), 'there')
expect(show(state)).toEqual(['First line', 'Hello there |world', 'Last line'])
})

it('continues each new chunk from where the last one ended, in order', () => {
const state = dictate(stateWithCursor('Hello |world'), 'one', 'two', 'three')
expect(show(state)).toEqual(['Hello one two three |world'])
})

it('adds a leading space after a word and none into an empty line', () => {
expect(show(dictate(stateWithCursor('Hello|'), 'there'))).toEqual(['Hello there|'])
expect(show(dictate(stateWithCursor('|'), 'Hello'))).toEqual(['Hello|'])
})

it('does not space away from surrounding punctuation or brackets', () => {
expect(show(dictate(stateWithCursor('Hello|.'), 'there'))).toEqual(['Hello there|.'])
expect(show(dictate(stateWithCursor('(|)'), 'aside'))).toEqual(['(aside|)'])
})

it('collapses a range selection to its end instead of overwriting it', () => {
const state = dictate(stateWithCursor('keep |this| text'), 'and more')
expect(show(state)).toEqual(['keep this and more| text'])
expect(state.selection.empty).toBe(true)
})

it('normalizes whitespace and newlines in the recognized text', () => {
expect(show(dictate(stateWithCursor('|'), ' one\n two '))).toEqual(['one two|'])
})

it('picks up the marks at the cursor, like typing does', () => {
const doc = schema.node('doc', null, [p(bold('bold'))])
const state = dictate(EditorState.create({ doc, selection: TextSelection.create(doc, 3) }), 'x')
const para = state.doc.firstChild!
expect(para.textContent).toBe('bo x ld')
expect(para.childCount).toBe(1)
expect(para.firstChild!.marks.map((m) => m.type.name)).toEqual(['strong'])
})

it('spaces away from an inline chip but not from a line break', () => {
const afterBreak = schema.node('doc', null, [p('a', schema.node('hard_break'))])
const s1 = dictate(EditorState.create({ doc: afterBreak, selection: TextSelection.atEnd(afterBreak) }), 'b')
expect(show(s1)).toEqual(['a\nb|'])

const afterChip = schema.node('doc', null, [p('see ', schema.node('mention'))])
const s2 = dictate(EditorState.create({ doc: afterChip, selection: TextSelection.atEnd(afterChip) }), 'this')
expect(show(s2)).toEqual(['see @ this|'])
})

it('refuses (leaving the transaction untouched) when the selection cannot hold text', () => {
const doc = schema.node('doc', null, [p('text'), schema.node('image')])
const state = EditorState.create({ doc, selection: NodeSelection.create(doc, 6) })
const tr = state.tr
expect(insertDictationAtSelection(tr, 'hello')).toBe(false)
expect(tr.docChanged).toBe(false)
expect(tr.selectionSet).toBe(false)
})

it('ignores empty or whitespace-only results', () => {
const tr = stateWithCursor('Hello|').tr
expect(insertDictationAtSelection(tr, ' ')).toBe(false)
expect(tr.docChanged).toBe(false)
})
})

describe('padDictatedText', () => {
it('pads only where the neighbours are word characters', () => {
expect(padDictatedText('b', 'a', 'c')).toBe(' b ')
expect(padDictatedText('b', '', '')).toBe('b')
expect(padDictatedText('b', ' ', ' ')).toBe('b')
})

it('does not put a space before text that starts with punctuation', () => {
expect(padDictatedText(', and then', 'a', '')).toBe(', and then')
})
})
61 changes: 61 additions & 0 deletions frontend/src/utils/dictationInsert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Node as PMNode } from 'prosemirror-model'
import { TextSelection, type Transaction } from 'prosemirror-state'

// Characters that a dictated phrase shouldn't be separated from by a space:
// nothing is needed after whitespace or an opening bracket/quote, and before
// whitespace or closing punctuation.
const NO_SPACE_AFTER = /[\s([{‘“]/
const NO_SPACE_BEFORE = /[\s.,;:!?)\]}’”]/

/** Speech results can carry stray leading/trailing spaces or line breaks; a
* dictated chunk is always inserted as a single run of words. */
export function normalizeDictatedText(text: string): string {
return text.trim().replace(/\s+/g, ' ')
}

/**
* Pads dictated `text` with spaces so it doesn't fuse onto the characters
* immediately `before` and `after` the insertion point (empty string when the
* insertion point is at the start/end of its line).
*/
export function padDictatedText(text: string, before: string, after: string): string {
let padded = text
if (before && !NO_SPACE_AFTER.test(before) && !NO_SPACE_BEFORE.test(text[0])) padded = ` ${padded}`
if (after && !NO_SPACE_BEFORE.test(after)) padded = `${padded} `
return padded
}

// Inline nodes without text (a hard break, a mention chip) still count as
// something to space away from; ones that render as whitespace don't.
const leafText = (node: PMNode) => node.type.spec.leafText?.(node) ?? ''

/**
* Types `text` into the document at the transaction's selection, the way the
* keyboard would: a range selection is collapsed to its end first (dictating
* never overwrites what's selected), the words pick up the marks at that
* position, and the caret is left right after them so the next dictated
* chunk continues from there.
*
* ProseMirror keeps its selection when the editor loses focus, so this works
* even while focus sits on the mic button rather than in the note.
*
* Returns false without touching `tr` when the selection isn't in a text
* block (e.g. an image block is selected, or the whole document), so the
* caller can fall back to starting a new paragraph.
*/
export function insertDictationAtSelection(tr: Transaction, text: string): boolean {
const words = normalizeDictatedText(text)
const $pos = tr.selection.$to
const parent = $pos.parent
if (!words || !parent.inlineContent) return false

const offset = $pos.parentOffset
const before = parent.textBetween(Math.max(0, offset - 1), offset, undefined, leafText)
const after = parent.textBetween(offset, Math.min(parent.content.size, offset + 1), undefined, leafText)
const padded = padDictatedText(words, before, after)

tr.insertText(padded, $pos.pos)
tr.setSelection(TextSelection.create(tr.doc, $pos.pos + padded.length))
tr.scrollIntoView()
return true
}
Loading
Loading