@plannotator/ui 0.34.0 → 0.35.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/HANDOFF.md +1 -0
- package/README.md +8 -0
- package/components/AnnotationPanel.tsx +37 -36
- package/components/AnnotationToolstrip.tsx +25 -15
- package/components/GraphvizBlock.tsx +15 -4
- package/components/HtmlSurfaceControls.tsx +16 -7
- package/components/ImageAnnotator/Toolbar.tsx +27 -0
- package/components/ImageAnnotator/index.tsx +51 -59
- package/components/ImageAnnotator/strokeHistory.ts +58 -0
- package/components/MermaidBlock.tsx +15 -4
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/Settings.tsx +16 -1
- package/components/StickyHeaderLane.tsx +4 -0
- package/components/html-viewer/useHtmlAnnotation.ts +20 -11
- package/config/index.ts +3 -0
- package/config/reviewView.ts +37 -0
- package/config/settings.ts +15 -0
- package/hooks/useCodeAnnotationDraft.ts +19 -2
- package/hooks/useHtmlRefresh.ts +19 -10
- package/hooks/useSharing.ts +57 -8
- package/hooks/useUndoHistory.ts +83 -0
- package/package.json +2 -2
- package/shortcuts/history.shortcuts.ts +25 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +10 -1
- package/utils/parser.ts +41 -14
- package/utils/runtimeRetry.ts +31 -0
- package/utils/undoHistory.ts +167 -0
- package/webmcp/changes.ts +24 -0
- package/webmcp/nudges.ts +22 -7
package/components/Settings.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
|
|
3
3
|
import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal';
|
|
4
4
|
import type { Origin } from '@plannotator/core/agents';
|
|
5
5
|
import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
|
|
6
|
-
import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config';
|
|
6
|
+
import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config';
|
|
7
7
|
import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
|
|
8
8
|
import { loadDiffFont } from '../utils/diffFonts';
|
|
9
9
|
import { TaterSpritePullup } from './TaterSpritePullup';
|
|
@@ -375,8 +375,23 @@ function ReviewAnalysisTab() {
|
|
|
375
375
|
const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => {
|
|
376
376
|
const defaultDiffType = useConfigValue('defaultDiffType');
|
|
377
377
|
const reviewPanelView = useConfigValue('reviewPanelView');
|
|
378
|
+
const reviewAutoViewed = useConfigValue('reviewAutoViewed');
|
|
378
379
|
return (
|
|
379
380
|
<div className="space-y-5">
|
|
381
|
+
<div className="space-y-2">
|
|
382
|
+
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
383
|
+
Viewed files
|
|
384
|
+
</div>
|
|
385
|
+
{/* Never write `reviewAutoViewed` directly — setReviewAutoViewed also
|
|
386
|
+
consumes the first-time notice, since an explicit toggle is proof
|
|
387
|
+
the reviewer already found the switch. */}
|
|
388
|
+
<ToggleSwitch
|
|
389
|
+
checked={reviewAutoViewed}
|
|
390
|
+
onChange={(v) => setReviewAutoViewed(v)}
|
|
391
|
+
label="Auto-mark viewed"
|
|
392
|
+
description="Mark a file viewed when you scroll past it or move on to another file. Files you un-view stay un-viewed, and files that change on refresh become un-viewed."
|
|
393
|
+
/>
|
|
394
|
+
</div>
|
|
380
395
|
<div className="space-y-2">
|
|
381
396
|
<div>
|
|
382
397
|
<div className="text-sm font-medium">Default review view</div>
|
|
@@ -65,6 +65,8 @@ interface StickyHeaderLaneProps {
|
|
|
65
65
|
mode: EditorMode;
|
|
66
66
|
onModeChange: (mode: EditorMode) => void;
|
|
67
67
|
taterMode?: boolean;
|
|
68
|
+
/** Omit the Quick Label tool in the compact toolstrip (mirrors AnnotationToolstripProps.hideQuickLabel). */
|
|
69
|
+
hideQuickLabel?: boolean;
|
|
68
70
|
|
|
69
71
|
// Badge state
|
|
70
72
|
repoInfo?: { display: string; branch?: string } | null;
|
|
@@ -95,6 +97,7 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
|
|
|
95
97
|
mode,
|
|
96
98
|
onModeChange,
|
|
97
99
|
taterMode,
|
|
100
|
+
hideQuickLabel,
|
|
98
101
|
repoInfo,
|
|
99
102
|
planDiffStats,
|
|
100
103
|
isPlanDiffActive,
|
|
@@ -252,6 +255,7 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
|
|
|
252
255
|
mode={mode}
|
|
253
256
|
onModeChange={onModeChange}
|
|
254
257
|
taterMode={taterMode}
|
|
258
|
+
hideQuickLabel={hideQuickLabel}
|
|
255
259
|
compact
|
|
256
260
|
iconOnly={isNarrow || isToolstripIconOnly}
|
|
257
261
|
/>
|
|
@@ -55,12 +55,12 @@ function nextHtmlAnnId(): string {
|
|
|
55
55
|
return `html-ann-${Date.now().toString(36)}-${(htmlAnnSeq++).toString(36)}`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
// Ids minted for locally created annotations (create-mark) live per hook
|
|
59
|
+
// instance (mintedIdsRef below), not per module: the unanchored union only
|
|
60
|
+
// consults them against the bridge of the instance that minted them (a
|
|
61
|
+
// remounted viewer restores from the host's list and never reports an id it
|
|
62
|
+
// was not asked for), and a module-wide set leaked every id ever minted into
|
|
63
|
+
// unrelated later instances of a long-lived host page.
|
|
64
64
|
|
|
65
65
|
function htmlCommentDraftKey(
|
|
66
66
|
text: string,
|
|
@@ -463,6 +463,15 @@ export function useHtmlAnnotation({
|
|
|
463
463
|
enabledRef.current = enabled;
|
|
464
464
|
const modeRef = useRef(mode);
|
|
465
465
|
modeRef.current = mode;
|
|
466
|
+
// Ids this instance minted (see the module comment above nextHtmlAnnId);
|
|
467
|
+
// released on unmount so nothing outlives the viewer that created it.
|
|
468
|
+
const mintedIdsRef = useRef<Set<string>>(new Set());
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
const minted = mintedIdsRef.current;
|
|
471
|
+
return () => {
|
|
472
|
+
minted.clear();
|
|
473
|
+
};
|
|
474
|
+
}, []);
|
|
466
475
|
// Mirror toolbar visibility into a ref so the (stable) message handler can gate
|
|
467
476
|
// type-to-comment on "the markup toolbar is showing", like AnnotationToolbar does.
|
|
468
477
|
const toolbarStateRef = useRef(toolbarState);
|
|
@@ -805,7 +814,7 @@ export function useHtmlAnnotation({
|
|
|
805
814
|
if (!text || type !== AnnotationType.DELETION) return;
|
|
806
815
|
|
|
807
816
|
const id = nextHtmlAnnId();
|
|
808
|
-
|
|
817
|
+
mintedIdsRef.current.add(id);
|
|
809
818
|
post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
|
|
810
819
|
onAddRef.current?.({
|
|
811
820
|
id,
|
|
@@ -864,7 +873,7 @@ export function useHtmlAnnotation({
|
|
|
864
873
|
: undefined;
|
|
865
874
|
|
|
866
875
|
const id = nextHtmlAnnId();
|
|
867
|
-
|
|
876
|
+
mintedIdsRef.current.add(id);
|
|
868
877
|
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
869
878
|
onAddRef.current?.({
|
|
870
879
|
id,
|
|
@@ -910,7 +919,7 @@ export function useHtmlAnnotation({
|
|
|
910
919
|
: undefined;
|
|
911
920
|
|
|
912
921
|
const id = nextHtmlAnnId();
|
|
913
|
-
|
|
922
|
+
mintedIdsRef.current.add(id);
|
|
914
923
|
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
915
924
|
onAddRef.current?.({
|
|
916
925
|
id,
|
|
@@ -970,7 +979,7 @@ export function useHtmlAnnotation({
|
|
|
970
979
|
const text = pendingTextRef.current;
|
|
971
980
|
if (!text) return;
|
|
972
981
|
const id = nextHtmlAnnId();
|
|
973
|
-
|
|
982
|
+
mintedIdsRef.current.add(id);
|
|
974
983
|
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
975
984
|
onAddRef.current?.({
|
|
976
985
|
id,
|
|
@@ -1067,6 +1076,6 @@ export function useHtmlAnnotation({
|
|
|
1067
1076
|
removeDraftTarget,
|
|
1068
1077
|
flashDraftTarget,
|
|
1069
1078
|
composerFocusToken,
|
|
1070
|
-
createdAnnotationIds:
|
|
1079
|
+
createdAnnotationIds: mintedIdsRef.current,
|
|
1071
1080
|
};
|
|
1072
1081
|
}
|
package/config/index.ts
CHANGED
package/config/reviewView.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { configStore } from './configStore';
|
|
2
2
|
import { SETTINGS } from './settings';
|
|
3
|
+
import { storage } from '../utils/storage';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The ONLY writers for the coupled setting pair (reviewPanelView,
|
|
@@ -73,3 +74,39 @@ export function setReviewDefaultDiffType(
|
|
|
73
74
|
store.set('reviewPanelViewLastUsed', 'tree');
|
|
74
75
|
}
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One-time gate for the auto-mark-viewed notice — the toast that fires the
|
|
81
|
+
* FIRST time auto-view actually marks a file, i.e. the moment the feature
|
|
82
|
+
* demonstrates itself. Cookie-based, mirroring the other review first-run
|
|
83
|
+
* gates, so it survives the random port each session runs on. Versioned so a
|
|
84
|
+
* meaningful revision can re-show it.
|
|
85
|
+
*
|
|
86
|
+
* It lives beside the setting rather than in the review app because BOTH
|
|
87
|
+
* writers of the setting (Settings > Git here, and the file-list gear in the
|
|
88
|
+
* review app) must consume the gate: someone who found the switch has
|
|
89
|
+
* demonstrably discovered the feature and must never be told about it.
|
|
90
|
+
*/
|
|
91
|
+
const AUTO_VIEWED_NOTICE_SEEN_KEY = 'plannotator-auto-viewed-notice-seen';
|
|
92
|
+
const AUTO_VIEWED_NOTICE_VERSION = '1';
|
|
93
|
+
|
|
94
|
+
export function needsAutoViewedNotice(): boolean {
|
|
95
|
+
return storage.getItem(AUTO_VIEWED_NOTICE_SEEN_KEY) !== AUTO_VIEWED_NOTICE_VERSION;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function markAutoViewedNoticeSeen(): void {
|
|
99
|
+
storage.setItem(AUTO_VIEWED_NOTICE_SEEN_KEY, AUTO_VIEWED_NOTICE_VERSION);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The only writer of `reviewAutoViewed` outside the notice's own "Turn off"
|
|
104
|
+
* action. Stamps the notice gate: an explicit toggle is proof of discovery.
|
|
105
|
+
*/
|
|
106
|
+
export function setReviewAutoViewed(
|
|
107
|
+
value: boolean,
|
|
108
|
+
store: PanelViewConfigStore = configStore,
|
|
109
|
+
): void {
|
|
110
|
+
markAutoViewedNoticeSeen();
|
|
111
|
+
store.set('reviewAutoViewed', value);
|
|
112
|
+
}
|
package/config/settings.ts
CHANGED
|
@@ -261,6 +261,21 @@ export const SETTINGS = {
|
|
|
261
261
|
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
262
262
|
},
|
|
263
263
|
|
|
264
|
+
// Mark a file viewed when the reviewer scrolls past it or moves on to
|
|
265
|
+
// another file. Cookie-only like the other review-chrome preferences: it
|
|
266
|
+
// shapes how the local file list checks itself off and changes no review
|
|
267
|
+
// semantics (viewed gates nothing on submit).
|
|
268
|
+
reviewAutoViewed: {
|
|
269
|
+
defaultValue: true as boolean,
|
|
270
|
+
fromCookie: () => {
|
|
271
|
+
const value = storage.getItem('plannotator-review-auto-viewed');
|
|
272
|
+
return value === 'true' ? true : value === 'false' ? false : undefined;
|
|
273
|
+
},
|
|
274
|
+
toCookie: (value: boolean) =>
|
|
275
|
+
storage.setItem('plannotator-review-auto-viewed', String(value)),
|
|
276
|
+
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
277
|
+
},
|
|
278
|
+
|
|
264
279
|
reviewShowStageControls: {
|
|
265
280
|
defaultValue: true as boolean,
|
|
266
281
|
fromCookie: () => {
|
|
@@ -16,6 +16,17 @@ interface DraftData {
|
|
|
16
16
|
descriptionAnnotations?: Annotation[];
|
|
17
17
|
commentAnnotations?: CommentAnnotation[];
|
|
18
18
|
viewedFiles?: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Files the reviewer manually un-viewed, which auto-mark-viewed must never
|
|
21
|
+
* re-check (the "come back to this" contract). Additive and optional: a
|
|
22
|
+
* draft written before this field restores fine, and a draft carrying it is
|
|
23
|
+
* ignored gracefully by an older build.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately absent from `isEmpty` and from the engagement signal — a
|
|
26
|
+
* session whose only state is suppression is still an empty draft and is
|
|
27
|
+
* still cleared, keeping #948's clear-everything semantics untouched.
|
|
28
|
+
*/
|
|
29
|
+
autoViewSuppressed?: string[];
|
|
19
30
|
draftGeneration?: number;
|
|
20
31
|
ts: number;
|
|
21
32
|
}
|
|
@@ -40,13 +51,14 @@ interface UseCodeAnnotationDraftOptions {
|
|
|
40
51
|
descriptionAnnotations?: Annotation[];
|
|
41
52
|
commentAnnotations?: CommentAnnotation[];
|
|
42
53
|
viewedFiles: Set<string>;
|
|
54
|
+
autoViewSuppressed?: Set<string>;
|
|
43
55
|
isApiMode: boolean;
|
|
44
56
|
submitted: boolean;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
interface UseCodeAnnotationDraftResult {
|
|
48
60
|
draftBanner: { count: number; viewedCount: number; timeAgo: string } | null;
|
|
49
|
-
restoreDraft: () => { annotations: CodeAnnotation[]; descriptionAnnotations: Annotation[]; commentAnnotations: CommentAnnotation[]; viewedFiles: string[] };
|
|
61
|
+
restoreDraft: () => { annotations: CodeAnnotation[]; descriptionAnnotations: Annotation[]; commentAnnotations: CommentAnnotation[]; viewedFiles: string[]; autoViewSuppressed: string[] };
|
|
50
62
|
getDraftGeneration: () => number;
|
|
51
63
|
dismissDraft: () => void;
|
|
52
64
|
}
|
|
@@ -56,6 +68,7 @@ export function useCodeAnnotationDraft({
|
|
|
56
68
|
descriptionAnnotations = [],
|
|
57
69
|
commentAnnotations = [],
|
|
58
70
|
viewedFiles,
|
|
71
|
+
autoViewSuppressed,
|
|
59
72
|
isApiMode,
|
|
60
73
|
submitted,
|
|
61
74
|
}: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
|
|
@@ -145,6 +158,9 @@ export function useCodeAnnotationDraft({
|
|
|
145
158
|
descriptionAnnotations,
|
|
146
159
|
commentAnnotations,
|
|
147
160
|
viewedFiles: [...viewedFiles],
|
|
161
|
+
...(autoViewSuppressed && autoViewSuppressed.size > 0
|
|
162
|
+
? { autoViewSuppressed: [...autoViewSuppressed] }
|
|
163
|
+
: {}),
|
|
148
164
|
draftGeneration,
|
|
149
165
|
ts: Date.now(),
|
|
150
166
|
};
|
|
@@ -155,7 +171,7 @@ export function useCodeAnnotationDraft({
|
|
|
155
171
|
return () => {
|
|
156
172
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
157
173
|
};
|
|
158
|
-
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, isApiMode, submitted]);
|
|
174
|
+
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted]);
|
|
159
175
|
|
|
160
176
|
const restoreDraft = useCallback(() => {
|
|
161
177
|
// Cancel any pending autosave so it can't fire with pre-restore state and
|
|
@@ -169,6 +185,7 @@ export function useCodeAnnotationDraft({
|
|
|
169
185
|
descriptionAnnotations: data?.descriptionAnnotations ?? [],
|
|
170
186
|
commentAnnotations: data?.commentAnnotations ?? [],
|
|
171
187
|
viewedFiles: data?.viewedFiles ?? [],
|
|
188
|
+
autoViewSuppressed: data?.autoViewSuppressed ?? [],
|
|
172
189
|
};
|
|
173
190
|
}, []);
|
|
174
191
|
|
package/hooks/useHtmlRefresh.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* A successful snapshot. `Extra` lets a host carry document metadata read
|
|
5
|
+
* alongside the bytes (Plannotator: the root document's version diff) through
|
|
6
|
+
* to `onSnapshot`; the hook never reads anything but `rawHtml`.
|
|
7
|
+
*/
|
|
8
|
+
export type HtmlRefreshOkSnapshot<Extra extends object = object> = { status: 'ok'; rawHtml: string } & Extra;
|
|
9
|
+
|
|
3
10
|
/** What a host's `fetchSnapshot` resolves to. */
|
|
4
|
-
export type HtmlRefreshSnapshot =
|
|
5
|
-
|
|
|
11
|
+
export type HtmlRefreshSnapshot<Extra extends object = object> =
|
|
12
|
+
| HtmlRefreshOkSnapshot<Extra>
|
|
6
13
|
| { status: 'missing' }
|
|
7
14
|
| { status: 'unavailable' };
|
|
8
15
|
|
|
9
16
|
/** The outcome of one `refresh()` call, for host notifications (toasts). */
|
|
10
17
|
export type HtmlRefreshResult = 'refreshed' | 'missing' | 'unavailable';
|
|
11
18
|
|
|
12
|
-
export interface UseHtmlRefreshOptions {
|
|
19
|
+
export interface UseHtmlRefreshOptions<Extra extends object = object> {
|
|
13
20
|
/** Whether refresh is offered at all. Default true. */
|
|
14
21
|
enabled?: boolean;
|
|
15
22
|
/**
|
|
@@ -22,9 +29,11 @@ export interface UseHtmlRefreshOptions {
|
|
|
22
29
|
documentKey?: string | null;
|
|
23
30
|
/** Fetch the current bytes of the document. Called with `documentKey`.
|
|
24
31
|
* A rejection is treated as `{ status: 'unavailable' }`. */
|
|
25
|
-
fetchSnapshot: (documentKey: string | null) => Promise<HtmlRefreshSnapshot
|
|
26
|
-
/** Apply the refreshed bytes (the host owns the viewer's `rawHtml`).
|
|
27
|
-
|
|
32
|
+
fetchSnapshot: (documentKey: string | null) => Promise<HtmlRefreshSnapshot<Extra>>;
|
|
33
|
+
/** Apply the refreshed bytes (the host owns the viewer's `rawHtml`). The
|
|
34
|
+
* whole successful snapshot is the second argument, for hosts whose
|
|
35
|
+
* `fetchSnapshot` reads metadata alongside the bytes. */
|
|
36
|
+
onSnapshot: (rawHtml: string, snapshot: HtmlRefreshOkSnapshot<Extra>) => void;
|
|
28
37
|
/**
|
|
29
38
|
* Once per refresh: the ids the remounted viewer could not re-anchor,
|
|
30
39
|
* possibly empty. Wire the viewer's `onUnanchoredChange` to the returned
|
|
@@ -57,14 +66,14 @@ export interface UseHtmlRefreshReturn {
|
|
|
57
66
|
* restore acknowledgement is armed per reload generation and consumed by
|
|
58
67
|
* the first viewer report for that generation.
|
|
59
68
|
*/
|
|
60
|
-
export function useHtmlRefresh({
|
|
69
|
+
export function useHtmlRefresh<Extra extends object = object>({
|
|
61
70
|
enabled = true,
|
|
62
71
|
documentKey,
|
|
63
72
|
fetchSnapshot,
|
|
64
73
|
onSnapshot,
|
|
65
74
|
onUnanchored,
|
|
66
75
|
onResult,
|
|
67
|
-
}: UseHtmlRefreshOptions): UseHtmlRefreshReturn {
|
|
76
|
+
}: UseHtmlRefreshOptions<Extra>): UseHtmlRefreshReturn {
|
|
68
77
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
69
78
|
const [reloadGeneration, setReloadGeneration] = useState(0);
|
|
70
79
|
const keyed = documentKey !== undefined;
|
|
@@ -98,7 +107,7 @@ export function useHtmlRefresh({
|
|
|
98
107
|
// A rejecting fetch is an unavailable snapshot: the host hears it
|
|
99
108
|
// through onResult like any other outcome, never as an unhandled
|
|
100
109
|
// rejection out of refresh().
|
|
101
|
-
let result: HtmlRefreshSnapshot
|
|
110
|
+
let result: HtmlRefreshSnapshot<Extra>;
|
|
102
111
|
try {
|
|
103
112
|
result = await fetchSnapshot(requestKey);
|
|
104
113
|
} catch {
|
|
@@ -111,7 +120,7 @@ export function useHtmlRefresh({
|
|
|
111
120
|
return;
|
|
112
121
|
}
|
|
113
122
|
|
|
114
|
-
onSnapshot(result.rawHtml);
|
|
123
|
+
onSnapshot(result.rawHtml, result);
|
|
115
124
|
const nextGeneration = reloadGenerationRef.current + 1;
|
|
116
125
|
reloadGenerationRef.current = nextGeneration;
|
|
117
126
|
// Armed until the remounted viewer's bridge reports its restore. The
|
package/hooks/useSharing.ts
CHANGED
|
@@ -76,6 +76,12 @@ interface UseSharingResult {
|
|
|
76
76
|
clearShareLoadError: () => void;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
type ShortShareUrlLifecycle =
|
|
80
|
+
| { readonly _tag: 'none' }
|
|
81
|
+
| { readonly _tag: 'incoming-hydration' }
|
|
82
|
+
| { readonly _tag: 'generating'; readonly requestContext: object }
|
|
83
|
+
| { readonly _tag: 'associated'; readonly requestContext: object }
|
|
84
|
+
| { readonly _tag: 'failed'; readonly requestContext: object };
|
|
79
85
|
|
|
80
86
|
// Share payloads are base64url-encoded deflate output: charset [A-Za-z0-9_-],
|
|
81
87
|
// realistically >=30 chars, and virtually always mixed-case because deflate
|
|
@@ -133,6 +139,7 @@ export function useSharing(
|
|
|
133
139
|
]);
|
|
134
140
|
const latestShareRequestContextRef = useRef(shareRequestContext);
|
|
135
141
|
latestShareRequestContextRef.current = shareRequestContext;
|
|
142
|
+
const shortShareUrlLifecycleRef = useRef<ShortShareUrlLifecycle>({ _tag: 'none' });
|
|
136
143
|
|
|
137
144
|
const clearPendingSharedAnnotations = useCallback(() => {
|
|
138
145
|
setPendingSharedAnnotations(null);
|
|
@@ -148,6 +155,11 @@ export function useSharing(
|
|
|
148
155
|
const pathMatch = window.location.pathname.match(/^\/p\/([A-Za-z0-9]{6,16})$/);
|
|
149
156
|
if (pathMatch) {
|
|
150
157
|
const pasteId = pathMatch[1];
|
|
158
|
+
// Capture before the async fetch. Concurrent loads (including the
|
|
159
|
+
// development Strict Mode replay) can complete after an earlier load
|
|
160
|
+
// removes /p/<id> from history; every completion must preserve the
|
|
161
|
+
// original short URL.
|
|
162
|
+
const incomingShortUrl = window.location.href;
|
|
151
163
|
|
|
152
164
|
// Extract key and optional paste origin from fragment: #key=<k>&paste=<base64url>
|
|
153
165
|
const fragment = window.location.hash.slice(1);
|
|
@@ -180,7 +192,8 @@ export function useSharing(
|
|
|
180
192
|
|
|
181
193
|
setPendingSharedAnnotations(restoredAnnotations);
|
|
182
194
|
setIsSharedSession(true);
|
|
183
|
-
|
|
195
|
+
shortShareUrlLifecycleRef.current = { _tag: 'incoming-hydration' };
|
|
196
|
+
setShortShareUrl(incomingShortUrl);
|
|
184
197
|
onSharedLoad?.();
|
|
185
198
|
|
|
186
199
|
// Remove the /p/<id> path from browser history so a refresh doesn't
|
|
@@ -294,17 +307,43 @@ export function useSharing(
|
|
|
294
307
|
refreshShareUrl();
|
|
295
308
|
}, [refreshShareUrl]);
|
|
296
309
|
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
310
|
+
// An incoming short URL becomes associated with the fully hydrated share
|
|
311
|
+
// context on its first committed render. From then on it follows the same
|
|
312
|
+
// lifecycle as a locally generated URL: any shareable-content change makes
|
|
313
|
+
// the immutable paste stale, so discard the URL without auto-uploading a
|
|
314
|
+
// replacement. Markdown users can still use the fresh hash URL; creating a
|
|
315
|
+
// new short link remains an explicit action.
|
|
301
316
|
useEffect(() => {
|
|
302
|
-
|
|
303
|
-
if (
|
|
317
|
+
const lifecycle = shortShareUrlLifecycleRef.current;
|
|
318
|
+
if (lifecycle._tag === 'incoming-hydration') {
|
|
319
|
+
if (!shortShareUrl) return;
|
|
320
|
+
// Hydration writes fresh annotation and attachment arrays, so this first
|
|
321
|
+
// committed request context represents the loaded snapshot. If those
|
|
322
|
+
// setters ever preserve identity, replace this consume-on-next-effect
|
|
323
|
+
// handoff with an explicit post-hydration signal.
|
|
324
|
+
shortShareUrlLifecycleRef.current = {
|
|
325
|
+
_tag: 'associated',
|
|
326
|
+
requestContext: shareRequestContext,
|
|
327
|
+
};
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (
|
|
331
|
+
(
|
|
332
|
+
lifecycle._tag === 'generating'
|
|
333
|
+
|| lifecycle._tag === 'associated'
|
|
334
|
+
|| lifecycle._tag === 'failed'
|
|
335
|
+
)
|
|
336
|
+
&& lifecycle.requestContext === shareRequestContext
|
|
337
|
+
) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (lifecycle._tag === 'none' && !shortShareUrl) return;
|
|
341
|
+
|
|
342
|
+
shortShareUrlLifecycleRef.current = { _tag: 'none' };
|
|
304
343
|
setIsGeneratingShortUrl(false);
|
|
305
344
|
setShortShareUrl('');
|
|
306
345
|
setShortUrlError('');
|
|
307
|
-
}, [
|
|
346
|
+
}, [shareRequestContext, shortShareUrl]);
|
|
308
347
|
|
|
309
348
|
/**
|
|
310
349
|
* Generate a short URL via the paste service.
|
|
@@ -318,6 +357,10 @@ export function useSharing(
|
|
|
318
357
|
setIsGeneratingShortUrl(true);
|
|
319
358
|
setShortUrlError('');
|
|
320
359
|
const requestContext = shareRequestContext;
|
|
360
|
+
shortShareUrlLifecycleRef.current = {
|
|
361
|
+
_tag: 'generating',
|
|
362
|
+
requestContext,
|
|
363
|
+
};
|
|
321
364
|
|
|
322
365
|
try {
|
|
323
366
|
const htmlForShare = rawHtml
|
|
@@ -334,15 +377,21 @@ export function useSharing(
|
|
|
334
377
|
if (latestShareRequestContextRef.current !== requestContext) return null;
|
|
335
378
|
|
|
336
379
|
if (result) {
|
|
380
|
+
shortShareUrlLifecycleRef.current = {
|
|
381
|
+
_tag: 'associated',
|
|
382
|
+
requestContext,
|
|
383
|
+
};
|
|
337
384
|
setShortShareUrl(result.shortUrl);
|
|
338
385
|
return result.shortUrl;
|
|
339
386
|
} else {
|
|
387
|
+
shortShareUrlLifecycleRef.current = { _tag: 'failed', requestContext };
|
|
340
388
|
setShortShareUrl('');
|
|
341
389
|
setShortUrlError('Short URL service unavailable');
|
|
342
390
|
return null;
|
|
343
391
|
}
|
|
344
392
|
} catch (e) {
|
|
345
393
|
if (latestShareRequestContextRef.current !== requestContext) return null;
|
|
394
|
+
shortShareUrlLifecycleRef.current = { _tag: 'failed', requestContext };
|
|
346
395
|
setShortShareUrl('');
|
|
347
396
|
setShortUrlError(e instanceof Error ? e.message : 'Failed to generate short URL');
|
|
348
397
|
return null;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
createUndoHistoryState,
|
|
4
|
+
recordUndoAction,
|
|
5
|
+
takeRedoAction,
|
|
6
|
+
takeUndoAction,
|
|
7
|
+
type HistoryDirection,
|
|
8
|
+
type UndoHistoryState,
|
|
9
|
+
} from '../utils/undoHistory';
|
|
10
|
+
|
|
11
|
+
/** Imperative bounded history API used by surface-specific command adapters. */
|
|
12
|
+
export interface UndoHistoryApi<TAction> {
|
|
13
|
+
readonly canUndo: boolean;
|
|
14
|
+
readonly canRedo: boolean;
|
|
15
|
+
record: (action: TAction) => void;
|
|
16
|
+
undo: () => boolean;
|
|
17
|
+
redo: () => boolean;
|
|
18
|
+
clear: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface UndoHistoryOptions<TAction> {
|
|
22
|
+
context: string;
|
|
23
|
+
apply: (action: TAction, direction: HistoryDirection) => void;
|
|
24
|
+
capacity?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Keep one bounded stack for the active surface context while replaying
|
|
29
|
+
* actions through the latest adapter callbacks. A context change starts a
|
|
30
|
+
* fresh baseline synchronously, before any shortcut can reach the new view.
|
|
31
|
+
*/
|
|
32
|
+
export function useUndoHistory<TAction>({
|
|
33
|
+
context,
|
|
34
|
+
apply,
|
|
35
|
+
capacity = 50,
|
|
36
|
+
}: UndoHistoryOptions<TAction>): UndoHistoryApi<TAction> {
|
|
37
|
+
const optionsRef = useRef({ apply, capacity });
|
|
38
|
+
optionsRef.current = { apply, capacity };
|
|
39
|
+
const historyRef = useRef<{
|
|
40
|
+
context: string;
|
|
41
|
+
state: UndoHistoryState<TAction>;
|
|
42
|
+
}>({ context, state: createUndoHistoryState<TAction>() });
|
|
43
|
+
if (historyRef.current.context !== context) {
|
|
44
|
+
historyRef.current = { context, state: createUndoHistoryState<TAction>() };
|
|
45
|
+
}
|
|
46
|
+
const apiRef = useRef<UndoHistoryApi<TAction> | null>(null);
|
|
47
|
+
|
|
48
|
+
if (!apiRef.current) {
|
|
49
|
+
apiRef.current = {
|
|
50
|
+
get canUndo() {
|
|
51
|
+
return historyRef.current.state.past.length > 0;
|
|
52
|
+
},
|
|
53
|
+
get canRedo() {
|
|
54
|
+
return historyRef.current.state.future.length > 0;
|
|
55
|
+
},
|
|
56
|
+
record(action) {
|
|
57
|
+
const history = historyRef.current;
|
|
58
|
+
history.state = recordUndoAction(history.state, action, optionsRef.current.capacity);
|
|
59
|
+
},
|
|
60
|
+
undo() {
|
|
61
|
+
const history = historyRef.current;
|
|
62
|
+
const step = takeUndoAction(history.state);
|
|
63
|
+
if (step.action === null) return false;
|
|
64
|
+
history.state = step.state;
|
|
65
|
+
optionsRef.current.apply(step.action, 'undo');
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
redo() {
|
|
69
|
+
const history = historyRef.current;
|
|
70
|
+
const step = takeRedoAction(history.state, optionsRef.current.capacity);
|
|
71
|
+
if (step.action === null) return false;
|
|
72
|
+
history.state = step.state;
|
|
73
|
+
optionsRef.current.apply(step.action, 'redo');
|
|
74
|
+
return true;
|
|
75
|
+
},
|
|
76
|
+
clear() {
|
|
77
|
+
historyRef.current.state = createUndoHistoryState<TAction>();
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return apiRef.current;
|
|
83
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./components/*": "./components/*.tsx",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"@lezer/highlight": "^1.2.3",
|
|
75
75
|
"@pierre/diffs": "1.3.2",
|
|
76
76
|
"@plannotator/atomic-editor": "^0.8.0",
|
|
77
|
-
"@plannotator/core": "
|
|
77
|
+
"@plannotator/core": "workspace:*",
|
|
78
78
|
"@plannotator/markdown-editor": "^0.4.0",
|
|
79
79
|
"@plannotator/web-highlighter": "^0.8.1",
|
|
80
80
|
"@tanstack/react-table": "^8.21.3",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineShortcutScope } from './core';
|
|
2
|
+
import { createShortcutScopeHook } from './runtime';
|
|
3
|
+
|
|
4
|
+
export const historyShortcuts = defineShortcutScope({
|
|
5
|
+
id: 'history',
|
|
6
|
+
title: 'History',
|
|
7
|
+
shortcuts: {
|
|
8
|
+
undo: {
|
|
9
|
+
description: 'Undo annotation change',
|
|
10
|
+
bindings: ['Mod+Z'],
|
|
11
|
+
section: 'History',
|
|
12
|
+
preventDefault: true,
|
|
13
|
+
displayOrder: 10,
|
|
14
|
+
},
|
|
15
|
+
redo: {
|
|
16
|
+
description: 'Redo annotation change',
|
|
17
|
+
bindings: ['Mod+Shift+Z', 'Mod+Y'],
|
|
18
|
+
section: 'History',
|
|
19
|
+
preventDefault: true,
|
|
20
|
+
displayOrder: 20,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const useHistoryShortcuts = createShortcutScopeHook(historyShortcuts);
|
package/shortcuts/index.ts
CHANGED
|
@@ -27,14 +27,23 @@ export const imageAnnotatorShortcuts = defineShortcutScope({
|
|
|
27
27
|
description: 'Undo',
|
|
28
28
|
bindings: ['Mod+Z'],
|
|
29
29
|
section: 'Image Annotator',
|
|
30
|
+
preventDefault: true,
|
|
30
31
|
displayOrder: 40,
|
|
31
32
|
},
|
|
33
|
+
redo: {
|
|
34
|
+
description: 'Redo',
|
|
35
|
+
bindings: ['Mod+Shift+Z', 'Mod+Y'],
|
|
36
|
+
section: 'Image Annotator',
|
|
37
|
+
preventDefault: true,
|
|
38
|
+
displayOrder: 50,
|
|
39
|
+
},
|
|
32
40
|
save: {
|
|
33
41
|
description: 'Save and close annotator',
|
|
34
42
|
bindings: ['Enter', 'Escape'],
|
|
35
43
|
section: 'Image Annotator',
|
|
44
|
+
preventDefault: true,
|
|
36
45
|
hint: 'When the image name field is focused, Escape blurs it first and Enter confirms the name; both close the annotator otherwise.',
|
|
37
|
-
displayOrder:
|
|
46
|
+
displayOrder: 60,
|
|
38
47
|
},
|
|
39
48
|
},
|
|
40
49
|
});
|