260719_8
This commit is contained in:
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Width of the toolbar containing all the tools on the left side of the screen
|
||||
*/
|
||||
export const TOOLBAR_WIDTH = 320;
|
||||
|
||||
/**
|
||||
* Very small number that will be used to compare floating point numbers on equality
|
||||
* since javascript isn't always very accurate with floating point numbers
|
||||
|
||||
@@ -1 +1,406 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--cad-title-height: 42px;
|
||||
--cad-ribbon-height: 82px;
|
||||
--cad-command-height: 58px;
|
||||
--cad-status-height: 28px;
|
||||
--cad-panel-width: 248px;
|
||||
font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif;
|
||||
color: #dce6f2;
|
||||
background: #11161d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.cad-app {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.controls {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
body > canvas[data-id="canvas"] {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
top: calc(var(--cad-title-height) + var(--cad-ribbon-height));
|
||||
right: 0;
|
||||
bottom: calc(var(--cad-command-height) + var(--cad-status-height));
|
||||
left: var(--cad-panel-width);
|
||||
width: calc(100vw - var(--cad-panel-width));
|
||||
height: calc(
|
||||
100vh -
|
||||
var(--cad-title-height) -
|
||||
var(--cad-ribbon-height) -
|
||||
var(--cad-command-height) -
|
||||
var(--cad-status-height)
|
||||
);
|
||||
background: #111;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.cad-titlebar {
|
||||
position: fixed;
|
||||
inset: 0 0 auto 0;
|
||||
height: var(--cad-title-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 0 12px;
|
||||
background: #19222d;
|
||||
border-bottom: 1px solid #344252;
|
||||
box-shadow: 0 1px 4px #0008;
|
||||
}
|
||||
.cad-brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
min-width: 220px;
|
||||
}
|
||||
.cad-brand strong {
|
||||
color: #f7fbff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cad-brand span,
|
||||
.cad-file-state {
|
||||
color: #91a2b5;
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-file-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.cad-file-state__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #49b675;
|
||||
box-shadow: 0 0 0 2px #49b67522;
|
||||
}
|
||||
.cad-title-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.cad-title-actions button {
|
||||
height: 28px;
|
||||
min-width: 32px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cad-title-actions button:hover {
|
||||
border-color: #4d6074;
|
||||
background: #273544;
|
||||
}
|
||||
|
||||
.cad-ribbon {
|
||||
position: fixed;
|
||||
top: var(--cad-title-height);
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: var(--cad-ribbon-height);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 5px 8px 3px;
|
||||
overflow-x: auto;
|
||||
background: #202b37;
|
||||
border-bottom: 1px solid #3c4a59;
|
||||
}
|
||||
.cad-ribbon-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: max-content;
|
||||
padding: 0 9px;
|
||||
border-right: 1px solid #40505f;
|
||||
}
|
||||
.cad-ribbon-tools {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 57px;
|
||||
}
|
||||
.cad-ribbon-group__label {
|
||||
margin-top: auto;
|
||||
color: #8293a5;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.cad-tool {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 48px;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
}
|
||||
.cad-tool:hover:not(:disabled),
|
||||
.cad-tool[data-active="true"] {
|
||||
border-color: #4f87b8;
|
||||
background: #2b455c;
|
||||
}
|
||||
.cad-tool[data-active="true"] {
|
||||
box-shadow: inset 0 -2px #4ca6e8;
|
||||
}
|
||||
.cad-tool:disabled {
|
||||
color: #657382;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cad-tool__glyph {
|
||||
height: 27px;
|
||||
color: #a9d7fb;
|
||||
font-size: 22px;
|
||||
line-height: 27px;
|
||||
}
|
||||
.cad-tool:disabled .cad-tool__glyph {
|
||||
color: #657382;
|
||||
}
|
||||
|
||||
.cad-inspector {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
top: calc(var(--cad-title-height) + var(--cad-ribbon-height));
|
||||
bottom: calc(var(--cad-command-height) + var(--cad-status-height));
|
||||
left: 0;
|
||||
width: var(--cad-panel-width);
|
||||
background: #1b2530;
|
||||
border-right: 1px solid #3d4c5c;
|
||||
transition: width 120ms ease;
|
||||
}
|
||||
.cad-inspector[data-collapsed="true"] {
|
||||
width: 0;
|
||||
}
|
||||
.cad-inspector__collapse {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 10px;
|
||||
left: 100%;
|
||||
width: 20px;
|
||||
height: 38px;
|
||||
border: 1px solid #465769;
|
||||
border-left: 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: #263442;
|
||||
color: #a9bbcd;
|
||||
}
|
||||
.cad-inspector-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
height: 38px;
|
||||
border-bottom: 1px solid #394857;
|
||||
}
|
||||
.cad-inspector-tabs button {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: #17202a;
|
||||
color: #94a7ba;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cad-inspector-tabs button[data-active="true"] {
|
||||
border-bottom-color: #4da3df;
|
||||
background: #223141;
|
||||
color: white;
|
||||
}
|
||||
.cad-properties {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
.cad-properties h2 {
|
||||
margin: 0 0 14px;
|
||||
color: #f3f7fb;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cad-properties dl {
|
||||
margin: 0;
|
||||
border-top: 1px solid #344352;
|
||||
}
|
||||
.cad-properties dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #2d3a47;
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-properties dt {
|
||||
color: #8092a4;
|
||||
}
|
||||
.cad-properties dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #d7e1eb;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-layer-manager {
|
||||
max-height: calc(100vh - 250px);
|
||||
padding: 9px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.cad-layer-manager button {
|
||||
min-height: 36px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
background: #202c38;
|
||||
}
|
||||
|
||||
.cad-view-controls {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
right: 14px;
|
||||
bottom: calc(var(--cad-command-height) + var(--cad-status-height) + 14px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid #465565;
|
||||
border-radius: 4px;
|
||||
background: #202b37e8;
|
||||
box-shadow: 0 3px 12px #0008;
|
||||
}
|
||||
.cad-view-controls button {
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-right: 1px solid #3d4c5b;
|
||||
background: transparent;
|
||||
font-size: 17px;
|
||||
}
|
||||
.cad-view-controls button:hover {
|
||||
background: #31516b;
|
||||
}
|
||||
.cad-view-controls span {
|
||||
min-width: 48px;
|
||||
color: #9fb0c1;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cad-command-area {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
right: 0;
|
||||
bottom: var(--cad-status-height);
|
||||
left: var(--cad-panel-width);
|
||||
height: var(--cad-command-height);
|
||||
padding: 5px 10px;
|
||||
background: #151c24f2;
|
||||
border-top: 1px solid #3b4b5a;
|
||||
}
|
||||
.cad-command-prompt {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 18px;
|
||||
overflow: hidden;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cad-command-prompt span {
|
||||
color: #6eaee0;
|
||||
}
|
||||
.cad-command-prompt strong {
|
||||
overflow: hidden;
|
||||
color: #aab8c6;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-command-area form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 28px;
|
||||
}
|
||||
.cad-command-area label {
|
||||
color: #e6eef6;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cad-command-area input {
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #46586a;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
background: #0e141a;
|
||||
color: #eef5fc;
|
||||
font: 11px Consolas, monospace;
|
||||
}
|
||||
.cad-command-area input:focus {
|
||||
border-color: #4b9bd3;
|
||||
box-shadow: 0 0 0 1px #4b9bd344;
|
||||
}
|
||||
|
||||
.cad-statusbar {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: var(--cad-status-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 8px;
|
||||
background: #263544;
|
||||
border-top: 1px solid #415263;
|
||||
}
|
||||
.cad-statusbar button {
|
||||
height: 21px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: #9caebf;
|
||||
font-size: 10px;
|
||||
}
|
||||
.cad-statusbar button:hover {
|
||||
background: #33485b;
|
||||
}
|
||||
.cad-statusbar button[data-active="true"] {
|
||||
border-color: #4f9bd0;
|
||||
background: #285579;
|
||||
color: white;
|
||||
}
|
||||
.cad-statusbar__hint {
|
||||
margin-left: auto;
|
||||
color: #7f91a2;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
:root {
|
||||
--cad-panel-width: 200px;
|
||||
}
|
||||
.cad-file-state,
|
||||
.cad-statusbar__hint {
|
||||
display: none;
|
||||
}
|
||||
.cad-brand {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,8 @@ import { Toolbar } from './components/Toolbar.tsx';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div
|
||||
className="overflow-y-scroll h-lvh pb-6 w-80 bg-slate-950"
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
<header className="px-3 py-3 border-b border-slate-700 text-white">
|
||||
<strong className="block text-sm">Aislo 2D Drawing</strong>
|
||||
<span className="text-xs text-slate-400">B07 상세 설계</span>
|
||||
</header>
|
||||
<div className="cad-app">
|
||||
<Toolbar />
|
||||
<footer className="px-3 py-3 border-t border-slate-700 text-xs text-slate-400">
|
||||
Drawing engine based on OpenWebCAD ·{' '}
|
||||
<a
|
||||
className="underline hover:text-white"
|
||||
href="./THIRD_PARTY_LICENSES.txt"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
MIT License
|
||||
</a>
|
||||
</footer>
|
||||
<ToastContainer position="bottom-right" theme="light" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {Arc, Circle, Point, Polygon, Segment} from '@flatten-js/core';
|
||||
import type { Arc, Circle, Point, Polygon, Segment } from '@flatten-js/core';
|
||||
|
||||
export type Shape = Polygon | Segment | Point | Circle | Arc;
|
||||
|
||||
@@ -35,6 +35,7 @@ export enum MouseButton {
|
||||
|
||||
export enum HtmlEvent {
|
||||
UPDATE_STATE = 'UPDATE_STATE',
|
||||
DRAWING_CHANGED = 'DRAWING_CHANGED',
|
||||
}
|
||||
|
||||
export interface StateMetaData {
|
||||
|
||||
@@ -1,624 +1,321 @@
|
||||
import { type FC, type MouseEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { type FC, type FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Actor } from 'xstate';
|
||||
import { COLOR_LIST } from '../App.consts';
|
||||
import { HtmlEvent, type Layer } from '../App.types';
|
||||
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
|
||||
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage.ts';
|
||||
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
|
||||
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
|
||||
import { importEntitiesFromJsonFile } from '../helpers/import-export-handlers/import-entities-from-json';
|
||||
import { importEntitiesFromSvgFile } from '../helpers/import-export-handlers/import-entities-from-svg.ts';
|
||||
import { importImageFromFile } from '../helpers/import-export-handlers/import-image-from-file';
|
||||
import { times } from '../helpers/times';
|
||||
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineWidth,
|
||||
getActiveToolActor,
|
||||
getAngleStep,
|
||||
getGridEnabled,
|
||||
getInputController,
|
||||
getLastStateInstructions,
|
||||
getLayers,
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
getSnapEnabled,
|
||||
redo,
|
||||
setActiveLayerId,
|
||||
setActiveLineColor,
|
||||
setActiveLineWidth,
|
||||
setActiveToolActor,
|
||||
setAngleStep,
|
||||
setEntities,
|
||||
setGridEnabled,
|
||||
setLayers,
|
||||
setSnapEnabled,
|
||||
undo,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { imageImportToolStateMachine } from '../tools/image-import-tool';
|
||||
import { TOOL_STATE_MACHINES } from '../tools/tool.consts';
|
||||
import { ActorEvent } from '../tools/tool.types';
|
||||
import { Button } from './Button.tsx';
|
||||
import { DropdownButton } from './DropdownButton.tsx';
|
||||
import { Icon, IconName } from './Icon/Icon.tsx';
|
||||
import { LayerManager } from './LayerManager.tsx';
|
||||
import { LayerManager } from './LayerManager';
|
||||
|
||||
interface RibbonTool {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
tool?: Tool;
|
||||
glyph: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RIBBON_GROUPS: { label: string; tools: RibbonTool[] }[] = [
|
||||
{
|
||||
label: '그리기',
|
||||
tools: [
|
||||
{ label: '선', shortcut: 'L', tool: Tool.LINE, glyph: '╱' },
|
||||
{ label: '폴리선', shortcut: 'PE', tool: Tool.PEDIT, glyph: '⌁' },
|
||||
{ label: '원', shortcut: 'C', tool: Tool.CIRCLE, glyph: '○' },
|
||||
{ label: '사각형', shortcut: 'R', tool: Tool.RECTANGLE, glyph: '□' },
|
||||
{ label: '호', glyph: '◜', disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '수정',
|
||||
tools: [
|
||||
{ label: '선택', shortcut: 'S', tool: Tool.SELECT, glyph: '↖' },
|
||||
{ label: '이동', tool: Tool.MOVE, glyph: '✥' },
|
||||
{ label: '복사', tool: Tool.COPY, glyph: '▣' },
|
||||
{ label: '회전', tool: Tool.ROTATE, glyph: '↻' },
|
||||
{ label: '자르기', tool: Tool.ERASER, glyph: '⌫' },
|
||||
{ label: '간격띄우기', glyph: '⇶', disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '주석',
|
||||
tools: [
|
||||
{ label: '치수', tool: Tool.MEASUREMENT, glyph: '↔' },
|
||||
{ label: '문자', glyph: 'A', disabled: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const COMMANDS = Object.values(Tool);
|
||||
|
||||
export const Toolbar: FC = () => {
|
||||
const [activeToolLocal, setActiveToolLocal] = useState<Tool>(Tool.LINE);
|
||||
const [angleStepLocal, setAngleStepLocal] = useState<number>(45);
|
||||
const [activeLineColorLocal, setActiveLineColorLocal] = useState<string>('#FFF');
|
||||
const [activeLineWidthLocal, setActiveLineWidthLocal] = useState<number>(1);
|
||||
const [screenZoomLocal, setScreenZoomLocal] = useState<number>(1);
|
||||
const [layersLocal, setLayersLocal] = useState<Layer[]>(getLayers());
|
||||
const [activeLayerIdLocal, setActiveLayerIdLocal] = useState(getLayers()[0].id);
|
||||
const [activeTool, setActiveTool] = useState<Tool>(Tool.LINE);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [layers, setLayersLocal] = useState<Layer[]>(getLayers());
|
||||
const [activeLayerId, setActiveLayerIdLocal] = useState(getActiveLayerId());
|
||||
const [selectedCount, setSelectedCount] = useState(0);
|
||||
const [selectedType, setSelectedType] = useState('선택 없음');
|
||||
const [instruction, setInstruction] = useState('명령을 입력하거나 도구를 선택하십시오.');
|
||||
const [panelTab, setPanelTab] = useState<'properties' | 'layers'>('properties');
|
||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||
const [snap, setSnap] = useState(getSnapEnabled());
|
||||
const [grid, setGrid] = useState(getGridEnabled());
|
||||
const [ortho, setOrtho] = useState(getAngleStep() === 90);
|
||||
const [command, setCommand] = useState('');
|
||||
const [commandLog, setCommandLog] = useState('준비');
|
||||
|
||||
const fetchStateUpdatesFromOutside = useCallback(() => {
|
||||
setActiveToolLocal(getActiveToolActor()?.getSnapshot()?.context.type);
|
||||
setAngleStepLocal(getAngleStep());
|
||||
setActiveLineColorLocal(getActiveLineColor());
|
||||
setActiveLineWidthLocal(getActiveLineWidth());
|
||||
setScreenZoomLocal(getScreenCanvasDrawController().getScreenScale());
|
||||
setLayersLocal(getLayers());
|
||||
const refresh = useCallback(() => {
|
||||
setActiveTool(getActiveToolActor()?.getSnapshot()?.context.type ?? Tool.LINE);
|
||||
setZoom(getScreenCanvasDrawController().getScreenScale());
|
||||
setLayersLocal([...getLayers()]);
|
||||
setActiveLayerIdLocal(getActiveLayerId());
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback((event: WheelEvent) => {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const selected = getSelectedEntities();
|
||||
setSelectedCount(selected.length);
|
||||
setSelectedType(
|
||||
selected.length === 1 ? selected[0].getType() : selected.length ? '여러 객체' : '선택 없음'
|
||||
);
|
||||
setInstruction(getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.');
|
||||
setSnap(getSnapEnabled());
|
||||
setGrid(getGridEnabled());
|
||||
setOrtho(getAngleStep() === 90);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('wheel', handleWheel, { passive: false });
|
||||
window.addEventListener(HtmlEvent.UPDATE_STATE, fetchStateUpdatesFromOutside);
|
||||
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('wheel', handleWheel);
|
||||
window.removeEventListener(HtmlEvent.UPDATE_STATE, fetchStateUpdatesFromOutside);
|
||||
};
|
||||
}, [fetchStateUpdatesFromOutside, handleWheel]);
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
'--cad-panel-width',
|
||||
panelCollapsed ? '0px' : '248px'
|
||||
);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, [panelCollapsed]);
|
||||
|
||||
const handleToolClick = useCallback((tool: Tool) => {
|
||||
getActiveToolActor()?.stop();
|
||||
|
||||
const newToolActor = new Actor(TOOL_STATE_MACHINES[tool]);
|
||||
setActiveToolActor(newToolActor, false);
|
||||
setActiveToolLocal(tool);
|
||||
const activateTool = useCallback((tool: Tool) => {
|
||||
const actor = new Actor(TOOL_STATE_MACHINES[tool]);
|
||||
setActiveToolActor(actor);
|
||||
setActiveTool(tool);
|
||||
setCommandLog(`${tool} 명령 실행`);
|
||||
}, []);
|
||||
|
||||
const handleAngleChanged = useCallback((angle: number) => {
|
||||
setAngleStepLocal(angle);
|
||||
setAngleStep(angle, false);
|
||||
}, []);
|
||||
|
||||
const noopClickHandler = (evt: MouseEvent) => {
|
||||
evt.stopPropagation();
|
||||
const handleCommand = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = command.trim();
|
||||
if (!value) return;
|
||||
getInputController().submitText(value);
|
||||
setCommandLog(`명령: ${value.toUpperCase()}`);
|
||||
setCommand('');
|
||||
};
|
||||
|
||||
const handleSetLayers = (newLayers: Layer[]) => {
|
||||
setLayersLocal(newLayers);
|
||||
setLayers(newLayers);
|
||||
};
|
||||
|
||||
const handleSetActiveLayerId = (newActiveLayerId: string) => {
|
||||
setActiveLayerIdLocal(newActiveLayerId);
|
||||
setActiveLayerId(newActiveLayerId);
|
||||
const changeZoom = (factor: number) => {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
controller.setScreenScale(Math.max(0.05, controller.getScreenScale() * factor));
|
||||
setZoom(controller.getScreenScale());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="controls top-0 left-0 flex flex-col gap-1 p-1 bg-slate-950 overscroll-y-auto">
|
||||
<DropdownButton
|
||||
label="Draw"
|
||||
title={'Draw tools'}
|
||||
iconName={IconName.Edit}
|
||||
defaultOpen
|
||||
dataId="dropdown-draw-tools"
|
||||
>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Select (s)"
|
||||
dataId="select-button"
|
||||
iconName={IconName.Direction}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.SELECT);
|
||||
}}
|
||||
active={activeToolLocal === Tool.SELECT}
|
||||
label="Select"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Line (l)"
|
||||
dataId="line-button"
|
||||
iconName={IconName.Line}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.LINE);
|
||||
}}
|
||||
active={activeToolLocal === Tool.LINE}
|
||||
label="Line"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Rectangle (r)"
|
||||
dataId="rectangle-button"
|
||||
iconName={IconName.Square}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.RECTANGLE);
|
||||
}}
|
||||
active={activeToolLocal === Tool.RECTANGLE}
|
||||
label="Rectangle"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Circle (c)"
|
||||
dataId="circle-button"
|
||||
iconName={IconName.Circle}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.CIRCLE);
|
||||
}}
|
||||
active={activeToolLocal === Tool.CIRCLE}
|
||||
label="Circle"
|
||||
/>
|
||||
<Button
|
||||
className="mt-2 w-full"
|
||||
title="Move"
|
||||
dataId="move-button"
|
||||
iconName={IconName.Expand}
|
||||
iconClassname={'transform rotate-45'}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.MOVE);
|
||||
}}
|
||||
active={activeToolLocal === Tool.MOVE}
|
||||
label="Move"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Copy"
|
||||
dataId="copy-button"
|
||||
iconName={IconName.Documents}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.COPY);
|
||||
}}
|
||||
active={activeToolLocal === Tool.COPY}
|
||||
label="Copy"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Scale"
|
||||
dataId="scale-button"
|
||||
iconName={IconName.Scale}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.SCALE);
|
||||
}}
|
||||
active={activeToolLocal === Tool.SCALE}
|
||||
label="Scale"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Rotate"
|
||||
dataId="rotate-button"
|
||||
iconName={IconName.Clockwise}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.ROTATE);
|
||||
}}
|
||||
active={activeToolLocal === Tool.ROTATE}
|
||||
label="Rotate"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Array"
|
||||
dataId="array-button"
|
||||
iconName={IconName.GridLayout}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.ARRAY);
|
||||
}}
|
||||
active={activeToolLocal === Tool.ARRAY}
|
||||
label="Array copy"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Create polyline lines and arcs"
|
||||
dataId="pedit-button"
|
||||
iconComponent={<Icon name={IconName.HomeAlt} className="rotate-270" />}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.PEDIT);
|
||||
}}
|
||||
active={activeToolLocal === Tool.PEDIT}
|
||||
label="Polyline edit"
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="mt-2 w-full"
|
||||
title="Add measurements"
|
||||
dataId="measurement-button"
|
||||
iconName={IconName.Measurement}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.MEASUREMENT);
|
||||
}}
|
||||
active={activeToolLocal === Tool.MEASUREMENT}
|
||||
label="Measurement"
|
||||
/>
|
||||
<Button
|
||||
className="mt-2 w-full"
|
||||
title="Delete segments"
|
||||
dataId="delete-segment-button"
|
||||
iconName={IconName.Crop}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.ERASER);
|
||||
}}
|
||||
active={activeToolLocal === Tool.ERASER}
|
||||
label="Eraser"
|
||||
/>
|
||||
</DropdownButton>
|
||||
|
||||
<DropdownButton dataId="layers" label="Layers" iconName={IconName.AlignTextJustify}>
|
||||
<LayerManager
|
||||
className="w-full"
|
||||
layers={layersLocal}
|
||||
activeLayerId={activeLayerIdLocal}
|
||||
setLayers={handleSetLayers}
|
||||
setActiveLayerId={handleSetActiveLayerId}
|
||||
/>
|
||||
</DropdownButton>
|
||||
|
||||
<Button
|
||||
className="mt-2"
|
||||
title="Undo (ctrl + z)"
|
||||
dataId="undo-button"
|
||||
iconName={IconName.ArrowLeftCircle}
|
||||
onClick={() => undo()}
|
||||
label="Undo"
|
||||
/>
|
||||
<Button
|
||||
title="Redo (ctrl + shift + z)"
|
||||
dataId="redo-button"
|
||||
iconName={IconName.ArrowRightCircle}
|
||||
onClick={() => redo()}
|
||||
label="Redo"
|
||||
/>
|
||||
<DropdownButton
|
||||
className="mt-2"
|
||||
title="Align"
|
||||
dataId="align-button"
|
||||
label="Align"
|
||||
iconName={IconName.AlignCenterHorizontal}
|
||||
>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Align left"
|
||||
dataId="align-left-button"
|
||||
iconName={IconName.AlignLeft}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.ALIGN_LEFT);
|
||||
}}
|
||||
active={activeToolLocal === Tool.ALIGN_LEFT}
|
||||
label="Left"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Align center horizontal"
|
||||
dataId="align-center-horizontal-button"
|
||||
iconName={IconName.AlignCenterHorizontal}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.ALIGN_CENTER_HORIZONTAL);
|
||||
}}
|
||||
active={activeToolLocal === Tool.ALIGN_CENTER_HORIZONTAL}
|
||||
label="Center"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Align right"
|
||||
dataId="align-right-button"
|
||||
iconName={IconName.AlignRight}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.ALIGN_RIGHT);
|
||||
}}
|
||||
active={activeToolLocal === Tool.ALIGN_RIGHT}
|
||||
label="Right"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Align top"
|
||||
dataId="align-top-button"
|
||||
iconName={IconName.AlignTop}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.ALIGN_TOP);
|
||||
}}
|
||||
active={activeToolLocal === Tool.ALIGN_TOP}
|
||||
label="Top"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Align center vertical"
|
||||
dataId="align-center-vertical-button"
|
||||
iconName={IconName.AlignCenterVertical}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.ALIGN_CENTER_VERTICAL);
|
||||
}}
|
||||
active={activeToolLocal === Tool.ALIGN_CENTER_VERTICAL}
|
||||
label="Middle"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Align bottom"
|
||||
dataId="align-bottom-button"
|
||||
iconName={IconName.AlignBottom}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleToolClick(Tool.ALIGN_BOTTOM);
|
||||
}}
|
||||
active={activeToolLocal === Tool.ALIGN_BOTTOM}
|
||||
label="Bottom"
|
||||
/>
|
||||
</DropdownButton>
|
||||
|
||||
<DropdownButton
|
||||
className="mt-2"
|
||||
title="Line color"
|
||||
dataId="line-color-button"
|
||||
label="Line color"
|
||||
iconComponent={
|
||||
<div className="w-5 h-5" style={{ backgroundColor: activeLineColorLocal }} />
|
||||
}
|
||||
>
|
||||
{COLOR_LIST.map((color) => (
|
||||
<Button
|
||||
key={`line-color--${color}`}
|
||||
title="Change line color"
|
||||
dataId={`line-color-${color}-button`}
|
||||
className="w-10"
|
||||
style={{ backgroundColor: color }}
|
||||
active={color === activeLineColorLocal}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
setActiveLineColor(color);
|
||||
<>
|
||||
<header className="cad-titlebar controls">
|
||||
<div className="cad-brand">
|
||||
<strong>Aislo CAD</strong>
|
||||
<span>B07 상세 설계</span>
|
||||
</div>
|
||||
<div className="cad-file-state">
|
||||
<span className="cad-file-state__dot" />
|
||||
현재 도면 · 저장됨
|
||||
</div>
|
||||
<div className="cad-title-actions">
|
||||
<button type="button" onClick={() => undo()} title="실행 취소 (Ctrl+Z)">
|
||||
↶
|
||||
</button>
|
||||
<button type="button" onClick={() => redo()} title="다시 실행 (Ctrl+Y)">
|
||||
↷
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await exportEntitiesToLocalStorage();
|
||||
toast.success('도면을 저장했습니다.');
|
||||
}}
|
||||
/>
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button type="button" onClick={() => exportEntitiesToJsonFile()}>
|
||||
내보내기
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="cad-ribbon controls" aria-label="CAD 도구 리본">
|
||||
{RIBBON_GROUPS.map((group) => (
|
||||
<section className="cad-ribbon-group" key={group.label}>
|
||||
<div className="cad-ribbon-tools">
|
||||
{group.tools.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.label}
|
||||
className="cad-tool"
|
||||
data-active={item.tool === activeTool}
|
||||
disabled={item.disabled}
|
||||
title={
|
||||
item.disabled
|
||||
? `${item.label} 도구는 후속 구현 예정입니다.`
|
||||
: `${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`
|
||||
}
|
||||
onClick={() => item.tool && activateTool(item.tool)}
|
||||
>
|
||||
<span className="cad-tool__glyph">{item.glyph}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="cad-ribbon-group__label">{group.label}</span>
|
||||
</section>
|
||||
))}
|
||||
</DropdownButton>
|
||||
<DropdownButton
|
||||
title="Line width"
|
||||
dataId="line-width-button"
|
||||
label="Line width"
|
||||
iconComponent={
|
||||
<div
|
||||
className="w-5 h-0 -rotate-45 border-t-white"
|
||||
style={{ borderTopWidth: `${activeLineWidthLocal}px` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{times<number>(9).map((width: number) => {
|
||||
const lineWidth = width + 1;
|
||||
return (
|
||||
<Button
|
||||
key={`line-width--${lineWidth}`}
|
||||
title="Change line width"
|
||||
dataId={`line-width-${lineWidth}-button`}
|
||||
label={`${String(lineWidth)}px`}
|
||||
active={lineWidth === activeLineWidthLocal}
|
||||
iconComponent={
|
||||
<div
|
||||
className="w-5 h-0 -rotate-45 border-t-white"
|
||||
style={{ borderTopWidth: `${lineWidth}px` }}
|
||||
/>
|
||||
}
|
||||
style={{ width: 'calc(50% - 2px)' }}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
setActiveLineWidth(lineWidth);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</DropdownButton>
|
||||
<DropdownButton
|
||||
title="Snap angles"
|
||||
iconComponent={<div className="w-5 text-blue-700">{`${angleStepLocal}°`}</div>}
|
||||
label="Snap angles"
|
||||
dataId="angle-guide-button"
|
||||
>
|
||||
{[5, 15, 30, 45, 90].map((angle: number) => (
|
||||
<Button
|
||||
key={`angle-guide--${angle}`}
|
||||
title={`Add guide every ${angle} degrees`}
|
||||
dataId={`angle-guide-${angle}-button`}
|
||||
label={`${angle}°`}
|
||||
iconComponent={
|
||||
<div
|
||||
className={'w-5 h-0 border-t-2 border-t-white'}
|
||||
style={{ rotate: `${-angle}deg` }}
|
||||
</nav>
|
||||
|
||||
<aside className="cad-inspector controls" data-collapsed={panelCollapsed}>
|
||||
<button
|
||||
className="cad-inspector__collapse"
|
||||
type="button"
|
||||
onClick={() => setPanelCollapsed((value) => !value)}
|
||||
title={panelCollapsed ? '패널 펼치기' : '패널 접기'}
|
||||
>
|
||||
{panelCollapsed ? '›' : '‹'}
|
||||
</button>
|
||||
{!panelCollapsed && (
|
||||
<>
|
||||
<div className="cad-inspector-tabs">
|
||||
<button
|
||||
type="button"
|
||||
data-active={panelTab === 'properties'}
|
||||
onClick={() => setPanelTab('properties')}
|
||||
>
|
||||
특성
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-active={panelTab === 'layers'}
|
||||
onClick={() => setPanelTab('layers')}
|
||||
>
|
||||
도면층
|
||||
</button>
|
||||
</div>
|
||||
{panelTab === 'properties' ? (
|
||||
<div className="cad-properties">
|
||||
<h2>{selectedType}</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>선택 객체</dt>
|
||||
<dd>{selectedCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>현재 도구</dt>
|
||||
<dd>{activeTool}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>현재 도면층</dt>
|
||||
<dd>{layers.find((layer) => layer.id === activeLayerId)?.name ?? '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
) : (
|
||||
<LayerManager
|
||||
className="cad-layer-manager"
|
||||
layers={layers}
|
||||
activeLayerId={activeLayerId}
|
||||
setLayers={(next) => {
|
||||
setLayersLocal(next);
|
||||
setLayers(next);
|
||||
}}
|
||||
setActiveLayerId={(id) => {
|
||||
setActiveLayerIdLocal(id);
|
||||
setActiveLayerId(id);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
style={{ width: 'calc(50% - 2px)' }}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handleAngleChanged(angle);
|
||||
}}
|
||||
active={angle === angleStepLocal}
|
||||
/>
|
||||
))}
|
||||
</DropdownButton>
|
||||
<DropdownButton
|
||||
title="Zoom level"
|
||||
iconComponent={<div className="w-5 text-blue-700">{screenZoomLocal.toFixed(1)}</div>}
|
||||
label="Zoom level"
|
||||
dataId="zoom-level-button"
|
||||
>
|
||||
{[20, 50, 75, 100, 150, 200, 400].map((zoom: number) => (
|
||||
<Button
|
||||
key={`zoom-level--${zoom}`}
|
||||
title={`Zoom level ${zoom}%`}
|
||||
dataId={`zoom-level-${zoom}-button`}
|
||||
label={`${zoom.toFixed(0)}%`}
|
||||
style={{ width: 'calc(30% - 2px)', padding: '8px' }}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
getScreenCanvasDrawController().setScreenScale(zoom / 100);
|
||||
setScreenZoomLocal(zoom / 100);
|
||||
}}
|
||||
active={zoom === screenZoomLocal}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
key="zoom-level--fit"
|
||||
title="Zoom fit screen"
|
||||
dataId="zoom-level-fit-button"
|
||||
label="Fit screen"
|
||||
style={{ width: 'calc(60% - 2px)', padding: '8px' }}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<div className="cad-view-controls controls">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
getScreenCanvasDrawController().zoomToFitScreen();
|
||||
setScreenZoomLocal(getScreenCanvasDrawController().getScreenScale());
|
||||
refresh();
|
||||
}}
|
||||
active={false}
|
||||
/>
|
||||
</DropdownButton>
|
||||
|
||||
<Button
|
||||
className="mt-2"
|
||||
title="Save current drawing"
|
||||
dataId="save-button"
|
||||
iconName={IconName.Save}
|
||||
onClick={async (evt) => {
|
||||
evt.stopPropagation();
|
||||
await exportEntitiesToLocalStorage();
|
||||
toast.success('Saved');
|
||||
}}
|
||||
label="Save drawing"
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="mt-2"
|
||||
title="Start a new drawing"
|
||||
dataId="new-button"
|
||||
iconName={IconName.FilePlus}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
setEntities([]);
|
||||
}}
|
||||
label="New drawing"
|
||||
/>
|
||||
|
||||
<DropdownButton
|
||||
label="Import"
|
||||
title={'Import files'}
|
||||
iconName={IconName.SendUp}
|
||||
dataId="dropdown-import-tools"
|
||||
>
|
||||
<Button
|
||||
className="relative w-full"
|
||||
title="Import image into the current drawing"
|
||||
dataId="import-image-button"
|
||||
iconName={IconName.ImageSolid}
|
||||
onClick={noopClickHandler}
|
||||
label="image"
|
||||
title="화면에 맞춤"
|
||||
>
|
||||
<input
|
||||
className="absolute inset-0 opacity-0"
|
||||
type="file"
|
||||
accept="*.jpg,*.jpeg,*.png"
|
||||
onChange={async (evt) => {
|
||||
const image: HTMLImageElement = await importImageFromFile(evt.target.files?.[0]);
|
||||
const imageImportActor = new Actor(imageImportToolStateMachine);
|
||||
imageImportActor.start();
|
||||
imageImportActor.send({
|
||||
type: ActorEvent.FILE_SELECTED,
|
||||
image,
|
||||
});
|
||||
setActiveToolActor(imageImportActor);
|
||||
evt.target.files = null;
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
className="relative w-full"
|
||||
title="Load from JSON file"
|
||||
dataId="json-open-button"
|
||||
iconName={IconName.JavascriptSolid}
|
||||
onClick={noopClickHandler}
|
||||
label="JSON"
|
||||
>
|
||||
<input
|
||||
className="absolute inset-0 opacity-0"
|
||||
type="file"
|
||||
accept="*.json"
|
||||
onChange={async (evt) => {
|
||||
await importEntitiesFromJsonFile(evt.target.files?.[0]);
|
||||
evt.target.files = null;
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
className="relative w-full"
|
||||
title="Load from SVG file"
|
||||
dataId="svg-open-button"
|
||||
iconName={IconName.VectorDocumentSolid}
|
||||
onClick={noopClickHandler}
|
||||
label="SVG"
|
||||
>
|
||||
<input
|
||||
className="absolute inset-0 opacity-0"
|
||||
type="file"
|
||||
accept="*.svg"
|
||||
onChange={async (evt) => {
|
||||
await importEntitiesFromSvgFile(evt.target.files?.[0]);
|
||||
evt.target.files = null;
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownButton>
|
||||
⌂
|
||||
</button>
|
||||
<button type="button" onClick={() => changeZoom(1.2)} title="확대">
|
||||
+
|
||||
</button>
|
||||
<span>{Math.round(zoom * 100)}%</span>
|
||||
<button type="button" onClick={() => changeZoom(0.8)} title="축소">
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DropdownButton
|
||||
label="Export"
|
||||
title={'Export file'}
|
||||
iconName={IconName.SendDown}
|
||||
dataId="dropdown-export-tools"
|
||||
>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Save to JSON file"
|
||||
dataId="json-save-button"
|
||||
iconName={IconName.JavascriptSolid}
|
||||
onClick={async (evt) => {
|
||||
evt.stopPropagation();
|
||||
await exportEntitiesToJsonFile();
|
||||
}}
|
||||
label="JSON"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Export to SVG file"
|
||||
dataId="svg-export-button"
|
||||
iconName={IconName.VectorDocumentSolid}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
exportEntitiesToSvgFile();
|
||||
}}
|
||||
label="SVG"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
title="Export to PNG file"
|
||||
dataId="png-export-button"
|
||||
iconName={IconName.ImageSolid}
|
||||
onClick={async (evt) => {
|
||||
evt.stopPropagation();
|
||||
await exportEntitiesToPngFile();
|
||||
}}
|
||||
label="PNG"
|
||||
/>
|
||||
</DropdownButton>
|
||||
</div>
|
||||
<section className="cad-command-area controls">
|
||||
<div className="cad-command-prompt">
|
||||
<span>{commandLog}</span>
|
||||
<strong>{instruction}</strong>
|
||||
</div>
|
||||
<form onSubmit={handleCommand}>
|
||||
<label htmlFor="cad-command">명령:</label>
|
||||
<input
|
||||
id="cad-command"
|
||||
list="cad-command-list"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder="명령 입력 (예: LINE, MOVE, CIRCLE)"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<datalist id="cad-command-list">
|
||||
{COMMANDS.map((item) => (
|
||||
<option value={item} key={item} />
|
||||
))}
|
||||
</datalist>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<footer className="cad-statusbar controls">
|
||||
<button type="button" data-active={snap} onClick={() => setSnapEnabled(!snap)}>
|
||||
OSNAP
|
||||
</button>
|
||||
<button type="button" data-active={ortho} onClick={() => setAngleStep(ortho ? 45 : 90)}>
|
||||
직교
|
||||
</button>
|
||||
<button type="button" data-active={grid} onClick={() => setGridEnabled(!grid)}>
|
||||
그리드
|
||||
</button>
|
||||
<span className="cad-statusbar__hint">휠: 줌 · 휠 드래그: 팬 · Esc: 취소</span>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import {Point, type Vector} from '@flatten-js/core';
|
||||
import {CANVAS_BACKGROUND_COLOR, MOUSE_ZOOM_MULTIPLIER} from '../App.consts';
|
||||
import {containRectangle} from '../helpers/contain-rect.ts';
|
||||
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, getScreenCanvasDrawController, triggerReactUpdate} from '../state.ts';
|
||||
import {DEFAULT_TEXT_OPTIONS, type DrawController} from './DrawController';
|
||||
import { Point, type Vector } from '@flatten-js/core';
|
||||
import { CANVAS_BACKGROUND_COLOR, MOUSE_ZOOM_MULTIPLIER } from '../App.consts';
|
||||
import { containRectangle } from '../helpers/contain-rect.ts';
|
||||
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,
|
||||
getScreenCanvasDrawController,
|
||||
triggerReactUpdate,
|
||||
} from '../state.ts';
|
||||
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
|
||||
|
||||
/**
|
||||
* Screen coordinate system:
|
||||
@@ -229,6 +234,21 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
|
||||
this.context.fillStyle = CANVAS_BACKGROUND_COLOR;
|
||||
this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y);
|
||||
if (getGridEnabled()) {
|
||||
this.context.strokeStyle = '#242b35';
|
||||
this.context.lineWidth = 1;
|
||||
this.context.setLineDash([]);
|
||||
this.context.beginPath();
|
||||
for (let x = 0.5; x < this.canvasSize.x; x += 24) {
|
||||
this.context.moveTo(x, 0);
|
||||
this.context.lineTo(x, this.canvasSize.y);
|
||||
}
|
||||
for (let y = 0.5; y < this.canvasSize.y; y += 24) {
|
||||
this.context.moveTo(0, y);
|
||||
this.context.lineTo(this.canvasSize.x, y);
|
||||
}
|
||||
this.context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Point} from '@flatten-js/core';
|
||||
import {compact, round} from 'es-toolkit';
|
||||
import {Actor} from 'xstate';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { compact, round } from 'es-toolkit';
|
||||
import { Actor } from 'xstate';
|
||||
import {
|
||||
CANVAS_INPUT_FIELD_BACKGROUND_COLOR,
|
||||
CANVAS_INPUT_FIELD_HEIGHT,
|
||||
@@ -10,19 +10,19 @@ import {
|
||||
CANVAS_INPUT_FIELD_WIDTH,
|
||||
HIGHLIGHT_ENTITY_DISTANCE,
|
||||
SNAP_POINT_DISTANCE,
|
||||
TOOLBAR_WIDTH,
|
||||
} from '../App.consts.ts';
|
||||
import {MouseButton} from '../App.types.ts';
|
||||
import type {ScreenCanvasDrawController} from '../drawControllers/screenCanvas.drawController.ts';
|
||||
import {calculateAngleGuidesAndSnapPoints} from '../helpers/calculate-angle-guides-and-snap-points.ts';
|
||||
import {findClosestEntity} from '../helpers/find-closest-entity.ts';
|
||||
import {getClosestSnapPointWithinRadius} from '../helpers/get-closest-snap-point.ts';
|
||||
import { MouseButton } from '../App.types.ts';
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
|
||||
import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts';
|
||||
import { findClosestEntity } from '../helpers/find-closest-entity.ts';
|
||||
import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-point.ts';
|
||||
import {
|
||||
getActiveToolActor,
|
||||
getCanvas,
|
||||
getEntities,
|
||||
getLastStateInstructions,
|
||||
getPanStartLocation,
|
||||
getSnapEnabled,
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
getSnapPoint,
|
||||
@@ -36,8 +36,8 @@ import {
|
||||
setShouldDrawCursor,
|
||||
undo,
|
||||
} from '../state.ts';
|
||||
import {Tool} from '../tools.ts';
|
||||
import {TOOL_STATE_MACHINES} from '../tools/tool.consts.ts';
|
||||
import { Tool } from '../tools.ts';
|
||||
import { TOOL_STATE_MACHINES } from '../tools/tool.consts.ts';
|
||||
import {
|
||||
type AbsolutePointInputEvent,
|
||||
ActorEvent,
|
||||
@@ -120,6 +120,11 @@ export class InputController {
|
||||
this.drawListBelowInputField(drawController, texts);
|
||||
}
|
||||
|
||||
public submitText(value: string) {
|
||||
this.text = value.trim().toUpperCase();
|
||||
this.handleEnterKey();
|
||||
}
|
||||
|
||||
public handleMouseUp(evt: MouseEvent) {
|
||||
if (evt.button === MouseButton.Right) {
|
||||
// Right click => confirm action (ENTER)
|
||||
@@ -146,10 +151,7 @@ export class InputController {
|
||||
);
|
||||
|
||||
const worldMouseLocationTemp = getScreenCanvasDrawController().targetToWorld(
|
||||
new Point(
|
||||
evt.clientX - TOOLBAR_WIDTH,
|
||||
getScreenCanvasDrawController().getCanvasSize().y - evt.clientY
|
||||
)
|
||||
this.getCanvasPoint(evt)
|
||||
);
|
||||
const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
|
||||
|
||||
@@ -171,10 +173,7 @@ export class InputController {
|
||||
public handleMouseMove(evt: MouseEvent) {
|
||||
setShouldDrawCursor(true);
|
||||
const screenCanvasDrawController = getScreenCanvasDrawController();
|
||||
const newScreenMouseLocation = new Point(
|
||||
evt.clientX - TOOLBAR_WIDTH,
|
||||
getScreenCanvasDrawController().getCanvasSize().y - evt.clientY
|
||||
);
|
||||
const newScreenMouseLocation = this.getCanvasPoint(evt);
|
||||
screenCanvasDrawController.setScreenMouseLocation(newScreenMouseLocation);
|
||||
|
||||
// If the middle mouse button is pressed, pan the screen
|
||||
@@ -188,7 +187,9 @@ export class InputController {
|
||||
}
|
||||
|
||||
// Calculate angle guides and snap points
|
||||
calculateAngleGuidesAndSnapPoints();
|
||||
if (getSnapEnabled()) {
|
||||
calculateAngleGuidesAndSnapPoints();
|
||||
}
|
||||
|
||||
// Highlight the entity closest to the mouse when the select tool is active
|
||||
if (getActiveToolActor()?.getSnapshot()?.context.type === Tool.SELECT) {
|
||||
@@ -223,11 +224,14 @@ export class InputController {
|
||||
public handleMouseDown(evt: MouseEvent) {
|
||||
if (evt.button !== MouseButton.Middle) return;
|
||||
|
||||
setPanStartLocation(
|
||||
new Point(
|
||||
evt.clientX - TOOLBAR_WIDTH,
|
||||
getScreenCanvasDrawController().getCanvasSize().y - evt.clientY
|
||||
)
|
||||
setPanStartLocation(this.getCanvasPoint(evt));
|
||||
}
|
||||
|
||||
private getCanvasPoint(evt: MouseEvent): Point {
|
||||
const bounds = getCanvas()?.getBoundingClientRect();
|
||||
return new Point(
|
||||
evt.clientX - (bounds?.left ?? 0),
|
||||
(bounds?.bottom ?? getScreenCanvasDrawController().getCanvasSize().y) - evt.clientY
|
||||
);
|
||||
}
|
||||
|
||||
@@ -261,6 +265,9 @@ export class InputController {
|
||||
}
|
||||
|
||||
public handleKeyStroke(evt: KeyboardEvent) {
|
||||
if ((evt.target as HTMLElement | null)?.closest('input, textarea, select')) {
|
||||
return;
|
||||
}
|
||||
console.log(`key pressed: ${evt.key}`);
|
||||
if (evt.key === 'F12') {
|
||||
// F12 => open developer tools
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts';
|
||||
import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts';
|
||||
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json.ts';
|
||||
import { HtmlEvent } from '../App.types.ts';
|
||||
import {
|
||||
getScreenCanvasDrawController,
|
||||
setActiveLayerId,
|
||||
@@ -11,21 +13,28 @@ export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b07:load-drawing';
|
||||
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b07:drawing-ready';
|
||||
export const AISLO_DRAWING_LOADED_MESSAGE = 'aislo:b07:drawing-loaded';
|
||||
export const AISLO_DRAWING_ERROR_MESSAGE = 'aislo:b07:drawing-error';
|
||||
export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b07:drawing-changed';
|
||||
export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b07:save-request';
|
||||
export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b07:save-response';
|
||||
|
||||
interface DrawingLoadMessage {
|
||||
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
|
||||
drawing: JsonDrawingFileSerialized;
|
||||
}
|
||||
|
||||
interface DrawingSaveRequestMessage {
|
||||
type: typeof AISLO_DRAWING_SAVE_REQUEST_MESSAGE;
|
||||
}
|
||||
|
||||
function isDrawingLoadMessage(value: unknown): value is DrawingLoadMessage {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const candidate = value as Partial<DrawingLoadMessage>;
|
||||
return candidate.type === AISLO_DRAWING_LOAD_MESSAGE && Boolean(candidate.drawing);
|
||||
}
|
||||
|
||||
function notifyParent(type: string, detail?: string) {
|
||||
function notifyParent(type: string, payload: Record<string, unknown> = {}) {
|
||||
if (window.parent === window) return;
|
||||
window.parent.postMessage({ type, detail }, window.location.origin);
|
||||
window.parent.postMessage({ type, ...payload }, window.location.origin);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,20 +44,36 @@ function notifyParent(type: string, detail?: string) {
|
||||
export function registerAisloDrawingBridge() {
|
||||
window.addEventListener('message', async (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
||||
const message = event.data as Partial<DrawingSaveRequestMessage>;
|
||||
if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) {
|
||||
try {
|
||||
const drawing = JSON.parse(
|
||||
await exportEntitiesAndLayersToJsonString()
|
||||
) as JsonDrawingFileSerialized;
|
||||
notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing });
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'Unable to serialize drawing';
|
||||
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isDrawingLoadMessage(event.data)) return;
|
||||
|
||||
try {
|
||||
const drawing = await getEntitiesAndLayersFromJsonObject(event.data.drawing);
|
||||
setEntities(drawing.entities, true);
|
||||
setEntities(drawing.entities, false);
|
||||
setLayers(drawing.layers);
|
||||
setActiveLayerId(drawing.layers[0].id);
|
||||
getScreenCanvasDrawController().zoomToFitScreen();
|
||||
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'Invalid drawing JSON';
|
||||
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, detail);
|
||||
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
|
||||
}
|
||||
});
|
||||
window.addEventListener(HtmlEvent.DRAWING_CHANGED, () => {
|
||||
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE);
|
||||
});
|
||||
|
||||
notifyParent(AISLO_DRAWING_READY_MESSAGE);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ import { Point } from '@flatten-js/core';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Actor, type MachineSnapshot } from 'xstate';
|
||||
import { HIGHLIGHT_ENTITY_DISTANCE, SNAP_POINT_DISTANCE, TOOLBAR_WIDTH } from './App.consts';
|
||||
import { HIGHLIGHT_ENTITY_DISTANCE, SNAP_POINT_DISTANCE } from './App.consts';
|
||||
import App from './App.tsx';
|
||||
import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
|
||||
import { draw } from './helpers/draw';
|
||||
import { findClosestEntity } from './helpers/find-closest-entity';
|
||||
import { getNewLayer } from './helpers/get-new-layer.ts';
|
||||
import type { JsonDrawingFileDeserialized } from './helpers/import-export-handlers/export-entities-to-json.ts';
|
||||
import { getEntitiesAndLayersFromLocalStorage } from './helpers/import-export-handlers/import-entities-from-local-storage.ts';
|
||||
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
|
||||
import { InputController } from './inputController/input-controller.ts';
|
||||
import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts';
|
||||
@@ -104,11 +102,14 @@ function startDrawLoop(
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
getScreenCanvasDrawController().setCanvasSize(new Point(window.innerWidth, window.innerHeight));
|
||||
const canvas = getCanvas();
|
||||
if (canvas) {
|
||||
canvas.width = window.innerWidth - TOOLBAR_WIDTH;
|
||||
canvas.height = window.innerHeight;
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.round(bounds.width));
|
||||
const height = Math.max(1, Math.round(bounds.height));
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
getScreenCanvasDrawController().setCanvasSize(new Point(width, height));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,21 +123,15 @@ function initApplication() {
|
||||
|
||||
setEntities([], true); // Creates the first undo entry
|
||||
|
||||
// Load the last drawing from local storage
|
||||
getEntitiesAndLayersFromLocalStorage().then((file: JsonDrawingFileDeserialized) => {
|
||||
setEntities(file.entities, true);
|
||||
let layers = file.layers;
|
||||
if (layers.length === 0) {
|
||||
layers = [getNewLayer()];
|
||||
}
|
||||
setLayers(layers);
|
||||
setActiveLayerId(layers[0].id);
|
||||
registerAisloDrawingBridge();
|
||||
});
|
||||
const layers = [getNewLayer()];
|
||||
setLayers(layers);
|
||||
setActiveLayerId(layers[0].id);
|
||||
registerAisloDrawingBridge();
|
||||
const screenCanvasDrawController = new ScreenCanvasDrawController(context);
|
||||
setScreenCanvasDrawController(screenCanvasDrawController);
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
new ResizeObserver(handleWindowResize).observe(canvas);
|
||||
const inputController = new InputController();
|
||||
setInputController(inputController);
|
||||
|
||||
|
||||
@@ -147,6 +147,9 @@ let layers: Layer[] = [
|
||||
*/
|
||||
let activeLayerId: string = layers[0].id;
|
||||
|
||||
let snapEnabled = true;
|
||||
let gridEnabled = false;
|
||||
|
||||
// getters
|
||||
export const getCanvas = () => canvas;
|
||||
export const getActiveToolActor = () => activeToolActor;
|
||||
@@ -194,6 +197,8 @@ export const getLayers = () => {
|
||||
export const getActiveLayerId = (): string => {
|
||||
return activeLayerId;
|
||||
};
|
||||
export const getSnapEnabled = () => snapEnabled;
|
||||
export const getGridEnabled = () => gridEnabled;
|
||||
|
||||
// setters
|
||||
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
|
||||
@@ -243,18 +248,23 @@ export const setActiveToolActor = (
|
||||
};
|
||||
export const setLastStateInstructions = (newInstructions: string | null) => {
|
||||
lastStateInstructions = newInstructions;
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
};
|
||||
export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => {
|
||||
if (trackInUndoStack) {
|
||||
trackUndoState(StateVariable.entities, newEntities);
|
||||
}
|
||||
entities = newEntities;
|
||||
if (trackInUndoStack) {
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
};
|
||||
export const setHighlightedEntityIds = (newEntityIds: string[]) => {
|
||||
highlightedEntityIds = newEntityIds;
|
||||
};
|
||||
export const setSelectedEntityIds = (newEntityIds: string[]) => {
|
||||
selectedEntityIds = newEntityIds;
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
};
|
||||
export const setShouldDrawCursor = (newValue: boolean) => {
|
||||
shouldDrawCursor = newValue;
|
||||
@@ -335,6 +345,20 @@ export const setActiveLayerId = (newActiveLayerId: string, triggerReact = true)
|
||||
triggerReactUpdate(StateVariable.layers);
|
||||
}
|
||||
};
|
||||
export const setSnapEnabled = (enabled: boolean) => {
|
||||
snapEnabled = enabled;
|
||||
if (!enabled) {
|
||||
setSnapPoint(null);
|
||||
setSnapPointOnAngleGuide(null);
|
||||
setHoveredSnapPoints([]);
|
||||
setAngleGuideEntities([]);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
};
|
||||
export const setGridEnabled = (enabled: boolean) => {
|
||||
gridEnabled = enabled;
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
};
|
||||
|
||||
// Computed setters
|
||||
export const deleteEntities = (entitiesToDelete: Entity[], trackInUndoStack: boolean): Entity[] => {
|
||||
@@ -393,6 +417,7 @@ export function undo() {
|
||||
if (!undoState) return;
|
||||
|
||||
updateStates(undoState);
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
|
||||
export function redo() {
|
||||
@@ -400,6 +425,7 @@ export function redo() {
|
||||
if (!redoState) return;
|
||||
|
||||
updateStates(redoState);
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
|
||||
export function triggerReactUpdate(variable: StateVariable) {
|
||||
|
||||
Reference in New Issue
Block a user