@zseven-w/dsh-openpencil 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/THIRD_PARTY_NOTICES.md +69 -0
  4. package/cordis.patch.yml +6 -0
  5. package/docs/images/dsh-openpencil-overview.png +0 -0
  6. package/lib/client/details-compat.d.ts +28 -0
  7. package/lib/client/details-compat.js +10 -0
  8. package/lib/client/editor-bridge.d.ts +84 -0
  9. package/lib/client/editor-bridge.js +127 -0
  10. package/lib/client/editor-dock-layout.d.ts +15 -0
  11. package/lib/client/editor-dock-layout.js +43 -0
  12. package/lib/client/editor-recovery.d.ts +27 -0
  13. package/lib/client/editor-recovery.js +83 -0
  14. package/lib/client/editor-successor.d.ts +28 -0
  15. package/lib/client/editor-successor.js +109 -0
  16. package/lib/client/presentation-hydration.d.ts +36 -0
  17. package/lib/client/presentation-hydration.js +168 -0
  18. package/lib/client/selection-polling.d.ts +24 -0
  19. package/lib/client/selection-polling.js +79 -0
  20. package/lib/client/selection-store.d.ts +26 -0
  21. package/lib/client/selection-store.js +85 -0
  22. package/lib/client.js +4033 -0
  23. package/lib/design-tools.d.ts +21 -0
  24. package/lib/design-tools.js +214 -0
  25. package/lib/editor-host.d.ts +62 -0
  26. package/lib/editor-host.js +1147 -0
  27. package/lib/editor-recovery.d.ts +38 -0
  28. package/lib/editor-recovery.js +319 -0
  29. package/lib/index.d.ts +24 -0
  30. package/lib/index.js +132 -0
  31. package/lib/mcp-client.d.ts +47 -0
  32. package/lib/mcp-client.js +148 -0
  33. package/lib/new-tool.d.ts +23 -0
  34. package/lib/new-tool.js +136 -0
  35. package/lib/presentation-hydration.d.ts +55 -0
  36. package/lib/presentation-hydration.js +561 -0
  37. package/lib/renderer.d.ts +203 -0
  38. package/lib/renderer.js +772 -0
  39. package/lib/tool-names.d.ts +13 -0
  40. package/lib/tool-names.js +19 -0
  41. package/lib/tool.d.ts +27 -0
  42. package/lib/tool.js +221 -0
  43. package/lib/types/client/details-compat.d.ts +28 -0
  44. package/lib/types/client/editor-bridge.d.ts +84 -0
  45. package/lib/types/client/editor-dock-layout.d.ts +15 -0
  46. package/lib/types/client/editor-modal.d.ts +54 -0
  47. package/lib/types/client/editor-panel.d.ts +127 -0
  48. package/lib/types/client/editor-recovery.d.ts +27 -0
  49. package/lib/types/client/editor-successor.d.ts +28 -0
  50. package/lib/types/client/editor-workbench-host.d.ts +43 -0
  51. package/lib/types/client/frame-gallery.d.ts +98 -0
  52. package/lib/types/client/index.d.ts +176 -0
  53. package/lib/types/client/presentation-hydration.d.ts +36 -0
  54. package/lib/types/client/selection-dock.d.ts +18 -0
  55. package/lib/types/client/selection-polling.d.ts +24 -0
  56. package/lib/types/client/selection-store.d.ts +26 -0
  57. package/lib/types/tool-names.d.ts +13 -0
  58. package/lib/viewer-assets/canvaskit/canvaskit.js +288 -0
  59. package/lib/viewer-assets/canvaskit/canvaskit.wasm +0 -0
  60. package/lib/viewer-assets/manifest.json +22 -0
  61. package/lib/viewer-assets/op_web_sdk_bg.wasm +0 -0
  62. package/lib/viewer-assets/sdk.js +225 -0
  63. package/lib/viewer-assets.d.ts +74 -0
  64. package/lib/viewer-assets.js +303 -0
  65. package/package.json +95 -0
  66. package/scripts/build-client.mjs +27 -0
  67. package/scripts/run-tool.mjs +63 -0
  68. package/scripts/sync-viewer-assets.mjs +304 -0
  69. package/scripts/test-host.mjs +311 -0
  70. package/scripts/test-viewer-assets.mjs +54 -0
  71. package/src/client/details-compat.ts +44 -0
  72. package/src/client/editor-bridge.ts +170 -0
  73. package/src/client/editor-dock-layout.ts +56 -0
  74. package/src/client/editor-modal.tsx +607 -0
  75. package/src/client/editor-panel.tsx +823 -0
  76. package/src/client/editor-recovery.ts +116 -0
  77. package/src/client/editor-successor.ts +136 -0
  78. package/src/client/editor-workbench-host.tsx +280 -0
  79. package/src/client/frame-gallery.tsx +546 -0
  80. package/src/client/index.tsx +987 -0
  81. package/src/client/presentation-hydration.ts +210 -0
  82. package/src/client/react-dom-compat.d.ts +22 -0
  83. package/src/client/selection-dock.tsx +89 -0
  84. package/src/client/selection-polling.ts +93 -0
  85. package/src/client/selection-store.ts +108 -0
  86. package/src/design-tools.ts +242 -0
  87. package/src/editor-host.ts +1238 -0
  88. package/src/editor-recovery.ts +374 -0
  89. package/src/index.ts +219 -0
  90. package/src/mcp-client.ts +193 -0
  91. package/src/new-tool.ts +160 -0
  92. package/src/presentation-hydration.ts +634 -0
  93. package/src/renderer.ts +884 -0
  94. package/src/tool-names.ts +21 -0
  95. package/src/tool.ts +258 -0
  96. package/src/viewer-assets.ts +353 -0
@@ -0,0 +1,83 @@
1
+ /** Same-origin client controls for durable managed-editor recovery. */
2
+ import { editorControlUrl } from './editor-bridge.js';
3
+ function isRecord(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ export function editorRecoverySummaryOf(value) {
7
+ if (!isRecord(value))
8
+ return undefined;
9
+ if (typeof value.id !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value.id)
10
+ || typeof value.capturedAt !== 'number' || !Number.isSafeInteger(value.capturedAt) || value.capturedAt <= 0
11
+ || typeof value.bytes !== 'number' || !Number.isSafeInteger(value.bytes) || value.bytes <= 0
12
+ || typeof value.sourceName !== 'string' || value.sourceName.length <= 0 || value.sourceName.length > 512
13
+ || typeof value.sourceChangedSinceCapture !== 'boolean'
14
+ || typeof value.cacheLabel !== 'string' || !value.cacheLabel.startsWith('dsh-openpencil/recovery/'))
15
+ return undefined;
16
+ return value;
17
+ }
18
+ export function editorRecoveryItemUrl(launch, recoveryId) {
19
+ if (launch.recoveryUrl === undefined)
20
+ throw new Error('OpenPencil recovery control is unavailable');
21
+ if (!/^[A-Za-z0-9_-]{43}$/.test(recoveryId))
22
+ throw new Error('OpenPencil recovery id is invalid');
23
+ return `${editorControlUrl(launch.recoveryUrl)}/${recoveryId}`;
24
+ }
25
+ async function checkedJson(response, action) {
26
+ if (!response.ok)
27
+ throw new Error(`${action} failed (${response.status})`);
28
+ return response.json();
29
+ }
30
+ /** Capture the authoritative daemon document; never serializes the iframe from React. */
31
+ export async function captureManagedEditorRecovery(launch, fetcher = fetch) {
32
+ if (launch.recoveryUrl === undefined)
33
+ return undefined;
34
+ const value = await checkedJson(await fetcher(editorControlUrl(launch.recoveryUrl), {
35
+ method: 'POST',
36
+ credentials: 'same-origin',
37
+ headers: { 'content-type': 'application/json' },
38
+ body: JSON.stringify({ sessionId: launch.sessionId }),
39
+ keepalive: true,
40
+ }), 'OpenPencil recovery capture');
41
+ if (!isRecord(value) || value.ok !== true)
42
+ throw new Error('OpenPencil recovery capture returned an invalid result');
43
+ if (value.recovery === null)
44
+ return undefined;
45
+ const recovery = editorRecoverySummaryOf(value.recovery);
46
+ if (recovery === undefined)
47
+ throw new Error('OpenPencil recovery capture omitted recovery metadata');
48
+ return recovery;
49
+ }
50
+ /** Explicitly restore into the live daemon. The user must still press Save to update `.op`. */
51
+ export async function restoreManagedEditorRecovery(launch, recovery, fetcher = fetch) {
52
+ const value = await checkedJson(await fetcher(editorRecoveryItemUrl(launch, recovery.id), {
53
+ method: 'POST', credentials: 'same-origin',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify({ sessionId: launch.sessionId }),
56
+ }), 'OpenPencil recovery restore');
57
+ if (!isRecord(value) || value.ok !== true || typeof value.docJson !== 'string') {
58
+ throw new Error('OpenPencil recovery restore returned an invalid result');
59
+ }
60
+ return value.docJson;
61
+ }
62
+ export async function discardManagedEditorRecovery(launch, recovery, fetcher = fetch) {
63
+ const value = await checkedJson(await fetcher(editorRecoveryItemUrl(launch, recovery.id), {
64
+ method: 'DELETE', credentials: 'same-origin',
65
+ headers: { 'content-type': 'application/json' },
66
+ body: JSON.stringify({ sessionId: launch.sessionId }),
67
+ }), 'OpenPencil recovery discard');
68
+ if (!isRecord(value) || value.ok !== true)
69
+ throw new Error('OpenPencil recovery discard returned an invalid result');
70
+ }
71
+ const COPY = {
72
+ 'zh-CN': {
73
+ available: sourceName => `发现 ${sourceName} 的未保存恢复稿。是否恢复?恢复后仍需点击“保存”才会写入源文件。`,
74
+ conflict: sourceName => `发现 ${sourceName} 的未保存恢复稿,但源文件之后已被修改。仍要将恢复稿载入画布吗?恢复不会自动覆盖源文件。`,
75
+ },
76
+ 'en-US': {
77
+ available: sourceName => `An unsaved recovery draft for ${sourceName} is available. Restore it? You must still select Save to write the source file.`,
78
+ conflict: sourceName => `An unsaved recovery draft for ${sourceName} is available, but the source changed later. Load the draft into the canvas anyway? Recovery will not overwrite the source automatically.`,
79
+ },
80
+ };
81
+ export function editorRecoveryCopy(locale) {
82
+ return COPY[locale];
83
+ }
@@ -0,0 +1,28 @@
1
+ /** Session-scoped successor capabilities for reopening a saved editor card. */
2
+ export interface EditorSuccessorGrant {
3
+ enabled: true;
4
+ launchUrl: string;
5
+ refreshUrl?: string;
6
+ }
7
+ export interface EditorSessionStorage {
8
+ getItem(key: string): string | null;
9
+ setItem(key: string, value: string): void;
10
+ removeItem(key: string): void;
11
+ }
12
+ interface SuccessorOptions {
13
+ storage?: EditorSessionStorage | null;
14
+ baseUrl?: string;
15
+ }
16
+ /** The original Tool grant scopes one successor chain for the life of this tab. */
17
+ export declare function editorSuccessorStorageKey(originalLaunchUrl: string, baseUrl?: string): string;
18
+ /** Parse only the successor grant from a successful save response. */
19
+ export declare function editorSuccessorFromSave(value: unknown, baseUrl?: string): EditorSuccessorGrant | undefined;
20
+ /**
21
+ * Persist the newest save successor under the immutable Tool grant. Invalid or
22
+ * absent successors clear an older value so a later reopen cannot use a stale
23
+ * source capability. Storage denial is intentionally non-fatal to saving.
24
+ */
25
+ export declare function rememberEditorSuccessor(originalLaunchUrl: string, saveResponse: unknown, options?: SuccessorOptions): EditorSuccessorGrant | undefined;
26
+ /** Resolve a saved successor, falling back to the original Tool grant safely. */
27
+ export declare function editorGrantForBoot(original: EditorSuccessorGrant, options?: SuccessorOptions): EditorSuccessorGrant;
28
+ export {};
@@ -0,0 +1,109 @@
1
+ /** Session-scoped successor capabilities for reopening a saved editor card. */
2
+ import { editorControlUrl } from './editor-bridge.js';
3
+ const STORAGE_PREFIX = 'dsh-openpencil:editor-successor:v1:';
4
+ function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ function browserStorage() {
8
+ try {
9
+ return window.sessionStorage;
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
15
+ function storageOf(options) {
16
+ return options.storage === undefined ? browserStorage() : options.storage ?? undefined;
17
+ }
18
+ function baseUrlOf(options) {
19
+ return options.baseUrl ?? window.location.href;
20
+ }
21
+ /** The original Tool grant scopes one successor chain for the life of this tab. */
22
+ export function editorSuccessorStorageKey(originalLaunchUrl, baseUrl = window.location.href) {
23
+ return `${STORAGE_PREFIX}${editorControlUrl(originalLaunchUrl, baseUrl)}`;
24
+ }
25
+ function persistedSuccessorOf(value, baseUrl) {
26
+ if (!isRecord(value))
27
+ return undefined;
28
+ const launchUrl = value.launchUrl;
29
+ const refreshUrl = value.refreshUrl;
30
+ if (typeof launchUrl !== 'string' || launchUrl.length === 0
31
+ || typeof refreshUrl !== 'string' || refreshUrl.length === 0)
32
+ return undefined;
33
+ try {
34
+ return {
35
+ launchUrl: editorControlUrl(launchUrl, baseUrl),
36
+ refreshUrl: editorControlUrl(refreshUrl, baseUrl),
37
+ };
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /** Parse only the successor grant from a successful save response. */
44
+ export function editorSuccessorFromSave(value, baseUrl = window.location.href) {
45
+ if (!isRecord(value) || !isRecord(value.editor) || value.editor.enabled !== true)
46
+ return undefined;
47
+ const persisted = persistedSuccessorOf(value.editor, baseUrl);
48
+ return persisted === undefined ? undefined : { enabled: true, ...persisted };
49
+ }
50
+ /**
51
+ * Persist the newest save successor under the immutable Tool grant. Invalid or
52
+ * absent successors clear an older value so a later reopen cannot use a stale
53
+ * source capability. Storage denial is intentionally non-fatal to saving.
54
+ */
55
+ export function rememberEditorSuccessor(originalLaunchUrl, saveResponse, options = {}) {
56
+ const baseUrl = baseUrlOf(options);
57
+ const successor = editorSuccessorFromSave(saveResponse, baseUrl);
58
+ const storage = storageOf(options);
59
+ if (storage === undefined)
60
+ return successor;
61
+ try {
62
+ const key = editorSuccessorStorageKey(originalLaunchUrl, baseUrl);
63
+ if (successor === undefined || successor.refreshUrl === undefined) {
64
+ storage.removeItem(key);
65
+ return undefined;
66
+ }
67
+ // Deliberately persist no document path/JSON, daemon token, or response fields.
68
+ storage.setItem(key, JSON.stringify({
69
+ launchUrl: successor.launchUrl,
70
+ refreshUrl: successor.refreshUrl,
71
+ }));
72
+ }
73
+ catch {
74
+ // sessionStorage can be denied or full; the original Tool grant remains usable.
75
+ }
76
+ return successor;
77
+ }
78
+ /** Resolve a saved successor, falling back to the original Tool grant safely. */
79
+ export function editorGrantForBoot(original, options = {}) {
80
+ const storage = storageOf(options);
81
+ if (storage === undefined)
82
+ return original;
83
+ const baseUrl = baseUrlOf(options);
84
+ let key;
85
+ try {
86
+ key = editorSuccessorStorageKey(original.launchUrl, baseUrl);
87
+ }
88
+ catch {
89
+ return original;
90
+ }
91
+ try {
92
+ const raw = storage.getItem(key);
93
+ if (raw === null)
94
+ return original;
95
+ const successor = persistedSuccessorOf(JSON.parse(raw), baseUrl);
96
+ if (successor !== undefined)
97
+ return { enabled: true, ...successor };
98
+ }
99
+ catch {
100
+ // Corrupt JSON and denied reads both fall through to best-effort cleanup.
101
+ }
102
+ try {
103
+ storage.removeItem(key);
104
+ }
105
+ catch {
106
+ // Ignore cleanup failures and keep using the original grant.
107
+ }
108
+ return original;
109
+ }
@@ -0,0 +1,36 @@
1
+ /** Recover browser-only presentation metadata omitted from nested Tool results. */
2
+ export declare const PRESENTATION_HYDRATION_ENDPOINT = "/_dsh/dsh-openpencil/presentation";
3
+ export declare const PRESENTATION_HYDRATION_META_KEY = "$dshOpenPencil";
4
+ export interface PresentationHydrationRequest {
5
+ sessionId: string;
6
+ callId: string;
7
+ documentSha256: string;
8
+ }
9
+ export interface PresentationHydrationCandidate {
10
+ block: unknown;
11
+ toolName: string;
12
+ sessionId: string;
13
+ callId: string;
14
+ embeddedGrant: unknown;
15
+ }
16
+ export type PresentationHydrationFetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
17
+ export interface PresentationHydrationOptions {
18
+ fetcher?: PresentationHydrationFetcher;
19
+ signal?: AbortSignal;
20
+ }
21
+ type PresentationMetaParser<Grant> = (meta: unknown) => Grant | undefined;
22
+ /**
23
+ * Read only the immutable document fingerprint from one canonical text result.
24
+ * Paths, image data, and every other model-visible result field are ignored.
25
+ */
26
+ export declare function documentSha256FromCanonicalResult(block: unknown): string | undefined;
27
+ /** Select only canonical nested render results that actually need hydration. */
28
+ export declare function presentationHydrationRequestOf(candidate: PresentationHydrationCandidate): PresentationHydrationRequest | undefined;
29
+ /**
30
+ * Exchange a non-secret result fingerprint for a same-origin presentation
31
+ * grant. Concurrent subscribers share one request; an unmounted subscriber
32
+ * can abort independently, and the network request is cancelled once nobody
33
+ * is waiting for it.
34
+ */
35
+ export declare function requestPresentationGrant<Grant>(request: PresentationHydrationRequest, parseMeta: PresentationMetaParser<Grant>, options?: PresentationHydrationOptions): Promise<Grant | undefined>;
36
+ export {};
@@ -0,0 +1,168 @@
1
+ /** Recover browser-only presentation metadata omitted from nested Tool results. */
2
+ import { OPENPENCIL_RENDER_TOOL_NAME } from '../tool-names.js';
3
+ export const PRESENTATION_HYDRATION_ENDPOINT = '/_dsh/dsh-openpencil/presentation';
4
+ export const PRESENTATION_HYDRATION_META_KEY = '$dshOpenPencil';
5
+ const MAX_CANONICAL_RESULT_CHARS = 1024 * 1024;
6
+ const MAX_SESSION_ID_CHARS = 256;
7
+ const MAX_CALL_ID_CHARS = 512;
8
+ const pendingByFetcher = new WeakMap();
9
+ function isRecord(value) {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+ function isRequest(value) {
13
+ return value.sessionId.length > 0
14
+ && value.sessionId.length <= MAX_SESSION_ID_CHARS
15
+ && value.callId.length > 0
16
+ && value.callId.length <= MAX_CALL_ID_CHARS
17
+ && /^[a-f0-9]{64}$/iu.test(value.documentSha256);
18
+ }
19
+ function requestKey(value) {
20
+ return `${value.sessionId}\n${value.callId}\n${value.documentSha256.toLowerCase()}`;
21
+ }
22
+ function pendingEnvelope(request, fetcher) {
23
+ let pending = pendingByFetcher.get(fetcher);
24
+ if (pending === undefined) {
25
+ pending = new Map();
26
+ pendingByFetcher.set(fetcher, pending);
27
+ }
28
+ const key = requestKey(request);
29
+ const existing = pending.get(key);
30
+ if (existing !== undefined)
31
+ return existing;
32
+ const controller = new AbortController();
33
+ const entry = {
34
+ subscribers: 0,
35
+ settled: false,
36
+ cancelIfUnused: () => { },
37
+ promise: Promise.resolve(undefined),
38
+ };
39
+ entry.cancelIfUnused = () => {
40
+ if (entry.subscribers !== 0 || entry.settled)
41
+ return;
42
+ if (pending?.get(key) === entry)
43
+ pending.delete(key);
44
+ controller.abort();
45
+ };
46
+ entry.promise = (async () => {
47
+ const response = await fetcher(PRESENTATION_HYDRATION_ENDPOINT, {
48
+ method: 'POST',
49
+ credentials: 'same-origin',
50
+ headers: {
51
+ accept: 'application/json',
52
+ 'content-type': 'application/json',
53
+ },
54
+ body: JSON.stringify(request),
55
+ signal: controller.signal,
56
+ });
57
+ if (!response.ok)
58
+ return undefined;
59
+ try {
60
+ return await response.json();
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ })().catch(() => undefined).finally(() => {
66
+ entry.settled = true;
67
+ if (pending?.get(key) === entry)
68
+ pending.delete(key);
69
+ });
70
+ pending.set(key, entry);
71
+ return entry;
72
+ }
73
+ /**
74
+ * Read only the immutable document fingerprint from one canonical text result.
75
+ * Paths, image data, and every other model-visible result field are ignored.
76
+ */
77
+ export function documentSha256FromCanonicalResult(block) {
78
+ if (!isRecord(block) || block.isError !== false || !Array.isArray(block.content) || block.content.length !== 1) {
79
+ return undefined;
80
+ }
81
+ const content = block.content[0];
82
+ if (!isRecord(content) || content.type !== 'text' || typeof content.text !== 'string')
83
+ return undefined;
84
+ if (content.text.length > MAX_CANONICAL_RESULT_CHARS)
85
+ return undefined;
86
+ let result;
87
+ try {
88
+ result = JSON.parse(content.text);
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ if (!isRecord(result) || !isRecord(result.document))
94
+ return undefined;
95
+ const fingerprint = result.document.sha256;
96
+ return typeof fingerprint === 'string' && /^[a-f0-9]{64}$/iu.test(fingerprint)
97
+ ? fingerprint.toLowerCase()
98
+ : undefined;
99
+ }
100
+ /** Select only canonical nested render results that actually need hydration. */
101
+ export function presentationHydrationRequestOf(candidate) {
102
+ if (candidate.embeddedGrant !== undefined || candidate.toolName !== OPENPENCIL_RENDER_TOOL_NAME) {
103
+ return undefined;
104
+ }
105
+ const documentSha256 = documentSha256FromCanonicalResult(candidate.block);
106
+ if (documentSha256 === undefined
107
+ || candidate.sessionId.length === 0
108
+ || candidate.sessionId.length > MAX_SESSION_ID_CHARS
109
+ || candidate.callId.length === 0
110
+ || candidate.callId.length > MAX_CALL_ID_CHARS) {
111
+ return undefined;
112
+ }
113
+ return {
114
+ sessionId: candidate.sessionId,
115
+ callId: candidate.callId,
116
+ documentSha256,
117
+ };
118
+ }
119
+ /**
120
+ * Exchange a non-secret result fingerprint for a same-origin presentation
121
+ * grant. Concurrent subscribers share one request; an unmounted subscriber
122
+ * can abort independently, and the network request is cancelled once nobody
123
+ * is waiting for it.
124
+ */
125
+ export function requestPresentationGrant(request, parseMeta, options = {}) {
126
+ if (!isRequest(request) || options.signal?.aborted === true)
127
+ return Promise.resolve(undefined);
128
+ const fetcher = options.fetcher ?? globalThis.fetch;
129
+ if (typeof fetcher !== 'function')
130
+ return Promise.resolve(undefined);
131
+ const entry = pendingEnvelope(request, fetcher);
132
+ entry.subscribers += 1;
133
+ return new Promise(resolve => {
134
+ let finished = false;
135
+ const release = () => {
136
+ entry.subscribers = Math.max(0, entry.subscribers - 1);
137
+ entry.cancelIfUnused();
138
+ };
139
+ const finish = (value) => {
140
+ if (finished)
141
+ return;
142
+ finished = true;
143
+ options.signal?.removeEventListener('abort', abort);
144
+ release();
145
+ resolve(value);
146
+ };
147
+ const abort = () => { finish(undefined); };
148
+ options.signal?.addEventListener('abort', abort, { once: true });
149
+ entry.promise.then(value => {
150
+ if (finished || options.signal?.aborted === true) {
151
+ finish(undefined);
152
+ return;
153
+ }
154
+ if (!isRecord(value) || !Object.hasOwn(value, PRESENTATION_HYDRATION_META_KEY)) {
155
+ finish(undefined);
156
+ return;
157
+ }
158
+ try {
159
+ finish(parseMeta({
160
+ [PRESENTATION_HYDRATION_META_KEY]: value[PRESENTATION_HYDRATION_META_KEY],
161
+ }));
162
+ }
163
+ catch {
164
+ finish(undefined);
165
+ }
166
+ }, () => { finish(undefined); });
167
+ });
168
+ }
@@ -0,0 +1,24 @@
1
+ /** Lifecycle-safe polling for the optional OpenPencil live-selection endpoint. */
2
+ export interface SelectionPollTimer {
3
+ schedule(callback: () => void, delayMs: number): unknown;
4
+ cancel(handle: unknown): void;
5
+ }
6
+ export interface EditorSelectionPollingOptions {
7
+ url: string;
8
+ onValue(value: unknown): void;
9
+ onStop(): void;
10
+ fetcher?: typeof fetch;
11
+ intervalMs?: number;
12
+ timer?: SelectionPollTimer;
13
+ }
14
+ /** Client failures other than retry-oriented HTTP statuses cannot recover by polling. */
15
+ export declare function isTerminalEditorSelectionStatus(status: number): boolean;
16
+ /**
17
+ * Start one immediate poll followed by non-overlapping delayed polls.
18
+ *
19
+ * The returned cleanup is idempotent, aborts an in-flight request, cancels the
20
+ * pending timer, and invokes `onStop` exactly once. Terminal HTTP responses
21
+ * use the same cleanup path; network errors and retryable statuses schedule a
22
+ * later attempt without disrupting the editor itself.
23
+ */
24
+ export declare function startEditorSelectionPolling(options: EditorSelectionPollingOptions): () => void;
@@ -0,0 +1,79 @@
1
+ /** Lifecycle-safe polling for the optional OpenPencil live-selection endpoint. */
2
+ const DEFAULT_INTERVAL_MS = 400;
3
+ const DEFAULT_TIMER = {
4
+ schedule: (callback, delayMs) => setTimeout(callback, delayMs),
5
+ cancel: handle => { clearTimeout(handle); },
6
+ };
7
+ /** Client failures other than retry-oriented HTTP statuses cannot recover by polling. */
8
+ export function isTerminalEditorSelectionStatus(status) {
9
+ return status >= 400 && status < 500 && status !== 408 && status !== 425 && status !== 429;
10
+ }
11
+ /**
12
+ * Start one immediate poll followed by non-overlapping delayed polls.
13
+ *
14
+ * The returned cleanup is idempotent, aborts an in-flight request, cancels the
15
+ * pending timer, and invokes `onStop` exactly once. Terminal HTTP responses
16
+ * use the same cleanup path; network errors and retryable statuses schedule a
17
+ * later attempt without disrupting the editor itself.
18
+ */
19
+ export function startEditorSelectionPolling(options) {
20
+ const fetcher = options.fetcher ?? fetch;
21
+ const timer = options.timer ?? DEFAULT_TIMER;
22
+ const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
23
+ let stopped = false;
24
+ let timerHandle;
25
+ let requestAbort;
26
+ const stop = () => {
27
+ if (stopped)
28
+ return;
29
+ stopped = true;
30
+ if (timerHandle !== undefined) {
31
+ timer.cancel(timerHandle);
32
+ timerHandle = undefined;
33
+ }
34
+ requestAbort?.abort();
35
+ requestAbort = undefined;
36
+ options.onStop();
37
+ };
38
+ const schedule = () => {
39
+ if (stopped)
40
+ return;
41
+ timerHandle = timer.schedule(() => {
42
+ timerHandle = undefined;
43
+ void poll();
44
+ }, intervalMs);
45
+ };
46
+ const poll = async () => {
47
+ if (stopped)
48
+ return;
49
+ const abort = new AbortController();
50
+ requestAbort = abort;
51
+ try {
52
+ const response = await fetcher(options.url, {
53
+ credentials: 'same-origin',
54
+ signal: abort.signal,
55
+ });
56
+ if (stopped)
57
+ return;
58
+ if (isTerminalEditorSelectionStatus(response.status)) {
59
+ stop();
60
+ return;
61
+ }
62
+ if (response.ok) {
63
+ const value = await response.json();
64
+ if (!stopped)
65
+ options.onValue(value);
66
+ }
67
+ }
68
+ catch {
69
+ // Selection is an enhancement. Transient transport failures retry later.
70
+ }
71
+ finally {
72
+ if (requestAbort === abort)
73
+ requestAbort = undefined;
74
+ schedule();
75
+ }
76
+ };
77
+ void poll();
78
+ return stop;
79
+ }
@@ -0,0 +1,26 @@
1
+ /** Page-local live selection store shared by the sidebar and conversation. */
2
+ export interface OpenPencilNodeSelection {
3
+ id: string;
4
+ type?: string;
5
+ name?: string;
6
+ x?: number;
7
+ y?: number;
8
+ width?: number;
9
+ height?: number;
10
+ }
11
+ export interface OpenPencilLiveSelection {
12
+ sourcePath: string;
13
+ activePageId: string;
14
+ selectedIds: string[];
15
+ nodes: OpenPencilNodeSelection[];
16
+ updatedAt: number;
17
+ }
18
+ export interface OpenPencilSelectionStoreSnapshot {
19
+ revision: number;
20
+ selection?: OpenPencilLiveSelection;
21
+ }
22
+ export declare function liveSelectionOf(value: unknown): OpenPencilLiveSelection | undefined;
23
+ export declare function publishOpenPencilSelection(sessionId: string, selection: OpenPencilLiveSelection): void;
24
+ export declare function clearOpenPencilSelection(sessionId: string, sourcePath?: string): void;
25
+ export declare function subscribeOpenPencilSelection(sessionId: string, listener: () => void): () => void;
26
+ export declare function getOpenPencilSelectionSnapshot(sessionId: string): OpenPencilSelectionStoreSnapshot;
@@ -0,0 +1,85 @@
1
+ /** Page-local live selection store shared by the sidebar and conversation. */
2
+ const stores = new Map();
3
+ const listeners = new Map();
4
+ const EMPTY_SELECTION_STORE_SNAPSHOT = Object.freeze({ revision: 0 });
5
+ function isRecord(value) {
6
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ }
8
+ function finite(value) {
9
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
10
+ }
11
+ function nodeOf(value) {
12
+ if (!isRecord(value) || typeof value.id !== 'string' || value.id.length === 0)
13
+ return undefined;
14
+ return {
15
+ id: value.id,
16
+ ...(typeof value.type === 'string' && value.type.length > 0 ? { type: value.type } : {}),
17
+ ...(typeof value.name === 'string' && value.name.length > 0 ? { name: value.name } : {}),
18
+ ...(finite(value.x) === undefined ? {} : { x: finite(value.x) }),
19
+ ...(finite(value.y) === undefined ? {} : { y: finite(value.y) }),
20
+ ...(finite(value.width) === undefined ? {} : { width: finite(value.width) }),
21
+ ...(finite(value.height) === undefined ? {} : { height: finite(value.height) }),
22
+ };
23
+ }
24
+ export function liveSelectionOf(value) {
25
+ if (!isRecord(value) || typeof value.sourcePath !== 'string' || value.sourcePath.length === 0)
26
+ return undefined;
27
+ return {
28
+ sourcePath: value.sourcePath,
29
+ activePageId: typeof value.activePageId === 'string' ? value.activePageId : '',
30
+ selectedIds: Array.isArray(value.selectedIds)
31
+ ? value.selectedIds.filter((id) => typeof id === 'string' && id.length > 0)
32
+ : [],
33
+ nodes: Array.isArray(value.nodes)
34
+ ? value.nodes.map(nodeOf).filter((node) => node !== undefined)
35
+ : [],
36
+ updatedAt: finite(value.updatedAt) ?? Date.now(),
37
+ };
38
+ }
39
+ function sameSelection(a, b) {
40
+ if (a === undefined || b === undefined)
41
+ return a === b;
42
+ return a.sourcePath === b.sourcePath
43
+ && a.activePageId === b.activePageId
44
+ && JSON.stringify(a.selectedIds) === JSON.stringify(b.selectedIds)
45
+ && JSON.stringify(a.nodes) === JSON.stringify(b.nodes);
46
+ }
47
+ export function publishOpenPencilSelection(sessionId, selection) {
48
+ const current = stores.get(sessionId) ?? { revision: 0 };
49
+ if (sameSelection(current.selection, selection))
50
+ return;
51
+ stores.set(sessionId, { revision: current.revision + 1, selection });
52
+ for (const listener of listeners.get(sessionId) ?? [])
53
+ listener();
54
+ }
55
+ export function clearOpenPencilSelection(sessionId, sourcePath) {
56
+ const current = stores.get(sessionId);
57
+ if (current === undefined)
58
+ return;
59
+ if (current.selection === undefined)
60
+ return;
61
+ if (sourcePath !== undefined && current.selection.sourcePath !== sourcePath)
62
+ return;
63
+ stores.set(sessionId, { revision: current.revision + 1 });
64
+ for (const listener of listeners.get(sessionId) ?? [])
65
+ listener();
66
+ }
67
+ export function subscribeOpenPencilSelection(sessionId, listener) {
68
+ let scoped = listeners.get(sessionId);
69
+ if (scoped === undefined) {
70
+ scoped = new Set();
71
+ listeners.set(sessionId, scoped);
72
+ }
73
+ scoped.add(listener);
74
+ return () => {
75
+ scoped?.delete(listener);
76
+ if (scoped?.size === 0)
77
+ listeners.delete(sessionId);
78
+ };
79
+ }
80
+ export function getOpenPencilSelectionSnapshot(sessionId) {
81
+ // useSyncExternalStore requires referentially stable snapshots while the
82
+ // underlying state is unchanged. Returning a new empty object here causes
83
+ // React to enter an update loop before the first selection poll completes.
84
+ return stores.get(sessionId) ?? EMPTY_SELECTION_STORE_SNAPSHOT;
85
+ }