完善 TipTap 编辑器与个人资料页:头像裁剪、Tab 缩进、文章内链、Markdown 工具与 Tooltip,并更新样式与 Feed 缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
66
frontend/src/utils/avatarCrop.ts
Normal file
66
frontend/src/utils/avatarCrop.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Area } from 'react-easy-crop';
|
||||
|
||||
export const AVATAR_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
export const AVATAR_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
/** 头像输出尺寸 */
|
||||
export const AVATAR_OUTPUT_SIZE = 512;
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('图片加载失败'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验头像文件,返回错误信息或 null */
|
||||
export function validateAvatarFile(file: File, maxMb: number): string | null {
|
||||
if (!AVATAR_MIME_TYPES.includes(file.type)) {
|
||||
return '仅支持 JPG、PNG、GIF、WebP 格式';
|
||||
}
|
||||
if (file.size > maxMb * 1024 * 1024) {
|
||||
return `头像不能超过 ${maxMb}MB`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 将裁剪区域渲染为 JPEG 文件 */
|
||||
export async function getCroppedAvatarFile(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
originalName = 'avatar.jpg',
|
||||
): Promise<File> {
|
||||
const image = await loadImage(imageSrc);
|
||||
const canvas = document.createElement('canvas');
|
||||
const size = AVATAR_OUTPUT_SIZE;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('裁剪失败');
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
size,
|
||||
size,
|
||||
);
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
b => (b ? resolve(b) : reject(new Error('裁剪失败'))),
|
||||
'image/jpeg',
|
||||
0.92,
|
||||
);
|
||||
});
|
||||
|
||||
const baseName = originalName.replace(/\.[^.]+$/, '') || 'avatar';
|
||||
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import type { PostItem } from '../api/types';
|
||||
|
||||
import type { FeedSort } from '../components/FeedSortBar';
|
||||
|
||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||
export type FeedNavState = { refreshFeed?: boolean };
|
||||
|
||||
|
||||
|
||||
export type FeedCache = {
|
||||
@@ -89,7 +92,6 @@ export function clearFeedCache(boardId: number, keyword: string, sort: FeedSort)
|
||||
|
||||
|
||||
/** 清除所有帖子列表缓存(置顶等操作后列表需全量刷新) */
|
||||
|
||||
export function clearAllFeedCache() {
|
||||
|
||||
try {
|
||||
@@ -110,3 +112,12 @@ export function clearAllFeedCache() {
|
||||
|
||||
}
|
||||
|
||||
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
||||
export const FEED_RESET_EVENT = 'feed-reset';
|
||||
|
||||
/** 清除缓存并导航到帖子列表(重复点击同一入口时也会刷新) */
|
||||
export function navigateFeed(nav: NavigateFunction, url: string) {
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event(FEED_RESET_EVENT));
|
||||
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
|
||||
}
|
||||
|
||||
229
frontend/src/utils/markdownContent.ts
Normal file
229
frontend/src/utils/markdownContent.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import TurndownService from 'turndown';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
|
||||
const MEMBERS_ONLY_BLOCK_RE = /<members-only>([\s\S]*?)<\/members-only>/gi;
|
||||
|
||||
const TURNDOWN_OPTIONS = {
|
||||
headingStyle: 'atx' as const,
|
||||
codeBlockStyle: 'fenced' as const,
|
||||
emDelimiter: '*',
|
||||
bulletListMarker: '-',
|
||||
};
|
||||
|
||||
/** 仅去除区块首尾空行,保留各行行首缩进 */
|
||||
function trimBlockBoundaryLines(content: string): string {
|
||||
return content.replace(/^\n+/, '').replace(/\n+$/, '');
|
||||
}
|
||||
|
||||
/** 不换行空格还原为普通空格,便于 Markdown 源码编辑 */
|
||||
function nbspToSpaces(text: string): string {
|
||||
return text.replace(/\u00A0/g, ' ');
|
||||
}
|
||||
|
||||
/** 将含 <br> 的段落拆成多个 <p>,避免聚合转换时丢失首行缩进 */
|
||||
function splitParagraphBreaks(html: string): string {
|
||||
if (!/<br\s*\/?>/i.test(html)) return html;
|
||||
|
||||
const doc = new DOMParser().parseFromString(`<div data-wrap="1">${html}</div>`, 'text/html');
|
||||
const container = doc.querySelector('[data-wrap]');
|
||||
if (!container) return html;
|
||||
|
||||
[...container.querySelectorAll('p')].forEach(p => {
|
||||
const inner = p.innerHTML;
|
||||
if (!/<br\s*\/?>/i.test(inner)) return;
|
||||
|
||||
const parts = inner.split(/<br\s*\/?>/i);
|
||||
const fragment = doc.createDocumentFragment();
|
||||
parts.forEach((part, index) => {
|
||||
if (part === '' && index === parts.length - 1) return;
|
||||
const newP = doc.createElement('p');
|
||||
newP.innerHTML = part;
|
||||
fragment.appendChild(newP);
|
||||
});
|
||||
p.replaceWith(fragment);
|
||||
});
|
||||
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
/** 为 Turndown 注册通用正文规则(不含 members-only) */
|
||||
function addTurndownContentRules(service: TurndownService): void {
|
||||
service.addRule('image', {
|
||||
filter: 'img',
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLImageElement;
|
||||
const alt = el.getAttribute('alt') ?? '';
|
||||
const src = el.getAttribute('src') ?? '';
|
||||
return src ? `` : '';
|
||||
},
|
||||
});
|
||||
|
||||
service.addRule('underline', {
|
||||
filter: ['u'],
|
||||
replacement: (content) => `<u>${content}</u>`,
|
||||
});
|
||||
|
||||
service.addRule('strikethrough', {
|
||||
filter: ['del', 's', 'strike'],
|
||||
replacement: (content) => `~~${content}~~`,
|
||||
});
|
||||
|
||||
service.addRule('horizontalRule', {
|
||||
filter: 'hr',
|
||||
replacement: () => '\n\n---\n\n',
|
||||
});
|
||||
|
||||
service.addRule('anchor', {
|
||||
filter: (node) => {
|
||||
if (node.nodeName !== 'A') return false;
|
||||
const href = (node as HTMLAnchorElement).getAttribute('href');
|
||||
return Boolean(href);
|
||||
},
|
||||
replacement: (content, node) => {
|
||||
const el = node as HTMLAnchorElement;
|
||||
const href = el.getAttribute('href') ?? '';
|
||||
const title = el.getAttribute('title');
|
||||
return title ? `[${content}](${href} "${title}")` : `[${content}](${href})`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 子节点转 Markdown 专用,避免 members-only 规则递归 */
|
||||
const contentTurndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(contentTurndown);
|
||||
|
||||
const turndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(turndown);
|
||||
|
||||
/** 登录可见区块转为 Markdown:逐子节点转换,保留首行缩进 */
|
||||
turndown.addRule('membersOnly', {
|
||||
filter: 'members-only',
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLElement;
|
||||
const parts: string[] = [];
|
||||
|
||||
el.childNodes.forEach(child => {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = (child.textContent ?? '').trim();
|
||||
if (text) parts.push(nbspToSpaces(text));
|
||||
return;
|
||||
}
|
||||
if (child instanceof HTMLElement) {
|
||||
parts.push(nbspToSpaces(contentTurndown.turndown(child.outerHTML).trim()));
|
||||
}
|
||||
});
|
||||
|
||||
const body = trimBlockBoundaryLines(parts.join('\n\n'));
|
||||
return `\n\n<members-only>\n\n${body}\n\n</members-only>\n\n`;
|
||||
},
|
||||
});
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
/** 禁用缩进代码块,Tab/空格缩进仅作正文排版;围栏 ``` 代码块不受影响 */
|
||||
marked.use({
|
||||
tokenizer: {
|
||||
code() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** 净化并保留 members-only 标签 */
|
||||
function sanitizeContentHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */
|
||||
function preserveLeadingIndent(markdown: string): string {
|
||||
return markdown.split(/(```[\s\S]*?```)/g).map((part, index) => {
|
||||
if (index % 2 === 1) return part;
|
||||
return part.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length));
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/** 将普通 Markdown 片段转为 HTML */
|
||||
function parseMarkdownFragment(markdown: string): string {
|
||||
if (!markdown.trim()) return '';
|
||||
return marked.parse(preserveLeadingIndent(markdown), { async: false }) as string;
|
||||
}
|
||||
|
||||
/** 转换前清理编辑态装饰结构,避免污染 Markdown */
|
||||
function prepareHtmlForMarkdown(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(sanitizeContentHtml(html), 'text/html');
|
||||
|
||||
doc.querySelectorAll('.post-members-only__badge, .post-members-only__exit-btn, .post-members-only__unwrap-btn').forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
doc.querySelectorAll('members-only').forEach(el => {
|
||||
const body = el.querySelector('.post-members-only__body');
|
||||
const raw = body ? body.innerHTML : el.innerHTML;
|
||||
el.innerHTML = splitParagraphBreaks(raw);
|
||||
});
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
/** 规范化 members-only 标签边界,避免闭合标签与正文粘连 */
|
||||
function normalizeMembersOnlyMarkdown(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/<\/members-only>(?=[^\s\n])/g, '</members-only>\n\n')
|
||||
.replace(/<members-only>\s*<\/members-only>/g, '<members-only>\n\n</members-only>');
|
||||
}
|
||||
|
||||
/** 列表标记后统一为单个空格(Turndown 默认会输出两个及以上空格) */
|
||||
function normalizeListMarkerSpacing(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/^(\s*[-+*])\s+(?=\S)/gm, '$1 ')
|
||||
.replace(/^(\s*\d+\.)\s+(?=\S)/gm, '$1 ');
|
||||
}
|
||||
|
||||
/** 编辑器 HTML 转为 Markdown 源码 */
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html.trim()) return '';
|
||||
const prepared = prepareHtmlForMarkdown(html);
|
||||
const raw = turndown.turndown(prepared).replace(/\n{3,}/g, '\n\n').trim();
|
||||
return normalizeListMarkerSpacing(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 源码转为编辑器 HTML。
|
||||
* 先提取 members-only 块再分别解析,避免闭合标签后同行文字被吞入区块。
|
||||
*/
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
if (!markdown.trim()) return '';
|
||||
|
||||
const normalized = normalizeMembersOnlyMarkdown(markdown);
|
||||
const re = new RegExp(MEMBERS_ONLY_BLOCK_RE.source, 'gi');
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null = re.exec(normalized);
|
||||
|
||||
while (match) {
|
||||
const before = normalized.slice(lastIndex, match.index);
|
||||
if (before.trim()) {
|
||||
result += parseMarkdownFragment(before);
|
||||
}
|
||||
|
||||
const innerMd = trimBlockBoundaryLines(match[1]);
|
||||
const innerHtml = innerMd.trim()
|
||||
? splitParagraphBreaks(parseMarkdownFragment(innerMd))
|
||||
: '';
|
||||
result += `<members-only>${innerHtml}</members-only>`;
|
||||
lastIndex = re.lastIndex;
|
||||
match = re.exec(normalized);
|
||||
}
|
||||
|
||||
const tail = normalized.slice(lastIndex);
|
||||
if (tail.trim()) {
|
||||
result += parseMarkdownFragment(tail);
|
||||
}
|
||||
|
||||
return sanitizeContentHtml(result);
|
||||
}
|
||||
109
frontend/src/utils/markdownFormat.ts
Normal file
109
frontend/src/utils/markdownFormat.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { applyTextareaChange } from './markdownIndent';
|
||||
|
||||
type ChangeHandler = (value: string) => void;
|
||||
|
||||
/** 在选区两侧包裹 Markdown 标记 */
|
||||
export function wrapMarkdownSelection(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
placeholder: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const selected = value.slice(selectionStart, selectionEnd);
|
||||
const text = selected || placeholder;
|
||||
const insert = prefix + text + suffix;
|
||||
const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd);
|
||||
const newStart = selectionStart + prefix.length;
|
||||
const newEnd = newStart + text.length;
|
||||
applyTextareaChange(textarea, next, newStart, newEnd, onChange);
|
||||
}
|
||||
|
||||
/** 为当前行或选区行添加前缀(如引用) */
|
||||
export function prefixMarkdownLines(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
prefix: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
const nextNewline = value.indexOf('\n', selectionEnd);
|
||||
const lineEnd = nextNewline === -1 ? value.length : nextNewline;
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const lines = block.split('\n').map(line => `${prefix}${line}`);
|
||||
const next = value.slice(0, lineStart) + lines.join('\n') + value.slice(lineEnd);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + prefix.length,
|
||||
selectionEnd + prefix.length * lines.length,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
|
||||
/** 切换当前行标题级别(源码模式) */
|
||||
export function cycleMarkdownHeading(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart } = textarea;
|
||||
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
const lineEnd = value.indexOf('\n', selectionStart);
|
||||
const end = lineEnd === -1 ? value.length : lineEnd;
|
||||
const line = value.slice(lineStart, end);
|
||||
const match = line.match(/^(#{2,6})\s+(.*)$/);
|
||||
let nextLine: string;
|
||||
if (!match) {
|
||||
nextLine = `## ${line}`;
|
||||
} else {
|
||||
const level = match[1].length;
|
||||
const body = match[2];
|
||||
nextLine = level >= 6 ? body : `${'#'.repeat(level + 1)} ${body}`;
|
||||
}
|
||||
const next = value.slice(0, lineStart) + nextLine + value.slice(end);
|
||||
const offset = nextLine.length - line.length;
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + Math.max(0, offset),
|
||||
selectionStart + Math.max(0, offset),
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
|
||||
/** 插入登录可见区块模板 */
|
||||
export function insertMarkdownMembersOnly(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const snippet = '\n\n<members-only>\n\n\n</members-only>\n\n';
|
||||
const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd);
|
||||
const cursor = selectionStart + '\n\n<members-only>\n\n'.length;
|
||||
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
||||
}
|
||||
|
||||
/** 在光标处插入链接 Markdown */
|
||||
export function insertMarkdownLink(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
url: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const selected = value.slice(selectionStart, selectionEnd) || '链接文字';
|
||||
const insert = `[${selected}](${url})`;
|
||||
const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + insert.length,
|
||||
selectionStart + insert.length,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
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