feat: add play apps and layout tools

This commit is contained in:
2026-05-25 17:29:21 +09:00
parent f59522ffc4
commit 51e0099bea
46 changed files with 37152 additions and 119 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { LayoutDrawPage } from './LayoutDrawPage';

View 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 }],
}),
});
}

View 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,
};
}

View 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;
}

View 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,
},
);
}

View 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,
};
}

View 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 }],
}),
});
}

View 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 : []);
}

View 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[];
};

View 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,
};
}

View File

@@ -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;

View File

@@ -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 =

View File

@@ -32,6 +32,7 @@ import {
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
import { confirmWithKeyboard } from '../../app/main/modalKeyboard';
import {
buildAutomationTypeOptions,
resolveAutomationTypeLabel,
@@ -646,6 +647,7 @@ export function PlanBoardPage({
const appConfig = useAppConfig();
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
const [messageApi, contextHolder] = message.useMessage();
const [modalApi, modalContextHolder] = Modal.useModal();
const screens = Grid.useBreakpoint();
const [items, setItems] = useState<PlanItem[]>([]);
const [reviewIndicatorsByPlanId, setReviewIndicatorsByPlanId] = useState<Record<number, ReviewListIndicator>>({});
@@ -1415,7 +1417,14 @@ export function PlanBoardPage({
return;
}
if (!window.confirm('선택한 작업 메모를 삭제할까요?')) {
const confirmed = await confirmWithKeyboard(modalApi, {
title: '선택한 작업 메모를 삭제할까요?',
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
});
if (!confirmed) {
return;
}
@@ -1748,6 +1757,7 @@ export function PlanBoardPage({
return (
<div className="plan-board-page">
{contextHolder}
{modalContextHolder}
{isMobileAutomationLayout ? null : (
<Card className="plan-board-page__overview" bordered={false}>

View File

@@ -15,6 +15,7 @@ import {
Tabs,
Tag,
Typography,
Modal,
message,
} from 'antd';
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
@@ -30,6 +31,7 @@ import {
} from '../../app/main/automationContextAccess';
import { buildPlansPath } from '../../app/main/routes';
import { useTokenAccess } from '../../app/main/tokenAccess';
import { confirmWithKeyboard } from '../../app/main/modalKeyboard';
import './planBoard.css';
import './planSchedule.css';
import { maskNotePreviewByWord } from './noteMasking';
@@ -596,6 +598,7 @@ export function PlanSchedulePage() {
const { automationTypes } = useAutomationTypeRegistry();
const { automationContexts } = useAutomationContextRegistry();
const [messageApi, contextHolder] = message.useMessage();
const [modalApi, modalContextHolder] = Modal.useModal();
const [items, setItems] = useState<PlanScheduledTask[]>([]);
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
const [editorOpen, setEditorOpen] = useState(false);
@@ -701,7 +704,14 @@ export function PlanSchedulePage() {
return;
}
if (!window.confirm('선택한 스케줄을 삭제할까요?')) {
const confirmed = await confirmWithKeyboard(modalApi, {
title: '선택한 스케줄을 삭제할까요?',
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
});
if (!confirmed) {
return;
}
@@ -752,6 +762,7 @@ export function PlanSchedulePage() {
return (
<div className="plan-schedule-page">
{contextHolder}
{modalContextHolder}
<Card className="plan-schedule-page__overview" bordered={false}>
<Flex justify="space-between" align="center" gap={12} wrap>
<div>