-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(ui): tabbed canvases #8553
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
base: main
Are you sure you want to change the base?
Conversation
3f40a1b
to
1f9019d
Compare
2b8c8d2
to
03b85da
Compare
03b85da
to
4c343f0
Compare
@psychedelicious, here is the current status of multi-canvas development and refactoring:
Outstanding tasks:
|
Since there is always only one canvas rendered, the active one, so the
|
After more contemplation and some experimentation, I think consolidating the slices into canvas instances is the right move. That said, I'm certainly open to discussion and being wrong about this! Here's a draft proposal for consolidated canvas state. I think it covers most of the required touch points, except interacting with the Consolidated root canvas state and instance state structure: // this gets added to root store
type RootCanvasState = {
_version: number;
activeCanvasId: string;
canvases: Record<string, CanvasInstance>;
};
// these instance-scoped slices do not get added to root store
type CanvasInstance = {
id: string;
name: string;
canvas: StateWithHistory<CanvasEntityState>; // previously `CanvasState`
params: ParamsState;
settings: SettingsState;
staging: StagingState;
}; Utilities for working with it: const isCanvasInstanceAction = isAnyOf(...Object.values(canvasSlice.actions).map((a) => a.match));
// use a Symbol to prevent key collisions
const CANVAS_ID_META_KEY = Symbol('canvasId');
const injectCanvasId = (canvasId: string, action: CanvasInstanceAction) => {
// mutates the action - could inject into payload instead
Object.assign(action, { meta: { [CANVAS_ID_META_KEY]: canvasId } });
}
const extractCanvasId = (action: CanvasInstanceAction): string | undefined => {
return action?.meta?.[CANVAS_ID_META_KEY];
} Use react context to provide current canvasId: const CanvasIdContext = createContext<string | null>(null);
const useCanvasIdContextSafe = () => useContext(CanvasIdContext);
const useCanvasIdContextRequired = () => {
const canvasId = useCanvasIdContext();
if (canvasId === null) {
throw new Error("useCanvasIdContextRequired must be used within a CanvasIdProvider");
}
return canvasId;
} Wrapper around useDispatch to auto-inject canvasId (could also be middleware I suppose): const useAppDispatch = () => {
const _dispatch = useDispatch();
const canvasId = useCanvasIdContextSafe() // string | null
return useCallback((action: UnknownAction) => {
if (canvasId !== null && isCanvasInstanceAction(action)) {
injectCanvasId(canvasId, action)
}
dispatch(action)
})
} Wrap the undoable slice reducers: const canvasEntitySlice = createSlice(...)
const undoableConfig = { /* as before */ }
export const undoableCanvasEntityReducer = undoable(canvasEntitySlice.reducer, undoableConfig) Use an "action router" pattern in the root canvas reducer to route actions to the correct canvas instance: extraReducers: (builder) => {
builder.addMatcher(isCanvasInstanceAction, (state, action) => {
const canvasId = extractCanvasId(action);
if (!canvasId) {
return;
}
const instance = state.canvases[canvasId];
if (!instance) {
return;
}
// delegate to sub-reducers (each handles own undo/redo)
if (isCanvasEntityAction(action)) {
instance.canvas = undoableCanvasEntityReducer(instance.canvas, action);
}
if (isParamsAction(action)) {
instance.params = paramsReducer(instance.params, action);
}
// etc
});
} To handle key-specific persist denylists, we could update type PersistConfig<TInternal, TSerialized> = {
serialize?: (state: TInternal) => TSerialized;
deserialize?: (state: TSerialized) => TInternal;
}
const wrapHistory = <T,>(state: T): StateWithHistory<T> => ({ /* ... */ })
const unwrapHistory = <T,>(state: StateWithHistory<T>): T => ({ /* ... */ })
const canvasEntityPersistConfig: PersistConfig<StateWithHistory<CanvasEntityState>, CanvasEntityState> = {
migrate: (state) => zConsolidatedCanvasState.parse(state),
serialize: (state) => ({
_version: state._version,
activeCanvasId: state.activeCanvasId,
canvases: Object.fromEntries(
Object.entries(state.canvases).map(([id, instance]) => [
id,
{
...instance,
canvas: unwrapHistory(instance.canvas)
params: omit(instance.params, ['sensitiveKey']) // key-specific denylist
staging: undefined, // don't persist staging (can't remember if this is needed in the actual codebase but you get the idea)
}
])
),
}),
deserialize: (state, initialState) => ({
_version: state._version,
activeCanvasId: state.activeCanvasId,
canvases: Object.fromEntries(
Object.entries(state.canvases).map(([id, instance]) => [
id,
{
...instance,
canvas: wrapHistory(instance.canvas),
params: merge(instance.params, defaults), // inject defaults
staging: getInitialStagingState(id), // re-initialize staging
}
])
),
}),
} In components, to select state, so long as we use export const selectCanvasInstance = createSelector(
[
(state: RootState) => state.canvas.canvases,
(state: RootState, canvasId: string) => canvasId
],
(canvases, canvasId) => canvases[canvasId]
);
export const selectCanvasSettings = createSelector(
[selectCanvasInstance],
(instance) => instance.settings
);
// because this is composed w/ the other selectors, it will have a second arg for canvasId
export const selectBrushWidth = createSelector(
[selectCanvasSettings],
(settings) => settings.brushWidth
);
// in the component
const canvasId = useCanvasIdContextRequired();
const brushWidth = useAppSelector((state) => selectBrushWidth(state, canvasId)); |
ced928f
to
4e622f0
Compare
@psychedelicious
|
No worries, I understand. Thinking about migrations... Currently, they are exclusively intra-slice, which means we have to jump through hoops to do inter-slice migration (as you found out). I'm not sure if those hoops are worth it 🤔. A redux action that we dispatch exactly once just doesn't feel quite right. I think for now, we should set aside data migration. Once we have everything working correctly, we can figure out a pattern for inter-slice migration.
Maybe I am misunderstanding, but it looks like the "Dispatch to the active canvas" will be the typical case, while "dispatch to some other canvas" will be the special case. Suggest to prioritize the DX for the typical case over the special case; docstrings can clarify the behaviour. For the special case, the proposed
That's unexpected. It worked for me when I experimented with this pattern a while ago. My understanding of RTK is that PS - Great work on this. Its gonna be a huge improvement to the app! |
Summary
A new feature was implemented to enable multiple canvases in the canvas editor.
SliceConfig
was updated intypes.js
to handle partially undoable slices:TInternalState
andTSerializedState
, were added to strongly type states used in Redux and the persisted into storagewrapState
andunwrapState
, were added to themigrate
field for wrapping/unwrapping state during serialization/deserializationundoableConfig
was deleted, asreduxUndoOptions
is used only in slices, so this field became redundantstore.ts
was refactored to use the updatedSliceConfig
zStateWithHistory
,zCanvasStateWithHistory
,zCanvasesState
,zCanvasesStateWithHistory
andzCanvasesStateWithoutHistory
, were created intypes.ts
to represent types in the partially undoable canvases sliceselectCanvases
selectSelectedCanvasWithHistory
andselectSelectedCanvas
, were added toselectors.ts
to isolate changes due to refactoring in the canvas slice from componentscanvasSlice
was split into two parts representing thecanvases
slice without history and thecanvas
with undoable historyundoableCanvasesReducer
higher order reducer was created to combinecanvases
andcanvas
reducersRTK
was updated to version2.9.0
Related Issues / Discussions
Closes #8380
Checklist
What's New
copy (if doing a release after this PR)