Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .babelrc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@
"@babel/plugin-transform-runtime",
"@babel/plugin-transform-block-scoping"
],
"compact": true,
"compact": false,
"sourceMaps": "inline"
}
22 changes: 22 additions & 0 deletions src/lib/acode.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ import appSettings from "lib/settings";
import FileBrowser from "pages/fileBrowser";
import ThemeBuilder from "theme/builder";
import themes from "theme/list";
import {
applyHighlightStyles,
clearHighlightCache,
getHighlightStyleSheet,
getHighlightStyles,
HIGHLIGHT_CLASS,
highlightCodeBlock,
highlightLine,
} from "utils/codeHighlight";
import Color from "utils/color";
import encodings, { decode, encode } from "utils/encodings";
import helpers from "utils/helpers";
Expand Down Expand Up @@ -312,6 +321,17 @@ class Acode {
},
};

const codeHighlightModule = Object.freeze({
highlightLine,
highlightCodeBlock,
highlight: highlightCodeBlock,
clearCache: clearHighlightCache,
applyStyles: applyHighlightStyles,
getStyles: getHighlightStyles,
getStyleSheet: getHighlightStyleSheet,
HIGHLIGHT_CLASS,
});

const codemirrorModule = Object.freeze({
autocomplete: cmAutocomplete,
commands: cmCommands,
Expand All @@ -326,6 +346,7 @@ class Acode {
search: cmSearch,
state: cmState,
view: cmView,
highlight: codeHighlightModule,
});

const configProxy = new Proxy(config, {
Expand Down Expand Up @@ -414,6 +435,7 @@ class Acode {
this.define("terminal", terminalModule);
this.define("webview", webview);
this.define("codemirror", codemirrorModule);
this.define("codeHighlight", codeHighlightModule);
this.define("@codemirror/autocomplete", cmAutocomplete);
this.define("@codemirror/commands", cmCommands);
this.define("@codemirror/language", cmLanguage);
Expand Down
6 changes: 6 additions & 0 deletions src/lib/editorFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import startDrag from "handlers/editorFileTab";
import actions from "handlers/quickTools";
import tag from "html-tag-js";
import mimeTypes from "mime-types";
import { applyHighlightStyles } from "utils/codeHighlight";
import helpers from "utils/helpers";
import Path from "utils/Path";
import { readRemoteFilePreview } from "utils/remoteFilePreview";
Expand Down Expand Up @@ -329,6 +330,7 @@ function maybeRecommendLanguageModeExtension(file, modeInfo) {
* @property {string} [paneId] target editor pane id
* @property {object} [pane] target editor pane
* @property {boolean} [isPanePlaceholder] temporary empty tab for an empty pane
* @property {boolean} [highlightStyles] adopt static CodeMirror highlight CSS into the custom tab shadow root
*/

export default class EditorFile {
Expand Down Expand Up @@ -590,6 +592,10 @@ export default class EditorFile {
this.#addCustomStyles(options.stylesheets, shadow);
}

if (options.highlightStyles) {
applyHighlightStyles(shadow);
}

const content = <div className="tab-page-content" />;

if (typeof options.content === "string") {
Expand Down
66 changes: 66 additions & 0 deletions src/test/editor.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,72 @@ export async function runCodeMirrorTests(writeOutput) {
);
});

runner.test(
"Acode exposes the static CodeMirror highlighter",
async (test) => {
const codeHighlight = acode.require("codeHighlight");
const codemirror = acode.require("codemirror");

test.assert(codeHighlight != null, "codeHighlight module should exist");
test.assertEqual(
typeof codeHighlight.highlightCodeBlock,
"function",
"highlightCodeBlock should be a function",
);
test.assertEqual(
typeof codeHighlight.highlightLine,
"function",
"highlightLine should be a function",
);
test.assertEqual(
typeof codeHighlight.applyStyles,
"function",
"applyStyles should be a function",
);
test.assertEqual(
typeof codeHighlight.getStyles,
"function",
"getStyles should be a function",
);
test.assertEqual(
codeHighlight.HIGHLIGHT_CLASS,
"cm-highlighted",
"HIGHLIGHT_CLASS should match the internal token wrapper",
);
test.assertEqual(
codemirror.highlight,
codeHighlight,
"codemirror.highlight should be the same highlighter module",
);

const css = codeHighlight.getStyles();
test.assert(
typeof css === "string" && css.includes(".tok-keyword"),
"getStyles should return token CSS",
);

const highlighted = await codeHighlight.highlightCodeBlock(
'const value = "acode";',
"javascript",
);
test.assert(
highlighted.includes("value") && !highlighted.includes("<script"),
"highlightCodeBlock should return escaped highlighted HTML",
);

const host = document.createElement("div");
const shadow = host.attachShadow({ mode: "open" });
codeHighlight.applyStyles(shadow);
const sheet = codeHighlight.getStyleSheet();
test.assert(
sheet == null ||
Array.from(shadow.adoptedStyleSheets || []).includes(sheet) ||
shadow.querySelector("#cm-static-highlight-styles") != null,
"applyStyles should attach highlight CSS to a shadow root",
);
},
);

runner.test("Editor creation", async (test) => {
const { view, container } = createEditor();
test.assert(view != null, "EditorView instance should be created");
Expand Down
182 changes: 166 additions & 16 deletions src/utils/codeHighlight.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ import settings from "lib/settings";

const highlightCache = new Map();
const MAX_CACHE_SIZE = 500;
const STYLE_ID = "cm-static-highlight-styles";

export const HIGHLIGHT_CLASS = "cm-highlighted";
export const REF_PREVIEW_CLASS = "ref-preview";

let styleElement = null;
let constructedSheet = null;
let currentThemeId = null;
let initialized = false;
const fallbackStyleElements = new Set();

export function sanitize(text) {
if (!text) return "";
Expand Down Expand Up @@ -42,6 +49,17 @@ function setCache(key, value) {
highlightCache.set(key, value);
}

function canUseConstructedStyleSheets() {
return (
typeof CSSStyleSheet !== "undefined" &&
typeof CSSStyleSheet.prototype.replaceSync === "function"
);
}

function currentEditorThemeId() {
return settings?.value?.editorTheme || "one_dark";
}

/**
* Generates CSS styles for syntax highlighting tokens
* @param {Object} config - Theme config with color values
Expand Down Expand Up @@ -106,25 +124,149 @@ ${selector} .tok-changed { color: ${number}; }
}

/**
* Injects dynamic CSS for syntax highlighting based on current editor theme
* CSS for the current editor theme. Token classes come from Lezer's
* `classHighlighter` (e.g. `.tok-keyword`).
* @returns {string}
*/
function injectStyles() {
const themeId = settings?.value?.editorTheme || "one_dark";
const config = getThemeConfig(themeId);
export function getHighlightStyles() {
const config = getThemeConfig(currentEditorThemeId());
const codeBlockStyles = generateStyles(config, `.${HIGHLIGHT_CLASS}`, true);
const refPreviewStyles = generateStyles(
config,
`.${REF_PREVIEW_CLASS}`,
false,
);
return `${codeBlockStyles}\n${refPreviewStyles}`;
}

function ensureConstructedSheet(css) {
if (!canUseConstructedStyleSheets()) return null;
if (!constructedSheet) {
constructedSheet = new CSSStyleSheet();
}
constructedSheet.replaceSync(css);
return constructedSheet;
}

// Code blocks need background, references panel uses parent's background
const codeBlockStyles = generateStyles(config, ".cm-highlighted", true);
const refPreviewStyles = generateStyles(config, ".ref-preview", false);
const allStyles = `${codeBlockStyles}\n${refPreviewStyles}`;
function syncFallbackStyleElements(css) {
for (const style of fallbackStyleElements) {
// `isConnected` is false while a custom-tab host is still detached.
// Keep updating those nodes; only drop styles that have been removed.
if (!style.parentNode) {
fallbackStyleElements.delete(style);
continue;
}
style.textContent = css;
}
}

if (!styleElement) {
function injectDocumentStyleElement(css) {
if (typeof document === "undefined") return null;
if (!styleElement || !styleElement.isConnected) {
styleElement = document.createElement("style");
styleElement.id = "cm-static-highlight-styles";
document.head.appendChild(styleElement);
styleElement.id = STYLE_ID;
(document.head || document.documentElement).appendChild(styleElement);
}
styleElement.textContent = css;
return styleElement;
}

/**
* Rebuilds the shared highlight stylesheet from the current editor theme.
* Constructed-sheet adopters update automatically via `replaceSync`.
*/
function syncHighlightStyles() {
const css = getHighlightStyles();
currentThemeId = currentEditorThemeId();
ensureConstructedSheet(css);
injectDocumentStyleElement(css);
syncFallbackStyleElements(css);
return css;
}

function resolveStyleRoot(root) {
if (!root || root === document) return document;
if (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot) {
return root;
}
if (root.shadowRoot) return root.shadowRoot;
return root;
}

function adoptSheet(root, sheet) {
if (!sheet || !root || !("adoptedStyleSheets" in root)) return false;
try {
const sheets = Array.from(root.adoptedStyleSheets || []);
if (sheets.includes(sheet)) return true;
root.adoptedStyleSheets = [...sheets, sheet];
return true;
} catch (e) {
console.warn("Failed to adopt highlight stylesheet", e);
return false;
}
}

function injectFallbackStyle(root, css) {
const owner =
root === document ? document.head || document.documentElement : root;
if (!owner || typeof owner.appendChild !== "function") return null;

let style = null;
if (typeof owner.querySelector === "function") {
style = owner.querySelector(`#${STYLE_ID}`);
}

if (!style) {
style = document.createElement("style");
style.id = STYLE_ID;
owner.appendChild(style);
}

style.textContent = css;
fallbackStyleElements.add(style);
return style;
}

/**
* Shared constructed stylesheet used for document + shadow roots.
* @returns {CSSStyleSheet|null}
*/
export function getHighlightStyleSheet() {
syncHighlightStyles();
return constructedSheet;
}

/**
* Applies current-theme highlight CSS to a document or shadow root.
* Custom editor tabs opt in with `highlightStyles: true`. Call this
* for other shadow roots (dialogs, custom elements) that insert
* highlighted HTML.
*
* Prefers `adoptedStyleSheets` so theme changes update in place.
*
* @param {Document|ShadowRoot|ParentNode|null} [root=document]
* @returns {CSSStyleSheet|HTMLStyleElement|null}
*/
export function applyHighlightStyles(root = document) {
const css = syncHighlightStyles();
const target = resolveStyleRoot(root);

if (constructedSheet && adoptSheet(target, constructedSheet)) {
return constructedSheet;
}

styleElement.textContent = allStyles;
currentThemeId = themeId;
if (target === document) {
return styleElement;
}

return injectFallbackStyle(target, css);
}

/**
* Injects dynamic CSS for syntax highlighting based on current editor theme
*/
function injectStyles() {
applyHighlightStyles(document);
}

/**
Expand Down Expand Up @@ -193,7 +335,7 @@ async function getParserForLanguage(langName) {
export async function highlightLine(text, uri, symbolName = null) {
if (!text || !text.trim()) return "";

const themeId = settings?.value?.editorTheme || "one_dark";
const themeId = currentEditorThemeId();
const cacheKey = `line:${themeId}:${uri}:${text}:${symbolName || ""}`;

if (highlightCache.has(cacheKey)) {
Expand Down Expand Up @@ -250,7 +392,7 @@ export async function highlightLine(text, uri, symbolName = null) {
export async function highlightCodeBlock(code, language) {
if (!code) return "";

const themeId = settings?.value?.editorTheme || "one_dark";
const themeId = currentEditorThemeId();
const langKey = (language || "text").toLowerCase();

const cacheKey = `block:${themeId}:${langKey}:${code}`;
Expand Down Expand Up @@ -305,8 +447,11 @@ export function clearHighlightCache() {
export function initHighlighting() {
injectStyles();

if (initialized) return;
initialized = true;

settings.on("update:editorTheme:after", () => {
const newThemeId = settings?.value?.editorTheme || "one_dark";
const newThemeId = currentEditorThemeId();
if (newThemeId !== currentThemeId) {
injectStyles();
highlightCache.clear();
Expand All @@ -320,4 +465,9 @@ export default {
highlightCodeBlock,
clearHighlightCache,
initHighlighting,
applyHighlightStyles,
getHighlightStyles,
getHighlightStyleSheet,
HIGHLIGHT_CLASS,
REF_PREVIEW_CLASS,
};
Loading