Skip to main content

ui_storybook/components/editor/
wisp_canvas_host.rs

1//! `WispCanvasHost` + `EditorDropZoneCanvas` (M-UI.17 / AUT-137).
2//!
3//! Editor canvas region. The Leptos component renders a host that
4//! picks one of three backends:
5//!
6//! - `CssFallback` — dotted drop-zone over the checkered editor canvas
7//!   (SSR-stable, used in the mdBook screenshot).
8//! - `WispAsset { asset_path }` — a deterministic Wisp-generated PNG
9//!   committed under `_docs/book/src/assets/ui/`. Loaded via `<img>`.
10//! - `WispRuntimeUnavailable` — banner explaining that runtime Wisp
11//!   embedding in CSR isn't wired yet.
12//!
13//! The component never touches `wgpu` directly; it just renders the
14//! backend the parent picked.
15
16use leptos::prelude::*;
17
18/// Which canvas backend to render.
19#[derive(Clone, Debug, PartialEq, Eq, Default)]
20pub enum CanvasBackendView {
21    /// CSS dotted drop-zone over the checkered editor canvas.
22    #[default]
23    CssFallback,
24    /// Pre-rendered Wisp asset (PNG path relative to the chapter).
25    WispAsset {
26        /// Asset path (e.g. `"../../assets/ui/editor-canvas-wisp.png"`).
27        asset_path: &'static str,
28    },
29    /// Wisp runtime is not available in the current environment.
30    WispRuntimeUnavailable,
31}
32
33impl CanvasBackendView {
34    /// Stable kebab-case slug.
35    #[must_use]
36    pub fn slug(&self) -> &'static str {
37        match self {
38            CanvasBackendView::CssFallback => "css-fallback",
39            CanvasBackendView::WispAsset { .. } => "wisp-asset",
40            CanvasBackendView::WispRuntimeUnavailable => "wisp-runtime-unavailable",
41        }
42    }
43}
44
45/// One drop-zone action card.
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct DropZoneActionView {
48    /// Stable id (`"record"`, `"library"`, `"file"`).
49    pub id: &'static str,
50    /// Display label.
51    pub label: &'static str,
52    /// Description shown beneath the label.
53    pub description: &'static str,
54    /// Leading glyph.
55    pub icon: &'static str,
56}
57
58/// One entry in the recent-clip strip.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct RecentClipView {
61    /// Stable id (file uuid).
62    pub id: &'static str,
63    /// Display title.
64    pub title: &'static str,
65    /// Duration label ("1m 24s").
66    pub duration_label: &'static str,
67    /// CSS gradient string for the thumbnail.
68    pub thumbnail_css: &'static str,
69}
70
71/// Drop-zone view-model.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct EditorDropZoneView {
74    /// Backend selection.
75    pub backend: CanvasBackendView,
76    /// Headline (`"Drop a clip to start editing"`).
77    pub headline: &'static str,
78    /// Subtext under the headline (often contains a shortcut hint).
79    pub subtext: &'static str,
80    /// Action cards in display order.
81    pub actions: Vec<DropZoneActionView>,
82    /// Recent clips strip below the actions. Empty hides the strip.
83    pub recent_clips: Vec<RecentClipView>,
84    /// `true` to render the active drag overlay (file hover).
85    pub drag_active: bool,
86}
87
88#[component]
89pub fn WispCanvasHost(
90    /// Backend selection (CSS fallback / Wisp asset / unavailable).
91    backend: CanvasBackendView,
92    /// Optional aria-label.
93    #[prop(optional, default = "Canvas")]
94    aria_label: &'static str,
95) -> impl IntoView {
96    let class = format!("wisp-canvas-host wisp-canvas-host-{}", backend.slug());
97    let body = match backend {
98        CanvasBackendView::CssFallback => view! {
99            <div class="wisp-canvas-css">
100                <span class="wisp-canvas-css-label">"CSS fallback canvas"</span>
101            </div>
102        }
103        .into_any(),
104        CanvasBackendView::WispAsset { asset_path } => view! {
105            <img class="wisp-canvas-asset" src=asset_path alt="Wisp-generated canvas preview" />
106        }
107        .into_any(),
108        CanvasBackendView::WispRuntimeUnavailable => view! {
109            <div class="wisp-canvas-banner">
110                <span class="wisp-canvas-banner-glyph">"⚠"</span>
111                <span class="wisp-canvas-banner-message">
112                    "Wisp runtime not available in this build. Falling back to CSS preview."
113                </span>
114            </div>
115        }
116        .into_any(),
117    };
118    view! {
119        <div class=class role="img" aria-label=aria_label>
120            {body}
121        </div>
122    }
123}
124
125#[component]
126pub fn EditorDropZoneCanvas(view: EditorDropZoneView) -> impl IntoView {
127    let EditorDropZoneView {
128        backend,
129        headline,
130        subtext,
131        actions,
132        recent_clips,
133        drag_active,
134    } = view;
135    let mut class = String::from("editor-drop-zone");
136    if drag_active {
137        class.push_str(" editor-drop-zone-active");
138    }
139    let action_cards: Vec<_> = actions
140        .into_iter()
141        .map(|a| {
142            view! {
143                <button class="editor-drop-zone-action" data-id=a.id>
144                    <span class="editor-drop-zone-action-icon" aria-hidden="true">{a.icon}</span>
145                    <span class="editor-drop-zone-action-label">{a.label}</span>
146                    <span class="editor-drop-zone-action-desc">{a.description}</span>
147                </button>
148            }
149        })
150        .collect();
151    let clip_strip = (!recent_clips.is_empty()).then(|| {
152        let cards: Vec<_> = recent_clips
153            .iter()
154            .map(|c| {
155                let style = format!("background: {};", c.thumbnail_css);
156                view! {
157                    <button class="editor-recent-clip" data-id=c.id>
158                        <span class="editor-recent-clip-thumb" style=style></span>
159                        <span class="editor-recent-clip-meta">
160                            <span class="editor-recent-clip-title">{c.title}</span>
161                            <span class="editor-recent-clip-duration">{c.duration_label}</span>
162                        </span>
163                    </button>
164                }
165            })
166            .collect();
167        view! {
168            <div class="editor-recent-clips" aria-label="Recent clips">
169                <span class="editor-recent-clips-heading">"RECENT CLIPS"</span>
170                <div class="editor-recent-clips-strip">{cards}</div>
171            </div>
172        }
173    });
174    let backend_slug = backend.slug();
175    view! {
176        <section class=class data-backend=backend_slug role="region" aria-label="Drop a clip">
177            <WispCanvasHost backend=backend aria_label="Editor canvas" />
178            <div class="editor-drop-zone-content">
179                <span class="editor-drop-zone-icon" aria-hidden="true">"⬆"</span>
180                <span class="editor-drop-zone-headline">{headline}</span>
181                <span class="editor-drop-zone-subtext">{subtext}</span>
182                <div class="editor-drop-zone-actions">{action_cards}</div>
183                {clip_strip}
184            </div>
185        </section>
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn backend_slugs_kebab_unique() {
195        let slugs = [
196            CanvasBackendView::CssFallback.slug(),
197            CanvasBackendView::WispAsset {
198                asset_path: "/x.png",
199            }
200            .slug(),
201            CanvasBackendView::WispRuntimeUnavailable.slug(),
202        ];
203        let mut sorted = slugs.to_vec();
204        sorted.sort_unstable();
205        sorted.dedup();
206        assert_eq!(sorted.len(), slugs.len());
207        for s in slugs {
208            assert!(s.chars().all(|c| c.is_ascii_lowercase() || c == '-'));
209        }
210    }
211}