@agent-native/core 0.70.0 → 0.70.1
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/README.md +15 -15
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +61 -0
- package/corpus/core/docs/design/durable-agent-runs.md +217 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/action.ts +89 -0
- package/corpus/core/src/agent/model-config.ts +0 -2
- package/corpus/core/src/agent/run-loop-with-resume.ts +48 -0
- package/corpus/core/src/cli/skills.ts +2 -2
- package/corpus/core/src/client/AgentPanel.tsx +5 -3
- package/corpus/core/src/client/AssistantChat.tsx +8 -0
- package/corpus/core/src/client/client-surface.ts +41 -0
- package/corpus/core/src/client/composer/TiptapComposer.tsx +123 -12
- package/corpus/core/src/client/feedback-context.ts +4 -0
- package/corpus/core/src/client/index.ts +1 -0
- package/corpus/core/src/client/sse-event-processor.ts +7 -1
- package/corpus/core/src/mcp/build-server.ts +57 -1
- package/corpus/core/src/mcp/embed-app.ts +86 -2
- package/corpus/core/src/mcp/oauth-route.ts +109 -13
- package/corpus/core/src/server/core-routes-plugin.ts +78 -0
- package/corpus/core/src/server/onboarding-html.ts +35 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +22 -0
- package/corpus/templates/analytics/AGENTS.md +8 -1
- package/corpus/templates/analytics/actions/install-dashboard-template.ts +97 -2
- package/corpus/templates/analytics/actions/update-dashboard.ts +36 -7
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +30 -3
- package/corpus/templates/analytics/app/components/layout/Header.tsx +0 -1
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -4
- package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +0 -3
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +32 -2
- package/corpus/templates/analytics/changelog/2026-06-23-long-dashboard-and-analysis-names-in-the-sidebar-now-truncat.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-23-open-any-dashboard-chart-full-screen-in-a-modal-from-the-pan.md +6 -0
- package/corpus/templates/assets/actions/generate-image.ts +24 -0
- package/corpus/templates/assets/app/components/layout/Sidebar.tsx +2 -2
- package/corpus/templates/assets/app/global.css +17 -1
- package/corpus/templates/assets/app/routes/_index.tsx +94 -14
- package/corpus/templates/assets/changelog/2026-06-23-chat-dates-in-the-sidebar-no-longer-wrap-onto-two-lines.md +6 -0
- package/corpus/templates/assets/changelog/2026-06-23-clicking-image-video-or-refine-on-the-start-screen-now-drops.md +6 -0
- package/corpus/templates/assets/changelog/2026-06-23-pick-your-image-generation-model-right-from-the-chat-model-m.md +6 -0
- package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -20
- package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +72 -48
- package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +35 -17
- package/corpus/templates/clips/changelog/2026-06-23-removed-select-button-from-library-hover-a-clip-to-select-it.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-shared-video-links-now-play-for-anyone-without-signing-in.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-skip-or-cancel-the-recording-countdown-with-buttons-beside-t.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-the-desktop-camera-bubble-now-looks-sharp-the-instant-it-ope.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-the-desktop-recording-widget-now-grows-downward-only-when-yo.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-the-recording-start-sound-is-now-a-softer-more-modern-chime.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +17 -8
- package/corpus/templates/clips/chrome-extension/src/background.ts +769 -180
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +177 -0
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +466 -329
- package/corpus/templates/clips/chrome-extension/src/overlay.css +377 -0
- package/corpus/templates/clips/chrome-extension/src/overlay.html +12 -0
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +330 -0
- package/corpus/templates/clips/chrome-extension/src/permission.html +227 -0
- package/corpus/templates/clips/chrome-extension/src/permission.ts +105 -0
- package/corpus/templates/clips/chrome-extension/src/popup.html +3 -0
- package/corpus/templates/clips/chrome-extension/src/popup.ts +50 -2
- package/corpus/templates/clips/chrome-extension/src/styles.css +50 -0
- package/corpus/templates/clips/chrome-extension/vite.config.ts +3 -0
- package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +41 -24
- package/corpus/templates/clips/desktop/src/lib/bubble-webrtc.ts +119 -2
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +21 -2
- package/corpus/templates/clips/desktop/src/styles.css +70 -2
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +73 -0
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +34 -5
- package/corpus/templates/content/AGENTS.md +20 -3
- package/corpus/templates/content/actions/_database-utils.ts +15 -0
- package/corpus/templates/content/actions/_property-utils.ts +371 -4
- package/corpus/templates/content/actions/configure-document-property.ts +35 -1
- package/corpus/templates/content/actions/create-content-database.ts +5 -1
- package/corpus/templates/content/actions/delete-document-property.ts +52 -2
- package/corpus/templates/content/actions/duplicate-document-property.ts +34 -17
- package/corpus/templates/content/actions/reorder-document-property.ts +79 -0
- package/corpus/templates/content/actions/set-document-property.ts +39 -0
- package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +806 -0
- package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +226 -68
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +59 -30
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +25 -1
- package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +180 -0
- package/corpus/templates/content/app/components/editor/blockFieldSaveRegistry.ts +179 -0
- package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +244 -0
- package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +132 -0
- package/corpus/templates/content/app/global.css +9 -0
- package/corpus/templates/content/app/hooks/use-document-properties.ts +21 -0
- package/corpus/templates/content/server/db/schema.ts +30 -0
- package/corpus/templates/content/server/plugins/db.ts +87 -0
- package/corpus/templates/content/shared/api.ts +7 -0
- package/corpus/templates/content/shared/properties.ts +109 -0
- package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +10 -0
- package/corpus/templates/forms/actions/export-responses.ts +6 -0
- package/corpus/templates/forms/actions/list-responses.ts +2 -0
- package/corpus/templates/forms/actions/response-insights.ts +2 -0
- package/corpus/templates/forms/app/pages/ResponsesPage.tsx +138 -1
- package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-the-page-each-submission-came-from-.md +6 -0
- package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-whether-feedback-came-from-the-web-.md +6 -0
- package/corpus/templates/forms/server/db/schema.ts +7 -0
- package/corpus/templates/forms/server/handlers/submissions.ts +19 -2
- package/corpus/templates/forms/server/lib/integrations.ts +76 -2
- package/corpus/templates/forms/server/plugins/db.ts +20 -0
- package/corpus/templates/forms/shared/types.ts +12 -0
- package/corpus/templates/plan/app/components/plan/wireframe/html-artboard.css +3 -1
- package/corpus/templates/plan/changelog/2026-06-23-large-diagrams-in-a-plan-now-scroll-within-their-block-inste.md +6 -0
- package/dist/action.js +87 -0
- package/dist/action.js.map +1 -1
- package/dist/agent/engine/builder-engine.d.ts +1 -1
- package/dist/agent/engine/builder-engine.d.ts.map +1 -1
- package/dist/agent/model-config.d.ts +2 -2
- package/dist/agent/model-config.d.ts.map +1 -1
- package/dist/agent/model-config.js +0 -2
- package/dist/agent/model-config.js.map +1 -1
- package/dist/agent/run-loop-with-resume.d.ts +13 -0
- package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
- package/dist/agent/run-loop-with-resume.js +44 -0
- package/dist/agent/run-loop-with-resume.js.map +1 -1
- package/dist/cli/skills.js +2 -2
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +5 -3
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +6 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +2 -2
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/client-surface.d.ts +17 -0
- package/dist/client/client-surface.d.ts.map +1 -0
- package/dist/client/client-surface.js +24 -0
- package/dist/client/client-surface.js.map +1 -0
- package/dist/client/composer/TiptapComposer.d.ts +26 -1
- package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/client/composer/TiptapComposer.js +27 -5
- package/dist/client/composer/TiptapComposer.js.map +1 -1
- package/dist/client/feedback-context.d.ts +3 -0
- package/dist/client/feedback-context.d.ts.map +1 -1
- package/dist/client/feedback-context.js +2 -0
- package/dist/client/feedback-context.js.map +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +7 -1
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/mcp/build-server.d.ts +21 -0
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +43 -1
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/mcp/embed-app.d.ts.map +1 -1
- package/dist/mcp/embed-app.js +86 -2
- package/dist/mcp/embed-app.js.map +1 -1
- package/dist/mcp/oauth-route.d.ts.map +1 -1
- package/dist/mcp/oauth-route.js +95 -12
- package/dist/mcp/oauth-route.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +63 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/onboarding-html.d.ts.map +1 -1
- package/dist/server/onboarding-html.js +35 -0
- package/dist/server/onboarding-html.js.map +1 -1
- package/dist/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
- package/docs/design/durable-agent-runs.md +217 -0
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
- package/corpus/templates/analytics/app/pages/About.tsx +0 -108
- package/corpus/templates/analytics/app/routes/about.tsx +0 -9
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useMemo,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
type PointerEvent as ReactPointerEvent,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { IconChevronRight, IconGripVertical } from "@tabler/icons-react";
|
|
10
|
+
import { cn } from "@/lib/utils";
|
|
11
|
+
import { useDocumentProperties } from "@/hooks/use-document-properties";
|
|
12
|
+
import { useReorderDocumentProperty } from "@/hooks/use-document-properties";
|
|
13
|
+
import { useSetDocumentProperty } from "@/hooks/use-document-properties";
|
|
14
|
+
import {
|
|
15
|
+
blocksRenderMode,
|
|
16
|
+
blocksStorageTarget,
|
|
17
|
+
isBlocksPropertyType,
|
|
18
|
+
isPrimaryBlocksField,
|
|
19
|
+
type BlocksStorageTarget,
|
|
20
|
+
} from "@shared/properties";
|
|
21
|
+
import type { DocumentProperty } from "@shared/api";
|
|
22
|
+
import { VisualEditor } from "./VisualEditor";
|
|
23
|
+
import {
|
|
24
|
+
createBlockFieldSaveController,
|
|
25
|
+
type BlockFieldSaveController,
|
|
26
|
+
} from "./blockFieldSaveController";
|
|
27
|
+
import {
|
|
28
|
+
acquireBlockFieldSaveController,
|
|
29
|
+
blockFieldSaveImplRef,
|
|
30
|
+
peekBlockFieldSaveController,
|
|
31
|
+
releaseBlockFieldSaveController,
|
|
32
|
+
} from "./blockFieldSaveRegistry";
|
|
33
|
+
|
|
34
|
+
const BLOCK_FIELD_DRAG_THRESHOLD = 6;
|
|
35
|
+
|
|
36
|
+
interface DocumentBlockFieldsProps {
|
|
37
|
+
documentId: string;
|
|
38
|
+
canEdit: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The fully-wired collaborative body editor for the primary "Content" field.
|
|
41
|
+
* Rendered as-is when solo (chromeless) and inside a header/collapsible shell
|
|
42
|
+
* when there are multiple Blocks fields.
|
|
43
|
+
*/
|
|
44
|
+
primaryEditor: ReactNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function blockFieldsFromProperties(
|
|
48
|
+
properties: DocumentProperty[],
|
|
49
|
+
): DocumentProperty[] {
|
|
50
|
+
return properties
|
|
51
|
+
.filter((property) => isBlocksPropertyType(property.definition.type))
|
|
52
|
+
.sort((a, b) => a.definition.position - b.definition.position);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type FieldReorderTarget = {
|
|
56
|
+
targetPropertyId: string;
|
|
57
|
+
position: "before" | "after";
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function computeFieldReorderTarget(
|
|
61
|
+
draggedId: string,
|
|
62
|
+
dropGapIndex: number,
|
|
63
|
+
orderedFields: DocumentProperty[],
|
|
64
|
+
): FieldReorderTarget | null {
|
|
65
|
+
const blockFields = orderedFields.filter((field) =>
|
|
66
|
+
isBlocksPropertyType(field.definition.type),
|
|
67
|
+
);
|
|
68
|
+
const draggedIndex = blockFields.findIndex(
|
|
69
|
+
(field) => field.definition.id === draggedId,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
draggedIndex === -1 ||
|
|
74
|
+
dropGapIndex < 0 ||
|
|
75
|
+
dropGapIndex > blockFields.length ||
|
|
76
|
+
blockFields.length < 2
|
|
77
|
+
) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (dropGapIndex === draggedIndex || dropGapIndex === draggedIndex + 1) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (dropGapIndex === blockFields.length) {
|
|
86
|
+
const lastField = blockFields[blockFields.length - 1];
|
|
87
|
+
if (!lastField || lastField.definition.id === draggedId) return null;
|
|
88
|
+
return { targetPropertyId: lastField.definition.id, position: "after" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const followingField = blockFields[dropGapIndex];
|
|
92
|
+
if (!followingField || followingField.definition.id === draggedId) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { targetPropertyId: followingField.definition.id, position: "before" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function blockFieldDragMoved(
|
|
100
|
+
startX: number,
|
|
101
|
+
startY: number,
|
|
102
|
+
clientX: number,
|
|
103
|
+
clientY: number,
|
|
104
|
+
) {
|
|
105
|
+
return (
|
|
106
|
+
Math.hypot(clientX - startX, clientY - startY) >= BLOCK_FIELD_DRAG_THRESHOLD
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function suppressNextDocumentClick() {
|
|
111
|
+
const handler = (event: MouseEvent) => {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
event.stopPropagation();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
globalThis.document.addEventListener("click", handler, {
|
|
117
|
+
capture: true,
|
|
118
|
+
once: true,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type BlockFieldDragPreviewState = {
|
|
123
|
+
label: string;
|
|
124
|
+
x: number;
|
|
125
|
+
y: number;
|
|
126
|
+
width: number;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function blockFieldDragPreviewFromElement(
|
|
130
|
+
element: HTMLElement,
|
|
131
|
+
label: string,
|
|
132
|
+
clientX: number,
|
|
133
|
+
clientY: number,
|
|
134
|
+
): BlockFieldDragPreviewState {
|
|
135
|
+
const rect = element.getBoundingClientRect();
|
|
136
|
+
return {
|
|
137
|
+
label,
|
|
138
|
+
x: clientX,
|
|
139
|
+
y: clientY,
|
|
140
|
+
width: Math.min(rect.width, 320),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function BlockFieldDragPreview({
|
|
145
|
+
preview,
|
|
146
|
+
}: {
|
|
147
|
+
preview: BlockFieldDragPreviewState | null;
|
|
148
|
+
}) {
|
|
149
|
+
if (!preview) return null;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div
|
|
153
|
+
aria-hidden="true"
|
|
154
|
+
className="pointer-events-none fixed left-0 top-0 z-[9999] flex h-8 max-w-80 items-center gap-1.5 overflow-hidden rounded-md border border-border bg-background/95 px-2 text-sm font-medium opacity-80 shadow-lg"
|
|
155
|
+
data-block-field-drag-preview
|
|
156
|
+
style={{
|
|
157
|
+
width: preview.width,
|
|
158
|
+
transform: `translate3d(${preview.x + 12}px, ${preview.y + 10}px, 0)`,
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<IconGripVertical className="size-4 shrink-0 text-muted-foreground" />
|
|
162
|
+
<span className="truncate">{preview.label}</span>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The render decision for a row's Blocks fields, computed from the loaded
|
|
168
|
+
// field list AND whether that list has actually arrived from the server.
|
|
169
|
+
//
|
|
170
|
+
// Three states must stay distinct so we NEVER blindly write the document body
|
|
171
|
+
// when the solo field's identity/primacy is unknown:
|
|
172
|
+
//
|
|
173
|
+
// - "loading" — field data has not arrived yet. The list is `[]` but that
|
|
174
|
+
// does NOT mean zero fields; render a non-editable
|
|
175
|
+
// placeholder, never a writable body editor.
|
|
176
|
+
// - "empty" — loaded, and there are genuinely zero Blocks fields (e.g.
|
|
177
|
+
// the only field was deleted → metadata-only row). Render no
|
|
178
|
+
// block editor (an "add a Blocks field" affordance is fine),
|
|
179
|
+
// NOT the body editor.
|
|
180
|
+
// - "solo" — loaded, exactly one Blocks field. Route to THAT field's
|
|
181
|
+
// store: primary → body editor, non-primary → block-field
|
|
182
|
+
// controller. Solo does NOT imply primary.
|
|
183
|
+
// - "multi" — loaded, 2+ fields. Each gets a header.
|
|
184
|
+
export type BlockFieldsRenderState =
|
|
185
|
+
| { kind: "loading" }
|
|
186
|
+
| { kind: "empty" }
|
|
187
|
+
| { kind: "solo"; field: DocumentProperty; target: BlocksStorageTarget }
|
|
188
|
+
| { kind: "multi"; fields: DocumentProperty[] };
|
|
189
|
+
|
|
190
|
+
// Whether the query data we are holding actually belongs to the CURRENT row.
|
|
191
|
+
// `useDocumentProperties` keeps the previous document's data as placeholder
|
|
192
|
+
// across a documentId change, so identity must be confirmed before the field
|
|
193
|
+
// layout is trusted — otherwise the old doc's solo-primary layout could route
|
|
194
|
+
// the new doc's edits to the body. The response carries its own `documentId`
|
|
195
|
+
// (shared/api.ts → DocumentPropertiesResponse).
|
|
196
|
+
export function isLoadedForDocument(
|
|
197
|
+
documentId: string,
|
|
198
|
+
data: { documentId: string } | undefined,
|
|
199
|
+
): boolean {
|
|
200
|
+
return data?.documentId === documentId;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function blockFieldsRenderState(args: {
|
|
204
|
+
loaded: boolean;
|
|
205
|
+
blockFields: DocumentProperty[];
|
|
206
|
+
}): BlockFieldsRenderState {
|
|
207
|
+
// Until the field list has arrived we cannot know how many Blocks fields the
|
|
208
|
+
// row has, nor which one is solo, nor whether it is primary. Treat as loading
|
|
209
|
+
// and render a non-editable placeholder — never a body-backed writable editor.
|
|
210
|
+
if (!args.loaded) return { kind: "loading" };
|
|
211
|
+
|
|
212
|
+
const { blockFields } = args;
|
|
213
|
+
if (blockFields.length === 0) return { kind: "empty" };
|
|
214
|
+
if (blocksRenderMode(blockFields.length) === "multi") {
|
|
215
|
+
return { kind: "multi", fields: blockFields };
|
|
216
|
+
}
|
|
217
|
+
const field = blockFields[0]!;
|
|
218
|
+
return {
|
|
219
|
+
kind: "solo",
|
|
220
|
+
field,
|
|
221
|
+
target: blocksStorageTarget(field.definition.options),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Renders all Blocks fields for a database row.
|
|
227
|
+
*
|
|
228
|
+
* - Exactly ONE Blocks field → chromeless: just the editing surface, exactly
|
|
229
|
+
* like the current Notion-style body (no header).
|
|
230
|
+
* - TWO or more → every field shows its name as a header and each is
|
|
231
|
+
* collapsible and reorderable.
|
|
232
|
+
*
|
|
233
|
+
* Solo reversibility: deleting down to one field returns to chromeless but keeps
|
|
234
|
+
* the surviving field's stored name.
|
|
235
|
+
*/
|
|
236
|
+
export function DocumentBlockFields({
|
|
237
|
+
documentId,
|
|
238
|
+
canEdit,
|
|
239
|
+
primaryEditor,
|
|
240
|
+
}: DocumentBlockFieldsProps) {
|
|
241
|
+
const query = useDocumentProperties(documentId);
|
|
242
|
+
const properties = query.data?.properties ?? [];
|
|
243
|
+
const blockFields = useMemo(
|
|
244
|
+
() => blockFieldsFromProperties(properties),
|
|
245
|
+
[properties],
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Loaded ONLY when the query data we hold actually belongs to the CURRENT
|
|
249
|
+
// documentId. `useDocumentProperties` uses `placeholderData: (prev) => prev`,
|
|
250
|
+
// so right after the viewed row changes, `query.data` still holds the PREVIOUS
|
|
251
|
+
// document's field layout for a tick. Trusting it would let the old doc's
|
|
252
|
+
// solo-primary layout route the NEW doc's edits to the body (clobbering a
|
|
253
|
+
// non-primary field). The response carries its own `documentId`
|
|
254
|
+
// (shared/api.ts → DocumentPropertiesResponse), so we gate on an identity
|
|
255
|
+
// match: until the data is for THIS document, treat the row as still loading
|
|
256
|
+
// (a non-editable placeholder), never a writable body editor.
|
|
257
|
+
const loaded = isLoadedForDocument(documentId, query.data);
|
|
258
|
+
const state = blockFieldsRenderState({ loaded, blockFields });
|
|
259
|
+
|
|
260
|
+
switch (state.kind) {
|
|
261
|
+
case "loading":
|
|
262
|
+
// Field data not yet arrived: render a NON-editable placeholder, never a
|
|
263
|
+
// writable body editor. We do not know yet whether the solo field (if any)
|
|
264
|
+
// is primary, so routing to the body could clobber a non-primary field.
|
|
265
|
+
return (
|
|
266
|
+
<div
|
|
267
|
+
className="grid gap-1"
|
|
268
|
+
data-block-fields-state="loading"
|
|
269
|
+
aria-busy="true"
|
|
270
|
+
>
|
|
271
|
+
<div className="h-24 animate-pulse rounded-md bg-muted/40" />
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
case "empty":
|
|
275
|
+
// Loaded with zero Blocks fields (the only field was deleted → a
|
|
276
|
+
// metadata-only row). Render NO block editor — definitely not the body
|
|
277
|
+
// editor. An affordance to add a Blocks field is appropriate here.
|
|
278
|
+
return (
|
|
279
|
+
<div className="grid gap-1" data-block-fields-state="empty">
|
|
280
|
+
{canEdit ? (
|
|
281
|
+
<p className="px-1 py-2 text-sm text-muted-foreground">
|
|
282
|
+
No Blocks fields. Add one from the property menu.
|
|
283
|
+
</p>
|
|
284
|
+
) : null}
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
case "solo":
|
|
288
|
+
// Solo (chromeless: no header) — but solo does NOT mean primary. Route to
|
|
289
|
+
// WHICHEVER store backs the lone field:
|
|
290
|
+
// - primary → the collaborative body editor (`documents.content`)
|
|
291
|
+
// - non-primary → the debounced block-field-store editor
|
|
292
|
+
if (state.target === "block_field_store") {
|
|
293
|
+
return (
|
|
294
|
+
<div className="grid gap-1" data-block-fields-state="solo">
|
|
295
|
+
<AdditionalBlockEditor
|
|
296
|
+
// Identity key: a documentId/propertyId change unmounts the old
|
|
297
|
+
// instance (flushing its pending save) and mounts a fresh one with
|
|
298
|
+
// a fresh save controller — so the controller can never DISPLAY one
|
|
299
|
+
// field while SAVING to another across an identity change.
|
|
300
|
+
key={`${documentId}:${state.field.definition.id}`}
|
|
301
|
+
documentId={documentId}
|
|
302
|
+
property={state.field}
|
|
303
|
+
canEdit={canEdit}
|
|
304
|
+
/>
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return (
|
|
309
|
+
<div className="grid gap-1" data-block-fields-state="solo">
|
|
310
|
+
{primaryEditor}
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
case "multi":
|
|
314
|
+
return (
|
|
315
|
+
<MultiBlockFields
|
|
316
|
+
documentId={documentId}
|
|
317
|
+
canEdit={canEdit}
|
|
318
|
+
blockFields={state.fields}
|
|
319
|
+
primaryEditor={primaryEditor}
|
|
320
|
+
/>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function MultiBlockFields({
|
|
326
|
+
documentId,
|
|
327
|
+
canEdit,
|
|
328
|
+
blockFields,
|
|
329
|
+
primaryEditor,
|
|
330
|
+
}: {
|
|
331
|
+
documentId: string;
|
|
332
|
+
canEdit: boolean;
|
|
333
|
+
blockFields: DocumentProperty[];
|
|
334
|
+
primaryEditor: ReactNode;
|
|
335
|
+
}) {
|
|
336
|
+
const reorder = useReorderDocumentProperty(documentId);
|
|
337
|
+
const [dragId, setDragId] = useState<string | null>(null);
|
|
338
|
+
const [dragOverGapIndex, setDragOverGapIndex] = useState<number | null>(null);
|
|
339
|
+
const [dragPreview, setDragPreview] =
|
|
340
|
+
useState<BlockFieldDragPreviewState | null>(null);
|
|
341
|
+
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
return () => {
|
|
344
|
+
globalThis.document.body.style.userSelect = "";
|
|
345
|
+
globalThis.document.body.style.cursor = "";
|
|
346
|
+
globalThis.document.body.classList.remove("notion-editor-is-dragging");
|
|
347
|
+
};
|
|
348
|
+
}, []);
|
|
349
|
+
|
|
350
|
+
function clearFieldDrag() {
|
|
351
|
+
setDragId(null);
|
|
352
|
+
setDragOverGapIndex(null);
|
|
353
|
+
setDragPreview(null);
|
|
354
|
+
globalThis.document.body.classList.remove("notion-editor-is-dragging");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function dropGapIndexFromPoint(clientX: number, clientY: number) {
|
|
358
|
+
const element = globalThis.document.elementFromPoint(clientX, clientY);
|
|
359
|
+
const gap = element?.closest<HTMLElement>("[data-block-field-drop-zone]");
|
|
360
|
+
const indexText = gap?.dataset.blockFieldDropGapIndex;
|
|
361
|
+
if (!indexText) return null;
|
|
362
|
+
|
|
363
|
+
const index = Number(indexText);
|
|
364
|
+
if (!Number.isInteger(index)) return null;
|
|
365
|
+
return index;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function moveFieldToGap(propertyId: string, dropGapIndex: number) {
|
|
369
|
+
const target = computeFieldReorderTarget(
|
|
370
|
+
propertyId,
|
|
371
|
+
dropGapIndex,
|
|
372
|
+
blockFields,
|
|
373
|
+
);
|
|
374
|
+
if (!target) return;
|
|
375
|
+
|
|
376
|
+
void reorder.mutateAsync({
|
|
377
|
+
documentId,
|
|
378
|
+
propertyId,
|
|
379
|
+
targetPropertyId: target.targetPropertyId,
|
|
380
|
+
position: target.position,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function startFieldPointerDrag(
|
|
385
|
+
property: DocumentProperty,
|
|
386
|
+
event: ReactPointerEvent<HTMLElement>,
|
|
387
|
+
) {
|
|
388
|
+
if (!canEdit || blockFields.length < 2) return;
|
|
389
|
+
|
|
390
|
+
event.preventDefault();
|
|
391
|
+
event.stopPropagation();
|
|
392
|
+
|
|
393
|
+
const propertyId = property.definition.id;
|
|
394
|
+
const sourceElement =
|
|
395
|
+
event.currentTarget.closest<HTMLElement>("[data-block-field-shell]") ??
|
|
396
|
+
event.currentTarget;
|
|
397
|
+
const startX = event.clientX;
|
|
398
|
+
const startY = event.clientY;
|
|
399
|
+
let dragging = false;
|
|
400
|
+
|
|
401
|
+
function validGapIndexFromPoint(clientX: number, clientY: number) {
|
|
402
|
+
const gapIndex = dropGapIndexFromPoint(clientX, clientY);
|
|
403
|
+
if (gapIndex === null) return null;
|
|
404
|
+
return computeFieldReorderTarget(propertyId, gapIndex, blockFields)
|
|
405
|
+
? gapIndex
|
|
406
|
+
: null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function beginDrag(moveEvent: PointerEvent) {
|
|
410
|
+
dragging = true;
|
|
411
|
+
setDragId(propertyId);
|
|
412
|
+
setDragOverGapIndex(null);
|
|
413
|
+
setDragPreview(
|
|
414
|
+
blockFieldDragPreviewFromElement(
|
|
415
|
+
sourceElement,
|
|
416
|
+
property.definition.name,
|
|
417
|
+
moveEvent.clientX,
|
|
418
|
+
moveEvent.clientY,
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
globalThis.document.body.style.userSelect = "none";
|
|
422
|
+
globalThis.document.body.style.cursor = "grabbing";
|
|
423
|
+
globalThis.document.body.classList.add("notion-editor-is-dragging");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const handlePointerMove = (moveEvent: PointerEvent) => {
|
|
427
|
+
if (
|
|
428
|
+
!dragging &&
|
|
429
|
+
!blockFieldDragMoved(
|
|
430
|
+
startX,
|
|
431
|
+
startY,
|
|
432
|
+
moveEvent.clientX,
|
|
433
|
+
moveEvent.clientY,
|
|
434
|
+
)
|
|
435
|
+
) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (!dragging) beginDrag(moveEvent);
|
|
439
|
+
moveEvent.preventDefault();
|
|
440
|
+
setDragPreview((current) =>
|
|
441
|
+
current
|
|
442
|
+
? { ...current, x: moveEvent.clientX, y: moveEvent.clientY }
|
|
443
|
+
: current,
|
|
444
|
+
);
|
|
445
|
+
setDragOverGapIndex(
|
|
446
|
+
validGapIndexFromPoint(moveEvent.clientX, moveEvent.clientY),
|
|
447
|
+
);
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const handlePointerUp = (upEvent: PointerEvent) => {
|
|
451
|
+
globalThis.document.body.style.userSelect = "";
|
|
452
|
+
globalThis.document.body.style.cursor = "";
|
|
453
|
+
globalThis.document.removeEventListener("pointermove", handlePointerMove);
|
|
454
|
+
globalThis.document.removeEventListener("pointerup", handlePointerUp);
|
|
455
|
+
|
|
456
|
+
if (dragging) {
|
|
457
|
+
suppressNextDocumentClick();
|
|
458
|
+
const gapIndex = validGapIndexFromPoint(
|
|
459
|
+
upEvent.clientX,
|
|
460
|
+
upEvent.clientY,
|
|
461
|
+
);
|
|
462
|
+
if (gapIndex !== null) moveFieldToGap(propertyId, gapIndex);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
clearFieldDrag();
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
globalThis.document.addEventListener("pointermove", handlePointerMove);
|
|
469
|
+
globalThis.document.addEventListener("pointerup", handlePointerUp);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
<div className="grid">
|
|
474
|
+
<BlockFieldDragPreview preview={dragPreview} />
|
|
475
|
+
{blockFields.map((property, index) => {
|
|
476
|
+
const primary = isPrimaryBlocksField(property.definition.options);
|
|
477
|
+
return (
|
|
478
|
+
<div key={property.definition.id}>
|
|
479
|
+
<FieldDropZone
|
|
480
|
+
active={canEdit && dragId !== null}
|
|
481
|
+
over={dragOverGapIndex === index}
|
|
482
|
+
gapIndex={index}
|
|
483
|
+
/>
|
|
484
|
+
<BlockFieldShell
|
|
485
|
+
property={property}
|
|
486
|
+
canEdit={canEdit}
|
|
487
|
+
isDragging={dragId === property.definition.id}
|
|
488
|
+
onPointerDown={(event) => startFieldPointerDrag(property, event)}
|
|
489
|
+
>
|
|
490
|
+
{primary ? (
|
|
491
|
+
primaryEditor
|
|
492
|
+
) : (
|
|
493
|
+
<AdditionalBlockEditor
|
|
494
|
+
// Identity key (see solo case): remount on a documentId/
|
|
495
|
+
// propertyId change so a reused instance never saves the new
|
|
496
|
+
// doc's edits to the old field's closure.
|
|
497
|
+
key={`${documentId}:${property.definition.id}`}
|
|
498
|
+
documentId={documentId}
|
|
499
|
+
property={property}
|
|
500
|
+
canEdit={canEdit}
|
|
501
|
+
/>
|
|
502
|
+
)}
|
|
503
|
+
</BlockFieldShell>
|
|
504
|
+
</div>
|
|
505
|
+
);
|
|
506
|
+
})}
|
|
507
|
+
<FieldDropZone
|
|
508
|
+
active={canEdit && dragId !== null}
|
|
509
|
+
over={dragOverGapIndex === blockFields.length}
|
|
510
|
+
gapIndex={blockFields.length}
|
|
511
|
+
/>
|
|
512
|
+
</div>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function FieldDropZone({
|
|
517
|
+
active,
|
|
518
|
+
over,
|
|
519
|
+
gapIndex,
|
|
520
|
+
}: {
|
|
521
|
+
active: boolean;
|
|
522
|
+
over: boolean;
|
|
523
|
+
gapIndex: number;
|
|
524
|
+
}) {
|
|
525
|
+
return (
|
|
526
|
+
<div
|
|
527
|
+
className={cn("relative h-3", active && "cursor-grabbing")}
|
|
528
|
+
data-block-field-drop-zone
|
|
529
|
+
data-block-field-drop-gap-index={gapIndex}
|
|
530
|
+
>
|
|
531
|
+
<div
|
|
532
|
+
className={cn(
|
|
533
|
+
"pointer-events-none absolute left-6 right-2 top-1/2 h-[3px] -translate-y-1/2 rounded-full opacity-0 transition-opacity",
|
|
534
|
+
over && "opacity-100",
|
|
535
|
+
)}
|
|
536
|
+
style={{
|
|
537
|
+
background: "hsl(210 100% 52%)",
|
|
538
|
+
boxShadow: "0 0 0 1px hsl(var(--background))",
|
|
539
|
+
}}
|
|
540
|
+
/>
|
|
541
|
+
</div>
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function BlockFieldShell({
|
|
546
|
+
property,
|
|
547
|
+
canEdit,
|
|
548
|
+
children,
|
|
549
|
+
isDragging,
|
|
550
|
+
onPointerDown,
|
|
551
|
+
}: {
|
|
552
|
+
property: DocumentProperty;
|
|
553
|
+
canEdit: boolean;
|
|
554
|
+
children: ReactNode;
|
|
555
|
+
isDragging: boolean;
|
|
556
|
+
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
|
|
557
|
+
}) {
|
|
558
|
+
const [open, setOpen] = useState(true);
|
|
559
|
+
|
|
560
|
+
return (
|
|
561
|
+
<section
|
|
562
|
+
className={cn(
|
|
563
|
+
// Borderless, Capacities-style: fields are separated by the header +
|
|
564
|
+
// spacing, not a box. Field drop targets live only in the explicit
|
|
565
|
+
// between-field zones rendered by MultiBlockFields.
|
|
566
|
+
"group/blockfield rounded-md",
|
|
567
|
+
isDragging && "opacity-50",
|
|
568
|
+
)}
|
|
569
|
+
data-block-field-shell
|
|
570
|
+
data-block-field-id={property.definition.id}
|
|
571
|
+
>
|
|
572
|
+
{/* ONE grip rail: the header grip sits in a fixed 24px left gutter, and
|
|
573
|
+
the content below is indented by the same 24px (pl-6). The editor's
|
|
574
|
+
per-block drag handle is 24px wide and positioned just left of the
|
|
575
|
+
block content, so it lands in that same gutter — header grip and block
|
|
576
|
+
grips align on a single vertical rail (Capacities-style). */}
|
|
577
|
+
<div className="flex items-center py-1 pr-2">
|
|
578
|
+
{canEdit ? (
|
|
579
|
+
<span
|
|
580
|
+
role="button"
|
|
581
|
+
aria-label={`Reorder ${property.definition.name}`}
|
|
582
|
+
className="flex w-6 shrink-0 justify-center cursor-grab text-muted-foreground/40 transition-colors hover:text-foreground active:cursor-grabbing"
|
|
583
|
+
onPointerDown={onPointerDown}
|
|
584
|
+
>
|
|
585
|
+
<IconGripVertical className="size-4" />
|
|
586
|
+
</span>
|
|
587
|
+
) : (
|
|
588
|
+
// Keep the 24px gutter so the header label stays aligned with the
|
|
589
|
+
// content indent even when reordering is disabled.
|
|
590
|
+
<span className="w-6 shrink-0" />
|
|
591
|
+
)}
|
|
592
|
+
<button
|
|
593
|
+
type="button"
|
|
594
|
+
aria-expanded={open}
|
|
595
|
+
aria-label={`Toggle ${property.definition.name}`}
|
|
596
|
+
className="flex min-w-0 flex-1 items-center gap-1 rounded py-0.5 text-left text-sm font-medium text-foreground hover:bg-muted/50"
|
|
597
|
+
onClick={() => setOpen((value) => !value)}
|
|
598
|
+
>
|
|
599
|
+
<IconChevronRight
|
|
600
|
+
className={cn(
|
|
601
|
+
"size-4 shrink-0 text-muted-foreground transition-transform",
|
|
602
|
+
open && "rotate-90",
|
|
603
|
+
)}
|
|
604
|
+
/>
|
|
605
|
+
<span className="truncate">{property.definition.name}</span>
|
|
606
|
+
</button>
|
|
607
|
+
</div>
|
|
608
|
+
{open ? (
|
|
609
|
+
<div className="block-field-content pb-3 pl-6 pr-2">{children}</div>
|
|
610
|
+
) : null}
|
|
611
|
+
</section>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Owns the save-controller wiring for one ADDITIONAL (non-primary) Blocks field:
|
|
617
|
+
* the debounced single-flight controller, content state, server-adopt effect,
|
|
618
|
+
* and unmount-flush. Extracted from the editor component so this behavior is
|
|
619
|
+
* testable WITHOUT rendering TipTap (the controller, its save target, and the
|
|
620
|
+
* remount/flush semantics are the parts the review flagged).
|
|
621
|
+
*
|
|
622
|
+
* Identity safety: callers mount this under an identity `key`
|
|
623
|
+
* (`${documentId}:${propertyId}`) so a row/field change unmounts the old
|
|
624
|
+
* instance (flushing its pending save to the OLD field) and mounts a fresh one
|
|
625
|
+
* with a fresh controller. The save target is ALSO read through a ref, so the
|
|
626
|
+
* controller never captures a documentId/propertyId that could go stale — the
|
|
627
|
+
* editor can never DISPLAY one field while SAVING to another (CORE alias hole).
|
|
628
|
+
*/
|
|
629
|
+
export function useBlockFieldEditor({
|
|
630
|
+
documentId,
|
|
631
|
+
propertyId,
|
|
632
|
+
initialContent,
|
|
633
|
+
save,
|
|
634
|
+
}: {
|
|
635
|
+
documentId: string;
|
|
636
|
+
propertyId: string;
|
|
637
|
+
initialContent: string;
|
|
638
|
+
save: (request: {
|
|
639
|
+
documentId: string;
|
|
640
|
+
propertyId: string;
|
|
641
|
+
value: string;
|
|
642
|
+
}) => Promise<unknown>;
|
|
643
|
+
}): { content: string; onChange: (markdown: string) => void } {
|
|
644
|
+
const key = `${documentId}:${propertyId}`;
|
|
645
|
+
|
|
646
|
+
// The shared controller calls the freshest save impl through a per-key ref. The
|
|
647
|
+
// save TARGET (documentId:propertyId) is fixed by the key; only the function
|
|
648
|
+
// identity changes per mount, never the field it writes to.
|
|
649
|
+
const implRef = blockFieldSaveImplRef(key);
|
|
650
|
+
implRef.current = (value: string) => save({ documentId, propertyId, value });
|
|
651
|
+
|
|
652
|
+
// Build (but do not yet ref-count) the controller factory for this key. The
|
|
653
|
+
// formal acquire/release happens in the effect below; we only need the factory
|
|
654
|
+
// here so the first acquire can create the instance.
|
|
655
|
+
const factory = () =>
|
|
656
|
+
createBlockFieldSaveController({
|
|
657
|
+
initialContent,
|
|
658
|
+
// Single-flight + trailing WITHIN this one shared controller now orders
|
|
659
|
+
// saves across ALL editor instances for the key (there is only ever one
|
|
660
|
+
// controller per key), so no cross-instance serialization lane is needed.
|
|
661
|
+
save: (value) => implRef.current(value),
|
|
662
|
+
onError: (error) =>
|
|
663
|
+
console.error("Failed to save Blocks field content", {
|
|
664
|
+
documentId,
|
|
665
|
+
propertyId,
|
|
666
|
+
error,
|
|
667
|
+
}),
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// Acquire the ONE shared controller for this field key, and release it on
|
|
671
|
+
// unmount / key change. Across ANY mount/unmount/collapse/reopen interleaving
|
|
672
|
+
// there is exactly one controller per key — one pending value, one in-flight
|
|
673
|
+
// save — so an older save can never overwrite a newer one for the same field,
|
|
674
|
+
// even when an old instance's unmount-flush overlaps a new instance's edit.
|
|
675
|
+
// Release flush-then-evicts: the latest dirty content still persists to THIS
|
|
676
|
+
// field, and a quick reopen before the flush settles re-acquires the SAME
|
|
677
|
+
// controller (no competing instance).
|
|
678
|
+
//
|
|
679
|
+
// `factory`/`implRef` are intentionally not effect deps: the impl ref is updated
|
|
680
|
+
// every render, and the identity key forces a remount for a DIFFERENT key, so
|
|
681
|
+
// within one mount the key is stable and we acquire exactly once.
|
|
682
|
+
const controllerRef = useRef<BlockFieldSaveController | null>(null);
|
|
683
|
+
useEffect(() => {
|
|
684
|
+
controllerRef.current = acquireBlockFieldSaveController(key, factory);
|
|
685
|
+
return () => {
|
|
686
|
+
controllerRef.current = null;
|
|
687
|
+
releaseBlockFieldSaveController(key);
|
|
688
|
+
};
|
|
689
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
690
|
+
}, [key]);
|
|
691
|
+
|
|
692
|
+
// Seed the displayed value for this mount. Precedence (controller's freshest
|
|
693
|
+
// known content wins over a stale `initialContent`, server props win only when
|
|
694
|
+
// genuinely newer):
|
|
695
|
+
// 1. dirty controller (`pending !== lastSaved`) → show `pending` (the true
|
|
696
|
+
// latest text the user typed; never clobber an in-progress edit).
|
|
697
|
+
// 2. clean controller that has saved locally (`hasSavedLocally`) but whose
|
|
698
|
+
// `lastSaved` differs from `initialContent` → show `lastSaved`. The server
|
|
699
|
+
// query hasn't refetched the just-saved value yet, so `initialContent` is
|
|
700
|
+
// STALE (older than what we persisted); showing it would flash pre-save
|
|
701
|
+
// content and base the next edit on stale text.
|
|
702
|
+
// 3. otherwise → `initialContent`. Covers no controller yet, a controller
|
|
703
|
+
// already in sync with the server, and a clean controller that never saved
|
|
704
|
+
// locally (so server props are authoritative / possibly newer external).
|
|
705
|
+
const [content, setContent] = useState(() => {
|
|
706
|
+
const existing = peekBlockFieldSaveController(key);
|
|
707
|
+
if (existing) {
|
|
708
|
+
if (existing.pending !== existing.lastSaved) {
|
|
709
|
+
return existing.pending;
|
|
710
|
+
}
|
|
711
|
+
if (existing.hasSavedLocally && existing.lastSaved !== initialContent) {
|
|
712
|
+
return existing.lastSaved;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return initialContent;
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// Adopt fresh server content when it is a GENUINELY newer external update (e.g.
|
|
719
|
+
// an agent edit) — not when it is merely stale server props lagging a local
|
|
720
|
+
// save. The controller is acquired in the [key] effect above, which runs first.
|
|
721
|
+
//
|
|
722
|
+
// Adopt only when ALL hold:
|
|
723
|
+
// - server content diverges from what we last confirmed saved
|
|
724
|
+
// (`initialContent !== lastSaved`), AND
|
|
725
|
+
// - the field is clean — the user hasn't typed something newer
|
|
726
|
+
// (`pending === lastSaved`; never clobber a dirty local edit), AND
|
|
727
|
+
// - the controller has NOT just saved locally (`!hasSavedLocally`). If it
|
|
728
|
+
// HAS, its `lastSaved` is content we originated that the server query
|
|
729
|
+
// hasn't refetched yet, so `initialContent` is STALE (older than
|
|
730
|
+
// `lastSaved`) — adopting it would regress to pre-save content. We wait for
|
|
731
|
+
// the server to catch up; once it echoes `lastSaved`, this condition is
|
|
732
|
+
// simply false. `mark()` below clears `hasSavedLocally`, so a later genuine
|
|
733
|
+
// external edit is adopted normally.
|
|
734
|
+
useEffect(() => {
|
|
735
|
+
const controller = controllerRef.current;
|
|
736
|
+
if (!controller) return;
|
|
737
|
+
// Never adopt over a dirty local edit.
|
|
738
|
+
if (controller.pending !== controller.lastSaved) return;
|
|
739
|
+
if (initialContent === controller.lastSaved) {
|
|
740
|
+
// The server has echoed our last-known content — we're back in sync, so
|
|
741
|
+
// server props are no longer "behind" this controller. Clear the
|
|
742
|
+
// just-saved latch so a LATER genuine external edit (e.g. an agent) is
|
|
743
|
+
// adopted normally. (Without this the latch would stick forever after the
|
|
744
|
+
// first save+echo and suppress all future external updates.)
|
|
745
|
+
if (controller.hasSavedLocally) controller.mark(initialContent);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// initialContent diverges from lastSaved and the field is clean:
|
|
749
|
+
if (!controller.hasSavedLocally) {
|
|
750
|
+
// Genuine newer external update — adopt it.
|
|
751
|
+
setContent(initialContent);
|
|
752
|
+
controller.mark(initialContent);
|
|
753
|
+
}
|
|
754
|
+
// else: server props are stale, lagging a local save the server hasn't
|
|
755
|
+
// echoed yet. Keep showing lastSaved and wait for the echo above to clear
|
|
756
|
+
// the latch. (Cross-client concurrent same-field edits remain last-write-
|
|
757
|
+
// wins for v1; true coherence needs the deferred server-side versioning.)
|
|
758
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
759
|
+
}, [initialContent]);
|
|
760
|
+
|
|
761
|
+
function onChange(markdown: string) {
|
|
762
|
+
setContent(markdown);
|
|
763
|
+
// The controller is acquired in the mount effect; user edits only arrive
|
|
764
|
+
// after mount, so it is present. Guard defensively regardless.
|
|
765
|
+
controllerRef.current?.change(markdown);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return { content, onChange };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Editor for an ADDITIONAL (non-primary) Blocks field. Uses the rich-text
|
|
773
|
+
* VisualEditor without collab (no Yjs) — independently editable, debounced
|
|
774
|
+
* save through set-document-property which persists to the field's own store.
|
|
775
|
+
*/
|
|
776
|
+
function AdditionalBlockEditor({
|
|
777
|
+
documentId,
|
|
778
|
+
property,
|
|
779
|
+
canEdit,
|
|
780
|
+
}: {
|
|
781
|
+
documentId: string;
|
|
782
|
+
property: DocumentProperty;
|
|
783
|
+
canEdit: boolean;
|
|
784
|
+
}) {
|
|
785
|
+
const setProperty = useSetDocumentProperty(documentId);
|
|
786
|
+
const propertyId = property.definition.id;
|
|
787
|
+
const initialContent =
|
|
788
|
+
typeof property.value === "string" ? property.value : "";
|
|
789
|
+
const { content, onChange } = useBlockFieldEditor({
|
|
790
|
+
documentId,
|
|
791
|
+
propertyId,
|
|
792
|
+
initialContent,
|
|
793
|
+
save: setProperty.mutateAsync,
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
return (
|
|
797
|
+
<VisualEditor
|
|
798
|
+
key={propertyId}
|
|
799
|
+
documentId={documentId}
|
|
800
|
+
content={content}
|
|
801
|
+
onChange={onChange}
|
|
802
|
+
editable={canEdit}
|
|
803
|
+
localFileMode
|
|
804
|
+
/>
|
|
805
|
+
);
|
|
806
|
+
}
|