-
-
Notifications
You must be signed in to change notification settings - Fork 711
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/toggles #2895
Feat/toggles #2895
Conversation
🦋 Changeset detectedLatest commit: 0d84809 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
apps/www/content/docs/toggle.mdx
Outdated
|
||
## Features | ||
|
||
- Turn any bock into a toggle: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Turn any bock into a toggle: | |
- Turn any block into a toggle: |
apps/www/content/docs/toggle.mdx
Outdated
## Installation | ||
|
||
```bash | ||
npm install @udecode/plate-indent @udecode/plate-indent-list @udecode/plate-node-id @udecode/toggle |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
npm install @udecode/plate-indent @udecode/plate-indent-list @udecode/plate-node-id @udecode/toggle | |
npm install @udecode/plate-indent @udecode/plate-indent-list @udecode/plate-node-id @udecode/plate-toggle |
return { | ||
toggleId, | ||
open, | ||
openIds, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use jotai so we don't need to pass the store here.
import { getEnclosingToggleIds } from './queries'; | ||
import { ToggleEditor } from './types'; | ||
|
||
export const injectToggleWrapper = (): InjectComponentReturnType => WithToggle; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
export const injectToggleWrapper = (): InjectComponentReturnType => WithToggle; | |
export const injectToggle = (): InjectComponentReturnType => WithToggle; |
Let's just use the plugin name
isElement: true, | ||
inject: { | ||
aboveComponent: injectToggleWrapper, | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With jotai, you'll need to add ToggleControllerProvider
in renderAboveEditable
@@ -0,0 +1,19 @@ | |||
import { PlateEditor, TNodeEntry } from '@udecode/plate-common'; | |||
import { last } from 'lodash'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Somehow, our build does not tree-shake well lodash. You'll need to import fromlodash/last.js
packages/toggle/src/store.ts
Outdated
export const createToggleStore = () => { | ||
return createZustandStore(ELEMENT_TOGGLE)({ | ||
openIds: new Set() as Set<string>, | ||
}); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use jotai (see my comment)
|
||
// When creating a toggle, we open it by default. | ||
// So before inserting the toggle, we update the store to mark the id of the blocks about to be turned into toggles as open. | ||
export const openFutureToggles = (editor: PlateEditor & ToggleEditor) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
export const openFutureToggles = (editor: PlateEditor & ToggleEditor) => { | |
export const openNextToggles = (editor: PlateEditor & ToggleEditor) => { |
packages/toggle/src/types.ts
Outdated
export const ELEMENT_TOGGLE = 'toggle'; | ||
|
||
export type ToggleEditor = { | ||
toggleStore: ToggleStore; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With jotai, we only have hooks. Using useEffect
in the plugin useHooks
, you could do:
const [openIds, setOpenIds] = useToggleControllerStore().use.openIds()
useEffect(() => {
const options = getPluginOptions(editor, ELEMENT_TOGGLE)
options.openIds = openIds
options.setOpenIds = setOpenIds
}, [openIds, setOpenIds])
This is a bit redundant but we plan to build a jotai layer in plate-core so we have an integrated store.
@@ -66,6 +66,7 @@ | |||
"@udecode/plate-serializer-md": "^29.0.1", | |||
"@udecode/plate-tabbable": "^29.0.1", | |||
"@udecode/plate-table": "^29.0.1", | |||
"@udecode/plate-toggle": "^30.1.2", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you remove all the templates
changes? We need to wait for the release first (CI condition)
Wow, thank you for this huge effort! A few comments: List vs Indent ListThe List plugin was the first one to come out. The nested block architecture was reflecting the HTML one. Normalizing was easy but it came with a lot of complexity and bugs about copy/pasting and moving nodes. The Indent List has less constraints being flat (like in Word). Complexity is much lower but we needed to mimic the HTML list behavior (list-start, style) and handle normalization of siblings, which is not yet 100% covered as you could see it. Toggle ListThis is not part of Notion, but apps like RoamResearch and Obsidian: 2024-01-29.at.12.36.50.mp4It would be nice we could reuse (part of) the plugin to support that use-case. Since the current plugin is not completely dependent on DecisionsI'll tag each point with ✅ (compatible) or ❌ (not compatible) with toggle list. These are just notes for later. I'm fine with separate plugin design to ease the development Element vs attributeSounds good. We could extend the plugin to support heading as a prop later. ❌ Flat vs NestedSounds good, see "List vs Indent List" ✅ Hiding the collapsible sectionNice trick, this will enable a lot of other cases like Tabs. ✅ The open stateNotion persists in the local storage (opening the same page in incognito will close all the toggles). You could use const { useToggleControllerStore, ToggleControllerProvider } = createAtomStore({
open: atomWithStorage('plate-toggle-open', {})
}, { name: 'toggleController' }); We generally add ✅ Hiding / Showing elements based on the open state✅ Wrapping sounds good. Re-computing the visibility of elements✅ Sounds good. Toggles are not indented by defaultSee "Toggle List". We should consider using indent 2 only if the plugin is reused for that. ❌ (if toggle has undefined Nested blocksSounds good for now. There can be "structural blocks" at the root, like pages or columns. I'll think to implement ✅ Ideas on how to improve this plugin
|
Here is the icon we could use for the toolbar: https://lucide.dev/icons/list-collapse |
Thanks for the review @zbeyens! The plugin is now using a JOTAI store as you suggested. I also revised the way the elements subscribe to the toggle store: We no longer customize IMO only one suboptimal thing remains: The fact that the toggle index (the index that maps an element id to its enclosing toggle ids) is memoized instead of being a state derived from Finally: I can't find the I reverted the templates changes as you requested, the revert commit is available to be re-reverted later. Documentation still needs some work. |
packages/toggle/src/ToggleStyle.tsx
Outdated
[openIds, children] | ||
); | ||
|
||
return <style>{css}</style>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice idea, but there are a couple of problems with this approach:
- This won't work in apps that use a restrictive content security policy that blocks inline style elements (and for good reason—see point 2).
- The code as it's currently written is vulnerable to CSS injection if a user opens a maliciously crafted document, which can be used to install CSS key loggers on the page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A better solution might be to rely on derived Jotai atoms to ensure that components only re-render when a specific ID is modified.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see 👍 Any idea on how to derive a state from editor.children
? It's the missing piece
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
useEditorSelector
is the most optimal one
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright guys I reverted back to injecting a component in lieu of adding a style tag.
The behaviour hasn't changed.
Now I just want to make sure the injected component only renders when necessary, and for that I want to define a derived state based on 3 things:
- The element id
- The
openIds
set - The toggleIndex that only depends on
editor.children
And also discard getEnclosingToggleIds
to always rely on the index atom
Sounds straightforward, but I fear that the learning curve of jotai
and especially jotai-x
is a bit too steep.
Help on this would be much appreciated 🙏 🆘
packages/toggle/src/injectToggle.tsx
Outdated
const [openIds] = useToggleControllerStore().use.openIds(); | ||
const toggleIndex = useToggleIndex(); | ||
const enclosedInToggleIds = toggleIndex.get(element.id as string) || []; | ||
const isOpen = enclosedInToggleIds.every((id) => openIds.has(id)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Something like?
// (id: string) is wrong, looking into an alternative...
export const isToggleOpenAtom = atom((get) => (id: string) => {
const openIds = get(toggleControllerStore.atom.openIds)
const toggleIndex = get(toggleIndexAtom)
const enclosedInToggleIds = toggleIndex.get(id) || [];
return enclosedInToggleIds.every((enclosedId) => openIds.has(enclosedId))
})
const isToggleOpen = useToggleControllerStore().get.atom(isOpenAtom)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
element
would be undefined?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would suggest something similar to @zbeyens' suggestion, except define the atom inside the component inside a React.useMemo
, and put element.id
in the dependency array.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From this example:
export const usePlateControllerEditorStore = ( |
export const useIsToggleOpen = (id: string) => {
const isOpenAtom = useMemo(
() => atom((get) => {
const openIds = get(toggleControllerStore.atom.openIds)
const toggleIndex = get(toggleIndexAtom)
const enclosedInToggleIds = toggleIndex.get(id) || [];
return enclosedInToggleIds.every((enclosedId) => openIds.has(enclosedId))
}),
[id],
)
return useToggleControllerStore().get.atom(isOpenAtom)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No luck. This part of jotai-x's doc states that you can derive state from a store created with createAtomStore
. In our case the state derives from both toggleControllerStore
and plateEditorStore
. So I guess that at least the useIsToggleOpen
hook should be symmetric in its use of toggleControllerStore
and plateEditorStore
.
Using your sample @zbeyens, the toggleIndexAtom stays blocked at an empty map, but the openIds
atom is updated correctly.
Using return usePlateStore().get.atom(isOpenAtom)
instead of return useToggleControllerStore().get.atom(isOpenAtom)
, the openIds
atom stays blocked at an empty array, but the index atom is updated correctly.
I'm so confused 😆
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is a limitation of Jotai; you can't create a derived atom that depends on multiple stores. I've definitely encountered this problem myself. My advice would be to use a different hook for each store, and put the one that's least likely to change first to reduce unnecessary re-renders.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good idea, opening and closing toggles will surely be much less frequent than updating children 👌
I guess that's what you meant but jotai can definitely derive from multiple atoms, it must be jotai-x
that stands in the way.
Just tested, it works without and without constant re-renders.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deriving from multiple atoms works fine, but not when those atoms come from different JotaiStores, which is where the limitation comes from.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
gotcha, I misunderstood 👌
I'm done with all the review fixes / optimizations. @zbeyens Next step: Validation by you, and then documentation and tests by me? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome, you can go on for the docs/tests and we'll land it asap.
I'm done with documentation! @zbeyens Testing the insert of a break deemed too difficult given the current Linting, typechecking, and testing passes in Not sure how you handle |
|
@zbeyens We could use |
|
|
For the record, in the markdown spec, |
@Liboul Opened a PR to fix the CI |
🟢 |
- Use JOTAI - Rename openFutureToggles into openNextToggles - Rename injectToggleWrapper into injectToggle - Don't rely on lodash treeshaking - Fix @udecode/toggle typo - Change description of apps/www/content/docs/toggle.mdx
…nt to the openIds store
…g children and unnecessary computations of the toggleIndex
…est to autoformat toggle, fix typing of plugin, lint
A pleasure to see such quality in this PR, I still think our DX could be improved with better docs and CLI tooling. Wish you the best for your app! |
Intro
This PR adds toggles! (à la Notion)
See Discussion in Top-Requested Features
Disclaimer 1: Although examples in
www
work, this PR is not production-ready:@ts-ignore
need fixingWhile some of these decisions may be revisited, this PR might be a good starting point for a final implementation.
Disclaimer 2: Tests fail on my computer, but they also fail on
main
Disclaimer 3: This is my first PR on this project, so I'll be adding a lot of explanations to smooth the back and forth with the reviewers.
Objective
The objective is to add toggles to Plate, i.e. adding the possibility to expand / collapse a section by clicking on a button located next to a block that is always visible.
Here's a basic demo in Notion:
Screen.Recording.2024-01-28.at.20.32.35.mov
Challenges
Before diving into the proposed implementation, here is a non-exhaustive list of the challenges to keep in mind. Again, the demo is done on Notion:
Challenge 1: Inserting breaks into a toggle
Depending on whether the toggle is currently open, the behaviour is different:
Screen.Recording.2024-01-28.at.20.40.41.mov
Challenge 2: Copying a toggle
Copying an open toggle, along with potentially part of the collapsible section, should copy exactly that.
Copying a closed toggle should copy both the visible and the collapsible sections.
Challenge 3: Indenting or moving an element can change whether it is inside the collapsible section of the toggle
Screen.Recording.2024-01-28.at.20.45.42.mov
Challenge 4: When moving the visible part of a toggle, the collapsible section may no longer be inside a toggle, and new elements may now be inside.
Challenge 5: Controlling open state of the toggles
When inserting a toggle, it should ideally be open by default. But when rendering the editable for the first time, it should rather be closed by default (this is debatable).
Challenge 6: When the toggle is closed, the collapsible section should not be visible / selectable
Challenge 7: Toggles can be nested
This means that to determine if an element is visible, ALL enclosing toggles must be open.
Decisions
Decision 0: Element vs attribute
Option 1: Toggles are a new block element, like "Table" or "Heading".
Option 2:
isToggle
is a new attribute that can be added to any blockSince blocks cannot contain blocks, if toggles are block elements, this implies that an element cannot be both a heading and a toggle. However, allowing all kinds of blocks to be toggles requires a lot of thought on how they interact. Notion chose to make toggles blocks, for instance an element cannot be both a blockquote and a toggle. However, it can be a heading, but that's because in Notion, headings are not blocks, but leafs, which is a sensible decision.
All this considered, I went with option 1. Headings becoming toggles is rather incumbent on the implementation of headings.
Decision 1: Flat vs Nested
Much like lists, toggles could be implemented either as:
=> I went with the flat version, mostly because I felt like it would make normalization easier.
In fact, we could very well imagine 2 separate plugins, one for flat, one for nested, much like lists. Therefore, this plugin depends on the
plate-indent
plugin.Decision 2: Hiding the collapsible section
I experimented with multiple CSS rules, and found that the following lead to a natural behaviour:
height: 0; visibility: hidden;
on elements enclosed in a closed toggle.editor.isSelectable
returnsfalse
for elements enclosed in a clsoed toggle.This also plays well with copying (see challenge 2)
Decision 3: The
open
stateOptions wrt. where & how to store that state:
toggle-element
ZustandStore
at the editor levelDecision 3bis: Identifying toggles
Identifying which toggles are open requires to identify the toggles by some kind of id. This plugin could implement this idea itself, or it could rely on another plugin,
plate-node-id
, which is the route I chose. Therefore, this plugin depends on theplate-node-id
plugin.Decision 4: Hiding / Showing elements based on the
open
stateI had 2 ideas for this:
inToggleIds
on elements that are enclosed in one or more toggles, then injecting style attributes.I started with option 1 but the normalization rules turned out to be too complex for my taste. Even more complex than the normalization rules of the
indent-list
plugin. Testing edge cases on theindent-list
plugin for the purpose of this toggle plugin actually surfaced bugs in theindent-list
due to normalization.So I went with option 2, which might be less performant, but is much easier normalization-wise. For performance, I made sure to compute the enclosing toggles of each element all in one go, in O(nb of nodes), using memoization, so that the cost really isn't too bad.
Decision 5: Re-computing the visibility of elements (see challenge 4)
On every
apply
operation, if the operation incurs the possibility of elements moving into a toggle or moving out, we trigger a store update so that elements can recompute their enclosing togglesThis may seem like a hack.
Decision 6: Toggles are not indented by default
The rationale is that if we indent a paragraph below a toggle, it should be considered inside the toggle.
This came as a surprise, but indented lists are indented at 1 by default. This means that a list that has not been manually indented would be considered inside a toggle. I added code to handle that specific use case, but other options could be considered. For instance, toggles could also have a default indent of 1, and in order to be considered inside a toggle, an element would need to have an indent of 2. This last option seems less natural.
Hypothesis: Nested blocks?
This I'm not sure of: I made the hypothesis that every block is at the first level of the document. This seems to be true on all the examples of Plate editors I found. This makes it easy to compute the enclosing toggles of every block, as there's a single non-nested list to traverse. The code can be adapted if this hypothesis turns out to be false, by looking at siblings of elements instead of
editor.children
. (I realize this point might not be crystal clear)Demo (sound on), commented, in 3 parts
Demo.part.1.mp4
Demo.part.2.mp4
Demo.part.3.mp4
Ideas on how to improve this plugin
initialValue
of opentoggleIds
and a callbackonChange
, for instance to store it in local storage to remember the state upon browser refreshclosedByDefault: false
instead of the current defaultindent
value of elements, right now we are relying on this being the defaultKEY_ELEMENT
id
attribute.