0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-08-06 06:47:38 +00:00

Merge branch 'master' into Better-Dropdowns

This commit is contained in:
Gazook89
2026-05-27 22:22:34 -05:00
34 changed files with 1304 additions and 1113 deletions
+51 -78
View File
@@ -1,4 +1,4 @@
/* eslint max-lines: ["error", { "max": 400 }] */
/* eslint max-lines: ["error", { "max": 405 }] */
import './codeEditor.less';
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
@@ -10,21 +10,26 @@ import {
highlightActiveLine,
scrollPastEnd,
Decoration,
ViewPlugin,
drawSelection,
dropCursor,
rectangularSelection,
crosshairCursor,
} from '@codemirror/view';
import { EditorState, Compartment, StateEffect, StateField } from '@codemirror/state';
import { foldAll as foldAllCmd, unfoldAll as unfoldAllCmd, foldGutter, foldKeymap, syntaxHighlighting } from '@codemirror/language';
import { foldEffect } from '@codemirror/language';
import {
unfoldAll as unfoldAllCmd,
foldGutter,
foldKeymap,
foldEffect,
foldState,
syntaxHighlighting,
} from '@codemirror/language';
import { defaultKeymap, history, undo, redo, undoDepth, redoDepth } from '@codemirror/commands';
import { languages } from '@codemirror/language-data';
import { css } from '@codemirror/lang-css';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { html } from '@codemirror/lang-html';
import { autocompleteEmoji } from './autocompleteEmoji.js';
import { autocompleteEmoji } from './extensions/autocompleteEmoji.js';
import { searchKeymap, search } from '@codemirror/search';
import { closeBrackets } from '@codemirror/autocomplete';
@@ -38,71 +43,13 @@ const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const themeCompartment = new Compartment();
const highlightCompartment = new Compartment();
console.log(themes);
import { generalKeymap, markdownKeymap } from './customKeyMaps.js';
import foldOnPages from './customFolding.js';
import { customHighlightStyle, tokenizeCustomMarkdown, tokenizeCustomCSS } from './customHighlight.js';
import { legacyCustomHighlightStyle, legacyTokenizeCustomMarkdown } from './legacyCustomHighlight.js';
import { generalKeymap, markdownKeymap } from './extensions/customKeyMaps.js';
import foldOnPages from './extensions/customFolding.js';
import { customHighlightPlugin, customHighlightStyle } from './extensions/customHighlight.js';
import { legacyCustomHighlightStyle } from './extensions/legacyCustomHighlight.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const createHighlightPlugin = (renderer, tab)=>{
//this function takes the custom tokens created in the tokenize function in customhighlight files
//takes the tokens defined by that function and assigns classes to them
//it also creates page number and snippet number widgets
let tokenize;
if(tab === 'brewStyles') {
tokenize = tokenizeCustomCSS;
} else {
tokenize = renderer === 'V3' ? tokenizeCustomMarkdown : legacyTokenizeCustomMarkdown;
}
return ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
if(update.docChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const decos = [];
const tokens = tokenize(view.state.doc.toString());
let pageCount = 1;
let snippetCount = 0;
tokens.forEach((tok)=>{
const line = view.state.doc.line(tok.line + 1);
if(tok.from != null && tok.to != null && tok.from < tok.to) {
decos.push(Decoration.mark({ class: `cm-${tok.type}` }).range(line.from + tok.from, line.from + tok.to));
} else {
decos.push(Decoration.line({ class: `cm-${tok.type}` }).range(line.from));
if(tok.type === 'pageLine' && tab === 'brewText') {
pageCount++;
line.from === 0 && pageCount--;
decos.push(Decoration.line({ attributes: { 'data-page-number': pageCount } }).range(line.from));
}
if(tok.type === 'snippetLine' && tab === 'brewSnippets') {
snippetCount++;
decos.push(Decoration.line({ attributes: { 'data-page-number': snippetCount } }).range(line.from));
}
}
});
decos.sort((a, b)=>a.from - b.from || a.to - b.to);
return Decoration.set(decos);
}
},
{ decorations: (v)=>v.decorations }
);
};
const setProgrammaticCursorLine = StateEffect.define();
const programmaticCursorLineField = StateField.define({
@@ -151,11 +98,14 @@ const CodeEditor = forwardRef(
const editorRef = useRef(null);
const viewRef = useRef(null);
const docsRef = useRef({});
const tabRef = useRef(tab);
const prevTabRef = useRef(tab);
const scrollRef = useRef({});
const foldsRef = useRef({});
const pageMap = useRef([]);
const recomputePages = (doc)=>{
if(tab !== 'brewText') return;
const pages = [0];
const text = doc.toString();
let offset = 0;
@@ -181,6 +131,14 @@ const CodeEditor = forwardRef(
return page;
};
const getFoldRanges = (state)=>{
const folds = [];
state.field(foldState, false)?.between(0, state.doc.length, (from, to)=>{
folds.push({ from, to });
});
return folds;
};
const createExtensions = ({ onChange, language, editorTheme })=>{
const setEventListeners = EditorView.updateListener.of((update)=>{
if(update.docChanged) {
@@ -198,8 +156,6 @@ const CodeEditor = forwardRef(
? syntaxHighlighting(customHighlightStyle)
: syntaxHighlighting(legacyCustomHighlightStyle);
const customHighlightPlugin = createHighlightPlugin(renderer, tab);
const languageExtension = language === 'css' ? css() : [markdown({ base: markdownLanguage, codeLanguages: languages }), html({ autoCloseTags: true })];
const themeExtension = Array.isArray(themes[editorTheme]) ? themes[editorTheme] : themes[editorTheme] || themes['default'];
@@ -222,7 +178,7 @@ const CodeEditor = forwardRef(
}),
//highlights
highlightCompartment.of([customHighlightPlugin, highlightExtension]),
highlightCompartment.of([customHighlightPlugin(renderer, tab), highlightExtension]),
themeCompartment.of(themeExtension),
highlightActiveLine(),
highlightActiveLineGutter(),
@@ -267,11 +223,10 @@ const CodeEditor = forwardRef(
ticking = true;
requestAnimationFrame(()=>{
const top = view.scrollDOM.scrollTop;
scrollRef.current[tabRef.current] = top;
const block = view.lineBlockAtHeight(top);
const page = findPageFromPos(block.from); // CHANGED
const page = findPageFromPos(block.from);
onViewChange(page);
ticking = false;
});
};
@@ -286,12 +241,23 @@ const CodeEditor = forwardRef(
};
}, []);
const restoreFolds = (view, folds)=>{
if(!folds?.length) return;
view.dispatch({
effects : folds.map((f)=>foldEffect.of(f))
});
};
useEffect(()=>{
const view = viewRef.current;
if(!view) return;
tabRef.current = tab;
const prevTab = prevTabRef.current;
foldsRef.current[prevTab] = getFoldRanges(view.state);
if(prevTab !== tab) {
docsRef.current[prevTab] = view.state;
@@ -305,6 +271,16 @@ const CodeEditor = forwardRef(
}
view.setState(nextState);
restoreFolds(view, foldsRef.current[tab]);
const savedScroll = scrollRef.current[tab];
if(savedScroll != null) {
requestAnimationFrame(()=>{
view.scrollDOM.scrollTop = savedScroll;
});
}
prevTabRef.current = tab;
}
view.focus();
@@ -343,10 +319,8 @@ const CodeEditor = forwardRef(
? syntaxHighlighting(customHighlightStyle)
: syntaxHighlighting(legacyCustomHighlightStyle);
const customHighlightPlugin = createHighlightPlugin(renderer, tab);
view.dispatch({
effects : highlightCompartment.reconfigure([customHighlightPlugin, highlightExtension]),
effects : highlightCompartment.reconfigure([customHighlightPlugin(renderer, tab), highlightExtension]),
});
}, [renderer, tab]);
@@ -355,7 +329,6 @@ const CodeEditor = forwardRef(
injectText : (text)=>{
const view = viewRef.current;
view.dispatch(
view.state.replaceSelection(text)
);
+33 -1
View File
@@ -63,7 +63,8 @@
&.term { color : rgb(96, 117, 143); }
&.definition { color : rgb(97, 57, 178); }
}
.cm-block:not(.cm-comment) {
.cm-block:not(.cm-comment),
.cm-block:not(.cm-comment) * {
font-weight : bold;
color : purple;
}
@@ -100,6 +101,10 @@
vertical-align : sub;
color : rgb(123, 123, 15);
}
.cm-strikethrough {
text-decoration: line-through;
}
.cm-definitionList {
.cm-definitionTerm { color : rgb(96, 117, 143); }
.cm-definitionColon:not(:has(.cm-comment)) {
@@ -157,6 +162,33 @@
outline : 1px inset #00000055 !important;
}
.cm-image {
position:relative;
.cm-preview {
object-fit: contain;
position:absolute;
bottom:0;
left:0;
height:200px;
width:200px;
height:200px;
padding:5px;
background-color: #fff;
border-radius:10px;
border:3px solid grey;
pointer-events: none;
opacity:0;
transition:0.2s opacity 0.5s;
translate:0 100%;
z-index:1000;
}
&:hover .cm-preview {
opacity:1;
}
}
/* Tab character visualization (optional) */
//.cm-tab {
// background: url(...) no-repeat right;
@@ -1,5 +1,15 @@
/* eslint max-lines: ["error", { "max": 500 }] */
import { HighlightStyle } from '@codemirror/language';
import { tags } from '@lezer/highlight';
import { legacyTokenizeCustomMarkdown } from './legacyCustomHighlight';
import {
Decoration,
ViewPlugin,
} from '@codemirror/view';
import {
syntaxTree,
ensureSyntaxTree
} from '@codemirror/language';
// Making the tokens
const customTags = {
@@ -16,13 +26,14 @@ const customTags = {
definitionTerm : 'definitionTerm', // .cm-definitionTerm
definitionDesc : 'definitionDesc', // .cm-definitionDesc
definitionColon : 'definitionColon', // .cm-definitionColon
strikethrough : 'strikethrough', // .cm-strikethrough
//CSS
variable : 'variable',
};
export function tokenizeCustomMarkdown(text) {
function tokenizeCustomMarkdown(text) {
const tokens = [];
const lines = text.split('\n');
@@ -81,6 +92,23 @@ export function tokenizeCustomMarkdown(text) {
}
}
// --- Strikethrough ---
if(/\~/.test(lineText)) {
const strikethroughRegex = /~(?!\s)(.+?)(?<!\s)~/g;
const match = strikethroughRegex.exec(lineText);
const type = customTags.strikethrough;
if(match) {
tokens.push({
line : lineNumber,
type,
from : match.index,
to : match.index + match[0].length,
});
}
}
// --- single line def list ---
const singleLineRegex = /^(?=.*[^:])(.+?)(\s*)(::)([^\n]*)$/dmy;
const match = singleLineRegex.exec(lineText);
@@ -131,20 +159,18 @@ export function tokenizeCustomMarkdown(text) {
if(!/^::/.test(lines[lineNumber]) && lineNumber + 1 < lines.length && /^::/.test(lines[lineNumber + 1])) {
const startLine = lineNumber;
const defs = [];
let endLine = startLine;
// collect all following :: definitions
for (let i = lineNumber + 1; i < lines.length; i++) {
const nextLine = lines[i];
const onlyColonsMatch = /^:*$/.test(nextLine);
const defMatch = /^(::)(.*\S.*)?\s*$/.exec(nextLine);
const defMatch = /^(::)(.+)$/.exec(nextLine);
if(!onlyColonsMatch && defMatch) {
defs.push({ colons: defMatch[1], desc: defMatch[2], line: i });
endLine = i;
} else break;
}
if(defs.length > 0) {
if(defs.length > 0 && lineText.trim().length > 0) {
tokens.push({
line : startLine,
type : customTags.definitionList,
@@ -175,20 +201,21 @@ export function tokenizeCustomMarkdown(text) {
line : d.line,
type : customTags.definitionDesc,
from : d.colons.length,
to : d.colons.length + d.desc.length,
to : d.colons.length + d.desc?.length,
});
});
}
}
if(lineText.includes('{') && lineText.includes('}')) {
const injectionRegex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gm;
const injectionRegex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gmd;
let match;
while ((match = injectionRegex.exec(lineText)) !== null) {
tokens.push({
line : lineNumber,
from : match.index,
to : match.index + match[1].length,
from : match.indices[1][0],
to : match.indices[1][1],
type : customTags.injection,
});
}
@@ -223,14 +250,21 @@ export function tokenizeCustomMarkdown(text) {
/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/,
);
if(match) endCh = match.index + match[0].length;
tokens.push({ line: lineNumber, type: customTags.block });
const closingMatch = lineText.match(/ *(}})/d);
if(closingMatch) {
tokens.push({ line: lineNumber, from: closingMatch.indices[1][0], to: closingMatch.indices[1][1], type: customTags.block });
} else {
tokens.push({ line: lineNumber, type: customTags.block });
}
}
});
return tokens;
}
export function tokenizeCustomCSS(text) {
function tokenizeCustomCSS(text) {
const tokens = [];
const lines = text.split('\n');
@@ -289,5 +323,150 @@ export const customHighlightStyle = HighlightStyle.define([
]);
function getUrl(node, doc) {
let url = null;
const cursor = node.node.cursor();
if(cursor.firstChild()) {
do {
if(cursor.name === 'URL') {
url = doc.sliceString(cursor.from, cursor.to);
break;
}
} while (cursor.nextSibling());
}
return url;
}
import { WidgetType } from '@codemirror/view';
class ImageWidget extends WidgetType {
constructor(url) {
super();
this.url = url;
}
toDOM() {
const img = document.createElement('img');
img.loading = "lazy";
img.className = 'cm-preview';
img.src = this.url;
img.onerror = ()=>{
img.src = 'client/icons/broken-image.jpg';
};
return img;
}
eq(other) {
return other.url === this.url;
}
}
export function customHighlightPlugin(renderer, tab) {
//this function takes the custom tokens created in the tokenize function in customhighlight files
//takes the tokens defined by that function and assigns classes to them
//it also creates page number and snippet number widgets
let tokenize;
if(tab === 'brewStyles') {
tokenize = tokenizeCustomCSS;
} else {
tokenize = renderer === 'V3' ? tokenizeCustomMarkdown : legacyTokenizeCustomMarkdown;
}
return ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
if(update.docChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const decos = [];
const tokens = tokenize(view.state.doc.toString());
let pageCount = 1;
let snippetCount = 0;
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 50) || syntaxTree(view.state);
tree.iterate({
enter : (node)=>{
if(node.name === 'Image') {
const url = getUrl(node, view.state.doc);
const widgetPosition = node.node.lastChild.from;
//this is not exactly standard, but should hold,
//and is the shortest way i could find of positioning
//the image inside the cm-image node
if(!url) return;
decos.push(
Decoration.mark({
class : 'cm-image'
}).range(node.from, node.to)
);
decos.push(
Decoration.widget({
widget : new ImageWidget(url),
side : 1
}).range(widgetPosition)
);
}
}
});
tokens.forEach((token)=>{
const line = view.state.doc.line(token.line + 1);
if(token.from != null && token.to != null && token.from < token.to) {
const from = line.from + token.from;
const to = line.from + token.to;
const attrs = {};
if(token.type === 'Image' && token.url) {
attrs['data-url'] = token.url;
}
decos.push(
Decoration.mark({
class : `cm-${token.type}`,
...(Object.keys(attrs).length
? { attributes: attrs }
: {})
}).range(from, to)
);
} else {
decos.push(
Decoration.line({
class : `cm-${token.type}`
}).range(line.from)
);
if(token.type === 'pageLine' && tab === 'brewText') {
pageCount++;
if(line.from === 0) pageCount--;
decos.push(Decoration.line({ attributes: { 'data-page-number': pageCount } }).range(line.from));
}
if(token.type === 'snippetLine' && tab === 'brewSnippets') {
snippetCount++;
decos.push(Decoration.line({ attributes: { 'data-page-number': snippetCount } }).range(line.from));
}
}
});
decos.sort((a, b)=>a.from - b.from || a.to - b.to);
return Decoration.set(decos);
}
},
{ decorations: (v)=>v.decorations }
);
};
@@ -3,6 +3,17 @@ import { keymap } from '@codemirror/view';
import { undo, redo, indentMore, deleteLine } from '@codemirror/commands';
import { Prec } from '@codemirror/state';
const insertTab = (view)=>{
const { from, to } = view.state.selection.main;
view.dispatch({
changes : { from, to, insert: ' ' },
selection : { anchor: from + 2 }
});
return true;
};
const indentLess = (view)=>{
const { from, to } = view.state.selection.main;
const lines = [];
@@ -17,147 +28,46 @@ const indentLess = (view)=>{
return true;
};
const makeBold = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const wrapSelection = (prefix, suffix)=>(view)=>{
const changes = [];
let text, cursor;
for (const range of view.state.selection.ranges) {
const { from, to } = range;
const selected = view.state.doc.sliceString(from, to);
if(from === to) {
text = '****';
cursor = from + 2;
} else if(selected.startsWith('**') && selected.endsWith('**')) {
text = selected.slice(2, -2);
cursor = from + text.length;
} else {
text = `**${selected}**`;
cursor = from + text.length;
let text;
if(from === to) { text = prefix + suffix; } else if(selected.startsWith(prefix) && selected.endsWith(suffix)) {
text = selected.slice(prefix.length, -suffix.length);
} else {text = `${prefix}${selected}${suffix}`;}
changes.push({ from, to, insert: text });
}
view.dispatch({
changes : { from, to, insert: text },
selection : { anchor: cursor },
changes
});
return true;
};
const makeItalic = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const makeNbsp = (view)=>{
const { from } = view.state.selection.main;
let text, cursor;
const prev2 = from >= 2
? view.state.doc.sliceString(from - 2, from)
: '';
if(from === to) {
text = '**';
cursor = from + 1;
} else if(selected.startsWith('*') && selected.endsWith('*')) {
text = selected.slice(2, -2);
cursor = from + text.length;
} else {
text = `*${selected}*`;
cursor = from + text.length;
}
const insert = (prev2 === ':>' || prev2 === '>>') ? '>' : ':>';
view.dispatch({
changes : { from, to, insert: text },
selection : { anchor: cursor },
changes : { from, to: from, insert },
selection : { anchor: from + insert.length },
});
return true;
};
const makeUnderline = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
let text, cursor;
if(from === to) {
text = '<u></u>';
cursor = from + 3;
} else if(selected.startsWith('<u>') && selected.endsWith('</u>')) {
text = selected.slice(3, -4);
cursor = from + text.length;
} else {
text = `<u>${selected}</u>`;
cursor = from + text.length;
}
view.dispatch({
changes : { from, to, insert: text },
selection : { anchor: cursor },
});
return true;
};
const makeSuper = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
let text, cursor;
if(from === to) {
text = '^^';
cursor = from + 1;
} else if(selected.startsWith('^') && selected.endsWith('^')) {
text = selected.slice(1, -1);
cursor = from + text.length;
} else {
text = `^${selected}^`;
cursor = from + text.length;
}
view.dispatch({
changes : { from, to, insert: text },
selection : { anchor: cursor },
});
return true;
};
const makeSub = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
let text, cursor;
if(from === to) {
text = '^^^^';
cursor = from + 2;
} else if(selected.startsWith('^^') && selected.endsWith('^^')) {
text = selected.slice(2, -2);
cursor = from + text.length;
} else {
text = `^^${selected}^^`;
cursor = from + text.length;
}
view.dispatch({
changes : { from, to, insert: text },
selection : { anchor: cursor },
});
return true;
};
const makeNbsp = (view) => {
const { from } = view.state.selection.main;
const prev2 = from >= 2
? view.state.doc.sliceString(from - 2, from)
: '';
const insert = (prev2 === ':>' || prev2 === '>>') ? '>' : ':>';
view.dispatch({
changes: { from, to: from, insert },
selection: { anchor: from + insert.length },
});
return true;
};
const makeSpace = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
@@ -259,7 +169,7 @@ const newPage = (view)=>{
};
export const generalKeymap = Prec.high(keymap.of([
{ key: 'Tab', run: indentMore },
{ key: 'Tab', run: insertTab },
{ key: 'Mod-z', run: undo }, //i think it may be unnecessary
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-y', run: redo },
@@ -268,27 +178,27 @@ export const generalKeymap = Prec.high(keymap.of([
export const markdownKeymap = Prec.highest(keymap.of([
//{ key: 'Shift-Tab', run: indentMore },
{ key: 'Shift-Tab', run: indentLess },
{ key: 'Mod-b', run: makeBold },
{ key: 'Mod-i', run: makeItalic },
{ key: 'Mod-u', run: makeUnderline },
{ key: 'Shift-Mod-=', run: makeSuper },
{ key: 'Mod-=', run: makeSub },
{ key: 'Mod-.', run: makeNbsp },
{ key: 'Shift-Mod-.', run: makeSpace },
{ key: 'Shift-Mod-,', run: removeSpace },
{ key: 'Mod-m', run: makeSpan },
{ key: 'Shift-Mod-m', run: makeDiv },
{ key: 'Mod-/', run: makeComment },
{ key: 'Mod-k', run: makeLink },
{ key: 'Mod-l', run: makeList('UL') },
{ key: 'Shift-Mod-l', run: makeList('OL') },
{ key: 'Shift-Mod-1', run: makeHeader(1) },
{ key: 'Shift-Mod-2', run: makeHeader(2) },
{ key: 'Shift-Mod-3', run: makeHeader(3) },
{ key: 'Shift-Mod-4', run: makeHeader(4) },
{ key: 'Shift-Mod-5', run: makeHeader(5) },
{ key: 'Shift-Mod-6', run: makeHeader(6) },
{ key: 'Shift-Tab', run: indentLess },
{ key: 'Mod-b', run: wrapSelection('**', '**') }, // makeBold
{ key: 'Mod-i', run: wrapSelection('*', '*') }, // makeItalic
{ key: 'Mod-u', run: wrapSelection('<u>', '</u>') }, // makeUnderline
{ key: 'Shift-Mod-=', run: wrapSelection('^', '^') }, // makeSuper
{ key: 'Mod-=', run: wrapSelection('^^', '^^') }, // makeSub
{ key: 'Mod-.', run: makeNbsp },
{ key: 'Shift-Mod-.', run: makeSpace },
{ key: 'Shift-Mod-,', run: removeSpace },
{ key: 'Mod-m', run: makeSpan },
{ key: 'Shift-Mod-m', run: makeDiv },
{ key: 'Mod-/', run: makeComment },
{ key: 'Mod-k', run: makeLink },
{ key: 'Mod-l', run: makeList('UL') },
{ key: 'Shift-Mod-l', run: makeList('OL') },
{ key: 'Shift-Mod-1', run: makeHeader(1) },
{ key: 'Shift-Mod-2', run: makeHeader(2) },
{ key: 'Shift-Mod-3', run: makeHeader(3) },
{ key: 'Shift-Mod-4', run: makeHeader(4) },
{ key: 'Shift-Mod-5', run: makeHeader(5) },
{ key: 'Shift-Mod-6', run: makeHeader(6) },
{ key: 'Mod-Enter', run: newPage },
{ key: 'Shift-Mod-Enter', run: newColumn },
{ key: 'Mod-Enter', run: newPage },
]));