feat: 完善表情选择与评论富文本展示
发帖编辑器支持 OwO 贴纸;评论区复用文章列表、段落与代码块阅读态,并优化表情面板间距与悬停反馈。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
|
||||
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo,
|
||||
type ReactNode, type Ref,
|
||||
} from 'react';
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import { TextSelection, NodeSelection } from '@tiptap/pm/state';
|
||||
@@ -37,7 +38,10 @@ import { ReplyOnly } from './editor/ReplyOnlyExtension';
|
||||
import { PointsOnly } from './editor/PointsOnlyExtension';
|
||||
import { TabIndent } from './editor/TabIndentExtension';
|
||||
import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension';
|
||||
import { ArticleSticker } from './editor/ArticleStickerExtension';
|
||||
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
|
||||
import StickerPicker from './emoji/StickerPicker';
|
||||
import type { Sticker } from '../data/stickers';
|
||||
import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagraph';
|
||||
import { ArticleLinkDialog, type ArticleLinkConfirm } from './editor/ArticleLinkDialog';
|
||||
import { ArticleImagePickerDialog } from './editor/ArticleImagePickerDialog';
|
||||
@@ -84,6 +88,7 @@ interface ToolBtn {
|
||||
align?: 'start' | 'center' | 'end';
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
buttonRef?: Ref<HTMLButtonElement>;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
@@ -119,9 +124,17 @@ function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** 判断编辑器内容是否为空 */
|
||||
/** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */
|
||||
function isEditorEmpty(editor: Editor): boolean {
|
||||
return editor.state.doc.textContent.trim().length === 0;
|
||||
if (editor.state.doc.textContent.trim().length > 0) return false;
|
||||
let hasMedia = false;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'image' || node.type.name === 'sticker' || node.type.name === 'imageGroup') {
|
||||
hasMedia = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return !hasMedia;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,10 +190,12 @@ function renderToolButtons(tools: ToolBtn[]) {
|
||||
) : null}
|
||||
<Tooltip content={t.title} hint={t.hint} align={t.align} side="bottom">
|
||||
<button
|
||||
ref={t.buttonRef}
|
||||
type="button"
|
||||
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={t.action}
|
||||
aria-pressed={t.active || undefined}
|
||||
>
|
||||
{t.icon}
|
||||
</button>
|
||||
@@ -213,7 +228,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
const [tableDialogOpen, setTableDialogOpen] = useState(false);
|
||||
const [tableTarget, setTableTarget] = useState<TableTarget>('rich');
|
||||
const [tableEditing, setTableEditing] = useState(false);
|
||||
const [showSticker, setShowSticker] = useState(false);
|
||||
const markdownRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editorBoxRef = useRef<HTMLDivElement>(null);
|
||||
const stickerBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -246,6 +264,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
},
|
||||
}),
|
||||
ArticleImage.configure({ inline: false, allowBase64: false }),
|
||||
ArticleSticker,
|
||||
ImageGroup,
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
@@ -322,23 +341,42 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
placeCaretInFirstTextblock(editor);
|
||||
}, [value, editor, mode]);
|
||||
|
||||
// 全屏时锁定页面滚动,Esc 退出
|
||||
// 全屏时锁定页面滚动;Esc 先关表情面板,再退出全屏
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return undefined;
|
||||
if (!fullscreen && !showSticker) return undefined;
|
||||
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const prevOverflow = fullscreen ? document.body.style.overflow : null;
|
||||
if (fullscreen) document.body.style.overflow = 'hidden';
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setFullscreen(false);
|
||||
if (e.key !== 'Escape') return;
|
||||
if (showSticker) {
|
||||
setShowSticker(false);
|
||||
stickerBtnRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (fullscreen) setFullscreen(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
if (prevOverflow !== null) document.body.style.overflow = prevOverflow;
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [fullscreen]);
|
||||
}, [fullscreen, showSticker]);
|
||||
|
||||
// 点击编辑器外关闭贴纸面板
|
||||
useEffect(() => {
|
||||
if (!showSticker) return;
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
if (editorBoxRef.current && !editorBoxRef.current.contains(e.target as Node)) {
|
||||
setShowSticker(false);
|
||||
stickerBtnRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
return () => document.removeEventListener('mousedown', onPointer);
|
||||
}, [showSticker]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getHTML: () => {
|
||||
@@ -523,6 +561,30 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
}, [editor]);
|
||||
|
||||
const insertSticker = useCallback((sticker: Sticker) => {
|
||||
if (mode === 'markdown') {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
const snippet = (sticker.type === 'text' && sticker.text)
|
||||
? sticker.text
|
||||
: (sticker.url ? `` : '');
|
||||
if (!snippet) return;
|
||||
insertAtCursor(textarea, markdownSource, snippet, handleMarkdownChange);
|
||||
setShowSticker(false);
|
||||
return;
|
||||
}
|
||||
if (!editor) return;
|
||||
if (sticker.type === 'text' && sticker.text) {
|
||||
editor.chain().focus().insertContent(sticker.text).run();
|
||||
} else if (sticker.url) {
|
||||
editor.chain().focus().insertContent([
|
||||
{ type: 'sticker', attrs: { src: sticker.url, alt: sticker.name } },
|
||||
{ type: 'text', text: ' ' },
|
||||
]).run();
|
||||
}
|
||||
setShowSticker(false);
|
||||
}, [editor, mode, markdownSource, handleMarkdownChange]);
|
||||
|
||||
const openImagePicker = useCallback((target: ImagePickerTarget) => {
|
||||
setImagePickerTarget(target);
|
||||
setImagePickerOpen(true);
|
||||
@@ -614,6 +676,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
const html = sanitizeHtml(editor.getHTML());
|
||||
lastValueRef.current = html;
|
||||
setMarkdownSource(htmlToMarkdown(html));
|
||||
setShowSticker(false);
|
||||
setMode('markdown');
|
||||
}, [editor]);
|
||||
|
||||
@@ -626,6 +689,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
editor.commands.setContent(html || '', { emitUpdate: false });
|
||||
placeCaretInFirstTextblock(editor);
|
||||
}
|
||||
setShowSticker(false);
|
||||
setMode('rich');
|
||||
}, [editor, markdownSource, onChange]);
|
||||
|
||||
@@ -676,6 +740,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
active: groupActive,
|
||||
action: wrapSelectedAsGroup,
|
||||
},
|
||||
{
|
||||
icon: <span className="article-tool-btn__owo">OwO</span>,
|
||||
title: '表情 OwO',
|
||||
hint: '插入贴纸或颜文字',
|
||||
active: showSticker,
|
||||
className: 'article-tool-btn--owo',
|
||||
buttonRef: stickerBtnRef,
|
||||
action: () => setShowSticker(v => !v),
|
||||
},
|
||||
];
|
||||
|
||||
if (editor.isActive('table')) {
|
||||
@@ -760,7 +833,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
}
|
||||
|
||||
return tools;
|
||||
}, [editor, enableContentGates, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||||
}, [editor, enableContentGates, showSticker, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||||
|
||||
const buildMarkdownTools = useCallback((): ToolBtn[] => {
|
||||
const tools: ToolBtn[] = [
|
||||
@@ -782,6 +855,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
hint: '上传、链接或从已上传中选择',
|
||||
action: () => openImagePicker('markdown'),
|
||||
},
|
||||
{
|
||||
icon: <span className="article-tool-btn__owo">OwO</span>,
|
||||
title: '表情 OwO',
|
||||
hint: '插入贴纸或颜文字',
|
||||
active: showSticker,
|
||||
className: 'article-tool-btn--owo',
|
||||
buttonRef: stickerBtnRef,
|
||||
action: () => setShowSticker(v => !v),
|
||||
},
|
||||
];
|
||||
if (enableContentGates) {
|
||||
tools.push(
|
||||
@@ -809,7 +891,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
);
|
||||
}
|
||||
return tools;
|
||||
}, [enableContentGates, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker]);
|
||||
}, [enableContentGates, showSticker, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker]);
|
||||
|
||||
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
||||
const words = mode === 'markdown'
|
||||
@@ -817,12 +899,16 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
: (editor ? countWords(editor.getText()) : 0);
|
||||
|
||||
return (
|
||||
<div className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}>
|
||||
<div
|
||||
ref={editorBoxRef}
|
||||
className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}
|
||||
>
|
||||
<div className="article-editor-bar">
|
||||
<div className="article-editor-tools">
|
||||
{renderToolButtons(tools)}
|
||||
</div>
|
||||
</div>
|
||||
{showSticker && <StickerPicker onSelect={insertSticker} />}
|
||||
|
||||
<div className="article-editor-body">
|
||||
{mode === 'rich' ? (
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { renderCommentContent } from '../utils/content';
|
||||
import { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks';
|
||||
import { userPath } from '../utils/userPath';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 渲染评论正文(支持正文内 @ 高亮与点击跳转) */
|
||||
/** 渲染评论正文(支持正文内 @ 高亮、代码块阅读态与点击跳转) */
|
||||
export default function CommentContent({ content }: Props) {
|
||||
const nav = useNavigate();
|
||||
const html = useMemo(() => renderCommentContent(content), [content]);
|
||||
|
||||
const openMention = async (name: string) => {
|
||||
try {
|
||||
@@ -26,17 +30,29 @@ export default function CommentContent({ content }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = useCallback(async (e: React.MouseEvent) => {
|
||||
try {
|
||||
if (await handleMdCodeBlockUiClick(e.target)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
notify.error('复制失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const el = (e.target as HTMLElement).closest('.mention') as HTMLElement | null;
|
||||
if (!el) return;
|
||||
const name = el.getAttribute('data-name');
|
||||
if (!name) return;
|
||||
e.preventDefault();
|
||||
void openMention(name);
|
||||
}, [nav]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="floor-body"
|
||||
onClick={(e) => {
|
||||
const el = (e.target as HTMLElement).closest('.mention') as HTMLElement | null;
|
||||
if (!el) return;
|
||||
const name = el.getAttribute('data-name');
|
||||
if (!name) return;
|
||||
e.preventDefault();
|
||||
void openMention(name);
|
||||
}}
|
||||
className="floor-body post-detail-content"
|
||||
onClick={(e) => { void onClick(e); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const el = e.target as HTMLElement;
|
||||
@@ -46,9 +62,7 @@ export default function CommentContent({ content }: Props) {
|
||||
e.preventDefault();
|
||||
void openMention(name);
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderCommentContent(content),
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
import { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks';
|
||||
import { loginPath, registerPath } from '../utils/authRedirect';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { notify } from '@/lib/notify';
|
||||
@@ -105,40 +106,12 @@ export default function PostContent({
|
||||
}
|
||||
return;
|
||||
}
|
||||
const foldBtn = target.closest<HTMLElement>('[data-code-fold]');
|
||||
if (foldBtn) {
|
||||
e.preventDefault();
|
||||
const block = foldBtn.closest('.md-codeblock');
|
||||
if (!block) return;
|
||||
const collapsed = block.classList.toggle('md-codeblock--collapsed');
|
||||
const lineCount = parseInt(block.getAttribute('data-line-count') || '0', 10)
|
||||
|| block.querySelectorAll('.md-code-line').length
|
||||
|| 1;
|
||||
if (collapsed && lineCount <= 5) block.classList.add('md-codeblock--short');
|
||||
else block.classList.remove('md-codeblock--short');
|
||||
foldBtn.textContent = collapsed ? '展开' : '收起';
|
||||
return;
|
||||
}
|
||||
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
|
||||
if (copyBtn) {
|
||||
e.preventDefault();
|
||||
const block = copyBtn.closest('.md-codeblock');
|
||||
const bodies = block?.querySelectorAll('.md-code-line__body');
|
||||
const text = bodies && bodies.length
|
||||
? [...bodies].map(el => el.textContent ?? '').join('\n')
|
||||
: (block?.querySelector('pre')?.textContent ?? '');
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const prev = copyBtn.textContent;
|
||||
copyBtn.textContent = '已复制';
|
||||
copyBtn.classList.add('is-copied');
|
||||
window.setTimeout(() => {
|
||||
copyBtn.textContent = prev || '复制';
|
||||
copyBtn.classList.remove('is-copied');
|
||||
}, 1600);
|
||||
} catch {
|
||||
notify.error('复制失败');
|
||||
try {
|
||||
if (await handleMdCodeBlockUiClick(target)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
} catch {
|
||||
notify.error('复制失败');
|
||||
}
|
||||
}, [nav, openLightbox, onRequestReply, onUnlocked, postId, unlocking]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from '@tiptap/extension-image';
|
||||
import { mergeAttributes } from '@tiptap/core';
|
||||
import { isStickerSrc } from './ArticleStickerExtension';
|
||||
|
||||
/** 单图展示形态(对齐 Notion / Medium 常见选项) */
|
||||
export type ImageDisplay = 'default' | 'wide' | 'float-left' | 'float-right';
|
||||
@@ -19,6 +20,19 @@ declare module '@tiptap/core' {
|
||||
export const ArticleImage = Image.extend({
|
||||
name: 'image',
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: this.options.allowBase64 ? 'img[src]' : 'img[src]:not([src^="data:"])',
|
||||
getAttrs: (node) => {
|
||||
if (typeof node === 'string') return false;
|
||||
if (isStickerSrc(node.getAttribute('src'))) return false;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
|
||||
54
frontend/src/components/editor/ArticleStickerExtension.ts
Normal file
54
frontend/src/components/editor/ArticleStickerExtension.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
|
||||
/** 贴纸资源路径:评论、私信、发帖共用 /stickers/ */
|
||||
export function isStickerSrc(src: string | null | undefined): boolean {
|
||||
return typeof src === 'string' && src.includes('/stickers/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内表情贴纸。文章 Image 是 block,不能拿来插表情,否则会独自占一段。
|
||||
* 解析 HTML 时优先于普通 img,避免贴纸被收成通栏大图。
|
||||
*/
|
||||
export const ArticleSticker = Node.create({
|
||||
name: 'sticker',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
priority: 60,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: { default: null },
|
||||
alt: { default: '' },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'img[src]',
|
||||
getAttrs: (node) => {
|
||||
if (typeof node === 'string') return false;
|
||||
const src = node.getAttribute('src') || '';
|
||||
if (!isStickerSrc(src)) return false;
|
||||
return {
|
||||
src,
|
||||
alt: node.getAttribute('alt') || '',
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'img',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
class: 'article-sticker',
|
||||
draggable: 'false',
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -36,17 +36,58 @@ export default function StickerPicker({ onSelect }: Props) {
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = gridRef.current?.querySelectorAll<HTMLElement>('[role="option"]')[focusIndex];
|
||||
el?.focus();
|
||||
}, [focusIndex, stickers]);
|
||||
if (loading || stickers.length === 0) return;
|
||||
gridRef.current?.focus();
|
||||
}, [loading, stickers]);
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
const cols = 8;
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); setFocusIndex((i) => Math.min(stickers.length - 1, i + 1)); }
|
||||
else if (e.key === 'ArrowLeft') { e.preventDefault(); setFocusIndex((i) => Math.max(0, i - 1)); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); setFocusIndex((i) => Math.min(stickers.length - 1, i + cols)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusIndex((i) => Math.max(0, i - cols)); }
|
||||
else if (e.key === 'Enter' || e.key === ' ') {
|
||||
const items = gridRef.current?.querySelectorAll<HTMLElement>('[role="option"]');
|
||||
if (!items?.length) return;
|
||||
|
||||
const moveTo = (next: number) => {
|
||||
e.preventDefault();
|
||||
const i = Math.max(0, Math.min(items.length - 1, next));
|
||||
setFocusIndex(i);
|
||||
items[i]?.focus();
|
||||
};
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
moveTo(focusIndex + 1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
moveTo(focusIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
// 颜文字宽度不固定,按视觉行列找下一格,避免按固定 8 列错位
|
||||
e.preventDefault();
|
||||
const cur = items[focusIndex];
|
||||
if (!cur) return;
|
||||
const cr = cur.getBoundingClientRect();
|
||||
const cx = cr.left + cr.width / 2;
|
||||
const cy = cr.top + cr.height / 2;
|
||||
const dir = e.key === 'ArrowDown' ? 1 : -1;
|
||||
let best = -1;
|
||||
let bestScore = Infinity;
|
||||
items.forEach((el, i) => {
|
||||
if (i === focusIndex) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const dy = (r.top + r.height / 2) - cy;
|
||||
if (dy * dir <= 6) return;
|
||||
const score = Math.abs(dy) * 24 + Math.abs((r.left + r.width / 2) - cx);
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = i;
|
||||
}
|
||||
});
|
||||
if (best >= 0) {
|
||||
setFocusIndex(best);
|
||||
items[best]?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const s = stickers[focusIndex];
|
||||
if (s) onSelect(s);
|
||||
@@ -73,6 +114,7 @@ export default function StickerPicker({ onSelect }: Props) {
|
||||
ref={gridRef}
|
||||
className="sticker-picker-grid"
|
||||
role="listbox"
|
||||
tabIndex={0}
|
||||
aria-label={`${active}贴纸`}
|
||||
aria-activedescendant={`${autoId}-opt-${focusIndex}`}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -82,33 +124,35 @@ export default function StickerPicker({ onSelect }: Props) {
|
||||
) : stickers.length === 0 ? (
|
||||
<div className="sticker-picker-loading">暂无贴纸</div>
|
||||
) : (
|
||||
stickers.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
id={`${autoId}-opt-${i}`}
|
||||
type="button"
|
||||
role="option"
|
||||
tabIndex={focusIndex === i ? 0 : -1}
|
||||
aria-selected={focusIndex === i}
|
||||
aria-label={s.name}
|
||||
className="sticker-picker-item"
|
||||
onClick={() => onSelect(s)}
|
||||
onFocus={() => setFocusIndex(i)}
|
||||
>
|
||||
{s.type === 'text' && s.text ? (
|
||||
<span className="sticker-picker-text">{s.text}</span>
|
||||
) : (
|
||||
<img
|
||||
src={s.url}
|
||||
alt={s.name}
|
||||
width={32}
|
||||
height={32}
|
||||
style={{ width: 32, height: 32, objectFit: 'contain' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
stickers.map((s, i) => {
|
||||
const isText = s.type === 'text' && !!s.text;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
id={`${autoId}-opt-${i}`}
|
||||
type="button"
|
||||
role="option"
|
||||
tabIndex={focusIndex === i ? 0 : -1}
|
||||
aria-selected={focusIndex === i}
|
||||
aria-label={s.name}
|
||||
className={isText ? 'sticker-picker-item sticker-picker-item--text' : 'sticker-picker-item sticker-picker-item--image'}
|
||||
onClick={() => onSelect(s)}
|
||||
onFocus={() => setFocusIndex(i)}
|
||||
>
|
||||
{isText ? (
|
||||
<span className="sticker-picker-text">{s.text}</span>
|
||||
) : (
|
||||
<img
|
||||
src={s.url}
|
||||
alt={s.name}
|
||||
width={32}
|
||||
height={32}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,10 +15,7 @@ export async function loadHotStickers(): Promise<Sticker[]> {
|
||||
const allEmoji = getAllStickers();
|
||||
const all = [...allEmoji, ...KAOMOJI_STICKERS];
|
||||
return all
|
||||
.filter((s) => {
|
||||
if (s.category === '颜文字') return true;
|
||||
return s.aliases?.some((a) => HOT_KEYWORDS.includes(a)) || HOT_KEYWORDS.includes(s.name);
|
||||
})
|
||||
.filter((s) => s.aliases?.some((a) => HOT_KEYWORDS.includes(a)) || HOT_KEYWORDS.includes(s.name))
|
||||
.slice(0, 30)
|
||||
.map((s) => ({ ...s, category: '热门' as const }));
|
||||
}
|
||||
|
||||
@@ -3,30 +3,54 @@ import type { Sticker } from './index';
|
||||
/** 颜文字贴纸 — 纯文本类型,选择器和编辑器中均作为纯文本显示 */
|
||||
|
||||
export const KAOMOJI_STICKERS: Sticker[] = [
|
||||
{ id: 'km-happy', name: '开心', category: '颜文字', type: 'text', text: '(ノ´∀`)ノ', aliases: ['哈哈', '开心'] },
|
||||
{ id: 'km-laugh', name: '大笑', category: '颜文字', type: 'text', text: '(≧∇≦)ノ', aliases: ['哈哈哈', '笑死'] },
|
||||
{ id: 'km-cry', name: '哭', category: '颜文字', type: 'text', text: '(´;ω;`)', aliases: ['呜呜', '伤心'] },
|
||||
{ id: 'km-angry', name: '生气', category: '颜文字', type: 'text', text: 'ヽ(`⌒´)ノ', aliases: ['怒', '气死'] },
|
||||
{ id: 'km-shrug', name: '无奈', category: '颜文字', type: 'text', text: '╮(°-°)╭', aliases: ['呵呵', '无语'] },
|
||||
{ id: 'km-determined', name: '加油', category: '颜文字', type: 'text', text: '(๑•̀ㅁ•́ฅ)', aliases: ['冲', '奥利给'] },
|
||||
{ id: 'km-sad', name: '难过', category: '颜文字', type: 'text', text: '( ´・ω・`)', aliases: ['失落', '低落'] },
|
||||
{ id: 'km-sparkle', name: '兴奋', category: '颜文字', type: 'text', text: '(ノ´ヮ`)ノ*: ・゚', aliases: ['太棒了', '耶'] },
|
||||
{ id: 'km-tear', name: '泪奔', category: '颜文字', type: 'text', text: '(╥﹏╥)', aliases: ['泪流', '呜呜'] },
|
||||
{ id: 'km-love', name: '喜欢', category: '颜文字', type: 'text', text: '(◕ᴗ◕✿)', aliases: ['爱', '心动'] },
|
||||
{ id: 'km-cool', name: '酷', category: '颜文字', type: 'text', text: '(⌐■_■)', aliases: ['帅', '墨镜'] },
|
||||
{ id: 'km-stare', name: '盯', category: '颜文字', type: 'text', text: 'ಠ_ಠ', aliases: ['凝视', '盯着看'] },
|
||||
{ id: 'km-tableflip', name: '掀桌', category: '颜文字', type: 'text', text: '(╯°□°)╯︵ ┻━┻', aliases: ['掀桌', '愤怒'] },
|
||||
{ id: 'km-bow', name: '拜托', category: '颜文字', type: 'text', text: '(人・ω・)💦', aliases: ['求求', '拜托了'] },
|
||||
{ id: 'km-proud', name: '得意', category: '颜文字', type: 'text', text: '( ̄▽ ̄)"', aliases: ['嘿嘿', '自满'] },
|
||||
{ id: 'km-sleep', name: '困', category: '颜文字', type: 'text', text: '(-ω-)Zzz', aliases: ['睡觉', '晚安'] },
|
||||
{ id: 'km-wave', name: '招手', category: '颜文字', type: 'text', text: '(´・ω・)ノ', aliases: ['你好', '拜拜'] },
|
||||
{ id: 'km-wink', name: '眨眼', category: '颜文字', type: 'text', text: '(◠‿◠)', aliases: ['抛媚眼', '嘿嘿'] },
|
||||
{ id: 'km-sorry', name: '抱歉', category: '颜文字', type: 'text', text: 'm(._.)m', aliases: ['对不起', '跪了'] },
|
||||
{ id: 'km-doubt', name: '疑惑', category: '颜文字', type: 'text', text: '(╬ Ò _ Ó)', aliases: ['什么', '???'] },
|
||||
{ id: 'km-hungry', name: '饿了', category: '颜文字', type: 'text', text: '(๑´ㅂ`๑)', aliases: ['想吃', '吃货'] },
|
||||
{ id: 'km-gameover', name: 'GG', category: '颜文字', type: 'text', text: '(╯︿╰﹀ )', aliases: ['GG', '完了'] },
|
||||
{ id: 'km-gift', name: '送花', category: '颜文字', type: 'text', text: '(✿◠‿◠)', aliases: ['送花', '谢谢'] },
|
||||
{ id: 'km-clap', name: '鼓掌', category: '颜文字', type: 'text', text: 'ヾ(´▽`;)ゝ', aliases: ['呱唧', '鼓掌'] },
|
||||
{ id: 'km-cheer', name: '加油', category: '颜文字', type: 'text', text: '\(^ω^\)', aliases: ['冲鸭', 'go'] },
|
||||
{ id: 'km-please', name: '拜托了', category: '颜文字', type: 'text', text: '( ´・ω・`)', aliases: ['嘤嘤', '求求了'] },
|
||||
{ id: 'km-wave-half', name: '勉强挥手', category: '颜文字', type: 'text', text: '( ̄▽ ̄)ノ', aliases: ['挥手', '嗨'] },
|
||||
{ id: 'km-shrug-ascii', name: '摊手', category: '颜文字', type: 'text', text: '¯\\_(ツ)_/¯', aliases: ['摊手', '无奈'] },
|
||||
{ id: 'km-eyeroll', name: '翻白眼', category: '颜文字', type: 'text', text: '(¬_¬)', aliases: ['白眼', '嫌弃'] },
|
||||
{ id: 'km-speechless', name: '无语凝噎', category: '颜文字', type: 'text', text: '(;一_一)', aliases: ['无语', '沉默'] },
|
||||
{ id: 'km-shock-idle', name: '震惊但不想管', category: '颜文字', type: 'text', text: '( ゚д゚)', aliases: ['震惊', '惊讶'] },
|
||||
{ id: 'km-dead-inside', name: '心死', category: '颜文字', type: 'text', text: '(。_。)', aliases: ['心死', '无力'] },
|
||||
{ id: 'km-lazy', name: '懒得动', category: '颜文字', type: 'text', text: '( ˘ω˘ )', aliases: ['懒', '摆烂'] },
|
||||
{ id: 'km-awkward-smile', name: '尴尬微笑', category: '颜文字', type: 'text', text: '( ̄ω ̄;)', aliases: ['尴尬', '呵呵'] },
|
||||
{ id: 'km-sob', name: '哭到抽搐', category: '颜文字', type: 'text', text: '(´;ω;`)', aliases: ['哭', '大哭'] },
|
||||
{ id: 'km-grievance', name: '委屈巴巴', category: '颜文字', type: 'text', text: '(๑•́ ₃ •̀๑)', aliases: ['委屈', '嘤嘤'] },
|
||||
{ id: 'km-blush-blur', name: '害羞到糊掉', category: '颜文字', type: 'text', text: '(⁄ ⁄•⁄ω⁄•⁄ ⁄)', aliases: ['害羞', '脸红'] },
|
||||
{ id: 'km-grit', name: '咬牙切齿', category: '颜文字', type: 'text', text: '(╬  ̄皿 ̄)', aliases: ['怒', '生气'] },
|
||||
{ id: 'km-short-rage', name: '暴怒短号', category: '颜文字', type: 'text', text: '(`Д´)', aliases: ['怒', '气死'] },
|
||||
{ id: 'km-fist-rage', name: '气到挥拳', category: '颜文字', type: 'text', text: '٩(๑`^´๑)۶', aliases: ['怒', '挥拳'] },
|
||||
{ id: 'km-grit-spark', name: '憋屈但要干', category: '颜文字', type: 'text', text: '(๑•̀ㅂ•́)و✧', aliases: ['冲', '加油'] },
|
||||
{ id: 'km-weep', name: '哭唧唧', category: '颜文字', type: 'text', text: '( ˃̣̣̥ω˂̣̣̥ )', aliases: ['哭', '呜呜'] },
|
||||
{ id: 'km-scamper', name: '撒欢跑走', category: '颜文字', type: 'text', text: 'ᕕ( ᐛ )ᕗ', aliases: ['跑', '溜了'] },
|
||||
{ id: 'km-star-throw', name: '丢星星', category: '颜文字', type: 'text', text: '(ノ≧∀≦)ノ ‥…━━━★', aliases: ['耶', '星星'] },
|
||||
{ id: 'km-flee', name: '落荒而逃', category: '颜文字', type: 'text', text: 'ε=ε=ε=┌(;*´Д`)ノ', aliases: ['逃', '溜'] },
|
||||
{ id: 'km-unflip', name: '把桌摆回去', category: '颜文字', type: 'text', text: '┬─┬ノ( º _ ºノ)', aliases: ['摆桌', '冷静'] },
|
||||
{ id: 'km-flip-hard', name: '狠掀桌', category: '颜文字', type: 'text', text: '(┛ಠ_ಠ)┛彡┻━┻', aliases: ['掀桌', '怒'] },
|
||||
{ id: 'km-victory-l', name: '胜利举手', category: '颜文字', type: 'text', text: '┏(^0^)┛', aliases: ['胜利', '耶'] },
|
||||
{ id: 'km-victory-r', name: '对面胜利', category: '颜文字', type: 'text', text: '┗(^0^)┓', aliases: ['胜利', '嗨'] },
|
||||
{ id: 'km-point', name: '指你呢', category: '颜文字', type: 'text', text: '(☞゚ヮ゚)☞', aliases: ['指', '就是你'] },
|
||||
{ id: 'km-point-back', name: '指回去', category: '颜文字', type: 'text', text: '☜(゚ヮ゚☜)', aliases: ['指回去', '你才'] },
|
||||
{ id: 'km-cat', name: '猫', category: '颜文字', type: 'text', text: '(=^・ω・^=)', aliases: ['猫', '喵'] },
|
||||
{ id: 'km-cat-round', name: '圆眼猫', category: '颜文字', type: 'text', text: '(ΦωΦ)', aliases: ['猫', '圆眼'] },
|
||||
{ id: 'km-bear', name: '熊', category: '颜文字', type: 'text', text: 'ʕ•ᴥ•ʔ', aliases: ['熊', '抱抱'] },
|
||||
{ id: 'km-flower-ear', name: '花耳', category: '颜文字', type: 'text', text: '◕‿◕✿', aliases: ['花', '可爱'] },
|
||||
{ id: 'km-angry-bird', name: '怒鸟', category: '颜文字', type: 'text', text: '(ꐦ°᷄д°᷅)', aliases: ['怒', '生气'] },
|
||||
{ id: 'km-smug-wolf', name: '狼尾得意', category: '颜文字', type: 'text', text: '( •̀ ω •́ )✧', aliases: ['得意', '酷'] },
|
||||
{ id: 'km-cat-wave', name: '猫招手', category: '颜文字', type: 'text', text: '~(=^‥^)ノ', aliases: ['猫', '招手'] },
|
||||
{ id: 'km-hehe', name: '呵呵', category: '颜文字', type: 'text', text: '( ´_ゝ`)', aliases: ['呵呵', '滑稽'] },
|
||||
{ id: 'km-server-down', name: '服务器炸了', category: '颜文字', type: 'text', text: '(;´Д`)', aliases: ['炸了', '宕机'] },
|
||||
{ id: 'km-double-shock', name: '双重震惊', category: '颜文字', type: 'text', text: '(゚Д゚≡゚д゚)!?', aliases: ['震惊', '惊讶'] },
|
||||
{ id: 'km-cold-sweat', name: '冷汗惊恐', category: '颜文字', type: 'text', text: 'Σ(°△°|||)︴', aliases: ['惊恐', '惊讶'] },
|
||||
{ id: 'km-hang', name: '宕机', category: '颜文字', type: 'text', text: '(;°○° )', aliases: ['宕机', '卡死'] },
|
||||
{ id: 'km-pass-box', name: '递箱子', category: '颜文字', type: 'text', text: '( ゚∀゚)つ□', aliases: ['递', '补丁'] },
|
||||
{ id: 'km-shoulder', name: '拍对方肩', category: '颜文字', type: 'text', text: '( ´▽`)σ)Д`)', aliases: ['拍肩', '兄弟'] },
|
||||
{ id: 'km-silent-crash', name: '无声崩溃', category: '颜文字', type: 'text', text: '(-_-;)・・・', aliases: ['崩溃', '尴尬'] },
|
||||
{ id: 'km-blank', name: '呆滞', category: '颜文字', type: 'text', text: '(。ŏ_ŏ)', aliases: ['发呆', '呆'] },
|
||||
{ id: 'km-side-eye', name: '眯眼嫌弃', category: '颜文字', type: 'text', text: '(๑¯ω¯๑)', aliases: ['嫌弃', '眯眼'] },
|
||||
{ id: 'km-lick', name: '舔嘴', category: '颜文字', type: 'text', text: '(๑´ڡ`๑)', aliases: ['好吃', '馋'] },
|
||||
{ id: 'km-round-laugh', name: '圆滚滚笑', category: '颜文字', type: 'text', text: '( ˶˚ ᗨ ˚˶ )', aliases: ['哈哈', '大笑'] },
|
||||
{ id: 'km-hug-ask', name: '求抱抱', category: '颜文字', type: 'text', text: '(っ˘̩╭╮˘̩)っ', aliases: ['抱抱', '求抱'] },
|
||||
{ id: 'km-rage-yi', name: '暴怒益字', category: '颜文字', type: 'text', text: '(╬ಠ益ಠ)', aliases: ['怒', '生气'] },
|
||||
{ id: 'km-fight', name: '对线准备', category: '颜文字', type: 'text', text: "(ง'̀-'́)ง", aliases: ['对线', '来战'] },
|
||||
{ id: 'km-cat-minimal', name: '极简猫', category: '颜文字', type: 'text', text: 'ᓚᘏᗢ', aliases: ['猫', '喵'] },
|
||||
{ id: 'km-pounce', name: '飞扑拥抱', category: '颜文字', type: 'text', text: '(づ。◕‿‿◕。)づ', aliases: ['拥抱', '爱'] },
|
||||
{ id: 'km-suspect', name: '怀疑人生', category: '颜文字', type: 'text', text: '( ◔ ʖ̯ ◔ )', aliases: ['怀疑', '思考'] },
|
||||
];
|
||||
|
||||
@@ -5742,7 +5742,7 @@ a.post-title:visited {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.post-detail-content img:not([src*="/stickers/"]) {
|
||||
.post-detail-content img:not([src*="/stickers/"]):not(.article-sticker) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
@@ -6849,6 +6849,17 @@ a.post-title:visited {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.article-editor > .sticker-picker {
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
box-shadow: none;
|
||||
z-index: 28;
|
||||
}
|
||||
|
||||
.sticker-picker-tabs {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
@@ -6875,30 +6886,86 @@ a.post-title:visited {
|
||||
}
|
||||
|
||||
.sticker-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
--sticker-size: 32px;
|
||||
--sticker-gap: var(--sticker-size);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
justify-content: flex-start;
|
||||
column-gap: var(--sticker-gap);
|
||||
row-gap: var(--sticker-gap);
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sticker-picker-grid:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sticker-picker-item {
|
||||
position: relative;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 2px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background 0.1s, transform 0.1s;
|
||||
transition: background 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sticker-picker-item--image {
|
||||
flex: 0 0 var(--sticker-size);
|
||||
width: var(--sticker-size);
|
||||
height: var(--sticker-size);
|
||||
}
|
||||
|
||||
.sticker-picker-item--image img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: var(--sticker-size);
|
||||
height: var(--sticker-size);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sticker-picker-item--text {
|
||||
flex: 0 0 auto;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
min-height: var(--sticker-size);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.sticker-picker-item:hover {
|
||||
background: var(--color-fill-2);
|
||||
transform: scale(1.15);
|
||||
background: color-mix(in srgb, var(--color-text-1) 8%, var(--j13-bg-surface));
|
||||
}
|
||||
|
||||
/* 图片格与表情同大,浅灰底扩到空隙里才看得见 */
|
||||
.sticker-picker-item--image::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -10px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sticker-picker-item--image:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sticker-picker-item--image:hover::before {
|
||||
background: color-mix(in srgb, var(--color-text-1) 8%, var(--j13-bg-surface));
|
||||
}
|
||||
|
||||
.sticker-picker-item:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sticker-picker-item:focus-visible {
|
||||
@@ -6907,7 +6974,8 @@ a.post-title:visited {
|
||||
}
|
||||
|
||||
.sticker-picker-loading {
|
||||
grid-column: 1 / -1;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--color-text-4);
|
||||
@@ -6939,9 +7007,10 @@ a.post-title:visited {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.comment-editor .article-tool-btn--owo {
|
||||
.comment-editor .article-tool-btn--owo,
|
||||
.article-tool-btn--owo {
|
||||
width: auto;
|
||||
min-width: 28px;
|
||||
min-width: 32px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
@@ -6972,11 +7041,19 @@ a.post-title:visited {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 评论正文中的贴纸 img */
|
||||
/* 评论 / 帖子正文 / 发帖编辑器中的贴纸:行内小图,不占一段 */
|
||||
.floor-body img[src*="/stickers/"],
|
||||
.comment-body img[src*="/stickers/"] {
|
||||
.comment-body img[src*="/stickers/"],
|
||||
.post-detail-content img[src*="/stickers/"],
|
||||
.post-detail-content img.article-sticker,
|
||||
.site-page__body img[src*="/stickers/"],
|
||||
.site-page__body img.article-sticker,
|
||||
.article-prosemirror img[src*="/stickers/"],
|
||||
.article-prosemirror img.article-sticker,
|
||||
.article-editor-content img[src*="/stickers/"],
|
||||
.article-editor-content img.article-sticker {
|
||||
display: inline-block !important;
|
||||
vertical-align: middle;
|
||||
vertical-align: text-bottom;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
max-width: 28px;
|
||||
@@ -6988,38 +7065,22 @@ a.post-title:visited {
|
||||
clear: none !important;
|
||||
}
|
||||
|
||||
/* 编辑器内贴纸 img 尺寸约束 */
|
||||
.comment-editor .article-prosemirror img[src*="/stickers/"],
|
||||
.comment-editor .article-editor-content img[src*="/stickers/"] {
|
||||
display: inline-block !important;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
max-width: 28px;
|
||||
vertical-align: middle;
|
||||
margin: 0 1px !important;
|
||||
border-radius: 4px;
|
||||
box-shadow: none !important;
|
||||
object-fit: contain;
|
||||
clear: none !important;
|
||||
}
|
||||
|
||||
/* 选择器中颜文字纯文本样式 */
|
||||
/* 选择器中颜文字:宽度随内容,不截断、不挤进固定格子 */
|
||||
.sticker-picker-text {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
font-family: 'Segoe UI Emoji', 'Apple Color Emoji', 'Noto Sans Mono', monospace;
|
||||
color: var(--color-text-1);
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
word-break: normal;
|
||||
text-align: center;
|
||||
padding: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 移动端:贴纸选择器 4 列 */
|
||||
/* 移动端:颜文字仍按内容宽度;图片间距仍等于一枚表情 */
|
||||
@media (max-width: 640px) {
|
||||
.sticker-picker { max-height: 240px; }
|
||||
.sticker-picker-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.sticker-picker-tab { padding: 6px 10px; font-size: 12px; }
|
||||
.comment-editor .article-tool-btn { width: 30px; height: 30px; }
|
||||
.comment-editor .article-tool-btn--owo { width: auto; min-width: 30px; padding: 0 8px; }
|
||||
@@ -7220,6 +7281,21 @@ a.waline-comment-author:hover {
|
||||
line-height: 1.65;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 评论沿用文章列表 / 段落间距:Enter 分段,Shift+Enter 为段内换行 */
|
||||
.floor-body.post-detail-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.floor-body.post-detail-content > p:first-child,
|
||||
.comment-editor .post-detail-content > p:first-child {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.waline-comment-bubble .quote-block {
|
||||
margin: 6px 0;
|
||||
background: var(--j13-bg-block);
|
||||
@@ -10401,7 +10477,7 @@ button.profile-stat:hover strong {
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.article-prosemirror img {
|
||||
.article-prosemirror img:not([src*="/stickers/"]):not(.article-sticker) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
import { enhanceCodeBlocks } from './enhanceCodeBlocks';
|
||||
|
||||
/** 转义 HTML 并保留换行 */
|
||||
function escapeWithBreaks(text: string): string {
|
||||
@@ -60,11 +61,15 @@ function processMentionsInHtml(html: string): string {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/** 渲染评论内容:HTML 净化 + @提及高亮,兼容旧版纯文本 */
|
||||
/** 渲染评论内容:HTML 净化 + @提及高亮 + 代码块阅读态,兼容旧版纯文本 */
|
||||
export function renderCommentContent(content: string): string {
|
||||
if (isHtmlContent(content)) {
|
||||
const sanitized = DOMPurify.sanitize(content, POST_CONTENT_PURIFY_CONFIG) as string;
|
||||
return processMentionsInHtml(sanitized);
|
||||
const withMentions = processMentionsInHtml(sanitized);
|
||||
const doc = new DOMParser().parseFromString(`<div id="j13-comment-root">${withMentions}</div>`, 'text/html');
|
||||
const root = doc.getElementById('j13-comment-root') ?? doc.body;
|
||||
enhanceCodeBlocks(root);
|
||||
return root.innerHTML;
|
||||
}
|
||||
return highlightMentions(content);
|
||||
}
|
||||
|
||||
@@ -177,3 +177,44 @@ export function enhanceCodeBlocks(root: ParentNode): void {
|
||||
if (display.lineNumbers) pre.classList.add('md-codeblock__pre--lines');
|
||||
});
|
||||
}
|
||||
|
||||
const CODE_FOLD_SHORT_LINES = 5;
|
||||
|
||||
/**
|
||||
* 阅读态代码块:折叠 / 复制。由帖子与评论共用,点击已处理时返回 true。
|
||||
*/
|
||||
export async function handleMdCodeBlockUiClick(target: EventTarget | null): Promise<boolean> {
|
||||
if (!(target instanceof Element)) return false;
|
||||
|
||||
const foldBtn = target.closest<HTMLElement>('[data-code-fold]');
|
||||
if (foldBtn) {
|
||||
const block = foldBtn.closest('.md-codeblock');
|
||||
if (!block) return true;
|
||||
const collapsed = block.classList.toggle('md-codeblock--collapsed');
|
||||
const lineCount = parseInt(block.getAttribute('data-line-count') || '0', 10)
|
||||
|| block.querySelectorAll('.md-code-line').length
|
||||
|| 1;
|
||||
if (collapsed && lineCount <= CODE_FOLD_SHORT_LINES) block.classList.add('md-codeblock--short');
|
||||
else block.classList.remove('md-codeblock--short');
|
||||
foldBtn.textContent = collapsed ? '展开' : '收起';
|
||||
return true;
|
||||
}
|
||||
|
||||
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
|
||||
if (!copyBtn) return false;
|
||||
|
||||
const block = copyBtn.closest('.md-codeblock');
|
||||
const bodies = block?.querySelectorAll('.md-code-line__body');
|
||||
const text = bodies && bodies.length
|
||||
? [...bodies].map(el => el.textContent ?? '').join('\n')
|
||||
: (block?.querySelector('pre')?.textContent ?? '');
|
||||
await navigator.clipboard.writeText(text);
|
||||
const prev = copyBtn.textContent;
|
||||
copyBtn.textContent = '已复制';
|
||||
copyBtn.classList.add('is-copied');
|
||||
window.setTimeout(() => {
|
||||
copyBtn.textContent = prev || '复制';
|
||||
copyBtn.classList.remove('is-copied');
|
||||
}, 1600);
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user