fix: resolve issues #1-#8 (side borders, align-self, min/max size, wavy/colored underline, cross-page layers, screenshot out path, feedback counts, nested component import)

This commit is contained in:
Marcos Paulo
2026-10-17 20:00:47 -03:00
parent fa0511bc94
commit 5169fe6342
21 changed files with 1379 additions and 283 deletions
+4 -1
View File
@@ -17,7 +17,10 @@ fn main() {
.set("ProductName", "PaperCraft")
.set("FileDescription", "PaperCraft infinite canvas")
.set("CompanyName", "PaperCraft Authors")
.set("LegalCopyright", "Copyright (c) the PaperCraft authors. MIT OR Apache-2.0.")
.set(
"LegalCopyright",
"Copyright (c) the PaperCraft authors. MIT OR Apache-2.0.",
)
.set("OriginalFilename", "papercraft.exe")
.set("InternalName", "papercraft");
if let Err(e) = res.compile() {
+8 -8
View File
@@ -23,9 +23,9 @@ papercraft-cli call get_basic_info # fails with exit 1 if the app is not ru
## 2. The loop: build, look, lint, fix
1. **Tokens first.** `set_variables` once (roles: `bg`, `surface`, `border`, `text`, `text-muted`, `accent`, `on-accent`, `danger`). Add a second mode for dark or light. No hex in the design afterwards.
2. **Components first.** Anything that appears twice (button, field, chip, row, tab bar) is built once as an artboard, `create_component`, then placed with `create_instance`.
2. **Components first.** Anything that appears twice (button, field, chip, row, tab bar) is built once as an artboard, `create_component`, then placed with `create_instance` or `<x-paper-instance component="Name" component-id="ID"/>`.
3. **Build in small steps.** `create_artboard`, then one `write_html` per visual group (header, card, row) so the user sees progress. Name every layer with `layer-name`.
4. **Look.** `get_screenshot` of the artboard after each group or two. Scale 2 to read small text. Read it as a designer: hierarchy, alignment, spacing rhythm, clipping, wrapping.
4. **Look.** `get_screenshot` of the artboard after each group or two (pass `out` or `-o`/`--out FILE` in `papercraft-cli` to choose the output path). Scale 2 to read small text. Read it as a designer: hierarchy, alignment, spacing rhythm, clipping, wrapping.
5. **Lint.** `lint_design` on the artboard. Fix every error and warning; consider the `info` items. Run it again until errors and warnings are zero.
6. **Fix with the smallest edit.** `update_styles` and `set_text_content` on the existing nodes, not a rewrite. Rewrite a group with `write_html` mode `replace` only when its structure is wrong.
7. **Finish.** `finish_working_on_nodes` (saves). Tell the user what you made in a sentence or two, with artboard names, not ids.
@@ -56,7 +56,7 @@ Lint passes are necessary, not sufficient: contrast and grids say nothing about
<div layer-name="Nav / Home" style="display:flex;align-items:center;gap:12px"><x-paper-icon name="phosphor:house" size="20" color="var(--text-muted)"/><div layer-name="Label" style="font-size:16px;color:var(--text-muted)">Home</div></div>
```
Artboards: mobile 390×844, tablet 768×1024, desktop 1440×900. Create with `height:"fit-content"` when the content decides the height. Replace margin with `gap` and `padding`.
Artboards: mobile 390×844, tablet 768×1024, desktop 1440×900. Create with `height:"fit-content"` when the content decides the height. Replace margin with `gap` and `padding`. `min-width`, `max-width`, `min-height`, `max-height`, `align-self` (`start`, `center`, `end`, `stretch`), per-side borders (`border-top`, `border-right`, `border-bottom`, `border-left`), and `text-decoration-color` / `wavy` (`text-decoration: underline wavy var(--warning)`) are also supported.
Full flows: [recipes.md](references/recipes.md).
@@ -71,14 +71,14 @@ Full flows: [recipes.md](references/recipes.md).
| Verify generated code against design | `verify_code` (`nodeId`, `target`, `code`) |
| Compare two frames or subtrees | `diff_frames` (`beforeId`, `afterId`) |
| Find nodes by text or style | `find_nodes` |
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2) |
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2, `out` / `-o\|--out FILE`) |
| New screen | `create_artboard` (`width` and `height` required) |
| Add content | `write_html` (`insert-children` or `replace`) |
| Change styles or text | `update_styles`, `set_text_content`, `rename_nodes` |
| Whiteboard shapes, sticky notes, arrows | `create_shape` |
| Copy, move, delete | `duplicate_nodes`, `move_nodes`, `delete_nodes` |
| Copy, move, delete (including across pages) | `duplicate_nodes` (`toPageId`), `move_nodes` (`toPageId`), `<x-paper-clone node-id="<page>:<id>"/>`, `delete_nodes` |
| Tokens | `get_variables` (`format`: `css` \| `json` \| `tailwind`), `set_variables` (`mode`), `set_tokens`, `set_variable_mode` |
| Reuse & components | `create_component`, `create_instance`, `swap_instance`, `detach_instance`, `import_components`, `get_components`, `<x-paper-instance>` |
| Reuse & components | `create_component`, `create_instance`, `swap_instance`, `detach_instance`, `import_components`, `get_components`, `<x-paper-instance component="Name" component-id="ID"/>` |
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
| Check & auto-fix quality | `lint_design` (`fix: true` to auto-fix grid/scale/variables/targets) |
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf` |
@@ -100,10 +100,10 @@ When implementing a PaperCraft design in production code (React/TSX, Tailwind JS
* **Color variables (`set_variables`) apply to fills and text color**; strokes and gradients keep fixed colors. Non-color tokens (`set_tokens`) provide shared spacing, radius, and shadow values expanded on write; `lint_design` (`fix: true`) enforces scale and 4px grid discipline.
* **No CSS margin, grid or tables in `write_html`; no CSS filters or transforms** (reported under `ignoredStyles`). Replace margin with `gap` and `padding`.
* **No rotation.** Nodes are axis-aligned.
* **Components and their instances live on one page** (use `import_components` to copy main components from another page). Variants are modeled as separate components (`Button / Primary`, `Button / Secondary`) and can be swapped in place with `swap_instance` (preserving overrides) or unlinked with `detach_instance`.
* **Components and their instances live on one page** (use `import_components` to copy main components from anywhere on another page, including inside artboards, then instantiate with `<x-paper-instance component="Name"/>` or `component-id="ID"`). Variants are modeled as separate components (`Button / Primary`, `Button / Secondary`) and can be swapped in place with `swap_instance` (preserving overrides) or unlinked with `detach_instance`.
* **Supported imports, diffs, and code handoff**: `.fig` file import is supported, `diff_frames` compares two frames/subtrees at the property level, `get_jsx` exports `inline`, `tailwind`, `react`, `angular`, `html`, or `egui`, and `get_design_spec` + `verify_code` provide full AI design-to-code handoff and enforcement.
* **Remote images are not fetched.** Use a local path or `data:` URI.
* **One project open at a time**; moving layers between pages is not supported.
* **One project open at a time**; copying and moving layers between pages is supported via `duplicate_nodes` / `move_nodes` (`toPageId`) and cross-page `<x-paper-clone node-id="<page>:<id>"/>`.
* **Text boxes with fixed widths can wrap earlier than in a browser.** Check headlines in the screenshot and widen.
* **Instances are skipped by lint**; lint the main component.
+6 -6
View File
@@ -20,30 +20,30 @@
| `<img src>` | an image fill. `src` is an absolute path, a `file://` URL, `paper-asset:///abs/path` or a `data:` URI. `http(s)` is not fetched |
| `<svg>...</svg>` | vector layers (paths, gradients, strokes) |
| `<x-paper-icon name="set:name" size="20" color="var(--x)"/>` | an icon from an installed set (`phosphor:gear`) |
| `<x-paper-instance component-id="ID"/>` | an instance of a component |
| `<x-paper-clone node-id="ID" style="..."/>` | a deep copy of an existing node |
| `<x-paper-instance component="Name" component-id="ID" overrides='{"Label":"Save"}'/>` | an instance of a component (lookup by `component="Name"` or `component-id="ID"`) |
| `<x-paper-clone node-id="ID" style="..."/>` | a deep copy of an existing node (supports cross-page `<page>:<id>`) |
| `layer-name="..."` on any of the above | the layer's name (names are truncated to 50 characters) |
## Layout
Supported: `display:flex` (and `inline-flex`), `flex-direction`, `flex-wrap`, `gap`, `row-gap`, `column-gap`, `padding` (shorthand, per side, `padding-inline`, `padding-block`), `align-items`, `justify-content` (including `space-between`), `align-self`, `flex-grow` / `flex: 1`, `width`, `height` (px, %, `fit-content`), `min-`/`max-` sizes (accepted), `overflow:hidden` (clips), `position:absolute` with `left`/`top`/`right`/`bottom`, `display:none`, `visibility`.
Supported: `display:flex` (and `inline-flex`), `flex-direction`, `flex-wrap`, `gap`, `row-gap`, `column-gap`, `padding` (shorthand, per side, `padding-inline`, `padding-block`), `align-items`, `justify-content` (including `space-between`), `align-self` (`start`, `center`, `end`, `stretch`), `flex-grow` / `flex: 1`, `width`, `height` (px, %, `fit-content`), `min-width`, `max-width`, `min-height`, `max-height`, `overflow:hidden` (clips), `position:absolute` with `left`/`top`/`right`/`bottom`, `display:none`, `visibility`.
Not supported (check `ignoredStyles` to be sure): `margin` (use `gap` and `padding`), `display:grid`, tables, `float`, `calc()`, viewport units, `transform` and rotation.
Tips:
* Artboards are `display:flex; flex-direction:column`. Children of a column stretch to the full width unless they have a width or `align-items` says otherwise.
* Artboards are `display:flex; flex-direction:column`. Children of a column stretch to the full width unless they have a width or `align-items` / `align-self` says otherwise.
* To center something, put it in a flex parent with `align-items:center; justify-content:center`.
* To make a row fill the remaining space, give the child `flex-grow:1`.
* Use `height:"fit-content"` on artboards that should grow with content; fixed heights clip.
## Text
Supported: `font-size` (px), `font-weight` (600 and above renders bold; otherwise regular), `font-style:italic`, `color`, `line-height` (px or unitless), `letter-spacing`, `text-align`, `text-decoration`. The font is always **Inter**; `font-family` is ignored, and there are only regular and bold weights (plus italic), so hierarchy comes from size, weight, color and space.
Supported: `font-size` (px), `font-weight` (600 and above renders bold; otherwise regular), `font-style:italic`, `color`, `line-height` (px or unitless), `letter-spacing`, `text-align`, `text-decoration` (including `text-decoration-color` and `text-decoration-style: wavy`, e.g. `text-decoration: underline wavy var(--warning)`). The font is always **Inter**; `font-family` is ignored, and there are only regular and bold weights (plus italic), so hierarchy comes from size, weight, color and space.
## Boxes
Supported: `background` and `background-color` (hex, `rgb()`, `hsl()`, `var(--name)`), gradients (`linear-gradient`, `radial-gradient`), `background-image: url()`, `background-size` (`contain`, `100% 100%`), `border` (width, style, color; one stroke per node), `border-radius` (shorthand and per corner; `999px` makes a pill), `opacity`, `mix-blend-mode`. Strokes and gradients keep their resolved colors; they do not follow variables later.
Supported: `background` and `background-color` (hex, `rgb()`, `hsl()`, `var(--name)`), gradients (`linear-gradient`, `radial-gradient`), `background-image: url()`, `background-size` (`contain`, `100% 100%`), `border` and per-side `border-top`, `border-right`, `border-bottom`, `border-left` (width, style, color), `border-radius` (shorthand and per corner; `999px` makes a pill), `opacity`, `mix-blend-mode`. Strokes and gradients keep their resolved colors; they do not follow variables later.
Not supported (reported as ignored): `box-shadow`, `filter`, `backdrop-filter`, `text-shadow`, `outline-offset`, CSS animations and transitions.
+60 -11
View File
@@ -199,12 +199,13 @@ Read a guide. Topics: "papercraft-mcp-instructions" (read first), "html" (what w
### `get_screenshot` (read-only)
Render a node (or the whole page when nodeId is omitted or "root") to a PNG and return it as an image. Scale 1 (default) is enough for layout checks; use 2 to read small text. Large images are scaled down to fit API limits.
Render a node (or the whole page when nodeId is omitted or "root") to a PNG and return it as an image. Scale 1 (default) is enough for layout checks; use 2 to read small text. Large images are scaled down to fit API limits. In `papercraft-cli`, pass `-o`/`--out FILE` or `{"out": "FILE"}` to choose the output file path.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | | The node to capture; omit for the whole page. |
| `out` | string | | Optional file path to save the PNG to (also used by `papercraft-cli`). |
| `scale` | number | | Render scale: 1 (default) or 2. |
@@ -248,12 +249,12 @@ Creates an artboard (top-level frame) and returns its id; then add content with
Parse HTML with inline styles into layers. Write incrementally: each call should add one visual group (a header, one list row, a card) so the user sees progress.
- mode "insert-children" appends the HTML inside targetNodeId ("root" = the page); "replace" replaces targetNodeId with it.
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, flex-grow, flex-wrap) is the layout model; block elements stack vertically. position:absolute with left/top is supported. No margin, grid or tables.
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration; <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border, border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content).
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, align-self, flex-grow, flex-wrap) is the layout model; block elements stack vertically. position:absolute with left/top is supported. No margin, grid or tables.
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration (including `wavy` and `text-decoration-color`); <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border (and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. <svg> markup is imported as vector layers.
- <x-paper-icon name="set:name" size="20" color="var(--accent)"/> places an installed icon (search_icons); <x-paper-instance component-id="ID" layer-name="..."/> places a component instance; colors may be var(--name) of set_variables.
- layer-name="..." names layers; <x-paper-clone node-id="ID" style="..."/> clones an existing node.
- <x-paper-icon name="set:name" size="20" color="var(--accent)"/> places an installed icon (search_icons); <x-paper-instance component="Name" component-id="ID" layer-name="..."/> places a component instance; colors may be var(--name) of set_variables.
- layer-name="..." names layers; <x-paper-clone node-id="ID" style="..."/> clones an existing node (supports cross-page `<page>:<id>`).
Returns the ids of the created top-level layers and the styles that were ignored.
@@ -306,22 +307,22 @@ Delete nodes and their descendants. Verify parents with get_node_info first when
### `duplicate_nodes`
Deep-clone nodes. Each copy goes under parentId when given, else next to its source; duplicated artboards are placed in an empty spot. Returns new ids and a descendantIdMap from every original descendant id to its copy.
Deep-clone nodes, including across pages (via `toPageId` or a cross-page `parentId`; `<x-paper-clone node-id="<page>:<id>"/>` also works across pages). Each copy goes under parentId when given, else next to its source; duplicated artboards are placed in an empty spot. Returns new ids and a descendantIdMap from every original descendant id to its copy.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodes` | array of object | yes | |
| `nodes` | array of object | yes | Items accept `{id, parentId?, toPageId?}`. |
### `move_nodes`
Move nodes keeping their ids. Each move is {nodeId, before: siblingId}, {nodeId, after: siblingId} or {nodeId, parentId, index?} (parentId "root" = the page root). Moves apply in order. In auto-layout parents this changes the visual order; in free parents it changes stacking and keeps the world position.
Move nodes keeping their ids (or onto another page via `toPageId` or a cross-page `parentId` / `before` / `after`). Each move is {nodeId, before: siblingId}, {nodeId, after: siblingId} or {nodeId, parentId, toPageId?, index?} (parentId "root" = the page root). Moves apply in order. In auto-layout parents this changes the visual order; in free parents it changes stacking and keeps the world position.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `moves` | array of object | yes | |
| `moves` | array of object | yes | Items accept `{nodeId, before?, after?, parentId?, toPageId?, index?}`. |
### `create_shape`
@@ -388,9 +389,20 @@ Switch the active variable mode (defined with set_variables {mode}): every varia
Main components on a page (id, name, size) with how many instances each has.
### `import_components`
Copy the main components anywhere on `fromPageId` (including inside artboards, typically on a `Design System` page) onto this one, so you can place instances here with `create_instance` or `<x-paper-instance component="Name" component-id="ID"/>`. Components with the same name already here are skipped. Variables and tokens are already shared by every page; components are copies, not live links.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `fromPageId` | string | yes | Source page id. |
| `names` | array of string | | Only these components (default all). |
### `create_component`
Turn a frame or artboard into a main component. Build reusable parts (button, row, chip, tab) once as components, then place them with create_instance or <x-paper-instance>. Components and instances live on one page.
Turn a frame or artboard into a main component. Build reusable parts (button, row, chip, tab) once as components, then place them with create_instance or `<x-paper-instance component="Name" component-id="ID"/>`. Components and instances live on one page.
| Argument | Type | Required | Notes |
@@ -462,6 +474,43 @@ Check a page or subtree for common design mistakes and get a fix for each: text
## Session
### `get_feedback` (read-only)
Notes the user left on layers (the Feedback box in the inspector, shown as amber dots on the canvas): which layer, where it sits, what to change. Omitting `pageId` queries all pages across the project; `openCount` and `resolvedCount` are returned alongside per-page counts in `get_basic_info`. Call it at the start of a session and after each round of edits; fix every open note, then `resolve_feedback`. `get_selection` also includes the selected layers' notes.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `includeResolved` | boolean | | Include resolved notes. |
| `nodeId` | string | | Only this layer. |
| `pageId` | string | | Omit to query all pages across the project. |
### `add_feedback`
Leave a note on a layer (author `agent` by default), e.g. a question or a problem you could not fix. The user sees it in the inspector and as a dot on the canvas.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `author` | string | | Defaults to `agent`. |
| `nodeId` | string | yes | |
| `text` | string | yes | |
### `resolve_feedback`
Mark notes on a layer resolved, with a short reply saying what you changed. Without `commentId` every open note on the layer is resolved. `delete` removes instead.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `commentId` | number | | |
| `delete` | boolean | | |
| `nodeId` | string | yes | |
| `reply` | string | | |
### `select_nodes`
Select nodes on the page the user is viewing so they can see what you mean; zoom: true also frames them in the viewport. An empty list clears the selection.
+10
View File
@@ -59,3 +59,13 @@ after a relay restart (tabs resync from `/files` on reconnect, so a lower rev is
locally; editing a page in tab A shows up in tab B without a manual reload. Wasm check builds
clean and native tests stay green.
- Local (non-shared) projects behave exactly as before.
## Where it lives
| Concern | Code |
|---------|------|
| Server store, `/p/` routes, path and token checks | `src/mcp/store.rs`, merged into the relay router in `src/mcp/relay.rs` (`spawn_with_data`); `--data` flag in `src/bin/papercraft-relay.rs` |
| Sync engine (both targets), `.share` file, link parsing, server-path sanitising | `src/sync.rs` (HTTP: reqwest native, `fetch` wasm) |
| Reload of the open project, status, join/share entry points | `src/session.rs` (`sync_poll`, `share_project`, `join_shared`) |
| Home screen: Share…, Join shared…, status badge, copy link | `src/ui/home.rs`; actions handled in `src/app.rs` and `src/web.rs`; `?share=` auto-join in `src/web.rs` |
| Tests | `tests/relay.rs` (store routes), `tests/sync.rs` (two folders through a real relay), unit tests in `src/sync.rs` and `src/mcp/store.rs` |
+27 -6
View File
@@ -19,7 +19,7 @@ USAGE:
papercraft-cli list tool names and one-line descriptions (works offline)
papercraft-cli schema <tool> a tool's full description and JSON input schema
papercraft-cli call <tool> [JSON|-] run a tool; JSON arguments inline, or `-` for stdin
[--out FILE] where to save an image result (default: <tool>.png)
[-o|--out FILE] where to save an image result (default: <tool>.png, or {\"out\": \"FILE\"})
papercraft-cli icons list installed and installable open icon sets
papercraft-cli icons add <set>... download sets (lucide tabler phosphor heroicons material)
papercraft-cli icons search <words> [--set S] find `set:name` references (works offline)
@@ -197,6 +197,13 @@ fn schema(name: &str) -> ExitCode {
}
async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, String> {
let out = out.or_else(|| {
args.get("out")
.or_else(|| args.get("path"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from)
});
let url = mcp::url(mcp::port());
let client = ()
.serve(StreamableHttpClientTransport::from_uri(url.clone()))
@@ -219,6 +226,12 @@ async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, St
.decode(v["data"].as_str().unwrap_or(""))
.map_err(|e| format!("bad image data: {e}"))?;
let path = out.clone().unwrap_or_else(|| format!("{name}.png"));
if let Some(p) = std::path::Path::new(&path)
.parent()
.filter(|p| !p.as_os_str().is_empty())
{
let _ = std::fs::create_dir_all(p);
}
std::fs::write(&path, data).map_err(|e| format!("{path}: {e}"))?;
println!("{path}");
}
@@ -230,11 +243,19 @@ async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, St
fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let out = args
.iter()
.position(|a| a == "--out")
.and_then(|i| (i + 1 < args.len()).then(|| args.drain(i..=i + 1).nth(1)))
.flatten();
let mut out: Option<String> = None;
let mut i = 0;
while i < args.len() {
if (args[i] == "--out" || args[i] == "-o") && i + 1 < args.len() {
out = Some(args.drain(i..=i + 1).nth(1).unwrap());
} else if let Some(v) = args[i].strip_prefix("--out=") {
let v = v.to_string();
args.remove(i);
out = Some(v);
} else {
i += 1;
}
}
match args.first().map(String::as_str) {
Some("list") => list(),
Some("schema") => match args.get(1) {
+1
View File
@@ -2000,6 +2000,7 @@ fn rest_layout_child(j: &Json) -> LayoutChild {
sizing,
absolute,
constraints,
..LayoutChild::default()
}
}
+130 -16
View File
@@ -29,6 +29,17 @@ fn set(r: &mut Rect, axis: usize, pos: f64, size: f64) {
}
}
fn clamp_size(lc: &crate::model::LayoutChild, axis: usize, s: f64) -> f64 {
let mut out = s.max(0.0);
if let Some(min) = lc.min_size[axis] {
out = out.max(min);
}
if let Some(max) = lc.max_size[axis] {
out = out.min(max.max(lc.min_size[axis].unwrap_or(0.0)));
}
out
}
/// Children that take part in the flow.
pub fn flow_children(doc: &Document, id: NodeId) -> Vec<NodeId> {
doc.children(Some(id))
@@ -45,7 +56,9 @@ pub fn flow_children(doc: &Document, id: NodeId) -> Vec<NodeId> {
fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
let n = doc.get(id).unwrap();
let (_, cur) = get(&n.rect, axis);
let Some(l) = n.layout else { return cur };
let Some(l) = n.layout else {
return clamp_size(&n.layout_child, axis, cur);
};
let kids = flow_children(doc, id);
let pad = if axis == 0 {
l.padding[3] + l.padding[1]
@@ -57,13 +70,13 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
.map(|k| {
let kn = doc.get(*k).unwrap();
match kn.layout_child.sizing[axis] {
Sizing::Hug => hug_size(doc, *k, axis),
Sizing::Hug => clamp_size(&kn.layout_child, axis, hug_size(doc, *k, axis)),
Sizing::Fill => 0.0,
Sizing::Fixed => get(&kn.rect, axis).1,
Sizing::Fixed => clamp_size(&kn.layout_child, axis, get(&kn.rect, axis).1),
}
})
.collect();
if axis == main_axis(&l) && !l.wrap {
let result = if axis == main_axis(&l) && !l.wrap {
let gap = if l.gap_auto { 0.0 } else { l.gap };
pad + sizes.iter().sum::<f64>() + gap * (kids.len().saturating_sub(1)) as f64
} else if axis == main_axis(&l) {
@@ -79,11 +92,12 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
line.iter()
.map(|k| {
let kn = doc.get(*k).unwrap();
match kn.layout_child.sizing[axis] {
let raw = match kn.layout_child.sizing[axis] {
Sizing::Hug => hug_size(doc, *k, axis),
Sizing::Fill => get(&kn.rect, axis).1,
Sizing::Fixed => get(&kn.rect, axis).1,
}
};
clamp_size(&kn.layout_child, axis, raw)
})
.fold(0.0, f64::max)
})
@@ -91,7 +105,8 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
pad + cross.iter().sum::<f64>() + l.cross_gap * cross.len().saturating_sub(1) as f64
} else {
pad + sizes.iter().cloned().fold(0.0, f64::max)
}
};
clamp_size(&n.layout_child, axis, result)
}
fn wrap_lines(
@@ -112,10 +127,11 @@ fn wrap_lines(
let mut x = 0.0;
for k in kids {
let kn = doc.get(*k).unwrap();
let s = match kn.layout_child.sizing[ma] {
let raw_s = match kn.layout_child.sizing[ma] {
Sizing::Hug => hug_size(doc, *k, ma),
_ => get(&kn.rect, ma).1,
};
let s = clamp_size(&kn.layout_child, ma, raw_s);
let line = lines.last_mut().unwrap();
if !line.is_empty() && x + l.gap + s > inner + 1e-9 {
lines.push(vec![*k]);
@@ -166,6 +182,8 @@ pub fn layout_frame(tx: &mut Tx, id: NodeId) -> bool {
set(&mut rect, axis, p, s.max(0.0));
}
}
let (p, cur_s) = get(&rect, axis);
set(&mut rect, axis, p, clamp_size(&n.layout_child, axis, cur_s));
}
if rect != n.rect {
tx.node_mut(id).unwrap().rect = rect;
@@ -208,10 +226,11 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
line.iter()
.map(|k| {
let kn = tx.doc().get(*k).unwrap();
match kn.layout_child.sizing[ca] {
let raw = match kn.layout_child.sizing[ca] {
Sizing::Hug => hug_size(tx.doc(), *k, ca),
Sizing::Fill | Sizing::Fixed => get(&kn.rect, ca).1,
}
};
clamp_size(&kn.layout_child, ca, raw)
})
.fold(0.0, f64::max)
})
@@ -239,8 +258,8 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
let kn = tx.doc().get(*k).unwrap();
match kn.layout_child.sizing[ma] {
Sizing::Fill => 0.0,
Sizing::Hug => hug_size(tx.doc(), *k, ma),
Sizing::Fixed => get(&kn.rect, ma).1,
Sizing::Hug => clamp_size(&kn.layout_child, ma, hug_size(tx.doc(), *k, ma)),
Sizing::Fixed => clamp_size(&kn.layout_child, ma, get(&kn.rect, ma).1),
}
})
.collect();
@@ -252,7 +271,7 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
if !fills.is_empty() && !l.wrap {
let share = ((inner_m - fixed) / fills.len() as f64).max(0.0);
for i in &fills {
sizes[*i] = share;
sizes[*i] = clamp_size(&tx.doc().get(line[*i]).unwrap().layout_child, ma, share);
}
}
let used: f64 = sizes.iter().sum::<f64>() + gap * line.len().saturating_sub(1) as f64;
@@ -274,15 +293,29 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
for (i, k) in line.iter().enumerate() {
let kn = tx.doc().get(*k).unwrap();
let mut r = kn.rect;
let cross_align = kn.layout_child.align_self.unwrap_or(l.align_cross);
let (cpos, cs) = match kn.layout_child.sizing[ca] {
Sizing::Fill => (cross_cursor, lh.max(0.0)),
Sizing::Fill => {
let cs = clamp_size(&kn.layout_child, ca, lh.max(0.0));
let cy = if cs < lh {
match cross_align {
Align::Start => cross_cursor,
Align::Center => cross_cursor + (lh - cs) * 0.5,
Align::End => cross_cursor + lh - cs,
}
} else {
cross_cursor
};
(cy, cs)
}
Sizing::Fixed | Sizing::Hug => {
let ch = if kn.layout_child.sizing[ca] == Sizing::Hug {
let raw_ch = if kn.layout_child.sizing[ca] == Sizing::Hug {
hug_size(tx.doc(), *k, ca)
} else {
get(&r, ca).1
};
let cy = match l.align_cross {
let ch = clamp_size(&kn.layout_child, ca, raw_ch);
let cy = match cross_align {
Align::Start => cross_cursor,
Align::Center => cross_cursor + (lh - ch) * 0.5,
Align::End => cross_cursor + lh - ch,
@@ -1007,4 +1040,85 @@ mod tests {
Rect::new(10.0, 70.0, 80.0, 50.0)
);
}
#[test]
fn align_self_overrides_parent_cross_align() {
let mut doc = Document::default();
let mut rec = Recording::new("t", &doc);
let mut tx = Tx::new(&mut doc, &mut rec);
let fid = tx.alloc_id();
let mut f = Node::new(
fid,
NodeKind::Frame { clip: true },
Rect::new(0.0, 0.0, 300.0, 100.0),
);
f.layout = Some(AutoLayout {
direction: Direction::Horizontal,
gap: 10.0,
padding: [0.0; 4],
align_cross: Align::Start,
..Default::default()
});
f.layout_child.sizing = FX;
tx.insert(f, None, None);
let mut ids = Vec::new();
for self_align in [None, Some(Align::Center), Some(Align::End)] {
let id = tx.alloc_id();
let mut n = Node::new(id, NodeKind::Rect, Rect::new(0.0, 0.0, 40.0, 20.0));
n.layout_child.sizing = FX;
n.layout_child.align_self = self_align;
tx.insert(n, Some(fid), None);
ids.push(id);
}
layout_frame(&mut tx, fid);
assert_eq!(
tx.doc().get(ids[0]).unwrap().rect,
Rect::new(0.0, 0.0, 40.0, 20.0)
);
assert_eq!(
tx.doc().get(ids[1]).unwrap().rect,
Rect::new(50.0, 40.0, 40.0, 20.0)
);
assert_eq!(
tx.doc().get(ids[2]).unwrap().rect,
Rect::new(100.0, 80.0, 40.0, 20.0)
);
}
#[test]
fn min_size_and_max_size_clamping() {
let mut doc = Document::default();
let mut rec = Recording::new("t", &doc);
let mut tx = Tx::new(&mut doc, &mut rec);
let fid = tx.alloc_id();
let mut f = Node::new(
fid,
NodeKind::Frame { clip: true },
Rect::new(0.0, 0.0, 400.0, 10.0),
);
f.layout = Some(AutoLayout {
direction: Direction::Horizontal,
gap: 0.0,
padding: [0.0; 4],
align_cross: Align::Center,
..Default::default()
});
f.layout_child.sizing = [Sizing::Hug, Sizing::Hug];
f.layout_child.min_size = [None, Some(44.0)];
f.layout_child.max_size = [Some(300.0), None];
tx.insert(f, None, None);
let cid = tx.alloc_id();
let mut c = Node::new(cid, NodeKind::Rect, Rect::new(0.0, 0.0, 500.0, 20.0));
c.layout_child.sizing = FX;
c.layout_child.max_size = [Some(350.0), None];
tx.insert(c, Some(fid), None);
layout_frame(&mut tx, fid);
let fr = tx.doc().get(fid).unwrap().rect;
assert_eq!((fr.w, fr.h), (300.0, 44.0));
let cr = tx.doc().get(cid).unwrap().rect;
assert_eq!(cr, Rect::new(0.0, 12.0, 350.0, 20.0));
}
}
+322 -40
View File
@@ -620,7 +620,7 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
.map(|(k, v)| match var_name(v) {
Some((name, fallback)) => match VARS.with(|m| m.borrow().get(name).copied()) {
Some(c) => {
if COLOR_PROPS.contains(&k.as_str()) {
if COLOR_PROPS.contains(&k.as_str()) && k != "textDecorationColor" {
linked.push(name);
}
(k.clone(), color_css(c))
@@ -664,7 +664,13 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
a
}
const COLOR_PROPS: [&str; 4] = ["backgroundColor", "background", "color", "fill"];
const COLOR_PROPS: [&str; 5] = [
"backgroundColor",
"background",
"color",
"fill",
"textDecorationColor",
];
thread_local! {
static VARS: std::cell::RefCell<std::collections::BTreeMap<String, Color>> = const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
@@ -826,8 +832,16 @@ pub fn override_keys(styles: &[(String, String)]) -> Vec<&'static str> {
"left" | "top" | "right" | "bottom" | "x" | "y" => "position",
"borderRadius" => "radius",
"opacity" | "mixBlendMode" => "opacity",
"fontSize" | "fontWeight" | "fontStyle" | "lineHeight" | "letterSpacing"
| "textAlign" => "font",
"fontSize"
| "fontWeight"
| "fontStyle"
| "lineHeight"
| "letterSpacing"
| "textAlign"
| "textDecoration"
| "textDecorationLine"
| "textDecorationColor"
| "textDecorationStyle" => "font",
"visibility" => "visible",
k if k.starts_with("border") || k == "stroke" => "stroke",
_ => "layout",
@@ -1075,23 +1089,49 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
None => false,
}
}
"alignSelf" => match ctx.parent_layout {
Some(pl) if lower == "stretch" => {
let ca = if pl.direction == Direction::Horizontal {
"alignSelf" => {
let ca = ctx.parent_layout.map(|pl| {
if pl.direction == Direction::Horizontal {
1
} else {
0
};
axis_set(n, ca, Sizing::Fill);
if ca == 0
&& let NodeKind::Text { auto_width, .. } = &mut n.kind
{
*auto_width = false;
}
true
});
match lower.as_str() {
"stretch" => {
n.layout_child.align_self = None;
if let Some(ca) = ca {
axis_set(n, ca, Sizing::Fill);
if ca == 0
&& let NodeKind::Text { auto_width, .. } = &mut n.kind
{
*auto_width = false;
}
}
true
}
"auto" | "normal" => {
n.layout_child.align_self = None;
true
}
_ if align_of(&lower).is_some() => {
let al = align_of(&lower).unwrap();
n.layout_child.align_self = Some(al);
if let Some(ca) = ca
&& n.layout_child.sizing[ca] == Sizing::Fill
{
n.layout_child.sizing[ca] = if matches!(n.kind, NodeKind::Frame { .. })
{
Sizing::Hug
} else {
Sizing::Fixed
};
}
true
}
_ => false,
}
_ => false,
},
}
"textTransform" if is_text => {
if let Some(t) = n.kind.text_mut() {
match lower.as_str() {
@@ -1121,14 +1161,44 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
}
None => false,
},
"flexShrink" | "flexBasis" | "boxSizing" | "minWidth" | "minHeight" | "maxWidth"
| "maxHeight" | "zIndex" | "cursor" | "whiteSpace" | "fontFamily" | "wordBreak"
| "overflowWrap" => {
"minWidth" | "minHeight" => {
let axis = usize::from(k == "minHeight");
if matches!(lower.as_str(), "none" | "auto" | "0") {
n.layout_child.min_size[axis] = None;
true
} else if let Some(px) = length(v, fs) {
n.layout_child.min_size[axis] = Some(px.max(0.0));
if axis == 0 && n.rect.w < px {
n.rect.w = px;
} else if axis == 1 && n.rect.h < px {
n.rect.h = px;
}
true
} else {
false
}
}
"maxWidth" | "maxHeight" => {
let axis = usize::from(k == "maxHeight");
if matches!(lower.as_str(), "none" | "auto") {
n.layout_child.max_size[axis] = None;
true
} else if let Some(px) = length(v, fs) {
n.layout_child.max_size[axis] = Some(px.max(0.0));
if axis == 0 && n.rect.w > px {
n.rect.w = px;
} else if axis == 1 && n.rect.h > px {
n.rect.h = px;
}
true
} else {
false
}
}
"flexShrink" | "flexBasis" | "boxSizing" | "zIndex" | "cursor" | "whiteSpace"
| "fontFamily" | "wordBreak" | "overflowWrap" => {
// Accepted without an equivalent; harmless. fontFamily: Inter is the only face.
!matches!(
k.as_str(),
"minWidth" | "minHeight" | "maxWidth" | "maxHeight"
)
true
}
"overflow" if n.is_frame() => {
if let NodeKind::Frame { clip } = &mut n.kind {
@@ -1224,18 +1294,79 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
"textDecoration" | "textDecorationLine" if is_text => {
let u = lower.contains("underline");
let s = lower.contains("line-through");
let wavy = lower.contains("wavy");
let dc = split_ws(v).into_iter().find_map(|tok| color(&tok));
set_text_flag(n, |sp| {
sp.underline = Some(u);
sp.strike = Some(s);
if wavy {
sp.underline_wavy = Some(true);
}
if let Some(c) = dc {
sp.underline_color = Some(c);
}
});
true
}
"fontWeight" | "fontStyle" | "lineHeight" | "letterSpacing" | "textAlign"
| "textDecoration" => true, // inherited by text children
"border" | "outline" | "borderTop" | "borderRight" | "borderBottom" | "borderLeft" => {
"textDecorationColor" if is_text => match color(v) {
Some(c) => {
set_text_flag(n, |sp| sp.underline_color = Some(c));
true
}
None => false,
},
"textDecorationStyle" if is_text => {
let wavy = lower.contains("wavy");
set_text_flag(n, |sp| sp.underline_wavy = Some(wavy));
true
}
"fontWeight"
| "fontStyle"
| "lineHeight"
| "letterSpacing"
| "textAlign"
| "textDecoration"
| "textDecorationLine"
| "textDecorationColor"
| "textDecorationStyle" => true, // inherited by text children
"border" | "outline" => {
n.style.stroke_sides = None;
apply_border(n, v, fs)
}
"borderColor" | "stroke" => match color(v) {
"borderTop" | "borderRight" | "borderBottom" | "borderLeft" => {
let side_idx = ["borderTop", "borderRight", "borderBottom", "borderLeft"]
.iter()
.position(|x| x == k)
.unwrap();
apply_border_side(n, side_idx, v, fs)
}
"borderTopWidth" | "borderRightWidth" | "borderBottomWidth" | "borderLeftWidth" => {
match length(v, fs) {
Some(w) => {
let side_idx = [
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
]
.iter()
.position(|x| x == k)
.unwrap();
let mut sides = n.style.stroke_sides.unwrap_or([0.0; 4]);
sides[side_idx] = w.max(0.0);
n.style.stroke_sides = if sides == [0.0; 4] { None } else { Some(sides) };
n.style.stroke_width = sides.iter().copied().fold(0.0, f64::max);
n.style.stroke_align = crate::model::StrokeAlign::Inside;
if n.style.stroke_width > 0.0 && n.style.stroke.is_none() {
n.style.stroke = Some(Color::INK);
}
true
}
None => false,
}
}
"borderColor" | "stroke" | "borderTopColor" | "borderRightColor"
| "borderBottomColor" | "borderLeftColor" => match color(v) {
Some(c) => {
n.style.stroke = (c.a > 0).then_some(c);
if n.style.stroke_width <= 0.0 {
@@ -1245,19 +1376,39 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
}
None => false,
},
"borderWidth" | "strokeWidth" => match length(v, fs) {
Some(w) => {
n.style.stroke_width = w;
if k == "borderWidth" {
n.style.stroke_align = crate::model::StrokeAlign::Inside;
"borderWidth" | "strokeWidth" => {
if k == "borderWidth"
&& let Some(w) = four(v, fs)
{
n.style.stroke_align = crate::model::StrokeAlign::Inside;
if w.iter().all(|&x| (x - w[0]).abs() < 1e-9) {
n.style.stroke_sides = None;
n.style.stroke_width = w[0];
} else {
n.style.stroke_sides = Some(w);
n.style.stroke_width = w.iter().copied().fold(0.0, f64::max);
}
if w > 0.0 && n.style.stroke.is_none() {
if n.style.stroke_width > 0.0 && n.style.stroke.is_none() {
n.style.stroke = Some(Color::INK);
}
true
} else {
match length(v, fs) {
Some(w) => {
n.style.stroke_sides = None;
n.style.stroke_width = w;
if k == "borderWidth" {
n.style.stroke_align = crate::model::StrokeAlign::Inside;
}
if w > 0.0 && n.style.stroke.is_none() {
n.style.stroke = Some(Color::INK);
}
true
}
None => false,
}
}
None => false,
},
}
"borderStyle" | "strokeStyle" => {
set_dash(n, &lower);
true
@@ -1425,6 +1576,53 @@ fn apply_border(n: &mut Node, v: &str, fs: f64) -> bool {
any
}
fn apply_border_side(n: &mut Node, side_idx: usize, v: &str, fs: f64) -> bool {
let lower = v.to_ascii_lowercase();
let mut sides = n.style.stroke_sides.unwrap_or([0.0; 4]);
if lower == "none" || lower == "0" {
sides[side_idx] = 0.0;
n.style.stroke_sides = if sides == [0.0; 4] { None } else { Some(sides) };
n.style.stroke_width = sides.iter().copied().fold(0.0, f64::max);
if sides == [0.0; 4] {
n.style.stroke = None;
}
return true;
}
let mut any = false;
let mut width = None;
let mut col = None;
let mut style = "solid".to_string();
for p in split_ws(v) {
if let Some(w) = length(&p, fs) {
width = Some(w);
any = true;
} else if let Some(c) = color(&p) {
col = Some(c);
any = true;
} else if matches!(
p.to_ascii_lowercase().as_str(),
"solid" | "dashed" | "dotted" | "double"
) {
style = p.to_ascii_lowercase();
any = true;
}
}
if any {
let w = width.unwrap_or(1.0);
sides[side_idx] = w;
n.style.stroke_sides = Some(sides);
n.style.stroke_width = sides.iter().copied().fold(0.0, f64::max);
if let Some(c) = col {
n.style.stroke = (c.a > 0).then_some(c);
} else if n.style.stroke.is_none() && !lower.contains("transparent") {
n.style.stroke = Some(Color::INK);
}
n.style.stroke_align = crate::model::StrokeAlign::Inside;
set_dash(n, &style);
}
any
}
fn apply_background(n: &mut Node, k: &str, v: &str, a: &mut Applied) -> bool {
let lower = v.to_ascii_lowercase();
if lower == "none" || lower == "transparent" {
@@ -1510,6 +1708,21 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
put("width", size_css(0, n.rect.w));
put("height", size_css(1, n.rect.h));
}
if let Some(mw) = n.layout_child.min_size[0] {
put("minWidth", px(mw));
}
if let Some(mh) = n.layout_child.min_size[1] {
put("minHeight", px(mh));
}
if let Some(xw) = n.layout_child.max_size[0] {
put("maxWidth", px(xw));
}
if let Some(xh) = n.layout_child.max_size[1] {
put("maxHeight", px(xh));
}
if let Some(al) = n.layout_child.align_self {
put("alignSelf", align_css(al).into());
}
if let Some(pl) = parent_layout
&& in_flow
{
@@ -1521,7 +1734,7 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
if n.layout_child.sizing[ma] == Sizing::Fill {
put("flexGrow", "1".into());
}
if n.layout_child.sizing[1 - ma] == Sizing::Fill {
if n.layout_child.align_self.is_none() && n.layout_child.sizing[1 - ma] == Sizing::Fill {
put("alignSelf", "stretch".into());
}
put("flexShrink", "0".into());
@@ -1635,10 +1848,23 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
} else {
"dashed"
};
put(
"border",
format!("{} {kind} {}", px(n.style.stroke_width), color_css(s)),
);
if let Some(sides) = n.style.stroke_sides
&& sides.iter().any(|&w| (w - sides[0]).abs() > 1e-9)
{
for (i, key) in ["borderTop", "borderRight", "borderBottom", "borderLeft"]
.into_iter()
.enumerate()
{
if sides[i] > 0.0 {
put(key, format!("{} {kind} {}", px(sides[i]), color_css(s)));
}
}
} else {
put(
"border",
format!("{} {kind} {}", px(n.style.stroke_width), color_css(s)),
);
}
}
if let Some(r) = n.style.radii {
put(
@@ -1774,4 +2000,60 @@ mod tests {
let r = n.style.rough.unwrap();
assert_eq!((r.roughness, r.fill_style), (2.0, FillStyle::CrossHatch));
}
#[test]
fn border_sides_align_self_min_max_and_wavy_underline() {
let mut n = Node::new(
NodeId(4),
NodeKind::Frame { clip: false },
Rect::new(0.0, 0.0, 100.0, 20.0),
);
let a = apply(
&mut n,
&parse_inline(
"border-left: 3px solid #5eead4; align-self: center; min-height: 44px; max-height: 120px",
),
&Context {
parent_layout: Some(AutoLayout::default()),
parent_size: Some((300.0, 300.0)),
},
);
assert!(a.ignored.is_empty(), "{:?}", a.ignored);
assert_eq!(n.style.stroke_sides, Some([0.0, 0.0, 0.0, 3.0]));
assert_eq!(n.style.stroke_width, 3.0);
assert_eq!(n.style.stroke, Some(Color::rgb(0x5e, 0xea, 0xd4)));
assert_eq!(n.layout_child.align_self, Some(Align::Center));
assert_eq!(n.layout_child.min_size[1], Some(44.0));
assert_eq!(n.layout_child.max_size[1], Some(120.0));
assert_eq!(n.rect.h, 44.0);
let back = computed(&n, Some(&AutoLayout::default()), false);
assert_eq!(back["borderLeft"], "3px solid #5eead4");
assert!(!back.contains_key("border"));
assert_eq!(back["alignSelf"], "center");
assert_eq!(back["minHeight"], "44px");
assert_eq!(back["maxHeight"], "120px");
let mut tn = Node::new(
NodeId(5),
NodeKind::Text {
text: "Hello".into(),
font_size: 16.0,
auto_width: true,
align: TextAlign::Left,
},
Rect::new(0.0, 0.0, 50.0, 20.0),
);
let ta = apply(
&mut tn,
&parse_inline(
"text-decoration: underline; text-decoration-color: #f9e2af; text-decoration-style: wavy",
),
&Context::default(),
);
assert!(ta.ignored.is_empty(), "{:?}", ta.ignored);
let sp = &tn.text_style.spans[0];
assert_eq!(sp.underline, Some(true));
assert_eq!(sp.underline_color, Some(Color::rgb(0xf9, 0xe2, 0xaf)));
assert_eq!(sp.underline_wavy, Some(true));
}
}
+517 -116
View File
@@ -508,9 +508,14 @@ fn basic_info(host: &mut dyn Host, a: &Args) -> ToolResult {
.iter()
.enumerate()
.map(|(i, p)| {
let pdoc = ed.page_doc(i);
let open_fb: usize = pdoc.nodes.values().map(|n| n.open_comments()).sum();
let resolved_fb: usize = pdoc.nodes.values().map(|n| n.resolved_comments()).sum();
json!({
"id": p.id, "name": p.name, "isActive": i == ed.active_page,
"nodeCount": ed.page_doc(i).nodes.len(),
"nodeCount": pdoc.nodes.len(),
"openFeedback": open_fb,
"resolvedFeedback": resolved_fb,
})
})
.collect();
@@ -532,6 +537,9 @@ fn basic_info(host: &mut dyn Host, a: &Args) -> ToolResult {
"openFeedback": (0..ed.pages.len())
.map(|p| ed.page_doc(p).nodes.values().map(|n| n.open_comments()).sum::<usize>())
.sum::<usize>(),
"resolvedFeedback": (0..ed.pages.len())
.map(|p| ed.page_doc(p).nodes.values().map(|n| n.resolved_comments()).sum::<usize>())
.sum::<usize>(),
"nodeIdFormat": "\"<page>:<n>\", e.g. \"".to_string() + &node_key(ed, page, NodeId(1)) + "\"",
}))
}
@@ -578,8 +586,16 @@ fn get_feedback(ed: &mut Editor, a: &Args) -> ToolResult {
(None, None) => (0..ed.pages.len()).collect(),
};
let mut items = Vec::new();
let mut total_open = 0usize;
let mut total_resolved = 0usize;
for page in pages {
let doc = ed.page_doc(page);
for n in doc.nodes.values() {
if only.is_none_or(|(_, id)| n.id == id) {
total_open += n.open_comments();
total_resolved += n.resolved_comments();
}
}
let mut nodes: Vec<&Node> = doc
.nodes
.values()
@@ -595,7 +611,9 @@ fn get_feedback(ed: &mut Editor, a: &Args) -> ToolResult {
nodes.sort_by_key(|n| n.id);
for n in nodes {
let mut v = json!({
"nodeId": node_key(ed, page, n.id), "page": ed.pages[page].name,
"nodeId": node_key(ed, page, n.id),
"pageId": ed.pages[page].id,
"page": ed.pages[page].name,
"name": n.name, "type": n.kind.type_name(), "path": node_path(doc, n.id),
"width": r2(n.rect.w), "height": r2(n.rect.h),
"feedback": comments_json(n, with_resolved),
@@ -608,6 +626,8 @@ fn get_feedback(ed: &mut Editor, a: &Args) -> ToolResult {
}
text(json!({
"count": items.len(),
"openCount": total_open,
"resolvedCount": total_resolved,
"items": items,
"next": "fix what each note asks, then resolve_feedback with a short reply",
}))
@@ -863,6 +883,11 @@ fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
if gpu.is_none() && !defer {
return Err("screenshots need a GPU and none is available".into());
}
let out_path = a
.str("out")
.or_else(|| a.str("path"))
.filter(|s| !s.is_empty())
.map(PathBuf::from);
let ed = host.editor();
let def = page_arg(ed, a)?;
let (page, roots) = match a.str("nodeId").filter(|k| !k.is_empty() && *k != ROOT) {
@@ -885,7 +910,21 @@ fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
};
loop {
let img = crate::io::png::render(&gpu, doc, Some(&roots), scale, Some(Color::WHITE))?;
if let Some(out) = shot_out(&img, &mut scale)? {
if let Some(mut out) = shot_out(&img, &mut scale)? {
if let Some(p) = &out_path {
if let Some(parent) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
let _ = std::fs::create_dir_all(parent);
}
if let Some(Out::Image { data, .. }) = out.first() {
std::fs::write(p, data).map_err(|e| format!("{}: {e}", p.display()))?;
}
if let Some(Out::Text(txt)) = out.get_mut(1)
&& let Ok(mut v) = serde_json::from_str::<Value>(txt)
{
v["path"] = json!(p.to_string_lossy());
*txt = v.to_string();
}
}
return Ok(out);
}
}
@@ -1663,6 +1702,9 @@ const INHERITED: &[&str] = &[
"fontFamily",
"whiteSpace",
"textDecoration",
"textDecorationLine",
"textDecorationColor",
"textDecorationStyle",
];
fn get<'s>(st: &'s Styles, k: &str) -> Option<&'s str> {
@@ -1697,6 +1739,8 @@ struct Run {
bold: Option<bool>,
italic: Option<bool>,
underline: Option<bool>,
underline_color: Option<Color>,
underline_wavy: Option<bool>,
strike: Option<bool>,
color: Option<Color>,
size: Option<f64>,
@@ -1747,6 +1791,22 @@ fn collect_runs(nodes: &[Html], run: Run, base_fs: f64, out: &mut Vec<(char, Run
"textDecoration" | "textDecorationLine" => {
r.underline = Some(lower.contains("underline"));
r.strike = Some(lower.contains("line-through"));
if lower.contains("wavy") {
r.underline_wavy = Some(true);
}
for tok in css::split_ws(v) {
if let Some((c, _)) = css::resolve_color_value(&tok) {
r.underline_color = Some(c);
}
}
}
"textDecorationColor" => {
if let Some((c, _)) = css::resolve_color_value(v) {
r.underline_color = Some(c);
}
}
"textDecorationStyle" => {
r.underline_wavy = Some(lower.contains("wavy"));
}
"color" => r.color = css::color(v).or(r.color),
"fontSize" => {
@@ -1790,9 +1850,17 @@ fn collapse(chars: Vec<(char, Run)>, pre: bool) -> Vec<(char, Run)> {
out
}
fn match_page_key(pid: &str, pname: &str, key: &str) -> bool {
let key = key.trim();
pid == key
|| (pid.starts_with(key) && key.len() >= 4)
|| (!pname.is_empty() && pname.eq_ignore_ascii_case(key))
}
struct Builder<'t, 'a> {
tx: &'t mut Tx<'a>,
page_id: String,
all_pages: &'t [(String, String, Document)],
ignored: BTreeSet<String>,
warnings: Vec<String>,
}
@@ -2018,6 +2086,12 @@ impl Builder<'_, '_> {
if r.underline.is_some() {
s.underline = r.underline;
}
if r.underline_color.is_some() {
s.underline_color = r.underline_color;
}
if r.underline_wavy.is_some() {
s.underline_wavy = r.underline_wavy;
}
if r.strike.is_some() {
s.strike = r.strike;
}
@@ -2257,11 +2331,17 @@ impl Builder<'_, '_> {
let key = el
.attr("component-id")
.or_else(|| el.attr("component"))
.unwrap_or("");
let num = key.rsplit(':').next().and_then(|n| n.parse::<u64>().ok());
// By id, or by component name (`component="Button / Primary"`).
let main = num
.map(NodeId)
.or_else(|| el.attr("name"))
.unwrap_or("")
.trim()
.trim_start_matches('#');
let (page_part, num_part) = match key.split_once(':') {
Some((p, n)) => (Some(p), n.parse::<u64>().ok().map(NodeId)),
None => (None, key.parse::<u64>().ok().map(NodeId)),
};
// By id on this page, or by component name (`component="Button / Primary"`).
let mut main = num_part
.filter(|_| page_part.is_none_or(|p| match_page_key(&self.page_id, "", p)))
.filter(|id| self.tx.doc().get(*id).is_some_and(|n| n.component))
.or_else(|| {
let mut named: Vec<&Node> = self
@@ -2274,6 +2354,53 @@ impl Builder<'_, '_> {
named.sort_by_key(|n| n.id);
named.first().map(|n| n.id)
});
// If not on this page, look across other pages and auto-import a copy.
if main.is_none() {
let ext = self.all_pages.iter().find_map(|(pid, pname, doc)| {
if pid == &self.page_id {
return None;
}
if let (Some(p), Some(id)) = (page_part, num_part) {
if match_page_key(pid, pname, p) && doc.get(id).is_some_and(|n| n.component) {
return Some((doc, id));
}
}
let mut named: Vec<&Node> = doc
.nodes
.values()
.filter(|n| n.component && n.name.eq_ignore_ascii_case(key))
.collect();
named.sort_by_key(|n| n.id);
named.first().map(|n| (doc, n.id))
});
if let Some((src_doc, src_comp)) = ext {
let right = self
.tx
.doc()
.root
.iter()
.filter_map(|r| self.tx.doc().world_rect(*r))
.map(|r| r.right())
.fold(f64::MIN, f64::max);
let x = if right == f64::MIN {
0.0
} else {
right + 160.0
};
let w = src_doc
.world_rect(src_comp)
.unwrap_or_else(|| src_doc.get(src_comp).unwrap().rect);
let (imported_id, _) = clone_subtree_from_doc(
self.tx,
src_doc,
src_comp,
None,
None,
v2(x, 0.0) - w.min(),
);
main = Some(imported_id);
}
}
let Some(main) = main else {
self.warnings.push(format!(
"x-paper-instance: `{key}` is not a component on this page (create_component first, \
@@ -2463,18 +2590,40 @@ impl Builder<'_, '_> {
parent: &Parent,
index: &mut Option<usize>,
) -> Option<NodeId> {
let key = el.attr("node-id").unwrap_or("");
let num = key.rsplit(':').next().and_then(|n| n.parse::<u64>().ok());
let Some(src) = num
.map(NodeId)
.filter(|id| self.tx.doc().get(*id).is_some())
else {
self.warnings.push(format!(
"x-paper-clone: node `{key}` not found on this page"
));
let key = el
.attr("node-id")
.unwrap_or("")
.trim()
.trim_start_matches('#');
let (page_part, num) = match key.split_once(':') {
Some((p, n)) => (Some(p), n.parse::<u64>().ok().map(NodeId)),
None => (None, key.parse::<u64>().ok().map(NodeId)),
};
let Some(src) = num else {
self.warnings
.push(format!("x-paper-clone: node `{key}` not found"));
return None;
};
let same_page = page_part.is_none_or(|p| match_page_key(&self.page_id, "", p));
let id = if same_page && self.tx.doc().get(src).is_some() {
clone_subtree(self.tx, src, parent.id, *index, V2::ZERO).0
} else if let Some((_, _, src_doc)) =
self.all_pages
.iter()
.find(|(pid, pname, doc)| match page_part {
Some(p) => match_page_key(pid, pname, p) && doc.get(src).is_some(),
None => pid != &self.page_id && doc.get(src).is_some(),
})
{
let w = src_doc
.world_rect(src)
.unwrap_or_else(|| src_doc.get(src).unwrap().rect);
clone_subtree_from_doc(self.tx, src_doc, src, parent.id, *index, -w.min()).0
} else {
self.warnings
.push(format!("x-paper-clone: node `{key}` not found"));
return None;
};
let (id, _) = clone_subtree(self.tx, src, parent.id, *index, V2::ZERO);
if let Some(i) = index {
*i += 1;
}
@@ -2492,6 +2641,66 @@ impl Builder<'_, '_> {
}
}
/// Deep-copy a subtree from `src_doc` into `tx` under `parent` (keeping its world position plus
/// `offset`). Returns the new root and the (old → new) id pairs.
fn clone_subtree_from_doc(
tx: &mut Tx,
src_doc: &Document,
src: NodeId,
parent: Option<NodeId>,
index: Option<usize>,
offset: V2,
) -> (NodeId, Vec<(NodeId, NodeId)>) {
let ids = src_doc.subtree(src);
let nodes: Vec<Node> = ids
.iter()
.map(|i| src_doc.get(*i).unwrap().clone())
.collect();
let mut map = HashMap::new();
for n in &nodes {
map.insert(n.id, tx.alloc_id());
}
let world = src_doc
.world_rect(src)
.unwrap_or_else(|| src_doc.get(src).unwrap().rect);
let origin = tx.doc().parent_origin(parent);
for n in &nodes {
let mut c = n.clone();
c.id = map[&n.id];
c.children = n
.children
.iter()
.filter_map(|x| map.get(x).copied())
.collect();
if let Some(l) = &mut c.instance {
if let Some(m) = map.get(&l.main) {
l.main = *m;
} else if !tx.doc().get(l.main).is_some_and(|mn| mn.component) {
c.instance = None;
}
}
if let NodeKind::Connector { start, end, .. } = &mut c.kind {
for b in [start, end].into_iter().flatten() {
if let Some(nn) = map.get(&b.node) {
b.node = *nn;
}
}
}
if n.id == src {
c.rect = world.translate(offset - origin);
tx.insert(c, parent, index);
} else {
c.parent = n.parent.and_then(|p| map.get(&p).copied());
tx.put(c);
}
}
for id in map.values() {
crate::editor::whiteboard::update_connector(tx, *id);
}
let pairs = ids.iter().map(|i| (*i, map[i])).collect();
(map[&src], pairs)
}
/// Deep-copy a subtree under `parent` (keeping its world position plus `offset`). Returns the
/// new root and the (old → new) id pairs.
fn clone_subtree(
@@ -2642,6 +2851,12 @@ fn write_html(ed: &mut Editor, a: &Args) -> ToolResult {
}
let tree = html::parse(src);
let page_id = ed.pages[page].id.clone();
let all_pages: Vec<(String, String, Document)> = ed
.pages
.iter()
.enumerate()
.map(|(i, p)| (p.id.clone(), p.name.clone(), ed.page_doc(i).clone()))
.collect();
let (created, ignored, warnings) = ed.with_page(page, |ed| {
ed.edit("Write HTML", |tx| {
// Where the new content goes.
@@ -2668,6 +2883,7 @@ fn write_html(ed: &mut Editor, a: &Args) -> ToolResult {
let mut b = Builder {
tx,
page_id: page_id.clone(),
all_pages: &all_pages,
ignored: BTreeSet::new(),
warnings: Vec::new(),
};
@@ -3338,7 +3554,13 @@ fn import_components(ed: &mut Editor, a: &Args) -> ToolResult {
let mut roots: Vec<&Node> = src
.nodes
.values()
.filter(|n| n.component && n.parent.is_none())
.filter(|n| {
n.component
&& !src
.ancestors(n.id)
.iter()
.any(|a| src.get(*a).is_some_and(|an| an.component))
})
.filter(|n| {
wanted
.as_ref()
@@ -3359,9 +3581,7 @@ fn import_components(ed: &mut Editor, a: &Args) -> ToolResult {
.collect()
};
if found.is_empty() {
return Err(
"no matching top-level components on that page (create_component first)".into(),
);
return Err("no matching components on that page (create_component first)".into());
}
let (imported, skipped) = ed.with_page(to, |ed| {
ed.edit("Import components", |tx| {
@@ -3678,61 +3898,93 @@ fn delete_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
fn duplicate_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
let top_to_page = match a.str("toPageId").filter(|p| !p.is_empty()) {
Some(p) => Some(find_page(ed, p)?),
None => None,
};
let mut jobs = Vec::new();
for item in a.arr("nodes")? {
let item = Args(item.as_object().ok_or("each item must be an object")?);
let (page, id) = resolve(ed, item.req_str("id")?, def)?;
let parent = match item.str("parentId").filter(|p| !p.is_empty()) {
None => None,
Some(ROOT) => Some(None),
let (src_page, id) = resolve(ed, item.req_str("id")?, def)?;
let item_to_page = match item.str("toPageId").filter(|p| !p.is_empty()) {
Some(p) => Some(find_page(ed, p)?),
None => top_to_page,
};
let (dest_page, parent) = match item.str("parentId").filter(|p| !p.is_empty()) {
None => match item_to_page {
Some(dp) if dp != src_page => (dp, Some(None)),
_ => (src_page, None),
},
Some(ROOT) => (item_to_page.unwrap_or(src_page), Some(None)),
Some(p) => {
let (pp, pid) = resolve(ed, p, page)?;
if pp != page {
return Err("duplicating to another page isn't supported".into());
}
let (pp, pid) = resolve(ed, p, item_to_page.unwrap_or(src_page))?;
if !ed.page_doc(pp).get(pid).unwrap().is_container() {
return Err("parentId must be a frame or group".into());
}
Some(Some(pid))
(pp, Some(Some(pid)))
}
};
jobs.push((page, id, parent));
jobs.push((src_page, dest_page, id, parent));
}
let mut out = Vec::new();
for (page, id, parent) in jobs {
let (new, pairs) = ed.with_page(page, |ed| {
ed.edit("Duplicate", |tx| {
let n = tx.doc().get(id).unwrap();
let same_parent = parent.is_none_or(|p| p == n.parent);
let target = parent.unwrap_or(n.parent);
let index = if same_parent {
tx.doc()
.children(target)
.iter()
.position(|c| *c == id)
.map(|i| i + 1)
} else {
None
};
let offset = if target.is_none() && n.is_frame() {
let w = tx.doc().world_rect(id).unwrap();
free_spot(tx.doc(), w.w) - w.min()
} else {
V2::ZERO
};
let r = clone_subtree(tx, id, target, index, offset);
relayout(tx, Some(r.0));
r
for (src_page, dest_page, id, parent) in jobs {
let (new, pairs) = if src_page == dest_page {
ed.with_page(src_page, |ed| {
ed.edit("Duplicate", |tx| {
let n = tx.doc().get(id).unwrap();
let same_parent = parent.is_none_or(|p| p == n.parent);
let target = parent.unwrap_or(n.parent);
let index = if same_parent {
tx.doc()
.children(target)
.iter()
.position(|c| *c == id)
.map(|i| i + 1)
} else {
None
};
let offset = if target.is_none() && n.is_frame() {
let w = tx.doc().world_rect(id).unwrap();
free_spot(tx.doc(), w.w) - w.min()
} else {
V2::ZERO
};
let r = clone_subtree(tx, id, target, index, offset);
relayout(tx, Some(r.0));
r
})
})
});
} else {
let src_doc = ed.page_doc(src_page).clone();
ed.with_page(dest_page, |ed| {
ed.edit("Duplicate", |tx| {
let n = src_doc.get(id).unwrap();
let target = parent.flatten();
let w = src_doc.world_rect(id).unwrap_or(n.rect);
let offset = if target.is_none() {
free_spot(tx.doc(), w.w) - w.min()
} else {
-w.min()
};
let r = clone_subtree_from_doc(tx, &src_doc, id, target, None, offset);
relayout(tx, target.or(Some(r.0)));
r
})
})
};
let map: Map<String, Value> = pairs
.iter()
.filter(|(o, _)| *o != id)
.map(|(o, n)| (node_key(ed, page, *o), json!(node_key(ed, page, *n))))
.map(|(o, n)| {
(
node_key(ed, src_page, *o),
json!(node_key(ed, dest_page, *n)),
)
})
.collect();
out.push(json!({
"sourceId": node_key(ed, page, id),
"newId": node_key(ed, page, new),
"sourceId": node_key(ed, src_page, id),
"newId": node_key(ed, dest_page, new),
"descendantIdMap": map,
}));
}
@@ -3741,80 +3993,135 @@ fn duplicate_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
fn move_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
let top_to_page = match a.str("toPageId").filter(|p| !p.is_empty()) {
Some(p) => Some(find_page(ed, p)?),
None => None,
};
let moves = a.arr("moves")?;
let mut done = 0;
for m in moves {
let m = Args(m.as_object().ok_or("each move must be an object")?);
let (page, id) = resolve(ed, m.req_str("nodeId")?, def)?;
let sib = |k: &str| -> Result<Option<NodeId>, String> {
match m.str(k) {
let (src_page, id) = resolve(ed, m.req_str("nodeId")?, def)?;
let mut dest_page = match m.str("toPageId").filter(|p| !p.is_empty()) {
Some(p) => find_page(ed, p)?,
None => top_to_page.unwrap_or(src_page),
};
let sib = |k: &str, dp: &mut usize| -> Result<Option<NodeId>, String> {
match m.str(k).filter(|s| !s.is_empty()) {
None => Ok(None),
Some(s) => {
let (p, sid) = resolve(ed, s, page)?;
if p != page {
return Err(
"moving between pages isn't supported; duplicate and delete instead"
.into(),
);
}
let (p, sid) = resolve(ed, s, *dp)?;
*dp = p;
Ok(Some(sid))
}
}
};
let (before, after) = (sib("before")?, sib("after")?);
let parent: Option<Option<NodeId>> = match m.str("parentId") {
None => None,
let before = sib("before", &mut dest_page)?;
let after = sib("after", &mut dest_page)?;
let parent: Option<Option<NodeId>> = match m.str("parentId").filter(|p| !p.is_empty()) {
None => {
if dest_page != src_page && before.is_none() && after.is_none() {
Some(None)
} else {
None
}
}
Some(ROOT) => Some(None),
Some(p) => {
let (pp, pid) = resolve(ed, p, page)?;
if pp != page {
return Err(
"moving between pages isn't supported; duplicate and delete instead".into(),
);
}
let (pp, pid) = resolve(ed, p, dest_page)?;
dest_page = pp;
Some(Some(pid))
}
};
let index = m.num("index").map(|i| i.max(0.0) as usize);
ed.with_page(page, |ed| {
ed.edit("Move", |tx| -> Result<(), String> {
let doc = tx.doc();
let (target, sibling) = if let Some(s) = before.or(after) {
(doc.get(s).unwrap().parent, Some(s))
} else if let Some(p) = parent {
(p, None)
} else {
return Err("each move needs before, after or parentId".into());
};
if sibling == Some(id) {
return Err("a node can't be moved next to itself".into());
}
if let Some(t) = target {
if t == id || doc.is_ancestor(id, t) {
return Err("can't move a node into itself or its descendants".into());
if dest_page == src_page {
ed.with_page(src_page, |ed| {
ed.edit("Move", |tx| -> Result<(), String> {
let doc = tx.doc();
let (target, sibling) = if let Some(s) = before.or(after) {
(doc.get(s).unwrap().parent, Some(s))
} else if let Some(p) = parent {
(p, None)
} else {
return Err("each move needs before, after or parentId".into());
};
if sibling == Some(id) {
return Err("a node can't be moved next to itself".into());
}
if !doc.get(t).unwrap().is_container() {
return Err("the destination can't have children".into());
if let Some(t) = target {
if t == id || doc.is_ancestor(id, t) {
return Err("can't move a node into itself or its descendants".into());
}
if !doc.get(t).unwrap().is_container() {
return Err("the destination can't have children".into());
}
}
}
tx.reparent(id, target, None);
let list: Vec<NodeId> = tx
.doc()
.children(target)
.iter()
.copied()
.filter(|c| *c != id)
.collect();
let i = match sibling {
Some(s) => {
list.iter().position(|c| *c == s).unwrap() + usize::from(after.is_some())
}
None => index.unwrap_or(list.len()).min(list.len()),
};
tx.move_to_index(id, i);
Ok(())
})
})?;
tx.reparent(id, target, None);
let list: Vec<NodeId> = tx
.doc()
.children(target)
.iter()
.copied()
.filter(|c| *c != id)
.collect();
let i = match sibling {
Some(s) => {
list.iter().position(|c| *c == s).unwrap()
+ usize::from(after.is_some())
}
None => index.unwrap_or(list.len()).min(list.len()),
};
tx.move_to_index(id, i);
Ok(())
})
})?;
} else {
let ddoc = ed.page_doc(dest_page);
let (target, sibling) = if let Some(s) = before.or(after) {
(ddoc.get(s).unwrap().parent, Some(s))
} else if let Some(p) = parent {
(p, None)
} else {
return Err("each move needs before, after, parentId or toPageId".into());
};
if let Some(t) = target
&& !ddoc.get(t).unwrap().is_container()
{
return Err("the destination can't have children".into());
}
let src_doc = ed.page_doc(src_page).clone();
ed.with_page(src_page, |ed| {
ed.edit("Move", |tx| {
tx.remove(id);
});
ed.selection.retain(|s| ed.doc.get(*s).is_some());
});
ed.with_page(dest_page, |ed| {
ed.edit("Move", |tx| {
let w = src_doc
.world_rect(id)
.unwrap_or_else(|| src_doc.get(id).unwrap().rect);
let offset = if target.is_none() {
free_spot(tx.doc(), w.w) - w.min()
} else {
-w.min()
};
let list_len = tx.doc().children(target).len();
let idx = match sibling {
Some(s) => tx
.doc()
.children(target)
.iter()
.position(|c| *c == s)
.map(|p| p + usize::from(after.is_some())),
None => Some(index.unwrap_or(list_len).min(list_len)),
};
let (new_root, _) =
clone_subtree_from_doc(tx, &src_doc, id, target, idx, offset);
relayout(tx, target.or(Some(new_root)));
});
});
}
done += 1;
}
text(json!({"moved": done}))
@@ -3885,4 +4192,98 @@ mod tests {
.collect();
assert_eq!(bold, "bold");
}
#[test]
fn cross_page_components_and_layers() {
let mut ed = Editor::default();
let p0_id = ed.pages[0].id.clone();
// Create an artboard with a nested component on page 0.
write_html(
&mut ed,
&Args(
json!({
"targetNodeId": "root",
"html": "<div layer-name=\"Sheet\" style=\"width: 400px; height: 300px\"><div layer-name=\"Button / Primary\" style=\"display: flex; background: #313244; padding: 8px; width: 100px; height: 40px\"><span>Save</span></div></div>"
})
.as_object()
.unwrap(),
),
)
.unwrap();
let btn_id = ed
.page_doc(0)
.nodes
.values()
.find(|n| n.name == "Button / Primary" && n.is_frame())
.unwrap()
.id;
let btn_key = node_key(&ed, 0, btn_id);
create_component(
&mut ed,
&Args(json!({"nodeId": btn_key}).as_object().unwrap()),
)
.unwrap();
// Create page 1 and import nested component from page 0.
create_page(
&mut ed,
&Args(json!({"name": "Page 2"}).as_object().unwrap()),
)
.unwrap();
let p1_id = ed.pages[1].id.clone();
let imp = import_components(
&mut ed,
&Args(
json!({"fromPageId": p0_id, "pageId": p1_id})
.as_object()
.unwrap(),
),
)
.unwrap();
let Out::Text(imp_txt) = &imp[0] else {
panic!()
};
assert!(imp_txt.contains("Button / Primary"), "{imp_txt}");
// Cross-page <x-paper-clone> from page 0 into page 1.
let cloned = write_html(
&mut ed,
&Args(
json!({
"pageId": p1_id,
"targetNodeId": "root",
"html": format!("<x-paper-clone node-id=\"{btn_key}\" layer-name=\"Cloned Btn\"/>")
})
.as_object()
.unwrap(),
),
)
.unwrap();
let Out::Text(cloned_txt) = &cloned[0] else {
panic!()
};
assert!(cloned_txt.contains("createdNodeIds"), "{cloned_txt}");
assert!(
ed.page_doc(1)
.nodes
.values()
.any(|n| n.name == "Cloned Btn")
);
// Also test feedback counts across pages.
add_feedback(
&mut ed,
&Args(
json!({"nodeId": btn_key, "text": "Make taller"})
.as_object()
.unwrap(),
),
)
.unwrap();
let fb = get_feedback(&mut ed, &Args(json!({}).as_object().unwrap())).unwrap();
let Out::Text(fb_txt) = &fb[0] else { panic!() };
let fb_val: Value = serde_json::from_str(fb_txt).unwrap();
assert_eq!(fb_val["openCount"], 1, "{fb_txt}");
assert!(fb_val["items"][0]["pageId"].is_string(), "{fb_txt}");
}
}
+4 -3
View File
@@ -501,9 +501,10 @@ pub fn spawn_with_data(
static_dir,
ct: thread_ct.child_token(),
});
if let Err(e) = axum::serve(listener, router(relay, data.map(super::store::Store::new)))
.with_graceful_shutdown(thread_ct.cancelled_owned())
.await
if let Err(e) =
axum::serve(listener, router(relay, data.map(super::store::Store::new)))
.with_graceful_shutdown(thread_ct.cancelled_owned())
.await
{
log::error!("relay: server stopped: {e}");
}
+10 -3
View File
@@ -220,11 +220,17 @@ async fn delete_project(State(store): State<Arc<Store>>, Path(token): Path<Strin
.map_or(0, |d| d.as_millis());
let trash = store.root.join(TRASH);
let moved = tokio::fs::create_dir_all(&trash).await.is_ok()
&& tokio::fs::rename(&dir, trash.join(format!("{token}-{ms}"))).await.is_ok();
&& tokio::fs::rename(&dir, trash.join(format!("{token}-{ms}")))
.await
.is_ok();
if !moved {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
store.projects.lock().unwrap_or_else(PoisonError::into_inner).remove(&token);
store
.projects
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(&token);
StatusCode::NO_CONTENT.into_response()
}
@@ -350,7 +356,8 @@ pub fn router(store: Arc<Store>) -> axum::Router {
.route("/library", get(library))
.route("/p/{token}", axum::routing::delete(delete_project));
}
app.layer(DefaultBodyLimit::max(MAX_FILE_BYTES)).with_state(store)
app.layer(DefaultBodyLimit::max(MAX_FILE_BYTES))
.with_state(store)
}
#[cfg(test)]
+46 -31
View File
@@ -187,6 +187,7 @@ pub fn definitions() -> Vec<Value> {
json!({
"nodeId": node_id("The node to capture; omit for the whole page."),
"scale": {"type": "number", "description": "Render scale: 1 (default) or 2."},
"out": {"type": "string", "description": "Optional file path to save the PNG to (also used by papercraft-cli)."},
"pageId": page_id(),
}),
&[],
@@ -345,22 +346,22 @@ pub fn definitions() -> Vec<Value> {
- mode \"insert-children\" appends the HTML inside targetNodeId (\"root\" = the page); \
\"replace\" replaces targetNodeId with it.\n\
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, \
align-items, justify-content, flex-grow, flex-wrap) is the layout model; block \
align-items, justify-content, align-self, flex-grow, flex-wrap) is the layout model; block \
elements stack vertically. position:absolute with left/top is supported. No margin, \
grid or tables.\n\
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, \
letter-spacing, text-align, text-decoration; <b>/<i>/<u>/<s>/<span style> make rich \
text. Font family is always Inter.\n\
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border, \
border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, \
fit-content).\n\
letter-spacing, text-align, text-decoration (including wavy and text-decoration-color); \
<b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.\n\
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border \
(and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, \
mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.\n\
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. \
<svg> markup is imported as vector layers.\n\
- <x-paper-icon name=\"set:name\" size=\"20\" color=\"var(--accent)\"/> places an installed icon (search_icons); \
<x-paper-instance component-id=\"ID\" layer-name=\"...\"/> places a component instance; \
<x-paper-instance component=\"Name\" component-id=\"ID\" layer-name=\"...\"/> places a component instance; \
colors may be var(--name) of set_variables.\n\
- layer-name=\"...\" names layers; <x-paper-clone node-id=\"ID\" style=\"...\"/> \
clones an existing node.\n\
clones an existing node (supports cross-page `<page>:<id>`).\n\
Returns the ids of the created top-level layers and the styles that were ignored.",
json!({
"html": {"type": "string", "description": "HTML to parse into layers."},
@@ -441,10 +442,11 @@ pub fn definitions() -> Vec<Value> {
tool(
"get_feedback",
"Notes the user left on layers (the Feedback box in the inspector, shown as amber dots \
on the canvas): which layer, where it sits, what to change. Call it at the start of a \
session and after each round of edits; fix every open note, then resolve_feedback. \
get_basic_info reports `openFeedback`, and get_selection includes the selected \
layers' notes.",
on the canvas): which layer, where it sits, what to change. Omitting `pageId` queries \
all pages across the project; `openCount` and `resolvedCount` are returned alongside \
per-page counts in `get_basic_info`. Call it at the start of a session and after each \
round of edits; fix every open note, then resolve_feedback. `get_selection` also \
includes the selected layers' notes.",
json!({"nodeId": {"type": "string", "description": "Only this layer."},
"pageId": page_id(),
"includeResolved": {"type": "boolean"}}),
@@ -543,11 +545,11 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"import_components",
"Copy the main components of another page (typically a `Design System` page) onto this \
one, so you can place instances here with create_instance or \
<x-paper-instance component=\"Name\"/>. Components with the same name already here \
are skipped. Variables and tokens are already shared by every page; components are \
copies, not live links.",
"Copy the main components anywhere on `fromPageId` (including inside artboards, \
typically on a `Design System` page) onto this one, so you can place instances here \
with create_instance or <x-paper-instance component=\"Name\"/> / component-id=\"ID\". \
Components with the same name already here are skipped. Variables and tokens are \
already shared by every page; components are copies, not live links.",
json!({"fromPageId": {"type": "string"}, "pageId": page_id(),
"names": {"type": "array", "items": {"type": "string"},
"description": "Only these components (default all)."}}),
@@ -618,12 +620,18 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"duplicate_nodes",
"Deep-clone nodes. Each copy goes under parentId when given, else next to its source; \
duplicated artboards are placed in an empty spot. Returns new ids and a \
descendantIdMap from every original descendant id to its copy.",
"Deep-clone nodes, including across pages (via `toPageId` or a cross-page `parentId`; \
`<x-paper-clone node-id=\"<page>:<id>\"/>` also works across pages). Each copy goes \
under parentId when given, else next to its source; duplicated artboards are placed \
in an empty spot. Returns new ids and a descendantIdMap from every original \
descendant id to its copy.",
json!({"nodes": {"type": "array", "items": {
"type": "object",
"properties": {"id": {"type": "string"}, "parentId": {"type": "string"}},
"properties": {
"id": {"type": "string"},
"parentId": {"type": "string"},
"toPageId": {"type": "string", "description": "Optional destination page id when copying/moving to another page."},
},
"required": ["id"],
}}}),
&["nodes"],
@@ -631,15 +639,17 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"move_nodes",
"Move nodes keeping their ids. Each move is {nodeId, before: siblingId}, {nodeId, \
after: siblingId} or {nodeId, parentId, index?} (parentId \"root\" = the page root). \
Moves apply in order. In auto-layout parents this changes the visual order; in free \
parents it changes stacking and keeps the world position.",
"Move nodes keeping their ids (or onto another page via `toPageId` or a cross-page \
`parentId` / `before` / `after`). Each move is {nodeId, before: siblingId}, {nodeId, \
after: siblingId} or {nodeId, parentId, toPageId?, index?} (parentId \"root\" = the \
page root). Moves apply in order. In auto-layout parents this changes the visual \
order; in free parents it changes stacking and keeps the world position.",
json!({"moves": {"type": "array", "minItems": 1, "items": {
"type": "object",
"properties": {
"nodeId": {"type": "string"}, "before": {"type": "string"},
"after": {"type": "string"}, "parentId": {"type": "string"},
"toPageId": {"type": "string", "description": "Optional destination page id when copying/moving to another page."},
"index": {"type": "integer", "minimum": 0},
},
"required": ["nodeId"],
@@ -724,17 +734,22 @@ pub const GUIDE_HTML: &str = "\
Elements: div/section/header/footer/nav/main/article/aside/ul/ol/li/button/form → frames;
p/h1–h6/span/label/a/strong/em/code/pre (elements holding only text) → text layers (with a
frame around them when they have a background, border or padding); img → image rectangle;
svg → vector layers; br → line break; x-paper-clone node-id=ID → copy of an existing layer.
svg → vector layers; br → line break;
<x-paper-instance component=\"Name\" component-id=\"ID\" overrides='{\"Label\":\"Save\"}'/> → component instance;
<x-paper-clone node-id=\"ID\"/> → copy of an existing layer (supports cross-page `<page>:<id>`).
Default sizes (like CSS): h1 32px bold, h2 24px bold, h3 20px bold, h4–h6 16px bold, p 16px,
small 13px, code/pre 14px; color is inherited, default black.
Styles: width, height (px, %, fit-content), left/top/right/bottom with position:absolute,
display (flex, block, none), flex-direction, flex-wrap, gap, row-gap, column-gap, padding*,
justify-content, align-items, align-self, flex-grow, overflow, background, background-color,
Styles: width, height (px, %, fit-content), min-width, max-width, min-height, max-height,
left/top/right/bottom with position:absolute, display (flex, block, none), flex-direction,
flex-wrap, gap, row-gap, column-gap, padding*, justify-content, align-items,
align-self (start, center, end, stretch), flex-grow, overflow, background, background-color,
background-image (url(), linear-gradient(), radial-gradient()), background-size/object-fit,
border, border-color/width/style, border-radius (1–4 values), opacity, mix-blend-mode, color,
font-size, font-weight, font-style, line-height, letter-spacing, text-align, text-decoration.
border, border-top/border-right/border-bottom/border-left, border-color/width/style,
border-radius (1–4 values), opacity, mix-blend-mode, color, font-size, font-weight, font-style,
line-height, letter-spacing, text-align, text-decoration (including `text-decoration-color` and
`text-decoration-style: wavy`, e.g. `text-decoration: underline wavy var(--warning)`).
Ignored (reported back): margin, grid, box-shadow, transform, filters, transitions.";
pub const GUIDE_WHITEBOARD: &str = "\
+7
View File
@@ -88,6 +88,9 @@ pub struct Style {
pub fills: Vec<Paint>,
pub stroke: Option<Color>,
pub stroke_width: f64,
/// Per-side stroke widths (top, right, bottom, left); overrides uniform `stroke_width` when set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke_sides: Option<[f64; 4]>,
#[serde(skip_serializing_if = "is_default")]
pub stroke_align: StrokeAlign,
/// Dash pattern (lengths in px); empty = solid.
@@ -133,6 +136,7 @@ impl Default for Style {
fills: Vec::new(),
stroke: None,
stroke_width: 1.0,
stroke_sides: None,
stroke_align: StrokeAlign::Center,
dash: Vec::new(),
cap: Cap::Butt,
@@ -434,6 +438,9 @@ impl Node {
pub fn open_comments(&self) -> usize {
self.comments.iter().filter(|c| !c.resolved).count()
}
pub fn resolved_comments(&self) -> usize {
self.comments.iter().filter(|c| c.resolved).count()
}
}
/// One anchor of a vector path, with optional bezier handles (node-local, absolute).
+49 -15
View File
@@ -306,6 +306,10 @@ pub struct TextSpan {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub underline: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub underline_color: Option<Color>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub underline_wavy: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strike: Option<bool>,
}
@@ -338,6 +342,8 @@ pub struct CharStyle {
pub size: f64,
pub color: Color,
pub underline: bool,
pub underline_color: Option<Color>,
pub underline_wavy: bool,
pub strike: bool,
}
@@ -349,6 +355,8 @@ impl TextStyle {
size,
color,
underline: false,
underline_color: None,
underline_wavy: false,
strike: false,
};
for sp in &self.spans {
@@ -368,6 +376,12 @@ impl TextStyle {
if let Some(u) = sp.underline {
s.underline = u;
}
if let Some(uc) = sp.underline_color {
s.underline_color = Some(uc);
}
if let Some(uw) = sp.underline_wavy {
s.underline_wavy = uw;
}
if let Some(u) = sp.strike {
s.strike = u;
}
@@ -440,13 +454,6 @@ impl TextStyle {
let mut any = false;
for s in self.spans.iter().filter(|s| s.start <= a && s.end >= b) {
any = true;
m.bold = s.bold.or(m.bold);
m.italic = s.italic.or(m.italic);
m.size = s.size.or(m.size);
m.color = s.color.or(m.color);
m.underline = s.underline.or(m.underline);
m.strike = s.strike.or(m.strike);
// Later spans take precedence.
if s.bold.is_some() {
m.bold = s.bold;
}
@@ -462,6 +469,12 @@ impl TextStyle {
if s.underline.is_some() {
m.underline = s.underline;
}
if s.underline_color.is_some() {
m.underline_color = s.underline_color;
}
if s.underline_wavy.is_some() {
m.underline_wavy = s.underline_wavy;
}
if s.strike.is_some() {
m.strike = s.strike;
}
@@ -471,18 +484,26 @@ impl TextStyle {
&& m.size.is_none()
&& m.color.is_none()
&& m.underline.is_none()
&& m.underline_color.is_none()
&& m.underline_wavy.is_none()
&& m.strike.is_none();
if !any || empty {
continue;
}
let key = |x: &TextSpan| {
(
x.bold,
x.italic,
x.size,
x.color,
x.underline,
x.underline_color,
x.underline_wavy,
x.strike,
)
};
match out.last_mut() {
Some(l)
if l.end == a
&& (l.bold, l.italic, l.size, l.color, l.underline, l.strike)
== (m.bold, m.italic, m.size, m.color, m.underline, m.strike) =>
{
l.end = b
}
Some(l) if l.end == a && key(l) == key(&m) => l.end = b,
_ => out.push(m),
}
}
@@ -573,8 +594,12 @@ pub struct Constraints {
pub v: Constraint,
}
fn is_none_pair(p: &[Option<f64>; 2]) -> bool {
p[0].is_none() && p[1].is_none()
}
/// Per-node layout participation (as a child of an auto-layout or constrained frame).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct LayoutChild {
/// Horizontal and vertical sizing.
@@ -582,6 +607,15 @@ pub struct LayoutChild {
/// Ignore auto layout (positioned by constraints instead).
pub absolute: bool,
pub constraints: Constraints,
/// Per-child cross-axis alignment override (`align-self: start | center | end`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub align_self: Option<Align>,
/// Minimum `[width, height]` in px (`min-width`, `min-height`).
#[serde(default, skip_serializing_if = "is_none_pair")]
pub min_size: [Option<f64>; 2],
/// Maximum `[width, height]` in px (`max-width`, `max-height`).
#[serde(default, skip_serializing_if = "is_none_pair")]
pub max_size: [Option<f64>; 2],
}
impl LayoutChild {
+123 -17
View File
@@ -354,18 +354,88 @@ pub fn node_draw(n: &Node, wr: Rect, opacity: f64) -> NodeDraw {
}
}
}
if let Some(c) = n.style.stroke
&& n.style.stroke_width > 0.0
&& let Some(sp) = stroke_path(n, wr)
{
ops.push(DrawOp::Stroke {
path: sp,
color: rgba(c, opacity),
width: n.style.stroke_width,
cap: n.style.cap,
join: n.style.join,
dash: n.style.dash.clone(),
});
if let Some(c) = n.style.stroke {
if let Some(sides) = n.style.stroke_sides
&& matches!(n.kind, NodeKind::Frame { .. } | NodeKind::Rect)
&& sides.iter().any(|&w| (w - sides[0]).abs() > 1e-9)
{
let col = rgba(c, opacity);
let side_specs = [
(
sides[0],
Rect::new(wr.x, wr.y, wr.w, sides[0].min(wr.h)),
[
v2(wr.x, wr.y + sides[0] * 0.5),
v2(wr.right(), wr.y + sides[0] * 0.5),
],
),
(
sides[1],
Rect::new(
(wr.right() - sides[1]).max(wr.x),
wr.y,
sides[1].min(wr.w),
wr.h,
),
[
v2(wr.right() - sides[1] * 0.5, wr.y),
v2(wr.right() - sides[1] * 0.5, wr.bottom()),
],
),
(
sides[2],
Rect::new(
wr.x,
(wr.bottom() - sides[2]).max(wr.y),
wr.w,
sides[2].min(wr.h),
),
[
v2(wr.x, wr.bottom() - sides[2] * 0.5),
v2(wr.right(), wr.bottom() - sides[2] * 0.5),
],
),
(
sides[3],
Rect::new(wr.x, wr.y, sides[3].min(wr.w), wr.h),
[
v2(wr.x + sides[3] * 0.5, wr.y),
v2(wr.x + sides[3] * 0.5, wr.bottom()),
],
),
];
for (w, sr, line_pts) in side_specs {
if w > 0.0 && sr.w > 0.0 && sr.h > 0.0 {
if !n.style.dash.is_empty() {
ops.push(DrawOp::Stroke {
path: VPath::polyline(&line_pts, false),
color: col,
width: w,
cap: Cap::Butt,
join: n.style.join,
dash: n.style.dash.clone(),
});
} else {
ops.push(DrawOp::Fill {
path: vector::rounded_rect(sr, [0.0; 4]),
paint: PaintSpec::Solid(col),
even_odd: false,
});
}
}
}
} else if n.style.stroke_width > 0.0
&& let Some(sp) = stroke_path(n, wr)
{
ops.push(DrawOp::Stroke {
path: sp,
color: rgba(c, opacity),
width: n.style.stroke_width,
cap: n.style.cap,
join: n.style.join,
dash: n.style.dash.clone(),
});
}
}
}
}
@@ -464,19 +534,55 @@ fn text_ops(n: &Node, wr: Rect, opacity: f64, ops: &mut Vec<DrawOp>) {
glyphs,
});
}
// Underline / strikethrough as thin rectangles.
// Underline / strikethrough as thin rectangles (or wavy stroke for wavy underlines).
for g in l.glyphs.iter().filter(|g| g.underline || g.strike) {
let th = (g.size / 14.0).max(0.5);
for (on, dy) in [(g.underline, g.size * 0.12), (g.strike, -g.size * 0.28)] {
if on {
let r = Rect::new(o.x + g.x, o.y + g.baseline + dy, g.advance, th);
if g.underline {
let u_col = g.underline_color.unwrap_or(g.color);
if g.underline_wavy {
let x0 = o.x + g.x;
let x1 = x0 + g.advance;
let y0 = o.y + g.baseline + g.size * 0.14;
let step = (g.size * 0.14).max(1.8);
let amp = (g.size * 0.06).max(0.8);
let mut pts = Vec::new();
let mut x = x0;
let mut k = 0usize;
while x < x1 - 1e-6 {
let dy = if k.is_multiple_of(2) { -amp } else { amp };
pts.push(v2(x, y0 + dy));
x += step;
k += 1;
}
let dy = if k.is_multiple_of(2) { -amp } else { amp };
pts.push(v2(x1, y0 + dy));
if pts.len() >= 2 {
ops.push(DrawOp::Stroke {
path: VPath::polyline(&pts, false),
color: rgba(u_col, opacity),
width: th,
cap: Cap::Round,
join: Join::Round,
dash: Vec::new(),
});
}
} else {
let r = Rect::new(o.x + g.x, o.y + g.baseline + g.size * 0.12, g.advance, th);
ops.push(DrawOp::Fill {
path: vector::rounded_rect(r, [0.0; 4]),
paint: PaintSpec::Solid(rgba(g.color, opacity)),
paint: PaintSpec::Solid(rgba(u_col, opacity)),
even_odd: false,
});
}
}
if g.strike {
let r = Rect::new(o.x + g.x, o.y + g.baseline - g.size * 0.28, g.advance, th);
ops.push(DrawOp::Fill {
path: vector::rounded_rect(r, [0.0; 4]),
paint: PaintSpec::Solid(rgba(g.color, opacity)),
even_odd: false,
});
}
}
}
+5 -1
View File
@@ -470,7 +470,11 @@ impl Sync {
}
let server = server.trim_end_matches('/').to_string();
let alive = Arc::new(AtomicBool::new(true));
net::spawn(run_library(root.to_path_buf(), server.clone(), alive.clone()));
net::spawn(run_library(
root.to_path_buf(),
server.clone(),
alive.clone(),
));
self.library = Some(Library { server, alive });
}
+4
View File
@@ -72,6 +72,8 @@ pub struct PlacedGlyph {
/// Char index into the source string this glyph comes from.
pub char_index: usize,
pub underline: bool,
pub underline_color: Option<Color>,
pub underline_wavy: bool,
pub strike: bool,
}
@@ -223,6 +225,8 @@ pub fn layout_rich(
advance: a,
char_index: i,
underline: st.underline,
underline_color: st.underline_color,
underline_wavy: st.underline_wavy,
strike: st.strike,
});
x += a;
+8 -2
View File
@@ -174,7 +174,10 @@ impl Web {
// A share link (`/?share=<token>`) opens the shared project; joining twice is a no-op.
let loc = window().location();
if let (Ok(search), Ok(origin)) = (loc.search(), loc.origin())
&& let Some(token) = search.trim_start_matches('?').split('&').find_map(|kv| kv.strip_prefix("share="))
&& let Some(token) = search
.trim_start_matches('?')
.split('&')
.find_map(|kv| kv.strip_prefix("share="))
&& let Err(e) = session.join_shared(&format!("{origin}/?share={token}"))
{
log::warn!("share link: {e}");
@@ -467,7 +470,10 @@ impl Web {
/// Look at the shared projects' sync engines (about once a second).
fn tick_sync(&mut self, now: f64) {
if self.session.sync_poll(&mut self.ed, web_time::Instant::now()) {
if self
.session
.sync_poll(&mut self.ed, web_time::Instant::now())
{
if self.st.home.is_some() {
self.show_home();
}
+11 -3
View File
@@ -5,8 +5,8 @@
use papercraft::mcp::Out;
use papercraft::mcp::relay::{self, NO_TAB_ERROR};
use papercraft::mcp::store::Data;
use papercraft::mcp::relay_proto::{Call as CallFrame, Reply};
use papercraft::mcp::store::Data;
use rmcp::ServiceExt;
use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock};
use serde_json::{Value, json};
@@ -314,8 +314,16 @@ async fn static_files_are_served_with_their_type() {
async fn shared_projects_store_files_and_tell_the_other_tabs() {
let dir = std::env::temp_dir().join(format!("papercraft-data-{:016x}", fastrand::u64(..)));
let listen: SocketAddr = "127.0.0.1:0".parse().expect("a valid address");
let running =
relay::spawn_with_data(listen, None, None, Some(Data { dir: dir.clone(), library: false })).expect("the relay binds");
let running = relay::spawn_with_data(
listen,
None,
None,
Some(Data {
dir: dir.clone(),
library: false,
}),
)
.expect("the relay binds");
let port = running.port();
let base = format!("http://127.0.0.1:{port}");
let http = reqwest::Client::new();
+27 -4
View File
@@ -23,7 +23,16 @@ fn read(dir: &Path, rel: &str) -> Option<String> {
async fn two_folders_follow_each_other_through_the_relay() {
let data = temp("data");
let listen: SocketAddr = "127.0.0.1:0".parse().unwrap();
let running = relay::spawn_with_data(listen, None, None, Some(Data { dir: data.clone(), library: false })).unwrap();
let running = relay::spawn_with_data(
listen,
None,
None,
Some(Data {
dir: data.clone(),
library: false,
}),
)
.unwrap();
let server = format!("http://127.0.0.1:{}", running.port());
// A shares a project with two pages.
@@ -96,7 +105,10 @@ fn names(root: &Path) -> Vec<String> {
fn library_relay(data: &Path, library: bool) -> (relay::Running, String) {
let listen: SocketAddr = "127.0.0.1:0".parse().unwrap();
let data = Data { dir: data.to_path_buf(), library };
let data = Data {
dir: data.to_path_buf(),
library,
};
let running = relay::spawn_with_data(listen, None, None, Some(data)).unwrap();
let server = format!("http://127.0.0.1:{}", running.port());
(running, server)
@@ -128,10 +140,21 @@ async fn a_library_shows_every_project_on_every_device() {
// B deletes it on the server: the data is kept in .trash, A is told it is deleted.
let http = reqwest::Client::new();
let res = http.delete(format!("{server}/p/{token}")).send().await.unwrap();
let res = http
.delete(format!("{server}/p/{token}"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 204);
assert!(data.join(".trash").read_dir().unwrap().next().is_some());
let listed = http.get(format!("{server}/library")).send().await.unwrap().text().await.unwrap();
let listed = http
.get(format!("{server}/library"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(!listed.contains(&token), "{listed}");
match sync::sync_once(&alpha).await {
Err(sync::SyncError::Gone(sync::Gone::Deleted)) => {}