Artboard tool: '+' buttons add a board beside the selected one; ⇧ keeps proportions (#3033) (#3046)

* Tools: Artboard tool (#2858)

Photoshop's Artboard tool in the Move flyout (V, ⇧V cycles Move ↔ Artboard):
dragging on an empty spot draws a new board (`layer.new.artboard`), clicking a
board selects it, dragging it moves it with its contents, and the selected
board's edge and corner handles resize it (`layer.artboard.set`,
moveContents: false). Each gesture is one undo step. Options bar: Size preset,
W/H, Make Portrait, Make Landscape, Add New Artboard. Original toolbar glyph
(assets/icons/artboard.svg, ATTRIBUTION.md row).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Artboard tool: '+' buttons add a board beside the selected one; ⇧ keeps proportions (#3033)

With the Artboard tool, the selected board shows a "+" button beside each
side. A click adds an empty board of the same size (background and preset
copied) on that side, the 100 px artboard gap away and past any board in
the way; ⌥-click adds a copy with its contents instead. A board left of or
above the canvas origin shifts the whole document right/down and grows the
canvas, so it stays visible. Both go through the new engine command
`layer.artboard.addBeside` {layer?, side, duplicate} (one undo step).

⇧ while dragging a corner handle keeps the board's aspect ratio: the
opposite corner stays and the board scales by the larger axis factor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 23:30:35 +02:00 committed by GitHub
parent 72bf3e13b6
commit 911f69d7b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 232 additions and 15 deletions

View File

@ -363,6 +363,90 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
/// The Artboard tool's "+" buttons: a board the size of board `layer` on one of its sides, a
/// [`GAP`] away (further out past any board in the way), empty or (`duplicate`, the ⌥-click) a
/// copy with its contents. A board left of or above the canvas origin shifts the whole document
/// right/down so the canvas can grow to show it. One undo step.
fn add_beside(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "layer.artboard.addBeside";
let d = s.active().ok_or(EngineError::NoDocument)?;
let id = match p.get("layer").and_then(Value::as_u64) {
Some(v) => LayerId(v),
None => active_artboard(s).ok_or_else(|| bad(cmd, "no artboard selected"))?,
};
let src = d.doc.layer(id).and_then(Layer::artboard).cloned().ok_or_else(|| bad(cmd, "the layer is not an artboard"))?;
let side = p.get("side").and_then(Value::as_str).unwrap_or("right");
let duplicate = p.get("duplicate").and_then(Value::as_bool).unwrap_or(false);
let [x0, y0, w, h] = xywh(src.rect);
let gap = i64::from(GAP);
// In i64: edges ± a board and a gap can pass the i32 range (#931); `board_rect` checks.
let (mut x, mut y) = match side {
"right" => (x0 + w + gap, y0),
"left" => (x0 - gap - w, y0),
"bottom" => (x0, y0 + h + gap),
"top" => (x0, y0 - gap - h),
o => return Err(bad(cmd, format!("unknown side `{o}` (left|right|top|bottom)"))),
};
let boards: Vec<Rect> = d.doc.artboards().iter().map(|b| b.2.rect).collect();
// Each round clears every board in the way, so one round per board settles it.
for _ in 0..=boards.len() {
let blocked: Vec<&Rect> =
boards.iter().filter(|b| i64::from(b.x0) < x + w && i64::from(b.x1) > x && i64::from(b.y0) < y + h && i64::from(b.y1) > y).collect();
if blocked.is_empty() {
break;
}
match side {
"right" => x = blocked.iter().map(|b| i64::from(b.x1)).max().unwrap_or(x) + gap,
"left" => x = blocked.iter().map(|b| i64::from(b.x0)).min().unwrap_or(x) - gap - w,
"bottom" => y = blocked.iter().map(|b| i64::from(b.y1)).max().unwrap_or(y) + gap,
_ => y = blocked.iter().map(|b| i64::from(b.y0)).min().unwrap_or(y) - gap - h,
}
}
let rect = board_rect(cmd, [x, y, w, h])?;
// The canvas starts at (0, 0): a board past it moves everything right/down instead.
let (sx, sy) = (rect.x0.min(0).checked_neg(), rect.y0.min(0).checked_neg());
let (Some(sx), Some(sy)) = (sx, sy) else {
return Err(bad(cmd, format!("no room for an artboard {side} of \"{}\"", d.doc.layer(id).map_or("", |l| l.name.as_str()))));
};
let grown = |len: u32, by: i32| (i64::from(len) + i64::from(by)).clamp(1, 300_000) as u32;
let label = if duplicate { "Duplicate Artboard" } else { "New Artboard" };
let (nid, rect) = s.edit(label, |doc, active| {
if (sx, sy) != (0, 0) {
crate::image_cmds::translate_doc(doc, cmd, sx, sy)?;
doc.size = Size::new(grown(doc.size.width, sx), grown(doc.size.height, sy));
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes);
}
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (rect.x0.checked_add(sx), rect.y0.checked_add(sy), rect.x1.checked_add(sx), rect.y1.checked_add(sy))
else {
return Err(bad(cmd, "the new artboard is outside the 32-bit coordinate range"));
};
let rect = Rect::new(x0, y0, x1, y1);
let nid = if duplicate {
let src_rect = doc.layer(id).and_then(Layer::artboard).map(|a| a.rect).ok_or(EngineError::NoLayer(id))?;
let (dx, dy) = (rect.x0.saturating_sub(src_rect.x0), rect.y0.saturating_sub(src_rect.y0));
let copy = crate::commands::layer_copy(doc, id)?;
let nid = doc.insert_above(Some(id), copy);
// Grow the canvas first, so shapes on the board re-render uncut where they land.
let fit = |len: u32, edge: i32| i64::from(len).max(i64::from(edge)).clamp(1, 300_000) as u32;
doc.size = Size::new(fit(doc.size.width, rect.x1), fit(doc.size.height, rect.y1));
let snapshot = doc.clone();
let l = doc.layer_mut(nid).ok_or(EngineError::NoLayer(nid))?;
crate::commands::translate_layer(&snapshot, l, dx, dy);
crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy));
nid
} else {
let mut g = Layer::group(next_artboard_name(doc), vec![]);
set_artboard(&mut g, Artboard { rect, background: src.background, preset: src.preset.clone() });
doc.insert_above(Some(id), g)
};
fit_canvas(doc);
*active = Some(nid);
Ok((nid, rect))
})?;
crate::layer_multi_cmds::set_selection(s, vec![nid], Some(nid), Some(nid))?;
Ok(json!({"layer": nid.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
fn clear_artboard_guides(s: &mut Session) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let id = active_artboard(s).ok_or(EngineError::Other("no artboard selected".into()))?;
@ -570,6 +654,14 @@ pub fn specs() -> Vec<CommandSpec> {
has_active_artboard,
|s, _| clear_artboard_guides(s)
),
spec!(
"layer.artboard.addBeside",
"Add Artboard Beside",
&[],
r##"{"layer":id? (default: the active artboard),"side":"left|right|top|bottom"="right","duplicate":bool=false (copy the board with its contents instead of an empty one)} → {layer, rect} (the Artboard tool's "+" buttons: same size, a 100 px gap away, past any board in the way)"##,
has_active_artboard,
add_beside
),
spec!(
"file.export.artboardsToFiles",
"Artboards to Files…",

View File

@ -426,3 +426,31 @@ fn moving_an_artboard_past_the_canvas_keeps_its_shapes_whole() {
}
}
}
#[test]
fn add_beside_places_a_board_on_each_side_and_alt_duplicates_contents() {
// #3033: the Artboard tool's "+" buttons. Board 40×40 at (10, 10) holding a red square.
let mut s = session(8);
let gid = board_with_square(&mut s);
let r = s.execute("layer.artboard.addBeside", json!({"side": "right"})).unwrap();
assert_eq!(r["rect"], json!([150, 10, 40, 40]), "same size, a gap to the right");
assert!(!red_at(&s, 155, 15), "a plain \"+\" adds an empty board");
let r = s.execute("layer.artboard.addBeside", json!({"layer": gid.0, "side": "bottom", "duplicate": true})).unwrap();
assert_eq!(r["rect"], json!([10, 150, 40, 40]));
assert!(red_at(&s, 15, 155), "⌥: the copy brings the contents");
// Again on the right: that spot is taken, so the next free one.
let r = s.execute("layer.artboard.addBeside", json!({"layer": gid.0})).unwrap();
assert_eq!(r["rect"], json!([290, 10, 40, 40]));
// Left of x = 10 is past the canvas origin: everything shifts right by 130 to make room.
let before = doc(&s).size;
let r = s.execute("layer.artboard.addBeside", json!({"layer": gid.0, "side": "left"})).unwrap();
assert_eq!(r["rect"], json!([0, 10, 40, 40]));
assert_eq!(board_rect(&s, gid.0), Rect::new(140, 10, 180, 50));
assert!(red_at(&s, 145, 15), "the original's contents moved with the shift");
assert_eq!(doc(&s).size.width, before.width + 130);
// One undo step.
s.undo();
assert_eq!(board_rect(&s, gid.0), Rect::new(10, 10, 50, 50));
assert_eq!(doc(&s).size, before);
assert!(s.execute("layer.artboard.addBeside", json!({"side": "diagonal"})).is_err());
}

View File

@ -252,7 +252,7 @@ fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
/// Fails, before moving anything, when some pixels would land outside the i32 coordinate
/// range: the surface copy saturates its target rectangle there, which no longer matches
/// the pixel data and panics (#959).
fn translate_doc(doc: &mut Document, cmd: &str, dx: i32, dy: i32) -> Result<()> {
pub(crate) fn translate_doc(doc: &mut Document, cmd: &str, dx: i32, dy: i32) -> Result<()> {
if dx == 0 && dy == 0 {
return Ok(());
}

View File

@ -1,9 +1,11 @@
//! Artboard tool (Move group, V / ⇧V cycles with the Move tool): drag on an empty spot to draw a
//! new artboard, click a board to select it, drag it to move it with its contents, and drag the
//! selected board's edge and corner handles to resize it. The options bar sets the selected board's
//! size (preset, W/H, portrait/landscape) and adds a board of the same size beside the last one.
//! Every change is one engine command (`layer.new.artboard`, `layer.artboard.set`), so each gesture
//! is one undo step; this module only turns pointer gestures into them.
//! selected board's edge and corner handles to resize it (⇧ on a corner keeps its proportions).
//! The "+" buttons beside the selected board's sides add a board of the same size there (⌥: a copy
//! with its contents). The options bar sets the selected board's size (preset, W/H,
//! portrait/landscape) and adds a board of the same size beside the last one. Every change is one
//! engine command (`layer.new.artboard`, `layer.artboard.set`, `layer.artboard.addBeside`), so each
//! gesture is one undo step; this module only turns pointer gestures into them.
use egui::{CursorIcon, Pos2, Rect, Stroke, vec2};
use photocraft_doc::{Document, Layer, LayerId};
@ -20,6 +22,13 @@ use crate::theme::Tokens;
const HANDLE_PX: f64 = 6.0;
/// A press that moves less than this (screen points) is a click, not a drawn board.
const CLICK_PX: f64 = 3.0;
/// How far the "+" buttons sit outside the selected board's edges (screen points, to their centre).
const PLUS_OFFSET_PX: f64 = 18.0;
/// The "+" buttons' radius (screen points).
const PLUS_RADIUS_PX: f64 = 8.0;
/// The sides the "+" buttons add a board on, as `layer.artboard.addBeside` names them, with the
/// direction from the board's centre.
const SIDES: [(&str, [f64; 2]); 4] = [("top", [0.0, -1.0]), ("right", [1.0, 0.0]), ("bottom", [0.0, 1.0]), ("left", [-1.0, 0.0])];
/// Artboard tool state (serde: readable through the control channel).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@ -37,7 +46,8 @@ pub enum ArtboardDrag {
/// Moving board `id`, whose rect was `rect` at the press.
Move { id: LayerId, rect: [i64; 4], start: [f64; 2], cur: [f64; 2] },
/// Dragging handle (`hx`, `hy`) of board `id`: −1 the left/top edge, 1 the right/bottom, 0 neither.
Resize { id: LayerId, rect: [i64; 4], hx: i8, hy: i8, start: [f64; 2], cur: [f64; 2] },
/// `keep`: ⇧ is down, so a corner keeps the board's proportions.
Resize { id: LayerId, rect: [i64; 4], hx: i8, hy: i8, start: [f64; 2], cur: [f64; 2], keep: bool },
}
fn edges(r: photocraft_geom::Rect) -> [i64; 4] {
@ -79,6 +89,27 @@ fn handle_at(app: &PhotocraftApp, p: [f64; 2]) -> Option<(LayerId, [i64; 4], i8,
}
}
/// Each side's name and "+" button centre.
type PlusButtons = [(&'static str, [f64; 2]); 4];
/// The "+" buttons of the selected board: its id and each side's name and button centre (document
/// pixels).
fn plus_buttons(app: &PhotocraftApp) -> Option<(LayerId, PlusButtons)> {
let (id, r) = active_board(app)?;
let r = as_f64(r);
let off = PLUS_OFFSET_PX / f64::from(app.point_zoom()).max(0.01);
let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
let (hw, hh) = ((r[2] - r[0]) / 2.0 + off, (r[3] - r[1]) / 2.0 + off);
Some((id, SIDES.map(|(side, [dx, dy])| (side, [cx + dx * hw, cy + dy * hh]))))
}
/// The side of the "+" button under a document point.
fn plus_at(app: &PhotocraftApp, p: [f64; 2]) -> Option<(LayerId, &'static str)> {
let (id, buttons) = plus_buttons(app)?;
let r = PLUS_RADIUS_PX / f64::from(app.point_zoom()).max(0.01);
buttons.iter().find(|(_, c)| (p[0] - c[0]).hypot(p[1] - c[1]) <= r).map(|(side, _)| (id, *side))
}
/// The board drawn from `a` to `b`, edges on whole pixels.
fn drawn(a: [f64; 2], b: [f64; 2]) -> [i64; 4] {
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(|v| v.round() as i64)
@ -105,6 +136,21 @@ fn resized(r: [i64; 4], hx: i8, hy: i8, d: [i64; 2]) -> [i64; 4] {
e
}
/// `r` with corner (`hx`, `hy`) dragged by `d`, keeping its proportions (⇧): the opposite corner
/// stays, and the board scales by the larger of the two axes' factors.
fn resized_keep(r: [i64; 4], hx: i8, hy: i8, d: [i64; 2]) -> [i64; 4] {
let (w, h) = ((r[2] - r[0]).max(1) as f64, (r[3] - r[1]).max(1) as f64);
// The fixed corner, and the dragged one's offset from it.
let (ax, ay) = (if hx > 0 { r[0] } else { r[2] }, if hy > 0 { r[1] } else { r[3] });
let (cx, cy) = (if hx > 0 { r[2] } else { r[0] }.saturating_add(d[0]), if hy > 0 { r[3] } else { r[1] }.saturating_add(d[1]));
let (dw, dh) = (cx.saturating_sub(ax) as f64, cy.saturating_sub(ay) as f64);
let k = (dw.abs() / w).max(dh.abs() / h);
let (nw, nh) = ((w * k).round().max(1.0) as i64, (h * k).round().max(1.0) as i64);
let span = |a: i64, len: i64, toward: f64| if toward < 0.0 { [a.saturating_sub(len), a] } else { [a, a.saturating_add(len)] };
let ([x0, x1], [y0, y1]) = (span(ax, nw, dw), span(ay, nh, dh));
[x0, y0, x1, y1]
}
/// The board a gesture would leave, for the preview.
fn preview(d: &ArtboardDrag) -> [i64; 4] {
match *d {
@ -113,6 +159,7 @@ fn preview(d: &ArtboardDrag) -> [i64; 4] {
let o = offset(start, cur);
[rect[0].saturating_add(o[0]), rect[1].saturating_add(o[1]), rect[2].saturating_add(o[0]), rect[3].saturating_add(o[1])]
}
ArtboardDrag::Resize { rect, hx, hy, start, cur, keep: true, .. } if hx != 0 && hy != 0 => resized_keep(rect, hx, hy, offset(start, cur)),
ArtboardDrag::Resize { rect, hx, hy, start, cur, .. } => resized(rect, hx, hy, offset(start, cur)),
}
}
@ -124,8 +171,17 @@ fn report(app: &mut PhotocraftApp, r: Result<Value, String>) {
}
}
/// Moves a gesture's pointer to `p`; ⇧ (held or released mid-drag) decides whether a corner keeps
/// the board's proportions.
fn follow(d: &mut ArtboardDrag, p: [f64; 2], mods: egui::Modifiers) {
match d {
ArtboardDrag::New { cur, .. } | ArtboardDrag::Move { cur, .. } => *cur = p,
ArtboardDrag::Resize { cur, keep, .. } => (*cur, *keep) = (p, mods.shift),
}
}
/// Pointer input for the Artboard tool. Returns true when the event was its own.
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) -> bool {
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
if app.ui.tool != Tool::Artboard {
return false;
}
@ -133,8 +189,15 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) -
match ev {
ToolEvent::Down { x, y, .. } => {
let p = [x, y];
// A "+" button: a board the size of the selected one on that side (⌥: a copy).
if let Some((id, side)) = plus_at(app, p) {
app.ui.artboard_tool.drag = None;
let r = app.run("layer.artboard.addBeside", json!({"layer": id.0, "side": side, "duplicate": mods.alt}));
report(app, r);
return true;
}
app.ui.artboard_tool.drag = Some(if let Some((id, rect, hx, hy)) = handle_at(app, p) {
ArtboardDrag::Resize { id, rect, hx, hy, start: p, cur: p }
ArtboardDrag::Resize { id, rect, hx, hy, start: p, cur: p, keep: mods.shift }
} else if let Some((id, rect)) = board_at(&doc, p) {
if app.session.active().and_then(|st| st.active_layer) != Some(id) {
let r = app.run("layer.select", json!({"layer": id.0}));
@ -146,14 +209,13 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) -
});
}
ToolEvent::Move { x, y, .. } => {
if let Some(ArtboardDrag::New { cur, .. } | ArtboardDrag::Move { cur, .. } | ArtboardDrag::Resize { cur, .. }) = &mut app.ui.artboard_tool.drag {
*cur = [x, y];
if let Some(d) = &mut app.ui.artboard_tool.drag {
follow(d, [x, y], mods);
}
}
ToolEvent::Up { x, y } => {
let Some(mut d) = app.ui.artboard_tool.drag.take() else { return true };
let (ArtboardDrag::New { cur, .. } | ArtboardDrag::Move { cur, .. } | ArtboardDrag::Resize { cur, .. }) = &mut d;
*cur = [x, y];
follow(&mut d, [x, y], mods);
let r = preview(&d);
let result = match d {
ArtboardDrag::New { start, cur } => {
@ -181,6 +243,7 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option<CursorIcon> {
let (hx, hy) = match app.ui.artboard_tool.drag {
Some(ArtboardDrag::Resize { hx, hy, .. }) => (hx, hy),
Some(_) => return None,
None if plus_at(app, p).is_some() => return Some(CursorIcon::PointingHand),
None => handle_at(app, p).map(|h| (h.2, h.3))?,
};
Some(match (hx, hy) {
@ -191,7 +254,7 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option<CursorIcon> {
})
}
/// The selected board's handles and the board a gesture in progress would leave.
/// The selected board's handles, its "+" buttons, and the board a gesture in progress would leave.
pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
if app.ui.tool != Tool::Artboard {
return;
@ -204,6 +267,19 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
painter.add(egui::Shape::line(outline(preview(d)), Stroke::new(1.5, t.accent)));
}
let Some((_, r)) = active_board(app).filter(|_| !matches!(drag, Some(ArtboardDrag::New { .. }))) else { return };
if drag.is_none()
&& let Some((_, buttons)) = plus_buttons(app)
{
let rad = PLUS_RADIUS_PX as f32;
for (_, c) in buttons {
let c = xf.to_screen(c[0] as f32, c[1] as f32);
painter.circle(c, rad, t.card, Stroke::new(1.0, t.accent));
let arm = rad * 0.5;
let s = Stroke::new(1.5, t.accent);
painter.line_segment([c - vec2(arm, 0.0), c + vec2(arm, 0.0)], s);
painter.line_segment([c - vec2(0.0, arm), c + vec2(0.0, arm)], s);
}
}
let r = drag.as_ref().map_or(r, preview);
let (mx, my) = (r[0].saturating_add(r[2]) / 2, r[1].saturating_add(r[3]) / 2);
for (x, y) in [(r[0], r[1]), (mx, r[1]), (r[2], r[1]), (r[2], my), (r[2], r[3]), (mx, r[3]), (r[0], r[3]), (r[0], my)] {
@ -312,6 +388,27 @@ mod tests {
assert_eq!(boards(&app).len(), 1);
}
/// #3033: a click on the selected board's right "+" adds a board of its size a gap to the right;
/// ⇧ on a corner keeps the board's proportions.
#[test]
fn plus_buttons_add_boards_and_shift_keeps_proportions() {
let mut app = app();
drag(&mut app, [20.0, 30.0], [120.0, 80.0]);
let (_, buttons) = plus_buttons(&app).unwrap();
let right = buttons.iter().find(|b| b.0 == "right").unwrap().1;
assert!(right[0] > 120.0 && (right[1] - 55.0).abs() < 1e-9, "{right:?}");
drag(&mut app, right, right);
assert_eq!(boards(&app), vec![photocraft_geom::Rect::new(20, 30, 120, 80), photocraft_geom::Rect::new(220, 30, 320, 80)]);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(boards(&app).len(), 1);
// The 100×50 board's bottom-right corner, dragged 40 right and 5 down with ⇧: 140×70.
let m = egui::Modifiers::SHIFT;
tool_event(&mut app, ToolEvent::Down { x: 120.0, y: 80.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Move { x: 160.0, y: 85.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Up { x: 160.0, y: 85.0 }, m);
assert_eq!(boards(&app), vec![photocraft_geom::Rect::new(20, 30, 160, 100)]);
}
/// V picks the Move group, ⇧V cycles Move ↔ Artboard as in Photoshop.
#[test]
fn shift_v_cycles_move_and_artboard() {

View File

@ -117,7 +117,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only |
| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults |
| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) |
| FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles; Duplicate Artboard places the copy beside (#1584); no + buttons, raster PDF pages only |
| FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only |
| FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) |
| FILE-216-1 | Every corpus fetched and enforced in CI, cached by pin | done | [#216](https://github.com/storytold/photocraft/issues/216) | ci.yml has a corpus job (name: corpus tests) on every PR and on main: the corpora are pinned in xtask/src/corpus_pins.rs, sha256-verified after both a cache restore and a download, and a missing corpus fails the tests. The cache key follows the pins, so it is cached by pin as this row asks (#190) |
| FILE-216-2 | Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests | partial | [#216](https://github.com/storytold/photocraft/issues/216) | 4-5 GB PSB fixtures: psd example big_psb writes them (raw and RLE, planes past 2 GiB), io example big_psb_check opens and checks them; opt-in io tests/large_psb.rs (2.5 GB merged image). No 2 GB PSD, 150-layer or 20 MP fixture, nothing in CI |

View File

@ -143,7 +143,7 @@ id = "FILE-215-20"
target = "Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages"
status = "partial"
issue = 215
note = "artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles; Duplicate Artboard places the copy beside (#1584); no + buttons, raster PDF pages only"
note = "artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only"
[[item]]
id = "FILE-215-21"