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:
@@ -208,9 +208,9 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
||||
{!activeEntityId ? (
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
|
||||
) : activeLinks.size ? (
|
||||
<div style={{ display: "grid", gap: "6px" }}>
|
||||
<div style={{ display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
|
||||
{Array.from(activeLinks).slice(0, 8).map((id) => {
|
||||
{Array.from(activeLinks).map((id) => {
|
||||
const w = wikiChoices.find((x) => x.id === id) || null;
|
||||
return (
|
||||
<div
|
||||
@@ -264,9 +264,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{activeLinks.size > 8 ? (
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>+{activeLinks.size - 8} more…</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
|
||||
|
||||
@@ -41,7 +41,6 @@ export default function GeometryBindingPanel({
|
||||
|
||||
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
||||
|
||||
const visibleRows = rows.slice(0, 12);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -101,8 +100,8 @@ export default function GeometryBindingPanel({
|
||||
</div>
|
||||
|
||||
{collapsed ? null : rows.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||
{visibleRows
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
||||
{rows
|
||||
.filter((g) => g.id !== selectedGeometryId)
|
||||
.map((g) => {
|
||||
const isBound = bindingSet.has(g.id);
|
||||
@@ -176,11 +175,7 @@ export default function GeometryBindingPanel({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{rows.length > visibleRows.length ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
|
||||
+{rows.length - visibleRows.length} more…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
||||
|
||||
+47
-33
@@ -43,8 +43,8 @@ type MapProps = {
|
||||
draft: FeatureCollection;
|
||||
backgroundVisibility: BackgroundLayerVisibility;
|
||||
geometryVisibility?: Record<string, boolean>;
|
||||
selectedFeatureId: string | number | null;
|
||||
onSelectFeatureId: (id: string | number | null) => void;
|
||||
selectedFeatureIds: (string | number)[];
|
||||
onSelectFeatureIds: (ids: (string | number)[]) => void;
|
||||
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
|
||||
onDeleteFeature?: (id: string | number) => void;
|
||||
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
|
||||
@@ -84,8 +84,8 @@ export default function Map({
|
||||
draft,
|
||||
backgroundVisibility,
|
||||
geometryVisibility,
|
||||
selectedFeatureId,
|
||||
onSelectFeatureId,
|
||||
selectedFeatureIds,
|
||||
onSelectFeatureIds,
|
||||
onCreateFeature,
|
||||
onDeleteFeature,
|
||||
onUpdateFeature,
|
||||
@@ -123,10 +123,10 @@ export default function Map({
|
||||
const focusFeatureCollectionRef = useRef<FeatureCollection | null>(focusFeatureCollection);
|
||||
const focusRequestKeyRef = useRef<MapProps["focusRequestKey"]>(focusRequestKey);
|
||||
const focusPaddingRef = useRef<MapProps["focusPadding"]>(focusPadding);
|
||||
// Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render).
|
||||
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
||||
// Mirror của callback onSelectFeatureId.
|
||||
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
||||
// Mirror của selectedFeatureIds để filter/select trên map (không phụ thuộc re-render).
|
||||
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
|
||||
// Mirror của callback onSelectFeatureIds.
|
||||
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
|
||||
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
|
||||
// Mirror của callback onCreateFeature.
|
||||
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
||||
@@ -225,26 +225,26 @@ export default function Map({
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedFeatureIdRef.current = selectedFeatureId;
|
||||
}, [selectedFeatureId]);
|
||||
selectedFeatureIdsRef.current = selectedFeatureIds;
|
||||
}, [selectedFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
onHoverFeatureChangeRef.current = onHoverFeatureChange;
|
||||
}, [onHoverFeatureChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "select" || selectedFeatureId === null) {
|
||||
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
|
||||
editingEngineRef.current?.clearEditing();
|
||||
}
|
||||
}, [mode, selectedFeatureId]);
|
||||
}, [mode, selectedFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
fitBoundsAppliedRef.current = false;
|
||||
}, [fitBoundsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectFeatureIdRef.current = onSelectFeatureId;
|
||||
}, [onSelectFeatureId]);
|
||||
onSelectFeatureIdsRef.current = onSelectFeatureIds;
|
||||
}, [onSelectFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
backgroundVisibilityRef.current = backgroundVisibility;
|
||||
@@ -315,7 +315,7 @@ export default function Map({
|
||||
}
|
||||
|
||||
const visibleDraftRaw = respectBindingFilterRef.current
|
||||
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
|
||||
? filterDraftByBinding(fc, selectedFeatureIdsRef.current, highlightFeaturesRef.current)
|
||||
: fc;
|
||||
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
|
||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||
@@ -326,11 +326,15 @@ export default function Map({
|
||||
(map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)
|
||||
?.setData(pathArrowShapes);
|
||||
|
||||
const selectedId = selectedFeatureIdRef.current;
|
||||
setSelectedFeatureState(map, selectedId, true);
|
||||
const currentSelectedIds = selectedFeatureIdsRef.current;
|
||||
currentSelectedIds.forEach((id) => {
|
||||
setSelectedFeatureState(map, id, true);
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
if (mapRef.current !== map) return;
|
||||
setSelectedFeatureState(map, selectedId, true);
|
||||
currentSelectedIds.forEach((id) => {
|
||||
setSelectedFeatureState(map, id, true);
|
||||
});
|
||||
});
|
||||
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
|
||||
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
|
||||
@@ -1034,7 +1038,7 @@ export default function Map({
|
||||
? (id: string | number) => {
|
||||
// ensure edit overlays are cleared when a feature gets removed
|
||||
editingEngineRef.current?.clearEditing();
|
||||
onSelectFeatureIdRef.current?.(null);
|
||||
onSelectFeatureIdsRef.current?.([]);
|
||||
onDeleteRef.current?.(id);
|
||||
}
|
||||
: undefined,
|
||||
@@ -1048,7 +1052,7 @@ export default function Map({
|
||||
editingEngineRef.current?.beginEditing((originalFeature || feature) as any);
|
||||
}
|
||||
: undefined,
|
||||
(id) => onSelectFeatureIdRef.current?.(id)
|
||||
(ids) => onSelectFeatureIdsRef.current?.(ids)
|
||||
);
|
||||
|
||||
const cleanupPoint = initPoint(
|
||||
@@ -1283,7 +1287,7 @@ export default function Map({
|
||||
editingEngineRef.current?.clearEditing();
|
||||
}
|
||||
}
|
||||
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
|
||||
}, [allowGeometryEditing, draft, selectedFeatureIds, applyDraftToMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestKey === null || focusRequestKey === undefined) return;
|
||||
@@ -1557,9 +1561,16 @@ function getSelectableLayers(map: maplibregl.Map): string[] {
|
||||
|
||||
function filterDraftByBinding(
|
||||
fc: FeatureCollection,
|
||||
selectedFeatureId: string | number | null
|
||||
selectedFeatureIds: (string | number)[],
|
||||
highlightFeatures?: FeatureCollection | null
|
||||
): FeatureCollection {
|
||||
const selectedId = selectedFeatureId !== null ? String(selectedFeatureId) : null;
|
||||
const selectedIds = new Set(selectedFeatureIds.map(String));
|
||||
if (highlightFeatures?.features) {
|
||||
for (const f of highlightFeatures.features) {
|
||||
if (f.properties?.id != null) selectedIds.add(String(f.properties.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Semantics:
|
||||
// - A feature's `binding` is a list of "child" geometry ids.
|
||||
// - Child geometries are hidden by default, and only shown when their parent is selected.
|
||||
@@ -1570,21 +1581,24 @@ function filterDraftByBinding(
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedId === null) {
|
||||
if (selectedIds.size === 0) {
|
||||
return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) };
|
||||
}
|
||||
|
||||
const selectedFeature =
|
||||
fc.features.find((feature) => String(feature.properties.id) === selectedId) || null;
|
||||
const selectedChildren = new Set<string>(
|
||||
normalizeBindingIds(selectedFeature?.properties.binding)
|
||||
);
|
||||
const selectedChildren = new Set<string>();
|
||||
for (const feature of fc.features) {
|
||||
if (selectedIds.has(String(feature.properties.id))) {
|
||||
for (const id of normalizeBindingIds(feature.properties.binding)) {
|
||||
selectedChildren.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...fc,
|
||||
features: fc.features.filter((feature) => {
|
||||
const featureId = String(feature.properties.id);
|
||||
if (featureId === selectedId) return true;
|
||||
if (selectedIds.has(featureId)) return true;
|
||||
if (selectedChildren.has(featureId)) return true;
|
||||
return !childIds.has(featureId);
|
||||
}),
|
||||
@@ -1778,9 +1792,9 @@ function buildPathArrowGeometry(coords: [number, number][]): Geometry | null {
|
||||
|
||||
if (bodyPoints.length < 2) return null;
|
||||
|
||||
const tailWidth = clampNumber(totalLength * 0.018, 25000, 140000);
|
||||
const shoulderWidth = clampNumber(totalLength * 0.055, 60000, 420000);
|
||||
const headWidth = shoulderWidth * 1.65;
|
||||
const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000);
|
||||
const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000);
|
||||
const headWidth = shoulderWidth * 2.0;
|
||||
|
||||
const leftBody: ProjectedPoint[] = [];
|
||||
const rightBody: ProjectedPoint[] = [];
|
||||
|
||||
@@ -97,8 +97,8 @@ export default function ProjectEntityRefsPanel({
|
||||
</div>
|
||||
|
||||
{collapsed ? null : entityRefs.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||
{entityRefs.slice(0, 8).map((e) => (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
||||
{entityRefs.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
style={{
|
||||
@@ -170,7 +170,7 @@ export default function ProjectEntityRefsPanel({
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{entityRefs.length > 8 ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>+{entityRefs.length - 8} more…</div> : null}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
||||
|
||||
@@ -258,11 +258,10 @@ export default function PublicWikiSidebar({
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs transition ${
|
||||
isActive
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs transition ${isActive
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300"
|
||||
: "bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
type Props = {
|
||||
selectedFeature: Feature | null;
|
||||
selectedFeatures: Feature[];
|
||||
selectedFeatureEntitySummary: string;
|
||||
selectedFeatureBindingSummary: string;
|
||||
entities: Entity[];
|
||||
@@ -28,7 +28,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function SelectedGeometryPanel({
|
||||
selectedFeature,
|
||||
selectedFeatures,
|
||||
selectedFeatureEntitySummary,
|
||||
selectedFeatureBindingSummary,
|
||||
entities,
|
||||
@@ -78,10 +78,11 @@ export default function SelectedGeometryPanel({
|
||||
const visibleGeoApplyFeedback =
|
||||
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
|
||||
|
||||
if (!selectedFeature) return null;
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) return null;
|
||||
const representativeFeature = selectedFeatures[0];
|
||||
|
||||
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
|
||||
const featureGeometryPreset = resolveFeatureGeometryPreset(selectedFeature);
|
||||
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
|
||||
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
||||
const groupedGeoTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
||||
allowedGroupIds.includes(group.id)
|
||||
@@ -130,7 +131,7 @@ export default function SelectedGeometryPanel({
|
||||
{collapsed ? null : (
|
||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||
<div style={{ color: "#e2e8f0" }}>
|
||||
ID: {String(selectedFeature.properties.id)}
|
||||
ID: {selectedFeatures.map(f => String(f.properties.id)).join(", ")}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1" }}>
|
||||
Entities hiện tại: {selectedFeatureEntitySummary}
|
||||
|
||||
@@ -575,8 +575,8 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
||||
</div>
|
||||
|
||||
{collapsed ? null : wikis.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||
{wikis.slice(0, 8).map((w) => (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
||||
{wikis.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
style={{
|
||||
@@ -629,9 +629,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{wikis.length > 8 ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikis.length - 8} more…</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
||||
|
||||
Reference in New Issue
Block a user