perf(B07): 도면을 미리 받아 두고, 커서 지연과 토스트 이질감을 걷는다

도면을 열 때마다 0.8~1.0초가 걸렸다. 서버가 요청마다 도면을 새로 조립하고(0.3~0.6초),
구조물 모듈이 공유 캐시를 건너뛰고 종횡단 상세를 매번 다시 받았기 때문이다(0.2초).
진입 후 첫 장을 띄우고 나서 목록 전체를 배경에서 받아 캐시에 담고, 구조물 모듈은
B05·B06과 같은 Section_Store를 쓴다. 클릭에서 렌더까지 55~65ms로 줄었다.

CAD 알림은 react-toastify라 프로젝트 공용 토스트와 모양이 달랐다. 호출부 30여 곳을
그대로 두려고 vite alias로 라이브러리 자리에 다리를 끼워, 부모가 있으면 메시지로 넘겨
부모 토스트로 띄운다. 백업 되살리기처럼 누르는 안내를 위해 showToast에 onClick을 더했다.

커서가 미묘하게 끌리던 것은 프레임이 느려서가 아니라(16.7ms, 60fps) 합성 파이프라인
때문이다. 화면 캔버스를 desynchronized+alpha:false로 만들어 그 큐를 건너뛴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 21:05:23 +09:00
co-authored by Claude Opus 5
parent bfaf32a043
commit 9c1cdf8f4d
7 changed files with 222 additions and 13 deletions
@@ -0,0 +1,106 @@
/**
* 토스트 다리 — CAD 앱의 알림을 **부모 페이지 토스트**로 띄운다.
*
* openwebcad 원본은 `react-toastify`를 쓴다. 임베드(B07 상세설계) 상태에서는 모양·자리·
* 닫기 버튼이 프로젝트 공용 토스트(`ui_template_elements.showToast`)와 달라 한 화면에
* 두 가지 토스트가 섞여 보였다(2026-08-30 사용자 지적). 그래서 부모가 있으면 메시지로
* 넘겨 부모가 자기 토스트로 띄우고, 단독 실행이면 예전처럼 직접 띄운다.
*
* 호출부는 그대로 `import { toast } from 'react-toastify'` 를 쓴다 — vite alias가 이
* 파일로 돌린다(`vite.config.ts`). 그래서 30여 개 파일을 고치지 않는다.
*/
const TOAST_MESSAGE = 'aislo:b08:toast';
const TOAST_ACTION_MESSAGE = 'aislo:b08:toast-action';
export type ToastKind = 'info' | 'success' | 'warning' | 'error';
interface ToastOptions {
/** false면 자동으로 닫지 않는다(부모에는 지속 시간으로 넘긴다). */
autoClose?: number | false;
onClick?: () => void;
}
/** 부모에서 눌린 토스트가 다시 찾아올 동작들. */
const actions = new Map<string, () => void>();
let actionSeq = 0;
let listening = false;
function embedded(): boolean {
return typeof window !== 'undefined' && window.parent !== window;
}
function listenForActions(): void {
if (listening || typeof window === 'undefined') return;
listening = true;
window.addEventListener('message', (event: MessageEvent<unknown>) => {
const data = event.data as { type?: string; actionId?: string } | null;
if (!data || data.type !== TOAST_ACTION_MESSAGE || !data.actionId) return;
const action = actions.get(data.actionId);
actions.delete(data.actionId);
action?.();
});
}
/** 단독 실행(부모 없음) 폴백 — 프로젝트 토스트와 같은 모양의 최소 구현. */
function showLocal(kind: ToastKind, text: string, options?: ToastOptions): void {
if (typeof document === 'undefined') return;
const host =
document.querySelector('.aislo-cad-toasts') ??
document.body.appendChild(
Object.assign(document.createElement('div'), { className: 'aislo-cad-toasts' })
);
const item = document.createElement('div');
item.className = `aislo-cad-toast aislo-cad-toast--${kind}`;
item.textContent = text;
if (options?.onClick) {
item.style.cursor = 'pointer';
item.addEventListener('click', () => {
options.onClick?.();
item.remove();
});
}
host.append(item);
if (options?.autoClose !== false) {
window.setTimeout(() => item.remove(), options?.autoClose ?? 3000);
}
}
function send(kind: ToastKind, text: string, options?: ToastOptions): void {
if (!embedded()) {
showLocal(kind, text, options);
return;
}
listenForActions();
let actionId: string | undefined;
if (options?.onClick) {
actionSeq += 1;
actionId = `toast-${actionSeq}`;
actions.set(actionId, options.onClick);
}
window.parent.postMessage(
{
type: TOAST_MESSAGE,
kind,
text,
actionId,
durationMs: options?.autoClose === false ? 0 : options?.autoClose,
},
window.location.origin
);
}
/** `react-toastify`의 toast와 같은 호출 모양만 맞춘 얇은 대체물. */
export const toast = {
info: (text: string, options?: ToastOptions) => send('info', text, options),
success: (text: string, options?: ToastOptions) => send('success', text, options),
warn: (text: string, options?: ToastOptions) => send('warning', text, options),
warning: (text: string, options?: ToastOptions) => send('warning', text, options),
error: (text: string, options?: ToastOptions) => send('error', text, options),
};
/** 원본 `ToastContainer` 자리 — 이제 그릴 것이 없다(부모가 그린다).
* 원본과 같은 props(position·theme 등)를 받아 넘겨도 조용히 무시한다. */
export function ToastContainer(_props?: Record<string, unknown>): null {
return null;
}
+7 -1
View File
@@ -143,7 +143,13 @@ function initApplication() {
if (canvas) {
setCanvas(canvas);
const context = canvas.getContext('2d');
// 저지연 캔버스 — 렌더러 합성 큐를 건너뛰어 커서·고무줄이 손에 붙는다
// (2026-08-30 커서가 미묘하게 끌린다는 지적. 프레임은 16.7ms로 정상이었고
// 남은 지연은 합성 파이프라인이었다). alpha:false는 불투명 캔버스로 만들어
// 합성을 한 겹 더 덜어 준다 — 도면 배경은 언제나 불투명하다.
// https://developer.chrome.com/blog/desynchronized
const context =
canvas.getContext('2d', { desynchronized: true, alpha: false }) ?? canvas.getContext('2d');
if (!context) return;
setEntities([], true); // Creates the first undo entry