文件
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

137 行
4.9 KiB
TypeScript

// Pure text transformations for the minimal Markdown editor. They take the
// textarea state and return the new content plus the selection to restore,
// which keeps them testable without a DOM.
export interface EditState {
content: string;
selectionStart: number;
selectionEnd: number;
}
export interface LinePrefix {
add: string;
detect: RegExp;
}
const lineBounds = (content: string, start: number, end: number) => {
const lineStart = content.lastIndexOf('\n', start - 1) + 1;
let lineEnd = content.indexOf('\n', end);
if (lineEnd < 0) lineEnd = content.length;
return { start: lineStart, end: lineEnd };
};
export const insertTextAtSelection = (state: EditState, text: string): EditState => {
const cursor = state.selectionStart + text.length;
return {
content: state.content.substring(0, state.selectionStart) + text + state.content.substring(state.selectionEnd),
selectionStart: cursor,
selectionEnd: cursor,
};
};
export const toggleWrap = (state: EditState, marker: string, placeholder: string): EditState => {
const { content, selectionStart, selectionEnd } = state;
const selected = content.substring(selectionStart, selectionEnd);
const before = content.substring(0, selectionStart);
const after = content.substring(selectionEnd);
if (before.endsWith(marker) && after.startsWith(marker)) {
return {
content: before.substring(0, before.length - marker.length) + selected + after.substring(marker.length),
selectionStart: selectionStart - marker.length,
selectionEnd: selectionEnd - marker.length,
};
}
if (selected.length >= marker.length * 2 && selected.startsWith(marker) && selected.endsWith(marker)) {
const inner = selected.substring(marker.length, selected.length - marker.length);
return {
content: before + inner + after,
selectionStart,
selectionEnd: selectionStart + inner.length,
};
}
const text = selected.length > 0 ? selected : placeholder;
return {
content: `${before}${marker}${text}${marker}${after}`,
selectionStart: selectionStart + marker.length,
selectionEnd: selectionStart + marker.length + text.length,
};
};
export const toggleLinePrefix = (state: EditState, prefix: LinePrefix): EditState => {
const { start, end } = lineBounds(state.content, state.selectionStart, state.selectionEnd);
const lines = state.content.substring(start, end).split('\n');
const nonEmpty = lines.filter(line => line.trim().length > 0);
const remove = nonEmpty.length > 0 && nonEmpty.every(line => prefix.detect.test(line));
const collapsed = state.selectionStart === state.selectionEnd;
let selectionStart = state.selectionStart;
let selectionEnd = state.selectionEnd;
const newLines: string[] = [];
let offset = start;
for (const line of lines) {
const isEmpty = line.trim().length === 0;
const newLine = isEmpty ? line : (remove ? line.replace(prefix.detect, '') : `${prefix.add}${line}`);
const delta = newLine.length - line.length;
// An inserted prefix lands before a cursor sitting at the line start, so
// the cursor follows it. A removed prefix leaves the line start unchanged.
if (offset < selectionStart || (offset === selectionStart && !remove)) selectionStart += delta;
if (offset < selectionEnd || (offset === selectionEnd && collapsed && !remove)) selectionEnd += delta;
offset += line.length + 1;
newLines.push(newLine);
}
return {
content: state.content.substring(0, start) + newLines.join('\n') + state.content.substring(end),
selectionStart,
selectionEnd,
};
};
export const toggleCodeBlock = (state: EditState): EditState => {
const { content, selectionStart, selectionEnd } = state;
const selected = content.substring(selectionStart, selectionEnd);
const before = content.substring(0, selectionStart);
const after = content.substring(selectionEnd);
const fence = '```';
const fenced = new RegExp(`^\\s*${fence}\\n([\\s\\S]*)\\n${fence}\\s*$`);
const fencedSelection = selected.match(fenced);
if (fencedSelection) {
const inner = fencedSelection[1];
return {
content: before + inner + after,
selectionStart,
selectionEnd: selectionStart + inner.length,
};
}
if (selected.length > 0) {
return {
content: `${before}${fence}\n${selected}\n${fence}${after}`,
selectionStart: selectionStart + fence.length + 1,
selectionEnd: selectionStart + fence.length + 1 + selected.length,
};
}
const cursor = selectionStart + fence.length + 1;
return {
content: `${before}${fence}\n\n${fence}${after}`,
selectionStart: cursor,
selectionEnd: cursor,
};
};
export const wrapWithLink = (state: EditState, url: string): EditState => {
const { content, selectionStart, selectionEnd } = state;
const selected = content.substring(selectionStart, selectionEnd);
const link = `[${selected}](${url})`;
return {
content: content.substring(0, selectionStart) + link + content.substring(selectionEnd),
selectionStart: selectionStart + 1,
selectionEnd: selectionStart + 1 + selected.length,
};
};