Skip to content

Conversation

@marcbouchenoire
Copy link
Contributor

@marcbouchenoire marcbouchenoire commented Nov 19, 2024

This PR introduces a new floating toolbar in the Comments composer to allow visual formatting. It's available by default in the high-level Composer component and can be opted-in with the Composer.* primitives. The PR also contains formatting-related APIs to allow building formatting toggles and similar controls.

composer-floating-island.mp4

In addition to the technical aspect, I really struggled with naming so if you have any opinions on "FloatingToolbar", "marks", etc, please share them! For "FloatingToolbar" I was thinking of "FormattingToolbar" as well, and for "marks", I initially thought it was too Slate/internal-related but my alternative ("text formats") felt too convoluted and I found out that "marks" is also used by ProseMirror/Tiptap and Lexical.

Fixes LB-254
Fixes LB-1323

Follow-up: #2085

# Conflicts:
#	packages/liveblocks-react-ui/src/components/Composer.tsx
#	packages/liveblocks-react-ui/src/primitives/Composer/contexts.ts
#	packages/liveblocks-react-ui/src/primitives/Composer/index.tsx
#	packages/liveblocks-react/src/_private.ts
Copy link
Contributor

@SugarDarius SugarDarius left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I'm the best person to review but from what I saw it looks dope 🚀
Huge work on this one 👏🏻

Comment on lines +379 to +381
const label = useMemo(() => {
return $.COMPOSER_TOGGLE_MARK(mark);
}, [$, mark]);
Copy link
Contributor

@SugarDarius SugarDarius Nov 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NB: it looks like a bit overkill to get the label with a useMemo here from what I understand. useMemo is expensive to use and like $ is already a memoized value (even if it should be a stable reference instead) I do not think it's really worth to memoize the label here. But I could be wrong ^^

Copy link
Contributor Author

@marcbouchenoire marcbouchenoire Nov 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do have a tendency to overuse useMemo/useCallback but here the COMPOSER_TOGGLE_MARK override can be set by users so it might be unexpected to run it on every render. I don't think anyone would put super expensive logic in a localization function but still I feel safer controlling its number of invokations

Copy link
Contributor

@CTNicholas CTNicholas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Docs

Cool feature! I like the API naming you've already chosen.

@CTNicholas
Copy link
Contributor

CTNicholas commented Nov 25, 2024

Actually, I have thoughts on the styling. The icons are quite faded out by default, and almost look disabled. I'd personally prefer to have them not be faded out, and instead put a faint background around them when (for example) they're selecting text that's already bold.

@marcbouchenoire
Copy link
Contributor Author

@CTNicholas We're somewhat limited by our existing tokens:

toggles.mp4

Comment on lines 222 to 225
export function useOnComposerEditorChangeWithEventSource(
editorChangeEventSource: EventSource<void>,
callback: () => void
) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this hook exported purely because we want to also listen to editor onchange events inside Composer.Form?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Composer.Form parent but also in children components (e.g. ComposerEditorMentionSuggestionsWrapper, ComposerEditorFloatingToolbarWrapper, etc).

Comment on lines 1489 to 1491
useOnComposerEditorChangeWithEventSource(editorChangeEventSource, () => {
setMarks(getMarks(editor));
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to this comment, if we do not want to export a hook just for this use case, we could maybe make this a useEffect and mimic the implementation of useOnComposerEditorChangeWithEventSource directly here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Composer's index.tsx file is already pretty long and complex so I wanted to avoid adding noise just for subscribing, I simplified this by making a single and generic useEventSource hook.

Copy link
Contributor

@nimeshnayaju nimeshnayaju left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing job @marcbouchenoire 🚀 . This was quite a big undertaking and you did a great job - the final product looks beautiful! I've tried to review this based on my limited Slate.js knowledge and I only have some general questions.

Comment on lines 6 to 17
export function useEventSource<T>(
eventSource: EventSource<T>,
callback: () => void
) {
const latestCallback = useLatest(callback);

useEffect(() => {
const unsubscribe = eventSource.subscribe(() => latestCallback.current());

return unsubscribe;
}, [eventSource, latestCallback]);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I like this generic hook!

One suggestion here is to only have it rely on the Observable API, not the full EventSource API, which should ideally be an implementation detail used by the source of the events — i.e. where those events get emitted. Any party interested in listening to those events, are better off just relying on the Observable API.

Suggested change
export function useEventSource<T>(
eventSource: EventSource<T>,
callback: () => void
) {
const latestCallback = useLatest(callback);
useEffect(() => {
const unsubscribe = eventSource.subscribe(() => latestCallback.current());
return unsubscribe;
}, [eventSource, latestCallback]);
}
export function useObservable<T>(
observable: Observable<T>,
callback: () => void
) {
const latestCallback = useLatest(callback);
useEffect(() => {
const unsubscribe = observable.subscribe(() => latestCallback.current());
return unsubscribe;
}, [observable, latestCallback]);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! Done here.

@marcbouchenoire marcbouchenoire merged commit 1abddbe into main Nov 28, 2024
18 checks passed
@marcbouchenoire marcbouchenoire deleted the composer-formatting-ui branch November 28, 2024 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants