@open-aippt/core 1.13.2

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 (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin.js +2 -0
  4. package/dist/build-DxTqmvsO.js +17 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +86 -0
  7. package/dist/config-CjzqjrEA.js +4280 -0
  8. package/dist/config-DIC-yVPp.d.ts +23 -0
  9. package/dist/design-cpzS8aud.js +35 -0
  10. package/dist/dev-BYuTeJbA.js +20 -0
  11. package/dist/format-BCeKbTOM.js +1605 -0
  12. package/dist/index.d.ts +134 -0
  13. package/dist/index.js +467 -0
  14. package/dist/locale/index.d.ts +24 -0
  15. package/dist/locale/index.js +3 -0
  16. package/dist/preview-DlQvnJPq.js +18 -0
  17. package/dist/sync-BPZ0m27m.js +139 -0
  18. package/dist/sync-EsYusbbL.js +3 -0
  19. package/dist/types-CHmFPIG_.d.ts +430 -0
  20. package/dist/vite/index.d.ts +14 -0
  21. package/dist/vite/index.js +4 -0
  22. package/env.d.ts +59 -0
  23. package/package.json +103 -0
  24. package/skills/apply-comments/SKILL.md +83 -0
  25. package/skills/create-slide/SKILL.md +91 -0
  26. package/skills/create-theme/SKILL.md +250 -0
  27. package/skills/current-slide/SKILL.md +110 -0
  28. package/skills/slide-authoring/SKILL.md +625 -0
  29. package/src/app/app.tsx +47 -0
  30. package/src/app/components/asset-view.tsx +966 -0
  31. package/src/app/components/history-provider.tsx +120 -0
  32. package/src/app/components/image-placeholder.tsx +243 -0
  33. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  34. package/src/app/components/inspector/comment-widget.tsx +93 -0
  35. package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
  36. package/src/app/components/inspector/inspect-overlay.tsx +387 -0
  37. package/src/app/components/inspector/inspector-panel.tsx +1115 -0
  38. package/src/app/components/inspector/inspector-provider.tsx +1218 -0
  39. package/src/app/components/inspector/save-bar.tsx +48 -0
  40. package/src/app/components/language-toggle.tsx +39 -0
  41. package/src/app/components/notes-drawer.tsx +120 -0
  42. package/src/app/components/overview-grid.tsx +363 -0
  43. package/src/app/components/panel/panel-fields.tsx +60 -0
  44. package/src/app/components/panel/panel-shell.tsx +80 -0
  45. package/src/app/components/panel/save-card.tsx +142 -0
  46. package/src/app/components/pdf-progress-toast.tsx +32 -0
  47. package/src/app/components/player.tsx +466 -0
  48. package/src/app/components/pptx-progress-toast.tsx +32 -0
  49. package/src/app/components/present/blackout-overlay.tsx +18 -0
  50. package/src/app/components/present/control-bar.tsx +315 -0
  51. package/src/app/components/present/help-overlay.tsx +57 -0
  52. package/src/app/components/present/jump-input.tsx +74 -0
  53. package/src/app/components/present/laser-pointer.tsx +39 -0
  54. package/src/app/components/present/progress-bar.tsx +26 -0
  55. package/src/app/components/present/use-idle.ts +46 -0
  56. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  57. package/src/app/components/present/use-presenter-channel.ts +66 -0
  58. package/src/app/components/present/use-touch-swipe.ts +66 -0
  59. package/src/app/components/shared-element.tsx +48 -0
  60. package/src/app/components/sidebar/folder-item.tsx +258 -0
  61. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  62. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  63. package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
  64. package/src/app/components/sidebar/sidebar.tsx +284 -0
  65. package/src/app/components/slide-canvas.tsx +102 -0
  66. package/src/app/components/slide-transition-layer.tsx +844 -0
  67. package/src/app/components/style-panel/design-provider.tsx +148 -0
  68. package/src/app/components/style-panel/style-panel.tsx +349 -0
  69. package/src/app/components/style-panel/use-design.ts +112 -0
  70. package/src/app/components/theme-toggle.tsx +59 -0
  71. package/src/app/components/themes/theme-detail.tsx +305 -0
  72. package/src/app/components/themes/themes-gallery.tsx +149 -0
  73. package/src/app/components/thumbnail-rail.tsx +805 -0
  74. package/src/app/components/ui/badge.tsx +45 -0
  75. package/src/app/components/ui/button.tsx +99 -0
  76. package/src/app/components/ui/card.tsx +92 -0
  77. package/src/app/components/ui/context-menu.tsx +237 -0
  78. package/src/app/components/ui/dialog.tsx +157 -0
  79. package/src/app/components/ui/dropdown-menu.tsx +245 -0
  80. package/src/app/components/ui/input.tsx +25 -0
  81. package/src/app/components/ui/label.tsx +24 -0
  82. package/src/app/components/ui/popover.tsx +75 -0
  83. package/src/app/components/ui/progress.tsx +31 -0
  84. package/src/app/components/ui/scroll-area.tsx +53 -0
  85. package/src/app/components/ui/select.tsx +196 -0
  86. package/src/app/components/ui/separator.tsx +28 -0
  87. package/src/app/components/ui/slider.tsx +61 -0
  88. package/src/app/components/ui/sonner.tsx +48 -0
  89. package/src/app/components/ui/tabs.tsx +79 -0
  90. package/src/app/components/ui/textarea.tsx +22 -0
  91. package/src/app/components/ui/toggle-group.tsx +83 -0
  92. package/src/app/components/ui/toggle.tsx +45 -0
  93. package/src/app/components/ui/tooltip.tsx +58 -0
  94. package/src/app/favicon.ico +0 -0
  95. package/src/app/index.html +13 -0
  96. package/src/app/lib/assets.ts +242 -0
  97. package/src/app/lib/design-presets.ts +94 -0
  98. package/src/app/lib/design.ts +58 -0
  99. package/src/app/lib/export-html.ts +326 -0
  100. package/src/app/lib/export-pdf.ts +298 -0
  101. package/src/app/lib/export-pptx.ts +284 -0
  102. package/src/app/lib/folders.ts +239 -0
  103. package/src/app/lib/inspector/fiber.test.ts +154 -0
  104. package/src/app/lib/inspector/fiber.ts +85 -0
  105. package/src/app/lib/inspector/use-comments.ts +74 -0
  106. package/src/app/lib/inspector/use-editor.ts +73 -0
  107. package/src/app/lib/inspector/use-notes.ts +134 -0
  108. package/src/app/lib/locale-store.ts +67 -0
  109. package/src/app/lib/page-context.tsx +38 -0
  110. package/src/app/lib/print-ready.test.ts +32 -0
  111. package/src/app/lib/print-ready.ts +51 -0
  112. package/src/app/lib/sdk.test.ts +13 -0
  113. package/src/app/lib/sdk.ts +37 -0
  114. package/src/app/lib/slides.ts +26 -0
  115. package/src/app/lib/step-context.tsx +261 -0
  116. package/src/app/lib/themes.ts +22 -0
  117. package/src/app/lib/transition.ts +30 -0
  118. package/src/app/lib/use-agent-socket.ts +18 -0
  119. package/src/app/lib/use-click-page-navigation.ts +60 -0
  120. package/src/app/lib/use-is-mobile.ts +21 -0
  121. package/src/app/lib/use-locale.ts +8 -0
  122. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  123. package/src/app/lib/use-slide-module.ts +48 -0
  124. package/src/app/lib/use-wheel-page-navigation.ts +99 -0
  125. package/src/app/lib/utils.test.ts +25 -0
  126. package/src/app/lib/utils.ts +6 -0
  127. package/src/app/main.tsx +14 -0
  128. package/src/app/routes/assets.tsx +9 -0
  129. package/src/app/routes/home-shell.tsx +213 -0
  130. package/src/app/routes/home.tsx +807 -0
  131. package/src/app/routes/presenter.tsx +418 -0
  132. package/src/app/routes/slide.tsx +1108 -0
  133. package/src/app/routes/themes.tsx +34 -0
  134. package/src/app/styles.css +429 -0
  135. package/src/app/virtual.d.ts +51 -0
  136. package/src/locale/en.ts +416 -0
  137. package/src/locale/format.ts +12 -0
  138. package/src/locale/index.ts +6 -0
  139. package/src/locale/ja.ts +422 -0
  140. package/src/locale/types.ts +443 -0
  141. package/src/locale/zh-cn.ts +414 -0
  142. package/src/locale/zh-tw.ts +414 -0
@@ -0,0 +1,239 @@
1
+ import buildManifest from 'virtual:open-aippt/folders';
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import type { Folder, FolderIcon, FoldersManifest } from './sdk';
4
+
5
+ const EMPTY: FoldersManifest = { folders: [], assignments: {} };
6
+
7
+ async function getManifest(): Promise<FoldersManifest> {
8
+ // In dev the manifest is mutable: read live from the plugin endpoint so
9
+ // edits made in the sidebar reflect immediately. In a static build there
10
+ // is no server, so fall back to the bundled snapshot from the virtual
11
+ // module (populated at build time from slides/.folders.json).
12
+ if (import.meta.env.DEV) {
13
+ const res = await fetch('/__folders');
14
+ if (!res.ok) throw new Error(`GET /__folders ${res.status}`);
15
+ const raw = (await res.json()) as Partial<FoldersManifest>;
16
+ return {
17
+ folders: raw.folders ?? [],
18
+ assignments: raw.assignments ?? {},
19
+ };
20
+ }
21
+ return {
22
+ folders: buildManifest.folders ?? [],
23
+ assignments: buildManifest.assignments ?? {},
24
+ };
25
+ }
26
+
27
+ async function patchSlideName(slideId: string, name: string): Promise<void> {
28
+ const res = await fetch(`/__slides/${slideId}`, {
29
+ method: 'PATCH',
30
+ headers: { 'content-type': 'application/json' },
31
+ body: JSON.stringify({ name }),
32
+ });
33
+ if (!res.ok) throw new Error(`PATCH /__slides/${slideId} ${res.status}`);
34
+ }
35
+
36
+ async function duplicateSlideReq(slideId: string, newId?: string): Promise<string> {
37
+ const init: RequestInit = { method: 'POST' };
38
+ if (newId !== undefined) {
39
+ init.headers = { 'content-type': 'application/json' };
40
+ init.body = JSON.stringify({ newId });
41
+ }
42
+ const res = await fetch(`/__slides/${slideId}/duplicate`, init);
43
+ if (!res.ok) throw new Error(`POST /__slides/${slideId}/duplicate ${res.status}`);
44
+ const body = (await res.json()) as { slideId?: unknown };
45
+ if (typeof body.slideId !== 'string') throw new Error('duplicate response missing slideId');
46
+ return body.slideId;
47
+ }
48
+
49
+ async function deleteSlideReq(slideId: string): Promise<void> {
50
+ const res = await fetch(`/__slides/${slideId}`, { method: 'DELETE' });
51
+ if (!res.ok) throw new Error(`DELETE /__slides/${slideId} ${res.status}`);
52
+ }
53
+
54
+ async function postFolder(name: string, icon: FolderIcon): Promise<Folder> {
55
+ const res = await fetch('/__folders', {
56
+ method: 'POST',
57
+ headers: { 'content-type': 'application/json' },
58
+ body: JSON.stringify({ name, icon }),
59
+ });
60
+ if (!res.ok) throw new Error(`POST /__folders ${res.status}`);
61
+ return (await res.json()) as Folder;
62
+ }
63
+
64
+ async function patchFolder(
65
+ id: string,
66
+ patch: { name?: string; icon?: FolderIcon },
67
+ ): Promise<Folder> {
68
+ const res = await fetch(`/__folders/${id}`, {
69
+ method: 'PATCH',
70
+ headers: { 'content-type': 'application/json' },
71
+ body: JSON.stringify(patch),
72
+ });
73
+ if (!res.ok) throw new Error(`PATCH /__folders/${id} ${res.status}`);
74
+ return (await res.json()) as Folder;
75
+ }
76
+
77
+ async function deleteFolder(id: string): Promise<void> {
78
+ const res = await fetch(`/__folders/${id}`, { method: 'DELETE' });
79
+ if (!res.ok) throw new Error(`DELETE /__folders/${id} ${res.status}`);
80
+ }
81
+
82
+ async function putAssign(slideId: string, folderId: string | null): Promise<void> {
83
+ const res = await fetch('/__folders/assign', {
84
+ method: 'PUT',
85
+ headers: { 'content-type': 'application/json' },
86
+ body: JSON.stringify({ slideId, folderId }),
87
+ });
88
+ if (!res.ok) throw new Error(`PUT /__folders/assign ${res.status}`);
89
+ }
90
+
91
+ async function putReorder(ids: string[]): Promise<void> {
92
+ const res = await fetch('/__folders/reorder', {
93
+ method: 'PUT',
94
+ headers: { 'content-type': 'application/json' },
95
+ body: JSON.stringify({ ids }),
96
+ });
97
+ if (!res.ok) throw new Error(`PUT /__folders/reorder ${res.status}`);
98
+ }
99
+
100
+ export type UseFoldersResult = {
101
+ manifest: FoldersManifest;
102
+ loading: boolean;
103
+ create: (name: string, icon: FolderIcon) => Promise<Folder>;
104
+ update: (id: string, patch: { name?: string; icon?: FolderIcon }) => Promise<void>;
105
+ remove: (id: string) => Promise<void>;
106
+ reorder: (ids: string[]) => Promise<void>;
107
+ assign: (slideId: string, folderId: string | null) => Promise<void>;
108
+ renameSlide: (slideId: string, name: string) => Promise<void>;
109
+ duplicateSlide: (slideId: string, newId?: string) => Promise<string>;
110
+ deleteSlide: (slideId: string) => Promise<void>;
111
+ refresh: () => Promise<void>;
112
+ };
113
+
114
+ export function useFolders(): UseFoldersResult {
115
+ const [manifest, setManifest] = useState<FoldersManifest>(EMPTY);
116
+ const [loading, setLoading] = useState(true);
117
+
118
+ const refresh = useCallback(async () => {
119
+ const m = await getManifest();
120
+ setManifest(m);
121
+ }, []);
122
+
123
+ useEffect(() => {
124
+ let cancelled = false;
125
+ getManifest()
126
+ .then((m) => {
127
+ if (!cancelled) {
128
+ setManifest(m);
129
+ setLoading(false);
130
+ }
131
+ })
132
+ .catch(() => {
133
+ if (!cancelled) setLoading(false);
134
+ });
135
+ return () => {
136
+ cancelled = true;
137
+ };
138
+ }, []);
139
+
140
+ useEffect(() => {
141
+ if (!import.meta.hot) return;
142
+ const handler = () => {
143
+ refresh().catch(() => {});
144
+ };
145
+ import.meta.hot.on('open-aippt:files-changed', handler);
146
+ return () => {
147
+ import.meta.hot?.off('open-aippt:files-changed', handler);
148
+ };
149
+ }, [refresh]);
150
+
151
+ const create = useCallback(
152
+ async (name: string, icon: FolderIcon) => {
153
+ const folder = await postFolder(name, icon);
154
+ await refresh();
155
+ return folder;
156
+ },
157
+ [refresh],
158
+ );
159
+
160
+ const update = useCallback(
161
+ async (id: string, patch: { name?: string; icon?: FolderIcon }) => {
162
+ await patchFolder(id, patch);
163
+ await refresh();
164
+ },
165
+ [refresh],
166
+ );
167
+
168
+ const remove = useCallback(
169
+ async (id: string) => {
170
+ await deleteFolder(id);
171
+ await refresh();
172
+ },
173
+ [refresh],
174
+ );
175
+
176
+ const reorder = useCallback(
177
+ async (ids: string[]) => {
178
+ const prev = manifest;
179
+ const byId = new Map(prev.folders.map((f) => [f.id, f]));
180
+ const next = ids.map((id) => byId.get(id)).filter((f): f is Folder => Boolean(f));
181
+ if (next.length !== prev.folders.length) return;
182
+ setManifest({ ...prev, folders: next });
183
+ try {
184
+ await putReorder(ids);
185
+ } catch (err) {
186
+ setManifest(prev);
187
+ throw err;
188
+ }
189
+ },
190
+ [manifest],
191
+ );
192
+
193
+ const assign = useCallback(
194
+ async (slideId: string, folderId: string | null) => {
195
+ await putAssign(slideId, folderId);
196
+ await refresh();
197
+ },
198
+ [refresh],
199
+ );
200
+
201
+ const renameSlide = useCallback(
202
+ async (slideId: string, name: string) => {
203
+ await patchSlideName(slideId, name);
204
+ await refresh();
205
+ },
206
+ [refresh],
207
+ );
208
+
209
+ const duplicateSlide = useCallback(
210
+ async (slideId: string, newId?: string) => {
211
+ const duplicatedId = await duplicateSlideReq(slideId, newId);
212
+ await refresh();
213
+ return duplicatedId;
214
+ },
215
+ [refresh],
216
+ );
217
+
218
+ const deleteSlide = useCallback(
219
+ async (slideId: string) => {
220
+ await deleteSlideReq(slideId);
221
+ await refresh();
222
+ },
223
+ [refresh],
224
+ );
225
+
226
+ return {
227
+ manifest,
228
+ loading,
229
+ create,
230
+ update,
231
+ remove,
232
+ reorder,
233
+ assign,
234
+ renameSlide,
235
+ duplicateSlide,
236
+ deleteSlide,
237
+ refresh,
238
+ };
239
+ }
@@ -0,0 +1,154 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { findSlideSource } from './fiber.ts';
3
+
4
+ class FakeHTMLElement {
5
+ dataset: Record<string, string> = {};
6
+ private closestSelf: FakeHTMLElement | null = null;
7
+ setClosestSelfForSlideLoc() {
8
+ this.closestSelf = this;
9
+ }
10
+ closest(selector: string): FakeHTMLElement | null {
11
+ if (selector === '[data-slide-loc]') return this.closestSelf;
12
+ return null;
13
+ }
14
+ }
15
+
16
+ type DebugSource = { fileName?: string; lineNumber?: number; columnNumber?: number };
17
+ type FakeFiber = {
18
+ return: FakeFiber | null;
19
+ stateNode?: unknown;
20
+ _debugSource?: DebugSource;
21
+ };
22
+
23
+ function makeEl(opts: { slideLoc?: string; fiber?: FakeFiber } = {}): FakeHTMLElement {
24
+ const el = new FakeHTMLElement();
25
+ if (opts.slideLoc) {
26
+ el.dataset.slideLoc = opts.slideLoc;
27
+ el.setClosestSelfForSlideLoc();
28
+ }
29
+ if (opts.fiber) {
30
+ (el as unknown as Record<string, FakeFiber>).__reactFiber$test = opts.fiber;
31
+ }
32
+ return el;
33
+ }
34
+
35
+ function makeFiber(opts: {
36
+ fileName?: string;
37
+ line?: number;
38
+ column?: number;
39
+ host?: boolean;
40
+ parent?: FakeFiber | null;
41
+ }): FakeFiber {
42
+ const source: DebugSource | undefined =
43
+ opts.fileName !== undefined
44
+ ? { fileName: opts.fileName, lineNumber: opts.line, columnNumber: opts.column }
45
+ : undefined;
46
+ return {
47
+ return: opts.parent ?? null,
48
+ stateNode: opts.host ? new FakeHTMLElement() : undefined,
49
+ _debugSource: source,
50
+ };
51
+ }
52
+
53
+ beforeAll(() => {
54
+ vi.stubGlobal('HTMLElement', FakeHTMLElement);
55
+ });
56
+
57
+ afterAll(() => {
58
+ vi.unstubAllGlobals();
59
+ });
60
+
61
+ describe('findSlideSource primary path', () => {
62
+ it('reads line:column from data-slide-loc', () => {
63
+ const el = makeEl({ slideLoc: '42:7' });
64
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
65
+ expect(hit).not.toBeNull();
66
+ expect(hit?.line).toBe(42);
67
+ expect(hit?.column).toBe(7);
68
+ expect(hit?.anchor).toBe(el as unknown as HTMLElement);
69
+ });
70
+ });
71
+
72
+ describe('findSlideSource fallback', () => {
73
+ it('matches a POSIX fileName', () => {
74
+ const fiber = makeFiber({
75
+ fileName: '/repo/slides/cover/index.tsx',
76
+ line: 10,
77
+ column: 4,
78
+ host: true,
79
+ });
80
+ const el = makeEl({ fiber });
81
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
82
+ expect(hit).not.toBeNull();
83
+ expect(hit?.line).toBe(10);
84
+ expect(hit?.column).toBe(4);
85
+ });
86
+
87
+ it('matches a Windows-backslash fileName', () => {
88
+ const fiber = makeFiber({
89
+ fileName: 'C:\\repo\\slides\\cover\\index.tsx',
90
+ line: 11,
91
+ column: 2,
92
+ host: true,
93
+ });
94
+ const el = makeEl({ fiber });
95
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
96
+ expect(hit).not.toBeNull();
97
+ expect(hit?.line).toBe(11);
98
+ expect(hit?.column).toBe(2);
99
+ });
100
+
101
+ it('matches a fileName carrying an HMR ?t= query', () => {
102
+ const fiber = makeFiber({
103
+ fileName: '/repo/slides/cover/index.tsx?t=1700000000000',
104
+ line: 12,
105
+ column: 0,
106
+ host: true,
107
+ });
108
+ const el = makeEl({ fiber });
109
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
110
+ expect(hit).not.toBeNull();
111
+ expect(hit?.line).toBe(12);
112
+ });
113
+
114
+ it('matches a Windows fileName with an HMR query', () => {
115
+ const fiber = makeFiber({
116
+ fileName: 'C:\\repo\\slides\\cover\\index.tsx?t=1700000000000',
117
+ line: 13,
118
+ column: 1,
119
+ host: true,
120
+ });
121
+ const el = makeEl({ fiber });
122
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
123
+ expect(hit).not.toBeNull();
124
+ expect(hit?.line).toBe(13);
125
+ expect(hit?.column).toBe(1);
126
+ });
127
+
128
+ it('returns null when the fiber fileName points at a different slideId', () => {
129
+ const fiber = makeFiber({
130
+ fileName: '/repo/slides/other/index.tsx',
131
+ line: 10,
132
+ column: 4,
133
+ host: true,
134
+ });
135
+ const el = makeEl({ fiber });
136
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
137
+ expect(hit).toBeNull();
138
+ });
139
+
140
+ it('walks up the fiber chain until it finds a matching source', () => {
141
+ const parent = makeFiber({
142
+ fileName: '/repo/slides/cover/index.tsx',
143
+ line: 99,
144
+ column: 3,
145
+ host: true,
146
+ });
147
+ const leaf = makeFiber({ parent, host: true });
148
+ const el = makeEl({ fiber: leaf });
149
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
150
+ expect(hit).not.toBeNull();
151
+ expect(hit?.line).toBe(99);
152
+ expect(hit?.column).toBe(3);
153
+ });
154
+ });
@@ -0,0 +1,85 @@
1
+ export type SlideSourceHit = {
2
+ line: number;
3
+ column: number;
4
+ anchor: HTMLElement;
5
+ };
6
+
7
+ export type FindSlideSourceOptions = {
8
+ // Visual editor uses this: skip component-invocation JSX (`<MyComp/>`)
9
+ // since most components don't forward `style`. Comments leave it off
10
+ // so any JSX can be annotated.
11
+ hostOnly?: boolean;
12
+ };
13
+
14
+ type FiberLike = {
15
+ return: FiberLike | null;
16
+ stateNode?: unknown;
17
+ _debugSource?: { fileName?: string; lineNumber?: number; columnNumber?: number };
18
+ memoizedProps?: { __source?: { fileName?: string; lineNumber?: number; columnNumber?: number } };
19
+ };
20
+
21
+ function getFiber(el: Element): FiberLike | null {
22
+ const key = Object.keys(el).find((k) => k.startsWith('__reactFiber$'));
23
+ if (!key) return null;
24
+ return (el as unknown as Record<string, FiberLike>)[key] ?? null;
25
+ }
26
+
27
+ function getSource(fiber: FiberLike) {
28
+ return fiber._debugSource ?? fiber.memoizedProps?.__source;
29
+ }
30
+
31
+ // `_debugSource.fileName` may carry Vite's HMR query (`?t=…`) and, on
32
+ // Windows, backslash separators. Both break the naive `endsWith` match.
33
+ function normalizeDebugFileName(fileName: string): string {
34
+ return fileName.split(/[?#]/)[0].replace(/\\/g, '/');
35
+ }
36
+
37
+ export function findSlideSource(
38
+ el: HTMLElement,
39
+ slideId: string,
40
+ opts?: FindSlideSourceOptions,
41
+ ): SlideSourceHit | null {
42
+ // Primary path: the `data-slide-loc` attribute injected by the
43
+ // loc-tags Vite plugin. Immune to HMR-stale fiber state.
44
+ const tagged = el.closest<HTMLElement>('[data-slide-loc]');
45
+ if (tagged) {
46
+ const loc = tagged.dataset.slideLoc;
47
+ if (loc) {
48
+ const idx = loc.indexOf(':');
49
+ if (idx > 0) {
50
+ const line = Number(loc.slice(0, idx));
51
+ const column = Number(loc.slice(idx + 1));
52
+ if (Number.isFinite(line) && Number.isFinite(column)) {
53
+ return { line, column, anchor: tagged };
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ // Fallback for JSX rendered from imported component files (which the
60
+ // loc-tags plugin doesn't transform).
61
+ const needle = `/slides/${slideId}/index.tsx`;
62
+ let fiber = getFiber(el);
63
+ let anchor: HTMLElement = el;
64
+ while (fiber) {
65
+ const src = getSource(fiber);
66
+ const isHost = fiber.stateNode instanceof HTMLElement;
67
+ if (
68
+ src?.fileName &&
69
+ normalizeDebugFileName(src.fileName).endsWith(needle) &&
70
+ src.lineNumber &&
71
+ (!opts?.hostOnly || isHost)
72
+ ) {
73
+ return {
74
+ line: src.lineNumber,
75
+ column: src.columnNumber ?? 0,
76
+ anchor: isHost ? (fiber.stateNode as HTMLElement) : anchor,
77
+ };
78
+ }
79
+ if (isHost) {
80
+ anchor = fiber.stateNode as HTMLElement;
81
+ }
82
+ fiber = fiber.return;
83
+ }
84
+ return null;
85
+ }
@@ -0,0 +1,74 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+
3
+ export type SlideComment = {
4
+ id: string;
5
+ line: number;
6
+ ts: string;
7
+ note: string;
8
+ hint?: string;
9
+ };
10
+
11
+ type ListResponse = { comments: SlideComment[] };
12
+
13
+ export function useComments(slideId: string) {
14
+ const [comments, setComments] = useState<SlideComment[]>([]);
15
+ const [error, setError] = useState<string | null>(null);
16
+
17
+ const refetch = useCallback(async () => {
18
+ if (!slideId) return;
19
+ try {
20
+ const res = await fetch(`/__comments?slideId=${encodeURIComponent(slideId)}`);
21
+ if (!res.ok) {
22
+ setError(`GET /__comments → ${res.status}`);
23
+ return;
24
+ }
25
+ const data = (await res.json()) as ListResponse;
26
+ setComments(data.comments);
27
+ setError(null);
28
+ } catch (e) {
29
+ setError(String((e as Error).message ?? e));
30
+ }
31
+ }, [slideId]);
32
+
33
+ const add = useCallback(
34
+ async (line: number, column: number, text: string) => {
35
+ const res = await fetch('/__comments/add', {
36
+ method: 'POST',
37
+ headers: { 'content-type': 'application/json' },
38
+ body: JSON.stringify({ slideId, line, column, text }),
39
+ });
40
+ if (!res.ok) {
41
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
42
+ throw new Error(body.error ?? `POST /__comments/add → ${res.status}`);
43
+ }
44
+ await refetch();
45
+ },
46
+ [slideId, refetch],
47
+ );
48
+
49
+ const remove = useCallback(
50
+ async (id: string) => {
51
+ const res = await fetch(`/__comments/${id}?slideId=${encodeURIComponent(slideId)}`, {
52
+ method: 'DELETE',
53
+ });
54
+ if (!res.ok) throw new Error(`DELETE /__comments/${id} → ${res.status}`);
55
+ await refetch();
56
+ },
57
+ [slideId, refetch],
58
+ );
59
+
60
+ useEffect(() => {
61
+ refetch();
62
+ }, [refetch]);
63
+
64
+ useEffect(() => {
65
+ if (!import.meta.hot) return;
66
+ const handler = () => refetch();
67
+ import.meta.hot.on('vite:afterUpdate', handler);
68
+ return () => {
69
+ import.meta.hot?.off('vite:afterUpdate', handler);
70
+ };
71
+ }, [refetch]);
72
+
73
+ return { comments, error, refetch, add, remove };
74
+ }
@@ -0,0 +1,73 @@
1
+ import { useCallback } from 'react';
2
+
3
+ export type EditOp =
4
+ | { kind: 'set-style'; key: string; value: string | null; prevText?: string }
5
+ | { kind: 'set-text'; value: string; prevText?: string }
6
+ | {
7
+ kind: 'set-text-range-style';
8
+ start: number;
9
+ end: number;
10
+ key: string;
11
+ value: string | null;
12
+ prevText?: string;
13
+ }
14
+ | { kind: 'set-attr-asset'; attr: string; assetPath: string; previewUrl: string }
15
+ | { kind: 'replace-placeholder-with-image'; assetPath: string };
16
+
17
+ export type Edit = { line: number; column: number; ops: EditOp[] };
18
+
19
+ export type EditResult = { ok: boolean; error?: string };
20
+
21
+ export class NoOpEditError extends Error {
22
+ constructor() {
23
+ super(
24
+ 'Edit completed but the source file did not change — the target JSX may already match, or the target element may not be directly editable here.',
25
+ );
26
+ this.name = 'NoOpEditError';
27
+ }
28
+ }
29
+
30
+ export function useEditor(slideId: string) {
31
+ const applyEdit = useCallback(
32
+ async (line: number, column: number, ops: EditOp[]) => {
33
+ const res = await fetch('/__edit', {
34
+ method: 'POST',
35
+ headers: { 'content-type': 'application/json' },
36
+ body: JSON.stringify({ slideId, line, column, ops }),
37
+ });
38
+ const body = (await res.json().catch(() => ({}))) as { error?: string; changed?: boolean };
39
+ if (!res.ok) {
40
+ throw new Error(body.error ?? `POST /__edit → ${res.status}`);
41
+ }
42
+ if (body.changed === false) {
43
+ throw new NoOpEditError();
44
+ }
45
+ },
46
+ [slideId],
47
+ );
48
+
49
+ // Batch many element edits into one file write and one HMR tick.
50
+ // Returns one result per input edit so callers can keep failed
51
+ // edits buffered while clearing the ones that landed.
52
+ const applyEdits = useCallback(
53
+ async (edits: Edit[]): Promise<EditResult[]> => {
54
+ if (edits.length === 0) return [];
55
+ const res = await fetch('/__edit/batch', {
56
+ method: 'POST',
57
+ headers: { 'content-type': 'application/json' },
58
+ body: JSON.stringify({ slideId, edits }),
59
+ });
60
+ const body = (await res.json().catch(() => ({}))) as {
61
+ error?: string;
62
+ results?: EditResult[];
63
+ };
64
+ if (!res.ok) {
65
+ throw new Error(body.error ?? `POST /__edit/batch → ${res.status}`);
66
+ }
67
+ return body.results ?? [];
68
+ },
69
+ [slideId],
70
+ );
71
+
72
+ return { applyEdit, applyEdits };
73
+ }