@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/CHANGELOG.md +2 -0
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +38 -167
- package/corpus/templates/analytics/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/assets/CHANGELOG.md +6 -0
- package/corpus/templates/assets/app/components/layout/Sidebar.tsx +51 -117
- package/corpus/templates/assets/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/brain/app/components/layout/Sidebar.tsx +34 -101
- package/corpus/templates/brain/app/routes/sources.tsx +1 -1
- package/corpus/templates/brain/vitest.config.ts +7 -0
- package/corpus/templates/calendar/CHANGELOG.md +6 -0
- package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +1 -1
- package/corpus/templates/calendar/changelog/2026-10-16-booking-links-show-localized-visibility-badges.md +6 -0
- package/corpus/templates/chat/app/components/layout/Sidebar.tsx +62 -101
- package/corpus/templates/clips/CHANGELOG.md +7 -20
- package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +20 -77
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +63 -90
- package/corpus/templates/clips/app/components/sharing/share-test-support.tsx +180 -0
- package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -584
- package/corpus/templates/clips/changelog/2026-10-16-share-dialogs-make-it-easier-to-see-who-has-access-and-manag.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-21-share-panels-describe-general-access-in-a-sentence.md +6 -0
- package/corpus/templates/community/qr/package.json +1 -1
- package/corpus/templates/content/CHANGELOG.md +2 -0
- package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +119 -30
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +56 -13
- package/corpus/templates/content/app/components/editor/SuggestionText.tsx +18 -3
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +89 -22
- package/corpus/templates/content/app/components/editor/extensions/SuggestionHighlight.ts +383 -34
- package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +31 -0
- package/corpus/templates/content/app/components/editor/suggestions/presentation-rebase.ts +82 -4
- package/corpus/templates/content/app/global.css +31 -0
- package/corpus/templates/content/changelog/2026-10-16-accepting-a-suggestion-on-a-page-that-changed-since-it-was-s.md +6 -0
- package/corpus/templates/content/changelog/2026-10-20-suggested-edits-that-add-remove-split-or-merge-paragraphs-li.md +6 -0
- package/corpus/templates/content/shared/suggestion-diff.ts +23 -6
- package/corpus/templates/content/shared/suggestion-formatting.ts +44 -14
- package/corpus/templates/content/shared/suggestion-text.ts +20 -5
- package/corpus/templates/design/CHANGELOG.md +3 -0
- package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
- package/corpus/templates/design/app/pages/Index.tsx +18 -64
- package/corpus/templates/design/changelog/2026-10-16-design-pages-use-localized-visibility-badges-and-a-shared-ow.md +6 -0
- package/corpus/templates/dispatch/CHANGELOG.md +4 -0
- package/corpus/templates/dispatch/app/i18n-data.ts +75 -0
- package/corpus/templates/dispatch/changelog/2026-10-21-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/factory/app/components/layout/Sidebar.tsx +34 -100
- package/corpus/templates/forms/app/pages/FormsListPage.tsx +1 -1
- package/corpus/templates/plan/CHANGELOG.md +6 -0
- package/corpus/templates/plan/app/components/layout/Sidebar.tsx +34 -109
- package/corpus/templates/plan/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/slides/CHANGELOG.md +1 -0
- package/corpus/templates/slides/app/components/deck/DeckCard.tsx +1 -1
- package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +1 -1
- package/corpus/templates/slides/app/pages/Index.tsx +7 -2
- package/corpus/templates/slides/changelog/2026-10-16-deck-lists-use-the-shared-ownership-filter-and-visibility-ba.md +6 -0
- package/package.json +1 -1
- package/corpus/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
- 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
|
-
|
|
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(
|
|
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 (
|
|
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 ===
|
|
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
|
-
|
|
4860
|
+
acceptedReadbackContent,
|
|
4812
4861
|
settlingSuggestion,
|
|
4813
4862
|
)
|
|
4814
4863
|
) {
|
|
4815
|
-
onAcceptedDecisionRendered?.(
|
|
4864
|
+
onAcceptedDecisionRendered?.(acceptedReadbackId);
|
|
4816
4865
|
} else if (
|
|
4866
|
+
actualContent !== reported &&
|
|
4817
4867
|
acceptedSuggestionReadbackOutdated(
|
|
4818
4868
|
actualContent,
|
|
4819
|
-
|
|
4869
|
+
acceptedReadbackContent,
|
|
4820
4870
|
settlingSuggestion,
|
|
4821
4871
|
)
|
|
4822
4872
|
) {
|
|
4823
|
-
|
|
4824
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
4848
|
-
onProposalDecisionRendered?.(
|
|
4909
|
+
if (actual === canonicalizeNfm(proposalReadbackContent))
|
|
4910
|
+
onProposalDecisionRendered?.(proposalReadbackGeneration);
|
|
4849
4911
|
else if (
|
|
4850
|
-
actual !==
|
|
4851
|
-
|
|
4912
|
+
actual !== reported &&
|
|
4913
|
+
actual !== canonicalizeNfm(proposalReadbackBefore)
|
|
4914
|
+
) {
|
|
4915
|
+
reported = actual;
|
|
4852
4916
|
onProposalDecisionReadbackOutdated?.(
|
|
4853
|
-
|
|
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
|
-
|
|
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 === "
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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,
|