@bhsd/codemirror-mediawiki
Version:
Modified CodeMirror mode based on wikimedia/mediawiki-extensions-CodeMirror
478 lines (477 loc) • 17.5 kB
JavaScript
import { showTooltip, keymap, GutterMarker, gutter, ViewPlugin, EditorView } from '@codemirror/view';
import { StateField, RangeSetBuilder, RangeSet } from '@codemirror/state';
import { syntaxTree, ensureSyntaxTree, foldEffect, unfoldEffect, foldedRanges, unfoldAll, codeFolding, foldGutter, foldKeymap, foldState, language, } from '@codemirror/language';
import { getRegex } from '@bhsd/common';
import { tokens } from './config';
import { matchTag, getTag } from './matchTag';
const getExtRegex = /* @__PURE__ */ getRegex(tag => new RegExp(`mw-tag-${tag}(?![a-z])`, 'u'));
const updateSelection = (pos, { to }) => Math.max(pos, to), updateAll = (pos, { from, to }) => from <= pos && to > pos ? to : pos;
/**
* Check if a SyntaxNode is among the specified components
* @param keys The keys of the tokens to check
*/
const isComponent = (keys) => (node) => keys.some(key => node?.name.includes(tokens[key])),
/** Check if a SyntaxNode is a template bracket (`{{` or `}}`) */
isTemplateBracket = /* @__PURE__ */ isComponent(['templateBracket', 'parserFunctionBracket']),
/** Check if a SyntaxNode is a template name */
isTemplateName = /* @__PURE__ */ isComponent(['templateName', 'parserFunctionName']),
/** Check if a SyntaxNode is a template delimiter (`|` or `:`) */
isDelimiter = /* @__PURE__ */ isComponent(['templateDelimiter', 'parserFunctionDelimiter']),
/**
* Check if a SyntaxNode is a template delimiter (`|` or `:`), excluding `subst:` and `safesubst:`
* @param node SyntaxNode
*/
isTemplateDelimiter = (node) => isDelimiter(node) && !isTemplateName(node.nextSibling),
/**
* Check if a SyntaxNode is part of a template, except for the brackets
* @param node 语法树节点
*/
isTemplate = (node) => /-(?:template|ext)[a-z\d-]+ground/u.test(node.name) && !isTemplateBracket(node),
/** Check if a SyntaxNode is an extension tag bracket (`<` or `>`) */
isExtBracket = /* @__PURE__ */ isComponent(['extTagBracket']),
/**
* Check if a SyntaxNode is part of a extension tag
* @param node 语法树节点
* @param refOnly 是否仅检查`<ref>`标签
*/
isExt = (node, refOnly) => node.name.includes(`mw-tag-${refOnly ? 'ref' : ''}`);
/**
* Update the stack of opening (+) or closing (-) brackets
* @param state
* @param node 语法树节点
*/
export const braceStackUpdate = (state, node) => {
const brackets = state.sliceDoc(node.from, node.to);
return [brackets.split('{{').length - 1, 1 - brackets.split('}}').length];
};
const refNames = new Set(['ref', 'references']);
/**
* 寻找可折叠的范围
* @param state
* @param posOrNode 字符位置或语法树节点
* @param tree 语法树
* @param refOnly 是否仅检查`<ref>`标签
*/
export const foldable = (state, posOrNode, tree, refOnly = false) => {
if (typeof posOrNode === 'number') {
tree = ensureSyntaxTree(state, posOrNode); // eslint-disable-line no-param-reassign
}
if (!tree) {
return false;
}
let node;
if (typeof posOrNode === 'number') {
// Find the initial template node on both sides of the position
const left = tree.resolve(posOrNode, -1);
if (!refOnly && isTemplate(left)) {
node = left;
}
else {
const right = tree.resolve(posOrNode, 1);
node = isExt(left, refOnly)
&& left.name.split('mw-tag-').length > right.name.split('mw-tag-').length
? left
: right;
}
}
else {
node = posOrNode;
}
if (refOnly || !isTemplate(node)) {
// Not a template
if (isExt(node, refOnly)) {
const { name } = node, [tag] = /^[a-z]+/u.exec(name.slice(name.lastIndexOf('mw-tag-') + 7)), regex = getExtRegex(tag);
let { nextSibling } = node;
while (nextSibling && !(isExtBracket(nextSibling) && !regex.test(nextSibling.name))) {
({ nextSibling } = nextSibling);
}
const next = nextSibling?.nextSibling;
// The closing bracket of the current extension tag
if (nextSibling && (!refOnly || next && refNames.has(getTag(state, next)?.name))) {
return { from: matchTag(state, nextSibling.to).end.to, to: nextSibling.from };
}
}
return false;
}
let { prevSibling, nextSibling } = node,
/** The stack of opening (+) or closing (-) brackets */ stack = 1,
/** The first delimiter */ delimiter = isTemplateDelimiter(node) ? node : null,
/** The start of the closing bracket */ to = 0;
while (nextSibling) {
if (isTemplateBracket(nextSibling)) {
const [lbrace, rbrace] = braceStackUpdate(state, nextSibling);
stack += rbrace;
if (stack <= 0) {
// The closing bracket of the current template
to = nextSibling.from
+ state.sliceDoc(nextSibling.from, nextSibling.to)
.split('}}').slice(0, stack - 1).join('}}').length;
break;
}
stack += lbrace;
}
else if (!delimiter && stack === 1 && isTemplateDelimiter(nextSibling)) {
// The first delimiter of the current template so far
delimiter = nextSibling;
}
({ nextSibling } = nextSibling);
}
if (!nextSibling) {
// The closing bracket of the current template is missing
return false;
}
stack = -1;
while (prevSibling) {
if (isTemplateBracket(prevSibling)) {
const [lbrace, rbrace] = braceStackUpdate(state, prevSibling);
stack += lbrace;
if (stack >= 0) {
// The opening bracket of the current template
break;
}
stack += rbrace;
}
else if (stack === -1 && isTemplateDelimiter(prevSibling)) {
// The first delimiter of the current template so far
delimiter = prevSibling;
}
({ prevSibling } = prevSibling);
}
const /** The end of the first delimiter */ from = delimiter?.to;
return from && from < to ? { from, to } : false;
};
/**
* 创建折叠提示
* @param state
*/
const create = (state) => {
const { selection: { main: { head } } } = state, range = foldable(state, head);
if (range) {
const { from, to } = range;
let folded = false;
foldedRanges(state).between(from, to, (i, j) => {
if (i === from && j === to) {
folded = true;
}
});
return folded // eslint-disable-line @typescript-eslint/no-unnecessary-condition
? null
: {
pos: head,
above: true,
create() {
const dom = document.createElement('div');
dom.className = 'cm-tooltip-fold';
dom.textContent = '\uff0d';
dom.title = state.phrase('Fold template or extension tag');
dom.dataset['from'] = String(from);
dom.dataset['to'] = String(to);
return { dom };
},
};
}
return null;
};
/**
* 执行折叠
* @param view
* @param effects 折叠
* @param anchor 光标位置
*/
const execute = (view, effects, anchor) => {
if (effects.length > 0) {
view.dom.querySelector('.cm-tooltip-fold')?.remove();
// Fold the template(s) and update the cursor position
view.dispatch({ effects, selection: { anchor } });
return true;
}
return false;
};
/**
* The rightmost position of all selections, to be updated with folding
* @param state
*/
const getAnchor = (state) => Math.max(...state.selection.ranges.map(({ to }) => to));
/**
* 折叠所有模板
* @param state
* @param tree 语法树
* @param effects 折叠
* @param node 语法树节点
* @param end 终止位置
* @param anchor 光标位置
* @param update 更新光标位置
* @param refOnly 是否仅检查`<ref>`标签
*/
const traverse = (state, tree, effects, node, end, anchor, update, refOnly) => {
while (node && node.from <= end) {
/* eslint-disable no-param-reassign */
const range = foldable(state, node, tree, refOnly);
if (range) {
effects.push(foldEffect.of(range));
node = tree.resolve(range.to, 1);
// Update the anchor with the end of the last folded range
anchor = update(anchor, range);
continue;
}
node = node.nextSibling;
/* eslint-enable no-param-reassign */
}
return anchor;
};
class FoldMarker extends GutterMarker {
constructor(open) {
super();
this.open = open;
}
eq(other) {
return this.open === other.open;
}
toDOM({ state }) {
const span = document.createElement('span');
span.textContent = this.open ? '⌄' : '›';
span.title = state.phrase(this.open ? 'Fold line' : 'Unfold line');
return span;
}
}
const canFold = /* @__PURE__ */ new FoldMarker(true), canUnfold = /* @__PURE__ */ new FoldMarker(false);
const findFold = ({ state }, line) => {
let found;
state.field(foldState, false)?.between(line.from, line.to, (from, to) => {
if (!found && to === line.to) {
found = { from, to };
}
});
return found;
};
export const foldableLine = ({ state, viewport: { to: end }, viewportLineBlocks }, { from: f, to: t }) => {
const tree = syntaxTree(state);
/**
* 获取标题层级
* @param pos 行首位置
*/
const getLevel = (pos) => {
const { name } = tree.resolve(pos, 1);
return name.includes(tokens.sectionHeader) ? Number(/mw-section--(\d)/u.exec(name)[1]) : 7;
},
/**
* 获取表格语法
* @param from 行首位置
* @param to 行尾位置
*/
getTable = (from, to) => {
const line = state.sliceDoc(from, to), bracket = /^\s*(?:(?::+\s*)?\{\||\|\})/u.exec(line)?.[0];
if (bracket) {
const { name } = tree.resolve(from + bracket.length, -1);
if (name.includes(tokens.tableBracket)) {
return bracket.endsWith('|}') ? -1 : 1;
}
}
return 0;
};
const level = getLevel(f);
if (level < 7) {
for (const { from } of viewportLineBlocks) {
if (from > f && getLevel(from) <= level) {
return t < from - 1 && { from: t, to: from - 1 };
}
}
return end === state.doc.length && end > t && { from: t, to: end };
}
else if (getTable(f, t) === 1) {
for (const { from, to } of viewportLineBlocks) {
if (from > f) {
const bracket = getTable(from, to);
if (bracket === -1) {
return t < from - 1 && { from: t, to: from - 1 };
}
else if (bracket === 1 || getLevel(from) < 7) {
break;
}
}
}
}
return false;
};
const buildMarkers = (view) => {
const builder = new RangeSetBuilder();
for (const line of view.viewportLineBlocks) {
let mark;
if (findFold(view, line)) {
mark = canUnfold;
}
else if (foldableLine(view, line)) {
mark = canFold;
}
if (mark) {
builder.add(line.from, line.from, mark);
}
}
return builder.finish();
};
const markers = /* @__PURE__ */ ViewPlugin.fromClass(class {
constructor(view) {
this.markers = buildMarkers(view);
}
update({ docChanged, viewportChanged, startState, state, view }) {
if (docChanged
|| viewportChanged
|| startState.facet(language) !== state.facet(language)
|| startState.field(foldState, false) !== state.field(foldState, false)
|| syntaxTree(startState) !== syntaxTree(state)) {
this.markers = buildMarkers(view);
}
}
});
const defaultFoldExtension = /* @__PURE__ */ (() => [foldGutter(), keymap.of(foldKeymap)])();
/**
* 生成折叠命令
* @param refOnly 是否仅检查`<ref>`标签
*/
const foldCommand = (refOnly) => view => {
const { state } = view, tree = syntaxTree(state), effects = [], anchor = traverse(state, tree, effects, tree.topNode.firstChild, Infinity, getAnchor(state), updateAll, refOnly);
return execute(view, effects, anchor);
};
export const foldRef = /* @__PURE__ */ foldCommand(true);
export default ((e = defaultFoldExtension) => e);
const selector = '.cm-tooltip-fold';
export const mediaWikiFold = /* @__PURE__ */ (() => [
codeFolding({
placeholderDOM(view) {
const element = document.createElement('span');
element.textContent = '…';
element.setAttribute('aria-label', 'folded code');
element.title = view.state.phrase('unfold');
element.className = 'cm-foldPlaceholder';
element.addEventListener('click', ({ target }) => {
const pos = view.posAtDOM(target), { state } = view, { selection } = state;
foldedRanges(state).between(pos, pos, (from, to) => {
if (from === pos) {
// Unfold the template and redraw the selections
view.dispatch({ effects: unfoldEffect.of({ from, to }), selection });
}
});
});
return element;
},
}),
/** @see https://codemirror.net/examples/tooltip/ */
StateField.define({
create,
update(tooltip, { state, docChanged, selection }) {
if (docChanged) {
return null;
}
return selection ? create(state) : tooltip;
},
provide(f) {
return showTooltip.from(f);
},
}),
keymap.of([
{
// Fold the template at the selection/cursor
key: 'Ctrl-Shift-[',
mac: 'Cmd-Alt-[',
run(view) {
const { state } = view, tree = syntaxTree(state), effects = [];
let anchor = getAnchor(state);
for (const { from, to, empty } of state.selection.ranges) {
let node;
if (empty) {
// No selection, try both sides of the cursor position
node = tree.resolve(from, -1);
}
if (!node || node.name === 'Document') {
node = tree.resolve(from, 1);
}
anchor = traverse(state, tree, effects, node, to, anchor, updateSelection);
}
return execute(view, effects, anchor);
},
},
{
// Fold all templates in the document
key: 'Ctrl-Alt-[',
run: foldCommand(),
},
{
// Fold all `<ref>` tags in the document
key: 'Mod-Alt-,',
run: foldRef,
},
{
// Unfold the template at the selection/cursor
key: 'Ctrl-Shift-]',
mac: 'Cmd-Alt-]',
run(view) {
const { state } = view, { selection } = state, effects = [], folded = foldedRanges(state);
for (const { from, to } of selection.ranges) {
// Unfold any folded range at the selection
folded.between(from, to, (i, j) => {
effects.push(unfoldEffect.of({ from: i, to: j }));
});
}
if (effects.length > 0) {
// Unfold the template(s) and redraw the selections
view.dispatch({ effects, selection });
return true;
}
return false;
},
},
{ key: 'Ctrl-Alt-]', run: unfoldAll },
]),
markers,
gutter({
class: 'cm-foldGutter',
markers(view) {
return view.plugin(markers)?.markers ?? RangeSet.empty;
},
initialSpacer() {
return new FoldMarker(false);
},
domEventHandlers: {
click(view, line) {
const folded = findFold(view, line);
if (folded) {
view.dispatch({ effects: unfoldEffect.of(folded) });
return true;
}
const range = foldableLine(view, line);
if (range) {
view.dispatch({ effects: foldEffect.of(range) });
return true;
}
return false;
},
},
}),
EditorView.theme({
[selector]: {
cursor: 'pointer',
lineHeight: 1.2,
padding: '0 1px',
opacity: 0.6,
},
[`${selector}:hover`]: {
opacity: 1,
},
}),
])();
/**
* 点击提示折叠模板参数
* @param view
*/
export const foldHandler = (view) => (e) => {
const dom = e.target.closest('.cm-tooltip-fold');
if (dom) {
e.preventDefault();
const { dataset } = dom, from = Number(dataset['from']), to = Number(dataset['to']);
view.dispatch({
effects: foldEffect.of({ from, to }),
selection: { anchor: to },
});
dom.remove();
}
};