feat: add play apps and layout tools
This commit is contained in:
1046
src/features/layout/draw/LayoutDrawPage.css
Normal file
1046
src/features/layout/draw/LayoutDrawPage.css
Normal file
File diff suppressed because it is too large
Load Diff
3516
src/features/layout/draw/LayoutDrawPage.tsx
Normal file
3516
src/features/layout/draw/LayoutDrawPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
src/features/layout/draw/index.ts
Normal file
1
src/features/layout/draw/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LayoutDrawPage } from './LayoutDrawPage';
|
||||
246
src/features/layout/draw/layoutDrawComponentStorage.ts
Normal file
246
src/features/layout/draw/layoutDrawComponentStorage.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { appendClientIdHeader } from '../../../app/main/clientIdentity';
|
||||
import { getRegisteredAccessToken } from '../../../app/main/tokenAccess';
|
||||
import type { SavedLayoutDrawComponentRecord } from './layoutDrawTypes';
|
||||
import { normalizeSavedLayoutDrawShapes, serializeSavedLayoutDrawShapes } from './layoutDrawStorageShapes.ts';
|
||||
|
||||
type SavedLayoutDrawComponentRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
shapes: SavedLayoutDrawComponentRecord['shapes'] | string;
|
||||
};
|
||||
|
||||
const WORK_SERVER_TIMEOUT_MS = 8000;
|
||||
const LAYOUT_DRAW_COMPONENT_TABLE = 'layout_draw_components';
|
||||
|
||||
let setupPromise: Promise<void> | null = null;
|
||||
|
||||
function normalizeTimestamp(value: unknown, fallback: string) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveWorkServerBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
function resolveWorkServerFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
|
||||
if (!isLocalHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
|
||||
const WORK_SERVER_FALLBACK_BASE_URL =
|
||||
!import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api'
|
||||
? resolveWorkServerFallbackBaseUrl()
|
||||
: null;
|
||||
|
||||
class LayoutDrawComponentStorageError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'LayoutDrawComponentStorageError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const token = getRegisteredAccessToken();
|
||||
if (token && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', token);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new LayoutDrawComponentStorageError('컴포넌트 저장소 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let message = text || '컴포넌트 저장소 요청에 실패했습니다.';
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
message = payload.message || message;
|
||||
} catch {
|
||||
// Keep raw text.
|
||||
}
|
||||
|
||||
throw new LayoutDrawComponentStorageError(message, response.status);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
throw new LayoutDrawComponentStorageError('컴포넌트 저장소 응답이 JSON이 아닙니다.', 502);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await requestOnce<T>(WORK_SERVER_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetry =
|
||||
WORK_SERVER_FALLBACK_BASE_URL &&
|
||||
WORK_SERVER_FALLBACK_BASE_URL !== WORK_SERVER_BASE_URL &&
|
||||
(error instanceof LayoutDrawComponentStorageError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && (/not found/i.test(error.message) || /404/.test(error.message)));
|
||||
|
||||
if (!shouldRetry) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(WORK_SERVER_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
function toRecord(row: SavedLayoutDrawComponentRow): SavedLayoutDrawComponentRecord {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
category: row.category,
|
||||
createdAt: normalizeTimestamp(row.created_at, now),
|
||||
updatedAt: normalizeTimestamp(row.updated_at, now),
|
||||
shapes: normalizeSavedLayoutDrawShapes(row.shapes).filter(
|
||||
(shape) => shape.type === 'line' || shape.type === 'rect',
|
||||
) as SavedLayoutDrawComponentRecord['shapes'],
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureLayoutDrawComponentTable() {
|
||||
if (!setupPromise) {
|
||||
setupPromise = (async () => {
|
||||
const schemaResponse = await request<{ items: Array<{ table_name: string }> }>('/schema/tables');
|
||||
const tableExists = schemaResponse.items.some((item) => item.table_name === LAYOUT_DRAW_COMPONENT_TABLE);
|
||||
if (tableExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await request<{ ok: boolean; tableName: string }>('/ddl/create-table', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tableName: LAYOUT_DRAW_COMPONENT_TABLE,
|
||||
columns: [
|
||||
{ name: 'id', type: 'text', nullable: false, primary: true },
|
||||
{ name: 'name', type: 'text', nullable: false },
|
||||
{ name: 'category', type: 'text', nullable: false },
|
||||
{ name: 'created_at', type: 'timestamp with time zone', nullable: false },
|
||||
{ name: 'updated_at', type: 'timestamp with time zone', nullable: false },
|
||||
{ name: 'shapes', type: 'jsonb', nullable: false },
|
||||
],
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof LayoutDrawComponentStorageError) || !/already exists/i.test(error.message)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})().catch((error) => {
|
||||
setupPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return setupPromise;
|
||||
}
|
||||
|
||||
export async function listSavedLayoutDrawComponents() {
|
||||
await ensureLayoutDrawComponentTable();
|
||||
|
||||
const response = await request<{ rows: SavedLayoutDrawComponentRow[] }>(`/crud/${LAYOUT_DRAW_COMPONENT_TABLE}/select`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
orderBy: [{ field: 'updated_at', direction: 'desc' }],
|
||||
limit: 200,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.rows.map(toRecord);
|
||||
}
|
||||
|
||||
export async function saveLayoutDrawComponent(record: SavedLayoutDrawComponentRecord) {
|
||||
await ensureLayoutDrawComponentTable();
|
||||
|
||||
await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_COMPONENT_TABLE}/insert`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
category: record.category,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
shapes: serializeSavedLayoutDrawShapes(record.shapes),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteLayoutDrawComponent(id: string) {
|
||||
await ensureLayoutDrawComponentTable();
|
||||
|
||||
await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_COMPONENT_TABLE}/delete`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
where: [{ field: 'id', operator: 'eq', value: id }],
|
||||
}),
|
||||
});
|
||||
}
|
||||
68
src/features/layout/draw/layoutDrawHistory.ts
Normal file
68
src/features/layout/draw/layoutDrawHistory.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { LayoutDrawDocument } from './layoutDrawTypes';
|
||||
|
||||
export type LayoutDrawHistoryState = {
|
||||
past: LayoutDrawDocument[];
|
||||
present: LayoutDrawDocument;
|
||||
future: LayoutDrawDocument[];
|
||||
};
|
||||
|
||||
function cloneDocument(document: LayoutDrawDocument): LayoutDrawDocument {
|
||||
return {
|
||||
backgroundMode: document.backgroundMode,
|
||||
shapes: document.shapes.map((shape) => ({ ...shape })),
|
||||
};
|
||||
}
|
||||
|
||||
function isDocumentEqual(left: LayoutDrawDocument, right: LayoutDrawDocument) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function createLayoutDrawHistoryState(initialDocument: LayoutDrawDocument): LayoutDrawHistoryState {
|
||||
return {
|
||||
past: [],
|
||||
present: cloneDocument(initialDocument),
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function commitLayoutDrawHistory(
|
||||
history: LayoutDrawHistoryState,
|
||||
nextDocument: LayoutDrawDocument,
|
||||
): LayoutDrawHistoryState {
|
||||
const nextSnapshot = cloneDocument(nextDocument);
|
||||
if (isDocumentEqual(history.present, nextSnapshot)) {
|
||||
return history;
|
||||
}
|
||||
|
||||
return {
|
||||
past: [...history.past, cloneDocument(history.present)],
|
||||
present: nextSnapshot,
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function undoLayoutDrawHistory(history: LayoutDrawHistoryState): LayoutDrawHistoryState {
|
||||
if (history.past.length === 0) {
|
||||
return history;
|
||||
}
|
||||
|
||||
const previous = history.past[history.past.length - 1];
|
||||
return {
|
||||
past: history.past.slice(0, -1),
|
||||
present: cloneDocument(previous),
|
||||
future: [cloneDocument(history.present), ...history.future],
|
||||
};
|
||||
}
|
||||
|
||||
export function redoLayoutDrawHistory(history: LayoutDrawHistoryState): LayoutDrawHistoryState {
|
||||
if (history.future.length === 0) {
|
||||
return history;
|
||||
}
|
||||
|
||||
const [next, ...restFuture] = history.future;
|
||||
return {
|
||||
past: [...history.past, cloneDocument(history.present)],
|
||||
present: cloneDocument(next),
|
||||
future: restFuture,
|
||||
};
|
||||
}
|
||||
237
src/features/layout/draw/layoutDrawRegions.ts
Normal file
237
src/features/layout/draw/layoutDrawRegions.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { DrawLine, DrawRect, DrawRegion, DrawableShape } from './layoutDrawTypes';
|
||||
|
||||
type RegionCell = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type ResolvedDrawRegion = {
|
||||
key: string;
|
||||
cells: RegionCell[];
|
||||
labelPosition: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
label: string;
|
||||
fillColor: string | null;
|
||||
assignmentId: string | null;
|
||||
};
|
||||
|
||||
function normalizeLine(line: DrawLine) {
|
||||
return {
|
||||
x1: Math.min(line.x1, line.x2),
|
||||
y1: Math.min(line.y1, line.y2),
|
||||
x2: Math.max(line.x1, line.x2),
|
||||
y2: Math.max(line.y1, line.y2),
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueSorted(values: number[]) {
|
||||
return [...new Set(values.filter((value) => Number.isFinite(value)))].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function isVerticalBoundaryBlocked(verticalLines: DrawLine[], rects: DrawRect[], x: number, startY: number, endY: number) {
|
||||
if (
|
||||
verticalLines.some((line) => {
|
||||
const normalized = normalizeLine(line);
|
||||
return normalized.x1 === x && normalized.y1 <= startY && normalized.y2 >= endY;
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return rects.some(
|
||||
(rect) =>
|
||||
(rect.x === x || rect.x + rect.width === x) &&
|
||||
rect.y <= startY &&
|
||||
rect.y + rect.height >= endY,
|
||||
);
|
||||
}
|
||||
|
||||
function isHorizontalBoundaryBlocked(horizontalLines: DrawLine[], rects: DrawRect[], y: number, startX: number, endX: number) {
|
||||
if (
|
||||
horizontalLines.some((line) => {
|
||||
const normalized = normalizeLine(line);
|
||||
return normalized.y1 === y && normalized.x1 <= startX && normalized.x2 >= endX;
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return rects.some(
|
||||
(rect) =>
|
||||
(rect.y === y || rect.y + rect.height === y) &&
|
||||
rect.x <= startX &&
|
||||
rect.x + rect.width >= endX,
|
||||
);
|
||||
}
|
||||
|
||||
export function splitDrawShapes(shapes: DrawableShape[] | (DrawableShape | DrawRegion)[]) {
|
||||
const drawableShapes: DrawableShape[] = [];
|
||||
const regionAssignments: DrawRegion[] = [];
|
||||
|
||||
shapes.forEach((shape) => {
|
||||
if (shape.type === 'region') {
|
||||
regionAssignments.push(shape);
|
||||
return;
|
||||
}
|
||||
|
||||
drawableShapes.push(shape);
|
||||
});
|
||||
|
||||
return { drawableShapes, regionAssignments };
|
||||
}
|
||||
|
||||
export function resolveDrawRegions(
|
||||
shapes: DrawableShape[] | (DrawableShape | DrawRegion)[],
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): ResolvedDrawRegion[] {
|
||||
if (canvasWidth <= 0 || canvasHeight <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { drawableShapes, regionAssignments } = splitDrawShapes(shapes);
|
||||
const lines = drawableShapes.filter((shape): shape is DrawLine => shape.type === 'line');
|
||||
const rects = drawableShapes.filter((shape): shape is DrawRect => shape.type === 'rect');
|
||||
const verticalLines = lines.filter((line) => line.orientation === 'vertical');
|
||||
const horizontalLines = lines.filter((line) => line.orientation === 'horizontal');
|
||||
|
||||
const xs = uniqueSorted([
|
||||
0,
|
||||
canvasWidth,
|
||||
...verticalLines.map((line) => line.x1),
|
||||
...rects.flatMap((rect) => [rect.x, rect.x + rect.width]),
|
||||
]);
|
||||
const ys = uniqueSorted([
|
||||
0,
|
||||
canvasHeight,
|
||||
...horizontalLines.map((line) => line.y1),
|
||||
...rects.flatMap((rect) => [rect.y, rect.y + rect.height]),
|
||||
]);
|
||||
|
||||
if (xs.length < 2 || ys.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const assignmentMap = new Map(regionAssignments.map((shape) => [shape.regionKey, shape]));
|
||||
|
||||
const regions: ResolvedDrawRegion[] = [];
|
||||
|
||||
for (let xIndex = 0; xIndex < xs.length - 1; xIndex += 1) {
|
||||
for (let yIndex = 0; yIndex < ys.length - 1; yIndex += 1) {
|
||||
const startKey = `${xIndex}:${yIndex}`;
|
||||
if (visited.has(startKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stack: Array<[number, number]> = [[xIndex, yIndex]];
|
||||
const cells: RegionCell[] = [];
|
||||
const cellKeys: string[] = [];
|
||||
let totalArea = 0;
|
||||
let weightedCenterX = 0;
|
||||
let weightedCenterY = 0;
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [currentXIndex, currentYIndex] = stack.pop() as [number, number];
|
||||
const currentKey = `${currentXIndex}:${currentYIndex}`;
|
||||
if (visited.has(currentKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(currentKey);
|
||||
cellKeys.push(currentKey);
|
||||
|
||||
const cell = {
|
||||
x: xs[currentXIndex],
|
||||
y: ys[currentYIndex],
|
||||
width: xs[currentXIndex + 1] - xs[currentXIndex],
|
||||
height: ys[currentYIndex + 1] - ys[currentYIndex],
|
||||
};
|
||||
cells.push(cell);
|
||||
|
||||
const area = cell.width * cell.height;
|
||||
totalArea += area;
|
||||
weightedCenterX += (cell.x + cell.width / 2) * area;
|
||||
weightedCenterY += (cell.y + cell.height / 2) * area;
|
||||
|
||||
if (
|
||||
currentXIndex > 0 &&
|
||||
!isVerticalBoundaryBlocked(verticalLines, rects, xs[currentXIndex], cell.y, cell.y + cell.height)
|
||||
) {
|
||||
stack.push([currentXIndex - 1, currentYIndex]);
|
||||
}
|
||||
|
||||
if (
|
||||
currentXIndex < xs.length - 2 &&
|
||||
!isVerticalBoundaryBlocked(verticalLines, rects, xs[currentXIndex + 1], cell.y, cell.y + cell.height)
|
||||
) {
|
||||
stack.push([currentXIndex + 1, currentYIndex]);
|
||||
}
|
||||
|
||||
if (
|
||||
currentYIndex > 0 &&
|
||||
!isHorizontalBoundaryBlocked(horizontalLines, rects, ys[currentYIndex], cell.x, cell.x + cell.width)
|
||||
) {
|
||||
stack.push([currentXIndex, currentYIndex - 1]);
|
||||
}
|
||||
|
||||
if (
|
||||
currentYIndex < ys.length - 2 &&
|
||||
!isHorizontalBoundaryBlocked(horizontalLines, rects, ys[currentYIndex + 1], cell.x, cell.x + cell.width)
|
||||
) {
|
||||
stack.push([currentXIndex, currentYIndex + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (cells.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const centerX = totalArea > 0 ? weightedCenterX / totalArea : cells[0].x + cells[0].width / 2;
|
||||
const centerY = totalArea > 0 ? weightedCenterY / totalArea : cells[0].y + cells[0].height / 2;
|
||||
const anchorCell = cells.reduce((closest, cell) => {
|
||||
const closestDistance = Math.hypot(
|
||||
closest.x + closest.width / 2 - centerX,
|
||||
closest.y + closest.height / 2 - centerY,
|
||||
);
|
||||
const currentDistance = Math.hypot(cell.x + cell.width / 2 - centerX, cell.y + cell.height / 2 - centerY);
|
||||
return currentDistance < closestDistance ? cell : closest;
|
||||
}, cells[0]);
|
||||
const key = cellKeys.sort().join('|');
|
||||
const assignment = assignmentMap.get(key) ?? null;
|
||||
|
||||
regions.push({
|
||||
key,
|
||||
cells,
|
||||
labelPosition: {
|
||||
x: anchorCell.x + anchorCell.width / 2,
|
||||
y: anchorCell.y + anchorCell.height / 2,
|
||||
},
|
||||
label: assignment?.label ?? '',
|
||||
fillColor: assignment?.fillColor ?? null,
|
||||
assignmentId: assignment?.id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
export function findRegionAtPoint(regions: ResolvedDrawRegion[], x: number, y: number) {
|
||||
for (let index = regions.length - 1; index >= 0; index -= 1) {
|
||||
const region = regions[index];
|
||||
if (
|
||||
region.cells.some(
|
||||
(cell) => x >= cell.x && x <= cell.x + cell.width && y >= cell.y && y <= cell.y + cell.height,
|
||||
)
|
||||
) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
99
src/features/layout/draw/layoutDrawSelectionUtils.ts
Normal file
99
src/features/layout/draw/layoutDrawSelectionUtils.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { DrawRect, DrawableShape } from './layoutDrawTypes';
|
||||
|
||||
export type SelectionRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function clampSelectionRect(startX: number, startY: number, endX: number, endY: number): SelectionRect {
|
||||
const x = Math.min(startX, endX);
|
||||
const y = Math.min(startY, endY);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.abs(endX - startX),
|
||||
height: Math.abs(endY - startY),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGroupedShapeIds(shapes: DrawableShape[], shapeIds: Iterable<string>) {
|
||||
const seedIds = new Set(shapeIds);
|
||||
if (seedIds.size === 0) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const matchedShapeIds = new Set(shapes.filter((shape) => seedIds.has(shape.id)).map((shape) => shape.id));
|
||||
if (matchedShapeIds.size === 0) {
|
||||
return seedIds;
|
||||
}
|
||||
|
||||
const groupIds = new Set(
|
||||
shapes.filter((shape) => seedIds.has(shape.id) && shape.groupId).map((shape) => shape.groupId as string),
|
||||
);
|
||||
|
||||
return new Set(
|
||||
shapes
|
||||
.filter((shape) => seedIds.has(shape.id) || (shape.groupId ? groupIds.has(shape.groupId) : false))
|
||||
.map((shape) => shape.id),
|
||||
);
|
||||
}
|
||||
|
||||
function isRectIntersectingSelection(shape: DrawRect, selection: SelectionRect) {
|
||||
return !(
|
||||
shape.x + shape.width < selection.x ||
|
||||
shape.x > selection.x + selection.width ||
|
||||
shape.y + shape.height < selection.y ||
|
||||
shape.y > selection.y + selection.height
|
||||
);
|
||||
}
|
||||
|
||||
function isLineIntersectingSelection(shape: DrawableShape, selection: SelectionRect) {
|
||||
if (shape.type !== 'line') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const minX = Math.min(shape.x1, shape.x2);
|
||||
const maxX = Math.max(shape.x1, shape.x2);
|
||||
const minY = Math.min(shape.y1, shape.y2);
|
||||
const maxY = Math.max(shape.y1, shape.y2);
|
||||
|
||||
return !(
|
||||
maxX < selection.x ||
|
||||
minX > selection.x + selection.width ||
|
||||
maxY < selection.y ||
|
||||
minY > selection.y + selection.height
|
||||
);
|
||||
}
|
||||
|
||||
export function findShapesInSelection(shapes: DrawableShape[], selection: SelectionRect) {
|
||||
return shapes.filter((shape) =>
|
||||
shape.type === 'rect' ? isRectIntersectingSelection(shape, selection) : isLineIntersectingSelection(shape, selection),
|
||||
);
|
||||
}
|
||||
|
||||
export function rebaseShapesToComponentBlueprint(shapes: DrawableShape[]) {
|
||||
if (shapes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const minX = Math.min(...shapes.map((shape) => (shape.type === 'line' ? Math.min(shape.x1, shape.x2) : shape.x)));
|
||||
const minY = Math.min(...shapes.map((shape) => (shape.type === 'line' ? Math.min(shape.y1, shape.y2) : shape.y)));
|
||||
|
||||
return shapes.map((shape) =>
|
||||
shape.type === 'line'
|
||||
? {
|
||||
...shape,
|
||||
x1: shape.x1 - minX,
|
||||
y1: shape.y1 - minY,
|
||||
x2: shape.x2 - minX,
|
||||
y2: shape.y2 - minY,
|
||||
}
|
||||
: {
|
||||
...shape,
|
||||
x: shape.x - minX,
|
||||
y: shape.y - minY,
|
||||
},
|
||||
);
|
||||
}
|
||||
32
src/features/layout/draw/layoutDrawShapeUtils.ts
Normal file
32
src/features/layout/draw/layoutDrawShapeUtils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { DrawableShape } from './layoutDrawTypes';
|
||||
|
||||
const DUPLICATE_OFFSET_PX = 24;
|
||||
|
||||
export function duplicateShapeWithLabel(
|
||||
shape: DrawableShape,
|
||||
nextId: string,
|
||||
label = shape.label,
|
||||
groupId = shape.groupId ?? null,
|
||||
): DrawableShape {
|
||||
if (shape.type === 'line') {
|
||||
return {
|
||||
...shape,
|
||||
id: nextId,
|
||||
...(groupId ? { groupId } : {}),
|
||||
label,
|
||||
x1: shape.x1 + DUPLICATE_OFFSET_PX,
|
||||
y1: shape.y1 + DUPLICATE_OFFSET_PX,
|
||||
x2: shape.x2 + DUPLICATE_OFFSET_PX,
|
||||
y2: shape.y2 + DUPLICATE_OFFSET_PX,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...shape,
|
||||
id: nextId,
|
||||
...(groupId ? { groupId } : {}),
|
||||
label,
|
||||
x: shape.x + DUPLICATE_OFFSET_PX,
|
||||
y: shape.y + DUPLICATE_OFFSET_PX,
|
||||
};
|
||||
}
|
||||
244
src/features/layout/draw/layoutDrawStorage.ts
Normal file
244
src/features/layout/draw/layoutDrawStorage.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { appendClientIdHeader } from '../../../app/main/clientIdentity';
|
||||
import { getRegisteredAccessToken } from '../../../app/main/tokenAccess';
|
||||
import type { SavedLayoutDrawRecord } from './layoutDrawTypes';
|
||||
import { normalizeSavedLayoutDrawShapes, serializeSavedLayoutDrawShapes } from './layoutDrawStorageShapes.ts';
|
||||
|
||||
type SavedLayoutDrawRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
background_mode: SavedLayoutDrawRecord['backgroundMode'];
|
||||
shapes: SavedLayoutDrawRecord['shapes'] | string;
|
||||
};
|
||||
|
||||
const WORK_SERVER_TIMEOUT_MS = 8000;
|
||||
const LAYOUT_DRAW_TABLE = 'layout_draw_snapshots';
|
||||
|
||||
let setupPromise: Promise<void> | null = null;
|
||||
|
||||
function normalizeTimestamp(value: unknown, fallback: string) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveWorkServerBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
function resolveWorkServerFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
|
||||
if (!isLocalHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
|
||||
const WORK_SERVER_FALLBACK_BASE_URL =
|
||||
!import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api'
|
||||
? resolveWorkServerFallbackBaseUrl()
|
||||
: null;
|
||||
|
||||
class LayoutDrawStorageError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'LayoutDrawStorageError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const token = getRegisteredAccessToken();
|
||||
if (token && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', token);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new LayoutDrawStorageError('저장소 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let message = text || '도면 저장소 요청에 실패했습니다.';
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
message = payload.message || message;
|
||||
} catch {
|
||||
// Keep raw text.
|
||||
}
|
||||
|
||||
throw new LayoutDrawStorageError(message, response.status);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
throw new LayoutDrawStorageError('도면 저장소 응답이 JSON이 아닙니다.', 502);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await requestOnce<T>(WORK_SERVER_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetry =
|
||||
WORK_SERVER_FALLBACK_BASE_URL &&
|
||||
WORK_SERVER_FALLBACK_BASE_URL !== WORK_SERVER_BASE_URL &&
|
||||
(error instanceof LayoutDrawStorageError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && (/not found/i.test(error.message) || /404/.test(error.message)));
|
||||
|
||||
if (!shouldRetry) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(WORK_SERVER_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
function toRecord(row: SavedLayoutDrawRow): SavedLayoutDrawRecord {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
createdAt: normalizeTimestamp(row.created_at, now),
|
||||
updatedAt: normalizeTimestamp(row.updated_at, now),
|
||||
backgroundMode: row.background_mode === 'plain' ? 'plain' : 'grid',
|
||||
shapes: normalizeSavedLayoutDrawShapes(row.shapes),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureLayoutDrawTable() {
|
||||
if (!setupPromise) {
|
||||
setupPromise = (async () => {
|
||||
const schemaResponse = await request<{ items: Array<{ table_name: string }> }>('/schema/tables');
|
||||
const tableExists = schemaResponse.items.some((item) => item.table_name === LAYOUT_DRAW_TABLE);
|
||||
if (tableExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await request<{ ok: boolean; tableName: string }>('/ddl/create-table', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tableName: LAYOUT_DRAW_TABLE,
|
||||
columns: [
|
||||
{ name: 'id', type: 'text', nullable: false, primary: true },
|
||||
{ name: 'name', type: 'text', nullable: false },
|
||||
{ name: 'created_at', type: 'timestamp with time zone', nullable: false },
|
||||
{ name: 'updated_at', type: 'timestamp with time zone', nullable: false },
|
||||
{ name: 'background_mode', type: 'text', nullable: false },
|
||||
{ name: 'shapes', type: 'jsonb', nullable: false },
|
||||
],
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof LayoutDrawStorageError) || !/already exists/i.test(error.message)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})().catch((error) => {
|
||||
setupPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return setupPromise;
|
||||
}
|
||||
|
||||
export async function listSavedLayoutDraws() {
|
||||
await ensureLayoutDrawTable();
|
||||
|
||||
const response = await request<{ rows: SavedLayoutDrawRow[] }>(`/crud/${LAYOUT_DRAW_TABLE}/select`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
orderBy: [{ field: 'updated_at', direction: 'desc' }],
|
||||
limit: 200,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.rows.map(toRecord);
|
||||
}
|
||||
|
||||
export async function saveLayoutDraw(record: SavedLayoutDrawRecord) {
|
||||
await ensureLayoutDrawTable();
|
||||
|
||||
await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_TABLE}/insert`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
background_mode: record.backgroundMode,
|
||||
shapes: serializeSavedLayoutDrawShapes(record.shapes),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteLayoutDraw(id: string) {
|
||||
await ensureLayoutDrawTable();
|
||||
|
||||
await request<{ ok: boolean }>(`/crud/${LAYOUT_DRAW_TABLE}/delete`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
where: [{ field: 'id', operator: 'eq', value: id }],
|
||||
}),
|
||||
});
|
||||
}
|
||||
24
src/features/layout/draw/layoutDrawStorageShapes.ts
Normal file
24
src/features/layout/draw/layoutDrawStorageShapes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { SavedLayoutDrawRecord } from './layoutDrawTypes.ts';
|
||||
|
||||
export function normalizeSavedLayoutDrawShapes(
|
||||
value: SavedLayoutDrawRecord['shapes'] | string | null | undefined,
|
||||
): SavedLayoutDrawRecord['shapes'] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return Array.isArray(parsed) ? (parsed as SavedLayoutDrawRecord['shapes']) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function serializeSavedLayoutDrawShapes(shapes: SavedLayoutDrawRecord['shapes']) {
|
||||
return JSON.stringify(Array.isArray(shapes) ? shapes : []);
|
||||
}
|
||||
62
src/features/layout/draw/layoutDrawTypes.ts
Normal file
62
src/features/layout/draw/layoutDrawTypes.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type DrawTool = 'select' | 'line' | 'rect' | 'paint';
|
||||
|
||||
export type BackgroundMode = 'grid' | 'plain';
|
||||
|
||||
export type DrawLine = {
|
||||
id: string;
|
||||
type: 'line';
|
||||
groupId?: string | null;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type DrawRect = {
|
||||
id: string;
|
||||
type: 'rect';
|
||||
groupId?: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
fillColor?: string | null;
|
||||
};
|
||||
|
||||
export type DrawRegion = {
|
||||
id: string;
|
||||
type: 'region';
|
||||
regionKey: string;
|
||||
label: string;
|
||||
fillColor?: string | null;
|
||||
};
|
||||
|
||||
export type DrawableShape = DrawLine | DrawRect;
|
||||
|
||||
export type DrawShape = DrawableShape | DrawRegion;
|
||||
|
||||
export type LayoutDrawDocument = {
|
||||
backgroundMode: BackgroundMode;
|
||||
shapes: DrawShape[];
|
||||
};
|
||||
|
||||
export type SavedLayoutDrawRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
backgroundMode: BackgroundMode;
|
||||
shapes: DrawShape[];
|
||||
};
|
||||
|
||||
export type SavedLayoutDrawComponentRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
shapes: DrawableShape[];
|
||||
};
|
||||
107
src/features/layout/draw/lineDraft.ts
Normal file
107
src/features/layout/draw/lineDraft.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
export type LineOrientation = 'horizontal' | 'vertical';
|
||||
|
||||
export type DrawLineLike = {
|
||||
type: 'line';
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
orientation: LineOrientation;
|
||||
};
|
||||
|
||||
export type DrawShapeLike =
|
||||
| DrawLineLike
|
||||
| {
|
||||
type: 'rect';
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function isBetween(value: number, start: number, end: number) {
|
||||
const min = Math.min(start, end);
|
||||
const max = Math.max(start, end);
|
||||
return value >= min && value <= max;
|
||||
}
|
||||
|
||||
function resolveNearestCoordinate(
|
||||
start: number,
|
||||
candidates: number[],
|
||||
direction: -1 | 1,
|
||||
) {
|
||||
return candidates.reduce<number | null>((closest, candidate) => {
|
||||
if ((candidate - start) * direction <= 0) {
|
||||
return closest;
|
||||
}
|
||||
|
||||
if (closest === null) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return Math.abs(candidate - start) < Math.abs(closest - start) ? candidate : closest;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function resolveLineOrientation(
|
||||
startX: number,
|
||||
startY: number,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
): LineOrientation {
|
||||
const deltaX = pointerX - startX;
|
||||
const deltaY = pointerY - startY;
|
||||
return Math.abs(deltaX) >= Math.abs(deltaY) ? 'vertical' : 'horizontal';
|
||||
}
|
||||
|
||||
export function resolveLineDraft(
|
||||
startX: number,
|
||||
startY: number,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
shapes: DrawShapeLike[],
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
preferredOrientation?: LineOrientation,
|
||||
) {
|
||||
const orientation =
|
||||
preferredOrientation ?? resolveLineOrientation(startX, startY, pointerX, pointerY);
|
||||
|
||||
if (orientation === 'horizontal') {
|
||||
const crossingXs = shapes.flatMap<number>((shape) => {
|
||||
if (shape.type !== 'line' || shape.orientation !== 'vertical') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return isBetween(startY, shape.y1, shape.y2) ? [shape.x1] : [];
|
||||
});
|
||||
const previousStop = resolveNearestCoordinate(startX, crossingXs, -1);
|
||||
const nextStop = resolveNearestCoordinate(startX, crossingXs, 1);
|
||||
|
||||
return {
|
||||
startX: previousStop ?? 0,
|
||||
startY,
|
||||
endX: nextStop ?? canvasWidth,
|
||||
endY: startY,
|
||||
orientation,
|
||||
};
|
||||
}
|
||||
|
||||
const crossingYs = shapes.flatMap<number>((shape) => {
|
||||
if (shape.type !== 'line' || shape.orientation !== 'horizontal') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return isBetween(startX, shape.x1, shape.x2) ? [shape.y1] : [];
|
||||
});
|
||||
const previousStop = resolveNearestCoordinate(startY, crossingYs, -1);
|
||||
const nextStop = resolveNearestCoordinate(startY, crossingYs, 1);
|
||||
|
||||
return {
|
||||
startX,
|
||||
startY: previousStop ?? 0,
|
||||
endX: startX,
|
||||
endY: nextStop ?? canvasHeight,
|
||||
orientation,
|
||||
};
|
||||
}
|
||||
@@ -135,7 +135,7 @@
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs .ant-tabs-nav {
|
||||
@@ -251,7 +251,7 @@
|
||||
.feature-menu-layout-page__editor-shell {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-self: stretch;
|
||||
height: calc(100% - 24px);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__field:first-of-type {
|
||||
@@ -290,14 +290,14 @@
|
||||
|
||||
.feature-menu-layout-page__textarea.ant-input {
|
||||
align-self: stretch;
|
||||
height: calc(100% - 4px) !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__notes {
|
||||
height: calc(100% - 4px);
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
padding: 7px 12px 7px;
|
||||
padding-bottom: 7px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Empty, Input, Modal, Space, Tabs, Tooltip, Typography, message
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../../app/main/chatTypeAccess';
|
||||
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
|
||||
import { createChatConversationRoom, fetchChatConversations } from '../../../app/main/mainChatPanel';
|
||||
import { buildChatPath } from '../../../app/main/routes';
|
||||
import { useTokenAccess } from '../../../app/main/tokenAccess';
|
||||
@@ -251,6 +252,7 @@ export function FeatureMenuLayoutPage({ layoutId, savedLayouts, onSavedLayoutsCh
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
okButtonProps: { danger: true },
|
||||
modalRender: renderModalWithEnterConfirm,
|
||||
async onOk() {
|
||||
const nextInteractions = selectedLayoutInteractions.filter((item) => item.id !== selectedFeature.id);
|
||||
const nextTree =
|
||||
|
||||
Reference in New Issue
Block a user