The message box: a format toggles off, over a whole run, part of one or a mixed selection, and undo steps back over it with the selection kept
Worked out on a copy of the box and put back with one insertHTML (one undo step, and the browser no longer re-applies the format of what it replaced); undo and redo put back the selection that was there. Fixes #336 Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Formatting in the message box, in a scratch moho with injected state (nothing is contacted): a format applied
|
||||
* to a selection can be taken off again - by the keyboard shortcut or the strip's button, over the whole run,
|
||||
* over part of it, or over a selection reaching past it - and each step can be undone with the box's own undo.
|
||||
*
|
||||
* node scripts/check-composer-format.mjs
|
||||
*/
|
||||
import { launchApp, sleep } from './lib/harness.mjs'
|
||||
|
||||
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
|
||||
const ev = (code) => page.evaluate(code)
|
||||
const results = []
|
||||
const check = (what, ok, detail = '') => {
|
||||
results.push(ok)
|
||||
console.log(ok ? ' ok ' : ' FAIL', what, ok ? '' : detail)
|
||||
}
|
||||
const BOX = '.composer-input'
|
||||
const html = () => ev(`document.querySelector(${JSON.stringify(BOX)}).innerHTML.replace(/ data-[a-z-]+="[^"]*"/g, (m) => m.startsWith(' data-fmt') ? m : '')`)
|
||||
/** The text the box would send, with the markers of what is bold, italic, and so on in it. */
|
||||
const marked = () =>
|
||||
ev(`(() => {
|
||||
const mark = { bold: '**', italic: '_', underline: '__', strike: '~~', code: '\`', spoiler: '||', quote: '> ' }
|
||||
const walk = (n) => n.nodeType === 3 ? n.nodeValue : n instanceof HTMLElement && n.dataset.fmt ? (mark[n.dataset.fmt] ?? '') + [...n.childNodes].map(walk).join('') + (n.dataset.fmt === 'quote' ? '' : mark[n.dataset.fmt] ?? '') : [...n.childNodes].map(walk).join('')
|
||||
return walk(document.querySelector(${JSON.stringify(BOX)}))
|
||||
})()`)
|
||||
const type = async (text) => {
|
||||
await ev(`(() => { const b = document.querySelector(${JSON.stringify(BOX)}); b.focus(); b.innerHTML = '' })()`)
|
||||
await page.call('Input.insertText', { text })
|
||||
}
|
||||
const select = (needle, nth = 0) =>
|
||||
ev(`(() => {
|
||||
const box = document.querySelector(${JSON.stringify(BOX)})
|
||||
const text = []
|
||||
const w = document.createTreeWalker(box, NodeFilter.SHOW_TEXT)
|
||||
for (let n; (n = w.nextNode()); ) text.push(n)
|
||||
const all = text.map((t) => t.nodeValue).join('')
|
||||
let at = -1
|
||||
for (let i = 0; i <= ${nth}; i++) at = all.indexOf(${JSON.stringify(needle)}, at + 1)
|
||||
if (at < 0) return false
|
||||
const locate = (offset) => { let seen = 0; for (const t of text) { if (offset <= seen + t.nodeValue.length) return [t, offset - seen]; seen += t.nodeValue.length } }
|
||||
const [a, ao] = locate(at)
|
||||
const [b, bo] = locate(at + ${JSON.stringify(needle)}.length)
|
||||
const r = document.createRange()
|
||||
r.setStart(a, ao); r.setEnd(b, bo)
|
||||
const s = getSelection(); s.removeAllRanges(); s.addRange(r)
|
||||
box.focus()
|
||||
return true
|
||||
})()`)
|
||||
const key = (k, { ctrl = false, commands } = {}) =>
|
||||
page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: 'Key' + k.toUpperCase(), windowsVirtualKeyCode: k.toUpperCase().charCodeAt(0), modifiers: ctrl ? 2 : 0, commands })
|
||||
const bold = () => key('b', { ctrl: true })
|
||||
const undo = () => key('z', { ctrl: true, commands: ['undo'] })
|
||||
const redo = () => key('z', { ctrl: true, commands: ['redo'] })
|
||||
const strip = async (title) => {
|
||||
await sleep(150)
|
||||
return ev(`(() => { const b = [...document.querySelectorAll('.format-bar button')].find((x) => x.title === ${JSON.stringify(title)}); if (!b) return false; b.click(); return true })()`)
|
||||
}
|
||||
|
||||
try {
|
||||
await sleep(2500)
|
||||
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
|
||||
await ev(`(() => {
|
||||
const s = window.__mohoShots.store
|
||||
const acct = { id: 'discord:me', service: 'discord', name: 'me', host: '', port: 0, state: 'connected', hasPassword: true, nick: 'me' }
|
||||
const buf = { id: 'discord:me|chat', accountId: 'discord:me', kind: 'channel', name: 'chat', lastActivityTs: 5, category: 'Channels', groupId: 'account:discord:me' }
|
||||
s.set({ accounts: [acct], groups: [{ id: 'account:discord:me', accountId: 'discord:me', service: 'discord', kind: 'account', name: 'me', position: 0 }], buffers: [buf], messagesByBuffer: { 'discord:me|chat': [] }, activeBufferId: 'discord:me|chat' })
|
||||
s.selectGroup('account:discord:me')
|
||||
})()`)
|
||||
await sleep(1200)
|
||||
|
||||
console.log('Applying and taking off')
|
||||
await type('one two three four')
|
||||
await select('two')
|
||||
await bold()
|
||||
check('bold goes on', (await marked()) === 'one **two** three four', await marked())
|
||||
await bold()
|
||||
check('the same key again takes it off', (await marked()) === 'one two three four', await marked())
|
||||
await bold()
|
||||
check('and again puts it back', (await marked()) === 'one **two** three four', await marked())
|
||||
|
||||
console.log('Undo')
|
||||
await undo()
|
||||
check('undo takes the bold back off', (await marked()) === 'one two three four', await marked())
|
||||
await redo()
|
||||
check('redo puts it on again', (await marked()) === 'one **two** three four', await marked())
|
||||
check('redo leaves the word selected, not the whole message', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
|
||||
await bold()
|
||||
await undo()
|
||||
check('undo puts it back after it was taken off', (await marked()) === 'one **two** three four', await marked())
|
||||
check('and leaves the word selected', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
|
||||
|
||||
console.log('Undo leaves a selection that is safe to type over')
|
||||
await type('one two three four')
|
||||
await select('two')
|
||||
await bold()
|
||||
await undo()
|
||||
check('after undo the word is selected, not the whole message', (await ev(`String(getSelection())`)) === 'two', await ev(`String(getSelection())`))
|
||||
|
||||
console.log('The ends of the box')
|
||||
await type('one two')
|
||||
await select('one')
|
||||
await bold()
|
||||
check('the first word goes bold', (await marked()) === '**one** two', await marked())
|
||||
await bold()
|
||||
check('and comes off', (await marked()) === 'one two', await marked())
|
||||
await select('two')
|
||||
await bold()
|
||||
check('the last word goes bold', (await marked()) === 'one **two**', await marked())
|
||||
await bold()
|
||||
check('and comes off', (await marked()) === 'one two', await marked())
|
||||
|
||||
console.log('Other formats')
|
||||
await type('one two three')
|
||||
await select('two')
|
||||
await key('i', { ctrl: true })
|
||||
check('italic goes on', (await marked()) === 'one _two_ three', await marked())
|
||||
await select('one two thr')
|
||||
await bold()
|
||||
check('bold over italic keeps both', (await marked()) === '**one _two_ thr**ee', await marked())
|
||||
await key('i', { ctrl: true })
|
||||
check('italic over text that is only partly italic makes all of it italic', (await marked()) === '**_one two thr_**ee', await marked())
|
||||
await key('i', { ctrl: true })
|
||||
check('and again takes italic off all of it, leaving the bold', (await marked()) === '**one two thr**ee', await marked())
|
||||
|
||||
console.log('Lines')
|
||||
await type('first line')
|
||||
await page.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, modifiers: 8 })
|
||||
await page.call('Input.insertText', { text: 'second line' })
|
||||
if (!(await select('line\nsecond'))) await select('linesecond')
|
||||
await bold()
|
||||
const lines = await ev(`document.querySelector(${JSON.stringify(BOX)}).innerHTML`)
|
||||
check('a selection over two lines is bold, and the break is still there', /data-fmt="bold"/.test(lines) && (/<br/.test(lines) || /\n/.test(lines)) && (await marked()).replace(/\n/g, '').includes('**'), lines)
|
||||
await bold()
|
||||
check('and comes off whole', !/data-fmt/.test(await html()), await html())
|
||||
|
||||
console.log('Part of a run')
|
||||
await type('one two three four')
|
||||
await select('two three')
|
||||
await bold()
|
||||
await select('wo th')
|
||||
await bold()
|
||||
check('part of a run is taken off, the rest stays', (await marked()) === 'one **t**wo th**ree** four', await marked())
|
||||
|
||||
console.log('Reaching past a run')
|
||||
await type('one two three four')
|
||||
await select('two')
|
||||
await bold()
|
||||
await select('one two thr')
|
||||
await bold()
|
||||
check('a selection partly bold is made all bold, not doubled', (await marked()) === '**one two thr**ee four', await marked())
|
||||
await bold()
|
||||
check('and the next press takes it all off', (await marked()) === 'one two three four', await marked())
|
||||
|
||||
console.log('The strip')
|
||||
await type('one two three four')
|
||||
await select('three')
|
||||
await sleep(300)
|
||||
check('the strip offers bold', await strip('Bold'))
|
||||
check('and it goes on', (await marked()) === 'one two **three** four', await marked())
|
||||
await sleep(300)
|
||||
check('the strip button again takes it off', (await strip('Bold')) && (await marked()) === 'one two three four', await marked())
|
||||
await undo()
|
||||
check('and undo puts it back', (await marked()) === 'one two **three** four', await marked())
|
||||
} catch (e) {
|
||||
console.log(' FAIL', e.message)
|
||||
results.push(false)
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
process.exit(results.every(Boolean) ? 0 : 1)
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormatBar } from './FormatBar'
|
||||
import { formatsFor, serialize, type FormatKind } from '../lib/composeFormat'
|
||||
import { applyFormat } from '../lib/composeFormatDom'
|
||||
import { applyFormat, restoreSelectionAfterHistory } from '../lib/composeFormatDom'
|
||||
import { useMediaUrl } from '../lib/route'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Icon, IconButton, MaskIcon } from './Icon'
|
||||
@@ -1181,6 +1181,7 @@ export function Composer({ thread = false }: { thread?: boolean } = {}): JSX.Ele
|
||||
aria-multiline="true"
|
||||
aria-label={thread ? 'Reply in thread' : `Message ${bufferDisplayName(buffer.name)}`}
|
||||
onInput={(e) => {
|
||||
restoreSelectionAfterHistory(e.currentTarget, (e.nativeEvent as InputEvent).inputType)
|
||||
setText(composerText(e.currentTarget))
|
||||
noteTyping()
|
||||
noteMention()
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { textNodes, transformFormat } from './composeFormatDom'
|
||||
import type { FormatKind } from './composeFormat'
|
||||
|
||||
/** What the box says, with the formats marked as they would be on Discord. */
|
||||
function marked(root: Node): string {
|
||||
const mark: Record<string, string> = { bold: '**', italic: '_', underline: '__', strike: '~~', code: '`', spoiler: '||', quote: '> ' }
|
||||
return Array.from(root.childNodes)
|
||||
.map((n) => {
|
||||
if (n.nodeType === 3) return n.nodeValue
|
||||
if (n instanceof HTMLElement && n.dataset.fmt === 'color') return `{${n.dataset.color}:${marked(n)}}`
|
||||
if (n instanceof HTMLElement && n.dataset.fmt) return `${mark[n.dataset.fmt]}${marked(n)}${mark[n.dataset.fmt]}`
|
||||
return marked(n)
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** A box holding `html`, with `needle` selected in its text. */
|
||||
function box(html: string, needle: string): { root: HTMLElement; range: Range } {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = html
|
||||
const nodes = textNodes(root)
|
||||
const all = nodes.map((n) => n.nodeValue).join('')
|
||||
const at = all.indexOf(needle)
|
||||
expect(at).toBeGreaterThanOrEqual(0)
|
||||
const locate = (offset: number, preferEnd: boolean): [Text, number] => {
|
||||
let seen = 0
|
||||
for (const n of nodes) {
|
||||
const len = n.nodeValue!.length
|
||||
if (preferEnd ? offset <= seen + len : offset < seen + len) return [n, offset - seen]
|
||||
seen += len
|
||||
}
|
||||
throw new Error('outside')
|
||||
}
|
||||
const range = document.createRange()
|
||||
range.setStart(...locate(at, false))
|
||||
range.setEnd(...locate(at + needle.length, true))
|
||||
return { root, range }
|
||||
}
|
||||
|
||||
function run(html: string, needle: string, kind: FormatKind, colour?: string): { text: string; selected: string; done: boolean } {
|
||||
const { root, range } = box(html, needle)
|
||||
const done = transformFormat(root, range, kind, colour)
|
||||
return { text: marked(root), selected: range.toString(), done }
|
||||
}
|
||||
|
||||
const B = (s: string): string => `<span data-fmt="bold">${s}</span>`
|
||||
|
||||
describe('putting a format on', () => {
|
||||
it('wraps the selection and leaves it selected', () => {
|
||||
expect(run('one two three', 'two', 'bold')).toEqual({ text: 'one **two** three', selected: 'two', done: true })
|
||||
})
|
||||
|
||||
it('makes a selection that is partly bold all bold, once', () => {
|
||||
const r = run(`one ${B('two')} three four`, 'one two thr', 'bold')
|
||||
expect(r.text).toBe('**one two thr**ee four')
|
||||
expect(r.selected).toBe('one two thr')
|
||||
})
|
||||
|
||||
it('joins a run that ends where the new one begins', () => {
|
||||
expect(run(`${B('ab')}cd`, 'cd', 'bold').text).toBe('**abcd**')
|
||||
expect(run(`ab${B('cd')}`, 'ab', 'bold').text).toBe('**abcd**')
|
||||
})
|
||||
|
||||
it('stacks on another format without breaking it', () => {
|
||||
const r = run('one <span data-fmt="italic">two three</span> four', 'three four', 'bold')
|
||||
expect(r.text).toBe('one _two _**_three_ four**')
|
||||
})
|
||||
|
||||
it('does nothing with nothing selected', () => {
|
||||
const { root, range } = box('one two', 'two')
|
||||
range.collapse(true)
|
||||
expect(transformFormat(root, range, 'bold')).toBe(false)
|
||||
expect(marked(root)).toBe('one two')
|
||||
})
|
||||
})
|
||||
|
||||
describe('taking a format off', () => {
|
||||
it('takes it off a whole run', () => {
|
||||
expect(run(`one ${B('two')} three`, 'two', 'bold')).toEqual({ text: 'one two three', selected: 'two', done: true })
|
||||
})
|
||||
|
||||
it('takes it off part of a run and leaves the rest of the run bold', () => {
|
||||
const r = run(`one ${B('two three')} four`, 'wo th', 'bold')
|
||||
expect(r.text).toBe('one **t**wo th**ree** four')
|
||||
expect(r.selected).toBe('wo th')
|
||||
})
|
||||
|
||||
it('takes it off the end of a run', () => {
|
||||
expect(run(`one ${B('two three')}`, 'three', 'bold').text).toBe('one **two **three')
|
||||
})
|
||||
|
||||
it('takes it off from two runs side by side when all of the selection has it', () => {
|
||||
expect(run(`${B('one')}${B('two')}`, 'netw', 'bold').text).toBe('**o**ne' + 'tw' + '**o**')
|
||||
})
|
||||
|
||||
it('does not take it off a selection that reaches a space which is not bold: that is put on instead', () => {
|
||||
expect(run(`${B('one')} ${B('two')}`, 'e t', 'bold').text).toBe('**one two**')
|
||||
})
|
||||
|
||||
it('only the format asked for', () => {
|
||||
const r = run('<span data-fmt="bold"><span data-fmt="italic">two</span></span>', 'two', 'bold')
|
||||
expect(r.text).toBe('_two_')
|
||||
})
|
||||
})
|
||||
|
||||
describe('colour', () => {
|
||||
it('puts a colour on, and a different one replaces it', () => {
|
||||
const on = box('one two', 'two')
|
||||
expect(transformFormat(on.root, on.range, 'color', '#ff0000')).toBe(true)
|
||||
expect(marked(on.root)).toBe('one {#ff0000:two}')
|
||||
expect(transformFormat(on.root, on.range, 'color', '#00ff00')).toBe(true)
|
||||
expect(marked(on.root)).toBe('one {#00ff00:two}')
|
||||
expect(transformFormat(on.root, on.range, 'color')).toBe(true)
|
||||
expect(marked(on.root)).toBe('one two')
|
||||
})
|
||||
|
||||
it('takes no colour that is not a plain hex value', () => {
|
||||
expect(run('one two', 'two', 'color', 'red; background:url(x)').done).toBe(false)
|
||||
})
|
||||
|
||||
it('has nothing to take off when there is no colour', () => {
|
||||
expect(run('one two', 'two', 'color').done).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,68 +1,303 @@
|
||||
import type { FormatKind } from './composeFormat'
|
||||
|
||||
/** The nearest enclosing element in the box that stands for `kind`, if any. */
|
||||
function holderOf(node: Node | null, root: HTMLElement, kind: FormatKind): HTMLElement | null {
|
||||
for (let n: Node | null = node; n && n !== root; n = n.parentNode) {
|
||||
if (n instanceof HTMLElement && n.dataset.fmt === kind) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Colours reach a style attribute, so only a plain hex value gets there. */
|
||||
const HEX = /^#[0-9a-fA-F]{6}$/
|
||||
|
||||
const isRun = (node: Node | null): node is HTMLElement => node instanceof HTMLElement && node.dataset.fmt !== undefined
|
||||
const isKind = (node: Node | null, kind: FormatKind): node is HTMLElement => isRun(node) && node.dataset.fmt === kind
|
||||
const isMarker = (node: Node | null): boolean => node instanceof HTMLElement && node.dataset.mark !== undefined
|
||||
const sameRun = (a: HTMLElement, b: HTMLElement): boolean => a.dataset.fmt === b.dataset.fmt && a.dataset.color === b.dataset.color
|
||||
|
||||
function nearestRun(from: Node, root: Node, kind: FormatKind): HTMLElement | null {
|
||||
for (let n: Node | null = from.parentNode; n && n !== root; n = n.parentNode) {
|
||||
if (isKind(n, kind)) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function marker(which: 's' | 'e'): HTMLElement {
|
||||
const m = document.createElement('i')
|
||||
m.dataset.mark = which
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a format to what is selected in the box, or takes it off again if
|
||||
* the selection is exactly that format already. Returns whether it did
|
||||
* anything.
|
||||
*
|
||||
* Goes through `insertHTML` rather than rearranging nodes, so the box's own
|
||||
* undo still steps back over it.
|
||||
* Takes a marker out of every run of `kind` around it, splitting each run in
|
||||
* two at the marker. A start marker ends up in front of the second half and
|
||||
* an end marker behind the first, so what lies between the two markers is
|
||||
* never inside a run that reaches beyond it.
|
||||
*/
|
||||
export function applyFormat(root: HTMLElement, kind: FormatKind, colour?: string): boolean {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return false
|
||||
const range = selection.getRangeAt(0)
|
||||
if (range.collapsed || !root.contains(range.commonAncestorContainer)) return false
|
||||
root.focus()
|
||||
|
||||
// The selection can be a whole formatted run (it is, right after one is
|
||||
// applied, so another can be stacked on), which names the run's parent as
|
||||
// its ancestor and the run itself only by position.
|
||||
const whole =
|
||||
range.startContainer === range.endContainer && range.endOffset - range.startOffset === 1
|
||||
? range.startContainer.childNodes[range.startOffset]
|
||||
: null
|
||||
const holder = holderOf(whole ?? range.commonAncestorContainer, root, kind)
|
||||
if (holder && (kind === 'color' ? !colour : holder.textContent === range.toString())) {
|
||||
const around = document.createRange()
|
||||
around.selectNode(holder)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(around)
|
||||
document.execCommand('insertHTML', false, holder.innerHTML)
|
||||
return true
|
||||
function lift(root: Node, point: HTMLElement, kind: FormatKind, atStart: boolean): void {
|
||||
for (let run = nearestRun(point, root, kind); run; run = nearestRun(point, root, kind)) {
|
||||
const rest = document.createRange()
|
||||
if (atStart) rest.setStartBefore(point)
|
||||
else rest.setStartAfter(point)
|
||||
rest.setEnd(run, run.childNodes.length)
|
||||
const tail = run.cloneNode(false) as HTMLElement
|
||||
tail.append(rest.extractContents())
|
||||
run.after(tail)
|
||||
if (atStart) tail.before(point)
|
||||
else run.after(point)
|
||||
}
|
||||
if (kind === 'color' && !(colour && HEX.test(colour))) return false
|
||||
}
|
||||
|
||||
const scratch = document.createElement('div')
|
||||
scratch.appendChild(range.cloneContents())
|
||||
const colourAttrs = kind === 'color' ? ` data-color="${colour}" style="color:${colour}"` : ''
|
||||
document.execCommand(
|
||||
'insertHTML',
|
||||
false,
|
||||
`<span data-fmt="${kind}"${colourAttrs} data-new="1">${scratch.innerHTML}</span>`
|
||||
/** Every text node under `node`, in document order. */
|
||||
export function textNodes(node: Node, into: Text[] = []): Text[] {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === 3) into.push(child as Text)
|
||||
else textNodes(child, into)
|
||||
}
|
||||
return into
|
||||
}
|
||||
|
||||
/** Text between two markers, in document order. */
|
||||
function textBetween(root: Node, from: Node, to: Node): Text[] {
|
||||
return textNodes(root).filter(
|
||||
(n) =>
|
||||
n.nodeValue &&
|
||||
from.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING &&
|
||||
to.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_PRECEDING
|
||||
)
|
||||
// Left selected, so another format can be stacked on without selecting again.
|
||||
const made = root.querySelector('[data-new]')
|
||||
if (made) {
|
||||
made.removeAttribute('data-new')
|
||||
// The run itself rather than its contents: Chromium replaces the whole
|
||||
// element when what is selected is all of what is inside it, which would
|
||||
// swallow the format just applied.
|
||||
const around = document.createRange()
|
||||
around.selectNode(made)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(around)
|
||||
}
|
||||
|
||||
function charactersBefore(root: Node, point: Node): number {
|
||||
return textNodes(root).reduce((sum, n) => sum + (point.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_PRECEDING ? n.nodeValue!.length : 0), 0)
|
||||
}
|
||||
|
||||
/** The point in the text that `count` characters in: a start prefers the beginning of the next piece, an end the finish of the last. */
|
||||
function place(root: Node, count: number, atStart: boolean): [Node, number] {
|
||||
const text = textNodes(root)
|
||||
let seen = 0
|
||||
for (const n of text) {
|
||||
const length = n.nodeValue!.length
|
||||
if (atStart ? count < seen + length : count <= seen + length) return [n, count - seen]
|
||||
seen += length
|
||||
}
|
||||
const last = text[text.length - 1]
|
||||
return last ? [last, last.nodeValue!.length] : [root, 0]
|
||||
}
|
||||
|
||||
function unwrap(run: HTMLElement): void {
|
||||
run.replaceWith(...Array.from(run.childNodes))
|
||||
}
|
||||
|
||||
/** Splits leave runs and wrappers with nothing in them; they would serialise as stray markers. */
|
||||
function dropEmpty(parent: Node): void {
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (child.nodeType === 3) {
|
||||
if (!child.nodeValue) child.remove()
|
||||
} else if (child instanceof HTMLElement && !isMarker(child)) {
|
||||
dropEmpty(child)
|
||||
if (!child.hasChildNodes() && child.nodeName !== 'BR' && child.nodeName !== 'IMG') child.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Two runs of one format side by side are one run, or they would be marked up twice. Markers between them stay in order. */
|
||||
function mergeRuns(parent: Node): void {
|
||||
let child = parent.firstChild
|
||||
while (child) {
|
||||
if (isRun(child)) {
|
||||
const between: Node[] = []
|
||||
let next = child.nextSibling
|
||||
while (next && (isMarker(next) || (next.nodeType === 3 && !next.nodeValue))) {
|
||||
between.push(next)
|
||||
next = next.nextSibling
|
||||
}
|
||||
if (isRun(next) && sameRun(child, next)) {
|
||||
child.append(...between, ...Array.from(next.childNodes))
|
||||
next.remove()
|
||||
continue
|
||||
}
|
||||
}
|
||||
child = child.nextSibling
|
||||
}
|
||||
for (const element of Array.from(parent.childNodes)) if (element instanceof HTMLElement && !isMarker(element)) mergeRuns(element)
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection of `range` with `kind` put on - or taken off, if all of it
|
||||
* already has it. Works on `root` (a copy of the box, in practice) and
|
||||
* leaves `range` on the same text afterwards. False when there was nothing
|
||||
* to do.
|
||||
*
|
||||
* Applying over text that is part bold makes all of it bold, once, and
|
||||
* taking it off from the middle of a run leaves the rest of the run as it was;
|
||||
* both come from cutting every run of the kind at the selection's ends first.
|
||||
*/
|
||||
export function transformFormat(root: HTMLElement, range: Range, kind: FormatKind, colour?: string): boolean {
|
||||
if (kind === 'color' && colour && !HEX.test(colour)) return false
|
||||
const start = marker('s')
|
||||
const end = marker('e')
|
||||
const atEnd = range.cloneRange()
|
||||
atEnd.collapse(false)
|
||||
atEnd.insertNode(end)
|
||||
const atStart = range.cloneRange()
|
||||
atStart.collapse(true)
|
||||
atStart.insertNode(start)
|
||||
|
||||
const leave = (done: boolean): boolean => {
|
||||
start.remove()
|
||||
end.remove()
|
||||
return done
|
||||
}
|
||||
if (textBetween(root, start, end).length === 0) return leave(false)
|
||||
|
||||
lift(root, end, kind, false)
|
||||
lift(root, start, kind, true)
|
||||
|
||||
const text = textBetween(root, start, end)
|
||||
const runs = Array.from(root.querySelectorAll<HTMLElement>(`[data-fmt="${kind}"]`)).filter(
|
||||
(run) => start.compareDocumentPosition(run) & Node.DOCUMENT_POSITION_FOLLOWING && end.compareDocumentPosition(run) & Node.DOCUMENT_POSITION_PRECEDING
|
||||
)
|
||||
const covered = text.every((t) => {
|
||||
for (let n: Node | null = t.parentNode; n && n !== root; n = n.parentNode) if (isKind(n, kind)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
if (kind === 'color' && !colour) {
|
||||
if (runs.length === 0) return leave(false)
|
||||
}
|
||||
// A colour is never toggled by choosing it again: a different one replaces.
|
||||
const off = kind === 'color' ? !colour : covered
|
||||
for (const run of runs) unwrap(run)
|
||||
if (!off) {
|
||||
const around = document.createRange()
|
||||
around.setStartAfter(start)
|
||||
around.setEndBefore(end)
|
||||
const span = document.createElement('span')
|
||||
span.dataset.fmt = kind
|
||||
if (kind === 'color' && colour) {
|
||||
span.dataset.color = colour
|
||||
span.style.color = colour
|
||||
}
|
||||
span.append(around.extractContents())
|
||||
around.insertNode(span)
|
||||
}
|
||||
|
||||
dropEmpty(root)
|
||||
mergeRuns(root)
|
||||
|
||||
// The markers say where the selection was, as a count of characters; the
|
||||
// text nodes are about to be joined, and a count survives that.
|
||||
const from = charactersBefore(root, start)
|
||||
const to = charactersBefore(root, end)
|
||||
start.remove()
|
||||
end.remove()
|
||||
root.normalize()
|
||||
range.setStart(...place(root, from, true))
|
||||
range.setEnd(...place(root, to, false))
|
||||
return true
|
||||
}
|
||||
|
||||
/** Where a point in the box is, by child positions from the box down, so it can be found again in a copy. */
|
||||
function pathOf(root: Node, node: Node, offset: number): number[] {
|
||||
const path = [offset]
|
||||
for (let n: Node = node; n !== root; n = n.parentNode as Node) {
|
||||
path.unshift(Array.prototype.indexOf.call((n.parentNode as Node).childNodes, n))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function resolve(root: Node, path: number[]): [Node, number] {
|
||||
let node = root
|
||||
for (const index of path.slice(0, -1)) node = node.childNodes[index]
|
||||
return [node, path[path.length - 1]]
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a format to what is selected in the box, or takes it off again if
|
||||
* all of the selection already has it. Returns whether it did anything.
|
||||
*
|
||||
* The change is worked out on a copy of the box and put back by replacing the
|
||||
* box's contents with one `insertHTML`, so it is a single step of the box's
|
||||
* own undo - and so the browser's habit of keeping the formatting of what a
|
||||
* replacement lands in cannot put back a format that was just taken off.
|
||||
*/
|
||||
export function applyFormat(root: HTMLElement, kind: FormatKind, colour?: string): boolean {
|
||||
const before = root.innerHTML
|
||||
const beforeSelection = currentSelection(root)
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return false
|
||||
const picked = selection.getRangeAt(0)
|
||||
if (picked.collapsed || !root.contains(picked.commonAncestorContainer)) return false
|
||||
root.focus()
|
||||
|
||||
const copy = root.cloneNode(true) as HTMLElement
|
||||
const range = document.createRange()
|
||||
range.setStart(...resolve(copy, pathOf(root, picked.startContainer, picked.startOffset)))
|
||||
range.setEnd(...resolve(copy, pathOf(root, picked.endContainer, picked.endOffset)))
|
||||
if (!transformFormat(copy, range, kind, colour)) return false
|
||||
|
||||
const start = pathOf(copy, range.startContainer, range.startOffset)
|
||||
const end = pathOf(copy, range.endContainer, range.endOffset)
|
||||
selection.selectAllChildren(root)
|
||||
document.execCommand('insertHTML', false, copy.innerHTML)
|
||||
|
||||
// Left selected, so another format can be stacked on without selecting again.
|
||||
select(root, { start, end })
|
||||
if (beforeSelection) remember({ before, beforeSelection, after: root.innerHTML, afterSelection: { start, end } })
|
||||
return true
|
||||
}
|
||||
|
||||
interface Picked {
|
||||
start: number[]
|
||||
end: number[]
|
||||
}
|
||||
|
||||
/** What the box held and had selected either side of a format, so undo and redo can put the selection back. */
|
||||
interface Edit {
|
||||
before: string
|
||||
beforeSelection: Picked
|
||||
after: string
|
||||
afterSelection: Picked
|
||||
}
|
||||
|
||||
const edits: Edit[] = []
|
||||
|
||||
function remember(edit: Edit): void {
|
||||
edits.push(edit)
|
||||
if (edits.length > 100) edits.shift()
|
||||
}
|
||||
|
||||
function currentSelection(root: HTMLElement): Picked | null {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return null
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null
|
||||
return { start: pathOf(root, range.startContainer, range.startOffset), end: pathOf(root, range.endContainer, range.endOffset) }
|
||||
}
|
||||
|
||||
function select(root: HTMLElement, picked: Picked): void {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
try {
|
||||
const range = document.createRange()
|
||||
range.setStart(...resolve(root, picked.start))
|
||||
range.setEnd(...resolve(root, picked.end))
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
} catch {
|
||||
/* the contents are not built the way they were; the caret is wherever the browser left it */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo and redo step over a format as the whole box being replaced, and the
|
||||
* browser leaves everything selected afterwards - where the next key typed
|
||||
* would replace the message. Called after either, this puts back the
|
||||
* selection that was there at that point, when the box holds exactly what it
|
||||
* held then.
|
||||
*/
|
||||
export function restoreSelectionAfterHistory(root: HTMLElement, inputType: string): void {
|
||||
const undo = inputType === 'historyUndo'
|
||||
if (!undo && inputType !== 'historyRedo') return
|
||||
const now = root.innerHTML
|
||||
for (let i = edits.length - 1; i >= 0; i--) {
|
||||
const edit = edits[i]
|
||||
if ((undo ? edit.before : edit.after) === now) {
|
||||
select(root, undo ? edit.beforeSelection : edit.afterSelection)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user