@nyaruka/temba-components 0.156.16 → 0.156.18
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.
- package/CHANGELOG.md +14 -0
- package/dist/temba-components.js +235 -228
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Options.ts +8 -0
- package/src/excellent/caret-utils.ts +179 -55
- package/src/flow/RevisionsWindow.ts +55 -9
- package/src/flow/revision-summary.ts +25 -0
- package/src/form/RichEditor.ts +21 -0
package/package.json
CHANGED
package/src/display/Options.ts
CHANGED
|
@@ -562,6 +562,14 @@ export class Options extends RapidElement {
|
|
|
562
562
|
return;
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
+
// Don't intercept keys when the popup isn't visible to the user. The
|
|
566
|
+
// element can still be in the layout tree (so offsetParent is non-null)
|
|
567
|
+
// while being hidden via opacity/pointer-events, in which case handling
|
|
568
|
+
// keys would steal them from the underlying input.
|
|
569
|
+
if (!this.block && !this.visible) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
565
573
|
// Only intercept keys when the event originated within our owning component.
|
|
566
574
|
// Without this, any temba-options with populated options would swallow arrow
|
|
567
575
|
// keys document-wide, breaking cursor movement in unrelated text editors.
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Cursor management utilities for contenteditable
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// Cursor management utilities for contenteditable.
|
|
3
|
+
//
|
|
4
|
+
// The DOM produced by our renderer is a flat list of <span> tokens (newlines
|
|
5
|
+
// are "\n" characters inside <span class="tok-newline">) plus an optional
|
|
6
|
+
// trailing <br data-sentinel> that exists only so Firefox renders an empty
|
|
7
|
+
// final line. Browser-inserted structures (e.g. from yank/paste) are also
|
|
8
|
+
// possible: a bare <br> represents a real newline, and a <div> or <p> at the
|
|
9
|
+
// editable's root level represents a line block. Both are translated to "\n"
|
|
10
|
+
// when computing plain-text offsets so the rebuild after handleInput sees the
|
|
11
|
+
// correct value.
|
|
6
12
|
// ---------------------------------------------------------------------------
|
|
7
13
|
|
|
8
14
|
/** Gets the Selection object, handling shadow DOM. */
|
|
@@ -14,20 +20,51 @@ export function getSelectionFromRoot(element: HTMLElement): Selection | null {
|
|
|
14
20
|
return window.getSelection();
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
function isSentinelBr(node: Node): boolean {
|
|
24
|
+
return (
|
|
25
|
+
node.nodeName === 'BR' &&
|
|
26
|
+
typeof (node as Element).hasAttribute === 'function' &&
|
|
27
|
+
(node as Element).hasAttribute('data-sentinel')
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isBlockElement(node: Node): boolean {
|
|
32
|
+
return node.nodeName === 'DIV' || node.nodeName === 'P';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Plain-text contribution of a node and its descendants. */
|
|
36
|
+
function textOfNode(node: Node): string {
|
|
19
37
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
20
|
-
return node.textContent
|
|
38
|
+
return node.textContent || '';
|
|
21
39
|
}
|
|
22
|
-
// Ignore browser-added <br> artifacts
|
|
23
40
|
if (node.nodeName === 'BR') {
|
|
24
|
-
return
|
|
41
|
+
return isSentinelBr(node) ? '' : '\n';
|
|
25
42
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
len += nodeTextLength(child);
|
|
43
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
44
|
+
return '';
|
|
29
45
|
}
|
|
30
|
-
return
|
|
46
|
+
return textOfChildren(node);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Plain-text contribution of an element's children, with block-prefix \n. */
|
|
50
|
+
function textOfChildren(parent: Node): string {
|
|
51
|
+
let text = '';
|
|
52
|
+
let hasContent = false;
|
|
53
|
+
for (const child of Array.from(parent.childNodes)) {
|
|
54
|
+
if (
|
|
55
|
+
child.nodeType === Node.ELEMENT_NODE &&
|
|
56
|
+
isBlockElement(child) &&
|
|
57
|
+
hasContent
|
|
58
|
+
) {
|
|
59
|
+
text += '\n';
|
|
60
|
+
}
|
|
61
|
+
const piece = textOfNode(child);
|
|
62
|
+
text += piece;
|
|
63
|
+
if (piece.length > 0) {
|
|
64
|
+
hasContent = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return text;
|
|
31
68
|
}
|
|
32
69
|
|
|
33
70
|
/** Converts a DOM selection position (container + offset) to a plain-text offset. */
|
|
@@ -36,38 +73,70 @@ function domPositionToTextOffset(
|
|
|
36
73
|
targetContainer: Node,
|
|
37
74
|
targetOffset: number
|
|
38
75
|
): number {
|
|
39
|
-
|
|
76
|
+
// Build the text from `root` up to (target, targetOffset) and return its
|
|
77
|
+
// length. This mirrors textOfChildren so block-prefix newlines and BR
|
|
78
|
+
// newlines are accounted for the same way when reading and writing.
|
|
79
|
+
let text = '';
|
|
80
|
+
let stopped = false;
|
|
81
|
+
|
|
82
|
+
const walk = (node: Node): void => {
|
|
83
|
+
if (stopped) return;
|
|
40
84
|
|
|
41
|
-
const walk = (node: Node): boolean => {
|
|
42
85
|
if (node === targetContainer) {
|
|
43
86
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
44
|
-
|
|
45
|
-
} else {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
87
|
+
text += (node.textContent || '').substring(0, targetOffset);
|
|
88
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
89
|
+
const children = Array.from(node.childNodes);
|
|
90
|
+
let hasContent = false;
|
|
91
|
+
for (let i = 0; i < Math.min(targetOffset, children.length); i++) {
|
|
92
|
+
const child = children[i];
|
|
93
|
+
if (
|
|
94
|
+
child.nodeType === Node.ELEMENT_NODE &&
|
|
95
|
+
isBlockElement(child) &&
|
|
96
|
+
hasContent
|
|
97
|
+
) {
|
|
98
|
+
text += '\n';
|
|
99
|
+
}
|
|
100
|
+
const piece = textOfNode(child);
|
|
101
|
+
text += piece;
|
|
102
|
+
if (piece.length > 0) hasContent = true;
|
|
49
103
|
}
|
|
50
104
|
}
|
|
51
|
-
|
|
105
|
+
stopped = true;
|
|
106
|
+
return;
|
|
52
107
|
}
|
|
53
108
|
|
|
54
109
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
55
|
-
|
|
56
|
-
return
|
|
110
|
+
text += node.textContent || '';
|
|
111
|
+
return;
|
|
57
112
|
}
|
|
58
|
-
// Ignore browser-added <br> artifacts
|
|
59
113
|
if (node.nodeName === 'BR') {
|
|
60
|
-
|
|
114
|
+
if (!isSentinelBr(node)) text += '\n';
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
118
|
+
return;
|
|
61
119
|
}
|
|
62
120
|
|
|
121
|
+
let hasContent = false;
|
|
63
122
|
for (const child of Array.from(node.childNodes)) {
|
|
64
|
-
if (
|
|
123
|
+
if (stopped) break;
|
|
124
|
+
if (
|
|
125
|
+
child.nodeType === Node.ELEMENT_NODE &&
|
|
126
|
+
isBlockElement(child) &&
|
|
127
|
+
hasContent
|
|
128
|
+
) {
|
|
129
|
+
text += '\n';
|
|
130
|
+
}
|
|
131
|
+
const before = text.length;
|
|
132
|
+
walk(child);
|
|
133
|
+
if (stopped) break;
|
|
134
|
+
if (text.length > before) hasContent = true;
|
|
65
135
|
}
|
|
66
|
-
return false;
|
|
67
136
|
};
|
|
68
137
|
|
|
69
138
|
walk(root);
|
|
70
|
-
return
|
|
139
|
+
return text.length;
|
|
71
140
|
}
|
|
72
141
|
|
|
73
142
|
/** Converts a plain-text offset to a DOM position (node + offset). */
|
|
@@ -76,49 +145,103 @@ function textOffsetToDomPosition(
|
|
|
76
145
|
targetOffset: number
|
|
77
146
|
): { node: Node; offset: number } | null {
|
|
78
147
|
let remaining = targetOffset;
|
|
148
|
+
// Track the last text node passed through so we can fall back to its end
|
|
149
|
+
// when the offset sits past all text content.
|
|
150
|
+
let lastTextNode: Node | null = null;
|
|
151
|
+
let lastTextOffset = 0;
|
|
152
|
+
let result: { node: Node; offset: number } | null = null;
|
|
153
|
+
|
|
154
|
+
const walk = (node: Node, parent: Node | null): void => {
|
|
155
|
+
if (result !== null) return;
|
|
79
156
|
|
|
80
|
-
const walk = (node: Node): { node: Node; offset: number } | null => {
|
|
81
157
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
82
|
-
|
|
83
|
-
|
|
158
|
+
const len = node.textContent.length;
|
|
159
|
+
// Use strict "<" so positions at the boundary between two text nodes
|
|
160
|
+
// resolve to the START of the next node rather than the END of the
|
|
161
|
+
// previous one. Firefox doesn't paint the caret reliably when it lands
|
|
162
|
+
// at the end of an inline span whose text is just "\n", but happily
|
|
163
|
+
// renders it at the start of the following node.
|
|
164
|
+
if (remaining < len) {
|
|
165
|
+
result = { node, offset: remaining };
|
|
166
|
+
return;
|
|
84
167
|
}
|
|
85
|
-
|
|
86
|
-
|
|
168
|
+
lastTextNode = node;
|
|
169
|
+
lastTextOffset = len;
|
|
170
|
+
remaining -= len;
|
|
171
|
+
return;
|
|
87
172
|
}
|
|
88
|
-
|
|
173
|
+
|
|
89
174
|
if (node.nodeName === 'BR') {
|
|
90
|
-
return
|
|
175
|
+
if (isSentinelBr(node)) return;
|
|
176
|
+
if (remaining === 0 && parent) {
|
|
177
|
+
const idx = Array.from(parent.childNodes).indexOf(node as ChildNode);
|
|
178
|
+
if (idx >= 0) {
|
|
179
|
+
result = { node: parent, offset: idx };
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
remaining -= 1;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
188
|
+
return;
|
|
91
189
|
}
|
|
92
190
|
|
|
191
|
+
let hasContent = false;
|
|
93
192
|
for (const child of Array.from(node.childNodes)) {
|
|
94
|
-
|
|
95
|
-
if (
|
|
193
|
+
if (result !== null) return;
|
|
194
|
+
if (
|
|
195
|
+
child.nodeType === Node.ELEMENT_NODE &&
|
|
196
|
+
isBlockElement(child) &&
|
|
197
|
+
hasContent
|
|
198
|
+
) {
|
|
199
|
+
if (remaining === 0) {
|
|
200
|
+
const idx = Array.from(node.childNodes).indexOf(child as ChildNode);
|
|
201
|
+
if (idx >= 0) {
|
|
202
|
+
result = { node, offset: idx };
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
remaining -= 1;
|
|
207
|
+
}
|
|
208
|
+
const before = remaining;
|
|
209
|
+
walk(child, node);
|
|
210
|
+
if (result !== null) return;
|
|
211
|
+
if (remaining < before) hasContent = true;
|
|
96
212
|
}
|
|
97
|
-
return null;
|
|
98
213
|
};
|
|
99
214
|
|
|
100
|
-
|
|
215
|
+
walk(root, null);
|
|
216
|
+
if (result) return result;
|
|
217
|
+
|
|
218
|
+
if (remaining === 0) {
|
|
219
|
+
// Past all content. If the editor ends in a <br>, position relative to it
|
|
220
|
+
// so the caret renders on the empty trailing line in Firefox.
|
|
221
|
+
const lastChild = root.lastChild;
|
|
222
|
+
if (lastChild && lastChild.nodeName === 'BR') {
|
|
223
|
+
// Sentinel: render before it. Non-sentinel (transient post-yank state):
|
|
224
|
+
// render after it so the caret sits on the empty line the BR produces.
|
|
225
|
+
const offset = isSentinelBr(lastChild)
|
|
226
|
+
? root.childNodes.length - 1
|
|
227
|
+
: root.childNodes.length;
|
|
228
|
+
return { node: root, offset };
|
|
229
|
+
}
|
|
230
|
+
if (lastTextNode) {
|
|
231
|
+
return { node: lastTextNode, offset: lastTextOffset };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
101
235
|
}
|
|
102
236
|
|
|
103
237
|
/**
|
|
104
|
-
* Extracts plain text from the contenteditable DOM
|
|
105
|
-
*
|
|
238
|
+
* Extracts plain text from the contenteditable DOM. Translates non-sentinel
|
|
239
|
+
* <br> elements to "\n" and inserts a "\n" before block-level (DIV/P) children
|
|
240
|
+
* that follow other content, so yank/paste-inserted DOM structures round-trip
|
|
241
|
+
* through handleInput correctly.
|
|
106
242
|
*/
|
|
107
243
|
export function getTextFromEditableDiv(element: HTMLElement): string {
|
|
108
|
-
|
|
109
|
-
for (const child of Array.from(element.childNodes)) {
|
|
110
|
-
if (child.nodeType === Node.TEXT_NODE) {
|
|
111
|
-
text += child.textContent;
|
|
112
|
-
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
113
|
-
// Skip browser-added <br> artifacts
|
|
114
|
-
if (child.nodeName === 'BR') {
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
// Recurse into spans and other elements
|
|
118
|
-
text += getTextFromEditableDiv(child as HTMLElement);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return text;
|
|
244
|
+
return textOfChildren(element);
|
|
122
245
|
}
|
|
123
246
|
|
|
124
247
|
/** Gets the caret (selection start) as a plain-text offset. */
|
|
@@ -171,3 +294,4 @@ export function setCaretRange(
|
|
|
171
294
|
selection.removeAllRanges();
|
|
172
295
|
selection.addRange(range);
|
|
173
296
|
}
|
|
297
|
+
|
|
@@ -9,6 +9,7 @@ import { fetchResults } from '../utils';
|
|
|
9
9
|
import { FLOW_SPEC_VERSION } from '../store/AppState';
|
|
10
10
|
import {
|
|
11
11
|
labelsFor,
|
|
12
|
+
normalizeChanges,
|
|
12
13
|
RevisionChanges,
|
|
13
14
|
summarizeChanges
|
|
14
15
|
} from './revision-summary';
|
|
@@ -151,7 +152,7 @@ export class RevisionsWindow extends RapidElement {
|
|
|
151
152
|
value=${rev.created_on}
|
|
152
153
|
display="duration"
|
|
153
154
|
></temba-date>
|
|
154
|
-
· ${
|
|
155
|
+
· ${this.renderUser(rev.user)}
|
|
155
156
|
</div>
|
|
156
157
|
${isCurrent
|
|
157
158
|
? html`<div
|
|
@@ -192,6 +193,13 @@ export class RevisionsWindow extends RapidElement {
|
|
|
192
193
|
|
|
193
194
|
// --- Private ---
|
|
194
195
|
|
|
196
|
+
private renderUser(user: Revision['user']): TemplateResult | string {
|
|
197
|
+
if (user?.email === 'system') {
|
|
198
|
+
return html`<em>System update</em>`;
|
|
199
|
+
}
|
|
200
|
+
return user?.name || user?.username || '';
|
|
201
|
+
}
|
|
202
|
+
|
|
195
203
|
private async fetchRevisions() {
|
|
196
204
|
const requestId = ++this.fetchRequestId;
|
|
197
205
|
this.isLoading = true;
|
|
@@ -217,19 +225,36 @@ export class RevisionsWindow extends RapidElement {
|
|
|
217
225
|
// the window. The merged revision is capped at three distinct displayed
|
|
218
226
|
// labels — once a fourth would be introduced we break out into a new row.
|
|
219
227
|
private collapseRevisions(revisions: Revision[]): Revision[] {
|
|
228
|
+
// Normalize at the boundary so the rest of the logic reasons about real
|
|
229
|
+
// edits only. After this step, `changes === null` is the single signal
|
|
230
|
+
// for "no-op" — used both for the author-barrier bypass and for keeping
|
|
231
|
+
// housekeeping tags out of the merged tag set and label cap.
|
|
232
|
+
const cleaned = revisions.map((r) => ({
|
|
233
|
+
...r,
|
|
234
|
+
changes: normalizeChanges(r.changes)
|
|
235
|
+
}));
|
|
220
236
|
// The API returns newest-first today; sort defensively so the head/window
|
|
221
237
|
// logic stays correct if that ever changes.
|
|
222
|
-
const sorted = [...
|
|
238
|
+
const sorted = [...cleaned].sort(
|
|
223
239
|
(a, b) =>
|
|
224
240
|
new Date(b.created_on).getTime() - new Date(a.created_on).getTime()
|
|
225
241
|
);
|
|
226
242
|
const result: Revision[] = [];
|
|
227
243
|
let group: Revision[] = [];
|
|
228
244
|
let groupLabels = new Set<string>();
|
|
245
|
+
let groupHasRealChange = false;
|
|
229
246
|
|
|
230
247
|
const flush = () => {
|
|
231
248
|
if (group.length === 0) return;
|
|
232
249
|
const head = group[0];
|
|
250
|
+
// Pick the user from the most recent real-change revision in the
|
|
251
|
+
// group. No-op authors (typically the system, doing spec bumps)
|
|
252
|
+
// shouldn't appear as the editor when a real user's edit was
|
|
253
|
+
// absorbed into the row — that would mislabel the change as
|
|
254
|
+
// "System update" even though a real person did the work. Fall back
|
|
255
|
+
// to the head if every revision was a no-op.
|
|
256
|
+
const realChange = group.find((r) => r.changes);
|
|
257
|
+
const displayUser = realChange?.user ?? head.user;
|
|
233
258
|
const tagSet = new Set<string>();
|
|
234
259
|
let anyKnown = false;
|
|
235
260
|
for (const r of group) {
|
|
@@ -240,41 +265,62 @@ export class RevisionsWindow extends RapidElement {
|
|
|
240
265
|
}
|
|
241
266
|
result.push({
|
|
242
267
|
...head,
|
|
268
|
+
user: displayUser,
|
|
243
269
|
changes: anyKnown ? { tags: Array.from(tagSet) } : null
|
|
244
270
|
});
|
|
245
271
|
group = [];
|
|
246
272
|
groupLabels = new Set();
|
|
273
|
+
groupHasRealChange = false;
|
|
247
274
|
};
|
|
248
275
|
|
|
249
276
|
for (const rev of sorted) {
|
|
250
277
|
if (group.length === 0) {
|
|
251
278
|
group.push(rev);
|
|
252
279
|
groupLabels = labelsFor(rev.changes);
|
|
280
|
+
groupHasRealChange = !!rev.changes;
|
|
253
281
|
continue;
|
|
254
282
|
}
|
|
255
283
|
const head = group[0];
|
|
256
284
|
const headTime = new Date(head.created_on).getTime();
|
|
257
285
|
const revTime = new Date(rev.created_on).getTime();
|
|
258
|
-
const withinWindow = headTime - revTime < GROUP_WINDOW_MS;
|
|
259
286
|
// Compare on whichever identifier the server provides — real data
|
|
260
287
|
// arrives with `email`, while test fixtures use `username`. Falling
|
|
261
288
|
// back through the chain keeps both shapes working.
|
|
262
289
|
const headId = head.user?.email ?? head.user?.username;
|
|
263
290
|
const revId = rev.user?.email ?? rev.user?.username;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
291
|
+
// Two conditions bypass the time/author barriers:
|
|
292
|
+
// 1. The incoming rev is itself a no-op — it carries no editorial
|
|
293
|
+
// intent and should disappear into whichever group it neighbors.
|
|
294
|
+
// 2. The group hasn't accumulated a real change yet — we never want
|
|
295
|
+
// to surface a row showing "nothing changed", so a no-op-only
|
|
296
|
+
// chain reaches forward to absorb the first real edit even if
|
|
297
|
+
// that edit is far away in time or by a different author.
|
|
298
|
+
const isNoOp = !rev.changes;
|
|
299
|
+
const bypassBarriers = isNoOp || !groupHasRealChange;
|
|
300
|
+
const withinWindow =
|
|
301
|
+
bypassBarriers || headTime - revTime < GROUP_WINDOW_MS;
|
|
302
|
+
const sameAuthor = bypassBarriers || headId === revId;
|
|
303
|
+
const prospective = new Set([...groupLabels, ...labelsFor(rev.changes)]);
|
|
304
|
+
// The label cap is meaningful only when adding a real change to a
|
|
305
|
+
// group that already has one. A no-op contributes zero labels by
|
|
306
|
+
// construction, so it never trips the cap; and a no-op-only chain
|
|
307
|
+
// reaching forward to absorb its first real edit must ignore the cap
|
|
308
|
+
// too, or a sweeping edit (4+ label areas) would still strand the
|
|
309
|
+
// no-op group as an empty-summary row.
|
|
310
|
+
const fitsLabelCap =
|
|
311
|
+
isNoOp ||
|
|
312
|
+
!groupHasRealChange ||
|
|
313
|
+
prospective.size <= MAX_GROUP_LABELS;
|
|
270
314
|
|
|
271
315
|
if (withinWindow && sameAuthor && fitsLabelCap) {
|
|
272
316
|
group.push(rev);
|
|
273
317
|
groupLabels = prospective;
|
|
318
|
+
if (!isNoOp) groupHasRealChange = true;
|
|
274
319
|
} else {
|
|
275
320
|
flush();
|
|
276
321
|
group.push(rev);
|
|
277
322
|
groupLabels = labelsFor(rev.changes);
|
|
323
|
+
groupHasRealChange = !!rev.changes;
|
|
278
324
|
}
|
|
279
325
|
}
|
|
280
326
|
flush();
|
|
@@ -2,6 +2,23 @@ export interface RevisionChanges {
|
|
|
2
2
|
tags: string[];
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
// "spec" is the housekeeping tag the system attaches when it bumps a flow's
|
|
6
|
+
// spec version. It carries no editorial intent, so we strip it at the
|
|
7
|
+
// boundary — every downstream consumer (summaries, label caps, no-op
|
|
8
|
+
// detection) then operates on a clean tag set without needing special cases.
|
|
9
|
+
const NOOP_TAGS = new Set(['spec']);
|
|
10
|
+
|
|
11
|
+
// Drop tags that don't represent real edits and collapse to null when nothing
|
|
12
|
+
// meaningful remains. Returning null lets `isNoOpChanges` and the collapse
|
|
13
|
+
// logic treat empty-after-filtering and originally-null the same way.
|
|
14
|
+
export function normalizeChanges(
|
|
15
|
+
changes: RevisionChanges | null | undefined
|
|
16
|
+
): RevisionChanges | null {
|
|
17
|
+
if (!changes) return null;
|
|
18
|
+
const tags = (changes.tags || []).filter((t) => !NOOP_TAGS.has(t));
|
|
19
|
+
return tags.length === 0 ? null : { tags };
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
const TAG_LABELS: Record<string, { label: string; order: number }> = {
|
|
6
23
|
metadata: { label: 'metadata', order: 0 },
|
|
7
24
|
nodes: { label: 'nodes', order: 1 },
|
|
@@ -32,6 +49,14 @@ export function labelsFor(
|
|
|
32
49
|
return result;
|
|
33
50
|
}
|
|
34
51
|
|
|
52
|
+
// A revision is a no-op when, after stripping housekeeping tags, nothing
|
|
53
|
+
// meaningful is left. These shouldn't break up adjacent edits in the browser.
|
|
54
|
+
export function isNoOpChanges(
|
|
55
|
+
changes: RevisionChanges | null | undefined
|
|
56
|
+
): boolean {
|
|
57
|
+
return normalizeChanges(changes) === null;
|
|
58
|
+
}
|
|
59
|
+
|
|
35
60
|
export function summarizeChanges(
|
|
36
61
|
changes: RevisionChanges | null | undefined
|
|
37
62
|
): string {
|
package/src/form/RichEditor.ts
CHANGED
|
@@ -97,6 +97,7 @@ export class RichEditor extends FieldElement {
|
|
|
97
97
|
overflow-y: auto;
|
|
98
98
|
min-height: var(--textarea-min-height, 100px);
|
|
99
99
|
resize: none;
|
|
100
|
+
position: relative;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
:host(:not([textarea])) {
|
|
@@ -119,6 +120,12 @@ export class RichEditor extends FieldElement {
|
|
|
119
120
|
content: attr(data-placeholder);
|
|
120
121
|
color: var(--color-placeholder, #999);
|
|
121
122
|
pointer-events: none;
|
|
123
|
+
/* Take the placeholder out of flow so the caret sits at offset 0
|
|
124
|
+
visually, not after the placeholder text (Firefox honors flow
|
|
125
|
+
strictly here while Chrome happens to overlay the caret). */
|
|
126
|
+
position: absolute;
|
|
127
|
+
inset: 0;
|
|
128
|
+
padding: inherit;
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
/* Token styles (shared) */
|
|
@@ -302,6 +309,11 @@ export class RichEditor extends FieldElement {
|
|
|
302
309
|
|
|
303
310
|
if (this.disableCompletion) {
|
|
304
311
|
div.textContent = text || '';
|
|
312
|
+
if (text && text.endsWith('\n')) {
|
|
313
|
+
const br = document.createElement('br');
|
|
314
|
+
br.setAttribute('data-sentinel', '');
|
|
315
|
+
div.appendChild(br);
|
|
316
|
+
}
|
|
305
317
|
return;
|
|
306
318
|
}
|
|
307
319
|
|
|
@@ -359,6 +371,15 @@ export class RichEditor extends FieldElement {
|
|
|
359
371
|
// Ensure there's at least an empty text node for cursor placement
|
|
360
372
|
if (!text || text === '') {
|
|
361
373
|
div.appendChild(document.createTextNode(''));
|
|
374
|
+
} else if (text.endsWith('\n')) {
|
|
375
|
+
// A trailing "\n" text node is collapsed by Firefox in contenteditable,
|
|
376
|
+
// making the empty final line invisible. A sentinel <br> forces the
|
|
377
|
+
// line break to render. The data-sentinel attribute distinguishes it
|
|
378
|
+
// from browser-inserted <br>s (which represent real newlines and must
|
|
379
|
+
// be preserved by the caret/text utilities).
|
|
380
|
+
const br = document.createElement('br');
|
|
381
|
+
br.setAttribute('data-sentinel', '');
|
|
382
|
+
div.appendChild(br);
|
|
362
383
|
}
|
|
363
384
|
}
|
|
364
385
|
|