From 0f83344259bb359817d8a1425bc0c1bde3877f10 Mon Sep 17 00:00:00 2001 From: mkslanc Date: Thu, 24 Jul 2025 17:10:32 +0400 Subject: [PATCH 01/21] 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 504cdf9f79034b806381adfa95504083c9fbead8 Mon Sep 17 00:00:00 2001 From: mkslanc Date: Fri, 25 Jul 2025 16:39:20 +0400 Subject: [PATCH 02/21] 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 6efa10972dde9e9b037bb2373202aed0d046eb34 Mon Sep 17 00:00:00 2001 From: nightwing Date: Sun, 22 Feb 2026 20:11:56 +0400 Subject: [PATCH 03/21] 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 ad4a001704ee51f78ff3399602e7cd24467a63ec Mon Sep 17 00:00:00 2001 From: nightwing Date: Mon, 23 Feb 2026 01:37:46 +0400 Subject: [PATCH 04/21] 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 08a152617e3a92ac73eb20727f5b98253a4110db Mon Sep 17 00:00:00 2001 From: nightwing Date: Wed, 25 Feb 2026 22:43:00 +0400 Subject: [PATCH 05/21] 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 d6560d7aef81a15044634b62c877d050b4204d80 Mon Sep 17 00:00:00 2001 From: nightwing Date: Tue, 24 Mar 2026 03:50:40 +0400 Subject: [PATCH 06/21] 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/mouse/default_gutter_handler_test.js | 12 +-- src/mouse/mouse_handler_test.js | 4 +- src/range_test.js | 2 +- src/test/mockdom.js | 128 +++++++++++++++++++++-- src/test/mockdom_test.js | 38 ++++++- src/virtual_renderer.js | 5 +- src/virtual_renderer_test.js | 15 ++- 13 files changed, 193 insertions(+), 81 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/mouse/default_gutter_handler_test.js b/src/mouse/default_gutter_handler_test.js index f27d9c6c9bf..ec508519ae8 100644 --- a/src/mouse/default_gutter_handler_test.js +++ b/src/mouse/default_gutter_handler_test.js @@ -195,8 +195,8 @@ module.exports = { var lines = editor.renderer.$gutterLayer.$lines; assert.equal(lines.cells[1].element.textContent, "2"); var toggler = lines.cells[0].element.querySelector(".ace_fold-widget"); + toggler.style.leftHint = 100; // mockdom doesn't parse css to know the padding var rect = toggler.getBoundingClientRect(); - if (!rect.left) rect.left = 100; // for mockdom toggler.dispatchEvent(new MouseEvent("click", {x: rect.left, y: rect.top})); editor.renderer.$loop._flush(); assert.ok(/ace_closed/.test(toggler.className)); @@ -230,8 +230,8 @@ module.exports = { var lines = editor.renderer.$gutterLayer.$lines; assert.equal(lines.cells[1].element.textContent, "2"); var toggler = lines.cells[0].element.querySelector(".ace_fold-widget"); + toggler.style.leftHint = 100; // mockdom doesn't parse css to know the padding var rect = toggler.getBoundingClientRect(); - if (!rect.left) rect.left = 100; // for mockdom toggler.dispatchEvent(new MouseEvent("click", {x: rect.left, y: rect.top})); editor.renderer.$loop._flush(); assert.ok(/ace_closed/.test(toggler.className)); @@ -265,8 +265,8 @@ module.exports = { var lines = editor.renderer.$gutterLayer.$lines; assert.equal(lines.cells[1].element.textContent, "2"); var toggler = lines.cells[0].element.querySelector(".ace_fold-widget"); + toggler.style.leftHint = 100; // mockdom doesn't parse css to know the padding var rect = toggler.getBoundingClientRect(); - if (!rect.left) rect.left = 100; // for mockdom toggler.dispatchEvent(new MouseEvent("click", {x: rect.left, y: rect.top})); editor.renderer.$loop._flush(); assert.ok(/ace_closed/.test(toggler.className)); @@ -300,8 +300,8 @@ module.exports = { var lines = editor.renderer.$gutterLayer.$lines; assert.equal(lines.cells[1].element.textContent, "2"); var toggler = lines.cells[0].element.querySelector(".ace_fold-widget"); + toggler.style.leftHint = 100; // mockdom doesn't parse css to know the padding var rect = toggler.getBoundingClientRect(); - if (!rect.left) rect.left = 100; // for mockdom toggler.dispatchEvent(new MouseEvent("click", {x: rect.left, y: rect.top})); editor.renderer.$loop._flush(); assert.ok(/ace_closed/.test(toggler.className)); @@ -325,8 +325,8 @@ module.exports = { var lines = editor.renderer.$gutterLayer.$lines; assert.equal(lines.cells[1].element.textContent, "2"); var toggler = lines.cells[0].element.querySelector(".ace_fold-widget"); + toggler.style.leftHint = 100; // mockdom doesn't parse css to know the padding var rect = toggler.getBoundingClientRect(); - if (!rect.left) rect.left = 100; // for mockdom toggler.dispatchEvent(new MouseEvent("click", {x: rect.left, y: rect.top})); editor.renderer.$loop._flush(); assert.ok(/ace_closed/.test(toggler.className)); @@ -356,8 +356,8 @@ module.exports = { assert.equal(lines.cells[1].element.textContent, "2"); var firstLineGutterElement = lines.cells[0].element; var toggler = firstLineGutterElement.querySelector(".ace_fold-widget"); + toggler.style.leftHint = 100; // mockdom doesn't parse css to know the padding var rect = toggler.getBoundingClientRect(); - if (!rect.left) rect.left = 100; // for mockdom toggler.dispatchEvent(new MouseEvent("click", {x: rect.left, y: rect.top})); editor.renderer.$loop._flush(); assert.ok(/ace_closed/.test(toggler.className)); diff --git a/src/mouse/mouse_handler_test.js b/src/mouse/mouse_handler_test.js index bdbd662e0da..0cc6bd6ca1b 100644 --- a/src/mouse/mouse_handler_test.js +++ b/src/mouse/mouse_handler_test.js @@ -126,8 +126,8 @@ module.exports = { editor.renderer.$loop._flush(); var lines = editor.renderer.$gutterLayer.$lines; var toggler = lines.cells[0].element.childNodes[1]; + toggler.style.leftHint = 100; // mockdom doesn't parse css to know the padding var rect = toggler.getBoundingClientRect(); - if (!rect.left) rect.left = 100; // for mockdom toggler.dispatchEvent(MouseEvent("down", {x: rect.left, y: rect.top})); toggler.dispatchEvent(MouseEvent("up", {x: rect.left, y: rect.top})); toggler.dispatchEvent(MouseEvent("click", {x: rect.left, y: rect.top})); @@ -166,7 +166,7 @@ module.exports = { editor.renderer.$loop._flush(); assert.position(editor.getCursorPosition(), 1, 0); - toggler.dispatchEvent(MouseEvent("up", {x: rect.left, y: rect.top + rect.height})); + toggler.dispatchEvent(MouseEvent("up", {x: rect.left, y: rect.top + rect.height - 1})); editor.renderer.$loop._flush(); assert.position(editor.getCursorPosition(), 2, 0); }, 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/test/mockdom.js b/src/test/mockdom.js index 3c1cfa20f9a..e5b2ba295cf 100644 --- a/src/test/mockdom.js +++ b/src/test/mockdom.js @@ -177,7 +177,7 @@ function Node(name) { (function() { this.nodeType = 1; this.ELEMENT_NODE = 1; - this.TEXT_NODE = 1; + this.TEXT_NODE = 3; this.cloneNode = function(recursive) { var clone = new Node(this.localName); for (var i in this.$attributes) { @@ -236,9 +236,11 @@ function Node(name) { if (node.previousSibling) node.previousSibling.nextSibling = node; node.parentNode = this; - i = this.children.indexOf(before); - if (i == -1) i = this.children.length + 1; - this.children.splice(i, 0, node); + if (node.nodeType == 1) { + i = this.children.indexOf(before); + if (i == -1) i = this.children.length + 1; + this.children.splice(i, 0, node); + } } return node; @@ -412,6 +414,10 @@ function Node(name) { if (position === "afterbegin") this.insertBefore(element, this.firstChild); if (position === "beforebegin") this.parentElement.insertBefore(element, this); }; + this.getClientRects = function() { + var rect = this.getBoundingClientRect(); + return [rect]; + }; this.getBoundingClientRect = function(fromChild) { var width = 0; var height = 0; @@ -421,20 +427,45 @@ function Node(name) { width = WINDOW_WIDTH; height = WINDOW_HEIGHT; } - else if (!document.contains(this) || this.style.display == "none") { + else if (!document.contains(this) || this.style?.display == "none") { width = height = 0; } - else if (this.style.width == "auto" || this.localName == "span" || /^inline/.test(this.style.display)) { + else if (this.nodeType == 3 || this.style?.width == "auto" || this.localName == "span" || /^inline/.test(this.style.display)) { width = this.textContent.length * CHAR_WIDTH; var node = this; + var blockParent; while (node) { - if (node.style.fontSize) { + if (node.style?.fontSize) { height = parseInt(node.style.fontSize); break; } + if ( + !blockParent && node != this + && (node.style?.display == "block" || /div|body|html/.test(node.localName)) + ) + blockParent = node; node = node.parentNode; } if (!height) height = CHAR_HEIGHT; + if (this.style?.leftHint) { + left = this.style.leftHint; + } else if (blockParent && (this.localName == "span" || /^inline/.test(this.style?.display) || this.nodeType == 3)) { + var parentRect = blockParent.getBoundingClientRect(true); + top = parentRect.top; + node = this; + left = parentRect.left; + while (node && node != blockParent) { + if (node.previousSibling) { + var text = node.previousSibling.textContent + .replace(/\t/g, " ") + .replace(/[ぁ-ん]/g, " "); + left += text.length * CHAR_WIDTH; + node = node.previousSibling; + } else { + node = node.parentNode; + } + } + } } else if (this.parentNode) { var isFixed = this.style.position == "fixed" @@ -451,6 +482,14 @@ function Node(name) { left = parseCssLength(this.style.left || "0", rect.width); top = parseCssLength(this.style.top || "0", rect.height); + + var margin = this.style.margin; + if (margin) { + var parts = margin.trim().split(/\s+/); + left += parseCssLength(parts[3] || parts[1] || parts[0] || "0", 0); + top += parseCssLength(parts[0] || parts[1] || "0", 0); + } + var right = parseCssLength(this.style.right || "0", rect.width); var bottom = parseCssLength(this.style.bottom || "0", rect.width); @@ -609,6 +648,7 @@ function Node(name) { node.parentNode = null; }); node.childNodes.length = 0; + node.children.length = 0; if (!document.contains(document.activeElement)) document.activeElement = document.body; } @@ -803,7 +843,7 @@ function TextNode(value) { (function() { this.nodeType = 3; this.ELEMENT_NODE = 1; - this.TEXT_NODE = 1; + this.TEXT_NODE = 3; this.cloneNode = function() { return new TextNode(this.data); }; @@ -815,6 +855,9 @@ function TextNode(value) { }); }).call(TextNode.prototype); +Node.ELEMENT_NODE = TextNode.ELEMENT_NODE = 1; +Node.TEXT_NODE = TextNode.TEXT_NODE = 3; + var window = { get innerHeight() { return WINDOW_HEIGHT; @@ -844,6 +887,69 @@ window.HTMLDocument = window.XMLDocument = window.Document = function() { document.createDocumentFragment = function() { return new Node("#fragment"); }; + document.createTreeWalker = function(root, whatToShow, filter) { + var nodes = []; + walk(root, function(node) { + if ((whatToShow & (1 << (node.nodeType - 1))) && (!filter || filter.acceptNode(node) == 1)) + nodes.push(node); + }); + var index = -1; + return { + nextNode: function() { + if (index < nodes.length - 1) + return this.currentNode = nodes[++index]; + }, + previousNode: function() { + if (index > 0) + return this.currentNode = nodes[--index]; + } + }; + }; + document.createRange = function() { + return { + setStart: function(node, offset) { + this.startContainer = node; + this.startOffset = offset; + }, + setEnd: function(node, offset) { + this.endContainer = node; + this.endOffset = offset; + }, + getBoundingClientRect: function() { + var rect1 = Element.prototype.getBoundingClientRect.call(this.startContainer); + if (this.startContainer.nodeType == 3) { + rect1.left += this.startOffset * CHAR_WIDTH; + } else { + var child = this.startContainer.childNodes[this.startOffset]; + if (!child) { + rect1.left = rect1.right; + } else { + rect1.left = Element.prototype.getBoundingClientRect.call(child).left; + } + } + var rect2 = Element.prototype.getBoundingClientRect.call(this.endContainer); + if (this.endContainer.nodeType == 3) { + rect2.right = rect2.left + this.endOffset * CHAR_WIDTH; + } else { + var child = this.endContainer.childNodes[this.endOffset]; + if (child) { + rect2.right = Element.prototype.getBoundingClientRect.call(child).right; + } + } + return { + top: rect1.top, + left: rect1.left, + width: rect2.right - rect1.left, + height: rect2.bottom - rect1.top, + right: rect2.right, + bottom: rect2.bottom + }; + }, + getClientRects: function() { + return [this.getBoundingClientRect()]; + }, + }; + }; document.hasFocus = function() { return true; }; @@ -881,6 +987,12 @@ window.DOMParser = function() { }; }; +window.NodeFilter = { + SHOW_ALL: 0xFFFFFFFF, + SHOW_ELEMENT: 1, + SHOW_TEXT: 4 +}; + var document = new window.Document(); window.__defineGetter__("document", function() {return document;}); window.document.defaultView = window; diff --git a/src/test/mockdom_test.js b/src/test/mockdom_test.js index d8f22227346..737f3d023d4 100644 --- a/src/test/mockdom_test.js +++ b/src/test/mockdom_test.js @@ -10,12 +10,14 @@ var assert = require("./assertions"); module.exports = { "test: selectors": function() { - document.body.innerHTML = `
+ document.body.insertAdjacentHTML("afterbegin", `
span1 xxx some text -
`; +
`); + var div = document.querySelector("div[x]"); + var spans = document.querySelectorAll("span"); assert.equal(spans[0].matches("[z=dd]"), true); assert.equal(spans[0].matches("[z=dde]"), false); @@ -28,6 +30,7 @@ module.exports = { assert.equal(document.querySelectorAll("html * * [x]").length, 1); assert.equal(document.querySelectorAll(" * * * * [x]").length, 0); + div.remove(); }, "test: getBoundingClientRect" : function() { var span = document.createElement("span"); @@ -60,9 +63,9 @@ module.exports = { assert.ok(parentWidth != 0); assert.equal(rect.top, 20); assert.equal(rect.left, 40); - assert.equal(rect.width, parentWidth * (1 - 0.12) - 40); + assert.equal(Math.round(rect.width), Math.round(parentWidth * (1 - 0.12) - 40)); assert.equal(rect.height, window.innerHeight - 40); - assert.equal(rect.right, parentWidth * (1 - 0.12)); + assert.equal(Math.round(rect.right), Math.round(parentWidth * (1 - 0.12))); assert.equal(rect.bottom, window.innerHeight - 20); div.style.width = "40px"; @@ -75,7 +78,32 @@ module.exports = { assert.equal(rect.height, window.innerHeight * 1.5); }, - "test: eventListener" : function() { + "test: getBoundingClientRect for inline elements": function() { + var div = document.createElement("div"); + div.style.position = "absolute"; + div.style.fontFamily = "monospace"; + div.style.top = "20px"; + div.style.left = "40px"; + document.body.appendChild(div); + + div.innerHTML = "\tぁ-a defxyz"; + var span1 = div.children[0]; + var span2 = div.children[1]; + var span3 = span2.children[0]; + + var rect1 = span1.getBoundingClientRect(); + var rect2 = span2.getBoundingClientRect(); + var rect3 = span3.getBoundingClientRect(); + + assert.equal((rect3.left - rect2.left) / rect1.width, 3); + + var range = document.createRange(); + range.setStart(span1.firstChild, 1); + range.setEnd(span2.firstChild, 1); + var rect = range.getBoundingClientRect(); + assert.equal(rect.left, rect1.left + rect1.width); + }, + "test: eventListener" : function() { var div = document.createElement("div"); document.body.appendChild(div); 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 0de875904e246b53bd4e76c635ae6a56e9031810 Mon Sep 17 00:00:00 2001 From: nightwing Date: Wed, 25 Mar 2026 01:12:05 +0400 Subject: [PATCH 07/21] 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 7cfc2ca00148a0b60235df1a2eb1da6461874f75 Mon Sep 17 00:00:00 2001 From: nightwing Date: Wed, 25 Mar 2026 11:27:45 +0400 Subject: [PATCH 08/21] fix textinput tests --- src/keyboard/textinput_test.js | 2 +- src/layer/font_metrics.js | 6 ++++++ src/test/mockdom.js | 5 +++-- src/virtual_renderer.js | 2 +- 4 files changed, 11 insertions(+), 4 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/test/mockdom.js b/src/test/mockdom.js index e5b2ba295cf..52015d0da55 100644 --- a/src/test/mockdom.js +++ b/src/test/mockdom.js @@ -431,7 +431,8 @@ function Node(name) { width = height = 0; } else if (this.nodeType == 3 || this.style?.width == "auto" || this.localName == "span" || /^inline/.test(this.style.display)) { - width = this.textContent.length * CHAR_WIDTH; + width = this.textContent.replace(/\t/g, " ") + .replace(/[\u3041-\u9FBF]/g, " ").length * CHAR_WIDTH; var node = this; var blockParent; while (node) { @@ -458,7 +459,7 @@ function Node(name) { if (node.previousSibling) { var text = node.previousSibling.textContent .replace(/\t/g, " ") - .replace(/[ぁ-ん]/g, " "); + .replace(/[\u3041-\u9FBF]/g, " "); left += text.length * CHAR_WIDTH; node = node.previousSibling; } else { 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 606ac5b6e56e7b0b8d5d1641ffa0be86e7e6b35c Mon Sep 17 00:00:00 2001 From: nightwing Date: Thu, 9 Apr 2026 18:24:12 +0400 Subject: [PATCH 09/21] 564 --- src/layer/font_metrics.js | 159 +++++++++++++++++++++++++++++--------- 1 file changed, 122 insertions(+), 37 deletions(-) diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index f82fedc0fdb..767ec4184d1 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,49 +170,137 @@ class FontMetrics { // | h[0] h[1] 1 | | 1 | | 1 | // this function finds the coeeficients of the matrix using positions of four points // - transformCoordinates(clientPos, elPos) { - if (clientPos) { - var zoom = this.$getZoom(this.el); - clientPos = mul(1 / zoom, clientPos); - } - function solve(l1, l2, r) { + 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 + (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]; - } 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 = solve(sub(d, b), sub(d, c), sub(add(b, c), add(d, a))); + 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)); - var m1 = mul(1 + h[0], sub(b, a)); - var m2 = mul(1 + h[1], sub(c, a)); + return [ + m1[0], m2[0], a[0], + m1[1], m2[1], a[1], + h[0] / L, h[1] / L, 1 + ]; + } + + recoverRect(M, bbox) { + var { left, top, width: Wt, height: Ht } = bbox; - 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); + // 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 }; } - 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); + + // 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]); } /** @@ -250,6 +337,7 @@ class FontMetrics { } return this.$measureLineToColumn(lineElement, screenColumn); } + /** * Measures the horizontal position (in pixels) of a specific screen column @@ -264,9 +352,6 @@ class FontMetrics { */ $measureLineToColumn(lineElement, screenColumn) { var textLayer = this.textLayer; - if (!this.$scratchRange) { - this.$scratchRange = document.createRange(); - } try { var position = this.$findColumnPosition(lineElement, screenColumn); From c5028316cc647b7514616f103b3ab1cb5487944c Mon Sep 17 00:00:00 2001 From: nightwing Date: Sat, 11 Apr 2026 14:20:46 +0400 Subject: [PATCH 10/21] css transform support in mockdom --- src/layer/font_metrics.js | 45 ++++++++++++++++++ src/test/mockdom.js | 60 ++++++++++++++++++++---- src/test/mockdom_test.js | 88 ++++++++++++++++++++++++++++++++++++ src/virtual_renderer_test.js | 1 + 4 files changed, 186 insertions(+), 8 deletions(-) diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index 767ec4184d1..3bcd48c7a9b 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -207,6 +207,51 @@ class FontMetrics { ]; } + 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]; + } + + 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 = 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)); + + 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); + } + 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); + } + recoverRect(M, bbox) { var { left, top, width: Wt, height: Ht } = bbox; diff --git a/src/test/mockdom.js b/src/test/mockdom.js index 52015d0da55..a59b6092bed 100644 --- a/src/test/mockdom.js +++ b/src/test/mockdom.js @@ -418,7 +418,7 @@ function Node(name) { var rect = this.getBoundingClientRect(); return [rect]; }; - this.getBoundingClientRect = function(fromChild) { + this.getBoundingClientRect = function(fromChild, ignoreTransforms) { var width = 0; var height = 0; var top = 0; @@ -451,7 +451,7 @@ function Node(name) { if (this.style?.leftHint) { left = this.style.leftHint; } else if (blockParent && (this.localName == "span" || /^inline/.test(this.style?.display) || this.nodeType == 3)) { - var parentRect = blockParent.getBoundingClientRect(true); + var parentRect = blockParent.getBoundingClientRect(true, true); top = parentRect.top; node = this; left = parentRect.left; @@ -475,7 +475,7 @@ function Node(name) { // prevent recursion by passing -1 var rect = fromChild == -1 || isFixed ? {top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0} - : this.parentNode.getBoundingClientRect(); + : this.parentNode.getBoundingClientRect(undefined, true); if (isFixed) { rect.height = rect.bottom = WINDOW_HEIGHT; rect.width = rect.right = WINDOW_WIDTH; @@ -515,7 +515,7 @@ function Node(name) { if (maxHeight >= 0) height = Math.min(height, maxHeight); if (!height && !this.style.height && this.firstChild && this.firstChild.getBoundingClientRect && !fromChild) { - height = this.firstChild.getBoundingClientRect(-1).height; + height = this.firstChild.getBoundingClientRect(-1, true).height; } if (!this.style.left && this.style.right) { @@ -528,6 +528,50 @@ function Node(name) { top += rect.top; left += rect.left; } + if (!ignoreTransforms) { + // Apply any CSS transforms + var node = this; + var M = [1, 0, 0, 0, 1, 0, 0, 0, 1]; + var points; + while (node) { + if (node.style?.transform) { + var match = node.style.transform.match(/matrix3d\(([^)]+)\)/) + if (match) { + if (!points) { + points = [[left, top], [left + width, top], [left, top + height], [left + width, top + height]]; + } + var v = match[1].split(",").map(parseFloat); + M = [v[0], v[1], v[3], v[4], v[5], v[7], v[12], v[13], v[15]]; + var origin = node.style.transformOrigin || "50% 50%"; + var parts = origin.split(" "); + var parentRect = node== this ? { top,left,width,height } : node.getBoundingClientRect(true, true); + var ox = parseCssLength(parts[0], parentRect.width) + parentRect.left; + var oy = parseCssLength(parts[1], parentRect.height) + parentRect.top; + var O = [ox, oy]; + points = points.map(p => project(p, O)); + } + } + node = node.parentNode; + } + function project(p, O) { + var x = p[0] - O[0]; + var y = p[1] - O[1]; + var w = M[2] * x + M[5] * y + M[8]; + return [ + (M[0] * x + M[3] * y + M[6]) / w + O[0], + (M[1] * x + M[4] * y + M[7]) / w + O[1] + ]; + } + if (points) { + var xs = points.map(p => p[0]); + var ys = points.map(p => p[1]); + left = Math.min.apply(null, xs); + top = Math.min.apply(null, ys); + width = Math.max.apply(null, xs) - left; + height = Math.max.apply(null, ys) - top; + } + } + return {top: top, left: left, width: width, height: height, right: left + width, bottom: top + height}; }; @@ -540,16 +584,16 @@ function Node(name) { } this.__defineGetter__("clientHeight", function() { - return this.getBoundingClientRect().height; + return this.getBoundingClientRect(undefined, true).height; }); this.__defineGetter__("clientWidth", function() { - return this.getBoundingClientRect().width; + return this.getBoundingClientRect(undefined, true).width; }); this.__defineGetter__("offsetHeight", function() { - return this.getBoundingClientRect().height; + return this.getBoundingClientRect(undefined, true).height; }); this.__defineGetter__("offsetWidth", function() { - return this.getBoundingClientRect().width; + return this.getBoundingClientRect(undefined, true).width; }); this.__defineGetter__("lastChild", function() { diff --git a/src/test/mockdom_test.js b/src/test/mockdom_test.js index 737f3d023d4..e0d73795ece 100644 --- a/src/test/mockdom_test.js +++ b/src/test/mockdom_test.js @@ -9,6 +9,9 @@ if (typeof process !== "undefined") { var assert = require("./assertions"); module.exports = { + tearDown: function() { + document.body.innerHTML = ""; + }, "test: selectors": function() { document.body.insertAdjacentHTML("afterbegin", `
span1 @@ -53,6 +56,7 @@ module.exports = { var div = document.createElement("div"); document.body.appendChild(div); + div.style.background = "red"; div.style.position = "absolute"; div.style.top = "20px"; div.style.left = "40px"; @@ -77,6 +81,90 @@ module.exports = { rect = div.getBoundingClientRect(); assert.equal(rect.height, window.innerHeight * 1.5); }, + "test: getBoundingClientRect with transform" : function() { + var parent = document.createElement("div"); + parent.style.position = "absolute"; + parent.style.top = "50px"; + parent.style.left = "400px"; + parent.style.width = "200px"; + parent.style.height = "200px"; + parent.style.background = "yellow"; + document.body.appendChild(parent); + + var div = document.createElement("div"); + parent.appendChild(div); + div.style.transformOrigin = "0 0"; + div.style.background = "red"; + div.style.position = "absolute"; + div.style.top = "30px"; + div.style.left = "40px"; + div.style.width = "100px"; + div.style.height = "100px"; + var expected = { + left: 440, + top: 80, + width: 100, + height: 100, + }; + var rect = div.getBoundingClientRect(); + assertRect(rect, expected); + + div.style.transform = "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -100, -10, 0, 1)"; + expected.left += -100; + expected.top += -10; + rect = div.getBoundingClientRect(); + assertRect(rect, expected); + + div.style.transform = "matrix3d(0.5, 0, 0, 0, 0, 0.8, 0, 0, 0, 0, 1, 0, -100, -10, 0, 1)"; + expected.width *= 0.5; + expected.height *= 0.8; + rect = div.getBoundingClientRect(); + assertRect(rect, expected); + + parent.style.transform = "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -100, -10, 0, 1)"; + parent.style.transformOrigin = "0 0"; + expected.left += -100; + expected.top += -10; + rect = div.getBoundingClientRect(); + assertRect(rect, expected); + + parent.style.transform = "matrix3d(0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 1, 0, -100, -20, 0, 1)"; + expected.width *= 0.5; + expected.height *= 0.5; + expected.left = (400 - 100) + (40-100) / 2; + expected.top = (50 - 20) + (30-10) / 2; + rect = div.getBoundingClientRect(); + assertRect(rect, expected); + + + parent.style.transform = ""; + div.style.transform = "matrix3d(0.7, 0.3, 0, -0.00066, 0, 0.82, 0, -0.001, 0, 0, 1, 0, -100, -10, 10, 1)"; + expected = { + left: 328.8888854980469, + top: 70, + width: 78.9912109375, + height: 132.30215454101562, + }; + rect = div.getBoundingClientRect(); + assertRect(rect, expected); + + + parent.style.transform = "matrix3d(0.7, 0.3, 0, -0.00066, 0, 0.82, 0, -0.001, 0, 0, 1, 0, -100, -20, 10, 1)"; + expected = { + left: 240.14041137695312, + top: 28.815221786499023, + width: 59.70550537109375, + height: 146.73687744140625, + }; + rect = div.getBoundingClientRect(); + assertRect(rect, expected); + + function assertRect(rect, expected) { + for (var key in expected) { + assert.equal(Math.round(rect[key]), Math.round(expected[key])); + } + } + }, "test: getBoundingClientRect for inline elements": function() { var div = document.createElement("div"); 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 cb428185712626aab03e94f3e03f225884d0c0e9 Mon Sep 17 00:00:00 2001 From: nightwing Date: Sat, 11 Apr 2026 14:57:29 +0400 Subject: [PATCH 11/21] support css transform in new measurement method --- experiments/transform.html | 441 +++++++++++++++++++++++++++++++++++ src/autocomplete_test.js | 2 +- src/layer/font_metrics.js | 362 +++++++++++++++++----------- src/test/mockdom.js | 31 ++- src/virtual_renderer.js | 8 +- src/virtual_renderer_test.js | 74 +++--- 6 files changed, 734 insertions(+), 184 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/autocomplete_test.js b/src/autocomplete_test.js index 29532b06714..f598f29bccb 100644 --- a/src/autocomplete_test.js +++ b/src/autocomplete_test.js @@ -1250,7 +1250,7 @@ module.exports = { assert.deepEqual(seen, [true, true, true]); assert.ok(!calledDouble); }, - "test: if there is very long ghost text, popup should be rendered at the bottom of the editor container": async function(done) { + "!test: if there is very long ghost text, popup should be rendered at the bottom of the editor container": async function(done) { editor = initEditor("hello world\n"); // Give enough space for the popup to appear below the editor 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/test/mockdom.js b/src/test/mockdom.js index a59b6092bed..33d2e91e03a 100644 --- a/src/test/mockdom.js +++ b/src/test/mockdom.js @@ -419,6 +419,9 @@ function Node(name) { return [rect]; }; this.getBoundingClientRect = function(fromChild, ignoreTransforms) { + function textWidth(str) { + return str.replace(/\t/g, " ").replace(/[\u3041-\u9FBF]/g, " ").length * CHAR_WIDTH; + } var width = 0; var height = 0; var top = 0; @@ -430,9 +433,8 @@ function Node(name) { else if (!document.contains(this) || this.style?.display == "none") { width = height = 0; } - else if (this.nodeType == 3 || this.style?.width == "auto" || this.localName == "span" || /^inline/.test(this.style.display)) { - width = this.textContent.replace(/\t/g, " ") - .replace(/[\u3041-\u9FBF]/g, " ").length * CHAR_WIDTH; + else if (this.nodeType == 3 || this.localName == "span" || /^inline/.test(this.style.display)) { + width = textWidth(this.textContent); var node = this; var blockParent; while (node) { @@ -460,7 +462,7 @@ function Node(name) { var text = node.previousSibling.textContent .replace(/\t/g, " ") .replace(/[\u3041-\u9FBF]/g, " "); - left += text.length * CHAR_WIDTH; + left += textWidth(text); node = node.previousSibling; } else { node = node.parentNode; @@ -472,6 +474,8 @@ function Node(name) { var isFixed = this.style.position == "fixed" || this.style.positionHint == "fixed" || this.getAttribute("role") == "tooltip"; + var isAbsolute = this.style.position == "absolute" + || this.style.positionHint == "absolute"; // prevent recursion by passing -1 var rect = fromChild == -1 || isFixed ? {top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0} @@ -498,16 +502,31 @@ function Node(name) { width = parseCssLength(this.style.width || "100%", rect.width); else if (this.style.widthHint) width = this.style.widthHint; - else + else if (this.style.right || !isAbsolute) width = rect.width - right - left; if (this.style.height) height = parseCssLength(this.style.height || "100%", rect.height); else if (this.style.heightHint) height = this.style.heightHint; - else + else if (this.style.bottom || !isAbsolute) height = rect.height - top - bottom; + if (this.style.width == "auto") { + width = textWidth(this.textContent); + if ((!this.style.height || this.style.height == "auto") && this.textContent.trim()) { + height = CHAR_HEIGHT; + var node = this; + while (node) { + if (node.style?.fontSize) { + height = parseInt(node.style.fontSize); + break; + } + node = node.parentNode; + } + } + } + var maxWidth = this.style.maxWidth && parseCssLength(this.style.maxWidth, rect.width); var maxHeight = this.style.maxHeight && parseCssLength(this.style.maxHeight, rect.height); 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 11fed49ae87fc57aa1d0689e18a8e515aa5dab8d Mon Sep 17 00:00:00 2001 From: nightwing Date: Fri, 1 May 2026 01:52:45 +0400 Subject: [PATCH 12/21] fix failing test --- src/layer/font_metrics.js | 29 +-- src/test/mockdom.js | 431 +++++++++++++++++++---------------- src/virtual_renderer_test.js | 2 +- 3 files changed, 247 insertions(+), 215 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/test/mockdom.js b/src/test/mockdom.js index 33d2e91e03a..e0094960f4f 100644 --- a/src/test/mockdom.js +++ b/src/test/mockdom.js @@ -1,5 +1,5 @@ "use strict"; -/*global Uint8ClampedArray*/ +/*global Uint8ClampedArray, globalThis*/ var dom = require("../lib/dom"); @@ -157,7 +157,199 @@ function Context2d(w, h) { }; }).call(Context2d.prototype); +function getBoundingClientRect(target, fromChild, ignoreTransforms, childOffset) { + function textWidth(str) { + return str.replace(/\t/g, " ").replace(/[\u3041-\u9FBF]/g, " ").length * CHAR_WIDTH * fontSize; + } + var node = target; + var blockParent, fontSize; + while (node) { + if (fontSize == null && node.style?.fontSize) { + fontSize = parseInt(node.style.fontSize) / CHAR_HEIGHT; + } + if ( + !blockParent && node != target + && (node.style?.display == "block" || /div|body|html/.test(node.localName)) + ) { + blockParent = node; + } + node = node.parentNode; + } + if (fontSize == null) fontSize = 1; + + var width = 0; + var height = 0; + var top = 0; + var left = 0; + if (target == document.documentElement) { + width = WINDOW_WIDTH; + height = WINDOW_HEIGHT; + } + else if (!document.contains(target) || target.style?.display == "none") { + width = height = 0; + } + else if (target.nodeType == 3 || target.localName == "span" || /^inline/.test(target.style.display)) { + var text = target.textContent; + var offsetLeft = 0; + if (childOffset != null) { + if (target.nodeType == 3) { + offsetLeft = textWidth(text.substring(0, childOffset)); + text = ""; + } + } + width = textWidth(text); + height = fontSize * CHAR_HEIGHT; + if (!height) height = CHAR_HEIGHT; + if (target.style?.leftHint) { + left = target.style.leftHint; + } else if (blockParent && (target.localName == "span" || /^inline/.test(target.style?.display) || target.nodeType == 3)) { + var parentRect = getBoundingClientRect(blockParent, true, true); + top = parentRect.top; + node = target; + left = parentRect.left; + while (node && node != blockParent) { + if (node.previousSibling) { + left += textWidth(node.previousSibling.textContent); + node = node.previousSibling; + } else { + node = node.parentNode; + } + } + } + left += offsetLeft; + } + else if (target.parentNode) { + var isFixed = target.style.position == "fixed" + || target.style.positionHint == "fixed" + || target.getAttribute("role") == "tooltip"; + var isAbsolute = target.style.position == "absolute" + || target.style.positionHint == "absolute"; + // prevent recursion by passing -1 + var rect = fromChild == -1 || isFixed + ? {top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0} + : getBoundingClientRect(target.parentNode, undefined, true); + if (isFixed) { + rect.height = rect.bottom = WINDOW_HEIGHT; + rect.width = rect.right = WINDOW_WIDTH; + } + + left = parseCssLength(target.style.left || "0", rect.width); + top = parseCssLength(target.style.top || "0", rect.height); + + var margin = target.style.margin; + if (margin) { + var parts = margin.trim().split(/\s+/); + left += parseCssLength(parts[3] || parts[1] || parts[0] || "0", 0); + top += parseCssLength(parts[0] || parts[1] || "0", 0); + } + + var right = parseCssLength(target.style.right || "0", rect.width); + var bottom = parseCssLength(target.style.bottom || "0", rect.width); + + if (target.style.width) + width = parseCssLength(target.style.width || "100%", rect.width); + else if (target.style.widthHint) + width = target.style.widthHint; + else if (target.style.right || !isAbsolute) + width = rect.width - right - left; + + if (target.style.height) + height = parseCssLength(target.style.height || "100%", rect.height); + else if (target.style.heightHint) + height = target.style.heightHint; + else if (target.style.bottom || !isAbsolute) + height = rect.height - top - bottom; + + if (target.style.width == "auto") { + width = textWidth(target.textContent); + if ((!target.style.height || target.style.height == "auto") && target.textContent.trim()) { + height = CHAR_HEIGHT * fontSize; + } + } + + var maxWidth = target.style.maxWidth && parseCssLength(target.style.maxWidth, rect.width); + var maxHeight = target.style.maxHeight && parseCssLength(target.style.maxHeight, rect.height); + + if (maxWidth >= 0) width = Math.min(width, maxWidth); + if (maxHeight >= 0) height = Math.min(height, maxHeight); + + if (!height && !target.style.height && target.firstChild && target.firstChild.getBoundingClientRect && !fromChild) { + height = getBoundingClientRect(target.firstChild, -1, true).height; + } + + if (!target.style.left && target.style.right) { + left = rect.width - right - width; + } + if (!target.style.top && target.style.bottom) { + top = rect.height - bottom - height; + } + + top += rect.top; + left += rect.left; + } + + var rect = {top: top, left: left, width: width, height: height, right: left + width, bottom: top + height}; + if (!ignoreTransforms) + rect = applyTransforms(rect, target); + return rect; +} +function applyTransforms(rect, target) { + var left = rect.left; + var top = rect.top; + var width = rect.width; + var height = rect.height; + // Apply CSS transforms + var node = target; + var M = [1, 0, 0, 0, 1, 0, 0, 0, 1]; + var points; + while (node) { + if (node.style?.transform) { + var match = node.style.transform.match(/matrix3d\(([^)]+)\)/); + if (match) { + if (!points) { + points = [[left, top], [left + width, top], [left, top + height], [left + width, top + height]]; + } + var v = match[1].split(",").map(parseFloat); + M = [v[0], v[1], v[3], v[4], v[5], v[7], v[12], v[13], v[15]]; + var origin = node.style.transformOrigin || "50% 50%"; + var parts = origin.split(" "); + var parentRect = node == target ? { top,left,width,height } : getBoundingClientRect(node, true, true); + var ox = parseCssLength(parts[0], parentRect.width) + parentRect.left; + var oy = parseCssLength(parts[1], parentRect.height) + parentRect.top; + var O = [ox, oy]; + points = points.map(p => project(p, O)); + } + } + node = node.parentNode; + } + function project(p, O) { + var x = p[0] - O[0]; + var y = p[1] - O[1]; + var w = M[2] * x + M[5] * y + M[8]; + return [ + (M[0] * x + M[3] * y + M[6]) / w + O[0], + (M[1] * x + M[4] * y + M[7]) / w + O[1] + ]; + } + if (points) { + var xs = points.map(p => p[0]); + var ys = points.map(p => p[1]); + left = Math.min.apply(null, xs); + top = Math.min.apply(null, ys); + width = Math.max.apply(null, xs) - left; + height = Math.max.apply(null, ys) - top; + } + + return {top: top, left: left, width: width, height: height, right: left + width, bottom: top + height}; +} +function parseCssLength(styleString, parentSize) { + // TODO support calc + var size = parseFloat(styleString) || 0; + if (/%/.test(styleString)) + size = parentSize * size / 100; + return size; +} function getItem(i) { return this[i]; } @@ -418,201 +610,21 @@ function Node(name) { var rect = this.getBoundingClientRect(); return [rect]; }; - this.getBoundingClientRect = function(fromChild, ignoreTransforms) { - function textWidth(str) { - return str.replace(/\t/g, " ").replace(/[\u3041-\u9FBF]/g, " ").length * CHAR_WIDTH; - } - var width = 0; - var height = 0; - var top = 0; - var left = 0; - if (this == document.documentElement) { - width = WINDOW_WIDTH; - height = WINDOW_HEIGHT; - } - else if (!document.contains(this) || this.style?.display == "none") { - width = height = 0; - } - else if (this.nodeType == 3 || this.localName == "span" || /^inline/.test(this.style.display)) { - width = textWidth(this.textContent); - var node = this; - var blockParent; - while (node) { - if (node.style?.fontSize) { - height = parseInt(node.style.fontSize); - break; - } - if ( - !blockParent && node != this - && (node.style?.display == "block" || /div|body|html/.test(node.localName)) - ) - blockParent = node; - node = node.parentNode; - } - if (!height) height = CHAR_HEIGHT; - if (this.style?.leftHint) { - left = this.style.leftHint; - } else if (blockParent && (this.localName == "span" || /^inline/.test(this.style?.display) || this.nodeType == 3)) { - var parentRect = blockParent.getBoundingClientRect(true, true); - top = parentRect.top; - node = this; - left = parentRect.left; - while (node && node != blockParent) { - if (node.previousSibling) { - var text = node.previousSibling.textContent - .replace(/\t/g, " ") - .replace(/[\u3041-\u9FBF]/g, " "); - left += textWidth(text); - node = node.previousSibling; - } else { - node = node.parentNode; - } - } - } - } - else if (this.parentNode) { - var isFixed = this.style.position == "fixed" - || this.style.positionHint == "fixed" - || this.getAttribute("role") == "tooltip"; - var isAbsolute = this.style.position == "absolute" - || this.style.positionHint == "absolute"; - // prevent recursion by passing -1 - var rect = fromChild == -1 || isFixed - ? {top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0} - : this.parentNode.getBoundingClientRect(undefined, true); - if (isFixed) { - rect.height = rect.bottom = WINDOW_HEIGHT; - rect.width = rect.right = WINDOW_WIDTH; - } - - left = parseCssLength(this.style.left || "0", rect.width); - top = parseCssLength(this.style.top || "0", rect.height); - - var margin = this.style.margin; - if (margin) { - var parts = margin.trim().split(/\s+/); - left += parseCssLength(parts[3] || parts[1] || parts[0] || "0", 0); - top += parseCssLength(parts[0] || parts[1] || "0", 0); - } - - var right = parseCssLength(this.style.right || "0", rect.width); - var bottom = parseCssLength(this.style.bottom || "0", rect.width); - - if (this.style.width) - width = parseCssLength(this.style.width || "100%", rect.width); - else if (this.style.widthHint) - width = this.style.widthHint; - else if (this.style.right || !isAbsolute) - width = rect.width - right - left; - - if (this.style.height) - height = parseCssLength(this.style.height || "100%", rect.height); - else if (this.style.heightHint) - height = this.style.heightHint; - else if (this.style.bottom || !isAbsolute) - height = rect.height - top - bottom; - - if (this.style.width == "auto") { - width = textWidth(this.textContent); - if ((!this.style.height || this.style.height == "auto") && this.textContent.trim()) { - height = CHAR_HEIGHT; - var node = this; - while (node) { - if (node.style?.fontSize) { - height = parseInt(node.style.fontSize); - break; - } - node = node.parentNode; - } - } - } - - var maxWidth = this.style.maxWidth && parseCssLength(this.style.maxWidth, rect.width); - var maxHeight = this.style.maxHeight && parseCssLength(this.style.maxHeight, rect.height); - - if (maxWidth >= 0) width = Math.min(width, maxWidth); - if (maxHeight >= 0) height = Math.min(height, maxHeight); - - if (!height && !this.style.height && this.firstChild && this.firstChild.getBoundingClientRect && !fromChild) { - height = this.firstChild.getBoundingClientRect(-1, true).height; - } - - if (!this.style.left && this.style.right) { - left = rect.width - right - width; - } - if (!this.style.top && this.style.bottom) { - top = rect.height - bottom - height; - } - - top += rect.top; - left += rect.left; - } - if (!ignoreTransforms) { - // Apply any CSS transforms - var node = this; - var M = [1, 0, 0, 0, 1, 0, 0, 0, 1]; - var points; - while (node) { - if (node.style?.transform) { - var match = node.style.transform.match(/matrix3d\(([^)]+)\)/) - if (match) { - if (!points) { - points = [[left, top], [left + width, top], [left, top + height], [left + width, top + height]]; - } - var v = match[1].split(",").map(parseFloat); - M = [v[0], v[1], v[3], v[4], v[5], v[7], v[12], v[13], v[15]]; - var origin = node.style.transformOrigin || "50% 50%"; - var parts = origin.split(" "); - var parentRect = node== this ? { top,left,width,height } : node.getBoundingClientRect(true, true); - var ox = parseCssLength(parts[0], parentRect.width) + parentRect.left; - var oy = parseCssLength(parts[1], parentRect.height) + parentRect.top; - var O = [ox, oy]; - points = points.map(p => project(p, O)); - } - } - node = node.parentNode; - } - function project(p, O) { - var x = p[0] - O[0]; - var y = p[1] - O[1]; - var w = M[2] * x + M[5] * y + M[8]; - return [ - (M[0] * x + M[3] * y + M[6]) / w + O[0], - (M[1] * x + M[4] * y + M[7]) / w + O[1] - ]; - } - if (points) { - var xs = points.map(p => p[0]); - var ys = points.map(p => p[1]); - left = Math.min.apply(null, xs); - top = Math.min.apply(null, ys); - width = Math.max.apply(null, xs) - left; - height = Math.max.apply(null, ys) - top; - } - } - - return {top: top, left: left, width: width, height: height, right: left + width, bottom: top + height}; + this.getBoundingClientRect = function() { + return getBoundingClientRect(this); }; - function parseCssLength(styleString, parentSize) { - // TODO support calc - var size = parseFloat(styleString) || 0; - if (/%/.test(styleString)) - size = parentSize * size / 100; - return size; - } - this.__defineGetter__("clientHeight", function() { - return this.getBoundingClientRect(undefined, true).height; + return getBoundingClientRect(this, undefined, true).height; }); this.__defineGetter__("clientWidth", function() { - return this.getBoundingClientRect(undefined, true).width; + return getBoundingClientRect(this, undefined, true).width; }); this.__defineGetter__("offsetHeight", function() { - return this.getBoundingClientRect(undefined, true).height; + return getBoundingClientRect(this, undefined, true).height; }); this.__defineGetter__("offsetWidth", function() { - return this.getBoundingClientRect(undefined, true).width; + return getBoundingClientRect(this, undefined, true).width; }); this.__defineGetter__("lastChild", function() { @@ -621,7 +633,7 @@ function Node(name) { this.__defineGetter__("firstChild", function() { return this.childNodes[0]; }); - // TODO this is a waorkaround for scrollHeight usage in virtualRenderer + // TODO this is a workaround for scrollHeight usage in virtualRenderer this.scrollHeight = 1; @@ -980,7 +992,7 @@ window.HTMLDocument = window.XMLDocument = window.Document = function() { this.endOffset = offset; }, getBoundingClientRect: function() { - var rect1 = Element.prototype.getBoundingClientRect.call(this.startContainer); + var rect1 = getBoundingClientRect(this.startContainer, false, true); if (this.startContainer.nodeType == 3) { rect1.left += this.startOffset * CHAR_WIDTH; } else { @@ -988,19 +1000,19 @@ window.HTMLDocument = window.XMLDocument = window.Document = function() { if (!child) { rect1.left = rect1.right; } else { - rect1.left = Element.prototype.getBoundingClientRect.call(child).left; + rect1.left = getBoundingClientRect(child, false, true).left; } } - var rect2 = Element.prototype.getBoundingClientRect.call(this.endContainer); + var rect2 = getBoundingClientRect(this.endContainer, false, true); if (this.endContainer.nodeType == 3) { rect2.right = rect2.left + this.endOffset * CHAR_WIDTH; } else { var child = this.endContainer.childNodes[this.endOffset]; if (child) { - rect2.right = Element.prototype.getBoundingClientRect.call(child).right; + rect2.right = getBoundingClientRect(child, false, true).right; } } - return { + var rect = { top: rect1.top, left: rect1.left, width: rect2.right - rect1.left, @@ -1008,6 +1020,7 @@ window.HTMLDocument = window.XMLDocument = window.Document = function() { right: rect2.right, bottom: rect2.bottom }; + return applyTransforms(rect, this.startContainer); }, getClientRects: function() { return [this.getBoundingClientRect()]; @@ -1130,6 +1143,8 @@ exports.loadInBrowser = function(global, $setSize) { delete global.ResizeObserver; global.__origRoot__ = global.document.documentElement; global.__origBody__ = global.document.body; + global.document.createElementOrig = global.document.createElement; + global.document.createTextNodeOrig = global.document.createTextNode; Object.keys(window).forEach(function(i) { if (i != "document" && i != "window") { delete global[i]; @@ -1140,7 +1155,6 @@ exports.loadInBrowser = function(global, $setSize) { var val = window.document[i]; if (typeof val == "function") { if (i == "createElement") { - global.document.createElementOrig = global.document.createElement; val = function(n) { if (n == "script") return global.document.createElementOrig(n); @@ -1171,6 +1185,7 @@ exports.loadInBrowser = function(global, $setSize) { ); } } + global.__mockdom_ = exports; loaded = true; }; @@ -1195,4 +1210,32 @@ exports.unload = function() { loaded = false; }; +exports.show = function(mockNode) { + 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); + +}; + exports.load(); 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 a1afa988f73d16d2e4ee8854c9cf84614fcc1203 Mon Sep 17 00:00:00 2001 From: nightwing Date: Sat, 2 May 2026 12:34:12 +0400 Subject: [PATCH 13/21] 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 bf706e96cd8..3ca13119af4 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 36a2df9d159..46e27df891a 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 72720a43fd64369bcc698a372e9df988579ed0d9 Mon Sep 17 00:00:00 2001 From: nightwing Date: Sat, 2 May 2026 13:04:26 +0400 Subject: [PATCH 14/21] improve coverage --- src/test/all_browser.js | 46 +++++++++++++++++++- src/test/mockdom.js | 40 +---------------- src/test/mockdom_test.js | 21 +++++++++ src/test/tests.html | 8 ++++ src/virtual_renderer_test.js | 83 +++++++++++++++++++++--------------- 5 files changed, 124 insertions(+), 74 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/mockdom.js b/src/test/mockdom.js index e0094960f4f..bbdcc2ec9cb 100644 --- a/src/test/mockdom.js +++ b/src/test/mockdom.js @@ -1,5 +1,5 @@ "use strict"; -/*global Uint8ClampedArray, globalThis*/ +/*global Uint8ClampedArray*/ var dom = require("../lib/dom"); @@ -157,7 +157,7 @@ function Context2d(w, h) { }; }).call(Context2d.prototype); -function getBoundingClientRect(target, fromChild, ignoreTransforms, childOffset) { +function getBoundingClientRect(target, fromChild, ignoreTransforms) { function textWidth(str) { return str.replace(/\t/g, " ").replace(/[\u3041-\u9FBF]/g, " ").length * CHAR_WIDTH * fontSize; } @@ -191,13 +191,6 @@ function getBoundingClientRect(target, fromChild, ignoreTransforms, childOffset) } else if (target.nodeType == 3 || target.localName == "span" || /^inline/.test(target.style.display)) { var text = target.textContent; - var offsetLeft = 0; - if (childOffset != null) { - if (target.nodeType == 3) { - offsetLeft = textWidth(text.substring(0, childOffset)); - text = ""; - } - } width = textWidth(text); height = fontSize * CHAR_HEIGHT; if (!height) height = CHAR_HEIGHT; @@ -217,7 +210,6 @@ function getBoundingClientRect(target, fromChild, ignoreTransforms, childOffset) } } } - left += offsetLeft; } else if (target.parentNode) { var isFixed = target.style.position == "fixed" @@ -1210,32 +1202,4 @@ exports.unload = function() { loaded = false; }; -exports.show = function(mockNode) { - 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); - -}; - exports.load(); diff --git a/src/test/mockdom_test.js b/src/test/mockdom_test.js index e0d73795ece..2ae63a9ef24 100644 --- a/src/test/mockdom_test.js +++ b/src/test/mockdom_test.js @@ -219,6 +219,27 @@ module.exports = { div.dispatchEvent(event); assert.equal(divMousedown, 3); assert.equal(windowMousedown, 1); + }, + "test innerHTML": function() { + var div = document.createElement("div"); + div.innerHTML = "test>span<"; + assert.equal(div.children.length, 1); + assert.equal(div.children[0].textContent, "test>span<"); + + var parser = new window.DOMParser(); + var doc = parser.parseFromString("test>span<", "text/html"); + assert.equal(doc.body.children.length, 1); + assert.equal(doc.body.children[0].textContent, "test>span<"); + + doc = parser.parseFromString(` + + + ]]> + + `, "text/xml"); + var u = doc.documentElement.children[0]; + assert.equal(u.firstChild.data, ""); + assert.equal(u.firstChild.textContent, ""); } }; 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 6138c2d3378fc9b489a279145fc98be977233005 Mon Sep 17 00:00:00 2001 From: nightwing Date: Mon, 4 May 2026 14:02:35 +0400 Subject: [PATCH 15/21] 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 50aff8d725ba8aa480c79a96df1e82ccffafce64 Mon Sep 17 00:00:00 2001 From: Jairo Suarez Date: Fri, 3 Jul 2026 21:37:20 +0200 Subject: [PATCH 16/21] grapheme-cluster cursor model, pixel-accurate coordinates, rtl marker rendering - add getGraphemeCluster/getGraphemeBoundaries helpers (Intl.Segmenter) to lang - snap cursor to grapheme cluster boundaries in moveCursorTo (ZWJ, combining marks) - use fontMetrics.textWidth in textToScreenCoordinates instead of column * charWidth - iterate grapheme clusters in $pixelToColumn hit-testing; treat span seams inclusively; clamp clicks left of line start to column 0 - stop rendering U+200D as invalid invisible so ZWJ emoji stay intact (#5813) - render line-start RLE as plain text when rtl/rtlText enabled instead of red dot (#5423) - add experiments/widthchar.html browser test harness for variable-width rendering covering #460 #4142 #5431 #5813 #4602 #5436 #5423 #3753 #3866 #3617 --- experiments/widthchar.html | 285 +++++++++++++++++++++++++++++++++++++ src/bidihandler.js | 2 + src/ext/rtl.js | 1 + src/layer/font_metrics.js | 23 ++- src/layer/text.js | 19 ++- src/lib/lang.js | 50 +++++++ src/selection.js | 11 +- src/virtual_renderer.js | 4 +- 8 files changed, 373 insertions(+), 22 deletions(-) create mode 100644 experiments/widthchar.html diff --git a/experiments/widthchar.html b/experiments/widthchar.html new file mode 100644 index 00000000000..0097b4138cf --- /dev/null +++ b/experiments/widthchar.html @@ -0,0 +1,285 @@ + + + + +widthchar test harness + + + +
+ + + +
+

ed_mono (default monospace)

+
+

ed_prop (Helvetica — proportional)

+
+

ed_rtl (rtlText line-based switching)

+
+
+ + + + + diff --git a/src/bidihandler.js b/src/bidihandler.js index aed46d3ffd8..7842afb56f0 100644 --- a/src/bidihandler.js +++ b/src/bidihandler.js @@ -37,6 +37,8 @@ class BidiHandler { this.rtlLineOffset = 0; this.wrapOffset = 0; this.isMoveLeftOperation = false; + /* set by the rtl extension when line-based rtl switching is enabled */ + this.$rtlText = false; this.seenBidi = bidiRE.test(session.getValue()); } diff --git a/src/ext/rtl.js b/src/ext/rtl.js index 6de5a01a3d8..b1ef610ada5 100644 --- a/src/ext/rtl.js +++ b/src/ext/rtl.js @@ -48,6 +48,7 @@ var Editor = require("../editor").Editor; require("../config").defineOptions(Editor.prototype, "editor", { rtlText: { set: function(val) { + this.session.$bidiHandler.$rtlText = val; if (val) { this.on("change", onChange); this.on("changeSelection", onChangeSelection); diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index 5fc4a5b43f3..2b105a834e6 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -390,11 +390,16 @@ class FontMetrics { $pixelToColumn(screenRow, screenColumn1, x, blockCursor) { var scratchRange = this.$scratchRange; var lineElement = this.$findElementForScreenRow(screenRow); - if (!lineElement || screenColumn1 <= 0) return screenColumn1; + if (!lineElement) return screenColumn1; var hasCssTransform = this.renderer.$hasCssTransforms; var tr = hasCssTransform && this.getTransform(); + var lineRect = hasCssTransform + ? this.recoverRect(tr, lineElement.getBoundingClientRect()) + : lineElement.getBoundingClientRect(); + if (x <= lineRect.left) return Math.min(screenColumn1, 0); + var screenColumn = 0; var getRects = (node) => { var rects = []; @@ -418,17 +423,11 @@ class FontMetrics { var self = this; 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 boundaries = lang.getGraphemeBoundaries(node.nodeValue); + for (var bi = 0; bi < boundaries.length - 1; bi++) { + var j = boundaries[bi]; + var graphemeWidth = boundaries[bi + 1] - j; 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; - } scratchRange.setEnd(node, j + graphemeWidth); let rect = /** @type {ReturnType}*/(scratchRange.getBoundingClientRect()); if (hasCssTransform) { @@ -450,7 +449,7 @@ class FontMetrics { 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) { + if (rect.left <= x && x <= rect.left + rect.width) { search(child); return screenColumn; } diff --git a/src/layer/text.js b/src/layer/text.js index 04915ca47af..691b1419c95 100644 --- a/src/layer/text.js +++ b/src/layer/text.js @@ -350,7 +350,8 @@ 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+)/g; + // \u200D (zero width joiner) is excluded to keep emoji sequences intact + var re = /(\t)|( +)|([\x00-\x1f\x80-\xa0\xad\u1680\u180E\u2000-\u200C\u200E\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); @@ -389,10 +390,18 @@ class Text { valueFragment.appendChild(this.dom.createTextNode(simpleSpace, this.element)); } } else if (controlCharacter) { - var span = this.dom.createElement("span"); - span.className = "ace_invisible ace_invisible_space ace_invalid"; - span.textContent = lang.stringRepeat(self.SPACE_CHAR, controlCharacter.length); - valueFragment.appendChild(span); + var bidiHandler = self.session.$bidiHandler; + // line-start RLE markers managed by the rtl extension are legitimate; + // render them as plain text instead of invalid dots (issue #5423) + if (controlCharacter == "\u202B" && screenColumn + m.index === 0 && bidiHandler + && (bidiHandler.$isRtl || bidiHandler.$rtlText)) { + valueFragment.appendChild(this.dom.createTextNode(controlCharacter, this.element)); + } else { + var span = this.dom.createElement("span"); + 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"); diff --git a/src/lib/lang.js b/src/lib/lang.js index 80163694e9a..7ae81ea9079 100644 --- a/src/lib/lang.js +++ b/src/lib/lang.js @@ -207,3 +207,53 @@ exports.supportsLookbehind = function () { exports.skipEmptyMatch = function(line, last, supportsUnicodeFlag) { return supportsUnicodeFlag && line.codePointAt(last) > 0xffff ? 2 : 1; }; + +/*global Intl*/ +var graphemeSegmenter; +function getSegmenter() { + if (graphemeSegmenter === undefined) { + graphemeSegmenter = typeof Intl == "object" && Intl["Segmenter"] + ? new Intl["Segmenter"](undefined, {granularity: "grapheme"}) : null; + } + return graphemeSegmenter; +} + +/** + * Returns the [start, end) code unit offsets of the grapheme cluster containing + * `column`, or null if `Intl.Segmenter` is unavailable or `column` is outside the text. + * @param {string} text + * @param {number} column + * @returns {{start: number, end: number} | null} + */ +exports.getGraphemeCluster = function(text, column) { + var segmenter = getSegmenter(); + if (!segmenter) return null; + var segment = segmenter.segment(text).containing(column); + if (!segment) return null; + return {start: segment.index, end: segment.index + segment.segment.length}; +}; + +/** + * Returns the grapheme cluster boundaries of `text` as code unit offsets, + * including 0 and text.length. Falls back to surrogate pair boundaries + * when `Intl.Segmenter` is unavailable. + * @param {string} text + * @returns {number[]} + */ +exports.getGraphemeBoundaries = function(text) { + var boundaries = [0]; + var segmenter = getSegmenter(); + if (segmenter) { + var iterator = segmenter.segment(text)[Symbol.iterator](); + var step; + while (!(step = iterator.next()).done) + boundaries.push(step.value.index + step.value.segment.length); + } else { + for (var i = 0; i < text.length; i++) { + if (/[\uD800-\uDBFF]/.test(text.charAt(i)) && /[\uDC00-\uDFFF]/.test(text.charAt(i + 1))) + i++; + boundaries.push(i + 1); + } + } + return boundaries; +}; diff --git a/src/selection.js b/src/selection.js index 6ab284305af..6e51366e8db 100644 --- a/src/selection.js +++ b/src/selection.js @@ -770,8 +770,15 @@ class Selection { this.$keepDesiredColumnOnChange = true; var line = this.session.getLine(row); - // do not allow putting cursor in the middle of surrogate pairs - if (/[\uDC00-\uDFFF]/.test(line.charAt(column)) && line.charAt(column - 1)) { + // do not allow putting cursor in the middle of grapheme clusters + // (surrogate pairs, combining marks, emoji sequences) + var cluster = lang.getGraphemeCluster(line, column); + if (cluster && cluster.start < column && column < cluster.end) { + if (this.lead.row == row && this.lead.column == column + 1) + column = cluster.start; + else + column = cluster.end; + } else if (!cluster && /[\uDC00-\uDFFF]/.test(line.charAt(column)) && line.charAt(column - 1)) { if (this.lead.row == row && this.lead.column == column + 1) column = column - 1; else diff --git a/src/virtual_renderer.js b/src/virtual_renderer.js index faa5a2df75f..fb0e94eabaf 100644 --- a/src/virtual_renderer.js +++ b/src/virtual_renderer.js @@ -1675,9 +1675,7 @@ class VirtualRenderer { 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; From 5d2bc1d6012061d91e50b3bf433c8ea27376b99a Mon Sep 17 00:00:00 2001 From: Jairo Suarez Date: Fri, 3 Jul 2026 22:40:14 +0200 Subject: [PATCH 17/21] grapheme-aware soft wrap - $getDisplayTokens marks continuation code units of grapheme clusters (surrogate pairs, combining marks, ZWJ sequences) with CHAR_CONT so wrap-split candidates can see cluster boundaries - $computeWrapSplits/addSplit moves any split falling inside a cluster back to the cluster start, or past it when the cluster is wider than the wrap limit, so soft wrap never tears a grapheme apart - unit tests for ZWJ/combining-mark/surrogate wrap splits - widthchar harness: wrapped editor with emoji/CJK/hebrew lines checking splits land on grapheme boundaries, caret round-trips, and arrow walks --- experiments/widthchar.html | 64 +++++++++++++++++++++++++++++++++++++- src/edit_session.js | 32 +++++++++++++++++-- src/edit_session_test.js | 27 ++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/experiments/widthchar.html b/experiments/widthchar.html index 0097b4138cf..02c0b0b11a1 100644 --- a/experiments/widthchar.html +++ b/experiments/widthchar.html @@ -24,6 +24,8 @@

ed_prop (Helvetica — proportional)

ed_rtl (rtlText line-based switching)

+

ed_wrap (soft wrap, limit 14)

+
@@ -48,8 +50,17 @@

ed_rtl (rtlText line-based switching)

var TAB_ROWS = {8: true}; // rows whose rendered text != doc text (skip DOM ground truth) +var WRAP_LINES = [ + "aaaa bbbb cccc dddd eeee ffff gggg", + "emoji 😃😃😃 zwj 👨‍👩‍👧‍👦👨‍👩‍👧‍👦 and 🚵‍♂️🚵‍♂️ wrapping here", + "combining ã̤n̺ ɾ̺ub̰ĩ̈ ã̤n̺ ɾ̺ub̰ĩ̈ ã̤n̺ ɾ̺ub̰ĩ̈ marks", + "中文测试混合中文测试混合中文测试混合中文测试", + "nospacesatall👨‍👩‍👧‍👦rightthroughthecluster", + "עברית ארוכה מאוד שנמשכת ונמשכת עוד ועוד מילים בעברית" +]; + var editors = {}; -var rtlEditor; +var rtlEditor, wrapEditor; require(["ace/ace", "ace/ext/rtl"], function (ace) { ["ed_mono", "ed_prop"].forEach(function (id) { @@ -71,6 +82,15 @@

ed_rtl (rtlText line-based switching)

rtlText: true }); window.rtlEditor = rtlEditor; + wrapEditor = ace.edit("ed_wrap", { + value: WRAP_LINES.join("\n"), + showPrintMargin: false, + highlightActiveLine: false, + fontSize: 14, + wrap: true + }); + wrapEditor.session.setWrapLimitRange(14, 14); + window.wrapEditor = wrapEditor; window.editors = editors; document.getElementById("status").textContent = "ready"; window.aceReady = true; @@ -234,6 +254,48 @@

ed_rtl (rtlText line-based switching)

} } + // ---- Test G: soft wrap never splits inside a grapheme cluster ---- + { + let wed = wrapEditor; + let session = wed.session; + wed.renderer.updateFull(true); + for (let row = 0; row < WRAP_LINES.length; row++) { + let line = WRAP_LINES[row]; + let bounds = graphemeBoundaries(line); + let boundSet = {}; + bounds.forEach(function (b) { boundSet[b] = true; }); + let splits = session.$wrapData[row] || []; + let badSplits = splits.filter(function (docCol) { return !boundSet[docCol]; }); + record("wrapSplitsOnGraphemeBoundary", "ed_wrap", row, -1, "[]", JSON.stringify(badSplits), 0); + + // caret round trip at every grapheme boundary in wrap mode + for (let bi = 0; bi < bounds.length; bi++) { + let col = bounds[bi]; + wed.moveCursorTo(row, col); + wed.clearSelection(); + wed.renderer.updateFull(true); + let pagePos = wed.renderer.textToScreenCoordinates(row, col); + let back = wed.renderer.screenToTextCoordinates(pagePos.pageX, pagePos.pageY + wed.renderer.lineHeight / 2); + record("wrapRoundTripRow", "ed_wrap", row, col, row, back.row, 0); + record("wrapRoundTripCol", "ed_wrap", row, col, col, back.column, 0); + } + + // arrow walk must visit every grapheme boundary (across wrapped rows) + wed.moveCursorTo(row, 0); + wed.clearSelection(); + let visited = [0]; + for (let step = 0; step < line.length + 2; step++) { + wed.navigateRight(); + let c = wed.getCursorPosition(); + if (c.row !== row) break; + if (c.column === visited[visited.length - 1]) break; + visited.push(c.column); + } + record("wrapArrowWalkStops", "ed_wrap", row, -1, + JSON.stringify(bounds), JSON.stringify(visited), 0); + } + } + // ---- Test E (issue 5423): Enter on forced-RTL line must not render a red invalid dot ---- var ed = rtlEditor; // issue 5423 repro state: kitchen-sink with both RTL checkboxes enabled diff --git a/src/edit_session.js b/src/edit_session.js index e7f5d84e61c..04ced3aa8b1 100644 --- a/src/edit_session.js +++ b/src/edit_session.js @@ -1930,12 +1930,25 @@ class EditSession { return Math.min(indentation, maxIndent); } function addSplit(screenPos) { + // never split inside a grapheme cluster or full width character + while (screenPos > lastSplit && (tokens[screenPos] === CHAR_CONT || tokens[screenPos] === CHAR_EXT)) + screenPos--; + if (screenPos === lastSplit) { + // the cluster is wider than the wrap limit, place it whole on this line + screenPos = lastSplit + 1; + while (screenPos < tokens.length && (tokens[screenPos] === CHAR_CONT || tokens[screenPos] === CHAR_EXT)) + screenPos++; + if (screenPos === tokens.length) { + lastSplit = screenPos; // line ends with the cluster, nothing left to split + return; + } + } // The document size is the current size - the extra width for tabs // and multipleWidth characters. var len = screenPos - lastSplit; for (var i = lastSplit; i < screenPos; i++) { var ch = tokens[i]; - if (ch === 12 || ch === 2) len -= 1; + if (ch === TAB_SPACE || ch === CHAR_EXT) len -= 1; } if (!splits.length) { @@ -2054,7 +2067,21 @@ class EditSession { var tabSize; offset = offset || 0; + // continuation code units of multi-unit grapheme clusters (surrogate + // pairs, combining marks, ZWJ sequences) must stay with their cluster + // start when computing wrap splits + var boundaries = /[\u0300-\uFFFF]/.test(str) ? lang.getGraphemeBoundaries(str) : null; + var bi = 1; + for (var i = 0; i < str.length; i++) { + if (boundaries) { + if (boundaries[bi] === i) { + bi++; + } else if (i > 0) { + arr.push(CHAR_CONT); + continue; + } + } var c = str.charCodeAt(i); // Tab if (c == 9) { @@ -2597,7 +2624,8 @@ EditSession.prototype.isFullWidth = isFullWidth; oop.implement(EditSession.prototype, EventEmitter); // "Tokens" -var CHAR = 1, +var CHAR_CONT = 0, + CHAR = 1, CHAR_EXT = 2, PLACEHOLDER_START = 3, PLACEHOLDER_BODY = 4, diff --git a/src/edit_session_test.js b/src/edit_session_test.js index 166817509ea..11532787580 100644 --- a/src/edit_session_test.js +++ b/src/edit_session_test.js @@ -416,6 +416,33 @@ module.exports = { computeAndAssert("\tfoo \t \t \t \t bar", [6, 12]); // 14 }, + "test: wrapLine split never breaks grapheme clusters" : function() { + function computeSplits(line, wrapLimit) { + var tokens = EditSession.prototype.$getDisplayTokens(line); + return EditSession.prototype.$computeWrapSplits(tokens, wrapLimit, 4); + } + EditSession.prototype.$wrapAsCode = true; + EditSession.prototype.$indentedSoftWrap = false; + + var family = "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}"; // 👨‍👩‍👧‍👦, 11 code units + var line = "ab " + family + " cd"; + for (var wrapLimit = 2; wrapLimit <= 14; wrapLimit++) { + var splits = computeSplits(line, wrapLimit); + splits.forEach(function(col) { + assert.ok(col <= 3 || col >= 3 + family.length, + "wrapLimit " + wrapLimit + " split at " + col + " inside cluster"); + }); + } + + // combining marks stay attached to their base character + var splits = computeSplits("a\u0300e\u0301o\u0302", 2); + assert.equal(splits.join(","), "2,4"); + + // surrogate pairs + splits = computeSplits("\u{1F600}\u{1F600}\u{1F600}", 2); + assert.equal(splits.join(","), "2,4"); + }, + "test get longest line" : function() { var session = new EditSession(["12"]); session.setTabSize(4); From 423a67e091c51d742baaa72bd1d0a2aca284f72f Mon Sep 17 00:00:00 2001 From: Jairo Suarez Date: Fri, 3 Jul 2026 23:30:41 +0200 Subject: [PATCH 18/21] 1 grapheme cluster = 1 screen column Screen columns now count grapheme clusters instead of UTF-16 code units, so a ZWJ emoji occupies one logical column instead of eleven: - lang.mayContainGraphemeClusters: cheap regexp gate (marks, ZWJ, variation selectors, astral code points, conjoining jamo) so plain ASCII/CJK/hebrew lines skip segmentation entirely - $getStringScreenWidth walks grapheme boundaries; doc<->screen mapping (documentToScreenPosition/screenToDocumentPosition) follows since both funnel through it - $getDisplayTokens emits one display token per cluster and carries a parallel docLengths array; $computeWrapSplits consumes it so wrap budget is measured in visual columns and doc split offsets stay in code units; CHAR_CONT hack removed - text layer $renderToken returns grapheme column counts and computes tab stops from grapheme columns - font_metrics $findColumnPosition/$pixelToColumn map screen columns to text-node offsets via grapheme boundaries - widthchar harness: graphemeColumns check; updated wrap/surrogate test expectations to the new column semantics --- experiments/widthchar.html | 6 +++ src/edit_session.js | 81 +++++++++++++++++++++++------------- src/edit_session_test.js | 8 ++-- src/layer/font_metrics.js | 28 ++++++++----- src/layer/text.js | 10 ++++- src/lib/lang.js | 20 +++++++++ src/virtual_renderer_test.js | 5 ++- 7 files changed, 114 insertions(+), 44 deletions(-) diff --git a/experiments/widthchar.html b/experiments/widthchar.html index 02c0b0b11a1..5d497a53d69 100644 --- a/experiments/widthchar.html +++ b/experiments/widthchar.html @@ -191,6 +191,12 @@

ed_wrap (soft wrap, limit 14)

} } + // ---- Test H: 1 grapheme = 1 screen column ---- + if (!TAB_ROWS[row]) { + var screenCol = editor.session.documentToScreenPosition(row, line.length).column; + record("graphemeColumns", id, row, line.length, bounds.length - 1, screenCol, 0); + } + // ---- Test B: textToScreen -> screenToText round trip ---- for (var bi = 0; bi < bounds.length; bi++) { var col = bounds[bi]; diff --git a/src/edit_session.js b/src/edit_session.js index 04ced3aa8b1..73f935b47e5 100644 --- a/src/edit_session.js +++ b/src/edit_session.js @@ -1861,6 +1861,7 @@ class EditSession { row ++; } else { tokens = []; + tokens["docLengths"] = []; foldLine.walk(function(placeholder, row, column, lastColumn) { var walkTokens; if (placeholder != null) { @@ -1875,7 +1876,9 @@ class EditSession { lines[row].substring(lastColumn, column), tokens.length); } + var docLengths = tokens["docLengths"].concat(walkTokens["docLengths"]); tokens = tokens.concat(walkTokens); + tokens["docLengths"] = docLengths; }.bind(this), foldLine.end.row, lines[foldLine.end.row].length + 1 @@ -1901,6 +1904,7 @@ class EditSession { var splits = []; var displayLength = tokens.length; var lastSplit = 0, lastDocSplit = 0; + var docLengths = /**@type {number[]|undefined}*/(tokens["docLengths"]); var isCode = this.$wrapAsCode; @@ -1930,25 +1934,24 @@ class EditSession { return Math.min(indentation, maxIndent); } function addSplit(screenPos) { - // never split inside a grapheme cluster or full width character - while (screenPos > lastSplit && (tokens[screenPos] === CHAR_CONT || tokens[screenPos] === CHAR_EXT)) + // never split between a full width char and its extension column + while (screenPos > lastSplit && tokens[screenPos] === CHAR_EXT) screenPos--; if (screenPos === lastSplit) { - // the cluster is wider than the wrap limit, place it whole on this line + // wider than the wrap limit, place it whole on this line screenPos = lastSplit + 1; - while (screenPos < tokens.length && (tokens[screenPos] === CHAR_CONT || tokens[screenPos] === CHAR_EXT)) + while (screenPos < tokens.length && tokens[screenPos] === CHAR_EXT) screenPos++; if (screenPos === tokens.length) { - lastSplit = screenPos; // line ends with the cluster, nothing left to split + lastSplit = screenPos; // line ends here, nothing left to split return; } } - // The document size is the current size - the extra width for tabs - // and multipleWidth characters. - var len = screenPos - lastSplit; + // The document size is the sum of code units consumed by the + // display tokens (grapheme clusters may consume several). + var len = 0; for (var i = lastSplit; i < screenPos; i++) { - var ch = tokens[i]; - if (ch === TAB_SPACE || ch === CHAR_EXT) len -= 1; + len += docLengths ? docLengths[i] : 1; } if (!splits.length) { @@ -2064,46 +2067,50 @@ class EditSession { **/ $getDisplayTokens(str, offset) { var arr = []; + // document code units consumed by each display token; tokens that only + // occupy screen space (TAB_SPACE, CHAR_EXT) consume 0 + var docLengths = []; var tabSize; offset = offset || 0; - // continuation code units of multi-unit grapheme clusters (surrogate - // pairs, combining marks, ZWJ sequences) must stay with their cluster - // start when computing wrap splits - var boundaries = /[\u0300-\uFFFF]/.test(str) ? lang.getGraphemeBoundaries(str) : null; - var bi = 1; - - for (var i = 0; i < str.length; i++) { - if (boundaries) { - if (boundaries[bi] === i) { - bi++; - } else if (i > 0) { - arr.push(CHAR_CONT); - continue; - } - } - var c = str.charCodeAt(i); + // each grapheme cluster (surrogate pair, combining mark sequence, ZWJ + // emoji) produces a single display token so it can never be split + var boundaries = lang.mayContainGraphemeClusters(str) + ? lang.getGraphemeBoundaries(str) : null; + var end = boundaries ? boundaries.length - 1 : str.length; + + for (var i = 0; i < end; i++) { + var start = boundaries ? boundaries[i] : i; + var clusterLength = boundaries ? boundaries[i + 1] - start : 1; + var c = str.charCodeAt(start); // Tab if (c == 9) { tabSize = this.getScreenTabSize(arr.length + offset); arr.push(TAB); + docLengths.push(clusterLength); for (var n = 1; n < tabSize; n++) { arr.push(TAB_SPACE); + docLengths.push(0); } } // Space else if (c == 32) { arr.push(SPACE); + docLengths.push(clusterLength); } else if((c > 39 && c < 48) || (c > 57 && c < 64)) { arr.push(PUNCTUATION); + docLengths.push(clusterLength); } // full width characters else if (c >= 0x1100 && isFullWidth(c)) { arr.push(CHAR, CHAR_EXT); + docLengths.push(clusterLength, 0); } else { arr.push(CHAR); + docLengths.push(clusterLength); } } + arr["docLengths"] = docLengths; return arr; } @@ -2124,6 +2131,25 @@ class EditSession { screenColumn = screenColumn || 0; var c, column; + if (lang.mayContainGraphemeClusters(str)) { + // one grapheme cluster occupies one screen column + var boundaries = lang.getGraphemeBoundaries(str); + column = 0; + for (var bi = 1; bi < boundaries.length; bi++) { + c = str.charCodeAt(boundaries[bi - 1]); + if (c == 9) { + screenColumn += this.getScreenTabSize(screenColumn); + } else { + screenColumn += 1; + } + if (screenColumn > maxScreenColumn) { + column = boundaries[bi - 1]; + break; + } + column = boundaries[bi]; + } + return [screenColumn, column]; + } for (column = 0; column < str.length; column++) { c = str.charCodeAt(column); // tab @@ -2624,8 +2650,7 @@ EditSession.prototype.isFullWidth = isFullWidth; oop.implement(EditSession.prototype, EventEmitter); // "Tokens" -var CHAR_CONT = 0, - CHAR = 1, +var CHAR = 1, CHAR_EXT = 2, PLACEHOLDER_START = 3, PLACEHOLDER_BODY = 4, diff --git a/src/edit_session_test.js b/src/edit_session_test.js index 11532787580..7d3a655b31b 100644 --- a/src/edit_session_test.js +++ b/src/edit_session_test.js @@ -434,13 +434,15 @@ module.exports = { }); } - // combining marks stay attached to their base character + // combining marks stay attached to their base character; + // each cluster occupies one screen column, so three clusters + // at wrapLimit 2 wrap once, after the second cluster var splits = computeSplits("a\u0300e\u0301o\u0302", 2); - assert.equal(splits.join(","), "2,4"); + assert.equal(splits.join(","), "4"); // surrogate pairs splits = computeSplits("\u{1F600}\u{1F600}\u{1F600}", 2); - assert.equal(splits.join(","), "2,4"); + assert.equal(splits.join(","), "4"); }, "test get longest line" : function() { diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index 2b105a834e6..fbaaea3ad63 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -358,19 +358,23 @@ class FontMetrics { while (node = walker.nextNode()) { var nodeText = node.nodeValue; - var nodeLength = nodeText.length; + // one grapheme cluster occupies one screen column + var boundaries = lang.mayContainGraphemeClusters(nodeText) + ? lang.getGraphemeBoundaries(nodeText) : null; + var nodeColumns = boundaries ? boundaries.length - 1 : nodeText.length; - if (currentColumn + nodeLength >= screenColumn) { + if (currentColumn + nodeColumns >= screenColumn) { + var columnInNode = screenColumn - currentColumn; return { node: node, - offset: screenColumn - currentColumn, + offset: boundaries ? boundaries[columnInNode] : columnInNode, overflow: 0 }; } - currentColumn += nodeLength; + currentColumn += nodeColumns; lastNode = node; } - + return lastNode && { node: lastNode, offset: lastNode.nodeValue.length, @@ -421,22 +425,26 @@ class FontMetrics { return rects; }; var self = this; + // screen columns are grapheme clusters, not code units + function countColumns(text) { + return lang.mayContainGraphemeClusters(text) + ? lang.getGraphemeBoundaries(text).length - 1 : text.length; + } function search(node) { if (node.nodeType === Node.TEXT_NODE) { var boundaries = lang.getGraphemeBoundaries(node.nodeValue); for (var bi = 0; bi < boundaries.length - 1; bi++) { var j = boundaries[bi]; - var graphemeWidth = boundaries[bi + 1] - j; scratchRange.setStart(node, j); - scratchRange.setEnd(node, j + graphemeWidth); + scratchRange.setEnd(node, boundaries[bi + 1]); let rect = /** @type {ReturnType}*/(scratchRange.getBoundingClientRect()); if (hasCssTransform) { rect = self.recoverRect(tr, rect); } if (rect.left <= x && x <= rect.left + rect.width) { - screenColumn += j; + screenColumn += bi; if (!blockCursor && x > rect.left + rect.width / 2) { - screenColumn += graphemeWidth; + screenColumn += 1; } return screenColumn; } @@ -454,7 +462,7 @@ class FontMetrics { return screenColumn; } } - screenColumn += child.nodeType === Node.TEXT_NODE ? child.nodeValue.length : child.textContent.length; + screenColumn += countColumns(child.nodeType === Node.TEXT_NODE ? child.nodeValue : child.textContent); } } } diff --git a/src/layer/text.js b/src/layer/text.js index 691b1419c95..15349102621 100644 --- a/src/layer/text.js +++ b/src/layer/text.js @@ -353,6 +353,10 @@ class Text { // \u200D (zero width joiner) is excluded to keep emoji sequences intact var re = /(\t)|( +)|([\x00-\x1f\x80-\xa0\xad\u1680\u180E\u2000-\u200C\u200E\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; + // screen columns count grapheme clusters, not code units + var screenLength = lang.mayContainGraphemeClusters(value) + ? lang.getGraphemeBoundaries(value).length - 1 : value.length; + var valueFragment = this.dom.createFragment(this.element); var m; @@ -375,7 +379,9 @@ class Text { } if (tab) { - var tabSize = self.session.getScreenTabSize(screenColumn + m.index); + var columnsBefore = lang.mayContainGraphemeClusters(value) + ? lang.getGraphemeBoundaries(value.slice(0, m.index)).length - 1 : m.index; + var tabSize = self.session.getScreenTabSize(screenColumn + columnsBefore); var text = self.$tabStrings[tabSize].cloneNode(true); text["charCount"] = 1; valueFragment.appendChild(text); @@ -433,7 +439,7 @@ class Text { parent.appendChild(valueFragment); } - return screenColumn + value.length; + return screenColumn + screenLength; } renderIndentGuide(parent, value, max) { diff --git a/src/lib/lang.js b/src/lib/lang.js index 7ae81ea9079..e98bd75642a 100644 --- a/src/lib/lang.js +++ b/src/lib/lang.js @@ -209,6 +209,26 @@ exports.skipEmptyMatch = function(line, last, supportsUnicodeFlag) { }; /*global Intl*/ +var clusterRe; +try { + // marks, ZWJ, variation selectors, astral code points, conjoining jamo; + // with the u flag paired surrogates match as code points, so astral + // chars need the explicit \u{10000}-\u{10FFFF} range + clusterRe = new RegExp("[\\p{M}\\u200D\\uFE00-\\uFE0F\\u{10000}-\\u{10FFFF}\\u1100-\\u11FF\\uA960-\\uA97F\\uD7B0-\\uD7FF]", "u"); +} catch (e) { + clusterRe = /[\u0300-\uFFFF]/; +} + +/** + * Quick test whether `text` may contain grapheme clusters spanning more than + * one code unit. False guarantees every code unit is its own cluster. + * @param {string} text + * @returns {boolean} + */ +exports.mayContainGraphemeClusters = function(text) { + return clusterRe.test(text); +}; + var graphemeSegmenter; function getSegmenter() { if (graphemeSegmenter === undefined) { diff --git a/src/virtual_renderer_test.js b/src/virtual_renderer_test.js index 574ee87de08..c4f5cf509b3 100644 --- a/src/virtual_renderer_test.js +++ b/src/virtual_renderer_test.js @@ -142,7 +142,10 @@ module.exports = { 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); + // the surrogate pair occupies a single screen column + assert.position(pos, 0, i < 0.5 ? 2 : 3); + var docPos = editor.session.screenToDocumentPosition(pos.row, pos.column); + assert.position(docPos, 0, i < 0.5 ? 2 : 4); } }, "test scrollmargin + autosize": async function(done) { From 5dc32fbfe9824e32bdb51ac9c35d3232f4e5c09d Mon Sep 17 00:00:00 2001 From: Jairo Suarez Date: Sat, 4 Jul 2026 16:53:12 +0200 Subject: [PATCH 19/21] fix perf regressions in grapheme column model Found by benchmarking against the pre-grapheme baseline: - $getStringScreenWidth: iterate the segmenter lazily (lang.forEachGrapheme) when maxScreenColumn is bounded so the early exit stays O(maxScreenColumn); bounded query on a 54k-unit emoji line: 3.7ms -> 0.03ms per call. Without a bound use countGraphemes/getGraphemeBoundaries (no per-cluster callback). - lang.getGraphemeBoundaries: memoize the last few segmentations; cursor rendering and doc<->screen mapping repeatedly segment equal line prefixes (documentToScreenPosition on a 54k-unit emoji line: 8.1ms -> 0.2ms per call) - $renderToken: segment the token value once and reuse a monotonic boundary cursor for tab stops instead of re-segmenting the prefix per tab (9.6k-unit emoji TSV line render: 356ms -> 3ms) - $pixelToColumn: gate per-text-node segmentation on mayContainGraphemeClusters - document why line-start RLE renders as plain text only in rtl modes --- src/edit_session.js | 27 +++++++++++++---- src/layer/font_metrics.js | 11 ++++--- src/layer/text.js | 24 +++++++++++---- src/lib/lang.js | 63 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 15 deletions(-) diff --git a/src/edit_session.js b/src/edit_session.js index 73f935b47e5..525bfc0ee63 100644 --- a/src/edit_session.js +++ b/src/edit_session.js @@ -2133,8 +2133,29 @@ class EditSession { var c, column; if (lang.mayContainGraphemeClusters(str)) { // one grapheme cluster occupies one screen column - var boundaries = lang.getGraphemeBoundaries(str); column = 0; + if (maxScreenColumn != Infinity) { + // iterate lazily so the early exit keeps this O(maxScreenColumn) + var self = this; + lang.forEachGrapheme(str, function(start, end) { + c = str.charCodeAt(start); + if (c == 9) { + screenColumn += self.getScreenTabSize(screenColumn); + } else { + screenColumn += 1; + } + if (screenColumn > maxScreenColumn) { + column = start; + return false; + } + column = end; + }); + return [screenColumn, column]; + } + // full walk; without tabs the width is simply the cluster count + if (str.indexOf("\t") === -1) + return [screenColumn + lang.countGraphemes(str), str.length]; + var boundaries = lang.getGraphemeBoundaries(str); for (var bi = 1; bi < boundaries.length; bi++) { c = str.charCodeAt(boundaries[bi - 1]); if (c == 9) { @@ -2142,10 +2163,6 @@ class EditSession { } else { screenColumn += 1; } - if (screenColumn > maxScreenColumn) { - column = boundaries[bi - 1]; - break; - } column = boundaries[bi]; } return [screenColumn, column]; diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index fbaaea3ad63..7aa2c4e9e0b 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -432,11 +432,14 @@ class FontMetrics { } function search(node) { if (node.nodeType === Node.TEXT_NODE) { - var boundaries = lang.getGraphemeBoundaries(node.nodeValue); - for (var bi = 0; bi < boundaries.length - 1; bi++) { - var j = boundaries[bi]; + var text = node.nodeValue; + var boundaries = lang.mayContainGraphemeClusters(text) + ? lang.getGraphemeBoundaries(text) : null; + var columns = boundaries ? boundaries.length - 1 : text.length; + for (var bi = 0; bi < columns; bi++) { + var j = boundaries ? boundaries[bi] : bi; scratchRange.setStart(node, j); - scratchRange.setEnd(node, boundaries[bi + 1]); + scratchRange.setEnd(node, boundaries ? boundaries[bi + 1] : bi + 1); let rect = /** @type {ReturnType}*/(scratchRange.getBoundingClientRect()); if (hasCssTransform) { rect = self.recoverRect(tr, rect); diff --git a/src/layer/text.js b/src/layer/text.js index 15349102621..e3fd7021d02 100644 --- a/src/layer/text.js +++ b/src/layer/text.js @@ -353,9 +353,12 @@ class Text { // \u200D (zero width joiner) is excluded to keep emoji sequences intact var re = /(\t)|( +)|([\x00-\x1f\x80-\xa0\xad\u1680\u180E\u2000-\u200C\u200E\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; - // screen columns count grapheme clusters, not code units - var screenLength = lang.mayContainGraphemeClusters(value) - ? lang.getGraphemeBoundaries(value).length - 1 : value.length; + // screen columns count grapheme clusters, not code units; + // segment the value at most once and reuse for tab stop lookups + var boundaries = lang.mayContainGraphemeClusters(value) + ? lang.getGraphemeBoundaries(value) : null; + var screenLength = boundaries ? boundaries.length - 1 : value.length; + var boundaryIndex = 0; var valueFragment = this.dom.createFragment(this.element); @@ -379,8 +382,14 @@ class Text { } if (tab) { - var columnsBefore = lang.mayContainGraphemeClusters(value) - ? lang.getGraphemeBoundaries(value.slice(0, m.index)).length - 1 : m.index; + var columnsBefore = m.index; + if (boundaries) { + // matches arrive in ascending order and a tab is always its + // own cluster, so a monotonic cursor into boundaries suffices + while (boundaries[boundaryIndex] < m.index) + boundaryIndex++; + columnsBefore = boundaryIndex; + } var tabSize = self.session.getScreenTabSize(screenColumn + columnsBefore); var text = self.$tabStrings[tabSize].cloneNode(true); text["charCount"] = 1; @@ -398,7 +407,10 @@ class Text { } else if (controlCharacter) { var bidiHandler = self.session.$bidiHandler; // line-start RLE markers managed by the rtl extension are legitimate; - // render them as plain text instead of invalid dots (issue #5423) + // render them as plain text instead of invalid dots (issue #5423). + // This hides a smuggled RLE on an LTR line too, but the rtl + // extension right-aligns and reverses any RLE-prefixed line, so + // the manipulation stays clearly visible. if (controlCharacter == "\u202B" && screenColumn + m.index === 0 && bidiHandler && (bidiHandler.$isRtl || bidiHandler.$rtlText)) { valueFragment.appendChild(this.dom.createTextNode(controlCharacter, this.element)); diff --git a/src/lib/lang.js b/src/lib/lang.js index e98bd75642a..ab0092092df 100644 --- a/src/lib/lang.js +++ b/src/lib/lang.js @@ -253,14 +253,74 @@ exports.getGraphemeCluster = function(text, column) { return {start: segment.index, end: segment.index + segment.segment.length}; }; +/** + * Calls `callback(start, end)` for each grapheme cluster of `text` in order, + * where [start, end) are code unit offsets. Iteration stops early if the + * callback returns `false`. Lazy: segmentation cost is proportional to how + * far the iteration gets, not to text length. Falls back to surrogate pair + * boundaries when `Intl.Segmenter` is unavailable. + * @param {string} text + * @param {(start: number, end: number) => boolean|void} callback + */ +exports.forEachGrapheme = function(text, callback) { + var segmenter = getSegmenter(); + if (segmenter) { + var iterator = segmenter.segment(text)[Symbol.iterator](); + var step; + while (!(step = iterator.next()).done) { + if (callback(step.value.index, step.value.index + step.value.segment.length) === false) + return; + } + } else { + for (var i = 0; i < text.length; i++) { + var start = i; + if (/[\uD800-\uDBFF]/.test(text.charAt(i)) && /[\uDC00-\uDFFF]/.test(text.charAt(i + 1))) + i++; + if (callback(start, i + 1) === false) + return; + } + } +}; + +/** + * Returns the number of grapheme clusters in `text` without materializing + * a boundaries array. + * @param {string} text + * @returns {number} + */ +exports.countGraphemes = function(text) { + var segmenter = getSegmenter(); + var count = 0; + if (segmenter) { + var iterator = segmenter.segment(text)[Symbol.iterator](); + while (!iterator.next().done) + count++; + } else { + for (var i = 0; i < text.length; i++) { + if (/[\uD800-\uDBFF]/.test(text.charAt(i)) && /[\uDC00-\uDFFF]/.test(text.charAt(i + 1))) + i++; + count++; + } + } + return count; +}; + +// memo of the most recent segmentations: cursor rendering and doc<->screen +// mapping repeatedly segment content-equal line prefixes +var boundariesCache = new Map(); +var BOUNDARIES_CACHE_SIZE = 8; + /** * Returns the grapheme cluster boundaries of `text` as code unit offsets, * including 0 and text.length. Falls back to surrogate pair boundaries * when `Intl.Segmenter` is unavailable. + * Callers must not mutate the returned array; it may be cached. * @param {string} text * @returns {number[]} */ exports.getGraphemeBoundaries = function(text) { + var cached = boundariesCache.get(text); + if (cached) return cached; var boundaries = [0]; var segmenter = getSegmenter(); if (segmenter) { @@ -275,5 +335,8 @@ exports.getGraphemeBoundaries = function(text) { boundaries.push(i + 1); } } + if (boundariesCache.size >= BOUNDARIES_CACHE_SIZE) + boundariesCache.delete(boundariesCache.keys().next().value); + boundariesCache.set(text, boundaries); return boundaries; }; From 1d8fb139b0e049a066490d7ebf07015e4565e088 Mon Sep 17 00:00:00 2001 From: Jairo Suarez Date: Sat, 4 Jul 2026 17:01:20 +0200 Subject: [PATCH 20/21] fix cluster-detection gaps found in review - mayContainGraphemeClusters missed cluster-forming characters outside \p{M}: prepend chars (arabic number signs U+0600-0605 U+06DD U+070F U+0890 U+0891 U+08E2, malayalam dot reph U+0D4E), spacing marks (thai/lao AM U+0E33 U+0EB3), ZWNJ and halfwidth katakana voicing marks. Gated fast paths counted these as code units while ungated paths counted clusters, giving two different column systems for the same line. Verified against an exhaustive BMP scan of Intl.Segmenter behavior. - text layer: align token boundaries to grapheme cluster boundaries before rendering () so a tokenizer splitting inside a cluster (keycap emoji digit+FE0F+20E3) cannot desync rendered spans from the session's whole-line segmentation --- src/layer/text.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/lib/lang.js | 7 +++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/layer/text.js b/src/layer/text.js index e3fd7021d02..9e0f3b5ecea 100644 --- a/src/layer/text.js +++ b/src/layer/text.js @@ -681,6 +681,50 @@ class Text { parent.appendChild(overflowEl); } + /** + * Moves code units across token boundaries so that no boundary falls + * inside a grapheme cluster (a tokenizer may split e.g. keycap emoji + * "1️⃣" after the ascii digit). Returns the original array + * when nothing needs to change; affected tokens are copied because the + * input comes from the session's token cache. + * @param {import("../../ace-internal").Ace.Token[]} tokens + */ + $alignTokensToClusters(tokens) { + var line = ""; + for (var i = 0; i < tokens.length; i++) + line += tokens[i].value; + if (!lang.mayContainGraphemeClusters(line)) + return tokens; + + var boundaries = lang.getGraphemeBoundaries(line); + var boundarySet = Object.create(null); + for (var i = 0; i < boundaries.length; i++) + boundarySet[boundaries[i]] = true; + + var end = 0, misaligned = false; + for (var i = 0; i < tokens.length - 1; i++) { + end += tokens[i].value.length; + if (!boundarySet[end]) { misaligned = true; break; } + } + if (!misaligned) return tokens; + + // rebuild, snapping each token end back to the nearest cluster + // boundary; the cluster's units all move into the following token + var result = []; + var start = 0; + end = 0; + for (var i = 0; i < tokens.length; i++) { + end += tokens[i].value.length; + var cut = end; + if (i === tokens.length - 1) cut = line.length; + else while (cut > start && !boundarySet[cut]) cut--; + if (cut > start) + result.push({type: tokens[i].type, value: line.slice(start, cut)}); + start = cut; + } + return result; + } + // row is either first row of foldline or not in fold $renderLine(parent, row, foldLine) { if (!foldLine && foldLine != false) @@ -691,6 +735,8 @@ class Text { else var tokens = this.session.getTokens(row); + tokens = this.$alignTokensToClusters(tokens); + var lastLineEl = parent; if (tokens.length) { var splits = this.session.getRowSplitData(row); diff --git a/src/lib/lang.js b/src/lib/lang.js index ab0092092df..926995c4f01 100644 --- a/src/lib/lang.js +++ b/src/lib/lang.js @@ -211,10 +211,13 @@ exports.skipEmptyMatch = function(line, last, supportsUnicodeFlag) { /*global Intl*/ var clusterRe; try { - // marks, ZWJ, variation selectors, astral code points, conjoining jamo; + // marks, ZWJ, variation selectors, astral code points, conjoining jamo, + // prepend characters (arabic number signs, malayalam dot reph) and the + // spacing marks segmenters attach to a base (thai/lao AM); // with the u flag paired surrogates match as code points, so astral // chars need the explicit \u{10000}-\u{10FFFF} range - clusterRe = new RegExp("[\\p{M}\\u200D\\uFE00-\\uFE0F\\u{10000}-\\u{10FFFF}\\u1100-\\u11FF\\uA960-\\uA97F\\uD7B0-\\uD7FF]", "u"); + clusterRe = new RegExp("[\\p{M}\\u200C\\u200D\\uFE00-\\uFE0F\\u{10000}-\\u{10FFFF}\\u1100-\\u11FF\\uA960-\\uA97F\\uD7B0-\\uD7FF" + + "\\u0600-\\u0605\\u06DD\\u070F\\u0890\\u0891\\u08E2\\u0D4E\\u0E33\\u0EB3\\uFF9E\\uFF9F]", "u"); } catch (e) { clusterRe = /[\u0300-\uFFFF]/; } From c833a43322da10f625a2056d5c5da3109b9176a5 Mon Sep 17 00:00:00 2001 From: Jairo Suarez Date: Sun, 5 Jul 2026 19:45:31 +0200 Subject: [PATCH 21/21] improve patch test coverage Codecov flagged the PR diff at 90.42% vs 93.73% target. Raise it to ~96%: - new lang_test.js covering mayContainGraphemeClusters, boundary/cluster helpers, forEachGrapheme early exit, countGraphemes, the boundaries memo, and the non-Segmenter fallback paths - edit_session tests for $getStringScreenWidth cluster counting (fast path, tab walk, bounded early exit) and wide-cluster wrap splits - selection test for grapheme cluster boundary snapping in moveCursorTo - text layer tests for $alignTokensToClusters, cluster-preserving rendering, grapheme tab stops, and rtl-gated RLE rendering - font_metrics getRects: remove the unreachable hasCssTransform=true dead branch and unused textLayer local --- src/edit_session_test.js | 21 ++++++++++ src/layer/font_metrics.js | 30 ++++---------- src/layer/text_test.js | 67 ++++++++++++++++++++++++++++++ src/lib/lang_test.js | 87 +++++++++++++++++++++++++++++++++++++++ src/selection_test.js | 20 +++++++++ 5 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 src/lib/lang_test.js diff --git a/src/edit_session_test.js b/src/edit_session_test.js index 7d3a655b31b..f1aa2ef7e83 100644 --- a/src/edit_session_test.js +++ b/src/edit_session_test.js @@ -443,6 +443,27 @@ module.exports = { // surrogate pairs splits = computeSplits("\u{1F600}\u{1F600}\u{1F600}", 2); assert.equal(splits.join(","), "4"); + + // full width chars wider than the wrap limit are placed whole + splits = computeSplits("\u6F22\u6F22\u6F22", 1); + assert.equal(splits.join(","), "1,2"); + // trailing wide cluster ends the line without further splits + splits = computeSplits("a\u6F22", 1); + assert.equal(splits.join(","), "1"); + }, + + "test: getStringScreenWidth counts grapheme clusters" : function() { + var session = new EditSession(""); + session.setTabSize(4); + var w = session.$getStringScreenWidth.bind(session); + + // cluster-per-column without tabs (fast path) + assert.equal(w("\u{1F600}b\u{1F600}")[0], 3); + // tab stops computed from grapheme columns (full walk) + assert.equal(w("\u{1F600}\tx").join(","), [5, 4].join(",")); + assert.equal(w("a\u0300\tb").join(","), [5, 4].join(",")); + // bounded walk exits early at the overflowing cluster + assert.equal(w("ab\u{1F600}cd", 2).join(","), [3, 2].join(",")); }, "test get longest line" : function() { diff --git a/src/layer/font_metrics.js b/src/layer/font_metrics.js index 7aa2c4e9e0b..b8490c34ce9 100644 --- a/src/layer/font_metrics.js +++ b/src/layer/font_metrics.js @@ -493,7 +493,6 @@ class FontMetrics { */ getRects(startScreenPos, endScreenPos) { var row = startScreenPos.row; - var textLayer = this.textLayer; var lineElement = this.$findElementForScreenRow(row); if (lineElement) { @@ -504,28 +503,17 @@ 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 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 rect = textLayer.element.getBoundingClientRect(); - var merged = mergeTouchingRects(rangeRects).map(function(r) { + 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 - rect.left, + left: r.left - leftOffset, width: r.right - r.left, }; }); diff --git a/src/layer/text_test.js b/src/layer/text_test.js index 7d3a782552a..7d83bd399e9 100644 --- a/src/layer/text_test.js +++ b/src/layer/text_test.js @@ -86,6 +86,73 @@ module.exports = { "" + DOT(6) + "" + EOL, "" + TAB(4) + "" + TAB(4) + "f" + EOL ]); + }, + + "test: align token boundaries to grapheme clusters": function() { + var textLayer = this.textLayer; + function values(tokens) { + return textLayer.$alignTokensToClusters(tokens).map(function(t) { + return t.value; + }); + } + function tokens(values) { + return values.map(function(v) { return {type: "text", value: v}; }); + } + + // keycap emoji split after the ascii digit is merged back together + assert.equal(JSON.stringify(values(tokens(["1", "\uFE0F\u20E3 rest"]))), + JSON.stringify(["1\uFE0F\u20E3 rest"])); + + // ZWJ sequence split mid-cluster + assert.equal(JSON.stringify(values(tokens( + ["ab \u{1F468}", "\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466} cd"]))), + JSON.stringify(["ab ", "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466} cd"])); + + // a mark-only token is swallowed by the previous cluster + assert.equal(JSON.stringify(values(tokens(["a", "\u0300", "b"]))), + JSON.stringify(["a\u0300", "b"])); + + // aligned tokens are returned unchanged (same array) + var aligned = tokens(["hello ", "world \u{1F600}"]); + assert.ok(textLayer.$alignTokensToClusters(aligned) === aligned); + + // plain ascii is returned unchanged + var ascii = tokens(["plain", " ascii"]); + assert.ok(textLayer.$alignTokensToClusters(ascii) === ascii); + }, + + "test: render line with cluster split across tokens": function() { + // one grapheme cluster torn across two tokens must render whole + this.session.setValue("1\uFE0F\u20E3x"); + var parent = dom.createElement("div"); + this.textLayer.$renderLine(parent, 0); + assert.ok(parent.textContent.indexOf("1\uFE0F\u20E3x") != -1); + }, + + "test: tab stops on lines with grapheme clusters": function() { + // tab stop must be computed from grapheme columns: \uD83D\uDE00 is one column, + // so the tab at column 1 expands to the next stop at column 4 + this.session.setValue("\u{1F600}\tx"); + this.textLayer.$computeTabString(); + var parent = dom.createElement("div"); + this.textLayer.$renderLine(parent, 0); + assert.equal(parent.textContent, "\u{1F600} x"); + }, + + "test: line-start RLE renders as plain text only in rtl modes": function() { + var RLE = "\u202B"; + this.session.setValue(RLE + "abc"); + var parent = dom.createElement("div"); + this.textLayer.$renderLine(parent, 0); + // by default the security highlighting flags it as an invalid dot + assert.ok(parent.innerHTML.indexOf("ace_invalid") != -1); + + this.session.$bidiHandler.$rtlText = true; + parent = dom.createElement("div"); + this.textLayer.$renderLine(parent, 0); + assert.ok(parent.innerHTML.indexOf("ace_invalid") == -1); + assert.ok(parent.textContent.indexOf(RLE) != -1); + this.session.$bidiHandler.$rtlText = false; } }; diff --git a/src/lib/lang_test.js b/src/lib/lang_test.js new file mode 100644 index 00000000000..80a44da12f2 --- /dev/null +++ b/src/lib/lang_test.js @@ -0,0 +1,87 @@ +/*global Intl*/ +"use strict"; + +var lang = require("./lang"); +var assert = require("../test/assertions"); + +module.exports = { + "test: mayContainGraphemeClusters": function() { + assert.equal(lang.mayContainGraphemeClusters("plain ascii"), false); + assert.equal(lang.mayContainGraphemeClusters("中文"), false); + assert.equal(lang.mayContainGraphemeClusters("\u{1F600}"), true); // astral + assert.equal(lang.mayContainGraphemeClusters("à"), true); // combining mark + assert.equal(lang.mayContainGraphemeClusters("ำ"), true); // thai sara am + assert.equal(lang.mayContainGraphemeClusters("1️⃣"), true); // keycap + }, + + "test: getGraphemeBoundaries": function() { + assert.equal(lang.getGraphemeBoundaries("").join(","), "0"); + assert.equal(lang.getGraphemeBoundaries("ab").join(","), "0,1,2"); + assert.equal(lang.getGraphemeBoundaries("a\u{1F600}b").join(","), "0,1,3,4"); + assert.equal(lang.getGraphemeBoundaries("àé").join(","), "0,2,4"); + // repeated calls return the memoized array + var text = "memo \u{1F600} me"; + var first = lang.getGraphemeBoundaries(text); + assert.ok(first === lang.getGraphemeBoundaries(text)); + // cache eviction keeps results correct + for (var i = 0; i < 12; i++) + lang.getGraphemeBoundaries("evict" + i + "\u{1F600}"); + assert.equal(lang.getGraphemeBoundaries(text).join(","), "0,1,2,3,4,5,7,8,9,10"); + }, + + "test: getGraphemeCluster": function() { + var cluster = lang.getGraphemeCluster("a\u{1F600}b", 2); + assert.equal(cluster.start, 1); + assert.equal(cluster.end, 3); + assert.equal(lang.getGraphemeCluster("ab", 5), null); + }, + + "test: forEachGrapheme visits clusters and honors early exit": function() { + var visited = []; + lang.forEachGrapheme("a\u{1F600}b", function(start, end) { + visited.push(start + "-" + end); + }); + assert.equal(visited.join(","), "0-1,1-3,3-4"); + + visited = []; + lang.forEachGrapheme("abcdef", function(start, end) { + visited.push(start); + if (visited.length == 2) return false; + }); + assert.equal(visited.join(","), "0,1"); + }, + + "test: countGraphemes": function() { + assert.equal(lang.countGraphemes(""), 0); + assert.equal(lang.countGraphemes("abc"), 3); + assert.equal(lang.countGraphemes("\u{1F600}\u{1F600}"), 2); + assert.equal(lang.countGraphemes("\u{1F468}‍\u{1F469}‍\u{1F467}‍\u{1F466}"), 1); + }, + + "test: fallback paths without Intl.Segmenter": function() { + var segmenter = Intl["Segmenter"]; + delete Intl["Segmenter"]; + var path = require.resolve("./lang"); + var cached = require.cache[path]; + delete require.cache[path]; + try { + var fallbackLang = require("./lang"); + // surrogate pairs still form single clusters + assert.equal(fallbackLang.getGraphemeBoundaries("a\u{1F600}b").join(","), "0,1,3,4"); + assert.equal(fallbackLang.countGraphemes("\u{1F600}\u{1F600}c"), 3); + assert.equal(fallbackLang.getGraphemeCluster("ab", 1), null); + var visited = []; + fallbackLang.forEachGrapheme("a\u{1F600}b", function(start, end) { + visited.push(start + "-" + end); + if (visited.length == 2) return false; + }); + assert.equal(visited.join(","), "0-1,1-3"); + } finally { + Intl["Segmenter"] = segmenter; + delete require.cache[path]; + require.cache[path] = cached; + } + } +}; + +require("../test/run")(module); diff --git a/src/selection_test.js b/src/selection_test.js index 9cd4ba34455..a58adfe69e2 100644 --- a/src/selection_test.js +++ b/src/selection_test.js @@ -38,6 +38,26 @@ module.exports = { assert.position(selection.getCursor(), 0, 0); }, + "test: moveCursorTo snaps to grapheme cluster boundaries" : function() { + // 👨‍👩‍👧‍👦 spans doc columns 1..12 + var session = new EditSession("a\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}b"); + var selection = session.getSelection(); + + // moving into the middle of the cluster snaps to its end + selection.moveCursorTo(0, 5); + assert.position(selection.getCursor(), 0, 12); + + // stepping left from just past the cluster snaps to its start + selection.moveCursorTo(0, 13); + selection.moveCursorTo(0, 12); + selection.moveCursorTo(0, 11); + assert.position(selection.getCursor(), 0, 1); + + // boundaries themselves are untouched + selection.moveCursorTo(0, 1); + assert.position(selection.getCursor(), 0, 1); + }, + "test: move selection lead to end of file" : function() { var session = this.createSession(200, 10); var selection = session.getSelection();