UNPKG

codemirror-textmate

Version:
243 lines (215 loc) 9.11 kB
import * as CodeMirror from 'codemirror' import { INITIAL, IRawTheme, StackElement } from 'monaco-textmate' import * as PCancelable from 'p-cancelable' import { Highlighter } from './Highlighter' export interface ITextmateThemePlus extends IRawTheme { gutterSettings?: { background?: string divider?: string foreground?: string lineActiveBackground?: string lineActiveForeground?: string } } export const addGrammar = Highlighter.addGrammar export const activateLanguage = Highlighter.activateLanguage /** * Inject grammars into grammars * Returns an array of language ID's that were udpated * * @param scopeName Scope name that needs to be injected into other grammars * @param injectInto List of host scope names */ export async function linkInjections(scopeName: string, injectInto: string[]) { const affectedLanguages = Highlighter.linkInjections(scopeName, injectInto) await updateCmTmBindings(null, affectedLanguages) return affectedLanguages } /** * Uninject grammars out of grammars * Returns an array of language ID's that were udpated * * @param scopeName Scope name that needs to be uninjected out of other grammars * @param unInjectFrom If provided, scope name will be uninjected only from this list of host scope names, otherwise will be uninjected from all */ export async function unlinkInjections(scopeName: string, unInjectFrom: string[]) { const affectedLanguages = Highlighter.unlinkInjections(scopeName, unInjectFrom) await updateCmTmBindings(null, affectedLanguages) return affectedLanguages } export const themedHighlighters: Map<string, Highlighter> = new Map() themedHighlighters.set('default', new Highlighter()) /** * Add a Textmate theme to CodeMirror * * @param theme Theme object */ export function addTheme(theme: ITextmateThemePlus): void { // TODO: add regex check to theme.name to make sure it's valid CSS classname too if (typeof theme.name !== 'string') { throw new Error(`RawTheme must have 'name' property for referencing purposes`) } themedHighlighters.set(theme.name, new Highlighter(theme)) } const updateCmTmBindings = (() => { // local "static" variables const cmModeToTheme: Map<string /* languageID */, string /* theme */> = new Map() const cmThemeRecord: WeakMap<CodeMirror.Editor, string /* theme */> = new WeakMap() const tmThemeStyleNodes: Map<string, { styleNode: HTMLStyleElement; inUseBy: WeakSet<CodeMirror.Editor>; inUseByCount: number }> = new Map() /** * wrapper around CodeMirror.defineMode * If CodeMirror.defineMode is directly called in the primary function below, it causes memory leak by not letting go of cm variable (forms a closure?) */ const defineMode = (languageId: string, tokenizer: any) => { CodeMirror.defineMode<{ ruleStack: StackElement }>(languageId, () => { return { copyState: (state) => ({ ruleStack: state.ruleStack.clone() }), startState: () => ({ ruleStack: INITIAL }), token: tokenizer, } }) } return (cm: CodeMirror.Editor, invalidateLanguages?: string[]) => new PCancelable<boolean>(async (resolve, reject, onCancel) => { (onCancel as any).shouldReject = false let canceled = false onCancel(() => canceled = true) if (!cm) { if (Array.isArray(invalidateLanguages)) { await Promise.all(invalidateLanguages.map(async (lang) => { // invalidate previously defined CM mode if (cmModeToTheme.delete(lang)) { // preload update await Highlighter.loadLanguage(lang) } })) } return resolve(false) } const languageId = cm.getOption('mode') const themeName = cm.getOption('theme') // get theme name that was bound last time this mode was baked const languageBoundTheme = cmModeToTheme.get(languageId) const prevThemeName = cmThemeRecord.get(cm) || 'default' const highlighter = themedHighlighters.get(themeName) || themedHighlighters.get('default') const isTextMateTheme = themeName !== 'default' && themedHighlighters.has(themeName) cmThemeRecord.set(cm, themeName) if (Highlighter.hasLanguageRegistered(languageId)) { cmModeToTheme.set(languageId, themeName) } // Cleanup previous theme resources (if any) if (typeof prevThemeName === 'string' && prevThemeName !== 'default' && prevThemeName !== themeName && themedHighlighters.has(themeName) && tmThemeStyleNodes.has(prevThemeName)) { const meta = tmThemeStyleNodes.get(prevThemeName) if (meta.inUseBy.has(cm) && meta.inUseByCount === 1) { tmThemeStyleNodes.delete(prevThemeName) document.head.removeChild(meta.styleNode) } else { meta.inUseBy.delete(cm) meta.inUseByCount-- } } // Allocate new theme resources (if applicable) if (isTextMateTheme) { if (tmThemeStyleNodes.has(themeName)) { const meta = tmThemeStyleNodes.get(themeName) if (!meta.inUseBy.has(cm)) { meta.inUseBy.add(cm) meta.inUseByCount++ } } else { const styleNode = document.createElement('style') styleNode.textContent = highlighter.cssText tmThemeStyleNodes.set(themeName, { styleNode, inUseBy: new WeakSet().add(cm), inUseByCount: 1 }) document.head.appendChild(styleNode) } } // Nothing much "changed", hence nothing much is needs to be done if (typeof languageId === 'string' && typeof themeName === 'string' && typeof languageBoundTheme === 'string' && // new theme is same as theme that was baked with language previously languageBoundTheme === themeName) { return resolve(prevThemeName !== themeName) } // skip if language id cannot be resolved to tm grammar scope if (!Highlighter.hasLanguageRegistered(languageId)) { return resolve(false) } const tokenizer = await highlighter.getTokenizer(languageId) // user probably changed theme or mode in the meantime, this fn will be triggered again anyway if (canceled) { return resolve(false) } defineMode(languageId, tokenizer) resolve(true) }) })() /** * Wrapper around `udpateCmTmBindings` that prevents race conditions and obsolute changes * Will queue all the CM instances that need an update and will update them one by one (while merging duplicate instances) */ const safeUpdateCM = (() => { const queue: CodeMirror.Editor[] = [] const resolverCallbacks: WeakMap<CodeMirror.Editor, (success: boolean) => void> = new WeakMap() let currentActivation: PCancelable.PCancelable<boolean> const proceed = async () => { const nextCM = queue[0] if (!nextCM) { return } currentActivation = updateCmTmBindings(nextCM) const resolver = resolverCallbacks.get(nextCM) resolver(await currentActivation) resolverCallbacks.delete(nextCM) queue.shift() currentActivation = null proceed() } return async (cm: CodeMirror.Editor) => { // currently happening but now obsolete if (queue[0] === cm && currentActivation) { currentActivation.cancel() const prevResolver = resolverCallbacks.get(cm) resolverCallbacks.delete(cm) queue.shift() queue.push(cm) prevResolver(false) } // if hasn't been queued up yet then do it if (queue.indexOf(cm) === -1) { queue.push(cm) } const prom = new Promise<boolean>((res) => { resolverCallbacks.set(cm, res) }) // No work is being done === queue not proceeding => start the queue if (!currentActivation) { proceed() } return prom } })() CodeMirror.defineInitHook(async (cm: CodeMirror.Editor) => { let shouldIgnoreNextEvent = false let lastLanguageId = null async function updateInstance() { const langId = cm.getOption('mode') if (shouldIgnoreNextEvent && langId === lastLanguageId) { shouldIgnoreNextEvent = false return } if (await safeUpdateCM(cm)) { lastLanguageId = langId shouldIgnoreNextEvent = true cm.setOption('mode', langId) } } cm.on('swapDoc', updateInstance) cm.on('optionChange' as any, (inst, option: unknown) => { if (option === 'mode' || option === 'theme') { updateInstance() } }) updateInstance() })