Card Sort Mode
Follows our Code Organization Principles for maintainable and testable code.
Current Issue
- The current implementation replaces the entire card list state on each update
- When multiple users make concurrent edits, updates can be lost
- Example scenario:
- User A adds Block 1
- User B (hasn't received A's update) adds Block 2
- Last update wins, causing either Block 1 or Block 2 to be lost
Root Cause
- Using a replace-whole-state approach instead of incremental updates
- Not properly utilizing Yjs's CRDT (Conflict-free Replicated Data Type) capabilities
- Current code violates immutability principle:
// ❌ Current implementation (mutates state directly)
updateCardsList = (sections: CardSection[]) => {
this.doc.transact(() => {
this.cardState?.set("list", sections); // Replaces entire state
});
};
Solution: Hidden ProseMirror as Single Source of Truth
The following code examples are for illustration purposes, demonstrating how to apply our code organization principles.
1. Separate Actions from Calculations
Pure Calculations
// ✅ Pure functions for data transformation
const transformToCardView = (proseMirrorState: PMState): CardView => {
return {
sections: extractSections(proseMirrorState),
};
};
const extractSections = (state: PMState): Section[] => {
// Pure calculation to extract sections
return state.doc.content.filter(isSection).map(toSectionData);
};
Actions (Side Effects)
// ✅ Clear separation of side effects
const useCardSortMode = () => {
// State management
const [cardView, setCardView] = useState<CardView>();
// Actions
const updateSection = (sectionId: string, data: SectionData) => {
proseMirror.current?.transact(() => {
const tr = createUpdateTransaction(sectionId, data);
proseMirror.current?.dispatch(tr);
});
};
// Side effect for syncing views
useEffect(() => {
const unsubscribe = proseMirror.current?.on("update", () => {
const newView = transformToCardView(proseMirror.current.state);
setCardView(newView);
});
return unsubscribe;
}, []);
return { cardView, updateSection };
};
2. Data Structure
// ✅ Immutable data structures
interface CardView {
readonly sections: ReadonlyArray<Section>;
}
interface Section {
readonly id: string;
readonly cards: ReadonlyArray<Card>;
}
3. Unidirectional Data Flow
┌─────────────────┐
│ ProseMirror │◄────────────┐
│ (Hidden) │ │
└────────┬────────┘ │
│ │
│ Pure │ Actions
│ Calculations │ (Side Effects)
▼ │
┌─────────────────┐ │
│ Cards View │─────────────┘
└─────────────────┘
4. Implementation Details
Hook Abstraction
// ✅ Complex logic hidden behind a simple interface
const useCardSort = () => {
const { cardView, updateSection, updateCard } = useCardSortMode();
const { isDragging, onDragStart, onDragEnd } = useDragAndDrop();
return {
// Public interface
view: cardView,
actions: {
updateSection,
updateCard,
onDragStart,
onDragEnd,
},
state: {
isDragging,
},
};
};
Using ProseMirror Node Insertion
The useInsertNode
module provides utility functions for inserting nodes in ProseMirror:
// Import the functions
import { insertNode, insertNodes, insertAtDocStart, insertAtDocEnd, insertSelectedBeforeNode } from '../hooks/useInsertNode';
// Example usage in a component
const CardEditor = ({ view }: { view: EditorView }) => {
const handleAddCard = () => {
// Insert at current selection
insertNode(view, cardNode);
};
const handleAddMultipleCards = () => {
// Insert multiple nodes at once
insertNodes(view, [cardNode1, cardNode2]);
};
const handleAddToStart = () => {
// Insert at document start
insertAtDocStart(view, cardNode);
};
const handleAddToEnd = () => {
// Insert at document end
insertAtDocEnd(view, cardNode);
};
const handleMoveCard = () => {
// Get the position of selected card
// Implement here ----
const startPos = ...
const endPos = ...
// Get the start position of the target card
// Implement here ---
const replacedPos = ...
// Insert the selected card to a position before the target card
insertSelectedBeforeNode(view, startPos, endPos, replacedPos);
}
};
Available Functions:
insertNode(view, node)
: Insert a single node at current selectioninsertNodes(view, nodes)
: Insert multiple nodes at current selectioninsertAtDocStart(view, node)
: Insert node at document startinsertAtDocEnd(view, node)
: Insert node at document endinsertSelectedBeforeNode(view, startPos, endPos, replacedPos)
: Replace the node from startPos to endPos to a position before targetPos
Each function handles the transaction creation and dispatch automatically, making it simple to modify the document state.
Testing Strategy
// ✅ Pure functions are easily testable
describe("transformToCardView", () => {
it("should correctly transform ProseMirror state to card view", () => {
const pmState = createTestPMState([
/* test data */
]);
const result = transformToCardView(pmState);
expect(result.sections).toHaveLength(2);
});
});
// ✅ Actions can be tested with mocks
describe("useCardSort", () => {
it("should update view when ProseMirror state changes", () => {
const { result } = renderHook(() => useCardSort());
act(() => {
mockProseMirrorUpdate(/* test data */);
});
expect(result.current.view.sections).toHaveLength(2);
});
});
Benefits
- ✅ No lost updates during concurrent editing
- ✅ Automatic conflict resolution through CRDT
- ✅ Better performance through granular updates
- ✅ Highly testable through pure functions
- ✅ Predictable state management
- ✅ Clear separation of concerns
- ✅ Self-documenting code structure
Technical Considerations
- Memory usage (running both views simultaneously)
- Initial setup complexity
- Need to maintain proper event disposal
- Ensure proper cleanup when switching modes