editor panel improve experience

This commit is contained in:
taDuc
2026-05-12 21:54:56 +07:00
parent cb3e720644
commit e725b52590
4 changed files with 145 additions and 184 deletions
-21
View File
@@ -47,13 +47,9 @@ import {
} from "@/uhm/lib/editor/snapshot/editorSnapshot"; } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import { import {
buildClientEntityId, buildClientEntityId,
formatEntityNamesForDisplay,
mergeEntitySearchResults, mergeEntitySearchResults,
} from "@/uhm/lib/editor/entity/entityBinding"; } from "@/uhm/lib/editor/entity/entityBinding";
import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding"; import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
import {
formatBindingIdsForDisplay,
} from "@/uhm/lib/editor/geometry/geometryMetadata";
import { import {
loadBackgroundLayerVisibilityFromStorage, loadBackgroundLayerVisibilityFromStorage,
persistBackgroundLayerVisibility, persistBackgroundLayerVisibility,
@@ -826,10 +822,6 @@ export default function Page() {
setGeometryMetaForm((prev) => ({ ...prev, [key]: value })); setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
}; };
const handleEntityIdsChange = (values: string[]) => {
setSelectedGeometryEntityIds(uniqueEntityIds(values));
};
const handleAddEntityRefToProject = useCallback((entity: Entity) => { const handleAddEntityRefToProject = useCallback((entity: Entity) => {
const id = String(entity.id || "").trim(); const id = String(entity.id || "").trim();
if (!id) return; if (!id) return;
@@ -1600,19 +1592,6 @@ export default function Page() {
{!wikiOnly && selectedFeature ? ( {!wikiOnly && selectedFeature ? (
<SelectedGeometryPanel <SelectedGeometryPanel
selectedFeatures={selectedFeatures} selectedFeatures={selectedFeatures}
selectedFeatureEntitySummary={
selectedFeature
? formatEntityNamesForDisplay(selectedFeature, entities)
: "Chưa gắn"
}
selectedFeatureBindingSummary={
selectedFeature
? formatBindingIdsForDisplay(selectedFeature)
: "Không có"
}
entities={entities}
selectedGeometryEntityIds={selectedGeometryEntityIds}
onEntityIdsChange={handleEntityIdsChange}
entityTypeOptions={GEOMETRY_TYPE_OPTIONS} entityTypeOptions={GEOMETRY_TYPE_OPTIONS}
geometryMetaForm={geometryMetaForm} geometryMetaForm={geometryMetaForm}
onGeometryMetaFormChange={handleGeometryMetaFormChange} onGeometryMetaFormChange={handleGeometryMetaFormChange}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState, type KeyboardEvent } from "react"; import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
import NewBadge from "@/uhm/components/editor/NewBadge"; import NewBadge from "@/uhm/components/editor/NewBadge";
type GeometryChoice = { type GeometryChoice = {
@@ -49,6 +49,16 @@ export default function GeometryBindingPanel({
if (!selectedGeometryId) return null; if (!selectedGeometryId) return null;
return rows.find((g) => g.id === selectedGeometryId) || null; return rows.find((g) => g.id === selectedGeometryId) || null;
}, [rows, selectedGeometryId]); }, [rows, selectedGeometryId]);
const visibleRows = useMemo(() => {
return rows
.filter((g) => g.id !== selectedGeometryId)
.sort((a, b) => {
const aBound = bindingSet.has(a.id);
const bBound = bindingSet.has(b.id);
if (aBound !== bBound) return aBound ? -1 : 1;
return a.id.localeCompare(b.id);
});
}, [bindingSet, rows, selectedGeometryId]);
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => { const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
if (!canFocusGeometry) return; if (!canFocusGeometry) return;
@@ -174,8 +184,7 @@ export default function GeometryBindingPanel({
{collapsed ? null : rows.length ? ( {collapsed ? null : rows.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}> <div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{rows {visibleRows
.filter((g) => g.id !== selectedGeometryId)
.map((g) => { .map((g) => {
const isBound = bindingSet.has(g.id); const isBound = bindingSet.has(g.id);
return ( return (
@@ -184,8 +193,8 @@ export default function GeometryBindingPanel({
style={{ style={{
padding: "8px", padding: "8px",
borderRadius: "6px", borderRadius: "6px",
border: "1px solid #1f2937", border: isBound ? "1px solid rgba(20, 184, 166, 0.65)" : "1px solid #1f2937",
background: "transparent", background: isBound ? "rgba(20, 184, 166, 0.12)" : "transparent",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
@@ -219,6 +228,7 @@ export default function GeometryBindingPanel({
> >
{g.label || g.id} {g.label || g.id}
</span> </span>
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
{g.isNew ? <NewBadge /> : null} {g.isNew ? <NewBadge /> : null}
</div> </div>
<div <div
@@ -283,6 +293,24 @@ export default function GeometryBindingPanel({
); );
} }
const boundBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(45, 212, 191, 0.5)",
background: "rgba(20, 184, 166, 0.18)",
color: "#99f6e4",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
function LockIcon() { function LockIcon() {
return ( return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -39,6 +39,22 @@ export default function ProjectEntityRefsPanel({
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [activeEntityId, setActiveEntityId] = useState<string | null>(null); const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const selectedEntityIdSet = useMemo(
() => new Set((selectedGeometryEntityIds || []).map(String)),
[selectedGeometryEntityIds]
);
const sortedEntityRefs = useMemo(() => {
const rows = [...(entityRefs || [])];
rows.sort((a, b) => {
const aBound = selectedEntityIdSet.has(String(a.id));
const bBound = selectedEntityIdSet.has(String(b.id));
if (aBound !== bBound) return aBound ? -1 : 1;
const aLabel = String(a.name || a.id || "");
const bLabel = String(b.name || b.id || "");
return aLabel.localeCompare(bLabel);
});
return rows;
}, [entityRefs, selectedEntityIdSet]);
const activeEntity = useMemo( const activeEntity = useMemo(
() => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null), () => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null),
@@ -90,83 +106,93 @@ export default function ProjectEntityRefsPanel({
</div> </div>
</div> </div>
{collapsed ? null : entityRefs.length ? ( {collapsed ? null : sortedEntityRefs.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}> <div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{entityRefs.map((e) => ( {sortedEntityRefs.map((e) => {
<div const entityId = String(e.id);
key={e.id} const isBoundToSelectedGeometry = selectedEntityIdSet.has(entityId);
style={{ const isActive = activeEntityId === entityId;
padding: "8px", return (
borderRadius: "6px", <div
border: activeEntityId === String(e.id) ? "1px solid #2563eb" : "1px solid #1f2937", key={e.id}
background: "transparent",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<button
type="button"
onClick={() => openEntityEditor(e)}
title="Chon de sua"
style={{ style={{
flex: 1, padding: "8px",
minWidth: 0, borderRadius: "6px",
textAlign: "left", border: isActive
border: "none", ? "1px solid #2563eb"
background: "transparent", : isBoundToSelectedGeometry
padding: 0, ? "1px solid rgba(20, 184, 166, 0.65)"
cursor: canEditEntity ? "pointer" : "default", : "1px solid #1f2937",
background: isBoundToSelectedGeometry ? "rgba(20, 184, 166, 0.12)" : "transparent",
display: "flex",
alignItems: "center",
gap: 10,
}} }}
disabled={!canEditEntity}
> >
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.name || e.id}
</span>
{isNewEntityRef(e) ? <NewBadge /> : null}
</div>
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.id}
</div>
</button>
{canBindToggle ? (
<button <button
type="button" type="button"
title={selectedGeometryEntityIds!.includes(String(e.id)) ? "Unbind from selected geometry" : "Bind to selected geometry"} onClick={() => openEntityEditor(e)}
onClick={() => title="Chon de sua"
onToggleBindEntityForSelectedGeometry!(
String(e.id),
!selectedGeometryEntityIds!.includes(String(e.id))
)
}
style={{ style={{
display: "inline-flex", flex: 1,
alignItems: "center", minWidth: 0,
justifyContent: "center", textAlign: "left",
width: 22, border: "none",
height: 22, background: "transparent",
borderRadius: 6, padding: 0,
border: "1px solid #334155", cursor: canEditEntity ? "pointer" : "default",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
}} }}
aria-label={ disabled={!canEditEntity}
selectedGeometryEntityIds!.includes(String(e.id))
? `Unbind entity ${String(e.id)} from selected geometry`
: `Bind entity ${String(e.id)} to selected geometry`
}
> >
{selectedGeometryEntityIds!.includes(String(e.id)) ? ( <div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<UnlockIcon /> <span style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
) : ( {e.name || e.id}
<LockIcon /> </span>
)} {isBoundToSelectedGeometry ? <span style={boundBadgeStyle}>bound</span> : null}
{isNewEntityRef(e) ? <NewBadge /> : null}
</div>
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.id}
</div>
</button> </button>
) : null} {canBindToggle ? (
</div> <button
))} type="button"
title={isBoundToSelectedGeometry ? "Unbind from selected geometry" : "Bind to selected geometry"}
onClick={() =>
onToggleBindEntityForSelectedGeometry!(
entityId,
!isBoundToSelectedGeometry
)
}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
}}
aria-label={
isBoundToSelectedGeometry
? `Unbind entity ${entityId} from selected geometry`
: `Bind entity ${entityId} to selected geometry`
}
>
{isBoundToSelectedGeometry ? (
<UnlockIcon />
) : (
<LockIcon />
)}
</button>
) : null}
</div>
);
})}
</div> </div>
) : ( ) : (
@@ -358,6 +384,24 @@ const entityInputStyle: CSSProperties = {
fontSize: "13px", fontSize: "13px",
}; };
const boundBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(45, 212, 191, 0.5)",
background: "rgba(20, 184, 166, 0.18)",
color: "#99f6e4",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
function LockIcon() { function LockIcon() {
return ( return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -1,7 +1,6 @@
"use client"; "use client";
import { type CSSProperties, useMemo, useState } from "react"; import { type CSSProperties, useMemo, useState } from "react";
import { Entity } from "@/uhm/api/entities";
import { Feature } from "@/uhm/lib/editor/state/useEditorState"; import { Feature } from "@/uhm/lib/editor/state/useEditorState";
import { import {
GeometryPreset, GeometryPreset,
@@ -14,11 +13,6 @@ import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionType
type Props = { type Props = {
selectedFeatures: Feature[]; selectedFeatures: Feature[];
selectedFeatureEntitySummary: string;
selectedFeatureBindingSummary: string;
entities: Entity[];
selectedGeometryEntityIds: string[];
onEntityIdsChange: (values: string[]) => void;
entityTypeOptions: GeometryTypeOption[]; entityTypeOptions: GeometryTypeOption[];
geometryMetaForm: GeometryMetaFormState; geometryMetaForm: GeometryMetaFormState;
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void; onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
@@ -29,11 +23,6 @@ type Props = {
export default function SelectedGeometryPanel({ export default function SelectedGeometryPanel({
selectedFeatures, selectedFeatures,
selectedFeatureEntitySummary,
selectedFeatureBindingSummary,
entities,
selectedGeometryEntityIds,
onEntityIdsChange,
entityTypeOptions, entityTypeOptions,
geometryMetaForm, geometryMetaForm,
onGeometryMetaFormChange, onGeometryMetaFormChange,
@@ -103,7 +92,7 @@ export default function SelectedGeometryPanel({
> >
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}> <div style={{ fontWeight: 700, fontSize: "14px" }}>
Entity & Geometry Geometry property
</div> </div>
<button <button
type="button" type="button"
@@ -130,67 +119,6 @@ export default function SelectedGeometryPanel({
{collapsed ? null : ( {collapsed ? null : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}> <div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<div style={{ color: "#e2e8f0" }}>
ID: {selectedFeatures.map(f => String(f.properties.id)).join(", ")}
</div>
<div style={{ color: "#cbd5e1" }}>
Entities hiện tại: {selectedFeatureEntitySummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Binding hiện tại: {selectedFeatureBindingSummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Entities đã chọn:
</div>
{selectedGeometryEntityIds.length ? (
<div style={{ display: "grid", gap: "6px" }}>
{selectedGeometryEntityIds.map((entityId) => {
const entity = entities.find((item) => item.id === entityId) || null;
const label = entity?.name
? `${entity.name} (${entityId})`
: entityId;
return (
<div
key={entityId}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
background: "#111827",
border: "1px solid #334155",
borderRadius: "6px",
padding: "6px 8px",
}}
>
<span style={{ color: "#e2e8f0" }}>{label}</span>
<button
type="button"
onClick={() =>
onEntityIdsChange(
selectedGeometryEntityIds.filter((id) => id !== entityId)
)
}
disabled={isEntitySubmitting}
style={removeButtonStyle}
>
Bỏ
</button>
</div>
);
})}
</div>
) : (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Chưa entity nào đưc gắn.
</div>
)}
<div <div
style={{ style={{
display: "grid", display: "grid",
@@ -306,16 +234,6 @@ const entityInputStyle: CSSProperties = {
fontSize: "13px", fontSize: "13px",
}; };
const removeButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "4px 8px",
cursor: "pointer",
background: "#7f1d1d",
color: "#ffffff",
fontSize: "12px",
};
const primaryGeometryButtonStyle: CSSProperties = { const primaryGeometryButtonStyle: CSSProperties = {
border: "none", border: "none",
borderRadius: "6px", borderRadius: "6px",
@@ -404,11 +322,3 @@ function getAllowedGroupIdsForPreset(
return ["polygon"]; return ["polygon"];
} }
function formatGeometryPresetLabel(preset: GeometryPreset | null): string {
if (preset === "point") return "point - Điểm";
if (preset === "line") return "line - Tuyến";
if (preset === "circle-area") return "circle - Tròn";
if (preset === "polygon") return "polygon - Đa giác";
return "unknown";
}