Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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<ienaga@next2d.app> (https://github.com/ienaga/)",
"license": "MIT",
Expand Down
Original file line number Diff line number Diff line change
@@ -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>): 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");
});
});
});
155 changes: 130 additions & 25 deletions packages/text/src/TextArea/service/TextAreaMovePositionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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`;
};
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`;
}
};
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"");
Expand Down