auto: 2026-08-29 16:28 (EOMSANGDON-HOME)
This commit is contained in:
@@ -7,9 +7,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
|
||||
<title>Aislo 2D Drawing</title>
|
||||
</head>
|
||||
<body class="m-0 p-0 overflow-hidden w-full h-full min-h-screen flex flex-row bg-white">
|
||||
<div id="root" data-id="root" class="bg-slate-950"></div>
|
||||
<canvas data-id="canvas" class="block bg-black cursor-none"></canvas>
|
||||
<body class="m-0 p-0 overflow-hidden w-full h-full min-h-screen flex flex-row">
|
||||
<div id="root" data-id="root"></div>
|
||||
<canvas data-id="canvas" class="block cursor-none"></canvas>
|
||||
<div class="export-pdf-wrapper">
|
||||
<!-- Used to append the svg before exporting it to pdf -->
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
/* 색 토큰의 정의처는 앱과 같은 파일 하나 — 여기서 색을 새로 정하지 않는다. */
|
||||
@import "../../../ui_template/ui_template_theme.css";
|
||||
|
||||
:root {
|
||||
--cad-title-height: 42px;
|
||||
@@ -7,9 +9,21 @@
|
||||
--cad-command-height: 0px;
|
||||
--cad-status-height: 28px;
|
||||
--cad-panel-width: 248px;
|
||||
font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif;
|
||||
color: #dce6f2;
|
||||
background: #11161d;
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text-body);
|
||||
background: var(--color-bg);
|
||||
|
||||
/* CAD 표면 단계 — 앱 토큰을 CAD 골격(툴바·리본·패널)에 이름만 붙여 쓴다. */
|
||||
--cad-chrome: var(--color-surface);
|
||||
--cad-chrome-raised: var(--color-surface-raised);
|
||||
--cad-chrome-sunken: var(--color-bg);
|
||||
--cad-line: var(--color-border);
|
||||
--cad-text: var(--color-text-body);
|
||||
--cad-text-dim: var(--color-text-secondary);
|
||||
--cad-text-muted: var(--color-text-muted);
|
||||
--cad-accent: var(--color-accent);
|
||||
--cad-hover: var(--color-mist-violet);
|
||||
--cad-canvas: var(--color-canvas);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -56,7 +70,7 @@ body > canvas[data-id="canvas"] {
|
||||
var(--cad-command-height) -
|
||||
var(--cad-status-height)
|
||||
);
|
||||
background: #111;
|
||||
background: var(--cad-canvas);
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
@@ -68,9 +82,9 @@ body > canvas[data-id="canvas"] {
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 0 12px;
|
||||
background: #19222d;
|
||||
border-bottom: 1px solid #344252;
|
||||
box-shadow: 0 1px 4px #0008;
|
||||
background: var(--cad-chrome);
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.cad-brand {
|
||||
display: flex;
|
||||
@@ -79,12 +93,12 @@ body > canvas[data-id="canvas"] {
|
||||
min-width: 220px;
|
||||
}
|
||||
.cad-brand strong {
|
||||
color: #f7fbff;
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.cad-brand span,
|
||||
.cad-file-state {
|
||||
color: #91a2b5;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-file-state {
|
||||
@@ -96,8 +110,8 @@ body > canvas[data-id="canvas"] {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #49b675;
|
||||
box-shadow: 0 0 0 2px #49b67522;
|
||||
background: var(--color-success);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 25%, transparent);
|
||||
}
|
||||
.cad-title-actions {
|
||||
display: flex;
|
||||
@@ -114,8 +128,8 @@ body > canvas[data-id="canvas"] {
|
||||
font-size: 12px;
|
||||
}
|
||||
.cad-title-actions button:hover {
|
||||
border-color: #4d6074;
|
||||
background: #273544;
|
||||
border-color: var(--cad-line);
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
|
||||
.cad-ribbon {
|
||||
@@ -128,15 +142,15 @@ body > canvas[data-id="canvas"] {
|
||||
align-items: stretch;
|
||||
padding: 5px 8px 3px;
|
||||
overflow-x: auto;
|
||||
background: #202b37;
|
||||
border-bottom: 1px solid #3c4a59;
|
||||
background: var(--cad-chrome-raised);
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-ribbon-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: max-content;
|
||||
padding: 0 9px;
|
||||
border-right: 1px solid #40505f;
|
||||
border-right: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-ribbon-tools {
|
||||
display: flex;
|
||||
@@ -145,7 +159,7 @@ body > canvas[data-id="canvas"] {
|
||||
}
|
||||
.cad-ribbon-group__label {
|
||||
margin-top: auto;
|
||||
color: #8293a5;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -159,7 +173,7 @@ body > canvas[data-id="canvas"] {
|
||||
gap: 3px;
|
||||
align-items: stretch;
|
||||
font-size: 10px;
|
||||
color: #8293a5;
|
||||
color: var(--cad-text-muted);
|
||||
}
|
||||
.cad-prop > span {
|
||||
text-align: center;
|
||||
@@ -169,9 +183,9 @@ body > canvas[data-id="canvas"] {
|
||||
height: 24px;
|
||||
min-width: 64px;
|
||||
padding: 0 4px;
|
||||
color: #dce6f2;
|
||||
background: #2a3745;
|
||||
border: 1px solid #40505f;
|
||||
color: var(--cad-text);
|
||||
background: var(--cad-chrome-sunken);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -183,8 +197,8 @@ body > canvas[data-id="canvas"] {
|
||||
height: 24px;
|
||||
width: 40px;
|
||||
padding: 1px;
|
||||
background: #2a3745;
|
||||
border: 1px solid #40505f;
|
||||
background: var(--cad-chrome-sunken);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -203,24 +217,24 @@ body > canvas[data-id="canvas"] {
|
||||
}
|
||||
.cad-tool:hover:not(:disabled),
|
||||
.cad-tool[data-active="true"] {
|
||||
border-color: #4f87b8;
|
||||
background: #2b455c;
|
||||
border-color: var(--cad-accent);
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-tool[data-active="true"] {
|
||||
box-shadow: inset 0 -2px #4ca6e8;
|
||||
box-shadow: inset 0 -2px var(--cad-accent);
|
||||
}
|
||||
.cad-tool:disabled {
|
||||
color: #657382;
|
||||
color: var(--color-smoke);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cad-tool__glyph {
|
||||
height: 27px;
|
||||
color: #a9d7fb;
|
||||
color: var(--cad-accent);
|
||||
font-size: 22px;
|
||||
line-height: 27px;
|
||||
}
|
||||
.cad-tool:disabled .cad-tool__glyph {
|
||||
color: #657382;
|
||||
color: var(--color-smoke);
|
||||
}
|
||||
|
||||
.cad-inspector {
|
||||
@@ -230,8 +244,8 @@ body > canvas[data-id="canvas"] {
|
||||
bottom: calc(var(--cad-command-height) + var(--cad-status-height));
|
||||
left: 0;
|
||||
width: var(--cad-panel-width);
|
||||
background: #1b2530;
|
||||
border-right: 1px solid #3d4c5c;
|
||||
background: var(--cad-chrome);
|
||||
border-right: 1px solid var(--cad-line);
|
||||
transition: width 120ms ease;
|
||||
}
|
||||
.cad-inspector[data-collapsed="true"] {
|
||||
@@ -244,57 +258,57 @@ body > canvas[data-id="canvas"] {
|
||||
left: 100%;
|
||||
width: 20px;
|
||||
height: 38px;
|
||||
border: 1px solid #465769;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-left: 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: #263442;
|
||||
color: #a9bbcd;
|
||||
background: var(--cad-chrome-raised);
|
||||
color: var(--cad-text-dim);
|
||||
}
|
||||
.cad-inspector-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
height: 38px;
|
||||
border-bottom: 1px solid #394857;
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-inspector-tabs button {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: #17202a;
|
||||
color: #94a7ba;
|
||||
background: var(--cad-chrome-sunken);
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cad-inspector-tabs button[data-active="true"] {
|
||||
border-bottom-color: #4da3df;
|
||||
background: #223141;
|
||||
color: white;
|
||||
border-bottom-color: var(--cad-accent);
|
||||
background: var(--cad-chrome-raised);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cad-properties {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
.cad-properties h2 {
|
||||
margin: 0 0 14px;
|
||||
color: #f3f7fb;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cad-properties dl {
|
||||
margin: 0;
|
||||
border-top: 1px solid #344352;
|
||||
border-top: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-properties dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #2d3a47;
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-properties dt {
|
||||
color: #8092a4;
|
||||
color: var(--cad-text-dim);
|
||||
}
|
||||
.cad-properties dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #d7e1eb;
|
||||
color: var(--cad-text);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-layer-manager {
|
||||
@@ -306,7 +320,23 @@ body > canvas[data-id="canvas"] {
|
||||
min-height: 36px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
background: #202c38;
|
||||
}
|
||||
/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */
|
||||
.cad-layer-manager button[data-type="regular"] {
|
||||
background: var(--cad-chrome-sunken);
|
||||
color: var(--cad-accent);
|
||||
}
|
||||
.cad-layer-manager button[data-type="transparent"] {
|
||||
background: transparent;
|
||||
color: var(--cad-accent);
|
||||
}
|
||||
.cad-layer-manager button:hover {
|
||||
background: var(--cad-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cad-layer-manager button[data-active="true"] {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.cad-view-controls {
|
||||
@@ -317,29 +347,29 @@ body > canvas[data-id="canvas"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid #465565;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: #202b37e8;
|
||||
box-shadow: 0 3px 12px #0008;
|
||||
background: var(--cad-chrome-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-view-controls button {
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-right: 1px solid #3d4c5b;
|
||||
border-right: 1px solid var(--cad-line);
|
||||
background: transparent;
|
||||
font-size: 17px;
|
||||
}
|
||||
.cad-view-controls button:hover {
|
||||
background: #31516b;
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-view-controls__fit {
|
||||
color: #7fd0ff;
|
||||
color: var(--cad-accent);
|
||||
font-size: 15px;
|
||||
}
|
||||
.cad-view-controls span {
|
||||
min-width: 48px;
|
||||
color: #9fb0c1;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -357,11 +387,11 @@ body > canvas[data-id="canvas"] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cad-command-prompt span {
|
||||
color: #6eaee0;
|
||||
color: var(--cad-accent);
|
||||
}
|
||||
.cad-command-prompt strong {
|
||||
overflow: hidden;
|
||||
color: #aab8c6;
|
||||
color: var(--cad-text-dim);
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -372,7 +402,7 @@ body > canvas[data-id="canvas"] {
|
||||
height: 28px;
|
||||
}
|
||||
.cad-command-area label {
|
||||
color: #e6eef6;
|
||||
color: var(--color-text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -380,16 +410,16 @@ body > canvas[data-id="canvas"] {
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #46586a;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
background: #0e141a;
|
||||
color: #eef5fc;
|
||||
background: var(--cad-chrome-sunken);
|
||||
color: var(--cad-text);
|
||||
font: 11px Consolas, monospace;
|
||||
}
|
||||
.cad-command-area input:focus {
|
||||
border-color: #4b9bd3;
|
||||
box-shadow: 0 0 0 1px #4b9bd344;
|
||||
border-color: var(--color-focus-ring);
|
||||
box-shadow: 0 0 0 1px var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.cad-statusbar {
|
||||
@@ -403,8 +433,8 @@ body > canvas[data-id="canvas"] {
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 8px;
|
||||
background: #263544;
|
||||
border-top: 1px solid #415263;
|
||||
background: var(--cad-chrome);
|
||||
border-top: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-statusbar button {
|
||||
height: 21px;
|
||||
@@ -412,20 +442,20 @@ body > canvas[data-id="canvas"] {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: #9caebf;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
.cad-statusbar button:hover {
|
||||
background: #33485b;
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-statusbar button[data-active="true"] {
|
||||
border-color: #4f9bd0;
|
||||
background: #285579;
|
||||
color: white;
|
||||
border-color: var(--cad-accent);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
.cad-statusbar__hint {
|
||||
margin-left: auto;
|
||||
color: #7f91a2;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -441,11 +471,11 @@ body > canvas[data-id="canvas"] {
|
||||
/* 표(가장 넓은 자식) 기준 폭 고정 → 접었다 펴도 폭이 변하지 않는다 */
|
||||
width: max-content;
|
||||
max-width: min(1040px, calc(100vw - var(--cad-panel-width) - 24px));
|
||||
border: 1px solid #465565;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 6px;
|
||||
background: #202b37f2;
|
||||
box-shadow: 0 4px 16px #0009;
|
||||
color: #dce6f2;
|
||||
background: var(--cad-chrome-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: var(--cad-text);
|
||||
}
|
||||
|
||||
.cad-qtable__header {
|
||||
@@ -463,12 +493,12 @@ body > canvas[data-id="canvas"] {
|
||||
min-width: 0;
|
||||
}
|
||||
.cad-qtable__title strong {
|
||||
color: #f7fbff;
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
.cad-qtable__title span {
|
||||
overflow: hidden;
|
||||
color: #91a2b5;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -480,26 +510,26 @@ body > canvas[data-id="canvas"] {
|
||||
font-style: normal;
|
||||
}
|
||||
.cad-qtable__status[data-confirmed="true"] {
|
||||
background: #1f4d33;
|
||||
color: #7fdca4;
|
||||
background: color-mix(in srgb, var(--color-success) 22%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
.cad-qtable__status[data-confirmed="false"] {
|
||||
background: #4a3a1c;
|
||||
color: #e6c07a;
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.cad-qtable__nav,
|
||||
.cad-qtable__collapse {
|
||||
width: 26px;
|
||||
height: 24px;
|
||||
border: 1px solid #40505f;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: #2a3745;
|
||||
color: #cdd8e4;
|
||||
background: var(--cad-chrome-sunken);
|
||||
color: var(--cad-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.cad-qtable__nav:hover:not(:disabled),
|
||||
.cad-qtable__collapse:hover {
|
||||
background: #31516b;
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-qtable__nav:disabled {
|
||||
opacity: 0.35;
|
||||
@@ -525,17 +555,17 @@ body > canvas[data-id="canvas"] {
|
||||
.cad-qtable th,
|
||||
.cad-qtable td {
|
||||
padding: 0;
|
||||
border: 1px solid #40505f;
|
||||
border: 1px solid var(--cad-line);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cad-qtable__label {
|
||||
padding: 2px 9px;
|
||||
color: #a9bccd;
|
||||
color: var(--cad-text-dim);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: #26333f;
|
||||
background: var(--cad-chrome);
|
||||
}
|
||||
.cad-qtable__label--vertical {
|
||||
width: 18px;
|
||||
@@ -548,18 +578,18 @@ body > canvas[data-id="canvas"] {
|
||||
padding: 3px 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #eaf1f8;
|
||||
color: var(--cad-text);
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
.cad-qtable__value input:focus {
|
||||
outline: 2px solid #4ca6e8;
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: -2px;
|
||||
background: #12324a;
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-qtable__value--derived input {
|
||||
color: #8fa3b5;
|
||||
background: #1a242e;
|
||||
color: var(--cad-text-muted);
|
||||
background: var(--cad-chrome-sunken);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import type {FC} from 'react';
|
||||
import AlignBottomIcon from 'teenyicons/outline/align-bottom.svg?react';
|
||||
import AlignCenterHorizontalIcon from 'teenyicons/outline/align-center-horizontal.svg?react';
|
||||
import AlignCenterVerticalIcon from 'teenyicons/outline/align-center-vertical.svg?react';
|
||||
import AlignLeftIcon from 'teenyicons/outline/align-left.svg?react';
|
||||
import AlignRightIcon from 'teenyicons/outline/align-right.svg?react';
|
||||
import AlignTextJustifyIcon from 'teenyicons/outline/align-text-justify.svg?react';
|
||||
import AlignTopIcon from 'teenyicons/outline/align-top.svg?react';
|
||||
import AntiClockwiseIcon from 'teenyicons/outline/anti-clockwise.svg?react';
|
||||
import ArrowLeftCircle from 'teenyicons/outline/arrow-left-circle.svg?react';
|
||||
import ArrowRightCircle from 'teenyicons/outline/arrow-right-circle.svg?react';
|
||||
import CircleIcon from 'teenyicons/outline/circle.svg?react';
|
||||
import ClockwiseIcon from 'teenyicons/outline/clockwise.svg?react';
|
||||
import CropIcon from 'teenyicons/outline/crop.svg?react';
|
||||
import DirectionIcon from 'teenyicons/outline/direction.svg?react';
|
||||
import DocumentsIcon from 'teenyicons/outline/documents.svg?react';
|
||||
import DownloadIcon from 'teenyicons/outline/download.svg?react';
|
||||
import EditIcon from 'teenyicons/outline/edit.svg?react';
|
||||
import ExpandIcon from 'teenyicons/outline/expand.svg?react';
|
||||
import EyeClosedIcon from 'teenyicons/outline/eye-closed.svg?react';
|
||||
import EyeIcon from 'teenyicons/outline/eye.svg?react';
|
||||
import FilePlusIcon from 'teenyicons/outline/file-plus.svg?react';
|
||||
import FolderPlusIcon from 'teenyicons/outline/folder-plus.svg?react';
|
||||
import FolderTickIcon from 'teenyicons/outline/folder-tick.svg?react';
|
||||
import FolderXIcon from 'teenyicons/outline/folder-x.svg?react';
|
||||
import FolderIcon from 'teenyicons/outline/folder.svg?react';
|
||||
import GithubIcon from 'teenyicons/outline/github.svg?react';
|
||||
import GridLayoutIcon from 'teenyicons/outline/grid-layout.svg?react';
|
||||
import ImageIcon from 'teenyicons/outline/image.svg?react';
|
||||
import JavascriptIcon from 'teenyicons/outline/javascript.svg?react';
|
||||
import LayersDifferenceIcon from 'teenyicons/outline/layers-difference.svg?react';
|
||||
import LineIcon from 'teenyicons/outline/line.svg?react';
|
||||
import LockIcon from 'teenyicons/outline/lock.svg?react';
|
||||
import PdfIcon from 'teenyicons/outline/pdf.svg?react';
|
||||
import PngIcon from 'teenyicons/outline/png.svg?react';
|
||||
import SaveIcon from 'teenyicons/outline/save.svg?react';
|
||||
import SendDownIcon from 'teenyicons/outline/send-down.svg?react';
|
||||
import SendUpIcon from 'teenyicons/outline/send-up.svg?react';
|
||||
import SquareIcon from 'teenyicons/outline/square.svg?react';
|
||||
import SvgIcon from 'teenyicons/outline/svg.svg?react';
|
||||
import UnlockIcon from 'teenyicons/outline/unlock.svg?react';
|
||||
import HomeAltIcon from 'teenyicons/outline/home-alt.svg?react';
|
||||
|
||||
import VectorDocumentIcon from 'teenyicons/outline/vector-document.svg?react';
|
||||
import SolidDownSmallIcon from 'teenyicons/solid/down-small.svg?react';
|
||||
import SolidDownIcon from 'teenyicons/solid/down.svg?react';
|
||||
import ImageSoldIcon from 'teenyicons/solid/image.svg?react';
|
||||
import JavascriptSolidIcon from 'teenyicons/solid/javascript.svg?react';
|
||||
import PdfSolidIcon from 'teenyicons/solid/pdf.svg?react';
|
||||
import SolidUpSmallIcon from 'teenyicons/solid/up-small.svg?react';
|
||||
import SolidUpIcon from 'teenyicons/solid/up.svg?react';
|
||||
import VectorDocumentSolidIcon from 'teenyicons/solid/vector-document.svg?react';
|
||||
import MeasurementIcon from './custom-icons/measurement.svg?react';
|
||||
import ScaleIcon from './custom-icons/scale.svg?react'; // https://icon-sets.iconify.design/teenyicons
|
||||
|
||||
// https://icon-sets.iconify.design/teenyicons
|
||||
enum IconName {
|
||||
// Outline icons
|
||||
Line = 'Line',
|
||||
Square = 'Square',
|
||||
Circle = 'Circle',
|
||||
Direction = 'Direction',
|
||||
VectorDocument = 'VectorDocument',
|
||||
VectorDocumentSolid = 'VectorDocumentSolid',
|
||||
LayersDifference = 'LayersDifference',
|
||||
AntiClockwise = 'AntiClockwise',
|
||||
Clockwise = 'Clockwise',
|
||||
Github = 'Github',
|
||||
Crop = 'Crop',
|
||||
Folder = 'Folder',
|
||||
FolderTick = 'FolderTick',
|
||||
Save = 'Save',
|
||||
Svg = 'Svg',
|
||||
Pdf = 'Pdf',
|
||||
PdfSolid = 'PdfSolid',
|
||||
Png = 'Png',
|
||||
Javascript = 'Javascript',
|
||||
JavascriptSolid = 'JavascriptSolid',
|
||||
Expand = 'Expand',
|
||||
Image = 'Image',
|
||||
ImageSolid = 'ImageSolid',
|
||||
ArrowLeftCircle = 'ArrowLeftCircle',
|
||||
ArrowRightCircle = 'ArrowRightCircle',
|
||||
Measurement = 'Measurement',
|
||||
FilePlus = 'FilePlus',
|
||||
Edit = 'Edit',
|
||||
SendUp = 'SendUp',
|
||||
SendDown = 'SendDown',
|
||||
AlignLeft = 'AlignLeft',
|
||||
AlignRight = 'AlignRight',
|
||||
AlignCenterHorizontal = 'AlignCenterHorizontal',
|
||||
AlignTop = 'AlignTop',
|
||||
AlignBottom = 'AlignBottom',
|
||||
AlignCenterVertical = 'AlignCenterVertical',
|
||||
Documents = 'Documents',
|
||||
Download = 'Download',
|
||||
FolderX = 'FolderX',
|
||||
FolderPlus = 'FolderPlus',
|
||||
AlignTextJustify = 'AlignTextJustify',
|
||||
Eye = 'Eye',
|
||||
EyeClosed = 'EyeClosed',
|
||||
Lock = 'Lock',
|
||||
Unlock = 'Unlock',
|
||||
GridLayout = 'GridLayout',
|
||||
HomeAlt = 'HomeAlt',
|
||||
|
||||
// Solid icons
|
||||
SolidDown = 'SolidDown',
|
||||
SolidUp = 'SolidUp',
|
||||
SolidUpSmall = 'SolidUpSmall',
|
||||
SolidDownSmall = 'SolidDownSmall',
|
||||
|
||||
// Custom icons
|
||||
Scale = 'Scale',
|
||||
}
|
||||
|
||||
const icons: Record<IconName, FC> = {
|
||||
// Outline icons
|
||||
[IconName.Line]: LineIcon,
|
||||
[IconName.Square]: SquareIcon,
|
||||
[IconName.Circle]: CircleIcon,
|
||||
[IconName.Direction]: DirectionIcon,
|
||||
[IconName.VectorDocument]: VectorDocumentIcon,
|
||||
[IconName.VectorDocumentSolid]: VectorDocumentSolidIcon,
|
||||
[IconName.LayersDifference]: LayersDifferenceIcon,
|
||||
[IconName.AntiClockwise]: AntiClockwiseIcon,
|
||||
[IconName.Clockwise]: ClockwiseIcon,
|
||||
[IconName.Github]: GithubIcon,
|
||||
[IconName.Crop]: CropIcon,
|
||||
[IconName.Folder]: FolderIcon,
|
||||
[IconName.FolderTick]: FolderTickIcon,
|
||||
[IconName.Save]: SaveIcon,
|
||||
[IconName.Svg]: SvgIcon,
|
||||
[IconName.Pdf]: PdfIcon,
|
||||
[IconName.PdfSolid]: PdfSolidIcon,
|
||||
[IconName.Png]: PngIcon,
|
||||
[IconName.Javascript]: JavascriptIcon,
|
||||
[IconName.JavascriptSolid]: JavascriptSolidIcon,
|
||||
[IconName.Expand]: ExpandIcon,
|
||||
[IconName.Image]: ImageIcon,
|
||||
[IconName.ImageSolid]: ImageSoldIcon,
|
||||
[IconName.ArrowLeftCircle]: ArrowLeftCircle,
|
||||
[IconName.ArrowRightCircle]: ArrowRightCircle,
|
||||
[IconName.Measurement]: MeasurementIcon,
|
||||
[IconName.FilePlus]: FilePlusIcon,
|
||||
[IconName.Edit]: EditIcon,
|
||||
[IconName.SendUp]: SendUpIcon,
|
||||
[IconName.SendDown]: SendDownIcon,
|
||||
[IconName.AlignLeft]: AlignLeftIcon,
|
||||
[IconName.AlignRight]: AlignRightIcon,
|
||||
[IconName.AlignCenterHorizontal]: AlignCenterHorizontalIcon,
|
||||
[IconName.AlignTop]: AlignTopIcon,
|
||||
[IconName.AlignBottom]: AlignBottomIcon,
|
||||
[IconName.AlignCenterVertical]: AlignCenterVerticalIcon,
|
||||
[IconName.Documents]: DocumentsIcon,
|
||||
[IconName.Download]: DownloadIcon,
|
||||
[IconName.FolderX]: FolderXIcon,
|
||||
[IconName.FolderPlus]: FolderPlusIcon,
|
||||
[IconName.AlignTextJustify]: AlignTextJustifyIcon,
|
||||
[IconName.Eye]: EyeIcon,
|
||||
[IconName.EyeClosed]: EyeClosedIcon,
|
||||
[IconName.Lock]: LockIcon,
|
||||
[IconName.Unlock]: UnlockIcon,
|
||||
[IconName.GridLayout]: GridLayoutIcon,
|
||||
[IconName.HomeAlt]: HomeAltIcon,
|
||||
|
||||
// Solid icons
|
||||
[IconName.SolidDown]: SolidDownIcon,
|
||||
[IconName.SolidUp]: SolidUpIcon,
|
||||
[IconName.SolidUpSmall]: SolidUpSmallIcon,
|
||||
[IconName.SolidDownSmall]: SolidDownSmallIcon,
|
||||
|
||||
// Custom icons
|
||||
[IconName.Scale]: ScaleIcon,
|
||||
};
|
||||
|
||||
export { IconName };
|
||||
|
||||
interface IconProps {
|
||||
name: IconName;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Icon: FC<IconProps> = ({ name, className }) => {
|
||||
const CurrentIcon = icons[name];
|
||||
return (
|
||||
<div className={`icon w-5${className ? ` ${className}` : ''}`}>
|
||||
<CurrentIcon />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -1,11 +1,12 @@
|
||||
import { Point, type Vector } from '@flatten-js/core';
|
||||
import { CANVAS_BACKGROUND_COLOR, MOUSE_ZOOM_MULTIPLIER } from '../App.consts';
|
||||
import { MOUSE_ZOOM_MULTIPLIER } from '../App.consts';
|
||||
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
|
||||
import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { mapNumberRange } from '../helpers/map-number-range.ts';
|
||||
import { StateVariable } from '../helpers/undo-stack.ts';
|
||||
import { getEntities, getGridEnabled, triggerReactUpdate } from '../state.ts';
|
||||
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
|
||||
import { paintColor, themeColor } from '../theme.ts';
|
||||
|
||||
/**
|
||||
* Screen coordinate system:
|
||||
@@ -231,10 +232,12 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
public setLineStyles(
|
||||
isHighlighted: boolean,
|
||||
isSelected: boolean,
|
||||
color: string,
|
||||
rawColor: string,
|
||||
lineWidth: number,
|
||||
dash: number[] = []
|
||||
) {
|
||||
// 저장된 도면 색은 다크 기준 — 라이트 테마에서는 그릴 때만 대비를 맞춘다.
|
||||
const color = paintColor(rawColor);
|
||||
if (this.batching) {
|
||||
const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth;
|
||||
const effectiveDash = isSelected ? [5, 5] : dash;
|
||||
@@ -298,7 +301,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
}
|
||||
|
||||
public setFillStyles(fillColor: string) {
|
||||
this.context.fillStyle = fillColor;
|
||||
this.context.fillStyle = paintColor(fillColor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,10 +333,10 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
if (!this.context) return;
|
||||
if (this.batching) this.flushBatch();
|
||||
|
||||
this.context.fillStyle = CANVAS_BACKGROUND_COLOR;
|
||||
this.context.fillStyle = themeColor('--cad-canvas', '#111');
|
||||
this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y);
|
||||
if (getGridEnabled()) {
|
||||
this.context.strokeStyle = '#242b35';
|
||||
this.context.strokeStyle = themeColor('--color-mist', '#242b35');
|
||||
this.context.lineWidth = 1;
|
||||
this.context.setLineDash([]);
|
||||
this.context.beginPath();
|
||||
@@ -554,7 +557,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
this.context.rotate(angle);
|
||||
this.context.font = `${opts.fontSize}px ${opts.fontFamily}`;
|
||||
this.context.textAlign = opts.textAlign;
|
||||
this.context.fillStyle = opts.textColor;
|
||||
this.context.fillStyle = paintColor(opts.textColor);
|
||||
this.context.textBaseline = 'middle';
|
||||
this.context.fillText(label, 0, 0);
|
||||
this.context.restore();
|
||||
@@ -630,7 +633,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {
|
||||
if (this.batching) this.flushBatch();
|
||||
// TODO see if we need to replace this with a call to fillPolygon
|
||||
this.context.fillStyle = color;
|
||||
this.context.fillStyle = paintColor(color);
|
||||
this.context.fillRect(xMin, this.canvasSize.y - yMin, width, height);
|
||||
}
|
||||
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import {compact} from 'es-toolkit';
|
||||
import {saveAs} from 'file-saver';
|
||||
import type {Layer} from '../../App.types.ts';
|
||||
import type {Entity, JsonEntity} from '../../entities/Entity';
|
||||
import {getEntities, getLayers} from '../../state';
|
||||
|
||||
export async function exportEntitiesToJsonFile() {
|
||||
const json = await exportEntitiesAndLayersToJsonString();
|
||||
|
||||
const blob = new Blob([json], { type: 'text/json;charset=utf-8' });
|
||||
saveAs(blob, 'open-web-cad--drawing.json');
|
||||
}
|
||||
|
||||
export async function exportEntitiesAndLayersToJsonString() {
|
||||
const entities = getEntities();
|
||||
|
||||
const jsonEntities = entities.map((entity) => entity.toJson());
|
||||
const jsonDrawingFile: JsonDrawingFileSerialized = {
|
||||
entities: compact(await Promise.all(jsonEntities)), // TODO use a mapLimit to avoid overloading the event loop
|
||||
layers: getLayers(),
|
||||
};
|
||||
return JSON.stringify(jsonDrawingFile, null, 2);
|
||||
}
|
||||
|
||||
export interface JsonDrawingFileSerialized {
|
||||
entities: JsonEntity[];
|
||||
layers: Layer[];
|
||||
}
|
||||
|
||||
export interface JsonDrawingFileDeserialized {
|
||||
entities: Entity[];
|
||||
layers: Layer[];
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import {LOCAL_STORAGE_KEY} from '../../App.types.ts';
|
||||
import {exportEntitiesAndLayersToJsonString} from './export-entities-to-json.ts';
|
||||
|
||||
export async function exportEntitiesToLocalStorage() {
|
||||
const json = await exportEntitiesAndLayersToJsonString();
|
||||
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY.DRAWING, json);
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { saveAs } from 'file-saver';
|
||||
import { convertEntitiesToSvgString } from './export-entities-to-svg';
|
||||
import { getEntities } from '../../state';
|
||||
|
||||
/**
|
||||
* Takes an svg string and converts it to a png data uri
|
||||
* by creating an svg element in the dom and drawing that element on the canvas
|
||||
* Then taking the canvas data and outputting it as a png data blob
|
||||
* @param svgLines
|
||||
* @param width
|
||||
* @param height
|
||||
* @param margin
|
||||
*/
|
||||
export function convertSvgToPngBlob(
|
||||
svgLines: string[],
|
||||
width: number,
|
||||
height: number,
|
||||
margin: number,
|
||||
): Promise<Blob> {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
const svg = new Blob(svgLines, { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(svg);
|
||||
|
||||
img.onload = () => {
|
||||
canvas.width = width + margin * 2;
|
||||
canvas.height = height + margin * 2;
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.drawImage(img, margin, margin);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
canvas.toBlob(blob => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Could not convert canvas to blob'));
|
||||
}
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportEntitiesToPngFile() {
|
||||
const entities = getEntities();
|
||||
|
||||
const svg = convertEntitiesToSvgString(entities);
|
||||
const pngDataBlob: Blob = await convertSvgToPngBlob(
|
||||
svg.svgLines,
|
||||
svg.width,
|
||||
svg.height,
|
||||
20,
|
||||
);
|
||||
|
||||
saveAs(pngDataBlob, 'open-web-cad--drawing.png');
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import {saveAs} from 'file-saver';
|
||||
import {SVG_MARGIN} from '../../App.consts';
|
||||
import {SvgDrawController} from '../../drawControllers/svg.drawController.ts';
|
||||
import type {Entity} from '../../entities/Entity';
|
||||
import {getEntities} from '../../state';
|
||||
import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts';
|
||||
|
||||
export function convertEntitiesToSvgString(entities: Entity[]): {
|
||||
svgLines: string[];
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
|
||||
|
||||
const svgDrawController = new SvgDrawController(
|
||||
boundingBox.minX - SVG_MARGIN,
|
||||
boundingBox.minY - SVG_MARGIN,
|
||||
boundingBox.maxX + SVG_MARGIN,
|
||||
boundingBox.maxY + SVG_MARGIN
|
||||
);
|
||||
|
||||
for (const entity of entities) {
|
||||
entity.draw(svgDrawController);
|
||||
}
|
||||
|
||||
return svgDrawController.export();
|
||||
}
|
||||
|
||||
export function exportEntitiesToSvgFile() {
|
||||
const entities = getEntities();
|
||||
|
||||
const svg = convertEntitiesToSvgString(entities);
|
||||
|
||||
const blob = new Blob(svg.svgLines, { type: 'text/svg;charset=utf-8' });
|
||||
saveAs(blob, 'open-web-cad--drawing.svg');
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import {compact} from 'es-toolkit';
|
||||
import {ArcEntity, type ArcJsonData} from '../../entities/ArcEntity';
|
||||
import {CircleEntity, type CircleJsonData} from '../../entities/CircleEntity';
|
||||
import {type Entity, EntityName, type JsonEntity} from '../../entities/Entity';
|
||||
import {ImageEntity, type ImageJsonData} from '../../entities/ImageEntity.ts';
|
||||
import {LineEntity, type LineJsonData} from '../../entities/LineEntity';
|
||||
import {MeasurementEntity, type MeasurementJsonData} from '../../entities/MeasurementEntity.ts';
|
||||
import {PointEntity, type PointJsonData} from '../../entities/PointEntity';
|
||||
import {PolyLineEntity, type PolyLineJsonData} from '../../entities/PolyLineEntity.ts';
|
||||
import {RectangleEntity, type RectangleJsonData} from '../../entities/RectangleEntity';
|
||||
import {TextEntity, type TextJsonData} from '../../entities/TextEntity.ts';
|
||||
import {setActiveLayerId, setEntities, setLayers} from '../../state';
|
||||
import {getNewLayer} from '../get-new-layer.ts';
|
||||
import type {JsonDrawingFileDeserialized, JsonDrawingFileSerialized,} from './export-entities-to-json';
|
||||
|
||||
/**
|
||||
* Open a file selection dialog to select *.json files
|
||||
* Parse the JSON file
|
||||
* Generate entities from the JSON data
|
||||
* Set the entities in the state
|
||||
*/
|
||||
export function importEntitiesFromJsonFile(file: File | null | undefined) {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', async () => {
|
||||
const json = reader.result as string;
|
||||
const file = await getEntitiesAndLayersFromJsonString(json);
|
||||
setEntities(file.entities);
|
||||
setLayers(file.layers);
|
||||
setActiveLayerId(file.layers[0].id);
|
||||
resolve();
|
||||
});
|
||||
reader.readAsText(file, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEntitiesAndLayersFromJsonObject(
|
||||
data: JsonDrawingFileSerialized
|
||||
): Promise<JsonDrawingFileDeserialized> {
|
||||
const entityPromises: Promise<Entity | null>[] = compact(
|
||||
data.entities.map((entity) => {
|
||||
switch (entity.type) {
|
||||
case EntityName.Arc:
|
||||
return ArcEntity.fromJson(entity as JsonEntity<ArcJsonData>);
|
||||
case EntityName.Circle:
|
||||
return CircleEntity.fromJson(entity as JsonEntity<CircleJsonData>);
|
||||
case EntityName.Line:
|
||||
return LineEntity.fromJson(entity as JsonEntity<LineJsonData>);
|
||||
case EntityName.Point:
|
||||
return PointEntity.fromJson(entity as JsonEntity<PointJsonData>);
|
||||
case EntityName.Rectangle:
|
||||
return RectangleEntity.fromJson(entity as JsonEntity<RectangleJsonData>);
|
||||
case EntityName.Text:
|
||||
return TextEntity.fromJson(entity as JsonEntity<TextJsonData>);
|
||||
case EntityName.Measurement:
|
||||
return MeasurementEntity.fromJson(entity as JsonEntity<MeasurementJsonData>);
|
||||
case EntityName.Image:
|
||||
return ImageEntity.fromJson(entity as JsonEntity<ImageJsonData>);
|
||||
case EntityName.PolyLine:
|
||||
return PolyLineEntity.fromJson(entity as JsonEntity<PolyLineJsonData>);
|
||||
|
||||
default:
|
||||
throw new Error(`Invalid entity type: ${entity.type}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const entities = compact(await Promise.all(entityPromises));
|
||||
let layers = data.layers;
|
||||
if (data.layers.length === 0) {
|
||||
layers = [getNewLayer()];
|
||||
}
|
||||
return {
|
||||
entities,
|
||||
layers,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEntitiesAndLayersFromJsonString(
|
||||
json: string
|
||||
): Promise<JsonDrawingFileDeserialized> {
|
||||
const data = JSON.parse(json) as JsonDrawingFileSerialized;
|
||||
|
||||
if (!data.entities) {
|
||||
throw new Error('Invalid JSON file');
|
||||
}
|
||||
|
||||
// TODO use map limit to avoid overloading the event loop
|
||||
return await getEntitiesAndLayersFromJsonObject(data);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import {LOCAL_STORAGE_KEY} from '../../App.types.ts';
|
||||
import {setActiveLayerId, setEntities, setLayers} from '../../state.ts';
|
||||
import {getNewLayer} from '../get-new-layer.ts';
|
||||
import {getEntitiesAndLayersFromJsonString} from './import-entities-from-json.ts';
|
||||
import type {JsonDrawingFileDeserialized} from "./export-entities-to-json.ts";
|
||||
|
||||
export async function importEntitiesAndLayersFromLocalStorage(): Promise<void> {
|
||||
const file = await getEntitiesAndLayersFromLocalStorage();
|
||||
setEntities(file.entities);
|
||||
setLayers(file.layers);
|
||||
setActiveLayerId(file.layers[0].id);
|
||||
}
|
||||
|
||||
export async function getEntitiesAndLayersFromLocalStorage(): Promise<JsonDrawingFileDeserialized> {
|
||||
const json = localStorage.getItem(LOCAL_STORAGE_KEY.DRAWING);
|
||||
if (!json) {
|
||||
return {
|
||||
entities: [],
|
||||
layers: [getNewLayer()],
|
||||
};
|
||||
}
|
||||
|
||||
const file = (await getEntitiesAndLayersFromJsonString(json)) || [];
|
||||
return file;
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import {toast} from 'react-toastify';
|
||||
import {CircleEntity} from '../../entities/CircleEntity';
|
||||
import type {Entity} from '../../entities/Entity';
|
||||
import {LineEntity} from '../../entities/LineEntity';
|
||||
import {RectangleEntity} from '../../entities/RectangleEntity';
|
||||
import {getActiveLayerId, getEntities, setEntities} from '../../state';
|
||||
|
||||
import {Point} from '@flatten-js/core';
|
||||
import {type Node, parse, type RootNode} from 'svg-parser';
|
||||
import {svgPathToSegments} from '../convert-svg-path-to-line-segments.ts';
|
||||
import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts';
|
||||
import {middle} from '../middle.ts';
|
||||
|
||||
function svgChildrenToEntities(root: RootNode): Entity[] {
|
||||
if (!root.children || !root.children?.[0]) {
|
||||
toast.error('Failed to load SVG file since it appears to be empty');
|
||||
console.error(new Error('Empty SVG file'));
|
||||
return [];
|
||||
}
|
||||
const entities: Entity[] = [];
|
||||
const childrenToProcess: (string | Node)[] = [root.children[0]];
|
||||
while (childrenToProcess[0]) {
|
||||
const child = childrenToProcess[0];
|
||||
if (typeof child === 'string') {
|
||||
continue; // Don't import text // TODO convert this text to a TextEntity
|
||||
}
|
||||
if (child.type === 'element') {
|
||||
if (child.tagName === 'rect') {
|
||||
const corner = new Point(
|
||||
Number.parseFloat(String(child.properties?.x)),
|
||||
Number.parseFloat(String(child.properties?.y))
|
||||
);
|
||||
entities.push(
|
||||
new RectangleEntity(
|
||||
getActiveLayerId(),
|
||||
corner,
|
||||
new Point(
|
||||
corner.x + Number.parseFloat(String(child.properties?.width)),
|
||||
corner.y + Number.parseFloat(String(child.properties?.height))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (child.tagName === 'ellipse') {
|
||||
if (child.properties?.rx === child.properties?.ry) {
|
||||
entities.push(
|
||||
new CircleEntity(
|
||||
getActiveLayerId(),
|
||||
new Point(
|
||||
Number.parseFloat(String(child.properties?.cx)),
|
||||
Number.parseFloat(String(child.properties?.cy))
|
||||
),
|
||||
Number.parseFloat(String(child.properties?.rx))
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// TODO convert ellipse to line segments
|
||||
}
|
||||
}
|
||||
if (child.tagName === 'path' && typeof child.properties?.d === 'string') {
|
||||
const lines = svgPathToSegments(child.properties.d);
|
||||
for (const line of lines) {
|
||||
entities.push(
|
||||
new LineEntity(
|
||||
getActiveLayerId(),
|
||||
new Point(line.x1, line.y1),
|
||||
new Point(line.x2, line.y2)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (child.tagName === 'polygon' && typeof child.properties?.points === 'string') {
|
||||
const coords: number[] = child.properties.points
|
||||
.split(' ')
|
||||
.map((coord) => Number.parseFloat(coord));
|
||||
for (let i = 0; i <= coords.length; i = i + 2) {
|
||||
if (i + 4 <= coords.length) {
|
||||
// still enough points, keep going
|
||||
const startPoint = new Point(coords[i], coords[i + 1]);
|
||||
const endPoint = new Point(coords[i + 2], coords[i + 3]);
|
||||
entities.push(new LineEntity(getActiveLayerId(), startPoint, endPoint));
|
||||
} else if (i + 2 === coords.length) {
|
||||
// last point, add line back to the start
|
||||
const startPoint = new Point(coords[i], coords[i + 1]);
|
||||
const endPoint = new Point(coords[0], coords[1]);
|
||||
entities.push(new LineEntity(getActiveLayerId(), startPoint, endPoint));
|
||||
} else {
|
||||
// stop
|
||||
toast.error(
|
||||
`Error processing SVG polygon: expected an even number of points, but got ${coords.length}`
|
||||
);
|
||||
console.error(`expected even number of points but got: ${coords.length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
childrenToProcess.push(...(child.children || []));
|
||||
}
|
||||
childrenToProcess.shift();
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file selection dialog to select *.svg files
|
||||
* Parse the SVG file
|
||||
* Generate entities from the XML data
|
||||
* Set the entities in the state
|
||||
*/
|
||||
export function importEntitiesFromSvgFile(file: File | null | undefined) {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', async () => {
|
||||
try {
|
||||
const svg = reader.result as string;
|
||||
|
||||
const data = parse(svg);
|
||||
|
||||
const svgEntities: Entity[] = svgChildrenToEntities(data);
|
||||
|
||||
// We still need to flip the image top to bottom since the coordinate system of svg has a y-axis that goes down
|
||||
// And the world coordinate system of this application has a mathematical y-axis that goes up
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(svgEntities);
|
||||
const centerPoint = new Point(
|
||||
middle(boundingBox.minX, boundingBox.maxX),
|
||||
middle(boundingBox.minY, boundingBox.maxY)
|
||||
);
|
||||
|
||||
const mirrorAxis = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
centerPoint,
|
||||
new Point(centerPoint.x + 1, centerPoint.y)
|
||||
);
|
||||
for (const svgEntity of svgEntities) {
|
||||
svgEntity.mirror(mirrorAxis);
|
||||
}
|
||||
|
||||
setEntities([...getEntities(), ...svgEntities]);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
toast.error('Failed to load SVG file');
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
reader.readAsText(file, 'utf-8');
|
||||
});
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
export interface SvgParseResult {
|
||||
type: string
|
||||
children: Children[]
|
||||
}
|
||||
|
||||
export interface Children {
|
||||
type: string
|
||||
tagName: string
|
||||
properties: Record<string, string>
|
||||
children: Children[]
|
||||
metadata?: string
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Open a file selection dialog to select *.jpg, *.jpeg, *.png files
|
||||
* Load the image data
|
||||
* Convert it to a base64 string
|
||||
*/
|
||||
export function importImageFromFile(
|
||||
file: File | null | undefined,
|
||||
): Promise<HTMLImageElement> {
|
||||
return new Promise<HTMLImageElement>(resolve => {
|
||||
if (!file) return;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve(img);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 호스트 앱(Aislo) 테마를 CAD iframe에 그대로 따라 붙인다.
|
||||
*
|
||||
* iframe은 호스트와 **같은 오리진**이라 `localStorage["frd_theme"]`를 공유한다.
|
||||
* 부모가 토글하며 값을 쓰면 이 문서에는 `storage` 이벤트가 오므로, 부모 쪽에
|
||||
* 별도 전달 코드(postMessage)를 넣지 않는다. 값이 없으면 미지정 상태로 두어
|
||||
* `ui_template_theme.css`의 `prefers-color-scheme` 폴백이 그대로 먹는다.
|
||||
*/
|
||||
|
||||
const THEME_STORAGE_KEY = 'frd_theme';
|
||||
|
||||
/** 테마가 바뀔 때마다 비우는 색 캐시 (CSS 변수 조회·휘도 보정 결과). */
|
||||
let cssVarCache = new Map<string, string>();
|
||||
let paintCache = new Map<string, string>();
|
||||
|
||||
function applyTheme(value: string | null): void {
|
||||
if (value === 'dark' || value === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', value);
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
cssVarCache = new Map();
|
||||
paintCache = new Map();
|
||||
}
|
||||
|
||||
/** 부팅 시 1회 호출 — 현재 테마 반영 + 이후 변경 구독. */
|
||||
export function syncThemeFromHost(): void {
|
||||
applyTheme(localStorage.getItem(THEME_STORAGE_KEY));
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === THEME_STORAGE_KEY) applyTheme(event.newValue);
|
||||
});
|
||||
// 시스템 설정 폴백으로 도는 중이면 OS 전환도 따라간다.
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (!document.documentElement.hasAttribute('data-theme')) applyTheme(null);
|
||||
});
|
||||
}
|
||||
|
||||
/** 토큰 값 조회 (테마 바뀔 때까지 캐시). */
|
||||
export function themeColor(varName: string, fallback: string): string {
|
||||
const cached = cssVarCache.get(varName);
|
||||
if (cached !== undefined) return cached;
|
||||
const value =
|
||||
getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || fallback;
|
||||
cssVarCache.set(varName, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isLightTheme(): boolean {
|
||||
const attr = document.documentElement.getAttribute('data-theme');
|
||||
if (attr === 'dark') return false;
|
||||
if (attr === 'light') return true;
|
||||
return !window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
function relativeLuminance(r: number, g: number, b: number): number {
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도면 색 보정 — 저장된 엔티티 색은 다크 배경 기준(밝은 회색·흰색)이라
|
||||
* 라이트 모드 흰 종이 위에서는 안 보인다. **그릴 때만** 휘도를 낮춰 색상(색조)은
|
||||
* 유지한 채 대비를 살린다. 저장 데이터는 건드리지 않는다.
|
||||
*/
|
||||
export function paintColor(color: string): string {
|
||||
if (!isLightTheme()) 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 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);
|
||||
const luminance = relativeLuminance(r, g, b);
|
||||
// 흰 배경에서 묻히는 밝기(0.58 초과)만 목표 휘도 0.3으로 눌러 준다.
|
||||
if (luminance > 0.58) {
|
||||
const scale = 0.3 / luminance;
|
||||
const to = (v: number) =>
|
||||
Math.round(Math.min(255, v * scale))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
result = `#${to(r)}${to(g)}${to(b)}`;
|
||||
}
|
||||
}
|
||||
paintCache.set(color, result);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"title": "circle 5",
|
||||
"selectorAttribute": "data-id",
|
||||
"steps": [
|
||||
{
|
||||
"type": "setViewport",
|
||||
"width": 1278,
|
||||
"height": 1430,
|
||||
"deviceScaleFactor": 1,
|
||||
"isMobile": false,
|
||||
"hasTouch": false,
|
||||
"isLandscape": false
|
||||
},
|
||||
{
|
||||
"type": "navigate",
|
||||
"url": "http://localhost:5173/",
|
||||
"assertedEvents": [
|
||||
{
|
||||
"type": "navigation",
|
||||
"url": "http://localhost:5173/",
|
||||
"title": "Open WebCAD"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"target": "main",
|
||||
"selectors": [
|
||||
["[data-id='circle-button'] svg"],
|
||||
["xpath///*[@data-id=\"circle-button\"]/div/svg"],
|
||||
["pierce/[data-id='circle-button'] svg"],
|
||||
["aria/Circle (c)", "aria/[role=\"image\"]"]
|
||||
],
|
||||
"offsetY": 14,
|
||||
"offsetX": 7
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"target": "main",
|
||||
"selectors": [
|
||||
["[data-id='canvas']"],
|
||||
["xpath///*[@data-id=\"canvas\"]"],
|
||||
["pierce/[data-id='canvas']"]
|
||||
],
|
||||
"offsetY": 257,
|
||||
"offsetX": 325
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"target": "main",
|
||||
"selectors": [
|
||||
["[data-id='canvas']"],
|
||||
["xpath///*[@data-id=\"canvas\"]"],
|
||||
["pierce/[data-id='canvas']"]
|
||||
],
|
||||
"offsetY": 424,
|
||||
"offsetX": 366
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/*
|
||||
* Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework
|
||||
*/
|
||||
import {expect, test} from 'vitest';
|
||||
import {getEntities} from '../../../src/state';
|
||||
import {EntityName, type JsonEntity} from '../../../src/entities/Entity';
|
||||
import {initApplication} from '../../helpers/init-application';
|
||||
import {CANVAS_HEIGHT} from '../../helpers/tests.consts';
|
||||
import type {CircleJsonData} from '../../../src/entities/CircleEntity';
|
||||
import circleRecording from './circle.recording.json';
|
||||
import {replayRecording} from '../../helpers/replay-recording';
|
||||
|
||||
test('Draw circle', async () => {
|
||||
const inputController = initApplication();
|
||||
|
||||
replayRecording(inputController, circleRecording);
|
||||
|
||||
const entities = getEntities();
|
||||
|
||||
expect(entities).toHaveLength(1);
|
||||
|
||||
const circleEntity = entities[0];
|
||||
expect(circleEntity.getType()).toBe(EntityName.Circle);
|
||||
const circleJson =
|
||||
(await circleEntity.toJson()) as JsonEntity<CircleJsonData>;
|
||||
|
||||
expect(circleJson.lineColor).toBe('#fff');
|
||||
expect(circleJson.lineWidth).toBe(1);
|
||||
expect(circleJson.type).toBe('Circle');
|
||||
expect(circleJson.shapeData.center.x).toBe(325);
|
||||
expect(circleJson.shapeData.center.y).toBe(CANVAS_HEIGHT - 257);
|
||||
|
||||
const diffX = 424 - 257;
|
||||
const diffY = 366 - 325;
|
||||
expect(circleJson.shapeData.radius).toBe(
|
||||
Math.sqrt(diffX * diffX + diffY * diffY),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/*
|
||||
* Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework
|
||||
*/
|
||||
import {expect, test} from 'vitest';
|
||||
import {getEntities} from '../../../src/state';
|
||||
import {Tool} from '../../../src/tools';
|
||||
import {EntityName, type JsonEntity} from '../../../src/entities/Entity';
|
||||
import {initApplication} from '../../helpers/init-application';
|
||||
import {CANVAS_HEIGHT} from '../../helpers/tests.consts';
|
||||
import {click} from '../../helpers/click';
|
||||
import type {LineJsonData} from '../../../src/entities/LineEntity';
|
||||
import {setActiveTool} from '../../helpers/set-active-tool';
|
||||
|
||||
test('Draw line', async () => {
|
||||
const inputController = initApplication();
|
||||
setActiveTool(Tool.LINE);
|
||||
click(inputController, 185, 94);
|
||||
click(inputController, 740, 395);
|
||||
const entities = getEntities();
|
||||
|
||||
const lineEntity = entities[0];
|
||||
expect(lineEntity.getType()).toBe(EntityName.Line);
|
||||
const lineJson = (await lineEntity.toJson()) as JsonEntity<LineJsonData>;
|
||||
|
||||
expect(lineJson.lineColor).toBe('#fff');
|
||||
expect(lineJson.lineWidth).toBe(1);
|
||||
expect(lineJson.type).toBe('Line');
|
||||
expect(lineJson.shapeData.startPoint.x).toBe(185);
|
||||
expect(lineJson.shapeData.startPoint.y).toBe(CANVAS_HEIGHT - 94);
|
||||
expect(lineJson.shapeData.endPoint.x).toBe(740);
|
||||
expect(lineJson.shapeData.endPoint.y).toBe(CANVAS_HEIGHT - 395);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/*
|
||||
* Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework
|
||||
*/
|
||||
import {expect, test} from 'vitest';
|
||||
import {EntityName, type JsonEntity} from '../../../src/entities/Entity';
|
||||
import type {RectangleJsonData} from '../../../src/entities/RectangleEntity';
|
||||
import {getEntities} from '../../../src/state';
|
||||
import {Tool} from '../../../src/tools';
|
||||
import {click} from '../../helpers/click';
|
||||
import {initApplication} from '../../helpers/init-application';
|
||||
import {setActiveTool} from '../../helpers/set-active-tool';
|
||||
import {CANVAS_HEIGHT} from '../../helpers/tests.consts';
|
||||
|
||||
test('Draw circle', async () => {
|
||||
const inputController = initApplication();
|
||||
setActiveTool(Tool.RECTANGLE);
|
||||
click(inputController, 185, 94);
|
||||
click(inputController, 740, 395);
|
||||
const entities = getEntities();
|
||||
|
||||
const rectangleEntity = entities[0];
|
||||
expect(rectangleEntity.getType()).toBe(EntityName.Rectangle);
|
||||
const rectangleJson = (await rectangleEntity.toJson()) as JsonEntity<RectangleJsonData>;
|
||||
|
||||
expect(rectangleJson.lineColor).toBe('#fff');
|
||||
expect(rectangleJson.lineWidth).toBe(1);
|
||||
expect(rectangleJson.type).toBe('Rectangle');
|
||||
expect(rectangleJson.shapeData.points[0].x).toBe(185);
|
||||
expect(rectangleJson.shapeData.points[0].y).toBe(CANVAS_HEIGHT - 395);
|
||||
|
||||
expect(rectangleJson.shapeData.points[1].x).toBe(185);
|
||||
expect(rectangleJson.shapeData.points[1].y).toBe(CANVAS_HEIGHT - 94);
|
||||
|
||||
expect(rectangleJson.shapeData.points[2].x).toBe(740);
|
||||
expect(rectangleJson.shapeData.points[2].y).toBe(CANVAS_HEIGHT - 94);
|
||||
|
||||
expect(rectangleJson.shapeData.points[3].x).toBe(740);
|
||||
expect(rectangleJson.shapeData.points[3].y).toBe(CANVAS_HEIGHT - 395);
|
||||
});
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
// noinspection JSUnusedLocalSymbols
|
||||
|
||||
import {Point, type Vector} from '@flatten-js/core';
|
||||
import type {DrawController} from '../../../src/drawControllers/DrawController';
|
||||
import {triggerReactUpdate} from '../../../src/state';
|
||||
import {StateVariable} from '../../../src/helpers/undo-stack';
|
||||
import {MOUSE_ZOOM_MULTIPLIER} from '../../../src/App.consts';
|
||||
import {mapNumberRange} from '../../../src/helpers/map-number-range';
|
||||
|
||||
/**
|
||||
* Screen coordinate system:
|
||||
* 0, 0 X
|
||||
* +---------->
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* Y v
|
||||
*
|
||||
*
|
||||
* World coordinate system:
|
||||
* Y ^
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* +---------->
|
||||
* 0, 0 X
|
||||
*
|
||||
* To convert between the 2 coordinate systems, you need the screenOffset and screenScale
|
||||
*/
|
||||
export class ScreenCanvasDrawController implements DrawController {
|
||||
private screenOffset: Point = new Point(0, 0);
|
||||
private screenScale = 1;
|
||||
private worldMouseLocation: Point;
|
||||
|
||||
constructor(
|
||||
private context: CanvasRenderingContext2D | null,
|
||||
private canvasSize: Point,
|
||||
) {
|
||||
this.worldMouseLocation = this.targetToWorld(
|
||||
new Point(canvasSize.x / 2, canvasSize.y / 2),
|
||||
);
|
||||
this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down
|
||||
}
|
||||
|
||||
public getCanvasSize() {
|
||||
return this.canvasSize;
|
||||
}
|
||||
|
||||
public getScreenScale() {
|
||||
return this.screenScale;
|
||||
}
|
||||
|
||||
public setScreenScale(newScreenScale: number) {
|
||||
this.screenScale = newScreenScale;
|
||||
triggerReactUpdate(StateVariable.screenZoom);
|
||||
}
|
||||
|
||||
public getScreenOffset() {
|
||||
return this.screenOffset;
|
||||
}
|
||||
|
||||
public setScreenOffset(newScreenOffset: Point) {
|
||||
this.screenOffset = newScreenOffset;
|
||||
triggerReactUpdate(StateVariable.screenOffset);
|
||||
}
|
||||
|
||||
public setScreenMouseLocation(newScreenMouseLocation: Point): void {
|
||||
this.worldMouseLocation = this.targetToWorld(newScreenMouseLocation);
|
||||
triggerReactUpdate(StateVariable.screenMouseLocation);
|
||||
}
|
||||
|
||||
public getWorldMouseLocation(): Point {
|
||||
return this.worldMouseLocation;
|
||||
}
|
||||
|
||||
public getScreenMouseLocation(): Point {
|
||||
return this.worldToTarget(this.worldMouseLocation);
|
||||
}
|
||||
|
||||
public panScreen(screenOffsetX: number, screenOffsetY: number) {
|
||||
this.screenOffset = new Point(
|
||||
this.screenOffset.x - screenOffsetX / this.screenScale,
|
||||
this.screenOffset.y - screenOffsetY / this.screenScale,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes the deltaY from the mouse wheel event and zooms the screen in or out
|
||||
* The location of the mouse in world space is preserved
|
||||
* @param deltaY
|
||||
*/
|
||||
public zoomScreen(deltaY: number) {
|
||||
const worldMouseLocationBeforeZoom = this.getWorldMouseLocation();
|
||||
const newScreenScale =
|
||||
this.getScreenScale() *
|
||||
(1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY)));
|
||||
this.setScreenScale(newScreenScale);
|
||||
|
||||
// now get the location of the cursor in world space again
|
||||
// It will have changed because the scale has changed,
|
||||
// but we can offset our world now to fix the zoom location in screen space,
|
||||
// because we know how much it changed laterally between the two spatial scales.
|
||||
const worldMouseLocationAfterZoom = this.getWorldMouseLocation();
|
||||
|
||||
// Adjust the screen offset to maintain the cursor position
|
||||
this.screenOffset = new Point(
|
||||
this.screenOffset.x +
|
||||
(worldMouseLocationBeforeZoom.x -
|
||||
worldMouseLocationAfterZoom.x),
|
||||
this.screenOffset.y +
|
||||
(worldMouseLocationBeforeZoom.y -
|
||||
worldMouseLocationAfterZoom.y),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates from World Space --> Screen Space
|
||||
*/
|
||||
public worldToTarget(worldCoordinate: Point): Point {
|
||||
return new Point(
|
||||
mapNumberRange(
|
||||
worldCoordinate.x,
|
||||
this.screenOffset.x,
|
||||
this.screenOffset.x + this.canvasSize.x / this.screenScale,
|
||||
0,
|
||||
this.canvasSize.x,
|
||||
),
|
||||
mapNumberRange(
|
||||
worldCoordinate.y,
|
||||
this.screenOffset.y + this.canvasSize.y / this.screenScale, // inverted since world origin is bottom left and screen origin is top left
|
||||
this.screenOffset.y,
|
||||
0,
|
||||
this.canvasSize.y,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public worldsToTargets(worldCoordinates: Point[]): Point[] {
|
||||
return worldCoordinates.map(this.worldToTarget.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates from Screen Space --> World Space
|
||||
* (0, 0) (1920, 0)
|
||||
*
|
||||
* (0, 1080) (1920, 1080)
|
||||
*
|
||||
* convert to
|
||||
*
|
||||
* (0, 1080) (1920, 1080)
|
||||
*
|
||||
* (0, 0) (1920, 0)
|
||||
*/
|
||||
public targetToWorld(screenCoordinate: Point): Point {
|
||||
// map the screen coordinate to the world coordinate based on this.getScreenOffset() and the this.getScreenScale()
|
||||
return new Point(
|
||||
mapNumberRange(
|
||||
screenCoordinate.x,
|
||||
0,
|
||||
this.canvasSize.x,
|
||||
this.screenOffset.x,
|
||||
this.screenOffset.x + this.canvasSize.x / this.screenScale,
|
||||
),
|
||||
mapNumberRange(
|
||||
screenCoordinate.y,
|
||||
0,
|
||||
this.canvasSize.y,
|
||||
this.screenOffset.y,
|
||||
this.screenOffset.y + this.canvasSize.y / this.screenScale,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public targetsToWorlds(screenCoordinates: Point[]): Point[] {
|
||||
return screenCoordinates.map(this.targetToWorld.bind(this));
|
||||
}
|
||||
|
||||
public setLineStyles(
|
||||
isHighlighted: boolean,
|
||||
isSelected: boolean,
|
||||
color: string,
|
||||
lineWidth: number,
|
||||
dash: number[] = [],
|
||||
) {}
|
||||
|
||||
public setFillStyles(fillColor: string) {}
|
||||
|
||||
public clear() {}
|
||||
|
||||
/**
|
||||
* Draws a line from startPoint to endPoint and auto converts to screen space first
|
||||
* @param worldStartPoint
|
||||
* @param worldEndPoint
|
||||
*/
|
||||
public drawLine(worldStartPoint: Point, worldEndPoint: Point): void {}
|
||||
|
||||
/**
|
||||
* Needs to be public to draw UI that is zoom independent, like snap point indicators
|
||||
* @param screenStartPoint
|
||||
* @param screenEndPoint
|
||||
*/
|
||||
public drawLineScreen(
|
||||
screenStartPoint: Point,
|
||||
screenEndPoint: Point,
|
||||
): void {}
|
||||
|
||||
/**
|
||||
* Draw an arc (segment of a circle) or a circle if startAngle = 0 and endAngle = 2PI
|
||||
* @param centerPoint
|
||||
* @param radius
|
||||
* @param startAngle
|
||||
* @param endAngle
|
||||
* @param counterClockWise
|
||||
*/
|
||||
public drawArc(
|
||||
centerPoint: Point,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockWise: boolean,
|
||||
) {}
|
||||
|
||||
public drawArcScreen(
|
||||
screenCenterPoint: Point,
|
||||
screenRadius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockWise: boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Draw some text at the base location
|
||||
* The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text * @param label
|
||||
* @param label
|
||||
* @param basePoint
|
||||
* @param options
|
||||
*/
|
||||
public drawText(
|
||||
label: string,
|
||||
basePoint: Point,
|
||||
options: Partial<{
|
||||
textDirection?: Vector;
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}> = {},
|
||||
): void {}
|
||||
|
||||
/**
|
||||
* Draw some text at the base location
|
||||
* The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text
|
||||
* @param label
|
||||
* @param basePoint
|
||||
* @param options
|
||||
*/
|
||||
public drawTextScreen(
|
||||
label: string,
|
||||
basePoint: Point,
|
||||
options: Partial<{
|
||||
textDirection?: Vector;
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}> = {},
|
||||
): void {}
|
||||
|
||||
/**
|
||||
* Draw an image to the canvas using world coordinates
|
||||
* @param imageElement
|
||||
* @param xMin
|
||||
* @param yMin
|
||||
* @param width
|
||||
* @param height
|
||||
* @param angle
|
||||
*/
|
||||
public drawImage(
|
||||
imageElement: HTMLImageElement,
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number,
|
||||
): void {}
|
||||
|
||||
public fillRect(
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fill rectangle with color, but interpret the provided coordinates as screen coordinates
|
||||
* @param xMin
|
||||
* @param yMin
|
||||
* @param width
|
||||
* @param height
|
||||
* @param color
|
||||
*/
|
||||
public fillRectScreen(
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fill polygon with color
|
||||
* @param points
|
||||
*/
|
||||
public fillPolygon(...points: Point[]) {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
ENTITIES
|
||||
0
|
||||
CIRCLE
|
||||
8
|
||||
0
|
||||
10
|
||||
30.0
|
||||
20
|
||||
30.0
|
||||
30
|
||||
0.0
|
||||
40
|
||||
5.0
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
EOF
|
||||
@@ -0,0 +1,8 @@
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
ENTITIES
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
EOF
|
||||
@@ -0,0 +1,36 @@
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
ENTITIES
|
||||
0
|
||||
LINE
|
||||
8
|
||||
0
|
||||
10
|
||||
10.0
|
||||
20
|
||||
10.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
20.0
|
||||
21
|
||||
20.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
CIRCLE
|
||||
8
|
||||
0
|
||||
10
|
||||
30.0
|
||||
20
|
||||
30.0
|
||||
30
|
||||
0.0
|
||||
40
|
||||
5.0
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
EOF
|
||||
@@ -0,0 +1,24 @@
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
ENTITIES
|
||||
0
|
||||
LINE
|
||||
8
|
||||
0
|
||||
10
|
||||
10.0
|
||||
20
|
||||
10.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
20.0
|
||||
21
|
||||
20.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
EOF
|
||||
@@ -0,0 +1,24 @@
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
ENTITIES
|
||||
0
|
||||
ARC
|
||||
8
|
||||
0
|
||||
10
|
||||
50.0
|
||||
20
|
||||
50.0
|
||||
30
|
||||
0.0
|
||||
40
|
||||
10.0
|
||||
50
|
||||
0.0
|
||||
51
|
||||
90.0
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
EOF
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"title": "record eraser 2",
|
||||
"selectorAttribute": "data-id",
|
||||
"steps": [
|
||||
{
|
||||
"type": "setViewport",
|
||||
"width": 1278,
|
||||
"height": 1430,
|
||||
"deviceScaleFactor": 1,
|
||||
"isMobile": false,
|
||||
"hasTouch": false,
|
||||
"isLandscape": false
|
||||
},
|
||||
{
|
||||
"type": "navigate",
|
||||
"url": "http://localhost:5173/",
|
||||
"assertedEvents": [
|
||||
{
|
||||
"type": "navigation",
|
||||
"url": "http://localhost:5173/",
|
||||
"title": "Open WebCAD"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"target": "main",
|
||||
"selectors": [
|
||||
["[data-id='canvas']"],
|
||||
["xpath///*[@data-id=\"canvas\"]"],
|
||||
["pierce/[data-id='canvas']"]
|
||||
],
|
||||
"offsetY": 148,
|
||||
"offsetX": 473
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"target": "main",
|
||||
"selectors": [
|
||||
["[data-id='canvas']"],
|
||||
["xpath///*[@data-id=\"canvas\"]"],
|
||||
["pierce/[data-id='canvas']"]
|
||||
],
|
||||
"offsetY": 698,
|
||||
"offsetX": 473
|
||||
},
|
||||
{
|
||||
"type": "keyDown",
|
||||
"target": "main",
|
||||
"key": "c"
|
||||
},
|
||||
{
|
||||
"type": "keyUp",
|
||||
"key": "c",
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"type": "keyDown",
|
||||
"target": "main",
|
||||
"key": "i"
|
||||
},
|
||||
{
|
||||
"type": "keyUp",
|
||||
"key": "i",
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"type": "keyDown",
|
||||
"target": "main",
|
||||
"key": "r"
|
||||
},
|
||||
{
|
||||
"type": "keyUp",
|
||||
"key": "r",
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"type": "keyDown",
|
||||
"target": "main",
|
||||
"key": "c"
|
||||
},
|
||||
{
|
||||
"type": "keyUp",
|
||||
"key": "c",
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"type": "keyDown",
|
||||
"target": "main",
|
||||
"key": "l"
|
||||
},
|
||||
{
|
||||
"type": "keyUp",
|
||||
"key": "l",
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"type": "keyDown",
|
||||
"target": "main",
|
||||
"key": "e"
|
||||
},
|
||||
{
|
||||
"type": "keyUp",
|
||||
"key": "e",
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"type": "keyDown",
|
||||
"target": "main",
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"type": "keyUp",
|
||||
"key": "Enter",
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"target": "main",
|
||||
"selectors": [
|
||||
["[data-id='canvas']"],
|
||||
["xpath///*[@data-id=\"canvas\"]"],
|
||||
["pierce/[data-id='canvas']"]
|
||||
],
|
||||
"offsetY": 402,
|
||||
"offsetX": 266
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"target": "main",
|
||||
"selectors": [
|
||||
["[data-id='canvas']"],
|
||||
["xpath///*[@data-id=\"canvas\"]"],
|
||||
["pierce/[data-id='canvas']"]
|
||||
],
|
||||
"offsetY": 441,
|
||||
"offsetX": 542
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"target": "main",
|
||||
"selectors": [
|
||||
["[data-id='delete-segment-button'] path"],
|
||||
["xpath///*[@data-id=\"delete-segment-button\"]/div/svg/path"],
|
||||
["pierce/[data-id='delete-segment-button'] path"],
|
||||
["aria/Delete segments", "aria/[role=\"graphics-symbol\"]"]
|
||||
],
|
||||
"offsetY": 15,
|
||||
"offsetX": 9
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"target": "main",
|
||||
"selectors": [
|
||||
["[data-id='canvas']"],
|
||||
["xpath///*[@data-id=\"canvas\"]"],
|
||||
["pierce/[data-id='canvas']"]
|
||||
],
|
||||
"offsetY": 291,
|
||||
"offsetX": 524
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {Point} from '@flatten-js/core'; /* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {expect, test} from 'vitest';
|
||||
import type {ArcJsonData} from '../../../src/entities/ArcEntity';
|
||||
import {EntityName, type JsonEntity} from '../../../src/entities/Entity';
|
||||
import type {LineJsonData} from '../../../src/entities/LineEntity';
|
||||
import {pointDistance} from '../../../src/helpers/distance-between-points';
|
||||
import {getEntities} from '../../../src/state';
|
||||
import {initApplication} from '../../helpers/init-application';
|
||||
import {replayRecording} from '../../helpers/replay-recording';
|
||||
import {CANVAS_HEIGHT} from "../../helpers/tests.consts";
|
||||
import eraserRecording from './eraser.recording.json';
|
||||
|
||||
test('Draw circle and line and erase part of circle', async () => {
|
||||
const inputController = initApplication();
|
||||
|
||||
replayRecording(inputController, eraserRecording);
|
||||
|
||||
const entities = getEntities();
|
||||
|
||||
expect(entities).toHaveLength(2);
|
||||
|
||||
const lineEntity = entities[0];
|
||||
expect(lineEntity.getType()).toBe(EntityName.Line);
|
||||
const lineJson = (await lineEntity.toJson()) as JsonEntity<LineJsonData>;
|
||||
|
||||
expect(lineJson.lineColor).toBe('#fff');
|
||||
expect(lineJson.lineWidth).toBe(1);
|
||||
expect(lineJson.type).toBe('Line');
|
||||
expect(lineJson.shapeData.startPoint.x).toBe(473);
|
||||
expect(lineJson.shapeData.startPoint.y).toBe(CANVAS_HEIGHT - 148);
|
||||
expect(lineJson.shapeData.endPoint.x).toBe(473);
|
||||
expect(lineJson.shapeData.endPoint.y).toBe(CANVAS_HEIGHT - 698);
|
||||
|
||||
const arcEntity = entities[1];
|
||||
expect(arcEntity.getType()).toBe(EntityName.Arc);
|
||||
const arcJson = (await arcEntity.toJson()) as JsonEntity<ArcJsonData>;
|
||||
|
||||
expect(arcJson.lineColor).toBe('#fff');
|
||||
expect(arcJson.lineWidth).toBe(1);
|
||||
expect(arcJson.type).toBe('Arc');
|
||||
expect(arcJson.shapeData.center.x).toBe(266);
|
||||
expect(arcJson.shapeData.center.y).toBe(CANVAS_HEIGHT - 402);
|
||||
const radius = pointDistance(
|
||||
new Point(266, CANVAS_HEIGHT - 402),
|
||||
new Point(542, CANVAS_HEIGHT - 441)
|
||||
);
|
||||
expect(arcJson.shapeData.radius).toBeCloseTo(radius, 5);
|
||||
expect(arcJson.shapeData.startAngle).toBeCloseTo(0.7338182524767606, 5);
|
||||
expect(arcJson.shapeData.endAngle).toBeCloseTo(-0.7338182524767606, 5);
|
||||
});
|
||||
Reference in New Issue
Block a user