ep_hljs 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.cjs +10 -0
- package/.github/workflows/automerge.yml +45 -0
- package/.github/workflows/backend-tests.yml +75 -0
- package/.github/workflows/codeql.yml +41 -0
- package/.github/workflows/frontend-tests.yml +72 -0
- package/.github/workflows/npmpublish.yml +124 -0
- package/.github/workflows/test-and-release.yml +32 -0
- package/CLAUDE.md +141 -0
- package/LICENSE +201 -0
- package/README.md +56 -0
- package/demo.gif +0 -0
- package/demo.png +0 -0
- package/ep.json +26 -0
- package/index.js +109 -0
- package/lib/exportRenderer.js +39 -0
- package/lib/languageAllowlist.js +16 -0
- package/lib/padLanguageStore.js +27 -0
- package/locales/en.json +10 -0
- package/package.json +59 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/build-vendor.js +45 -0
- package/static/css/editor.css +94 -0
- package/static/css/themes/github-dark.css +118 -0
- package/static/css/themes/github.css +118 -0
- package/static/js/codeIndent.js +107 -0
- package/static/js/constants.js +7 -0
- package/static/js/domOverlay.js +16 -0
- package/static/js/highlightRegistry.js +109 -0
- package/static/js/hljsAdapter.js +80 -0
- package/static/js/index.js +124 -0
- package/static/js/lruCache.js +28 -0
- package/static/js/syntaxRenderer.js +201 -0
- package/static/js/themeBridge.js +76 -0
- package/static/js/vendor/hljs.min.js +5 -0
- package/static/tests/backend/specs/codeIndent.test.js +144 -0
- package/static/tests/backend/specs/export.test.js +47 -0
- package/static/tests/backend/specs/highlightRegistry.test.js +59 -0
- package/static/tests/backend/specs/hljsAdapter.test.js +43 -0
- package/static/tests/backend/specs/lruCache.test.js +45 -0
- package/static/tests/backend/specs/padLanguageStore.test.js +63 -0
- package/static/tests/backend/specs/socket.test.js +54 -0
- package/static/tests/frontend-new/helper/highlights.ts +64 -0
- package/static/tests/frontend-new/specs/caret-stability.spec.ts +106 -0
- package/static/tests/frontend-new/specs/code-indent.spec.ts +78 -0
- package/static/tests/frontend-new/specs/collaboration.spec.ts +59 -0
- package/static/tests/frontend-new/specs/content-sync.spec.ts +45 -0
- package/static/tests/frontend-new/specs/dark-mode.spec.ts +87 -0
- package/static/tests/frontend-new/specs/export.spec.ts +31 -0
- package/static/tests/frontend-new/specs/initial-paint.spec.ts +49 -0
- package/static/tests/frontend-new/specs/language-picker.spec.ts +54 -0
- package/static/tests/frontend-new/specs/large-pad.spec.ts +36 -0
- package/static/tests/frontend-new/specs/lifecycle.spec.ts +27 -0
- package/static/tests/frontend-new/specs/multi-user-caret.spec.ts +167 -0
- package/static/tests/frontend-new/specs/single-line-while.spec.ts +50 -0
- package/templates/editbarButtons.ejs +29 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const syntaxRenderer = require('ep_hljs/static/js/syntaxRenderer');
|
|
4
|
+
const codeIndent = require('ep_hljs/static/js/codeIndent');
|
|
5
|
+
const themeBridge = require('ep_hljs/static/js/themeBridge');
|
|
6
|
+
const socketio = require('ep_etherpad-lite/static/js/socketio');
|
|
7
|
+
// Sub-path import keeps the client bundle clean.
|
|
8
|
+
const {padToggle} = require('ep_plugin_helpers/pad-toggle');
|
|
9
|
+
const {padSelect} = require('ep_plugin_helpers/pad-select');
|
|
10
|
+
|
|
11
|
+
let socket = null;
|
|
12
|
+
let currentPadId = null;
|
|
13
|
+
|
|
14
|
+
const highlightToggle = padToggle({
|
|
15
|
+
pluginName: 'ep_hljs',
|
|
16
|
+
settingId: 'syntax-highlighting',
|
|
17
|
+
l10nId: 'ep_hljs.user_enable',
|
|
18
|
+
defaultLabel: 'Highlight syntax in pads',
|
|
19
|
+
defaultEnabled: true,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const indentSelect = padSelect({
|
|
23
|
+
pluginName: 'ep_hljs',
|
|
24
|
+
settingId: 'indent-size',
|
|
25
|
+
l10nId: 'ep_hljs.indent_size',
|
|
26
|
+
defaultLabel: 'Indent size',
|
|
27
|
+
options: [
|
|
28
|
+
{value: 2, label: '2 spaces'},
|
|
29
|
+
{value: 4, label: '4 spaces'},
|
|
30
|
+
],
|
|
31
|
+
defaultValue: 2,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Both helpers re-export handleClientMessage_CLIENT_MESSAGE so each can refresh
|
|
35
|
+
// its own UI on padoptions broadcasts; chain both invocations.
|
|
36
|
+
exports.handleClientMessage_CLIENT_MESSAGE = (hookName, ctx) => {
|
|
37
|
+
highlightToggle.handleClientMessage_CLIENT_MESSAGE(hookName, ctx);
|
|
38
|
+
indentSelect.handleClientMessage_CLIENT_MESSAGE(hookName, ctx);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const loadHljs = () => {
|
|
42
|
+
if (typeof window !== 'undefined' && window.hljs) return Promise.resolve();
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const script = document.createElement('script');
|
|
45
|
+
script.src = '/static/plugins/ep_hljs/static/js/vendor/hljs.min.js';
|
|
46
|
+
script.onload = resolve;
|
|
47
|
+
script.onerror = resolve;
|
|
48
|
+
document.head.appendChild(script);
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const onLanguageChanged = (msg) => {
|
|
53
|
+
const sel = document.getElementById('ep_hljs_select');
|
|
54
|
+
if (sel) {
|
|
55
|
+
const newVal = msg.autoDetect ? 'auto' : msg.language;
|
|
56
|
+
sel.value = newVal;
|
|
57
|
+
const $ = window.$;
|
|
58
|
+
if ($ && $.fn && $.fn.niceSelect) $(sel).niceSelect('update');
|
|
59
|
+
}
|
|
60
|
+
syntaxRenderer.setState({language: msg.language, autoDetect: !!msg.autoDetect});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
exports.postAceInit = async (hookName, context) => {
|
|
64
|
+
await loadHljs();
|
|
65
|
+
currentPadId = context.pad.getPadId();
|
|
66
|
+
const initial = (typeof clientVars !== 'undefined' && clientVars.ep_hljs) ||
|
|
67
|
+
{language: 'auto', autoDetect: true};
|
|
68
|
+
|
|
69
|
+
const pad = require('ep_etherpad-lite/static/js/pad');
|
|
70
|
+
socket = socketio.connect(pad.baseURL || '/', '/syntax-highlighting');
|
|
71
|
+
socket.on('connect', () => socket.emit('joinPad', {padId: currentPadId}));
|
|
72
|
+
socket.on('languageChanged', onLanguageChanged);
|
|
73
|
+
socket.on('languageChangeRejected', (reason) => {
|
|
74
|
+
console.warn('[ep_hljs] language change rejected:', reason && reason.error);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Reflect initial language in the dropdown without dispatching a change event.
|
|
78
|
+
const sel = document.getElementById('ep_hljs_select');
|
|
79
|
+
if (sel) {
|
|
80
|
+
sel.value = initial.autoDetect ? 'auto' : initial.language;
|
|
81
|
+
const $ = window.$;
|
|
82
|
+
if ($ && $.fn && $.fn.niceSelect) $(sel).niceSelect('update');
|
|
83
|
+
const handler = () => {
|
|
84
|
+
const val = sel.value;
|
|
85
|
+
const payload = val === 'auto'
|
|
86
|
+
? {padId: currentPadId, language: 'auto', autoDetect: true}
|
|
87
|
+
: {padId: currentPadId, language: val, autoDetect: false};
|
|
88
|
+
socket.emit('setLanguage', payload);
|
|
89
|
+
};
|
|
90
|
+
if ($ && $.fn) $(sel).on('change', handler);
|
|
91
|
+
else sel.addEventListener('change', handler);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
syntaxRenderer.start(context, initial);
|
|
95
|
+
codeIndent.start({
|
|
96
|
+
indentSize: 2, // padSelect.init() fires onChange synchronously below with the effective value
|
|
97
|
+
getLanguage: () => syntaxRenderer.getState().language,
|
|
98
|
+
getAutoDetect: () => syntaxRenderer.getState().autoDetect,
|
|
99
|
+
});
|
|
100
|
+
themeBridge.start();
|
|
101
|
+
|
|
102
|
+
// padToggle drives per-user / pad-wide enable. init() binds the checkboxes
|
|
103
|
+
// (without it, the checkboxes render unchecked even when defaultEnabled is
|
|
104
|
+
// true — the helper renders empty <input type="checkbox"> server-side and
|
|
105
|
+
// relies on init() to set the correct state from cookie/pad option/default).
|
|
106
|
+
highlightToggle.init({
|
|
107
|
+
onChange: (enabled) => syntaxRenderer.setUserEnabled(enabled),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// padSelect drives the indent-size dropdown. init() fires onChange once with
|
|
111
|
+
// the effective initial value (cookie / pad option / default), and again
|
|
112
|
+
// whenever the user changes the dropdown OR a remote padoptions broadcast
|
|
113
|
+
// arrives via handleClientMessage_CLIENT_MESSAGE.
|
|
114
|
+
indentSelect.init({
|
|
115
|
+
onChange: (size) => codeIndent.setIndentSize(size),
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
exports.acePostWriteDomLineHTML = syntaxRenderer.acePostWriteDomLineHTML;
|
|
120
|
+
exports.aceKeyEvent = codeIndent.handleKey;
|
|
121
|
+
|
|
122
|
+
exports.aceEditorCSS = () => [
|
|
123
|
+
'ep_hljs/static/css/editor.css',
|
|
124
|
+
];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class LRU {
|
|
4
|
+
constructor(capacity) {
|
|
5
|
+
this.capacity = capacity;
|
|
6
|
+
this.map = new Map();
|
|
7
|
+
}
|
|
8
|
+
get size() { return this.map.size; }
|
|
9
|
+
has(key) { return this.map.has(key); }
|
|
10
|
+
get(key) {
|
|
11
|
+
if (!this.map.has(key)) return undefined;
|
|
12
|
+
const value = this.map.get(key);
|
|
13
|
+
this.map.delete(key);
|
|
14
|
+
this.map.set(key, value);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
set(key, value) {
|
|
18
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
19
|
+
this.map.set(key, value);
|
|
20
|
+
while (this.map.size > this.capacity) {
|
|
21
|
+
const oldest = this.map.keys().next().value;
|
|
22
|
+
this.map.delete(oldest);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
clear() { this.map.clear(); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = LRU;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const LRU = require('./lruCache');
|
|
4
|
+
const {setLineRanges, clearAll} = require('./highlightRegistry');
|
|
5
|
+
const {tokenize, detect} = require('./hljsAdapter');
|
|
6
|
+
const {MAX_LINES, AUTO_REDETECT_MS, LRU_CAPACITY} = require('./constants');
|
|
7
|
+
|
|
8
|
+
const cache = new LRU(LRU_CAPACITY);
|
|
9
|
+
let state = {language: 'auto', autoDetect: true};
|
|
10
|
+
let userEnabled = true;
|
|
11
|
+
let paused = false;
|
|
12
|
+
let aceContext = null;
|
|
13
|
+
let autoDetectTimer = null;
|
|
14
|
+
let lastDetectAt = 0;
|
|
15
|
+
|
|
16
|
+
const isHighlightingEnabled = () => {
|
|
17
|
+
if (paused || !userEnabled) return false;
|
|
18
|
+
if (!state.language || state.language === 'off') return false;
|
|
19
|
+
if (state.language === 'auto' && !state.autoDetect) return false;
|
|
20
|
+
return true;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const getInnerDoc = () => {
|
|
24
|
+
const outer = document.getElementsByName('ace_outer')[0];
|
|
25
|
+
if (!outer) return null;
|
|
26
|
+
const outerDoc = outer.contentWindow && outer.contentWindow.document;
|
|
27
|
+
if (!outerDoc) return null;
|
|
28
|
+
const inner = outerDoc.getElementsByName('ace_inner')[0];
|
|
29
|
+
if (!inner) return null;
|
|
30
|
+
return inner.contentWindow && inner.contentWindow.document;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const renderLine = (node) => {
|
|
34
|
+
if (!node) return;
|
|
35
|
+
if (!isHighlightingEnabled() || state.language === 'auto') {
|
|
36
|
+
setLineRanges(node, []);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const text = node.textContent;
|
|
40
|
+
if (!text) {
|
|
41
|
+
setLineRanges(node, []);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const key = `${state.language}:${text}`;
|
|
45
|
+
let ranges = cache.get(key);
|
|
46
|
+
if (ranges === undefined) {
|
|
47
|
+
ranges = tokenize(text, state.language);
|
|
48
|
+
if (ranges == null) {
|
|
49
|
+
// hljs not yet loaded. Don't cache, don't strip existing highlights —
|
|
50
|
+
// a later render (or MutationObserver tick) will retry once hljs is in.
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
cache.set(key, ranges);
|
|
54
|
+
}
|
|
55
|
+
setLineRanges(node, ranges);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const repaintAllLines = () => {
|
|
59
|
+
const innerDoc = getInnerDoc();
|
|
60
|
+
if (!innerDoc) return;
|
|
61
|
+
innerDoc.querySelectorAll('div[id^="magicdomid"]').forEach(renderLine);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const padText = () => {
|
|
65
|
+
if (!aceContext) return '';
|
|
66
|
+
let result = '';
|
|
67
|
+
try {
|
|
68
|
+
aceContext.ace.callWithAce((ace) => {
|
|
69
|
+
result = ace.ace_exportText();
|
|
70
|
+
}, 'syntax-read');
|
|
71
|
+
} catch (_e) { /* ignore */ }
|
|
72
|
+
return result;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const lineCount = () => {
|
|
76
|
+
const innerDoc = getInnerDoc();
|
|
77
|
+
if (!innerDoc) return 0;
|
|
78
|
+
return innerDoc.querySelectorAll('div[id^="magicdomid"]').length;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const tickAutoRedetect = () => {
|
|
82
|
+
if (!state.autoDetect) return;
|
|
83
|
+
if (Date.now() - lastDetectAt < AUTO_REDETECT_MS) return;
|
|
84
|
+
lastDetectAt = Date.now();
|
|
85
|
+
const text = padText();
|
|
86
|
+
if (!text) return;
|
|
87
|
+
const detected = detect(text);
|
|
88
|
+
if (!detected || detected === state.language) return;
|
|
89
|
+
state = {language: detected, autoDetect: true};
|
|
90
|
+
cache.clear();
|
|
91
|
+
clearAll();
|
|
92
|
+
repaintAllLines();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const checkPaused = () => {
|
|
96
|
+
const n = lineCount();
|
|
97
|
+
const wasPaused = paused;
|
|
98
|
+
paused = n > MAX_LINES;
|
|
99
|
+
return wasPaused !== paused;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Toggle a marker class on the inner iframe's body when this plugin is
|
|
103
|
+
// actively painting. Lets editor.css scope plugin-specific CSS rules (e.g.
|
|
104
|
+
// the authorship-bg suppression) so they only apply to pads with
|
|
105
|
+
// highlighting on — keeps core Etherpad tests on plain pads unaffected.
|
|
106
|
+
const updateActiveClass = () => {
|
|
107
|
+
const innerDoc = getInnerDoc();
|
|
108
|
+
if (!innerDoc || !innerDoc.body) return;
|
|
109
|
+
const active = isHighlightingEnabled() && state.language && state.language !== 'auto';
|
|
110
|
+
innerDoc.body.classList.toggle('ep-syntax-highlighting-active', !!active);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
exports.start = (ctx, initialState) => {
|
|
114
|
+
aceContext = ctx;
|
|
115
|
+
state = {...state, ...(initialState || {})};
|
|
116
|
+
setTimeout(tickAutoRedetect, 500);
|
|
117
|
+
if (autoDetectTimer) clearInterval(autoDetectTimer);
|
|
118
|
+
autoDetectTimer = setInterval(tickAutoRedetect, 1000);
|
|
119
|
+
startMutationObserver(); // eslint-disable-line no-use-before-define
|
|
120
|
+
// The initial line renders fire BEFORE postAceInit completes (i.e. before
|
|
121
|
+
// we know the language and before hljs is loaded), so the
|
|
122
|
+
// acePostWriteDomLineHTML hook short-circuits and leaves them un-tokenized.
|
|
123
|
+
// Repaint once now that state and hljs are ready. Small timeout so the inner
|
|
124
|
+
// iframe is fully populated.
|
|
125
|
+
setTimeout(() => { repaintAllLines(); updateActiveClass(); }, 100);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
exports.setState = (next) => {
|
|
129
|
+
state = {...state, ...next};
|
|
130
|
+
cache.clear();
|
|
131
|
+
clearAll();
|
|
132
|
+
repaintAllLines();
|
|
133
|
+
updateActiveClass();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
exports.setUserEnabled = (enabled) => {
|
|
137
|
+
if (enabled === userEnabled) return;
|
|
138
|
+
userEnabled = !!enabled;
|
|
139
|
+
clearAll();
|
|
140
|
+
repaintAllLines();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
exports.acePostWriteDomLineHTML = (hookName, context) => {
|
|
144
|
+
if (checkPaused() && paused) clearAll();
|
|
145
|
+
if (paused) return;
|
|
146
|
+
renderLine(context.node);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Etherpad does incremental DOM updates on typing — the acePostWriteDomLineHTML
|
|
150
|
+
// hook only fires on FULL line re-renders (paste, language change, line split).
|
|
151
|
+
// To catch every text mutation (typing, remote changesets, IME composition,
|
|
152
|
+
// undo/redo) we observe the inner doc for character-level changes and
|
|
153
|
+
// re-render only the affected line divs. Since CSS Custom Highlights does
|
|
154
|
+
// not mutate the DOM, our setLineRanges calls don't trigger this observer.
|
|
155
|
+
let mutationObserver = null;
|
|
156
|
+
|
|
157
|
+
const startMutationObserver = () => {
|
|
158
|
+
const innerDoc = getInnerDoc();
|
|
159
|
+
if (!innerDoc || !innerDoc.body) {
|
|
160
|
+
setTimeout(startMutationObserver, 100);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (mutationObserver) return;
|
|
164
|
+
const win = innerDoc.defaultView;
|
|
165
|
+
if (!win || !win.MutationObserver) return;
|
|
166
|
+
const findLineAncestor = (node, dirtyLines) => {
|
|
167
|
+
let n = node;
|
|
168
|
+
while (n && n !== innerDoc.body) {
|
|
169
|
+
if (n.nodeType === 1 && n.id && n.id.startsWith('magicdomid')) {
|
|
170
|
+
dirtyLines.add(n);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
n = n.parentNode;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
mutationObserver = new win.MutationObserver((mutations) => {
|
|
177
|
+
if (paused) return;
|
|
178
|
+
const dirtyLines = new Set();
|
|
179
|
+
for (const m of mutations) {
|
|
180
|
+
// characterData: m.target is the text node — walk up to its line div.
|
|
181
|
+
// childList: m.target is the parent of changed children. If
|
|
182
|
+
// Etherpad replaces a whole line div, m.target is
|
|
183
|
+
// innerdocbody and the new line is in addedNodes.
|
|
184
|
+
findLineAncestor(m.target, dirtyLines);
|
|
185
|
+
if (m.addedNodes) for (const n of m.addedNodes) findLineAncestor(n, dirtyLines);
|
|
186
|
+
}
|
|
187
|
+
for (const line of dirtyLines) renderLine(line);
|
|
188
|
+
});
|
|
189
|
+
mutationObserver.observe(innerDoc.body, {
|
|
190
|
+
childList: true,
|
|
191
|
+
subtree: true,
|
|
192
|
+
characterData: true,
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
exports.getState = () => ({...state});
|
|
197
|
+
|
|
198
|
+
exports.__test_internal = { // eslint-disable-line camelcase
|
|
199
|
+
cache,
|
|
200
|
+
getState: () => ({...state}),
|
|
201
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const LIGHT_FILE = 'github.css';
|
|
4
|
+
const DARK_FILE = 'github-dark.css';
|
|
5
|
+
const PATH_MARKER = '/static/plugins/ep_hljs/static/css/themes/';
|
|
6
|
+
|
|
7
|
+
let listenersAttached = false;
|
|
8
|
+
|
|
9
|
+
const resolveDark = () => {
|
|
10
|
+
// Etherpad signals dark mode via the html element's `super-dark-editor`
|
|
11
|
+
// class (or `dark-editor` for medium dark). Fall back to legacy
|
|
12
|
+
// class names and prefers-color-scheme for environments outside Etherpad.
|
|
13
|
+
const html = document.documentElement;
|
|
14
|
+
if (html.classList.contains('super-dark-editor')) return true;
|
|
15
|
+
if (html.classList.contains('dark-editor')) return true;
|
|
16
|
+
if (html.classList.contains('darkMode')) return true;
|
|
17
|
+
if (document.body && document.body.dataset && document.body.dataset.theme === 'dark') return true;
|
|
18
|
+
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const collectThemeLinks = () => {
|
|
22
|
+
const links = [];
|
|
23
|
+
document
|
|
24
|
+
.querySelectorAll(`link[href*="${PATH_MARKER}"]`)
|
|
25
|
+
.forEach((l) => links.push(l));
|
|
26
|
+
// ace_getDocument() returns the outer pad document (ace2_inner.ts runs in the outer bundle).
|
|
27
|
+
// The ace_inner iframe's document is where the theme CSS links are injected.
|
|
28
|
+
try {
|
|
29
|
+
const outerIframe = document.getElementsByName('ace_outer')[0];
|
|
30
|
+
const outerDoc = outerIframe && outerIframe.contentWindow && outerIframe.contentWindow.document;
|
|
31
|
+
const innerIframe = outerDoc && outerDoc.getElementsByName('ace_inner')[0];
|
|
32
|
+
const innerDoc = innerIframe && innerIframe.contentWindow && innerIframe.contentWindow.document;
|
|
33
|
+
if (innerDoc) {
|
|
34
|
+
innerDoc
|
|
35
|
+
.querySelectorAll(`link[href*="${PATH_MARKER}"]`)
|
|
36
|
+
.forEach((l) => links.push(l));
|
|
37
|
+
}
|
|
38
|
+
} catch (_e) { /* cross-origin or not loaded yet */ }
|
|
39
|
+
return links;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const swap = () => {
|
|
43
|
+
const targetFile = resolveDark() ? DARK_FILE : LIGHT_FILE;
|
|
44
|
+
const otherFile = resolveDark() ? LIGHT_FILE : DARK_FILE;
|
|
45
|
+
collectThemeLinks().forEach((link) => {
|
|
46
|
+
if (link.href.includes(PATH_MARKER + otherFile)) {
|
|
47
|
+
link.href = link.href.replace(PATH_MARKER + otherFile, PATH_MARKER + targetFile);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
exports.start = () => {
|
|
53
|
+
swap();
|
|
54
|
+
if (!listenersAttached) {
|
|
55
|
+
if (window.matchMedia) {
|
|
56
|
+
try {
|
|
57
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', swap);
|
|
58
|
+
} catch (_e) {
|
|
59
|
+
// older Safari fallback
|
|
60
|
+
window.matchMedia('(prefers-color-scheme: dark)').addListener(swap);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
new MutationObserver(swap).observe(document.documentElement, {
|
|
64
|
+
attributes: true,
|
|
65
|
+
attributeFilter: ['class', 'data-theme'],
|
|
66
|
+
});
|
|
67
|
+
listenersAttached = true;
|
|
68
|
+
}
|
|
69
|
+
// The inner ace iframe may finish loading the link tag AFTER start(); re-run
|
|
70
|
+
// a few times shortly after start so the swap catches a late insertion.
|
|
71
|
+
setTimeout(swap, 250);
|
|
72
|
+
setTimeout(swap, 1000);
|
|
73
|
+
setTimeout(swap, 3000);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
exports.swap = swap; // exposed for tests / manual triggering
|