diff --git a/package-lock.json b/package-lock.json index e0836596..9255bcff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@next2d/player", - "version": "3.8.0", + "version": "3.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next2d/player", - "version": "3.8.0", + "version": "3.8.1", "license": "MIT", "workspaces": [ "packages/*" @@ -3276,9 +3276,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.13", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", - "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 02167e97..ff47c482 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@next2d/player", - "version": "3.8.0", + "version": "3.8.1", "description": "Experience the fast and beautiful anti-aliased rendering of WebGL/WebGPU. You can create rich, interactive graphics, cross-platform applications and games without worrying about browser or device compatibility.", "author": "Toshiyuki Ienaga (https://github.com/ienaga/)", "license": "MIT", diff --git a/packages/text/src/TextArea/service/TextAreaMovePositionService.test.ts b/packages/text/src/TextArea/service/TextAreaMovePositionService.test.ts index fd07d427..4334cbec 100644 --- a/packages/text/src/TextArea/service/TextAreaMovePositionService.test.ts +++ b/packages/text/src/TextArea/service/TextAreaMovePositionService.test.ts @@ -1,31 +1,87 @@ import type { TextField } from "../../TextField"; import { execute } from "./TextAreaMovePositionService"; import { describe, expect, it } from "vitest"; -import { - $textArea, -} from "../../TextUtil"; +import { $textArea } from "../../TextUtil"; + +/** + * テスト環境では stage.rendererScale = 1 / rendererWidth = stageWidth = 0 / + * $mainCanvasPosition = (0, 0) / devicePixelRatio = 1 のため、 + * localToGlobal を恒等変換にすると textarea の left/top はカーソルのローカル座標と一致する。 + */ +const createTextField = (override: Partial): TextField => +{ + return { + "localToGlobal": (point: { x: number; y: number }) => ({ "x": point.x, "y": point.y }), + "scrollX": 0, + "scrollY": 0, + ...override + } as unknown as TextField; +}; describe("TextAreaMovePositionService.js Test", () => { - it("test case", () => + it("カーソルが行の途中にある場合、カーソルのx位置・行の上端(真裏)に配置され、高さがテキストサイズに合う", () => { - // Arrange - const textField = { - localToGlobal: () => ({ x: 20, y: 30 }), - $textData: { - textTable: [ - { line: 0, mode: "break", w: 10 }, - { line: 1, mode: "normal", w: 20 }, + // 1行目「あい」、focusIndex=2(「い」の前)、フォントサイズ20 + const textField = createTextField({ + "focusIndex": 2, + "$textData": { + "textTable": [ + { "line": 0, "mode": "break", "w": 0 }, + { "line": 0, "mode": "text", "w": 24, "textFormat": { "size": 20 } }, + { "line": 0, "mode": "text", "w": 24, "textFormat": { "size": 20 } } ], - heightTable: [10, 20], + "heightTable": [24] + } + } as unknown as TextField); + + execute(textField); + + // x = 「あ」の幅(24)、y = 1行目の上端(0) + expect($textArea.style.left).toBe("24px"); + expect($textArea.style.top).toBe("0px"); + // 高さ = 行高(24)、font-size = テキストサイズ(20)(テスト環境では scale=1) + expect($textArea.style.height).toBe("24px"); + expect($textArea.style.fontSize).toBe("20px"); + }); + + it("カーソルが文末(textTable[focusIndex]が未定義)にある場合でも、最後の文字の行・末尾に配置される", () => + { + // 「あいうえお\nかきくけこ」、focusIndex=12(文末) + const textTable = [{ "line": 0, "mode": "break", "w": 0 }]; + for (let idx = 0; idx < 5; idx++) { + textTable.push({ "line": 0, "mode": "text", "w": 24 }); + } + textTable.push({ "line": 1, "mode": "break", "w": 0 }); + for (let idx = 0; idx < 5; idx++) { + textTable.push({ "line": 1, "mode": "text", "w": 24 }); + } + + const textField = createTextField({ + "focusIndex": textTable.length, // 12 = 文末 + "$textData": { + textTable, + "heightTable": [24, 24] } - } as unknown as TextField; + } as unknown as TextField); + + execute(textField); + + // x = 2行目「かきくけこ」の幅(120)、y = 2行目の上端(24) + expect($textArea.style.left).toBe("120px"); + expect($textArea.style.top).toBe("24px"); + }); + + it("$textData が無い場合は原点(0,0)に配置される", () => + { + const textField = createTextField({ + "focusIndex": 1, + "$textData": null + } as unknown as TextField); - // Act execute(textField); - // Assert - expect($textArea.style.left).toBe("20px"); - expect($textArea.style.top).toBe("30px"); + expect($textArea.style.left).toBe("0px"); + expect($textArea.style.top).toBe("0px"); }); -}); \ No newline at end of file +}); diff --git a/packages/text/src/TextArea/service/TextAreaMovePositionService.ts b/packages/text/src/TextArea/service/TextAreaMovePositionService.ts index 6f0e985c..8dce7846 100644 --- a/packages/text/src/TextArea/service/TextAreaMovePositionService.ts +++ b/packages/text/src/TextArea/service/TextAreaMovePositionService.ts @@ -12,9 +12,107 @@ import { */ const $devicePixelRatio: number = window.devicePixelRatio; +/** + * @description フォーカスしているテキストのカーソル位置(ローカル座標)を算出します。 + * 入力中の文字の「真裏」に input(textarea) を配置するため、カーソルのある行の + * 上端を基準に x/y を返却します。これにより IME の変換文字が canvas 側の文字に + * ちょうど重なり、他の文字に被らないようにします。カーソルが文末にある場合 + * (textTable[focusIndex] が存在しない場合)も直前の文字から行と x を求めます。 + * Calculate the caret position (local coordinates) of the focused text. + * Returns x/y based on the top of the caret line so that the input(textarea) + * is placed directly behind the character being typed, making the IME + * composition overlap the canvas character (and not other characters). + * Handles the end-of-text case (textTable[focusIndex] is undefined) by using + * the previous character. + * + * あわせて、input(textarea) の高さ・フォントサイズを canvas のテキストサイズへ + * 合わせるため、カーソル行の行高(lineHeight)とフォントサイズ(fontSize)も返却します。 + * Also returns the caret line height and font size so that the input(textarea) + * height/font-size can match the canvas text size. + * + * @param {TextField} text_field + * @return {{ x: number, y: number, lineHeight: number, fontSize: number }} + * @method + * @private + */ +const calcCaretLocalPosition = ( + text_field: TextField +): { x: number; y: number; lineHeight: number; fontSize: number } => +{ + const textData = text_field.$textData; + if (!textData) { + return { "x": 0, "y": 0, "lineHeight": 0, "fontSize": 0 }; + } + + const textTable = textData.textTable; + + // カーソル行の決定 + let caretLine = 0; + const focusIndex = text_field.focusIndex; + const focusTextObject = textTable[focusIndex]; + if (focusTextObject) { + // 改行・折り返しの直前にカーソルがある場合は前の行の末尾を指す + caretLine = focusTextObject.mode === "break" || focusTextObject.mode === "wrap" + ? focusTextObject.line - 1 + : focusTextObject.line; + } else { + // 文末(textTable[focusIndex] が存在しない)は直前の文字の行を採用 + const lastTextObject = textTable[textTable.length - 1]; + caretLine = lastTextObject ? lastTextObject.line : 0; + } + + if (caretLine < 0) { + caretLine = 0; + } + + // カーソル行で、カーソルより前にある文字幅の合計(= カーソルの x 位置) + let x = 0; + for (let idx = 1; idx < textTable.length; ++idx) { + if (idx >= focusIndex) { + break; + } + const textObject = textTable[idx]; + if (!textObject || textObject.mode !== "text" || textObject.line !== caretLine) { + continue; + } + x += textObject.w; + } + + // カーソル行の上端(= 入力文字の真裏) + let y = 0; + for (let idx = 0; idx < caretLine; ++idx) { + y += textData.heightTable[idx] || 0; + } + + // カーソル行の行高(= input の高さに使用) + const lineHeight = textData.heightTable[caretLine] || 0; + + // カーソル行のフォントサイズ(= input の font-size に使用)。 + // カーソル位置までの最後の文字サイズを優先し、無ければ defaultTextFormat を採用する。 + let fontSize = text_field.defaultTextFormat?.size || 0; + for (let idx = 1; idx < textTable.length; ++idx) { + const textObject = textTable[idx]; + if (!textObject || textObject.mode !== "text" || textObject.line !== caretLine) { + continue; + } + if (textObject.textFormat?.size) { + fontSize = textObject.textFormat.size; + } + if (idx >= focusIndex) { + break; + } + } + + return { x, y, lineHeight, fontSize }; +}; + /** * @description フォーカスしているテキストの位置にテキストエリアを移動します。 + * 入力中の文字の真裏に input(textarea) を配置し、高さ・フォントサイズを + * canvas のテキストサイズに合わせます。 * Move the text area to the position of the text that is focusing. + * The input(textarea) is placed directly behind the character being typed, + * and its height/font-size are matched to the canvas text size. * * @param {TextField} text_field * @return {void} @@ -23,32 +121,39 @@ const $devicePixelRatio: number = window.devicePixelRatio; */ export const execute = (text_field: TextField): void => { - const point = text_field.localToGlobal(new Point()); + const caret = calcCaretLocalPosition(text_field); - const textData = text_field.$textData; - if (textData) { - - const focusTextObject = textData.textTable[text_field.focusIndex]; - if (focusTextObject) { - for (let idx = text_field.focusIndex - 1; idx > -1; --idx) { - const textObject = textData.textTable[idx]; - if (!textObject || textObject.line !== focusTextObject.line) { - break; - } - point.x += textObject.w; - } - - const line = focusTextObject.mode === "break" - ? focusTextObject.line - 1 - : focusTextObject.line; - - for (let idx = 0; idx < line; ++idx) { - point.y += textData.heightTable[idx]; - } - } + // スクロール量を反映して、表示されているカーソル位置に補正する + let localX = caret.x; + if (text_field.scrollX > 0) { + const width = text_field.width; + localX -= text_field.scrollX * (text_field.textWidth - width) / width; + } + + let localY = caret.y; + if (text_field.scrollY > 0) { + const height = text_field.height; + localY -= text_field.scrollY * (text_field.textHeight - height) / height; } + // ローカル座標をステージ座標へ変換(TextField のスケール・回転・配置を考慮) + const point = text_field.localToGlobal(new Point(localX, localY)); + + // ステージ座標 → 画面(CSS)座標へ変換。 + // PlayerSetCurrentMousePointService の逆変換に対応する。 const scale = stage.rendererScale / $devicePixelRatio; - $textArea.style.left = `${$mainCanvasPosition.x + point.x * scale}px`; - $textArea.style.top = `${$mainCanvasPosition.y + point.y * scale}px`; -}; \ No newline at end of file + const tx = (stage.rendererWidth - stage.stageWidth * stage.rendererScale) / 2; + const ty = (stage.rendererHeight - stage.stageHeight * stage.rendererScale) / 2; + + $textArea.style.left = `${$mainCanvasPosition.x + tx / $devicePixelRatio + point.x * scale}px`; + $textArea.style.top = `${$mainCanvasPosition.y + ty / $devicePixelRatio + point.y * scale}px`; + + // canvas のテキストサイズに合わせて input の高さ・フォントサイズを設定する + const lineHeight = caret.lineHeight; + if (lineHeight > 0) { + const fontSize = caret.fontSize || lineHeight; + $textArea.style.height = `${lineHeight * scale}px`; + $textArea.style.fontSize = `${fontSize * scale}px`; + $textArea.style.lineHeight = `${lineHeight * scale}px`; + } +}; diff --git a/src/index.ts b/src/index.ts index 0bf27f51..bb6b72fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { Next2D } from "@next2d/core"; if (!("next2d" in window)) { - console.log("%c Next2D Player %c 3.8.0 %c https://next2d.app", + console.log("%c Next2D Player %c 3.8.1 %c https://next2d.app", "color: #fff; background: #5f5f5f", "color: #fff; background: #4bc729", "");