auto: 2026-08-29 16:28 (EOMSANGDON-HOME)

This commit is contained in:
2026-08-29 16:28:13 +09:00
parent a485e22851
commit 47bfc88714
28 changed files with 1676 additions and 101 deletions
+3 -3
View File
@@ -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>
+121 -91
View File
@@ -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);
}
@@ -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[];
}
@@ -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);
}
@@ -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');
}
@@ -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');
}
@@ -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);
}
@@ -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;
}
@@ -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');
});
}
@@ -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
}
@@ -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);
});
}
+96
View 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);
});
@@ -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);
});