Skip to content

Commit 718b059

Browse files
feat: drag-to-reorder slides in thumbnail panel (#9)
1 parent f6c87bb commit 718b059

4 files changed

Lines changed: 190 additions & 3 deletions

File tree

src/editor/Editor.tsx

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
66

77
import { ParseError, parseDeck } from '@/ir/parse';
88
import { planDeck } from '@/ir/plan';
9+
import { reorderSlide } from '@/ir/source-edit';
910
import type { Brand, Deck, Density, Mode, ThemeRef } from '@/ir/schema';
1011
import { createAsset, assetSrc } from '@/storage/asset-store';
1112
import { getDeck, type StoredDeck, updateDeck } from '@/storage/deck-store';
@@ -164,6 +165,11 @@ export function Editor({ deckId }: Props) {
164165
setSelectedSlide(index);
165166
}, []);
166167

168+
const handleReorderSlide = useCallback((from: number, to: number) => {
169+
setSource((s) => reorderSlide(s, from, to));
170+
setSelectedSlide(to);
171+
}, []);
172+
167173
const handleInsert = useCallback((snippet: string) => {
168174
insertRef.current?.(snippet);
169175
}, []);
@@ -315,6 +321,7 @@ export function Editor({ deckId }: Props) {
315321
deck={result.deck}
316322
selectedSlide={selectedSlide}
317323
onSelectSlide={handleSelectSlide}
324+
onReorderSlide={handleReorderSlide}
318325
/>
319326
) : (
320327
<div className="editor__error">
@@ -426,10 +433,12 @@ function PreviewStage({
426433
deck,
427434
selectedSlide,
428435
onSelectSlide,
436+
onReorderSlide,
429437
}: {
430438
deck: Deck;
431439
selectedSlide: number;
432440
onSelectSlide: (i: number) => void;
441+
onReorderSlide: (from: number, to: number) => void;
433442
}) {
434443
const total = deck.slides.length;
435444
const safeIndex = Math.min(Math.max(selectedSlide, 0), Math.max(total - 1, 0));
@@ -461,7 +470,12 @@ function PreviewStage({
461470
<DeckRenderer deck={visibleDeck} />
462471
</div>
463472
</div>
464-
<ThumbStrip deck={deck} selectedIndex={safeIndex} onSelect={onSelectSlide} />
473+
<ThumbStrip
474+
deck={deck}
475+
selectedIndex={safeIndex}
476+
onSelect={onSelectSlide}
477+
onReorder={onReorderSlide}
478+
/>
465479
</div>
466480
);
467481
}
@@ -470,13 +484,17 @@ function ThumbStrip({
470484
deck,
471485
selectedIndex,
472486
onSelect,
487+
onReorder,
473488
}: {
474489
deck: Deck;
475490
selectedIndex: number;
476491
onSelect: (i: number) => void;
492+
onReorder: (from: number, to: number) => void;
477493
}) {
478494
const total = deck.slides.length;
479495
const activeRef = useRef<HTMLButtonElement>(null);
496+
const [dragIndex, setDragIndex] = useState<number | null>(null);
497+
const [overIndex, setOverIndex] = useState<number | null>(null);
480498

481499
const singles = useMemo<Deck[]>(
482500
() => deck.slides.map((slide) => ({ ...deck, slides: [slide] })),
@@ -510,7 +528,26 @@ function ThumbStrip({
510528
index={i}
511529
single={singles[i]}
512530
active={active}
531+
dragging={dragIndex === i}
532+
over={overIndex === i && dragIndex !== null && dragIndex !== i}
513533
onSelect={onSelect}
534+
onDragStart={() => setDragIndex(i)}
535+
onDragEnter={() => {
536+
if (dragIndex !== null) setOverIndex(i);
537+
}}
538+
onDragOver={(e) => {
539+
if (dragIndex !== null) e.preventDefault();
540+
}}
541+
onDrop={(e) => {
542+
e.preventDefault();
543+
if (dragIndex !== null && dragIndex !== i) onReorder(dragIndex, i);
544+
setDragIndex(null);
545+
setOverIndex(null);
546+
}}
547+
onDragEnd={() => {
548+
setDragIndex(null);
549+
setOverIndex(null);
550+
}}
514551
/>
515552
);
516553
})}
@@ -523,20 +560,58 @@ type ThumbProps = {
523560
index: number;
524561
single: Deck;
525562
active: boolean;
563+
dragging?: boolean;
564+
over?: boolean;
526565
onSelect: (i: number) => void;
566+
onDragStart?: () => void;
567+
onDragEnter?: () => void;
568+
onDragOver?: (e: React.DragEvent<HTMLButtonElement>) => void;
569+
onDrop?: (e: React.DragEvent<HTMLButtonElement>) => void;
570+
onDragEnd?: () => void;
527571
ref?: React.Ref<HTMLButtonElement>;
528572
};
529573

530-
const Thumb = memo(function Thumb({ index, single, active, onSelect, ref }: ThumbProps) {
574+
const Thumb = memo(function Thumb({
575+
index,
576+
single,
577+
active,
578+
dragging,
579+
over,
580+
onSelect,
581+
onDragStart,
582+
onDragEnter,
583+
onDragOver,
584+
onDrop,
585+
onDragEnd,
586+
ref,
587+
}: ThumbProps) {
588+
const cls = [
589+
'thumb-strip__item',
590+
active ? 'thumb-strip__item--active' : '',
591+
dragging ? 'thumb-strip__item--dragging' : '',
592+
over ? 'thumb-strip__item--over' : '',
593+
]
594+
.filter(Boolean)
595+
.join(' ');
531596
return (
532597
<button
533598
ref={ref}
534599
type="button"
535600
role="tab"
536601
aria-selected={active}
537602
aria-label={`Slide ${index + 1}`}
538-
className={`thumb-strip__item${active ? ' thumb-strip__item--active' : ''}`}
603+
className={cls}
539604
onClick={() => onSelect(index)}
605+
draggable
606+
onDragStart={(e) => {
607+
e.dataTransfer.effectAllowed = 'move';
608+
e.dataTransfer.setData('text/plain', String(index));
609+
onDragStart?.();
610+
}}
611+
onDragEnter={onDragEnter}
612+
onDragOver={onDragOver}
613+
onDrop={onDrop}
614+
onDragEnd={onDragEnd}
540615
>
541616
<span className="thumb-strip__num">{String(index + 1).padStart(2, '0')}</span>
542617
<div className="thumb-strip__frame">

src/editor/editor.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,15 @@
870870
background: transparent;
871871
}
872872

873+
.thumb-strip__item--dragging {
874+
opacity: 0.4;
875+
}
876+
877+
.thumb-strip__item--over .thumb-strip__frame {
878+
outline: 2px dashed var(--accent, #6ee7b7);
879+
outline-offset: 2px;
880+
}
881+
873882
.thumb-strip__item {
874883
flex-shrink: 0;
875884
display: flex;

src/ir/source-edit.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import matter from 'gray-matter';
2+
3+
/**
4+
* Split the markdown source into slide sections without losing frontmatter.
5+
* Returns the frontmatter prefix plus an array of slide-section strings, each
6+
* including its leading `::slide` line if present.
7+
*/
8+
function splitSourceIntoSlides(source: string): { prefix: string; slides: string[] } {
9+
const fm = matter(source);
10+
const body = fm.content;
11+
const lines = body.split('\n');
12+
const sections: string[][] = [[]];
13+
for (const line of lines) {
14+
if (/^::slide(\{[^}]*\})?\s*$/.test(line)) {
15+
sections.push([line]);
16+
continue;
17+
}
18+
sections[sections.length - 1].push(line);
19+
}
20+
const slides = sections.map((chunk) => chunk.join('\n'));
21+
// The first chunk may be empty if the very first line is ::slide; that is
22+
// still a slide. Filter out an empty leading chunk only when there is no
23+
// body content at all, otherwise preserve.
24+
const fmPrefix = source.slice(0, source.length - body.length);
25+
return { prefix: fmPrefix, slides };
26+
}
27+
28+
function joinSlides(prefix: string, slides: string[]): string {
29+
// Reassemble with the original frontmatter prefix plus the slide chunks
30+
// joined back with newlines. Each chunk that started with ::slide already
31+
// carries that marker; the very first chunk does not, so we just join.
32+
return prefix + slides.join('\n').replace(/\n+$/, '\n');
33+
}
34+
35+
/**
36+
* Move slide at `from` index so it appears at `to` index. Pure: same input,
37+
* same output. Returns the new source string. Out-of-range indices are no-ops.
38+
*/
39+
export function reorderSlide(source: string, from: number, to: number): string {
40+
if (from === to) return source;
41+
const { prefix, slides } = splitSourceIntoSlides(source);
42+
if (from < 0 || from >= slides.length) return source;
43+
if (to < 0 || to >= slides.length) return source;
44+
const next = slides.slice();
45+
const [moved] = next.splice(from, 1);
46+
next.splice(to, 0, moved);
47+
return joinSlides(prefix, next);
48+
}
49+
50+
export function countSlides(source: string): number {
51+
return splitSourceIntoSlides(source).slides.length;
52+
}

tests/ir/source-edit.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { reorderSlide, countSlides } from '@/ir/source-edit';
4+
5+
describe('source-edit', () => {
6+
const sample = `---
7+
title: Demo
8+
---
9+
10+
# Slide A
11+
12+
::slide
13+
14+
# Slide B
15+
16+
::slide
17+
18+
# Slide C
19+
`;
20+
21+
it('counts slides correctly', () => {
22+
expect(countSlides(sample)).toBe(3);
23+
});
24+
25+
it('moves the first slide to the end', () => {
26+
const next = reorderSlide(sample, 0, 2);
27+
expect(next).toContain('# Slide B');
28+
const idxA = next.indexOf('# Slide A');
29+
const idxC = next.indexOf('# Slide C');
30+
expect(idxC).toBeLessThan(idxA);
31+
});
32+
33+
it('moves the last slide to the start', () => {
34+
const next = reorderSlide(sample, 2, 0);
35+
expect(next.indexOf('# Slide C')).toBeLessThan(next.indexOf('# Slide A'));
36+
});
37+
38+
it('is a no-op when from === to', () => {
39+
expect(reorderSlide(sample, 1, 1)).toBe(sample);
40+
});
41+
42+
it('is a no-op for out-of-range indices', () => {
43+
expect(reorderSlide(sample, 5, 0)).toBe(sample);
44+
expect(reorderSlide(sample, 0, -1)).toBe(sample);
45+
});
46+
47+
it('preserves frontmatter', () => {
48+
const next = reorderSlide(sample, 0, 2);
49+
expect(next).toMatch(/^---\ntitle: Demo\n---/);
50+
});
51+
});

0 commit comments

Comments
 (0)