- JavaScript 86.2%
- CSS 10.6%
- HTML 3.1%
| src | ||
| tests | ||
| .dockerignore | ||
| app.js | ||
| compose.yml | ||
| Dockerfile | ||
| index.html | ||
| package.json | ||
| README.md | ||
| styles.css | ||
Columns Canvas
A dependency-free browser proof of concept for breaking a markdown document into movable text boxes across a pannable 2D plane, while preserving a single linear markdown document underneath.
Run
Open index.html in a browser, or serve the folder with any static file server:
python3 -m http.server 8000
Then visit http://localhost:8000.
Containerized Development
The project can run and test without installing Node, npm, or browser test tools on the host machine. Use Docker Compose:
docker compose up web
Then visit http://localhost:8000.
Run the current unit test harness in the same containerized environment:
docker compose run --rm test
Controls
- Use the workspace picker under the app title to switch workspaces, or the + button beside it to create one.
- Use the bottom segmented control, or
Cmd+\, to switch between the 2D canvas and sequential document mode. - Use New or Upload in the sidebar to add documents.
- Rename, download, or delete each document from its row in the sidebar.
- Click in a text box to edit text normally.
- Press
Cmd+Enteror click Break to split the selected box at the cursor. - Press
Shift+Cmd+Enterto merge the active box into the previous one. - In document mode, the active document is shown as a linear top-to-bottom markdown view, with document navigation on the left and annotation cards in the right gutter.
- Hold
Shiftand click boxes to toggle them in the selection. - Hold
Shiftand drag to select boxes with a rectangle. - Hold
Shift+Cmdand drag to add boxes with a selection rectangle. - Hold
Shift+Optionand click a box to select its entire document chain. - Hold
Cmdand drag a selected box to move the selection together. - Hold
Altand drag, or drag the canvas background, to pan. - Hold
Shiftwhile scrolling to zoom. Trackpad pinch gestures also zoom where supported. - Use the floating zoom control's percentage button to return to 100%.
Annotations
Annotations are a separate data layer over the linear document. They are stored as character ranges plus annotation text, and are not included when downloading the markdown document.
- Select text in a box to show the floating annotation button.
- Click the annotation button to open an in-canvas editor for the selected range.
- Press
Cmd+Enterin the annotation editor to save. - Press
Escapein the annotation editor to cancel. - Hover annotated text for 0.2 seconds to show the annotation text.
- Click annotated text to reopen and edit the first annotation at that point.
- Overlapping annotations stack their highlight opacity. When multiple annotations overlap under the pointer, the hover card shows all of them in range-start order with matching numbered markers.
For now, each document has one annotation layer named Notes, but the saved data model uses annotationLayers and activeAnnotationLayerId so additional layers can be added later.
Workspace Data Model
Columns Canvas persists one JSON workspace index in localStorage under columns-canvas-workspaces. A workspace is the unit that should later map cleanly to a backend resource: it has a stable id, a human-readable name, a set of documents, the current editor view, and a long-lived undo/redo history.
Each document is stored as three explicit layers:
source: the backing vanilla markdown file, currently{ format: "markdown", markdown: "..." }.annotations: editor-owned annotation layers. Annotation ranges are character offsets intosource.markdown, not into rendered boxes.editorState: editor metadata such as section boundaries and section positions. Sections store{ id, boundaryOffset, x, y }, whereboundaryOffsetis the designated newline character that ends the section; the final section usesnull.
The persisted shape is intentionally API-friendly:
{
"schemaVersion": 1,
"activeWorkspaceId": "workspace-id",
"workspaces": [
{
"schemaVersion": 1,
"id": "workspace-id",
"name": "Default Workspace",
"view": {
"camera": { "x": 120, "y": 72 },
"zoom": 1,
"settings": { "columnWidthUnits": 11 },
"activeDocId": "document-id",
"selected": [{ "docId": "document-id", "sectionId": "section-id" }]
},
"documents": [
{
"id": "document-id",
"name": "example-doc.md",
"source": { "format": "markdown", "markdown": "# Title\n\nBody" },
"annotations": {
"activeLayerId": "layer-id",
"layers": [
{
"id": "layer-id",
"name": "Notes",
"color": "34, 107, 95",
"annotations": [
{ "id": "annotation-id", "start": 0, "end": 7, "text": "A note" }
]
}
]
},
"editorState": {
"sections": [{ "id": "section-id", "boundaryOffset": null, "x": 0, "y": 0 }]
}
}
],
"history": {
"undo": [{ "id": "entry-id", "kind": "snapshot", "group": "text:document-id:section-id", "label": "Typing", "createdAt": 1710000000000, "snapshot": "{...}" }],
"redo": []
}
}
]
}
At runtime the editor derives editable canvas sections from source.markdown plus editorState.sections. The markdown source remains the single text stream used for document mode and downloads. Text edits are grouped by document/section for a short interval so normal typing becomes a single undo step instead of one action per character.
Editing Behavior
Text edits update annotation ranges alongside the underlying markdown:
- Insertions before an annotation move the range forward.
- Insertions inside a range expand it.
- Deletions shrink, move, or remove affected ranges depending on what text remains.
- Splitting marks a real newline as a section boundary; merging removes only that boundary metadata.
- Annotations cannot be created across section boundaries.