Merge pull request #4144 from Taha-Mahmoodi/feat/port-double-click-breaks-thread

Type: a double-click on a threaded port breaks the thread there
This commit is contained in:
BFlat
2026-10-26 16:06:52 +07:00
committed by GitHub
32 changed files with 247 additions and 10 deletions
+121
View File
@@ -50,6 +50,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<_>>()))
}),
@@ -648,6 +657,56 @@ fn remove(s: &mut Session, _: &Value) -> Result<Value> {
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 {
use super::*;
@@ -1056,6 +1115,68 @@ mod tests {
(s, [a, b, c])
}
fn continues(s: &Session, id: u64) -> bool {
text_of(&s.doc().unwrap().doc, NodeId(id)).is_some_and(|t| t.first_para_continues)
}
/// A double-click on a threaded port breaks the thread there: 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. One undo step restores the thread and its text.
#[test]
fn break_splits_the_thread_and_the_story_flows_into_the_first_part() {
let story = TEXT.repeat(12);
let (mut s, [a, b, c]) = three_frames(&story, 40.0);
let d = new_id(s.execute("text.create", &json!({"x": 200, "y": 100, "text": "", "area": {"width": 150, "height": 100}})).unwrap());
s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap();
let all = [a, b, c, d];
let shown: Vec<String> = all.iter().map(|id| text(&s, *id)).collect();
assert!(shown.iter().all(|t| !t.is_empty()), "each frame shows part of the story");
let joined = |s: &Session, ids: &[u64]| ids.iter().map(|id| text(s, *id)).collect::<String>();
// (frame, port, threads after, the frames that hold the story, the frames left empty)
let cases = [
(a, "out", vec![vec![b, c, d]], vec![a], vec![b, c, d]),
(b, "in", vec![vec![b, c, d]], vec![a], vec![b, c, d]),
(b, "out", vec![vec![a, b], vec![c, d]], vec![a, b], vec![c, d]),
(d, "in", vec![vec![a, b, c]], vec![a, b, c], vec![d]),
(c, "out", vec![vec![a, b, c]], vec![a, b, c], vec![d]),
];
for (id, port, after, full, empty) in cases {
let case = format!("{port} port of {id}");
s.execute("text.thread.break", &json!({"id": id, "port": port})).unwrap();
assert_eq!(threads(&s), after, "{case}");
assert_eq!(joined(&s, &full), story, "{case}: the story flows into the first part");
assert!(empty.iter().all(|id| text(&s, *id).is_empty()), "{case}: the frames after the break are empty");
assert!(!continues(&s, empty[0]), "{case}: the first frame after the break starts a paragraph");
assert!(!continues(&s, a), "{case}: the first frame starts the story");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [all.to_vec()], "{case}: undo");
assert_eq!(all.iter().map(|id| text(&s, *id)).collect::<Vec<_>>(), shown, "{case}: undo");
}
}
#[test]
fn break_refuses_a_port_with_no_thread_and_bad_params() {
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0);
let lone = new_id(s.execute("text.create", &json!({"x": 400, "y": 10, "text": "Alone.", "area": {"width": 100, "height": 100}})).unwrap());
let ids = [a, b, c, lone];
let shown: Vec<String> = ids.iter().map(|id| text(&s, *id)).collect();
let before = threads(&s);
for params in [
json!({"id": a, "port": "in"}),
json!({"id": c, "port": "out"}),
json!({"id": lone, "port": "out"}),
json!({"id": lone, "port": "in"}),
json!({"id": a, "port": "up"}),
json!({"id": a}),
json!({"port": "out"}),
json!({"id": 9999, "port": "out"}),
] {
assert!(s.execute("text.thread.break", &params).is_err(), "{params}");
}
assert_eq!(threads(&s), before);
assert_eq!(ids.iter().map(|id| text(&s, *id)).collect::<Vec<_>>(), shown);
}
/// Whether the document holds area type or type on a path `id`.
fn is_frame(s: &Session, id: u64) -> bool {
text_of(&s.doc().unwrap().doc, NodeId(id)).is_some()
+51 -1
View File
@@ -23,7 +23,10 @@
//! kind, shape and size at the click on empty canvas, and a drag draws a rectangle for the new
//! frame (`text.thread.add`), which comes after the clicked one in its thread. A click on the in
//! port does the same with the new frame put before it: the story then starts there and flows on
//! into the clicked one. Escape drops the loaded text.
//! into the clicked one. Escape drops the loaded text. A double-click on a port where the frame is
//! threaded splits the thread at that port (`text.thread.break`), as the archived help section on
//! threading describes
//! (<https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html>).
//! With Smart Guides and Invoke Distance Guides on, Alt/Option held over another object shows how
//! far the selection is from it, and over an empty part of an artboard how far it is from the
//! artboard's edges ([`crate::guides::distance_guides`]).
@@ -498,6 +501,14 @@ impl SelectionTool {
fn loaded(&mut self, cx: &ToolContext, ev: &PointerEvent, from: NodeId, before: bool, press: Option<(Point, Point)>) -> Option<Vec<Action>> {
let p = ev.pos;
match (ev.kind, press) {
// The second press of a double-click on the port that loaded the text: the text is
// put down, and the double-click breaks the thread there.
(PointerKind::Down, _)
if crate::text::port_at(cx, p) == Some((from, if before { crate::text::Port::In } else { crate::text::Port::Out })) =>
{
self.state = State::Idle;
return Some(vec![]);
}
(PointerKind::Down, _) if !cx.doc.node(from).is_some_and(is_threadable) => {
self.state = State::Idle;
return None;
@@ -678,6 +689,16 @@ impl Tool for SelectionTool {
match (ev.kind, self.state.clone()) {
(PointerKind::DoubleClick, _) => {
self.state = State::Idle;
// A threaded port: the thread breaks there. Any other port takes the double-click
// too, so it neither edits the type nor isolates.
if let Some((id, port)) = crate::text::port_at(cx, p) {
let (input, output) = crate::text::port_states(cx.doc, id, false);
let (state, name) = if port == crate::text::Port::In { (input, "in") } else { (output, "out") };
if state == crate::text::PortState::Threaded {
return vec![Action::Exec("text.thread.break".into(), json!({ "id": id.0, "port": name }))];
}
return vec![];
}
if let Some(a) = corners::double_click(cx, p, false).or_else(|| pie::double_click(cx, p)).or_else(|| typewidget::double_click(cx, p))
{
return vec![a];
@@ -2720,6 +2741,35 @@ mod tests {
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]);
}
/// A double-click on a threaded port breaks the thread there, its in port before the frame
/// and its out port after it; it doesn't thread the loaded text into a new frame. A
/// double-click on a port with no thread does nothing (it doesn't switch to the Type tool).
#[test]
fn a_double_click_on_a_threaded_port_breaks_the_thread() {
let (mut d, id, input, output) = fitting_area_type();
let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id;
let mut s = Selection::default();
s.set([id]);
let p = paint();
let double_click = |d: &vectorcraft_doc::Document, at: Point| {
let cx = cx(d, &s, &p);
let mut t = SelectionTool::default();
let kinds = [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick];
let out: Vec<Action> = kinds.into_iter().flat_map(|k| t.pointer(&cx, &ev(k, at.x, at.y))).collect();
assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText, "no text stays loaded");
out
};
let breaks = |port: &str| vec![Action::Exec("text.thread.break".into(), json!({"id": id.0, "port": port}))];
d.text_threads = vec![vec![id, rect]];
assert_eq!(double_click(&d, output), breaks("out"));
assert_eq!(double_click(&d, input), vec![], "no frame before it");
d.text_threads = vec![vec![rect, id]];
assert_eq!(double_click(&d, input), breaks("in"));
assert_eq!(double_click(&d, output), vec![], "no frame after it");
d.text_threads.clear();
assert_eq!(double_click(&d, output), vec![]);
}
/// The memo answers for the revision, the selected objects and Use Preview Bounds it measured
/// for, and measures again when any of them changes.
#[test]
+30
View File
@@ -10787,6 +10787,36 @@ mod tests {
assert!(thread_overlays(&app, zoom).is_empty());
}
/// A double-click with the Selection tool on the out port of a threaded frame breaks the
/// thread there: no new frame is threaded on, and the story flows back into the first frame.
#[test]
fn a_double_click_on_a_threaded_out_port_breaks_the_thread() {
use vectorcraft_tools::text::{Port, port_centre};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
let text = "Words that fill more than the frame holds. ".repeat(10);
let a =
app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
app.select_tool("selection");
let doc = |app: &VectorcraftApp| app.session.active().unwrap().doc.clone();
let before = doc(&app);
let NodeKind::Text(t) = &before.node(vectorcraft_doc::NodeId(a)).unwrap().kind else { panic!("not text") };
let view = app.view_info();
let at = port_centre(t, Port::Out, 1.0 / view.zoom).unwrap();
let count = doc(&app).layers[0].children().map_or(0, |c| c.len());
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] {
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, at.x, at.y), view);
}
let d = doc(&app);
assert!(d.text_threads.is_empty(), "the thread is broken");
assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), count, "no new frame");
let NodeKind::Text(t) = &d.node(vectorcraft_doc::NodeId(a)).unwrap().kind else { panic!("not text") };
assert_eq!(t.plain_text(), text, "the story flows into the first frame");
assert_eq!(app.session.tool_id(), "selection");
}
/// With more than `PORTS_MAX` type objects selected the tools draw no ports, and Show Text
/// Threads draws only its lines, no port boxes.
#[test]
+1
View File
@@ -2545,6 +2545,7 @@ font-technology Variable Променливи
Remove Plug-in Премахване на добавка
Remove Selected Assets Премахване на избраните активи
Remove Threading Премахване на свързването
Break Thread Прекъсване на свързването
Remove “{keyword}” Премахване на „{keyword}“
Rename Преименуване
Rename Asset Преименуване на актива
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -374,6 +374,7 @@
Create Vytvořit
Release Selection Uvolnit výběr
Remove Threading Odstranit zřetězení
Break Thread Přerušit zřetězení
Fit Headline Přizpůsobit titulek
Find Font… Najít písmo…
Change Case Změnit velikost písmen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -336,6 +336,7 @@
Create Opret
Release Selection Frigør markering
Remove Threading Fjern sammenkædning
Break Thread Bryd sammenkædning
Fit Headline Tilpas overskrift
Find Font… Find skrifttype…
Change Case Ændr store/små bogstaver
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2566,6 +2566,7 @@ mark-style Japanese Japanisch
Remove Plug-in Plug-in entfernen
Remove Selected Assets Ausgewählte Assets entfernen
Remove Threading Verkettung entfernen
Break Thread Verkettung trennen
Remove “{keyword}” „{keyword}“ entfernen
Rename Umbenennen
Rename Asset Asset umbenennen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3144,6 +3144,7 @@ theme Appearance Εμφάνιση
Remove Plug-in Αφαίρεση προσθέτου
Remove Selected Assets Αφαίρεση επιλεγμένων στοιχείων
Remove Threading Αφαίρεση σύνδεσης κειμένου
Break Thread Διακοπή σύνδεσης κειμένου
Remove “{keyword}” Αφαίρεση «{keyword}»
Rename Asset Μετονομασία στοιχείου
Rename Character Style Μετονομασία στυλ χαρακτήρων
Can't render this file because it contains an unexpected character in line 2069 and column 19.
+1
View File
@@ -2600,6 +2600,7 @@ font-technology Variable Variables
Remove Plug-in Quitar plugin
Remove Selected Assets Quitar recursos seleccionados
Remove Threading Quitar enlace
Break Thread Romper enlace
Remove “{keyword}” Quitar “{keyword}”
Rename Cambiar nombre
Rename Asset Cambiar nombre del recurso
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -371,6 +371,7 @@
Create Luo
Release Selection Vapauta valinta
Remove Threading Poista ketjutus
Break Thread Katkaise ketjutus
Fit Headline Sovita otsikko
Find Font… Etsi fontti…
Change Case Vaihda kirjainkoko
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2597,6 +2597,7 @@ font-technology Variable Variables
Remove Plug-in Supprimer le module externe
Remove Selected Assets Supprimer les ressources sélectionnées
Remove Threading Supprimer le chaînage
Break Thread Rompre le chaînage
Remove “{keyword}” Supprimer « {keyword} »
Rename Renommer
Rename Asset Renommer la ressource
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3126,6 +3126,7 @@ mark-style Japanese Japán
Remove Selected Assets Kijelölt elemek eltávolítása
Remove the shortcut Billentyűparancs eltávolítása
Remove Threading Szövegláncolás megszüntetése
Break Thread Szövegláncolás megszakítása
Remove “{keyword}” Eltávolítás: „{keyword}”
Rename Átnevezés
Rename Asset Elem átnevezése
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -336,6 +336,7 @@
Create Buat
Release Selection Lepaskan seleksi
Remove Threading Hapus rangkaian
Break Thread Putus rangkaian
Fit Headline Sesuaikan judul
Find Font… Cari font…
Change Case Ubah huruf besar-kecil
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
336
337
338
339
340
341
342
+1
View File
@@ -2631,6 +2631,7 @@ mark-style Japanese Giapponese
Remove Plug-in Rimuovi plug-in
Remove Selected Assets Rimuovi risorse selezionate
Remove Threading Rimuovi concatenamento
Break Thread Interrompi concatenamento
Remove “{keyword}” Rimuovi “{keyword}”
Rename Rinomina
Rename Asset Rinomina risorsa
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -363,6 +363,7 @@ font-technology Variable バリアブル
Create 作成
Release Selection 選択部分をリリース
Remove Threading スレッドを解除
Break Thread スレッドを切断
Fit Headline ヘッドラインを合わせる
Decrease Font Size フォントサイズを小さく
Decrease Font Size by Five Steps フォントサイズを5段階小さく
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -294,6 +294,7 @@
Create შექმნა
Release Selection მონიშნულის მოხსნა
Remove Threading დაკავშირების მოხსნა
Break Thread დაკავშირების გაწყვეტა
Fit Headline სათაურის მორგება
Find Font… შრიფტის ძებნა…
Change Case რეგისტრის შეცვლა
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -359,6 +359,7 @@ font-technology Variable 가변
Create 만들기
Release Selection 선택 해제
Remove Threading 연결 제거
Break Thread 연결 끊기
Fit Headline 제목에 맞추기
Decrease Font Size 글꼴 크기 줄이기
Decrease Font Size by Five Steps 글꼴 크기 5단계 줄이기
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -350,6 +350,7 @@
Create Maken
Release Selection Selectie vrijgeven
Remove Threading Koppeling opheffen
Break Thread Koppeling verbreken
Fit Headline Kop passend maken
Find Font… Lettertype zoeken…
Change Case Hoofdlettergebruik wijzigen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -334,6 +334,7 @@
Create Utwórz
Release Selection Uwolnij zaznaczenie
Remove Threading Usuń wątki
Break Thread Przerwij wątek
Fit Headline Dopasuj nagłówek
Find Font… Znajdź czcionkę…
Change Case Zmień wielkość liter
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2149,6 +2149,7 @@
Remove Plug-in Remover Plug-in
Remove Selected Assets Remover Ativos Selecionados
Remove Threading Remover Encadeamento
Break Thread Quebrar Encadeamento
Remove blank lines between paragraphs Remover linhas em branco entre parágrafos
Remove “{keyword}” Remover “{keyword}”
Rename Renomear
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2627,6 +2627,7 @@ mark-style Japanese Японский
Remove Plug-in Убрать модуль
Remove Selected Assets Убрать выбранные ресурсы
Remove Threading Убрать связывание
Break Thread Разорвать связывание
Remove “{keyword}” Убрать «{keyword}»
Rename Переименовать
Rename Asset Переименовать ресурс
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2545,6 +2545,7 @@ font-technology Variable Variabla
Remove Plug-in Ta bort plugin
Remove Selected Assets Ta bort markerade resurser
Remove Threading Ta bort textlänkning
Break Thread Bryt textlänkning
Remove “{keyword}” Ta bort ”{keyword}”
Rename Byt namn
Rename Asset Byt namn på resurs
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -2419,6 +2419,7 @@ font-technology Variable Değişken
Remove Plug-in Eklentiyi Kaldır
Remove Selected Assets Seçilen Varlıkları Kaldır
Remove Threading Diş Açmayı Kaldır
Break Thread Metin Bağını Kopar
Remove “{keyword}” “{keyword}”yi kaldırın
Rename Yeniden adlandır
Rename Asset Varlığı Yeniden Adlandır
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -2623,6 +2623,7 @@ mark-style Japanese Японський
Remove Plug-in Забрати модуль
Remove Selected Assets Забрати вибрані ресурси
Remove Threading Прибрати зв’язування
Break Thread Розірвати зв’язування
Remove “{keyword}” Прибрати "{keyword}"
Rename Перейменувати
Rename Asset Перейменувати ресурс
Can't render this file because it contains an unexpected character in line 748 and column 40.
+1
View File
@@ -2892,6 +2892,7 @@ mark-style Japanese Kiểu Nhật
Remove Plug-in Gỡ plug-in
Remove Selected Assets Gỡ các asset đã chọn
Remove Threading Gỡ liên kết chuỗi văn bản
Break Thread Ngắt chuỗi văn bản
Remove “{keyword}” Gỡ “{keyword}”
Rename Đổi tên
Rename Asset Đổi tên asset
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
2892
2893
2894
2895
2896
2897
2898
+1
View File
@@ -2500,6 +2500,7 @@ font-script Japanese 汉字
Remove Plug-in 移除插件
Remove Selected Assets 移除所选资源
Remove Threading 移除线程
Break Thread 断开串接
Remove “{keyword}” 移除“{keyword}”
Rename 重命名
Rename Asset 重命名资源
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3404,6 +3404,7 @@ font-script Japanese 漢字
Remove Plug-in 移除增效模組
Remove Selected Assets 移除所選資產
Remove Threading 移除串連
Break Thread 中斷串連
Remove “{keyword}” 移除「{keyword}」
Rename 重新命名
Rename Asset 重新命名資產
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -3
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -4279,6 +4279,13 @@ that doesn't exist, a `from` that is point type, a `to` that is an open path, `f
or already in a thread (`from` may be threaded already: the frame goes in beside it), coordinates that aren't finite
numbers and a `before` that isn't true or false.
A double-click on a port where the frame is threaded breaks the thread there (one undo step, `text.thread.break`):
`text.thread.break {id, port: "in" | "out"}` splits the thread of area type or type on a path `id` before it (`in`) or
after it (`out`). The whole story flows into the frames before the break; the frames after it are left empty, still
threaded with each other when two or more remain, and the first of them starts a paragraph. It refuses an `id` that
doesn't exist, a `port` other than `in` or `out`, and a side where no frame of a thread comes before or after `id`.
In every thread, a paragraph that continues from one frame into the next has its first line, first-line indent and
bullet or number in the frame where it starts. In the next frame its first line starts where its wrapped lines start,
and it has no bullet or number. In a frame that is no longer threaded (after `text.thread.releaseSelection` or
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long