@citisen/litearea 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/README.zh.md +362 -0
  4. package/dist/grammars.cjs +1228 -0
  5. package/dist/grammars.cjs.map +1 -0
  6. package/dist/grammars.js +1213 -0
  7. package/dist/grammars.js.map +1 -0
  8. package/dist/index.cjs +3103 -0
  9. package/dist/index.cjs.map +1 -0
  10. package/dist/index.js +3040 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/react.cjs +3032 -0
  13. package/dist/react.cjs.map +1 -0
  14. package/dist/react.js +3010 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/styles.cjs +453 -0
  17. package/dist/styles.cjs.map +1 -0
  18. package/dist/styles.css +432 -0
  19. package/dist/styles.js +447 -0
  20. package/dist/styles.js.map +1 -0
  21. package/dist/types/core/complete.d.ts +70 -0
  22. package/dist/types/core/complete.d.ts.map +1 -0
  23. package/dist/types/core/format.d.ts +34 -0
  24. package/dist/types/core/format.d.ts.map +1 -0
  25. package/dist/types/core/grammar.d.ts +14 -0
  26. package/dist/types/core/grammar.d.ts.map +1 -0
  27. package/dist/types/core/hover.d.ts +23 -0
  28. package/dist/types/core/hover.d.ts.map +1 -0
  29. package/dist/types/core/index.d.ts +12 -0
  30. package/dist/types/core/index.d.ts.map +1 -0
  31. package/dist/types/core/inspect.d.ts +34 -0
  32. package/dist/types/core/inspect.d.ts.map +1 -0
  33. package/dist/types/core/rank.d.ts +82 -0
  34. package/dist/types/core/rank.d.ts.map +1 -0
  35. package/dist/types/core/scan.d.ts +51 -0
  36. package/dist/types/core/scan.d.ts.map +1 -0
  37. package/dist/types/core/segments.d.ts +44 -0
  38. package/dist/types/core/segments.d.ts.map +1 -0
  39. package/dist/types/core/text.d.ts +113 -0
  40. package/dist/types/core/text.d.ts.map +1 -0
  41. package/dist/types/core/types.d.ts +604 -0
  42. package/dist/types/core/types.d.ts.map +1 -0
  43. package/dist/types/core/vocabulary.d.ts +82 -0
  44. package/dist/types/core/vocabulary.d.ts.map +1 -0
  45. package/dist/types/dom/create.d.ts +17 -0
  46. package/dist/types/dom/create.d.ts.map +1 -0
  47. package/dist/types/dom/editing.d.ts +89 -0
  48. package/dist/types/dom/editing.d.ts.map +1 -0
  49. package/dist/types/dom/editor.d.ts +366 -0
  50. package/dist/types/dom/editor.d.ts.map +1 -0
  51. package/dist/types/dom/index.d.ts +9 -0
  52. package/dist/types/dom/index.d.ts.map +1 -0
  53. package/dist/types/dom/mirror.d.ts +107 -0
  54. package/dist/types/dom/mirror.d.ts.map +1 -0
  55. package/dist/types/dom/overlay.d.ts +52 -0
  56. package/dist/types/dom/overlay.d.ts.map +1 -0
  57. package/dist/types/dom/popup.d.ts +95 -0
  58. package/dist/types/dom/popup.d.ts.map +1 -0
  59. package/dist/types/dom/support.d.ts +41 -0
  60. package/dist/types/dom/support.d.ts.map +1 -0
  61. package/dist/types/dom/tooltip.d.ts +39 -0
  62. package/dist/types/dom/tooltip.d.ts.map +1 -0
  63. package/dist/types/grammars/dshFont.d.ts +127 -0
  64. package/dist/types/grammars/dshFont.d.ts.map +1 -0
  65. package/dist/types/grammars/dshSentry.d.ts +84 -0
  66. package/dist/types/grammars/dshSentry.d.ts.map +1 -0
  67. package/dist/types/grammars/index.d.ts +3 -0
  68. package/dist/types/grammars/index.d.ts.map +1 -0
  69. package/dist/types/index.d.ts +15 -0
  70. package/dist/types/index.d.ts.map +1 -0
  71. package/dist/types/react/index.d.ts +91 -0
  72. package/dist/types/react/index.d.ts.map +1 -0
  73. package/dist/types/styles.d.ts +29 -0
  74. package/dist/types/styles.d.ts.map +1 -0
  75. package/docs/architecture.md +316 -0
  76. package/docs/completion.md +320 -0
  77. package/docs/grammar.md +823 -0
  78. package/package.json +105 -0
  79. package/scripts/browser-check.mjs +838 -0
  80. package/scripts/build-css.mjs +35 -0
  81. package/scripts/release.mjs +91 -0
  82. package/scripts/verify-package.mjs +253 -0
  83. package/src/core/complete.ts +286 -0
  84. package/src/core/format.ts +71 -0
  85. package/src/core/grammar.ts +40 -0
  86. package/src/core/hover.ts +129 -0
  87. package/src/core/index.ts +98 -0
  88. package/src/core/inspect.ts +198 -0
  89. package/src/core/rank.ts +317 -0
  90. package/src/core/scan.ts +720 -0
  91. package/src/core/segments.ts +185 -0
  92. package/src/core/text.ts +238 -0
  93. package/src/core/types.ts +681 -0
  94. package/src/core/vocabulary.ts +196 -0
  95. package/src/dom/create.ts +31 -0
  96. package/src/dom/editing.ts +213 -0
  97. package/src/dom/editor.ts +1143 -0
  98. package/src/dom/index.ts +46 -0
  99. package/src/dom/mirror.ts +305 -0
  100. package/src/dom/overlay.ts +106 -0
  101. package/src/dom/popup.ts +323 -0
  102. package/src/dom/support.ts +88 -0
  103. package/src/dom/tooltip.ts +112 -0
  104. package/src/grammars/dshFont.ts +1004 -0
  105. package/src/grammars/dshSentry.ts +742 -0
  106. package/src/grammars/index.ts +57 -0
  107. package/src/index.ts +122 -0
  108. package/src/react/index.tsx +248 -0
  109. package/src/styles.ts +529 -0
@@ -0,0 +1,196 @@
1
+ // ─── vocabulary: the four facts that must agree ─────────────────────────────
2
+ //
3
+ // A closed set of words appears four times in a language-aware editor: as the
4
+ // syntax that accepts it, as the colour it is painted in, as the message a typo
5
+ // earns, and as the documentation a hover shows. Written out four times they
6
+ // drift — a word added to the parser and forgotten by the highlighter is the
7
+ // ordinary way a grammar rots — and the drift is invisible until someone types
8
+ // the new word and watches the editor get it wrong.
9
+ //
10
+ // So they are declared once. `defineVocabulary` returns an object the engine
11
+ // already knows how to consume: hand it to a `words` rule and it paints, accepts,
12
+ // rejects, and documents without being mentioned again.
13
+ //
14
+ // const SHAPES = defineVocabulary({
15
+ // id: 'shape',
16
+ // words: ['circle', 'rounded', 'square', 'none'],
17
+ // unknownMessage: '"{word}" is not a shape — expected one of {allowed}.',
18
+ // docs: { circle: 'A full disc, the background for a busy state.' },
19
+ // })
20
+ //
21
+ // { kind: 'words', words: SHAPES }
22
+
23
+ import type {
24
+ ResolvedVocabulary,
25
+ Scope,
26
+ Severity,
27
+ VocabularyContext,
28
+ VocabularyEntry,
29
+ WordsSource,
30
+ } from './types.js'
31
+ import { fillTemplate, listPhrase } from './format.js'
32
+
33
+ /** What a vocabulary is told about the document it is being resolved for. */
34
+ export interface VocabularySpec<State = unknown> {
35
+ /** A stable id, used in the diagnostic code and for debugging. */
36
+ id: string
37
+ /**
38
+ * The members.
39
+ *
40
+ * A function is how a set that lives outside the document reaches the grammar
41
+ * without the grammar holding mutable state: the installed fonts, the states
42
+ * the host actually has. It is called once per scan.
43
+ */
44
+ words: readonly string[] | ((context: VocabularyContext<State>) => readonly string[])
45
+ /** The scope a member is painted with. Defaults to `vocabulary:<id>`. */
46
+ scope?: Scope | ((word: string) => Scope)
47
+ /** The scope a rejected word is painted with. Defaults to `'invalid'`. */
48
+ unknownScope?: Scope
49
+ /**
50
+ * The message a rejected word earns.
51
+ *
52
+ * Present, a word-shaped token outside the set is reported; absent, it is
53
+ * reported by nobody and the grammar is the poorer for it. `{word}` and
54
+ * `{allowed}` are substituted, the latter as a readable list.
55
+ */
56
+ unknownMessage?: string | ((word: string, allowed: readonly string[]) => string)
57
+ /** How loudly a rejected word complains. Default `'error'`. */
58
+ unknownSeverity?: Severity
59
+ /** A stable code for the rejection. Defaults to `vocabulary:<id>`. */
60
+ unknownCode?: string
61
+ /**
62
+ * What each member explains about itself, for hover. A bare string is taken as
63
+ * the body.
64
+ */
65
+ docs?: Readonly<Record<string, string | VocabularyEntry>>
66
+ /** A dimmed line shown beside a member in the completion list. */
67
+ detail?: (word: string) => string | undefined
68
+ /** Whether membership is case-sensitive. Default `false`. */
69
+ caseSensitive?: boolean
70
+ /**
71
+ * How a member is written into the document when it is accepted.
72
+ *
73
+ * Separate from the word itself because a member is not always insertable as
74
+ * typed: a font family containing a space has to be quoted, even though the
75
+ * vocabulary calls it `IBM Plex Mono`.
76
+ */
77
+ format?: (word: string) => string
78
+ }
79
+
80
+ /**
81
+ * Build a vocabulary: the set, its colour, its rejection, and its documentation.
82
+ * @param spec - the declaration.
83
+ * @returns a resolved vocabulary, ready for a `words` rule.
84
+ */
85
+ export function defineVocabulary<State = unknown>(
86
+ spec: VocabularySpec<State>,
87
+ ): ResolvedVocabulary<State> {
88
+ const caseSensitive = spec.caseSensitive === true
89
+ const unknownScope = spec.unknownScope ?? 'invalid'
90
+ const defaultScope = `vocabulary:${spec.id}`
91
+ const unknownCode = spec.unknownCode ?? `vocabulary:${spec.id}`
92
+ const unknownSeverity = spec.unknownSeverity ?? 'error'
93
+ /** Fold a word for lookup, honouring the vocabulary's case rule. */
94
+ const fold = (word: string): string => (caseSensitive ? word : word.toLowerCase())
95
+ /** Find the member a word names, preserving the declared spelling. */
96
+ const memberOf = (word: string, allowed: readonly string[]): string | undefined => {
97
+ const needle = fold(word)
98
+ return allowed.find((candidate) => fold(candidate) === needle)
99
+ }
100
+ /** The declaration for one member, whichever of the two shapes it took. */
101
+ const docOf = (word: string): VocabularyEntry | undefined => {
102
+ if (spec.docs === undefined) return undefined
103
+ for (const [key, value] of Object.entries(spec.docs)) {
104
+ if (fold(key) !== fold(word)) continue
105
+ return typeof value === 'string' ? { body: value } : value
106
+ }
107
+ return undefined
108
+ }
109
+
110
+ return {
111
+ id: spec.id,
112
+ caseSensitive,
113
+ resolve: (context) => {
114
+ const words = typeof spec.words === 'function' ? spec.words(context) : spec.words
115
+ return Array.isArray(words) ? words : []
116
+ },
117
+ has: (word, context) => {
118
+ const words = typeof spec.words === 'function' ? spec.words(context) : spec.words
119
+ return memberOf(word, Array.isArray(words) ? words : []) !== undefined
120
+ },
121
+ scopeFor: (word) => {
122
+ if (typeof spec.scope === 'function') return spec.scope(memberOf(word, [word]) ?? word)
123
+ return spec.scope ?? defaultScope
124
+ },
125
+ unknownScope,
126
+ reject: (word, context) => {
127
+ if (spec.unknownMessage === undefined) return undefined
128
+ const allowed = typeof spec.words === 'function' ? spec.words(context) : spec.words
129
+ const members = Array.isArray(allowed) ? allowed : []
130
+ const message =
131
+ typeof spec.unknownMessage === 'function'
132
+ ? spec.unknownMessage(word, members)
133
+ : fillTemplate(spec.unknownMessage, {
134
+ word,
135
+ allowed: listPhrase(members, { conjunction: 'or' }),
136
+ })
137
+ return { message, severity: unknownSeverity, code: unknownCode }
138
+ },
139
+ entryFor: (word) => docOf(word),
140
+ format: (word) => (spec.format === undefined ? word : spec.format(word)),
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The members of a vocabulary, resolved for a document.
146
+ *
147
+ * A helper for the common case of a caller that has a vocabulary and wants its
148
+ * words, without reaching through to `resolve` and re-supplying the context.
149
+ * @param vocabulary - a resolved vocabulary.
150
+ * @param context - the document it is being resolved for.
151
+ * @returns the members, in declaration order.
152
+ */
153
+ export function vocabularyWords<State>(
154
+ vocabulary: ResolvedVocabulary<State>,
155
+ context: VocabularyContext<State>,
156
+ ): readonly string[] {
157
+ return vocabulary.resolve(context)
158
+ }
159
+
160
+ /**
161
+ * Recognize a vocabulary among the shapes a `WordsSource` may take.
162
+ *
163
+ * The test is structural rather than `instanceof`, so a grammar may hand over its
164
+ * own object as long as it carries the four facts — which is what lets a host
165
+ * wrap a vocabulary in logging, caching, or a translation layer without the
166
+ * engine needing to know.
167
+ * @param source - a words source.
168
+ * @returns the vocabulary, or undefined when the source is a bare list or function.
169
+ */
170
+ export function asResolvedVocabulary<State>(
171
+ source: WordsSource<State>,
172
+ ): ResolvedVocabulary<State> | undefined {
173
+ if (typeof source !== 'object' || source === null || Array.isArray(source)) return undefined
174
+ const candidate = source as { resolve?: unknown }
175
+ return typeof candidate.resolve === 'function' ? (source as ResolvedVocabulary<State>) : undefined
176
+ }
177
+
178
+ /**
179
+ * The members any words source stands for, whichever shape it took.
180
+ * @param source - a bare list, a vocabulary, or a function.
181
+ * @param context - the document it is being resolved for.
182
+ * @returns the members, or an empty list when the source resolved to nothing.
183
+ */
184
+ export function resolveWordsSource<State>(
185
+ source: WordsSource<State>,
186
+ context: VocabularyContext<State>,
187
+ ): readonly string[] {
188
+ const vocabulary = asResolvedVocabulary(source)
189
+ const raw =
190
+ vocabulary !== undefined
191
+ ? vocabulary.resolve(context)
192
+ : typeof source === 'function'
193
+ ? source(context)
194
+ : source
195
+ return Array.isArray(raw) ? raw.filter((word) => word !== '') : []
196
+ }
@@ -0,0 +1,31 @@
1
+ // ─── create: the one-line entry point ───────────────────────────────────────
2
+ //
3
+ // The class exists for a host that wants to hold the instance and drive it. This
4
+ // exists for the far more common case of wanting an editor in an element and not
5
+ // much else.
6
+
7
+ import { LiteArea, type LiteAreaOptions } from './editor.js'
8
+
9
+ /**
10
+ * Build an editor and put it in an element.
11
+ *
12
+ * The `refresh` after mounting is not a formality. A textarea that is not in the
13
+ * document has no layout, so it has no width, so nothing can be measured against it
14
+ * — and a measurement taken then is not merely imprecise, it wraps the text at every
15
+ * character and comes back several times too tall. The editor's own
16
+ * `ResizeObserver` would correct it on the next frame, which means one frame of a box
17
+ * at the wrong height. Measuring again here, synchronously, means the first frame is
18
+ * already right.
19
+ * @param target - where to mount it. Its contents are appended to, not replaced.
20
+ * @param options - the grammar, the initial text, and the behaviour to use.
21
+ * @returns the editor, so the caller can drive it.
22
+ */
23
+ export function createEditor<State = unknown>(
24
+ target: Element,
25
+ options: LiteAreaOptions<State>,
26
+ ): LiteArea<State> {
27
+ const editor = new LiteArea(options)
28
+ target.appendChild(editor.element)
29
+ editor.refresh()
30
+ return editor
31
+ }
@@ -0,0 +1,213 @@
1
+ // ─── editing: changing a textarea without breaking its history ──────────────
2
+ //
3
+ // This is the module the whole library exists to get right.
4
+ //
5
+ // A textarea owns its own undo stack, and that stack is maintained by the
6
+ // browser's editing pipeline — not by the value property. Assigning to
7
+ // `field.value` replaces the text and DESTROYS the history, which is why a
8
+ // controlled component that round-trips every keystroke through React state (or a
9
+ // state setter of any kind) has no working Ctrl+Z. It also resets the caret to the
10
+ // end, which is the other half of the same mistake.
11
+ //
12
+ // So the editor never writes `value` for an edit the user could have made. It
13
+ // moves the selection and asks the browser to insert text, which the browser
14
+ // records as one undoable edit:
15
+ //
16
+ // field.setSelectionRange(from, to)
17
+ // document.execCommand('insertText', false, text) // ← undoable
18
+ //
19
+ // `execCommand` is deprecated, and there is no replacement. A synthetic
20
+ // `beforeinput` or `InputEvent` is untrusted, so the browser will not run it
21
+ // through the editing pipeline; `setRangeText` is the standard API and bypasses
22
+ // the history entirely. Every editor that supports undo on a textarea uses this
23
+ // call. Where it is missing the edit still happens through `setRangeText`, and the
24
+ // caller is told plainly that the history did not get it, so a host can decide
25
+ // whether that matters.
26
+
27
+ import { clamp } from '../core/text.js'
28
+ import { canEditThroughPipeline } from './support.js'
29
+
30
+ /** A caret or selection, as offsets into the value. */
31
+ export interface TextSelection {
32
+ /** The anchor end. Equal to `end` for a caret. */
33
+ start: number
34
+ /** The moving end. */
35
+ end: number
36
+ }
37
+
38
+ /**
39
+ * How an edit reached the document.
40
+ *
41
+ * `pipeline` means the browser recorded it and Ctrl+Z will undo it. `direct` means
42
+ * the text changed and the history did not, which only happens where
43
+ * `document.execCommand` is unavailable — a test environment, or a browser that
44
+ * has finally removed it.
45
+ */
46
+ export type EditOutcome = 'pipeline' | 'direct' | 'unchanged'
47
+
48
+ /** Read the caret or selection out of a field. */
49
+ export function readSelection(field: HTMLTextAreaElement): TextSelection {
50
+ const start = field.selectionStart ?? 0
51
+ const end = field.selectionEnd ?? start
52
+ return { start, end }
53
+ }
54
+
55
+ /**
56
+ * Put a caret or selection into a field.
57
+ *
58
+ * Setting a selection moves the caret and nothing else: it does not touch the
59
+ * value, so it cannot disturb the undo stack. This is what the editor uses instead
60
+ * of re-rendering text to reposition the caret — the re-render was the bug.
61
+ *
62
+ * A range given the wrong way round is ordered rather than collapsed. `start` and
63
+ * `end` are named for a caret and a selection, and a caller that computes them from a
64
+ * backwards drag has still asked for that text to be selected; quietly selecting
65
+ * nothing at the far end would be a silent wrong answer.
66
+ * @param field - the textarea.
67
+ * @param start - the anchor offset.
68
+ * @param end - the moving offset; defaults to `start`.
69
+ */
70
+ export function writeSelection(
71
+ field: HTMLTextAreaElement,
72
+ start: number,
73
+ end: number = start,
74
+ ): void {
75
+ const length = field.value.length
76
+ const from = clamp(Math.min(start, end), 0, length)
77
+ const to = clamp(Math.max(start, end), from, length)
78
+ field.setSelectionRange(from, to)
79
+ }
80
+
81
+ /** The line height in force on a field, falling back to the computed font size. */
82
+ export function fieldLineHeight(field: HTMLTextAreaElement): number {
83
+ const styles = field.ownerDocument.defaultView?.getComputedStyle(field)
84
+ if (styles === null || styles === undefined) return 0
85
+ const lineHeight = Number.parseFloat(styles.lineHeight)
86
+ if (Number.isFinite(lineHeight)) return lineHeight
87
+ const fontSize = Number.parseFloat(styles.fontSize)
88
+ return Number.isFinite(fontSize) ? fontSize * 1.2 : 0
89
+ }
90
+
91
+ /**
92
+ * Replace a range through the browser's editing pipeline, so it can be undone.
93
+ *
94
+ * The field is focused first because the pipeline only runs on the focused
95
+ * element, and a completion accepted by mouse click would otherwise arrive after
96
+ * the click had already blurred the field.
97
+ * @param field - the textarea.
98
+ * @param from - the first offset to replace.
99
+ * @param to - the offset after the last.
100
+ * @param text - what to put there. The empty string deletes.
101
+ * @returns how the edit landed.
102
+ */
103
+ export function replaceThroughPipeline(
104
+ field: HTMLTextAreaElement,
105
+ from: number,
106
+ to: number,
107
+ text: string,
108
+ ): EditOutcome {
109
+ const start = clamp(Math.min(from, to), 0, field.value.length)
110
+ const end = clamp(Math.max(from, to), start, field.value.length)
111
+ if (start === end && text === '') return 'unchanged'
112
+
113
+ if (field.ownerDocument.activeElement !== field) field.focus({ preventScroll: true })
114
+ writeSelection(field, start, end)
115
+
116
+ const before = field.value
117
+ if (canEditThroughPipeline()) {
118
+ try {
119
+ field.ownerDocument.execCommand('insertText', false, text)
120
+ } catch {
121
+ // Firefox throws rather than returning false for some input types. Falling
122
+ // through to the direct path is the right answer either way.
123
+ }
124
+ // The return value is not trustworthy across browsers, and it does not need to
125
+ // be: the VALUE decides. A change we did not make ourselves is one the
126
+ // browser's history already knows about, which is the whole point.
127
+ if (field.value !== before) return 'pipeline'
128
+ }
129
+
130
+ field.setRangeText(text, start, end, 'end')
131
+ if (field.value === before) return 'unchanged'
132
+ dispatchInput(field, text)
133
+ return 'direct'
134
+ }
135
+
136
+ /**
137
+ * Fire the event the browser would have fired, for the direct path only.
138
+ *
139
+ * The pipeline path needs no help: the browser dispatches `input` itself, which is
140
+ * exactly why the editor can treat both paths the same way downstream.
141
+ * @param field - the textarea.
142
+ * @param text - what was written.
143
+ */
144
+ export function dispatchInput(field: HTMLTextAreaElement, text: string): void {
145
+ const view = field.ownerDocument.defaultView
146
+ const InputEventCtor = view === null ? undefined : (view as Window & { InputEvent?: typeof InputEvent }).InputEvent
147
+ if (typeof InputEventCtor === 'function') {
148
+ field.dispatchEvent(
149
+ new InputEventCtor('input', { bubbles: true, inputType: 'insertText', data: text }),
150
+ )
151
+ return
152
+ }
153
+ field.dispatchEvent(new Event('input', { bubbles: true }))
154
+ }
155
+
156
+ /**
157
+ * Undo, through the browser's own history.
158
+ *
159
+ * Exposed so a host can offer a button without reimplementing a stack, and so the
160
+ * browser harness can drive the same path a keyboard shortcut drives.
161
+ * @param field - the textarea.
162
+ * @returns whether the call was possible at all. It cannot report whether anything
163
+ * was actually undone, because no browser exposes that.
164
+ */
165
+ export function undoField(field: HTMLTextAreaElement): boolean {
166
+ if (!canEditThroughPipeline()) return false
167
+ if (field.ownerDocument.activeElement !== field) field.focus({ preventScroll: true })
168
+ try {
169
+ return field.ownerDocument.execCommand('undo')
170
+ } catch {
171
+ return false
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Redo, through the browser's own history.
177
+ * @param field - the textarea.
178
+ * @returns whether the call was possible at all.
179
+ */
180
+ export function redoField(field: HTMLTextAreaElement): boolean {
181
+ if (!canEditThroughPipeline()) return false
182
+ if (field.ownerDocument.activeElement !== field) field.focus({ preventScroll: true })
183
+ try {
184
+ return field.ownerDocument.execCommand('redo')
185
+ } catch {
186
+ return false
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Write a whole document into a field.
192
+ *
193
+ * Two paths, and the difference matters. `preserveHistory` selects everything and
194
+ * inserts through the pipeline, so the replacement is one undoable edit and Ctrl+Z
195
+ * brings the previous text back — the right behaviour for a Reset button. Without
196
+ * it the value property is assigned, which is faster and clears the history, which
197
+ * is the right behaviour when a host is loading a different document entirely.
198
+ * @param field - the textarea.
199
+ * @param next - the new text.
200
+ * @param preserveHistory - whether the change should be undoable.
201
+ * @returns how the write landed.
202
+ */
203
+ export function writeDocument(
204
+ field: HTMLTextAreaElement,
205
+ next: string,
206
+ preserveHistory = false,
207
+ ): EditOutcome {
208
+ if (field.value === next) return 'unchanged'
209
+ if (preserveHistory) return replaceThroughPipeline(field, 0, field.value.length, next)
210
+ field.value = next
211
+ writeSelection(field, next.length, next.length)
212
+ return 'direct'
213
+ }