diff --git a/B07_DesignDetail/openwebcad/src/App.consts.ts b/B07_DesignDetail/openwebcad/src/App.consts.ts index 63613edbf..a27ef6276 100644 --- a/B07_DesignDetail/openwebcad/src/App.consts.ts +++ b/B07_DesignDetail/openwebcad/src/App.consts.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/fit-view.ts b/B07_DesignDetail/openwebcad/src/drawControllers/fit-view.ts new file mode 100644 index 000000000..bdfe2e259 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/drawControllers/fit-view.ts @@ -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 + ), + }; +} diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.test.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.test.ts index e1e6e1fbe..4e1418c52 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.test.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.test.ts @@ -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; + 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]); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 0388acdb3..d48309e2e 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -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 아래에 찍혔고, 줌을 해도 화면 픽셀 어긋남이라 diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index acec2ed3e..8e8a27c61 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -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, - } - ); - }); - } } /** diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts b/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts new file mode 100644 index 000000000..ab2d70848 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts @@ -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, + } + ); + }); +} diff --git a/resources/tester/cad/theme-auto-color.test.ts b/resources/tester/cad/theme-auto-color.test.ts index a10e809f5..34422b7ca 100644 --- a/resources/tester/cad/theme-auto-color.test.ts +++ b/resources/tester/cad/theme-auto-color.test.ts @@ -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> = { + 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 자동색 시험 통과');