Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -91,10 +91,35 @@ export function resolveBoxBorders(
|
||||
};
|
||||
}
|
||||
|
||||
/** 색 글을 비교용으로 정리 — 공백 · 대소문자 · `#` 3자리 · ARGB(`#FF000000`) · `rgb(0,0,0)` 를 `#rrggbb` 로. */
|
||||
function normalizeColor(c: string): string {
|
||||
const t = c.trim().toLowerCase().replace(/\s+/g, "");
|
||||
const rgb = /^rgba?\((\d+),(\d+),(\d+)(?:,1(?:\.0+)?)?\)$/.exec(t);
|
||||
if (rgb)
|
||||
return `#${[rgb[1], rgb[2], rgb[3]].map((n) => Number(n).toString(16).padStart(2, "0")).join("")}`;
|
||||
if (/^#[0-9a-f]{3}$/.test(t)) return `#${t[1]}${t[1]}${t[2]}${t[2]}${t[3]}${t[3]}`;
|
||||
if (/^#ff[0-9a-f]{6}$/.test(t)) return `#${t.slice(3)}`;
|
||||
return t;
|
||||
}
|
||||
|
||||
/** 엑셀 「자동」 글자색 · 테두리색 — 검정 · windowtext · auto 는 테마 글자색을 따름(저장 값은 그대로 · 그릴 때만). */
|
||||
export function isAutoInk(c: string | undefined): boolean {
|
||||
if (!c) return true;
|
||||
const n = normalizeColor(c);
|
||||
return n === "#000000" || n === "black" || n === "windowtext" || n === "auto";
|
||||
}
|
||||
|
||||
/** 엑셀 「자동」 채움 — 흰색 · window 는 채움 없음처럼(테마 바탕이 비침). */
|
||||
export function isAutoPaper(c: string | undefined): boolean {
|
||||
if (!c) return true;
|
||||
const n = normalizeColor(c);
|
||||
return n === "#ffffff" || n === "white" || n === "window";
|
||||
}
|
||||
|
||||
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). `scale` = 확대 · 축소 배율(선 굵기도 같이). */
|
||||
function borderCss(side: BorderSide | undefined, scale: number): string {
|
||||
if (!side) return "";
|
||||
const color = side.색 ?? "currentColor";
|
||||
const color = isAutoInk(side.색) ? "var(--aislo-grid-fg)" : (side.색 as string);
|
||||
const w = (px: number): number => Math.max(1, Math.round(px * scale));
|
||||
switch (side.선) {
|
||||
case "hair":
|
||||
@@ -147,8 +172,8 @@ export function applyCellStyle(
|
||||
s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
s.color = style.글자색 ?? "";
|
||||
s.backgroundColor = style.채움 ?? "";
|
||||
s.color = isAutoInk(style.글자색) ? "" : (style.글자색 as string);
|
||||
s.backgroundColor = isAutoPaper(style.채움) ? "" : (style.채움 as string);
|
||||
s.borderTop = borderCss(borders.위, scale);
|
||||
s.borderLeft = borderCss(borders.왼, scale);
|
||||
s.borderBottom = borderCss(borders.아래, scale);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { colIndex, colName, parseA1, parseRange, toA1 } from "./spreadsheet_addr
|
||||
import {
|
||||
applyCellStyle,
|
||||
cellStyleAt,
|
||||
isAutoInk,
|
||||
resolveBoxBorders,
|
||||
setCellText,
|
||||
type ResolvedBorders,
|
||||
@@ -460,7 +461,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식);
|
||||
applyCellStyle(cellEl, style, borders, display.hAlign, zoom);
|
||||
// 조건부 서식 글자색이 이기고(엑셀과 같음) · 없으면 숫자 형식([Red] 등)이 정한 색.
|
||||
if (display.color && !condStyle?.글자색) cellEl.style.color = display.color;
|
||||
if (display.color && !condStyle?.글자색 && !isAutoInk(display.color))
|
||||
cellEl.style.color = display.color;
|
||||
const comment = sheet.comments?.[addr];
|
||||
// 수는 칸 폭보다 길면 `#####`(엑셀과 같음) · 글은 지금처럼 줄임표.
|
||||
let text = display.text;
|
||||
|
||||
@@ -176,8 +176,9 @@ export const CANVAS_INPUT_FIELD_HEIGHT = 20;
|
||||
|
||||
/**
|
||||
* Canvas input field background color
|
||||
* 자동색(검정) — 어두운 바탕 검정 칸에 흰 글 · 밝은 바탕 흰 칸에 검정 글(`theme.ts` paintColor)
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#161616';
|
||||
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#000000';
|
||||
|
||||
/**
|
||||
* Canvas input field text color
|
||||
|
||||
@@ -151,6 +151,12 @@ body > canvas[data-id="canvas"] {
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
transition: height 120ms ease;
|
||||
}
|
||||
/* 접힘 모드에서 탭으로 잠깐 펼침 — 도면 자리는 그대로 두고 위에 겹쳐 뜸(122px = 펼친 리본 높이) */
|
||||
.cad-ribbon[data-peek="true"] {
|
||||
z-index: 10;
|
||||
height: 122px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-ribbon-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { QuickAccessBar } from './QuickAccessBar';
|
||||
import { Ribbon } from '../ribbon/Ribbon';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { useCadRefresh } from './use-cad-refresh';
|
||||
import { isRibbonCollapsed } from './ui-state';
|
||||
import { isInspectorCollapsed, isRibbonCollapsed, setInspectorCollapsed } from './ui-state';
|
||||
import { getActiveToolActor } from '../state';
|
||||
import type { Tool } from '../tools';
|
||||
import { ViewControls } from './ViewControls';
|
||||
@@ -20,11 +20,12 @@ import { DropHint } from './DropHint';
|
||||
|
||||
export const Toolbar: FC = () => {
|
||||
useCadRefresh();
|
||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||
const [commandLineVisible, setCommandLineVisible] = useState(false);
|
||||
|
||||
const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null;
|
||||
const ribbonCollapsed = isRibbonCollapsed();
|
||||
// 좌측 패널 접힘 — 호스트(M02 미리보기)가 접힌 채로 실을 수 있게 ui-state 에 둔다
|
||||
const panelCollapsed = isInspectorCollapsed();
|
||||
|
||||
useEffect(() => {
|
||||
// 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아
|
||||
@@ -54,7 +55,7 @@ export const Toolbar: FC = () => {
|
||||
/>
|
||||
<InspectorPanel
|
||||
collapsed={panelCollapsed}
|
||||
onToggleCollapsed={() => setPanelCollapsed((value) => !value)}
|
||||
onToggleCollapsed={() => setInspectorCollapsed(!panelCollapsed)}
|
||||
/>
|
||||
<ViewControls />
|
||||
<DropHint />
|
||||
|
||||
@@ -242,7 +242,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
lineWidth: number,
|
||||
dash: number[] = []
|
||||
) {
|
||||
// 저장된 도면 색은 다크 배경 기준 — 라이트 테마에서는 그릴 때만 대비를 맞춘다.
|
||||
// 자동색(검정·흰색·기본색)은 바탕 따라 흰/검정 · 라이트 테마는 밝은 색 대비도 맞춤(그릴 때만).
|
||||
const color = paintColor(rawColor);
|
||||
if (this.batching) {
|
||||
const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getShouldDrawCursor,
|
||||
getSnapPoint,
|
||||
getSnapPointOnAngleGuide,
|
||||
isViewOnly,
|
||||
} from '../state';
|
||||
import {
|
||||
drawCursor,
|
||||
@@ -65,7 +66,8 @@ export function draw(drawController: ScreenCanvasDrawController) {
|
||||
);
|
||||
drawSnapPoint(drawController, closestSnapPoint, isMarked);
|
||||
|
||||
if (getShouldDrawCursor()) {
|
||||
// 미리보기는 십자 커서 · 명령 안내 · 좌표 풍선을 그리지 않는다(손 모양 커서).
|
||||
if (getShouldDrawCursor() && !isViewOnly()) {
|
||||
drawCursor(drawController);
|
||||
getInputController().draw(drawController);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
getSnapPoint,
|
||||
getSnapPointOnAngleGuide,
|
||||
isHostSave,
|
||||
isViewOnly,
|
||||
redo,
|
||||
setActiveToolActor,
|
||||
setAngleStep,
|
||||
@@ -111,6 +112,11 @@ export class InputController {
|
||||
canvas?.addEventListener('contextmenu', (evt) => {
|
||||
evt.preventDefault();
|
||||
});
|
||||
// 가운데 단추 — 브라우저 자동 스크롤(페이지가 같이 움직임) · 붙여넣기를 막는다.
|
||||
// 막는 자리는 mousedown(아래 handleMouseDown) — pointerdown 을 막으면 mousedown 이 안 와 팬이 끊긴다.
|
||||
canvas?.addEventListener('auxclick', (evt: MouseEvent) => {
|
||||
if (evt.button === MouseButton.Middle) evt.preventDefault();
|
||||
});
|
||||
// 캔버스 밖(리본·패널) 위에서의 트랙패드 핀치도 브라우저를 확대시키지 않는다.
|
||||
// ctrl이 없는 휠은 그대로 둬서 패널 스크롤은 살린다.
|
||||
document.addEventListener(
|
||||
@@ -191,6 +197,10 @@ export class InputController {
|
||||
if (evt.button === MouseButton.Middle) {
|
||||
setPanStartLocation(null);
|
||||
}
|
||||
if (evt.button === MouseButton.Left && isViewOnly()) {
|
||||
setPanStartLocation(null);
|
||||
return;
|
||||
}
|
||||
if (evt.button === MouseButton.Left) {
|
||||
if (this.isSelectToolActive()) {
|
||||
// 선택 도구는 누를 때 첫 점을 이미 보냈다. 끌었으면 놓는 자리로 사각형을 닫는다.
|
||||
@@ -264,6 +274,9 @@ export class InputController {
|
||||
setPanStartLocation(newScreenMouseLocation);
|
||||
}
|
||||
|
||||
// 미리보기는 보기만 — 스냅 · 가까운 개체 강조를 하지 않는다.
|
||||
if (isViewOnly()) return;
|
||||
|
||||
// Calculate angle guides and snap points
|
||||
if (getSnapEnabled()) {
|
||||
calculateAngleGuidesAndSnapPoints();
|
||||
@@ -322,11 +335,18 @@ export class InputController {
|
||||
|
||||
public handleMouseDown(evt: MouseEvent) {
|
||||
if (evt.button === MouseButton.Middle) {
|
||||
evt.preventDefault(); // 자동 스크롤로 부모 페이지가 같이 굴러가던 것
|
||||
setPanStartLocation(this.getCanvasPoint(evt));
|
||||
return;
|
||||
}
|
||||
if (evt.button !== MouseButton.Left) return;
|
||||
if ((evt.target as HTMLElement | null)?.closest('.controls')) return;
|
||||
if (isViewOnly()) {
|
||||
// 미리보기 — 왼쪽 끌기는 선택 상자 대신 화면 옮기기.
|
||||
evt.preventDefault();
|
||||
setPanStartLocation(this.getCanvasPoint(evt));
|
||||
return;
|
||||
}
|
||||
// AutoCAD처럼 누른 자리에서 사각형이 시작되도록 선택 도구에만 첫 점을 미리 보낸다.
|
||||
// 다른 도구는 예전처럼 놓을 때 한 점을 받는다 (끌다가 점이 두 개 찍히지 않게).
|
||||
if (!this.isSelectToolActive()) return;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getScreenCanvasDrawController,
|
||||
isDrawingDirty,
|
||||
isDrawingReadOnly,
|
||||
isViewOnly,
|
||||
resetUndoBaseline,
|
||||
setActiveLayerId,
|
||||
setDesignMeta,
|
||||
@@ -24,8 +25,9 @@ import {
|
||||
setHostSave,
|
||||
setHostTitle,
|
||||
setLayers,
|
||||
setViewOnly,
|
||||
} from '../state.ts';
|
||||
import { setRibbonCollapsed } from '../components/ui-state.ts';
|
||||
import { setInspectorCollapsed, setRibbonCollapsed } from '../components/ui-state.ts';
|
||||
import { toast } from 'react-toastify';
|
||||
import { runCommandInput } from '../commands/run-command.ts';
|
||||
import { setRecoveryScope } from '../helpers/autosave.ts';
|
||||
@@ -60,6 +62,10 @@ interface DrawingLoadMessage {
|
||||
hostSave?: boolean;
|
||||
/** 리본을 접힌 채로 여는가 — 없으면(B07) 펼침 그대로. */
|
||||
ribbonCollapsed?: boolean;
|
||||
/** 좌측 패널(특성 · 도면층)을 접힌 채로 여는가 — 없으면(B07) 지금 상태 그대로. */
|
||||
panelsCollapsed?: boolean;
|
||||
/** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
interface DrawingSaveRequestMessage {
|
||||
@@ -110,6 +116,10 @@ function registerTextDoubleClickEdit() {
|
||||
const canvas = getCanvas();
|
||||
if (!canvas) return;
|
||||
canvas.addEventListener('dblclick', (event: MouseEvent) => {
|
||||
if (isViewOnly()) {
|
||||
getScreenCanvasDrawController().zoomToFitScreen();
|
||||
return;
|
||||
}
|
||||
if (isDrawingReadOnly()) {
|
||||
toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.');
|
||||
return;
|
||||
@@ -211,6 +221,8 @@ export function registerAisloDrawingBridge() {
|
||||
setHostTitle(event.data.hostTitle ?? 'B07 상세 설계');
|
||||
setHostSave(event.data.hostSave === true);
|
||||
setRibbonCollapsed(event.data.ribbonCollapsed === true);
|
||||
if (event.data.panelsCollapsed) setInspectorCollapsed(true);
|
||||
setViewOnly(event.data.viewOnly === true);
|
||||
if (event.data.frameEdit) applyFramePreview();
|
||||
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
|
||||
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getLastDrawTimestamp,
|
||||
getScreenCanvasDrawController,
|
||||
getSnapPoint,
|
||||
isViewOnly,
|
||||
setActiveLayerId,
|
||||
setActiveToolActor,
|
||||
setCanvas,
|
||||
@@ -83,7 +84,7 @@ function startDrawLoop(
|
||||
/**
|
||||
* Highlight the entity closest to the mouse when the select tool is active
|
||||
*/
|
||||
if (getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT) {
|
||||
if (getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT && !isViewOnly()) {
|
||||
const screenCanvasDrawController = getScreenCanvasDrawController();
|
||||
if (!screenCanvasDrawController) {
|
||||
throw new Error('getScreenCanvasDrawController() returned null');
|
||||
|
||||
@@ -153,6 +153,20 @@ export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) =
|
||||
const activeTabId = getActiveRibbonTab();
|
||||
const activeTab = RIBBON_TABS.find((tab) => tab.id === activeTabId) ?? RIBBON_TABS[0];
|
||||
const collapsed = isRibbonCollapsed();
|
||||
// 접힘 모드에서 탭을 누르면 잠깐 펼침(도면 위에 겹쳐 뜸) — 리본 밖을 누르면 다시 접힘
|
||||
// (오피스 「리본 자동 숨기기」). 접기 단추로 펼치면 고정 펼침.
|
||||
const [peek, setPeek] = useState(false);
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
const peeking = collapsed && peek;
|
||||
|
||||
useEffect(() => {
|
||||
if (!peeking) return;
|
||||
const close = (event: PointerEvent) => {
|
||||
if (!navRef.current?.contains(event.target as Node)) setPeek(false);
|
||||
};
|
||||
window.addEventListener('pointerdown', close, true);
|
||||
return () => window.removeEventListener('pointerdown', close, true);
|
||||
}, [peeking]);
|
||||
|
||||
const handleRun = (command: CadCommand) => {
|
||||
runCommand(command);
|
||||
@@ -160,7 +174,7 @@ export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) =
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="cad-ribbon controls" aria-label="CAD 리본">
|
||||
<nav ref={navRef} className="cad-ribbon controls" aria-label="CAD 리본" data-peek={peeking}>
|
||||
<div className="cad-ribbon-tabs">
|
||||
{RIBBON_TABS.map((tab) => (
|
||||
<button
|
||||
@@ -169,7 +183,7 @@ export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) =
|
||||
data-active={tab.id === activeTab.id}
|
||||
onClick={() => {
|
||||
setActiveRibbonTab(tab.id);
|
||||
if (collapsed) setRibbonCollapsed(false);
|
||||
if (collapsed) setPeek(true);
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -178,13 +192,16 @@ export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) =
|
||||
<button
|
||||
type="button"
|
||||
className="cad-ribbon-tabs__collapse"
|
||||
onClick={() => setRibbonCollapsed(!collapsed)}
|
||||
onClick={() => {
|
||||
setPeek(false);
|
||||
setRibbonCollapsed(!collapsed);
|
||||
}}
|
||||
title={collapsed ? '리본 펼치기' : '리본 접기'}
|
||||
>
|
||||
{collapsed ? '⌄' : '⌃'}
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
{(!collapsed || peeking) && (
|
||||
<div className="cad-ribbon-panels">
|
||||
{activeTab.panels.map((panel) => (
|
||||
<Panel
|
||||
|
||||
@@ -192,6 +192,8 @@ let frameEditMode = false;
|
||||
let frameFields: Record<string, string> = {};
|
||||
/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */
|
||||
let hostReadOnly = false;
|
||||
/** 미리보기(M02 집계표 카드) — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
|
||||
let viewOnly = false;
|
||||
let hostTitle = 'B07 상세 설계';
|
||||
let hostSave = false;
|
||||
|
||||
@@ -272,6 +274,7 @@ export const isDrawingDirty = () => drawingDirty;
|
||||
* 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다.
|
||||
*/
|
||||
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true || hostReadOnly;
|
||||
export const isViewOnly = (): boolean => viewOnly;
|
||||
|
||||
// setters
|
||||
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
|
||||
@@ -515,6 +518,13 @@ export const setHostReadOnly = (readOnly: boolean) => {
|
||||
hostReadOnly = readOnly;
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
export const setViewOnly = (enabled: boolean) => {
|
||||
viewOnly = enabled;
|
||||
// 그리는 십자 커서 대신 손 모양 — 캔버스는 `cursor-none` 이라 안 두면 커서가 안 보임.
|
||||
const canvas = getCanvas();
|
||||
if (canvas) canvas.style.cursor = enabled ? 'grab' : '';
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
export const setFrameEditMode = (enabled: boolean, fields: Record<string, string> = {}) => {
|
||||
frameEditMode = enabled;
|
||||
frameFields = enabled ? fields : {};
|
||||
|
||||
@@ -61,26 +61,38 @@ function relativeLuminance(r: number, g: number, b: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* 도면 색 보정 — 저장된 엔티티 색은 다크 배경 기준(밝은 회색·흰색)이라
|
||||
* 라이트 모드 흰 종이 위에서는 안 보인다. **그릴 때만** 휘도를 낮춰 색상(색조)은
|
||||
* 유지한 채 대비를 살린다. 저장 데이터는 건드리지 않는다.
|
||||
* 자동색 — 오토캐드 색 번호 7 처럼 바탕을 따라 흰/검정으로 그리는 색.
|
||||
* 검정 · 흰색(DXF 색 7) · 기본색(`#f5f7fa` = 새 개체 · DXF ByLayer 가져온 색).
|
||||
*/
|
||||
const AUTO_COLORS = new Set(['#000000', '#ffffff', '#f5f7fa', 'black', 'white']);
|
||||
|
||||
function normalizeHex(color: string): string {
|
||||
const value = color.trim().toLowerCase();
|
||||
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(value);
|
||||
return short ? `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}` : value;
|
||||
}
|
||||
|
||||
export function isAutoColor(color: string): boolean {
|
||||
return AUTO_COLORS.has(normalizeHex(color));
|
||||
}
|
||||
|
||||
/**
|
||||
* 도면 색 보정 — **그릴 때만** 바꾼다(저장 데이터는 그대로).
|
||||
* - 자동색: 어두운 바탕 흰색 · 밝은 바탕 검정.
|
||||
* - 밝은 바탕: 흰 종이에 묻히는 밝은 색은 휘도를 낮춰 색조를 살린 채 대비를 맞춘다.
|
||||
* - 어두운 바탕: 자동색 밖은 그대로(어두운 색을 밝히지 않음).
|
||||
*/
|
||||
export function paintColor(color: string): string {
|
||||
if (!isLightTheme()) return color;
|
||||
const light = isLightTheme();
|
||||
if (isAutoColor(color)) return light ? '#000000' : '#ffffff';
|
||||
if (!light) return color;
|
||||
const cached = paintCache.get(color);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color);
|
||||
let result = color;
|
||||
if (hex) {
|
||||
const raw = hex[1];
|
||||
const full =
|
||||
raw.length === 3
|
||||
? raw
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: raw;
|
||||
const full = normalizeHex(color).slice(1);
|
||||
const r = Number.parseInt(full.slice(0, 2), 16);
|
||||
const g = Number.parseInt(full.slice(2, 4), 16);
|
||||
const b = Number.parseInt(full.slice(4, 6), 16);
|
||||
|
||||
@@ -128,6 +128,31 @@ export interface StructureNumbers {
|
||||
export const fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>> =>
|
||||
call("/structures/numbers");
|
||||
|
||||
/** 구조물집계표조합 한 줄 — 구조물 열 하나(키 `SS` + 6자리 · 한 번 준 키는 안 바뀜) */
|
||||
export interface StructureCombo extends StructureNumbers {
|
||||
키: string;
|
||||
열: string;
|
||||
종류: string | null;
|
||||
분류1: string | null;
|
||||
분류2: string | null;
|
||||
분류3: string | null;
|
||||
단위: string | null;
|
||||
일위대가: string | null;
|
||||
바인딩: unknown;
|
||||
펼침: unknown;
|
||||
}
|
||||
|
||||
export interface StructureCombos {
|
||||
그룹: string;
|
||||
판: number;
|
||||
비고: string;
|
||||
다음: number;
|
||||
줄: StructureCombo[];
|
||||
}
|
||||
|
||||
/** 조합 표 — 서버가 집계표 [저장] 때 맞춤(읽기만) */
|
||||
export const fetchStructureCombos = (): Promise<StructureCombos> => call("/structure-combos");
|
||||
|
||||
/** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */
|
||||
export const copyStructure = async (column: string, from: string): Promise<void> => {
|
||||
await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
|
||||
@@ -39,6 +39,10 @@ export interface DrawingTemplateOptions {
|
||||
onChanged?: (dirty: boolean) => void;
|
||||
/** 페이지 제목 줄 안의 빈 칸 — 있으면 작도 영역 칸 · 파일 불러오기를 거기 한 줄로 놓는다. */
|
||||
headerSlot?: HTMLElement;
|
||||
/** CAD 좌측 패널(특성 · 도면층)을 접힌 채로 연다 — 누르면 펼침(구조물 도면 미리보기). */
|
||||
panelsCollapsed?: boolean;
|
||||
/** 미리보기(집계표 카드) — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__";
|
||||
@@ -195,6 +199,8 @@ export function mountDrawingTemplate(
|
||||
hostTitle: "도면 양식 편집",
|
||||
hostSave: true,
|
||||
ribbonCollapsed: true,
|
||||
panelsCollapsed: options.panelsCollapsed,
|
||||
viewOnly: options.preview === true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,6 +37,12 @@ def get_structure_numbers() -> dict:
|
||||
return _call(store.structure_numbers)
|
||||
|
||||
|
||||
@router.get("/structure-combos")
|
||||
def get_structure_combos() -> dict:
|
||||
"""구조물집계표조합 — 구조물 열마다 줄 하나(키 SS · 제목행 · 번호 · 바인딩) · 읽기만."""
|
||||
return _call(store.read_combos)
|
||||
|
||||
|
||||
@router.post("/structures/{column}")
|
||||
def post_structure(column: str) -> dict:
|
||||
"""구조물 도면 새로 — 열 id 하나 · 도번 자동 · 빈 면적산출 · 재료산출도 · 이미 있으면 409."""
|
||||
|
||||
@@ -120,6 +120,7 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게
|
||||
prune_removed_structures()
|
||||
sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 · 재료번 칸
|
||||
return info
|
||||
|
||||
|
||||
@@ -369,3 +370,59 @@ def backfill_missing_structures() -> list[str]:
|
||||
for cid in _column_ids():
|
||||
made += [f"{kind}/{cid}" for kind in _KEY if _ensure(kind, cid)]
|
||||
return made
|
||||
|
||||
|
||||
# ── 구조물집계표조합 ─────────────────────
|
||||
# 구조물 열마다 줄 하나(제어용 장부 · 관리자는 집계표 화면으로 고침 · 서버가 집계표로 맞춤) ·
|
||||
# 키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억)
|
||||
COMBO_FILE = layers.COMBO_FILE
|
||||
_COMBO_HEAD = "SS"
|
||||
_COMBO_NOTE = (
|
||||
"구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · "
|
||||
"키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀"
|
||||
)
|
||||
|
||||
|
||||
def _combo_row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, Any]:
|
||||
kind, level1, level2, level3 = _heads(col)
|
||||
return {
|
||||
"키": key,
|
||||
"열": col["id"],
|
||||
"종류": kind,
|
||||
"분류1": level1,
|
||||
"분류2": level2,
|
||||
"분류3": level3,
|
||||
"단위": col.get("단위"),
|
||||
"일위대가": col.get("일위대가"),
|
||||
**{field: numbers.get(field, "") for _, field, _, _ in PARTS},
|
||||
"바인딩": col.get("바인딩"),
|
||||
"펼침": col.get("펼침"),
|
||||
}
|
||||
|
||||
|
||||
def sync_combos() -> dict[str, Any]:
|
||||
"""조합 표를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
|
||||
줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀."""
|
||||
path = FOLDER / COMBO_FILE
|
||||
with _LOCK:
|
||||
old = json.loads(path.read_bytes()) if path.is_file() else {}
|
||||
keys = {row["열"]: row["키"] for row in old.get("줄", [])}
|
||||
taken = [int(k[len(_COMBO_HEAD) :]) for k in keys.values()]
|
||||
after = max(old.get("다음", 1), max(taken, default=0) + 1)
|
||||
numbers = structure_numbers()
|
||||
rows = []
|
||||
for col in _structure_columns():
|
||||
if col["id"] not in keys:
|
||||
keys[col["id"]] = f"{_COMBO_HEAD}{after:06d}"
|
||||
after += 1
|
||||
rows.append(_combo_row(keys[col["id"]], col, numbers.get(col["id"], {})))
|
||||
doc = {"그룹": "구조물집계표조합", "판": 1, "비고": _COMBO_NOTE, "다음": after, "줄": rows}
|
||||
if doc != old:
|
||||
atomic_write_json(path, doc)
|
||||
return doc
|
||||
|
||||
|
||||
def read_combos() -> dict[str, Any]:
|
||||
"""조합 표 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦."""
|
||||
path = FOLDER / COMBO_FILE
|
||||
return json.loads(path.read_bytes()) if path.is_file() else sync_combos()
|
||||
|
||||
@@ -333,8 +333,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
if (!inner?.head || inner.getElementById("m02-struct-hide")) return;
|
||||
const style = inner.createElement("style");
|
||||
style.id = "m02-struct-hide";
|
||||
style.textContent =
|
||||
".cad-titlebar, .cad-ribbon, .cad-inspector { display: none !important; }";
|
||||
style.textContent = ".cad-titlebar, .cad-ribbon { display: none !important; }";
|
||||
inner.head.append(style);
|
||||
};
|
||||
frame.addEventListener("load", apply);
|
||||
@@ -377,6 +376,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
readOnly: true,
|
||||
name: `preview:${column ?? ""}`,
|
||||
layer: "system",
|
||||
panelsCollapsed: true, // 특성 · 도면층 패널은 접힌 채 — › 를 누르면 펼침
|
||||
preview: true, // 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤
|
||||
});
|
||||
const frame = previewHost.querySelector("iframe");
|
||||
if (frame) hideToolbar(frame);
|
||||
|
||||
@@ -31,6 +31,7 @@ KINDS = ("table", "drawing", "structure", "area", "material")
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
COMBO_FILE = "구조물집계표조합.json" # 층 뿌리의 한 파일(종류 폴더 밖)
|
||||
|
||||
#: 시스템 층 뿌리 — 시험이 임시 폴더로 바꿈.
|
||||
SYSTEM_ROOT = config_system.PROJECT_ROOT / "resources" / "master_template"
|
||||
@@ -317,6 +318,7 @@ def seed_project(project_root: str | Path, *, only_missing: bool = False) -> dic
|
||||
프로젝트 만들 때 · 옛 프로젝트 넣기(`only_missing`) 둘 다 이 길.
|
||||
|
||||
`_initial/` 은 늘 없는 것만 더함 — 이미 있으면 절대 덮지 않음.
|
||||
구조물집계표조합(층 뿌리의 한 파일)도 같이 실림.
|
||||
"""
|
||||
return {
|
||||
"작업본": copy_templates(
|
||||
@@ -324,13 +326,27 @@ def seed_project(project_root: str | Path, *, only_missing: bool = False) -> dic
|
||||
project_dir(project_root),
|
||||
source_layer="system",
|
||||
only_missing=only_missing,
|
||||
),
|
||||
)
|
||||
+ _copy_combo(project_dir(project_root), only_missing),
|
||||
"초기": copy_templates(
|
||||
system_dir(), initial_dir(project_root), source_layer="system", only_missing=True
|
||||
),
|
||||
)
|
||||
+ _copy_combo(initial_dir(project_root), True),
|
||||
}
|
||||
|
||||
|
||||
def _copy_combo(target_dir: Path, only_missing: bool) -> list[str]:
|
||||
source = system_dir() / COMBO_FILE
|
||||
target = target_dir / COMBO_FILE
|
||||
if not source.is_file() or (only_missing and target.exists()):
|
||||
return []
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_name(f".{target.name}.tmp")
|
||||
shutil.copyfile(source, temporary)
|
||||
temporary.replace(target)
|
||||
return [target.stem]
|
||||
|
||||
|
||||
def reset_project(
|
||||
project_root: str | Path, *, kind: str | None = None, name: str | None = None
|
||||
) -> list[str]:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* CAD 자동색(`theme.ts` paintColor) 검증 — 검정 · 흰색 · 기본색은 바탕 따라 흰/검정, 다른 색은 그대로.
|
||||
* 실행: node --experimental-strip-types resources/tester/cad/theme-auto-color.test.ts
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
let theme = 'dark';
|
||||
// biome-ignore lint: 시험 — 브라우저 전역 흉내
|
||||
const g = globalThis as any;
|
||||
g.document = { documentElement: { getAttribute: () => theme } };
|
||||
g.window = { matchMedia: () => ({ matches: true }) };
|
||||
|
||||
const { paintColor, isAutoColor } = await import(
|
||||
'../../../B07_DesignDetail/openwebcad/src/theme.ts'
|
||||
);
|
||||
|
||||
for (const color of ['#000000', '#000', '#FFFFFF', '#fff', '#f5f7fa', 'black']) {
|
||||
assert.ok(isAutoColor(color), `${color} 자동색`);
|
||||
}
|
||||
for (const color of ['#ff0000', '#111111', '#333333', '#1a237e', '#a55200']) {
|
||||
assert.ok(!isAutoColor(color), `${color} 자동색 아님`);
|
||||
}
|
||||
|
||||
theme = 'dark';
|
||||
assert.equal(paintColor('#000000'), '#ffffff', '어두운 바탕: 검정은 흰색');
|
||||
assert.equal(paintColor('#f5f7fa'), '#ffffff', '어두운 바탕: 기본색은 흰색');
|
||||
assert.equal(paintColor('#ffffff'), '#ffffff');
|
||||
assert.equal(paintColor('#ff0000'), '#ff0000', '어두운 바탕: 빨강 그대로');
|
||||
assert.equal(paintColor('#1a237e'), '#1a237e', '어두운 바탕: 남색 밝히지 않음');
|
||||
assert.equal(paintColor('#333333'), '#333333', '어두운 바탕: 어두운 회색 밝히지 않음');
|
||||
|
||||
theme = 'light';
|
||||
assert.equal(paintColor('#ffffff'), '#000000', '밝은 바탕: 흰색은 검정');
|
||||
assert.equal(paintColor('#FFF'), '#000000');
|
||||
assert.equal(paintColor('#000000'), '#000000');
|
||||
assert.equal(paintColor('#f5f7fa'), '#000000', '밝은 바탕: 기본색은 검정');
|
||||
assert.equal(paintColor('#ff0000'), '#ff0000', '밝은 바탕: 빨강 그대로');
|
||||
assert.notEqual(paintColor('#ffff00'), '#ffff00', '밝은 바탕: 노랑 등 밝은 색은 대비 보정(기존)');
|
||||
|
||||
console.log('theme 자동색 시험 통과');
|
||||
@@ -0,0 +1,109 @@
|
||||
"""구조물집계표조합 — 서버가 집계표 [저장] 때 맞추는 장부(키 SS · 열마다 한 줄).
|
||||
|
||||
견본 표(`write_sample_table`)로만 봄 — 실제 마스터 양식은 안 읽음.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TABLE = "/api/m02/templates/table/구조물집계표"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
write_sample_table(tmp_path)
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _save_table(client, change) -> None:
|
||||
got = client.get(TABLE).json()
|
||||
change(got["문서"])
|
||||
assert client.put(TABLE, json={"판": got["판"], "문서": got["문서"]}).status_code == 200
|
||||
|
||||
|
||||
def _keys(client) -> dict[str, str]:
|
||||
return {row["열"]: row["키"] for row in client.get("/api/m02/structure-combos").json()["줄"]}
|
||||
|
||||
|
||||
def test_처음_한_번_만듦_글_열_빼고_칸은_집계표_그대로(client, tmp_path):
|
||||
assert not (tmp_path / store.COMBO_FILE).exists()
|
||||
doc = client.get("/api/m02/structure-combos").json()
|
||||
assert (doc["그룹"], doc["다음"]) == ("구조물집계표조합", 14)
|
||||
rows = doc["줄"]
|
||||
assert [r["열"] for r in rows][:3] == ["rv", "c1", "pv_b"]
|
||||
assert {"no", "sta", "memo"}.isdisjoint(r["열"] for r in rows) # 글 열 빼기
|
||||
assert (rows[0]["키"], rows[-1]["키"]) == ("SS000001", "SS000013")
|
||||
assert rows[-1]["열"] == "h_rip_m"
|
||||
fb = next(r for r in rows if r["열"] == "fb")
|
||||
assert (fb["종류"], fb["분류1"], fb["분류2"], fb["분류3"]) == (
|
||||
"세월교",
|
||||
"(관경 × 련수마다)",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert (tmp_path / store.COMBO_FILE).is_file()
|
||||
|
||||
|
||||
def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지(client):
|
||||
before = _keys(client)
|
||||
|
||||
def change(table):
|
||||
cols = table["열"]
|
||||
cols[:] = [c for c in cols if c["id"] != "h_rip_m"] # 맨 끝 키 SS000013 열 지움
|
||||
rv = next(c for c in cols if c["id"] == "rv")
|
||||
rv["머리"] = ["돌기슭막이", "메쌓기", "1.2m", "기초유"]
|
||||
rv.update(단위="m", 일위대가="UA000004", 펼침=True, 바인딩={"종류": "revetment"})
|
||||
cols.insert(3, {"id": "new1", "머리": ["옹벽", None, None, None], "단위": "m", "꼴": "수"})
|
||||
|
||||
_save_table(client, change)
|
||||
doc = client.get("/api/m02/structure-combos").json()
|
||||
rows = {r["열"]: r for r in doc["줄"]}
|
||||
assert "h_rip_m" not in rows
|
||||
assert rows["new1"]["키"] == "SS000014" # 지운 열의 SS000013 은 다시 안 씀
|
||||
assert [r["열"] for r in doc["줄"]][:4] == ["rv", "new1", "c1", "pv_b"] # 집계표 차례
|
||||
assert all(rows[c]["키"] == k for c, k in before.items() if c != "h_rip_m")
|
||||
rv = rows["rv"]
|
||||
assert (rv["분류1"], rv["분류3"], rv["단위"], rv["일위대가"]) == (
|
||||
"메쌓기",
|
||||
"기초유",
|
||||
"m",
|
||||
"UA000004",
|
||||
)
|
||||
assert (rv["바인딩"], rv["펼침"]) == ({"종류": "revetment"}, True)
|
||||
# 번호는 세 파일과 같음 — 새 열도 [저장] 한 번에 SD · SA · SM 이 섬
|
||||
numbers = client.get("/api/m02/structures/numbers").json()
|
||||
assert {k: rows["new1"][k] for k in ("도번", "면적번", "재료번")} == numbers["new1"]
|
||||
assert rows["new1"]["도번"].startswith("SD")
|
||||
|
||||
# 또 저장해도(바뀐 것 없음) 키 · 다음 그대로
|
||||
_save_table(client, lambda table: None)
|
||||
again = client.get("/api/m02/structure-combos").json()
|
||||
assert (again["다음"], again["줄"]) == (15, doc["줄"])
|
||||
|
||||
|
||||
def test_SS_머리글은_키대장과_안_겹침():
|
||||
ledger = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8"))
|
||||
assert "SS" not in ledger["다음"]
|
||||
assert store._COMBO_HEAD == "SS"
|
||||
|
||||
|
||||
def test_새_프로젝트_복사에_실림(client, tmp_path, monkeypatch):
|
||||
client.get("/api/m02/structure-combos")
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path)
|
||||
copied = layers.seed_project(tmp_path / "project")
|
||||
assert "구조물집계표조합" in copied["작업본"] and "구조물집계표조합" in copied["초기"]
|
||||
target = layers.project_dir(tmp_path / "project") / store.COMBO_FILE
|
||||
assert json.loads(target.read_bytes())["줄"][0]["키"] == "SS000001"
|
||||
@@ -125,3 +125,23 @@ def test_열_머리_글_잇기():
|
||||
"SD010101 돌기슭막이 H=1.0",
|
||||
"돌기슭막이",
|
||||
]
|
||||
|
||||
|
||||
def test_미리보기_CAD_패널은_숨기지_않고_접힌_채로_엶():
|
||||
"""특성 · 도면층 패널을 숨기지 않음 — 곁값 panelsCollapsed 로 접어 실음(B07 은 안 보냄)."""
|
||||
panel = _text(PANEL)
|
||||
assert ".cad-inspector" not in panel and "panelsCollapsed: true" in panel
|
||||
drawing = _text(M02 / "M02_MasterTemplete_Drawing.ts")
|
||||
assert "panelsCollapsed: options.panelsCollapsed" in drawing
|
||||
assert "panelsCollapsed?: boolean" in _text(ROOT / "ui_template/cad_host/cad_host.ts")
|
||||
cad = ROOT / "B07_DesignDetail/openwebcad/src"
|
||||
bridge = _text(cad / "integration/aislo-drawing-bridge.ts")
|
||||
assert "if (event.data.panelsCollapsed) setInspectorCollapsed(true);" in bridge
|
||||
assert "isInspectorCollapsed()" in _text(cad / "components/Toolbar.tsx")
|
||||
|
||||
|
||||
def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘():
|
||||
ribbon = _text(ROOT / "B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx")
|
||||
assert "if (collapsed) setPeek(true);" in ribbon # 탭 = 잠깐 펼침(접힘 모드 유지)
|
||||
assert "addEventListener('pointerdown', close, true)" in ribbon # 리본 밖 = 다시 접힘
|
||||
assert "setPeek(false);" in ribbon and "setRibbonCollapsed(!collapsed);" in ribbon # 단추
|
||||
|
||||
@@ -65,6 +65,10 @@ export interface CadLoadExtra {
|
||||
hostSave?: boolean;
|
||||
/** 리본을 접힌 채로 연다 — 안 보내면 펼침 그대로. */
|
||||
ribbonCollapsed?: boolean;
|
||||
/** 좌측 패널(특성 · 도면층)을 접힌 채로 연다 — 안 보내면(B07) 지금 상태 그대로. */
|
||||
panelsCollapsed?: boolean;
|
||||
/** 미리보기 — 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤 · 선택 · 명령 안내 없음. */
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface CadHost<D extends CadHostDrawing, M, Q> {
|
||||
|
||||
@@ -248,6 +248,20 @@
|
||||
transform: translateY(-50%);
|
||||
border-left: 0;
|
||||
border-radius: 0 var(--radius-buttons) var(--radius-buttons) 0;
|
||||
transition:
|
||||
right var(--transition-base),
|
||||
width var(--transition-base);
|
||||
}
|
||||
|
||||
/* 펼친 title 패널 — 손잡이를 패널 안쪽 오른쪽 가장자리로(본문 카드 왼쪽 끝을 덮던 조각 없앰).
|
||||
폭 16px = 패널 본문 오른쪽 여백 8 + 스크롤바 8 이라 패널 내용도 안 덮음.
|
||||
접히면 위 규칙대로 패널 밖(화면 왼쪽 끝)으로 나와 다시 펼 수 있음. */
|
||||
.ui-workflow-overlay__panel--title:not(.is-collapsed) > .ui-workflow-overlay__handle--side {
|
||||
right: 0;
|
||||
width: var(--spacing-16);
|
||||
border-right: 0;
|
||||
border-left: 1px solid var(--panel-handle-border, var(--color-border));
|
||||
border-radius: var(--radius-buttons) 0 0 var(--radius-buttons);
|
||||
}
|
||||
|
||||
/* 하단 도킹 패널 상단 중앙 핸들 */
|
||||
|
||||
Reference in New Issue
Block a user