T22: Collaboration
- presence: avatars ringed in each peer's color with where they are, press to follow; a breathing live dot; follow mode frames the view and stops on Escape - remote cursors and selections painted over a field from its own layout - comment threads with replies, reactions and resolve; a marker that opens a thread in a bubble; a sidebar of open and resolved threads - annotations washed in their authors' colors, notes on hover - suggestion mode: edits become suggestions word by word; track changes shows them struck and underlined, to accept or reject - sharing: invite by email with checked chips, roles, the access list and who the link lets in, in one dialog - primitives::hold_focus gives a mode its own focus, shared with ZenMode - AvatarGroup's tuck and count shared; Avatar::ring_color; TagInput::check; TextInput::bounds_for_range; Popover::size - icons: smile-plus, user-plus, message-square-diff - gallery: the Collaboration page with scripted states
This commit is contained in:
34 files changed
+3297
-86
No files matched your search
@@ -0,0 +1,57 @@
|
||||
mod people;
|
||||
mod sharing;
|
||||
mod talk;
|
||||
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 22,
|
||||
slug: "collab",
|
||||
title: "Collaboration",
|
||||
summary: "People in the same document: who is here and where, comments and reactions, suggested changes, and sharing.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::HoverAt("collab-peers", 35.0, 13.0),
|
||||
Step::Wait(700),
|
||||
Step::Shot("presence"),
|
||||
Step::DownAt("collab-peers", 35.0, 13.0),
|
||||
Step::UpAt("collab-peers", 35.0, 13.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("follow"),
|
||||
Step::DownAt("collab-peers", 35.0, 13.0),
|
||||
Step::UpAt("collab-peers", 35.0, 13.0),
|
||||
Step::DownAt("collab-marker", 12.0, 12.0),
|
||||
Step::UpAt("collab-marker", 12.0, 12.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("bubble"),
|
||||
Step::Key("escape"),
|
||||
Step::DownAt("collab-sidebar", 238.0, 24.0),
|
||||
Step::UpAt("collab-sidebar", 238.0, 24.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("resolved"),
|
||||
Step::DownAt("collab-sidebar", 90.0, 24.0),
|
||||
Step::UpAt("collab-sidebar", 90.0, 24.0),
|
||||
Step::DownAt("collab-share", 30.0, 14.0),
|
||||
Step::UpAt("collab-share", 30.0, 14.0),
|
||||
Step::Wait(400),
|
||||
Step::Shot("share"),
|
||||
Step::Key("escape"),
|
||||
Step::Wait(300),
|
||||
];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(people::presence(window, cx))
|
||||
.child(talk::comments(window, cx))
|
||||
.child(talk::annotations(window, cx))
|
||||
.child(talk::changes(window, cx))
|
||||
.child(sharing::sharing(window, cx))
|
||||
.child(sharing::activity(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use ely_gpui_component::{
|
||||
collab::{FollowMode, LiveIndicator, Peer, PresenceAvatars, RemoteCursor, RemoteSelection},
|
||||
forms::{Input, TextInput},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
use gpui::{
|
||||
App, Entity, FontWeight, IntoElement, ParentElement, SharedString, Styled, Window, div, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{keep, section, set},
|
||||
};
|
||||
|
||||
/// The people in the demo document, each somewhere in it.
|
||||
pub fn peers() -> Vec<Peer> {
|
||||
[
|
||||
("ada", "Ada Park", "Reading “In dark”"),
|
||||
("grace", "Grace Lin", "Writing the opening"),
|
||||
("alan", "Alan Reyes", "In the comments"),
|
||||
("mae", "Mae Okafor", "Viewing"),
|
||||
("tim", "Tim Novak", "Viewing"),
|
||||
("joan", "Joan Ito", "Viewing"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(hue, (key, name, place))| Peer {
|
||||
place: Some(place.into()),
|
||||
..Peer::new(key, name, hue)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The one reading the demo.
|
||||
pub fn you() -> Peer {
|
||||
Peer::new("you", "You", 6)
|
||||
}
|
||||
|
||||
const DRAFT: &str = "A lift blends a color toward white.\nHalf a lift sits midway; a full lift is white itself.\nIn dark, accents lift further so they read with the same weight.";
|
||||
|
||||
fn draft(window: &mut Window, cx: &mut App) -> Entity<TextInput> {
|
||||
window.use_keyed_state("collab-draft", cx, |window, cx| {
|
||||
let mut input = TextInput::new(window, cx).multi_line(3, 6);
|
||||
input.set_text(DRAFT, cx);
|
||||
input
|
||||
})
|
||||
}
|
||||
|
||||
pub fn presence(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let following = keep("collab-following", || None::<SharedString>, window, cx);
|
||||
let now = following.read(cx).clone();
|
||||
let people = peers();
|
||||
let followed = now
|
||||
.as_ref()
|
||||
.and_then(|key| people.iter().find(|peer| &peer.key == key).cloned());
|
||||
let draft = draft(window, cx);
|
||||
let selected = DRAFT
|
||||
.find("a full lift")
|
||||
.expect("the draft names a full lift");
|
||||
let caret = DRAFT.find("accents").expect("the draft names accents");
|
||||
let theme = cx.theme();
|
||||
let (toggle, stop) = (following.clone(), following);
|
||||
section(
|
||||
"PresenceAvatars / LiveIndicator / FollowMode / RemoteCursor / RemoteSelection",
|
||||
"Who else is here, ringed in their colors, their carets and selections live in the text. Rest on someone to see where they are; press to follow their view, and Escape to stop.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(760.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child("Handbook / Color / Lift"),
|
||||
)
|
||||
.child(LiveIndicator::new("collab-live", people.len()))
|
||||
.child(probe(
|
||||
"collab-peers",
|
||||
PresenceAvatars::new("collab-peers", people.clone()).follow(
|
||||
now,
|
||||
move |key, _, cx| {
|
||||
let next =
|
||||
(toggle.read(cx).as_ref() != Some(key)).then(|| key.clone());
|
||||
set(&toggle, next, cx)
|
||||
},
|
||||
),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
FollowMode::new("collab-follow", followed, move |_, cx| set(&stop, None, cx))
|
||||
.child(
|
||||
div().p_2().child(
|
||||
div()
|
||||
.relative()
|
||||
.child(Input::new(&draft))
|
||||
.child(RemoteSelection::new(
|
||||
&people[2],
|
||||
&draft,
|
||||
selected..selected + "a full lift".len(),
|
||||
))
|
||||
.child(RemoteCursor::new(&people[1], &draft, caret)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
collab::{AccessList, InviteInput, LinkAccess, Member, Role, ShareDialog},
|
||||
data_display::{Activity, ActivityFeed},
|
||||
forms::is_email,
|
||||
primitives::IconName,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
use jiff::{Timestamp, ToSpan};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{keep, section, set},
|
||||
};
|
||||
|
||||
fn member(name: &str, email: &str, role: Role) -> Member {
|
||||
Member {
|
||||
name: name.to_string().into(),
|
||||
email: email.to_string().into(),
|
||||
picture: None,
|
||||
role,
|
||||
owner: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn members() -> Vec<Member> {
|
||||
vec![
|
||||
Member {
|
||||
owner: true,
|
||||
..member("You", "you@example.com", Role::Edit)
|
||||
},
|
||||
member("Ada Park", "ada@example.com", Role::Edit),
|
||||
member("Grace Lin", "grace@example.com", Role::Comment),
|
||||
member("Alan Reyes", "alan@example.com", Role::View),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn sharing(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let open = keep("collab-share-open", || false, window, cx);
|
||||
let emails = keep(
|
||||
"collab-emails",
|
||||
|| {
|
||||
vec![
|
||||
SharedString::from("mae@example.com"),
|
||||
SharedString::from("tim@example"),
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let role = keep("collab-invite-role", || Role::Edit, window, cx);
|
||||
let people = keep("collab-members", members, window, cx);
|
||||
let link = keep("collab-link", || None::<Role>, window, cx);
|
||||
let dialog = open.read(cx).then(|| {
|
||||
let (now_emails, now_role, now_people, now_link) = (
|
||||
emails.read(cx).clone(),
|
||||
*role.read(cx),
|
||||
people.read(cx).clone(),
|
||||
*link.read(cx),
|
||||
);
|
||||
let (close, typed, invited, cleared, picked) = (
|
||||
open.clone(),
|
||||
emails.clone(),
|
||||
people.clone(),
|
||||
emails.clone(),
|
||||
role.clone(),
|
||||
);
|
||||
let (roled, removed) = (people.clone(), people.clone());
|
||||
ShareDialog::new("collab-share", "Lift", move |_, cx| set(&close, false, cx))
|
||||
.invite(
|
||||
InviteInput::new(
|
||||
"collab-invite",
|
||||
now_emails,
|
||||
move |next, _, cx| set(&typed, next.to_vec(), cx),
|
||||
move |_, cx| {
|
||||
let (added, role) = (cleared.read(cx).clone(), *picked.read(cx));
|
||||
invited.update(cx, |people, cx| {
|
||||
people.extend(added.iter().filter(|email| is_email(email)).map(
|
||||
|email| {
|
||||
let (name, _) =
|
||||
email.split_once('@').expect("an address holds @");
|
||||
member(name, email, role)
|
||||
},
|
||||
));
|
||||
cx.notify();
|
||||
});
|
||||
set(&cleared, Vec::new(), cx)
|
||||
},
|
||||
)
|
||||
.role(now_role, move |next, _, cx| set(&role, next, cx)),
|
||||
)
|
||||
.access(
|
||||
AccessList::new("collab-access", now_people)
|
||||
.on_role(move |ix, next, _, cx| {
|
||||
roled.update(cx, |people, cx| {
|
||||
people[ix].role = next;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.on_remove(move |ix, _, cx| {
|
||||
removed.update(cx, |people, cx| {
|
||||
people.remove(ix);
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
.link(LinkAccess::new(
|
||||
"collab-link",
|
||||
"https://example.com/d/lift",
|
||||
now_link,
|
||||
move |next, _, cx| set(&link, next, cx),
|
||||
))
|
||||
});
|
||||
section(
|
||||
"ShareDialog / InviteInput / PermissionSelect / AccessList",
|
||||
"Sharing in one place: add people by email, each chip marked until it is an address; choose what they may do; change or take away anyone's access; and choose who the link lets in.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"collab-share",
|
||||
div().flex().child(
|
||||
Button::new("collab-share-open", "Share")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.icon(IconName::UserPlus)
|
||||
.on_click(move |_, _, cx| set(&open, true, cx)),
|
||||
),
|
||||
))
|
||||
.children(dialog)
|
||||
}
|
||||
|
||||
pub fn activity(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let now = Timestamp::now();
|
||||
let entry = |key: &str, actor: &str, verb: &str, object: &str, minutes: i64| {
|
||||
Activity::new(
|
||||
key.to_string(),
|
||||
actor.to_string(),
|
||||
verb.to_string(),
|
||||
now - minutes.minutes(),
|
||||
)
|
||||
.object(object.to_string())
|
||||
};
|
||||
section(
|
||||
"ActivityLog → data_display::ActivityFeed",
|
||||
"What people did in the document, newest first.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div().w(px(560.)).child(
|
||||
ActivityFeed::new("collab-activity")
|
||||
.entry(entry(
|
||||
"reply",
|
||||
"Alan Reyes",
|
||||
"replied on",
|
||||
"blends in gamma space",
|
||||
4,
|
||||
))
|
||||
.entry(entry(
|
||||
"suggest",
|
||||
"Grace Lin",
|
||||
"suggested a change to",
|
||||
"Lift",
|
||||
35,
|
||||
))
|
||||
.entry(entry("resolve", "Ada Park", "resolved", "On Lift", 180))
|
||||
.entry(entry("share", "You", "shared", "Lift", 1440)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
use ely_gpui_component::{
|
||||
collab::{
|
||||
AnnotatedText, Annotation, Comment, CommentMarker, CommentSidebar, CommentThread, Decision,
|
||||
EditMode, Reaction, Suggestion, SuggestionMode, Thread, TrackChanges, accept, suggested,
|
||||
toggled,
|
||||
},
|
||||
forms::{Input, TextInput},
|
||||
theme::ActiveTheme,
|
||||
};
|
||||
use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
use jiff::{Timestamp, ToSpan};
|
||||
|
||||
use super::people::{peers, you};
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{keep, section, set},
|
||||
};
|
||||
|
||||
fn threads(now: Timestamp) -> Vec<Thread> {
|
||||
let people = peers();
|
||||
let comment = |ix: usize, hours: i64, body: &str, reactions: Vec<Reaction>| Comment {
|
||||
author: people[ix].clone(),
|
||||
at: now - hours.hours(),
|
||||
body: body.to_string().into(),
|
||||
reactions,
|
||||
};
|
||||
let reaction = |emoji: &str, count: usize, mine: bool| Reaction {
|
||||
emoji: emoji.to_string().into(),
|
||||
count,
|
||||
mine,
|
||||
};
|
||||
vec![
|
||||
Thread {
|
||||
key: "gamma".into(),
|
||||
quote: Some("blends in gamma space".into()),
|
||||
comments: vec![
|
||||
comment(
|
||||
0,
|
||||
5,
|
||||
"Should this say gamma? Most readers won't know the word.",
|
||||
vec![reaction("👀", 2, false)],
|
||||
),
|
||||
comment(
|
||||
2,
|
||||
3,
|
||||
"Keep it, and link the glossary entry beside it.",
|
||||
vec![reaction("👍", 1, true)],
|
||||
),
|
||||
],
|
||||
resolved: false,
|
||||
},
|
||||
Thread {
|
||||
key: "weight".into(),
|
||||
quote: Some("read with the same weight".into()),
|
||||
comments: vec![comment(
|
||||
1,
|
||||
26,
|
||||
"Could we show a light and a dark pair side by side here?",
|
||||
Vec::new(),
|
||||
)],
|
||||
resolved: false,
|
||||
},
|
||||
Thread {
|
||||
key: "title".into(),
|
||||
quote: Some("On Lift".into()),
|
||||
comments: vec![
|
||||
comment(3, 50, "The title reads well now.", Vec::new()),
|
||||
comment(0, 49, "Agreed, closing this.", Vec::new()),
|
||||
],
|
||||
resolved: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Changes the thread keyed `key`.
|
||||
fn change(
|
||||
all: &Entity<Vec<Thread>>,
|
||||
key: &SharedString,
|
||||
cx: &mut App,
|
||||
edit: impl FnOnce(&mut Thread),
|
||||
) {
|
||||
all.update(cx, |all, cx| {
|
||||
let thread = all
|
||||
.iter_mut()
|
||||
.find(|thread| &thread.key == key)
|
||||
.expect("the demo keeps its threads");
|
||||
edit(thread);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds the field's words to the thread keyed `key` as yours, and clears it.
|
||||
fn answer(all: &Entity<Vec<Thread>>, key: &SharedString, field: &Entity<TextInput>, cx: &mut App) {
|
||||
let body = field.read(cx).text().trim().to_string();
|
||||
field.update(cx, |field, cx| field.set_text("", cx));
|
||||
change(all, key, cx, |thread| {
|
||||
thread.comments.push(Comment {
|
||||
author: you(),
|
||||
at: Timestamp::now(),
|
||||
body: body.into(),
|
||||
reactions: Vec::new(),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn reply_field(key: &'static str, window: &mut Window, cx: &mut App) -> Entity<TextInput> {
|
||||
window.use_keyed_state(key, cx, |window, cx| {
|
||||
TextInput::new(window, cx).placeholder("Reply")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn comments(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let all = keep("collab-threads", || threads(Timestamp::now()), window, cx);
|
||||
let now = all.read(cx).clone();
|
||||
let (one, marked, side) = (
|
||||
reply_field("collab-reply-one", window, cx),
|
||||
reply_field("collab-reply-marker", window, cx),
|
||||
reply_field("collab-reply-side", window, cx),
|
||||
);
|
||||
let active = keep(
|
||||
"collab-active",
|
||||
|| Some(SharedString::from("gamma")),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let picked = active.read(cx).clone();
|
||||
let thread = |key: &str| {
|
||||
now.iter()
|
||||
.find(|thread| thread.key.as_ref() == key)
|
||||
.expect("the demo keeps its threads")
|
||||
.clone()
|
||||
};
|
||||
let key = |key: &'static str| SharedString::from(key);
|
||||
let (sent, resolved, reacted) = (all.clone(), all.clone(), all.clone());
|
||||
let first = CommentThread::new("collab-thread", thread("gamma"))
|
||||
.reply(&one, {
|
||||
let one = one.clone();
|
||||
move |_, cx| answer(&sent, &key("gamma"), &one, cx)
|
||||
})
|
||||
.on_resolve(move |on, _, cx| {
|
||||
change(&resolved, &key("gamma"), cx, |thread| thread.resolved = on)
|
||||
})
|
||||
.on_react(move |ix, emoji, _, cx| {
|
||||
change(&reacted, &key("gamma"), cx, |thread| {
|
||||
thread.comments[ix].reactions = toggled(&thread.comments[ix].reactions, emoji)
|
||||
})
|
||||
});
|
||||
let (sent, resolved) = (all.clone(), all.clone());
|
||||
let marker = CommentMarker::new("collab-marker", thread("weight"))
|
||||
.reply(&marked, {
|
||||
let marked = marked.clone();
|
||||
move |_, cx| answer(&sent, &key("weight"), &marked, cx)
|
||||
})
|
||||
.on_resolve(move |on, _, cx| {
|
||||
change(&resolved, &key("weight"), cx, |thread| thread.resolved = on)
|
||||
});
|
||||
let (sent, resolved, reacted, pick) = (all.clone(), all.clone(), all, active.clone());
|
||||
let sidebar = CommentSidebar::new("collab-sidebar", now.clone())
|
||||
.active(picked.clone(), move |key, _, cx| {
|
||||
set(&pick, Some(key.clone()), cx)
|
||||
})
|
||||
.reply(&side, {
|
||||
let side = side.clone();
|
||||
move |_, cx| {
|
||||
if let Some(key) = active.read(cx).clone() {
|
||||
answer(&sent, &key, &side, cx)
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_resolve(move |key, on, _, cx| change(&resolved, key, cx, |thread| thread.resolved = on))
|
||||
.on_react(move |key, ix, emoji, _, cx| {
|
||||
change(&reacted, key, cx, |thread| {
|
||||
thread.comments[ix].reactions = toggled(&thread.comments[ix].reactions, emoji)
|
||||
})
|
||||
});
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.child(
|
||||
section(
|
||||
"CommentThread / Reply / Reactions / ReactionPicker",
|
||||
"A conversation on the words it quotes: each comment with its author, time and reactions; a reply below, Enter to send; resolve above. Press a reaction to add yours or take it back, or pick another.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("collab-thread", div().w(px(440.)).child(first))),
|
||||
)
|
||||
.child(
|
||||
section(
|
||||
"CommentBubble / CommentMarker",
|
||||
"A mark beside the words a thread is about, with its count; a press opens the thread in a bubble over the page.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(560.))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(div().text_color(theme.colors.fg).child("In dark, accents lift further so they read with the same weight."))
|
||||
.child(probe("collab-marker", marker)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section(
|
||||
"CommentSidebar",
|
||||
"Every conversation down a column, open or resolved; a press picks one, and the one picked takes replies.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"collab-sidebar",
|
||||
div()
|
||||
.w(px(400.))
|
||||
.h(px(560.))
|
||||
.rounded(theme.radius(ely_gpui_component::theme::Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.bg(theme.colors.sunken)
|
||||
.child(sidebar),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
const PASSAGE: &str = "Every accent in the library is a base color and a lift. The base carries the hue; the lift carries how loud the color is. Hover, press and focus are lifts of the accent.";
|
||||
|
||||
pub fn annotations(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let picked = keep("collab-picked", || None::<usize>, window, cx);
|
||||
let now = *picked.read(cx);
|
||||
let people = peers();
|
||||
let mark = |words: &str, author: usize, note: Option<&str>| {
|
||||
let start = PASSAGE.find(words).expect("the passage holds its marks");
|
||||
Annotation {
|
||||
range: start..start + words.len(),
|
||||
author: people[author].clone(),
|
||||
note: note.map(|note| note.to_string().into()),
|
||||
}
|
||||
};
|
||||
section(
|
||||
"Annotation / Highlight",
|
||||
"Stretches people marked, washed in their colors; the ones with a note are underlined, and resting on one shows it. A press picks one. A plain mark with no author is typography::Highlight.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"collab-annotations",
|
||||
div().w(px(560.)).child(
|
||||
AnnotatedText::new(
|
||||
"collab-annotated",
|
||||
PASSAGE,
|
||||
[
|
||||
mark("a base color and a lift", 0, Some("The one idea to remember.")),
|
||||
mark("how loud the color is", 1, None),
|
||||
mark("lifts of the accent", 2, Some("Link to the motion page.")),
|
||||
],
|
||||
)
|
||||
.picked(now, move |ix, _, cx| set(&picked, Some(ix), cx)),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
const BASE: &str = "A lift blends color toward white. Half a lift sits midway between.";
|
||||
|
||||
/// `base` with every one of `suggestions` taken.
|
||||
fn applied(base: &str, suggestions: Vec<Suggestion>) -> String {
|
||||
let (mut text, mut left) = (base.to_string(), suggestions);
|
||||
while !left.is_empty() {
|
||||
(text, left) = accept(&text, &left, 0);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
pub fn changes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let mode = keep("collab-mode", || EditMode::Suggesting, window, cx);
|
||||
let now_mode = *mode.read(cx);
|
||||
let base = keep("collab-base", || BASE.to_string(), window, cx);
|
||||
let now_base = base.read(cx).clone();
|
||||
let draft = window.use_keyed_state("collab-suggesting", cx, |window, cx| {
|
||||
let mut input = TextInput::new(window, cx);
|
||||
input.set_text(
|
||||
"A tint blends every color toward white. Half a lift sits midway.",
|
||||
cx,
|
||||
);
|
||||
input
|
||||
});
|
||||
let edited = draft.read(cx).text().to_string();
|
||||
let (switch_base, switch_draft) = (base.clone(), draft.clone());
|
||||
let switch = SuggestionMode::new("collab-mode", now_mode, move |next, _, cx| {
|
||||
if *mode.read(cx) == EditMode::Editing {
|
||||
let text = switch_draft.read(cx).text().to_string();
|
||||
set(&switch_base, text, cx);
|
||||
}
|
||||
switch_draft.update(cx, |input, cx| {
|
||||
input.set_disabled(next == EditMode::Viewing, cx)
|
||||
});
|
||||
set(&mode, next, cx)
|
||||
});
|
||||
let track = match now_mode {
|
||||
EditMode::Editing => TrackChanges::new("collab-track", edited, Vec::new()),
|
||||
EditMode::Viewing => {
|
||||
let pending = suggested(&now_base, &edited, &you());
|
||||
TrackChanges::new("collab-track", now_base, pending)
|
||||
}
|
||||
EditMode::Suggesting => {
|
||||
let pending = suggested(&now_base, &edited, &you());
|
||||
let (decided, field) = (base.clone(), draft.clone());
|
||||
TrackChanges::new("collab-track", now_base, pending).on_decide(
|
||||
move |decision, _, cx| {
|
||||
let base_text = decided.read(cx).clone();
|
||||
let pending = suggested(&base_text, field.read(cx).text(), &you());
|
||||
match decision {
|
||||
Decision::Accept(ix) => {
|
||||
set(&decided, accept(&base_text, &pending, ix).0, cx)
|
||||
}
|
||||
Decision::AcceptAll => set(&decided, applied(&base_text, pending), cx),
|
||||
Decision::Reject(ix) => {
|
||||
let mut rest = pending;
|
||||
rest.remove(ix);
|
||||
let text = applied(&base_text, rest);
|
||||
field.update(cx, |input, cx| input.set_text(text, cx));
|
||||
}
|
||||
Decision::RejectAll => {
|
||||
field.update(cx, |input, cx| input.set_text(base_text, cx))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
section(
|
||||
"SuggestionMode / TrackChanges",
|
||||
"Editing changes the words; suggesting keeps them and proposes the change, removed words struck and added ones underlined in the author's color, each to accept or reject; viewing only reads. Type in the field while suggesting.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"collab-changes",
|
||||
div()
|
||||
.w(px(560.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(switch)
|
||||
.child(Input::new(&draft))
|
||||
.child(track),
|
||||
))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod buttons;
|
||||
mod charts;
|
||||
mod collab;
|
||||
mod data;
|
||||
mod debug;
|
||||
mod documents;
|
||||
@@ -57,6 +58,7 @@ pub const ALL: &[Page] = &[
|
||||
git::PAGE,
|
||||
debug::PAGE,
|
||||
documents::PAGE,
|
||||
collab::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user