multi-modal gui text editor for spatially drafting documents
  • JavaScript 86.2%
  • CSS 10.6%
  • HTML 3.1%
Find a file
2026-05-05 11:54:51 -04:00
src refactor: major first step toward refactor 2026-05-05 11:54:51 -04:00
tests refactor: major first step toward refactor 2026-05-05 11:54:51 -04:00
.dockerignore refactor: major first step toward refactor 2026-05-05 11:54:51 -04:00
app.js refactor: major first step toward refactor 2026-05-05 11:54:51 -04:00
compose.yml refactor: major first step toward refactor 2026-05-05 11:54:51 -04:00
Dockerfile refactor: major first step toward refactor 2026-05-05 11:54:51 -04:00
index.html refactor: major first step toward refactor 2026-05-05 11:54:51 -04:00
package.json refactor: major first step toward refactor 2026-05-05 11:54:51 -04:00
README.md refactor: major first step toward refactor 2026-05-05 11:54:51 -04:00
styles.css refactor: major first step toward refactor 2026-05-05 11:54:51 -04:00

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+Enter or click Break to split the selected box at the cursor.
  • Press Shift+Cmd+Enter to 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 Shift and click boxes to toggle them in the selection.
  • Hold Shift and drag to select boxes with a rectangle.
  • Hold Shift+Cmd and drag to add boxes with a selection rectangle.
  • Hold Shift+Option and click a box to select its entire document chain.
  • Hold Cmd and drag a selected box to move the selection together.
  • Hold Alt and drag, or drag the canvas background, to pan.
  • Hold Shift while 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+Enter in the annotation editor to save.
  • Press Escape in 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 into source.markdown, not into rendered boxes.
  • editorState: editor metadata such as section boundaries and section positions. Sections store { id, boundaryOffset, x, y }, where boundaryOffset is the designated newline character that ends the section; the final section uses null.

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.