fix(B07-CAD): 커서 입력창 칸 · 글 색이 테마를 따름 — 자동색 뒤집기 건너뜀(97-1)

- 칸 = --cad-chrome-raised(밝음 흰 칸 + --cad-line 테두리 · 어두움 #251f38) · 글 = --cad-text · 안내 = --cad-text-dim
- fillRectScreen · drawTextScreen 에 paintColor 건너뛰는 rawColor 선택 인자
- 입력창 그리기를 inputController/input-field-draw.ts 로 · 전체 맞춤 계산을 drawControllers/fit-view.ts 로(700줄)
- App.consts 입력창 색 상수 = 토큰 폴백 · 틀린 주석 고침
- 시험 theme-auto-color: 칸 · 글 색이 두 테마 모두 다름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-08 13:50:07 +09:00
co-authored by Claude Opus 5.5
parent 418c16170f
commit 9f0ab53c53
6 changed files with 205 additions and 116 deletions
+12 -6
View File
@@ -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
),
};
}
@@ -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;
}
@@ -577,6 +543,8 @@ export class ScreenCanvasDrawController implements DrawController {
fontFamily: string;
bold: boolean;
italic: boolean;
/** 색을 그대로 칠함 — 자동색 뒤집기(paintColor) 건너뜀(테마 토큰 색) */
rawColor: boolean;
}> = {}
): void {
const opts = {
@@ -596,7 +564,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 +641,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,79 @@
/**
* 커서 따라다니는 입력창 — 캔버스에 직접 그림(`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),
};
}
/** 입력 글 칸 + 그 아래 안내 · 좌표 · 명령 후보 줄 */
export function drawInputField(
drawController: ScreenCanvasDrawController,
input: string,
lines: string[]
): void {
const mouse = drawController.getScreenMouseLocation();
const colors = inputFieldColors();
const left = mouse.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET;
// 화면 좌표는 위가 + — fillRectScreen 의 y 는 칸 아래 변
const bottom = mouse.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT;
const width = CANVAS_INPUT_FIELD_WIDTH;
drawController.fillRectScreen(
left - 1,
bottom - 1,
width + 2,
CANVAS_INPUT_FIELD_HEIGHT + 2,
colors.border,
true
);
drawController.fillRectScreen(
left,
bottom,
width,
CANVAS_INPUT_FIELD_HEIGHT,
colors.background,
true
);
const textOptions = {
textAlign: 'left' as const,
fontSize: CANVAS_INPUT_FIELD_FONT_SIZE,
rawColor: true,
};
if (input) {
drawController.drawTextScreen(input, new Point(left + 2, bottom - 2), {
...textOptions,
textColor: colors.text,
});
}
lines.forEach((line, index) => {
drawController.drawTextScreen(
line,
new Point(
left + 2,
bottom - CANVAS_INPUT_FIELD_HEIGHT - 2 - index * CANVAS_INPUT_FIELD_HEIGHT
),
{ ...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 자동색 시험 통과');