From 5203c961f98497307383e695df824f807377cab9 Mon Sep 17 00:00:00 2001 From: mkslanc Date: Thu, 24 Jul 2025 17:10:32 +0400 Subject: [PATCH 01/26] WIP: add text width measurement and cursor positioning based on pixel calculations # Conflicts: # src/virtual_renderer.js --- src/edit_session.js | 18 ++----- src/layer/cursor.js | 11 ++-- src/layer/marker.js | 18 ++++--- src/layer/text.js | 109 ++++++++++++++++++++++++++++++++-------- src/virtual_renderer.js | 58 +++++++++++++++++++-- 5 files changed, 168 insertions(+), 46 deletions(-) diff --git a/src/edit_session.js b/src/edit_session.js index fd03473cdd0..28437ae3aaf 100644 --- a/src/edit_session.js +++ b/src/edit_session.js @@ -1935,7 +1935,7 @@ class EditSession { var len = screenPos - lastSplit; for (var i = lastSplit; i < screenPos; i++) { var ch = tokens[i]; - if (ch === 12 || ch === 2) len -= 1; + if (ch === 12) len -= 1; } if (!splits.length) { @@ -2036,8 +2036,8 @@ class EditSession { split = lastSplit + wrapLimit; // The split is inside of a CHAR or CHAR_EXT token and no space // around -> force a split. - if (tokens[split] == CHAR_EXT) - split--; + /*if (tokens[split] == CHAR_EXT) + split--;*/ addSplit(split - indent); } return splits; @@ -2070,10 +2070,7 @@ class EditSession { } else if((c > 39 && c < 48) || (c > 57 && c < 64)) { arr.push(PUNCTUATION); } - // full width characters - else if (c >= 0x1100 && isFullWidth(c)) { - arr.push(CHAR, CHAR_EXT); - } else { + else { arr.push(CHAR); } } @@ -2103,12 +2100,7 @@ class EditSession { if (c == 9) { screenColumn += this.getScreenTabSize(screenColumn); } - // full width characters - else if (c >= 0x1100 && isFullWidth(c)) { - screenColumn += 2; - } else { - screenColumn += 1; - } + screenColumn += 1; if (screenColumn > maxScreenColumn) { break; } diff --git a/src/layer/cursor.js b/src/layer/cursor.js index 463cb8db2c4..d4d00225031 100644 --- a/src/layer/cursor.js +++ b/src/layer/cursor.js @@ -183,14 +183,17 @@ class Cursor { 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) + /*var cursorLeft = this.$padding + (this.session.$bidiHandler.isBidiRow(pos.row, position.row) ? this.session.$bidiHandler.getPosLeft(pos.column) - : pos.column * this.config.characterWidth); + : pos.column * this.config.characterWidth);*/ + var textWidth = this.config.textWidth(pos.row, position.column); + var cursorLeft = this.$padding + textWidth; var cursorTop = (pos.row - (onScreen ? this.config.firstRowScreen : 0)) * this.config.lineHeight; + var cursorWidth = (this.config.textWidth(pos.row, pos.column + 1) - textWidth) || this.config.characterWidth; - return {left : cursorLeft, top : cursorTop}; + return {left : cursorLeft, top : cursorTop, width : cursorWidth}; } isCursorInView(pixelPos, config) { @@ -223,7 +226,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/marker.js b/src/layer/marker.js index 3307c7b89b9..71a1b4e347d 100644 --- a/src/layer/marker.js +++ b/src/layer/marker.js @@ -77,10 +77,13 @@ class Marker { var range = marker.range.clipRows(config.firstRow, config.lastRow); if (range.isEmpty()) continue; - range = range.toScreenRange(this.session); + var docRange = range; + range = range.toScreenRange(this.session); //TODO: what the point in that? + range.start.column = docRange.start.column; + range.end.column = docRange.end.column; 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.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); @@ -154,7 +157,9 @@ 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 textWidth = config.textWidth(range.start.row, range.start.column); + var left = padding + textWidth; + var width = config.width - textWidth; extraStyle = extraStyle || ""; if (this.session.$bidiHandler.isBidiRow(range.start.row)) { @@ -176,7 +181,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.textWidth(range.end.row, range.end.column); this.elt( clazz + " ace_br12", @@ -216,10 +221,11 @@ 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 textWidth = config.textWidth(range.start.row, range.start.column); + var width = config.textWidth(range.start.row, range.end.column + (extraLength || 0)) - textWidth; var top = this.$getTop(range.start.row, config); - var left = this.$padding + range.start.column * config.characterWidth; + var left = this.$padding + textWidth; this.elt( clazz, diff --git a/src/layer/text.js b/src/layer/text.js index 5d0df50e2c0..40eb7f9dfd6 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]+)/g; var valueFragment = this.dom.createFragment(this.element); @@ -360,8 +360,6 @@ class Text { var tab = m[1]; var simpleSpace = m[2]; var controlCharacter = m[3]; - var cjkSpace = m[4]; - var cjk = m[5]; if (!self.showSpaces && simpleSpace) continue; @@ -394,22 +392,6 @@ class Text { span.className = "ace_invisible ace_invisible_space ace_invalid"; 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); } } @@ -419,7 +401,7 @@ class Text { var classes = "ace_" + token.type.replace(/\./g, " ace_"); var span = this.dom.createElement("span"); if (token.type == "fold"){ - span.style.width = (token.value.length * this.config.characterWidth) + "px"; + span.style.width = (value.length * this.config.characterWidth) + "px"; span.setAttribute("title", nls("inline-fold.closed.title", "Unfold code")); } @@ -435,6 +417,93 @@ class Text { return screenColumn + value.length; } + $measureText(tokens, column) { + // build HTML for tokens + var parent = this.dom.createElement("span"); + var len = 0; + var i = 0; + var screenColumn = 0; + while (len < column && i < tokens.length) { + var token = tokens[i++]; + var value = token.value; + + // truncate once length is larger than 'column' + len += value.length; + if (len > column) + value = value.substring(0, value.length - (len - column)); + + screenColumn = this.$renderToken(parent, screenColumn, token, value); + } + + // Create a measurement container that isolates width calculation + var measureContainer = document.createElement("div"); + var containerStyle = measureContainer.style; + containerStyle.position = "absolute"; + containerStyle.top = "-1000000px"; + containerStyle.left = "-1000000px"; + containerStyle.width = "auto"; + containerStyle.height = "auto"; + containerStyle.overflow = "visible"; + containerStyle.pointerEvents = "none"; + + var el = document.createElement("div"); + var style = el.style; + style.display = "inline-block"; + style.width = "auto"; + style.whiteSpace = "pre"; + style.margin = "0"; + style.padding = "0"; + style.border = "none"; + + var computedStyle = window.getComputedStyle(this.element); + style.font = computedStyle.font; + style.fontSize = computedStyle.fontSize; + style.fontFamily = computedStyle.fontFamily; + style.fontWeight = computedStyle.fontWeight; + style.fontStyle = computedStyle.fontStyle; + style.letterSpacing = computedStyle.letterSpacing; + style.lineHeight = computedStyle.lineHeight; + + el.className = "ace_line"; + el.innerHTML = parent.innerHTML; + + measureContainer.appendChild(el); + this.element.appendChild(measureContainer); + console.log(el) + + // measure pixel length + var width = el.offsetWidth; + console.log(width) + this.element.removeChild(measureContainer); + + return width; + } + + textWidth(row, column) { + //return this.$characterSize.width * column; + var line = this.session.getTokens(row); + + // cache in tokens object + // this way the cache gets invalidated automatically when the tokens change + /* if (line.widthCache && line.widthCache[column]) { + // invalidate if font size has changed + if (line.widthCache.rowHeight == this.getLineHeight()) { + return line.widthCache[column]; + } + else + delete line.widthCache; + }*/ + + var width = this.$measureText(line, column); + + /*if (!line.widthCache) + line.widthCache = { rowHeight: this.getLineHeight() }; + + line.widthCache[column] = width;*/ + + return width; + } + renderIndentGuide(parent, value, max) { var cols = value.search(this.$indentGuideRe); if (cols <= 0 || cols >= max) diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js index 88ce5c728f1..98a303d9fbb 100644 --- a/src/virtual_renderer.js +++ b/src/virtual_renderer.js @@ -122,6 +122,7 @@ class VirtualRenderer { lastRow : 0, lineHeight : 0, characterWidth : 0, + textWidth: function() { return 1; }, minHeight : 1, maxHeight : 1, offset : 0, @@ -1188,6 +1189,7 @@ class VirtualRenderer { lastRow : lastRow, lineHeight : lineHeight, characterWidth : this.characterWidth, + textWidth: this.$textLayer.textWidth.bind(this.$textLayer), minHeight : minHeight, maxHeight : maxHeight, offset : offset, @@ -1624,6 +1626,44 @@ class VirtualRenderer { return true; } + /** + * Convert pixel to column using binary search with actual measurements + */ + $pixelToColumn(row, offsetX) { + if (row == undefined || offsetX <= 0) return 0; + + var lineText = this.session.getLine(row) || ""; + + var left = 0; + var right = lineText.length; + var bestCol = 0; + + while (left <= right) { + var mid = Math.floor((left + right) / 2); + var width = this.$textLayer.textWidth(row, mid); + + if (width <= offsetX) { + bestCol = mid; + left = mid + 1; + } else { + right = mid - 1; + } + } + + if (bestCol < lineText.length) { + var currentWidth = this.$textLayer.textWidth(row, bestCol); + var nextWidth = this.$textLayer.textWidth(row, bestCol + 1); + var charWidth = nextWidth - currentWidth; + var clickPos = offsetX - currentWidth; + + if (clickPos > charWidth / 2) { + bestCol++; + } + } + + return Math.min(bestCol, lineText.length); + } + /** * * @param {number} x @@ -1667,12 +1707,24 @@ class VirtualRenderer { } else { canvasPos = this.scroller.getBoundingClientRect(); } - + var row = (y + this.scrollTop - canvasPos.top) / this.lineHeight; var offsetX = x + this.scrollLeft - canvasPos.left - this.$padding; - var offset = offsetX / this.characterWidth; + + var docRow = this.session.screenToDocumentRow(row, 0); + + var col = this.$pixelToColumn(docRow, offsetX); + + var side = 0; +/* if (col > 0 && col < lineText.length) { + var actualPos = this.$columnToPixel(lineText, col); + var nextPos = this.$columnToPixel(lineText, col + 1); + var charWidth = nextPos - actualPos; + side = (offsetX - actualPos) > charWidth / 2 ? 1 : -1; + }*/ + /*var offset = offsetX / this.characterWidth; var col = this.$blockCursor ? Math.floor(offset) : Math.round(offset); - var row = (y + this.scrollTop - canvasPos.top) / this.lineHeight; + var row = (y + this.scrollTop - canvasPos.top) / this.lineHeight;*/ return this.session.screenToDocumentPosition(row, Math.max(col, 0), offsetX); } From 982731e238e752b9176d3df399b7d50ef94aa05b Mon Sep 17 00:00:00 2001 From: mkslanc Date: Fri, 25 Jul 2025 16:39:20 +0400 Subject: [PATCH 02/26] text width calculation based on createRange --- src/layer/text.js | 131 ++++++++++++++++++++-------------------------- 1 file changed, 56 insertions(+), 75 deletions(-) diff --git a/src/layer/text.js b/src/layer/text.js index 40eb7f9dfd6..13c0868b812 100644 --- a/src/layer/text.js +++ b/src/layer/text.js @@ -417,91 +417,72 @@ class Text { return screenColumn + value.length; } - $measureText(tokens, column) { - // build HTML for tokens - var parent = this.dom.createElement("span"); - var len = 0; - var i = 0; - var screenColumn = 0; - while (len < column && i < tokens.length) { - var token = tokens[i++]; - var value = token.value; - - // truncate once length is larger than 'column' - len += value.length; - if (len > column) - value = value.substring(0, value.length - (len - column)); + textWidth(row, column) { + if (column === 0) return 0; - screenColumn = this.$renderToken(parent, screenColumn, token, value); + var lineElement = this.element.children[row - this.config.firstRow]; + if (!lineElement) { + // Fallback for lines not currently rendered + return column * this.config.characterWidth; } - // Create a measurement container that isolates width calculation - var measureContainer = document.createElement("div"); - var containerStyle = measureContainer.style; - containerStyle.position = "absolute"; - containerStyle.top = "-1000000px"; - containerStyle.left = "-1000000px"; - containerStyle.width = "auto"; - containerStyle.height = "auto"; - containerStyle.overflow = "visible"; - containerStyle.pointerEvents = "none"; - - var el = document.createElement("div"); - var style = el.style; - style.display = "inline-block"; - style.width = "auto"; - style.whiteSpace = "pre"; - style.margin = "0"; - style.padding = "0"; - style.border = "none"; - - var computedStyle = window.getComputedStyle(this.element); - style.font = computedStyle.font; - style.fontSize = computedStyle.fontSize; - style.fontFamily = computedStyle.fontFamily; - style.fontWeight = computedStyle.fontWeight; - style.fontStyle = computedStyle.fontStyle; - style.letterSpacing = computedStyle.letterSpacing; - style.lineHeight = computedStyle.lineHeight; - - el.className = "ace_line"; - el.innerHTML = parent.innerHTML; - - measureContainer.appendChild(el); - this.element.appendChild(measureContainer); - console.log(el) - - // measure pixel length - var width = el.offsetWidth; - console.log(width) - this.element.removeChild(measureContainer); - - return width; + return this.$measureLineToColumn(lineElement, column); } - textWidth(row, column) { - //return this.$characterSize.width * column; - var line = this.session.getTokens(row); - - // cache in tokens object - // this way the cache gets invalidated automatically when the tokens change - /* if (line.widthCache && line.widthCache[column]) { - // invalidate if font size has changed - if (line.widthCache.rowHeight == this.getLineHeight()) { - return line.widthCache[column]; + $measureLineToColumn(lineElement, column) { + if (!this.$scratchRange) { + this.$scratchRange = document.createRange(); + } + + try { + var firstTextNode = this.$findFirstTextNode(lineElement); + if (!firstTextNode) { + return column * this.config.characterWidth; + } + + var position = this.$findColumnPosition(lineElement, column); + if (!position) { + return column * this.config.characterWidth; } - else - delete line.widthCache; - }*/ - var width = this.$measureText(line, column); + this.$scratchRange.setStart(firstTextNode, 0); + this.$scratchRange.setEnd(position.node, position.offset); + + var rect = this.$scratchRange.getBoundingClientRect(); + return rect.width; + } catch (e) { + return column * this.config.characterWidth; + } + } + + $findFirstTextNode(element) { + var walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, function (node) { + return node.nodeValue && node.nodeValue.length > 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + }); + return walker.nextNode(); + } + + $findColumnPosition(lineElement, targetColumn) { + var walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null); + + var currentColumn = 0; + var node; - /*if (!line.widthCache) - line.widthCache = { rowHeight: this.getLineHeight() }; + while (node = walker.nextNode()) { + var nodeText = node.nodeValue; + var nodeLength = nodeText.length; - line.widthCache[column] = width;*/ + if (currentColumn + nodeLength >= targetColumn) { + return { + node: node, + offset: targetColumn - currentColumn + }; + } + currentColumn += nodeLength; + } - return width; + // Column is beyond the line content + return null; } renderIndentGuide(parent, value, max) { From 3c0e0c00ecf39b786400633b344c28bc05358470 Mon Sep 17 00:00:00 2001 From: nightwing Date: Sun, 22 Feb 2026 20:11:56 +0400 Subject: [PATCH 03/26] refactor --- demo/kitchen-sink/demo.js | 10 ++ demo/kitchen-sink/inline_editor.js | 2 +- src/bidihandler.js | 99 -------------- src/edit_session.js | 17 +-- src/ext/diff/inline_diff_view.js | 14 +- src/keyboard/vim.js | 2 +- src/layer/cursor.js | 9 +- src/layer/font_metrics.js | 205 ++++++++++++++++++++++++++++- src/layer/lines.js | 19 +++ src/layer/marker.js | 35 +++-- src/layer/text.js | 70 +--------- src/virtual_renderer.js | 80 ++--------- 12 files changed, 275 insertions(+), 287 deletions(-) diff --git a/demo/kitchen-sink/demo.js b/demo/kitchen-sink/demo.js index b71c7a5adb5..f38aaf14921 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" + "ジャパン + 八洲", 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/src/bidihandler.js b/src/bidihandler.js index 20f023613e2..503f9a21aae 100644 --- a/src/bidihandler.js +++ b/src/bidihandler.js @@ -258,105 +258,6 @@ class BidiHandler { 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 28437ae3aaf..e7f5d84e61c 100644 --- a/src/edit_session.js +++ b/src/edit_session.js @@ -1935,7 +1935,7 @@ class EditSession { var len = screenPos - lastSplit; for (var i = lastSplit; i < screenPos; i++) { var ch = tokens[i]; - if (ch === 12) len -= 1; + if (ch === 12 || ch === 2) len -= 1; } if (!splits.length) { @@ -2036,8 +2036,8 @@ class EditSession { split = lastSplit + wrapLimit; // The split is inside of a CHAR or CHAR_EXT token and no space // around -> force a split. - /*if (tokens[split] == CHAR_EXT) - split--;*/ + if (tokens[split] == CHAR_EXT) + split--; addSplit(split - indent); } return splits; @@ -2070,7 +2070,10 @@ class EditSession { } else if((c > 39 && c < 48) || (c > 57 && c < 64)) { arr.push(PUNCTUATION); } - else { + // full width characters + else if (c >= 0x1100 && isFullWidth(c)) { + arr.push(CHAR, CHAR_EXT); + } else { arr.push(CHAR); } } @@ -2099,8 +2102,9 @@ class EditSession { // tab if (c == 9) { screenColumn += this.getScreenTabSize(screenColumn); + } else { + screenColumn += 1; } - screenColumn += 1; if (screenColumn > maxScreenColumn) { break; } @@ -2308,9 +2312,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/ext/diff/inline_diff_view.js b/src/ext/diff/inline_diff_view.js index e3cb1984e51..cee36b90102 100644 --- a/src/ext/diff/inline_diff_view.js +++ b/src/ext/diff/inline_diff_view.js @@ -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/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 d4d00225031..e997b4ee4fa 100644 --- a/src/layer/cursor.js +++ b/src/layer/cursor.js @@ -183,17 +183,14 @@ class Cursor { 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.textWidth(pos.row, position.column); + 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.textWidth(pos.row, pos.column + 1) - textWidth) || this.config.characterWidth; + var cursorWidth = (this.config.fontMetrics.textWidth(pos.row, pos.column + 1) - textWidth) || this.config.characterWidth; - return {left : cursorLeft, top : cursorTop, width : cursorWidth}; + return {left : cursorLeft, top : cursorTop, width : Math.abs(cursorWidth)}; } isCursorInView(pixelPos, config) { diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index 4ddc758dc82..57ec546f94c 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -14,7 +14,10 @@ class FontMetrics { /** * @param {HTMLElement} parentEl */ - constructor(parentEl) { + constructor(parentEl, textLayer) { + this.$characterSize = {width: 0, height: 0}; + this.textLayer = textLayer; + this.el = dom.createElement("div"); this.$setMeasureNodeStyles(this.el.style, true); @@ -31,13 +34,12 @@ 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); } $setMeasureNodeStyles(style, isRoot) { @@ -207,8 +209,201 @@ class FontMetrics { return mul(L, f); } + + $findElementForScreenRow(screenRow) { + var textLayer = this.textLayer; + var data = textLayer.$lines.$getCellByScreenRow(screenRow, textLayer.config); + var lineElement = data && data.cell.element; + // console.trace("cell", lineElement, screenRow); + + if (lineElement && textLayer.$useLineGroups()) { + var index = Math.floor(data.offset / textLayer.config.lineHeight); + lineElement = lineElement.children[Math.max(index, 0)]; + } + return lineElement; + } + + textWidth(row, column) { + + var textLayer = this.textLayer; + + var lineElement = this.$findElementForScreenRow(row); + if (!lineElement) { + // Fallback for lines not currently rendered + return column * textLayer.config.characterWidth; + } + + return this.$measureLineToColumn(lineElement, column); + } + + $measureLineToColumn(lineElement, screenColumn) { + var textLayer = this.textLayer; + if (!this.$scratchRange) { + this.$scratchRange = document.createRange(); + } + + try { + var position = this.$findColumnPosition(lineElement, screenColumn); + if (!position) { + return screenColumn * textLayer.config.characterWidth; + } + + this.$scratchRange.setStart(position.node, position.offset); + this.$scratchRange.setEnd(position.node, position.offset); + + var rangeRect = this.$scratchRange.getBoundingClientRect(); + var rect = textLayer.element.getBoundingClientRect() + return rangeRect.left - rect.left; + } catch (e) { + console.error("Error measuring text width:", e); + return screenColumn * textLayer.config.characterWidth; + } + } + + $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 + }; + } + currentColumn += nodeLength; + lastNode = node; + } + + return lastNode && { + node: lastNode, + offset: lastNode.nodeValue.length + }; + } + + $pixelToColumn(screenRow, screenColumn1, x, blockCursor) { + var scratchRange = this.$scratchRange; + var lineElement = this.$findElementForScreenRow(screenRow); + if (!lineElement) return screenColumn1; + + var screenColumn = 0; + function getRects(node) { + if (node.nodeType === Node.TEXT_NODE) { + scratchRange.setStart(node, 0); + scratchRange.setEnd(node, node.nodeValue.length); + return scratchRange.getClientRects(); + } else if (node.nodeType === Node.ELEMENT_NODE) { + return node.getClientRects(); + } + return []; + } + function search(node) { + if (node.nodeType === Node.TEXT_NODE) { + var textLength = node.nodeValue.length; + for (var j = 0; j < textLength; j++) { + scratchRange.setStart(node, j); + scratchRange.setEnd(node, j + 1); + let rect = scratchRange.getBoundingClientRect(); + if (rect.left <= x && x <= rect.right) { + screenColumn += j + if (!blockCursor && x > rect.left + rect.width / 2) { + screenColumn++; + } + return screenColumn; + } + } + } 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; + } + + 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); + + this.$scratchRange.setStart(p1.node, p1.offset); + this.$scratchRange.setEnd(p2.node, p2.offset); + var rangeRects = this.$scratchRange.getClientRects(); + 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.row * textLayer.config.characterWidth, + width: (endScreenPos.column - startScreenPos.column) * textLayer.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) || + (m.left <= rect.right && rect.right <= m.right) || + (m.left <= rect.left && 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; +} + oop.implement(FontMetrics.prototype, EventEmitter); 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 71a1b4e347d..870deae8864 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) { @@ -77,13 +79,10 @@ class Marker { var range = marker.range.clipRows(config.firstRow, config.lastRow); if (range.isEmpty()) continue; - var docRange = range; - range = range.toScreenRange(this.session); //TODO: what the point in that? - range.start.column = docRange.start.column; - range.end.column = docRange.end.column; + range = range.toScreenRange(this.session); if (marker.renderer) { var top = this.$getTop(range.start.row, config); - var left = this.$padding + config.textWidth(range.start.row, range.start.column); + 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); @@ -102,6 +101,8 @@ class Marker { while (this.i < this.element.childElementCount) this.element.removeChild(this.element.lastChild); } + + this.element.style.display = ""; } /** @@ -157,9 +158,7 @@ class Marker { var padding = this.$padding; var height = config.lineHeight; var top = this.$getTop(range.start.row, config); - var textWidth = config.textWidth(range.start.row, range.start.column); - var left = padding + textWidth; - var width = config.width - textWidth; + var left = padding + config.fontMetrics.textWidth(range.start.row, range.start.column);; extraStyle = extraStyle || ""; if (this.session.$bidiHandler.isBidiRow(range.start.row)) { @@ -181,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 = config.textWidth(range.end.row, range.end.column); + var width = config.fontMetrics.textWidth(range.end.row, range.end.column); this.elt( clazz + " ace_br12", @@ -221,18 +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 textWidth = config.textWidth(range.start.row, range.start.column); - var width = config.textWidth(range.start.row, range.end.column + (extraLength || 0)) - textWidth; + var right = config.fontMetrics.textWidth(range.start.row, (range.end.column + (extraLength || 0) )); var top = this.$getTop(range.start.row, config); - var left = this.$padding + textWidth; + 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 || "") ); } @@ -247,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 13c0868b812..eaeff56e59f 100644 --- a/src/layer/text.js +++ b/src/layer/text.js @@ -401,7 +401,7 @@ class Text { var classes = "ace_" + token.type.replace(/\./g, " ace_"); var span = this.dom.createElement("span"); if (token.type == "fold"){ - span.style.width = (value.length * this.config.characterWidth) + "px"; + span.style.width = (token.value.length * this.config.characterWidth) + "px"; span.setAttribute("title", nls("inline-fold.closed.title", "Unfold code")); } @@ -417,74 +417,6 @@ class Text { return screenColumn + value.length; } - textWidth(row, column) { - if (column === 0) return 0; - - var lineElement = this.element.children[row - this.config.firstRow]; - if (!lineElement) { - // Fallback for lines not currently rendered - return column * this.config.characterWidth; - } - - return this.$measureLineToColumn(lineElement, column); - } - - $measureLineToColumn(lineElement, column) { - if (!this.$scratchRange) { - this.$scratchRange = document.createRange(); - } - - try { - var firstTextNode = this.$findFirstTextNode(lineElement); - if (!firstTextNode) { - return column * this.config.characterWidth; - } - - var position = this.$findColumnPosition(lineElement, column); - if (!position) { - return column * this.config.characterWidth; - } - - this.$scratchRange.setStart(firstTextNode, 0); - this.$scratchRange.setEnd(position.node, position.offset); - - var rect = this.$scratchRange.getBoundingClientRect(); - return rect.width; - } catch (e) { - return column * this.config.characterWidth; - } - } - - $findFirstTextNode(element) { - var walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, function (node) { - return node.nodeValue && node.nodeValue.length > 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; - }); - return walker.nextNode(); - } - - $findColumnPosition(lineElement, targetColumn) { - var walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null); - - var currentColumn = 0; - var node; - - while (node = walker.nextNode()) { - var nodeText = node.nodeValue; - var nodeLength = nodeText.length; - - if (currentColumn + nodeLength >= targetColumn) { - return { - node: node, - offset: targetColumn - currentColumn - }; - } - currentColumn += nodeLength; - } - - // Column is beyond the line content - return null; - } - renderIndentGuide(parent, value, max) { var cols = value.search(this.$indentGuideRe); if (cols <= 0 || cols >= max) diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js index 98a303d9fbb..a60b6462425 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.$textLayer.on("changeCharacterSize", function(e) { _self.updateCharacterSize(); _self.onResize(true, _self.gutterWidth, _self.$size.width, _self.$size.height); @@ -122,7 +121,7 @@ class VirtualRenderer { lastRow : 0, lineHeight : 0, characterWidth : 0, - textWidth: function() { return 1; }, + fontMetrics: this.$fontMetrics, minHeight : 1, maxHeight : 1, offset : 0, @@ -1189,7 +1188,7 @@ class VirtualRenderer { lastRow : lastRow, lineHeight : lineHeight, characterWidth : this.characterWidth, - textWidth: this.$textLayer.textWidth.bind(this.$textLayer), + fontMetrics: this.$fontMetrics, minHeight : minHeight, maxHeight : maxHeight, offset : offset, @@ -1626,44 +1625,6 @@ class VirtualRenderer { return true; } - /** - * Convert pixel to column using binary search with actual measurements - */ - $pixelToColumn(row, offsetX) { - if (row == undefined || offsetX <= 0) return 0; - - var lineText = this.session.getLine(row) || ""; - - var left = 0; - var right = lineText.length; - var bestCol = 0; - - while (left <= right) { - var mid = Math.floor((left + right) / 2); - var width = this.$textLayer.textWidth(row, mid); - - if (width <= offsetX) { - bestCol = mid; - left = mid + 1; - } else { - right = mid - 1; - } - } - - if (bestCol < lineText.length) { - var currentWidth = this.$textLayer.textWidth(row, bestCol); - var nextWidth = this.$textLayer.textWidth(row, bestCol + 1); - var charWidth = nextWidth - currentWidth; - var clickPos = offsetX - currentWidth; - - if (clickPos > charWidth / 2) { - bestCol++; - } - } - - return Math.min(bestCol, lineText.length); - } - /** * * @param {number} x @@ -1684,9 +1645,11 @@ class VirtualRenderer { 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}; } @@ -1698,35 +1661,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 row = (y + this.scrollTop - canvasPos.top) / this.lineHeight; - var offsetX = x + this.scrollLeft - canvasPos.left - this.$padding; - - var docRow = this.session.screenToDocumentRow(row, 0); - - var col = this.$pixelToColumn(docRow, offsetX); - - var side = 0; -/* if (col > 0 && col < lineText.length) { - var actualPos = this.$columnToPixel(lineText, col); - var nextPos = this.$columnToPixel(lineText, col + 1); - var charWidth = nextPos - actualPos; - side = (offsetX - actualPos) > charWidth / 2 ? 1 : -1; - }*/ - /*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), screenPos.offsetX); } /** From 30ee806fe87154d80aa85f011bf9648b25dc5fbf Mon Sep 17 00:00:00 2001 From: nightwing Date: Mon, 23 Feb 2026 01:37:46 +0400 Subject: [PATCH 04/26] test --- src/layer/font_metrics.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index 57ec546f94c..4443b458990 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -224,11 +224,10 @@ class FontMetrics { } textWidth(row, column) { - var textLayer = this.textLayer; var lineElement = this.$findElementForScreenRow(row); - if (!lineElement) { + if (!lineElement || !document.createRange) { // Fallback for lines not currently rendered return column * textLayer.config.characterWidth; } From 66b07cb8d8949253378555f53a66c0a7316763e5 Mon Sep 17 00:00:00 2001 From: nightwing Date: Wed, 25 Feb 2026 22:43:00 +0400 Subject: [PATCH 05/26] address review comments --- src/layer/font_metrics.js | 104 ++++++++++++++++++++++++++++++-------- src/layer/marker.js | 2 +- 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index 4443b458990..5816878cb37 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -210,11 +210,16 @@ class FontMetrics { } + /** + * 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; var data = textLayer.$lines.$getCellByScreenRow(screenRow, textLayer.config); var lineElement = data && data.cell.element; - // console.trace("cell", lineElement, screenRow); if (lineElement && textLayer.$useLineGroups()) { var index = Math.floor(data.offset / textLayer.config.lineHeight); @@ -223,18 +228,36 @@ class FontMetrics { return lineElement; } - textWidth(row, column) { + /** + * 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 textLayer = this.textLayer; - var lineElement = this.$findElementForScreenRow(row); + var lineElement = this.$findElementForScreenRow(screenRow); if (!lineElement || !document.createRange) { // Fallback for lines not currently rendered - return column * textLayer.config.characterWidth; + return screenColumn * textLayer.config.characterWidth; } - return this.$measureLineToColumn(lineElement, column); + 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; if (!this.$scratchRange) { @@ -251,7 +274,7 @@ class FontMetrics { this.$scratchRange.setEnd(position.node, position.offset); var rangeRect = this.$scratchRange.getBoundingClientRect(); - var rect = textLayer.element.getBoundingClientRect() + var rect = textLayer.element.getBoundingClientRect(); return rangeRect.left - rect.left; } catch (e) { console.error("Error measuring text width:", e); @@ -259,6 +282,18 @@ class FontMetrics { } } + /** + * 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} | 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); @@ -285,6 +320,15 @@ class FontMetrics { }; } + /** + * 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} screenColumn1 - The initial screen column index. + * @param {number} x - The x-coordinate (in pixels) to convert to a column index. + * @param {boolean} blockCursor - Whether the cursor is in block mode. + * @returns {number} The calculated screen column index corresponding to the x-coordinate. + */ $pixelToColumn(screenRow, screenColumn1, x, blockCursor) { var scratchRange = this.$scratchRange; var lineElement = this.$findElementForScreenRow(screenRow); @@ -309,7 +353,7 @@ class FontMetrics { scratchRange.setEnd(node, j + 1); let rect = scratchRange.getBoundingClientRect(); if (rect.left <= x && x <= rect.right) { - screenColumn += j + screenColumn += j; if (!blockCursor && x > rect.left + rect.width / 2) { screenColumn++; } @@ -338,6 +382,23 @@ class FontMetrics { 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; @@ -347,24 +408,25 @@ class FontMetrics { try { var p1 = this.$findColumnPosition(lineElement, startScreenPos.column); var p2 = this.$findColumnPosition(lineElement, endScreenPos.column); - - this.$scratchRange.setStart(p1.node, p1.offset); - this.$scratchRange.setEnd(p2.node, p2.offset); - var rangeRects = this.$scratchRange.getClientRects(); - 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; + if (p1 && p2) { + this.$scratchRange.setStart(p1.node, p1.offset); + this.$scratchRange.setEnd(p2.node, p2.offset); + var rangeRects = this.$scratchRange.getClientRects(); + 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.row * textLayer.config.characterWidth, + left: startScreenPos.column * textLayer.config.characterWidth, width: (endScreenPos.column - startScreenPos.column) * textLayer.config.characterWidth, }]; } @@ -381,8 +443,6 @@ function mergeTouchingRects(rects) { if ( (m.left <= rect.left && rect.left <= m.right) || (m.left <= rect.right && rect.right <= m.right) || - (m.left <= rect.right && rect.right <= m.right) || - (m.left <= rect.left && rect.right <= m.right) || (rect.left <= m.left && m.right <= rect.right) ) { m.left = Math.min(m.left, rect.left); diff --git a/src/layer/marker.js b/src/layer/marker.js index 870deae8864..d3d2965afb4 100644 --- a/src/layer/marker.js +++ b/src/layer/marker.js @@ -158,7 +158,7 @@ class Marker { var padding = this.$padding; var height = config.lineHeight; var top = this.$getTop(range.start.row, config); - var left = padding + config.fontMetrics.textWidth(range.start.row, range.start.column);; + var left = padding + config.fontMetrics.textWidth(range.start.row, range.start.column); extraStyle = extraStyle || ""; if (this.session.$bidiHandler.isBidiRow(range.start.row)) { From 108fe739fc2d50579b295742aad2a4161da11e44 Mon Sep 17 00:00:00 2001 From: nightwing Date: Tue, 24 Mar 2026 03:50:40 +0400 Subject: [PATCH 06/26] fix most of failing tests --- demo/kitchen-sink/demo.js | 2 +- src/bidihandler.js | 21 --------------------- src/edit_session_test.js | 10 +++++----- src/layer/font_metrics.js | 30 +++++++++++++++--------------- src/layer/marker.js | 2 +- src/layer/text_markers_test.js | 5 +---- src/range_test.js | 2 +- src/virtual_renderer.js | 5 ++--- src/virtual_renderer_test.js | 15 ++++++--------- 9 files changed, 32 insertions(+), 60 deletions(-) diff --git a/demo/kitchen-sink/demo.js b/demo/kitchen-sink/demo.js index f38aaf14921..1cd8cd04c8e 100644 --- a/demo/kitchen-sink/demo.js +++ b/demo/kitchen-sink/demo.js @@ -677,7 +677,7 @@ optionsPanelContainer.insertBefore( ["div", {}, ["button", {onclick: function() { editor.setOption("fontFamily", "cursive"); - session.setValue( session.getValue() + "שלום עולם בעברית123" +"\n" + "ジャパン + 八洲", 1); + session.setValue( session.getValue() + "שלום עולם בעברית123" +"\n" + "ジャパン + 八洲\n" + "𒐫𒈙⸻ဪ", 1); }}, "cursive"], ["button", {onclick: function() { editor.setOption("fontFamily", "Tahoma"); diff --git a/src/bidihandler.js b/src/bidihandler.js index 503f9a21aae..aed46d3ffd8 100644 --- a/src/bidihandler.js +++ b/src/bidihandler.js @@ -174,27 +174,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; 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/layer/font_metrics.js b/src/layer/font_metrics.js index 5816878cb37..2b01c333f4b 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -15,6 +15,7 @@ class FontMetrics { * @param {HTMLElement} parentEl */ constructor(parentEl, textLayer) { + this.config = {characterWidth: 1}; this.$characterSize = {width: 0, height: 0}; this.textLayer = textLayer; @@ -208,7 +209,6 @@ class FontMetrics { var f = solve(sub(m1, mul(h[0], u)), sub(m2, mul(h[1], u)), u); return mul(L, f); } - /** * Finds and returns the DOM element corresponding to a given screen row. @@ -218,11 +218,12 @@ class FontMetrics { */ $findElementForScreenRow(screenRow) { var textLayer = this.textLayer; - var data = textLayer.$lines.$getCellByScreenRow(screenRow, textLayer.config); + 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 / textLayer.config.lineHeight); + var index = Math.floor(data.offset / this.config.lineHeight); lineElement = lineElement.children[Math.max(index, 0)]; } return lineElement; @@ -236,14 +237,11 @@ class FontMetrics { * @returns {number} The width of the text in pixels up to the specified column. */ textWidth(screenRow, screenColumn) { - var textLayer = this.textLayer; - var lineElement = this.$findElementForScreenRow(screenRow); if (!lineElement || !document.createRange) { // Fallback for lines not currently rendered - return screenColumn * textLayer.config.characterWidth; + return screenColumn * this.config.characterWidth; } - return this.$measureLineToColumn(lineElement, screenColumn); } @@ -267,7 +265,7 @@ class FontMetrics { try { var position = this.$findColumnPosition(lineElement, screenColumn); if (!position) { - return screenColumn * textLayer.config.characterWidth; + return screenColumn * this.config.characterWidth; } this.$scratchRange.setStart(position.node, position.offset); @@ -275,10 +273,10 @@ class FontMetrics { var rangeRect = this.$scratchRange.getBoundingClientRect(); var rect = textLayer.element.getBoundingClientRect(); - return rangeRect.left - rect.left; + return rangeRect.left - rect.left + position.overflow * this.config.characterWidth; } catch (e) { console.error("Error measuring text width:", e); - return screenColumn * textLayer.config.characterWidth; + return screenColumn * this.config.characterWidth; } } @@ -291,7 +289,7 @@ class FontMetrics { * * @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} | null} An object containing the text node and the offset + * @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) { @@ -307,7 +305,8 @@ class FontMetrics { if (currentColumn + nodeLength >= screenColumn) { return { node: node, - offset: screenColumn - currentColumn + offset: screenColumn - currentColumn, + overflow: 0 }; } currentColumn += nodeLength; @@ -316,7 +315,8 @@ class FontMetrics { return lastNode && { node: lastNode, - offset: lastNode.nodeValue.length + offset: lastNode.nodeValue.length, + overflow: screenColumn - currentColumn, }; } @@ -426,8 +426,8 @@ class FontMetrics { } } return [{ - left: startScreenPos.column * textLayer.config.characterWidth, - width: (endScreenPos.column - startScreenPos.column) * textLayer.config.characterWidth, + left: startScreenPos.column * this.config.characterWidth, + width: (endScreenPos.column - startScreenPos.column) * this.config.characterWidth, }]; } } diff --git a/src/layer/marker.js b/src/layer/marker.js index d3d2965afb4..3399294b983 100644 --- a/src/layer/marker.js +++ b/src/layer/marker.js @@ -220,7 +220,7 @@ class Marker { if (this.session.$bidiHandler.isBidiRow(range.start.row)) return this.drawBidiSingleLineMarker(stringBuilder, range, clazz, config, extraLength, extraStyle); var height = config.lineHeight; - var right = config.fontMetrics.textWidth(range.start.row, (range.end.column + (extraLength || 0) )); + 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 = config.fontMetrics.textWidth(range.start.row, range.start.column); 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/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/virtual_renderer.js b/src/virtual_renderer.js index a60b6462425..7c62e8db6f8 100644 --- a/src/virtual_renderer.js +++ b/src/virtual_renderer.js @@ -128,6 +128,7 @@ class VirtualRenderer { height : 1, gutterOffset: 1 }; + this.$fontMetrics.config = this.layerConfig; this.scrollMargin = { left: 0, @@ -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 || @@ -1195,6 +1193,7 @@ class VirtualRenderer { 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); diff --git a/src/virtual_renderer_test.js b/src/virtual_renderer_test.js index fbf7589407f..de804af1776 100644 --- a/src/virtual_renderer_test.js +++ b/src/virtual_renderer_test.js @@ -60,15 +60,12 @@ 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() { var renderer = editor.renderer; From 685d2af63daf090a01c9f72740e6c96e65e79712 Mon Sep 17 00:00:00 2001 From: nightwing Date: Wed, 25 Mar 2026 01:12:05 +0400 Subject: [PATCH 07/26] fix rendering of cjk spaces with show invisibles --- src/layer/text.js | 13 ++++++++++++- src/layer/text_markers.js | 5 +++-- src/layer/text_test.js | 4 ++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/layer/text.js b/src/layer/text.js index eaeff56e59f..04915ca47af 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]+)/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); @@ -360,6 +360,7 @@ class Text { var tab = m[1]; var simpleSpace = m[2]; var controlCharacter = m[3]; + var cjkSpace = m[4]; if (!self.showSpaces && simpleSpace) continue; @@ -392,6 +393,15 @@ class Text { span.className = "ace_invisible ace_invisible_space ace_invalid"; span.textContent = lang.stringRepeat(self.SPACE_CHAR, controlCharacter.length); valueFragment.appendChild(span); + } else if (cjkSpace) { + 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)); + } } } @@ -767,6 +777,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_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"] ]); }, From d5684d144bff0c1f876db972bf8de49f41e944a6 Mon Sep 17 00:00:00 2001 From: nightwing Date: Wed, 25 Mar 2026 11:27:45 +0400 Subject: [PATCH 08/26] fix textinput tests --- src/keyboard/textinput_test.js | 2 +- src/layer/font_metrics.js | 6 ++++++ src/virtual_renderer.js | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) 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/layer/font_metrics.js b/src/layer/font_metrics.js index 2b01c333f4b..f82fedc0fdb 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -137,6 +137,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) diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js index 7c62e8db6f8..f9b7230907f 100644 --- a/src/virtual_renderer.js +++ b/src/virtual_renderer.js @@ -708,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; From 037318f33be892bbf646291b6803d0613753de1d Mon Sep 17 00:00:00 2001 From: nightwing Date: Thu, 9 Apr 2026 18:24:12 +0400 Subject: [PATCH 09/26] css transform support in mockdom --- src/layer/font_metrics.js | 146 +++++++++++++++++++++++++++++++++-- src/virtual_renderer_test.js | 1 + 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index f82fedc0fdb..3bcd48c7a9b 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -41,6 +41,8 @@ class FontMetrics { this.checkForSizeChanges(); this.textLayer.$setFontMetrics(this); + + this.$scratchRange = document.createRange(); } $setMeasureNodeStyles(style, isRoot) { @@ -50,11 +52,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"; } @@ -157,6 +155,7 @@ class FontMetrics { return (Number(window.getComputedStyle(element)["zoom"]) || 1) * this.$getZoom(element.parentElement); } + $initTransformMeasureNodes() { var t = function(t, l) { return ["div", { @@ -171,6 +170,43 @@ class FontMetrics { // | h[0] h[1] 1 | | 1 | | 1 | // this function finds the coeeficients of the matrix using positions of four points // + getTransformMatrix() { + if (!this.els) this.$initTransformMeasureNodes(); + + var p = (el) => { + var r = el.getBoundingClientRect(); + var zoom = this.$getZoom ? this.$getZoom(this.el) : 1; + return [r.left / zoom, r.top / zoom]; + }; + + var sub = (a, b) => [a[0] - b[0], a[1] - b[1]]; + var add = (a, b) => [a[0] + b[0], a[1] + b[1]]; + var mul = (s, a) => [s * a[0], s * a[1]]; + + var solve2x2 = (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 + ]; + } + + var a = p(this.els[0]); + var b = p(this.els[1]); + var c = p(this.els[2]); + var d = p(this.els[3]); + + var h = solve2x2(sub(d, b), sub(d, c), sub(add(b, c), add(d, a))); + var m1 = mul((1 + h[0]) / L, sub(b, a)); + var m2 = mul((1 + h[1]) / L, sub(c, a)); + + return [ + m1[0], m2[0], a[0], + m1[1], m2[1], a[1], + h[0] / L, h[1] / L, 1 + ]; + } + transformCoordinates(clientPos, elPos) { if (clientPos) { var zoom = this.$getZoom(this.el); @@ -216,6 +252,102 @@ class FontMetrics { return mul(L, f); } + recoverRect(M, bbox) { + var { left, top, width: Wt, height: Ht } = bbox; + + // 1. Detect Affine Case (Perspective components are zero) + var isAffine = Math.abs(M[6]) < 1e-10 && Math.abs(M[7]) < 1e-10; + + 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?.lineHeight || 0; + // Solve: w = (Wt - |m01|*h) / |m00| + w = (Wt - absM[1] * h) / absM[0]; + } else { + // Normal case: use your analytical Cramer's formula + 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 { x: cx - w / 2, y: cy - h / 2, width: w, height: h }; + } + + // 2. Projective Case (Full Jacobian + 4x4 Solver) + var xMax = left + Wt; + var yMax = top + Ht; + + var getCorner = (px, py, isYAxis, dir) => { + var g = M[6] * px + M[7] * py + M[8]; + var f = isYAxis ? (M[3] * px + M[4] * py + M[5]) : (M[0] * px + M[1] * py + M[2]); + var d_dx = (isYAxis ? M[3] : M[0]) * g - f * M[6]; + var d_dy = (isYAxis ? M[4] : M[1]) * g - f * M[7]; + return [(dir * d_dx > 0) ? 1 : 0, (dir * d_dy > 0) ? 1 : 0]; + }; + + var corners = [ + getCorner(left, top + Ht/2, false, -1), // Left + getCorner(left + Wt/2, top, true, -1), // Top + getCorner(xMax, top + Ht/2, false, 1), // Right + getCorner(left + Wt/2, yMax, true, 1) // Bottom + ]; + + var targets = [left, top, xMax, yMax]; + var isY = [false, true, false, true]; + + var rows = corners.map((c, i) => { + var [dx, dy] = c; + var target = targets[i]; + var m = isY[i] ? M.slice(3, 6) : M.slice(0, 3); + var mp = M.slice(6, 9); + var ax = m[0] - target * mp[0], ay = m[1] - target * mp[1]; + return [ax, ay, dx * ax, dy * ay, target * mp[2] - m[2]]; + }); + + var res = this.$solve4x4(rows); + return res ? { x: res[0], y: res[1], width: res[2], height: res[3] } : null; + } + + recoverRects(matrix, rects) { + return Array.from(rects).map(r => this.recoverRect(matrix, r)); + } + + $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-12) return null; + for (let j = i; j <= n; j++) m[i][j] /= p; + for (let k = 0; k < n; k++) { + if (k !== i) { + let factor = m[k][i]; + for (let j = i; j <= n; j++) m[k][j] -= factor * m[i][j]; + } + } + } + return m.map(row => row[n]); + } + /** * Finds and returns the DOM element corresponding to a given screen row. * @@ -250,6 +382,7 @@ class FontMetrics { } return this.$measureLineToColumn(lineElement, screenColumn); } + /** * Measures the horizontal position (in pixels) of a specific screen column @@ -264,9 +397,6 @@ class FontMetrics { */ $measureLineToColumn(lineElement, screenColumn) { var textLayer = this.textLayer; - if (!this.$scratchRange) { - this.$scratchRange = document.createRange(); - } try { var position = this.$findColumnPosition(lineElement, screenColumn); diff --git a/src/virtual_renderer_test.js b/src/virtual_renderer_test.js index de804af1776..0464cafe805 100644 --- a/src/virtual_renderer_test.js +++ b/src/virtual_renderer_test.js @@ -86,6 +86,7 @@ module.exports = { renderer.gutterWidth = 40; editor.setOption("hasCssTransforms", true); + editor.container.style.transformOrigin = "0 0"; 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); From 6952ce9e10f5d0ed35b98235b9326f968bf5518f Mon Sep 17 00:00:00 2001 From: nightwing Date: Sat, 11 Apr 2026 14:57:29 +0400 Subject: [PATCH 10/26] support css transform in new measurement method --- experiments/transform.html | 441 +++++++++++++++++++++++++++++++++++ src/layer/font_metrics.js | 362 +++++++++++++++++----------- src/virtual_renderer.js | 8 +- src/virtual_renderer_test.js | 74 +++--- 4 files changed, 708 insertions(+), 177 deletions(-) create mode 100644 experiments/transform.html 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/layer/font_metrics.js b/src/layer/font_metrics.js index 3bcd48c7a9b..ef6e6382338 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -14,10 +14,11 @@ class FontMetrics { /** * @param {HTMLElement} parentEl */ - constructor(parentEl, textLayer) { + 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); @@ -149,17 +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); @@ -170,62 +164,13 @@ class FontMetrics { // | h[0] h[1] 1 | | 1 | | 1 | // this function finds the coeeficients of the matrix using positions of four points // - getTransformMatrix() { - if (!this.els) this.$initTransformMeasureNodes(); - - var p = (el) => { - var r = el.getBoundingClientRect(); - var zoom = this.$getZoom ? this.$getZoom(this.el) : 1; - return [r.left / zoom, r.top / zoom]; - }; - - var sub = (a, b) => [a[0] - b[0], a[1] - b[1]]; - var add = (a, b) => [a[0] + b[0], a[1] + b[1]]; - var mul = (s, a) => [s * a[0], s * a[1]]; - - var solve2x2 = (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; } - - var a = p(this.els[0]); - var b = p(this.els[1]); - var c = p(this.els[2]); - var d = p(this.els[3]); - - var h = solve2x2(sub(d, b), sub(d, c), sub(add(b, c), add(d, a))); - var m1 = mul((1 + h[0]) / L, sub(b, a)); - var m2 = mul((1 + h[1]) / L, sub(c, a)); - - return [ - m1[0], m2[0], a[0], - m1[1], m2[1], a[1], - h[0] / L, h[1] / L, 1 - ]; - } - - 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 - ]; - } - 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]; @@ -238,26 +183,49 @@ 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); } - 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 project(tr.MInv, clientPos[0] - tr.t[0], clientPos[1] - tr.t[1]); } - recoverRect(M, bbox) { - var { left, top, width: Wt, height: Ht } = 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; @@ -274,7 +242,6 @@ class FontMetrics { // Solve: w = (Wt - |m01|*h) / |m00| w = (Wt - absM[1] * h) / absM[0]; } else { - // Normal case: use your analytical Cramer's formula w = (absM[3] * Wt - absM[1] * Ht) / delta; h = (-absM[2] * Wt + absM[0] * Ht) / delta; } @@ -287,65 +254,10 @@ class FontMetrics { var cx = (m11 * ctx - m01 * cty) / detM; var cy = (-m10 * ctx + m00 * cty) / detM; - return { x: cx - w / 2, y: cy - h / 2, width: w, height: h }; + return { left: cx - w / 2, top: cy - h / 2, width: w, height: h }; } - // 2. Projective Case (Full Jacobian + 4x4 Solver) - var xMax = left + Wt; - var yMax = top + Ht; - - var getCorner = (px, py, isYAxis, dir) => { - var g = M[6] * px + M[7] * py + M[8]; - var f = isYAxis ? (M[3] * px + M[4] * py + M[5]) : (M[0] * px + M[1] * py + M[2]); - var d_dx = (isYAxis ? M[3] : M[0]) * g - f * M[6]; - var d_dy = (isYAxis ? M[4] : M[1]) * g - f * M[7]; - return [(dir * d_dx > 0) ? 1 : 0, (dir * d_dy > 0) ? 1 : 0]; - }; - - var corners = [ - getCorner(left, top + Ht/2, false, -1), // Left - getCorner(left + Wt/2, top, true, -1), // Top - getCorner(xMax, top + Ht/2, false, 1), // Right - getCorner(left + Wt/2, yMax, true, 1) // Bottom - ]; - - var targets = [left, top, xMax, yMax]; - var isY = [false, true, false, true]; - - var rows = corners.map((c, i) => { - var [dx, dy] = c; - var target = targets[i]; - var m = isY[i] ? M.slice(3, 6) : M.slice(0, 3); - var mp = M.slice(6, 9); - var ax = m[0] - target * mp[0], ay = m[1] - target * mp[1]; - return [ax, ay, dx * ax, dy * ay, target * mp[2] - m[2]]; - }); - - var res = this.$solve4x4(rows); - return res ? { x: res[0], y: res[1], width: res[2], height: res[3] } : null; - } - - recoverRects(matrix, rects) { - return Array.from(rects).map(r => this.recoverRect(matrix, r)); - } - - $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-12) return null; - for (let j = i; j <= n; j++) m[i][j] /= p; - for (let k = 0; k < n; k++) { - if (k !== i) { - let factor = m[k][i]; - for (let j = i; j <= n; j++) m[k][j] -= factor * m[i][j]; - } - } - } - return m.map(row => row[n]); + return recoverRect(transform, bbox) } /** @@ -408,7 +320,13 @@ class FontMetrics { this.$scratchRange.setEnd(position.node, position.offset); var rangeRect = this.$scratchRange.getBoundingClientRect(); - var rect = textLayer.element.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); @@ -470,25 +388,43 @@ class FontMetrics { var lineElement = this.$findElementForScreenRow(screenRow); if (!lineElement) return screenColumn1; + var hasCssTransform = this.renderer.$hasCssTransforms; + var tr = hasCssTransform && this.getTransform(); + var screenColumn = 0; - function getRects(node) { + var getRects = (node) => { + var rects = []; if (node.nodeType === Node.TEXT_NODE) { scratchRange.setStart(node, 0); scratchRange.setEnd(node, node.nodeValue.length); - return scratchRange.getClientRects(); + rects = Array.from(scratchRange.getClientRects()); } else if (node.nodeType === Node.ELEMENT_NODE) { - return node.getClientRects(); + 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 []; + return rects; } + var self = this; function search(node) { if (node.nodeType === Node.TEXT_NODE) { var textLength = node.nodeValue.length; for (var j = 0; j < textLength; j++) { scratchRange.setStart(node, j); + if (/[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(j))) + j++ // skip low surrogate scratchRange.setEnd(node, j + 1); let rect = scratchRange.getBoundingClientRect(); - if (rect.left <= x && x <= rect.right) { + if (hasCssTransform) { + rect = self.recoverRect(tr, rect); + } + if (rect.left <= x && x <= rect.left + rect.width) { screenColumn += j; if (!blockCursor && x > rect.left + rect.width / 2) { screenColumn++; @@ -548,6 +484,24 @@ class FontMetrics { this.$scratchRange.setStart(p1.node, p1.offset); this.$scratchRange.setEnd(p2.node, p2.offset); var rangeRects = this.$scratchRange.getClientRects(); + var hasCssTransform = true; + 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 { @@ -598,8 +552,136 @@ function mergeTouchingRects(rects) { } 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, mappingIdx: i }; + break; + } + } + } + if (!result) + console.warn("No valid mapping found in 256 combinations."); + return result; +} + +const invert3x3 = (m) => { + var [a, b, c, d, e, f, g, h, i] = m; + var det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); + if (Math.abs(det) < 1e-14) return null; + var invDet = 1 / det; + return [ + (e * i - f * h) * invDet, (c * h - b * i) * invDet, (b * f - c * e) * invDet, + (f * g - d * i) * invDet, (a * i - c * g) * invDet, (c * d - a * f) * invDet, + (d * h - e * g) * invDet, (g * b - a * h) * invDet, (a * e - b * d) * invDet + ]; +}; +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]); +} \ No newline at end of file diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js index f9b7230907f..faa5a2df75f 100644 --- a/src/virtual_renderer.js +++ b/src/virtual_renderer.js @@ -98,7 +98,7 @@ class VirtualRenderer { column : 0 }; - this.$fontMetrics = new FontMetrics(this.container, this.$textLayer); + 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); @@ -1634,10 +1634,10 @@ 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(); } diff --git a/src/virtual_renderer_test.js b/src/virtual_renderer_test.js index 0464cafe805..1b6c3e3e501 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); @@ -68,50 +69,57 @@ module.exports = { testPixelToText(renderer.characterWidth * 1.5, 0, 0, 2); }, "test: handle css transforms" : function() { + editor.setValue("hello world"); var renderer = editor.renderer; var fontMetrics = renderer.$fontMetrics; 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.transformOrigin = "0 0"; - 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 H1 = -0.0007, H2 = -0.001; + var m0 = 0.7, m1 = 0.1, m2 = 0.3, m3 = 0.82; + var t1 = 100, t2 = 20; + editor.container.style.transform = `matrix3d( + ${m0}, ${m2}, 0, ${H1}, + ${m1}, ${m3}, 0, ${H2}, + 0, 0, 1, 0, + ${t1}, ${t2}, 0, 1 + )`; - 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 expected = [ + m0 - H1* t1, m1 - H2* t1, 0, + m2 - H1* t2, m3 - H2* t2, 0, + H1, H2, 1 + ] + 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]; + } + + 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 r0 = els[0].getBoundingClientRect(); - pos = renderer.pixelToScreenCoordinates(r0.left + 100, r0.top + 200); - assert.position(pos, 10, 11); + 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 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(); + var docPos = editor.session.screenToDocumentPosition(pos.row, pos.column); + assert.position(docPos, 0, 4); }, "test scrollmargin + autosize": async function(done) { From 086b165e45a12e8a01094e993b939edb5767c062 Mon Sep 17 00:00:00 2001 From: nightwing Date: Fri, 1 May 2026 01:52:45 +0400 Subject: [PATCH 11/26] fix failing test --- src/layer/font_metrics.js | 29 +++++++++-------------------- src/virtual_renderer_test.js | 2 +- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index ef6e6382338..899ea0dff46 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; @@ -238,7 +238,7 @@ class FontMetrics { 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?.lineHeight || 0; + h = this.config && this.config.lineHeight || 0; // Solve: w = (Wt - |m01|*h) / |m00| w = (Wt - absM[1] * h) / absM[0]; } else { @@ -257,7 +257,7 @@ class FontMetrics { return { left: cx - w / 2, top: cy - h / 2, width: w, height: h }; } - return recoverRect(transform, bbox) + return recoverRect(transform, bbox); } /** @@ -321,7 +321,7 @@ class FontMetrics { var rangeRect = this.$scratchRange.getBoundingClientRect(); if (this.renderer.$hasCssTransforms) { - var tr = this.getTransform() + 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; @@ -410,7 +410,7 @@ class FontMetrics { rects = fixedRects; } return rects; - } + }; var self = this; function search(node) { if (node.nodeType === Node.TEXT_NODE) { @@ -418,7 +418,7 @@ class FontMetrics { for (var j = 0; j < textLength; j++) { scratchRange.setStart(node, j); if (/[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(j))) - j++ // skip low surrogate + j++; // skip low surrogate scratchRange.setEnd(node, j + 1); let rect = scratchRange.getBoundingClientRect(); if (hasCssTransform) { @@ -486,7 +486,7 @@ class FontMetrics { var rangeRects = this.$scratchRange.getClientRects(); var hasCssTransform = true; if (hasCssTransform) { - var tr = this.getTransform() + var tr = this.getTransform(); var rects = []; for (var i = 0; i < rangeRects.length; i++) { var rangeRect = this.recoverRect(tr, rangeRects[i]); @@ -634,17 +634,6 @@ function recoverRect(transform, bbox) { return result; } -const invert3x3 = (m) => { - var [a, b, c, d, e, f, g, h, i] = m; - var det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); - if (Math.abs(det) < 1e-14) return null; - var invDet = 1 / det; - return [ - (e * i - f * h) * invDet, (c * h - b * i) * invDet, (b * f - c * e) * invDet, - (f * g - d * i) * invDet, (a * i - c * g) * invDet, (c * d - a * f) * invDet, - (d * h - e * g) * invDet, (g * b - a * h) * invDet, (a * e - b * d) * invDet - ]; -}; 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]; @@ -660,9 +649,9 @@ function validateSolution(M, x, y, w, h, targetBbox) { ]; return pts.every(p => { - var mapped = project(M, p[0], p[1]) + 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 + && targetBbox.top - 0.5 <= mapped[1] && mapped[1] <= targetBbox.top + targetBbox.height + 0.5; }); } diff --git a/src/virtual_renderer_test.js b/src/virtual_renderer_test.js index 1b6c3e3e501..6c92b2d3f32 100644 --- a/src/virtual_renderer_test.js +++ b/src/virtual_renderer_test.js @@ -92,7 +92,7 @@ module.exports = { m0 - H1* t1, m1 - H2* t1, 0, m2 - H1* t2, m3 - H2* t2, 0, H1, H2, 1 - ] + ]; function project(M, point) { var px = point[0], py = point[1]; var k = 1 / (M[6] * px + M[7] * py + M[8]); From d31444660d6d622b8be6168aee9a377b6f6ba8a2 Mon Sep 17 00:00:00 2001 From: nightwing Date: Sat, 2 May 2026 12:34:12 +0400 Subject: [PATCH 12/26] fix types --- ace-internal.d.ts | 10 +++++ ace.d.ts | 10 +++++ src/layer/font_metrics.js | 10 +++-- types/ace-ext.d.ts | 2 +- types/ace-modules.d.ts | 94 +++++++++++++++++++++++++++++---------- 5 files changed, 99 insertions(+), 27 deletions(-) diff --git a/ace-internal.d.ts b/ace-internal.d.ts index 7380d3c1833..afbb9260a89 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 { 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/src/layer/font_metrics.js b/src/layer/font_metrics.js index 899ea0dff46..cfbb595d0d7 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -216,6 +216,10 @@ class FontMetrics { 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; @@ -254,7 +258,7 @@ class FontMetrics { 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 }; + 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); @@ -420,7 +424,7 @@ class FontMetrics { if (/[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(j))) j++; // skip low surrogate scratchRange.setEnd(node, j + 1); - let rect = scratchRange.getBoundingClientRect(); + let rect = /** @type {ReturnType}*/(scratchRange.getBoundingClientRect()); if (hasCssTransform) { rect = self.recoverRect(tr, rect); } @@ -624,7 +628,7 @@ function recoverRect(transform, bbox) { 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, mappingIdx: i }; + result = { left: x0, top: y0, width: w, height: h, right: x0 + w, bottom: y0 + h, mappingIdx: i }; break; } } diff --git a/types/ace-ext.d.ts b/types/ace-ext.d.ts index c85f9b565a1..7771ebee96c 100644 --- a/types/ace-ext.d.ts +++ b/types/ace-ext.d.ts @@ -474,7 +474,7 @@ declare module "ace-code/src/ext/diff/base_diff_view" { selectionRangeB: any; setupScrollbars(): void; updateScrollBarDecorators(): void; - setProvider(provider: import("ace-code/src/ext/diff").DiffProvider): void; + setProvider(provider: import("ace-code/src/ext/diff/providers/default").DiffProvider): void; /** * scroll locking * @abstract diff --git a/types/ace-modules.d.ts b/types/ace-modules.d.ts index dd8b7d432fd..bcdaed95359 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,11 @@ declare module "ace-code/src/layer/cursor" { getPixelPosition(position?: import("ace-code").Ace.Point, onScreen?: boolean): { left: number; top: number; + width?: undefined; + } | { + left: any; + top: number; + width: number; }; isCursorInView(pixelPos: any, config: any): boolean; update(config: any): void; @@ -896,6 +965,7 @@ declare module "ace-code/src/virtual_renderer" { lastRow: number; lineHeight: number; characterWidth: number; + fontMetrics: FontMetrics; minHeight: number; maxHeight: number; offset: number; @@ -3910,13 +3980,6 @@ declare module "ace-code/src/bidihandler" { * 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; @@ -3929,21 +3992,6 @@ declare module "ace-code/src/bidihandler" { * @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"); } From dc3609246db758597f641a64c1bed7bb22302e87 Mon Sep 17 00:00:00 2001 From: nightwing Date: Sat, 2 May 2026 13:04:26 +0400 Subject: [PATCH 13/26] improve coverage --- src/test/all_browser.js | 46 +++++++++++++++++++- src/test/tests.html | 8 ++++ src/virtual_renderer_test.js | 83 +++++++++++++++++++++--------------- 3 files changed, 101 insertions(+), 36 deletions(-) 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_test.js b/src/virtual_renderer_test.js index 6c92b2d3f32..61af0556366 100644 --- a/src/virtual_renderer_test.js +++ b/src/virtual_renderer_test.js @@ -69,57 +69,70 @@ module.exports = { testPixelToText(renderer.characterWidth * 1.5, 0, 0, 2); }, "test: handle css transforms" : function() { - editor.setValue("hello world"); + 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]); renderer.onResize(true); - editor.setOption("hasCssTransforms", true); 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; - 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 - ]; - 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]; - } + 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]}`); + } - project(expected, [20, 30]); + 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 transform = editor.renderer.$fontMetrics.getTransform(); + var pos = renderer.pixelToScreenCoordinates(p[0], p[1]); + + var docPos = editor.session.screenToDocumentPosition(pos.row, pos.column); + assert.position(docPos, 0, 4); - 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]}`); + editor.renderer.$loop._flush(); } - 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]; + testTransform(); + H1 = H2 = 0; + testTransform(); + m0 = m1 = m3 = 1; + m2 = -1; + testTransform(); - var pos = renderer.pixelToScreenCoordinates(p[0], p[1]); - - var docPos = editor.session.screenToDocumentPosition(pos.row, pos.column); - assert.position(docPos, 0, 4); + 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 scrollmargin + autosize": async function(done) { From a71a0a06dda39a40e777be89bf86ef565cbf4c91 Mon Sep 17 00:00:00 2001 From: nightwing Date: Mon, 4 May 2026 14:02:35 +0400 Subject: [PATCH 14/26] fix position calculation for surrogate pairs --- src/layer/font_metrics.js | 18 ++++++++++++------ src/virtual_renderer_test.js | 12 +++++++++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index cfbb595d0d7..5fc4a5b43f3 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -390,7 +390,7 @@ class FontMetrics { $pixelToColumn(screenRow, screenColumn1, x, blockCursor) { var scratchRange = this.$scratchRange; var lineElement = this.$findElementForScreenRow(screenRow); - if (!lineElement) return screenColumn1; + if (!lineElement || screenColumn1 <= 0) return screenColumn1; var hasCssTransform = this.renderer.$hasCssTransforms; var tr = hasCssTransform && this.getTransform(); @@ -419,11 +419,17 @@ class FontMetrics { function search(node) { if (node.nodeType === Node.TEXT_NODE) { var textLength = node.nodeValue.length; - for (var j = 0; j < textLength; j++) { + var graphemeWidth = 1; + for (var j = 0; j < textLength; j+= graphemeWidth) { scratchRange.setStart(node, j); - if (/[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(j))) - j++; // skip low surrogate - scratchRange.setEnd(node, j + 1); + graphemeWidth = 1; + if ( + /[\uD800-\uDBFF]/.test(node.nodeValue.charAt(j)) && j + 1 < textLength && + /[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(j + 1)) + ) { + graphemeWidth = 2; + } + scratchRange.setEnd(node, j + graphemeWidth); let rect = /** @type {ReturnType}*/(scratchRange.getBoundingClientRect()); if (hasCssTransform) { rect = self.recoverRect(tr, rect); @@ -431,7 +437,7 @@ class FontMetrics { if (rect.left <= x && x <= rect.left + rect.width) { screenColumn += j; if (!blockCursor && x > rect.left + rect.width / 2) { - screenColumn++; + screenColumn += graphemeWidth; } return screenColumn; } diff --git a/src/virtual_renderer_test.js b/src/virtual_renderer_test.js index 61af0556366..574ee87de08 100644 --- a/src/virtual_renderer_test.js +++ b/src/virtual_renderer_test.js @@ -134,7 +134,17 @@ module.exports = { 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, From 17cae98ae41bf2fae70abaa06f0345933eea9e31 Mon Sep 17 00:00:00 2001 From: nightwing Date: Fri, 22 May 2026 12:03:10 +0400 Subject: [PATCH 15/26] fix selection rendering in RTL mode --- src/bidihandler.js | 99 +--------------------------------------------- src/selection.js | 7 +--- 2 files changed, 2 insertions(+), 104 deletions(-) diff --git a/src/bidihandler.js b/src/bidihandler.js index aed46d3ffd8..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 @@ -204,39 +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; - } } exports.BidiHandler = BidiHandler; diff --git a/src/selection.js b/src/selection.js index 6ab284305af..d1c17f5cd00 100644 --- a/src/selection.js +++ b/src/selection.js @@ -714,12 +714,7 @@ class Selection { 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]; - } + // TODO use fontmetrics } if (this.$desiredColumn) From 0f32ef26f24d769fa749e141cc47c93cd71f8ba0 Mon Sep 17 00:00:00 2001 From: mkslanc Date: Tue, 19 May 2026 13:52:52 +0400 Subject: [PATCH 16/26] fix: add width to fallback; remove unnesseccary check --- src/layer/cursor.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/layer/cursor.js b/src/layer/cursor.js index e997b4ee4fa..919b4230bf9 100644 --- a/src/layer/cursor.js +++ b/src/layer/cursor.js @@ -175,10 +175,11 @@ 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(); @@ -188,7 +189,7 @@ class Cursor { var cursorTop = (pos.row - (onScreen ? this.config.firstRowScreen : 0)) * this.config.lineHeight; - var cursorWidth = (this.config.fontMetrics.textWidth(pos.row, pos.column + 1) - textWidth) || this.config.characterWidth; + var cursorWidth = this.config.fontMetrics.textWidth(pos.row, pos.column + 1) - textWidth; return {left : cursorLeft, top : cursorTop, width : Math.abs(cursorWidth)}; } From 0fbd29ab3a08186879546bc274947c52fcab9b26 Mon Sep 17 00:00:00 2001 From: mkslanc Date: Thu, 21 May 2026 17:46:15 +0400 Subject: [PATCH 17/26] fix: improve text-to-screen coordinate calculation --- src/layer/font_metrics.js | 4 ++-- src/virtual_renderer.js | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index 5fc4a5b43f3..fe8792a429b 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -494,7 +494,7 @@ class FontMetrics { this.$scratchRange.setStart(p1.node, p1.offset); this.$scratchRange.setEnd(p2.node, p2.offset); var rangeRects = this.$scratchRange.getClientRects(); - var hasCssTransform = true; + var hasCssTransform = this.renderer && this.renderer.$hasCssTransforms; if (hasCssTransform) { var tr = this.getTransform(); var rects = []; @@ -683,4 +683,4 @@ function solve4x4(m) { } } return m.map(row => row[n]); -} \ No newline at end of file +} diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js index faa5a2df75f..66f79d4f9f5 100644 --- a/src/virtual_renderer.js +++ b/src/virtual_renderer.js @@ -1672,15 +1672,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) { //TODO: check + 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 From 6fb71cffe369f30bae4938c3efcb2394a25856d0 Mon Sep 17 00:00:00 2001 From: nightwing Date: Mon, 15 Jun 2026 19:46:12 +0400 Subject: [PATCH 18/26] workaround for slow pixelToColumn on long tokens --- src/layer/text.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/layer/text.js b/src/layer/text.js index 04915ca47af..f154329cf70 100644 --- a/src/layer/text.js +++ b/src/layer/text.js @@ -405,7 +405,14 @@ class Text { } } - 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_"); From 62bf8ce6d38b46999cad75b6ade22e8ada70bd2c Mon Sep 17 00:00:00 2001 From: mkslanc Date: Thu, 28 May 2026 12:53:55 +0400 Subject: [PATCH 19/26] fix: remove unused `offsetX` parameter from `screenToDocumentPosition` and related methods --- src/edit_session.js | 3 +-- src/ext/diff/inline_diff_view.js | 4 ++-- src/mouse/multi_select_handler.js | 2 +- src/multi_select.js | 8 ++------ src/selection.js | 4 +--- src/virtual_renderer.js | 2 +- types/ace-ext.d.ts | 2 +- types/ace-modules.d.ts | 18 ++---------------- 8 files changed, 11 insertions(+), 32 deletions(-) diff --git a/src/edit_session.js b/src/edit_session.js index e7f5d84e61c..f58492aacf0 100644 --- a/src/edit_session.js +++ b/src/edit_session.js @@ -2234,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}; diff --git a/src/ext/diff/inline_diff_view.js b/src/ext/diff/inline_diff_view.js index cee36b90102..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); 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/selection.js b/src/selection.js index d1c17f5cd00..27e5bb0b4d1 100644 --- a/src/selection.js +++ b/src/selection.js @@ -710,8 +710,6 @@ class Selection { this.lead.column ); - var offsetX; - if (chars === 0) { if (rows !== 0) { // TODO use fontmetrics @@ -731,7 +729,7 @@ class Selection { rows += widget.rowCount - (widget.rowsAbove || 0); } - var docPos = this.session.screenToDocumentPosition(screenPos.row + rows, screenPos.column, offsetX); + var docPos = this.session.screenToDocumentPosition(screenPos.row + rows, screenPos.column); if (rows !== 0 && chars === 0 && docPos.row === this.lead.row && docPos.column === this.lead.column) { diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js index 66f79d4f9f5..6872f267cb5 100644 --- a/src/virtual_renderer.js +++ b/src/virtual_renderer.js @@ -1661,7 +1661,7 @@ class VirtualRenderer { */ screenToTextCoordinates(x, y) { var screenPos = this.pixelToScreenCoordinates(x, y); - return this.session.screenToDocumentPosition(screenPos.row, Math.max(screenPos.column, 0), screenPos.offsetX); + return this.session.screenToDocumentPosition(screenPos.row, Math.max(screenPos.column, 0)); } /** diff --git a/types/ace-ext.d.ts b/types/ace-ext.d.ts index 7771ebee96c..c85f9b565a1 100644 --- a/types/ace-ext.d.ts +++ b/types/ace-ext.d.ts @@ -474,7 +474,7 @@ declare module "ace-code/src/ext/diff/base_diff_view" { selectionRangeB: any; setupScrollbars(): void; updateScrollBarDecorators(): void; - setProvider(provider: import("ace-code/src/ext/diff/providers/default").DiffProvider): void; + setProvider(provider: import("ace-code/src/ext/diff").DiffProvider): void; /** * scroll locking * @abstract diff --git a/types/ace-modules.d.ts b/types/ace-modules.d.ts index bcdaed95359..08e4c19aebf 100644 --- a/types/ace-modules.d.ts +++ b/types/ace-modules.d.ts @@ -633,10 +633,6 @@ declare module "ace-code/src/layer/cursor" { getPixelPosition(position?: import("ace-code").Ace.Point, onScreen?: boolean): { left: number; top: number; - width?: undefined; - } | { - left: any; - top: number; width: number; }; isCursorInView(pixelPos: any, config: any): boolean; @@ -3971,11 +3967,9 @@ 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 **/ @@ -3985,13 +3979,6 @@ declare module "ace-code/src/bidihandler" { 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; } import bidiUtil = require("ace-code/src/lib/bidiutil"); } @@ -4648,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 From 45108d6f709d3489247a253773fbc2c0492c855d Mon Sep 17 00:00:00 2001 From: mkslanc Date: Tue, 26 May 2026 20:00:59 +0400 Subject: [PATCH 20/26] fix: enhance `$pixelToColumn` with `isTextWidthCoordinate` for improved text-to-pixel accuracy and update related cursor positioning logic fix: remove unused `offsetX` parameter from `screenToDocumentPosition` and related methods fix: set $fontMetrics to session instead of adding new selection methods fix: refine cursor positioning using pixel-based calculations # Conflicts: # src/layer/font_metrics.js # src/selection.js --- src/editor.js | 2 + src/layer/font_metrics.js | 125 ++++++++++++++++++++++++++++++++------ src/selection.js | 21 ++++--- 3 files changed, 121 insertions(+), 27 deletions(-) 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/layer/font_metrics.js b/src/layer/font_metrics.js index fe8792a429b..98262aa955c 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -8,6 +8,7 @@ var EventEmitter = require("../lib/event_emitter").EventEmitter; var CHAR_COUNT = 512; var USE_OBSERVER = typeof ResizeObserver == "function"; var L = 200; +var BINARY_SEARCH_TEXT_LENGTH = 256; class FontMetrics { @@ -385,17 +386,37 @@ class FontMetrics { * @param {number} screenColumn1 - The initial screen column index. * @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, screenColumn1, x, blockCursor) { + $pixelToColumn(screenRow, screenColumn1, x, blockCursor, isTextWidthCoordinate) { var scratchRange = this.$scratchRange; var lineElement = this.$findElementForScreenRow(screenRow); if (!lineElement || screenColumn1 <= 0) return screenColumn1; var hasCssTransform = this.renderer.$hasCssTransforms; var tr = hasCssTransform && this.getTransform(); + var textLayerRect = isTextWidthCoordinate && !hasCssTransform && this.textLayer.element.getBoundingClientRect(); + var leftOffset = isTextWidthCoordinate && hasCssTransform + && this.renderer.gutterWidth + this.renderer.margin.left + this.renderer.$padding - this.renderer.scrollLeft; var screenColumn = 0; + var self = this; + function normalizeRect(rect) { + if (hasCssTransform) + rect = self.recoverRect(tr, rect); + if (!isTextWidthCoordinate) + return rect; + var left = rect.left - (hasCssTransform ? leftOffset : textLayerRect.left); + return { + left: left, + right: left + rect.width, + width: rect.width, + top: rect.top, + bottom: rect.bottom, + height: rect.height + }; + } var getRects = (node) => { var rects = []; if (node.nodeType === Node.TEXT_NODE) { @@ -405,35 +426,99 @@ class FontMetrics { } 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; + for (var i = 0; i < rects.length; i++) { + rects[i] = normalizeRect(rects[i]); } return rects; }; - var self = this; + function isLowSurrogate(node, offset) { + return /[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(offset)); + } + function getGraphemeWidth(node, offset) { + if ( + /[\uD800-\uDBFF]/.test(node.nodeValue.charAt(offset)) && + offset + 1 < node.nodeValue.length && + /[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(offset + 1)) + ) { + return 2; + } + return 1; + } + function getCollapsedLeft(node, offset) { + scratchRange.setStart(node, offset); + scratchRange.setEnd(node, offset); + var rect = /**@type{{left: number, top: number, width: number, height: number}}*/(scratchRange.getBoundingClientRect()); + return normalizeRect(rect).left; + } + function canBinarySearchTextNode(node) { + scratchRange.setStart(node, 0); + scratchRange.setEnd(node, node.nodeValue.length); + return scratchRange.getClientRects().length <= 1; + } + function findTextNodeColumn(node) { + var textLength = node.nodeValue.length; + if (textLength < BINARY_SEARCH_TEXT_LENGTH || !canBinarySearchTextNode(node)) + return null; + + var left = getCollapsedLeft(node, 0); + var right = getCollapsedLeft(node, textLength); + if (left === right) + return null; + + var isRtl = left > right; + // This should normally be filtered at the element/text-node rect level. + // If it still happens, skip the linear fallback for this text node. + if (x < Math.min(left, right) || x > Math.max(left, right)) + return -1; + + var low = 0; + var high = textLength; + while (low < high) { + var mid = (low + high) >> 1; + if (isLowSurrogate(node, mid)) + mid--; + if (mid < low) + mid = low; + + var midLeft = getCollapsedLeft(node, mid); + if (isRtl ? midLeft >= x : midLeft <= x) + low = mid + getGraphemeWidth(node, mid); + else + high = mid; + } + + var column = Math.max(0, Math.min(low - 1, textLength - 1)); + if (isLowSurrogate(node, column)) + column--; + var graphemeWidth = getGraphemeWidth(node, column); + + scratchRange.setStart(node, column); + scratchRange.setEnd(node, column + graphemeWidth); + var rect = /** @type {ReturnType}*/(normalizeRect(scratchRange.getBoundingClientRect())); + + if (rect.left <= x && x <= rect.left + rect.width) { + if (!blockCursor && x > rect.left + rect.width / 2) + column += graphemeWidth; + return column; + } + return null; + } function search(node) { if (node.nodeType === Node.TEXT_NODE) { var textLength = node.nodeValue.length; + var column = findTextNodeColumn(node); + if (column != null && column >= 0) { + screenColumn += column; + return screenColumn; + } + if (column === -1) + return; var graphemeWidth = 1; for (var j = 0; j < textLength; j+= graphemeWidth) { scratchRange.setStart(node, j); - graphemeWidth = 1; - if ( - /[\uD800-\uDBFF]/.test(node.nodeValue.charAt(j)) && j + 1 < textLength && - /[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(j + 1)) - ) { - graphemeWidth = 2; - } + graphemeWidth = getGraphemeWidth(node, j); scratchRange.setEnd(node, j + graphemeWidth); - let rect = /** @type {ReturnType}*/(scratchRange.getBoundingClientRect()); - if (hasCssTransform) { - rect = self.recoverRect(tr, rect); - } + let rect = /** @type {ReturnType}*/(normalizeRect(scratchRange.getBoundingClientRect())); if (rect.left <= x && x <= rect.left + rect.width) { screenColumn += j; if (!blockCursor && x > rect.left + rect.width / 2) { diff --git a/src/selection.js b/src/selection.js index 27e5bb0b4d1..943f7f833ff 100644 --- a/src/selection.js +++ b/src/selection.js @@ -710,9 +710,15 @@ class Selection { this.lead.column ); + var offsetX; + var fontMetrics = this.session.$fontMetrics; + var useFontMetrics = chars === 0 && rows !== 0 && fontMetrics; + if (chars === 0) { - if (rows !== 0) { - // TODO use fontmetrics + if (useFontMetrics && this.$desiredColumn == null) { + offsetX = fontMetrics.textWidth(screenPos.row, screenPos.column); + if (isFinite(offsetX)) + this.$desiredColumn = offsetX / fontMetrics.config.characterWidth; } if (this.$desiredColumn) @@ -729,16 +735,17 @@ class Selection { rows += widget.rowCount - (widget.rowsAbove || 0); } - var docPos = this.session.screenToDocumentPosition(screenPos.row + rows, screenPos.column); - - 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 From 23ffa01fcdd22620b54581f718b0adef88e6c834 Mon Sep 17 00:00:00 2001 From: nightwing Date: Sat, 20 Jun 2026 23:08:25 +0400 Subject: [PATCH 21/26] revert binary search changes as it doesn't work well with bidi text --- src/layer/font_metrics.js | 128 ++++++++------------------------------ 1 file changed, 26 insertions(+), 102 deletions(-) diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index 98262aa955c..d32206f36e6 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -396,27 +396,15 @@ class FontMetrics { var hasCssTransform = this.renderer.$hasCssTransforms; var tr = hasCssTransform && this.getTransform(); - var textLayerRect = isTextWidthCoordinate && !hasCssTransform && this.textLayer.element.getBoundingClientRect(); - var leftOffset = isTextWidthCoordinate && hasCssTransform - && this.renderer.gutterWidth + this.renderer.margin.left + this.renderer.$padding - this.renderer.scrollLeft; + 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 self = this; - function normalizeRect(rect) { - if (hasCssTransform) - rect = self.recoverRect(tr, rect); - if (!isTextWidthCoordinate) - return rect; - var left = rect.left - (hasCssTransform ? leftOffset : textLayerRect.left); - return { - left: left, - right: left + rect.width, - width: rect.width, - top: rect.top, - bottom: rect.bottom, - height: rect.height - }; - } var getRects = (node) => { var rects = []; if (node.nodeType === Node.TEXT_NODE) { @@ -426,99 +414,35 @@ class FontMetrics { } else if (node.nodeType === Node.ELEMENT_NODE) { rects = Array.from(node.getClientRects()); } - for (var i = 0; i < rects.length; i++) { - rects[i] = normalizeRect(rects[i]); + 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; }; - function isLowSurrogate(node, offset) { - return /[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(offset)); - } - function getGraphemeWidth(node, offset) { - if ( - /[\uD800-\uDBFF]/.test(node.nodeValue.charAt(offset)) && - offset + 1 < node.nodeValue.length && - /[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(offset + 1)) - ) { - return 2; - } - return 1; - } - function getCollapsedLeft(node, offset) { - scratchRange.setStart(node, offset); - scratchRange.setEnd(node, offset); - var rect = /**@type{{left: number, top: number, width: number, height: number}}*/(scratchRange.getBoundingClientRect()); - return normalizeRect(rect).left; - } - function canBinarySearchTextNode(node) { - scratchRange.setStart(node, 0); - scratchRange.setEnd(node, node.nodeValue.length); - return scratchRange.getClientRects().length <= 1; - } - function findTextNodeColumn(node) { - var textLength = node.nodeValue.length; - if (textLength < BINARY_SEARCH_TEXT_LENGTH || !canBinarySearchTextNode(node)) - return null; - - var left = getCollapsedLeft(node, 0); - var right = getCollapsedLeft(node, textLength); - if (left === right) - return null; - - var isRtl = left > right; - // This should normally be filtered at the element/text-node rect level. - // If it still happens, skip the linear fallback for this text node. - if (x < Math.min(left, right) || x > Math.max(left, right)) - return -1; - - var low = 0; - var high = textLength; - while (low < high) { - var mid = (low + high) >> 1; - if (isLowSurrogate(node, mid)) - mid--; - if (mid < low) - mid = low; - - var midLeft = getCollapsedLeft(node, mid); - if (isRtl ? midLeft >= x : midLeft <= x) - low = mid + getGraphemeWidth(node, mid); - else - high = mid; - } - - var column = Math.max(0, Math.min(low - 1, textLength - 1)); - if (isLowSurrogate(node, column)) - column--; - var graphemeWidth = getGraphemeWidth(node, column); - - scratchRange.setStart(node, column); - scratchRange.setEnd(node, column + graphemeWidth); - var rect = /** @type {ReturnType}*/(normalizeRect(scratchRange.getBoundingClientRect())); - - if (rect.left <= x && x <= rect.left + rect.width) { - if (!blockCursor && x > rect.left + rect.width / 2) - column += graphemeWidth; - return column; - } - return null; - } + var self = this; function search(node) { if (node.nodeType === Node.TEXT_NODE) { var textLength = node.nodeValue.length; - var column = findTextNodeColumn(node); - if (column != null && column >= 0) { - screenColumn += column; - return screenColumn; - } - if (column === -1) - return; var graphemeWidth = 1; for (var j = 0; j < textLength; j+= graphemeWidth) { scratchRange.setStart(node, j); - graphemeWidth = getGraphemeWidth(node, j); + graphemeWidth = 1; + if ( + /[\uD800-\uDBFF]/.test(node.nodeValue.charAt(j)) && j + 1 < textLength && + /[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(j + 1)) + ) { + graphemeWidth = 2; + } scratchRange.setEnd(node, j + graphemeWidth); - let rect = /** @type {ReturnType}*/(normalizeRect(scratchRange.getBoundingClientRect())); + let rect = /** @type {ReturnType}*/(scratchRange.getBoundingClientRect()); + if (hasCssTransform) { + rect = self.recoverRect(tr, rect); + } if (rect.left <= x && x <= rect.left + rect.width) { screenColumn += j; if (!blockCursor && x > rect.left + rect.width / 2) { From c6c9f87a587d99fb015adf3c905339d16bc54ee0 Mon Sep 17 00:00:00 2001 From: nightwing Date: Thu, 2 Jul 2026 22:18:25 +0400 Subject: [PATCH 22/26] improve handling of bidi text --- ace-internal.d.ts | 1 + experiments/bidi.html | 146 ++++++++++++++++++++++++++++++++++++++ src/layer/font_metrics.js | 32 +++++---- 3 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 experiments/bidi.html diff --git a/ace-internal.d.ts b/ace-internal.d.ts index afbb9260a89..85cefb7d05e 100644 --- a/ace-internal.d.ts +++ b/ace-internal.d.ts @@ -1459,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/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/src/layer/font_metrics.js b/src/layer/font_metrics.js index d32206f36e6..ad963669329 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -398,7 +398,7 @@ class FontMetrics { var tr = hasCssTransform && this.getTransform(); if (isTextWidthCoordinate) { if (!hasCssTransform) { - x += this.textLayer.element.getBoundingClientRect().left + x += this.textLayer.element.getBoundingClientRect().left; } else { x += this.renderer.gutterWidth + this.renderer.margin.left + this.renderer.$padding - this.renderer.scrollLeft; } @@ -428,29 +428,33 @@ class FontMetrics { function search(node) { if (node.nodeType === Node.TEXT_NODE) { var textLength = node.nodeValue.length; - var graphemeWidth = 1; - for (var j = 0; j < textLength; j+= graphemeWidth) { + var maxDistance = Number.MAX_VALUE; + var index = -1; + var value = node.nodeValue; + for (var j = 0; j <= textLength; j++) { scratchRange.setStart(node, j); - graphemeWidth = 1; + scratchRange.setEnd(node, j); if ( - /[\uD800-\uDBFF]/.test(node.nodeValue.charAt(j)) && j + 1 < textLength && - /[\uDC00-\uDFFF]/.test(node.nodeValue.charAt(j + 1)) + j > 0 && j < textLength && /[\uDC00-\uDFFF]/.test(value.charAt(j)) && + /[\uD800-\uDBFF]/.test(value.charAt(j - 1)) + ) { - graphemeWidth = 2; + continue; } - scratchRange.setEnd(node, j + graphemeWidth); let rect = /** @type {ReturnType}*/(scratchRange.getBoundingClientRect()); if (hasCssTransform) { rect = self.recoverRect(tr, rect); } - if (rect.left <= x && x <= rect.left + rect.width) { - screenColumn += j; - if (!blockCursor && x > rect.left + rect.width / 2) { - screenColumn += graphemeWidth; - } - return screenColumn; + 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; From 7317a8f3e88f4adee0b83682b7c20678530a4071 Mon Sep 17 00:00:00 2001 From: nightwing Date: Wed, 8 Jul 2026 21:22:42 +0400 Subject: [PATCH 23/26] partial fix for horizontal scroll --- src/virtual_renderer.js | 72 ++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js index 6872f267cb5..7a08475936d 100644 --- a/src/virtual_renderer.js +++ b/src/virtual_renderer.js @@ -937,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; @@ -971,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; @@ -996,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; @@ -1009,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) @@ -1083,6 +1077,53 @@ 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; + 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 = longestRendered + 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} @@ -1124,8 +1165,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); From 32ae297abead042c6680563f46b8e10dcb6483ec Mon Sep 17 00:00:00 2001 From: nightwing Date: Fri, 10 Jul 2026 19:42:11 +0400 Subject: [PATCH 24/26] update node in ci, since new typescript doesn't work with node 16 --- .github/workflows/nodejs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 40e5bdc846720749cb5392b96a1181744b87c3bb Mon Sep 17 00:00:00 2001 From: nightwing Date: Fri, 10 Jul 2026 20:14:16 +0400 Subject: [PATCH 25/26] fix multiline selection marker on wrapped bidi lines --- src/layer/marker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layer/marker.js b/src/layer/marker.js index 3399294b983..92d16a69eea 100644 --- a/src/layer/marker.js +++ b/src/layer/marker.js @@ -164,7 +164,7 @@ class Marker { 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( From 8f9018cdcadd14972e6cd8d2c6aa0c6a1ba25aeb Mon Sep 17 00:00:00 2001 From: nightwing Date: Mon, 13 Jul 2026 03:07:39 +0400 Subject: [PATCH 26/26] cleanup --- src/layer/font_metrics.js | 7 +++---- src/virtual_renderer.js | 12 +++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index ad963669329..d7a78dd338b 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -8,7 +8,6 @@ var EventEmitter = require("../lib/event_emitter").EventEmitter; var CHAR_COUNT = 512; var USE_OBSERVER = typeof ResizeObserver == "function"; var L = 200; -var BINARY_SEARCH_TEXT_LENGTH = 256; class FontMetrics { @@ -383,16 +382,16 @@ class FontMetrics { * 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} screenColumn1 - The initial screen column index. + * @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, screenColumn1, x, blockCursor, isTextWidthCoordinate) { + $pixelToColumn(screenRow, screenColumnMonospace, x, blockCursor, isTextWidthCoordinate) { var scratchRange = this.$scratchRange; var lineElement = this.$findElementForScreenRow(screenRow); - if (!lineElement || screenColumn1 <= 0) return screenColumn1; + if (!lineElement || screenColumnMonospace < 0) return screenColumnMonospace; var hasCssTransform = this.renderer.$hasCssTransforms; var tr = hasCssTransform && this.getTransform(); diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js index 7a08475936d..9c149131d19 100644 --- a/src/virtual_renderer.js +++ b/src/virtual_renderer.js @@ -1084,9 +1084,11 @@ class VirtualRenderer { */ $updateContentTransformAndSize(config, changes) { var sm = this.scrollMargin; - var longestRendered = this.$getLongestRenderedLine(); - if (config.width < longestRendered) { - config.width = longestRendered; + 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 @@ -1097,7 +1099,7 @@ class VirtualRenderer { } dom.translate(this.content, -this.scrollLeft, -config.offset); - var width = longestRendered + 2 * this.$padding + "px"; + 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); @@ -1718,7 +1720,7 @@ class VirtualRenderer { var x = this.$padding + this.$fontMetrics.textWidth(pos.row, pos.column); var y = pos.row * this.lineHeight; - if (this.$hasCssTransforms) { //TODO: check + if (this.$hasCssTransforms) { var pagePos = this.$fontMetrics.transformCoordinates(null, [ this.gutterWidth + this.margin.left + x - this.scrollLeft, this.margin.top + y - this.scrollTop