Skip to content

Conversation

@BlaiseOfGlory
Copy link

Collaborative Notes Feature - Implementation Document

Branch: feature-collab-notes
Date: January 2026
Commits: 19 commits (f6d6e4e..c367ef3)


1. Features Implemented

1.1 Hierarchical Note Organization

  • Folders and Notes: Users can create a tree structure with folders (containers) and notes (leaf nodes with content)
  • Unlimited Nesting: Folders can contain other folders and notes at any depth
  • Position-based Ordering: Items maintain their order within a parent via a position field

1.2 Real-Time Collaborative Editing

  • Multi-user Editing: Multiple users can edit the same note simultaneously
  • Conflict-free Synchronization: Uses Yjs CRDT (Conflict-free Replicated Data Types) for merging concurrent edits
  • Live Cursors/Presence: Built on Hocuspocus WebSocket infrastructure (same as existing collab forms)

1.3 Rich Content Support

  • Multi-field Notes: Each note can contain multiple reorderable fields
  • Rich Text Fields: TipTap-based rich text editor with formatting support
  • Image Fields: Upload images via file picker or paste from clipboard
  • Drag-and-Drop Reordering: Fields within a note can be reordered via drag-and-drop

1.4 Tree Management

  • Drag-and-Drop Reorganization: Move notes/folders within the tree via drag-and-drop
  • Rename in Place: Double-click to rename items
  • Delete with Confirmation: Confirmation modal for deleting notes/folders (cascades to children)
  • Real-time Tree Sync: Tree structure changes broadcast to all connected clients

1.5 Export Functionality

  • ZIP Export: Download all notes as a ZIP file containing:
    • Markdown files (HTML converted to Markdown)
    • Associated images in companion folders
    • Folder structure preserved

2. Implementation Details

2.1 Database Models

ProjectCollabNote (ghostwriter/rolodex/models.py:757-837)

Fields:
- project (FK → Project)
- parent (FK → self, nullable for root items)
- title (CharField)
- node_type (enum: "folder" | "note")
- content (TextField, legacy field)
- position (PositiveIntegerField)
- created_at, updated_at (auto timestamps)

Constraints:
- folder_has_no_content: Folders must have empty content

ProjectCollabNoteField (ghostwriter/rolodex/models.py:847-927)

Fields:
- note (FK → ProjectCollabNote)
- field_type (enum: "rich_text" | "image")
- content (TextField for HTML)
- image (ImageField → collab_note_images/%Y/%m/%d/)
- image_width, image_height (auto-populated)
- position (PositiveIntegerField)
- created_at, updated_at (auto timestamps)

Constraints:
- field_type_matches_content: rich_text fields have no image, image fields must have image

2.2 Database Migrations

Migration Purpose
0060 Create ProjectCollabNote model
0061 Migrate existing notes (if any legacy data)
0062 Add timestamp defaults
0063 Create ProjectCollabNoteField model
0064 Migrate note content to fields
0065 Add defaults to timestamps

2.3 GraphQL/Hasura Layer

New Tables Exposed:

  • projectCollabNote - Full CRUD with row-level security
  • projectCollabNoteField - Full CRUD with row-level security

Permission Model:

Role Access
admin Full access to all records
manager Full access to all records
user Only projects they're assigned to, invited to, or client-invited to

Relationships:

  • projectCollabNote.fields → array of projectCollabNoteField
  • projectCollabNote.children → self-referential array
  • projectCollabNote.parent → self-referential object
  • project.collabNotes → array of root-level notes

2.4 Collab Server Handlers

project_collab_note.ts - Handles real-time note content editing

  • Loads note with all fields from GraphQL
  • Creates Yjs XmlFragment for each rich_text field (field_{id})
  • Stores field metadata in Yjs Map (meta.fields)
  • Saves all field content back to database via GraphQL mutation

project_tree_sync.ts - Handles tree structure synchronization

  • Minimal handler (no persistence)
  • Provides WebSocket room for broadcasting "tree-changed" messages
  • Clients refetch tree data on receiving notification

2.5 Frontend Components

Component Hierarchy:

ProjectCollabNotesContainer (index.tsx)
├── NoteTreeView
│   ├── SortableTreeItem (recursive)
│   │   └── TreeItem
│   ├── CreateModal
│   └── DeleteConfirmModal
└── NoteEditor
    ├── AddFieldToolbar
    ├── NoteFieldEditor (for each field)
    │   ├── TipTap Editor (rich_text)
    │   └── ImageField / ImageFieldPlaceholder (image)
    └── DeleteConfirmModal

Key Hooks:

  • useNoteTree - Fetches and transforms tree data from GraphQL
  • useNoteMutations - CRUD operations for notes/folders
  • useTreeDnd - Drag-and-drop logic for tree reorganization
  • useTreeSync - Real-time tree change notifications
  • useFieldMutations - CRUD operations for note fields
  • useImageUpload - Image upload handling (file picker + paste)

2.6 Django Views

Image Upload (views.py:2289-2369)

  • POST /rolodex/ajax/project/{pk}/collab-note/upload
  • Creates new image field and uploads file
  • Returns field ID and image URL

Upload to Existing Field (views.py:2372-2459)

  • POST /rolodex/ajax/project/{note_pk}/collab-note/field/{field_pk}/upload
  • Updates existing placeholder field with image

ZIP Export (views.py:2463-2545)

  • GET /rolodex/ajax/project/{pk}/notes/export
  • Generates ZIP with markdown files and images
  • Preserves folder hierarchy

3. Changes Affecting Other Application Parts

3.1 API Views (ghostwriter/api/views.py)

  • Added ProjectCollabNote to CheckEditPermissions.available_models
  • Added special case handling for project_tree_sync model type

3.2 Project Detail Page (ghostwriter/rolodex/templates/rolodex/project_detail.html)

  • Added new "Collab Notes" tab
  • Includes container div for React component mount point

3.3 URL Configuration (ghostwriter/rolodex/urls.py)

  • Added 3 new URL patterns for image upload and export

3.4 Docker Configuration (local.yml)

  • Made ports configurable via environment variables:
    • POSTGRES_HOST_PORT (default: 5432)
    • NGINX_PORT (default: 8000)
    • HASURA_GRAPHQL_HOST_PORT (default: 8080)

3.5 Hasura Metadata

  • Added public_rolodex_projectcollabnote.yaml
  • Added public_rolodex_projectcollabnotefield.yaml
  • Updated public_rolodex_project.yaml with collabNotes relationship
  • Updated tables.yaml to include new tables

4. Database Connection Dynamics

4.1 When User Opens Collab Notes Tab

  1. Initial Load (GraphQL)

    • React component mounts
    • useNoteTree hook fetches tree structure via Apollo Client
    • Single GraphQL query: projectCollabNote with nested children and fields
  2. WebSocket Connections Established

    • Tree Sync: Connects to project_tree_sync/{projectId} room
      • Lightweight - no data sync, only broadcasts
      • Used for "tree-changed" notifications
    • Note Editor (when note selected): Connects to project_collab_note/{noteId} room
      • Full Yjs document sync
      • Loads all field content into Yjs

4.2 During Editing Session

Real-time Content Editing:

User Types → TipTap Editor → Yjs XmlFragment → WebSocket → Other Clients
                                    ↓
                           Debounced (2s) Save
                                    ↓
                           GraphQL Mutation → PostgreSQL

Tree Structure Changes (Create/Delete/Rename/Move):

User Action → GraphQL Mutation → PostgreSQL
                    ↓
            notifyTreeChanged()
                    ↓
            WebSocket Broadcast → All Clients Refetch Tree

4.3 Connection Lifecycle

Event Database Impact
Tab opened 1 GraphQL query (tree fetch)
Note selected 1 GraphQL query (note + fields), WebSocket connection
Typing Debounced saves every 2 seconds (1 mutation per save)
Add field 1 mutation (insert) + WebSocket broadcast
Delete field 1 mutation (delete) + WebSocket broadcast
Reorder fields 1 mutation (bulk update) + WebSocket broadcast
Tree change 1 mutation + stateless WebSocket message to all clients
Tab closed WebSocket disconnections, final save if pending

4.4 Concurrent User Handling

  • Content Conflicts: Resolved by Yjs CRDT - changes merge automatically
  • Tree Conflicts: Last-write-wins at database level; all clients refetch on any change
  • Field Order Conflicts: Last-write-wins; all clients see same order after refetch

5. Potential Next Steps

5.1 Feature Enhancements

  • Search: Full-text search across note content
  • Tagging: Add tags to notes for categorization
  • Templates: Pre-defined note templates (e.g., meeting notes, findings)
  • Version History: Track changes with ability to revert
  • Comments: Add inline comments or annotations
  • Linking: Link between notes or to other Ghostwriter entities

5.2 Performance Optimizations

  • Lazy Loading: Load note content only when selected (currently loads all)
  • Pagination: For projects with many notes
  • Caching: Client-side caching of tree structure
  • Optimistic Updates: Show changes before server confirmation

5.3 UX Improvements

  • Keyboard Shortcuts: Navigate and edit without mouse
  • Breadcrumb Navigation: Show path to current note
  • Expand/Collapse All: Bulk tree operations
  • Drag Preview: Better visual feedback during drag-and-drop
  • Mobile Support: Responsive design for smaller screens

5.4 Integration

  • Report Integration: Reference notes in reports
  • Evidence Linking: Link evidence files to notes
  • Activity Feed: Show note activity in project dashboard
  • Notifications: Alert users when shared notes are modified

5.5 Technical Debt

  • Legacy Content Migration: Remove content field from ProjectCollabNote after data migration verified
  • Test Coverage: Add unit and integration tests for new components
  • Error Handling: Improve error messages and recovery flows
  • Accessibility: ARIA labels and keyboard navigation improvements

6. File Summary

New Files (30)

ghostwriter/rolodex/migrations/0060-0065 (6 files)
hasura-docker/metadata/.../public_rolodex_projectcollabnote.yaml
hasura-docker/metadata/.../public_rolodex_projectcollabnotefield.yaml
javascript/src/collab_server/handlers/project_collab_note.ts
javascript/src/collab_server/handlers/project_tree_sync.ts
javascript/src/frontend/collab_forms/forms/project_collabnotes/*.tsx (12 files)
javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/*.ts (6 files)
javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css
javascript/src/frontend/collab_forms/forms/project_collabnotes/types.ts

Modified Files (8)

ghostwriter/api/views.py
ghostwriter/rolodex/models.py
ghostwriter/rolodex/views.py
ghostwriter/rolodex/urls.py
ghostwriter/rolodex/templates/rolodex/project_detail.html
hasura-docker/metadata/.../public_rolodex_project.yaml
hasura-docker/metadata/.../tables.yaml
local.yml

Replace the single collab_note TextField with a folder/note tree structure.
Notes support collaborative real-time editing via Hocuspocus, folders act
as containers. Includes Django model with self-referential FK for hierarchy,
Hasura metadata with role-based permissions, frontend tree view with
create/rename/delete operations, and data migration for existing notes.

Fixes null handling for Hasura bigint fields by using separate queries
for root vs child nodes, and adds database-level timestamp defaults for
GraphQL inserts.
Use environment variables for host ports in local.yml to avoid
conflicts when running multiple instances or on systems where
default ports are already in use.
Implement @dnd-kit based drag-and-drop to allow users to reorder
notes and folders, and move items into folders. Includes visual
drop indicators and keyboard accessibility support.

- Add @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities deps
- Create useTreeDnd hook for DnD state and handlers
- Create SortableTreeItem wrapper component
- Update TreeItem with drag/drop visual states
- Wrap NoteTreeView with DndContext and SortableContext
- Add tree.css for drop indicator styles
- Use 1000-gap positions for better reordering room
Increased the nesting indentation from 16px to 20px per depth level to
provide clearer visual hierarchy in the collaborative notes tree.
Removed the px-2 Bootstrap class that was overriding inline padding
styles and replaced it with explicit paddingRight inline style.
- Add ProjectCollabNoteField model for reorderable note content fields
- Support rich text and image field types within each note
- Add image upload endpoint for note field images
- Implement cascade delete for note fields when deleting notes
- Add immediate UI feedback for field add/delete operations
- Fix delete button not triggering in draggable field editor
- Add drag-and-drop reordering for fields within notes
- Include migrations for new field model and data migration
Remove CSS transforms from SortableTreeItem since verticalListSortingStrategy
causes visual scrambling in hierarchical trees. The DragOverlay now handles
the visual preview while items remain stationary.
Both handleAddImage and paste handler were missing the code to update
local state and sync to Yjs after image upload. Added setFields and
addFieldToYjsDoc calls to both handlers for immediate UI feedback.
The GraphQL query returns raw file paths from the database (e.g.,
collab_note_images/2026/01/19/file.png) without the /media/ prefix.
Prepend /media/ to image paths in the collab server handler so images
load correctly from Django's media URL.
Add missing fixed positioning properties to ReactModal overlay style
so the Create Folder/Note modal renders centered in viewport instead
of at the bottom of the page with buttons cut off.
When users create, delete, rename, or move notes/folders, other users
viewing the same project now see updates in real-time without needing
to refresh. Uses Hocuspocus stateless messages to broadcast tree change
notifications through the existing WebSocket infrastructure.
Prevents accidental data loss by requiring user confirmation before
deleting text fields or images from collaborative notes.
Observe the fields Y.Array directly instead of only the meta Y.Map,
so changes to the array contents (add/remove) trigger updates for
all connected clients viewing the same note.
Use Bootstrap CSS variables instead of hardcoded colors so the toolbar
adapts to light/dark mode automatically.
Instead of immediately opening the file picker when clicking "Add Image",
users now see a placeholder where they can paste, drag-drop, or click to
upload. This provides a better UX and enables real-time sync of the
placeholder to other collaborators before the image is uploaded.

- Add ImageFieldPlaceholder component with paste/drag-drop/click support
- Add createImageField GraphQL mutation for empty image fields
- Add uploadToField function to upload images to existing fields
- Add ajax_upload_to_existing_field Django endpoint
- Update ImageField to render placeholder when image is null
- Simplify AddFieldToolbar to just create placeholder on click
Replace browser confirm() dialog with DeleteConfirmModal for tree items.
The modal now supports notes, folders, text fields, and images with
appropriate messaging. Folders show a warning about deleting children.

Also fix recursive deletion of folders with children by fetching all
descendants and deleting them depth-first (children before parents)
to avoid foreign key constraint violations.
- Add real-time sync for field reordering via Yjs document
- Fix GraphQL reorder mutation to use dynamic update_by_pk calls
- Update tree item action icons: left-align, larger size, white on selection
- Add proper text truncation for long folder/note names
- Add download button in NoteTreeView toolbar to export all notes as zip
- Create export_collab_notes_zip view that generates zip with folder structure,
  markdown files (HTML converted via markdownify), and images
- Add debounce to Hocuspocus server for periodic content saves (5s)
- Fix save handler to read fields from Y.js meta instead of cached data,
  ensuring dynamically added fields are properly saved to database
Faster database persistence for better data durability during
collaborative editing sessions.
Resolve peer dependency markers and add optional dependencies.
Comment on lines +1383 to +1392
# Special case: project_tree_sync uses project_id as id
# and checks if user can access the project
if model == "project_tree_sync":
try:
project = Project.objects.get(id=self.input["id"])
except ObjectDoesNotExist:
return JsonResponse(utils.generate_hasura_error_payload("Not Found", "ModelDoesNotExist"), status=404)
if not project.user_can_edit(self.user_obj):
return JsonResponse(utils.generate_hasura_error_payload("Not allowed to edit", "Unauthorized"), status=403)
return JsonResponse(self.user_obj.username, status=200, safe=False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add user_can_edit method to ProjectSyncTree instead of special casing

Comment on lines +1 to +20
# Generated migration to add database defaults for timestamps

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("rolodex", "0064_migrate_note_content_to_fields"),
]

operations = [
migrations.RunSQL(
sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN created_at SET DEFAULT NOW();",
reverse_sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN created_at DROP DEFAULT;",
),
migrations.RunSQL(
sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN updated_at SET DEFAULT NOW();",
reverse_sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN updated_at DROP DEFAULT;",
),
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is raw sql needed here? Django should have an ORM option for this

return JsonResponse({"result": "error", "message": "Invalid request method"}, status=405)

# Import here to avoid circular imports
from ghostwriter.rolodex.models import ProjectCollabNote, ProjectCollabNoteField
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this circular?
(Applies to all API methods in this file that this PR adds)

except Exception as exception:
template = "An exception of type {0} occurred. Arguments:\n{1!r}"
log_message = template.format(type(exception).__name__, exception.args)
logger.error(log_message)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log.exception
(Applies to all API methods in this file that this PR adds)

Comment on lines +2307 to +2318
if 'image' not in request.FILES:
return JsonResponse({"result": "error", "message": "No image file provided"}, status=400)

image_file = request.FILES['image']

# Validate file type
allowed_types = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp']
if image_file.content_type not in allowed_types:
return JsonResponse(
{"result": "error", "message": f"Invalid file type: {image_file.content_type}. Allowed types: png, jpg, jpeg, gif, webp"},
status=400
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a django form and ImageField

Comment on lines +102 to +115
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
minHeight: "150px",
border: `2px dashed ${dragActive ? "var(--bs-primary)" : "var(--bs-border-color)"}`,
borderRadius: "8px",
backgroundColor: dragActive
? "var(--bs-primary-bg-subtle)"
: "var(--bs-tertiary-bg)",
cursor: uploading ? "wait" : "pointer",
transition: "all 0.2s ease",
outline: "none",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use css

id: noteId.toString(),
});

const [fields, setFields] = useState<NoteField[]>([]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be better to use observeDeep and use the yjs doc as the source of truth rather than trying to keep these two in sync

* When any client modifies the tree structure (create/delete/rename/move),
* it calls notifyTreeChanged() which broadcasts to all other connected clients,
* triggering their onTreeChanged callback to refetch the tree.
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Store the tree structure in yjs rather than constantly re-fetching. Even if it's just an array of note values that the frontend arranges into a tree as needed.

croniter==3.0.3
cvss==3.2
markdown==3.9
markdownify==0.13.1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need two markdown packages?

condition: service_started
ports:
- "${HASURA_GRAPHQL_SERVER_PORT}:8080"
- "${HASURA_GRAPHQL_HOST_PORT:-8080}:8080"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why these are changed

@ColonelThirtyTwo ColonelThirtyTwo changed the title Feature collab notes Per-Project Tree of Collab Notes Jan 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants