mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 19:17:06 -06:00
Merge PR #479 from NikitkaRa: Web: keep a picked file on the document and slide that asked for it
# Conflicts: # crates/ui-egui/src/lib.rs
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: copy and paste rich text and pictures with other apps; previous: hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (animation preview on the canvas; breadth 79% → 80%, Animations 79% → 85%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Shape fill transparency follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: web file picker keeps its document and slide, #414; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
@@ -220,6 +221,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged |
|
||||
| 2026-10-14 | A trackpad swipe turns the page: sideways when the slide fits the window, and any flick during the slide show. One gesture turns one page. Pinch-zoom is unchanged |
|
||||
| 2026-10-14 | Format Shape: the Fill transparency slider shows the selected solid fill, and leaving it no longer clears that transparency |
|
||||
| 2026-10-13 | Web file picker: a picture, sound, video or outline finishes on the document and slide that asked for it, and is dropped if that presentation was closed or the picker dismissed (#414) |
|
||||
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
||||
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
||||
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
|
||||
@@ -289,6 +291,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | trivial | Progress log: copy and paste rich text and pictures with other apps |
|
||||
| 2026-10-13 | trivial | Progress log: animation preview on the editor canvas; breadth 80%, Animations 85% |
|
||||
| 2026-10-14 | trivial | Progress log: Format Shape fill transparency follows the selection |
|
||||
| 2026-10-13 | trivial | Progress log: web file picker keeps its document and slide (#414) |
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//! Differences from the desktop app:
|
||||
//! - no TCP control channel (browsers can't listen on sockets);
|
||||
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
|
||||
//! asynchronously through `Services::inbox` (presentations open, media is inserted);
|
||||
//! asynchronously through `Services::inbox`, applied to the document, slide and operation
|
||||
//! that started the pick (a dismissed picker or a closed document drops the file);
|
||||
//! - Save and Export trigger a browser download;
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
|
||||
//! - preferences are not persisted.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_ui_egui::{Inbox, Services, SlideApp};
|
||||
use deckcraft_ui_egui::{Inbox, InboxEvent, InboxFile, Services, SlideApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const PRESENTATION_EXTS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "txt", "md"];
|
||||
@@ -77,16 +77,23 @@ impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
// Capture the document and slide now. The bytes arrive later, after the user may
|
||||
// have switched slides.
|
||||
let id = self.app.begin_external_file("drop");
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
push_inbox(&inbox, InboxEvent::File(InboxFile { id, name, bytes }));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("couldn't read dropped file {name}: {e}");
|
||||
push_inbox(&inbox, InboxEvent::Cancelled { id });
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -107,7 +114,7 @@ impl eframe::App for WebShell {
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move |purpose: &str| {
|
||||
open_async: Some(Box::new(move |purpose: &str, request: u64| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
let dialog = match purpose {
|
||||
@@ -118,10 +125,12 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
push_inbox(&inbox, InboxEvent::Cancelled { id: request });
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
push_inbox(&inbox, InboxEvent::File(InboxFile { id: request, name: file.file_name(), bytes }));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
@@ -136,6 +145,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
fn push_inbox(inbox: &Inbox, event: InboxEvent) {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(event);
|
||||
}
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! #414: a browser file pick finishes on the document, slide and operation that started it.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use deckcraft_model::ShapeKind;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Inbox, InboxEvent, InboxFile, Services, SlideApp};
|
||||
use crate::Session;
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
// 1×1 RGB PNG. Enough for `insert.picture` to accept it.
|
||||
const HEX: &str =
|
||||
"89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de0000000c49444154789c63f8cfc0000003010100c9fe92ef0000000049454e44ae426082";
|
||||
(0..HEX.len()).step_by(2).map(|i| u8::from_str_radix(&HEX[i..i + 2], 16).expect("hex")).collect()
|
||||
}
|
||||
|
||||
fn wav() -> Vec<u8> {
|
||||
let data = [0u8; 8];
|
||||
let mut b = b"RIFF".to_vec();
|
||||
b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(b"WAVEfmt ");
|
||||
b.extend_from_slice(&16u32.to_le_bytes());
|
||||
b.extend_from_slice(&1u16.to_le_bytes());
|
||||
b.extend_from_slice(&1u16.to_le_bytes());
|
||||
b.extend_from_slice(&8000u32.to_le_bytes());
|
||||
b.extend_from_slice(&16000u32.to_le_bytes());
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&16u16.to_le_bytes());
|
||||
b.extend_from_slice(b"data");
|
||||
b.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(&data);
|
||||
b
|
||||
}
|
||||
|
||||
fn setup() -> (SlideApp, Inbox, Arc<Mutex<Vec<(String, u64)>>>) {
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let started = Arc::new(Mutex::new(Vec::new()));
|
||||
let record = started.clone();
|
||||
let services = Services {
|
||||
inbox: Some(inbox.clone()),
|
||||
open_async: Some(Box::new(move |purpose, id| {
|
||||
record.lock().expect("request log").push((purpose.to_string(), id));
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(SlideApp::new(Session::with_new(), services), inbox, started)
|
||||
}
|
||||
|
||||
fn deliver(inbox: &Inbox, id: u64, name: &str, bytes: Vec<u8>) {
|
||||
inbox.lock().expect("inbox").push(InboxEvent::File(InboxFile { id, name: name.into(), bytes }));
|
||||
}
|
||||
|
||||
fn pictures(app: &SlideApp, doc: u64, slide: deckcraft_model::SlideId) -> usize {
|
||||
let st = app.session.documents().iter().find(|d| d.uid == doc).expect("document");
|
||||
let i = st.doc.slide_index(slide).expect("slide");
|
||||
st.doc.slides[i].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Picture { .. })).count()
|
||||
}
|
||||
|
||||
fn audios(app: &SlideApp, doc: u64, slide: deckcraft_model::SlideId) -> usize {
|
||||
let st = app.session.documents().iter().find(|d| d.uid == doc).expect("document");
|
||||
let i = st.doc.slide_index(slide).expect("slide");
|
||||
st.doc.slides[i].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Media(ref m) if !m.video)).count()
|
||||
}
|
||||
|
||||
/// Two picks finish in reverse order, after the user has changed slide and document.
|
||||
/// Each file uses the operation that was requested and lands on the slide that asked.
|
||||
#[test]
|
||||
fn late_picker_files_keep_their_document_slide_and_operation() {
|
||||
let (mut app, inbox, started) = setup();
|
||||
let uid_a = app.session.active().unwrap().uid;
|
||||
let slide0 = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
app.session.execute("slide.new", &json!({})).unwrap();
|
||||
let slide1 = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
assert_ne!(slide0, slide1);
|
||||
|
||||
// Audio was asked for on slide 1; the picture and the outline on slide 0.
|
||||
app.pick_and_insert("audio", "insert.audio");
|
||||
app.session.execute("slide.go", &json!({"id": slide0.0})).unwrap();
|
||||
app.pick_and_insert("picture", "insert.picture");
|
||||
app.pick_and_open();
|
||||
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
let uid_b = app.session.active().unwrap().uid;
|
||||
assert_ne!(uid_a, uid_b);
|
||||
let shapes_b = app.session.active().unwrap().doc.slides[0].shapes.len();
|
||||
// A video was requested on B. Delivering a PNG must not insert a picture anywhere.
|
||||
app.pick_and_insert("video", "insert.video");
|
||||
|
||||
let log = started.lock().expect("request log").clone();
|
||||
assert_eq!(log.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), vec!["audio", "picture", "open", "video"]);
|
||||
let audio_id = log[0].1;
|
||||
let picture_id = log[1].1;
|
||||
let outline_id = log[2].1;
|
||||
let video_id = log[3].1;
|
||||
|
||||
// The video request completes first, while B is still current. A PNG must not become a picture.
|
||||
deliver(&inbox, video_id, "clip.mp4", png());
|
||||
app.drain_inbox();
|
||||
let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default();
|
||||
assert!(status.contains("not a supported video") || status.contains("no video"), "{status}");
|
||||
assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), shapes_b);
|
||||
|
||||
// The earlier picks then finish in reverse order.
|
||||
deliver(&inbox, outline_id, "talk.md", b"Brought Later\n".to_vec());
|
||||
deliver(&inbox, picture_id, "shot.png", png());
|
||||
deliver(&inbox, audio_id, "tone.wav", wav());
|
||||
app.drain_inbox();
|
||||
|
||||
assert_eq!(app.session.active().unwrap().uid, uid_b, "the view stays on the document the user switched to");
|
||||
assert_eq!(app.session.active().unwrap().doc.slides[0].shapes.len(), shapes_b, "the video request must not insert the PNG here");
|
||||
assert_eq!(pictures(&app, uid_b, app.session.documents().iter().find(|d| d.uid == uid_b).unwrap().doc.slides[0].id), 0);
|
||||
|
||||
let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default();
|
||||
assert_eq!(pictures(&app, uid_a, slide0), 1, "{status}");
|
||||
assert_eq!(pictures(&app, uid_a, slide1), 0);
|
||||
assert_eq!(audios(&app, uid_a, slide1), 1);
|
||||
assert_eq!(audios(&app, uid_a, slide0), 0);
|
||||
let a = app.session.documents().iter().find(|d| d.uid == uid_a).unwrap();
|
||||
assert!(a.doc.slides.iter().any(|s| s.title() == "Brought Later"), "the outline stays on the document that opened it");
|
||||
assert_eq!(a.doc.slides.len(), 3);
|
||||
// The user had moved to slide 0 of A before switching documents; B is what's on screen.
|
||||
assert_eq!(app.session.active().unwrap().selection.slide, 0);
|
||||
}
|
||||
|
||||
/// Closing the presentation, or dismissing the picker, drops the file instead of inserting
|
||||
/// it into whatever is open now.
|
||||
#[test]
|
||||
fn a_closed_or_cancelled_request_does_not_insert_into_the_active_document() {
|
||||
let (mut app, inbox, started) = setup();
|
||||
let slide = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
app.pick_and_insert("picture", "insert.picture");
|
||||
app.session.execute("slide.new", &json!({})).unwrap();
|
||||
let kept = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
app.session.execute("slide.delete", &json!({"ids": [slide.0]})).unwrap();
|
||||
app.pick_and_insert("picture", "insert.picture");
|
||||
let log = started.lock().expect("request log").clone();
|
||||
let deleted_slide = log[0].1;
|
||||
let dismiss = log[1].1;
|
||||
|
||||
inbox.lock().expect("inbox").push(InboxEvent::Cancelled { id: dismiss });
|
||||
deliver(&inbox, dismiss, "late.png", png());
|
||||
deliver(&inbox, deleted_slide, "gone.png", png());
|
||||
// An id that was never started must not insert either.
|
||||
deliver(&inbox, 999, "stray.png", png());
|
||||
app.drain_inbox();
|
||||
|
||||
assert_eq!(pictures(&app, app.session.active().unwrap().uid, kept), 0);
|
||||
let status = app.status.as_ref().map(|(t, _)| t.as_str()).unwrap_or("");
|
||||
assert!(status.contains("deleted"), "{status}");
|
||||
|
||||
app.session.execute("file.close", &json!({})).unwrap();
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
// The deleted-slide request was already consumed. Start another and close before it arrives.
|
||||
let closed = app.session.active().unwrap().uid;
|
||||
app.pick_and_insert("audio", "insert.audio");
|
||||
let audio_id = started.lock().expect("request log").last().unwrap().1;
|
||||
app.session.execute("file.close", &json!({})).unwrap();
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
let other = app.session.active().unwrap().uid;
|
||||
assert_ne!(closed, other);
|
||||
deliver(&inbox, audio_id, "tone.wav", wav());
|
||||
app.drain_inbox();
|
||||
let st = app.session.documents().iter().find(|d| d.uid == other).unwrap();
|
||||
let n = st.doc.slides.iter().flat_map(|s| s.shapes.iter()).filter(|s| matches!(s.kind, ShapeKind::Media(_))).count();
|
||||
assert_eq!(n, 0, "a file for a closed presentation is not inserted into the one now open");
|
||||
let status = app.status.as_ref().map(|(t, _)| t.as_str()).unwrap_or("");
|
||||
assert!(status.contains("closed"), "{status}");
|
||||
}
|
||||
|
||||
/// A drop is bound to the slide under the pointer at drop time, not the slide current when
|
||||
/// the bytes have been read.
|
||||
#[test]
|
||||
fn a_dropped_file_stays_on_the_slide_it_was_dropped_on() {
|
||||
let (mut app, inbox, _) = setup();
|
||||
let uid = app.session.active().unwrap().uid;
|
||||
let slide0 = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
let id = app.begin_external_file("drop");
|
||||
app.session.execute("slide.new", &json!({})).unwrap();
|
||||
let slide1 = app.session.active().unwrap().current_slide().unwrap().id;
|
||||
deliver(&inbox, id, "shot.png", png());
|
||||
app.drain_inbox();
|
||||
assert_eq!(app.session.active().unwrap().current_slide().unwrap().id, slide1, "the view stays on the slide the user moved to");
|
||||
let status = app.status.as_ref().map(|(t, _)| t.clone()).unwrap_or_default();
|
||||
assert_eq!(pictures(&app, uid, slide0), 1, "{status}");
|
||||
assert_eq!(pictures(&app, uid, slide1), 0);
|
||||
}
|
||||
+328
-46
@@ -36,7 +36,8 @@ pub mod widgets;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use deckcraft_engine::{Mods, Session, UiRequest};
|
||||
use deckcraft_engine::{Mods, Session, Target, UiRequest};
|
||||
use deckcraft_model::{LayoutId, MasterId, SlideId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -45,10 +46,144 @@ pub use control::{ControlRequest, ControlResponse};
|
||||
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
|
||||
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
pub type PickFn = Box<dyn FnMut(&str) -> Option<String>>;
|
||||
pub type OpenAsyncFn = Box<dyn FnMut(&str)>;
|
||||
/// `(purpose, request id)`. The host shows a picker and later pushes an [`InboxEvent`]
|
||||
/// carrying that same id. Completing a cancelled or unknown id does nothing: the file is
|
||||
/// not applied to whichever document happens to be active.
|
||||
pub type OpenAsyncFn = Box<dyn FnMut(&str, u64)>;
|
||||
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
|
||||
/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files).
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InboxFile {
|
||||
/// Cancellation identity. A file whose id was cancelled, or never started, is ignored.
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// What the host delivers through [`Inbox`] after an asynchronous read.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum InboxEvent {
|
||||
File(InboxFile),
|
||||
/// The picker was dismissed or the file could not be read. `id` will not be applied.
|
||||
Cancelled {
|
||||
id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Files delivered asynchronously by the host (web file picker, dropped files).
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<InboxEvent>>>;
|
||||
|
||||
/// The operation the user asked for. Kept with the request so a late completion does not
|
||||
/// guess a command from the file name.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FilePurpose {
|
||||
Open,
|
||||
Picture,
|
||||
Audio,
|
||||
Video,
|
||||
/// A drop, or any other purpose: decide from the name and bytes.
|
||||
Infer,
|
||||
}
|
||||
|
||||
/// Where in a presentation a file should land. Ids, not indices: slides can move while
|
||||
/// the browser is still reading.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FilePlace {
|
||||
Slide(SlideId),
|
||||
Master(MasterId),
|
||||
Layout {
|
||||
master: MasterId,
|
||||
layout: LayoutId,
|
||||
},
|
||||
/// The document was open but had no current slide, master or layout.
|
||||
Unspecified,
|
||||
}
|
||||
|
||||
/// The presentation (and place in it) captured when the request started.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum FileTarget {
|
||||
/// Nothing was open. An insert makes its own document rather than using one opened
|
||||
/// while the file was being read.
|
||||
Absent,
|
||||
Document {
|
||||
doc: u64,
|
||||
place: FilePlace,
|
||||
},
|
||||
}
|
||||
|
||||
struct PendingFile {
|
||||
id: u64,
|
||||
purpose: FilePurpose,
|
||||
target: FileTarget,
|
||||
}
|
||||
|
||||
struct LocatedPlace {
|
||||
target: Target,
|
||||
slide: Option<usize>,
|
||||
}
|
||||
|
||||
fn purpose_of(purpose: &str) -> FilePurpose {
|
||||
match purpose {
|
||||
"open" => FilePurpose::Open,
|
||||
"picture" => FilePurpose::Picture,
|
||||
"audio" => FilePurpose::Audio,
|
||||
"video" => FilePurpose::Video,
|
||||
_ => FilePurpose::Infer,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_presentation_name(lower: &str) -> bool {
|
||||
lower.ends_with(".deckcraft")
|
||||
|| lower.ends_with(".slidecraft")
|
||||
|| lower.ends_with(".pptx")
|
||||
|| lower.ends_with(".potx")
|
||||
|| lower.ends_with(".ppsx")
|
||||
}
|
||||
|
||||
/// File → Open keeps the requested operation: a presentation opens, an outline becomes slides.
|
||||
fn open_command(name: &str) -> Option<&'static str> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if is_presentation_name(&lower) {
|
||||
Some("file.openBytes")
|
||||
} else if lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
Some("slide.fromOutline")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A drop has no requested operation, so the name and bytes decide: a presentation opens,
|
||||
/// then a recognised picture, sound or video is inserted, then an outline becomes slides.
|
||||
fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if is_presentation_name(&lower) {
|
||||
return Some("file.openBytes");
|
||||
}
|
||||
let ct = deckcraft_engine::cmd::insert::content_type(name, bytes);
|
||||
if ct.starts_with("image/") {
|
||||
Some("insert.picture")
|
||||
} else if ct.starts_with("audio/") {
|
||||
Some("insert.audio")
|
||||
} else if ct.starts_with("video/") {
|
||||
Some("insert.video")
|
||||
} else if lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
Some("slide.fromOutline")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'static str, String> {
|
||||
let cmd = match purpose {
|
||||
FilePurpose::Picture => Some("insert.picture"),
|
||||
FilePurpose::Audio => Some("insert.audio"),
|
||||
FilePurpose::Video => Some("insert.video"),
|
||||
FilePurpose::Open => open_command(name),
|
||||
FilePurpose::Infer => infer_command(name, bytes),
|
||||
};
|
||||
cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file"))
|
||||
}
|
||||
|
||||
/// Platform services injected by the host (desktop or web).
|
||||
#[derive(Default)]
|
||||
@@ -59,7 +194,8 @@ pub struct Services {
|
||||
pub pick_save: Option<PickFn>,
|
||||
pub read: Option<ReadFn>,
|
||||
pub write: Option<WriteFn>,
|
||||
/// Asynchronous open dialog (web): the chosen file arrives through `inbox`.
|
||||
/// Asynchronous open dialog (web). The callback receives the purpose and the request id
|
||||
/// from [`SlideApp::begin_external_file`]; the chosen file arrives through `inbox`.
|
||||
pub open_async: Option<OpenAsyncFn>,
|
||||
/// Hand bytes to the user as a named file (browser download).
|
||||
pub download: Option<DownloadFn>,
|
||||
@@ -343,6 +479,10 @@ pub struct SlideApp {
|
||||
pub(crate) slides_focused: bool,
|
||||
/// A trackpad twist is in progress (one undo step when it ends).
|
||||
twisting: bool,
|
||||
/// Next cancellation identity for a browser pick or a dropped file.
|
||||
next_file_id: u64,
|
||||
/// Picks and drops whose bytes have not arrived yet.
|
||||
pending_files: Vec<PendingFile>,
|
||||
}
|
||||
|
||||
impl SlideApp {
|
||||
@@ -391,6 +531,8 @@ impl SlideApp {
|
||||
sorter: sorter::State::default(),
|
||||
slides_focused: false,
|
||||
twisting: false,
|
||||
next_file_id: 1,
|
||||
pending_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,8 +985,11 @@ impl SlideApp {
|
||||
{
|
||||
self.notify(e);
|
||||
}
|
||||
} else if let Some(f) = self.services.open_async.as_mut() {
|
||||
f("open");
|
||||
} else if self.services.open_async.is_some() {
|
||||
let id = self.begin_external_file("open");
|
||||
if let Some(f) = self.services.open_async.as_mut() {
|
||||
f("open", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,8 +1034,11 @@ impl SlideApp {
|
||||
self.set_status(e.to_string());
|
||||
}
|
||||
}
|
||||
} else if let Some(f) = self.services.open_async.as_mut() {
|
||||
f(purpose);
|
||||
} else if self.services.open_async.is_some() {
|
||||
let id = self.begin_external_file(purpose);
|
||||
if let Some(f) = self.services.open_async.as_mut() {
|
||||
f(purpose, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -973,48 +1121,179 @@ impl SlideApp {
|
||||
|
||||
fn drain_inbox(&mut self) {
|
||||
let Some(inbox) = self.services.inbox.clone() else { return };
|
||||
let files: Vec<(String, Vec<u8>)> = inbox.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default();
|
||||
for (name, bytes) in files {
|
||||
self.open_or_insert_bytes(&name, &bytes);
|
||||
let events = match inbox.lock() {
|
||||
Ok(mut v) => std::mem::take(&mut *v),
|
||||
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
|
||||
};
|
||||
for event in events {
|
||||
match event {
|
||||
InboxEvent::Cancelled { id } => self.pending_files.retain(|r| r.id != id),
|
||||
InboxEvent::File(file) => self.apply_external_file(file.id, &file.name, &file.bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A dropped or picked file: presentations open, media is inserted.
|
||||
/// Remember the open presentation, the slide (or master/layout) and the operation for a
|
||||
/// file the host will read asynchronously. Returns the cancellation identity the host must
|
||||
/// echo on the [`InboxEvent`]. Call this when the pick or the drop starts, not when the
|
||||
/// bytes arrive.
|
||||
pub fn begin_external_file(&mut self, purpose: &str) -> u64 {
|
||||
let id = self.next_file_id;
|
||||
self.next_file_id = self.next_file_id.wrapping_add(1).max(1);
|
||||
self.pending_files.push(PendingFile { id, purpose: purpose_of(purpose), target: self.capture_file_target() });
|
||||
id
|
||||
}
|
||||
|
||||
/// A dropped or picked file read synchronously: presentations open, media is inserted on
|
||||
/// the slide that is current now.
|
||||
pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let data = deckcraft_engine::cmd::base64_encode(bytes);
|
||||
let ct = deckcraft_engine::cmd::insert::content_type(name, bytes);
|
||||
let cmd = if lower.ends_with(".deckcraft")
|
||||
|| lower.ends_with(".slidecraft")
|
||||
|| lower.ends_with(".pptx")
|
||||
|| lower.ends_with(".potx")
|
||||
|| lower.ends_with(".ppsx")
|
||||
{
|
||||
"file.openBytes"
|
||||
} else if ct.starts_with("image/") {
|
||||
"insert.picture"
|
||||
} else if ct.starts_with("audio/") {
|
||||
"insert.audio"
|
||||
} else if ct.starts_with("video/") {
|
||||
"insert.video"
|
||||
} else if lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
"slide.fromOutline"
|
||||
} else {
|
||||
self.set_status(format!("{name}: DeckCraft can't open this kind of file"));
|
||||
return;
|
||||
let id = self.begin_external_file("drop");
|
||||
self.apply_external_file(id, name, bytes);
|
||||
}
|
||||
|
||||
fn capture_file_target(&self) -> FileTarget {
|
||||
let Some(d) = self.session.active() else { return FileTarget::Absent };
|
||||
let place = match d.selection.target {
|
||||
Target::Slides => match d.current_slide() {
|
||||
Some(s) => FilePlace::Slide(s.id),
|
||||
None => FilePlace::Unspecified,
|
||||
},
|
||||
Target::Master { master } => match d.doc.masters.get(master) {
|
||||
Some(m) => FilePlace::Master(m.id),
|
||||
None => FilePlace::Unspecified,
|
||||
},
|
||||
Target::Layout { master, layout } => match d.doc.masters.get(master) {
|
||||
Some(m) => match m.layouts.get(layout) {
|
||||
Some(l) => FilePlace::Layout { master: m.id, layout: l.id },
|
||||
None => FilePlace::Unspecified,
|
||||
},
|
||||
None => FilePlace::Unspecified,
|
||||
},
|
||||
};
|
||||
let p = if cmd == "slide.fromOutline" {
|
||||
json!({"text": String::from_utf8_lossy(bytes), "markdown": lower.ends_with(".md")})
|
||||
} else {
|
||||
json!({"name": name, "data": data})
|
||||
};
|
||||
if self.session.active().is_none() && cmd != "file.openBytes" {
|
||||
let _ = self.session.execute("file.new", &json!({}));
|
||||
FileTarget::Document { doc: d.uid, place }
|
||||
}
|
||||
|
||||
/// True when the user is still looking at the place this request captured.
|
||||
fn viewing_target(&self, target: &FileTarget) -> bool {
|
||||
let FileTarget::Document { doc, place } = target else { return false };
|
||||
let Some(d) = self.session.active() else { return false };
|
||||
if d.uid != *doc {
|
||||
return false;
|
||||
}
|
||||
match self.session.execute(cmd, &p) {
|
||||
Err(e) if cmd == "file.openBytes" => self.notify(e.to_string()),
|
||||
Err(e) => self.set_status(e.to_string()),
|
||||
Ok(_) => {}
|
||||
match place {
|
||||
FilePlace::Slide(id) => d.selection.target == Target::Slides && d.current_slide().is_some_and(|s| s.id == *id),
|
||||
FilePlace::Master(id) => {
|
||||
matches!(d.selection.target, Target::Master { master } if d.doc.masters.get(master).is_some_and(|m| m.id == *id))
|
||||
}
|
||||
FilePlace::Layout { master, layout } => {
|
||||
matches!(d.selection.target, Target::Layout { master: mi, layout: li }
|
||||
if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout)))
|
||||
}
|
||||
FilePlace::Unspecified => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_external_file(&mut self, id: u64, name: &str, bytes: &[u8]) {
|
||||
let Some(pos) = self.pending_files.iter().position(|r| r.id == id) else { return };
|
||||
let req = self.pending_files.remove(pos);
|
||||
let cmd = match command_for(req.purpose, name, bytes) {
|
||||
Ok(cmd) => cmd,
|
||||
Err(e) => {
|
||||
self.set_status(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let params = if cmd == "slide.fromOutline" {
|
||||
json!({"text": String::from_utf8_lossy(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")})
|
||||
} else {
|
||||
json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)})
|
||||
};
|
||||
let result = if cmd == "file.openBytes" || self.viewing_target(&req.target) {
|
||||
self.session.execute(cmd, ¶ms).map(|_| ()).map_err(|e| e.to_string())
|
||||
} else {
|
||||
self.execute_on_target(&req.target, name, cmd, ¶ms)
|
||||
};
|
||||
match result {
|
||||
Err(e) if cmd == "file.openBytes" => self.notify(e),
|
||||
Err(e) => self.set_status(e),
|
||||
Ok(()) => {}
|
||||
}
|
||||
self.drain_requests();
|
||||
}
|
||||
|
||||
/// Run `cmd` on the document and slide captured for the request, then put the view back
|
||||
/// if the user has since moved. A missing document or slide cancels the file.
|
||||
fn execute_on_target(&mut self, target: &FileTarget, name: &str, cmd: &str, params: &Value) -> Result<(), String> {
|
||||
let FileTarget::Document { doc, place } = target else {
|
||||
self.session.execute("file.new", &json!({})).map_err(|e| e.to_string())?;
|
||||
return self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string());
|
||||
};
|
||||
let Some(idx) = self.session.documents().iter().position(|d| d.uid == *doc) else {
|
||||
return Err(format!("{name} was not applied: the presentation it was chosen for is closed"));
|
||||
};
|
||||
let view = self.session.active_index();
|
||||
let saved = self.session.documents().get(idx).map(|d| d.selection.clone());
|
||||
self.session.set_active(idx);
|
||||
let located = match self.locate_place(place) {
|
||||
Ok(located) => located,
|
||||
Err(e) => {
|
||||
self.restore_file_view(view);
|
||||
return Err(format!("{name} was not applied: {e}"));
|
||||
}
|
||||
};
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
st.selection.target = located.target;
|
||||
if let Some(slide) = located.slide {
|
||||
st.selection.slide = slide;
|
||||
st.selection.slides.clear();
|
||||
}
|
||||
st.selection.shapes.clear();
|
||||
st.selection.text = None;
|
||||
st.selection.cells = None;
|
||||
}
|
||||
let result = self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string());
|
||||
if view == Some(idx) {
|
||||
if let (Some(sel), Some(st)) = (saved, self.session.active_mut()) {
|
||||
st.selection = sel;
|
||||
}
|
||||
} else {
|
||||
self.restore_file_view(view);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn restore_file_view(&mut self, view: Option<usize>) {
|
||||
if let Some(i) = view
|
||||
&& i < self.session.documents().len()
|
||||
{
|
||||
self.session.set_active(i);
|
||||
}
|
||||
}
|
||||
|
||||
fn locate_place(&self, place: &FilePlace) -> Result<LocatedPlace, String> {
|
||||
let d = self.session.doc().map_err(|e| e.to_string())?;
|
||||
match place {
|
||||
FilePlace::Slide(id) => {
|
||||
let slide = d.doc.slide_index(*id).ok_or_else(|| "the slide it was chosen for was deleted".to_string())?;
|
||||
Ok(LocatedPlace { target: Target::Slides, slide: Some(slide) })
|
||||
}
|
||||
FilePlace::Master(id) => {
|
||||
let master =
|
||||
d.doc.masters.iter().position(|m| m.id == *id).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?;
|
||||
Ok(LocatedPlace { target: Target::Master { master }, slide: None })
|
||||
}
|
||||
FilePlace::Layout { master, layout } => {
|
||||
let mi =
|
||||
d.doc.masters.iter().position(|m| m.id == *master).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?;
|
||||
let li = d
|
||||
.doc
|
||||
.masters
|
||||
.get(mi)
|
||||
.and_then(|m| m.layouts.iter().position(|l| l.id == *layout))
|
||||
.ok_or_else(|| "the layout it was chosen for was deleted".to_string())?;
|
||||
Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None })
|
||||
}
|
||||
FilePlace::Unspecified => Err("there was no slide to put it on".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1071,7 +1350,7 @@ impl SlideApp {
|
||||
}
|
||||
}
|
||||
// On the web, dropped files can only be read asynchronously: the host reads them and
|
||||
// delivers them through `Services::inbox`.
|
||||
// delivers them through `Services::inbox`, tagged with the request captured at the drop.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into());
|
||||
@@ -1488,6 +1767,9 @@ mod ui_soak_tests;
|
||||
#[path = "datetime_tests.rs"]
|
||||
mod datetime_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod inbox_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
Reference in New Issue
Block a user