Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1
This commit is contained in:
@@ -176,14 +176,20 @@ export const CANVAS_INPUT_FIELD_HEIGHT = 20;
|
||||
|
||||
/**
|
||||
* Canvas input field background color
|
||||
* 자동색(검정) — 어두운 바탕 검정 칸에 흰 글 · 밝은 바탕 흰 칸에 검정 글(`theme.ts` paintColor)
|
||||
* 토큰 `--cad-chrome-raised` 못 읽을 때 폴백(어두움 값) — 칸 · 글 · 테두리는 토큰 색을 그대로 칠함
|
||||
* (자동색 뒤집기 `theme.ts` paintColor 건너뜀 · `inputController/input-field-draw.ts`)
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#000000';
|
||||
export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#251f38';
|
||||
|
||||
/**
|
||||
* Canvas input field text color
|
||||
* Canvas input field border color — 토큰 `--cad-line` 폴백
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_TEXT_COLOR = '#FFF';
|
||||
export const CANVAS_INPUT_FIELD_BORDER_COLOR = '#322a45';
|
||||
|
||||
/**
|
||||
* Canvas input field text color — 토큰 `--cad-text` 폴백
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_TEXT_COLOR = '#e4e0f0';
|
||||
|
||||
/**
|
||||
* Canvas input field background color when text is selected
|
||||
@@ -201,9 +207,9 @@ export const CANVAS_INPUT_FIELD_SELECTION_TEXT_COLOR = '#000';
|
||||
export const CANVAS_INPUT_FIELD_FONT_SIZE = 12;
|
||||
|
||||
/**
|
||||
* Canvas input field instruction text color
|
||||
* Canvas input field instruction text color — 토큰 `--cad-text-dim` 폴백
|
||||
*/
|
||||
export const CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR = '#999';
|
||||
export const CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR = '#b3aec6';
|
||||
|
||||
/**
|
||||
* Multiplier to determine the pdf line width from the in application line width
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** 전체 맞춤 계산 — 맞출 개체 고르기 · 배율 · 화면 원점(`screenCanvas.drawController.ts` zoomToFitScreen 이 부름). */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { getEntities, getPickableEntities } from '../state.ts';
|
||||
|
||||
/** M02 가 구조물 도면에 얹는 도각 층 — 서버 `STRUCTURE_FRAME_LAYER` 와 같음 */
|
||||
const STRUCTURE_FRAME_LAYER_ID = 'm02-structure-frame';
|
||||
|
||||
/** 맞출 개체 */
|
||||
export function fitEntities(): Entity[] {
|
||||
// 도각·원지반 같은 잠금 도면층은 빼고 설계 콘텐츠에만 맞춘다 (2026-08-31 사용자
|
||||
// 확정). 도각까지 넣으면 A1 한 장 전체가 잡혀 화면의 40%가 여백으로 간다.
|
||||
// 구조물 도면(M02·B08)은 얹은 도각(작도 틀)이 있으면 도각 전체에 맞춘다 — 옛 DXF 가 자기 도곽·
|
||||
// 표제 띠를 품어 범위가 크면 도각이 화면 밖으로 밀린다.
|
||||
const structureFrame = getEntities().filter((e) => e.layerId === STRUCTURE_FRAME_LAYER_ID);
|
||||
const pickable = getPickableEntities();
|
||||
return structureFrame.length ? structureFrame : pickable.length ? pickable : getEntities();
|
||||
}
|
||||
|
||||
/** 개체 범위를 화면 가운데에 놓는 배율 · 화면 원점(월드 좌표) */
|
||||
export function fitView(entities: Entity[], canvasSize: Point): { zoom: number; offset: Point } {
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
|
||||
const boundingWidth = boundingBox.maxX - boundingBox.minX;
|
||||
const boundingHeight = boundingBox.maxY - boundingBox.minY;
|
||||
|
||||
// 10% 여백을 남기고 두 축 중 더 빡빡한 쪽에 맞춘다 (종횡비 유지)
|
||||
const FIT_MARGIN = 0.9;
|
||||
const scaleX =
|
||||
boundingWidth > 0 ? (canvasSize.x * FIT_MARGIN) / boundingWidth : Number.POSITIVE_INFINITY;
|
||||
const scaleY =
|
||||
boundingHeight > 0 ? (canvasSize.y * FIT_MARGIN) / boundingHeight : Number.POSITIVE_INFINITY;
|
||||
let zoomLevel = Math.min(scaleX, scaleY);
|
||||
if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) zoomLevel = 1;
|
||||
|
||||
// screen = (world - offset) * zoom 이므로, 도면 중심을 화면 중심에 맞추려면
|
||||
// offset = worldCenter - (화면 절반 픽셀) / zoom
|
||||
const worldCenterX = (boundingBox.minX + boundingBox.maxX) / 2;
|
||||
const worldCenterY = (boundingBox.minY + boundingBox.maxY) / 2;
|
||||
return {
|
||||
zoom: zoomLevel,
|
||||
offset: new Point(
|
||||
worldCenterX - canvasSize.x / 2 / zoomLevel,
|
||||
worldCenterY - canvasSize.y / 2 / zoomLevel
|
||||
),
|
||||
};
|
||||
}
|
||||
+51
-1
@@ -3,6 +3,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import type { Layer } from '../App.types';
|
||||
import { setEntities, setLayers } from '../state';
|
||||
import { drawInputField } from '../inputController/input-field-draw';
|
||||
import { ScreenCanvasDrawController } from './screenCanvas.drawController';
|
||||
|
||||
/** translate·drawImage 인자만 기록하는 최소 2d 컨텍스트. */
|
||||
@@ -12,12 +13,19 @@ function recordingContext() {
|
||||
rotate: [],
|
||||
image: [],
|
||||
};
|
||||
const rects: number[][] = [];
|
||||
const texts: { label: string; at: number[] }[] = [];
|
||||
const context = {
|
||||
translate: (x: number, y: number) => calls.translate.push([x, y]),
|
||||
rotate: (a: number) => calls.rotate.push(a),
|
||||
drawImage: (_img: unknown, ...rest: number[]) => calls.image.push(...rest),
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
fillRect: (...rect: number[]) => rects.push(rect),
|
||||
fillText: (label: string) => texts.push({ label, at: calls.translate.at(-1) ?? [] }),
|
||||
measureText: (label: string) => ({ width: label.length * 7 }),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
return { context, calls };
|
||||
return { context, calls, rects, texts };
|
||||
}
|
||||
|
||||
describe('ScreenCanvasDrawController.drawImage', () => {
|
||||
@@ -71,3 +79,45 @@ describe('ScreenCanvasDrawController.zoomToFitScreen', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('커서 입력창(drawInputField)', () => {
|
||||
it('글 중심이 칸 안 · 긴 줄에 맞춰 칸 폭이 늘고 목록도 바탕 안', () => {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g.document = { documentElement: {} };
|
||||
g.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
||||
const { context, rects, texts } = recordingContext();
|
||||
const controller = new ScreenCanvasDrawController(context);
|
||||
controller.setCanvasSize(new Point(800, 600));
|
||||
controller.setScreenMouseLocation(new Point(100, 300));
|
||||
const lines = ['LINE (L) 선', 'LINETYPE (LT) 선종류 — 아주 긴 이름의 명령 후보 줄'];
|
||||
drawInputField(controller, 'LIN', lines);
|
||||
|
||||
const [border, box, divider] = rects; // [x, 캔버스 y(위 변), w, h]
|
||||
expect(border).toEqual([119, 319, box[2] + 2, box[3] + 2]);
|
||||
expect(box[0]).toBe(120);
|
||||
expect(box[1]).toBe(600 - 280); // 커서 아래 20px
|
||||
expect(box[3]).toBe(20 * 3); // 입력 줄 + 목록 두 줄
|
||||
expect(box[2]).toBe(Math.ceil(lines[1].length * 7) + 8); // 가장 긴 줄 + 여백
|
||||
expect(divider).toEqual([120, 339, box[2], 1]); // 입력 줄 아래 끝 한 줄
|
||||
|
||||
expect(texts.map((text) => text.label)).toEqual(['LIN', ...lines]);
|
||||
texts.forEach((text, row) => {
|
||||
const [x, y] = text.at;
|
||||
expect(x).toBe(124);
|
||||
// 글 중심 = 그 줄 세로 가운데 · 12px 글 전체가 칸 안
|
||||
expect(y).toBe(box[1] + 20 * row + 10);
|
||||
expect(y - 6).toBeGreaterThanOrEqual(box[1]);
|
||||
expect(y + 6).toBeLessThanOrEqual(box[1] + box[3]);
|
||||
});
|
||||
});
|
||||
|
||||
it('빈 입력 · 짧은 글은 최소 폭 150 · 한 줄', () => {
|
||||
const { context, rects } = recordingContext();
|
||||
const controller = new ScreenCanvasDrawController(context);
|
||||
controller.setCanvasSize(new Point(800, 600));
|
||||
controller.setScreenMouseLocation(new Point(100, 300));
|
||||
drawInputField(controller, '', []);
|
||||
expect(rects).toHaveLength(2);
|
||||
expect(rects[1]).toEqual([120, 320, 150, 20]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Point, type Vector } from '@flatten-js/core';
|
||||
import { WHEEL_ZOOM_EXPONENT } 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, getPickableEntities, triggerReactUpdate } from '../state.ts';
|
||||
import { getGridEnabled, triggerReactUpdate } from '../state.ts';
|
||||
import { paintColor, themeColor } from '../theme.ts';
|
||||
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
|
||||
import { fitEntities, fitView } from './fit-view.ts';
|
||||
|
||||
/** 토큰을 못 읽을 때만 쓰는 캔버스 배경 폴백. */
|
||||
const CANVAS_BACKGROUND_FALLBACK = '#111';
|
||||
@@ -31,8 +31,6 @@ const CANVAS_BACKGROUND_FALLBACK = '#111';
|
||||
*
|
||||
* To convert between the 2 coordinate systems, you need the screenOffset and screenScale
|
||||
*/
|
||||
/** M02 가 구조물 도면에 얹는 도각 층 — 서버 `STRUCTURE_FRAME_LAYER` 와 같음 */
|
||||
const STRUCTURE_FRAME_LAYER_ID = 'm02-structure-frame';
|
||||
// Batch-mode stroke decimation: skip chain segments shorter than this (screen px)
|
||||
const BATCH_LOD_PX = 0.5;
|
||||
// Batch-mode text smaller than this (screen px) is unreadable — skip drawing it
|
||||
@@ -150,17 +148,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
* 월드 좌표 offset에 그대로 대입해 중심 배치가 어긋나는 문제가 있었다.)
|
||||
*/
|
||||
public zoomToFitScreen() {
|
||||
// 도각·원지반 같은 잠금 도면층은 빼고 설계 콘텐츠에만 맞춘다 (2026-08-31 사용자
|
||||
// 확정). 도각까지 넣으면 A1 한 장 전체가 잡혀 화면의 40%가 여백으로 간다.
|
||||
// 구조물 도면(M02·B08)은 얹은 도각(작도 틀)이 있으면 도각 전체에 맞춘다 — 옛 DXF 가 자기 도곽·
|
||||
// 표제 띠를 품어 범위가 크면 도각이 화면 밖으로 밀린다.
|
||||
const structureFrame = getEntities().filter((e) => e.layerId === STRUCTURE_FRAME_LAYER_ID);
|
||||
const pickable = getPickableEntities();
|
||||
const entities = structureFrame.length
|
||||
? structureFrame
|
||||
: pickable.length
|
||||
? pickable
|
||||
: getEntities();
|
||||
const entities = fitEntities();
|
||||
if (!entities.length) {
|
||||
// 빈 도면 — 원점(0,0)을 화면 가운데로. 안 옮기면 기준점 십자가 왼쪽 아래 귀퉁이에 잘린다.
|
||||
const canvasSize = this.getCanvasSize();
|
||||
@@ -169,31 +157,9 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
this.viewFitted = true;
|
||||
return;
|
||||
}
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
|
||||
const boundingWidth = boundingBox.maxX - boundingBox.minX;
|
||||
const boundingHeight = boundingBox.maxY - boundingBox.minY;
|
||||
const canvasSize = this.getCanvasSize();
|
||||
|
||||
// 10% 여백을 남기고 두 축 중 더 빡빡한 쪽에 맞춘다 (종횡비 유지)
|
||||
const FIT_MARGIN = 0.9;
|
||||
const scaleX =
|
||||
boundingWidth > 0 ? (canvasSize.x * FIT_MARGIN) / boundingWidth : Number.POSITIVE_INFINITY;
|
||||
const scaleY =
|
||||
boundingHeight > 0 ? (canvasSize.y * FIT_MARGIN) / boundingHeight : Number.POSITIVE_INFINITY;
|
||||
let zoomLevel = Math.min(scaleX, scaleY);
|
||||
if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) zoomLevel = 1;
|
||||
this.setScreenScale(zoomLevel);
|
||||
|
||||
// screen = (world - offset) * zoom 이므로, 도면 중심을 화면 중심에 맞추려면
|
||||
// offset = worldCenter - (화면 절반 픽셀) / zoom
|
||||
const worldCenterX = (boundingBox.minX + boundingBox.maxX) / 2;
|
||||
const worldCenterY = (boundingBox.minY + boundingBox.maxY) / 2;
|
||||
this.setScreenOffset(
|
||||
new Point(
|
||||
worldCenterX - canvasSize.x / 2 / zoomLevel,
|
||||
worldCenterY - canvasSize.y / 2 / zoomLevel
|
||||
)
|
||||
);
|
||||
const { zoom, offset } = fitView(entities, this.getCanvasSize());
|
||||
this.setScreenScale(zoom);
|
||||
this.setScreenOffset(offset);
|
||||
this.viewFitted = true;
|
||||
}
|
||||
|
||||
@@ -559,6 +525,15 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
});
|
||||
}
|
||||
|
||||
/** 화면 글 폭(px) — drawTextScreen 과 같은 글꼴로 잼 */
|
||||
public measureTextScreen(label: string, fontSize: number): number {
|
||||
this.context.save();
|
||||
this.context.font = `${fontSize}px ${DEFAULT_TEXT_OPTIONS.fontFamily}`;
|
||||
const width = this.context.measureText(label).width;
|
||||
this.context.restore();
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -577,6 +552,8 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
fontFamily: string;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
/** 색을 그대로 칠함 — 자동색 뒤집기(paintColor) 건너뜀(테마 토큰 색) */
|
||||
rawColor: boolean;
|
||||
}> = {}
|
||||
): void {
|
||||
const opts = {
|
||||
@@ -596,7 +573,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
this.context.rotate(angle);
|
||||
this.context.font = `${opts.italic ? 'italic ' : ''}${opts.bold ? 'bold ' : ''}${opts.fontSize}px ${opts.fontFamily}`;
|
||||
this.context.textAlign = opts.textAlign;
|
||||
this.context.fillStyle = paintColor(opts.textColor);
|
||||
this.context.fillStyle = opts.rawColor ? opts.textColor : paintColor(opts.textColor);
|
||||
this.context.textBaseline = 'middle';
|
||||
this.context.fillText(label, 0, 0);
|
||||
this.context.restore();
|
||||
@@ -673,11 +650,19 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
* @param width
|
||||
* @param height
|
||||
* @param color
|
||||
* @param rawColor 색을 그대로 칠함 — 자동색 뒤집기(paintColor) 건너뜀(테마 토큰 색)
|
||||
*/
|
||||
public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {
|
||||
public fillRectScreen(
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: string,
|
||||
rawColor = false
|
||||
) {
|
||||
if (this.batching) this.flushBatch();
|
||||
// TODO see if we need to replace this with a call to fillPolygon
|
||||
this.context.fillStyle = paintColor(color);
|
||||
this.context.fillStyle = rawColor ? color : paintColor(color);
|
||||
// 화면 좌표는 아래에서 위로 재는 규약이다(getCanvasPoint·drawLineScreen과 같다).
|
||||
// yMin은 사각형의 아래 변이므로 캔버스 y로 옮길 때 높이를 함께 빼야 제자리다.
|
||||
// 안 빼서 선택 그립이 선보다 8px 아래에 찍혔고, 줌을 해도 화면 픽셀 어긋남이라
|
||||
|
||||
@@ -2,13 +2,6 @@ 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_FONT_SIZE,
|
||||
CANVAS_INPUT_FIELD_HEIGHT,
|
||||
CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR,
|
||||
CANVAS_INPUT_FIELD_MOUSE_OFFSET,
|
||||
CANVAS_INPUT_FIELD_TEXT_COLOR,
|
||||
CANVAS_INPUT_FIELD_WIDTH,
|
||||
PINCH_ZOOM_EXPONENT,
|
||||
SNAP_POINT_DISTANCE,
|
||||
WHEEL_LINE_PX,
|
||||
@@ -66,6 +59,7 @@ import {
|
||||
type TextInputEvent,
|
||||
} from '../tools/tool.types.ts';
|
||||
import { activeToolAcceptsText, ImeSink, isImeKey } from './ime-sink.ts';
|
||||
import { drawInputField } from './input-field-draw.ts';
|
||||
import {
|
||||
ABSOLUTE_POINT_REGEXP,
|
||||
ABSOLUTE_POLAR_REGEXP,
|
||||
@@ -127,36 +121,7 @@ export class InputController {
|
||||
}
|
||||
|
||||
public draw(drawController: ScreenCanvasDrawController) {
|
||||
const screenMouseLocation = drawController.getScreenMouseLocation();
|
||||
|
||||
// draw input field
|
||||
drawController.fillRectScreen(
|
||||
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET,
|
||||
// fillRectScreen이 아래 변 기준으로 바로잡혔으므로, 칸이 있던 자리를 지키려고
|
||||
// 높이만큼 내려 잡는다.
|
||||
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT,
|
||||
CANVAS_INPUT_FIELD_WIDTH,
|
||||
CANVAS_INPUT_FIELD_HEIGHT,
|
||||
CANVAS_INPUT_FIELD_BACKGROUND_COLOR
|
||||
);
|
||||
this.ime.sync(activeToolAcceptsText());
|
||||
// Draw text in input field (조합 중인 한글도 함께)
|
||||
if (this.text || this.ime.composing) {
|
||||
drawController.drawTextScreen(
|
||||
this.text + this.ime.composing,
|
||||
new Point(
|
||||
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2,
|
||||
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2
|
||||
),
|
||||
{
|
||||
textAlign: 'left',
|
||||
textColor: CANVAS_INPUT_FIELD_TEXT_COLOR,
|
||||
fontSize: CANVAS_INPUT_FIELD_FONT_SIZE,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const matchingCommands = this.getCommandSuggestions();
|
||||
const toolInstruction = getLastStateInstructions();
|
||||
const texts: string[] = [];
|
||||
if (toolInstruction) {
|
||||
@@ -166,11 +131,10 @@ export class InputController {
|
||||
const roundedY = round(drawController.getWorldMouseLocation().y, 2);
|
||||
texts.push(`${roundedX},${roundedY}`);
|
||||
}
|
||||
if (matchingCommands.length) {
|
||||
// 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO)
|
||||
texts.push(...matchingCommands);
|
||||
}
|
||||
this.drawListBelowInputField(drawController, texts);
|
||||
// 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO)
|
||||
texts.push(...this.getCommandSuggestions());
|
||||
// 입력 글(조합 중인 한글도 함께)
|
||||
drawInputField(drawController, this.text + this.ime.composing, texts);
|
||||
}
|
||||
|
||||
public submitText(value: string) {
|
||||
@@ -661,32 +625,6 @@ export class InputController {
|
||||
type: ActorEvent.ESC,
|
||||
});
|
||||
}
|
||||
|
||||
private drawListBelowInputField(
|
||||
drawController: ScreenCanvasDrawController,
|
||||
texts: string[]
|
||||
): void {
|
||||
const screenMouseLocation = drawController.worldToTarget(
|
||||
drawController.getWorldMouseLocation()
|
||||
);
|
||||
const startY =
|
||||
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT * 2 - 2;
|
||||
const offsetY = CANVAS_INPUT_FIELD_HEIGHT;
|
||||
texts.forEach((text, index) => {
|
||||
drawController.drawTextScreen(
|
||||
text,
|
||||
new Point(
|
||||
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2,
|
||||
startY - index * offsetY
|
||||
),
|
||||
{
|
||||
textAlign: 'left',
|
||||
textColor: CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR,
|
||||
fontSize: CANVAS_INPUT_FIELD_FONT_SIZE,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 커서 따라다니는 입력창 — 캔버스에 직접 그림(`input-controller.ts` draw 가 부름).
|
||||
* 칸 · 글은 테마 토큰 색을 그대로 칠함(자동색 뒤집기 건너뜀) — 명령줄 후보 상자(`App.css`
|
||||
* `.cad-command-suggestions`)와 같은 색.
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
import {
|
||||
CANVAS_INPUT_FIELD_BACKGROUND_COLOR,
|
||||
CANVAS_INPUT_FIELD_BORDER_COLOR,
|
||||
CANVAS_INPUT_FIELD_FONT_SIZE,
|
||||
CANVAS_INPUT_FIELD_HEIGHT,
|
||||
CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR,
|
||||
CANVAS_INPUT_FIELD_MOUSE_OFFSET,
|
||||
CANVAS_INPUT_FIELD_TEXT_COLOR,
|
||||
CANVAS_INPUT_FIELD_WIDTH,
|
||||
} from '../App.consts.ts';
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
|
||||
import { themeColor } from '../theme.ts';
|
||||
|
||||
/** 입력창 색 — 밝음 = 흰 칸 + 테두리 · 어두움 = 짙은 보라 칸 · 글 = CAD 글 색 */
|
||||
export function inputFieldColors(read: (name: string, fallback: string) => string = themeColor) {
|
||||
return {
|
||||
background: read('--cad-chrome-raised', CANVAS_INPUT_FIELD_BACKGROUND_COLOR),
|
||||
border: read('--cad-line', CANVAS_INPUT_FIELD_BORDER_COLOR),
|
||||
text: read('--cad-text', CANVAS_INPUT_FIELD_TEXT_COLOR),
|
||||
dimText: read('--cad-text-dim', CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR),
|
||||
};
|
||||
}
|
||||
|
||||
/** 글 좌우 여백(px) */
|
||||
const INPUT_FIELD_PADDING = 4;
|
||||
|
||||
/**
|
||||
* 입력 글 칸 + 그 아래 안내 · 좌표 · 명령 후보 줄 — 바탕 한 장(명령줄 후보 상자 꼴).
|
||||
* 줄마다 높이 CANVAS_INPUT_FIELD_HEIGHT · 글 중심 = 줄 세로 가운데 · 폭 = max(최소 폭, 가장 긴 줄 + 여백).
|
||||
*/
|
||||
export function drawInputField(
|
||||
drawController: ScreenCanvasDrawController,
|
||||
input: string,
|
||||
lines: string[]
|
||||
): void {
|
||||
const mouse = drawController.getScreenMouseLocation();
|
||||
const colors = inputFieldColors();
|
||||
const rowHeight = CANVAS_INPUT_FIELD_HEIGHT;
|
||||
const fontSize = CANVAS_INPUT_FIELD_FONT_SIZE;
|
||||
const left = mouse.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET;
|
||||
// 화면 좌표는 위가 + — 칸 위 변이 커서 아래 OFFSET · fillRectScreen 의 y 는 아래 변
|
||||
const top = mouse.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET;
|
||||
const height = rowHeight * (1 + lines.length);
|
||||
const textWidth = Math.max(
|
||||
0,
|
||||
...[input, ...lines].map((text) => drawController.measureTextScreen(text, fontSize))
|
||||
);
|
||||
const width = Math.max(CANVAS_INPUT_FIELD_WIDTH, Math.ceil(textWidth) + INPUT_FIELD_PADDING * 2);
|
||||
drawController.fillRectScreen(
|
||||
left - 1,
|
||||
top - height - 1,
|
||||
width + 2,
|
||||
height + 2,
|
||||
colors.border,
|
||||
true
|
||||
);
|
||||
drawController.fillRectScreen(left, top - height, width, height, colors.background, true);
|
||||
if (lines.length) {
|
||||
// 입력 줄과 목록 사이 가는 줄
|
||||
drawController.fillRectScreen(left, top - rowHeight, width, 1, colors.border, true);
|
||||
}
|
||||
const textOptions = { textAlign: 'left' as const, fontSize, rawColor: true };
|
||||
const rowCenter = (row: number) => top - rowHeight * row - rowHeight / 2;
|
||||
if (input) {
|
||||
drawController.drawTextScreen(input, new Point(left + INPUT_FIELD_PADDING, rowCenter(0)), {
|
||||
...textOptions,
|
||||
textColor: colors.text,
|
||||
});
|
||||
}
|
||||
lines.forEach((line, index) => {
|
||||
drawController.drawTextScreen(
|
||||
line,
|
||||
new Point(left + INPUT_FIELD_PADDING, rowCenter(index + 1)),
|
||||
{
|
||||
...textOptions,
|
||||
textColor: colors.dimText,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -37,4 +37,47 @@ assert.equal(paintColor('#f5f7fa'), '#000000', '밝은 바탕: 기본색은 검
|
||||
assert.equal(paintColor('#ff0000'), '#ff0000', '밝은 바탕: 빨강 그대로');
|
||||
assert.notEqual(paintColor('#ffff00'), '#ffff00', '밝은 바탕: 노랑 등 밝은 색은 대비 보정(기존)');
|
||||
|
||||
// 커서 입력창 — 칸 · 글 색이 두 테마 모두 달라야(자동색 둘을 쓰면 paintColor 가 같은 색으로 뒤집음)
|
||||
const { inputFieldColors } = await import(
|
||||
'../../../B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts'
|
||||
);
|
||||
const TOKENS: Record<string, Record<string, string>> = {
|
||||
light: {
|
||||
'--cad-chrome-raised': '#ffffff',
|
||||
'--cad-line': '#e6e2e3',
|
||||
'--cad-text': '#333333',
|
||||
'--cad-text-dim': '#615e6e',
|
||||
},
|
||||
dark: {
|
||||
'--cad-chrome-raised': '#251f38',
|
||||
'--cad-line': '#322a45',
|
||||
'--cad-text': '#e4e0f0',
|
||||
'--cad-text-dim': '#b3aec6',
|
||||
},
|
||||
};
|
||||
for (const name of ['light', 'dark']) {
|
||||
theme = name;
|
||||
const colors = inputFieldColors(
|
||||
(token: string, fallback: string) => TOKENS[name][token] ?? fallback
|
||||
);
|
||||
assert.equal(
|
||||
colors.background,
|
||||
TOKENS[name]['--cad-chrome-raised'],
|
||||
`${name}: 칸 = 토큰 색 그대로`
|
||||
);
|
||||
assert.notEqual(colors.background, colors.text, `${name}: 칸 · 글 색 다름`);
|
||||
assert.notEqual(colors.background, colors.dimText, `${name}: 칸 · 안내 글 색 다름`);
|
||||
assert.notEqual(colors.background, colors.border, `${name}: 칸 · 테두리 색 다름`);
|
||||
// 옛 상수 짝(#000000 칸 · #FFF 글)은 paintColor 를 거치면 같은 색 — 입력창은 건너뛰어야
|
||||
assert.equal(
|
||||
paintColor('#000000'),
|
||||
paintColor('#FFF'),
|
||||
`${name}: 자동색 두 개는 같은 색으로 뒤집힘`
|
||||
);
|
||||
}
|
||||
// 토큰 못 읽으면 폴백도 칸 · 글 다름
|
||||
const fallback = inputFieldColors((_token: string, value: string) => value);
|
||||
assert.notEqual(fallback.background, fallback.text, '폴백: 칸 · 글 색 다름');
|
||||
assert.ok(!isAutoColor(fallback.background) && !isAutoColor(fallback.text), '폴백: 자동색 아님');
|
||||
|
||||
console.log('theme 자동색 시험 통과');
|
||||
|
||||
Reference in New Issue
Block a user