feat: save, undo and redo direct edits

Step 5 of the map editor. EditSession becomes the single owner of the constraint
set, replacing the pair of refs step 4 kept alongside it: it now holds the saved
operations too, and `fragment()` assembles the constraints plus every operation
they reference — the shape both preview and save send. That removes the class of
bug that produced the earlier 400, because callers can no longer ship a
constraint whose provenance points at nothing.

Save goes through `expectedDocumentVersion`. A 409 adopts the server's version so
the next attempt is checked against reality, and says which version won instead
of failing silently. Undo of a saved gesture appends an inverse operation rather
than rewriting persisted history; undo of an unsaved one just moves the cursor.
Discard drops unsaved commands, cancels anything in flight, and returns the map
to the baseline.

One gesture mints one operation id, released on pointerup. Reusing an id across
gestures put two operations with the same id in the document, which
validateEditDocument() rejects.

The API test now covers the payload the client actually sends — `constraints` +
`operations` merged into the active document, rather than a whole document — and
asserts that a save survives a reload as `exact`. Sending constraints without
their operations is asserted to be rejected rather than written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 18:11:52 +08:00
parent 92297270f1
commit 38593f6f67
4 changed files with 280 additions and 42 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Download, RefreshCw, Save, Upload } from 'lucide-react';
import { api } from './lib/api';
import { api, isVersionConflict } from './lib/api';
import { MapCanvas } from './components/MapCanvas';
import { directEditEnabled, intervalEditingSupported } from './edit/flag';
import { PreviewRequester, type PreviewDraft } from './edit/preview-request';
@@ -15,6 +15,7 @@ import type {
RoadConstraint,
RoadEditOperation,
RoadIntervalAnchor,
VersionConflict,
} from './edit/types';
import { Button } from './ui/button';
import type { GeoFeature, GeoJson, Override, Road, WorkbenchState } from './types/state';
@@ -112,10 +113,10 @@ function App() {
.then((value) => {
if (!('handles' in value)) return;
setManifest(value.handles);
// A drag drafts on top of whatever is already saved, so keep the active
// document's constraints rather than sending a lone constraint.
savedConstraints.current = value.document.constraints;
savedOperations.current = value.document.operations;
// The session owns the constraint set from here on, so a drag drafts on top
// of the active document instead of sending a lone constraint.
sessionRef.current?.load(value.document.constraints, value.document.operations, value.documentVersion);
touch();
})
// A manifest failure must not take the workbench down with it; the map just
// shows no handles.
@@ -149,19 +150,32 @@ function App() {
}, [editHandles, manifest, selected, state]);
const [preview, setPreview] = useState<Partial<Record<PreviewLayerName, GeoJson | null>> | null>(null);
const [editDiagnostics, setEditDiagnostics] = useState<EditDiagnostic[]>([]);
// The working set: the saved document plus whatever this session has drafted.
// Advanced on pointerup, not on every move, so an abandoned drag leaves nothing
// behind — and so a later range drag can re-anchor the edit a value drag made.
const savedConstraints = useRef<RoadConstraint[]>([]);
/** Their operations. A constraint without its operation is rejected as a 400. */
const savedOperations = useRef<RoadEditOperation[]>([]);
const sessionRef = useRef<EditSession | null>(null);
if (directEditEnabled && !sessionRef.current) sessionRef.current = new EditSession();
// The session is a mutable object in a ref, so React cannot see it change.
// Bumping this after every mutation is what keeps the buttons in step with it.
const [editRevision, setEditRevision] = useState(0);
const touch = () => setEditRevision((value) => value + 1);
const session = sessionRef.current;
/**
* One gesture, one operation id. Minted on the first draft of a drag and cleared
* on release: reusing an id across gestures would put two operations with the
* same id in the document, which `validateEditDocument()` rejects outright.
*/
const gesture = useRef<{ operationId: string; createdAt: string } | null>(null);
const gestureIdentity = () => {
if (!gesture.current)
gesture.current = {
operationId: `operation:${editRevision}:${Date.now()}`,
createdAt: new Date().toISOString(),
};
return gesture.current;
};
const requester = useMemo(() => {
const session = sessionRef.current;
if (!session) return null;
const owner = sessionRef.current;
if (!owner) return null;
return new PreviewRequester({
session,
session: owner,
send: (request, signal) => api.editPreview(request, signal),
onSettled: (outcome, response) => {
setEditDiagnostics(response.diagnostics);
@@ -175,31 +189,28 @@ function App() {
onError: (error) => setStatus(`预览失败:${(error as Error).message}`),
});
}, []);
/** A drag becomes one drafted constraint layered over the saved document. */
/** A drag becomes one drafted constraint layered over the session's document. */
const draftFor = (handle: EditHandle, value: number): PreviewDraft | null => {
const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : null;
const road = roadId ? state?.compiled.model.roads.find((item) => item.id === roadId) : undefined;
// Without a centerline there is no snapshot to record, so drop the drag rather
// than send a constraint the server would have to reject.
if (!road || road.centerline.length < 2) return null;
if (!session || !road || road.centerline.length < 2) return null;
const constraint = draftConstraint(
handle,
handle.anchor,
constraintValueFor(handle, value),
anchorSnapshotFor(handle, road.centerline, road.sourceNodeIds),
{
constraintId: `constraint:${handle.handleId}`,
operationId: `operation:${handle.handleId}`,
createdAt: new Date().toISOString(),
},
{ constraintId: `constraint:${handle.handleId}`, ...gestureIdentity() },
);
const operation = operationFor(constraint);
const base = session.fragment();
// Both halves travel: validateEditDocument() rejects a constraint whose
// provenance.operationId is not a recorded operation, and that check covers the
// already-saved constraints too, not just the one being dragged.
return {
constraints: [...savedConstraints.current.filter((item) => item.id !== constraint.id), constraint],
operations: [...savedOperations.current.filter((item) => item.id !== operation.id), operation],
constraints: [...base.constraints.filter((item) => item.id !== constraint.id), constraint],
operations: [...base.operations.filter((item) => item.id !== operation.id), operation],
};
};
/**
@@ -208,23 +219,86 @@ function App() {
* constraint on it. With no constraint yet there is nothing to re-anchor.
*/
const rangeDraftFor = (range: IntervalRangeHandle, anchor: RoadIntervalAnchor): PreviewDraft | null => {
const base = session?.fragment();
const onRoad = (item: RoadConstraint) =>
item.anchor.type === 'road-interval' && item.anchor.roadId === range.roadId;
if (!savedConstraints.current.some(onRoad)) return null;
if (!base || !base.constraints.some(onRoad)) return null;
return {
constraints: savedConstraints.current.map((item) =>
constraints: base.constraints.map((item) =>
onRoad(item)
? { ...item, anchor: { ...item.anchor, startStation: anchor.startStation, endStation: anchor.endStation } }
: item,
),
operations: savedOperations.current,
operations: base.operations,
};
};
/** Both drag kinds commit their result here so the next drag builds on it. */
const advance = (draft: PreviewDraft | null) => {
if (!draft) return;
savedConstraints.current = draft.constraints ?? savedConstraints.current;
savedOperations.current = draft.operations ?? savedOperations.current;
/**
* Pointerup closes the gesture: one undoable command, then the id is released so
* the next drag mints its own. An abandoned drag commits nothing.
*/
const commitGesture = (draft: PreviewDraft | null, constraintIds: string[]) => {
if (!draft || !session) return;
requester?.flush(draft);
session.commit(draft.constraints ?? [], constraintIds, gestureIdentity());
gesture.current = null;
touch();
};
/** Re-previews whatever the session now holds, after undo/redo moved the cursor. */
const previewSession = () => {
const fragment = session?.fragment();
if (fragment) requester?.flush(fragment);
};
const undoEdit = () => {
// A saved gesture cannot be un-applied — persisted history is append-only — so
// it is reversed by an inverse operation, which needs its own identity.
const reversed = session?.undo({
operationId: `operation:undo:${Date.now()}`,
createdAt: new Date().toISOString(),
});
if (!reversed) return;
touch();
previewSession();
};
const redoEdit = () => {
if (!session?.redo()) return;
touch();
previewSession();
};
const discardEdits = () => {
if (!session) return;
requester?.cancel();
session.discard();
gesture.current = null;
touch();
setPreview(null);
setEditDiagnostics([]);
setStatus('已放弃未保存的直接编辑');
};
const saveDirectEdits = async () => {
if (!session) return;
const fragment = session.fragment();
try {
const result = await api.saveEdits({
expectedDocumentVersion: session.documentVersion,
constraints: fragment.constraints,
operations: fragment.operations,
});
session.markSaved(result.documentVersion);
touch();
setStatus(`直接编辑已保存,文档版本 ${result.documentVersion}`);
} catch (error) {
if (!isVersionConflict(error)) {
setStatus(`保存失败:${(error as Error).message}`);
return;
}
// Single-writer protection fired: another tab moved the document. Adopt the
// server's version so the next attempt is checked against reality, and say so
// rather than failing silently.
const current = (error.body as VersionConflict | null)?.current?.documentVersion;
if (Number.isInteger(current)) session.setDocumentVersion(current as number);
touch();
setStatus(`保存冲突:文档已被改到版本 ${current ?? '未知'},请刷新后重做本次编辑。`);
}
};
const stage = (change: Override) =>
setStaged((current) => [...current.filter((item) => item.id !== change.id), change]);
@@ -312,6 +386,22 @@ function App() {
<Button onClick={() => void compile()}>
<RefreshCw size={15} />
</Button>
{directEditEnabled ? (
<>
<Button onClick={() => void saveDirectEdits()} disabled={!session?.dirty}>
<Save size={15} />
</Button>
<Button onClick={undoEdit} disabled={!session?.canUndo}>
</Button>
<Button onClick={redoEdit} disabled={!session?.canRedo}>
</Button>
<Button onClick={discardEdits} disabled={!session?.dirty}>
</Button>
</>
) : null}
</header>
<main>
<aside className="issues">
@@ -368,12 +458,7 @@ function App() {
const draft = draftFor(handle, value);
if (draft) requester?.schedule(draft);
}}
onHandleDragEnd={(handle, value) => {
const draft = draftFor(handle, value);
if (!draft) return;
requester?.flush(draft);
advance(draft);
}}
onHandleDragEnd={(handle, value) => commitGesture(draftFor(handle, value), [`constraint:${handle.handleId}`])}
onHandleBlocked={(_handle, reason) => setStatus(reason)}
onRangeDrag={(range, anchor) => {
const draft = rangeDraftFor(range, anchor);
@@ -385,8 +470,10 @@ function App() {
setStatus('先拖动路缘、步行带或车道分隔手柄产生一次编辑,范围手柄才有可调整的区间。');
return;
}
requester?.flush(draft);
advance(draft);
const touched = (draft.constraints ?? [])
.filter((item) => item.anchor.type === 'road-interval' && item.anchor.roadId === range.roadId)
.map((item) => item.id);
commitGesture(draft, touched);
}}
/>
<Inspector

View File

@@ -40,7 +40,7 @@ const error: EditDiagnostic = {
describe('command stack', () => {
it('starts clean on the loaded baseline', () => {
const session = new EditSession([constraint('c1', 1)], 3);
const session = new EditSession([constraint('c1', 1)], [], 3);
expect(session.constraints()).toHaveLength(1);
expect(session.canUndo).toBe(false);
expect(session.canRedo).toBe(false);
@@ -124,12 +124,77 @@ describe('saved history is append-only', () => {
});
it('adopts the server version after a conflict', () => {
const session = new EditSession([], 1);
const session = new EditSession([], [], 1);
session.setDocumentVersion(7);
expect(session.documentVersion).toBe(7);
});
});
describe('document fragment', () => {
const operation = (id: string, constraintIds: string[]) => ({ id, createdAt: 'now', constraintIds });
it('carries the operations the saved constraints reference', () => {
// Sending a constraint without its operation is a 400 from
// validateEditDocument(), so the fragment has to cover the saved half too.
const session = new EditSession([constraint('c1', 1)], [operation('op', ['c1'])], 2);
expect(session.fragment()).toEqual({
constraints: [constraint('c1', 1)],
operations: [operation('op', ['c1'])],
});
});
it('adds the pending operations of unsaved gestures', () => {
const session = new EditSession([constraint('c1', 1)], [operation('op', ['c1'])], 2);
session.commit([constraint('c1', 1), constraint('c2', 3)], ['c2'], identity(1));
const fragment = session.fragment();
expect(fragment.constraints.map((item) => item.id)).toEqual(['c1', 'c2']);
expect(fragment.operations.map((item) => item.id)).toEqual(['op', 'op-1']);
});
it('does not list an operation twice when a gesture is re-committed', () => {
const session = new EditSession([]);
session.commit([constraint('c1', 1)], ['c1'], identity(1));
session.markSaved(1);
// The same operation is now in the baseline; committing again must not duplicate it.
session.commit([constraint('c1', 5)], ['c1'], identity(1));
expect(session.fragment().operations.map((item) => item.id)).toEqual(['op-1']);
});
});
describe('loading and discarding', () => {
it('adopts a server document as the new baseline with no history', () => {
const session = new EditSession([]);
session.commit([constraint('c1', 2)], ['c1'], identity(1));
session.load([constraint('c9', 4)], [{ id: 'op-9', createdAt: 'now', constraintIds: ['c9'] }], 12);
expect(session.constraints()).toEqual([constraint('c9', 4)]);
expect(session.documentVersion).toBe(12);
expect(session.canUndo).toBe(false);
expect(session.canRedo).toBe(false);
expect(session.dirty).toBe(false);
});
it('mutates in place so the preview requester keeps arbitrating', () => {
// The requester captures the session once; a swapped object would leave it
// deciding staleness for a session nobody reads.
const session = new EditSession([]);
const captured = session;
session.load([constraint('c1', 1)], [], 3);
expect(captured.constraints()).toEqual([constraint('c1', 1)]);
});
it('discards unsaved gestures but keeps saved ones', () => {
const session = new EditSession([]);
session.commit([constraint('c1', 2)], ['c1'], identity(1));
session.markSaved(1);
session.commit([constraint('c1', 2), constraint('c2', 3)], ['c2'], identity(2));
expect(session.dirty).toBe(true);
session.discard();
expect(session.constraints()).toEqual([constraint('c1', 2)]);
expect(session.dirty).toBe(false);
expect(session.canRedo).toBe(false);
});
});
describe('previewSeq arbitration', () => {
it('hands out a monotonic sequence', () => {
const session = new EditSession([]);

View File

@@ -37,6 +37,8 @@ export interface PreviewOutcome {
export class EditSession {
private baseline: RoadConstraint[];
/** Operations of the saved constraints. A constraint whose operation is missing is a 400. */
private baselineOperations: RoadEditOperation[];
private stack: Command[] = [];
private cursor = 0;
private seq = 0;
@@ -49,11 +51,26 @@ export class EditSession {
constraintStates: ConstraintStateReport[] = [];
degraded = false;
constructor(constraints: RoadConstraint[] = [], documentVersion = 0) {
constructor(constraints: RoadConstraint[] = [], operations: RoadEditOperation[] = [], documentVersion = 0) {
this.baseline = [...constraints];
this.baselineOperations = [...operations];
this.version = documentVersion;
}
/**
* The document fragment both preview and save send: the effective constraints
* plus every operation they reference. Assembling it here is what keeps callers
* from shipping constraints whose provenance points at nothing.
*/
fragment(): { constraints: RoadConstraint[]; operations: RoadEditOperation[] } {
const pending = this.pendingOperations();
const ids = new Set(pending.map((operation) => operation.id));
return {
constraints: this.constraints(),
operations: [...this.baselineOperations.filter((operation) => !ids.has(operation.id)), ...pending],
};
}
/** Effective constraint set: the top applied command, or the saved baseline. */
constraints(): RoadConstraint[] {
return this.cursor > 0 ? this.stack[this.cursor - 1].after : this.baseline;
@@ -144,6 +161,7 @@ export class EditSession {
/** After a successful save: current state becomes the baseline, history closes. */
markSaved(documentVersion: number): void {
this.baselineOperations = this.fragment().operations;
this.baseline = this.constraints();
this.stack = this.stack.slice(0, this.cursor).map((command) => ({ ...command, saved: true }));
this.cursor = this.stack.length;
@@ -155,6 +173,27 @@ export class EditSession {
this.version = documentVersion;
}
/**
* Adopt a document the server handed us: new baseline, history cleared.
*
* Mutates in place rather than returning a new session because the preview
* requester holds this object; swapping it would leave the requester arbitrating
* against a session nobody reads.
*/
load(constraints: RoadConstraint[], operations: RoadEditOperation[], documentVersion: number): void {
this.baseline = [...constraints];
this.baselineOperations = [...operations];
this.stack = [];
this.cursor = 0;
this.version = documentVersion;
}
/** Drop every unsaved command and return to the last saved baseline. */
discard(): void {
this.stack = this.stack.filter((command) => command.saved);
this.cursor = this.stack.length;
}
/** Monotonic per-session sequence stamped on every preview request. */
nextPreviewSeq(): number {
this.seq += 1;