Architecture & Document Model

What this means: Before, each workform was its own little isolated editor — like having 24 separate Word documents open at once. TipTap replaces all of that with one single editor for the whole chapter, where every workform is a structured "block" inside it. Think of it like going from a pile of Post-it notes to a proper notebook with enforced sections. This is the foundation everything else is built on — once your content is a proper structured document, you can track changes, add comments, and save intelligently.
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

Edit / Save — Without Track Changes

What this means: This is the basic "just make it work" phase — a user opens a chapter, edits some text, and it saves. Before, every single keystroke could trigger a save API call, and the system had no idea what actually changed. Now, a dirty tracking plugin quietly watches which workforms were touched since the last save. When the autosave fires (after 1 second of inactivity), it only processes those workforms, compares them against what was loaded, and only sends an API call if something genuinely changed. It's like the difference between photographing your entire desk every time you move a pen, versus just noting "the pen moved."
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

Edit / Save — With Track Changes

What this means: Track changes is essentially a "show me what someone touched" mode. The golden rule is: the original document is never modified while changes are pending. Instead of actually deleting text, the system re-inserts it with a "deleted" tag so it stays visible but struck-through. Instead of just saving new text, additions get tagged with who typed them. Images and checkboxes store their previous value alongside the new one. When you save, the system is smart enough to strip away the "new" additions and keep only the originals — until a reviewer explicitly accepts or rejects the change. All the tracked changes live in their own separate MongoDB collection and get reconstructed into the editor when the page reloads.
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

Comments

What this means: Before, comment highlights were literal <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