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
43 changes: 43 additions & 0 deletions .github/workflows/editor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Web editor
on:
push:
pull_request:
permissions:
contents: read
jobs:
editor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.90.0'
targets: wasm32-unknown-unknown
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all -- --check
- run: cargo test --workspace --locked
- run: cargo clippy --workspace --all-targets --locked -- -D warnings
- run: cargo check -p web_view --target wasm32-unknown-unknown --locked
- uses: taiki-e/install-action@v2
with:
tool: trunk@0.21.14
- run: trunk build --release --locked
env:
NO_COLOR: 'true'
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: browser-test-results
path: test-results/
- uses: actions/upload-artifact@v4
with:
name: pace-web-editor
path: dist/
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,46 @@ Save downloads the full project and attempts an atomic browser recovery copy.
Recovery is origin-specific and limited by browser quota; a warning instructs you
to save a file if it is unavailable. Project files are the portable source of
truth. Keep a downloaded copy before clearing browser data.

## Room tools

Add a room and import its background. Use **Draw hotspot** to drag a rectangle
in either direction, or **Add hotspot** for keyboard-friendly placement. Drag a
selected hotspot to move it; arrow keys nudge it by 1% of the image. Set exact
X/Y/width/height percentages in the inspector. Image replacement preserves the
normalized bounds. A **Go to room** action links to a stable room ID, so renaming
a destination does not break the link. Add a separate return hotspot for a
bidirectional connection.

Validation entries identify the object ID and field; click an entry to select
its room, hotspot, item, or dialogue. Saves may contain incomplete drafts.
Invalid drafts cannot be rehearsed.

## Browser checks

```sh
npm ci
npx playwright install --with-deps chromium
trunk build
npm run test:e2e
```

For an existing Chromium installation, set `CHROMIUM_PATH=/usr/bin/chromium`.

## Dialogue, inventory, and walkthrough

Create inventory definitions and dialogue nodes from their sidebar sections.
Dialogue choices can branch to another node, grant an item, or end the
conversation. Hotspots can inspect text, link rooms, pick up an item, or start a
dialogue; any action can require an inventory item. Names are editable while IDs
stay stable.

Follow [the illustrated walkthrough](docs/walkthrough.md) to author **The Garden
Key**, a three-room example, using only the editor controls. The browser test
reproduces the walkthrough, including save/reopen equality for the whole project.

**Rehearse draft** runs an isolated editor simulation to exercise the draft's
links, dialogue branches and item gates. Restart clears its session. It never
modifies the source project and is **not** the PACE core runtime. **Core preview**
and **Export game** remain disabled. This implementation does not yet meet the
core-format serialization or pinned-runtime preview/export requirements.
2 changes: 1 addition & 1 deletion app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ edition = "2021"

[dependencies]
dioxus = { version = "=0.5.6", default-features = false, features = ["macro", "html", "signals", "hooks"] }
serde = { version = "1", features = ["derive"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
base64 = "0.22"
imagesize = "0.13"
Expand Down
25 changes: 25 additions & 0 deletions app/src/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ mod web {
}
Ok(())
}
pub fn capture_pointer(id: i32) {
if let Some(element) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("room-stage"))
{
let _ = element.set_pointer_capture(id);
}
}
#[wasm_bindgen::prelude::wasm_bindgen(
inline_js = "export function setDirty(dirty) { window.onbeforeunload = dirty ? (event) => { event.preventDefault(); event.returnValue = ''; } : null; } export function openRehearsal() { const dialog = document.getElementById('draft-rehearsal'); if (!dialog) return; dialog.oncancel = event => { event.preventDefault(); dialog.querySelector('#close-rehearsal').click(); }; dialog.showModal(); }"
)]
extern "C" {
#[wasm_bindgen::prelude::wasm_bindgen(js_name = setDirty)]
pub fn set_dirty(dirty: bool);
#[wasm_bindgen::prelude::wasm_bindgen(js_name = openRehearsal)]
pub fn open_rehearsal();
}
pub fn stage_point(x: f64, y: f64) -> Option<(f64, f64)> {
let rect = web_sys::window()?
.document()?
Expand Down Expand Up @@ -94,3 +111,11 @@ pub fn download(_: &str) -> Result<(), String> {
pub fn stage_point(_: f64, _: f64) -> Option<(f64, f64)> {
None
}

#[cfg(not(target_arch = "wasm32"))]
pub fn capture_pointer(_: i32) {}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_dirty(_: bool) {}

#[cfg(not(target_arch = "wasm32"))]
pub fn open_rehearsal() {}
2 changes: 2 additions & 0 deletions app/src/editor.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions app/src/editor/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use crate::model::Project;
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT_DOCUMENT: AtomicUsize = AtomicUsize::new(1);

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Selection {
Expand All @@ -11,6 +13,7 @@ pub enum Selection {

#[derive(Clone)]
pub struct Editor {
pub document_id: usize,
pub project: Project,
pub selection: Selection,
pub dirty: bool,
Expand All @@ -32,6 +35,7 @@ impl Editor {
.map(|r| Selection::Room(r.id.clone()))
.unwrap_or(Selection::Project);
Self {
document_id: NEXT_DOCUMENT.fetch_add(1, Ordering::Relaxed),
project,
selection,
dirty: false,
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@
mod browser;
pub mod editor;
pub mod model;
mod rooms;
mod ui;
pub use ui::App;

mod preview;
pub mod rehearsal;
mod story;
37 changes: 32 additions & 5 deletions app/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//! format: the inspected upstream revision has no serializable model or runtime.
use base64::{engine::general_purpose::STANDARD, Engine};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::{collections::HashSet, sync::Arc};

pub const FORMAT: &str = "pace-editor-draft";
pub const VERSION: u32 = 1;
Expand Down Expand Up @@ -35,7 +35,7 @@ pub struct Room {
#[serde(deny_unknown_fields)]
pub struct Background {
pub name: String,
pub data_url: String,
pub data_url: Arc<str>,
pub width: u32,
pub height: u32,
}
Expand Down Expand Up @@ -154,6 +154,16 @@ impl Default for Project {

impl Project {
pub fn to_json(&self) -> Result<String, String> {
if self.rooms.iter().flat_map(|r| &r.hotspots).any(|h| {
[h.rect.x, h.rect.y, h.rect.width, h.rect.height]
.iter()
.any(|n| !n.is_finite())
}) {
return Err(
"Cannot save a hotspot with non-finite coordinates. Correct its bounds first."
.into(),
);
}
let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
if text.len() > MAX_PROJECT_BYTES {
return Err("Project exceeds the 64 MiB file limit. Reduce background sizes.".into());
Expand Down Expand Up @@ -287,10 +297,10 @@ impl Project {
if self.title.trim().is_empty() {
push(None, "title", "Give the project a title.".into());
}
if !self
if self
.start_room
.as_deref()
.is_some_and(|id| self.room(id).is_some())
.is_none_or(|id| self.room(id).is_none())
{
push(
None,
Expand Down Expand Up @@ -400,6 +410,23 @@ impl Project {
}

impl Background {
/// Only embedded raster data is rendered; malformed imports cannot load a
/// remote URL or an active document through the image element.
pub fn display_url(&self) -> &str {
if [
"data:image/png;base64,",
"data:image/jpeg;base64,",
"data:image/webp;base64,",
]
.iter()
.any(|prefix| self.data_url.starts_with(prefix))
{
&self.data_url
} else {
""
}
}

pub fn import(name: String, bytes: &[u8]) -> Result<Self, String> {
if bytes.len() > MAX_IMAGE_BYTES {
return Err("Background exceeds 8 MiB. Resize it before importing.".into());
Expand All @@ -420,7 +447,7 @@ impl Background {
}
Ok(Self {
name,
data_url: format!("data:{mime};base64,{}", STANDARD.encode(bytes)),
data_url: format!("data:{mime};base64,{}", STANDARD.encode(bytes)).into(),
width: size.width as u32,
height: size.height as u32,
})
Expand Down
47 changes: 47 additions & 0 deletions app/src/preview.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use crate::rehearsal::Rehearsal;
use dioxus::prelude::*;

#[component]
pub fn DraftPreview(mut session: Signal<Option<Rehearsal>>) -> Element {
use_effect(crate::browser::open_rehearsal);
let state = session.read().clone()?;
let room = state.project.room(&state.room_id)?;
let dialogue = state
.dialogue_id
.as_ref()
.and_then(|id| state.project.dialogues.iter().find(|d| &d.id == id));
rsx! {
dialog { id: "draft-rehearsal", class: "preview-overlay", role: "dialog", "aria-modal": "true", "aria-label": "Draft rehearsal",
div { class: "preview",
div { class: "preview-head", h2 { "{room.name}" }
button { onclick: move |_| { let project = session.read().as_ref().map(|s| s.project.clone()); if let Some(p) = project { session.set(Rehearsal::new(&p).ok()); } }, "Restart" }
button { id: "close-rehearsal", autofocus: true, onclick: move |_| session.set(None), "Close rehearsal" }
}
p { class: "preview-note", "Editor rehearsal only · PACE runtime integration is pending. This does not verify runtime compatibility." }
if let Some(bg) = &room.background {
div { class: "stage", style: "aspect-ratio:{bg.width}/{bg.height}",
img { src: bg.display_url(), alt: "{room.name} background" }
for hotspot in room.hotspots.clone() {
button { class: "hotspot", disabled: dialogue.is_some(), style: format!("left:{}%;top:{}%;width:{}%;height:{}%",hotspot.rect.x*100.,hotspot.rect.y*100.,hotspot.rect.width*100.,hotspot.rect.height*100.), "aria-label": "Interact with {hotspot.name}",
onclick: move |_| { if let Some(state) = session.write().as_mut() { if let Err(e) = state.activate(&hotspot.id) { state.message = e; } } }, "{hotspot.name}"
}
}
}
}
div { class: "inventory", strong { "INVENTORY" }
if state.inventory.is_empty() { span { class: "muted", "Nothing collected yet" } }
for item in state.project.items.iter().filter(|i| state.inventory.contains(&i.id)) { span { class: "chip", title: "{item.description}", "{item.name}" } }
}
if let Some(d) = dialogue {
div { class: "dialogue", strong { "{d.speaker}" } p { "{d.text}" }
for choice in d.choices.clone() {
button { onclick: move |_| { if let Some(state) = session.write().as_mut() { if let Err(e) = state.choose(&choice.id) { state.message = e; } } }, "{choice.text}" }
}
if d.choices.is_empty() { button { onclick: move |_| { if let Some(state) = session.write().as_mut() { if let Err(e) = state.continue_dialogue() { state.message = e; } } }, "Continue" } }
}
}
p { role: "status", "{state.message}" }
}
}
}
}
Loading
Loading