magazine editor · tiptap migration · all three phases
| Topic | ❌ Before (Old) | ✅ After (TipTap) |
|---|---|---|
| Editor scope | One separate editor instance per workform — each workform lived in its own isolated contentEditable div |
One single TipTap editor instance owns the entire chapter — all workforms live inside one document tree |
| Content model | Raw HTML strings stored per workform. No enforced structure — a user could theoretically break the layout by typing in the wrong place | Strictly typed ProseMirror node tree: doc → workformTable → workformRow → cells. Schema enforces what can go where |
| Workform rendering | React components rendered workforms by reading MongoDB data directly and producing JSX. Each workform had its own React render cycle | Each workform is a workformTable node. A React NodeView renders the toolbar; ProseMirror renders the rows inside via <NodeViewContent /> |
| Cell types | Various React components rendered different cell types. No unified concept — labels, inputs, images were all handled ad-hoc | Exactly 5 typed cell nodes: labelCell, contentCell, imageCell, checkboxCell, addRowButtonCell |
| Non-editable labels | contentEditable={false} set manually on label elements. Easy to forget, inconsistent enforcement |
labelCell has atom: true — ProseMirror treats it as an opaque unit. The cursor physically cannot enter it |
| Cross-workform cursor | Ctrl+A inside one workform could inadvertently select across workform boundaries or into adjacent editors | isolating: true on workformTable and contentCell — operations stay contained within the boundary |
| Adding new workform types | Required changes scattered across multiple React components, rendering logic, save logic, and workform type guards | Write one generator function + one registry entry in WORKFORM_GENERATORS. No other code changes needed |
| Undo / redo | Undo worked for text edits within a cell but NOT for structural operations like moving or deleting a workform | All operations (text edits, move, delete, reorder) are ProseMirror transactions — undo/redo works on everything automatically |
| Topic | ❌ Before | ✅ After |
|---|---|---|
| How content loads | MongoDB data was mapped directly to React component props. Each workform type had its own mapping logic in its component | MongoDB data → generator function → HTML string → parsed into ProseMirror nodes via parseHTML() rules on each node definition |
| Where workform type logic lives | Scattered across individual workform React components. Each component knew about its own fields | Centralized in WORKFORM_GENERATORS registry. One generator per workform type, all returning the same node structure |
| Saving trigger | onInput event on each cell → immediate updateWorkform() call. Every keystroke = potential API call |
Dirty tracking plugin flags changed workforms → 1-second debounce → only dirty workforms extracted and saved |
| Knowing what changed | No diffing — the entire workform content was re-sent on every save, even if only one character changed | hasChanges() diffs extracted data against the initial snapshot. API call only fires if something actually changed |
| Extracting field values | Each workform component read its own state directly. No unified extraction logic | extractWorkformData() walks the ProseMirror document tree, collecting field values from cell attributes and content |
| Simple vs. array fields | Handled differently in each component. Array items (Q&A pairs, answers) were managed separately from top-level fields | Unified extraction: simpleFields for top-level fields, arrayFields[arrayKey][itemId][fieldKey] for nested array items |
| Image / checkbox extraction | Image and checkbox state was read from component state or DOM attributes separately from text content | Same walk — imageCell.attrs.imageId and checkboxCell.attrs.isCorrect are extracted alongside text cells |
| Performance | Every save call walked all workforms or the entire chapter. Expensive on large chapters | Dirty plugin only flags touched workforms. Only those are walked and extracted at save time |
| Topic | ❌ Before | ✅ After |
|---|---|---|
| Tracking deletions | Deleted text was removed from the DOM. No way to show "what was here before" without custom undo history | Deleted text is re-inserted at the deletion point with a deletion mark. It stays in the document, visually struck-through |
| Tracking insertions | New text was indistinguishable from original text once typed. No annotation possible without baking spans into the HTML | New text receives an addition mark. ProseMirror renders it with a blue underline. The mark carries author + timestamp metadata |
| Intercepting keystrokes | Would have required overriding keydown handlers and manually managing DOM mutations — extremely fragile |
trackChangePlugin intercepts ProseMirror transactions via apply(). Replaces steps before they land. No DOM touching needed |
| Image / checkbox tracking | No mechanism to track "this image was swapped" or "this checkbox was toggled" — changes were invisible to reviewers | Node attributes: previousImageData / previousIsCorrect store originals. trackChange attr stores who changed it and when |
| Row-level tracking | Adding or deleting a Q&A row was not trackable — it either happened or it didn't | Added rows get trackChange: { type: "addition" } — virtual, data in MagazineTrackChange.afterValue. Deleted rows get { type: "deletion" } — stay in document, read-only |
| Workform-level tracking | Adding or deleting an entire workform was not auditable. No before/after state preserved | Addition tables are virtual. Deletion tables stay visible with a red overlay. Reorders use a Ghost + Target pattern with shared changeId |
| Saving with pending changes | Saving always wrote the current state to MongoDB — there was no concept of "pending" vs "accepted" | Save strips addition marks, unwraps deletion marks, saves previousIsCorrect/previousImageData for tracked nodes. Original is always preserved until accepted |
| Track change persistence | N/A — did not exist | Each tracked change saved as a MagazineTrackChange MongoDB document with changeId, level, positions, before/after values |
| Reloading tracked changes | N/A | applyMarkDataToEditor() reconstructs all pending marks on load. Deletions applied first (no position shift), then additions (which shift positions) |
| Database bloat prevention | N/A | CellSession pattern batches consecutive same-type edits in the same cell into one changeId. Orphan cleanup deletes stale records on save |
<span> tags baked into the cell's HTML — meaning if you edited text near a comment, you could accidentally destroy it, and the system needed a MutationObserver constantly watching the DOM to keep click listeners alive. Now, comments are completely separate from the document. They're "decorations" — visual overlays painted on top by a plugin, fetched from the server every 5 seconds. Clicking a highlight, capturing a selection, finding which workform a comment belongs to — all of this now reads directly from ProseMirror's internal state instead of scraping the DOM. The document content and the comment layer are fully independent, so one can never break the other.
| Topic | ❌ Before | ✅ After |
|---|---|---|
| How highlights are stored | data-comment / data-commentai spans baked into the cell HTML string in MongoDB. Editing the cell could destroy or misalign comments |
DecorationSet plugin renders highlights as overlays. They are NOT in the document — editing never destroys them |
| Click handling | MutationObserver watched the DOM and reattached click listeners after any DOM change. Fragile and expensive |
Plugin's handleClick checks if the click position falls inside any decoration range. No listeners to manage |
| Capturing a selection | window.getSelection() + DOM Range API + custom checkExistingSelection event + document.selectionchange listener |
Read editor.state.selection directly. One line of code, no events, no DOM API |
| Finding workform context from selection | Walk up the DOM tree reading data-workformid, data-workformkey attributes from ancestor elements |
$pos.node(depth).attrs — walk up the ProseMirror node tree. Always accurate, never stale |
| Comment position storage | Stored as character offsets relative to raw HTML — fragile if the HTML structure ever changed | Stored as offsets relative to the cell's content start (textFrom, textTo). Reconstructed by finding the cell node and adding offsets |
| Optimistic UI | pushLocalFeedback() — a custom function that manually injected comment spans into the DOM |
Dispatch updated feedback array to the plugin via setMeta. Plugin rebuilds the DecorationSet immediately |
| Active comment highlight | CSS class toggled manually on DOM nodes. Required finding and re-querying the DOM element | Dispatch { activeCommentId } to plugin. Plugin rebuilds decorations with the active one getting a different CSS class |
| Comment polling | Polling data was applied by re-rendering React components and re-injecting comment spans into HTML | updateCommentDecorations(editor, feedbacks) — one function call dispatches the new data into the plugin |