@plannotator/ui 0.29.1 → 0.31.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 +1 -0
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +68 -35
- package/components/AnnotationToolbar.tsx +28 -8
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +212 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/InlineMarkdown.tsx +3 -2
- 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/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 +136 -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/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +215 -39
- package/components/html-viewer/bridge-script.ts +384 -66
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/useHtmlAnnotation.ts +179 -58
- 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/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/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +3 -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 +34 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/htmlChrome.ts +20 -16
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/parser.ts +37 -2
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -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';
|
|
@@ -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
|
|
|
@@ -34,6 +34,17 @@ interface PlanHeaderMenuProps {
|
|
|
34
34
|
obsidianConfigured: boolean;
|
|
35
35
|
bearConfigured: boolean;
|
|
36
36
|
octarineConfigured: boolean;
|
|
37
|
+
compactTouchLayout?: boolean;
|
|
38
|
+
compactSessionActions?: CompactPlanAction[];
|
|
39
|
+
compactDocumentActions?: CompactPlanAction[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CompactPlanAction {
|
|
43
|
+
id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'annotations' | 'ai' | 'review';
|
|
44
|
+
label: string;
|
|
45
|
+
subtitle?: string;
|
|
46
|
+
onSelect: () => void;
|
|
47
|
+
disabled?: boolean;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
|
|
@@ -57,6 +68,9 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
|
|
|
57
68
|
obsidianConfigured,
|
|
58
69
|
bearConfigured,
|
|
59
70
|
octarineConfigured,
|
|
71
|
+
compactTouchLayout = false,
|
|
72
|
+
compactSessionActions = [],
|
|
73
|
+
compactDocumentActions = [],
|
|
60
74
|
}) => {
|
|
61
75
|
const { theme, setTheme } = useTheme();
|
|
62
76
|
|
|
@@ -68,13 +82,20 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
|
|
|
68
82
|
return (
|
|
69
83
|
<ActionMenu
|
|
70
84
|
panelWidth="wide"
|
|
85
|
+
panelClassName={compactTouchLayout
|
|
86
|
+
? 'absolute top-full right-0 mt-1 w-[min(18rem,calc(100vw-1rem))] max-h-[calc(var(--pn-viewport-height,100vh)-4.5rem-var(--pn-safe-top)-var(--pn-safe-bottom))] overflow-y-auto rounded-lg border border-border bg-popover py-1 shadow-xl z-[70]'
|
|
87
|
+
: undefined
|
|
88
|
+
}
|
|
71
89
|
renderTrigger={({ isOpen, toggleMenu }) => (
|
|
72
90
|
<button
|
|
91
|
+
id={compactTouchLayout ? 'pn-compact-plan-options-trigger' : undefined}
|
|
92
|
+
data-pn-touch-target={compactTouchLayout || undefined}
|
|
93
|
+
data-pn-touch-target-icon={compactTouchLayout || undefined}
|
|
73
94
|
onClick={() => {
|
|
74
95
|
if (!isOpen && showUpdateDot) updateInfo?.dismiss();
|
|
75
96
|
toggleMenu();
|
|
76
97
|
}}
|
|
77
|
-
className={`relative flex items-center gap-1.5 p-1.5 md:px-2.5 md:py-1
|
|
98
|
+
className={`relative flex items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors ${compactTouchLayout ? 'h-11 w-11 p-0' : 'p-1.5 md:px-2.5 md:py-1'} ${
|
|
78
99
|
isOpen
|
|
79
100
|
? 'bg-muted text-foreground'
|
|
80
101
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
@@ -99,11 +120,34 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
|
|
|
99
120
|
>
|
|
100
121
|
{({ closeMenu }) => (
|
|
101
122
|
<>
|
|
123
|
+
{compactTouchLayout && compactSessionActions.length > 0 && (
|
|
124
|
+
<>
|
|
125
|
+
<CompactActionSection
|
|
126
|
+
label="Review"
|
|
127
|
+
actions={compactSessionActions}
|
|
128
|
+
closeMenu={closeMenu}
|
|
129
|
+
/>
|
|
130
|
+
<ActionMenuDivider />
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{compactTouchLayout && compactDocumentActions.length > 0 && (
|
|
135
|
+
<>
|
|
136
|
+
<CompactActionSection
|
|
137
|
+
label="Document"
|
|
138
|
+
actions={compactDocumentActions}
|
|
139
|
+
closeMenu={closeMenu}
|
|
140
|
+
/>
|
|
141
|
+
<ActionMenuDivider />
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
144
|
+
|
|
102
145
|
<div className="px-3 py-2 space-y-1.5">
|
|
103
146
|
<ActionMenuSectionLabel>Theme</ActionMenuSectionLabel>
|
|
104
147
|
<div className="flex items-center gap-1 rounded-lg bg-muted/50 p-0.5">
|
|
105
148
|
{THEME_MODES.map(({ id, label, Icon }) => (
|
|
106
149
|
<button
|
|
150
|
+
data-pn-touch-target={compactTouchLayout || undefined}
|
|
107
151
|
key={id}
|
|
108
152
|
onClick={() => {
|
|
109
153
|
closeMenu();
|
|
@@ -243,6 +287,101 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
|
|
|
243
287
|
);
|
|
244
288
|
};
|
|
245
289
|
|
|
290
|
+
const CompactActionSection = ({
|
|
291
|
+
label,
|
|
292
|
+
actions,
|
|
293
|
+
closeMenu,
|
|
294
|
+
}: {
|
|
295
|
+
label: string;
|
|
296
|
+
actions: CompactPlanAction[];
|
|
297
|
+
closeMenu: () => void;
|
|
298
|
+
}) => (
|
|
299
|
+
<div className="py-1">
|
|
300
|
+
<div className="px-3 py-1">
|
|
301
|
+
<ActionMenuSectionLabel>{label}</ActionMenuSectionLabel>
|
|
302
|
+
</div>
|
|
303
|
+
{actions.map((action) => (
|
|
304
|
+
<ActionMenuItem
|
|
305
|
+
key={action.id}
|
|
306
|
+
onClick={() => {
|
|
307
|
+
closeMenu();
|
|
308
|
+
action.onSelect();
|
|
309
|
+
}}
|
|
310
|
+
disabled={action.disabled}
|
|
311
|
+
icon={<CompactPlanActionIcon kind={action.id} />}
|
|
312
|
+
label={action.label}
|
|
313
|
+
subtitle={action.subtitle}
|
|
314
|
+
/>
|
|
315
|
+
))}
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const CompactPlanActionIcon = ({ kind }: { kind: CompactPlanAction['id'] }) => {
|
|
320
|
+
if (kind === 'review') {
|
|
321
|
+
return (
|
|
322
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
323
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 11l2 2 4-4m6 3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
324
|
+
</svg>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (kind === 'annotations') {
|
|
328
|
+
return (
|
|
329
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
330
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
|
331
|
+
</svg>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (kind === 'ai') {
|
|
335
|
+
return (
|
|
336
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
|
337
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3l1.2 3.8L17 8l-3.8 1.2L12 13l-1.2-3.8L7 8l3.8-1.2L12 3zM18.5 13l.8 2.7L22 16.5l-2.7.8-.8 2.7-.8-2.7-2.7-.8 2.7-.8.8-2.7zM5 13l.7 2.3L8 16l-2.3.7L5 19l-.7-2.3L2 16l2.3-.7L5 13z" />
|
|
338
|
+
</svg>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (kind === 'approve' || kind === 'done') {
|
|
342
|
+
return (
|
|
343
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
344
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
345
|
+
</svg>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
if (kind === 'feedback') {
|
|
349
|
+
return (
|
|
350
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
351
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
|
352
|
+
</svg>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (kind === 'copy') {
|
|
356
|
+
return (
|
|
357
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
358
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
359
|
+
</svg>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (kind === 'edit' || kind === 'annotate') {
|
|
363
|
+
return (
|
|
364
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
365
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
366
|
+
</svg>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
if (kind === 'tools') {
|
|
370
|
+
// Eye, matching the desktop header's show/hide-tools toggle.
|
|
371
|
+
return (
|
|
372
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
373
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
|
374
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
375
|
+
</svg>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
return (
|
|
379
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
380
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
381
|
+
</svg>
|
|
382
|
+
);
|
|
383
|
+
};
|
|
384
|
+
|
|
246
385
|
const MenuIcon = () => (
|
|
247
386
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
248
387
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
|
@@ -107,6 +107,7 @@ export function SearchableSelect<T extends { id: string }>({
|
|
|
107
107
|
/>
|
|
108
108
|
</svg>
|
|
109
109
|
<input
|
|
110
|
+
data-pn-mobile-editable
|
|
110
111
|
ref={inputRef}
|
|
111
112
|
value={search}
|
|
112
113
|
onChange={(e) => {
|
|
@@ -128,6 +129,7 @@ export function SearchableSelect<T extends { id: string }>({
|
|
|
128
129
|
) : (
|
|
129
130
|
filtered.map((item, i) => (
|
|
130
131
|
<button
|
|
132
|
+
data-pn-touch-target
|
|
131
133
|
key={item.id}
|
|
132
134
|
type="button"
|
|
133
135
|
data-index={i}
|