Skip to main content

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:
    1. User A adds Block 1
    2. User B (hasn't received A's update) adds Block 2
    3. 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 selection
  • insertNodes(view, nodes): Insert multiple nodes at current selection
  • insertAtDocStart(view, node): Insert node at document start
  • insertAtDocEnd(view, node): Insert node at document end
  • insertSelectedBeforeNode(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