ui_storybook/components/editor/
wisp_canvas_host.rs1use leptos::prelude::*;
17
18#[derive(Clone, Debug, PartialEq, Eq, Default)]
20pub enum CanvasBackendView {
21 #[default]
23 CssFallback,
24 WispAsset {
26 asset_path: &'static str,
28 },
29 WispRuntimeUnavailable,
31}
32
33impl CanvasBackendView {
34 #[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#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct DropZoneActionView {
48 pub id: &'static str,
50 pub label: &'static str,
52 pub description: &'static str,
54 pub icon: &'static str,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct RecentClipView {
61 pub id: &'static str,
63 pub title: &'static str,
65 pub duration_label: &'static str,
67 pub thumbnail_css: &'static str,
69}
70
71#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct EditorDropZoneView {
74 pub backend: CanvasBackendView,
76 pub headline: &'static str,
78 pub subtext: &'static str,
80 pub actions: Vec<DropZoneActionView>,
82 pub recent_clips: Vec<RecentClipView>,
84 pub drag_active: bool,
86}
87
88#[component]
89pub fn WispCanvasHost(
90 backend: CanvasBackendView,
92 #[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}