Skip to content
Closed
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
5 changes: 0 additions & 5 deletions manual-editor/netlify/functions/page.mts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,7 @@ export default async (request: Request): Promise<Response> => {
return json({ error: "not found" }, 404);
}
}
const base = url.searchParams.get("base") === "1";
try {
if (base) {
const content = await backend.readBasePage(path);
return json(content);
}
return json(await backend.readPage(path));
} catch {
return json({ error: "not found" }, 404);
Expand Down
22 changes: 3 additions & 19 deletions manual-editor/src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { api as defaultApi, type makeApi, type Me } from "./api";
import { Editor } from "./Editor";
import { PageList } from "./PageList";
Expand Down Expand Up @@ -71,8 +71,6 @@ export function App({
| null
>(null);

const editorFlushRef = useRef<(() => Promise<void>) | null>(null);

function openNewPage(parent: ManualPageMeta | null) {
setNewPageIntent(parent ? { kind: "child", parent } : { kind: "top" });
}
Expand Down Expand Up @@ -100,7 +98,6 @@ export function App({
setPublishError(null);
setPublishResult(null);
try {
await editorFlushRef.current?.();
const result = await api.publish();
setPublishResult(result);
api.listPages().then(setPages);
Expand Down Expand Up @@ -190,17 +187,9 @@ export function App({
// (same "always re-fetch rather than patch locally" reasoning as
// `handleDraftSaved` above — the backend's listing already reflects the
// deletion by the time `onDeleted` fires).
function handleDeleted(deletedPath: string) {
function handleDeleted() {
setSource(null);
setActivePath(null);
// Optimistically drop the page so the sidebar updates instantly — the
// GitHub tree API returns a cached ref for a moment after the delete
// commit lands, so a bare re-fetch here would briefly show the page as
// still present. The background re-fetch corrects any other side effects
// (hasDraft dots etc.) once GitHub catches up.
setPages((prev) =>
prev ? prev.filter((p) => p.path !== deletedPath) : prev,
);
api.listPages().then(setPages);
}

Expand All @@ -209,9 +198,6 @@ export function App({
// `Editor` to gate the header's delete action (deleting a parent would
// orphan its children in the tree).
const activeSlug = activeSlugFromPath(activePath);
const activePageMeta = activePath
? pages?.find((p) => p.path === activePath)
: undefined;
const hasChildren =
activeSlug !== null &&
(pages?.some(
Expand Down Expand Up @@ -318,15 +304,13 @@ export function App({
sections={sections}
pages={pages ?? []}
api={api}
flushRef={editorFlushRef}
onDraftSaved={handleDraftSaved}
onAddSubpage={() => {
const meta = pages?.find((p) => p.path === activePath);
if (meta) openNewPage(meta);
}}
hasChildren={hasChildren}
hasDraft={activePageMeta?.hasDraft ?? false}
onDeleted={(p) => handleDeleted(p)}
onDeleted={handleDeleted}
/>
) : (
<p className="app-main__placeholder">
Expand Down
190 changes: 41 additions & 149 deletions manual-editor/src/app/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import { getBlockActions, type BlockAction } from "./blockActions";
import { getSelectedBlocks } from "./blockSelection";
import { HandleMenu } from "./HandleMenu";
import { SelectionBar } from "./SelectionBar";
import { ReadOnlyDoc } from "./ReadOnlyDoc";

/** Matches the manual content collection schema's `sectionOrder`/`order` default. */
const DEFAULT_ORDER = 99;
Expand Down Expand Up @@ -235,8 +234,6 @@ export function Editor({
hasChildren,
onDeleted,
enableDragHandle = true,
flushRef,
hasDraft = false,
}: {
source: string;
path: string;
Expand Down Expand Up @@ -287,7 +284,7 @@ export function Editor({
* on unmount (see its cleanup), so no extra guard is needed here for the
* delete-then-unmount sequence.
*/
onDeleted: (path: string) => void;
onDeleted: () => void;
/**
* Renders the Notion-style block drag handle. Defaults on for the real
* app; existing suites that mount `Editor` under happy-dom leave it on
Expand All @@ -298,18 +295,6 @@ export function Editor({
* case can still opt out per-test without changing the default.
*/
enableDragHandle?: boolean;
/**
* When provided, Editor populates this ref with an async function that
* flushes any pending autosave and resolves once the save lands. App uses
* it to ensure unsaved changes are committed before calling publish.
*/
flushRef?: { current: (() => Promise<void>) | null };
/**
* True when this page has unpublished changes on the drafts branch. Used
* to show/hide the "Compare" toggle in the header — comparing only makes
* sense when there's a published version to compare against.
*/
hasDraft?: boolean;
}) {
const [frontmatterData, setFrontmatterData] = useState<FrontmatterData>(() =>
toFrontmatterData(parseFrontmatter(source).data),
Expand All @@ -332,27 +317,6 @@ export function Editor({
// starts collapsed.
const [detailsExpanded, setDetailsExpanded] = useState(false);

// Compare mode: show published (base branch) alongside the draft side by
// side. `baseSource` is null when the page is draft-only (no published
// version), "loading" while the fetch is in flight, or the fetched source
// string once resolved. Reset to off on `path` change via `useEditor`'s
// recreation — but `path` is already the mount key so a fresh Editor
// instance always starts with compare off.
const [compareMode, setCompareMode] = useState(false);
const [baseSource, setBaseSource] = useState<string | null | "loading">(null);

async function handleToggleCompare() {
if (compareMode) {
setCompareMode(false);
setBaseSource(null);
return;
}
setCompareMode(true);
setBaseSource("loading");
const content = await api.getBasePage(path);
setBaseSource(content ? content.source : null);
}

const doc = useMemo(() => {
const { doc } = mdastToDoc(parseMdx(source));
return doc;
Expand Down Expand Up @@ -561,19 +525,7 @@ export function Editor({
// fire-and-forget (the component may already be gone — no setState), and
// is nulled the moment the debounce fires normally so a flush never
// double-saves.
const pendingSaveRef = useRef<{ flush: () => Promise<void> } | null>(null);

// Populate flushRef once so App can await any pending save before publishing.
useEffect(() => {
if (!flushRef) return;
flushRef.current = async () => {
if (pendingSaveRef.current) await pendingSaveRef.current.flush();
};
return () => {
if (flushRef) flushRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const pendingSaveRef = useRef<{ flush: () => void } | null>(null);

// The debounced autosave itself. `saveVersion` is the trigger: it only
// increments once an actual edit has happened (content or frontmatter),
Expand All @@ -586,15 +538,13 @@ export function Editor({
pendingSaveRef.current = {
flush: () => {
pendingSaveRef.current = null;
return api
void api
.saveDraftDoc(
savingPath,
editor.getJSON(),
serializeFrontmatter(frontmatterData),
)
.then(() => {
onDraftSaved?.(savingPath);
})
.then(() => onDraftSaved?.(savingPath))
.catch(() => {});
},
};
Expand Down Expand Up @@ -636,7 +586,7 @@ export function Editor({
// not flush per keystroke; only leaving the page does.
useEffect(() => {
return () => {
void pendingSaveRef.current?.flush();
pendingSaveRef.current?.flush();
};
}, [path]);

Expand Down Expand Up @@ -675,7 +625,7 @@ export function Editor({
setDeleting(true);
try {
await api.deletePage(path);
onDeleted(path);
onDeleted();
} catch {
setDeleting(false);
setConfirmingDelete(false);
Expand All @@ -693,11 +643,11 @@ export function Editor({

const saveStatusLabel =
saveStatus === "saving"
? "Saving changes…"
? "Saving…"
: saveStatus === "saved"
? "Changes saved ●"
? "Saved draft ●"
: saveStatus === "dirty"
? "Unsaved changes"
? "Edited"
: saveStatus === "error"
? "Save failed"
: saveStatus === "delete-error"
Expand Down Expand Up @@ -732,17 +682,6 @@ export function Editor({
</button>
</div>
<div className="editor-header__actions">
{hasDraft ? (
<button
type="button"
data-testid="editor-compare-toggle"
className="editor-header__compare"
aria-pressed={compareMode}
onClick={handleToggleCompare}
>
{compareMode ? "Close compare" : "Compare"}
</button>
) : null}
<button
type="button"
data-testid="editor-add-subpage"
Expand Down Expand Up @@ -805,86 +744,39 @@ export function Editor({
/>
) : null}
</div>
{compareMode ? (
<div className="editor-compare">
<div className="compare-pane compare-pane--published">
<div className="compare-pane__label">Published</div>
<div className="compare-pane__scroll">
{baseSource === "loading" ? (
<p className="compare-pane__loading">Loading…</p>
) : baseSource === null ? (
<p className="compare-pane__empty">
No published version — this page has not been merged yet.
</p>
) : (
<ReadOnlyDoc source={baseSource} />
)}
</div>
</div>
<div className="compare-pane compare-pane--draft">
<div className="compare-pane__label">Your changes</div>
<div className="editor-scroll" onScroll={() => setHandleMenu(null)}>
{enableDragHandle && editor ? (
<DragHandle
editor={editor}
computePositionConfig={HANDLE_COMPUTE_POSITION_CONFIG}
onNodeChange={handleNodeChange}
nested={NESTED_DRAG_HANDLE_OPTIONS}
className="drag-handle-wrapper"
>
<button
type="button"
className="drag-handle"
data-testid="drag-handle"
aria-label="Drag to move block"
aria-haspopup="menu"
title="Drag to move · Click for actions"
tabIndex={-1}
onClick={handleDragHandleClick}
>
</button>
</DragHandle>
) : null}
<EditorContent editor={editor} />
</div>
</div>
</div>
) : (
<div
className="editor-scroll"
// A stale menu anchored to a handle rect that's about to scroll out
// from under it reads as broken (the popup floats over the wrong
// block, or over nothing). Simplest fix: any scroll of the document
// pane closes it outright rather than trying to keep it glued to a
// moving anchor.
onScroll={() => setHandleMenu(null)}
>
{enableDragHandle && editor ? (
<DragHandle
editor={editor}
computePositionConfig={HANDLE_COMPUTE_POSITION_CONFIG}
onNodeChange={handleNodeChange}
nested={NESTED_DRAG_HANDLE_OPTIONS}
className="drag-handle-wrapper"
<div
className="editor-scroll"
// A stale menu anchored to a handle rect that's about to scroll out
// from under it reads as broken (the popup floats over the wrong
// block, or over nothing). Simplest fix: any scroll of the document
// pane closes it outright rather than trying to keep it glued to a
// moving anchor.
onScroll={() => setHandleMenu(null)}
>
{enableDragHandle && editor ? (
<DragHandle
editor={editor}
computePositionConfig={HANDLE_COMPUTE_POSITION_CONFIG}
onNodeChange={handleNodeChange}
nested={NESTED_DRAG_HANDLE_OPTIONS}
className="drag-handle-wrapper"
>
<button
type="button"
className="drag-handle"
data-testid="drag-handle"
aria-label="Drag to move block"
aria-haspopup="menu"
title="Drag to move · Click for actions"
tabIndex={-1}
onClick={handleDragHandleClick}
>
<button
type="button"
className="drag-handle"
data-testid="drag-handle"
aria-label="Drag to move block"
aria-haspopup="menu"
title="Drag to move · Click for actions"
tabIndex={-1}
onClick={handleDragHandleClick}
>
</button>
</DragHandle>
) : null}
<EditorContent editor={editor} />
</div>
)}
</button>
</DragHandle>
) : null}
<EditorContent editor={editor} />
</div>
{handleMenu && editor ? (
<HandleMenu
editor={editor}
Expand Down
Loading