diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index c4f1e81c5e4..3b34011e098 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -19,7 +19,7 @@ jobs: strategy: matrix: - node-version: [16.x] + node-version: [22.x] steps: - uses: actions/checkout@v2 diff --git a/ace-internal.d.ts b/ace-internal.d.ts index 7380d3c1833..85cefb7d05e 100644 --- a/ace-internal.d.ts +++ b/ace-internal.d.ts @@ -85,6 +85,16 @@ export namespace Ace { offset: number, height: number, gutterOffset: number + fontMetrics: { + textWidth: (row: number, column: number) => number, + getRects: (start: Position, end: Position) => Rect[] + } + } + interface Rect { + left: number, + top: number, + width: number, + height: number, } interface HardWrapOptions { @@ -1449,6 +1459,7 @@ declare module "./src/edit_session" { _changedWidgets?: any, $options: any, $wrapMethod?: any, + $fontMetrics?: FontMetrics|null, $enableVarChar?: any, $wrap?: any, $navigateWithinSoftTabs?: boolean, diff --git a/ace.d.ts b/ace.d.ts index e42bef67a26..592fe419efe 100644 --- a/ace.d.ts +++ b/ace.d.ts @@ -84,6 +84,16 @@ declare module "ace-code" { offset: number; height: number; gutterOffset: number; + fontMetrics: { + textWidth: (row: number, column: number) => number; + getRects: (start: Position, end: Position) => Rect[]; + }; + } + interface Rect { + left: number; + top: number; + width: number; + height: number; } interface HardWrapOptions { /** First row of the range to process */ diff --git a/demo/kitchen-sink/demo.js b/demo/kitchen-sink/demo.js index b71c7a5adb5..1cd8cd04c8e 100644 --- a/demo/kitchen-sink/demo.js +++ b/demo/kitchen-sink/demo.js @@ -673,6 +673,16 @@ optionsPanelContainer.insertBefore( "Open Dialog ", ["button", {onclick: openTestDialog.bind(null, false)}, "Scale"], ["button", {onclick: openTestDialog.bind(null, true)}, "Height"] + ], + ["div", {}, + ["button", {onclick: function() { + editor.setOption("fontFamily", "cursive"); + session.setValue( session.getValue() + "שלום עולם בעברית123" +"\n" + "ジャパン + 八洲\n" + "𒐫𒈙⸻ဪ", 1); + }}, "cursive"], + ["button", {onclick: function() { + editor.setOption("fontFamily", "Tahoma"); + session.setValue( session.getValue() + "שלום עולם בעברית123" +"\n" + "ジャパン + 八洲", 1); + }}, "Tahoma"], ] ]), optionsPanelContainer.children[1] diff --git a/demo/kitchen-sink/inline_editor.js b/demo/kitchen-sink/inline_editor.js index ec1b6d9bfab..40e82b53b3f 100644 --- a/demo/kitchen-sink/inline_editor.js +++ b/demo/kitchen-sink/inline_editor.js @@ -21,7 +21,7 @@ require("ace/commands/default_commands").commands.push({ return; } - var rowCount = 10; + var rowCount = 5.5; var w = { row: row, // rowCount: rowCount, diff --git a/experiments/bidi.html b/experiments/bidi.html new file mode 100644 index 00000000000..f299ebc9bb9 --- /dev/null +++ b/experiments/bidi.html @@ -0,0 +1,146 @@ + + + + + + + + + + + diff --git a/experiments/transform.html b/experiments/transform.html new file mode 100644 index 00000000000..6f53f791074 --- /dev/null +++ b/experiments/transform.html @@ -0,0 +1,441 @@ + + + + + \ No newline at end of file diff --git a/src/bidihandler.js b/src/bidihandler.js index 20f023613e2..16ce32f334b 100644 --- a/src/bidihandler.js +++ b/src/bidihandler.js @@ -51,12 +51,7 @@ class BidiHandler { isBidiRow(screenRow, docRow, splitIndex) { if (!this.seenBidi) return false; - if (screenRow !== this.currentRow) { - this.currentRow = screenRow; - this.updateRowLine(docRow, splitIndex); - this.updateBidiMap(); - } - return this.bidiMap.bidiLevels; + return true; } /** @@ -107,65 +102,6 @@ class BidiHandler { return splitIndex; } - updateRowLine(docRow, splitIndex) { - if (docRow === undefined) - docRow = this.getDocumentRow(); - - var isLastRow = (docRow === this.session.getLength() - 1), - endOfLine = isLastRow ? this.EOF : this.EOL; - - this.wrapIndent = 0; - this.line = this.session.getLine(docRow); - this.isRtlDir = this.$isRtl || this.line.charAt(0) === this.RLE; - if (this.session.$useWrapMode) { - var splits = this.session.$wrapData[docRow]; - if (splits) { - if (splitIndex === undefined) - splitIndex = this.getSplitIndex(); - - if(splitIndex > 0 && splits.length) { - this.wrapIndent = splits.indent; - this.wrapOffset = this.wrapIndent * this.charWidths[bidiUtil.L]; - this.line = (splitIndex < splits.length) ? - this.line.substring(splits[splitIndex - 1], splits[splitIndex]) : - this.line.substring(splits[splits.length - 1]); - } else { - this.line = this.line.substring(0, splits[splitIndex]); - } - - if (splitIndex == splits.length) { - this.line += (this.showInvisibles) ? endOfLine : bidiUtil.DOT; - } - } - } else { - this.line += this.showInvisibles ? endOfLine : bidiUtil.DOT; - } - - /* replace tab and wide characters by commensurate spaces */ - var session = this.session, shift = 0, size; - this.line = this.line.replace(/\t|[\u1100-\u2029, \u202F-\uFFE6]/g, function(ch, i){ - if (ch === '\t' || session.isFullWidth(ch.charCodeAt(0))) { - size = (ch === '\t') ? session.getScreenTabSize(i + shift) : 2; - shift += size - 1; - return lang.stringRepeat(bidiUtil.DOT, size); - } - return ch; - }); - - if (this.isRtlDir) { - this.fontMetrics.$main.textContent = (this.line.charAt(this.line.length - 1) == bidiUtil.DOT) ? this.line.substr(0, this.line.length - 1) : this.line; - this.rtlLineOffset = this.contentWidth - this.fontMetrics.$main.getBoundingClientRect().width; - } - } - - updateBidiMap() { - var textCharTypes = []; - if (bidiUtil.hasBidiCharacters(this.line, textCharTypes) || this.isRtlDir) { - this.bidiMap = bidiUtil.doBidiReorder(this.line, textCharTypes, this.isRtlDir); - } else { - this.bidiMap = {}; - } - } /** * Resets stored info related to current screen row @@ -174,27 +110,6 @@ class BidiHandler { this.currentRow = null; } - /** - * Updates array of character widths - * @param {Object} fontMetrics metrics - * - **/ - updateCharacterWidths(fontMetrics) { - if (this.characterWidth === fontMetrics.$characterSize.width) - return; - - this.fontMetrics = fontMetrics; - var characterWidth = this.characterWidth = fontMetrics.$characterSize.width; - var bidiCharWidth = fontMetrics.$measureCharWidth("\u05d4"); - - this.charWidths[bidiUtil.L] = this.charWidths[bidiUtil.EN] = this.charWidths[bidiUtil.ON_R] = characterWidth; - this.charWidths[bidiUtil.R] = this.charWidths[bidiUtil.AN] = bidiCharWidth; - this.charWidths[bidiUtil.R_H] = bidiCharWidth * 0.45; - this.charWidths[bidiUtil.B] = this.charWidths[bidiUtil.RLE] = 0; - - this.currentRow = null; - } - setShowInvisibles(showInvisibles) { this.showInvisibles = showInvisibles; this.currentRow = null; @@ -225,138 +140,6 @@ class BidiHandler { editor.session.doc.insert({column: 0, row: row}, editor.session.$bidiHandler.RLE); } } - - - /** - * Returns offset of character at position defined by column. - * @param {Number} col the screen column position - * - * @return {Number} horizontal pixel offset of given screen column - **/ - getPosLeft(col) { - col -= this.wrapIndent; - var leftBoundary = (this.line.charAt(0) === this.RLE) ? 1 : 0; - var logicalIdx = (col > leftBoundary) ? (this.session.getOverwrite() ? col : col - 1) : leftBoundary; - var visualIdx = bidiUtil.getVisualFromLogicalIdx(logicalIdx, this.bidiMap), - levels = this.bidiMap.bidiLevels, left = 0; - - if (!this.session.getOverwrite() && col <= leftBoundary && levels[visualIdx] % 2 !== 0) - visualIdx++; - - for (var i = 0; i < visualIdx; i++) { - left += this.charWidths[levels[i]]; - } - - if (!this.session.getOverwrite() && (col > leftBoundary) && (levels[visualIdx] % 2 === 0)) - left += this.charWidths[levels[visualIdx]]; - - if (this.wrapIndent) - left += this.isRtlDir ? (-1 * this.wrapOffset) : this.wrapOffset; - - if (this.isRtlDir) - left += this.rtlLineOffset; - - return left; - } - - /** - * Returns 'selections' - array of objects defining set of selection rectangles - * @param {Number} startCol the start column position - * @param {Number} endCol the end column position - * - * @return {Object[]} Each object contains 'left' and 'width' values defining selection rectangle. - **/ - getSelections(startCol, endCol) { - var map = this.bidiMap, levels = map.bidiLevels, level, selections = [], offset = 0, - selColMin = Math.min(startCol, endCol) - this.wrapIndent, selColMax = Math.max(startCol, endCol) - this.wrapIndent, - isSelected = false, isSelectedPrev = false, selectionStart = 0; - - if (this.wrapIndent) - offset += this.isRtlDir ? (-1 * this.wrapOffset) : this.wrapOffset; - - for (var logIdx, visIdx = 0; visIdx < levels.length; visIdx++) { - logIdx = map.logicalFromVisual[visIdx]; - level = levels[visIdx]; - isSelected = (logIdx >= selColMin) && (logIdx < selColMax); - if (isSelected && !isSelectedPrev) { - selectionStart = offset; - } else if (!isSelected && isSelectedPrev) { - selections.push({left: selectionStart, width: offset - selectionStart}); - } - offset += this.charWidths[level]; - isSelectedPrev = isSelected; - } - - if (isSelected && (visIdx === levels.length)) { - selections.push({left: selectionStart, width: offset - selectionStart}); - } - - if(this.isRtlDir) { - for (var i = 0; i < selections.length; i++) { - selections[i].left += this.rtlLineOffset; - } - } - return selections; - } - - /** - * Converts character coordinates on the screen to respective document column number - * @param {Number} posX character horizontal offset - * - * @return {Number} screen column number corresponding to given pixel offset - **/ - offsetToCol(posX) { - if(this.isRtlDir) - posX -= this.rtlLineOffset; - - var logicalIdx = 0, posX = Math.max(posX, 0), - offset = 0, visualIdx = 0, levels = this.bidiMap.bidiLevels, - charWidth = this.charWidths[levels[visualIdx]]; - - if (this.wrapIndent) - posX -= this.isRtlDir ? (-1 * this.wrapOffset) : this.wrapOffset; - - while(posX > offset + charWidth/2) { - offset += charWidth; - if(visualIdx === levels.length - 1) { - /* quit when we on the right of the last character, flag this by charWidth = 0 */ - charWidth = 0; - break; - } - charWidth = this.charWidths[levels[++visualIdx]]; - } - - if (visualIdx > 0 && (levels[visualIdx - 1] % 2 !== 0) && (levels[visualIdx] % 2 === 0)){ - /* Bidi character on the left and None Bidi character on the right */ - if(posX < offset) - visualIdx--; - logicalIdx = this.bidiMap.logicalFromVisual[visualIdx]; - - } else if (visualIdx > 0 && (levels[visualIdx - 1] % 2 === 0) && (levels[visualIdx] % 2 !== 0)){ - /* None Bidi character on the left and Bidi character on the right */ - logicalIdx = 1 + ((posX > offset) ? this.bidiMap.logicalFromVisual[visualIdx] - : this.bidiMap.logicalFromVisual[visualIdx - 1]); - - } else if ((this.isRtlDir && visualIdx === levels.length - 1 && charWidth === 0 && (levels[visualIdx - 1] % 2 === 0)) - || (!this.isRtlDir && visualIdx === 0 && (levels[visualIdx] % 2 !== 0))){ - /* To the right of last character, which is None Bidi, in RTL direction or */ - /* to the left of first Bidi character, in LTR direction */ - logicalIdx = 1 + this.bidiMap.logicalFromVisual[visualIdx]; - } else { - /* Tweak visual position when Bidi character on the left in order to map it to corresponding logical position */ - if (visualIdx > 0 && (levels[visualIdx - 1] % 2 !== 0) && charWidth !== 0) - visualIdx--; - - /* Regular case */ - logicalIdx = this.bidiMap.logicalFromVisual[visualIdx]; - } - - if (logicalIdx === 0 && this.isRtlDir) - logicalIdx++; - - return (logicalIdx + this.wrapIndent); - } - } exports.BidiHandler = BidiHandler; diff --git a/src/edit_session.js b/src/edit_session.js index fd03473cdd0..f58492aacf0 100644 --- a/src/edit_session.js +++ b/src/edit_session.js @@ -2102,10 +2102,6 @@ class EditSession { // tab if (c == 9) { screenColumn += this.getScreenTabSize(screenColumn); - } - // full width characters - else if (c >= 0x1100 && isFullWidth(c)) { - screenColumn += 2; } else { screenColumn += 1; } @@ -2238,13 +2234,12 @@ class EditSession { * Converts characters coordinates on the screen to characters coordinates within the document. [This takes into account code folding, word wrap, tab size, and any other visual modifications.]{: #conversionConsiderations} * @param {Number} screenRow The screen row to check * @param {Number} screenColumn The screen column to check - * @param {Number} [offsetX] screen character x-offset [optional] * * @returns {Point} The object returned has two properties: `row` and `column`. * * @related EditSession.documentToScreenPosition **/ - screenToDocumentPosition(screenRow, screenColumn, offsetX) { + screenToDocumentPosition(screenRow, screenColumn) { if (screenRow < 0) return {row: 0, column: 0}; @@ -2316,9 +2311,6 @@ class EditSession { } } - if (offsetX !== undefined && this.$bidiHandler.isBidiRow(row + splitIndex, docRow, splitIndex)) - screenColumn = this.$bidiHandler.offsetToCol(offsetX); - docColumn += this.$getStringScreenWidth(line, screenColumn - wrapIndent)[1]; // We remove one character at the end so that the docColumn diff --git a/src/edit_session_test.js b/src/edit_session_test.js index 05b105ab32a..166817509ea 100644 --- a/src/edit_session_test.js +++ b/src/edit_session_test.js @@ -207,7 +207,7 @@ module.exports = { assert.equal(session.getScreenLastRowColumn(0), 4); assert.equal(session.getScreenLastRowColumn(1), 10); - assert.equal(session.getScreenLastRowColumn(2), 5); + assert.equal(session.getScreenLastRowColumn(2), 3); }, "test: convert document to screen coordinates" : function() { @@ -252,7 +252,7 @@ module.exports = { assert.position(session.documentToScreenPosition(0, 3), 0, 3); assert.position(session.documentToScreenPosition(1, 3), 1, 4); assert.position(session.documentToScreenPosition(1, 4), 1, 8); - assert.position(session.documentToScreenPosition(2, 2), 2, 4); + assert.position(session.documentToScreenPosition(2, 2), 2, 2); }, "test: documentToScreen with soft wrap": function() { @@ -326,9 +326,9 @@ module.exports = { session.setUseWrapMode(true); session.adjustWrapLimit(80); - assert.position(session.screenToDocumentPosition(0, 1), 0, 0); - assert.position(session.screenToDocumentPosition(0, 2), 0, 1); - assert.position(session.screenToDocumentPosition(0, 3), 0, 2); + assert.position(session.screenToDocumentPosition(0, 1), 0, 1); + assert.position(session.screenToDocumentPosition(0, 2), 0, 2); + assert.position(session.screenToDocumentPosition(0, 3), 0, 3); assert.position(session.screenToDocumentPosition(0, 4), 0, 3); assert.position(session.screenToDocumentPosition(0, 5), 0, 3); }, diff --git a/src/editor.js b/src/editor.js index 6f9391a2bf4..408e72aa24f 100644 --- a/src/editor.js +++ b/src/editor.js @@ -272,6 +272,7 @@ class Editor { this.session.off("endOperation", this.$onEndOperation); var selection = this.session.getSelection(); + this.session.$fontMetrics = null; selection.off("changeCursor", this.$onCursorChange); selection.off("changeSelection", this.$onSelectionChange); } @@ -281,6 +282,7 @@ class Editor { this.$onDocumentChange = this.onDocumentChange.bind(this); session.on("change", this.$onDocumentChange); this.renderer.setSession(session); + session.$fontMetrics = this.renderer.$fontMetrics; this.$onChangeMode = this.onChangeMode.bind(this); session.on("changeMode", this.$onChangeMode); diff --git a/src/ext/diff/inline_diff_view.js b/src/ext/diff/inline_diff_view.js index e3cb1984e51..73f24ce5447 100644 --- a/src/ext/diff/inline_diff_view.js +++ b/src/ext/diff/inline_diff_view.js @@ -197,8 +197,8 @@ class InlineDiffView extends BaseDiffView { var screenPos = ev.editor.renderer.pixelToScreenCoordinates(ev.clientX, ev.clientY); var sessionA = this.activeEditor.session; var sessionB = this.otherEditor.session; - var posA = sessionA.screenToDocumentPosition(screenPos.row, screenPos.column, screenPos.offsetX); - var posB = sessionB.screenToDocumentPosition(screenPos.row, screenPos.column, screenPos.offsetX); + var posA = sessionA.screenToDocumentPosition(screenPos.row, screenPos.column); + var posB = sessionB.screenToDocumentPosition(screenPos.row, screenPos.column); var posAx = sessionA.documentToScreenPosition(posA); var posBx = sessionB.documentToScreenPosition(posB); @@ -381,13 +381,6 @@ class InlineDiffView extends BaseDiffView { cloneRenderer.$computeLayerConfig(); var newConfig = cloneRenderer.layerConfig; - - this.gutterLayer.update(newConfig); - - newConfig.firstRowScreen = config.firstRowScreen; - - cloneRenderer.$cursorLayer.config = newConfig; - cloneRenderer.$cursorLayer.update(newConfig); if (changes & cloneRenderer.CHANGE_LINES || changes & cloneRenderer.CHANGE_FULL @@ -395,6 +388,13 @@ class InlineDiffView extends BaseDiffView { || changes & cloneRenderer.CHANGE_TEXT ) this.textLayer.update(newConfig); + + this.gutterLayer.update(newConfig); + + newConfig.firstRowScreen = config.firstRowScreen; + + cloneRenderer.$cursorLayer.config = newConfig; + cloneRenderer.$cursorLayer.update(newConfig); this.markerLayer.setMarkers(this.otherSession.getMarkers()); this.markerLayer.update(newConfig); diff --git a/src/keyboard/textinput_test.js b/src/keyboard/textinput_test.js index b4af590ac09..1b84d38d992 100644 --- a/src/keyboard/textinput_test.js +++ b/src/keyboard/textinput_test.js @@ -187,7 +187,7 @@ module.exports = { { _: "input", range: [3,3], value: "きもの"}, function() { assert.ok(editor.renderer.$composition); - assert.ok(Math.abs(parseFloat(textarea.style.width) - editor.renderer.characterWidth * 6) < 1); + assert.ok(Math.abs(parseFloat(textarea.style.width) - editor.renderer.characterWidth * 6) < 2); assert.ok(Math.abs(parseFloat(textarea.style.height) - (editor.renderer.lineHeight)) < 1); assert.ok(Math.abs(parseFloat(textarea.style.top)) < 1); assert.ok(/ace_composition/.test(textarea.className)); diff --git a/src/keyboard/vim.js b/src/keyboard/vim.js index f057eb5984d..b0c829fd316 100644 --- a/src/keyboard/vim.js +++ b/src/keyboard/vim.js @@ -7351,7 +7351,7 @@ domLib.importCssString(`.normal-mode .ace_cursor{ $id: "ace/keyboard/vim", drawCursor: function(element, pixelPos, config, sel, session) { var vim = this.state.vim || {}; - var w = config.characterWidth; + var w = pixelPos.width || config.characterWidth; var h = config.lineHeight; var top = pixelPos.top; var left = pixelPos.left; diff --git a/src/layer/cursor.js b/src/layer/cursor.js index 463cb8db2c4..919b4230bf9 100644 --- a/src/layer/cursor.js +++ b/src/layer/cursor.js @@ -175,22 +175,23 @@ class Cursor { /** * @param {import("../../ace-internal").Ace.Point} [position] * @param {boolean} [onScreen] + * @return {{left: number, top: number, width: number}} */ getPixelPosition(position, onScreen) { if (!this.config || !this.session) - return {left : 0, top : 0}; + return {left : 0, top : 0, width: 0}; if (!position) position = this.session.selection.getCursor(); var pos = this.session.documentToScreenPosition(position); - var cursorLeft = this.$padding + (this.session.$bidiHandler.isBidiRow(pos.row, position.row) - ? this.session.$bidiHandler.getPosLeft(pos.column) - : pos.column * this.config.characterWidth); + var textWidth = this.config.fontMetrics.textWidth(pos.row, pos.column); + var cursorLeft = this.$padding + textWidth; var cursorTop = (pos.row - (onScreen ? this.config.firstRowScreen : 0)) * this.config.lineHeight; + var cursorWidth = this.config.fontMetrics.textWidth(pos.row, pos.column + 1) - textWidth; - return {left : cursorLeft, top : cursorTop}; + return {left : cursorLeft, top : cursorTop, width : Math.abs(cursorWidth)}; } isCursorInView(pixelPos, config) { @@ -223,7 +224,7 @@ class Cursor { } else { dom.setStyle(style, "display", "block"); dom.translate(element, pixelPos.left, pixelPos.top); - dom.setStyle(style, "width", Math.round(config.characterWidth) + "px"); + dom.setStyle(style, "width", Math.round(pixelPos.width) + "px"); dom.setStyle(style, "height", config.lineHeight + "px"); } } else { diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index 4ddc758dc82..d7a78dd338b 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -1,8 +1,8 @@ +/*global Node, NodeFilter*/ var oop = require("../lib/oop"); var dom = require("../lib/dom"); var lang = require("../lib/lang"); var event = require("../lib/event"); -var useragent = require("../lib/useragent"); var EventEmitter = require("../lib/event_emitter").EventEmitter; var CHAR_COUNT = 512; @@ -14,7 +14,12 @@ class FontMetrics { /** * @param {HTMLElement} parentEl */ - constructor(parentEl) { + constructor(parentEl, textLayer, renderer) { + this.config = {characterWidth: 1}; + this.$characterSize = {width: 0, height: 0}; + this.textLayer = textLayer; + this.renderer = renderer; + this.el = dom.createElement("div"); this.$setMeasureNodeStyles(this.el.style, true); @@ -31,13 +36,14 @@ class FontMetrics { this.$measureNode.textContent = lang.stringRepeat("X", CHAR_COUNT); - this.$characterSize = {width: 0, height: 0}; - - if (USE_OBSERVER) this.$addObserver(); else this.checkForSizeChanges(); + + this.textLayer.$setFontMetrics(this); + + this.$scratchRange = document.createRange(); } $setMeasureNodeStyles(style, isRoot) { @@ -47,11 +53,7 @@ class FontMetrics { style.position = "absolute"; style.whiteSpace = "pre"; - if (useragent.isIE < 8) { - style["font-family"] = "inherit"; - } else { - style.font = "inherit"; - } + style.font = "inherit"; style.overflow = isRoot ? "hidden" : "visible"; } @@ -134,6 +136,12 @@ class FontMetrics { return w; } + getTextWidth(text) { + if (!text) return 0; + this.$main.textContent = text; + return this.$main.clientWidth; + } + destroy() { clearInterval(this.$pollSizeChangesTimer); if (this.$observer) @@ -142,16 +150,10 @@ class FontMetrics { this.el.parentNode.removeChild(this.el); } - - $getZoom(element) { - if (!element || !element.parentElement) return 1; - return (Number(window.getComputedStyle(element)["zoom"]) || 1) * this.$getZoom(element.parentElement); - } - $initTransformMeasureNodes() { - var t = function(t, l) { + var t = function(l, t) { return ["div", { - style: "position: absolute;top:" + t + "px;left:" + l + "px;" + style: "position: absolute;left:" + l + "px;top:" + t + "px;" }]; }; this.els = dom.buildDom([t(0, 0), t(L, 0), t(0, L), t(L, L)], this.el); @@ -162,25 +164,13 @@ class FontMetrics { // | h[0] h[1] 1 | | 1 | | 1 | // this function finds the coeeficients of the matrix using positions of four points // - transformCoordinates(clientPos, elPos) { - if (clientPos) { - var zoom = this.$getZoom(this.el); - clientPos = mul(1 / zoom, clientPos); - } - function solve(l1, l2, r) { - var det = l1[1] * l2[0] - l1[0] * l2[1]; - return [ - (-l2[1] * r[0] + l2[0] * r[1]) / det, - (+l1[1] * r[0] - l1[0] * r[1]) / det - ]; + getTransform() { + if (this.config.$transformData) { + return this.config.$transformData; } - function sub(a, b) { return [a[0] - b[0], a[1] - b[1]]; } - function add(a, b) { return [a[0] + b[0], a[1] + b[1]]; } - function mul(a, b) { return [a * b[0], a * b[1]]; } - if (!this.els) this.$initTransformMeasureNodes(); - + function p(el) { var r = el.getBoundingClientRect(); return [r.left, r.top]; @@ -193,23 +183,516 @@ class FontMetrics { var h = solve(sub(d, b), sub(d, c), sub(add(b, c), add(d, a))); - var m1 = mul(1 + h[0], sub(b, a)); - var m2 = mul(1 + h[1], sub(c, a)); - + var m1 = mul((1 + h[0])/L, sub(b, a)); + var m2 = mul((1 + h[1])/L, sub(c, a)); + + var M = [ + m1[0], m2[0], 0, + m1[1], m2[1], 0, + h[0]/L, h[1]/L, 1 + ]; + + var detM = 1 / (M[0] * M[4] - M[3] * M[1]); + var MInv = [ + M[4] * detM, -M[1] * detM, 0, + -M[3] * detM, M[0] * detM, 0, + (M[3] * M[7] - M[4] * M[6]) * detM, (M[1] * M[6] - M[0] * M[7]) * detM, 1 + ]; + + this.config.$transformData = { + M, MInv, t: a + }; + return this.config.$transformData; + } + + transformCoordinates(clientPos, elPos) { + if (!this.config.$transformData) + this.getTransform(); + var tr = this.config.$transformData; + if (elPos) { - var x = elPos; - var k = h[0] * x[0] / L + h[1] * x[1] / L + 1; - var ut = add(mul(x[0], m1), mul(x[1], m2)); - return add(mul(1 / k / L, ut), a); + return add(project(tr.M, elPos[0], elPos[1]), tr.t); + } + return project(tr.MInv, clientPos[0] - tr.t[0], clientPos[1] - tr.t[1]); + } + + /** + * @param {{M: number[], t: number[]}} transform + * @param {{left: number, top: number, width: number, height: number}} bbox + */ + recoverRect(transform, bbox) { + var M = transform.M; + + // 1. Detect Affine Case (Perspective components are zero) + var isAffine = Math.abs(M[6]) < 1e-10 && Math.abs(M[7]) < 1e-10; + + var { left, top, width: Wt, height: Ht } = bbox; + left -= transform.t[0]; + top -= transform.t[1]; + + if (isAffine) { + var [m00, m01, m02, m10, m11, m12] = M; + + // analytical formula: {w, h} = inverse(|M|) * {Wt, Ht} + var absM = [Math.abs(m00), Math.abs(m01), Math.abs(m10), Math.abs(m11)]; + var delta = absM[0] * absM[3] - absM[2] * absM[1]; + + let w, h; + // Handle 45-degree ambiguity (Delta is near zero) + if (Math.abs(delta) < 1e-10) { + // At 45 deg: Wt = |m00|*w + |m01|*h + // We use lineHeight as h and solve for w + h = this.config && this.config.lineHeight || 0; + // Solve: w = (Wt - |m01|*h) / |m00| + w = (Wt - absM[1] * h) / absM[0]; + } else { + w = (absM[3] * Wt - absM[1] * Ht) / delta; + h = (-absM[2] * Wt + absM[0] * Ht) / delta; + } + + // Recover position by back-projecting center + var detM = m00 * m11 - m01 * m10; + var ctx = left + Wt / 2 - m02; + var cty = top + Ht / 2 - m12; + + var cx = (m11 * ctx - m01 * cty) / detM; + var cy = (-m10 * ctx + m00 * cty) / detM; + + return { left: cx - w / 2, top: cy - h / 2, width: w, height: h, right: cx + w / 2, bottom: cy + h / 2 }; + } + + return recoverRect(transform, bbox); + } + + /** + * Finds and returns the DOM element corresponding to a given screen row. + * + * @param {number} screenRow - The screen row number for which to find the element. + * @returns {HTMLElement|null} The DOM element corresponding to the screen row, or null if not found. + */ + $findElementForScreenRow(screenRow) { + var textLayer = this.textLayer; + if (!this.config) return null; // not initialized yet + var data = textLayer.$lines.$getCellByScreenRow(screenRow, this.config); + var lineElement = data && data.cell.element; + + if (lineElement && textLayer.$useLineGroups()) { + var index = Math.floor(data.offset / this.config.lineHeight); + lineElement = lineElement.children[Math.max(index, 0)]; } - var u = sub(clientPos, a); - var f = solve(sub(m1, mul(h[0], u)), sub(m2, mul(h[1], u)), u); - return mul(L, f); + return lineElement; } + /** + * Calculates the width of the text up to a specific scrrenColumn on a given screen row. + * + * @param {number} screenRow - The row index on the screen for which the text width is calculated. + * @param {number} screenColumn - The column index up to which the text width is measured. + * @returns {number} The width of the text in pixels up to the specified column. + */ + textWidth(screenRow, screenColumn) { + var lineElement = this.$findElementForScreenRow(screenRow); + if (!lineElement || !document.createRange) { + // Fallback for lines not currently rendered + return screenColumn * this.config.characterWidth; + } + return this.$measureLineToColumn(lineElement, screenColumn); + } + + + /** + * Measures the horizontal position (in pixels) of a specific screen column + * within a given line element. This method calculates the pixel offset + * from the left edge of the text layer's container to the specified column. + * + * @param {HTMLElement} lineElement - The DOM element representing the line of text. + * @param {number} screenColumn - The screen column index to measure. + * @returns {number} The horizontal position (in pixels) of the specified column + * relative to the left edge of the text layer's container. If the position cannot + * be determined, it falls back to an approximation based on the character width. + */ + $measureLineToColumn(lineElement, screenColumn) { + var textLayer = this.textLayer; + + try { + var position = this.$findColumnPosition(lineElement, screenColumn); + if (!position) { + return screenColumn * this.config.characterWidth; + } + + this.$scratchRange.setStart(position.node, position.offset); + this.$scratchRange.setEnd(position.node, position.offset); + + var rangeRect = this.$scratchRange.getBoundingClientRect(); + if (this.renderer.$hasCssTransforms) { + var tr = this.getTransform(); + var transformed = this.recoverRect(tr, rangeRect); + var leftOffset = this.renderer.gutterWidth + this.renderer.margin.left + this.renderer.$padding - this.renderer.scrollLeft; + return transformed.left - leftOffset + position.overflow * this.config.characterWidth; + } + var rect = textLayer.element.getBoundingClientRect(); + return rangeRect.left - rect.left + position.overflow * this.config.characterWidth; + } catch (e) { + console.error("Error measuring text width:", e); + return screenColumn * this.config.characterWidth; + } + } + + /** + * Finds the position of a specific column within a line element. + * + * This method traverses the text nodes within the given line element to locate + * the node and offset corresponding to the specified screen column. If the + * column exceeds the total length of the text, it returns the last node and its length. + * + * @param {HTMLElement} lineElement - The DOM element representing the line of text. + * @param {number} screenColumn - The target column position within the line (0-based). + * @returns {{node: Node, offset: number, overflow: number} | null} An object containing the text node and the offset + * within that node corresponding to the column position, or `null` if no nodes are found. + */ + $findColumnPosition(lineElement, screenColumn) { + var walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null); + + var currentColumn = 0; + var node, lastNode; + + while (node = walker.nextNode()) { + var nodeText = node.nodeValue; + var nodeLength = nodeText.length; + + if (currentColumn + nodeLength >= screenColumn) { + return { + node: node, + offset: screenColumn - currentColumn, + overflow: 0 + }; + } + currentColumn += nodeLength; + lastNode = node; + } + + return lastNode && { + node: lastNode, + offset: lastNode.nodeValue.length, + overflow: screenColumn - currentColumn, + }; + } + + /** + * Converts a pixel position (x-coordinate) to a screen column index within a given row. + * + * @param {number} screenRow - The row index on the screen. + * @param {number} screenColumnMonospace - Screen column if text was monospace. + * @param {number} x - The x-coordinate (in pixels) to convert to a column index. + * @param {boolean} blockCursor - Whether the cursor is in block mode. + * @param {boolean} [isTextWidthCoordinate] - Whether x is in the same coordinate space as textWidth(). + * @returns {number} The calculated screen column index corresponding to the x-coordinate. + */ + $pixelToColumn(screenRow, screenColumnMonospace, x, blockCursor, isTextWidthCoordinate) { + var scratchRange = this.$scratchRange; + var lineElement = this.$findElementForScreenRow(screenRow); + if (!lineElement || screenColumnMonospace < 0) return screenColumnMonospace; + + var hasCssTransform = this.renderer.$hasCssTransforms; + var tr = hasCssTransform && this.getTransform(); + if (isTextWidthCoordinate) { + if (!hasCssTransform) { + x += this.textLayer.element.getBoundingClientRect().left; + } else { + x += this.renderer.gutterWidth + this.renderer.margin.left + this.renderer.$padding - this.renderer.scrollLeft; + } + } + + var screenColumn = 0; + var getRects = (node) => { + var rects = []; + if (node.nodeType === Node.TEXT_NODE) { + scratchRange.setStart(node, 0); + scratchRange.setEnd(node, node.nodeValue.length); + rects = Array.from(scratchRange.getClientRects()); + } else if (node.nodeType === Node.ELEMENT_NODE) { + rects = Array.from(node.getClientRects()); + } + if (hasCssTransform) { + var fixedRects = []; + for (var i = 0; i < rects.length; i++) { + var rect = rects[i]; + fixedRects.push(this.recoverRect(tr, rect)); + } + rects = fixedRects; + } + return rects; + }; + var self = this; + function search(node) { + if (node.nodeType === Node.TEXT_NODE) { + var textLength = node.nodeValue.length; + var maxDistance = Number.MAX_VALUE; + var index = -1; + var value = node.nodeValue; + for (var j = 0; j <= textLength; j++) { + scratchRange.setStart(node, j); + scratchRange.setEnd(node, j); + if ( + j > 0 && j < textLength && /[\uDC00-\uDFFF]/.test(value.charAt(j)) && + /[\uD800-\uDBFF]/.test(value.charAt(j - 1)) + + ) { + continue; + } + let rect = /** @type {ReturnType}*/(scratchRange.getBoundingClientRect()); + if (hasCssTransform) { + rect = self.recoverRect(tr, rect); + } + var d = Math.abs(x - rect.left); + if (d < maxDistance) { + index = j; + maxDistance = d; + } + } + if (blockCursor) { + // TODO + } + return screenColumn = screenColumn + index; + } else if (node.nodeType === Node.ELEMENT_NODE) { + var childNodes = node.childNodes; + + for (var i = 0; i < childNodes.length; i++) { + var child = childNodes[i]; + var rects = getRects(child); + for (var j = 0; j < rects.length; j++) { + let rect = rects[j]; + if (rect.left < x && x < rect.left + rect.width) { + search(child); + return screenColumn; + } + } + screenColumn += child.nodeType === Node.TEXT_NODE ? child.nodeValue.length : child.textContent.length; + } + } + } + search(lineElement); + + return screenColumn; + } + + /** + * Calculates and returns an array of rectangles representing the visual positions + * of a range of text between two screen positions within a text layer. + * + * @param {Object} startScreenPos - The starting screen position of the range. + * @param {number} startScreenPos.row - The row index of the starting position. + * @param {number} startScreenPos.column - The column index of the starting position. + * @param {Object} endScreenPos - The ending screen position of the range. + * @param {number} endScreenPos.row - The row index of the ending position. + * @param {number} endScreenPos.column - The column index of the ending position. + * @returns {Array} An array of rectangle objects representing the visual + * positions of the text range. Each rectangle object contains: + * - `left` {number}: The left offset of the rectangle relative to the text layer. + * - `width` {number}: The width of the rectangle. + * If an error occurs or the line element is not found, a fallback rectangle is returned + * based on character width and column positions. + */ + getRects(startScreenPos, endScreenPos) { + var row = startScreenPos.row; + var textLayer = this.textLayer; + var lineElement = this.$findElementForScreenRow(row); + + if (lineElement) { + try { + var p1 = this.$findColumnPosition(lineElement, startScreenPos.column); + var p2 = this.$findColumnPosition(lineElement, endScreenPos.column); + if (p1 && p2) { + this.$scratchRange.setStart(p1.node, p1.offset); + this.$scratchRange.setEnd(p2.node, p2.offset); + var rangeRects = this.$scratchRange.getClientRects(); + var hasCssTransform = this.renderer && this.renderer.$hasCssTransforms; + if (hasCssTransform) { + var tr = this.getTransform(); + var rects = []; + for (var i = 0; i < rangeRects.length; i++) { + var rangeRect = this.recoverRect(tr, rangeRects[i]); + rangeRect.right = rangeRect.left + rangeRect.width; + rects.push(rangeRect); + } + var leftOffset = this.renderer.gutterWidth + this.renderer.margin.left + this.renderer.$padding - this.renderer.scrollLeft; + var merged = mergeTouchingRects(rects).map(function(r) { + return { + left: r.left - leftOffset, + width: r.right - r.left, + }; + }); + return merged; + } + var rect = textLayer.element.getBoundingClientRect(); + var merged = mergeTouchingRects(rangeRects).map(function(r) { + return { + left: r.left - rect.left, + width: r.right - r.left, + }; + }); + return merged; + } + } catch (e) { + console.error("Error measuring text width:", e); + } + } + return [{ + left: startScreenPos.column * this.config.characterWidth, + width: (endScreenPos.column - startScreenPos.column) * this.config.characterWidth, + }]; + } } -FontMetrics.prototype.$characterSize = {width: 0, height: 0}; + + +function mergeTouchingRects(rects) { + var merged = []; + for (var i = 0; i < rects.length; i++) { + var rect = rects[i]; + var found = false; + for (var j = 0; j < merged.length; j++) { + var m = merged[j]; + if ( + (m.left <= rect.left && rect.left <= m.right) || + (m.left <= rect.right && rect.right <= m.right) || + (rect.left <= m.left && m.right <= rect.right) + ) { + m.left = Math.min(m.left, rect.left); + m.right = Math.max(m.right, rect.right); + found = true; + break; + } + } + if (!found) { + merged.push({ + left: rect.left, + right: rect.right, + top: rect.top, + height: rect.height, + }); + } + } + return merged; +} + + + +function solve(l1, l2, r) { + var det = l1[1] * l2[0] - l1[0] * l2[1]; + return [ + (-l2[1] * r[0] + l2[0] * r[1]) / det, + (+l1[1] * r[0] - l1[0] * r[1]) / det + ]; +} +function sub(a, b) { return [a[0] - b[0], a[1] - b[1]]; } +function add(a, b) { return [a[0] + b[0], a[1] + b[1]]; } +function mul(a, b) { return [a * b[0], a * b[1]]; } + oop.implement(FontMetrics.prototype, EventEmitter); exports.FontMetrics = FontMetrics; + + + + + +function recoverRect(transform, bbox) { + var { left, top, width, height } = bbox; + var M = transform.M; + left -= transform.t[0]; + top -= transform.t[1]; + bbox = { left, top, width, height }; + var targets = [top, left + width, top + height, left]; // minY, maxX, maxY, minX + var isYAxis = [true, false, true, false]; // minY=Y, maxX=X, maxY=Y, minX=X + + var corners = [ + [0, 0], [1, 0], [1, 1], [0, 1] + ]; + var result = null; + + var mainMappings = [27, 57, 23, 53, 43, 9, 10, 11, 14, 37, 31, 56, 40, 41, 47]; + + for (let i = -mainMappings.length; i < 256; i++) { + var index = i < 0 ? mainMappings[mainMappings.length + i] : i; + // Decode i into 4 corner indices (base 4) + var mappingIdx = [ + (index >> 0) & 3, + (index >> 2) & 3, + (index >> 4) & 3, + (index >> 6) & 3 + ]; + + var mapping = mappingIdx.map(idx => corners[idx]); + + // Build the 4x5 linear system for [x0, y0, w, h] + var rows = mapping.map((c, j) => { + var [dx, dy] = c; + var target = targets[j]; + var m = isYAxis[j] ? M.slice(3, 6) : M.slice(0, 3); + var mp = M.slice(6, 9); + + // Equation: (m0 - T*m6)x0 + (m1 - T*m7)y0 + dx(m0 - T*m6)w + dy(m1 - T*m7)h = T*m8 - m2 + var ax = m[0] - target * mp[0]; + var ay = m[1] - target * mp[1]; + + return [ax, ay, dx * ax, dy * ay, target * mp[2] - m[2]]; + }); + + var res = solve4x4(rows); + var result; + if (res) { + var [x0, y0, w, h] = res; + if (w < 0) { x0 += w; w = -w; } + if (h < 0) { y0 += h; h = -h; } + if (validateSolution(M, x0, y0, w, h, bbox)) { + result = { left: x0, top: y0, width: w, height: h, right: x0 + w, bottom: y0 + h, mappingIdx: i }; + break; + } + } + } + if (!result) + console.warn("No valid mapping found in 256 combinations."); + return result; +} + +var project = (m, px, py) => { + var k = m[6] * px + m[7] * py + m[8]; + return [(m[0] * px + m[1] * py + m[2]) / k, (m[3] * px + m[4] * py + m[5]) / k]; +}; + +/** + * Forward projects the 4 corners of the solution and checks if the + * resulting BBox matches the input bbox. + */ +function validateSolution(M, x, y, w, h, targetBbox) { + var pts = [ + [x, y], [x + w, y], [x + w, y + h], [x, y + h] + ]; + + return pts.every(p => { + var mapped = project(M, p[0], p[1]); + return targetBbox.left - 0.5 <= mapped[0] && mapped[0] <= targetBbox.left + targetBbox.width + 0.5 + && targetBbox.top - 0.5 <= mapped[1] && mapped[1] <= targetBbox.top + targetBbox.height + 0.5; + }); +} + +function solve4x4(m) { + let n = 4; + for (let i = 0; i < n; i++) { + let max = i; + for (let j = i + 1; j < n; j++) + if (Math.abs(m[j][i]) > Math.abs(m[max][i])) max = j; + [m[i], m[max]] = [m[max], m[i]]; + let p = m[i][i]; + if (Math.abs(p) < 1e-10) return null; + for (let j = i; j <= n; j++) m[i][j] /= p; + for (let k = 0; k < n; k++) { + if (k !== i) { + let f = m[k][i]; + for (let j = i; j <= n; j++) m[k][j] -= f * m[i][j]; + } + } + } + return m.map(row => row[n]); +} diff --git a/src/layer/lines.js b/src/layer/lines.js index ae0cb71e262..98ed03a62ab 100644 --- a/src/layer/lines.js +++ b/src/layer/lines.js @@ -50,6 +50,25 @@ class Lines { return lineTop - (screenPage * this.canvasHeight); } + $getCellByScreenRow(screenRow, config) { + var screenTop = config.firstRowScreen * config.lineHeight; + var screenPage = Math.floor(screenTop / this.canvasHeight); + var lineTop = screenRow * config.lineHeight - (screenPage * this.canvasHeight); + for (var i = this.cells.length -1; i >= 0; i--) { + var cell = this.cells[i]; + var top = parseInt(cell.element.style.top); + var height = parseInt(cell.element.style.height); + if (top <= lineTop) { + if (top + height < lineTop) { + return null; + } + var offset = lineTop - top; + return {cell, offset}; + } + } + return null; + } + /** * @param {number} row * @param {LayerConfig} config diff --git a/src/layer/marker.js b/src/layer/marker.js index 3307c7b89b9..92d16a69eea 100644 --- a/src/layer/marker.js +++ b/src/layer/marker.js @@ -64,6 +64,8 @@ class Marker { this.config = config; + this.element.style.display = "none"; + this.i = 0; var html; for (var key in this.markers) { @@ -80,7 +82,7 @@ class Marker { range = range.toScreenRange(this.session); if (marker.renderer) { var top = this.$getTop(range.start.row, config); - var left = this.$padding + range.start.column * config.characterWidth; + var left = this.$padding + config.fontMetrics.textWidth(range.start.row, range.start.column); marker.renderer(html, range, left, top, config); } else if (marker.type == "fullLine") { this.drawFullLineMarker(html, range, marker.clazz, config); @@ -99,6 +101,8 @@ class Marker { while (this.i < this.element.childElementCount) this.element.removeChild(this.element.lastChild); } + + this.element.style.display = ""; } /** @@ -154,13 +158,13 @@ class Marker { var padding = this.$padding; var height = config.lineHeight; var top = this.$getTop(range.start.row, config); - var left = padding + range.start.column * config.characterWidth; + var left = padding + config.fontMetrics.textWidth(range.start.row, range.start.column); extraStyle = extraStyle || ""; if (this.session.$bidiHandler.isBidiRow(range.start.row)) { var range1 = range.clone(); range1.end.row = range1.start.row; - range1.end.column = this.session.getLine(range1.start.row).length; + range1.end.column = Number.MAX_VALUE; this.drawBidiSingleLineMarker(stringBuilder, range1, clazz + " ace_br1 ace_start", config, null, extraStyle); } else { this.elt( @@ -176,7 +180,7 @@ class Marker { this.drawBidiSingleLineMarker(stringBuilder, range1, clazz + " ace_br12", config, null, extraStyle); } else { top = this.$getTop(range.end.row, config); - var width = range.end.column * config.characterWidth; + var width = config.fontMetrics.textWidth(range.end.row, range.end.column); this.elt( clazz + " ace_br12", @@ -216,17 +220,17 @@ class Marker { if (this.session.$bidiHandler.isBidiRow(range.start.row)) return this.drawBidiSingleLineMarker(stringBuilder, range, clazz, config, extraLength, extraStyle); var height = config.lineHeight; - var width = (range.end.column + (extraLength || 0) - range.start.column) * config.characterWidth; + var right = config.fontMetrics.textWidth(range.start.row, range.end.column) + (extraLength || 0) * config.characterWidth; var top = this.$getTop(range.start.row, config); - var left = this.$padding + range.start.column * config.characterWidth; + var left = config.fontMetrics.textWidth(range.start.row, range.start.column); this.elt( clazz, "height:"+ height+ "px;"+ - "width:"+ width+ "px;"+ + "width:"+ (right-left)+ "px;"+ "top:"+ top+ "px;"+ - "left:"+ left+ "px;"+ (extraStyle || "") + "left:"+ (this.$padding + left)+ "px;"+ (extraStyle || "") ); } @@ -241,15 +245,14 @@ class Marker { */ drawBidiSingleLineMarker(stringBuilder, range, clazz, config, extraLength, extraStyle) { var height = config.lineHeight, top = this.$getTop(range.start.row, config), padding = this.$padding; - var selections = this.session.$bidiHandler.getSelections(range.start.column, range.end.column); - - selections.forEach(function(selection) { + var rects = this.config.fontMetrics.getRects(range.start, range.end); + rects.forEach(function(rect) { this.elt( clazz, "height:" + height + "px;" + - "width:" + (selection.width + (extraLength || 0)) + "px;" + + "width:" + (rect.width + (extraLength || 0)) + "px;" + "top:" + top + "px;" + - "left:" + (padding + selection.left) + "px;" + (extraStyle || "") + "left:" + (padding + rect.left) + "px;" + (extraStyle || "") ); }, this); } diff --git a/src/layer/text.js b/src/layer/text.js index 5d0df50e2c0..f154329cf70 100644 --- a/src/layer/text.js +++ b/src/layer/text.js @@ -350,7 +350,7 @@ class Text { $renderToken(parent, screenColumn, token, value) { var self = this; - var re = /(\t)|( +)|([\x00-\x1f\x80-\xa0\xad\u1680\u180E\u2000-\u200f\u2028\u2029\u202F\u205F\uFEFF\uFFF9-\uFFFC\u2066\u2067\u2068\u202A\u202B\u202D\u202E\u202C\u2069\u2060\u2061\u2062\u2063\u2064\u206A\u206B\u206B\u206C\u206D\u206E\u206F]+)|(\u3000)|([\u1100-\u115F\u11A3-\u11A7\u11FA-\u11FF\u2329-\u232A\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFB\u3001-\u303E\u3041-\u3096\u3099-\u30FF\u3105-\u312D\u3131-\u318E\u3190-\u31BA\u31C0-\u31E3\u31F0-\u321E\u3220-\u3247\u3250-\u32FE\u3300-\u4DBF\u4E00-\uA48C\uA490-\uA4C6\uA960-\uA97C\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFF01-\uFF60\uFFE0-\uFFE6]|[\uD800-\uDBFF][\uDC00-\uDFFF])/g; + var re = /(\t)|( +)|([\x00-\x1f\x80-\xa0\xad\u1680\u180E\u2000-\u200f\u2028\u2029\u202F\u205F\uFEFF\uFFF9-\uFFFC\u2066\u2067\u2068\u202A\u202B\u202D\u202E\u202C\u2069\u2060\u2061\u2062\u2063\u2064\u206A\u206B\u206B\u206C\u206D\u206E\u206F]+)|(\u3000+)/g; var valueFragment = this.dom.createFragment(this.element); @@ -361,7 +361,6 @@ class Text { var simpleSpace = m[2]; var controlCharacter = m[3]; var cjkSpace = m[4]; - var cjk = m[5]; if (!self.showSpaces && simpleSpace) continue; @@ -395,25 +394,25 @@ class Text { span.textContent = lang.stringRepeat(self.SPACE_CHAR, controlCharacter.length); valueFragment.appendChild(span); } else if (cjkSpace) { - // U+3000 is both invisible AND full-width, so must be handled uniquely - screenColumn += 1; - - var span = this.dom.createElement("span"); - span.style.width = (self.config.characterWidth * 2) + "px"; - span.className = self.showSpaces ? "ace_cjk ace_invisible ace_invisible_space" : "ace_cjk"; - span.textContent = self.showSpaces ? self.SPACE_CHAR : cjkSpace; - valueFragment.appendChild(span); - } else if (cjk) { - screenColumn += 1; - var span = this.dom.createElement("span"); - span.style.width = (self.config.characterWidth * 2) + "px"; - span.className = "ace_cjk"; - span.textContent = cjk; - valueFragment.appendChild(span); + if (self.showSpaces) { + var span = this.dom.createElement("span"); + span.className = "ace_invisible ace_invisible_space"; + span.textContent = lang.stringRepeat(self.CJK_SPACE_CHAR, cjkSpace.length); + valueFragment.appendChild(span); + } else { + valueFragment.appendChild(this.dom.createTextNode(cjkSpace, this.element)); + } } } - valueFragment.appendChild(this.dom.createTextNode(i ? value.slice(i) : value, this.element)); + var trimmedValue = i ? value.slice(i) : value; + if (trimmedValue.length > 256) { + for (var j = 0; j < trimmedValue.length; ) { + valueFragment.appendChild(this.dom.createTextNode(trimmedValue.slice(j, j += 256), this.element)); + } + } else { + valueFragment.appendChild(this.dom.createTextNode(trimmedValue, this.element)); + } if (!isTextToken(token.type)) { var classes = "ace_" + token.type.replace(/\./g, " ace_"); @@ -785,6 +784,7 @@ Text.prototype.EOL_CHAR_CRLF = "\xa4"; Text.prototype.EOL_CHAR = Text.prototype.EOL_CHAR_LF; Text.prototype.TAB_CHAR = "\u2014"; //"\u21E5"; Text.prototype.SPACE_CHAR = "\xB7"; +Text.prototype.CJK_SPACE_CHAR = "\u30FB"; Text.prototype.$padding = 0; Text.prototype.MAX_LINE_LENGTH = 10000; Text.prototype.showInvisibles = false; diff --git a/src/layer/text_markers.js b/src/layer/text_markers.js index 79307b1d290..2256a8ce077 100644 --- a/src/layer/text_markers.js +++ b/src/layer/text_markers.js @@ -177,8 +177,9 @@ var textMarkerMixin = { if (/^\s+$/.test(segment)) { span = this.dom.createElement("span"); span.className = marker.className; - var symbol = node["charCount"] ? this.TAB_CHAR : this.SPACE_CHAR; - span.textContent = lang.stringRepeat(symbol, segment.length); + span.textContent = + node["charCount"] ? this.TAB_CHAR.repeat(segment.length) + : segment.replace(/\u3000/g, this.CJK_SPACE_CHAR).replace(/ /g, this.SPACE_CHAR); span.setAttribute("data-whitespace", segment); fragment.appendChild(span); } diff --git a/src/layer/text_markers_test.js b/src/layer/text_markers_test.js index ddb73c99fb0..1a75379e17b 100644 --- a/src/layer/text_markers_test.js +++ b/src/layer/text_markers_test.js @@ -152,10 +152,7 @@ module.exports = { }); assert.equal(markedText, "试function测"); - var result = normalize(` - function - - `); + var result = normalize(`试function测试`); var actual = normalize(this.textLayer.element.childNodes[0].innerHTML); assert.equal(actual, result); }, diff --git a/src/layer/text_test.js b/src/layer/text_test.js index dd957321c31..7d3a782552a 100644 --- a/src/layer/text_test.js +++ b/src/layer/text_test.js @@ -42,13 +42,13 @@ module.exports = { var parent = dom.createElement("div"); this.textLayer.$renderLine(parent, 0); - assert.domNode(parent, ["div", {}, ["span", {class: "ace_cjk", style: "width: 20px;"}, "\u3000"]]); + assert.domNode(parent, ["div", {}, "\u3000"]); this.textLayer.setShowInvisibles(true); var parent = dom.createElement("div"); this.textLayer.$renderLine(parent, 0); assert.domNode(parent, ["div", {}, - ["span", {class: "ace_cjk ace_invisible ace_invisible_space", style: "width: 20px;"}, this.textLayer.SPACE_CHAR], + ["span", {class: "ace_invisible ace_invisible_space"}, this.textLayer.CJK_SPACE_CHAR], ["span", {class: "ace_invisible ace_invisible_eol"}, "\xB6"] ]); }, diff --git a/src/mouse/multi_select_handler.js b/src/mouse/multi_select_handler.js index bbc0adf7531..81619fd71a5 100644 --- a/src/mouse/multi_select_handler.js +++ b/src/mouse/multi_select_handler.js @@ -110,7 +110,7 @@ function onMouseDown(e) { var rectSel = []; var blockSelect = function() { var newCursor = editor.renderer.pixelToScreenCoordinates(mouseX, mouseY); - var cursor = session.screenToDocumentPosition(newCursor.row, newCursor.column, newCursor.offsetX); + var cursor = session.screenToDocumentPosition(newCursor.row, newCursor.column); if (isSamePoint(screenCursor, newCursor) && isSamePoint(cursor, selection.lead)) return; diff --git a/src/multi_select.js b/src/multi_select.js index d2a39007522..ac2078bc6db 100644 --- a/src/multi_select.js +++ b/src/multi_select.js @@ -268,13 +268,9 @@ var EditSession = require("./edit_session").EditSession; if (xBackwards) { var startColumn = screenCursor.column; var endColumn = screenAnchor.column; - var startOffsetX = screenCursor.offsetX; - var endOffsetX = screenAnchor.offsetX; } else { var startColumn = screenAnchor.column; var endColumn = screenCursor.column; - var startOffsetX = screenAnchor.offsetX; - var endOffsetX = screenCursor.offsetX; } var yBackwards = screenCursor.row < screenAnchor.row; @@ -297,8 +293,8 @@ var EditSession = require("./edit_session").EditSession; var docEnd; for (var row = startRow; row <= endRow; row++) { var range = Range.fromPoints( - this.session.screenToDocumentPosition(row, startColumn, startOffsetX), - this.session.screenToDocumentPosition(row, endColumn, endOffsetX) + this.session.screenToDocumentPosition(row, startColumn), + this.session.screenToDocumentPosition(row, endColumn) ); if (range.isEmpty()) { if (docEnd && isSamePoint(range.end, docEnd)) diff --git a/src/range_test.js b/src/range_test.js index 41c7b082798..084af4666bf 100644 --- a/src/range_test.js +++ b/src/range_test.js @@ -142,7 +142,7 @@ module.exports = { assert.range(range.toScreenRange(session), 1, 1, 1, 4); var range = new Range(2, 1, 2, 2); - assert.range(range.toScreenRange(session), 2, 2, 2, 4); + assert.range(range.toScreenRange(session), 2, 1, 2, 2); var range = new Range(3, 0, 3, 4); assert.range(range.toScreenRange(session), 3, 0, 3, 10); diff --git a/src/selection.js b/src/selection.js index 6ab284305af..943f7f833ff 100644 --- a/src/selection.js +++ b/src/selection.js @@ -711,15 +711,14 @@ class Selection { ); var offsetX; + var fontMetrics = this.session.$fontMetrics; + var useFontMetrics = chars === 0 && rows !== 0 && fontMetrics; if (chars === 0) { - if (rows !== 0) { - if (this.session.$bidiHandler.isBidiRow(screenPos.row, this.lead.row)) { - offsetX = this.session.$bidiHandler.getPosLeft(screenPos.column); - screenPos.column = Math.round(offsetX / this.session.$bidiHandler.charWidths[0]); - } else { - offsetX = screenPos.column * this.session.$bidiHandler.charWidths[0]; - } + if (useFontMetrics && this.$desiredColumn == null) { + offsetX = fontMetrics.textWidth(screenPos.row, screenPos.column); + if (isFinite(offsetX)) + this.$desiredColumn = offsetX / fontMetrics.config.characterWidth; } if (this.$desiredColumn) @@ -736,16 +735,17 @@ class Selection { rows += widget.rowCount - (widget.rowsAbove || 0); } - var docPos = this.session.screenToDocumentPosition(screenPos.row + rows, screenPos.column, offsetX); - - if (rows !== 0 && chars === 0 && docPos.row === this.lead.row && docPos.column === this.lead.column) { - + var targetScreenRow = screenPos.row + rows; + if (useFontMetrics && this.$desiredColumn != null) { + offsetX = this.$desiredColumn * fontMetrics.config.characterWidth; + var blockCursor = fontMetrics.renderer && fontMetrics.renderer.$blockCursor; + screenPos.column = fontMetrics.$pixelToColumn(targetScreenRow, screenPos.column, offsetX, blockCursor, true); } + var docPos = this.session.screenToDocumentPosition(targetScreenRow, screenPos.column); // move the cursor and update the desired column this.moveCursorTo(docPos.row, docPos.column + chars, chars === 0); } - /** * Moves the selection to the position indicated by its `row` and `column`. * @param {Point} position The position to move to diff --git a/src/test/all_browser.js b/src/test/all_browser.js index 994673a1a52..a5982db94a9 100644 --- a/src/test/all_browser.js +++ b/src/test/all_browser.js @@ -1,5 +1,5 @@ "use strict"; - +/*global globalThis*/ require("ace/lib/fixoldbrowsers"); var runner = require("./run"); @@ -31,9 +31,11 @@ window.addEventListener('unhandledrejection', (event) => { } }); +var hideLog = localStorage.getItem("hideLog") === "true"; var hidePassed = localStorage.getItem("hidePassedTests") === "true"; runner.pauseOnError = localStorage.getItem("pauseTestsOnError") === "true"; log.classList.toggle("hide-passed", hidePassed); +log.classList.toggle("compact-log", hideLog); var testNames = require("./test_list").filter(name => !/_test\/highlight_rules_test/.test(name)); var html = [ @@ -55,6 +57,16 @@ var html = [ checked: hidePassed ? "checked" : undefined }], ["label", {for: "hide-passed"}, "Hide passed tests"], + ["input", {type: "checkbox", id: "hide-log", + onchange: function() { + hideLog = this.checked; + log.classList.toggle("compact-log", hideLog); + localStorage.setItem("hideLog", hideLog); + }, + checked: hideLog ? "checked" : undefined + }], + ["label", {for: "hide-log"}, "Hide log"], + ["br"], ["input", {type: "checkbox", id: "wait-on-error", onchange: function() { runner.pauseOnError = this.checked; localStorage.setItem("pauseTestsOnError", runner.pauseOnError); @@ -242,3 +254,35 @@ require(selectedTests, async function() { resumeOrRetry(); }); + + +function showMockdom(mockNode) { + if (!mockNode) mockNode = document.body; + var global = globalThis; + var el = global.document.createElementOrig.bind(global.document); + var text = global.document.createTextNodeOrig.bind(global.document); + function cloneNode(node) { + if (node.nodeType == 3) { + return text(node.data); + } + var newNode = el(node.localName); + node.attributes.forEach(function(attr) { + newNode.setAttribute(attr.name, attr.value); + }); + node.childNodes.forEach(function(ch) { + newNode.appendChild(cloneNode(ch)); + }); + var rect = node.getBoundingClientRect(); // to compute sizes + newNode.style.top = rect.top + "px"; + newNode.style.left = rect.left + "px"; + newNode.style.height = rect.height + "px"; + newNode.style.width = rect.width + "px"; + newNode.style.position = "fixed"; + return newNode; + } + var result = cloneNode(mockNode || global.document.documentElement); + return global.__origBody__.appendChild(result); + +} + +globalThis.showMockdom = showMockdom; \ No newline at end of file diff --git a/src/test/tests.html b/src/test/tests.html index 2e716575fe6..59a477bd89e 100644 --- a/src/test/tests.html +++ b/src/test/tests.html @@ -50,6 +50,14 @@ .hide-passed div.passed { display: none; } + .compact-log>:not(.summary) { + display: none; + } + #log.compact-log { + top: initial; + bottom: 0; + height: auto; + } diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js index 88ce5c728f1..9c149131d19 100644 --- a/src/virtual_renderer.js +++ b/src/virtual_renderer.js @@ -98,8 +98,7 @@ class VirtualRenderer { column : 0 }; - this.$fontMetrics = new FontMetrics(this.container); - this.$textLayer.$setFontMetrics(this.$fontMetrics); + this.$fontMetrics = new FontMetrics(this.container, this.$textLayer, this); this.$textLayer.on("changeCharacterSize", function(e) { _self.updateCharacterSize(); _self.onResize(true, _self.gutterWidth, _self.$size.width, _self.$size.height); @@ -122,12 +121,14 @@ class VirtualRenderer { lastRow : 0, lineHeight : 0, characterWidth : 0, + fontMetrics: this.$fontMetrics, minHeight : 1, maxHeight : 1, offset : 0, height : 1, gutterOffset: 1 }; + this.$fontMetrics.config = this.layerConfig; this.scrollMargin = { left: 0, @@ -707,7 +708,7 @@ class VirtualRenderer { else { if (composition.useTextareaForIME) { var val = this.textarea.value; - w = this.characterWidth * (this.session.$getStringScreenWidth(val)[0]); + w = this.$fontMetrics.getTextWidth(val) + 1; } else { posTop += this.lineHeight + 2; @@ -911,9 +912,6 @@ class VirtualRenderer { this._signal("beforeRender", changes); - if (this.session && this.session.$bidiHandler) - this.session.$bidiHandler.updateCharacterWidths(this.$fontMetrics); - var config = this.layerConfig; // text, scrolling and resize changes can cause the view port size to change if (changes & this.CHANGE_FULL || @@ -939,23 +937,11 @@ class VirtualRenderer { } } config = this.layerConfig; - // update scrollbar first to not lose scroll position when gutter calls resize - this.$updateScrollBarV(); - if (changes & this.CHANGE_H_SCROLL) - this.$updateScrollBarH(); - - dom.translate(this.content, -this.scrollLeft, -config.offset); - - var width = config.width + 2 * this.$padding + "px"; - var height = config.minHeight + "px"; - - dom.setStyle(this.content.style, "width", width); - dom.setStyle(this.content.style, "height", height); } // horizontal scrolling if (changes & this.CHANGE_H_SCROLL) { - dom.translate(this.content, -this.scrollLeft, -config.offset); + this.$updateContentTransformAndSize(config, changes); this.scroller.className = this.scrollLeft <= 0 ? "ace_scroller " : "ace_scroller ace_scroll-left "; if (this.enableKeyboardAccessibility) this.scroller.className += this.keyboardFocusClassName; @@ -973,6 +959,8 @@ class VirtualRenderer { this.$markerBack.update(config); this.$markerFront.update(config); this.$cursorLayer.update(config); + // Ensure content transform and size are applied after rendering text + this.$updateContentTransformAndSize(config, changes); this.$moveTextAreaToCursor(); this._signal("afterRender", changes); return; @@ -998,6 +986,8 @@ class VirtualRenderer { this.$markerBack.update(config); this.$markerFront.update(config); this.$cursorLayer.update(config); + // Update content transform/size after text rendering + this.$updateContentTransformAndSize(config, changes); this.$moveTextAreaToCursor(); this._signal("afterRender", changes); return; @@ -1011,6 +1001,8 @@ class VirtualRenderer { if (this.$customScrollbar) { this.$scrollDecorator.$updateDecorators(config); } + // Ensure content transform and size are applied after rendering text + this.$updateContentTransformAndSize(config, changes); } else if (changes & this.CHANGE_LINES) { if (this.$updateLines() || (changes & this.CHANGE_GUTTER) && this.$showGutter) @@ -1085,6 +1077,55 @@ class VirtualRenderer { } } + /** + * Apply content translate and set content element size based on layer config. + * Extracted to avoid repeating the same DOM operations in multiple places. + * @param {{width:number,minHeight:number,offset:number}} config + */ + $updateContentTransformAndSize(config, changes) { + var sm = this.scrollMargin; + if (this.session && !this.session.getUseWrapMode()) { + var longestRendered = this.$getLongestRenderedLine(); + if (config.width < longestRendered) { + config.width = longestRendered; + } + } + // Also clamp horizontal scroll using the actual rendered widths when + // a session is available. Centralizing this here avoids repeating + // the same logic in multiple render branches. + if (this.session) { + this.session.setScrollLeft(Math.max(-sm.left, Math.min(this.scrollLeft, + config.width + 2 * this.$padding - this.$size.scrollerWidth + sm.right))); + } + + dom.translate(this.content, -this.scrollLeft, -config.offset); + var width = config.width + 2 * this.$padding + "px"; + var height = config.minHeight + "px"; + dom.setStyle(this.content.style, "width", width); + dom.setStyle(this.content.style, "height", height); + + + // update scrollbar first to not lose scroll position when gutter calls resize + this.$updateScrollBarV(); + if (changes & this.CHANGE_H_SCROLL) + this.$updateScrollBarH(); + } + + + $getLongestRenderedLine() { + var max = 0; + var cells = this.$textLayer.$lines.cells; + for (var i = 0; i < cells.length; i++) { + var lineElement = cells[i].element; + this.$fontMetrics.$scratchRange.setStart(lineElement, 0); + this.$fontMetrics.$scratchRange.setEnd(lineElement, lineElement.childNodes.length); + + var w = this.$fontMetrics.$scratchRange.getBoundingClientRect().width; + if (w > max) max = w; + } + return max; + } + /** * @returns {number} @@ -1126,8 +1167,9 @@ class VirtualRenderer { this.session.setScrollTop(Math.max(-sm.top, Math.min(this.scrollTop, maxHeight - size.scrollerHeight + sm.bottom))); - this.session.setScrollLeft(Math.max(-sm.left, Math.min(this.scrollLeft, - longestLine + 2 * this.$padding - size.scrollerWidth + sm.right))); + // horizontal scroll clamping is deferred until after the text layer + // is rendered so we can measure the actual rendered line widths + // using `$getLongestRenderedLine` instead of estimating here. var vScroll = !hideScrollbars && (this.$vScrollBarAlwaysVisible || size.scrollerHeight - maxHeight + scrollPastEnd < 0 || this.scrollTop > sm.top); @@ -1188,12 +1230,14 @@ class VirtualRenderer { lastRow : lastRow, lineHeight : lineHeight, characterWidth : this.characterWidth, + fontMetrics: this.$fontMetrics, minHeight : minHeight, maxHeight : maxHeight, offset : offset, gutterOffset : lineHeight ? Math.max(0, Math.ceil((offset + size.height - size.scrollerHeight) / lineHeight)) : 0, height : this.$size.scrollerHeight }; + this.$fontMetrics.config = this.layerConfig; if (this.session.$bidiHandler) this.session.$bidiHandler.setContentWidth(longestLine - this.$padding); @@ -1634,19 +1678,21 @@ class VirtualRenderer { pixelToScreenCoordinates(x, y) { var canvasPos; if (this.$hasCssTransforms) { - canvasPos = {top:0, left: 0}; + canvasPos = {top: this.margin.top, left: this.gutterWidth + this.margin.left}; var p = this.$fontMetrics.transformCoordinates([x, y]); - x = p[1] - this.gutterWidth - this.margin.left; - y = p[0]; + x = p[0]; + y = p[1]; } else { canvasPos = this.scroller.getBoundingClientRect(); } var offsetX = x + this.scrollLeft - canvasPos.left - this.$padding; var offset = offsetX / this.characterWidth; - var row = Math.floor((y + this.scrollTop - canvasPos.top) / this.lineHeight); + var row = (y + this.scrollTop - canvasPos.top) / this.lineHeight; var col = this.$blockCursor ? Math.floor(offset) : Math.round(offset); + col = this.$fontMetrics.$pixelToColumn(row, col, x, this.$blockCursor); + return {row: row, column: col, side: offset - col > 0 ? 1 : -1, offsetX: offsetX}; } @@ -1658,23 +1704,8 @@ class VirtualRenderer { */ screenToTextCoordinates(x, y) { - var canvasPos; - if (this.$hasCssTransforms) { - canvasPos = {top:0, left: 0}; - var p = this.$fontMetrics.transformCoordinates([x, y]); - x = p[1] - this.gutterWidth - this.margin.left; - y = p[0]; - } else { - canvasPos = this.scroller.getBoundingClientRect(); - } - - var offsetX = x + this.scrollLeft - canvasPos.left - this.$padding; - var offset = offsetX / this.characterWidth; - var col = this.$blockCursor ? Math.floor(offset) : Math.round(offset); - - var row = (y + this.scrollTop - canvasPos.top) / this.lineHeight; - - return this.session.screenToDocumentPosition(row, Math.max(col, 0), offsetX); + var screenPos = this.pixelToScreenCoordinates(x, y); + return this.session.screenToDocumentPosition(screenPos.row, Math.max(screenPos.column, 0)); } /** @@ -1685,15 +1716,22 @@ class VirtualRenderer { * @returns {{ pageX: number, pageY: number}} **/ textToScreenCoordinates(row, column) { - var canvasPos = this.scroller.getBoundingClientRect(); var pos = this.session.documentToScreenPosition(row, column); - var x = this.$padding + (this.session.$bidiHandler.isBidiRow(pos.row, row) - ? this.session.$bidiHandler.getPosLeft(pos.column) - : Math.round(pos.column * this.characterWidth)); - + var x = this.$padding + this.$fontMetrics.textWidth(pos.row, pos.column); var y = pos.row * this.lineHeight; - + if (this.$hasCssTransforms) { + var pagePos = this.$fontMetrics.transformCoordinates(null, [ + this.gutterWidth + this.margin.left + x - this.scrollLeft, + this.margin.top + y - this.scrollTop + ]); + + return { + pageX: pagePos[0], + pageY: pagePos[1] + }; + } + var canvasPos = this.scroller.getBoundingClientRect(); return { pageX: canvasPos.left + x - this.scrollLeft, pageY: canvasPos.top + y - this.scrollTop diff --git a/src/virtual_renderer_test.js b/src/virtual_renderer_test.js index fbf7589407f..574ee87de08 100644 --- a/src/virtual_renderer_test.js +++ b/src/virtual_renderer_test.js @@ -38,6 +38,7 @@ module.exports = { el.style.top = "30px"; el.style.width = "300px"; el.style.height = "100px"; + el.style.position = "fixed"; document.body.appendChild(el); var renderer = new VirtualRenderer(el); editor = new Editor(renderer); @@ -60,62 +61,90 @@ module.exports = { assert.position(renderer.screenToTextCoordinates(x+r.left, y+r.top), row, column); } - renderer.characterWidth = 10; - renderer.lineHeight = 15; - - testPixelToText(4, 0, 0, 0); - testPixelToText(5, 0, 0, 1); - testPixelToText(9, 0, 0, 1); - testPixelToText(10, 0, 0, 1); - testPixelToText(14, 0, 0, 1); - testPixelToText(15, 0, 0, 2); + testPixelToText(renderer.characterWidth * 0.4, 0, 0, 0); + testPixelToText(renderer.characterWidth * 0.5, 0, 0, 1); + testPixelToText(renderer.characterWidth * 0.9, 0, 0, 1); + testPixelToText(renderer.characterWidth * 1.0, 0, 0, 1); + testPixelToText(renderer.characterWidth * 1.4, 0, 0, 1); + testPixelToText(renderer.characterWidth * 1.5, 0, 0, 2); }, "test: handle css transforms" : function() { + editor.setValue("hello world\nabc -א,ב,ג+ xyz"); var renderer = editor.renderer; var fontMetrics = renderer.$fontMetrics; + editor.setOption("hasCssTransforms", true); setScreenPosition(editor.container, [20, 30, 300, 100]); - var measureNode = fontMetrics.$measureNode; - setScreenPosition(measureNode, [0, 0, 10 * measureNode.textContent.length, 15]); - setScreenPosition(fontMetrics.$main, [0, 0, 10 * measureNode.textContent.length, 15]); - fontMetrics.$characterSize.width = 10; - renderer.setPadding(0); renderer.onResize(true); - assert.equal(fontMetrics.getCharacterWidth(), 1); - - renderer.characterWidth = 10; - renderer.lineHeight = 15; - - renderer.gutterWidth = 40; - editor.setOption("hasCssTransforms", true); - editor.container.style.transform = "matrix3d(0.7, 0, 0, -0.00066, 0, 0.82, 0, -0.001, 0, 0, 1, 0, -100, -20, 10, 1)"; - editor.container.style.zoom = 1.5; - var pos = renderer.pixelToScreenCoordinates(100, 200); - - var els = fontMetrics.els; - var rects = [ - [0, 0], - [-37.60084843635559, 161.62494659423828], - [114.50254130363464, -6.890693664550781], - [98.85665202140808, 179.16063690185547] - ]; - rects.forEach(function(rect, i) { - els[i].getBoundingClientRect = function() { - return { left: rect[0], top: rect[1] }; - }; - }); - - var r0 = els[0].getBoundingClientRect(); - pos = renderer.pixelToScreenCoordinates(r0.left + 100, r0.top + 200); - assert.position(pos, 10, 11); - - var pos1 = fontMetrics.transformCoordinates(null, [0, 200]); - assert.ok(pos1[0] - rects[2][0] < 10e-6); - assert.ok(pos1[1] - rects[2][1] < 10e-6); - editor.renderer.$loop._flush(); + editor.container.style.transformOrigin = "0 0"; + var H1 = -0.0007, H2 = -0.001; + var m0 = 0.7, m1 = 0.1, m2 = 0.3, m3 = 0.82; + var t1 = 100, t2 = 20; + function testTransform() { + fontMetrics.config.$transformData = null; //FIXME + editor.container.style.transform = `matrix3d( + ${m0}, ${m2}, 0, ${H1}, + ${m1}, ${m3}, 0, ${H2}, + 0, 0, 1, 0, + ${t1}, ${t2}, 0, 1 + )`; + + var expected = [ + m0 - H1* t1, m1 - H2* t1, 0, + m2 - H1* t2, m3 - H2* t2, 0, + H1, H2, 1 + ]; + + project(expected, [20, 30]); + + var transform = editor.renderer.$fontMetrics.getTransform(); + + for (var i = 0; i < 9; i++) { + assert.ok(Math.abs(transform.M[i] - expected[i]) < 10e-6, `Expected M[${i}] to be approximately ${expected[i]}, but got ${transform.M[i]}`); + } + + assert.equal(transform.t + "", [100 + 20, 20 + 30] + ""); + + var p = project(expected, [ + renderer.gutterWidth + renderer.$padding + renderer.characterWidth * 4, + renderer.lineHeight / 2 + ]); + p[0] += transform.t[0]; + p[1] += transform.t[1]; + + var pos = renderer.pixelToScreenCoordinates(p[0], p[1]); + + var docPos = editor.session.screenToDocumentPosition(pos.row, pos.column); + assert.position(docPos, 0, 4); + + editor.renderer.$loop._flush(); + } + + testTransform(); + H1 = H2 = 0; + testTransform(); + m0 = m1 = m3 = 1; + m2 = -1; + testTransform(); + + function project(M, point) { + var px = point[0], py = point[1]; + var k = 1 / (M[6] * px + M[7] * py + M[8]); + return [(M[0] * px + M[1] * py + M[2]) * k, (M[3] * px + M[4] * py + M[5]) * k]; + } + }, + "test pixelposition in surrogate pairs": function() { + var renderer = editor.renderer; + editor.setValue("ab\ud83d\ude02cd"); + renderer.onResize(true); + var p1 = renderer.textToScreenCoordinates(0, 2); + var p2 = renderer.textToScreenCoordinates(0, 4); + for (var i = 0.01; i <= 1.1; i+=0.1) { + var pos = renderer.pixelToScreenCoordinates((1-i) * p1.pageX+ i * p2.pageX, p1.pageY); + assert.position(pos, 0, i < 0.5 ? 2 : 4); + } }, - "test scrollmargin + autosize": async function(done) { editor.setOptions({ maxLines: 100, diff --git a/types/ace-modules.d.ts b/types/ace-modules.d.ts index dd8b7d432fd..08e4c19aebf 100644 --- a/types/ace-modules.d.ts +++ b/types/ace-modules.d.ts @@ -2,7 +2,12 @@ declare module "ace-code/src/layer/font_metrics" { export class FontMetrics { - constructor(parentEl: HTMLElement); + constructor(parentEl: HTMLElement, textLayer: any, renderer: any); + config: { + characterWidth: number; + }; + textLayer: any; + renderer: any; el: HTMLDivElement; checkForSizeChanges(size?: { height: number; @@ -12,9 +17,67 @@ declare module "ace-code/src/layer/font_metrics" { allowBoldFonts: boolean; setPolling(val: boolean): void; getCharacterWidth(ch: any): any; + getTextWidth(text: any): number; destroy(): void; els: any[] | HTMLElement | Text; + getTransform(): any; transformCoordinates(clientPos: any, elPos: any): any[]; + recoverRect(transform: { + M: number[]; + t: number[]; + }, bbox: { + left: number; + top: number; + width: number; + height: number; + }): { + left: any; + top: any; + width: any; + height: any; + right: any; + bottom: any; + mappingIdx: number; + } | { + left: number; + top: number; + width: number; + height: any; + right: number; + bottom: number; + }; + /** + * Calculates the width of the text up to a specific scrrenColumn on a given screen row. + * + * @param {number} screenRow - The row index on the screen for which the text width is calculated. + * @param {number} screenColumn - The column index up to which the text width is measured. + * @returns {number} The width of the text in pixels up to the specified column. + */ + textWidth(screenRow: number, screenColumn: number): number; + /** + * Calculates and returns an array of rectangles representing the visual positions + * of a range of text between two screen positions within a text layer. + * + * @param {Object} startScreenPos - The starting screen position of the range. + * @param {number} startScreenPos.row - The row index of the starting position. + * @param {number} startScreenPos.column - The column index of the starting position. + * @param {Object} endScreenPos - The ending screen position of the range. + * @param {number} endScreenPos.row - The row index of the ending position. + * @param {number} endScreenPos.column - The column index of the ending position. + * @returns {Array} An array of rectangle objects representing the visual + * positions of the text range. Each rectangle object contains: + * - `left` {number}: The left offset of the rectangle relative to the text layer. + * - `width` {number}: The width of the rectangle. + * If an error occurs or the line element is not found, a fallback rectangle is returned + * based on character width and column positions. + */ + getRects(startScreenPos: { + row: number; + column: number; + }, endScreenPos: { + row: number; + column: number; + }): Array; } namespace Ace { type EventEmitter void; @@ -569,6 +633,7 @@ declare module "ace-code/src/layer/cursor" { getPixelPosition(position?: import("ace-code").Ace.Point, onScreen?: boolean): { left: number; top: number; + width: number; }; isCursorInView(pixelPos: any, config: any): boolean; update(config: any): void; @@ -896,6 +961,7 @@ declare module "ace-code/src/virtual_renderer" { lastRow: number; lineHeight: number; characterWidth: number; + fontMetrics: FontMetrics; minHeight: number; maxHeight: number; offset: number; @@ -3901,49 +3967,18 @@ declare module "ace-code/src/bidihandler" { * @param {Number} [docRow] the document row to be checked [optional] * @param {Number} [splitIndex] the wrapped screen line index [ optional] **/ - isBidiRow(screenRow: number, docRow?: number, splitIndex?: number): any; + isBidiRow(screenRow: number, docRow?: number, splitIndex?: number): boolean; getDocumentRow(): number; getSplitIndex(): number; - updateRowLine(docRow: any, splitIndex: any): void; - updateBidiMap(): void; /** * Resets stored info related to current screen row **/ markAsDirty(): void; - /** - * Updates array of character widths - * @param {Object} fontMetrics metrics - * - **/ - updateCharacterWidths(fontMetrics: any): void; - characterWidth: any; setShowInvisibles(showInvisibles: any): void; setEolChar(eolChar: any): void; setContentWidth(width: any): void; isRtlLine(row: any): boolean; setRtlDirection(editor: any, isRtlDir: any): void; - /** - * Returns offset of character at position defined by column. - * @param {Number} col the screen column position - * - * @return {Number} horizontal pixel offset of given screen column - **/ - getPosLeft(col: number): number; - /** - * Returns 'selections' - array of objects defining set of selection rectangles - * @param {Number} startCol the start column position - * @param {Number} endCol the end column position - * - * @return {Object[]} Each object contains 'left' and 'width' values defining selection rectangle. - **/ - getSelections(startCol: number, endCol: number): any[]; - /** - * Converts character coordinates on the screen to respective document column number - * @param {Number} posX character horizontal offset - * - * @return {Number} screen column number corresponding to given pixel offset - **/ - offsetToCol(posX: number): number; } import bidiUtil = require("ace-code/src/lib/bidiutil"); } @@ -4600,13 +4635,12 @@ declare module "ace-code/src/edit_session" { * Converts characters coordinates on the screen to characters coordinates within the document. [This takes into account code folding, word wrap, tab size, and any other visual modifications.]{: #conversionConsiderations} * @param {Number} screenRow The screen row to check * @param {Number} screenColumn The screen column to check - * @param {Number} [offsetX] screen character x-offset [optional] * * @returns {Point} The object returned has two properties: `row` and `column`. * * @related EditSession.documentToScreenPosition **/ - screenToDocumentPosition(screenRow: number, screenColumn: number, offsetX?: number): Point; + screenToDocumentPosition(screenRow: number, screenColumn: number): Point; /** * Converts document coordinates to screen coordinates. {:conversionConsiderations} * @param {Number|Point} docRow The document row to check