Files
2026-07-13 12:30:20 +08:00

579 lines
22 KiB
JavaScript

function initEditor(el) {
if (window.editor !== undefined && el.id === 'editor-textarea' ) {
editor.off();
const wrapper = editor.getWrapperElement();
if (wrapper && wrapper.parentNode) {
wrapper.parentNode.removeChild(wrapper);
}
editor2.off();
const wrapper2 = editor2.getWrapperElement();
if (wrapper2 && wrapper2.parentNode) {
wrapper2.parentNode.removeChild(wrapper2);
}
} else if (window.editor2 !== undefined && el.id === 'editor2-textarea') {
editor2.off();
const wrapper = editor2.getWrapperElement();
if (wrapper && wrapper.parentNode) {
wrapper.parentNode.removeChild(wrapper);
}
}
let newEditor = HyperMD.fromTextArea(el, {
dragDrop: false,
viewportMargin: 10,
mode: {
name: 'hypermd',
math: true,
},
lineNumbers: false,
extraKeys: {
// 'Shift-Space': 'autocomplete',
'Cmd-[': false, 'Cmd-]': false,
// Jump to doc end, then leave breathing room below the cursor so the
// last line isn't pinned to the bottom edge. Scoped to this explicit
// jump only - a global cursorScrollMargin would also shift the view
// on every keystroke near an edge.
'Cmd-Down': cm => { cm.execCommand('goDocEnd'); cm.scrollIntoView(cm.getCursor(), 300); },
'Cmd-End': cm => { cm.execCommand('goDocEnd'); cm.scrollIntoView(cm.getCursor(), 300); },
'Ctrl-End': cm => { cm.execCommand('goDocEnd'); cm.scrollIntoView(cm.getCursor(), 300); },
// Mac's default delWrappedLineLeft is a no-op at column 0, so
// Cmd-Backspace gets stuck at the line start instead of joining
// with the previous line. Fall back to delCharBefore there.
'Cmd-Backspace': cm => {
if (cm.somethingSelected() || cm.getCursor().ch > 0) {
return CodeMirror.Pass;
}
cm.execCommand('delCharBefore');
},
},
hintOptions: {
hint: CompleteEmoji.createHintFunc(),
closeCharacters: /$^/,
closeOnUnfocus: false,
completeSingle: false,
alignWithWord: false
},
hmdFoldEmoji: {
myEmoji: createAutocompleteDict
},
hmdFoldMath: {
renderer: KatexRenderer,
},
// Enable fold-code so ```mermaid blocks get rendered via the
// hypermd-mermaid renderer (registered as suggested:true on load).
hmdFoldCode: { mermaid: true },
configureMouse: () => ({addNew: false}) // disable multicursor
});
newEditor.setSize(null, '100%');
newEditor.on('focus', function() {
currentEditor = newEditor; // FIXME possible RC here? If isMessingWithCurrentEditor is hold, this would overwrite
currentEditor.refresh(); // Cursor & hide tokens conflict if we don't call it
closeChatModal();
log('Focused to:', newEditor.path);
});
newEditor.hmdResolveURL = function (path) {
if (typeof path === 'undefined') {
return path
}
path = path.replace(/%20/g, ' ');
// Decode parens escaped by encodeLinkPath when the link was written,
// so the bare-filename lookup below matches the real file.
path = path.replace(/%28/g, '(').replace(/%29/g, ')');
// TODO really dirty fix for links like:
// ../media/image.png, remove
if (path.startsWith('../')) {
path = path.replace('../', '');
}
// Bare domain like google.com/test - window.open treats it as a relative
// path without a protocol, which makes the PWA navigate to a sub-path.
// Exclude local-file extensions (md, image types) so `![](img.png)`
// isn't mistaken for a domain.
if (/^[a-z0-9-]+(\.[a-z0-9-]+)+(\/|$)/i.test(path)
&& !/\.(md|png|jpg|jpeg|gif|webp|mp4|webm|mov|mp3|ogg|oga|weba|wav)$/i.test(path)) {
return 'https://' + path;
}
if (/^(?!http|https|\[).+\.md$/.test(path)) {
const isMobile = window.matchMedia('(max-width: 670px)').matches;
const target = isMobile ? 'editor-textarea' : 'editor2-textarea';
const fullPath = path.startsWith('/') ? path : '/' + path;
openFile(fullPath, true, target);
return path;
}
// Look up by bare filename so media/img lookups work even when path
// has a folder prefix.
if (isMediaPath(path)) {
const bareName = path.split('/').pop();
if (files['media/'] && files['media/'][bareName]) {
return files['media/'][bareName].imageUrl;
}
if (files['img/'] && files['img/'][bareName]) {
return files['img/'][bareName].imageUrl;
}
// Fallback - look up bare filename in the global media index
// built by loadLocalFiles. Resolves media stored in any folder
// when the markdown link's path doesn't match.
if (mediaIndex[bareName] && mediaIndex[bareName].imageUrl) {
return mediaIndex[bareName].imageUrl;
}
}
return path;
};
newEditor.hmdReadLink = async function (path) {
path = path.replace(/\|.*]$/, '');
path = path.replace('[', '').replace(']', '');
// Decode parens escaped by encodeLinkPath when the link was written.
path = path.replace(/%28/g, '(').replace(/%29/g, ')');
// Handle action links
if (path === 'cmd:openDir') {
openDir();
return;
}
if (path === 'cmd:openChat') {
openChat();
return;
}
// If it is a web link open window blank
if (/^(http|https):\/\//.test(path)) {
window.open(path, '_blank');
return;
}
path += '.md';
// Phones don't have the room for the split-view editor2 -
// route link follows into the main editor instead.
const target = window.matchMedia('(max-width: 670px)').matches
? 'editor-textarea'
: 'editor2-textarea';
if (getMemFile(path) !== null) {
openFile(path, true, target)
return;
}
// Try to find filename is any folder
let filename = toFilename(path);
walk(files, (path, isFile) => {
if (!isFile) {
return;
}
if (toFilename(path) === filename) {
openFile(path, true, target);
return false;
}
});
};
newEditor.on('inputRead', async function (cm, change) {
if (change.text.length === 1 && change.text[0] === '`') {
const cursor = cm.getCursor();
const line = cm.getLine(cursor.line);
const before = line.slice(0, cursor.ch);
const after = line.slice(cursor.ch);
// Trigger only on the third backtick at the start of a line.
if (!/^ {0,3}`{3}$/.test(before)) return;
if (after.length > 0) return;
// Skip when this ``` is closing an already-open fence.
if (cursor.line > 0) {
const prevState = cm.getStateAfter(cursor.line - 1);
if (prevState && prevState.fencedEndRE) return;
}
cm.replaceRange('\n\n```', cursor);
cm.setCursor({ line: cursor.line + 1, ch: 0 });
return;
}
if (change.text.length === 1 && change.text[0] === '[') {
const cursor = cm.getCursor();
// Skip the link autocomplete when the [ is preceded by a
// backslash - that's an escaped bracket, not the start of a link.
const charBefore = cursor.ch >= 2 ? cm.getRange({line: cursor.line, ch: cursor.ch - 2}, {line: cursor.line, ch: cursor.ch - 1}) : '';
if (charBefore === '\\') {
return;
}
// Skip when the [ sits inside an inline code span (`...[...`).
// The token at the cursor carries the `inline-code` style and we
// don't want to insert links into code.
const token = cm.getTokenAt(cursor);
if (token && token.type && /\binline-code\b/.test(token.type)) {
return;
}
cm.showHint({
completeSingle: false, updateOnCursorActivity: true,
})
}
})
// Auto-select/highlight title when clicking on the first line
// TODO clear on second click
newEditor.getWrapperElement().addEventListener('mousedown', function(e) {
// Get the position where the mouse was clicked
const coords = newEditor.coordsChar({left: e.clientX, top: e.clientY});
if (coords.line === 0) {
// Check if cursor is already on line 0
const currentCursor = newEditor.getCursor();
if (currentCursor.line === 0) {
// Cursor already on line 0, don't select
return;
}
// Cursor not on line 0, select the title
setTimeout(() => {
const lineLength = newEditor.getLine(0).length;
newEditor.setSelection(
{line: 0, ch: 2}, // Start from character 2 (skip "# ")
{line: 0, ch: lineLength}
);
}, 150);
}
}, true);
// Edits flip the sync dot to "unsynced" until the server acks them.
// Programmatic loads (openFile's setValue) are not user edits.
newEditor.on('change', function () {
if (isMessingWithCurrentEditor) return;
markSyncDirty();
});
// Force '# ' to remain at first line.
newEditor.on('change', function (cm, change) {
if (change.from.line === 0) {
const line = cm.getLine(0);
if (!line.startsWith('# ')) {
const content = line.replace(/^#*\s*/, '');
cm.replaceRange('# ' + content, {line: 0, ch: 0}, {line: 0, ch: line.length});
}
}
});
// Image upload
newEditor.on('paste', async (_, event) => {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (const item of items) {
const isMedia = item.kind === 'file'
&& (item.type.startsWith('image/') || item.type.startsWith('video/') || item.type.startsWith('audio/'));
if (isMedia) {
event.preventDefault();
const file = item.getAsFile();
const fileName = `${new Date().toISOString().replace(/[:.]/g, '-')}.${getImageExtension(item.type)}`;
try {
const fileHandle = await writeMediaFile(fileName, file);
if (fileHandle) {
if (!files['media/']) {
files['media/'] = {};
}
files['media/'][fileName] = {
isFile: true,
handle: fileHandle,
lastModified: Date.now(),
imageUrl: URL.createObjectURL(file)
};
const markdownImageSyntax = `![](media/${encodeLinkPath(fileName)})\n`;
currentEditor.replaceSelection(markdownImageSyntax);
log(`Media saved as: ${fileName}`);
} else {
logError('Failed to save the media.');
alert('Failed to save the file. Please try again.');
}
} catch (error) {
logError('Error saving media:', error);
alert('Error saving file: ' + error.message);
}
}
}
});
// Editor keybindings
newEditor.addKeyMap({
'Enter': function (cm) { // If header is selected, enter should move cursor to next line
if (tableEnterCell(cm)) return;
const cursor = cm.getCursor();
// If there's a selection on the header line, just move cursor
if (cursor.line === 0) {
if (cm.somethingSelected()) {
const selections = cm.listSelections();
const isHeaderSelection = selections.some(sel =>
sel.anchor.line === 0 || sel.head.line === 0
);
if (isHeaderSelection) {
// Clear selection and move cursor to start of line 1
cm.setCursor({line: 1, ch: 0});
return;
}
} else {
// No selection, just move cursor to next line
cm.setCursor({line: 1, ch: 0});
return;
}
}
// For all other lines, use default Enter behavior
return CodeMirror.Pass;
},
'Cmd-A': function (cm) {
if (tableSelectCell(cm)) return;
const cursor = cm.getCursor();
// If cursor is on the first line, select all text in that line
if (cursor.line === 0) {
const lineLength = cm.getLine(0).length;
cm.setSelection(
{line: 0, ch: 0},
{line: 0, ch: lineLength}
);
return;
}
// Otherwise, use default Cmd-A behavior (select all except first line)
const lastLine = cm.lastLine();
const lastLineLength = cm.getLine(lastLine).length;
cm.setSelection(
{line: 1, ch: 0},
{line: lastLine, ch: lastLineLength},
{scroll: false}
);
},
'Ctrl-A': function (cm) {
if (tableSelectCell(cm)) return;
const cursor = cm.getCursor();
// If cursor is on the first line, select all text in that line
if (cursor.line === 0) {
const lineLength = cm.getLine(0).length;
cm.setSelection(
{line: 0, ch: 0},
{line: 0, ch: lineLength}
);
return;
}
// Otherwise, use default Cmd-A behavior (select all except first line)
const lastLine = cm.lastLine();
const lastLineLength = cm.getLine(lastLine).length;
cm.setSelection(
{line: 1, ch: 0},
{line: lastLine, ch: lastLineLength},
{scroll: false}
);
},
'Cmd-T': function (cm) {
tableInsert(cm);
},
'Ctrl-T': function (cm) {
tableInsert(cm);
},
'Cmd-Y': function (cm) {
var cursor = cm.getCursor();
var lineStart = {line: cursor.line, ch: 0};
cm.replaceRange('- [ ] ', lineStart);
cm.focus();
},
'Ctrl-Y': function (cm) {
var cursor = cm.getCursor();
var lineStart = {line: cursor.line, ch: 0};
cm.replaceRange('- [ ] ', lineStart);
cm.focus();
},
'Cmd-B': function (cm) {
let selection = cm.getSelection();
let trimmedSelection = selection.trim();
let prefix = selection.slice(0, selection.indexOf(trimmedSelection));
let suffix = selection.slice(selection.indexOf(trimmedSelection) + trimmedSelection.length);
const isBold = trimmedSelection.startsWith('**') && trimmedSelection.endsWith('**');
let start = cm.getCursor('start');
let end = cm.getCursor('end');
if (isBold) {
cm.replaceSelection(prefix + trimmedSelection.slice(2, -2) + suffix);
cm.setSelection(
{line: start.line, ch: start.ch + prefix.length},
{line: end.line, ch: end.ch - suffix.length - 4}
);
} else {
cm.replaceSelection(prefix + `**${trimmedSelection}**` + suffix);
cm.setSelection(
{line: start.line, ch: start.ch + prefix.length},
{line: end.line, ch: end.ch - suffix.length + 4}
);
}
cm.focus();
},
'Cmd-I': function (cm) {
let selection = cm.getSelection();
let trimmedSelection = selection.trim();
let prefix = selection.slice(0, selection.indexOf(trimmedSelection));
let suffix = selection.slice(selection.indexOf(trimmedSelection) + trimmedSelection.length);
const isItalic = trimmedSelection.startsWith('*') && trimmedSelection.endsWith('*');
let start = cm.getCursor('start');
let end = cm.getCursor('end');
if (isItalic) {
cm.replaceSelection(prefix + trimmedSelection.slice(1, -1) + suffix);
cm.setSelection(
{line: start.line, ch: start.ch + prefix.length},
{line: end.line, ch: end.ch - suffix.length - 2}
);
} else {
cm.replaceSelection(prefix + `*${trimmedSelection}*` + suffix);
cm.setSelection(
{line: start.line, ch: start.ch + prefix.length},
{line: end.line, ch: end.ch - suffix.length + 2}
);
}
cm.focus();
}
});
function showCopiedToast() {
const toast = document.createElement('div');
toast.textContent = 'Copied!';
toast.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: var(--col-bg-alt); color: var(--col-tx); padding: 8px 16px; border-radius: 5px;
border: 1px solid var(--col-border);
z-index: 9999; font-size: 14px;
`;
document.body.appendChild(toast);
setTimeout(() => document.body.removeChild(toast), 1000);
}
newEditor.getWrapperElement().addEventListener('mousedown', function (e) {
if (!isMetaKey(e)) return;
e.preventDefault();
const code = e.target.closest('.cm-inline-code');
if (!code) return;
navigator.clipboard.writeText(code.textContent);
showCopiedToast();
}, true);
newEditor.on('renderLine', function (cm, lineHandle, el) {
if (el.querySelector('.code-copy-btn')) return;
const lineNo = lineHandle.lineNo();
if (lineNo == null) return;
const here = cm.getStateAfter(lineNo);
const prev = lineNo > 0 ? cm.getStateAfter(lineNo - 1) : null;
if (!(here && here.fencedEndRE && (!prev || !prev.fencedEndRE))) return;
if (/^\s*```\s*mermaid\b/.test(cm.getLine(lineNo))) return;
const btn = document.createElement('button');
btn.className = 'code-copy-btn';
btn.title = 'Copy';
btn.onmousedown = function (e) {
e.preventDefault();
e.stopPropagation();
const begin = lineHandle.lineNo();
const lines = [];
for (let L = begin + 1, last = cm.lineCount(); L < last; L++) {
const st = cm.getStateAfter(L);
if (!st || !st.fencedEndRE) break;
lines.push(cm.getLine(L));
}
navigator.clipboard.writeText(lines.join('\n'));
showCopiedToast();
};
el.appendChild(btn);
});
initAutoscroll(newEditor);
initTablePlus(newEditor);
return newEditor;
}
// Focus last line before the links.
function focusLastLine() {
let lastLine = currentEditor.lastLine();
let targetLine = lastLine;
// Eat all empty lines before first links.
while (lastLine >= 0) {
const lineContent = currentEditor.getLine(lastLine).trim();
if (lineContent === '') {
lastLine--;
continue;
}
lastLine = Math.min(lastLine + 1, currentEditor.lastLine());
break;
}
for (let i = lastLine; i >= 0; i--) {
const lineContent = currentEditor.getLine(i).trim();
if (!lineContent.startsWith('[') && (!lineContent.endsWith(']') || !lineContent.endsWith(')'))) {
targetLine = i;
break;
}
}
const targetChar = currentEditor.getLine(targetLine).length;
currentEditor.setCursor({ line: targetLine, ch: targetChar });
// Cursor at the end, but scroll the doc to top
currentEditor.scrollTo(null, 0);
// TODO only focus if there's no quick dialogue
currentEditor.focus();
}
let savedScrollTop;
function rememberEditorPos() {
savedScrollTop = editor.getScrollInfo().top;
}
function restoreEditorPos() {
if (savedScrollTop === undefined) {
return;
}
editor.refresh();
editor.scrollTo(null, savedScrollTop);
}
// KaTeX renderer for HyperMD's fold-math addon. fold-math instantiates
// this class with (container, mode) and calls startRender/clear as the
// user edits. mode === 'display' for $$...$$, anything else for $...$.
function KatexRenderer(container, mode) {
this.container = container;
this.mode = mode;
this.span = document.createElement('span');
container.appendChild(this.span);
}
KatexRenderer.prototype.startRender = function (expr) {
try {
window.katex.render(expr, this.span, {
displayMode: this.mode === 'display',
throwOnError: false,
});
} catch (e) {
this.span.textContent = expr;
}
if (this.onChanged) this.onChanged(expr);
};
KatexRenderer.prototype.clear = function () {
if (this.span.parentNode === this.container) {
this.container.removeChild(this.span);
}
};
KatexRenderer.prototype.isReady = function () {
return true;
};