完善 TipTap 编辑器与个人资料页:头像裁剪、Tab 缩进、文章内链、Markdown 工具与 Tooltip,并更新样式与 Feed 缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
149
frontend/src/utils/markdownIndent.ts
Normal file
149
frontend/src/utils/markdownIndent.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
export const TAB_SPACES = ' ';
|
||||
|
||||
interface LineRange {
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
}
|
||||
|
||||
/** 获取选区覆盖的整行文本范围 */
|
||||
function getLineRange(value: string, start: number, end: number): LineRange {
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const nextNewline = value.indexOf('\n', end);
|
||||
const lineEnd = nextNewline === -1 ? value.length : nextNewline;
|
||||
return { lineStart, lineEnd };
|
||||
}
|
||||
|
||||
/** 多行整体增加一级缩进 */
|
||||
function indentBlock(value: string, lineStart: number, lineEnd: number): string {
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const indented = block.split('\n').map(line => TAB_SPACES + line).join('\n');
|
||||
return value.slice(0, lineStart) + indented + value.slice(lineEnd);
|
||||
}
|
||||
|
||||
/** 多行整体减少一级缩进,返回新文本及选区偏移 */
|
||||
function outdentBlock(
|
||||
value: string,
|
||||
lineStart: number,
|
||||
lineEnd: number,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
): { next: string; newStart: number; newEnd: number } {
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const lines = block.split('\n');
|
||||
let cursor = lineStart;
|
||||
let newStart = selectionStart;
|
||||
let newEnd = selectionEnd;
|
||||
|
||||
const outdented = lines.map(line => {
|
||||
const match = line.match(/^ {1,4}/);
|
||||
const removed = match ? match[0].length : 0;
|
||||
if (removed > 0) {
|
||||
if (selectionStart > cursor) {
|
||||
newStart -= Math.min(removed, selectionStart - cursor);
|
||||
}
|
||||
if (selectionEnd > cursor) {
|
||||
newEnd -= Math.min(removed, selectionEnd - cursor);
|
||||
}
|
||||
}
|
||||
cursor += line.length + 1;
|
||||
return line.replace(/^ {1,4}/, '');
|
||||
}).join('\n');
|
||||
|
||||
return {
|
||||
next: value.slice(0, lineStart) + outdented + value.slice(lineEnd),
|
||||
newStart: Math.max(lineStart, newStart),
|
||||
newEnd: Math.max(lineStart, newEnd),
|
||||
};
|
||||
}
|
||||
|
||||
/** 在光标处插入文本并恢复选区 */
|
||||
export function applyTextareaChange(
|
||||
textarea: HTMLTextAreaElement,
|
||||
nextValue: string,
|
||||
cursorStart: number,
|
||||
cursorEnd = cursorStart,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
onChange(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = cursorStart;
|
||||
textarea.selectionEnd = cursorEnd;
|
||||
textarea.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** 在 textarea 光标处插入文本 */
|
||||
export function insertAtCursor(
|
||||
textarea: HTMLTextAreaElement,
|
||||
currentValue: string,
|
||||
insertText: string,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const next = currentValue.slice(0, selectionStart) + insertText + currentValue.slice(selectionEnd);
|
||||
const cursor = selectionStart + insertText.length;
|
||||
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
||||
}
|
||||
|
||||
/** Markdown 源码 Tab / Shift+Tab 缩进 */
|
||||
export function handleMarkdownTabKey(
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const { selectionStart, selectionEnd, value } = textarea;
|
||||
const { lineStart, lineEnd } = getLineRange(value, selectionStart, selectionEnd);
|
||||
const hasSelection = selectionStart !== selectionEnd;
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const multiLine = hasSelection && block.split('\n').length > 1;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (multiLine) {
|
||||
const { next, newStart, newEnd } = outdentBlock(value, lineStart, lineEnd, selectionStart, selectionEnd);
|
||||
applyTextareaChange(textarea, next, newStart, newEnd, onChange);
|
||||
return;
|
||||
}
|
||||
|
||||
const lineText = value.slice(lineStart, selectionStart);
|
||||
const trailingMatch = lineText.match(/ {1,4}$/);
|
||||
if (trailingMatch) {
|
||||
const removeLen = trailingMatch[0].length;
|
||||
const next = value.slice(0, selectionStart - removeLen) + value.slice(selectionStart);
|
||||
applyTextareaChange(textarea, next, selectionStart - removeLen, selectionEnd - removeLen, onChange);
|
||||
return;
|
||||
}
|
||||
|
||||
const leadingMatch = value.slice(lineStart, selectionStart).match(/^ {1,4}/);
|
||||
if (leadingMatch) {
|
||||
const removeLen = leadingMatch[0].length;
|
||||
const next = value.slice(0, lineStart) + value.slice(lineStart + removeLen);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart - removeLen,
|
||||
selectionEnd - removeLen,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (multiLine) {
|
||||
const next = indentBlock(value, lineStart, lineEnd);
|
||||
const lineCount = value.slice(lineStart, lineEnd).split('\n').length;
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + TAB_SPACES.length,
|
||||
selectionEnd + TAB_SPACES.length * lineCount,
|
||||
onChange,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = value.slice(0, selectionStart) + TAB_SPACES + value.slice(selectionEnd);
|
||||
applyTextareaChange(textarea, next, selectionStart + TAB_SPACES.length, selectionStart + TAB_SPACES.length, onChange);
|
||||
}
|
||||
Reference in New Issue
Block a user