@agent-native/core-corpus 0.217.0 → 0.219.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/.agents/skills/hubspot/SKILL.md +5 -3
- 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 +7 -0
- package/corpus/templates/content/app/components/editor/CommentStack.tsx +301 -0
- package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +770 -179
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +75 -27
- 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/block-field-comment-threads.ts +15 -0
- package/corpus/templates/content/app/components/editor/block-field-comments.tsx +5 -9
- package/corpus/templates/content/app/components/editor/comment-stacks.ts +143 -0
- 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/app/i18n-data.ts +8 -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/changelog/2026-10-22-blocks-with-several-comment-threads-fold-into-one-stack-in-t.md +6 -0
- package/corpus/templates/content/changelog/2026-10-22-opening-a-page-no-longer-saves-an-edit-again-after-another-o.md +6 -0
- package/corpus/templates/content/docs/product/capabilities/content.comment.page-owned.md +8 -1
- 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/.generated/bridge/interact-links.generated.ts +8 -8
- package/corpus/templates/design/CHANGELOG.md +8 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +18 -1
- package/corpus/templates/design/app/components/design/bridge/interact-links.bridge.ts +8 -8
- package/corpus/templates/design/app/components/editor/PromptDialog.tsx +15 -13
- package/corpus/templates/design/app/i18n/en-US.ts +1 -1
- package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
- package/corpus/templates/design/app/pages/Index.tsx +18 -64
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-modes.ts +15 -6
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-screen-rendering.tsx +29 -5
- package/corpus/templates/design/app/pages/design-editor/prototype-navigation-transition.ts +423 -0
- package/corpus/templates/design/app/pages/design-editor/view/single-screen-canvas.tsx +35 -9
- package/corpus/templates/design/changelog/2026-10-16-design-pages-use-localized-visibility-badges-and-a-shared-ow.md +6 -0
- package/corpus/templates/design/changelog/2026-10-20-the-design-full-app-toggle-on-the-home-page-no-longer-hides-.md +6 -0
- package/corpus/templates/design/changelog/2026-10-22-prototype-links-can-play-code-authored-screen-transitions.md +6 -0
- package/corpus/templates/design/shared/motion-authored-css-import.ts +938 -7
- package/corpus/templates/design/shared/motion-html-source.ts +1689 -135
- package/corpus/templates/design/shared/motion-prototype-transition.ts +329 -0
- package/corpus/templates/design/shared/motion-react-source.ts +2154 -126
- package/corpus/templates/design/shared/motion-text-runtime-v1.fixture.txt +221 -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
|
@@ -327,6 +327,8 @@ import {
|
|
|
327
327
|
planSuggestionDraftPersistence,
|
|
328
328
|
saveUnlessSuggestionChanged,
|
|
329
329
|
suggestionAmendmentIdempotencyKey,
|
|
330
|
+
suggestionProposalCreationIdempotencyKey,
|
|
331
|
+
forgetSuggestionProposalCreationIdempotencyKey,
|
|
330
332
|
type DraftSuggestion,
|
|
331
333
|
type SuggestionDraftSession,
|
|
332
334
|
type SuggestionPersistenceEntry,
|
|
@@ -336,11 +338,13 @@ import {
|
|
|
336
338
|
import { suggestedEditorIsolation } from "./suggestions/editor-isolation";
|
|
337
339
|
import { useRememberedEditorMode } from "./suggestions/editor-mode-memory";
|
|
338
340
|
import {
|
|
341
|
+
createDraftSuggestionPresentationTransition,
|
|
339
342
|
createObservedSuggestionPresentationTransition,
|
|
340
343
|
hydrateSuggestionPresentationTransitions,
|
|
341
344
|
preciseSuggestionPresentationOperations,
|
|
342
345
|
retainCommittedSuggestionPresentationTransitions,
|
|
343
346
|
resolveSuggestionPresentationRange,
|
|
347
|
+
resolveSuggestionPresentationRangeInDraft,
|
|
344
348
|
suggestionPresentationTransitionKey,
|
|
345
349
|
type SuggestionPresentationTransition,
|
|
346
350
|
type SuggestionPresentationTransitions,
|
|
@@ -635,6 +639,7 @@ export function suggestionPresentation(
|
|
|
635
639
|
currentMarkdown: string,
|
|
636
640
|
transition?: SuggestionPresentationTransition,
|
|
637
641
|
observedTransition?: SuggestionPresentationTransition,
|
|
642
|
+
draftTransition?: SuggestionPresentationTransition,
|
|
638
643
|
): VisualEditorSuggestion | null {
|
|
639
644
|
if (suggestion.status !== "pending") return null;
|
|
640
645
|
const operation = suggestion.operations[0];
|
|
@@ -671,12 +676,22 @@ export function suggestionPresentation(
|
|
|
671
676
|
) {
|
|
672
677
|
return null;
|
|
673
678
|
}
|
|
674
|
-
const
|
|
679
|
+
const canonicalRange = resolveSuggestionPresentationRange(
|
|
675
680
|
currentMarkdown,
|
|
676
681
|
operation,
|
|
677
682
|
transition,
|
|
678
683
|
observedTransition,
|
|
679
684
|
);
|
|
685
|
+
const range =
|
|
686
|
+
canonicalRange ??
|
|
687
|
+
(draftTransition?.after === currentMarkdown
|
|
688
|
+
? resolveSuggestionPresentationRangeInDraft(
|
|
689
|
+
draftTransition,
|
|
690
|
+
operation,
|
|
691
|
+
transition,
|
|
692
|
+
observedTransition,
|
|
693
|
+
)
|
|
694
|
+
: null);
|
|
680
695
|
if (!range) return null;
|
|
681
696
|
const editorMarkdown = canonicalizeNfm(currentMarkdown);
|
|
682
697
|
const currentText = currentMarkdown.slice(range.from, range.to);
|
|
@@ -694,7 +709,9 @@ export function suggestionPresentation(
|
|
|
694
709
|
return {
|
|
695
710
|
id: suggestion.id,
|
|
696
711
|
kind: operation.kind as VisualEditorSuggestion["kind"],
|
|
697
|
-
|
|
712
|
+
// The editor re-resolves a canonical operation on its own text, where only
|
|
713
|
+
// the draft carries this one, so the anchor places it instead.
|
|
714
|
+
canonicalOperation: canonicalRange ? operation : undefined,
|
|
698
715
|
canonicalTransition: transition,
|
|
699
716
|
observedTransition,
|
|
700
717
|
beforeText: before.changedText,
|
|
@@ -726,6 +743,7 @@ export function suggestionPresentations(
|
|
|
726
743
|
currentMarkdown: string,
|
|
727
744
|
transition?: SuggestionPresentationTransition,
|
|
728
745
|
observedTransition?: SuggestionPresentationTransition,
|
|
746
|
+
draftTransition?: SuggestionPresentationTransition,
|
|
729
747
|
): VisualEditorSuggestion[] {
|
|
730
748
|
// Decided edits have no presentation; skip their full-Page diff.
|
|
731
749
|
if (suggestion.status !== "pending") return [];
|
|
@@ -734,6 +752,7 @@ export function suggestionPresentations(
|
|
|
734
752
|
currentMarkdown,
|
|
735
753
|
transition,
|
|
736
754
|
observedTransition,
|
|
755
|
+
draftTransition,
|
|
737
756
|
);
|
|
738
757
|
if (suggestion.operations.length !== 1) return original ? [original] : [];
|
|
739
758
|
const saved = suggestion.operations[0]!;
|
|
@@ -746,6 +765,7 @@ export function suggestionPresentations(
|
|
|
746
765
|
currentMarkdown,
|
|
747
766
|
transition,
|
|
748
767
|
observedTransition,
|
|
768
|
+
draftTransition,
|
|
749
769
|
),
|
|
750
770
|
);
|
|
751
771
|
return precise.every((presentation) => presentation !== null)
|
|
@@ -764,6 +784,13 @@ export function documentEditorSuggestionPresentations(args: {
|
|
|
764
784
|
transitions: SuggestionPresentationTransitions;
|
|
765
785
|
observedTransition?: SuggestionPresentationTransition;
|
|
766
786
|
}): VisualEditorSuggestion[] {
|
|
787
|
+
const draftTransition =
|
|
788
|
+
args.savedSuggestions.length > 0
|
|
789
|
+
? (createDraftSuggestionPresentationTransition(
|
|
790
|
+
args.drafts,
|
|
791
|
+
args.currentMarkdown,
|
|
792
|
+
) ?? undefined)
|
|
793
|
+
: undefined;
|
|
767
794
|
const savedPresentations: VisualEditorSuggestion[] = [];
|
|
768
795
|
for (const suggestion of args.savedSuggestions) {
|
|
769
796
|
if (suggestion.id === args.editingSuggestionId) continue;
|
|
@@ -773,6 +800,7 @@ export function documentEditorSuggestionPresentations(args: {
|
|
|
773
800
|
args.currentMarkdown,
|
|
774
801
|
args.transitions.get(suggestionPresentationTransitionKey(suggestion)),
|
|
775
802
|
args.observedTransition,
|
|
803
|
+
draftTransition,
|
|
776
804
|
),
|
|
777
805
|
);
|
|
778
806
|
}
|
|
@@ -4160,6 +4188,15 @@ function PageEditorSessionBody({
|
|
|
4160
4188
|
options.editGeneration ?? editorEditGenerationRef.current;
|
|
4161
4189
|
const contentObservationEpoch =
|
|
4162
4190
|
options.contentObservationEpoch ?? contentObservationEpochRef.current;
|
|
4191
|
+
// Text another writer added while this save was in flight is theirs to
|
|
4192
|
+
// save, so it holds back neither the confirmation nor the journal clear.
|
|
4193
|
+
// An entry kept for it outlives this tab's edits, and the next tab to
|
|
4194
|
+
// open the page replays it as a save of its own.
|
|
4195
|
+
const settleConfirmedEdit = () => {
|
|
4196
|
+
if (contentEditVersionRef.current !== contentEditVersion) return;
|
|
4197
|
+
confirmedContentEditVersionRef.current = contentEditVersion;
|
|
4198
|
+
clearConfirmedDraftJournal(editorEditGeneration);
|
|
4199
|
+
};
|
|
4163
4200
|
lastSavedContentRef.current = refreshUnchangedContentSaveWatermark({
|
|
4164
4201
|
serverContent: documentContentRef.current,
|
|
4165
4202
|
serverUpdatedAt: documentUpdatedAtRef.current,
|
|
@@ -4240,12 +4277,14 @@ function PageEditorSessionBody({
|
|
|
4240
4277
|
{ title, content },
|
|
4241
4278
|
options,
|
|
4242
4279
|
);
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4280
|
+
const confirmed =
|
|
4281
|
+
!isDocumentUpdateConflict(attested) &&
|
|
4282
|
+
!isDocumentUpdateSuperseded(attested) &&
|
|
4283
|
+
!isDocumentUpdatePreservationRequired(attested);
|
|
4284
|
+
// The page already held this edit, as when a peer's save carried it,
|
|
4285
|
+
// so the attestation settles it as a written save would.
|
|
4286
|
+
if (confirmed) settleConfirmedEdit();
|
|
4287
|
+
return { contentPersisted: confirmed };
|
|
4249
4288
|
}
|
|
4250
4289
|
if (!hasUpdates) {
|
|
4251
4290
|
return { contentPersisted: !contentChanged };
|
|
@@ -4465,14 +4504,7 @@ function PageEditorSessionBody({
|
|
|
4465
4504
|
: null,
|
|
4466
4505
|
});
|
|
4467
4506
|
}
|
|
4468
|
-
|
|
4469
|
-
// save, so it holds back neither the confirmation nor the journal clear.
|
|
4470
|
-
// An entry kept for it outlives this tab's edits, and the next tab to
|
|
4471
|
-
// open the page replays it as a save of its own.
|
|
4472
|
-
if (contentEditVersionRef.current === contentEditVersion) {
|
|
4473
|
-
confirmedContentEditVersionRef.current = contentEditVersion;
|
|
4474
|
-
clearConfirmedDraftJournal(editorEditGeneration);
|
|
4475
|
-
}
|
|
4507
|
+
settleConfirmedEdit();
|
|
4476
4508
|
|
|
4477
4509
|
if (autoSync) {
|
|
4478
4510
|
const status = queryClient.getQueryData<DocumentSyncStatus>(
|
|
@@ -5906,6 +5938,10 @@ function PageEditorSessionBody({
|
|
|
5906
5938
|
unresolved.request.idempotencyKey,
|
|
5907
5939
|
unresolved.baseId,
|
|
5908
5940
|
);
|
|
5941
|
+
forgetSuggestionProposalCreationIdempotencyKey(
|
|
5942
|
+
suggestionProposalCreationKeysRef.current,
|
|
5943
|
+
unresolved.request,
|
|
5944
|
+
);
|
|
5909
5945
|
unresolvedProposalCreationRef.current = null;
|
|
5910
5946
|
}
|
|
5911
5947
|
const operations = suggestionDraftOperations(base, draft);
|
|
@@ -6086,29 +6122,28 @@ function PageEditorSessionBody({
|
|
|
6086
6122
|
if (create.length > 0) {
|
|
6087
6123
|
const pending = create.map(({ operation }) => operation);
|
|
6088
6124
|
const pendingKeys = create.map(({ key }) => key);
|
|
6089
|
-
const requestKey = JSON.stringify([base.id, pendingKeys]);
|
|
6090
|
-
const idempotencyKey =
|
|
6091
|
-
suggestionProposalCreationKeysRef.current.get(requestKey) ??
|
|
6092
|
-
globalThis.crypto.randomUUID();
|
|
6093
|
-
suggestionProposalCreationKeysRef.current.set(
|
|
6094
|
-
requestKey,
|
|
6095
|
-
idempotencyKey,
|
|
6096
|
-
);
|
|
6097
6125
|
const existingProposal = suggestionProposalsRef.current.get(base.id);
|
|
6098
6126
|
const proposalSummary =
|
|
6099
6127
|
existingProposal?.summary ?? t("editor.toolbar.suggestEdits");
|
|
6100
|
-
const
|
|
6128
|
+
const fields = {
|
|
6101
6129
|
resourceType: "document",
|
|
6102
6130
|
resourceId: documentId,
|
|
6103
6131
|
adapterKind: "content.document-markdown",
|
|
6104
6132
|
baseRevision: base.baseRevision,
|
|
6105
6133
|
summary: proposalSummary,
|
|
6106
6134
|
proposalId: existingProposal?.id,
|
|
6107
|
-
idempotencyKey,
|
|
6108
6135
|
suggestions: pending.map((operation) => ({
|
|
6109
6136
|
summary: proposalSummary,
|
|
6110
6137
|
operations: [operation],
|
|
6111
6138
|
})),
|
|
6139
|
+
};
|
|
6140
|
+
const idempotencyKey = suggestionProposalCreationIdempotencyKey(
|
|
6141
|
+
suggestionProposalCreationKeysRef.current,
|
|
6142
|
+
fields,
|
|
6143
|
+
);
|
|
6144
|
+
const request = {
|
|
6145
|
+
...fields,
|
|
6146
|
+
idempotencyKey,
|
|
6112
6147
|
} satisfies Parameters<
|
|
6113
6148
|
typeof createSuggestionProposal.mutateAsync
|
|
6114
6149
|
>[0];
|
|
@@ -6120,6 +6155,10 @@ function PageEditorSessionBody({
|
|
|
6120
6155
|
};
|
|
6121
6156
|
const created = await createSuggestionProposal.mutateAsync(request);
|
|
6122
6157
|
recordCreated(created, pendingKeys, pending, idempotencyKey, base.id);
|
|
6158
|
+
forgetSuggestionProposalCreationIdempotencyKey(
|
|
6159
|
+
suggestionProposalCreationKeysRef.current,
|
|
6160
|
+
request,
|
|
6161
|
+
);
|
|
6123
6162
|
unresolvedProposalCreationRef.current = null;
|
|
6124
6163
|
pendingKeys.forEach((operationKey, index) => {
|
|
6125
6164
|
persisted.set(operationKey, created.suggestions[index]!);
|
|
@@ -6135,7 +6174,14 @@ function PageEditorSessionBody({
|
|
|
6135
6174
|
status < 500 &&
|
|
6136
6175
|
status !== 408 &&
|
|
6137
6176
|
status !== 429;
|
|
6138
|
-
|
|
6177
|
+
const refused = clientError && unresolvedProposalCreationRef.current;
|
|
6178
|
+
if (refused) {
|
|
6179
|
+
forgetSuggestionProposalCreationIdempotencyKey(
|
|
6180
|
+
suggestionProposalCreationKeysRef.current,
|
|
6181
|
+
refused.request,
|
|
6182
|
+
);
|
|
6183
|
+
unresolvedProposalCreationRef.current = null;
|
|
6184
|
+
}
|
|
6139
6185
|
adoptConfirmedSuggestions();
|
|
6140
6186
|
if (error instanceof SuggestionFormattingMappingError) {
|
|
6141
6187
|
// The draft keeps its persistent unsupported-formatting alert.
|
|
@@ -8650,7 +8696,9 @@ function PageEditorSessionBody({
|
|
|
8650
8696
|
onActivateSuggestion={activateSuggestionOrThread}
|
|
8651
8697
|
onSelectedThreadChange={setSelectedThreadId}
|
|
8652
8698
|
onHoveredThreadChange={setHoveredThreadId}
|
|
8699
|
+
hoveredThreadId={hoveredThreadId}
|
|
8653
8700
|
currentUserEmail={session?.email}
|
|
8701
|
+
currentUserName={session?.name}
|
|
8654
8702
|
currentUserOrgId={session?.orgId}
|
|
8655
8703
|
canComment={canComment}
|
|
8656
8704
|
canResolve={canEdit}
|
|
@@ -9,8 +9,9 @@ import { Fragment, type ReactNode } from "react";
|
|
|
9
9
|
|
|
10
10
|
function renderNode(node: SuggestionPresentationNode, key: string): ReactNode {
|
|
11
11
|
if (node.type === "text") return <Fragment key={key}>{node.value}</Fragment>;
|
|
12
|
-
if (node.type === "indent")
|
|
12
|
+
if (node.type === "indent" || node.type === "block")
|
|
13
13
|
return <Fragment key={key}>{node.value}</Fragment>;
|
|
14
|
+
if (node.type === "lineBreak") return <br key={key} />;
|
|
14
15
|
const children = node.children.map((child, index) =>
|
|
15
16
|
renderNode(child, `${key}-${index}`),
|
|
16
17
|
);
|
|
@@ -42,16 +43,30 @@ export function SuggestionText({
|
|
|
42
43
|
context?: SuggestionPresentationContext;
|
|
43
44
|
}) {
|
|
44
45
|
const t = useT();
|
|
45
|
-
const
|
|
46
|
+
const presented = context
|
|
46
47
|
? suggestionTextPresentationForSource(content, context)
|
|
47
48
|
: suggestionTextPresentation(content);
|
|
48
|
-
if (!
|
|
49
|
+
if (!presented) {
|
|
49
50
|
return (
|
|
50
51
|
<span data-suggestion-text-unavailable="true">
|
|
51
52
|
{t("editor.sourceComponent.previewUnavailable")}
|
|
52
53
|
</span>
|
|
53
54
|
);
|
|
54
55
|
}
|
|
56
|
+
// A new paragraph's own line break would open or close the card with an
|
|
57
|
+
// empty line, so a break at either edge shows as its marker instead; the
|
|
58
|
+
// breaks between lines stay.
|
|
59
|
+
let start = 0;
|
|
60
|
+
let end = presented.length;
|
|
61
|
+
while (start < end && presented[start]!.type === "lineBreak") start += 1;
|
|
62
|
+
while (end > start && presented[end - 1]!.type === "lineBreak") end -= 1;
|
|
63
|
+
const marker = (count: number): SuggestionPresentationNode[] =>
|
|
64
|
+
count ? [{ type: "text", value: "↵".repeat(count) }] : [];
|
|
65
|
+
const nodes = [
|
|
66
|
+
...marker(start),
|
|
67
|
+
...presented.slice(start, end),
|
|
68
|
+
...marker(presented.length - end),
|
|
69
|
+
];
|
|
55
70
|
return (
|
|
56
71
|
<span className="whitespace-pre-wrap break-words">
|
|
57
72
|
{nodes.map((node, index) => renderNode(node, String(index)))}
|
|
@@ -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(() => {
|
|
@@ -16,6 +16,21 @@ export function commentThreadFieldId(
|
|
|
16
16
|
return thread.field?.fieldId ?? null;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The threads the margin shows with one tab open: that tab's and the
|
|
21
|
+
* Property threads. A page without tabs shows them all.
|
|
22
|
+
*/
|
|
23
|
+
export function commentRailThreads<
|
|
24
|
+
T extends Pick<CommentThread, "field" | "property">,
|
|
25
|
+
>(threads: readonly T[], openFieldId: string | null): T[] {
|
|
26
|
+
if (openFieldId === null && !threads.some((thread) => thread.field)) {
|
|
27
|
+
return threads as T[];
|
|
28
|
+
}
|
|
29
|
+
return threads.filter(
|
|
30
|
+
(thread) => thread.property || commentThreadFieldId(thread) === openFieldId,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
/** A key for a tab that a Map or a filter can hold: "" is the page body. */
|
|
20
35
|
export function commentTabKey(fieldId: string | null): string {
|
|
21
36
|
return fieldId ?? "";
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import type { CommentThread } from "@/hooks/use-comments";
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
+
commentRailThreads,
|
|
17
18
|
commentThreadFieldId,
|
|
18
19
|
openReviewCountsByField,
|
|
19
20
|
type BlockFieldOpenCounts,
|
|
@@ -353,15 +354,10 @@ export function useBlockFieldComments({
|
|
|
353
354
|
}, [scrollContainerRef, tabs, targetFieldId, targetKey, targetSettled]);
|
|
354
355
|
|
|
355
356
|
const allThreads = threads ?? NO_THREADS;
|
|
356
|
-
const railThreads = useMemo(
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
return allThreads.filter(
|
|
361
|
-
(thread) =>
|
|
362
|
-
thread.property || commentThreadFieldId(thread) === openFieldId,
|
|
363
|
-
);
|
|
364
|
-
}, [allThreads, openFieldId]);
|
|
357
|
+
const railThreads = useMemo(
|
|
358
|
+
() => commentRailThreads(allThreads as CommentThread[], openFieldId),
|
|
359
|
+
[allThreads, openFieldId],
|
|
360
|
+
);
|
|
365
361
|
|
|
366
362
|
const pendingFieldId = pendingComment?.property
|
|
367
363
|
? undefined
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { CommentThread } from "@/hooks/use-comments";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
commentTabKey,
|
|
5
|
+
commentThreadFieldId,
|
|
6
|
+
} from "./block-field-comment-threads";
|
|
7
|
+
import { inHiddenBlockFieldPanel } from "./blockFieldTabs";
|
|
8
|
+
|
|
9
|
+
/** Where a thread's first highlight sits: its stack, and its document order. */
|
|
10
|
+
export type CommentBlockPlacement = { key: string; order: number };
|
|
11
|
+
|
|
12
|
+
/** Two or more open threads in one block, in document order. */
|
|
13
|
+
export type CommentStack<T> = { key: string; threads: T[] };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The block a highlight is in, as child indexes down from its editor: the
|
|
17
|
+
* innermost list item holding it, else the editor's top-level node. A
|
|
18
|
+
* highlight outside an editor, such as a Property's, has no block.
|
|
19
|
+
*/
|
|
20
|
+
export function commentBlockKey(highlight: Element): string | null {
|
|
21
|
+
const block = commentBlock(highlight);
|
|
22
|
+
return block ? blockPath(block) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function commentBlock(highlight: Element) {
|
|
26
|
+
const editor = highlight.closest(".ProseMirror");
|
|
27
|
+
if (!editor) return null;
|
|
28
|
+
const item = highlight.closest("li");
|
|
29
|
+
if (item && editor.contains(item)) return { editor, block: item };
|
|
30
|
+
let block: Element | null = highlight;
|
|
31
|
+
while (block && block.parentElement !== editor) block = block.parentElement;
|
|
32
|
+
return block ? { editor, block } : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function blockPath({ editor, block }: { editor: Element; block: Element }) {
|
|
36
|
+
const path: number[] = [];
|
|
37
|
+
for (let node: Element = block; node !== editor; ) {
|
|
38
|
+
const parent: Element | null = node.parentElement;
|
|
39
|
+
if (!parent) return null;
|
|
40
|
+
path.unshift(Array.prototype.indexOf.call(parent.children, node));
|
|
41
|
+
node = parent;
|
|
42
|
+
}
|
|
43
|
+
return path.join(".");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Block keys repeat across Blocks field tabs, so a stack's key has both. */
|
|
47
|
+
export function commentStackKey(fieldId: string | null, blockKey: string) {
|
|
48
|
+
return `${commentTabKey(fieldId)}:${blockKey}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Each thread's first highlight on the page, in document order. Property
|
|
53
|
+
* threads, threads not listed, and highlights in a closed tab are left out.
|
|
54
|
+
*/
|
|
55
|
+
export function measureCommentBlocks(
|
|
56
|
+
container: ParentNode,
|
|
57
|
+
threads: readonly CommentThread[],
|
|
58
|
+
): Map<string, CommentBlockPlacement> {
|
|
59
|
+
const byId = new Map(
|
|
60
|
+
threads
|
|
61
|
+
.filter((thread) => !thread.property)
|
|
62
|
+
.map((thread) => [thread.threadId, thread]),
|
|
63
|
+
);
|
|
64
|
+
const placements = new Map<string, CommentBlockPlacement>();
|
|
65
|
+
// Highlights share blocks, and a block's path walks its siblings.
|
|
66
|
+
const paths = new Map<Element, string | null>();
|
|
67
|
+
for (const highlight of container.querySelectorAll("[data-comment-thread]")) {
|
|
68
|
+
const threadId = highlight.getAttribute("data-comment-thread");
|
|
69
|
+
const thread = threadId ? byId.get(threadId) : undefined;
|
|
70
|
+
if (!thread || placements.has(thread.threadId)) continue;
|
|
71
|
+
if (inHiddenBlockFieldPanel(highlight)) continue;
|
|
72
|
+
const found = commentBlock(highlight);
|
|
73
|
+
if (!found) continue;
|
|
74
|
+
let block = paths.get(found.block);
|
|
75
|
+
if (block === undefined) {
|
|
76
|
+
block = blockPath(found);
|
|
77
|
+
paths.set(found.block, block);
|
|
78
|
+
}
|
|
79
|
+
if (block === null) continue;
|
|
80
|
+
placements.set(thread.threadId, {
|
|
81
|
+
key: commentStackKey(commentThreadFieldId(thread), block),
|
|
82
|
+
order: placements.size,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return placements;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The blocks holding two or more open threads, in document order. */
|
|
89
|
+
export function groupCommentStacks<
|
|
90
|
+
T extends Pick<CommentThread, "threadId" | "resolved" | "property">,
|
|
91
|
+
>(
|
|
92
|
+
threads: readonly T[],
|
|
93
|
+
placements: ReadonlyMap<string, CommentBlockPlacement>,
|
|
94
|
+
): CommentStack<T>[] {
|
|
95
|
+
const groups = new Map<string, T[]>();
|
|
96
|
+
for (const thread of threads) {
|
|
97
|
+
if (thread.resolved || thread.property) continue;
|
|
98
|
+
const placement = placements.get(thread.threadId);
|
|
99
|
+
if (!placement) continue;
|
|
100
|
+
const members = groups.get(placement.key) ?? [];
|
|
101
|
+
members.push(thread);
|
|
102
|
+
groups.set(placement.key, members);
|
|
103
|
+
}
|
|
104
|
+
const order = (thread: T) => placements.get(thread.threadId)?.order ?? 0;
|
|
105
|
+
return [...groups]
|
|
106
|
+
.filter(([, members]) => members.length >= 2)
|
|
107
|
+
.map(([key, members]) => ({
|
|
108
|
+
key,
|
|
109
|
+
threads: members.sort((left, right) => order(left) - order(right)),
|
|
110
|
+
}))
|
|
111
|
+
.sort((left, right) => order(left.threads[0]) - order(right.threads[0]));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function latestCommentTime(thread: Pick<CommentThread, "comments">) {
|
|
115
|
+
let latest = -Infinity;
|
|
116
|
+
for (const comment of thread.comments) {
|
|
117
|
+
const time = Date.parse(comment.created_at);
|
|
118
|
+
if (time > latest) latest = time;
|
|
119
|
+
}
|
|
120
|
+
return latest;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The thread a folded stack shows: the one last touched, else the one with
|
|
125
|
+
* the newest comment. Ties go to the first in document order.
|
|
126
|
+
*/
|
|
127
|
+
export function commentStackFront<
|
|
128
|
+
T extends Pick<CommentThread, "threadId" | "comments">,
|
|
129
|
+
>(threads: readonly T[], touches: ReadonlyMap<string, number>): T {
|
|
130
|
+
let front = threads[0];
|
|
131
|
+
let frontTouch = touches.get(front.threadId) ?? -Infinity;
|
|
132
|
+
let frontTime = latestCommentTime(front);
|
|
133
|
+
for (const thread of threads.slice(1)) {
|
|
134
|
+
const touch = touches.get(thread.threadId) ?? -Infinity;
|
|
135
|
+
const time = latestCommentTime(thread);
|
|
136
|
+
if (touch > frontTouch || (touch === frontTouch && time > frontTime)) {
|
|
137
|
+
front = thread;
|
|
138
|
+
frontTouch = touch;
|
|
139
|
+
frontTime = time;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return front;
|
|
143
|
+
}
|