Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 12 additions & 2 deletions crates/knot-markdown/src/from_markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,8 @@ pub fn parse(src: &str) -> Result<(DocHandle, Vec<u8>), ParseError> {
let Some(parent) = stack.last() else {
continue;
};
parent.push_back(&mut txn, XmlElementPrelim::empty("hard_break"));
let target = inline_container(parent, &mut txn);
target.push_back(&mut txn, XmlElementPrelim::empty("hard_break"));
}
Event::Rule => {
// CommonMark horizontal rule. Always lives at the top level.
Expand Down Expand Up @@ -442,7 +443,8 @@ pub fn parse(src: &str) -> Result<(DocHandle, Vec<u8>), ParseError> {
// Outside blockquote: treat as a space.
if parent.tag().as_ref() != "code_block" {
let attrs = attrs_from_marks(&active_marks);
let text_ref = ensure_text_child(parent, &mut txn);
let target = inline_container(parent, &mut txn);
let text_ref = ensure_text_child(&target, &mut txn);
let pos = text_ref.len(&txn);
text_ref.insert_with_attributes(&mut txn, pos, " ", attrs);
}
Expand Down Expand Up @@ -551,6 +553,14 @@ fn ensure_para_child(parent: &XmlElementRef, txn: &mut yrs::TransactionMut) -> X
parent.push_back(txn, XmlElementPrelim::empty("paragraph"))
}

fn inline_container(parent: &XmlElementRef, txn: &mut yrs::TransactionMut) -> XmlElementRef {
if parent.tag().as_ref() == "list_item" {
ensure_para_child(parent, txn)
} else {
parent.clone()
}
}

/// Find the last XmlText child of `parent`, or create one if none.
/// Subsequent calls always return the same single XmlText so we can append.
fn ensure_text_child(parent: &XmlElementRef, txn: &mut yrs::TransactionMut) -> XmlTextRef {
Expand Down
8 changes: 8 additions & 0 deletions crates/knot-markdown/tests/round_trip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,14 @@ fn round_trip_all_fixtures() {
}
}

#[test]
fn list_item_hard_break_serialises() {
let (doc, _) = knot_markdown::from_markdown::parse("- first \n second\n").unwrap();
let got = knot_markdown::to_markdown::serialise(&YrsEngine, &doc).unwrap();
assert!(got.starts_with("- first \n"), "got {got:?}");
assert!(got.contains("second"), "got {got:?}");
}

#[test]
fn boards_sentinel_parses_to_excalidraw_board() {
use yrs::XmlOut;
Expand Down
6 changes: 5 additions & 1 deletion crates/knot-storage/src/lexorank.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ fn decrement(b: &str) -> String {
for &c in bytes {
let ch = c as char;
if ch > MIN {
out.push(mid(MIN, ch) as u8);
let m = mid(MIN, ch);
out.push(m as u8);
if m == MIN {
out.push(MID as u8);
}
return String::from_utf8(out).unwrap();
}
out.push(c);
Expand Down
175 changes: 137 additions & 38 deletions web/src/features/docs/DocTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,25 @@ import {

import {
DndContext,
DragOverlay,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCenter,
pointerWithin,
type CollisionDetection,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
type SortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

import { useEffectiveRole } from "../../auth/useEffectiveRole";
import { useUi } from "../../stores/ui";
Expand All @@ -43,7 +47,22 @@ import { archiveConfirmText, archiveErrorText } from "./archive";
import { docsApi } from "./docs.api";
import { markDocEditMode } from "./editMode";
import { placementParent, type Placement } from "./placement";
import { buildTree, applyOptimisticMove, descendantIds, dropIntent, moveArgs, type TreeNode } from "./tree";
import {
buildTree,
applyOptimisticMove,
descendantIds,
dropIntent,
resolveDrop,
type DropPlan,
type TreeNode,
} from "./tree";

const noShift: SortingStrategy = () => null;

const pointerFirst: CollisionDetection = (args) => {
const hits = pointerWithin(args);
return hits.length > 0 ? hits : closestCenter(args);
};

export function DocTree() {
const qc = useQueryClient();
Expand Down Expand Up @@ -110,33 +129,75 @@ export function DocTree() {
[list.data],
);

const [drop, setDrop] = useState<{ overId: string; intent: "before" | "after" | "into" } | null>(null);
const tree = list.data && "ok" in list.data ? buildTree(list.data.ok) : [];

function onDragOver(e: DragOverEvent) {
const [drop, setDrop] = useState<DropPlan | null>(null);
const [dragging, setDragging] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());

function toggleCollapsed(id: string) {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}

function updateDrop(e: DragOverEvent | DragMoveEvent) {
const over = e.over;
const activeRect = e.active.rect.current.translated;
if (!over || over.id === e.active.id || !activeRect) { setDrop(null); return; }
const centerY = activeRect.top + activeRect.height / 2;
const intent = dropIntent(centerY, { top: over.rect.top, height: over.rect.height });
setDrop({ overId: String(over.id), intent });
if (!over || !activeRect) { setDrop(null); return; }
const start = e.activatorEvent;
const pointer =
start instanceof MouseEvent
? { x: start.clientX + e.delta.x, y: start.clientY + e.delta.y }
: { x: activeRect.left, y: activeRect.top + activeRect.height / 2 };
const intent = dropIntent(pointer.y, { top: over.rect.top, height: over.rect.height });
const plan = resolveDrop(tree, String(e.active.id), String(over.id), intent, {
pointerX: pointer.x,
titleX: document.querySelector(`[data-testid="doc-row-${String(over.id)}"]`)?.getBoundingClientRect().left ?? over.rect.left,
isExpanded: (id) => !collapsed.has(id),
});
setDrop((prev) =>
prev && plan && prev.overId === plan.overId && prev.intent === plan.intent && prev.depth === plan.depth
? prev
: plan,
);
}

function onDragStart(e: DragStartEvent) {
setDragging(String(e.active.id));
}

function swallowClickAfterDrag() {
const swallow = (ev: MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
window.addEventListener("click", swallow, { capture: true, once: true });
window.setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 100);
}

function onDragEnd(e: DragEndEvent) {
const d = drop;
swallowClickAfterDrag();
const plan = drop;
setDrop(null);
if (!e.over || !d) return;
setDragging(null);
const movedId = String(e.active.id);
const targetId = String(e.over.id);
if (movedId === targetId) return;
const docs = list.data && "ok" in list.data ? list.data.ok : [];
const target = docs.find((x) => x.id === targetId) ?? null;
if (!target) return;
const destParent = d.intent === "into" ? target.id : (target.parent_id ?? null);
if (destParent === movedId || (destParent && descendantIds(docs, movedId).has(destParent))) {
notify("error", "Can't move a document inside itself");
if (!plan) {
const docs = list.data && "ok" in list.data ? list.data.ok : [];
if (e.over && descendantIds(docs, movedId).has(String(e.over.id))) {
notify("error", "Can't move a document inside itself");
}
return;
}
move.mutate({ id: movedId, body: moveArgs(target, d.intent) });
move.mutate({ id: movedId, body: plan.args });
}

function onDragCancel() {
setDrop(null);
setDragging(null);
}

// Named rather than an inline `async` handler: `onPickTemplate` is typed
Expand All @@ -162,7 +223,8 @@ export function DocTree() {
await nav(`/doc/${created.id}`);
}

const tree = list.data && "ok" in list.data ? buildTree(list.data.ok) : [];
const draggingTitle =
dragging && list.data && "ok" in list.data ? list.data.ok.find((d) => d.id === dragging)?.title : undefined;

return (
<div data-testid="doc-tree" className="flex flex-col h-full">
Expand Down Expand Up @@ -192,8 +254,8 @@ export function DocTree() {
<p className="px-3 py-2 text-sm text-fg-muted">No documents yet.</p>
)}
{list.data && "ok" in list.data && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragOver={onDragOver} onDragEnd={onDragEnd} onDragCancel={() => setDrop(null)}>
<SortableContext items={flatIds} strategy={verticalListSortingStrategy}>
<DndContext sensors={sensors} collisionDetection={pointerFirst} onDragStart={onDragStart} onDragOver={updateDrop} onDragMove={updateDrop} onDragEnd={onDragEnd} onDragCancel={onDragCancel}>
<SortableContext items={flatIds} strategy={noShift}>
{/* 12px clear on the right, like the rest of the sidebar: rows
are drag targets, and the 8px resize handle on the edge needs
that much room around it (WCAG 2.5.8 target spacing). */}
Expand All @@ -208,12 +270,22 @@ export function DocTree() {
isOwner={isOwner}
retentionDays={retentionDays}
onNewChild={(pid) => create.mutate(pid)}
dropHint={drop && drop.overId === n.id ? drop.intent : null}
dropHint={drop && drop.overId === n.id ? drop : null}
drop={drop}
collapsed={collapsed}
onToggle={toggleCollapsed}
/>
))}
</ul>
</SortableContext>
<DragOverlay dropAnimation={null}>
{draggingTitle !== undefined ? (
<div className="pointer-events-none inline-flex max-w-56 items-center gap-1 h-7 px-2 rounded bg-muted text-fg text-[13px] shadow-md">
<FileText size={14} aria-hidden className="text-fg-muted shrink-0" />
<span className="truncate">{draggingTitle}</span>
</div>
) : null}
</DragOverlay>
</DndContext>
)}
{pickerOpen && (
Expand Down Expand Up @@ -374,6 +446,8 @@ function TreeRow({
onNewChild,
dropHint,
drop,
collapsed,
onToggle,
}: {
node: TreeNode;
depth: number;
Expand All @@ -382,17 +456,19 @@ function TreeRow({
isOwner: boolean;
retentionDays: number;
onNewChild: (parentId: string) => void;
dropHint: "before" | "after" | "into" | null;
drop: { overId: string; intent: "before" | "after" | "into" } | null;
dropHint: DropPlan | null;
drop: DropPlan | null;
collapsed: Set<string>;
onToggle: (id: string) => void;
}) {
const qc = useQueryClient();
const nav = useNavigate();
const notify = useUi((s) => s.notify);
const isActive = activeId === node.id;
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const [expanded, setExpanded] = useState(true);
const expanded = !collapsed.has(node.id);

const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, isDragging } =
const { attributes, listeners, setNodeRef, setActivatorNodeRef, isDragging } =
useSortable({
id: node.id,
disabled: !canEdit,
Expand All @@ -401,12 +477,11 @@ function TreeRow({
// the sensor starts a drag (and swallows the key) on Enter/Space anywhere
// inside the row, so its link and buttons never got the key. Pointer
// drags still start anywhere in the row.
const setRowRef = (el: HTMLLIElement | null) => {
const setRowRef = (el: HTMLDivElement | null) => {
setNodeRef(el);
setActivatorNodeRef(el);
};
const sortableStyle = {
transform: CSS.Transform.toString(transform),
opacity: isDragging ? 0.5 : 1,
};

Expand Down Expand Up @@ -444,9 +519,22 @@ function TreeRow({
await qc.invalidateQueries({ queryKey: ["templates"] });
}

async function onDuplicate() {
const r = await docsApi.createFromTemplate(node.id, node.parent_id ? { parent_id: node.parent_id } : {});
if ("error" in r) {
notify("error", "Couldn't duplicate");
return;
}
const copy = r.ok as { id: string };
await docsApi.move(copy.id, { parent_id: node.parent_id, after_id: node.id });
await qc.invalidateQueries({ queryKey: ["docs"] });
await nav(`/doc/${copy.id}`);
}

const items: ContextMenuItem[] = [];
if (canEdit) {
items.push({ label: "Rename", testId: "ctx-rename", onSelect: () => void onRename() });
items.push({ label: "Duplicate", testId: "ctx-duplicate", onSelect: () => void onDuplicate() });
}
if (isOwner) {
items.push({
Expand All @@ -465,26 +553,35 @@ function TreeRow({
}

return (
<li ref={setRowRef} style={sortableStyle} {...attributes} {...listeners}>
<li style={sortableStyle}>
<div
ref={setRowRef}
{...attributes}
{...listeners}
className={`relative group flex items-center gap-1 rounded h-7 pr-1 transition-colors ease-swift duration-150 ${
isActive
? "bg-muted text-fg"
: "text-fg-muted hover:text-fg hover:bg-muted/60"
} ${dropHint === "into" ? "ring-1 ring-accent bg-accent/10" : ""}`}
} ${dropHint?.intent === "into" ? "ring-1 ring-accent bg-accent/10" : ""}`}
style={{ paddingLeft: 4 + depth * 12 }}
>
{dropHint === "before" && (
<span className="pointer-events-none absolute left-0 right-1 top-0 h-0.5 bg-accent rounded" />
{dropHint?.intent === "before" && (
<span
className="pointer-events-none absolute right-1 top-0 h-0.5 bg-accent rounded"
style={{ left: 4 + dropHint.depth * 12 }}
/>
)}
{dropHint === "after" && (
<span className="pointer-events-none absolute left-0 right-1 bottom-0 h-0.5 bg-accent rounded" />
{dropHint?.intent === "after" && (
<span
className="pointer-events-none absolute right-1 bottom-0 h-0.5 bg-accent rounded"
style={{ left: 4 + dropHint.depth * 12 }}
/>
)}
{node.children.length > 0 ? (
<button
type="button"
aria-label={expanded ? "Collapse" : "Expand"}
onClick={(e) => { e.preventDefault(); setExpanded((v) => !v); }}
onClick={(e) => { e.preventDefault(); onToggle(node.id); }}
className="h-5 w-5 inline-flex items-center justify-center text-fg-muted hover:text-fg rounded shrink-0"
>
<ChevronRight
Expand Down Expand Up @@ -549,8 +646,10 @@ function TreeRow({
isOwner={isOwner}
retentionDays={retentionDays}
onNewChild={onNewChild}
dropHint={drop && drop.overId === c.id ? drop.intent : null}
dropHint={drop && drop.overId === c.id ? drop : null}
drop={drop}
collapsed={collapsed}
onToggle={onToggle}
/>
))}
</ul>
Expand Down
Loading