feat(map): add operational Cesium workspace
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
export type MapInspectorSection =
|
||||
| "base-terrain"
|
||||
| "atmosphere-light"
|
||||
| "buildings"
|
||||
| "targets"
|
||||
| "grid-lod"
|
||||
| "camera"
|
||||
| "tile-cache"
|
||||
| "selection";
|
||||
|
||||
export interface MapCamera {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
height: number;
|
||||
heading: number;
|
||||
pitch: number;
|
||||
roll: number;
|
||||
}
|
||||
|
||||
export interface MapVisualSettings {
|
||||
atmosphereEnabled: boolean;
|
||||
lightingEnabled: boolean;
|
||||
monochromeEnabled: boolean;
|
||||
terrainExaggeration: number;
|
||||
buildingsMaximumScreenSpaceError: number;
|
||||
cameraAnimationEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface MapLayerVisibility {
|
||||
imagery: boolean;
|
||||
terrain: boolean;
|
||||
buildings: boolean;
|
||||
grid: boolean;
|
||||
targets: boolean;
|
||||
}
|
||||
|
||||
export interface MapCacheIntent {
|
||||
enabled: boolean;
|
||||
noOverwrite: boolean;
|
||||
}
|
||||
|
||||
export interface MapView {
|
||||
camera: MapCamera | null;
|
||||
visualSettings: MapVisualSettings;
|
||||
mapHeight: number;
|
||||
inspectorOpenSections: MapInspectorSection[];
|
||||
cacheIntent: MapCacheIntent;
|
||||
selectedSubjectId: string | null;
|
||||
layerVisibility: MapLayerVisibility;
|
||||
}
|
||||
|
||||
export interface MapViewDocument {
|
||||
revision: number;
|
||||
view: MapView;
|
||||
}
|
||||
|
||||
const inspectorSections = new Set<MapInspectorSection>([
|
||||
"base-terrain",
|
||||
"atmosphere-light",
|
||||
"buildings",
|
||||
"targets",
|
||||
"grid-lod",
|
||||
"camera",
|
||||
"tile-cache",
|
||||
"selection",
|
||||
]);
|
||||
|
||||
export function defaultMapViewDocument(): MapViewDocument {
|
||||
return {
|
||||
revision: 0,
|
||||
view: {
|
||||
camera: null,
|
||||
visualSettings: {
|
||||
atmosphereEnabled: true,
|
||||
lightingEnabled: true,
|
||||
monochromeEnabled: false,
|
||||
terrainExaggeration: 1,
|
||||
buildingsMaximumScreenSpaceError: 16,
|
||||
cameraAnimationEnabled: true,
|
||||
},
|
||||
mapHeight: 720,
|
||||
inspectorOpenSections: [],
|
||||
cacheIntent: {
|
||||
enabled: true,
|
||||
noOverwrite: true,
|
||||
},
|
||||
selectedSubjectId: null,
|
||||
layerVisibility: {
|
||||
imagery: true,
|
||||
terrain: true,
|
||||
buildings: true,
|
||||
grid: false,
|
||||
targets: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeMapViewDocument(value: unknown): MapViewDocument {
|
||||
const document = requireRecord(value, "map view");
|
||||
requireExactKeys(document, ["schema_version", "revision", "view"], "map view");
|
||||
if (document.schema_version !== "missioncore.map-view/v1") {
|
||||
throw new Error("Версия сохранённого состояния карты не поддерживается.");
|
||||
}
|
||||
return {
|
||||
revision: requireInteger(document.revision, "map view.revision", 0, Number.MAX_SAFE_INTEGER),
|
||||
view: decodeMapView(document.view),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeMapViewPut(document: MapViewDocument): unknown {
|
||||
return {
|
||||
revision: document.revision,
|
||||
view: {
|
||||
camera: document.view.camera
|
||||
? {
|
||||
longitude: document.view.camera.longitude,
|
||||
latitude: document.view.camera.latitude,
|
||||
height: document.view.camera.height,
|
||||
heading: document.view.camera.heading,
|
||||
pitch: document.view.camera.pitch,
|
||||
roll: document.view.camera.roll,
|
||||
}
|
||||
: null,
|
||||
visual_settings: {
|
||||
atmosphere_enabled: document.view.visualSettings.atmosphereEnabled,
|
||||
lighting_enabled: document.view.visualSettings.lightingEnabled,
|
||||
monochrome_enabled: document.view.visualSettings.monochromeEnabled,
|
||||
terrain_exaggeration: document.view.visualSettings.terrainExaggeration,
|
||||
buildings_maximum_screen_space_error:
|
||||
document.view.visualSettings.buildingsMaximumScreenSpaceError,
|
||||
camera_animation_enabled: document.view.visualSettings.cameraAnimationEnabled,
|
||||
},
|
||||
map_height: document.view.mapHeight,
|
||||
inspector_open_sections: document.view.inspectorOpenSections,
|
||||
cache_intent: {
|
||||
enabled: document.view.cacheIntent.enabled,
|
||||
no_overwrite: document.view.cacheIntent.noOverwrite,
|
||||
},
|
||||
selected_subject_id: document.view.selectedSubjectId,
|
||||
layer_visibility: {
|
||||
imagery: document.view.layerVisibility.imagery,
|
||||
terrain: document.view.layerVisibility.terrain,
|
||||
buildings: document.view.layerVisibility.buildings,
|
||||
grid: document.view.layerVisibility.grid,
|
||||
targets: document.view.layerVisibility.targets,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneMapViewDocument(document: MapViewDocument): MapViewDocument {
|
||||
return {
|
||||
revision: document.revision,
|
||||
view: {
|
||||
...document.view,
|
||||
camera: document.view.camera ? { ...document.view.camera } : null,
|
||||
visualSettings: { ...document.view.visualSettings },
|
||||
inspectorOpenSections: [...document.view.inspectorOpenSections],
|
||||
cacheIntent: { ...document.view.cacheIntent },
|
||||
layerVisibility: { ...document.view.layerVisibility },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeMapView(value: unknown): MapView {
|
||||
const view = requireRecord(value, "map view.view");
|
||||
requireExactKeys(
|
||||
view,
|
||||
[
|
||||
"camera",
|
||||
"visual_settings",
|
||||
"map_height",
|
||||
"inspector_open_sections",
|
||||
"cache_intent",
|
||||
"selected_subject_id",
|
||||
"layer_visibility",
|
||||
],
|
||||
"map view.view",
|
||||
);
|
||||
const sections = requireArray(view.inspector_open_sections, "inspector_open_sections");
|
||||
if (
|
||||
sections.length > 1 ||
|
||||
sections.some((section) => typeof section !== "string" || !inspectorSections.has(
|
||||
section as MapInspectorSection,
|
||||
))
|
||||
) {
|
||||
throw new Error("Секции Inspector карты некорректны.");
|
||||
}
|
||||
const selectedSubjectId = requireNullableString(
|
||||
view.selected_subject_id,
|
||||
"map view.view.selected_subject_id",
|
||||
);
|
||||
if (sections[0] === "selection" && selectedSubjectId === null) {
|
||||
throw new Error("Секция выбранного объекта требует стабильный subject id.");
|
||||
}
|
||||
return {
|
||||
camera: view.camera === null ? null : decodeCamera(view.camera),
|
||||
visualSettings: decodeVisualSettings(view.visual_settings),
|
||||
mapHeight: requireInteger(view.map_height, "map view.view.map_height", 420, 2160),
|
||||
inspectorOpenSections: sections as MapInspectorSection[],
|
||||
cacheIntent: decodeCacheIntent(view.cache_intent),
|
||||
selectedSubjectId,
|
||||
layerVisibility: decodeLayerVisibility(view.layer_visibility),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeCamera(value: unknown): MapCamera {
|
||||
const camera = requireRecord(value, "map view.view.camera");
|
||||
requireExactKeys(
|
||||
camera,
|
||||
["longitude", "latitude", "height", "heading", "pitch", "roll"],
|
||||
"map view.view.camera",
|
||||
);
|
||||
return {
|
||||
longitude: requireNumber(camera.longitude, "camera.longitude", -180, 180),
|
||||
latitude: requireNumber(camera.latitude, "camera.latitude", -90, 90),
|
||||
height: requireNumber(camera.height, "camera.height", 1, 100_000_000),
|
||||
heading: requireNumber(camera.heading, "camera.heading", -360, 360),
|
||||
pitch: requireNumber(camera.pitch, "camera.pitch", -90, 90),
|
||||
roll: requireNumber(camera.roll, "camera.roll", -360, 360),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeVisualSettings(value: unknown): MapVisualSettings {
|
||||
const settings = requireRecord(value, "map view.view.visual_settings");
|
||||
requireExactKeys(
|
||||
settings,
|
||||
[
|
||||
"atmosphere_enabled",
|
||||
"lighting_enabled",
|
||||
"monochrome_enabled",
|
||||
"terrain_exaggeration",
|
||||
"buildings_maximum_screen_space_error",
|
||||
"camera_animation_enabled",
|
||||
],
|
||||
"map view.view.visual_settings",
|
||||
);
|
||||
return {
|
||||
atmosphereEnabled: requireBoolean(settings.atmosphere_enabled, "atmosphere_enabled"),
|
||||
lightingEnabled: requireBoolean(settings.lighting_enabled, "lighting_enabled"),
|
||||
monochromeEnabled: requireBoolean(settings.monochrome_enabled, "monochrome_enabled"),
|
||||
terrainExaggeration: requireNumber(
|
||||
settings.terrain_exaggeration,
|
||||
"terrain_exaggeration",
|
||||
0.1,
|
||||
20,
|
||||
),
|
||||
buildingsMaximumScreenSpaceError: requireNumber(
|
||||
settings.buildings_maximum_screen_space_error,
|
||||
"buildings_maximum_screen_space_error",
|
||||
1,
|
||||
64,
|
||||
),
|
||||
cameraAnimationEnabled: requireBoolean(
|
||||
settings.camera_animation_enabled,
|
||||
"camera_animation_enabled",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeCacheIntent(value: unknown): MapCacheIntent {
|
||||
const intent = requireRecord(value, "map view.view.cache_intent");
|
||||
requireExactKeys(intent, ["enabled", "no_overwrite"], "map view.view.cache_intent");
|
||||
return {
|
||||
enabled: requireBoolean(intent.enabled, "cache_intent.enabled"),
|
||||
noOverwrite: requireBoolean(intent.no_overwrite, "cache_intent.no_overwrite"),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeLayerVisibility(value: unknown): MapLayerVisibility {
|
||||
const layers = requireRecord(value, "map view.view.layer_visibility");
|
||||
requireExactKeys(
|
||||
layers,
|
||||
["imagery", "terrain", "buildings", "grid", "targets"],
|
||||
"map view.view.layer_visibility",
|
||||
);
|
||||
return {
|
||||
imagery: requireBoolean(layers.imagery, "layer_visibility.imagery"),
|
||||
terrain: requireBoolean(layers.terrain, "layer_visibility.terrain"),
|
||||
buildings: requireBoolean(layers.buildings, "layer_visibility.buildings"),
|
||||
grid: requireBoolean(layers.grid, "layer_visibility.grid"),
|
||||
targets: requireBoolean(layers.targets, "layer_visibility.targets"),
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${path} должен быть объектом.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireArray(value: unknown, path: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`${path} должен быть массивом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireExactKeys(
|
||||
record: Record<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
path: string,
|
||||
): void {
|
||||
const allowedKeys = new Set(allowed);
|
||||
const unexpected = Object.keys(record).find((key) => !allowedKeys.has(key));
|
||||
if (unexpected) {
|
||||
throw new Error(`${path}.${unexpected} не поддерживается.`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireBoolean(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`${path} должен быть boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNumber(
|
||||
value: unknown,
|
||||
path: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isFinite(value) ||
|
||||
value < minimum ||
|
||||
value > maximum
|
||||
) {
|
||||
throw new Error(`${path} выходит за допустимый диапазон.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireInteger(
|
||||
value: unknown,
|
||||
path: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const result = requireNumber(value, path, minimum, maximum);
|
||||
if (!Number.isSafeInteger(result)) {
|
||||
throw new Error(`${path} должен быть целым числом.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function requireNullableString(value: unknown, path: string): string | null {
|
||||
if (value === null) return null;
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(value)
|
||||
) {
|
||||
throw new Error(`${path} не является стабильным subject id.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface MapGatewayHealthSnapshot {
|
||||
ionConfigured: boolean;
|
||||
assetAllowlist: number[];
|
||||
cache: {
|
||||
mode: string;
|
||||
writePolicy: string;
|
||||
entries: number;
|
||||
bytes: number;
|
||||
maxBytes: number | null;
|
||||
atCapacity: boolean;
|
||||
persistent: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MapGatewayHealth {
|
||||
state: "idle" | "loading" | "ready" | "stale" | "error";
|
||||
snapshot: MapGatewayHealthSnapshot | null;
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
export function useMapGatewayHealth(active: boolean): MapGatewayHealth {
|
||||
const [health, setHealth] = useState<MapGatewayHealth>({
|
||||
state: "idle",
|
||||
snapshot: null,
|
||||
code: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let disposed = false;
|
||||
let running = false;
|
||||
let lastKnownGood: MapGatewayHealthSnapshot | null = null;
|
||||
let activeController: AbortController | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
const timeout = window.setTimeout(() => controller.abort(), 10_000);
|
||||
if (!lastKnownGood && !disposed) {
|
||||
setHealth({ state: "loading", snapshot: null, code: null });
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/api/v1/map/gateway/health", {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(safeCode(payload));
|
||||
}
|
||||
const snapshot = decodeHealth(payload);
|
||||
lastKnownGood = snapshot;
|
||||
if (!disposed) {
|
||||
setHealth({ state: "ready", snapshot, code: null });
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!disposed) {
|
||||
setHealth({
|
||||
state: lastKnownGood ? "stale" : "error",
|
||||
snapshot: lastKnownGood,
|
||||
code: reason instanceof Error ? reason.message : "map_gateway_unavailable",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
if (activeController === controller) activeController = null;
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
const interval = window.setInterval(() => void poll(), 15_000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
activeController?.abort();
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
function decodeHealth(value: unknown): MapGatewayHealthSnapshot {
|
||||
const document = record(value, "gateway health");
|
||||
if (document.ok !== true || document.service !== "nodedc-map-gateway") {
|
||||
throw new Error("map_gateway_invalid_response");
|
||||
}
|
||||
const cache = record(document.cache, "gateway health.cache");
|
||||
const assetAllowlist = array(document.assetAllowlist, "gateway health.assetAllowlist");
|
||||
if (
|
||||
typeof document.ionConfigured !== "boolean" ||
|
||||
!assetAllowlist.every((asset) => typeof asset === "number" && Number.isSafeInteger(asset))
|
||||
) {
|
||||
throw new Error("map_gateway_invalid_response");
|
||||
}
|
||||
return {
|
||||
ionConfigured: document.ionConfigured,
|
||||
assetAllowlist: assetAllowlist as number[],
|
||||
cache: {
|
||||
mode: string(cache.mode),
|
||||
writePolicy: string(cache.writePolicy),
|
||||
entries: integer(cache.entries),
|
||||
bytes: integer(cache.bytes),
|
||||
maxBytes: cache.maxBytes === null ? null : integer(cache.maxBytes),
|
||||
atCapacity: boolean(cache.atCapacity),
|
||||
persistent: boolean(cache.persistent),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function safeCode(value: unknown): string {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
const document = value as Record<string, unknown>;
|
||||
if (typeof document.code === "string" && /^[a-z][a-z0-9_]{0,95}$/.test(document.code)) {
|
||||
return document.code;
|
||||
}
|
||||
}
|
||||
return "map_gateway_unavailable";
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${path} is invalid`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, path: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${path} is invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("map_gateway_invalid_response");
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error("map_gateway_invalid_response");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown): boolean {
|
||||
if (typeof value !== "boolean") throw new Error("map_gateway_invalid_response");
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
decodeMapViewDocument,
|
||||
defaultMapViewDocument,
|
||||
encodeMapViewPut,
|
||||
type MapViewDocument,
|
||||
} from "./mapView";
|
||||
|
||||
export interface MapViewController {
|
||||
document: MapViewDocument;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
save: (draft: MapViewDocument) => Promise<MapViewDocument>;
|
||||
reload: () => Promise<MapViewDocument>;
|
||||
}
|
||||
|
||||
async function responseError(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const body = await response.json() as { detail?: unknown; code?: unknown };
|
||||
if (typeof body.detail === "string" && body.detail.trim()) return body.detail;
|
||||
if (typeof body.code === "string" && body.code.trim()) return body.code;
|
||||
} catch {
|
||||
// Reverse-proxy HTML or an interrupted response receives stable product copy.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function loadMapView(signal?: AbortSignal): Promise<MapViewDocument> {
|
||||
const response = await fetch("/api/v1/map/view", {
|
||||
signal,
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
"Не удалось загрузить состояние карты.",
|
||||
));
|
||||
}
|
||||
return decodeMapViewDocument(await response.json());
|
||||
}
|
||||
|
||||
export function useMapView(): MapViewController {
|
||||
const [document, setDocument] = useState<MapViewDocument>(defaultMapViewDocument);
|
||||
const [state, setState] = useState<MapViewController["state"]>("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const next = await loadMapView();
|
||||
setDocument(next);
|
||||
setState("ready");
|
||||
return next;
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить состояние карты.";
|
||||
setState("error");
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadMapView(controller.signal).then((next) => {
|
||||
setDocument(next);
|
||||
setState("ready");
|
||||
setError(null);
|
||||
}).catch((reason: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setState("error");
|
||||
setError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить состояние карты.");
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (draft: MapViewDocument) => {
|
||||
setState("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/v1/map/view", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(encodeMapViewPut(draft)),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
response.status === 412
|
||||
? "Состояние карты изменилось в другом окне. Обновите его перед сохранением."
|
||||
: "Не удалось сохранить состояние карты.",
|
||||
));
|
||||
}
|
||||
const accepted = decodeMapViewDocument(await response.json());
|
||||
setDocument(accepted);
|
||||
setState("ready");
|
||||
return accepted;
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить состояние карты.";
|
||||
setState("error");
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { document, state, error, save, reload };
|
||||
}
|
||||
Reference in New Issue
Block a user