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:
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2000,6 +2000,7 @@ fn rest_layout_child(j: &Json) -> LayoutChild {
|
||||
sizing,
|
||||
absolute,
|
||||
constraints,
|
||||
..LayoutChild::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+130
-16
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = "\
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)) => {}
|
||||
|
||||
Reference in New Issue
Block a user