feat: Support multi-select editor workflow and improve UI/UX

- Refactored state from single selectedFeatureId to selectedFeatureIds array in Editor and Viewer
- Updated Map component to support multi-select filtering for geometry binding visibility
- Made entity, wiki, and geometry side panels scrollable for better overflow handling
- Fixed viewer mode wiki link navigation for independent wikis
- Improved geometry binding UX and state synchronization
This commit is contained in:
taDuc
2026-05-11 04:49:28 +07:00
parent f2f5295218
commit fe7696b72d
14 changed files with 200 additions and 161 deletions
+17 -13
View File
@@ -16,7 +16,7 @@ type EditorDraftApi = {
type Options = {
editor: EditorDraftApi;
selectedFeature: Feature | null;
selectedFeatures: Feature[];
geometryMetaForm: GeometryMetaFormState;
setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>;
selectedGeometryEntityIds: string[];
@@ -29,7 +29,7 @@ type Options = {
export function useFeatureCommands(options: Options) {
const {
editor,
selectedFeature,
selectedFeatures,
geometryMetaForm,
setGeometryMetaForm,
selectedGeometryEntityIds,
@@ -40,8 +40,8 @@ export function useFeatureCommands(options: Options) {
} = options;
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
if (!selectedFeature) {
const msg = "Hãy chọn một geometry trước.";
if (!selectedFeatures || selectedFeatures.length === 0) {
const msg = "Hãy chọn ít nhất một geometry trước.";
setEntityFormStatus(msg);
return { ok: false, error: msg };
}
@@ -64,7 +64,9 @@ export function useFeatureCommands(options: Options) {
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
for (const feature of selectedFeatures) {
editor.patchFeatureProperties(feature.properties.id, metadata.patch);
}
setGeometryMetaForm(metadata.formState);
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
return { ok: true };
@@ -74,15 +76,15 @@ export function useFeatureCommands(options: Options) {
}, [
editor,
geometryMetaForm,
selectedFeature,
selectedFeatures,
setEntityFormStatus,
setGeometryMetaForm,
setIsEntitySubmitting,
]);
const applyEntitiesToSelectedGeometry = useCallback(async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
if (!selectedFeatures || selectedFeatures.length === 0) {
setEntityFormStatus("Hãy chọn ít nhất một geometry trước.");
return;
}
@@ -90,10 +92,12 @@ export function useFeatureCommands(options: Options) {
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
editor.patchFeatureProperties(
selectedFeature.properties.id,
buildFeatureEntityPatch(selectedFeature, entityIds, entities)
);
for (const feature of selectedFeatures) {
editor.patchFeatureProperties(
feature.properties.id,
buildFeatureEntityPatch(feature, entityIds, entities)
);
}
setSelectedGeometryEntityIds(entityIds);
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
} catch (err) {
@@ -108,7 +112,7 @@ export function useFeatureCommands(options: Options) {
}, [
editor,
entities,
selectedFeature,
selectedFeatures,
selectedGeometryEntityIds,
setEntityFormStatus,
setIsEntitySubmitting,
+77 -51
View File
@@ -128,8 +128,8 @@ export default function Page() {
setSnapshotEntities,
entityStatus,
setEntityStatus,
selectedFeatureId,
setSelectedFeatureId,
selectedFeatureIds,
setSelectedFeatureIds,
entityForm,
setEntityForm,
selectedGeometryEntityIds,
@@ -263,12 +263,20 @@ export default function Page() {
rows.sort((a, b) => a.name.localeCompare(b.name));
return rows;
}, [entities, snapshotEntitiesVisible]);
const selectedFeature =
selectedFeatureId === null
? null
: editor.draft.features.find((feature) =>
String(feature.properties.id) === String(selectedFeatureId)
) || null;
const selectedFeatures = useMemo(() => {
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return [];
return selectedFeatureIds
.map(id => editor.draft.features.find(f => String(f.properties.id) === String(id)))
.filter(Boolean) as Feature[];
}, [selectedFeatureIds, editor.draft.features]);
const isMultiEditValid = useMemo(() => {
if (selectedFeatures.length <= 1) return true;
const firstShape = selectedFeatures[0].geometry.type;
return selectedFeatures.every(f => f.geometry.type === firstShape);
}, [selectedFeatures]);
const selectedFeature = selectedFeatures.length > 0 && isMultiEditValid ? selectedFeatures[0] : null;
const geometryChoices = useMemo(() => {
const rows = (editor.draft.features || [])
@@ -383,7 +391,7 @@ export default function Page() {
setSnapshotWikis,
setSnapshotEntityWikiLinks,
setEntityFormStatus,
setSelectedFeatureId,
setSelectedFeatureIds,
setEntityStatus,
setIsSaving,
setIsSubmitting,
@@ -682,14 +690,14 @@ export default function Page() {
}, [geoSearchRequestRef, searchKind, searchQuery]);
useEffect(() => {
if (selectedFeatureId === null) return;
const stillExists = timelineVisibleDraft.features.some((feature) =>
String(feature.properties.id) === String(selectedFeatureId)
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
const stillExistIds = selectedFeatureIds.filter(id =>
timelineVisibleDraft.features.some(feature => String(feature.properties.id) === String(id))
);
if (!stillExists) {
setSelectedFeatureId(null);
if (stillExistIds.length !== selectedFeatureIds.length) {
setSelectedFeatureIds(stillExistIds);
}
}, [timelineVisibleDraft, selectedFeatureId, setSelectedFeatureId]);
}, [timelineVisibleDraft, selectedFeatureIds, setSelectedFeatureIds]);
useEffect(() => {
if (!selectedFeature) {
@@ -868,10 +876,14 @@ export default function Page() {
}, [editor, flashEntityFormStatus]);
const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => {
if (!selectedFeature) {
if (!selectedFeatures || selectedFeatures.length === 0) {
flashEntityFormStatus("Chưa chọn geometry để bind entity.");
return;
}
if (!isMultiEditValid) {
flashEntityFormStatus("Không thể bind entity cho nhiều geometry khác loại.");
return;
}
const id = String(entityId || "").trim();
if (!id) return;
const nextEntityIds = (() => {
@@ -888,10 +900,12 @@ export default function Page() {
setIsEntitySubmitting(true);
flashEntityFormStatus(null, 0);
try {
editor.patchFeatureProperties(
selectedFeature.properties.id,
buildFeatureEntityPatch(selectedFeature, nextEntityIds, entities)
);
for (const feature of selectedFeatures) {
editor.patchFeatureProperties(
feature.properties.id,
buildFeatureEntityPatch(feature, nextEntityIds, entities)
);
}
setSelectedGeometryEntityIds(nextEntityIds);
flashEntityFormStatus(
nextChecked
@@ -906,37 +920,53 @@ export default function Page() {
editor,
entities,
flashEntityFormStatus,
selectedFeature,
selectedFeatures,
isMultiEditValid,
selectedGeometryEntityIds,
setIsEntitySubmitting,
setSelectedGeometryEntityIds,
]);
const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => {
if (!selectedFeature) {
if (!selectedFeatures || selectedFeatures.length === 0) {
flashGeoBindingStatus("Chưa chọn geometry để bind.");
return;
}
if (!isMultiEditValid) {
flashGeoBindingStatus("Không thể bind geometry cho nhiều geometry khác loại.");
return;
}
const id = String(geoId || "").trim();
if (!id) return;
if (String(selectedFeature.properties.id) === id) return;
if (selectedFeatures.some(f => String(f.properties.id) === id)) return;
const prevBindingIds = normalizeFeatureBindingIds(selectedFeature);
const has = prevBindingIds.includes(id);
const nextBindingIds = (() => {
if (nextChecked) {
if (has) return prevBindingIds;
return [...prevBindingIds, id];
}
if (!has) return prevBindingIds;
return prevBindingIds.filter((x) => x !== id);
})();
setIsEntitySubmitting(true);
flashGeoBindingStatus(null, 0);
try {
editor.patchFeatureProperties(selectedFeature.properties.id, { binding: nextBindingIds });
setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIds.join(", ") }));
for (const feature of selectedFeatures) {
const prevBindingIds = normalizeFeatureBindingIds(feature);
const has = prevBindingIds.includes(id);
const nextBindingIds = (() => {
if (nextChecked) {
if (has) return prevBindingIds;
return [...prevBindingIds, id];
}
if (!has) return prevBindingIds;
return prevBindingIds.filter((x) => x !== id);
})();
editor.patchFeatureProperties(feature.properties.id, { binding: nextBindingIds });
}
// Assume selectedFeature (the first one) reflects the representative binding in UI
const firstFeaturePrevBindings = normalizeFeatureBindingIds(selectedFeatures[0]);
const firstFeatureHas = firstFeaturePrevBindings.includes(id);
const nextBindingIdsForUI = (() => {
if (nextChecked) return firstFeatureHas ? firstFeaturePrevBindings : [...firstFeaturePrevBindings, id];
return firstFeatureHas ? firstFeaturePrevBindings.filter(x => x !== id) : firstFeaturePrevBindings;
})();
setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIdsForUI.join(", ") }));
flashGeoBindingStatus(
nextChecked
? "Đã bind geometry vào binding. Commit khi sẵn sàng."
@@ -949,7 +979,8 @@ export default function Page() {
}, [
editor,
flashGeoBindingStatus,
selectedFeature,
selectedFeatures,
isMultiEditValid,
setGeometryMetaForm,
setIsEntitySubmitting,
]);
@@ -996,7 +1027,7 @@ export default function Page() {
const existing = editor.draft.features.find((f) => String(f.properties.id) === geoId) || null;
if (existing) {
setSelectedFeatureId(existing.properties.id);
setSelectedFeatureIds([existing.properties.id]);
flashEntityFormStatus("Đã chọn geometry từ kết quả search.", 3000);
return;
}
@@ -1027,19 +1058,19 @@ export default function Page() {
};
editor.createFeature(feature);
setSelectedFeatureId(feature.properties.id);
setSelectedFeatureIds([feature.properties.id]);
flashEntityFormStatus("Đã import geometry từ search GEO. Commit khi sẵn sàng.", 3000);
}, [
editor,
flashEntityFormStatus,
handleAddEntityRefToProject,
setSelectedFeatureId,
setSelectedFeatureIds,
setTimelineFilterEnabled,
]);
const featureCommands = useFeatureCommands({
editor,
selectedFeature,
selectedFeatures,
geometryMetaForm,
setGeometryMetaForm,
selectedGeometryEntityIds,
@@ -1119,7 +1150,7 @@ export default function Page() {
const handleCreateFeature = (feature: Feature) => {
editor.createFeature(feature);
setSelectedFeatureId(feature.properties.id);
setSelectedFeatureIds([feature.properties.id]);
};
return (
@@ -1205,8 +1236,8 @@ export default function Page() {
<Map
mode={mode}
draft={timelineVisibleDraft}
selectedFeatureId={selectedFeatureId}
onSelectFeatureId={setSelectedFeatureId}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={setSelectedFeatureIds}
onCreateFeature={handleCreateFeature}
onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature}
@@ -1416,8 +1447,8 @@ export default function Page() {
</div>
) : null}
{Array.isArray(item.geometries) && item.geometries.length ? (
<div style={{ display: "grid", gap: 6 }}>
{item.geometries.slice(0, 4).map((geo) => (
<div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
{item.geometries.map((geo) => (
<div
key={geo.id}
style={{
@@ -1462,11 +1493,6 @@ export default function Page() {
</button>
</div>
))}
{item.geometries.length > 4 ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>
+{item.geometries.length - 4} more
</div>
) : null}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>
@@ -1520,7 +1546,7 @@ export default function Page() {
/>
{!wikiOnly && selectedFeature ? (
<SelectedGeometryPanel
selectedFeature={selectedFeature}
selectedFeatures={selectedFeatures}
selectedFeatureEntitySummary={
selectedFeature
? formatEntityNamesForDisplay(selectedFeature, entities)
+26 -23
View File
@@ -57,7 +57,7 @@ const EMPTY_RELATIONS: RelationIndex = {
export default function Page() {
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]);
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
const [timeRange, setTimeRange] = useState<number>(0);
@@ -94,17 +94,21 @@ export default function Page() {
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
const selectedFeature = useMemo(() => {
if (selectedFeatureId === null) return null;
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return null;
return (
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureIds[0])) || null
);
}, [data.features, selectedFeatureId]);
}, [data.features, selectedFeatureIds]);
useEffect(() => {
if (selectedFeatureId === null) return;
const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId));
if (!stillExists) setSelectedFeatureId(null);
}, [data.features, selectedFeatureId]);
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
const stillExistIds = selectedFeatureIds.filter(id =>
data.features.some(feature => String(feature.properties.id) === String(id))
);
if (stillExistIds.length !== selectedFeatureIds.length) {
setSelectedFeatureIds(stillExistIds);
}
}, [data.features, selectedFeatureIds]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
@@ -315,25 +319,26 @@ export default function Page() {
if (options?.focusMap !== false) {
setEntityFocusToken((prev) => prev + 1);
}
if (options?.selectGeometry && options?.sourceFeatureId !== undefined) {
setSelectedFeatureId(options.sourceFeatureId);
if (options?.selectGeometry && options?.sourceFeatureId != null) {
setSelectedFeatureIds([options.sourceFeatureId]);
}
}, [relations.entitiesById, relations.entityWikisById]);
useEffect(() => {
if (selectedFeatureId === null) return;
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureId)] || [];
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
// For UI simplicity in viewer, just link to the first selected geometry
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
if (linkedEntityIds.length !== 1) return;
const onlyEntityId = linkedEntityIds[0];
if (activeEntityId === onlyEntityId) return;
selectEntity(onlyEntityId, {
sourceFeatureId: selectedFeatureId,
sourceFeatureId: selectedFeatureIds[0],
focusMap: false,
selectGeometry: false,
});
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureId]);
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
const handleMapHoverChange = useCallback((payload: MapHoverPayload | null) => {
clearHoverHideTimer();
@@ -461,12 +466,12 @@ export default function Page() {
<Map
mode="select"
draft={data}
selectedFeatureId={selectedFeatureId}
onSelectFeatureId={setSelectedFeatureId}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={setSelectedFeatureIds}
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
allowGeometryEditing={false}
respectBindingFilter={false}
respectBindingFilter={true}
onHoverFeatureChange={handleMapHoverChange}
highlightFeatures={activeEntityGeometries}
focusFeatureCollection={activeEntityGeometries}
@@ -514,11 +519,10 @@ export default function Page() {
key={layer.id}
type="button"
onClick={() => handleToggleBackgroundLayer(layer.id)}
className={`rounded-md border px-2.5 py-1 text-xs transition ${
active
className={`rounded-md border px-2.5 py-1 text-xs transition ${active
? "border-sky-400/40 bg-sky-500/10 text-sky-200"
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
}`}
}`}
>
{layer.label}
</button>
@@ -544,11 +548,10 @@ export default function Page() {
[typeKey]: prev[typeKey] === false,
}));
}}
className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${
active
className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${active
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200"
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
}`}
}`}
>
{typeKey.replaceAll("_", " ")}
</button>