@agent-native/core-corpus 0.217.0 → 0.218.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 (56) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/CHANGELOG.md +2 -0
  3. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +38 -167
  4. package/corpus/templates/analytics/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  5. package/corpus/templates/assets/CHANGELOG.md +6 -0
  6. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +51 -117
  7. package/corpus/templates/assets/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  8. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +34 -101
  9. package/corpus/templates/brain/app/routes/sources.tsx +1 -1
  10. package/corpus/templates/brain/vitest.config.ts +7 -0
  11. package/corpus/templates/calendar/CHANGELOG.md +6 -0
  12. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +1 -1
  13. package/corpus/templates/calendar/changelog/2026-10-16-booking-links-show-localized-visibility-badges.md +6 -0
  14. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +62 -101
  15. package/corpus/templates/clips/CHANGELOG.md +7 -20
  16. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +20 -77
  17. package/corpus/templates/clips/app/components/player/share-dialog.tsx +63 -90
  18. package/corpus/templates/clips/app/components/sharing/share-test-support.tsx +180 -0
  19. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -584
  20. package/corpus/templates/clips/changelog/2026-10-16-share-dialogs-make-it-easier-to-see-who-has-access-and-manag.md +6 -0
  21. package/corpus/templates/clips/changelog/2026-10-21-share-panels-describe-general-access-in-a-sentence.md +6 -0
  22. package/corpus/templates/community/qr/package.json +1 -1
  23. package/corpus/templates/content/CHANGELOG.md +2 -0
  24. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +119 -30
  25. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +56 -13
  26. package/corpus/templates/content/app/components/editor/SuggestionText.tsx +18 -3
  27. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +89 -22
  28. package/corpus/templates/content/app/components/editor/extensions/SuggestionHighlight.ts +383 -34
  29. package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +31 -0
  30. package/corpus/templates/content/app/components/editor/suggestions/presentation-rebase.ts +82 -4
  31. package/corpus/templates/content/app/global.css +31 -0
  32. package/corpus/templates/content/changelog/2026-10-16-accepting-a-suggestion-on-a-page-that-changed-since-it-was-s.md +6 -0
  33. package/corpus/templates/content/changelog/2026-10-20-suggested-edits-that-add-remove-split-or-merge-paragraphs-li.md +6 -0
  34. package/corpus/templates/content/shared/suggestion-diff.ts +23 -6
  35. package/corpus/templates/content/shared/suggestion-formatting.ts +44 -14
  36. package/corpus/templates/content/shared/suggestion-text.ts +20 -5
  37. package/corpus/templates/design/CHANGELOG.md +3 -0
  38. package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
  39. package/corpus/templates/design/app/pages/Index.tsx +18 -64
  40. package/corpus/templates/design/changelog/2026-10-16-design-pages-use-localized-visibility-badges-and-a-shared-ow.md +6 -0
  41. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  42. package/corpus/templates/dispatch/app/i18n-data.ts +75 -0
  43. package/corpus/templates/dispatch/changelog/2026-10-21-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  44. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +34 -100
  45. package/corpus/templates/forms/app/pages/FormsListPage.tsx +1 -1
  46. package/corpus/templates/plan/CHANGELOG.md +6 -0
  47. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +34 -109
  48. package/corpus/templates/plan/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  49. package/corpus/templates/slides/CHANGELOG.md +1 -0
  50. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +1 -1
  51. package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +1 -1
  52. package/corpus/templates/slides/app/pages/Index.tsx +7 -2
  53. package/corpus/templates/slides/changelog/2026-10-16-deck-lists-use-the-shared-ownership-filter-and-visibility-ba.md +6 -0
  54. package/package.json +1 -1
  55. package/corpus/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
  56. package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +0 -83
@@ -1439,7 +1439,26 @@ function suggestionAnchorRange(
1439
1439
  const collapsedRange = collapsedDeletionTextblock();
1440
1440
  if (collapsedRange) return collapsedRange;
1441
1441
  if (sourceMatches) {
1442
- const exactRange = sourceRangeToPm(sourceFrom, sourceTo);
1442
+ // An empty paragraph has no text to map, so a span that opens or closes
1443
+ // with one maps the text beside it, or the place it starts when that's
1444
+ // all it holds.
1445
+ const emptyLine = "<empty-block/>";
1446
+ let mapFrom = sourceFrom;
1447
+ let mapTo = sourceTo;
1448
+ for (;;) {
1449
+ if (
1450
+ mapTo - mapFrom > emptyLine.length &&
1451
+ source.slice(mapTo - emptyLine.length - 1, mapTo) === `\n${emptyLine}`
1452
+ )
1453
+ mapTo -= emptyLine.length + 1;
1454
+ else if (
1455
+ mapTo - mapFrom > emptyLine.length &&
1456
+ source.startsWith(`${emptyLine}\n`, mapFrom)
1457
+ )
1458
+ mapFrom += emptyLine.length + 1;
1459
+ else break;
1460
+ }
1461
+ const exactRange = sourceRangeToPm(mapFrom, mapTo);
1443
1462
  if (exactRange) return exactRange;
1444
1463
  }
1445
1464
  const mappedSource = suggestionAnchorText(source);
@@ -1592,11 +1611,18 @@ export function suggestionHighlightSpec(
1592
1611
  editableText: true,
1593
1612
  };
1594
1613
  }
1614
+ // Removed syntax with no text, such as indentation, a list marker, or an
1615
+ // empty paragraph, is not in the document text, so it is drawn instead.
1595
1616
  const structuralDeletion =
1596
1617
  /^\n+$/.test(suggestionAnchorText(suggestion.beforeText)) ||
1597
1618
  (beforePresentation &&
1598
1619
  beforePresentation.length > 0 &&
1599
- beforePresentation.every((node) => node.type === "indent"));
1620
+ beforePresentation.every(
1621
+ (node) =>
1622
+ node.type === "indent" ||
1623
+ node.type === "block" ||
1624
+ (node.type === "text" && /^↵+$/.test(node.value)),
1625
+ ));
1600
1626
  return {
1601
1627
  suggestionId: suggestion.id,
1602
1628
  kind:
@@ -4743,9 +4769,17 @@ export function VisualEditor({
4743
4769
  const apply = (report = true) => {
4744
4770
  if (editor.isDestroyed) return;
4745
4771
  const specs = suggestions
4746
- .map((suggestion) =>
4747
- suggestionHighlightSpec(editor.state.doc, suggestion),
4748
- )
4772
+ .map((suggestion) => {
4773
+ const spec = suggestionHighlightSpec(editor.state.doc, suggestion);
4774
+ // While suggesting, a saved suggestion's text outside the document
4775
+ // is somewhere to type beside, not a control to focus.
4776
+ return spec &&
4777
+ suggesting &&
4778
+ editable &&
4779
+ suggestion.presentation === "canonical"
4780
+ ? { ...spec, editableBoundary: true }
4781
+ : spec;
4782
+ })
4749
4783
  .filter((spec): spec is SuggestionHighlightSpec => spec !== null);
4750
4784
  if (report)
4751
4785
  onSuggestionAnchorsChange?.(
@@ -4771,8 +4805,10 @@ export function VisualEditor({
4771
4805
  };
4772
4806
  }, [
4773
4807
  activeSuggestionId,
4808
+ editable,
4774
4809
  editor,
4775
4810
  onSuggestionAnchorsChange,
4811
+ suggesting,
4776
4812
  suggestions,
4777
4813
  suggestionsSignature,
4778
4814
  showCommentIndicators,
@@ -4796,34 +4832,46 @@ export function VisualEditor({
4796
4832
  };
4797
4833
  }, [editor]);
4798
4834
 
4835
+ // Callers build the readbacks inline and render again on every report, so
4836
+ // these checks key on the readbacks' fields and report each divergent body
4837
+ // once: keyed on the objects, each report re-runs its check until React
4838
+ // aborts the render.
4839
+ const acceptedReadbackId = acceptedDecisionReadback?.id ?? null;
4840
+ const acceptedReadbackContent = acceptedDecisionReadback?.content ?? null;
4799
4841
  useLayoutEffect(() => {
4800
- if (!editor || editor.isDestroyed || !acceptedDecisionReadback) return;
4842
+ if (
4843
+ !editor ||
4844
+ editor.isDestroyed ||
4845
+ acceptedReadbackId === null ||
4846
+ acceptedReadbackContent === null
4847
+ )
4848
+ return;
4801
4849
  const settlingSuggestion = suggestions.find(
4802
- (suggestion) => suggestion.id === acceptedDecisionReadback.id,
4850
+ (suggestion) => suggestion.id === acceptedReadbackId,
4803
4851
  );
4804
4852
  if (!settlingSuggestion) return;
4853
+ let reported: string | null = null;
4805
4854
  const check = () => {
4806
4855
  if (editor.isDestroyed) return;
4807
4856
  const actualContent = docToNfm(editor.getJSON() as any);
4808
4857
  if (
4809
4858
  acceptedSuggestionRendered(
4810
4859
  actualContent,
4811
- acceptedDecisionReadback.content,
4860
+ acceptedReadbackContent,
4812
4861
  settlingSuggestion,
4813
4862
  )
4814
4863
  ) {
4815
- onAcceptedDecisionRendered?.(acceptedDecisionReadback.id);
4864
+ onAcceptedDecisionRendered?.(acceptedReadbackId);
4816
4865
  } else if (
4866
+ actualContent !== reported &&
4817
4867
  acceptedSuggestionReadbackOutdated(
4818
4868
  actualContent,
4819
- acceptedDecisionReadback.content,
4869
+ acceptedReadbackContent,
4820
4870
  settlingSuggestion,
4821
4871
  )
4822
4872
  ) {
4823
- onAcceptedDecisionReadbackOutdated?.(
4824
- acceptedDecisionReadback.id,
4825
- actualContent,
4826
- );
4873
+ reported = actualContent;
4874
+ onAcceptedDecisionReadbackOutdated?.(acceptedReadbackId, actualContent);
4827
4875
  }
4828
4876
  };
4829
4877
  editor.on("transaction", check);
@@ -4832,27 +4880,44 @@ export function VisualEditor({
4832
4880
  editor.off("transaction", check);
4833
4881
  };
4834
4882
  }, [
4835
- acceptedDecisionReadback,
4883
+ acceptedReadbackContent,
4884
+ acceptedReadbackId,
4836
4885
  editor,
4837
4886
  onAcceptedDecisionRendered,
4838
4887
  onAcceptedDecisionReadbackOutdated,
4839
4888
  suggestions,
4840
4889
  ]);
4841
4890
 
4891
+ const proposalReadbackGeneration =
4892
+ proposalDecisionReadback?.generation ?? null;
4893
+ const proposalReadbackContent = proposalDecisionReadback?.content ?? null;
4894
+ const proposalReadbackBefore =
4895
+ proposalDecisionReadback?.beforeContent ?? null;
4842
4896
  useLayoutEffect(() => {
4843
- if (!editor || editor.isDestroyed || !proposalDecisionReadback) return;
4897
+ if (
4898
+ !editor ||
4899
+ editor.isDestroyed ||
4900
+ proposalReadbackGeneration === null ||
4901
+ proposalReadbackContent === null ||
4902
+ proposalReadbackBefore === null
4903
+ )
4904
+ return;
4905
+ let reported: string | null = null;
4844
4906
  const check = () => {
4845
4907
  if (editor.isDestroyed) return;
4846
4908
  const actual = canonicalizeNfm(docToNfm(editor.getJSON() as any));
4847
- if (actual === canonicalizeNfm(proposalDecisionReadback.content))
4848
- onProposalDecisionRendered?.(proposalDecisionReadback.generation);
4909
+ if (actual === canonicalizeNfm(proposalReadbackContent))
4910
+ onProposalDecisionRendered?.(proposalReadbackGeneration);
4849
4911
  else if (
4850
- actual !== canonicalizeNfm(proposalDecisionReadback.beforeContent)
4851
- )
4912
+ actual !== reported &&
4913
+ actual !== canonicalizeNfm(proposalReadbackBefore)
4914
+ ) {
4915
+ reported = actual;
4852
4916
  onProposalDecisionReadbackOutdated?.(
4853
- proposalDecisionReadback.generation,
4917
+ proposalReadbackGeneration,
4854
4918
  actual,
4855
4919
  );
4920
+ }
4856
4921
  };
4857
4922
  editor.on("transaction", check);
4858
4923
  check();
@@ -4863,7 +4928,9 @@ export function VisualEditor({
4863
4928
  editor,
4864
4929
  onProposalDecisionReadbackOutdated,
4865
4930
  onProposalDecisionRendered,
4866
- proposalDecisionReadback,
4931
+ proposalReadbackBefore,
4932
+ proposalReadbackContent,
4933
+ proposalReadbackGeneration,
4867
4934
  ]);
4868
4935
 
4869
4936
  useEffect(() => {
@@ -80,7 +80,11 @@ function appendPresentationNode(
80
80
  node: SuggestionPresentationNode,
81
81
  showLinkDestination: boolean,
82
82
  ): void {
83
- if (node.type === "text" || node.type === "indent") {
83
+ if (node.type === "lineBreak") {
84
+ parent.append(document.createElement("br"));
85
+ return;
86
+ }
87
+ if (node.type === "text" || node.type === "indent" || node.type === "block") {
84
88
  parent.append(document.createTextNode(node.value));
85
89
  return;
86
90
  }
@@ -127,7 +131,40 @@ function appendSuggestionText(
127
131
  }
128
132
  }
129
133
 
130
- function insertionWidget(spec: SuggestionHighlightSpec, active: boolean) {
134
+ // While the reader can type, a click on suggested text that isn't in the
135
+ // document places the caret where the widget sits instead of focusing it.
136
+ // `control` takes the button role when the widget itself has a role to keep.
137
+ function identifyWidget(
138
+ widget: HTMLElement,
139
+ spec: SuggestionHighlightSpec,
140
+ label: string,
141
+ position = spec.from,
142
+ control = widget,
143
+ ) {
144
+ widget.setAttribute("data-suggestion-id", spec.suggestionId);
145
+ widget.setAttribute("data-suggestion-widget", "true");
146
+ if (spec.editableBoundary) {
147
+ widget.setAttribute("data-suggestion-edit-boundary", "true");
148
+ widget.setAttribute("data-suggestion-position", String(position));
149
+ } else {
150
+ control.setAttribute("role", "button");
151
+ control.setAttribute("tabindex", "0");
152
+ control.setAttribute("aria-label", label);
153
+ }
154
+ }
155
+
156
+ function insertionClass(spec: SuggestionHighlightSpec) {
157
+ return `${
158
+ spec.kind === "add_block" ? "suggestion-add-block" : "suggestion-insert"
159
+ } suggestion-proposed-text`;
160
+ }
161
+
162
+ function insertionWidget(
163
+ spec: SuggestionHighlightSpec,
164
+ active: boolean,
165
+ part?: { text: string; context?: SuggestionPresentationContext },
166
+ position?: number,
167
+ ) {
131
168
  return () => {
132
169
  const widget = document.createElement("span");
133
170
  if (spec.settling) {
@@ -142,46 +179,35 @@ function insertionWidget(spec: SuggestionHighlightSpec, active: boolean) {
142
179
  return widget;
143
180
  }
144
181
  widget.className = classes(
145
- `${
146
- spec.kind === "add_block" ? "suggestion-add-block" : "suggestion-insert"
147
- } suggestion-proposed-text suggestion-inline-widget`,
182
+ `${insertionClass(spec)} suggestion-inline-widget`,
148
183
  active,
149
184
  );
150
- widget.setAttribute("data-suggestion-id", spec.suggestionId);
151
- widget.setAttribute("data-suggestion-widget", "true");
152
- widget.setAttribute("role", "button");
153
- widget.setAttribute("tabindex", "0");
154
- widget.setAttribute("aria-label", "Inspect suggested insertion");
185
+ identifyWidget(widget, spec, "Inspect suggested insertion", position);
155
186
  appendSuggestionText(
156
187
  widget,
157
- spec.insertedText ?? "",
158
- spec.insertedPresentation,
188
+ part ? part.text : (spec.insertedText ?? ""),
189
+ part ? part.context : spec.insertedPresentation,
159
190
  );
160
191
  return widget;
161
192
  };
162
193
  }
163
194
 
164
- function deletionWidget(spec: SuggestionHighlightSpec, active: boolean) {
195
+ function deletionWidget(
196
+ spec: SuggestionHighlightSpec,
197
+ active: boolean,
198
+ part?: { text: string; context?: SuggestionPresentationContext },
199
+ ) {
165
200
  return () => {
166
201
  const widget = document.createElement("span");
167
202
  widget.className = classes(
168
203
  "suggestion-delete-widget suggestion-deleted-text suggestion-inline-widget",
169
204
  active,
170
205
  );
171
- widget.setAttribute("data-suggestion-id", spec.suggestionId);
172
- widget.setAttribute("data-suggestion-widget", "true");
173
- if (spec.editableBoundary) {
174
- widget.setAttribute("data-suggestion-edit-boundary", "true");
175
- widget.setAttribute("data-suggestion-position", String(spec.from));
176
- } else {
177
- widget.setAttribute("role", "button");
178
- widget.setAttribute("tabindex", "0");
179
- widget.setAttribute("aria-label", "Inspect suggested deletion");
180
- }
206
+ identifyWidget(widget, spec, "Inspect suggested deletion");
181
207
  appendSuggestionText(
182
208
  widget,
183
- spec.deletedText ?? "",
184
- spec.deletedPresentation,
209
+ part ? part.text : (spec.deletedText ?? ""),
210
+ part ? part.context : spec.deletedPresentation,
185
211
  );
186
212
  return widget;
187
213
  };
@@ -199,6 +225,300 @@ export function editorDocumentSource(doc: ProseMirrorNode): string {
199
225
  return source;
200
226
  }
201
227
 
228
+ type SourceSpan = { from: number; to: number };
229
+
230
+ // How suggested text with line breaks meets the lines around it: the part
231
+ // that joins the line before the point, the whole lines it adds or removes,
232
+ // and the part that joins the line after.
233
+ interface SuggestedLines {
234
+ source: string;
235
+ head: SourceSpan | null;
236
+ lines: SourceSpan[];
237
+ tail: SourceSpan | null;
238
+ side: "before" | "after";
239
+ }
240
+
241
+ function suggestedLines(
242
+ text: string | undefined,
243
+ context: SuggestionPresentationContext | undefined,
244
+ ): SuggestedLines | null {
245
+ if (!text || !context || !text.includes("\n")) return null;
246
+ const { source, from, to } = context;
247
+ if (source.slice(from, to) !== text) return null;
248
+ const startsLine = from === 0 || source[from - 1] === "\n";
249
+ const endsLine = to === source.length || source[to] === "\n";
250
+ // Text that splits a line in two has no whole line to show on its own.
251
+ if (!startsLine && !endsLine) return null;
252
+ const spans: SourceSpan[] = [];
253
+ let start = from;
254
+ for (let index = from; index < to; index += 1) {
255
+ if (source[index] !== "\n") continue;
256
+ spans.push({ from: start, to: index });
257
+ start = index + 1;
258
+ }
259
+ spans.push({ from: start, to });
260
+ const head = startsLine ? null : spans.shift()!;
261
+ const tail = endsLine ? null : spans.pop()!;
262
+ const lines = spans.filter((span) => span.to > span.from);
263
+ if (!lines.length) return null;
264
+ return {
265
+ source,
266
+ head: head && head.to > head.from ? head : null,
267
+ lines,
268
+ tail: tail && tail.to > tail.from ? tail : null,
269
+ side: startsLine ? "before" : "after",
270
+ };
271
+ }
272
+
273
+ const LIST_LINE = /^\t*(?:[-*+] (\[[ xX]\] )?|(\d+)[.)] )/;
274
+ const LIST = /^(?:bulletList|orderedList|taskList)$/;
275
+
276
+ // Where a whole line belongs: how far it is indented, and the kind of list
277
+ // it adds an item to, if it is a list item.
278
+ interface LineShape {
279
+ indent: number;
280
+ list: string | null;
281
+ }
282
+
283
+ function lineShape(text: string): LineShape {
284
+ const list = LIST_LINE.exec(text);
285
+ return {
286
+ indent: /^\t*/.exec(text)![0].length,
287
+ list: !list
288
+ ? null
289
+ : list[1]
290
+ ? "taskList"
291
+ : list[2]
292
+ ? "orderedList"
293
+ : "bulletList",
294
+ };
295
+ }
296
+
297
+ // The document position beside the block at `point` where a whole line
298
+ // goes: out of the containers the point sits at the edge of, but no further
299
+ // than the line's indentation allows, and into a list only of its own kind.
300
+ // A line that can't be placed as written has no position.
301
+ function lineBoundary(
302
+ doc: ProseMirrorNode,
303
+ point: number,
304
+ side: "before" | "after",
305
+ shape: LineShape,
306
+ ): { position: number; inList: boolean } | null {
307
+ if (point < 0 || point > doc.content.size) return null;
308
+ const $point = doc.resolve(point);
309
+ // Lines added to code are code text, not blocks of their own.
310
+ if (
311
+ !$point.parent.isTextblock ||
312
+ $point.parent.type.spec.code ||
313
+ $point.parentOffset !== (side === "before" ? 0 : $point.parent.content.size)
314
+ )
315
+ return null;
316
+ // A Page indents a block one tab per block it sits in; a list's items
317
+ // indent their children, so the list itself adds none.
318
+ const indentInside = (depth: number) => {
319
+ let indent = 0;
320
+ for (let level = 1; level <= depth; level += 1) {
321
+ const node = $point.node(level);
322
+ indent += LIST.test(node.type.name) ? 0 : 1;
323
+ indent += Number(node.attrs.indent) || 0;
324
+ }
325
+ return indent;
326
+ };
327
+ let depth = $point.depth;
328
+ while (depth > 1) {
329
+ const container = $point.node(depth - 1);
330
+ if (
331
+ container.type.name === shape.list &&
332
+ indentInside(depth - 1) === shape.indent
333
+ )
334
+ break;
335
+ const index = $point.index(depth - 1);
336
+ if (side === "before" ? index !== 0 : index !== container.childCount - 1)
337
+ break;
338
+ if (indentInside(depth - 2) < shape.indent) break;
339
+ depth -= 1;
340
+ }
341
+ const container = $point.node(depth - 1);
342
+ const inList = LIST.test(container.type.name);
343
+ if (
344
+ indentInside(depth - 1) !== shape.indent ||
345
+ (inList && container.type.name !== shape.list)
346
+ )
347
+ return null;
348
+ return {
349
+ position: side === "before" ? $point.before(depth) : $point.after(depth),
350
+ inList,
351
+ };
352
+ }
353
+
354
+ // A whole suggested line drawn as the block it would become.
355
+ function lineWidget(
356
+ spec: SuggestionHighlightSpec,
357
+ active: boolean,
358
+ className: string,
359
+ label: string,
360
+ text: string,
361
+ nodes: SuggestionPresentationNode[],
362
+ inList: boolean,
363
+ ) {
364
+ return () => {
365
+ const list = LIST_LINE.exec(text);
366
+ const heading = /^\t*(#{1,6}) /.exec(text);
367
+ const quote = /^\t*> /.test(text);
368
+ const widget = document.createElement(inList ? "li" : "div");
369
+ widget.className = classes(`${className} suggestion-block-widget`, active);
370
+ const block = document.createElement(
371
+ heading ? `h${heading[1]!.length}` : "p",
372
+ );
373
+ if (list?.[1])
374
+ block.append(document.createTextNode(/x/i.test(list[1]) ? "☑ " : "☐ "));
375
+ // The element and where it sits show the line's structure, so its
376
+ // indentation and marker aren't repeated; an empty paragraph keeps its
377
+ // pilcrow so the line can be seen.
378
+ for (const node of nodes)
379
+ if (
380
+ node.type !== "indent" &&
381
+ (node.type !== "block" || node.value === "¶")
382
+ )
383
+ appendPresentationNode(block, node, true);
384
+ let content: HTMLElement = block;
385
+ if (quote) {
386
+ content = document.createElement("blockquote");
387
+ content.append(block);
388
+ }
389
+ if (list && !inList) {
390
+ const item = document.createElement("li");
391
+ item.append(content);
392
+ content = document.createElement(list[2] ? "ol" : "ul");
393
+ // Each line is its own list, so a numbered line keeps its number.
394
+ if (list[2]) content.setAttribute("start", list[2]);
395
+ content.append(item);
396
+ }
397
+ widget.append(content);
398
+ // An item in a list stays a list item to assistive tech; its content is
399
+ // the button.
400
+ identifyWidget(widget, spec, label, undefined, inList ? content : widget);
401
+ return widget;
402
+ };
403
+ }
404
+
405
+ // Whole suggested lines go beside the block at the point, in order; the
406
+ // parts that join a line stay inline at the point.
407
+ function lineDecorations(
408
+ doc: ProseMirrorNode,
409
+ spec: SuggestionHighlightSpec,
410
+ active: boolean,
411
+ lines: SuggestedLines,
412
+ deleted: boolean,
413
+ attrs: Record<string, string>,
414
+ ): Decoration[] | null {
415
+ const className = deleted
416
+ ? "suggestion-delete-widget suggestion-deleted-text"
417
+ : insertionClass(spec);
418
+ const label = deleted
419
+ ? "Inspect suggested deletion"
420
+ : "Inspect suggested insertion";
421
+ const placed = lines.lines.map((line) => {
422
+ const text = lines.source.slice(line.from, line.to);
423
+ return {
424
+ line,
425
+ boundary: lineBoundary(doc, spec.from, lines.side, lineShape(text)),
426
+ nodes: suggestionTextPresentationForSource(text, {
427
+ source: lines.source,
428
+ ...line,
429
+ }),
430
+ };
431
+ });
432
+ if (placed.some(({ boundary, nodes }) => !boundary || !nodes)) return null;
433
+ const decorations: Decoration[] = [];
434
+ const inline = (span: SourceSpan | null, part: "head" | "tail") => {
435
+ if (!span) return;
436
+ const text = lines.source.slice(span.from, span.to);
437
+ const context = { source: lines.source, ...span };
438
+ decorations.push(
439
+ Decoration.widget(
440
+ spec.from,
441
+ deleted
442
+ ? deletionWidget(spec, active, { text, context })
443
+ : insertionWidget(spec, active, { text, context }),
444
+ {
445
+ key: JSON.stringify([
446
+ spec.suggestionId,
447
+ part,
448
+ spec.from,
449
+ text,
450
+ active,
451
+ !!spec.editableBoundary,
452
+ ]),
453
+ marks: [],
454
+ side: part === "head" ? 1 : 2,
455
+ ...attrs,
456
+ },
457
+ ),
458
+ );
459
+ };
460
+ inline(lines.head, "head");
461
+ placed.forEach(({ line, boundary, nodes }, index) => {
462
+ decorations.push(
463
+ Decoration.widget(
464
+ boundary!.position,
465
+ lineWidget(
466
+ spec,
467
+ active,
468
+ className,
469
+ label,
470
+ lines.source.slice(line.from, line.to),
471
+ nodes!,
472
+ boundary!.inList,
473
+ ),
474
+ {
475
+ key: JSON.stringify([
476
+ spec.suggestionId,
477
+ deleted ? "deleted-line" : "inserted-line",
478
+ boundary!.position,
479
+ lines.source.slice(line.from, line.to),
480
+ boundary!.inList,
481
+ active,
482
+ !!spec.editableBoundary,
483
+ ]),
484
+ marks: [],
485
+ side: lines.side === "before" ? index - placed.length : index + 1,
486
+ ...attrs,
487
+ },
488
+ ),
489
+ );
490
+ });
491
+ inline(lines.tail, "tail");
492
+ return decorations;
493
+ }
494
+
495
+ // Whole blocks a saved deletion removes are marked as blocks, so the
496
+ // paragraph itself reads as removed rather than only its text.
497
+ function deletedBlockDecorations(
498
+ doc: ProseMirrorNode,
499
+ range: Range,
500
+ active: boolean,
501
+ ): Decoration[] {
502
+ const decorations: Decoration[] = [];
503
+ doc.nodesBetween(range.from, range.to, (node, position) => {
504
+ if (!node.isTextblock) return true;
505
+ const contentFrom = position + 1;
506
+ const contentTo = position + 1 + node.content.size;
507
+ if (
508
+ node.content.size > 0 &&
509
+ range.from <= contentFrom &&
510
+ range.to >= contentTo
511
+ )
512
+ decorations.push(
513
+ Decoration.node(position, position + node.nodeSize, {
514
+ class: classes("suggestion-delete-block", active),
515
+ }),
516
+ );
517
+ return false;
518
+ });
519
+ return decorations;
520
+ }
521
+
202
522
  function insertionAtOperationAnchor(
203
523
  doc: ProseMirrorNode,
204
524
  from: number,
@@ -414,6 +734,8 @@ function buildDecorations(
414
734
  class: classes("suggestion-delete suggestion-deleted-text", active),
415
735
  }),
416
736
  );
737
+ if (spec.kind === "delete")
738
+ decorations.push(...deletedBlockDecorations(doc, range, active));
417
739
  }
418
740
  } else if (spec.kind === "mark" && range) {
419
741
  decorations.push(
@@ -429,20 +751,45 @@ function buildDecorations(
429
751
  spec.kind === "insert" ||
430
752
  spec.kind === "add_block"
431
753
  ) {
754
+ const lines =
755
+ spec.kind === "replace"
756
+ ? null
757
+ : suggestedLines(spec.insertedText, spec.insertedPresentation);
758
+ const lineParts =
759
+ lines && lineDecorations(doc, spec, active, lines, false, attrs);
432
760
  const anchor = clampPosition(
433
761
  spec.kind === "replace" && range ? range.to : spec.from,
434
762
  size,
435
763
  );
436
- decorations.push(
437
- Decoration.widget(anchor, insertionWidget(spec, active), {
438
- key: `${spec.suggestionId}:inserted:${anchor}`,
439
- marks: [],
440
- side: 1,
441
- ...attrs,
442
- }),
443
- );
764
+ if (lineParts) decorations.push(...lineParts);
765
+ else
766
+ decorations.push(
767
+ Decoration.widget(
768
+ anchor,
769
+ insertionWidget(spec, active, undefined, anchor),
770
+ {
771
+ // ProseMirror reuses a widget's element while its key holds, so
772
+ // the key covers everything the element shows.
773
+ key: `${spec.suggestionId}:inserted:${anchor}:${JSON.stringify([
774
+ spec.insertedText,
775
+ active,
776
+ !!spec.editableBoundary,
777
+ ])}`,
778
+ marks: [],
779
+ side: 1,
780
+ ...attrs,
781
+ },
782
+ ),
783
+ );
444
784
  }
445
- if (spec.deletedText) {
785
+ const deletedLines =
786
+ spec.deletedText &&
787
+ suggestedLines(spec.deletedText, spec.deletedPresentation);
788
+ const deletedLineParts =
789
+ deletedLines &&
790
+ lineDecorations(doc, spec, active, deletedLines, true, attrs);
791
+ if (deletedLineParts) decorations.push(...deletedLineParts);
792
+ else if (spec.deletedText) {
446
793
  decorations.push(
447
794
  Decoration.widget(
448
795
  clampPosition(spec.from, size),
@@ -451,8 +798,10 @@ function buildDecorations(
451
798
  key: JSON.stringify([
452
799
  spec.suggestionId,
453
800
  "deleted",
801
+ clampPosition(spec.from, size),
454
802
  spec.deletedText,
455
803
  active,
804
+ !!spec.editableBoundary,
456
805
  ]),
457
806
  marks: [],
458
807
  side: 1,