refactor(important): reduce editor drill state
This commit is contained in:
@@ -1,34 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { persistBackgroundLayerVisibility } from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerId,
|
||||
BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type Props = {
|
||||
visibility: BackgroundLayerVisibility;
|
||||
onToggleLayer: (id: BackgroundLayerId) => void;
|
||||
onShowAll: () => void;
|
||||
onHideAll: () => void;
|
||||
geometryVisibility?: Record<string, boolean>;
|
||||
onToggleGeometryType?: (typeKey: string) => void;
|
||||
topContent?: ReactNode;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export default function BackgroundLayersPanel({
|
||||
visibility,
|
||||
onToggleLayer,
|
||||
onShowAll,
|
||||
onHideAll,
|
||||
geometryVisibility,
|
||||
onToggleGeometryType,
|
||||
topContent,
|
||||
width = 240,
|
||||
}: Props) {
|
||||
const {
|
||||
visibility,
|
||||
setBackgroundVisibility,
|
||||
geometryVisibility,
|
||||
setGeometryVisibility,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
visibility: state.backgroundVisibility,
|
||||
setBackgroundVisibility: state.setBackgroundVisibility,
|
||||
geometryVisibility: state.geometryVisibility,
|
||||
setGeometryVisibility: state.setGeometryVisibility,
|
||||
}))
|
||||
);
|
||||
|
||||
const updateBackgroundVisibility = (
|
||||
updater: (prev: typeof visibility) => typeof visibility
|
||||
) => {
|
||||
setBackgroundVisibility((prev) => {
|
||||
const next = updater(prev);
|
||||
persistBackgroundLayerVisibility(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleLayer = (id: BackgroundLayerId) => {
|
||||
updateBackgroundVisibility((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleShowAll = () => {
|
||||
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
const handleHideAll = () => {
|
||||
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
@@ -52,7 +83,7 @@ export default function BackgroundLayersPanel({
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
onClick={() => onToggleLayer(layer.id)}
|
||||
onClick={() => handleToggleLayer(layer.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
@@ -75,44 +106,75 @@ export default function BackgroundLayersPanel({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{geometryVisibility && onToggleGeometryType ? (
|
||||
<>
|
||||
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
|
||||
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
|
||||
Geometries
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
|
||||
{GEO_TYPE_KEYS.map((typeKey) => {
|
||||
const on = geometryVisibility[typeKey] !== false;
|
||||
return (
|
||||
<button
|
||||
key={typeKey}
|
||||
type="button"
|
||||
onClick={() => onToggleGeometryType(typeKey)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: "pointer",
|
||||
color: on ? "#22c55e" : "#e5e7eb",
|
||||
textDecorationLine: on ? "none" : "line-through",
|
||||
textDecorationThickness: on ? undefined : "2px",
|
||||
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
|
||||
fontSize: 13,
|
||||
fontWeight: 750,
|
||||
whiteSpace: "nowrap",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
title={on ? "On" : "Off"}
|
||||
>
|
||||
{typeKey.replaceAll("_", " ")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div style={{ marginTop: 10, display: "flex", gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowAll}
|
||||
style={secondaryButtonStyle}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHideAll}
|
||||
style={secondaryButtonStyle}
|
||||
>
|
||||
Hide all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
|
||||
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
|
||||
Geometries
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
|
||||
{GEO_TYPE_KEYS.map((typeKey) => {
|
||||
const on = geometryVisibility[typeKey] !== false;
|
||||
return (
|
||||
<button
|
||||
key={typeKey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setGeometryVisibility((prev) => ({
|
||||
...prev,
|
||||
[typeKey]: prev[typeKey] === false,
|
||||
}));
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: "pointer",
|
||||
color: on ? "#22c55e" : "#e5e7eb",
|
||||
textDecorationLine: on ? "none" : "line-through",
|
||||
textDecorationThickness: on ? undefined : "2px",
|
||||
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
|
||||
fontSize: 13,
|
||||
fontWeight: 750,
|
||||
whiteSpace: "nowrap",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
title={on ? "On" : "Off"}
|
||||
>
|
||||
{typeKey.replaceAll("_", " ")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const secondaryButtonStyle = {
|
||||
border: "1px solid #334155",
|
||||
borderRadius: 6,
|
||||
background: "#0b1220",
|
||||
color: "#cbd5e1",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
padding: "6px 8px",
|
||||
} as const;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type EntityChoice = { id: string; name: string; isNew?: boolean };
|
||||
type WikiChoice = { id: string; title: string; isNew?: boolean };
|
||||
@@ -18,9 +20,6 @@ type BindingRow = {
|
||||
};
|
||||
|
||||
type Props = {
|
||||
entities: EntityChoice[];
|
||||
wikis: WikiSnapshot[];
|
||||
links: EntityWikiLinkSnapshot[];
|
||||
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
};
|
||||
|
||||
@@ -29,7 +28,20 @@ function wikiTitle(w: WikiSnapshot): string {
|
||||
return t.length ? t : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
||||
export default function EntityWikiBindingsPanel({ setLinks }: Props) {
|
||||
const {
|
||||
entityCatalog,
|
||||
snapshotEntities,
|
||||
wikis,
|
||||
links,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
entityCatalog: state.entityCatalog,
|
||||
snapshotEntities: state.snapshotEntities,
|
||||
wikis: state.snapshotWikis,
|
||||
links: state.snapshotEntityWikiLinks,
|
||||
}))
|
||||
);
|
||||
const [activeEntityId, setActiveEntityId] = useState<string>("");
|
||||
const [activeWikiId, setActiveWikiId] = useState<string>("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -46,11 +58,29 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
||||
[wikis]
|
||||
);
|
||||
|
||||
const entityChoices = useMemo(() => {
|
||||
const cleaned = (entities || []).filter((e) => e && typeof e.id === "string" && e.id.trim().length > 0);
|
||||
cleaned.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return cleaned;
|
||||
}, [entities]);
|
||||
const entityChoices = useMemo<EntityChoice[]>(() => {
|
||||
const visibleSnapshotEntities = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
|
||||
for (const ref of snapshotEntities || []) {
|
||||
const id = String(ref?.id || "").trim();
|
||||
if (!id || ref?.operation === "delete" || visibleSnapshotEntities.has(id)) continue;
|
||||
visibleSnapshotEntities.set(id, {
|
||||
id,
|
||||
name: String(ref?.name || id),
|
||||
isNew: ref?.source === "inline" && ref?.operation === "create",
|
||||
});
|
||||
}
|
||||
|
||||
const rows = Array.from(visibleSnapshotEntities.values()).map((entity) => {
|
||||
const found = entityCatalog.find((item) => String(item.id) === entity.id) || null;
|
||||
return {
|
||||
id: entity.id,
|
||||
name: String(found?.name || entity.name || entity.id),
|
||||
isNew: entity.isNew,
|
||||
};
|
||||
});
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return rows;
|
||||
}, [entityCatalog, snapshotEntities]);
|
||||
|
||||
const activeLinks = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type GeometryChoice = {
|
||||
id: string;
|
||||
@@ -11,13 +13,10 @@ type GeometryChoice = {
|
||||
|
||||
type Props = {
|
||||
geometries: GeometryChoice[];
|
||||
selectedGeometryId: string | null;
|
||||
selectedGeometryId?: string | null;
|
||||
selectedGeometryBindingIds: string[];
|
||||
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
||||
onFocusGeometry?: (geometryId: string) => void;
|
||||
statusText?: string | null;
|
||||
bindingFilterEnabled: boolean;
|
||||
onBindingFilterEnabledChange: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export default function GeometryBindingPanel({
|
||||
@@ -26,12 +25,29 @@ export default function GeometryBindingPanel({
|
||||
selectedGeometryBindingIds,
|
||||
onToggleBindGeometryForSelectedGeometry,
|
||||
onFocusGeometry,
|
||||
statusText,
|
||||
bindingFilterEnabled,
|
||||
onBindingFilterEnabledChange,
|
||||
}: Props) {
|
||||
const {
|
||||
selectedFeatureIds,
|
||||
statusText,
|
||||
bindingFilterEnabled,
|
||||
setGeometryBindingFilterEnabled,
|
||||
hoveredGeometryId,
|
||||
setHoveredGeometryId,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
selectedFeatureIds: state.selectedFeatureIds,
|
||||
statusText: state.geoBindingStatus,
|
||||
bindingFilterEnabled: state.geometryBindingFilterEnabled,
|
||||
setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled,
|
||||
hoveredGeometryId: state.hoveredGeometryId,
|
||||
setHoveredGeometryId: state.setHoveredGeometryId,
|
||||
}))
|
||||
);
|
||||
const effectiveSelectedGeometryId =
|
||||
selectedGeometryId ??
|
||||
(selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null);
|
||||
const canBindToggle =
|
||||
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||
Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||
const canFocusGeometry = typeof onFocusGeometry === "function";
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -46,24 +62,30 @@ export default function GeometryBindingPanel({
|
||||
|
||||
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
||||
const selectedGeometry = useMemo(() => {
|
||||
if (!selectedGeometryId) return null;
|
||||
return rows.find((g) => g.id === selectedGeometryId) || null;
|
||||
}, [rows, selectedGeometryId]);
|
||||
if (!effectiveSelectedGeometryId) return null;
|
||||
return rows.find((g) => g.id === effectiveSelectedGeometryId) || null;
|
||||
}, [effectiveSelectedGeometryId, rows]);
|
||||
const visibleRows = useMemo(() => {
|
||||
return rows
|
||||
.filter((g) => g.id !== selectedGeometryId)
|
||||
.filter((g) => g.id !== effectiveSelectedGeometryId)
|
||||
.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]);
|
||||
}, [bindingSet, effectiveSelectedGeometryId, rows]);
|
||||
|
||||
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
|
||||
if (!canFocusGeometry) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
setHoveredGeometryId((current) => (current === geometryId ? null : current));
|
||||
onFocusGeometry?.(geometryId);
|
||||
};
|
||||
|
||||
const handleFocusGeometry = (geometryId: string) => {
|
||||
setHoveredGeometryId((current) => (current === geometryId ? null : current));
|
||||
onFocusGeometry?.(geometryId);
|
||||
};
|
||||
|
||||
@@ -75,6 +97,7 @@ export default function GeometryBindingPanel({
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
onMouseLeave={() => setHoveredGeometryId(null)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
||||
@@ -92,7 +115,7 @@ export default function GeometryBindingPanel({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bindingFilterEnabled}
|
||||
onChange={(e) => onBindingFilterEnabledChange(e.target.checked)}
|
||||
onChange={(e) => setGeometryBindingFilterEnabled(e.target.checked)}
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
|
||||
@@ -130,15 +153,26 @@ export default function GeometryBindingPanel({
|
||||
marginTop: 10,
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(59, 130, 246, 0.45)",
|
||||
background: "rgba(37, 99, 235, 0.12)",
|
||||
border:
|
||||
hoveredGeometryId === selectedGeometry.id
|
||||
? "1px solid rgba(245, 158, 11, 0.95)"
|
||||
: "1px solid rgba(59, 130, 246, 0.45)",
|
||||
background:
|
||||
hoveredGeometryId === selectedGeometry.id
|
||||
? "rgba(245, 158, 11, 0.18)"
|
||||
: "rgba(37, 99, 235, 0.12)",
|
||||
cursor: canFocusGeometry ? "pointer" : "default",
|
||||
boxShadow:
|
||||
hoveredGeometryId === selectedGeometry.id
|
||||
? "0 0 0 2px rgba(251, 191, 36, 0.18)"
|
||||
: "none",
|
||||
}}
|
||||
title={selectedGeometry.id}
|
||||
role={canFocusGeometry ? "button" : undefined}
|
||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||
onClick={() => onFocusGeometry?.(selectedGeometry.id)}
|
||||
onClick={() => handleFocusGeometry(selectedGeometry.id)}
|
||||
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
|
||||
onMouseEnter={() => setHoveredGeometryId(selectedGeometry.id)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -187,25 +221,36 @@ export default function GeometryBindingPanel({
|
||||
{visibleRows
|
||||
.map((g) => {
|
||||
const isBound = bindingSet.has(g.id);
|
||||
const isHovered = hoveredGeometryId === g.id;
|
||||
return (
|
||||
<div
|
||||
key={g.id}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: isBound ? "1px solid rgba(20, 184, 166, 0.65)" : "1px solid #1f2937",
|
||||
background: isBound ? "rgba(20, 184, 166, 0.12)" : "transparent",
|
||||
border: isHovered
|
||||
? "1px solid rgba(245, 158, 11, 0.95)"
|
||||
: isBound
|
||||
? "1px solid rgba(20, 184, 166, 0.65)"
|
||||
: "1px solid #1f2937",
|
||||
background: isHovered
|
||||
? "rgba(245, 158, 11, 0.18)"
|
||||
: isBound
|
||||
? "rgba(20, 184, 166, 0.12)"
|
||||
: "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
cursor: canFocusGeometry ? "pointer" : "default",
|
||||
opacity: canBindToggle ? 1 : 0.75,
|
||||
boxShadow: isHovered ? "0 0 0 2px rgba(251, 191, 36, 0.18)" : "none",
|
||||
}}
|
||||
title={g.id}
|
||||
role={canFocusGeometry ? "button" : undefined}
|
||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||
onClick={() => onFocusGeometry?.(g.id)}
|
||||
onClick={() => handleFocusGeometry(g.id)}
|
||||
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
|
||||
onMouseEnter={() => setHoveredGeometryId(g.id)}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
|
||||
@@ -2,34 +2,40 @@
|
||||
|
||||
import { useMemo, useState, type CSSProperties } from "react";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type Props = {
|
||||
entityRefs: EntitySnapshot[];
|
||||
entityForm: EntityFormState;
|
||||
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
|
||||
isEntitySubmitting: boolean;
|
||||
onCreateEntityOnly: () => void;
|
||||
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void;
|
||||
entityFormStatus: string | null;
|
||||
selectedGeometryEntityIds?: string[];
|
||||
hasSelectedGeometry?: boolean;
|
||||
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ProjectEntityRefsPanel({
|
||||
entityRefs,
|
||||
entityForm,
|
||||
onEntityFormChange,
|
||||
isEntitySubmitting,
|
||||
onCreateEntityOnly,
|
||||
onUpdateEntity,
|
||||
entityFormStatus,
|
||||
selectedGeometryEntityIds,
|
||||
hasSelectedGeometry,
|
||||
onToggleBindEntityForSelectedGeometry,
|
||||
}: Props) {
|
||||
const {
|
||||
snapshotEntities,
|
||||
entityForm,
|
||||
setEntityForm,
|
||||
isEntitySubmitting,
|
||||
entityFormStatus,
|
||||
selectedGeometryEntityIds,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
snapshotEntities: state.snapshotEntities,
|
||||
entityForm: state.entityForm,
|
||||
setEntityForm: state.setEntityForm,
|
||||
isEntitySubmitting: state.isEntitySubmitting,
|
||||
entityFormStatus: state.entityFormStatus,
|
||||
selectedGeometryEntityIds: state.selectedGeometryEntityIds,
|
||||
}))
|
||||
);
|
||||
const canBindToggle =
|
||||
Boolean(hasSelectedGeometry) &&
|
||||
Array.isArray(selectedGeometryEntityIds) &&
|
||||
@@ -43,6 +49,16 @@ export default function ProjectEntityRefsPanel({
|
||||
() => new Set((selectedGeometryEntityIds || []).map(String)),
|
||||
[selectedGeometryEntityIds]
|
||||
);
|
||||
const entityRefs = useMemo(() => {
|
||||
const byId = new globalThis.Map<string, EntitySnapshot>();
|
||||
for (const ref of snapshotEntities || []) {
|
||||
const id = String(ref?.id || "").trim();
|
||||
if (!id || byId.has(id)) continue;
|
||||
if (ref.operation === "delete") continue;
|
||||
byId.set(id, ref);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}, [snapshotEntities]);
|
||||
const sortedEntityRefs = useMemo(() => {
|
||||
const rows = [...(entityRefs || [])];
|
||||
rows.sort((a, b) => {
|
||||
@@ -68,6 +84,9 @@ export default function ProjectEntityRefsPanel({
|
||||
setEditName(typeof entity.name === "string" ? entity.name : "");
|
||||
setEditDescription(entity.description == null ? "" : String(entity.description));
|
||||
};
|
||||
const handleEntityFormChange = (key: "name" | "description", value: string) => {
|
||||
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -325,14 +344,14 @@ export default function ProjectEntityRefsPanel({
|
||||
<>
|
||||
<input
|
||||
value={entityForm.name}
|
||||
onChange={(event) => onEntityFormChange("name", event.target.value)}
|
||||
onChange={(event) => handleEntityFormChange("name", event.target.value)}
|
||||
placeholder="Tên entity mới"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={entityForm.description}
|
||||
onChange={(event) => onEntityFormChange("description", event.target.value)}
|
||||
onChange={(event) => handleEntityFormChange("description", event.target.value)}
|
||||
placeholder="Description"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { type CSSProperties, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { Feature } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import {
|
||||
GEOMETRY_TYPE_OPTIONS,
|
||||
GeometryPreset,
|
||||
GeometryTypeGroupId,
|
||||
GeometryTypeOption,
|
||||
findGeometryTypeOption,
|
||||
groupGeometryTypeOptions,
|
||||
} from "@/uhm/lib/map/geo/geometryTypeOptions";
|
||||
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type Props = {
|
||||
selectedFeatures: Feature[];
|
||||
entityTypeOptions: GeometryTypeOption[];
|
||||
geometryMetaForm: GeometryMetaFormState;
|
||||
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||
isEntitySubmitting: boolean;
|
||||
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
||||
changeCount: number;
|
||||
onReplayEdit?: (id: string | number) => void;
|
||||
@@ -25,14 +22,21 @@ type Props = {
|
||||
|
||||
export default function SelectedGeometryPanel({
|
||||
selectedFeatures,
|
||||
entityTypeOptions,
|
||||
geometryMetaForm,
|
||||
onGeometryMetaFormChange,
|
||||
isEntitySubmitting,
|
||||
onApplyGeometryMetadata,
|
||||
changeCount,
|
||||
onReplayEdit,
|
||||
}: Props) {
|
||||
const {
|
||||
geometryMetaForm,
|
||||
setGeometryMetaForm,
|
||||
isEntitySubmitting,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
geometryMetaForm: state.geometryMetaForm,
|
||||
setGeometryMetaForm: state.setGeometryMetaForm,
|
||||
isEntitySubmitting: state.isEntitySubmitting,
|
||||
}))
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
|
||||
| {
|
||||
@@ -73,7 +77,7 @@ export default function SelectedGeometryPanel({
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) return null;
|
||||
const representativeFeature = selectedFeatures[0];
|
||||
|
||||
const groupedGeometryTypeOptions = groupGeometryTypeOptions(entityTypeOptions);
|
||||
const groupedGeometryTypeOptions = groupGeometryTypeOptions(GEOMETRY_TYPE_OPTIONS);
|
||||
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
|
||||
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
||||
const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) =>
|
||||
@@ -143,7 +147,12 @@ export default function SelectedGeometryPanel({
|
||||
</div>
|
||||
<select
|
||||
value={geometryMetaForm.type_key}
|
||||
onChange={(event) => onGeometryMetaFormChange("type_key", event.target.value)}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
type_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
@@ -176,14 +185,24 @@ export default function SelectedGeometryPanel({
|
||||
) : null}
|
||||
<input
|
||||
value={geometryMetaForm.time_start}
|
||||
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_start: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_start"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={geometryMetaForm.time_end}
|
||||
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_end: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_end"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { initLine } from "@/uhm/lib/map/engines/lineEngine";
|
||||
import { initPath } from "@/uhm/lib/map/engines/pathEngine";
|
||||
import { initCircle } from "@/uhm/lib/map/engines/circleEngine";
|
||||
import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine";
|
||||
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import { buildClientFeatureId, getSelectableLayers } from "./mapUtils";
|
||||
import { MapHoverPayload } from "../Map";
|
||||
@@ -15,7 +15,7 @@ import { MapHoverPayload } from "../Map";
|
||||
type EngineBinding = {
|
||||
cleanup: () => void;
|
||||
cancel?: () => void;
|
||||
clearSelection?: () => void;
|
||||
clearSelection?: (skipNotify?: boolean) => void;
|
||||
};
|
||||
|
||||
type UseMapInteractionProps = {
|
||||
@@ -143,7 +143,9 @@ export function useMapInteraction({
|
||||
const originalFeature = draftRef.current.features.find(
|
||||
(item) => String(item.properties.id) === String(rawId)
|
||||
);
|
||||
editingEngineRef.current?.beginEditing((originalFeature || feature) as any);
|
||||
editingEngineRef.current?.beginEditing(
|
||||
(originalFeature || feature) as unknown as maplibregl.MapGeoJSONFeature
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
(ids) => onSelectFeatureIdsRef.current?.(ids),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
@@ -13,6 +14,7 @@ import { newId } from "@/uhm/lib/utils/id";
|
||||
import type ReactQuill from "react-quill-new";
|
||||
import { checkWikiSlugExists, fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
||||
type QuillRange = { index: number; length: number };
|
||||
@@ -39,7 +41,7 @@ type QuillLinkFormat = {
|
||||
type QuillImageFormatCtor = {
|
||||
new (): {
|
||||
domNode: Element;
|
||||
format: (name: string, value: string) => void;
|
||||
format(name: string, value: string): void;
|
||||
};
|
||||
formats: (domNode: Element) => Record<string, string>;
|
||||
};
|
||||
@@ -53,9 +55,7 @@ let quillLinkSanitizePatched = false;
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
wikis: WikiSnapshot[];
|
||||
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
||||
requestedActiveId?: string | null;
|
||||
};
|
||||
|
||||
function clampTitle(title: string) {
|
||||
@@ -63,7 +63,13 @@ function clampTitle(title: string) {
|
||||
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function WikiSidebarPanel({ projectId, wikis, setWikis, requestedActiveId }: Props) {
|
||||
export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
|
||||
const { wikis, requestedActiveId } = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
wikis: state.snapshotWikis,
|
||||
requestedActiveId: state.requestedActiveWikiId,
|
||||
}))
|
||||
);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -122,13 +128,18 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, requested
|
||||
|
||||
const ImageFormat = Quill.import?.("formats/image") as QuillImageFormatCtor | undefined;
|
||||
if (ImageFormat) {
|
||||
class CustomImage extends ImageFormat {
|
||||
const BaseImageFormat = ImageFormat;
|
||||
class CustomImage extends BaseImageFormat {
|
||||
static formats(domNode: Element) {
|
||||
const formats = ImageFormat.formats(domNode) || {};
|
||||
if (domNode.hasAttribute("style")) formats.style = domNode.getAttribute("style");
|
||||
if (domNode.hasAttribute("width")) formats.width = domNode.getAttribute("width");
|
||||
if (domNode.hasAttribute("height")) formats.height = domNode.getAttribute("height");
|
||||
if (domNode.hasAttribute("class")) formats.class = domNode.getAttribute("class");
|
||||
const formats = BaseImageFormat.formats(domNode) || {};
|
||||
const style = domNode.getAttribute("style");
|
||||
const width = domNode.getAttribute("width");
|
||||
const height = domNode.getAttribute("height");
|
||||
const className = domNode.getAttribute("class");
|
||||
if (style) formats.style = style;
|
||||
if (width) formats.width = width;
|
||||
if (height) formats.height = height;
|
||||
if (className) formats.class = className;
|
||||
return formats;
|
||||
}
|
||||
|
||||
@@ -291,13 +302,10 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, requested
|
||||
if (!activeId) return;
|
||||
|
||||
const fmt = detectWikiDocStorageFormat(wikiDocHtml);
|
||||
const label = fmt === "json" ? "json" : fmt === "text" ? "txt" : "html";
|
||||
const mime =
|
||||
fmt === "json"
|
||||
? "application/json;charset=utf-8"
|
||||
: fmt === "text"
|
||||
? "text/plain;charset=utf-8"
|
||||
: "text/html;charset=utf-8";
|
||||
const label = fmt === "text" ? "txt" : "html";
|
||||
const mime = fmt === "text"
|
||||
? "text/plain;charset=utf-8"
|
||||
: "text/html;charset=utf-8";
|
||||
|
||||
const base =
|
||||
normalizeWikiSlugInput(wikiSlug) ||
|
||||
|
||||
Reference in New Issue
Block a user