Repository navigation
Expand file tree
/
Copy pathpatch.ps1
More file actions
3853 lines (3512 loc) · 186 KB
/
Copy pathpatch.ps1
File metadata and controls
3853 lines (3512 loc) · 186 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<#
.SYNOPSIS
Claude Desktop Smart RTL Patcher & Service Fixer
.DESCRIPTION
Injects smart RTL support into Claude Desktop without breaking English/Code.
Handles ASAR repackaging, executable hash patching, and cowork-svc binary certificate swapping.
Strictly uses PURE BYTE-ARRAY manipulation matching the original Python script.
#>
param(
[switch]$Auto,
[string]$TrustedPubKey,
[Alias('ArabicScriptFont')]
[string]$CustomFont,
[string]$CustomFontScope
)
# Env-var fallback for `irm | iex` invocations where param binding is not possible.
if (-not $Auto -and $env:CLAUDE_RTL_AUTO -eq '1') { $Auto = $true }
if (-not $CustomFont -and $env:CLAUDE_RTL_CUSTOM_FONT) { $CustomFont = $env:CLAUDE_RTL_CUSTOM_FONT }
if (-not $CustomFont -and $env:CLAUDE_RTL_ARABIC_FONT) { $CustomFont = $env:CLAUDE_RTL_ARABIC_FONT }
if (-not $CustomFontScope -and $env:CLAUDE_RTL_CUSTOM_FONT_SCOPE) { $CustomFontScope = $env:CLAUDE_RTL_CUSTOM_FONT_SCOPE }
# Passed as a PARAMETER, not an env var: env vars don't survive the UAC elevation
# boundary. Mirror it into the env var the rest of the script reads.
if ($TrustedPubKey) { $env:CLAUDE_RTL_TRUSTED_PUBKEY = $TrustedPubKey }
# -----------------------------------------------------------------------------
# AUTO-ELEVATION: Request Administrator Privileges Automatically
# Supports both file execution and irm|iex piped execution
# -----------------------------------------------------------------------------
$IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $IsAdmin) {
Write-Host "Requesting Administrator privileges..." -ForegroundColor Yellow
# -CustomFont/-CustomFontScope would be lost across the update.ps1/install.ps1
# delegation (parameters aren't forwarded and env vars don't survive the UAC
# boundary), so stage them in HKCU -- shared with the elevated same-user child,
# which consumes and deletes them. HKCU is standard-user-writable, so the staged
# values are treated as UNTRUSTED and sanitized before they touch the payload.
if ($CustomFont -or $CustomFontScope) {
try {
New-Item -Path 'HKCU:\Software\ClaudeRtlPatch' -Force | Out-Null
if ($CustomFont) { Set-ItemProperty -Path 'HKCU:\Software\ClaudeRtlPatch' -Name 'CustomFontPending' -Value $CustomFont }
if ($CustomFontScope) { Set-ItemProperty -Path 'HKCU:\Software\ClaudeRtlPatch' -Name 'CustomFontScopePending' -Value $CustomFontScope }
} catch {
Write-Host "Could not stage -CustomFont for the elevated run: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
# Prefer the local verified-update helper (written admin-only at install time):
# it verifies patch.ps1 against the pinned pubkey before elevation. Falling back
# to the unsigned install.ps1 is acceptable only for first-time bootstrap.
$LocalUpdate = Join-Path $env:ProgramData "ClaudeRtlPatch\update.ps1"
if (Test-Path $LocalUpdate) {
if ($Auto) { $env:CLAUDE_RTL_AUTO = '1' }
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $LocalUpdate
Exit
}
# First-install bootstrap: no local pin yet. TOFU on install.ps1 (same exposure
# as `irm install.ps1 | iex`). PS 5.1 defaults to TLS 1.0; GitHub needs 1.2+.
try {
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
} catch { }
$InstallUrl = "https://raw.githubusercontent.com/shraga100/claude-desktop-rtl-patch/main/install.ps1"
if ($Auto) { $env:CLAUDE_RTL_AUTO = '1' }
Invoke-Expression (Invoke-RestMethod $InstallUrl)
Exit
}
# -----------------------------------------------------------------------------
# GLOBAL SETTINGS & RTL JS PAYLOAD
# -----------------------------------------------------------------------------
$ErrorActionPreference = "Stop"
Import-Module Microsoft.PowerShell.Security -ErrorAction SilentlyContinue
# Pinned npm package for the rare fuse fallback only (C4 mitigation). Bump by hand
# after reviewing the upstream changelog -- never 'latest'. The ASAR itself is
# patched by the embedded $ASAR_HELPER_CODE, with no npm involved.
$script:FusesPackage = '@electron/fuses@2.1.1'
# Minimum Node for the ASAR helper and the pinned fuse package (engines.node
# >=22.12.0). Keep in sync when bumping; drives the "upgrade Node" error message.
$script:MinNodeVersion = '22.12.0'
# Exact JS logic from r.js
$RTL_INJECTION_CODE = @'
// --- CLAUDE RTL PATCH START ---
;(function() {
'use strict';
if (typeof document === 'undefined') return;
// Once-per-window guard. The payload is prepended to EVERY renderer chunk and a
// window loads many; without this each would register its own observer/listener.
if (window.__claudeRtlInit) return;
window.__claudeRtlInit = true;
try {
var WRITING_SEL = '[data-testid="chat-input"]';
// Never mutate DOM a live editor owns (issue #33): ProseMirror reverts foreign
// mutations, re-firing our observer into an infinite loop. WRITING_SEL alone is
// brittle (the testid can change), so we detect editors by their nature. Not a
// bare [contenteditable]: that matches contenteditable="false" widgets too.
var EDITOR_SEL = WRITING_SEL + ', [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"], .ProseMirror, [role="textbox"]';
// --- NATIVE RTL INTEROP (claude.ai "alluvium" renderer) ---
// claude.ai stamps dir="rtl|ltr" on markdown blocks natively (first-strong over
// the first 80 chars incl. inline code/URLs; lists by first item, tables by first
// header cell). Cells, list items, user messages, inputs and UI chrome get none.
//
// Ownership rule: every dir the patch sets carries MANAGED_FLAG. A dir WITHOUT it
// is native and is NEVER removed or reset (React re-stamps on each re-render;
// fighting it garbles the page). The patch overrides native dir only via the
// code-aware confident layers, and freely fills the gaps native leaves.
var MANAGED_FLAG = 'data-rtl-managed';
var NATIVE_DIR_SEL = '[dir]:not([' + MANAGED_FLAG + '])';
// Streaming markdown host: frontier blocks re-render continuously, so structural
// work (math islands) waits for quiet.
var STREAM_HOST_SEL = '[data-alluvium]';
// Optional custom text font (issue #39). patch.ps1 splices a sanitized,
// locally-installed family name over the placeholder at patch time ('' = off),
// plus a scope: 'arabic' | 'hebrew' | 'all' (which glyphs the font covers).
// The guards keep unreplaced placeholders from leaking into live values.
var CUSTOM_FONT = '__RTL_CUSTOM_FONT__';
if (CUSTOM_FONT.indexOf('__') === 0) CUSTOM_FONT = '';
var CUSTOM_FONT_SCOPE = '__RTL_CUSTOM_FONT_SCOPE__';
if (CUSTOM_FONT_SCOPE.indexOf('__') === 0) CUSTOM_FONT_SCOPE = 'arabic';
function isNativeDir(el) {
return el.hasAttribute('dir') && !el.hasAttribute(MANAGED_FLAG);
}
// True when el sits under a native-dir'd ancestor (or is one). <html>/<body> are
// excluded: on a Hebrew-locale OS claude.ai stamps dir="rtl" on the ROOT (a
// page-wide default), and treating that as native ownership no-ops every guard.
function inNativeDirSubtree(el) {
var host = el.closest ? el.closest(NATIVE_DIR_SEL) : null;
return !!(host && host !== document.documentElement && host !== document.body);
}
function stampDir(el, dir) {
el.setAttribute(MANAGED_FLAG, '1');
el.dir = dir;
el.style.direction = dir;
}
function unstampDir(el) {
if (el.hasAttribute('dir')) el.removeAttribute('dir');
el.removeAttribute(MANAGED_FLAG);
el.style.direction = '';
}
// --- PURE DETECTION CORE (inlined from src/rtl-core.js by build-payload.ps1) ---
// rtl-core.js -- pure, DOM-free RTL/LaTeX detection logic.
//
// SOURCE OF TRUTH for the detection engine. tools/build-payload.ps1 inlines this
// file into the injected IIFE inside patch.ps1 (stripping the module.exports guard
// at the bottom); test/rtl-core.test.js requires it directly. Keep it DOM-free.
'use strict';
// Strong-RTL code-point ranges, [lo, hi] inclusive. Covers living RTL scripts plus
// common historic/astral ones and the explicit RTL bidi controls (RLM/RLE/RLO/RLI),
// matching claude.ai's native detector. Tested against code points (codePointAt),
// not UTF-16 code units, so astral blocks like Adlam work.
var RTL_RANGES = [
[0x0590, 0x05FF], // Hebrew
[0x0600, 0x06FF], // Arabic
[0x0700, 0x074F], // Syriac
[0x0750, 0x077F], // Arabic Supplement
[0x0780, 0x07BF], // Thaana
[0x07C0, 0x07FF], // NKo
[0x0800, 0x083F], // Samaritan
[0x0840, 0x085F], // Mandaic
[0x0860, 0x086F], // Syriac Supplement
[0x0870, 0x089F], // Arabic Extended-B
[0x08A0, 0x08FF], // Arabic Extended-A
[0x200F, 0x200F], // Right-to-Left Mark (RLM)
[0x202B, 0x202B], // Right-to-Left Embedding (RLE)
[0x202E, 0x202E], // Right-to-Left Override (RLO)
[0x2067, 0x2067], // Right-to-Left Isolate (RLI)
[0xFB1D, 0xFB4F], // Hebrew presentation forms
[0xFB50, 0xFDFF], // Arabic presentation forms-A
[0xFE70, 0xFEFF], // Arabic presentation forms-B
[0x10800, 0x1083F], // Cypriot Syllabary block (incl. early RTL scripts)
[0x10840, 0x1085F], // Imperial Aramaic
[0x10A00, 0x10A5F], // Kharoshthi
[0x10E60, 0x10E7F], // Rumi Numeral Symbols
[0x1E800, 0x1E8DF], // Mende Kikakui
[0x1E900, 0x1E95F], // Adlam
[0x1EE00, 0x1EEFF] // Arabic Mathematical Alphabetic Symbols
];
function isRTL(cp) {
for (var i = 0; i < RTL_RANGES.length; i++) {
if (cp >= RTL_RANGES[i][0] && cp <= RTL_RANGES[i][1]) return true;
}
return false;
}
function hasRTL(text) {
if (!text) return false;
for (var i = 0; i < text.length;) {
var cp = text.codePointAt(i);
if (isRTL(cp)) return true;
i += cp > 0xFFFF ? 2 : 1;
}
return false;
}
// Direction of the first strong character: 'rtl', 'ltr', or null (no strong char).
function firstStrong(text) {
if (!text) return null;
for (var i = 0; i < text.length;) {
var cp = text.codePointAt(i);
if (isRTL(cp)) return 'rtl';
// ASCII Latin letters are strong-LTR.
if ((cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A)) return 'ltr';
i += cp > 0xFFFF ? 2 : 1;
}
return null;
}
// Majority script: strong-RTL code points vs Latin letters. Last-resort tie-breaker
// when first-strong says LTR but RTL characters exist -- an English sentence quoting
// a Hebrew word stays LTR; a Hebrew paragraph opening with a Latin run flips RTL.
function rtlMajority(text) {
if (!text) return false;
var r = 0, l = 0;
for (var i = 0; i < text.length;) {
var cp = text.codePointAt(i);
if (isRTL(cp)) r++;
else if ((cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A)) l++;
i += cp > 0xFFFF ? 2 : 1;
}
return r > l;
}
// Remove leading LTR-only noise (filenames, URLs, paths, backtick-code) so a Hebrew
// sentence that starts with "foo.js" still detects as RTL.
function stripLeadingLTR(text) {
return text
.replace(/^[\s]*(?:[\w.\-]+\.[\w]{1,5})\s*/g, '')
.replace(/https?:\/\/\S+/g, '')
.replace(/[\w.\-]+[\/\\][\w.\-\/\\]+/g, '')
.replace(/`[^`]+`/g, '');
}
// A "$...$" body is math only with a real LaTeX signal (currency guard: "$5.99" stays text).
var LATEX_SIGNAL = /[\\^_{}]|\b(?:frac|sqrt|sum|prod|int|lim|infty|cdot|times|div|leq|geq|neq|approx|partial|nabla|alpha|beta|gamma|delta|theta|lambda|mu|pi|sigma|omega|matrix|begin|end|left|right|text|mathbb|mathcal|vec|hat|bar|overline|underline)\b/;
function hasLatexSignal(body) {
return LATEX_SIGNAL.test(body);
}
// Find math regions as [start, end) index pairs. Unambiguous delimiters
// ($$...$$, \[...\], \(...\)) always count; single $...$ only with a LaTeX signal
// and only outside already-claimed regions.
function findLatexRanges(text) {
var ranges = [];
if (!text) return ranges;
function claim(re, requireSignal, bodyStart, bodyEnd) {
var m;
re.lastIndex = 0;
while ((m = re.exec(text)) !== null) {
var start = m.index;
var end = m.index + m[0].length;
if (overlaps(start, end)) continue;
if (requireSignal) {
var body = m[0].slice(bodyStart, m[0].length - bodyEnd);
if (!hasLatexSignal(body)) continue;
}
ranges.push([start, end]);
}
}
function overlaps(s, e) {
for (var i = 0; i < ranges.length; i++) {
if (s < ranges[i][1] && e > ranges[i][0]) return true;
}
return false;
}
// Claim the unambiguous, greedier delimiters first.
claim(/\$\$[\s\S]+?\$\$/g, false, 0, 0);
claim(/\\\[[\s\S]+?\\\]/g, false, 0, 0);
claim(/\\\([\s\S]+?\\\)/g, false, 0, 0);
claim(/\$[^$\n]+?\$/g, true, 1, 1); // single $...$: no newline, must carry a LaTeX signal
ranges.sort(function (a, b) { return a[0] - b[0]; });
return ranges;
}
// --- BARE NUMERIC / ARITHMETIC ISOLATION ---
// Claude often writes arithmetic without LaTeX delimiters ("2 + 3 = 5"); inside an
// RTL paragraph the bidi algorithm mirrors it to "5 = 3 + 2". findMathRanges marks
// such runs so the DOM can isolate them LTR.
//
// Operator characters proving a run is a genuine expression. Built with
// String.fromCharCode so the SOURCE stays pure ASCII (patch.ps1 is BOM-less and
// PowerShell 5.1 corrupts non-ASCII source bytes); '-' is escaped for the regex
// class. Codes: U+00D7 U+00F7 U+00B1 U+2212 U+2264 U+2265 U+2260 U+2248 U+2192
// U+00B7 U+2022 U+2219 U+2217 U+22C5 U+221A.
var MATH_OP_CHARS = '+\\-*/=<>%' + String.fromCharCode(
0xD7, 0xF7, 0xB1, 0x2212, 0x2264, 0x2265, 0x2260,
0x2248, 0x2192, 0xB7, 0x2022, 0x2219, 0x2217, 0x22C5, 0x221A);
var MATH_OP_RE = new RegExp('[' + MATH_OP_CHARS + ']');
var MATH_DIGIT_RE = /[0-9]/;
// A token is "mathy" when built only from digits and math punctuation/operators, OR
// it is a single Latin variable letter (x, y, n). Multi-letter Latin tokens (words,
// "3D", "4K") break a run and keep prose out of the island.
var MATH_TOKEN_RE = new RegExp('^(?:[0-9.,:;()\\[\\]{}|' + MATH_OP_CHARS + ']+|[A-Za-z])$');
function isMathyToken(tok) {
return !!tok && MATH_TOKEN_RE.test(tok);
}
// A token may BOUND a run only if it carries an operand (a digit or single Latin
// variable letter). Pure operator/punctuation tokens sit inside but never bound it.
function isOperandToken(tok) {
return MATH_DIGIT_RE.test(tok) || /^[A-Za-z]$/.test(tok);
}
// Find bare numeric/arithmetic runs as [start, end) pairs. A run must be
// whitespace/line delimited, operand-bounded, and contain a digit AND an operator.
// Lone numbers, "$5", Hebrew-glued constructs, dates/IPs, and "1." list markers are
// left alone.
function findMathRanges(text) {
var ranges = [];
if (!text || !MATH_OP_RE.test(text) || !MATH_DIGIT_RE.test(text)) return ranges;
// Scan line by line so a run never spans a newline (each line is its own bidi
// paragraph). `base` is the absolute offset of the current line.
var base = 0;
var lines = text.split('\n');
for (var li = 0; li < lines.length; li++) {
scanLine(lines[li], base);
base += lines[li].length + 1; // +1 for the '\n' removed by split
}
return ranges;
function scanLine(line, off) {
var toks = [];
var re = /\S+/g;
var m;
while ((m = re.exec(line)) !== null) {
toks.push({ v: m[0], start: m.index, end: m.index + m[0].length });
}
var i = 0;
while (i < toks.length) {
if (!isMathyToken(toks[i].v)) { i++; continue; }
var j = i;
while (j + 1 < toks.length && isMathyToken(toks[j + 1].v)) j++;
// Trim non-operand tokens off both ends so the run is operand-bounded.
var a = i, b = j;
while (a <= b && !isOperandToken(toks[a].v)) a++;
while (b >= a && !isOperandToken(toks[b].v)) b--;
if (a <= b) {
var s = off + toks[a].start;
var e = off + toks[b].end;
// Drop sentence punctuation clinging to the ends.
while (e > s && '.,:;'.indexOf(text.charAt(e - 1)) !== -1) e--;
while (e > s && ',:;'.indexOf(text.charAt(s)) !== -1) s++;
var sub = text.slice(s, e);
if (e - s >= 2 && MATH_DIGIT_RE.test(sub) && MATH_OP_RE.test(sub)) {
ranges.push([s, e]);
}
}
i = j + 1;
}
}
}
// Split text into alternating {type:'text'|'math', value} segments. 'math' covers
// LaTeX islands and bare arithmetic; the DOM layer isolates both LTR. LaTeX wins
// when the two overlap.
function segmentText(text) {
var segs = [];
if (!text) return segs;
var ranges = findLatexRanges(text);
var numeric = findMathRanges(text);
for (var n = 0; n < numeric.length; n++) {
var ns = numeric[n][0], ne = numeric[n][1], clash = false;
for (var c = 0; c < ranges.length; c++) {
if (ns < ranges[c][1] && ne > ranges[c][0]) { clash = true; break; }
}
if (!clash) ranges.push(numeric[n]);
}
if (!ranges.length) {
segs.push({ type: 'text', value: text });
return segs;
}
ranges.sort(function (a, b) { return a[0] - b[0]; });
var pos = 0;
for (var i = 0; i < ranges.length; i++) {
if (ranges[i][0] > pos) {
segs.push({ type: 'text', value: text.slice(pos, ranges[i][0]) });
}
segs.push({ type: 'math', value: text.slice(ranges[i][0], ranges[i][1]) });
pos = ranges[i][1];
}
if (pos < text.length) segs.push({ type: 'text', value: text.slice(pos) });
return segs;
}
// Classify a table cell's direction. A cell is RTL if it *contains* any RTL char
// (header labels often start with a Latin term yet belong to a Hebrew column, so
// first-strong is too weak here). Neutral cells return null so they don't sway
// the majority.
function cellDir(text) {
if (hasRTL(text)) return 'rtl';
if (firstStrong(text) === 'ltr') return 'ltr';
return null;
}
// Decide a table's column direction from header / first-column cell dirs (each an
// array of 'rtl'|'ltr'|null). Header wins; first column is the tie-breaker.
// Returns 'rtl' (flip columns) or null (leave LTR).
function tableDirFromCells(headerDirs, firstColDirs) {
// First header is the semantic key column: if it and the first data cell are
// both RTL, it's a Hebrew table regardless of Latin names in later headers.
if (headerDirs && headerDirs[0] === 'rtl' &&
firstColDirs && firstColDirs[0] === 'rtl') return 'rtl';
var h = majorityDir(headerDirs || []);
if (h === 'rtl') return 'rtl';
if (h === 'ltr') return null;
var c = majorityDir(firstColDirs || []);
return c === 'rtl' ? 'rtl' : null;
}
function majorityDir(dirs) {
var r = 0, l = 0;
for (var i = 0; i < dirs.length; i++) {
if (dirs[i] === 'rtl') r++;
else if (dirs[i] === 'ltr') l++;
}
if (r > l) return 'rtl';
if (l > r) return 'ltr';
return null;
}
// --- END PURE DETECTION CORE ---
// Text of an element excluding <code>/<pre> children.
function textWithoutCode(el) {
var out = '';
var nodes = el.childNodes;
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
if (n.nodeType === 3) { out += n.textContent; }
else if (n.nodeType === 1 && n.tagName !== 'CODE' && n.tagName !== 'PRE') {
out += textWithoutCode(n);
}
}
return out;
}
// --- PER-LINE DIRECTIONAL SPLITTING ---
// A paragraph with <br>/newline separators may carry multiple lines in different
// scripts; forcing one dir mangles the disagreeing lines. We defer to
// unicode-bidi:plaintext and flag data-rtl-split so later passes skip it.
var RTL_SPLIT_FLAG = 'data-rtl-split';
var BR_OR_NL_SPLIT = /(<br\s*\/?>|\n)/i;
function hasMultiScriptLines(el) {
var src = el.textContent;
if (!src) return false;
if (!/[a-zA-Z]{2,}/.test(src)) return false;
if (!hasRTL(src)) return false;
return BR_OR_NL_SPLIT.test(el.innerHTML) || src.indexOf('\n') !== -1;
}
function splitToDirectionalSpans(el) {
if (el.hasAttribute(RTL_SPLIT_FLAG)) return;
// No DOM rewriting -- assigning innerHTML broke React reconciliation. Defer to
// unicode-bidi:plaintext: each line (a bidi paragraph) auto-picks its direction
// from first-strong. Only a patch-owned dir may be removed; a native dir stays
// (plaintext neutralizes it anyway).
el.setAttribute(RTL_SPLIT_FLAG, '1');
if (!isNativeDir(el)) unstampDir(el);
el.style.direction = '';
el.style.textAlign = 'start';
el.style.unicodeBidi = 'plaintext';
}
// If RTL is inherited via a parent CSS class (not an explicit dir on the element),
// removing dir alone won't free it -- pin direction=ltr. Never touches a native dir.
function resetDirOrPinLTR(el) {
if (isNativeDir(el)) return;
if (window.getComputedStyle(el).direction === 'rtl') {
stampDir(el, 'ltr');
return;
}
unstampDir(el);
}
// --- HYBRID DIRECTION DETECTION ---
// For DOM elements (output): 3-layer detection.
function detectElDir(el) {
var full = el.textContent || '';
if (!hasRTL(full)) return null;
// Layer 1: first-strong on text excluding <code> children.
var noCode = textWithoutCode(el);
var d = firstStrong(noCode);
if (d === 'rtl') return 'rtl';
// Layer 2: strip leading filenames/URLs, then first-strong.
var stripped = stripLeadingLTR(noCode);
d = firstStrong(stripped);
if (d === 'rtl') return 'rtl';
// Layer 3: RTL chars exist but first-strong still says LTR after stripping --
// majority script decides.
return rtlMajority(noCode) ? 'rtl' : null;
}
// Majority first-strong over a list's items: confident enough to overrule a native
// list dir (which is judged by the FIRST item only).
function listConfidentDir(el) {
var dirs = [];
for (var i = 0; i < el.children.length; i++) {
var li = el.children[i];
if (li.tagName !== 'LI') continue;
var t = textWithoutCode(li);
var d = firstStrong(t);
if (d !== 'rtl') d = firstStrong(stripLeadingLTR(t)) || d;
dirs.push(d || null);
}
return majorityDir(dirs);
}
// For plain text (input box, dialogs without DOM structure).
function detectTextDir(text) {
if (!text || !text.trim()) return null;
var d = firstStrong(text);
if (d === 'rtl') return 'rtl';
if (!hasRTL(text)) return 'ltr';
var stripped = stripLeadingLTR(text);
d = firstStrong(stripped);
if (d === 'rtl') return 'rtl';
return rtlMajority(text) ? 'rtl' : 'ltr';
}
// --- ELEMENT PROCESSING ---
// querySelectorAll that INCLUDES root itself if it matches.
function qsa(root, sel) {
var base = root.querySelectorAll ? root : document;
var els = Array.from(base.querySelectorAll(sel));
if (root.matches && root.matches(sel)) els.unshift(root);
return els;
}
function forceCodeLTR(root) {
// Inside editors the stylesheet already pins pre/code/katex LTR; stamping here
// would fight ProseMirror (issue #33).
qsa(root, 'pre, .code-block__code, .relative.group\\/copy').forEach(function(b) {
if (b.closest(EDITOR_SEL)) return;
stampDir(b, 'ltr'); b.style.textAlign = 'left'; b.style.unicodeBidi = 'embed';
});
qsa(root, 'code').forEach(function(c) {
if (c.closest(EDITOR_SEL)) return;
if (!c.closest('pre') && !c.closest('.code-block__code')) stampDir(c, 'ltr');
});
qsa(root, '.katex, .katex-display, mjx-container').forEach(function(m) {
if (m.closest(EDITOR_SEL)) return;
m.style.unicodeBidi = 'isolate'; m.style.direction = 'ltr';
});
}
// --- RAW LaTeX + BARE-ARITHMETIC ISOLATION ---
// Claude Desktop (Windows) shows raw "$...$" text, and inside an RTL paragraph the
// neutral $ \ { } chars scramble formulas while bare arithmetic gets mirrored. We
// isolate each math segment (per segmentText) in its own ltr/isolate span via
// replaceChild -- never innerHTML -- to stay gentle on React, flagging islands so
// we never re-wrap during streaming.
var ISLAND_FLAG = 'data-rtl-island';
// While a stream host is actively mutating, replaceChild is clobbered each tick.
// Islands there wait for a quiet window; the observer schedules a catch-up pass.
var lastStreamMut = 0;
var STREAM_QUIET_MS = 500;
function inActiveStream(el) {
if (!el.closest || !el.closest(STREAM_HOST_SEL)) return false;
return (Date.now() - lastStreamMut) < STREAM_QUIET_MS;
}
function isolateMath(root) {
if (typeof document.createTreeWalker !== 'function') return;
var host = (root && root.nodeType === 1) ? root : document.body;
if (!host) return;
var walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT, {
acceptNode: function(node) {
var v = node.nodeValue;
if (!v) return NodeFilter.FILTER_REJECT;
// Cheap pre-filter: a LaTeX hint ($ or \) OR a numeric hint (digit AND operator).
var hasTex = v.indexOf('$') !== -1 || v.indexOf('\\') !== -1;
var hasNum = MATH_DIGIT_RE.test(v) && MATH_OP_RE.test(v);
if (!hasTex && !hasNum) return NodeFilter.FILTER_REJECT;
var p = node.parentElement;
if (!p) return NodeFilter.FILTER_REJECT;
if (p.tagName === 'SCRIPT' || p.tagName === 'STYLE') return NodeFilter.FILTER_REJECT;
// EDITOR_SEL (not WRITING_SEL): replaceChild on a text node the user is
// typing into ignited the issue #33 freeze loop.
if (p.closest('pre, code, .code-block__code, [' + ISLAND_FLAG + '], ' + EDITOR_SEL)) return NodeFilter.FILTER_REJECT;
if (inActiveStream(p)) return NodeFilter.FILTER_REJECT; // defer to settle pass
return NodeFilter.FILTER_ACCEPT;
}
});
// Collect first -- mutating during the walk invalidates the walker.
var targets = [];
var n;
while ((n = walker.nextNode())) targets.push(n);
targets.forEach(function(textNode) {
var segs = segmentText(textNode.nodeValue);
var hasMath = segs.some(function(s) { return s.type === 'math'; });
if (!hasMath) return;
var frag = document.createDocumentFragment();
segs.forEach(function(s) {
if (s.type === 'math') {
var span = document.createElement('span');
span.setAttribute(ISLAND_FLAG, '1');
span.style.unicodeBidi = 'isolate';
span.style.direction = 'ltr';
span.textContent = s.value;
frag.appendChild(span);
} else {
frag.appendChild(document.createTextNode(s.value));
}
});
if (textNode.parentNode) textNode.parentNode.replaceChild(frag, textNode);
});
}
// --- TABLE COLUMN ORDERING ---
// A Hebrew table should read RTL (first column on the right). Per-cell direction is
// handled by processText; here we only flip column order via dir="rtl" on a stable
// <table>, and only once confident it's a Hebrew table.
var TABLE_FLAG = 'data-rtl-table';
function processTables(root) {
qsa(root, 'table').forEach(function(t) {
if (t.getAttribute(TABLE_FLAG) === 'rtl') return;
if (t.closest(EDITOR_SEL)) return;
// Native flip: claude.ai stamps dir on the table's wrapper div. If columns
// already flow RTL under a native dir, adopt it -- only per-cell work remains.
if (inNativeDirSubtree(t) && getComputedStyle(t).direction === 'rtl') {
t.setAttribute(TABLE_FLAG, 'rtl');
return;
}
var headerCells = Array.from(t.querySelectorAll('thead th'));
if (!headerCells.length) {
var firstRow = t.querySelector('tr');
if (firstRow) headerCells = Array.from(firstRow.querySelectorAll('th, td'));
}
var headerDirs = headerCells.map(function(c) { return cellDir(c.textContent || ''); });
var rows = Array.from(t.querySelectorAll('tbody tr'));
if (!rows.length) rows = Array.from(t.querySelectorAll('tr')).slice(1);
var firstColDirs = rows.map(function(r) {
var cell = r.querySelector('th, td');
return cell ? cellDir(cell.textContent || '') : null;
});
if (tableDirFromCells(headerDirs, firstColDirs) === 'rtl') {
// Native missed this one -- flip the <table> itself (stamped, never the wrapper).
t.setAttribute(TABLE_FLAG, 'rtl');
stampDir(t, 'rtl');
}
});
}
function processText(root) {
// Standard text elements
qsa(root, 'p, li, h1, h2, h3, h4, h5, h6, blockquote, td, th, summary, label, dt, dd').forEach(function(el) {
if (el.closest(EDITOR_SEL) || el.closest('pre') || el.closest('.code-block__code')) return;
if (el.hasAttribute(RTL_SPLIT_FLAG)) return;
if (isNativeDir(el)) {
// Native already directed this block. Add only what native cannot
// express; never remove or downgrade its dir. Multi-script lines first,
// for either native dir: native gives the whole block one dir, so a
// Hebrew quote whose first line is a Latin marker renders backwards.
if (hasRTL(el.textContent || '') && hasMultiScriptLines(el)) {
splitToDirectionalSpans(el);
} else if (el.getAttribute('dir') !== 'rtl' &&
detectElDir(el) === 'rtl') {
// Disagreement (code-prefixed or Latin-first Hebrew): override.
stampDir(el, 'rtl');
}
if (el.getAttribute('dir') === 'rtl' && el.tagName === 'LI') {
el.style.listStylePosition = 'inside';
}
return;
}
var dir = detectElDir(el);
if (dir) {
if (dir === 'rtl' && hasMultiScriptLines(el)) {
splitToDirectionalSpans(el);
return;
}
stampDir(el, dir);
if (el.tagName === 'LI') {
el.style.listStylePosition = (dir === 'rtl') ? 'inside' : '';
var parentList = el.closest('ul, ol');
if (parentList && dir === 'rtl' && !parentList.hasAttribute('dir')) {
stampDir(parentList, 'rtl');
var pl = getComputedStyle(parentList).paddingLeft;
if (parseFloat(pl) > 0) { parentList.style.paddingRight = pl; parentList.style.paddingLeft = '0'; }
}
}
} else {
resetDirOrPinLTR(el);
if (el.tagName === 'LI') el.style.listStylePosition = '';
}
});
// Lists
qsa(root, 'ul, ol').forEach(function(el) {
if (el.closest(EDITOR_SEL) || el.closest('pre')) return;
if (isNativeDir(el)) {
// Native judges a list by its FIRST item only. Overrule ltr only on a
// confident majority of item dirs.
if (el.getAttribute('dir') !== 'rtl' &&
listConfidentDir(el) === 'rtl') {
stampDir(el, 'rtl');
var npl = getComputedStyle(el).paddingLeft;
if (parseFloat(npl) > 0) { el.style.paddingRight = npl; el.style.paddingLeft = '0'; }
}
return;
}
var dir = detectElDir(el);
if (dir === 'rtl') {
stampDir(el, 'rtl');
var pl = getComputedStyle(el).paddingLeft;
if (parseFloat(pl) > 0) { el.style.paddingRight = pl; el.style.paddingLeft = '0'; }
} else {
resetDirOrPinLTR(el);
el.style.paddingRight = ''; el.style.paddingLeft = '';
}
});
}
// Universal: process ANY leaf text container (dialogs, tooltips, etc.)
function processContainers(root) {
qsa(root, 'div, span, button, a, label').forEach(function(el) {
if (el.closest('pre') || el.closest('code') || el.closest(EDITOR_SEL)) return;
// A subtree under native dir control is native's problem space: its inline
// spans are React-owned and the block dir already resolves them.
if (inNativeDirSubtree(el)) return;
if (el.hasAttribute(RTL_SPLIT_FLAG)) return;
if (el.hasAttribute(ISLAND_FLAG)) return;
var parent = el.parentElement;
if (parent && parent.hasAttribute(RTL_SPLIT_FLAG)) return;
if (el.querySelector('p, div, ul, ol, h1, h2, h3, h4, h5, h6, pre, table')) return;
if (/^(P|LI|H[1-6]|BLOCKQUOTE|TD|TH|UL|OL)$/.test(el.tagName)) return;
var text = (el.textContent || '').trim();
if (text.length < 2) return;
if (hasRTL(text)) {
if (hasMultiScriptLines(el)) {
splitToDirectionalSpans(el);
} else {
stampDir(el, detectTextDir(text) || 'rtl');
el.style.textAlign = 'start';
}
} else if (el.hasAttribute('dir')) {
unstampDir(el);
el.style.textAlign = '';
}
});
}
function processInput() {
document.querySelectorAll(WRITING_SEL).forEach(function(input) {
var text = input.textContent || input.innerText || '';
var dir = detectTextDir(text);
if (dir === 'rtl') {
input.style.direction = 'rtl'; input.style.textAlign = 'right'; input.style.paddingRight = '25px';
} else {
input.style.direction = 'ltr'; input.style.textAlign = 'left'; input.style.paddingRight = '';
}
});
}
function processAll() {
isolateMath(document.body);
processText(document);
processContainers(document.body);
processTables(document.body);
processInput();
forceCodeLTR(document.body);
}
function injectStyles() {
if (document.getElementById('claude-rtl-styles')) return;
var s = document.createElement('style');
s.id = 'claude-rtl-styles';
s.textContent = [
'p:not([dir]),li:not([dir]),h1:not([dir]),h2:not([dir]),h3:not([dir]),h4:not([dir]),h5:not([dir]),h6:not([dir]),blockquote:not([dir]),td:not([dir]),th:not([dir]),summary:not([dir]),label:not([dir]),legend:not([dir]),dt:not([dir]),dd:not([dir]),figcaption:not([dir]),caption:not([dir]){unicode-bidi:plaintext!important;text-align:start!important}',
'pre,.code-block__code,.relative.group\\/copy{unicode-bidi:embed!important;direction:ltr!important;text-align:left!important}',
'code{unicode-bidi:isolate!important;direction:ltr!important}',
// Raw LaTeX islands and rendered math are isolated LTR units.
'[data-rtl-island]{unicode-bidi:isolate!important;direction:ltr!important}',
'.katex,.katex-display,mjx-container{unicode-bidi:isolate!important;direction:ltr!important}',
// Hebrew tables: flip column order; cells keep their own direction.
'table[dir="rtl"]{direction:rtl!important}',
// Scoped to patch-owned dirs: native-dir'd blocks have their own layout
// children (word-fade spans) that a blanket plaintext rule disturbs.
'[data-rtl-managed][dir]{text-align:start!important}[data-rtl-managed][dir="rtl"]{direction:rtl!important}[data-rtl-managed][dir="ltr"]{direction:ltr!important}',
'[data-rtl-managed][dir]>*:not([dir]):not(pre):not(code):not(.code-block__code){unicode-bidi:plaintext;text-align:start}',
// RTL: flip sidebar truncation gradient to fade the LEFT edge (issue #7).
'[dir="rtl"][class*="mask-image:linear-gradient(to_right"]{-webkit-mask-image:linear-gradient(to left,hsl(var(--always-black)) 85%,transparent 99%)!important;mask-image:linear-gradient(to left,hsl(var(--always-black)) 85%,transparent 99%)!important}',
'.group:hover [dir="rtl"][class*="mask-image:linear-gradient(to_right"],.group:focus-within [dir="rtl"][class*="mask-image:linear-gradient(to_right"],[data-menu-open="true"] [dir="rtl"][class*="mask-image:linear-gradient(to_right"]{-webkit-mask-image:linear-gradient(to left,hsl(var(--always-black)) 60%,transparent 78%)!important;mask-image:linear-gradient(to left,hsl(var(--always-black)) 60%,transparent 78%)!important}'
].join('');
document.head.appendChild(s);
}
// Route glyphs in CUSTOM_FONT_SCOPE to CUSTOM_FONT without touching any other
// script. @font-face + local() (+ unicode-range for the script-limited scopes)
// defines a scoped family that silently no-ops when the font isn't installed.
// To win over claude.ai's own stacks WITHOUT overriding its font variables
// (fragile; the reason PR #19's approach was rejected), every same-origin
// font-family declaration is re-declared verbatim in a shadow stylesheet with
// the scoped family prepended -- appended last so source order settles the
// tie. Idempotent; re-run as the SPA's split CSS chunks arrive.
function injectCustomFont() {
if (!CUSTOM_FONT || !document.head || !document.body) return;
var FAM = 'claude-rtl-custom';
var RANGES = {
arabic: 'U+0600-06FF,U+0750-077F,U+08A0-08FF,U+FB50-FDFF,U+FE70-FEFF',
hebrew: 'U+0590-05FF,U+FB1D-FB4F'
};
// 'all' (or anything unknown) -> no unicode-range: the face covers every
// glyph the font supplies; glyphs it lacks fall through to the next family.
var range = RANGES[CUSTOM_FONT_SCOPE] || '';
var css = [];
// Weight-specific faces via full + PostScript names (the static-TTF naming
// convention Vazirmatn and most families follow). A missing name just makes
// local() fail and the browser synthesize from the closest weight.
[['', 'Regular', '400'], [' Medium', 'Medium', '500'],
[' SemiBold', 'SemiBold', '600'], [' Bold', 'Bold', '700']].forEach(function(w) {
css.push('@font-face{font-family:"' + FAM + '";src:local("' + CUSTOM_FONT + w[0] +
'"),local("' + CUSTOM_FONT + '-' + w[1] + '");font-weight:' + w[2] +
(range ? ';unicode-range:' + range : '') + '}');
});
// Baseline for text that gets its stack purely by inheritance from <body>.
// Strip our own family before re-reading, or re-runs would stack it up.
var base = '';
try { base = getComputedStyle(document.body).fontFamily || ''; } catch (e) {}
base = base.replace(new RegExp('^\\s*["\']?' + FAM + '["\']?\\s*,\\s*'), '');
if (base && !/mono/i.test(base)) css.push('body{font-family:"' + FAM + '",' + base + '}');
function scanRules(rules, prefix, suffix) {
for (var i = 0; i < rules.length; i++) {
var r = rules[i];
if (r.type === 1 && r.selectorText && r.style) {
var ff = r.style.getPropertyValue('font-family');
if (!ff || ff.indexOf(FAM) !== -1) continue;
var bang = r.style.getPropertyPriority('font-family') ? ' !important' : '';
// A mono stack must stay mono even for Arabic glyphs inside
// code; icon/math faces map codepoints privately; CSS-wide
// keywords can't take a prefix. These are still re-declared
// VERBATIM, in place: every font-family rule is copied in its
// original order, so a prefixed copy of an earlier rule can't
// overtake a later mono rule it used to lose to
// (e.g. class="font-claude-response font-mono").
var keep = /mono|icon|katex|emoji|math/i.test(ff) ||
/^\s*(inherit|initial|unset|revert|revert-layer)\s*$/i.test(ff);
css.push(prefix + r.selectorText + '{font-family:' + (keep ? '' : '"' + FAM + '",') + ff + bang + '}' + suffix);
} else if (r.cssRules && r.cssRules.length) {
// claude.ai (Tailwind v4) keeps nearly every font-family inside
// @layer base/components/utilities. Re-entering the same named
// layer keeps each copy in its original cascade layer; anonymous
// layers can't be re-entered and are skipped.
var at = '';
if (typeof CSSLayerBlockRule !== 'undefined' && r instanceof CSSLayerBlockRule) {
if (r.name) at = '@layer ' + r.name;
} else if (typeof CSSScopeRule !== 'undefined' && r instanceof CSSScopeRule) {
// A prelude-less @scope is bound to its <style>'s parent;
// a copy in our sheet would widen it to the whole page.
if (r.start) at = '@scope (' + r.start + ')' + (r.end ? ' to (' + r.end + ')' : '');
} else if (typeof CSSContainerRule !== 'undefined' && r instanceof CSSContainerRule) {
at = '@container ' + r.conditionText;
} else if (r.type === 12) {
at = '@supports ' + r.conditionText;
} else if (r.type === 4) {
at = '@media ' + r.conditionText;
}
if (at) scanRules(r.cssRules, prefix + at + '{', '}' + suffix);
}
}
}
for (var si = 0; si < document.styleSheets.length; si++) {
var sheet = document.styleSheets[si];
if (sheet.ownerNode && sheet.ownerNode.id === 'claude-rtl-custom-font') continue;
var rules;
try { rules = sheet.cssRules; } catch (e) { continue; } // cross-origin
if (rules) scanRules(rules, '', '');
}
var text = css.join('');
var el = document.getElementById('claude-rtl-custom-font');
if (!el) {
el = document.createElement('style');
el.id = 'claude-rtl-custom-font';
}
// (Re-)append so the shadow sheet stays behind late-loaded chunk CSS. It goes
// after <body>, not into <head>: claude.ai loads its CSS via <link>s inside
// <body>, which would otherwise follow us in document order and win ties.
var root = document.documentElement;
if (el.textContent !== text || el.parentNode !== root || el.nextSibling) {
el.textContent = text;
root.appendChild(el);
}
}
function init() {
injectStyles();
if (CUSTOM_FONT) {
injectCustomFont();
// The SPA's stylesheets stream in after DOMContentLoaded; re-scan on a
// tapering schedule instead of observing (cheap, self-terminating).
[1500, 4000, 10000, 25000].forEach(function(d) { setTimeout(injectCustomFont, d); });
if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
document.fonts.ready.then(function() { injectCustomFont(); });
}
}
processAll();
// Input box live direction switching
document.addEventListener('input', function(e) {
var t = e.target;
if (!t || !(t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable)) return;
var text = t.textContent || t.innerText || t.value || '';
var dir = detectTextDir(text);
if (dir === 'rtl') {
t.style.direction = 'rtl'; t.style.textAlign = 'right'; t.style.paddingRight = '25px';
} else {
t.style.direction = 'ltr'; t.style.textAlign = 'left'; t.style.paddingRight = '';
}
}, true);
// Watch DOM changes (throttle, not debounce -- process DURING streaming)
var pendingMuts = [];
// Structural loop-breaker (issue #33): a freeze loop needs "editor mutation ->
// schedule -> write into editor -> editor mutation". Dropping editor-internal
// records at intake cuts that chain at its first link.
function mutInsideEditor(m) {
var t = m.target;
var el = (t && t.nodeType === 1) ? t : (t ? t.parentElement : null);
return !!(el && el.closest && el.closest(EDITOR_SEL));
}
function mutInsideStream(m) {
var t = m.target;
var el = (t && t.nodeType === 1) ? t : (t ? t.parentElement : null);
return !!(el && el.closest && el.closest(STREAM_HOST_SEL));
}
// One-shot settle pass: after a stream host goes quiet, run the deferred math
// isolation over it (islands skipped mid-stream).
var settleTimer = null;
function scheduleStreamSettle() {
lastStreamMut = Date.now();
if (settleTimer) clearTimeout(settleTimer);
settleTimer = setTimeout(function() {
settleTimer = null;
document.querySelectorAll(STREAM_HOST_SEL).forEach(function(h) {
isolateMath(h);
});
}, STREAM_QUIET_MS + 100);
}
var obs = new MutationObserver(function(muts) {
var relevant = [];
var touchedStream = false;
for (var i = 0; i < muts.length; i++) {
var m = muts[i];
if (m.addedNodes.length === 0 && m.type !== 'characterData') continue;
if (mutInsideEditor(m)) continue;
if (!touchedStream && mutInsideStream(m)) touchedStream = true;
relevant.push(m);
}
if (touchedStream) scheduleStreamSettle();
if (!relevant.length) return;
for (var j = 0; j < relevant.length; j++) pendingMuts.push(relevant[j]);
if (window._rtlT) return; // throttle: already scheduled
window._rtlT = setTimeout(function() {
window._rtlT = null;
var toProcess = pendingMuts;
pendingMuts = [];
var roots = new Set();
toProcess.forEach(function(m) {
m.addedNodes.forEach(function(n) { if (n.nodeType === 1) roots.add(n); });
if (m.type === 'characterData' && m.target.parentElement) roots.add(m.target.parentElement);
});
var expanded = new Set(roots);
roots.forEach(function(r) {
if (!r.closest) return;
var txt = r.closest('p, li, h1, h2, h3, h4, h5, h6, blockquote, td, th, summary, label, dt, dd');