@plannotator/ui 0.30.0 → 0.32.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/README.md +46 -1
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +158 -36
- package/components/AnnotationToolbar.tsx +50 -30
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +238 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/GraphvizBlock.tsx +86 -7
- package/components/HtmlSurfaceControls.tsx +170 -0
- package/components/InlineMarkdown.tsx +25 -4
- package/components/KeyboardShortcuts.tsx +9 -0
- package/components/Landing.tsx +1 -1
- package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
- package/components/MarkdownEditor/embedPicker.ts +349 -0
- package/components/MarkdownEditor.tsx +12 -0
- package/components/MermaidBlock.tsx +60 -26
- package/components/ModeToggle.tsx +2 -1
- package/components/PermissionModeSetup.tsx +24 -5
- package/components/PinpointOverlay.tsx +11 -6
- package/components/PlanHeaderMenu.tsx +140 -1
- package/components/SearchableSelect.tsx +2 -0
- package/components/Settings.tsx +175 -10
- package/components/SkillReferenceMenu.tsx +9 -0
- package/components/StickyHeaderLane.tsx +9 -2
- package/components/TableOfContents.tsx +9 -4
- package/components/TextShimmer.tsx +8 -5
- package/components/ThemeProvider.tsx +43 -1
- package/components/ThemeTab.tsx +52 -1
- package/components/Tooltip.tsx +3 -1
- package/components/Viewer.tsx +19 -5
- package/components/VimTargetReticle.tsx +12 -4
- package/components/ai/DocumentAIChatPanel.tsx +1 -0
- package/components/blocks/MathBlock.tsx +26 -14
- package/components/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +320 -41
- package/components/html-viewer/bridge-script.ts +360 -72
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/hostThreads.ts +37 -0
- package/components/html-viewer/index.ts +9 -0
- package/components/html-viewer/unanchored.ts +47 -0
- package/components/html-viewer/useHtmlAnnotation.ts +240 -61
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
- package/components/sidebar/FileBrowser.tsx +17 -5
- package/components/sidebar/SidebarContainer.tsx +124 -28
- package/components/ui/button.tsx +10 -8
- package/components/ui/dialog.tsx +35 -25
- package/config/index.ts +6 -1
- package/config/reviewView.ts +42 -9
- package/config/settings.ts +141 -0
- package/configure.ts +32 -0
- package/hooks/useAIProviderConfig.ts +8 -7
- package/hooks/useActiveSection.ts +6 -4
- package/hooks/useAgentJobs.ts +3 -0
- package/hooks/useAnnotationHighlighter.ts +20 -0
- package/hooks/useHtmlRefresh.ts +149 -0
- package/hooks/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useMathRenderer.ts +30 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useSharing.ts +31 -5
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +5 -2
- package/shortcuts/index.ts +3 -0
- package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
- package/styles.css +1 -1
- package/theme.css +229 -0
- package/types.ts +35 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/generateIdentity.ts +64 -14
- package/utils/htmlChrome.ts +20 -16
- package/utils/identity-tater.ts +36 -0
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/math-eager.ts +25 -0
- package/utils/math.ts +146 -0
- package/utils/mermaid-eager.ts +28 -0
- package/utils/mermaid.ts +132 -0
- package/utils/parser.ts +75 -2
- package/utils/quickLabels.ts +13 -0
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -0
- package/webmcp/activity.ts +46 -0
- package/webmcp/changes.ts +227 -0
- package/webmcp/index.ts +72 -0
- package/webmcp/modelContext.ts +103 -0
- package/webmcp/nudges.ts +174 -0
- package/webmcp/policy.ts +50 -0
- package/webmcp/preference.ts +50 -0
- package/webmcp/schema.ts +81 -0
- package/webmcp/toolset.ts +337 -0
- package/webmcp/useToolset.ts +74 -0
- package/components/PlanAIAnnouncementDialog.tsx +0 -187
- package/components/VimModeAnnouncementDialog.tsx +0 -557
- package/utils/planAIAnnouncement.ts +0 -17
- package/utils/vimModeAnnouncement.ts +0 -23
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pickedCompletion,
|
|
3
|
+
startCompletion,
|
|
4
|
+
type Completion,
|
|
5
|
+
type CompletionSource,
|
|
6
|
+
} from '@codemirror/autocomplete';
|
|
7
|
+
import {
|
|
8
|
+
EditorState,
|
|
9
|
+
MapMode,
|
|
10
|
+
StateEffect,
|
|
11
|
+
StateField,
|
|
12
|
+
type Extension,
|
|
13
|
+
type StateEffectType,
|
|
14
|
+
} from '@codemirror/state';
|
|
15
|
+
import type { EditorView } from '@codemirror/view';
|
|
16
|
+
import type { SlashCommandItem } from '@plannotator/atomic-editor';
|
|
17
|
+
import { planEmbedInsert } from '@plannotator/core/embed-insert';
|
|
18
|
+
|
|
19
|
+
/** What an embed can point at. The union grows as new media kinds ship. */
|
|
20
|
+
export type EmbedKind = 'html';
|
|
21
|
+
|
|
22
|
+
/** One embeddable target supplied by the host. */
|
|
23
|
+
export interface EmbedTarget {
|
|
24
|
+
/** Media kind represented by this target. */
|
|
25
|
+
readonly kind: EmbedKind;
|
|
26
|
+
/** Host-meaningful target identifier or path. */
|
|
27
|
+
readonly path: string;
|
|
28
|
+
/** Display title. Blank and absent titles fall back to {@link path}. */
|
|
29
|
+
readonly title?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Per-editor host callbacks for the `/embed` picker. */
|
|
33
|
+
export interface EmbedPickerConfig {
|
|
34
|
+
/** Live read of embeddable targets. Dynamic data belongs behind this callback. */
|
|
35
|
+
readonly getTargets: () => readonly EmbedTarget[];
|
|
36
|
+
/** Build the exact host-owned embed line for a picked target. */
|
|
37
|
+
readonly buildInsertLine: (target: EmbedTarget) => string;
|
|
38
|
+
/**
|
|
39
|
+
* Optionally upload a target. Resolve with a target to insert it, or `null`
|
|
40
|
+
* when the user cancels. The host owns error presentation.
|
|
41
|
+
*/
|
|
42
|
+
readonly uploadTarget?: (kind: EmbedKind) => Promise<EmbedTarget | null>;
|
|
43
|
+
/** Return an optional informational row for the current document text. */
|
|
44
|
+
readonly getNotice?: (docBody: string) => string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type IconCompletion = Completion & {
|
|
48
|
+
readonly slashCommandIcon?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type UploadAnchor = {
|
|
52
|
+
readonly from: number;
|
|
53
|
+
readonly to: number;
|
|
54
|
+
readonly text: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type UploadState =
|
|
58
|
+
| { readonly _tag: 'idle' }
|
|
59
|
+
| { readonly _tag: 'uploading'; readonly anchor: UploadAnchor | null };
|
|
60
|
+
|
|
61
|
+
type UploadStateEffect =
|
|
62
|
+
| { readonly _tag: 'start'; readonly anchor: UploadAnchor }
|
|
63
|
+
| { readonly _tag: 'finish' };
|
|
64
|
+
|
|
65
|
+
type UploadStateEffectType = StateEffectType<UploadStateEffect>;
|
|
66
|
+
|
|
67
|
+
const IDLE_UPLOAD_STATE: UploadState = { _tag: 'idle' };
|
|
68
|
+
const EMBED_PREFIX = '/embed ';
|
|
69
|
+
const HTML_KIND: EmbedKind = 'html';
|
|
70
|
+
|
|
71
|
+
// Framed `</>` for an HTML document rendered in place. This follows the
|
|
72
|
+
// editor package convention: 16x16 viewBox, currentColor, 1.5 stroke.
|
|
73
|
+
const EMBED_ICON =
|
|
74
|
+
'<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M6.4 6 4.4 8l2 2"/><path d="M9.6 6 11.6 8l-2 2"/></svg>';
|
|
75
|
+
|
|
76
|
+
// A document with an upward arrow. `slashCommands()` owns the shared menu's
|
|
77
|
+
// icon renderer; its completion metadata lets this picker use the same gutter.
|
|
78
|
+
const UPLOAD_ICON =
|
|
79
|
+
'<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5V13h10V8.5"/><path d="M8 11V3M5 6l3-3 3 3"/></svg>';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the static slash-menu item that hands completion to the `/embed` picker.
|
|
83
|
+
*
|
|
84
|
+
* @returns A slash command that rewrites the typed query to `/embed ` and
|
|
85
|
+
* immediately reopens completion.
|
|
86
|
+
*/
|
|
87
|
+
export function embedSlashItem(): SlashCommandItem {
|
|
88
|
+
return {
|
|
89
|
+
label: 'Embed HTML',
|
|
90
|
+
detail: 'live preview',
|
|
91
|
+
icon: EMBED_ICON,
|
|
92
|
+
apply: (view, completion, from, to) => {
|
|
93
|
+
view.dispatch({
|
|
94
|
+
changes: { from, to, insert: EMBED_PREFIX },
|
|
95
|
+
selection: { anchor: from + EMBED_PREFIX.length },
|
|
96
|
+
annotations: pickedCompletion.of(completion),
|
|
97
|
+
});
|
|
98
|
+
startCompletion(view);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the `/embed` target picker extension for one editor mount.
|
|
105
|
+
*
|
|
106
|
+
* The editor captures extensions once per document mount. Keep the returned
|
|
107
|
+
* extension stable and feed changing target data through the config callbacks.
|
|
108
|
+
*
|
|
109
|
+
* @param config - Host target, serialization, upload, and notice callbacks.
|
|
110
|
+
* @returns A CodeMirror extension that composes with `slashCommands()`.
|
|
111
|
+
*/
|
|
112
|
+
export function embedPicker(config: EmbedPickerConfig): Extension {
|
|
113
|
+
const uploadStateEffect = StateEffect.define<UploadStateEffect>();
|
|
114
|
+
const uploadStateField = StateField.define<UploadState>({
|
|
115
|
+
create: () => IDLE_UPLOAD_STATE,
|
|
116
|
+
update: (current, transaction) => {
|
|
117
|
+
let next = current;
|
|
118
|
+
if (next._tag === 'uploading' && next.anchor !== null && transaction.docChanged) {
|
|
119
|
+
const mappedFrom = transaction.changes.mapPos(next.anchor.from, 1, MapMode.TrackAfter);
|
|
120
|
+
const mappedTo = transaction.changes.mapPos(next.anchor.to, -1, MapMode.TrackBefore);
|
|
121
|
+
next = {
|
|
122
|
+
_tag: 'uploading',
|
|
123
|
+
anchor:
|
|
124
|
+
mappedFrom === null || mappedTo === null || mappedFrom > mappedTo
|
|
125
|
+
? null
|
|
126
|
+
: { ...next.anchor, from: mappedFrom, to: mappedTo },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const effect of transaction.effects) {
|
|
131
|
+
if (!effect.is(uploadStateEffect)) continue;
|
|
132
|
+
next =
|
|
133
|
+
effect.value._tag === 'start'
|
|
134
|
+
? { _tag: 'uploading', anchor: effect.value.anchor }
|
|
135
|
+
: IDLE_UPLOAD_STATE;
|
|
136
|
+
}
|
|
137
|
+
return next;
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const source = createEmbedPickerSource(config, uploadStateField, uploadStateEffect);
|
|
142
|
+
return [uploadStateField, EditorState.languageData.of(() => [{ autocomplete: source }])];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function createEmbedPickerSource(
|
|
146
|
+
config: EmbedPickerConfig,
|
|
147
|
+
uploadStateField: StateField<UploadState>,
|
|
148
|
+
uploadStateEffect: UploadStateEffectType,
|
|
149
|
+
): CompletionSource {
|
|
150
|
+
return (context) => {
|
|
151
|
+
const match = context.matchBefore(/\/embed +[^\n]*$/);
|
|
152
|
+
if (match === null) return null;
|
|
153
|
+
|
|
154
|
+
// Like the package slash source, the trigger must open its line. An embed
|
|
155
|
+
// is a block insertion and belongs in its own paragraph.
|
|
156
|
+
const line = context.state.doc.lineAt(match.from);
|
|
157
|
+
if (!/^\s*$/.test(line.text.slice(0, match.from - line.from))) return null;
|
|
158
|
+
|
|
159
|
+
const query = match.text.replace(/^\/embed +/, '');
|
|
160
|
+
const targets = config.getTargets().filter((target) => target.kind === HTML_KIND);
|
|
161
|
+
const matches = filterTargets(targets, query);
|
|
162
|
+
const options: IconCompletion[] = [];
|
|
163
|
+
const notice = config.getNotice?.(context.state.doc.toString()) ?? null;
|
|
164
|
+
if (notice !== null) {
|
|
165
|
+
options.push({
|
|
166
|
+
label: notice,
|
|
167
|
+
apply: () => {
|
|
168
|
+
// Informational row: selection closes the menu and leaves the typed
|
|
169
|
+
// command in place so the author can keep deciding.
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (matches.length === 0) {
|
|
175
|
+
options.push({
|
|
176
|
+
label:
|
|
177
|
+
targets.length === 0
|
|
178
|
+
? 'No HTML files in this workspace'
|
|
179
|
+
: `No HTML files match “${query.trim()}”`,
|
|
180
|
+
apply: (view, completion, _from, to) => {
|
|
181
|
+
view.dispatch({
|
|
182
|
+
changes: { from: line.from, to, insert: '' },
|
|
183
|
+
annotations: pickedCompletion.of(completion),
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
for (const target of matches) {
|
|
189
|
+
options.push({
|
|
190
|
+
label: targetLabel(target),
|
|
191
|
+
detail: target.path,
|
|
192
|
+
apply: (view, completion, from, to) => {
|
|
193
|
+
applyTargetInsert(view, completion, from, to, target, config.buildInsertLine);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const uploadTarget = config.uploadTarget;
|
|
200
|
+
if (uploadTarget !== undefined) {
|
|
201
|
+
const uploadState = context.state.field(uploadStateField);
|
|
202
|
+
options.push(
|
|
203
|
+
uploadState._tag === 'uploading'
|
|
204
|
+
? {
|
|
205
|
+
label: 'Uploading...',
|
|
206
|
+
slashCommandIcon: UPLOAD_ICON,
|
|
207
|
+
apply: () => {
|
|
208
|
+
// Inert while the existing host callback is still pending.
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
: {
|
|
212
|
+
label: 'Upload HTML...',
|
|
213
|
+
slashCommandIcon: UPLOAD_ICON,
|
|
214
|
+
apply: (view, completion, from, to) => {
|
|
215
|
+
startUpload(
|
|
216
|
+
view,
|
|
217
|
+
completion,
|
|
218
|
+
from,
|
|
219
|
+
to,
|
|
220
|
+
config,
|
|
221
|
+
uploadTarget,
|
|
222
|
+
uploadStateField,
|
|
223
|
+
uploadStateEffect,
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
// The entire `/embed query` is the completion range. Our case-insensitive
|
|
232
|
+
// substring matcher owns filtering because CM's fuzzy matcher closes a
|
|
233
|
+
// completion session when a title query contains spaces.
|
|
234
|
+
from: match.from,
|
|
235
|
+
options,
|
|
236
|
+
filter: false,
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function filterTargets(targets: readonly EmbedTarget[], query: string): readonly EmbedTarget[] {
|
|
242
|
+
const needle = query.trim().toLowerCase();
|
|
243
|
+
if (needle === '') return targets;
|
|
244
|
+
return targets.filter((target) => {
|
|
245
|
+
const title = (target.title ?? '').toLowerCase();
|
|
246
|
+
return title.includes(needle) || target.path.toLowerCase().includes(needle);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function targetLabel(target: EmbedTarget): string {
|
|
251
|
+
const title = target.title ?? '';
|
|
252
|
+
return title.trim() === '' ? target.path : title;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function applyTargetInsert(
|
|
256
|
+
view: EditorView,
|
|
257
|
+
completion: Completion,
|
|
258
|
+
from: number,
|
|
259
|
+
to: number,
|
|
260
|
+
target: EmbedTarget,
|
|
261
|
+
buildInsertLine: EmbedPickerConfig['buildInsertLine'],
|
|
262
|
+
): void {
|
|
263
|
+
const body = view.state.doc.toString();
|
|
264
|
+
const plan = planEmbedInsert(body, from, to, buildInsertLine(target));
|
|
265
|
+
view.dispatch({
|
|
266
|
+
changes: { from: plan.from, to: plan.to, insert: plan.insert },
|
|
267
|
+
selection: { anchor: plan.cursor },
|
|
268
|
+
scrollIntoView: true,
|
|
269
|
+
annotations: pickedCompletion.of(completion),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function startUpload(
|
|
274
|
+
view: EditorView,
|
|
275
|
+
completion: Completion,
|
|
276
|
+
from: number,
|
|
277
|
+
to: number,
|
|
278
|
+
config: EmbedPickerConfig,
|
|
279
|
+
uploadTarget: NonNullable<EmbedPickerConfig['uploadTarget']>,
|
|
280
|
+
uploadStateField: StateField<UploadState>,
|
|
281
|
+
uploadStateEffect: UploadStateEffectType,
|
|
282
|
+
): void {
|
|
283
|
+
const uploadState = view.state.field(uploadStateField, false);
|
|
284
|
+
if (uploadState?._tag === 'uploading') return;
|
|
285
|
+
|
|
286
|
+
const anchor: UploadAnchor = {
|
|
287
|
+
from,
|
|
288
|
+
to,
|
|
289
|
+
text: view.state.doc.sliceString(from, to),
|
|
290
|
+
};
|
|
291
|
+
view.dispatch({
|
|
292
|
+
effects: uploadStateEffect.of({ _tag: 'start', anchor }),
|
|
293
|
+
annotations: pickedCompletion.of(completion),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
void (async () => {
|
|
297
|
+
try {
|
|
298
|
+
const target = await uploadTarget(HTML_KIND);
|
|
299
|
+
finishUpload(view, completion, target, config, uploadStateField, uploadStateEffect);
|
|
300
|
+
} catch {
|
|
301
|
+
clearUpload(view, uploadStateField, uploadStateEffect);
|
|
302
|
+
}
|
|
303
|
+
})();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function finishUpload(
|
|
307
|
+
view: EditorView,
|
|
308
|
+
completion: Completion,
|
|
309
|
+
target: EmbedTarget | null,
|
|
310
|
+
config: EmbedPickerConfig,
|
|
311
|
+
uploadStateField: StateField<UploadState>,
|
|
312
|
+
uploadStateEffect: UploadStateEffectType,
|
|
313
|
+
): void {
|
|
314
|
+
const uploadState = view.state.field(uploadStateField, false);
|
|
315
|
+
if (uploadState?._tag !== 'uploading') return;
|
|
316
|
+
|
|
317
|
+
const anchor = uploadState.anchor;
|
|
318
|
+
if (target === null || anchor === null || !isLiveAnchor(view, anchor)) {
|
|
319
|
+
clearUpload(view, uploadStateField, uploadStateEffect);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const body = view.state.doc.toString();
|
|
324
|
+
const plan = planEmbedInsert(body, anchor.from, anchor.to, config.buildInsertLine(target));
|
|
325
|
+
view.dispatch({
|
|
326
|
+
changes: { from: plan.from, to: plan.to, insert: plan.insert },
|
|
327
|
+
selection: { anchor: plan.cursor },
|
|
328
|
+
scrollIntoView: true,
|
|
329
|
+
effects: uploadStateEffect.of({ _tag: 'finish' }),
|
|
330
|
+
annotations: pickedCompletion.of(completion),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isLiveAnchor(view: EditorView, anchor: UploadAnchor): boolean {
|
|
335
|
+
if (anchor.from > anchor.to || anchor.to > view.state.doc.length) return false;
|
|
336
|
+
if (view.state.doc.sliceString(anchor.from, anchor.to) !== anchor.text) return false;
|
|
337
|
+
const line = view.state.doc.lineAt(anchor.from);
|
|
338
|
+
return /^\s*$/.test(line.text.slice(0, anchor.from - line.from));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function clearUpload(
|
|
342
|
+
view: EditorView,
|
|
343
|
+
uploadStateField: StateField<UploadState>,
|
|
344
|
+
uploadStateEffect: UploadStateEffectType,
|
|
345
|
+
): void {
|
|
346
|
+
const uploadState = view.state.field(uploadStateField, false);
|
|
347
|
+
if (uploadState?._tag !== 'uploading') return;
|
|
348
|
+
view.dispatch({ effects: uploadStateEffect.of({ _tag: 'finish' }) });
|
|
349
|
+
}
|
|
@@ -34,6 +34,18 @@ export type { SlashCommandItem, SlashCommandsConfig } from '@plannotator/atomic-
|
|
|
34
34
|
export { selectionToolbar } from '@plannotator/atomic-editor';
|
|
35
35
|
export type { SelectionToolbarConfig, InlineFormat } from '@plannotator/atomic-editor';
|
|
36
36
|
|
|
37
|
+
/* Host-configured embed media authoring. The picker is per editor mount so its
|
|
38
|
+
callbacks can close over live route state; nothing enters configurePlannotatorUI.
|
|
39
|
+
The package owns paragraph-safe splicing while the host owns embed grammar. */
|
|
40
|
+
export { embedPicker, embedSlashItem } from './MarkdownEditor/embedPicker';
|
|
41
|
+
export type {
|
|
42
|
+
EmbedKind,
|
|
43
|
+
EmbedPickerConfig,
|
|
44
|
+
EmbedTarget,
|
|
45
|
+
} from './MarkdownEditor/embedPicker';
|
|
46
|
+
export { planEmbedInsert } from '@plannotator/core/embed-insert';
|
|
47
|
+
export type { EmbedInsertPlan } from '@plannotator/core/embed-insert';
|
|
48
|
+
|
|
37
49
|
/* Grid-mode card utilities stay here (not in the package): they're Plannotator
|
|
38
50
|
design-system Tailwind classes, and this file is @source-scanned. */
|
|
39
51
|
const GRID_CARD_CLASSES = 'px-5 md:px-8 lg:px-10 xl:px-12 shadow-xl border border-border/50';
|
|
@@ -1,33 +1,26 @@
|
|
|
1
1
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
|
-
import
|
|
3
|
+
import type { Mermaid } from 'mermaid';
|
|
4
4
|
import type { Block } from '../types';
|
|
5
5
|
import { normalizeMermaidSvgMarkup } from './mermaidSvg';
|
|
6
|
+
import {
|
|
7
|
+
MERMAID_CONFIG,
|
|
8
|
+
getMermaidRetryDelayMs,
|
|
9
|
+
loadMermaidRuntime,
|
|
10
|
+
__setMermaidRuntimeLoaderForTests,
|
|
11
|
+
} from '../utils/mermaid';
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
background: '#1e293b',
|
|
19
|
-
mainBkg: '#1e293b',
|
|
20
|
-
nodeBorder: '#475569',
|
|
21
|
-
clusterBkg: '#1e293b',
|
|
22
|
-
clusterBorder: '#475569',
|
|
23
|
-
titleColor: '#f8fafc',
|
|
24
|
-
edgeLabelBackground: '#1e293b',
|
|
25
|
-
},
|
|
26
|
-
flowchart: {
|
|
27
|
-
htmlLabels: true,
|
|
28
|
-
curve: 'basis',
|
|
29
|
-
},
|
|
30
|
-
});
|
|
13
|
+
// Re-exported: the config pin test and the lazy-retry test import them from here.
|
|
14
|
+
export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The runtime comes from the slot in utils/mermaid: filled eagerly by
|
|
18
|
+
* Plannotator (utils/mermaid-eager, imported by the editor App), loaded
|
|
19
|
+
* lazily otherwise. See that module for the retry contract.
|
|
20
|
+
*/
|
|
21
|
+
const getMermaid = loadMermaidRuntime;
|
|
22
|
+
|
|
23
|
+
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
31
24
|
|
|
32
25
|
interface ViewBox {
|
|
33
26
|
x: number;
|
|
@@ -133,6 +126,11 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
133
126
|
const expandedOverlayRef = useRef<HTMLDivElement>(null);
|
|
134
127
|
const [svg, setSvg] = useState('');
|
|
135
128
|
const [error, setError] = useState<string | null>(null);
|
|
129
|
+
// True when the failure was the runtime import itself (a chunking host's
|
|
130
|
+
// fetch), which is the only failure a Retry can change; a diagram syntax
|
|
131
|
+
// error keeps the panel exactly as it always was.
|
|
132
|
+
const [runtimeUnavailable, setRuntimeUnavailable] = useState(false);
|
|
133
|
+
const [retryToken, setRetryToken] = useState(0);
|
|
136
134
|
const [showSource, setShowSource] = useState(false);
|
|
137
135
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
138
136
|
|
|
@@ -188,6 +186,27 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
188
186
|
|
|
189
187
|
// Render mermaid diagram
|
|
190
188
|
const renderDiagram = async () => {
|
|
189
|
+
let mermaid: Mermaid;
|
|
190
|
+
try {
|
|
191
|
+
try {
|
|
192
|
+
mermaid = await getMermaid();
|
|
193
|
+
} catch {
|
|
194
|
+
// Transient chunk failure on a chunking host: one automatic
|
|
195
|
+
// re-attempt with a fresh import() after a short delay. In a
|
|
196
|
+
// single-file build the first await never rejects, so this branch
|
|
197
|
+
// is unreachable there and the success path is unchanged.
|
|
198
|
+
await wait(getMermaidRetryDelayMs());
|
|
199
|
+
if (cancelled) return;
|
|
200
|
+
mermaid = await getMermaid();
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (!cancelled) {
|
|
204
|
+
setError(err instanceof Error ? err.message : 'Failed to render diagram');
|
|
205
|
+
setRuntimeUnavailable(true);
|
|
206
|
+
setSvg('');
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
191
210
|
try {
|
|
192
211
|
const id = `mermaid-${block.id}`;
|
|
193
212
|
const { svg: renderedSvg } = await mermaid.render(id, block.content);
|
|
@@ -196,10 +215,12 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
196
215
|
naturalBoundsRef.current = parseViewBoxFromMarkup(normalizedSvg);
|
|
197
216
|
setSvg(normalizedSvg);
|
|
198
217
|
setError(null);
|
|
218
|
+
setRuntimeUnavailable(false);
|
|
199
219
|
}
|
|
200
220
|
} catch (err) {
|
|
201
221
|
if (!cancelled) {
|
|
202
222
|
setError(err instanceof Error ? err.message : 'Failed to render diagram');
|
|
223
|
+
setRuntimeUnavailable(false);
|
|
203
224
|
setSvg('');
|
|
204
225
|
}
|
|
205
226
|
}
|
|
@@ -210,7 +231,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
210
231
|
return () => {
|
|
211
232
|
cancelled = true;
|
|
212
233
|
};
|
|
213
|
-
}, [block.content, block.id]);
|
|
234
|
+
}, [block.content, block.id, retryToken]);
|
|
214
235
|
|
|
215
236
|
// Reset zoom and pan when content changes
|
|
216
237
|
useEffect(() => {
|
|
@@ -419,6 +440,19 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
419
440
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
420
441
|
</svg>
|
|
421
442
|
<span className="text-xs text-destructive font-medium">Mermaid Error</span>
|
|
443
|
+
{runtimeUnavailable && (
|
|
444
|
+
<button
|
|
445
|
+
type="button"
|
|
446
|
+
onClick={() => {
|
|
447
|
+
setError(null);
|
|
448
|
+
setRetryToken((token) => token + 1);
|
|
449
|
+
}}
|
|
450
|
+
className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
|
|
451
|
+
title="Retry loading the diagram renderer"
|
|
452
|
+
>
|
|
453
|
+
Retry
|
|
454
|
+
</button>
|
|
455
|
+
)}
|
|
422
456
|
</div>
|
|
423
457
|
<pre className="p-3 text-xs text-destructive/80 overflow-x-auto">{error}</pre>
|
|
424
458
|
<pre className="p-3 text-xs text-muted-foreground bg-muted/30 border-t border-border/30 overflow-x-auto">
|
|
@@ -21,8 +21,9 @@ export function ModeToggle() {
|
|
|
21
21
|
<div className="relative" ref={dropdownRef}>
|
|
22
22
|
<button
|
|
23
23
|
onClick={() => setIsOpen(!isOpen)}
|
|
24
|
-
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
24
|
+
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors relative pointer-coarse:before:absolute pointer-coarse:before:-inset-2 pointer-coarse:before:content-['']"
|
|
25
25
|
title="Toggle theme"
|
|
26
|
+
aria-label="Toggle theme"
|
|
26
27
|
>
|
|
27
28
|
{/* Sun */}
|
|
28
29
|
<svg
|
|
@@ -25,10 +25,29 @@ export const PermissionModeSetup: React.FC<PermissionModeSetupProps> = ({
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
return createPortal(
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// Same bounded shell as the sibling one-time dialogs (see
|
|
29
|
+
// LookAndFeelAnnouncementDialog): safe-area padding, a card capped to the
|
|
30
|
+
// visible viewport, and the option list as the only scrolling region. The
|
|
31
|
+
// hand-rolled unbounded card used to overflow both edges of a short
|
|
32
|
+
// landscape phone with nothing left to tap.
|
|
33
|
+
<div
|
|
34
|
+
className="fixed inset-0 z-[100] flex items-center justify-center overflow-y-auto bg-background/90 backdrop-blur-sm"
|
|
35
|
+
style={{
|
|
36
|
+
paddingTop: 'max(1rem, var(--pn-safe-top, 0px))',
|
|
37
|
+
paddingRight: 'max(1rem, var(--pn-safe-right, 0px))',
|
|
38
|
+
paddingBottom: 'max(1rem, var(--pn-safe-bottom, 0px))',
|
|
39
|
+
paddingLeft: 'max(1rem, var(--pn-safe-left, 0px))',
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<div
|
|
43
|
+
className="bg-card border border-border rounded-xl w-full max-w-lg shadow-2xl flex flex-col overflow-hidden"
|
|
44
|
+
style={{
|
|
45
|
+
maxHeight:
|
|
46
|
+
'calc(var(--pn-viewport-height, 100vh) - max(1rem, var(--pn-safe-top, 0px)) - max(1rem, var(--pn-safe-bottom, 0px)))',
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
30
49
|
{/* Header */}
|
|
31
|
-
<div className="p-5 border-b border-border">
|
|
50
|
+
<div className="p-5 border-b border-border flex-shrink-0">
|
|
32
51
|
<div className="flex items-center gap-2 mb-2">
|
|
33
52
|
<div className="p-1.5 rounded-lg bg-primary/15">
|
|
34
53
|
<svg className="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
@@ -47,7 +66,7 @@ export const PermissionModeSetup: React.FC<PermissionModeSetupProps> = ({
|
|
|
47
66
|
</div>
|
|
48
67
|
|
|
49
68
|
{/* Options */}
|
|
50
|
-
<div className="p-4 space-y-2">
|
|
69
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-4 space-y-2">
|
|
51
70
|
{PERMISSION_MODE_OPTIONS.map((option) => (
|
|
52
71
|
<label
|
|
53
72
|
key={option.value}
|
|
@@ -74,7 +93,7 @@ export const PermissionModeSetup: React.FC<PermissionModeSetupProps> = ({
|
|
|
74
93
|
</div>
|
|
75
94
|
|
|
76
95
|
{/* Footer */}
|
|
77
|
-
<div className="p-4 border-t border-border flex justify-between items-center">
|
|
96
|
+
<div className="p-4 border-t border-border flex flex-shrink-0 flex-wrap gap-2 justify-between items-center">
|
|
78
97
|
<p className="text-xs text-muted-foreground">
|
|
79
98
|
You can change this later in Settings.
|
|
80
99
|
</p>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import React, { useLayoutEffect, useState, useRef } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
addScrollViewportListener,
|
|
4
|
+
useScrollViewport,
|
|
5
|
+
} from '../hooks/useScrollViewport';
|
|
3
6
|
|
|
4
7
|
interface PinpointOverlayProps {
|
|
5
8
|
target: { element: HTMLElement; label: string } | null;
|
|
@@ -49,15 +52,17 @@ export const PinpointOverlay: React.FC<PinpointOverlayProps> = ({ target, contai
|
|
|
49
52
|
// delivers its viewport.
|
|
50
53
|
window.addEventListener('resize', handleUpdate, { passive: true });
|
|
51
54
|
|
|
52
|
-
// The
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
55
|
+
// The shared viewport is the nested main element on desktop and the page
|
|
56
|
+
// scroller on compact touch layouts. The helper binds the latter to the
|
|
57
|
+
// window scroll event so the overlay does not drift as Safari chrome moves.
|
|
58
|
+
const removeScrollListener = scrollViewport
|
|
59
|
+
? addScrollViewportListener(scrollViewport, handleUpdate)
|
|
60
|
+
: undefined;
|
|
56
61
|
|
|
57
62
|
return () => {
|
|
58
63
|
cancelAnimationFrame(rafRef.current);
|
|
59
64
|
window.removeEventListener('resize', handleUpdate);
|
|
60
|
-
|
|
65
|
+
removeScrollListener?.();
|
|
61
66
|
};
|
|
62
67
|
}, [target, containerRef, scrollViewport]);
|
|
63
68
|
|