fix(canvas): enforce read-only viewer mode at editor layer #27
No reviewers
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_collab!27
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/canvas-viewer-readonly-enforcement"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Closes #26.
Fixes the data-loss illusion for canvas viewers. The local Tiptap editor previously accepted toolbar-driven and keyboard-driven mutations even with
setEditable(false), so format changes appeared then vanished on refresh. Server-side gates were already correct — this PR is purely client-side.What changed
filterTransactionProseMirror plugin registered on viewers' editors. Rejects any doc-changing transaction not tagged withySyncPluginKey(the meta@tiptap/y-tiptapsets on remote Yjs sync transactions). This is the structural choke point — catches programmatic dispatch (editor.chain().toggleBold().run()), keyboard shortcuts (Ctrl+B), and any future command path.editable: !isViewerat editor construction (vs latesetEditable(false)) — eliminates a flash-of-editable window and pairs cleanly with the plugin.computeIsViewer(user, canvas)helper + module-scopeisViewerflag, hoisted so the filterTransaction plugin (registered after editor creation) closes over the resolved value. Computed beforeinitEditor()so the editor enters the right mode from the first render.body[data-canvas-mode="viewer"]hide the static top toolbar, share button, and the popovers/pickers as belt-and-suspenders against any errant selection event.bi-eyeicon, replacing the previous subtle "View only" sync-status text. The status text is now free to show real connection state for viewers.ySyncPluginKey(from@tiptap/y-tiptap) andPlugin(from@tiptap/pm/state). One-time./scripts/vendor-bundle/build.shregen included in the diff.Why filterTransaction (research note)
The naive fix —
editor.setEditable(false)+ hide buttons — doesn't work because Tiptap'seditableflag is a UI hint that gates contenteditable input but not programmatic transaction dispatch.editor.chain().toggleBold().run()callsview.dispatch(tr)which doesn't checkeditable. The bubble-menu onclick handlers go through this path, and so do keyboard shortcuts via the keymap plugin.filterTransactionis the canonical ProseMirror hook for blocking transactions before they apply to state. Plugins registered on the editor have theirfilterTransactioninvoked for every transaction; returning false drops it. Allowing transactions tagged withySyncPluginKeypreserves live updates from editors so viewers still see real-time collaboration.This is the same pattern Outline (the open-source Notion alternative) uses for read-only collaborative documents.
Dev-mode regression check
is_editor=is_devdefault in WS handler + RPC dev-mode bypass at canvas.rs:69)currentUser.id===0The two "behavior changes" for dev-mode picker-as-non-editor scenarios are bug-fixes, not regressions: the existing client-side intent at the previous
isViewerline said "Viewer if explicitly set as viewer, OR if no collaborator record" but was being defeated by the bubble menu's programmatic dispatch combined with the WS handler defaultingis_editor=is_devand the RPC dev-mode bypass.Test plan
viewer_test(canvas rolevieweron canvas owned by sameh): hard-refresh → see content, no toolbar, no bubble menu on selection, "View only" badge visible.viewer_test: try every formatting affordance you can find (color, bold via console / keyboard shortcuts) → no visible changes apply, OR appear momentarily then revert (depending on how filterTransaction synchronizes with the view).viewer_test: refresh → DBcanvas_stateandcanvases.updated_atunchanged.ySyncPluginKeyandPlugin(verified in served bytes).node --checkon canvas-app.js passes.canvas.save_stateRPC) verified earlier in #10 — still authoritative; this PR doesn't change them.Out of scope
window.editor.chain().toggleBold().run()from DevTools is blocked by the same filterTransaction plugin. Even if it weren't, the server still drops the resulting Yjs Update; refresh reverts. Honest UI for legitimate users; not a security boundary.loadChannelsauto-create-#generalfallback (chat-app.js:962) surfaced in the same investigation is a separate chat-sidebar concern; should get its own issue + PR.🤖 Generated with Claude Code