@@ -12,14 +12,28 @@ document.addEventListener("DOMContentLoaded", function () {
1212 } ) ;
1313 }
1414
15+ const _loadedStyles = new Set ( ) ;
16+ function loadStyle ( url ) {
17+ if ( _loadedStyles . has ( url ) ) return Promise . resolve ( ) ;
18+ return new Promise ( function ( resolve , reject ) {
19+ const link = document . createElement ( 'link' ) ;
20+ link . rel = 'stylesheet' ;
21+ link . href = url ;
22+ link . onload = function ( ) { _loadedStyles . add ( url ) ; resolve ( ) ; } ;
23+ link . onerror = function ( ) { reject ( new Error ( 'Failed to load style: ' + url ) ) ; } ;
24+ document . head . appendChild ( link ) ;
25+ } ) ;
26+ }
27+
1528 // CDN URLs for lazy-loaded libraries
1629 const CDN = {
1730 mermaid : 'https://cdn.jsdelivr.net/npm/mermaid@11.6.0/dist/mermaid.min.js' ,
1831 mathjax : 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js' ,
1932 jspdf : 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js' ,
2033 html2canvas : 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js' ,
2134 pako : 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js' ,
22- joypixels : 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/lib/js/joypixels.min.js'
35+ joypixels : 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/lib/js/joypixels.min.js' ,
36+ joypixels_css : 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/extras/css/joypixels.min.css'
2337 } ;
2438
2539 let markdownRenderTimeout = null ;
@@ -1129,19 +1143,27 @@ document.addEventListener("DOMContentLoaded", function () {
11291143 if ( tabId ) switchTab ( tabId ) ;
11301144 } ;
11311145
1132- // "+ Create" button at end of tab list
1146+ // "+ Create" button at end of tab list (placed outside tabList to prevent ARIA child violation)
11331147 const newBtn = document . createElement ( 'button' ) ;
11341148 newBtn . className = 'tab-new-btn' ;
11351149 newBtn . title = 'New Tab (Ctrl+T)' ;
11361150 newBtn . setAttribute ( 'aria-label' , 'Open new tab' ) ;
11371151 newBtn . innerHTML = '<i class="bi bi-plus-lg"></i>' ;
11381152 newBtn . addEventListener ( 'click' , function ( ) { newTab ( ) ; } ) ;
1139- tabList . appendChild ( newBtn ) ;
1153+
1154+ const resetBtn = document . getElementById ( 'tab-reset-btn' ) ;
1155+ if ( resetBtn ) {
1156+ tabList . parentElement . insertBefore ( newBtn , resetBtn ) ;
1157+ } else {
1158+ tabList . parentElement . appendChild ( newBtn ) ;
1159+ }
11401160
1141- // Auto-scroll active tab into view
1161+ // Auto-scroll active tab into view (paint-aligned to prevent forced reflows)
11421162 const activeItem = tabList . querySelector ( '.tab-item.active' ) ;
11431163 if ( activeItem ) {
1144- activeItem . scrollIntoView ( { block : 'nearest' , inline : 'nearest' } ) ;
1164+ requestAnimationFrame ( function ( ) {
1165+ activeItem . scrollIntoView ( { block : 'nearest' , inline : 'nearest' } ) ;
1166+ } ) ;
11451167 }
11461168
11471169 // Arrow-key keyboard navigation inside tabList (WAI-ARIA compliance with manual selection)
@@ -1581,6 +1603,9 @@ document.addEventListener("DOMContentLoaded", function () {
15811603 // Configure and load MathJax on first use
15821604 window . MathJax = {
15831605 loader : { load : [ '[tex]/ams' , '[tex]/boldsymbol' ] } ,
1606+ options : {
1607+ a11y : { inTabOrder : false }
1608+ } ,
15841609 tex : {
15851610 inlineMath : [ [ '$' , '$' ] , [ '\\(' , '\\)' ] ] ,
15861611 displayMath : [ [ '$$' , '$$' ] , [ '\\[' , '\\]' ] ] ,
@@ -2103,7 +2128,10 @@ document.addEventListener("DOMContentLoaded", function () {
21032128
21042129 // PERF-002: Lazy-load JoyPixels on first use
21052130 if ( typeof joypixels === 'undefined' ) {
2106- loadScript ( CDN . joypixels ) . then ( function ( ) { processEmojis ( element ) ; } ) ;
2131+ Promise . all ( [
2132+ loadScript ( CDN . joypixels ) ,
2133+ loadStyle ( CDN . joypixels_css )
2134+ ] ) . then ( function ( ) { processEmojis ( element ) ; } ) ;
21072135 return ;
21082136 }
21092137
@@ -3645,15 +3673,15 @@ document.addEventListener("DOMContentLoaded", function () {
36453673
36463674 function updateFindHighlights ( ) {
36473675 if ( ! editorHighlightLayer ) return ;
3648- const text = markdownEditor . value || '' ;
3649- const scrollTop = markdownEditor . scrollTop ;
3650- const scrollLeft = markdownEditor . scrollLeft ;
36513676 if ( ! isFindModalOpen || ! findReplaceInput || ! findReplaceInput . value || ! findMatches . length ) {
3652- editorHighlightLayer . textContent = text ;
3653- editorHighlightLayer . scrollTop = scrollTop ;
3654- editorHighlightLayer . scrollLeft = scrollLeft ;
3677+ if ( editorHighlightLayer . textContent !== '' ) {
3678+ editorHighlightLayer . textContent = '' ;
3679+ }
36553680 return ;
36563681 }
3682+ const text = markdownEditor . value || '' ;
3683+ const scrollTop = markdownEditor . scrollTop ;
3684+ const scrollLeft = markdownEditor . scrollLeft ;
36573685 const fragment = document . createDocumentFragment ( ) ;
36583686 let lastIndex = 0 ;
36593687 findMatches . forEach ( function ( match , index ) {
@@ -3684,65 +3712,95 @@ document.addEventListener("DOMContentLoaded", function () {
36843712 editorPaneElement . style . setProperty ( '--line-number-gutter' , gutterSize ) ;
36853713 }
36863714
3687- function ensureLineNumberMeasure ( ) {
3688- if ( ! lineNumbers ) return ;
3689- if ( ! lineNumberMeasure ) {
3690- lineNumberMeasure = document . createElement ( 'div' ) ;
3691- lineNumberMeasure . setAttribute ( 'aria-hidden' , 'true' ) ;
3692- document . body . appendChild ( lineNumberMeasure ) ;
3693- }
3694- const styles = window . getComputedStyle ( markdownEditor ) ;
3695- lineNumberMeasure . style . position = 'absolute' ;
3696- lineNumberMeasure . style . visibility = 'hidden' ;
3697- lineNumberMeasure . style . whiteSpace = 'pre-wrap' ;
3698- lineNumberMeasure . style . wordWrap = 'break-word' ;
3699- lineNumberMeasure . style . boxSizing = 'border-box' ;
3700- lineNumberMeasure . style . padding = styles . padding ;
3701- lineNumberMeasure . style . fontFamily = styles . fontFamily ;
3702- lineNumberMeasure . style . fontSize = styles . fontSize ;
3703- lineNumberMeasure . style . lineHeight = styles . lineHeight ;
3704- lineNumberMeasure . style . letterSpacing = styles . letterSpacing ;
3705- lineNumberMeasure . style . width = `${ markdownEditor . clientWidth } px` ;
3706- lineNumberMeasure . style . top = '-9999px' ;
3707- lineNumberMeasure . style . left = '-9999px' ;
3708- }
3709-
37103715 function getLineHeight ( styles ) {
37113716 const computed = parseFloat ( styles . lineHeight ) ;
37123717 if ( ! Number . isNaN ( computed ) ) return computed ;
37133718 const fontSize = parseFloat ( styles . fontSize ) || 14 ;
37143719 return fontSize * 1.5 ;
37153720 }
37163721
3717- function getWrappedLineCount ( line , lineHeight , paddingSum ) {
3718- if ( ! lineNumberMeasure ) return 1 ;
3719- lineNumberMeasure . textContent = line . length ? line : LINE_NUMBER_EMPTY_PLACEHOLDER ;
3720- const contentHeight = lineNumberMeasure . scrollHeight - paddingSum ;
3721- return Math . max ( 1 , Math . round ( contentHeight / lineHeight ) ) ;
3722+ function getWrappedLineCountMonospace ( lineText , maxCharsPerLine ) {
3723+ if ( ! lineText ) return 1 ;
3724+ const words = lineText . replace ( / \t / g, ' ' ) . split ( ' ' ) ;
3725+ let linesCount = 1 ;
3726+ let currentLineLength = 0 ;
3727+
3728+ for ( let i = 0 ; i < words . length ; i ++ ) {
3729+ const word = words [ i ] ;
3730+ const wordLength = word . length ;
3731+
3732+ if ( wordLength === 0 ) {
3733+ if ( currentLineLength + 1 > maxCharsPerLine ) {
3734+ linesCount ++ ;
3735+ currentLineLength = 1 ;
3736+ } else {
3737+ currentLineLength ++ ;
3738+ }
3739+ continue ;
3740+ }
3741+
3742+ if ( wordLength > maxCharsPerLine ) {
3743+ const remainingSpace = maxCharsPerLine - currentLineLength ;
3744+ if ( remainingSpace > 0 && currentLineLength > 0 ) {
3745+ const firstPart = wordLength - remainingSpace ;
3746+ linesCount += 1 + Math . floor ( firstPart / maxCharsPerLine ) ;
3747+ currentLineLength = firstPart % maxCharsPerLine ;
3748+ } else {
3749+ linesCount += Math . floor ( wordLength / maxCharsPerLine ) ;
3750+ currentLineLength = wordLength % maxCharsPerLine ;
3751+ }
3752+ continue ;
3753+ }
3754+
3755+ const spaceRequired = currentLineLength === 0 ? 0 : 1 ;
3756+ if ( currentLineLength + spaceRequired + wordLength > maxCharsPerLine ) {
3757+ linesCount ++ ;
3758+ currentLineLength = wordLength ;
3759+ } else {
3760+ currentLineLength += spaceRequired + wordLength ;
3761+ }
3762+ }
3763+
3764+ return Math . max ( 1 , linesCount ) ;
37223765 }
37233766
37243767 const lineCache = new Map ( ) ;
37253768 let lastEditorWidth = 0 ;
3769+ let charWidth = 0 ;
3770+ let maxCharsPerLine = 0 ;
37263771
37273772 function updateLineNumbers ( ) {
37283773 if ( ! lineNumbers || ! markdownEditor ) return ;
37293774 const lines = ( markdownEditor . value || '' ) . split ( '\n' ) ;
37303775 const lineCount = Math . max ( 1 , lines . length ) ;
37313776
3732- // Clear height cache if editor width has changed
37333777 const currentWidth = markdownEditor . clientWidth ;
3734- if ( currentWidth !== lastEditorWidth ) {
3778+ const styles = window . getComputedStyle ( markdownEditor ) ;
3779+ const paddingLeft = parseFloat ( styles . paddingLeft ) || 10 ;
3780+ const paddingRight = parseFloat ( styles . paddingRight ) || 10 ;
3781+ const availableWidth = currentWidth - paddingLeft - paddingRight ;
3782+
3783+ // Measure character width exactly once per resize / layout width change
3784+ if ( currentWidth !== lastEditorWidth || charWidth === 0 ) {
37353785 lineCache . clear ( ) ;
37363786 lastEditorWidth = currentWidth ;
3787+
3788+ const testSpan = document . createElement ( 'span' ) ;
3789+ testSpan . style . fontFamily = styles . fontFamily ;
3790+ testSpan . style . fontSize = styles . fontSize ;
3791+ testSpan . style . visibility = 'hidden' ;
3792+ testSpan . style . position = 'absolute' ;
3793+ testSpan . style . whiteSpace = 'pre' ;
3794+ testSpan . textContent = 'a' . repeat ( 100 ) ;
3795+ document . body . appendChild ( testSpan ) ;
3796+ charWidth = testSpan . getBoundingClientRect ( ) . width / 100 ;
3797+ document . body . removeChild ( testSpan ) ;
3798+
3799+ maxCharsPerLine = Math . max ( 1 , Math . floor ( availableWidth / charWidth ) ) ;
37373800 }
37383801
37393802 updateLineNumberGutter ( lineCount ) ;
3740- ensureLineNumberMeasure ( ) ;
3741- const styles = window . getComputedStyle ( markdownEditor ) ;
37423803 const lineHeight = getLineHeight ( styles ) ;
3743- const paddingSum =
3744- ( parseFloat ( styles . paddingTop ) || 0 ) +
3745- ( parseFloat ( styles . paddingBottom ) || 0 ) ;
37463804
37473805 const existingItems = lineNumbers . children ;
37483806 const existingCount = existingItems . length ;
@@ -3762,12 +3820,12 @@ document.addEventListener("DOMContentLoaded", function () {
37623820 }
37633821 }
37643822
3765- // Update only the heights and numbers that changed, querying cache
3823+ // Update only the heights and numbers that changed, using monospace simulator to avoid forced reflows
37663824 for ( let i = 0 ; i < lineCount ; i += 1 ) {
37673825 const lineText = lines [ i ] ;
37683826 let wrapHeight = lineCache . get ( lineText ) ;
37693827 if ( wrapHeight === undefined ) {
3770- const wrapCount = getWrappedLineCount ( lineText , lineHeight , paddingSum ) ;
3828+ const wrapCount = getWrappedLineCountMonospace ( lineText , maxCharsPerLine ) ;
37713829 wrapHeight = wrapCount * lineHeight ;
37723830 lineCache . set ( lineText , wrapHeight ) ;
37733831 }
0 commit comments