mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
712 lines
32 KiB
Rust
712 lines
32 KiB
Rust
//! Type → Threaded Text: one story flowing through frames in order: area type and type on a path,
|
|
//! in any mix. Illustrator links type objects of any shape, as long as their text is in an area or
|
|
//! along a path, not at a point (<https://helpx.adobe.com/illustrator/using/manage-text-area.html>).
|
|
//!
|
|
//! Frames are ordinary text objects holding their slice of the story; the document lists the
|
|
//! thread order. After every edit, threads whose frames changed are re-flowed ([`reflow`]).
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
|
|
|
|
use super::edit::selected_roots;
|
|
use super::typecmd::refresh_bounds;
|
|
use super::*;
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
"text.thread.create",
|
|
"Create",
|
|
["Type", "Threaded Text"],
|
|
None,
|
|
"{} thread the selected area type and type on a path (and closed paths, which become area frames) in stacking order → {ids}",
|
|
has_multi,
|
|
create
|
|
),
|
|
cmd!(
|
|
"text.thread.add",
|
|
"Thread Text",
|
|
[],
|
|
None,
|
|
"{from, to?, rect?: [x, y, width, height], at?: [x, y], before?} thread area type or type on a path `from` (a click on its out port) into one more frame, put after it in its thread, or before it with `before: true` (a click on its in port: the story starts there and flows on into `from`): closed path, area type or type on a path `to`, a new rectangle `rect`, or a new frame of `from`'s kind, shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected",
|
|
has_doc,
|
|
add
|
|
),
|
|
cmd!(
|
|
"text.thread.releaseSelection",
|
|
"Release Selection",
|
|
["Type", "Threaded Text"],
|
|
None,
|
|
"{} take the selected frames out of their threads (the story re-flows through the rest)",
|
|
has_thread_selection,
|
|
release
|
|
),
|
|
cmd!(
|
|
"text.thread.remove",
|
|
"Remove Threading",
|
|
["Type", "Threaded Text"],
|
|
None,
|
|
"{} break the selected frames' threads; each frame keeps the text it shows",
|
|
has_thread_selection,
|
|
remove
|
|
),
|
|
cmd!(
|
|
"text.thread.break",
|
|
"Break Thread",
|
|
[],
|
|
None,
|
|
"{id, port: \"in\"|\"out\"} break the thread of area type or type on a path `id` before it (its in port) or after it (its out port), as a double-click on that port does: the story flows into the frames before the break, and the frames after it are left empty (still threaded with each other when two or more)",
|
|
has_doc,
|
|
break_thread
|
|
),
|
|
cmd!(query "text.thread.info", "Threads", [], None, "{} → [[id…]] every thread in order", has_doc, |s, _| {
|
|
Ok(json!(s.doc()?.doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect::<Vec<_>>()).collect::<Vec<_>>()))
|
|
}),
|
|
]
|
|
}
|
|
|
|
fn has_thread_selection(s: &Session) -> std::result::Result<(), String> {
|
|
has_selection(s)?;
|
|
let st = s.active().ok_or("no document open")?;
|
|
if st.selection.objects.iter().any(|id| st.doc.text_threads.iter().any(|t| t.contains(id))) {
|
|
Ok(())
|
|
} else {
|
|
Err("no threaded text selected".into())
|
|
}
|
|
}
|
|
|
|
/// The text object `id` when it can be a frame of a thread: area type or type on a path.
|
|
fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> {
|
|
match d.node(id).map(|n| &n.kind) {
|
|
Some(NodeKind::Text(t)) if !matches!(t.kind, TextKind::Point) => Some(t),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
thread_local! {
|
|
/// Tests: how many threads' stories [`reflow`] copied on this thread.
|
|
static COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
|
}
|
|
|
|
/// Re-flow every thread with a frame that changed between `before` and `doc` (structural sharing
|
|
/// makes "changed" a pointer comparison). Deleted frames' text flows into the remaining ones; a
|
|
/// frame that is now point type leaves its thread with the text it holds.
|
|
pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
|
let threads = std::mem::take(&mut doc.text_threads);
|
|
let mut kept = vec![];
|
|
for thread in threads {
|
|
let changed = thread.iter().any(|id| match (before.node(*id), doc.node(*id)) {
|
|
(Some(a), Some(b)) => !std::ptr::eq(a, b),
|
|
(None, None) => false,
|
|
_ => true,
|
|
});
|
|
// When no frame changed, the thread keeps its area-type frames and their text is not copied.
|
|
if !changed {
|
|
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
|
|
if live.len() >= 2 {
|
|
kept.push(live);
|
|
}
|
|
continue;
|
|
}
|
|
#[cfg(test)]
|
|
COPIED.with(|c| c.set(c.get() + 1));
|
|
// The story: live frames' runs, plus the text of frames deleted by this edit.
|
|
let mut story: Vec<TextRun> = vec![];
|
|
let mut sources: Vec<TextObject> = vec![];
|
|
let mut later_wins = vec![];
|
|
let mut live = vec![];
|
|
for id in &thread {
|
|
match (text_of(doc, *id), text_of(before, *id)) {
|
|
(Some(t), old) => {
|
|
story.extend(t.runs.iter().cloned());
|
|
// A frame whose first paragraph was just restyled gives the paragraph it
|
|
// shares with the frame before it that style.
|
|
later_wins.push(old.is_some_and(|o| o.para_at(0) != t.para_at(0)));
|
|
sources.push(t.clone());
|
|
live.push(*id);
|
|
}
|
|
// A frame converted to point type keeps the text it holds and leaves the thread.
|
|
(None, Some(_)) if matches!(doc.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_))) => {}
|
|
(None, Some(old)) => {
|
|
story.extend(old.runs.iter().cloned());
|
|
later_wins.push(false);
|
|
sources.push(old.clone());
|
|
}
|
|
(None, None) => {}
|
|
}
|
|
}
|
|
if live.is_empty() {
|
|
continue;
|
|
}
|
|
let source_refs: Vec<&TextObject> = sources.iter().collect();
|
|
let paras = vectorcraft_text::thread::story_paras(&source_refs, &later_wins);
|
|
vectorcraft_text::edit::normalize(&mut story);
|
|
if live.len() >= 2 {
|
|
// Threaded frames keep their size: Auto Size is off for them (as in Illustrator).
|
|
for id in &live {
|
|
if text_of(doc, *id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight)
|
|
&& let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
|
|
{
|
|
t.area.fit = vectorcraft_doc::AreaFit::None;
|
|
}
|
|
}
|
|
}
|
|
let optical = if live.len() >= 2 { optical_margin(before, doc, &live) } else { None };
|
|
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
|
|
let refs: Vec<&TextObject> = frames.iter().collect();
|
|
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, ¶s);
|
|
for (id, part) in live.iter().zip(parts) {
|
|
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) {
|
|
let mut next = (**t).clone();
|
|
next.runs = part.runs;
|
|
next.set_paragraph_styles(part.paras);
|
|
let optical = optical.unwrap_or(t.optical_margin);
|
|
let continues = part.continues;
|
|
if next.runs != t.runs
|
|
|| next.para != t.para
|
|
|| next.paras != t.paras
|
|
|| optical != t.optical_margin
|
|
|| part.list_count != t.list_count
|
|
|| continues != t.first_para_continues
|
|
{
|
|
t.runs = next.runs;
|
|
t.para = next.para;
|
|
t.paras = next.paras;
|
|
t.optical_margin = optical;
|
|
t.list_count = part.list_count;
|
|
t.first_para_continues = continues;
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
}
|
|
if live.len() >= 2 {
|
|
kept.push(live);
|
|
}
|
|
}
|
|
doc.text_threads = kept;
|
|
}
|
|
|
|
/// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count
|
|
/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a
|
|
/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of
|
|
/// its thread) gets 0 and is laid out again.
|
|
pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
|
|
let mut stale = vec![];
|
|
doc.walk(|n| {
|
|
if let NodeKind::Text(t) = &n.kind
|
|
&& t.list_count != 0
|
|
{
|
|
stale.push(n.id);
|
|
}
|
|
});
|
|
if stale.is_empty() {
|
|
return;
|
|
}
|
|
let threaded: std::collections::HashSet<NodeId> = doc.text_threads.iter().flatten().copied().filter(|id| text_of(doc, *id).is_some()).collect();
|
|
stale.retain(|id| !threaded.contains(id));
|
|
if stale.is_empty() {
|
|
return;
|
|
}
|
|
let doc = std::sync::Arc::make_mut(doc);
|
|
for id in stale {
|
|
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
|
t.list_count = 0;
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes:
|
|
/// that of the first frame whose setting changed between `before` and `doc`, else the first frame's.
|
|
fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option<bool> {
|
|
let now = |id: &NodeId| text_of(doc, *id).map(|t| t.optical_margin);
|
|
let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.optical_margin) != now(id)));
|
|
changed.or(live.first()).and_then(now)
|
|
}
|
|
|
|
/// Give every frame of each thread the first frame's Optical Margin Alignment setting, without
|
|
/// re-flowing the story: for a document restored from a file as it was saved.
|
|
pub(crate) fn share_optical_margin(doc: &mut Document) {
|
|
for thread in doc.text_threads.clone() {
|
|
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
|
|
let Some(on) = optical_margin(doc, doc, &live) else { continue };
|
|
for id in &live {
|
|
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
|
|
&& t.optical_margin != on
|
|
{
|
|
t.optical_margin = on;
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mark the frames of each thread whose first paragraph continues from the frame before
|
|
/// ([`TextObject::first_para_continues`]), without re-flowing the story: for a document restored
|
|
/// from a file as it was saved.
|
|
pub(crate) fn mark_continued(doc: &mut Document) {
|
|
for thread in doc.text_threads.clone() {
|
|
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
|
|
let frames: Vec<&TextObject> = live.iter().filter_map(|id| text_of(doc, *id)).collect();
|
|
let marks = vectorcraft_text::thread::continued(&frames);
|
|
for (id, on) in live.iter().zip(marks) {
|
|
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
|
|
&& t.first_para_continues != on
|
|
{
|
|
t.first_para_continues = on;
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `ids` followed by the other frames of the threads they are in, each once, in thread order.
|
|
pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
|
let mut out = ids.to_vec();
|
|
for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| ids.contains(id))) {
|
|
for id in thread {
|
|
if !out.contains(id) && text_of(d, *id).is_some() {
|
|
out.push(*id);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Every frame of `d`'s threads with its type, for [`leave_threads`] after an edit that may turn
|
|
/// some of them into other art.
|
|
pub(crate) fn frames(d: &Document) -> Vec<(NodeId, TextObject)> {
|
|
d.text_threads.iter().flatten().filter_map(|id| Some((*id, text_of(d, *id)?.clone()))).collect()
|
|
}
|
|
|
|
/// Update the threads for each frame of `was` (from [`frames`] before the edit) that `d` no longer
|
|
/// holds as area type or type on a path under its id. When the art now at that id holds the frame's
|
|
/// type under a new id (Expand Appearance groups type with its shadows, outer glows or Crop Marks),
|
|
/// that type takes the frame's place in its thread. Otherwise (Create Outlines and Expand Appearance
|
|
/// made outlines of it) the frame leaves its thread with the text it shows, and the other frames keep
|
|
/// the text they hold. When a thread's last frame leaves, the text it has no room for goes after the
|
|
/// text of the last frame that stays. A thread left with one frame is no longer a thread. Each frame
|
|
/// that stays continues a paragraph ([`TextObject::first_para_continues`]) only when it is still
|
|
/// threaded and the frames before it end inside one. A threaded frame whose mark changes lays out
|
|
/// its first line again, so its thread re-flows after the edit.
|
|
pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
|
|
let gone: Vec<(NodeId, &TextObject, Option<NodeId>)> =
|
|
was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, t, kept_type(d, *id))).collect();
|
|
if gone.is_empty() {
|
|
return;
|
|
}
|
|
let find = |id: &NodeId| gone.iter().find(|(g, _, _)| g == id);
|
|
let mut stayed = vec![];
|
|
for i in 0..d.text_threads.len() {
|
|
let thread = d.text_threads[i].clone();
|
|
if !thread.iter().any(|id| find(id).is_some()) {
|
|
continue;
|
|
}
|
|
let keep: Vec<NodeId> = thread
|
|
.iter()
|
|
.filter_map(|id| match find(id) {
|
|
Some((_, _, kept)) => *kept,
|
|
None => Some(*id),
|
|
})
|
|
.collect();
|
|
if let (Some((_, last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) {
|
|
take_overflow(d, *stay, last);
|
|
}
|
|
stayed.extend(keep.iter().copied());
|
|
d.text_threads[i] = keep;
|
|
}
|
|
d.text_threads.retain(|t| t.len() >= 2);
|
|
let marks: Vec<(NodeId, bool)> = stayed
|
|
.iter()
|
|
.map(|id| {
|
|
let on = d.text_threads.iter().find(|t| t.contains(id)).is_some_and(|t| {
|
|
let live: Vec<(NodeId, &TextObject)> = t.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect();
|
|
let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect();
|
|
live.iter().zip(vectorcraft_text::thread::continued(&frames)).any(|((i, _), on)| on && i == id)
|
|
});
|
|
(*id, on)
|
|
})
|
|
.collect();
|
|
for (id, on) in marks {
|
|
if text_of(d, id).is_some_and(|t| t.first_para_continues != on)
|
|
&& let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
|
{
|
|
t.first_para_continues = on;
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cut each frame in `art`, a copy of objects of `d` under their ids, that is the last frame of a
|
|
/// thread with frames outside `art`, to the text it shows. When such a frame leaves its thread,
|
|
/// [`leave_threads`] puts the text it has no room for after the text of the last frame that stays.
|
|
pub(crate) fn keep_shown_text(d: &Document, art: &mut Node) {
|
|
let mut inside = vec![];
|
|
art.walk(&mut |n| inside.push(n.id));
|
|
let last: Vec<NodeId> = d
|
|
.text_threads
|
|
.iter()
|
|
.filter(|t| t.iter().any(|id| !inside.contains(id)))
|
|
.filter_map(|t| t.last().copied().filter(|id| inside.contains(id)))
|
|
.collect();
|
|
if !last.is_empty() {
|
|
cut_to_shown(art, &last);
|
|
}
|
|
}
|
|
|
|
/// Cut the area type and type on a path `ids` in `n` to the text each one shows.
|
|
fn cut_to_shown(n: &mut Node, ids: &[NodeId]) {
|
|
if ids.contains(&n.id)
|
|
&& let NodeKind::Text(t) = &mut n.kind
|
|
&& !matches!(t.kind, TextKind::Point)
|
|
{
|
|
// Split as `take_overflow` splits it: the first part is what the frame shows.
|
|
let from: &TextObject = t;
|
|
let parts = vectorcraft_text::thread::distribute_continuing(
|
|
vectorcraft_text::FontDb::global(),
|
|
&[from, from],
|
|
&from.runs,
|
|
&from.paragraph_styles(),
|
|
from.first_para_continues,
|
|
from.list_count,
|
|
);
|
|
if let (Some(head), Some(_)) = (parts.first(), parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty()))) {
|
|
t.runs = head.runs.clone();
|
|
t.set_paragraph_styles(head.paras.clone());
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
if let Some(children) = n.children_mut() {
|
|
for c in children {
|
|
cut_to_shown(std::sync::Arc::make_mut(c), ids);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The only area type or type on a path in the art at `id`.
|
|
fn kept_type(d: &Document, id: NodeId) -> Option<NodeId> {
|
|
let mut found = vec![];
|
|
d.node(id)?.walk(&mut |n| {
|
|
if matches!(&n.kind, NodeKind::Text(t) if !matches!(t.kind, TextKind::Point)) {
|
|
found.push(n.id);
|
|
}
|
|
});
|
|
match found[..] {
|
|
[one] => Some(one),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Put the text that frame `from` holds beyond what it shows after the text of frame `to`.
|
|
fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) {
|
|
let db = vectorcraft_text::FontDb::global();
|
|
// `from`'s text split as if it flowed on into a second frame: the second part is what `from`
|
|
// has no room for. Its share is measured as it is laid out, continuing a paragraph or not and
|
|
// with its numbered list paragraphs counted on from the frames before it.
|
|
let parts = vectorcraft_text::thread::distribute_continuing(
|
|
db,
|
|
&[from, from],
|
|
&from.runs,
|
|
&from.paragraph_styles(),
|
|
from.first_para_continues,
|
|
from.list_count,
|
|
);
|
|
let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return };
|
|
let mut tail = from.clone();
|
|
tail.runs = rest.runs.clone();
|
|
tail.set_paragraph_styles(rest.paras.clone());
|
|
if let Some(NodeKind::Text(t)) = d.node_mut(to).map(|n| &mut n.kind) {
|
|
let head = (**t).clone();
|
|
t.runs = vectorcraft_text::thread::story(&[&head, &tail]);
|
|
t.set_paragraph_styles(vectorcraft_text::thread::story_paras(&[&head, &tail], &[]));
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
|
|
/// A closed path converted to an empty area-type frame with `style`.
|
|
fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle, system_layout: bool) -> Option<Node> {
|
|
let NodeKind::Path { path, .. } = &n.kind else { return None };
|
|
if !path.subpaths.first().is_some_and(|s| s.closed) {
|
|
return None;
|
|
}
|
|
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", style);
|
|
t.kind = TextKind::Area { frame: path.clone() };
|
|
t.xf = vectorcraft_geom::Affine::IDENTITY;
|
|
t.system_layout = system_layout;
|
|
refresh_bounds(&mut t);
|
|
Some(Node::new(n.id, NodeKind::Text(Box::new(t))))
|
|
}
|
|
|
|
fn create(s: &mut Session, _: &Value) -> Result<Value> {
|
|
const C: &str = "text.thread.create";
|
|
let ids = selected_roots(s)?;
|
|
let d = &s.doc()?.doc;
|
|
let style = ids.iter().find_map(|i| text_of(d, *i)).map(|t| t.first_style()).unwrap_or_default();
|
|
let system = d.setup.system_layout;
|
|
let ok = ids.iter().all(|i| text_of(d, *i).is_some() || d.node(*i).is_some_and(|n| frame_from_path(n, style.clone(), system).is_some()));
|
|
if ids.len() < 2 || !ok || !ids.iter().any(|i| text_of(d, *i).is_some()) {
|
|
return Err(bad(C, "select area type and closed paths (at least one area type object)"));
|
|
}
|
|
let out = ids.clone();
|
|
s.edit("Thread Text", |d, _| {
|
|
for id in &ids {
|
|
if text_of(d, *id).is_none() {
|
|
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
|
let f = frame_from_path(&n, style.clone(), d.setup.system_layout).ok_or_else(|| bad(C, "not a closed path"))?;
|
|
*d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = f;
|
|
}
|
|
}
|
|
// Existing threads containing any of these frames merge into the new one, keeping order.
|
|
let mut order: Vec<NodeId> = vec![];
|
|
for id in &ids {
|
|
match d.text_threads.iter().position(|t| t.contains(id)) {
|
|
Some(i) => {
|
|
for x in d.text_threads.remove(i) {
|
|
if !order.contains(&x) {
|
|
order.push(x);
|
|
}
|
|
}
|
|
}
|
|
None if !order.contains(id) => order.push(*id),
|
|
None => {}
|
|
}
|
|
}
|
|
d.text_threads.push(order);
|
|
// Force a re-flow of the new thread on this edit.
|
|
touch(d, ids[0]);
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
|
}
|
|
|
|
/// `text.thread.add`: the loaded text of a port, placed.
|
|
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "text.thread.add";
|
|
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
|
let from = checked_id(s, given("from").unwrap_or(&Value::Null), C)?;
|
|
let before = switch_or(p, "before", false, C)?;
|
|
let d = &s.doc()?.doc;
|
|
let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type or type on a path"))?;
|
|
let style = src.first_style();
|
|
// The frame to thread into: an existing object (`to`), or a new one beside `from`.
|
|
let (to, new) = match (given("to"), given("rect"), given("at")) {
|
|
(Some(v), None, None) => {
|
|
let to = checked_id(s, v, C)?;
|
|
let parent = d.parent_of(to).and_then(|i| d.node(i));
|
|
let system = d.setup.system_layout;
|
|
let frame = text_of(d, to).is_some() || d.node(to).is_some_and(|n| frame_from_path(n, style.clone(), system).is_some());
|
|
if to == from || !frame || parent.is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) {
|
|
return Err(bad(C, "`to` must be a closed path, area type or type on a path other than `from`"));
|
|
}
|
|
if d.text_threads.iter().any(|t| t.contains(&to)) {
|
|
return Err(bad(C, "`to` is already threaded"));
|
|
}
|
|
(to, None)
|
|
}
|
|
(None, Some(v), None) => {
|
|
let [x, y, w, h] = finite_numbers(v)
|
|
.filter(|[_, _, w, h]| *w > 0.0 && *h > 0.0)
|
|
.ok_or_else(|| bad(C, "`rect` must be [x, y, width, height] with a positive size"))?;
|
|
let frame = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, w, h));
|
|
(NodeId(0), Some(empty_frame(src, style.clone(), TextKind::Area { frame }, vectorcraft_geom::Affine::translate((x, y)))))
|
|
}
|
|
(None, None, Some(v)) => {
|
|
let [x, y] = finite_numbers(v).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
|
|
let shape = match &src.kind {
|
|
TextKind::Area { frame } => frame,
|
|
TextKind::OnPath { path, .. } => path,
|
|
TextKind::Point => return Err(bad(C, "`from` must be area type or type on a path")),
|
|
};
|
|
let b = shape.bounds().map(|b| src.xf.transform_rect_bbox(b)).ok_or_else(|| bad(C, "`from` has an empty frame"))?;
|
|
let xf = vectorcraft_geom::Affine::translate((x - b.x0, y - b.y0)) * src.xf;
|
|
(NodeId(0), Some(empty_frame(src, style.clone(), src.kind.clone(), xf)))
|
|
}
|
|
_ => return Err(bad(C, "give one of `to`, `rect` or `at`")),
|
|
};
|
|
let appearance = d.node(from).map(|n| n.appearance.clone());
|
|
let id = s.edit("Thread Text", |d, sel| {
|
|
let to = match new {
|
|
Some(t) => {
|
|
let (parent, idx, _) = d.position(from).ok_or(EngineError::NoNode(from))?;
|
|
let id = d.alloc_id();
|
|
let mut n = Node::new(id, NodeKind::Text(Box::new(t)));
|
|
if let Some(a) = appearance {
|
|
n.appearance = a;
|
|
}
|
|
d.insert(parent, idx + 1, n)?;
|
|
id
|
|
}
|
|
None => {
|
|
if text_of(d, to).is_none() {
|
|
let n = d.node(to).cloned().ok_or(EngineError::NoNode(to))?;
|
|
*d.node_mut(to).ok_or(EngineError::NoNode(to))? =
|
|
frame_from_path(&n, style, d.setup.system_layout).ok_or_else(|| bad(C, "not a closed path"))?;
|
|
}
|
|
to
|
|
}
|
|
};
|
|
match d.text_threads.iter_mut().find(|t| t.contains(&from)) {
|
|
Some(t) => {
|
|
let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| if before { i } else { i + 1 });
|
|
t.insert(at, to);
|
|
}
|
|
None if before => d.text_threads.push(vec![to, from]),
|
|
None => d.text_threads.push(vec![from, to]),
|
|
}
|
|
// Force a re-flow of the thread on this edit: the edit leaves existing area type `to`
|
|
// unchanged.
|
|
touch(d, to);
|
|
sel.set([from, to]);
|
|
Ok(to)
|
|
})?;
|
|
Ok(json!({ "id": id.0 }))
|
|
}
|
|
|
|
/// An empty frame of `kind` (area type or type on a path) placed by `xf`, with the type options of
|
|
/// `src`.
|
|
fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, kind: TextKind, xf: vectorcraft_geom::Affine) -> TextObject {
|
|
let mut t = src.clone();
|
|
t.runs = vec![TextRun { text: String::new(), style, inline: None }];
|
|
t.set_all_paras(src.para_at(0).clone());
|
|
t.kind = kind;
|
|
t.xf = xf;
|
|
refresh_bounds(&mut t);
|
|
t
|
|
}
|
|
|
|
/// Give a node a fresh allocation so [`reflow`] sees its thread as changed.
|
|
fn touch(d: &mut Document, id: NodeId) {
|
|
if let Some(n) = d.node_mut(id) {
|
|
let copy = n.clone();
|
|
*n = copy;
|
|
}
|
|
}
|
|
|
|
fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
|
let ids = selected_roots(s)?;
|
|
s.edit("Release Threaded Text", |d, _| {
|
|
// The released frames' text joins the rest of the story (they become empty).
|
|
for thread in d.text_threads.clone() {
|
|
let (out, keep): (Vec<NodeId>, Vec<NodeId>) = thread.iter().partition(|i| ids.contains(i));
|
|
if out.is_empty() {
|
|
continue;
|
|
}
|
|
let mut story: Vec<TextRun> = vec![];
|
|
let sources: Vec<TextObject> = thread.iter().filter_map(|id| text_of(d, *id).cloned()).collect();
|
|
for t in &sources {
|
|
story.extend(t.runs.iter().cloned());
|
|
}
|
|
let paras = vectorcraft_text::thread::story_paras(&sources.iter().collect::<Vec<_>>(), &[]);
|
|
let style = story.first().map(|r| r.style.clone()).unwrap_or_default();
|
|
let first_para = paras.first().cloned().unwrap_or_default();
|
|
for id in &out {
|
|
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
|
t.runs = vec![TextRun { text: String::new(), style: style.clone(), inline: None }];
|
|
t.set_all_paras(first_para.clone());
|
|
t.first_para_continues = false;
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
// The first frame that stays holds the whole story, and the others none, for the
|
|
// re-flow to share it out among them once.
|
|
let end_style = story.last().map_or_else(|| style.clone(), |r| r.style.clone());
|
|
let last_para = paras.last().cloned().unwrap_or_default();
|
|
for id in keep.iter().skip(1) {
|
|
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
|
t.runs = vec![TextRun { text: String::new(), style: end_style.clone(), inline: None }];
|
|
t.set_all_paras(last_para.clone());
|
|
}
|
|
}
|
|
if let Some(first) = keep.first()
|
|
&& let Some(NodeKind::Text(t)) = d.node_mut(*first).map(|n| &mut n.kind)
|
|
{
|
|
vectorcraft_text::edit::normalize(&mut story);
|
|
t.runs = story;
|
|
t.set_paragraph_styles(paras);
|
|
// It starts the story, also when it is left alone and so out of a thread.
|
|
t.first_para_continues = false;
|
|
refresh_bounds(t);
|
|
}
|
|
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
|
|
d.text_threads[i] = keep;
|
|
}
|
|
d.text_threads.retain(|t| t.len() >= 2);
|
|
Ok(())
|
|
})?;
|
|
ok()
|
|
}
|
|
|
|
fn remove(s: &mut Session, _: &Value) -> Result<Value> {
|
|
let ids = selected_roots(s)?;
|
|
s.edit("Remove Threading", |d, _| {
|
|
let (gone, kept): (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) =
|
|
std::mem::take(&mut d.text_threads).into_iter().partition(|t| t.iter().any(|i| ids.contains(i)));
|
|
d.text_threads = kept;
|
|
// Each frame's first paragraph now starts in it.
|
|
for id in gone.iter().flatten() {
|
|
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind)
|
|
&& t.first_para_continues
|
|
{
|
|
t.first_para_continues = false;
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
Ok(())
|
|
})?;
|
|
ok()
|
|
}
|
|
|
|
/// `text.thread.break`: a double-click on a threaded port. The thread is split there; the whole
|
|
/// story moves into the frames before the split, and the frames after it are emptied, staying
|
|
/// threaded with each other when two or more remain.
|
|
fn break_thread(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "text.thread.break";
|
|
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?;
|
|
let before = match p.get("port").and_then(Value::as_str) {
|
|
Some("in") => true,
|
|
Some("out") => false,
|
|
_ => return Err(bad(C, "`port` must be \"in\" or \"out\"")),
|
|
};
|
|
let d = &s.doc()?.doc;
|
|
let thread = d.text_threads.iter().find(|t| t.contains(&id)).cloned().unwrap_or_default();
|
|
let at = thread.iter().position(|x| *x == id).map(|i| if before { i } else { i + 1 });
|
|
let Some(at) = at.filter(|at| *at > 0 && *at < thread.len()) else {
|
|
return Err(bad(C, if before { "no frame comes before `id` in a thread" } else { "no frame comes after `id` in a thread" }));
|
|
};
|
|
let (head, tail) = (thread[..at].to_vec(), thread[at..].to_vec());
|
|
s.edit("Break Thread", |d, _| {
|
|
let sources: Vec<TextObject> = thread.iter().filter_map(|id| text_of(d, *id).cloned()).collect();
|
|
let mut story: Vec<TextRun> = sources.iter().flat_map(|t| t.runs.iter().cloned()).collect();
|
|
let paras = vectorcraft_text::thread::story_paras(&sources.iter().collect::<Vec<_>>(), &[]);
|
|
let end_style = story.last().map(|r| r.style.clone()).unwrap_or_default();
|
|
let last_para = paras.last().cloned().unwrap_or_default();
|
|
// Every frame but the first holds nothing, for the re-flow to share the story out among
|
|
// the first part's frames once.
|
|
for id in head.iter().skip(1).chain(&tail) {
|
|
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
|
t.runs = vec![TextRun { text: String::new(), style: end_style.clone(), inline: None }];
|
|
t.set_all_paras(last_para.clone());
|
|
t.first_para_continues = false;
|
|
refresh_bounds(t);
|
|
}
|
|
}
|
|
if let Some(NodeKind::Text(t)) = head.first().and_then(|id| d.node_mut(*id)).map(|n| &mut n.kind) {
|
|
vectorcraft_text::edit::normalize(&mut story);
|
|
t.runs = story;
|
|
t.set_paragraph_styles(paras);
|
|
t.first_para_continues = false;
|
|
refresh_bounds(t);
|
|
}
|
|
if let Some(i) = d.text_threads.iter().position(|t| *t == thread) {
|
|
d.text_threads.splice(i..=i, [head, tail]);
|
|
}
|
|
d.text_threads.retain(|t| t.len() >= 2);
|
|
Ok(())
|
|
})?;
|
|
ok()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|