Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 78 additions & 3 deletions src/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';

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

const handleReorderSlide = useCallback((from: number, to: number) => {
setSource((s) => reorderSlide(s, from, to));
setSelectedSlide(to);
}, []);

const handleInsert = useCallback((snippet: string) => {
insertRef.current?.(snippet);
}, []);
Expand Down Expand Up @@ -315,6 +321,7 @@ export function Editor({ deckId }: Props) {
deck={result.deck}
selectedSlide={selectedSlide}
onSelectSlide={handleSelectSlide}
onReorderSlide={handleReorderSlide}
/>
) : (
<div className="editor__error">
Expand Down Expand Up @@ -426,10 +433,12 @@ function PreviewStage({
deck,
selectedSlide,
onSelectSlide,
onReorderSlide,
}: {
deck: Deck;
selectedSlide: number;
onSelectSlide: (i: number) => void;
onReorderSlide: (from: number, to: number) => void;
}) {
const total = deck.slides.length;
const safeIndex = Math.min(Math.max(selectedSlide, 0), Math.max(total - 1, 0));
Expand Down Expand Up @@ -461,7 +470,12 @@ function PreviewStage({
<DeckRenderer deck={visibleDeck} />
</div>
</div>
<ThumbStrip deck={deck} selectedIndex={safeIndex} onSelect={onSelectSlide} />
<ThumbStrip
deck={deck}
selectedIndex={safeIndex}
onSelect={onSelectSlide}
onReorder={onReorderSlide}
/>
</div>
);
}
Expand All @@ -470,13 +484,17 @@ function ThumbStrip({
deck,
selectedIndex,
onSelect,
onReorder,
}: {
deck: Deck;
selectedIndex: number;
onSelect: (i: number) => void;
onReorder: (from: number, to: number) => void;
}) {
const total = deck.slides.length;
const activeRef = useRef<HTMLButtonElement>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);

const singles = useMemo<Deck[]>(
() => deck.slides.map((slide) => ({ ...deck, slides: [slide] })),
Expand Down Expand Up @@ -510,7 +528,26 @@ function ThumbStrip({
index={i}
single={singles[i]}
active={active}
dragging={dragIndex === i}
over={overIndex === i && dragIndex !== null && dragIndex !== i}
onSelect={onSelect}
onDragStart={() => setDragIndex(i)}
onDragEnter={() => {
if (dragIndex !== null) setOverIndex(i);
}}
onDragOver={(e) => {
if (dragIndex !== null) e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
if (dragIndex !== null && dragIndex !== i) onReorder(dragIndex, i);
setDragIndex(null);
setOverIndex(null);
}}
onDragEnd={() => {
setDragIndex(null);
setOverIndex(null);
}}
/>
);
})}
Expand All @@ -523,20 +560,58 @@ type ThumbProps = {
index: number;
single: Deck;
active: boolean;
dragging?: boolean;
over?: boolean;
onSelect: (i: number) => void;
onDragStart?: () => void;
onDragEnter?: () => void;
onDragOver?: (e: React.DragEvent<HTMLButtonElement>) => void;
onDrop?: (e: React.DragEvent<HTMLButtonElement>) => void;
onDragEnd?: () => void;
ref?: React.Ref<HTMLButtonElement>;
};

const Thumb = memo(function Thumb({ index, single, active, onSelect, ref }: ThumbProps) {
const Thumb = memo(function Thumb({
index,
single,
active,
dragging,
over,
onSelect,
onDragStart,
onDragEnter,
onDragOver,
onDrop,
onDragEnd,
ref,
}: ThumbProps) {
const cls = [
'thumb-strip__item',
active ? 'thumb-strip__item--active' : '',
dragging ? 'thumb-strip__item--dragging' : '',
over ? 'thumb-strip__item--over' : '',
]
.filter(Boolean)
.join(' ');
return (
<button
ref={ref}
type="button"
role="tab"
aria-selected={active}
aria-label={`Slide ${index + 1}`}
className={`thumb-strip__item${active ? ' thumb-strip__item--active' : ''}`}
className={cls}
onClick={() => onSelect(index)}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
onDragStart?.();
}}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
>
<span className="thumb-strip__num">{String(index + 1).padStart(2, '0')}</span>
<div className="thumb-strip__frame">
Expand Down
9 changes: 9 additions & 0 deletions src/editor/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,15 @@
background: transparent;
}

.thumb-strip__item--dragging {
opacity: 0.4;
}

.thumb-strip__item--over .thumb-strip__frame {
outline: 2px dashed var(--accent, #6ee7b7);
outline-offset: 2px;
}

.thumb-strip__item {
flex-shrink: 0;
display: flex;
Expand Down
52 changes: 52 additions & 0 deletions src/ir/source-edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import matter from 'gray-matter';

/**
* Split the markdown source into slide sections without losing frontmatter.
* Returns the frontmatter prefix plus an array of slide-section strings, each
* including its leading `::slide` line if present.
*/
function splitSourceIntoSlides(source: string): { prefix: string; slides: string[] } {
const fm = matter(source);
const body = fm.content;
const lines = body.split('\n');
const sections: string[][] = [[]];
for (const line of lines) {
if (/^::slide(\{[^}]*\})?\s*$/.test(line)) {
sections.push([line]);
continue;
}
sections[sections.length - 1].push(line);
}
const slides = sections.map((chunk) => chunk.join('\n'));
// The first chunk may be empty if the very first line is ::slide; that is
// still a slide. Filter out an empty leading chunk only when there is no
// body content at all, otherwise preserve.
const fmPrefix = source.slice(0, source.length - body.length);
return { prefix: fmPrefix, slides };
}

function joinSlides(prefix: string, slides: string[]): string {
// Reassemble with the original frontmatter prefix plus the slide chunks
// joined back with newlines. Each chunk that started with ::slide already
// carries that marker; the very first chunk does not, so we just join.
return prefix + slides.join('\n').replace(/\n+$/, '\n');
}

/**
* Move slide at `from` index so it appears at `to` index. Pure: same input,
* same output. Returns the new source string. Out-of-range indices are no-ops.
*/
export function reorderSlide(source: string, from: number, to: number): string {
if (from === to) return source;
const { prefix, slides } = splitSourceIntoSlides(source);
if (from < 0 || from >= slides.length) return source;
if (to < 0 || to >= slides.length) return source;
const next = slides.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return joinSlides(prefix, next);
}

export function countSlides(source: string): number {
return splitSourceIntoSlides(source).slides.length;
}
51 changes: 51 additions & 0 deletions tests/ir/source-edit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';

import { reorderSlide, countSlides } from '@/ir/source-edit';

describe('source-edit', () => {
const sample = `---
title: Demo
---

# Slide A

::slide

# Slide B

::slide

# Slide C
`;

it('counts slides correctly', () => {
expect(countSlides(sample)).toBe(3);
});

it('moves the first slide to the end', () => {
const next = reorderSlide(sample, 0, 2);
expect(next).toContain('# Slide B');
const idxA = next.indexOf('# Slide A');
const idxC = next.indexOf('# Slide C');
expect(idxC).toBeLessThan(idxA);
});

it('moves the last slide to the start', () => {
const next = reorderSlide(sample, 2, 0);
expect(next.indexOf('# Slide C')).toBeLessThan(next.indexOf('# Slide A'));
});

it('is a no-op when from === to', () => {
expect(reorderSlide(sample, 1, 1)).toBe(sample);
});

it('is a no-op for out-of-range indices', () => {
expect(reorderSlide(sample, 5, 0)).toBe(sample);
expect(reorderSlide(sample, 0, -1)).toBe(sample);
});

it('preserves frontmatter', () => {
const next = reorderSlide(sample, 0, 2);
expect(next).toMatch(/^---\ntitle: Demo\n---/);
});
});
Loading