Skip to content

Commit aa66fce

Browse files
committed
fix(bolt-slides): dock export, grid delete, and presenter page sync
Move export beside notes, hide the rail on Play, add an editor-only grid trash overlay, and keep present in lockstep with the console.
1 parent 4555bf7 commit aa66fce

7 files changed

Lines changed: 145 additions & 40 deletions

File tree

bolt-slides/src/deck/Deck.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,18 @@ export default function Deck({
229229
useDeckHashSync({ slideIndex, slideCount, go, skipHash });
230230

231231
const isLeader = isPresenter || !!onExit;
232+
const applyBroadcastIndex = useCallback(
233+
(index: number) => {
234+
setSlideIndex(index);
235+
syncStoreCurrent(index);
236+
},
237+
[syncStoreCurrent]
238+
);
232239
useDeckBroadcastSync({
233240
slideIndex,
234241
clicks,
235242
isLeader,
236-
setSlideIndex,
243+
setSlideIndex: applyBroadcastIndex,
237244
setClicks,
238245
});
239246

bolt-slides/src/deck/Dock.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import {
1616
IconClose,
1717
IconExpand,
18+
IconExport,
1819
IconGrid,
1920
IconGrip,
2021
IconLeft,
@@ -326,6 +327,17 @@ const Dock = forwardRef<HTMLDivElement, DockProps>(function Dock(
326327
<IconRight />
327328
</button>
328329
{(isEditor || isPresentSurface) && <span className="noir-sep" />}
330+
{isEditor && exportItems && (
331+
<MenuButton
332+
label="Export as…"
333+
buttonClassName="noir-icon-btn"
334+
tip={exportBusy ? undefined : 'Export as PDF or JSON'}
335+
disabled={!!exportBusy}
336+
items={exportItems}
337+
>
338+
<IconExport />
339+
</MenuButton>
340+
)}
329341
{isEditor && onNotes && (
330342
<button
331343
ref={notesBtnRef}
@@ -390,15 +402,6 @@ const Dock = forwardRef<HTMLDivElement, DockProps>(function Dock(
390402
<IconPlay />
391403
</button>
392404
)}
393-
{isEditor && exportItems && (
394-
<MenuButton
395-
label="Export as…"
396-
buttonClassName="noir-chip"
397-
tip={exportBusy ? undefined : 'Export the deck as PDF or JSON'}
398-
disabled={!!exportBusy}
399-
items={exportItems}
400-
/>
401-
)}
402405
{onBack && (
403406
<>
404407
<span className="noir-sep" />

bolt-slides/src/deck/SlideBrowser.tsx

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { useStore } from '../data/store';
2323
import ContextMenu, { type MenuItem } from '../edit/ContextMenu';
2424
import SlideView from '../slide/SlideView';
2525
import Thumb from './Thumb';
26-
import { IconClose } from './icons';
26+
import { IconClose, IconTrash } from './icons';
2727
import { useScrolled } from './deckHooks';
2828
import { STAGE_LAYOUT_TRANSITION } from './stageLayout';
2929

@@ -160,6 +160,7 @@ function SortableThumb({
160160
onOpen,
161161
onKeyDown,
162162
onMenu,
163+
onDelete,
163164
}: {
164165
slide: SlideData;
165166
index: number;
@@ -173,6 +174,7 @@ function SortableThumb({
173174
onOpen?: () => void;
174175
onKeyDown?: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
175176
onMenu: (event: React.MouseEvent) => void;
177+
onDelete?: () => void;
176178
}) {
177179
const {
178180
attributes,
@@ -187,34 +189,50 @@ function SortableThumb({
187189
const label = grid
188190
? `Slide ${index + 1}${name ? ' — ' + name : ''}. Double-click to open.`
189191
: `Go to slide ${index + 1}${name ? ' — ' + name : ''}`;
192+
const showTrash = !!(onDelete && grid && selected);
193+
const dragStyle = {
194+
transform: CSS.Transform.toString(transform),
195+
transition,
196+
opacity: isDragging ? 0.55 : 1,
197+
};
190198

191199
return (
192-
<button
193-
ref={(node) => {
194-
setNodeRef(node);
195-
thumbRef?.(node);
196-
}}
197-
type="button"
198-
style={{
199-
transform: CSS.Transform.toString(transform),
200-
transition,
201-
opacity: isDragging ? 0.55 : 1,
202-
}}
203-
className={thumbClass(grid, active, selected)}
204-
aria-label={label}
205-
aria-current={active ? 'true' : undefined}
206-
{...attributes}
207-
{...listeners}
208-
tabIndex={tabIndex}
209-
role={grid ? 'option' : undefined}
210-
aria-selected={grid ? selected : undefined}
211-
onClick={onPick}
212-
onDoubleClick={onOpen}
213-
onKeyDown={onKeyDown}
214-
onContextMenu={onMenu}
215-
>
216-
<ThumbBody slide={slide} index={index} grid={grid} />
217-
</button>
200+
<div ref={setNodeRef} className="noir-thumb-shell" style={dragStyle}>
201+
<button
202+
ref={thumbRef}
203+
type="button"
204+
className={thumbClass(grid, active, selected)}
205+
aria-label={label}
206+
aria-current={active ? 'true' : undefined}
207+
{...attributes}
208+
{...listeners}
209+
tabIndex={tabIndex}
210+
role={grid ? 'option' : undefined}
211+
aria-selected={grid ? selected : undefined}
212+
onClick={onPick}
213+
onDoubleClick={onOpen}
214+
onKeyDown={onKeyDown}
215+
onContextMenu={onMenu}
216+
>
217+
<ThumbBody slide={slide} index={index} grid={grid} />
218+
</button>
219+
{showTrash && (
220+
<button
221+
type="button"
222+
className="noir-thumb-trash"
223+
data-tip="Delete"
224+
aria-label={`Delete slide ${index + 1}`}
225+
onPointerDown={(event) => event.stopPropagation()}
226+
onClick={(event) => {
227+
event.preventDefault();
228+
event.stopPropagation();
229+
onDelete();
230+
}}
231+
>
232+
<IconTrash />
233+
</button>
234+
)}
235+
</div>
218236
);
219237
}
220238

@@ -223,6 +241,7 @@ export default function SlideBrowser({
223241
current,
224242
browse,
225243
mutable = false,
244+
canDelete = false,
226245
navLabel,
227246
onGo,
228247
onClose,
@@ -231,6 +250,8 @@ export default function SlideBrowser({
231250
current: number;
232251
browse: BrowseMode;
233252
mutable?: boolean;
253+
/** Editor-only: trash on the selected grid thumb. Hidden in present/published. */
254+
canDelete?: boolean;
234255
navLabel?: (index: number) => string | undefined;
235256
onGo: (index: number) => void;
236257
onClose: () => void;
@@ -388,6 +409,9 @@ export default function SlideBrowser({
388409
onOpen={onOpen}
389410
onKeyDown={onKeyDown}
390411
onMenu={(event) => onMenu(event, slide.id)}
412+
onDelete={
413+
canDelete && grid ? () => deleteSlide(slide.id) : undefined
414+
}
391415
/>
392416
);
393417
});

bolt-slides/src/deck/icons.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,23 @@ export const IconGrip = () => (
7070
<circle cx="15" cy="17.5" r="1.15" />
7171
</svg>
7272
);
73+
export const IconExport = () => (
74+
<svg {...base}>
75+
<path d="M12 3v12M8 7l4-4 4 4" />
76+
<path d="M5 14v5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5" />
77+
</svg>
78+
);
7379
export const IconNotes = () => (
7480
<svg {...base}>
7581
<rect x="4" y="4" width="16" height="16" rx="3" />
7682
<path d="M8 9.5h8M8 13h8M8 16.5h4.5" />
7783
</svg>
7884
);
85+
export const IconTrash = () => (
86+
<svg {...base}>
87+
<path d="M5 7h14M10 7V5h4v2M6 7l1 13h10l1-13" />
88+
</svg>
89+
);
7990
export const IconPlay = () => (
8091
<svg {...base} fill="currentColor" stroke="none">
8192
<path d="M8 5.14v13.72c0 .9 1 1.45 1.77.97l10.4-6.86a1.15 1.15 0 0 0 0-1.94L9.77 4.17C9 3.69 8 4.24 8 5.14Z" />

bolt-slides/src/edit/EditorApp.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export default function EditorApp() {
3535
);
3636
const closeBrowse = useCallback(() => setBrowse('none'), []);
3737

38+
useEffect(() => {
39+
if (presenting) setBrowse('none');
40+
}, [presenting]);
41+
3842
useEffect(() => {
3943
useStore.getState().load();
4044
}, []);
@@ -81,6 +85,7 @@ export default function EditorApp() {
8185
current={current}
8286
browse={browse}
8387
mutable
88+
canDelete
8489
onGo={setCurrent}
8590
onClose={closeBrowse}
8691
/>

bolt-slides/src/edit/MenuButton.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
/* WAI-ARIA APG menu button — opens a role=menu, roving tabindex, and
22
returns focus to the button on Escape. Tab leaves the widget. */
3-
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
3+
import {
4+
useEffect,
5+
useId,
6+
useLayoutEffect,
7+
useRef,
8+
useState,
9+
type ReactNode,
10+
} from 'react';
411

512
export interface MenuButtonItem {
613
id: string;
@@ -15,12 +22,14 @@ export default function MenuButton({
1522
disabled,
1623
tip,
1724
buttonClassName = 'ghost-btn',
25+
children,
1826
}: {
1927
label: string;
2028
items: MenuButtonItem[];
2129
disabled?: boolean;
2230
tip?: string;
2331
buttonClassName?: string;
32+
children?: ReactNode;
2433
}) {
2534
const uid = useId();
2635
const buttonId = `${uid}-btn`;
@@ -148,16 +157,17 @@ export default function MenuButton({
148157
ref={btnRef}
149158
id={buttonId}
150159
type="button"
151-
className={buttonClassName}
160+
className={buttonClassName + (open ? ' on' : '')}
152161
data-tip={tip}
153162
disabled={disabled}
163+
aria-label={label}
154164
aria-haspopup="menu"
155165
aria-expanded={open}
156166
aria-controls={menuId}
157167
onClick={() => (open ? close(true) : openAt(enabled[0] ?? 0))}
158168
onKeyDown={onButtonKey}
159169
>
160-
{label}
170+
{children ?? label}
161171
</button>
162172
{open && (
163173
<div

bolt-slides/src/styles/base.css

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1898,6 +1898,9 @@ strong {
18981898
background: var(--ed-divider);
18991899
margin: 0 4px;
19001900
}
1901+
.noir-bar .dl-wrap {
1902+
flex: none;
1903+
}
19011904
.noir-counter {
19021905
display: flex;
19031906
align-items: baseline;
@@ -2132,6 +2135,48 @@ strong {
21322135
.noir-thumb-grid .noir-thumb-no {
21332136
width: auto;
21342137
}
2138+
.noir-thumb-shell {
2139+
position: relative;
2140+
min-width: 0;
2141+
width: 100%;
2142+
}
2143+
.noir-thumb-shell .noir-thumb {
2144+
width: 100%;
2145+
}
2146+
.noir-thumb-shell > .noir-thumb-trash {
2147+
/* Beat `[data-tip] { position: relative }` so this stays an overlay. */
2148+
position: absolute;
2149+
top: 8px;
2150+
right: 8px;
2151+
z-index: 2;
2152+
width: 28px;
2153+
height: 28px;
2154+
display: grid;
2155+
place-items: center;
2156+
border: none;
2157+
border-radius: 50%;
2158+
background: rgb(17 17 20 / 0.78);
2159+
color: var(--ed-text);
2160+
cursor: pointer;
2161+
backdrop-filter: blur(8px);
2162+
-webkit-backdrop-filter: blur(8px);
2163+
}
2164+
.noir-thumb-shell > .noir-thumb-trash:hover {
2165+
background: var(--ed-danger);
2166+
color: var(--ed-on-danger);
2167+
}
2168+
.noir-thumb-shell > .noir-thumb-trash svg {
2169+
width: 14px;
2170+
height: 14px;
2171+
}
2172+
.noir-thumb-shell > .noir-thumb-trash[data-tip]::after {
2173+
bottom: auto;
2174+
top: calc(100% + 6px);
2175+
transform: translateX(-50%) translateY(-4px);
2176+
}
2177+
.noir-thumb-shell > .noir-thumb-trash[data-tip]:hover::after {
2178+
transform: translateX(-50%) translateY(0);
2179+
}
21352180
@media (max-width: 700px) {
21362181
.noir-grid-list {
21372182
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));

0 commit comments

Comments
 (0)