diff --git a/crates/knot-markdown/src/from_markdown.rs b/crates/knot-markdown/src/from_markdown.rs index 0267c5c..b1b1f0f 100644 --- a/crates/knot-markdown/src/from_markdown.rs +++ b/crates/knot-markdown/src/from_markdown.rs @@ -397,7 +397,8 @@ pub fn parse(src: &str) -> Result<(DocHandle, Vec), 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. @@ -442,7 +443,8 @@ pub fn parse(src: &str) -> Result<(DocHandle, Vec), 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); } @@ -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 { diff --git a/crates/knot-markdown/tests/round_trip.rs b/crates/knot-markdown/tests/round_trip.rs index 20f8e0d..fa5367a 100644 --- a/crates/knot-markdown/tests/round_trip.rs +++ b/crates/knot-markdown/tests/round_trip.rs @@ -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; diff --git a/crates/knot-storage/src/lexorank.rs b/crates/knot-storage/src/lexorank.rs index 464a17a..f79c69c 100644 --- a/crates/knot-storage/src/lexorank.rs +++ b/crates/knot-storage/src/lexorank.rs @@ -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); diff --git a/web/src/features/docs/DocTree.tsx b/web/src/features/docs/DocTree.tsx index 8fba684..4170d02 100644 --- a/web/src/features/docs/DocTree.tsx +++ b/web/src/features/docs/DocTree.tsx @@ -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"; @@ -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(); @@ -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(null); + const [dragging, setDragging] = useState(null); + const [collapsed, setCollapsed] = useState>(() => 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 @@ -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 (
@@ -192,8 +254,8 @@ export function DocTree() {

No documents yet.

)} {list.data && "ok" in list.data && ( - setDrop(null)}> - + + {/* 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). */} @@ -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} /> ))} + + {draggingTitle !== undefined ? ( +
+ + {draggingTitle} +
+ ) : null} +
)} {pickerOpen && ( @@ -374,6 +446,8 @@ function TreeRow({ onNewChild, dropHint, drop, + collapsed, + onToggle, }: { node: TreeNode; depth: number; @@ -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; + 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, @@ -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, }; @@ -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({ @@ -465,26 +553,35 @@ function TreeRow({ } return ( -
  • +
  • - {dropHint === "before" && ( - + {dropHint?.intent === "before" && ( + )} - {dropHint === "after" && ( - + {dropHint?.intent === "after" && ( + )} {node.children.length > 0 ? (