From 76be8926f2cfc1a9c58ff0d773f69fd6ef6393d0 Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 2 Sep 2026 03:57:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E8=A1=A8=E6=83=85?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E4=B8=8E=E8=AF=84=E8=AE=BA=E5=AF=8C=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发帖编辑器支持 OwO 贴纸;评论区复用文章列表、段落与代码块阅读态,并优化表情面板间距与悬停反馈。 Co-authored-by: Cursor --- frontend/src/components/ArticleEditor.tsx | 112 +++++++++++-- frontend/src/components/CommentContent.tsx | 40 +++-- frontend/src/components/PostContent.tsx | 39 +---- .../editor/ArticleImageExtension.tsx | 14 ++ .../editor/ArticleStickerExtension.ts | 54 +++++++ .../src/components/emoji/StickerPicker.tsx | 116 +++++++++----- frontend/src/data/stickers/hot.ts | 5 +- frontend/src/data/stickers/kaomoji.ts | 76 ++++++--- frontend/src/styles/global.css | 150 +++++++++++++----- frontend/src/utils/content.ts | 9 +- frontend/src/utils/enhanceCodeBlocks.ts | 41 +++++ 11 files changed, 492 insertions(+), 164 deletions(-) create mode 100644 frontend/src/components/editor/ArticleStickerExtension.ts diff --git a/frontend/src/components/ArticleEditor.tsx b/frontend/src/components/ArticleEditor.tsx index dfaeeb1..f4990d2 100644 --- a/frontend/src/components/ArticleEditor.tsx +++ b/frontend/src/components/ArticleEditor.tsx @@ -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; 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} @@ -213,7 +228,10 @@ const ArticleEditor = forwardRef(function ArticleEdi const [tableDialogOpen, setTableDialogOpen] = useState(false); const [tableTarget, setTableTarget] = useState('rich'); const [tableEditing, setTableEditing] = useState(false); + const [showSticker, setShowSticker] = useState(false); const markdownRef = useRef(null); + const editorBoxRef = useRef(null); + const stickerBtnRef = useRef(null); const editor = useEditor({ extensions: [ @@ -246,6 +264,7 @@ const ArticleEditor = forwardRef(function ArticleEdi }, }), ArticleImage.configure({ inline: false, allowBase64: false }), + ArticleSticker, ImageGroup, Placeholder.configure({ placeholder: ({ node }) => { @@ -322,23 +341,42 @@ const ArticleEditor = forwardRef(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(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 ? `![${sticker.name || '表情'}](${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(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(function ArticleEdi editor.commands.setContent(html || '', { emitUpdate: false }); placeCaretInFirstTextblock(editor); } + setShowSticker(false); setMode('rich'); }, [editor, markdownSource, onChange]); @@ -676,6 +740,15 @@ const ArticleEditor = forwardRef(function ArticleEdi active: groupActive, action: wrapSelectedAsGroup, }, + { + icon: OwO, + 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(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(function ArticleEdi hint: '上传、链接或从已上传中选择', action: () => openImagePicker('markdown'), }, + { + icon: OwO, + 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(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(function ArticleEdi : (editor ? countWords(editor.getText()) : 0); return ( -
+
{renderToolButtons(tools)}
+ {showSticker && }
{mode === 'rich' ? ( diff --git a/frontend/src/components/CommentContent.tsx b/frontend/src/components/CommentContent.tsx index 0ad22df..8c51b12 100644 --- a/frontend/src/components/CommentContent.tsx +++ b/frontend/src/components/CommentContent.tsx @@ -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 (
{ - 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 }} /> ); } diff --git a/frontend/src/components/PostContent.tsx b/frontend/src/components/PostContent.tsx index a8e8986..31209a8 100644 --- a/frontend/src/components/PostContent.tsx +++ b/frontend/src/components/PostContent.tsx @@ -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('[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('[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]); diff --git a/frontend/src/components/editor/ArticleImageExtension.tsx b/frontend/src/components/editor/ArticleImageExtension.tsx index 2257e48..41a569a 100644 --- a/frontend/src/components/editor/ArticleImageExtension.tsx +++ b/frontend/src/components/editor/ArticleImageExtension.tsx @@ -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?.(), diff --git a/frontend/src/components/editor/ArticleStickerExtension.ts b/frontend/src/components/editor/ArticleStickerExtension.ts new file mode 100644 index 0000000..47272c6 --- /dev/null +++ b/frontend/src/components/editor/ArticleStickerExtension.ts @@ -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', + }), + ]; + }, +}); diff --git a/frontend/src/components/emoji/StickerPicker.tsx b/frontend/src/components/emoji/StickerPicker.tsx index a9d489f..ef24ed7 100644 --- a/frontend/src/components/emoji/StickerPicker.tsx +++ b/frontend/src/components/emoji/StickerPicker.tsx @@ -36,17 +36,58 @@ export default function StickerPicker({ onSelect }: Props) { }, [active]); useEffect(() => { - const el = gridRef.current?.querySelectorAll('[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('[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 ? (
暂无贴纸
) : ( - stickers.map((s, i) => ( - - )) + stickers.map((s, i) => { + const isText = s.type === 'text' && !!s.text; + return ( + + ); + }) )}
diff --git a/frontend/src/data/stickers/hot.ts b/frontend/src/data/stickers/hot.ts index cbb281c..f4a0298 100644 --- a/frontend/src/data/stickers/hot.ts +++ b/frontend/src/data/stickers/hot.ts @@ -15,10 +15,7 @@ export async function loadHotStickers(): Promise { 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 })); } diff --git a/frontend/src/data/stickers/kaomoji.ts b/frontend/src/data/stickers/kaomoji.ts index 98f7039..a6bbed2 100644 --- a/frontend/src/data/stickers/kaomoji.ts +++ b/frontend/src/data/stickers/kaomoji.ts @@ -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: ['怀疑', '思考'] }, ]; diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 9cb538b..c5475b4 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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; diff --git a/frontend/src/utils/content.ts b/frontend/src/utils/content.ts index 23921d2..1a6e659 100644 --- a/frontend/src/utils/content.ts +++ b/frontend/src/utils/content.ts @@ -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(`
${withMentions}
`, 'text/html'); + const root = doc.getElementById('j13-comment-root') ?? doc.body; + enhanceCodeBlocks(root); + return root.innerHTML; } return highlightMentions(content); } diff --git a/frontend/src/utils/enhanceCodeBlocks.ts b/frontend/src/utils/enhanceCodeBlocks.ts index aebb0b7..7608f6d 100644 --- a/frontend/src/utils/enhanceCodeBlocks.ts +++ b/frontend/src/utils/enhanceCodeBlocks.ts @@ -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 { + if (!(target instanceof Element)) return false; + + const foldBtn = target.closest('[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('[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; +}