Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6d2ffe9
Revert "revert draft overlay to one-shot handle descriptors"
paulsonnentag Jul 22, 2026
9bc7376
Revert "drop leftover scopeReplaced handling from the reverted overla…
paulsonnentag Jul 22, 2026
10894a4
update lockfile for patchwork-providers 0.3.0 in drafts
paulsonnentag Jul 22, 2026
082aa57
scrub history without remounting: stream checkpoint pins through desc…
paulsonnentag Jul 23, 2026
f462992
reset editor undo stacks when a scope swap re-points the doc handle
paulsonnentag Jul 23, 2026
9dfbfc8
tweak draft scrubber: black marker on top, drag-to-top returns to latest
paulsonnentag Jul 24, 2026
04fc7c8
cache draft timeline groups: chunked background fill into per-draft c…
paulsonnentag Jul 24, 2026
0f7b5e6
add diff eye toggle: checkpoint baselines become the sole source of d…
paulsonnentag Jul 24, 2026
7bdb809
add draggable diff baseline: a second scrubber handle anchors the dif…
paulsonnentag Jul 24, 2026
9b1b22b
bump drafts to 0.1.2 so the preview picks up the new package build
paulsonnentag Jul 24, 2026
cc523f3
add author attribution to draft timelines: map actor ids to contact a…
paulsonnentag Jul 27, 2026
5a82fa3
rework draft actions into a per-card menu: fork, fork from version, m…
paulsonnentag Jul 27, 2026
317c298
sticker diff counts follow the baseline: sum per-member range diffs w…
paulsonnentag Jul 28, 2026
b296b06
merged drafts hand their children up to the merge target; new forks g…
paulsonnentag Jul 28, 2026
63ca71c
bring back the version footer in the drafts sidebar so deployed build…
paulsonnentag Jul 28, 2026
72120bb
merge origin/main: adopt slim automerge imports across branch-only fi…
paulsonnentag Jul 28, 2026
22f7bdb
editor read-only follows the handle across backing swaps: the readOnl…
paulsonnentag Jul 28, 2026
0bdc8d4
tldraw read-only follows the handle across backing swaps: track isRea…
paulsonnentag Jul 28, 2026
ceb5a79
drafts are deep-linkable via a draft= hash param
paulsonnentag Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codemirror-base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"dependencies": {
"@automerge/automerge-codemirror": "^0.2.0",
"@codemirror/commands": "^6.9.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.4",
"@inkandswitch/patchwork-bootloader": "^0.2.8",
Expand Down
16 changes: 15 additions & 1 deletion codemirror-base/src/lib/codemirror.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createReadOnlyExtension,
createDecorationsExtension,
createDiffExtension,
createHistoryExtension,
createScrollHighlightIntoViewExtension,
} from "./extensions";

Expand Down Expand Up @@ -39,6 +40,8 @@ type CodeMirrorProps<T> = {
// When the returned range changes, the editor scrolls it into view -- unless
// it's already visible. Used to follow focus driven by other views.
scrollTarget?: () => readonly [number, number] | null;
// Forces the editor read-only. A heads-pinned handle makes it read-only
// regardless (tracked internally), so this is only needed as an override.
readOnly?: boolean;
withView?(view: EditorView): void;
};
Expand All @@ -54,7 +57,16 @@ export function CodeMirror<T>(props: CodeMirrorProps<T>) {
);

const [readOnlyExtension, createEffectReconfigureReadOnly] =
createReadOnlyExtension(() => !!props.readOnly);
createReadOnlyExtension(
() => props.handle,
() => !!props.readOnly
);

// Undo/redo lives here (not in tool-supplied extensions) so the stack can
// be reset when the handle's backing is swapped in place -- see history.ts.
const [historyExtension, createEffectResetHistory] = createHistoryExtension(
() => props.handle
);

const [decorationsExtension, createEffectReconfigureDecorations] =
createDecorationsExtension(() => props.decorations?.());
Expand Down Expand Up @@ -90,6 +102,7 @@ export function CodeMirror<T>(props: CodeMirrorProps<T>) {
// syncExtension must come before diffExtension so diff stays in sync with edits.
syncExtension,
diffExtension,
historyExtension,
scrollHighlightIntoViewExtension,
userExtensionsCompartment.of(props.extensions || []),
readOnlyExtension,
Expand All @@ -111,6 +124,7 @@ export function CodeMirror<T>(props: CodeMirrorProps<T>) {
createEffectReconfigureReadOnly(view);
createEffectReconfigureDecorations?.(view);
createEffectReconfigureDiff(view);
createEffectResetHistory(view);
createEffectScrollHighlightIntoView(view);

// Reconfigure user extensions when props.extensions changes
Expand Down
57 changes: 43 additions & 14 deletions codemirror-base/src/lib/extensions/automergeSync.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { createEffect } from "solid-js";
import { createEffect, onCleanup } from "solid-js";

/** CodeMirror */
import { EditorView } from "@codemirror/view";
import { Compartment } from "@codemirror/state";
import { Compartment, Transaction } from "@codemirror/state";

/** Automerge */
import type { Prop as AutomergeProp } from "@automerge/automerge/slim";
import { automergeSyncPlugin } from "@automerge/automerge-codemirror";
import type { DocHandle } from "@automerge/automerge-repo/slim";
import type {
DocHandle,
DocHandleChangePayload,
} from "@automerge/automerge-repo/slim";

/**
* Create a CodeMirror extension for synchronizing with an Automerge document using a CodeMirror
Compartment.
* @param handle The Automerge document handle.
* @param path The path to the specific document property to synchronize.
* @returns A tuple containing the extension and a function to create an effect for reconfiguring the extension when the handle or path change.
* @returns A tuple containing the extension and a function to create an effect for reconfiguring the extension when the handle or path change (or the handle's backing is swapped in place).
*/
export function createSyncExtension<T>(
handle: () => DocHandle<T>,
Expand All @@ -35,18 +38,44 @@ export function createSyncExtension<T>(
// instance is destroyed without seeing this transaction, so the full-doc
// reset below is not echoed back into the automerge doc) and the fresh
// plugin re-seeds its reconciled heads from the current doc.
//
// The reset is a synthetic remote transaction, not a user edit: it must not
// land on the undo stack (undoing it would restore a different timeline's
// text and write it back into the doc). Marking it also makes the history
// reset on `scopeReplaced` (see `createHistoryExtension`) order-independent
// across the two `change` listeners.
const createReconfigureEffect = (view: EditorView) =>
createEffect(() => {
// The `handle()`/`path()` reads inside `syncExtension` are tracked, so a
// reactive prop change re-runs this and rebuilds the plugin.
view.dispatch({
effects: sync.reconfigure(syncExtension()),
changes: {
from: 0,
to: view.state.doc.length,
insert: initialDoc(),
},
});
const rebuild = () => {
view.dispatch({
effects: sync.reconfigure(syncExtension()),
changes: {
from: 0,
to: view.state.doc.length,
insert: initialDoc(),
},
annotations: [
Transaction.addToHistory.of(false),
Transaction.remote.of(true),
],
});
};
// Runs inside the effect, so the `handle()`/`path()` reads are tracked
// and a reactive prop change re-runs this whole wiring.
rebuild();

// The draft overlay can re-point a live handle at a different clone
// without the handle identity changing (a `change` event with
// `scopeReplaced: true`). The sync plugin diffs incrementally from its
// reconciled heads, which don't exist in the new backing's history — so
// rebuild the plugin (and the editor content) from the swapped-in doc.
const h = handle();
if (!h) return;
const onChange = (payload: DocHandleChangePayload<T>) => {
if (payload.scopeReplaced) rebuild();
};
h.on("change", onChange);
onCleanup(() => h.off("change", onChange));
});

return [sync.of(syncExtension()), createReconfigureEffect] as const;
Expand Down
16 changes: 15 additions & 1 deletion codemirror-base/src/lib/extensions/commentUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ const buttonTooltip = (
options: CommentUIOptions
): Tooltip | null => {
const sel = state.selection.main;
if (sel.empty) return null;
// A read-only editor can't take the comment write, so don't offer the button.
if (sel.empty || state.readOnly) return null;

// Anchor the button at the drag tail (the selection head) rather than the
// start, so dragging left-to-right lands it on the right and right-to-left
Expand Down Expand Up @@ -203,6 +204,15 @@ const commentUIField = (options: CommentUIOptions, ui: CommentUIState) =>
let buttonDismissed = value.buttonDismissed;
let popoverChanged = false;

// Read-only can flip mid-session: entering it closes any open popover —
// its reply box writes to a doc that rejects writes — and the flip
// itself must recompute the tooltip so the button hides/re-arms.
const readOnlyChanged = tr.startState.readOnly !== tr.state.readOnly;
if (readOnlyChanged && tr.state.readOnly && popover) {
popover = null;
popoverChanged = true;
}

// Keep the popover pinned to its text as the document shifts under it.
if (popover && tr.docChanged) {
popover = { ...popover, anchor: tr.changes.mapPos(popover.anchor) };
Expand Down Expand Up @@ -248,6 +258,7 @@ const commentUIField = (options: CommentUIOptions, ui: CommentUIState) =>
!popoverChanged &&
!tr.docChanged &&
!tr.selection &&
!readOnlyChanged &&
buttonDismissed === value.buttonDismissed
) {
return value;
Expand Down Expand Up @@ -283,6 +294,9 @@ const commentInteractions = (
if ((event.target as HTMLElement | null)?.closest("." + POPOVER_CLASS)) {
return false;
}
// Read-only: don't open threads either — the popover's reply box would
// write into a doc that rejects writes.
if (view.state.readOnly) return false;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
if (pos == null) return false;
const hit = options.threadAtPos(pos);
Expand Down
52 changes: 52 additions & 0 deletions codemirror-base/src/lib/extensions/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createEffect, onCleanup } from "solid-js";

/** CodeMirror */
import { EditorView, keymap } from "@codemirror/view";
import { Compartment } from "@codemirror/state";
import { history, historyKeymap } from "@codemirror/commands";

/** Automerge */
import type {
DocHandle,
DocHandleChangePayload,
} from "@automerge/automerge-repo/slim";

/**
* Undo/redo history, owned by the base editor. Tools must not add their own
* `history()`: the underlying history state field is a module-level singleton
* in `@codemirror/commands`, so a second copy elsewhere in the configuration
* would keep the field alive across our reset and defeat it.
*
* The stack is reset whenever the handle's backing is swapped in place (a
* `change` event with `scopeReplaced: true` — e.g. scrubbing history or
* switching drafts re-points the handle at a different clone): the old
* entries describe a different timeline, and undo must only revert what the
* user has done since. CodeMirror has no clear-history API; the sanctioned
* reset is cycling the extension out of and back into the configuration,
* which drops the history field's state and re-creates it empty.
*/
export function createHistoryExtension<T>(handle: () => DocHandle<T>) {
const compartment = new Compartment();
const historyExtension = () => [history(), keymap.of(historyKeymap)];

const createResetEffect = (view: EditorView) =>
createEffect(() => {
const h = handle();
if (!h) return;
const onChange = (payload: DocHandleChangePayload<T>) => {
if (!payload.scopeReplaced) return;
// Two dispatches: the first removes the history field (dropping its
// state), the second re-adds it empty. The sync extension's rebuild
// for the same event is dispatched with `addToHistory: false`, so the
// relative order of the two `change` listeners doesn't matter.
view.dispatch({ effects: compartment.reconfigure([]) });
view.dispatch({
effects: compartment.reconfigure(historyExtension()),
});
};
h.on("change", onChange);
onCleanup(() => h.off("change", onChange));
});

return [compartment.of(historyExtension()), createResetEffect] as const;
}
1 change: 1 addition & 0 deletions codemirror-base/src/lib/extensions/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { createDecorationsExtension } from "./decorations";
export { createReadOnlyExtension } from "./readOnly";
export { createSyncExtension } from "./automergeSync";
export { createHistoryExtension } from "./history";
export { createDiffExtension } from "./diff";
export { createScrollHighlightIntoViewExtension } from "./scrollHighlightIntoView";
55 changes: 45 additions & 10 deletions codemirror-base/src/lib/extensions/readOnly.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,66 @@
import { createEffect } from "solid-js";
import { createEffect, createSignal, onCleanup } from "solid-js";

/** CodeMirror */
import { EditorView } from "@codemirror/view";
import { Compartment, EditorState } from "@codemirror/state";

/** Automerge */
import type {
DocHandle,
DocHandleChangePayload,
} from "@automerge/automerge-repo/slim";

/**
* Create a CodeMirror extension for controlling read-only mode using a CodeMirror Compartment.
* @param readOnly Whether the editor should be read-only.
* @returns A tuple containing the extension and a function to create an effect for reconfiguring
the extension when the readOnly prop changes.
* Create a CodeMirror extension that makes the editor read-only whenever the
* handle is (`handle.isReadOnly()`, e.g. a heads-pinned view) or the `force`
* override says so.
*
* The handle's read-only state can flip in place: its backing may be swapped
* without the handle identity changing (a `change` event with
* `scopeReplaced: true`), so it is tracked as a signal re-read on every swap
* rather than sampled once.
*
* @param handle The Automerge document handle.
* @param force Forces read-only regardless of the handle's state.
* @returns A tuple containing the extension and a function to create the
* effects that track the handle and reconfigure the extension.
*/
export function createReadOnlyExtension(readOnly: () => boolean) {
export function createReadOnlyExtension<T>(
handle: () => DocHandle<T> | undefined,
force: () => boolean = () => false
) {
const readOnlyCompartment = new Compartment();

// Function to get the desired state of the read-only extensions based on the readOnly parameter
// Seeded at factory time so the initial EditorState is already correct when
// the editor mounts on a read-only handle.
const [handleReadOnly, setHandleReadOnly] = createSignal(
!!handle()?.isReadOnly()
);

const readOnlyExtensions = () =>
readOnly()
force() || handleReadOnly()
? [EditorState.readOnly.of(true), EditorView.editable.of(false)]
: [];

// Function to create an effect that reconfigures the read-only compartment
const createReconfigureEffect = (view: EditorView) =>
const createReconfigureEffect = (view: EditorView) => {
// Follow the handle's read-only state across backing swaps.
createEffect(() => {
const h = handle();
if (!h) return;
setHandleReadOnly(h.isReadOnly());
const onChange = (payload: DocHandleChangePayload<T>) => {
if (payload.scopeReplaced) setHandleReadOnly(h.isReadOnly());
};
h.on("change", onChange);
onCleanup(() => h.off("change", onChange));
});

createEffect(() => {
view.dispatch({
effects: readOnlyCompartment.reconfigure(readOnlyExtensions()),
});
});
};

return [
readOnlyCompartment.of(readOnlyExtensions()),
Expand Down
3 changes: 0 additions & 3 deletions codemirror-base/src/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@ type Baseline = { heads: UrlHeads | null };
const PATH = ["content"];

export function CodeMirrorEditor(props: PatchworkToolProps<TextDoc>) {
const isReadOnly = props.handle.isReadOnly();

// Diff baseline from the active draft overlay; plain JSON `{ heads }`. Fed to
// the CodeMirror diff extension, which recomputes spans on every doc change
// (driven by the sync plugin's transactions, so no manual tick is needed).
Expand Down Expand Up @@ -269,7 +267,6 @@ export function CodeMirrorEditor(props: PatchworkToolProps<TextDoc>) {
decorations={decorations}
baseline={() => baseline()?.heads ?? null}
extensions={extensions()}
readOnly={isReadOnly}
onChangeSelection={onChangeSelection}
scrollTarget={scrollTarget}
/>
Expand Down
14 changes: 6 additions & 8 deletions codemirror-markdown/src/extensions/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
/** CodeMirror Extensions */
import { completionKeymap } from "@codemirror/autocomplete";
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { foldKeymap, indentOnInput, indentUnit } from "@codemirror/language";
import { languages } from "@codemirror/language-data";
Expand All @@ -15,15 +10,18 @@ import { EditorView, keymap } from "@codemirror/view";
/** Styles */
import { theme } from "../themes/markdown.ts";

// Undo/redo (`history()`/`historyKeymap`) is deliberately absent: the base
// editor (codemirror-base) owns it, so it can reset the stack when a scope
// swap re-points the doc handle (see codemirror-base's history extension).
// Adding a second `history()` here would keep the underlying singleton state
// field alive across that reset and break it.
export function markdownExtensions() {
return [
...theme("sans"),
history(),
indentOnInput(),
keymap.of([
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
Expand Down
1 change: 1 addition & 0 deletions contact/src/components/InlineContactAvatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function renderInlineContactAvatar(

const isRegistered = contact.type === "registered";
const name = isRegistered ? contact.name : "Anonymous";
avatar.title = name;

// avatar image
let avatarImgUrl: string | undefined;
Expand Down
4 changes: 2 additions & 2 deletions drafts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tiny-patchwork/drafts",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"private": true,
"main": "./dist/index.js",
Expand All @@ -15,7 +15,7 @@
"@automerge/automerge-repo": "^2.6.0-subduction.26",
"@inkandswitch/patchwork-elements": "1.0.0",
"@inkandswitch/patchwork-plugins": "^0.0.11",
"@inkandswitch/patchwork-providers": "0.2.2",
"@inkandswitch/patchwork-providers": "0.3.0",
"@inkandswitch/patchwork-providers-solid": "0.2.3",
"solid-automerge": "^2.0.0",
"solid-js": "^1.9.9"
Expand Down
Loading
Loading