@open-slide/core 0.0.8 → 0.0.10
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/dist/{build-CXY2DSzy.js → build-DHiRlpjn.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-BYTf0qVz.js → config-LZM903FE.js} +742 -44
- package/dist/{dev-BxCKugi3.js → dev-B3JzCYn7.js} +1 -1
- package/dist/{preview-C1F-rHfx.js → preview-UikovHEt.js} +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +3 -1
- package/src/app/App.tsx +2 -0
- package/src/app/components/AssetView.tsx +846 -0
- package/src/app/components/ClickNavZones.tsx +2 -2
- package/src/app/components/PdfProgressToast.tsx +23 -0
- package/src/app/components/ThumbnailRail.tsx +2 -2
- package/src/app/components/inspector/CommentWidget.tsx +1 -1
- package/src/app/components/inspector/InspectOverlay.tsx +81 -41
- package/src/app/components/inspector/InspectorPanel.tsx +948 -0
- package/src/app/components/inspector/InspectorProvider.tsx +229 -13
- package/src/app/components/inspector/SaveBar.tsx +77 -0
- package/src/app/components/ui/input.tsx +21 -0
- package/src/app/components/ui/label.tsx +24 -0
- package/src/app/components/ui/progress.tsx +31 -0
- package/src/app/components/ui/select.tsx +190 -0
- package/src/app/components/ui/slider.tsx +61 -0
- package/src/app/components/ui/sonner.tsx +38 -0
- package/src/app/components/ui/textarea.tsx +18 -0
- package/src/app/components/ui/toggle-group.tsx +83 -0
- package/src/app/components/ui/toggle.tsx +45 -0
- package/src/app/components/ui/tooltip.tsx +55 -0
- package/src/app/lib/assets.ts +166 -0
- package/src/app/lib/export-pdf.ts +194 -0
- package/src/app/lib/inspector/fiber.ts +40 -5
- package/src/app/lib/inspector/useEditor.ts +62 -0
- package/src/app/lib/print-ready.ts +58 -0
- package/src/app/routes/Slide.tsx +140 -51
- package/src/app/components/inspector/CommentPopover.tsx +0 -94
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
export type EditOp =
|
|
4
|
+
| { kind: 'set-style'; key: string; value: string | null }
|
|
5
|
+
| { kind: 'set-text'; value: string }
|
|
6
|
+
| { kind: 'set-attr-asset'; attr: string; assetPath: string; previewUrl: string };
|
|
7
|
+
|
|
8
|
+
export type Edit = { line: number; column: number; ops: EditOp[] };
|
|
9
|
+
|
|
10
|
+
export class NoOpEditError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super(
|
|
13
|
+
'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.',
|
|
14
|
+
);
|
|
15
|
+
this.name = 'NoOpEditError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useEditor(slideId: string) {
|
|
20
|
+
const applyEdit = useCallback(
|
|
21
|
+
async (line: number, column: number, ops: EditOp[]) => {
|
|
22
|
+
const res = await fetch('/__edit', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'content-type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ slideId, line, column, ops }),
|
|
26
|
+
});
|
|
27
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string; changed?: boolean };
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
throw new Error(body.error ?? `POST /__edit → ${res.status}`);
|
|
30
|
+
}
|
|
31
|
+
if (body.changed === false) {
|
|
32
|
+
throw new NoOpEditError();
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
[slideId],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Batch many element edits into one file write and one HMR tick.
|
|
39
|
+
const applyEdits = useCallback(
|
|
40
|
+
async (edits: Edit[]) => {
|
|
41
|
+
if (edits.length === 0) return;
|
|
42
|
+
const res = await fetch('/__edit/batch', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'content-type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ slideId, edits }),
|
|
46
|
+
});
|
|
47
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
48
|
+
error?: string;
|
|
49
|
+
changed?: boolean;
|
|
50
|
+
results?: Array<{ ok: boolean; error?: string }>;
|
|
51
|
+
};
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
throw new Error(body.error ?? `POST /__edit/batch → ${res.status}`);
|
|
54
|
+
}
|
|
55
|
+
const failed = body.results?.find((r) => !r.ok);
|
|
56
|
+
if (failed?.error) throw new Error(failed.error);
|
|
57
|
+
},
|
|
58
|
+
[slideId],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return { applyEdit, applyEdits };
|
|
62
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Helpers used by the PDF export flow to wait for the page to settle before
|
|
2
|
+
// invoking window.print(). Browser-only — no Node / headless dependency.
|
|
3
|
+
|
|
4
|
+
const DEFAULT_WAITFOR_TIMEOUT_MS = 10_000;
|
|
5
|
+
|
|
6
|
+
export async function waitForFonts(): Promise<void> {
|
|
7
|
+
if (!('fonts' in document)) return;
|
|
8
|
+
await document.fonts.ready;
|
|
9
|
+
const pending: Promise<unknown>[] = [];
|
|
10
|
+
for (const face of document.fonts) {
|
|
11
|
+
if (face.status !== 'loaded') pending.push(face.load());
|
|
12
|
+
}
|
|
13
|
+
if (pending.length) {
|
|
14
|
+
await Promise.all(pending.map((p) => p.catch(() => undefined)));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function waitForDataWaitfor(
|
|
19
|
+
root: HTMLElement,
|
|
20
|
+
timeoutMs = DEFAULT_WAITFOR_TIMEOUT_MS,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const targets = Array.from(root.querySelectorAll<HTMLElement>('[data-waitfor]'));
|
|
23
|
+
if (targets.length === 0) return;
|
|
24
|
+
const deadline = performance.now() + timeoutMs;
|
|
25
|
+
await Promise.all(
|
|
26
|
+
targets.map(async (el) => {
|
|
27
|
+
const selector = el.getAttribute('data-waitfor');
|
|
28
|
+
if (!selector) return;
|
|
29
|
+
while (performance.now() < deadline) {
|
|
30
|
+
try {
|
|
31
|
+
if (el.querySelector(selector)) return;
|
|
32
|
+
} catch {
|
|
33
|
+
return; // invalid selector — skip rather than hang
|
|
34
|
+
}
|
|
35
|
+
await nextFrame();
|
|
36
|
+
}
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Returns true if `frame` has no running finite-iteration animations. */
|
|
42
|
+
export function isFrameAnimationSettled(frame: Element): boolean {
|
|
43
|
+
if (typeof document.getAnimations !== 'function') return true;
|
|
44
|
+
for (const anim of document.getAnimations()) {
|
|
45
|
+
const effect = anim.effect as KeyframeEffect | null;
|
|
46
|
+
if (!effect) continue;
|
|
47
|
+
const target = effect.target;
|
|
48
|
+
if (!target || !frame.contains(target)) continue;
|
|
49
|
+
const timing = effect.getComputedTiming();
|
|
50
|
+
if (timing.iterations === Infinity) continue;
|
|
51
|
+
if (anim.playState !== 'finished') return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nextFrame(): Promise<void> {
|
|
57
|
+
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
58
|
+
}
|
package/src/app/routes/Slide.tsx
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import config from 'virtual:open-slide/config';
|
|
2
|
-
import { ChevronLeft, Download, FileCode2, Loader2, Pencil, Play } from 'lucide-react';
|
|
2
|
+
import { ChevronLeft, Download, FileCode2, FileText, Loader2, Pencil, Play } from 'lucide-react';
|
|
3
3
|
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
|
5
|
+
import { toast } from 'sonner';
|
|
6
|
+
import { AssetView } from '@/components/AssetView';
|
|
5
7
|
import { CommentWidget } from '@/components/inspector/CommentWidget';
|
|
6
8
|
import { InspectOverlay } from '@/components/inspector/InspectOverlay';
|
|
9
|
+
import { InspectorPanel } from '@/components/inspector/InspectorPanel';
|
|
7
10
|
import {
|
|
8
11
|
InspectorProvider,
|
|
9
12
|
InspectToggleButton,
|
|
10
13
|
useInspector,
|
|
11
14
|
} from '@/components/inspector/InspectorProvider';
|
|
15
|
+
import { SaveBar } from '@/components/inspector/SaveBar';
|
|
12
16
|
import { Button, buttonVariants } from '@/components/ui/button';
|
|
13
17
|
import {
|
|
14
18
|
DropdownMenu,
|
|
@@ -16,15 +20,17 @@ import {
|
|
|
16
20
|
DropdownMenuItem,
|
|
17
21
|
DropdownMenuTrigger,
|
|
18
22
|
} from '@/components/ui/dropdown-menu';
|
|
19
|
-
import {
|
|
23
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
20
24
|
import { useFolders } from '@/lib/folders';
|
|
21
25
|
import { useWheelPageNavigation } from '@/lib/useWheelPageNavigation';
|
|
22
26
|
import { cn } from '@/lib/utils';
|
|
23
27
|
import { ClickNavZones } from '../components/ClickNavZones';
|
|
28
|
+
import { PdfProgressToast } from '../components/PdfProgressToast';
|
|
24
29
|
import { Player } from '../components/Player';
|
|
25
30
|
import { SlideCanvas } from '../components/SlideCanvas';
|
|
26
31
|
import { ThumbnailRail } from '../components/ThumbnailRail';
|
|
27
32
|
import { exportSlideAsHtml } from '../lib/export-html';
|
|
33
|
+
import { exportSlideAsPdf } from '../lib/export-pdf';
|
|
28
34
|
import type { SlideModule } from '../lib/sdk';
|
|
29
35
|
import { loadSlide } from '../lib/slides';
|
|
30
36
|
|
|
@@ -60,6 +66,7 @@ export function Slide() {
|
|
|
60
66
|
const pageCount = pages.length;
|
|
61
67
|
const rawIndex = Number(searchParams.get('p') ?? '1') - 1;
|
|
62
68
|
const index = Number.isFinite(rawIndex) ? Math.max(0, Math.min(pageCount - 1, rawIndex)) : 0;
|
|
69
|
+
const view = searchParams.get('view') === 'assets' ? 'assets' : 'slides';
|
|
63
70
|
|
|
64
71
|
const goTo = useCallback(
|
|
65
72
|
(i: number) => {
|
|
@@ -166,15 +173,50 @@ export function Slide() {
|
|
|
166
173
|
<header className="relative flex shrink-0 items-center justify-between border-b bg-card px-3 py-2 md:px-5 md:py-3">
|
|
167
174
|
<div className="flex items-center gap-2 md:gap-3">
|
|
168
175
|
{showSlideBrowser && (
|
|
169
|
-
|
|
170
|
-
<
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
<Button asChild variant="ghost" size="sm" className="px-2 md:px-3">
|
|
177
|
+
<Link to="/">
|
|
178
|
+
<ChevronLeft className="size-4" />
|
|
179
|
+
<span className="hidden md:inline">Home</span>
|
|
180
|
+
</Link>
|
|
181
|
+
</Button>
|
|
182
|
+
)}
|
|
183
|
+
{import.meta.env.DEV && (
|
|
184
|
+
<Tabs
|
|
185
|
+
value={view}
|
|
186
|
+
onValueChange={(next) => {
|
|
187
|
+
setSearchParams(
|
|
188
|
+
(prev) => {
|
|
189
|
+
const params = new URLSearchParams(prev);
|
|
190
|
+
if (next === 'assets') params.set('view', 'assets');
|
|
191
|
+
else params.delete('view');
|
|
192
|
+
return params;
|
|
193
|
+
},
|
|
194
|
+
{ replace: true },
|
|
195
|
+
);
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
<TabsList className="relative h-7 rounded-md p-0.5 group-data-[orientation=horizontal]/tabs:h-7">
|
|
199
|
+
<div
|
|
200
|
+
aria-hidden
|
|
201
|
+
className="pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-2px)] rounded-[5px] bg-background shadow-sm transition-transform duration-200 ease-out"
|
|
202
|
+
style={{
|
|
203
|
+
transform: view === 'assets' ? 'translateX(100%)' : 'translateX(0)',
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
<TabsTrigger
|
|
207
|
+
value="slides"
|
|
208
|
+
className="relative z-10 h-6 px-3 text-xs data-[state=active]:bg-transparent data-[state=active]:shadow-none dark:data-[state=active]:bg-transparent"
|
|
209
|
+
>
|
|
210
|
+
Slides
|
|
211
|
+
</TabsTrigger>
|
|
212
|
+
<TabsTrigger
|
|
213
|
+
value="assets"
|
|
214
|
+
className="relative z-10 h-6 px-3 text-xs data-[state=active]:bg-transparent data-[state=active]:shadow-none dark:data-[state=active]:bg-transparent"
|
|
215
|
+
>
|
|
216
|
+
Assets
|
|
217
|
+
</TabsTrigger>
|
|
218
|
+
</TabsList>
|
|
219
|
+
</Tabs>
|
|
178
220
|
)}
|
|
179
221
|
</div>
|
|
180
222
|
|
|
@@ -185,7 +227,7 @@ export function Slide() {
|
|
|
185
227
|
</div>
|
|
186
228
|
|
|
187
229
|
<div className="flex items-center gap-1.5">
|
|
188
|
-
{allowHtmlDownload && (
|
|
230
|
+
{view === 'slides' && allowHtmlDownload && (
|
|
189
231
|
<DropdownMenu>
|
|
190
232
|
<DropdownMenuTrigger
|
|
191
233
|
type="button"
|
|
@@ -218,53 +260,100 @@ export function Slide() {
|
|
|
218
260
|
<FileCode2 />
|
|
219
261
|
Download HTML
|
|
220
262
|
</DropdownMenuItem>
|
|
263
|
+
<DropdownMenuItem
|
|
264
|
+
disabled={exporting}
|
|
265
|
+
onSelect={async () => {
|
|
266
|
+
if (!slide || exporting) return;
|
|
267
|
+
setExporting(true);
|
|
268
|
+
const toastId = `pdf-export-${slideId}`;
|
|
269
|
+
toast.custom(
|
|
270
|
+
() => (
|
|
271
|
+
<PdfProgressToast
|
|
272
|
+
progress={{
|
|
273
|
+
phase: 'processing',
|
|
274
|
+
current: 0,
|
|
275
|
+
total: pages.length,
|
|
276
|
+
percent: 0,
|
|
277
|
+
}}
|
|
278
|
+
/>
|
|
279
|
+
),
|
|
280
|
+
{ id: toastId, duration: Infinity },
|
|
281
|
+
);
|
|
282
|
+
try {
|
|
283
|
+
await exportSlideAsPdf(slide, slideId, (p) => {
|
|
284
|
+
toast.custom(() => <PdfProgressToast progress={p} />, {
|
|
285
|
+
id: toastId,
|
|
286
|
+
duration: Infinity,
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
} catch (err) {
|
|
290
|
+
console.error('[open-slide] pdf export failed', err);
|
|
291
|
+
toast.error('PDF export failed', { id: toastId, duration: 4000 });
|
|
292
|
+
} finally {
|
|
293
|
+
setExporting(false);
|
|
294
|
+
toast.dismiss(toastId);
|
|
295
|
+
}
|
|
296
|
+
}}
|
|
297
|
+
>
|
|
298
|
+
<FileText />
|
|
299
|
+
Download PDF
|
|
300
|
+
</DropdownMenuItem>
|
|
221
301
|
</DropdownMenuContent>
|
|
222
302
|
</DropdownMenu>
|
|
223
303
|
)}
|
|
224
|
-
<InspectToggleButton />
|
|
225
|
-
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
304
|
+
{view === 'slides' && <InspectToggleButton />}
|
|
305
|
+
{view === 'slides' && (
|
|
306
|
+
<Button size="sm" onClick={() => setPlaying(true)} className="px-2 md:px-3">
|
|
307
|
+
<Play className="size-4" />
|
|
308
|
+
<span className="hidden md:inline">Play</span>
|
|
309
|
+
<kbd className="ml-1 hidden rounded bg-primary-foreground/20 px-1 text-[10px] md:inline">
|
|
310
|
+
F
|
|
311
|
+
</kbd>
|
|
312
|
+
</Button>
|
|
313
|
+
)}
|
|
232
314
|
</div>
|
|
233
315
|
</header>
|
|
234
316
|
|
|
235
|
-
|
|
236
|
-
<div className="
|
|
237
|
-
<
|
|
317
|
+
{view === 'assets' ? (
|
|
318
|
+
<div className="min-h-0 flex-1">
|
|
319
|
+
<AssetView slideId={slideId} />
|
|
238
320
|
</div>
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
>
|
|
244
|
-
<SlideWheelNavigation
|
|
245
|
-
targetRef={slideViewportRef}
|
|
246
|
-
onPrev={() => goTo(index - 1)}
|
|
247
|
-
onNext={() => goTo(index + 1)}
|
|
248
|
-
canPrev={index > 0}
|
|
249
|
-
canNext={index < pageCount - 1}
|
|
250
|
-
/>
|
|
251
|
-
<SlideCanvas>
|
|
252
|
-
<CurrentPage />
|
|
253
|
-
</SlideCanvas>
|
|
254
|
-
<ClickNavZones
|
|
255
|
-
onPrev={() => goTo(index - 1)}
|
|
256
|
-
onNext={() => goTo(index + 1)}
|
|
257
|
-
canPrev={index > 0}
|
|
258
|
-
canNext={index < pageCount - 1}
|
|
259
|
-
/>
|
|
260
|
-
<InspectOverlay />
|
|
261
|
-
<div className="pointer-events-none absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/50 px-2.5 py-0.5 text-[11px] font-medium tabular-nums text-white backdrop-blur md:hidden">
|
|
262
|
-
{index + 1} / {pageCount}
|
|
321
|
+
) : (
|
|
322
|
+
<div className="flex min-h-0 flex-1">
|
|
323
|
+
<div className="hidden w-[17rem] shrink-0 md:block">
|
|
324
|
+
<ThumbnailRail pages={pages} current={index} onSelect={goTo} />
|
|
263
325
|
</div>
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
326
|
+
<main
|
|
327
|
+
ref={slideViewportRef}
|
|
328
|
+
data-inspector-root
|
|
329
|
+
className="relative min-h-0 min-w-0 flex-1 bg-background p-2 md:p-8"
|
|
330
|
+
>
|
|
331
|
+
<SlideWheelNavigation
|
|
332
|
+
targetRef={slideViewportRef}
|
|
333
|
+
onPrev={() => goTo(index - 1)}
|
|
334
|
+
onNext={() => goTo(index + 1)}
|
|
335
|
+
canPrev={index > 0}
|
|
336
|
+
canNext={index < pageCount - 1}
|
|
337
|
+
/>
|
|
338
|
+
<SlideCanvas>
|
|
339
|
+
<CurrentPage />
|
|
340
|
+
</SlideCanvas>
|
|
341
|
+
<ClickNavZones
|
|
342
|
+
onPrev={() => goTo(index - 1)}
|
|
343
|
+
onNext={() => goTo(index + 1)}
|
|
344
|
+
canPrev={index > 0}
|
|
345
|
+
canNext={index < pageCount - 1}
|
|
346
|
+
/>
|
|
347
|
+
<InspectOverlay />
|
|
348
|
+
<SaveBar />
|
|
349
|
+
<CommentWidget />
|
|
350
|
+
<div className="pointer-events-none absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/50 px-2.5 py-0.5 text-[11px] font-medium tabular-nums text-white backdrop-blur md:hidden">
|
|
351
|
+
{index + 1} / {pageCount}
|
|
352
|
+
</div>
|
|
353
|
+
</main>
|
|
354
|
+
<InspectorPanel />
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
268
357
|
</div>
|
|
269
358
|
</InspectorProvider>
|
|
270
359
|
);
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { createPortal } from 'react-dom';
|
|
3
|
-
import { useInspector } from './InspectorProvider';
|
|
4
|
-
|
|
5
|
-
const POPOVER_W = 320;
|
|
6
|
-
const POPOVER_H = 180;
|
|
7
|
-
|
|
8
|
-
export function CommentPopover() {
|
|
9
|
-
const { pending, setPending, add, cancel } = useInspector();
|
|
10
|
-
const [text, setText] = useState('');
|
|
11
|
-
const [submitting, setSubmitting] = useState(false);
|
|
12
|
-
const [error, setError] = useState<string | null>(null);
|
|
13
|
-
const taRef = useRef<HTMLTextAreaElement>(null);
|
|
14
|
-
|
|
15
|
-
useEffect(() => {
|
|
16
|
-
taRef.current?.focus();
|
|
17
|
-
}, []);
|
|
18
|
-
|
|
19
|
-
if (!pending) return null;
|
|
20
|
-
|
|
21
|
-
const left = clamp(pending.clickX + 12, 8, window.innerWidth - POPOVER_W - 8);
|
|
22
|
-
const top = clamp(pending.clickY + 12, 8, window.innerHeight - POPOVER_H - 8);
|
|
23
|
-
|
|
24
|
-
const onSubmit = async () => {
|
|
25
|
-
const trimmed = text.trim();
|
|
26
|
-
if (!trimmed) return;
|
|
27
|
-
setSubmitting(true);
|
|
28
|
-
try {
|
|
29
|
-
await add(pending.line, pending.column, trimmed);
|
|
30
|
-
setPending(null);
|
|
31
|
-
} catch (e) {
|
|
32
|
-
setError(String((e as Error).message ?? e));
|
|
33
|
-
setSubmitting(false);
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
return createPortal(
|
|
38
|
-
<div
|
|
39
|
-
data-inspector-ui
|
|
40
|
-
className="fixed z-50 rounded-md border bg-card p-3 shadow-xl"
|
|
41
|
-
style={{ left, top, width: POPOVER_W }}
|
|
42
|
-
>
|
|
43
|
-
<div className="mb-2 flex items-center justify-between">
|
|
44
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
45
|
-
Line {pending.line} · Comment
|
|
46
|
-
</span>
|
|
47
|
-
<button
|
|
48
|
-
type="button"
|
|
49
|
-
className="text-xs text-muted-foreground hover:text-foreground"
|
|
50
|
-
onClick={cancel}
|
|
51
|
-
>
|
|
52
|
-
✕
|
|
53
|
-
</button>
|
|
54
|
-
</div>
|
|
55
|
-
<textarea
|
|
56
|
-
ref={taRef}
|
|
57
|
-
value={text}
|
|
58
|
-
onChange={(e) => setText(e.target.value)}
|
|
59
|
-
placeholder="Describe the change…"
|
|
60
|
-
className="h-20 w-full resize-none rounded border bg-background p-2 text-sm outline-none focus:ring-2 focus:ring-primary/40"
|
|
61
|
-
onKeyDown={(e) => {
|
|
62
|
-
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
63
|
-
e.preventDefault();
|
|
64
|
-
onSubmit();
|
|
65
|
-
}
|
|
66
|
-
}}
|
|
67
|
-
/>
|
|
68
|
-
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
|
69
|
-
<div className="mt-2 flex items-center justify-end gap-2">
|
|
70
|
-
<button
|
|
71
|
-
type="button"
|
|
72
|
-
onClick={cancel}
|
|
73
|
-
className="rounded border px-2 py-1 text-xs hover:bg-muted"
|
|
74
|
-
>
|
|
75
|
-
Cancel
|
|
76
|
-
</button>
|
|
77
|
-
<button
|
|
78
|
-
type="button"
|
|
79
|
-
disabled={submitting || !text.trim()}
|
|
80
|
-
onClick={onSubmit}
|
|
81
|
-
className="rounded bg-primary px-3 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50"
|
|
82
|
-
>
|
|
83
|
-
{submitting ? 'Saving…' : 'Submit'}
|
|
84
|
-
</button>
|
|
85
|
-
</div>
|
|
86
|
-
<p className="mt-2 text-[10px] text-muted-foreground">⌘/Ctrl + Enter to submit</p>
|
|
87
|
-
</div>,
|
|
88
|
-
document.body,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function clamp(n: number, lo: number, hi: number): number {
|
|
93
|
-
return Math.max(lo, Math.min(hi, n));
|
|
94
|
-
}
|