feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增: - BrandCropModal 品牌图裁剪弹窗 + cropImage 工具 - Markdown 行内短代码(inlineShortcodes + remark 插件) - MdDetails 折叠块、MdVideo 视频块组件 - directiveBlocks/inlineShortcodes 测试 其它: - 聊天后端(handler/service chat)调整 - Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
import { Images, Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "@/components/Modal";
|
||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
|
||||
import { apiAdminMediaLibrary, apiMyMedia } from "@/lib/api";
|
||||
import { normalizeBrandURL, type BrandSlot } from "@/lib/brand";
|
||||
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||
|
||||
const SLOT_LABEL: Record<BrandSlot, string> = {
|
||||
@@ -13,6 +13,13 @@ const SLOT_LABEL: Record<BrandSlot, string> = {
|
||||
favicon: "Favicon",
|
||||
};
|
||||
|
||||
/** 选图条目:本人附件带 attachmentId;品牌目录历史图仅 URL(已在品牌目录,无需复制) */
|
||||
export type BrandPickItem = {
|
||||
key: string;
|
||||
url: string;
|
||||
attachmentId: number | null;
|
||||
};
|
||||
|
||||
export default function BrandMediaPicker({
|
||||
open,
|
||||
slot,
|
||||
@@ -22,9 +29,9 @@ export default function BrandMediaPicker({
|
||||
open: boolean;
|
||||
slot: BrandSlot;
|
||||
onClose: () => void;
|
||||
onPick: (item: MediaAttachment) => void;
|
||||
onPick: (item: BrandPickItem) => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<MediaAttachment[]>([]);
|
||||
const [items, setItems] = useState<BrandPickItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -33,16 +40,29 @@ export default function BrandMediaPicker({
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void apiMyMedia()
|
||||
.then((res) => {
|
||||
// 本人附件 + 媒体库盘点里的品牌目录历史图(品牌图不落附件,需合并展示)
|
||||
Promise.all([apiMyMedia(), apiAdminMediaLibrary().catch(() => null)])
|
||||
.then(([mediaRes, libRes]) => {
|
||||
if (cancelled) return;
|
||||
if (res.error) {
|
||||
setError(res.error);
|
||||
if (mediaRes.error) {
|
||||
setError(mediaRes.error);
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
const all = res.attachments || [];
|
||||
setItems(all.filter((item) => mediaURLFitsSlot(slot, item.url)));
|
||||
const out: BrandPickItem[] = (mediaRes.attachments || []).map((a) => ({
|
||||
key: `att-${a.id}`,
|
||||
url: a.url,
|
||||
attachmentId: a.id,
|
||||
}));
|
||||
const seen = new Set(out.map((i) => i.url));
|
||||
for (const it of libRes?.items || []) {
|
||||
if (it.category !== "brand") continue;
|
||||
if (!normalizeBrandURL(slot, it.url)) continue;
|
||||
if (seen.has(it.url)) continue;
|
||||
seen.add(it.url);
|
||||
out.unshift({ key: `brand-${it.url}`, url: it.url, attachmentId: null });
|
||||
}
|
||||
setItems(out);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("加载媒体库失败");
|
||||
@@ -59,7 +79,7 @@ export default function BrandMediaPicker({
|
||||
<Modal
|
||||
open={open}
|
||||
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
|
||||
description="只列出格式符合的图片。保存后会复制一份到品牌目录,之后删除原图不会影响 Logo。"
|
||||
description="位图选中后进入裁剪并转成 WebP;SVG / ICO 与历史品牌图直接引用,之后删除原图不会影响 Logo。"
|
||||
onClose={onClose}
|
||||
maxWidthClass="max-w-lg"
|
||||
dismissOnOverlay
|
||||
@@ -73,12 +93,12 @@ export default function BrandMediaPicker({
|
||||
{error}
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="meta text-[13px] py-6 text-center">没有可用图片。Logo 需要 PNG / WebP / SVG,Favicon 还可选 ICO。</p>
|
||||
<p className="meta text-[13px] py-6 text-center">媒体库还没有图片,先上传一张吧。</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick(item);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AdminSettingsActions,
|
||||
AdminSettingsGroup,
|
||||
} from "@/components/admin";
|
||||
import BrandCropModal from "@/components/BrandCropModal";
|
||||
import BrandLockup from "@/components/BrandLockup";
|
||||
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
brandAccept,
|
||||
brandAliasPath,
|
||||
brandFileOk,
|
||||
normalizeBrandURL,
|
||||
type BrandLogoFit,
|
||||
type BrandLogoSize,
|
||||
type BrandMark,
|
||||
@@ -77,6 +79,9 @@ export default function BrandSeoPanel({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
||||
const [cropSlot, setCropSlot] = useState<BrandSlot | null>(null);
|
||||
const [cropSeedFile, setCropSeedFile] = useState<File | null>(null);
|
||||
const [cropSeedUrl, setCropSeedUrl] = useState("");
|
||||
const [copiedSlot, setCopiedSlot] = useState<BrandSlot | null>(null);
|
||||
const blobs = useRef<string[]>([]);
|
||||
const saveGen = useRef(0);
|
||||
@@ -123,6 +128,18 @@ export default function BrandSeoPanel({
|
||||
return draft.committed;
|
||||
};
|
||||
|
||||
const openCrop = (slot: BrandSlot, file: File | null, url: string) => {
|
||||
setCropSlot(slot);
|
||||
setCropSeedFile(file);
|
||||
setCropSeedUrl(url);
|
||||
};
|
||||
|
||||
const closeCrop = () => {
|
||||
setCropSlot(null);
|
||||
setCropSeedFile(null);
|
||||
setCropSeedUrl("");
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
[light, dark, favicon].forEach((d) => {
|
||||
if (d.blob) URL.revokeObjectURL(d.blob);
|
||||
@@ -237,8 +254,8 @@ export default function BrandSeoPanel({
|
||||
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(160)) : preview}
|
||||
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 160) : undefined}
|
||||
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(384)) : preview}
|
||||
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 384) : undefined}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
@@ -277,7 +294,14 @@ export default function BrandSeoPanel({
|
||||
toast(err);
|
||||
return;
|
||||
}
|
||||
replaceFile(slot, file);
|
||||
// SVG / ICO 无需裁剪,直接走原流程
|
||||
const isVector = file.name.toLowerCase().endsWith(".svg");
|
||||
const isIco = file.name.toLowerCase().endsWith(".ico");
|
||||
if (isVector || isIco) {
|
||||
replaceFile(slot, file);
|
||||
return;
|
||||
}
|
||||
openCrop(slot, file, "");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -418,11 +442,50 @@ export default function BrandSeoPanel({
|
||||
onClose={() => setPicker(null)}
|
||||
onPick={(item) => {
|
||||
if (!picker) return;
|
||||
const [prev, set] = drafts[picker];
|
||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||
set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url });
|
||||
const slot = picker;
|
||||
setPicker(null);
|
||||
// 已是品牌目录的历史图(含裁剪产物):直接引用,不再复制
|
||||
const brandURL = normalizeBrandURL(slot, item.url);
|
||||
if (brandURL) {
|
||||
const [prev, set] = drafts[slot];
|
||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||
set({ committed: brandURL, file: null, blob: "", mediaId: null, mediaUrl: "" });
|
||||
return;
|
||||
}
|
||||
const lower = item.url.toLowerCase();
|
||||
const isVector = lower.endsWith(".svg");
|
||||
const isIco = lower.endsWith(".ico");
|
||||
// SVG / ICO 不可裁剪,复制一份进品牌目录后引用
|
||||
if ((isVector || isIco) && item.attachmentId != null) {
|
||||
const [prev, set] = drafts[slot];
|
||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||
set({ ...prev, file: null, blob: "", mediaId: item.attachmentId, mediaUrl: item.url });
|
||||
return;
|
||||
}
|
||||
openCrop(slot, null, item.url);
|
||||
}}
|
||||
/>
|
||||
{cropSlot ? (
|
||||
<BrandCropModal
|
||||
open
|
||||
slot={cropSlot}
|
||||
seedFile={cropSeedFile}
|
||||
seedUrl={cropSeedUrl}
|
||||
onClose={closeCrop}
|
||||
onSeedConsumed={() => {
|
||||
setCropSeedFile(null);
|
||||
setCropSeedUrl("");
|
||||
}}
|
||||
onCropped={(url) => {
|
||||
const [, set] = drafts[cropSlot];
|
||||
set((prev) => {
|
||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||
return { committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" };
|
||||
});
|
||||
closeCrop();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -581,6 +581,14 @@ export default function ChatRoomClient({
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
||||
const [hasMore, setHasMore] = useState(initialHasMore);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
// 加载历史的在飞锁(同步 ref,滚动事件连发时 state 闭包来不及更新,见 loadMore)
|
||||
const loadingMoreRef = useRef(false);
|
||||
// 上翻加载完成后恢复 scrollTop:column-reverse 理论上 prepend 不移动视口,
|
||||
// 但 React 重渲染期间某些浏览器/场景会重置 scrollTop,强制恢复兜底
|
||||
const stabilizeScrollRef = useRef(false);
|
||||
const stabilizeScrollValueRef = useRef(0);
|
||||
// handleScroll 冷却:一次自动加载后 800ms 内不再触发,防链式冲顶
|
||||
const lastAutoLoadRef = useRef(0);
|
||||
// 「回到底部」悬浮钮:column-reverse 下 scrollTop≤0(视觉底部为 0,上翻为负),
|
||||
// -scrollTop 即距底部距离;newBelowCount 为翻历史期间新到的、尚未“看见”的消息数
|
||||
const [showJumpBottom, setShowJumpBottom] = useState(false);
|
||||
@@ -700,6 +708,9 @@ export default function ChatRoomClient({
|
||||
|
||||
const openUserCard = useCallback(
|
||||
(u: User) => {
|
||||
// 菜单互斥:打开资料卡前关掉两个右键菜单
|
||||
setCtxMenu(null);
|
||||
setUserCtxMenu(null);
|
||||
const mem = members.find((m) => m.user_id === u.id);
|
||||
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted, mutedUntil: mem?.muted_until });
|
||||
},
|
||||
@@ -810,8 +821,9 @@ export default function ChatRoomClient({
|
||||
if (-el.scrollTop < 120) {
|
||||
scrollToBottom(true);
|
||||
markReadToLatest();
|
||||
} else {
|
||||
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示
|
||||
} else if (data.sender_id !== 0) {
|
||||
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示;
|
||||
// 系统提示(禁言/解禁等管理动作)不计入「新消息」提醒
|
||||
setNewBelowCount((c) => c + 1);
|
||||
}
|
||||
});
|
||||
@@ -908,41 +920,60 @@ export default function ChatRoomClient({
|
||||
});
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
// 在飞锁必须用 ref:滚动事件在一次上翻里连发,React 尚未重渲染时
|
||||
// loadingMore(state 闭包)仍是 false,会并发连发多页请求。
|
||||
if (loadingMoreRef.current) return;
|
||||
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
|
||||
loadingMoreRef.current = true;
|
||||
setLoadingMore(true);
|
||||
const el0 = listRef.current;
|
||||
const prevHeight = el0?.scrollHeight ?? 0;
|
||||
const prevTop = el0?.scrollTop ?? 0;
|
||||
// 保存当前滚动位置,prepend 完成后恢复(防 React 重渲染导致的意外重置)
|
||||
const el = listRef.current;
|
||||
const savedScrollTop = el?.scrollTop ?? 0;
|
||||
try {
|
||||
const before = messages[0].id;
|
||||
const res = await apiListChatMessages(roomId, before, 30);
|
||||
if ((res.messages || []).length > 0) {
|
||||
setMessages((prev) => [...res.messages, ...prev]);
|
||||
setHasMore(res.has_more);
|
||||
requestAnimationFrame(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
// column-reverse:历史插在上方,scrollTop 为负;
|
||||
// 新增高度 H 使锚点内容距底部多 H,需 scrollTop 再减 H(更负)保持视口不动
|
||||
el.scrollTop = prevTop - (el.scrollHeight - prevHeight);
|
||||
// 去重兜底:防止旧闭包重复拉同一页导致重复 key
|
||||
setMessages((prev) => {
|
||||
const seen = new Set(prev.map((p) => p.id));
|
||||
const fresh = res.messages.filter((x) => !seen.has(x.id));
|
||||
return fresh.length ? [...fresh, ...prev] : prev;
|
||||
});
|
||||
setHasMore(res.has_more);
|
||||
// 标记需要在提交后恢复滚动位置
|
||||
stabilizeScrollValueRef.current = savedScrollTop;
|
||||
stabilizeScrollRef.current = true;
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch {
|
||||
toast("加载历史消息失败");
|
||||
} finally {
|
||||
loadingMoreRef.current = false;
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [canView, hasMore, loadingMore, messages, roomId]);
|
||||
|
||||
// prepend 完成后恢复滚动位置:column-reverse 理论上应稳定,但某些浏览器在
|
||||
// React 提交 DOM 时会重置 scrollTop,强制恢复以保证视口不动
|
||||
useLayoutEffect(() => {
|
||||
if (!stabilizeScrollRef.current) return;
|
||||
stabilizeScrollRef.current = false;
|
||||
const el = listRef.current;
|
||||
if (el) {
|
||||
el.scrollTop = stabilizeScrollValueRef.current;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
// column-reverse:scrollTop≤0(视觉底部为 0,上翻为负),-scrollTop 即距底部距离
|
||||
const fromBottom = -el.scrollTop;
|
||||
// 滚向上方历史:距顶部不足 60px 时加载更多
|
||||
if (el.scrollHeight - el.clientHeight - fromBottom < 60) {
|
||||
// 滚向上方历史:距顶部不足 60px 时加载更多;加 800ms 冷却防链式冲顶
|
||||
const now = Date.now();
|
||||
if (el.scrollHeight - el.clientHeight - fromBottom < 60 && now - lastAutoLoadRef.current > 800) {
|
||||
lastAutoLoadRef.current = now;
|
||||
loadMore();
|
||||
}
|
||||
if (fromBottom < 60) {
|
||||
@@ -1285,6 +1316,9 @@ export default function ChatRoomClient({
|
||||
|
||||
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
|
||||
if (m.recalled_at) return;
|
||||
// 菜单互斥:打开消息菜单前关掉头像菜单与资料卡
|
||||
setUserCtxMenu(null);
|
||||
setUserCard(null);
|
||||
setCtxMenu({ x, y, message: m });
|
||||
};
|
||||
|
||||
@@ -1368,6 +1402,10 @@ export default function ChatRoomClient({
|
||||
|
||||
const handleMute = (uid: number, nickname: string, muted: boolean) => {
|
||||
if (!canKickMembers) return;
|
||||
// 入口可能是头像右键菜单或成员列表按钮:先兜底关闭浮层菜单,
|
||||
// 避免低概率时序下菜单残留并与弹窗叠层(菜单 z-index 高于弹窗)
|
||||
setUserCtxMenu(null);
|
||||
setCtxMenu(null);
|
||||
if (muted) {
|
||||
// 打开时长选择弹窗(QQ 风格)
|
||||
setMuteTarget({ uid, nickname });
|
||||
@@ -1440,7 +1478,11 @@ export default function ChatRoomClient({
|
||||
|
||||
const canRecallMsg = (m: ChatMessage) => {
|
||||
if (m.recalled_at) return false;
|
||||
if (canModerateChat) return true;
|
||||
if (canModerateChat) {
|
||||
// 站长(站点 Owner)的消息仅全站监管可撤回,群主/群管/站点消息管无权
|
||||
if (!oversee && isSiteOwner(m.sender.role)) return false;
|
||||
return true;
|
||||
}
|
||||
return m.sender_id === meId && isRealMember;
|
||||
};
|
||||
|
||||
@@ -1752,7 +1794,9 @@ export default function ChatRoomClient({
|
||||
ref={listRef}
|
||||
onScroll={handleScroll}
|
||||
className="j13-chat-scroll flex-1 overflow-y-auto overflow-x-hidden flex flex-col-reverse px-4 sm:px-5 py-4"
|
||||
style={{ minHeight: 0 }}
|
||||
// overflowAnchor:none:column-reverse 原点在底部,prepend 天然不移动视口;
|
||||
// 关掉浏览器原生锚定,避免它对顶部内容变化做多余补偿
|
||||
style={{ minHeight: 0, overflowAnchor: "none" }}
|
||||
>
|
||||
{!canView ? (
|
||||
<div className="text-center py-12 px-4">
|
||||
@@ -1796,9 +1840,14 @@ export default function ChatRoomClient({
|
||||
new Date(prev.created_at).getTime() <
|
||||
5 * 60_000;
|
||||
const reEditText = mine ? takeReEditDraft(m.id) : null;
|
||||
// 发送者的群内角色(群主/管理徽标展示用;私聊不需要)
|
||||
const senderRoomRole = !isDirect
|
||||
? members.find((x) => x.user_id === m.sender_id)?.role
|
||||
: undefined;
|
||||
|
||||
// 系统消息(如禁言通知):居中灰字
|
||||
// 系统消息(如禁言通知):居中灰字;禁言类通知红色突出
|
||||
if (m.sender_id === 0) {
|
||||
const isMuteNotice = m.content.includes("已被禁言");
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
@@ -1806,7 +1855,12 @@ export default function ChatRoomClient({
|
||||
>
|
||||
<p
|
||||
className="text-[12px] leading-relaxed text-center max-w-[90%]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
style={{
|
||||
color: isMuteNotice
|
||||
? "var(--danger)"
|
||||
: "var(--ink-3)",
|
||||
fontWeight: isMuteNotice ? 600 : undefined,
|
||||
}}
|
||||
>
|
||||
{m.content}
|
||||
</p>
|
||||
@@ -1881,6 +1935,9 @@ export default function ChatRoomClient({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const mem = members.find((x) => x.user_id === m.sender_id);
|
||||
// 菜单互斥:打开头像菜单前关掉消息菜单与资料卡
|
||||
setCtxMenu(null);
|
||||
setUserCard(null);
|
||||
setUserCtxMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
@@ -1913,6 +1970,18 @@ export default function ChatRoomClient({
|
||||
>
|
||||
{m.sender.nickname}
|
||||
</span>
|
||||
{!isDirect && senderRoomRole === "admin" && (
|
||||
<span
|
||||
className="text-[10px] font-medium px-1 rounded"
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--accent) 12%, transparent)",
|
||||
}}
|
||||
>
|
||||
管理
|
||||
</span>
|
||||
)}
|
||||
{!isDirect && (
|
||||
<>
|
||||
<LevelBadge level={m.sender.level} />
|
||||
@@ -1928,23 +1997,37 @@ export default function ChatRoomClient({
|
||||
<div
|
||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] [overflow-wrap:anywhere] whitespace-pre-wrap select-text w-full"
|
||||
style={{
|
||||
background: "var(--panel-2)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--gold) 7%, var(--panel-2))",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--line) 80%, var(--ink-3))",
|
||||
"1.5px dashed color-mix(in srgb, var(--gold) 55%, transparent)",
|
||||
color: "var(--ink)",
|
||||
borderBottomRightRadius: mine ? 4 : undefined,
|
||||
borderBottomLeftRadius: mine ? undefined : 4,
|
||||
opacity: 0.92,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="inline-flex items-center gap-1 mb-1.5 text-[11px]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<Undo2 size={11} strokeWidth={2.4} />
|
||||
已撤回
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10.5px] font-medium px-1.5 py-px rounded-full"
|
||||
style={{
|
||||
color: "var(--gold)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--gold) 14%, transparent)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--gold) 35%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Undo2 size={10} strokeWidth={2.4} />
|
||||
已撤回
|
||||
</span>
|
||||
<span
|
||||
className="text-[10.5px]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
仅管理可见
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: "var(--ink-2)" }}>
|
||||
<div className="italic" style={{ color: "var(--ink-2)" }}>
|
||||
{renderChatContent(m.content, userIndex, openUserCard)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1977,6 +2060,9 @@ export default function ChatRoomClient({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const mem = members.find((x) => x.user_id === m.sender_id);
|
||||
// 菜单互斥:打开头像菜单前关掉消息菜单与资料卡
|
||||
setCtxMenu(null);
|
||||
setUserCard(null);
|
||||
setUserCtxMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
@@ -2032,6 +2118,23 @@ export default function ChatRoomClient({
|
||||
)}
|
||||
{m.sender.nickname}
|
||||
</button>
|
||||
{!isDirect && senderRoomRole === "admin" && (
|
||||
<span
|
||||
className="text-[10px] font-medium px-1 rounded"
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
background:
|
||||
"color-mix(in srgb, var(--accent) 12%, transparent)",
|
||||
}}
|
||||
>
|
||||
管理
|
||||
</span>
|
||||
)}
|
||||
{/* 群聊与私聊都展示等级/徽章标识 */}
|
||||
<LevelBadge level={m.sender.level} />
|
||||
{(m.sender.badges ?? []).slice(0, 1).map((ub) => (
|
||||
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||
))}
|
||||
<span className="meta text-[10.5px]">
|
||||
{formatTime(m.created_at)}
|
||||
</span>
|
||||
@@ -2434,7 +2537,8 @@ export default function ChatRoomClient({
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{ctxMenu && (
|
||||
{/* 弹层打开期间不再渲染右键菜单:渲染级互斥兜底,杜绝菜单悬在弹窗上方 */}
|
||||
{ctxMenu && !muteTarget && !confirm && !userCard && (
|
||||
<ContextMenu
|
||||
x={ctxMenu.x}
|
||||
y={ctxMenu.y}
|
||||
@@ -2443,7 +2547,7 @@ export default function ChatRoomClient({
|
||||
/>
|
||||
)}
|
||||
|
||||
{userCtxMenu && (
|
||||
{userCtxMenu && !muteTarget && !confirm && !userCard && (
|
||||
<ContextMenu
|
||||
x={userCtxMenu.x}
|
||||
y={userCtxMenu.y}
|
||||
|
||||
@@ -237,7 +237,11 @@ body {
|
||||
/* 亚像素渲染在 Windows 高分屏更实;强制 grayscale 会让 400/500 中文偏虚 */
|
||||
-webkit-font-smoothing: auto;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-synthesis: none; /* 禁止伪粗,避免缺档字重糊成一团 */
|
||||
/* 长手属性只禁伪粗(缺档字重糊成一团);放开伪斜体,让 em/斜体按钮生效。
|
||||
首行兜底:不支持长手属性的老浏览器维持全禁。 */
|
||||
font-synthesis: none;
|
||||
font-synthesis-weight: none;
|
||||
font-synthesis-style: auto;
|
||||
isolation: isolate; /* 让 z-index:-1 的画布层停在内容之下、html 之上 */
|
||||
}
|
||||
|
||||
@@ -4699,7 +4703,9 @@ a.j13-mention-token:hover {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.admin-brand-swatch img {
|
||||
max-height: 3.5rem;
|
||||
/* 按槽位高度铺满,宽度按比例自适应;超宽时由 max-width + contain 收边 */
|
||||
height: 100%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -8271,6 +8277,248 @@ a.j13-mention-token:hover {
|
||||
--entry-title-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----- 正文短代码:[details] 折叠 / [bilibili][youtube] 视频 / ==高亮== / [kbd] 键帽 ----- */
|
||||
.prose-content .j13-md-details {
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.prose-content .j13-md-details > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
color: var(--ink);
|
||||
list-style: none;
|
||||
}
|
||||
.prose-content .j13-md-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.prose-content .j13-md-details > summary:hover {
|
||||
background: var(--tint);
|
||||
}
|
||||
.prose-content .j13-md-details[open] > summary {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.prose-content .j13-md-details .j13-md-details-caret {
|
||||
flex-shrink: 0;
|
||||
color: var(--ink-3);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.prose-content .j13-md-details[open] .j13-md-details-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.prose-content .j13-md-details-body {
|
||||
padding: 0.35rem 0.9rem 0.6rem;
|
||||
}
|
||||
.prose-content .j13-md-details-body > :first-child {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.prose-content .j13-md-details-body > :last-child {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
/* 视频嵌入:16:9 容器 + 懒加载 iframe 铺满 */
|
||||
.j13-md-video {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 1rem 0;
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
background: var(--tint);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.j13-md-video iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ==高亮==:荧光笔。UA 默认黄底黑字在暗色下刺眼,显式覆盖 */
|
||||
.prose-content mark {
|
||||
background: color-mix(in srgb, #fde047 55%, transparent);
|
||||
color: inherit;
|
||||
padding: 0.05em 0.25em;
|
||||
border-radius: 4px;
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
.dark .prose-content mark {
|
||||
background: color-mix(in srgb, #facc15 30%, transparent);
|
||||
color: #fef9c3;
|
||||
}
|
||||
|
||||
/* [kbd] 键帽:等宽 + 下边加厚模拟按键立体 */
|
||||
.prose-content kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1em 0.45em;
|
||||
border: 1px solid var(--line-2);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82em;
|
||||
line-height: 1.45;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/* 编辑器「文字颜色」弹窗:前景/背景两行,每行预设 + 色轮 + hex 输入 */
|
||||
.j13-md-color-popover {
|
||||
width: 288px;
|
||||
}
|
||||
.j13-md-color-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.j13-md-color-row-label {
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.j13-md-color-row-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.j13-md-color-none {
|
||||
height: 26px;
|
||||
padding: 0 9px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.j13-md-color-none.is-active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.j13-md-color-dot {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.j13-md-color-dot.is-active {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
/* 自定义取色器触发按钮:彩虹渐变底 + 吸管图标 */
|
||||
.j13-md-color-wheel {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
background: conic-gradient(
|
||||
from 210deg,
|
||||
#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00
|
||||
);
|
||||
}
|
||||
.j13-md-color-wheel svg {
|
||||
filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.65));
|
||||
}
|
||||
.j13-md-color-wheel:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.j13-md-color-wheel.is-active {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.j13-md-color-hex {
|
||||
flex: 1 1 90px;
|
||||
min-width: 90px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* 内联 HSV 取色器:在弹层文档流内展开,只撑开不浮层,不遮挡预览 */
|
||||
.j13-md-hsv {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.j13-md-hsv-sv {
|
||||
position: relative;
|
||||
height: 110px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.j13-md-hsv-sv-thumb {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.j13-md-hsv-hue {
|
||||
position: relative;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00
|
||||
);
|
||||
}
|
||||
.j13-md-hsv-hue-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.35);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 实时预览置于弹层顶部:取色器在行内展开时始终可见,未选背景时透明棋盘格 */
|
||||
.j13-md-color-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 7px 10px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
repeating-conic-gradient(var(--tint) 0% 25%, transparent 0% 50%) 0 0 / 14px 14px;
|
||||
}
|
||||
.j13-md-color-preview span {
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose-content h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; }
|
||||
.prose-content h2 {
|
||||
font-size: 1.4rem;
|
||||
|
||||
368
frontend/components/BrandCropModal.tsx
Normal file
368
frontend/components/BrandCropModal.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Cropper, { type Area } from "react-easy-crop";
|
||||
import "react-easy-crop/react-easy-crop.css";
|
||||
import {
|
||||
UploadCloud, ZoomIn, Check, Loader2, Images, RotateCcw, Crop,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiMyMedia, apiUploadBrand,
|
||||
type MediaAttachment,
|
||||
} from "@/lib/api";
|
||||
import { getCroppedBrandWebp } from "@/lib/cropImage";
|
||||
import Modal from "@/components/Modal";
|
||||
import { brandFileOk, type BrandSlot, type BrandLogoSize } from "@/lib/brand";
|
||||
|
||||
// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准)
|
||||
const SOURCE_MAX_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
// 品牌图槽位到默认裁剪比例的映射
|
||||
const SLOT_ASPECT: Record<BrandSlot, number> = {
|
||||
logo_light: 1, // 方标 1:1
|
||||
logo_dark: 1, // 方标 1:1
|
||||
favicon: 1, // Favicon 1:1
|
||||
};
|
||||
|
||||
// 品牌图比例选项(与 BRAND_LOGO_SIZES 对应)
|
||||
const ASPECT_OPTIONS: { id: BrandLogoSize; label: string; aspect: number }[] = [
|
||||
{ id: "sq", label: "1:1", aspect: 1 },
|
||||
{ id: "2x1", label: "2:1", aspect: 2 },
|
||||
{ id: "3x1", label: "3:1", aspect: 3 },
|
||||
{ id: "4x1", label: "4:1", aspect: 4 },
|
||||
{ id: "16x9", label: "16:9", aspect: 16 / 9 },
|
||||
];
|
||||
|
||||
interface BrandCropModalProps {
|
||||
open: boolean;
|
||||
slot: BrandSlot;
|
||||
onClose: () => void;
|
||||
/** 裁剪完成后返回新 URL */
|
||||
onCropped: (url: string) => void;
|
||||
/** 外部传入的文件:打开后直接进入裁剪 */
|
||||
seedFile?: File | null;
|
||||
/** 外部传入的 URL(如媒体库图片):打开后直接进入裁剪 */
|
||||
seedUrl?: string;
|
||||
/** 已消费 seedFile / seedUrl */
|
||||
onSeedConsumed?: () => void;
|
||||
}
|
||||
|
||||
export default function BrandCropModal({
|
||||
open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed,
|
||||
}: BrandCropModalProps) {
|
||||
const [media, setMedia] = useState<MediaAttachment[]>([]);
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [srcIsBlob, setSrcIsBlob] = useState(false);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pixelCrop, setPixelCrop] = useState<Area | null>(null);
|
||||
const [aspect, setAspect] = useState<number>(SLOT_ASPECT[slot] || 1);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 打开时拉取媒体库
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError("");
|
||||
apiMyMedia()
|
||||
.then((res) => setMedia(res.attachments || []))
|
||||
.catch(() => setMedia([]));
|
||||
}, [open]);
|
||||
|
||||
// 关闭时复位并释放本地 blob URL
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setImageSrc((prev) => {
|
||||
if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setSrcIsBlob(false);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setPixelCrop(null);
|
||||
setAspect(SLOT_ASPECT[slot] || 1);
|
||||
setError("");
|
||||
setBusy(false);
|
||||
setDragOver(false);
|
||||
}
|
||||
}, [open, slot]);
|
||||
|
||||
const onCropComplete = useCallback((_: Area, areaPixels: Area) => {
|
||||
setPixelCrop(areaPixels);
|
||||
}, []);
|
||||
|
||||
const enterCrop = useCallback((src: string, isBlob: boolean) => {
|
||||
setError("");
|
||||
setImageSrc((prev) => {
|
||||
if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);
|
||||
return src;
|
||||
});
|
||||
setSrcIsBlob(isBlob);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
}, []);
|
||||
|
||||
/** 校验并进入裁剪;返回错误文案,成功返回空串 */
|
||||
const acceptImageFile = useCallback((file: File): string => {
|
||||
const err = brandFileOk(slot, file);
|
||||
if (err) return err;
|
||||
if (file.size > SOURCE_MAX_BYTES) {
|
||||
return "原图不能超过 15MB,裁剪后的品牌图需在 2MB 以内";
|
||||
}
|
||||
enterCrop(URL.createObjectURL(file), true);
|
||||
return "";
|
||||
}, [enterCrop, slot]);
|
||||
|
||||
// 外部传入:弹窗打开后立刻进入裁剪
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (seedFile) {
|
||||
const errMsg = acceptImageFile(seedFile);
|
||||
if (errMsg) setError(errMsg);
|
||||
onSeedConsumed?.();
|
||||
return;
|
||||
}
|
||||
if (seedUrl) {
|
||||
enterCrop(seedUrl, false);
|
||||
onSeedConsumed?.();
|
||||
}
|
||||
}, [open, seedFile, seedUrl, acceptImageFile, enterCrop, onSeedConsumed]);
|
||||
|
||||
/**
|
||||
* 点击媒体库图片:直接进入裁剪界面
|
||||
* 品牌图不做"直接选用",统一走裁剪后上传,保证比例一致
|
||||
*/
|
||||
const handlePick = (item: MediaAttachment) => {
|
||||
if (busy) return;
|
||||
enterCrop(item.url, false);
|
||||
};
|
||||
|
||||
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
const errMsg = acceptImageFile(file);
|
||||
if (errMsg) setError(errMsg);
|
||||
};
|
||||
|
||||
const onDropZoneDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!busy) setDragOver(true);
|
||||
};
|
||||
|
||||
const onDropZoneDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const onDropZoneDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
if (busy) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (!file) return;
|
||||
const errMsg = acceptImageFile(file);
|
||||
if (errMsg) setError(errMsg);
|
||||
};
|
||||
|
||||
const backToLibrary = () => {
|
||||
if (imageSrc && srcIsBlob) URL.revokeObjectURL(imageSrc);
|
||||
setImageSrc(null);
|
||||
setSrcIsBlob(false);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const confirmUpload = async () => {
|
||||
if (!imageSrc || !pixelCrop || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
// 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB)
|
||||
const blob = await getCroppedBrandWebp(imageSrc, pixelCrop, aspect);
|
||||
const file = new File([blob], "brand.webp", { type: "image/webp" });
|
||||
const url = await apiUploadBrand(slot, file);
|
||||
onCropped(url);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "上传失败,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const slotLabel = slot === "logo_light" ? "浅色 Logo" : slot === "logo_dark" ? "深色 Logo" : "Favicon";
|
||||
// 裁剪区底色与槽位主题相反:浅色主题 Logo(通常为深色图)用浅底,深色主题 Logo 用深底
|
||||
const cropBg = slot === "logo_dark" ? "#0c0d12" : "#f2f3f5";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={imageSrc ? `裁剪${slotLabel}` : `上传${slotLabel}`}
|
||||
onClose={onClose}
|
||||
busy={busy}
|
||||
dismissOnOverlay={!busy}
|
||||
maxWidthClass="max-w-[440px]"
|
||||
panelClassName="p-5"
|
||||
ariaLabel={`上传${slotLabel}`}
|
||||
>
|
||||
{error && <div className="alert-error mb-4">{error}</div>}
|
||||
|
||||
{!imageSrc ? (
|
||||
<>
|
||||
{/* 选择新图片:点击或拖放 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={onDropZoneDragOver}
|
||||
onDragLeave={onDropZoneDragLeave}
|
||||
onDrop={onDropZoneDrop}
|
||||
className="w-full flex flex-col items-center justify-center gap-2 rounded-xl py-7 px-4 text-center transition-colors hover:border-[var(--accent)]"
|
||||
style={{
|
||||
border: `1.5px dashed ${dragOver ? "var(--accent)" : "var(--line-2)"}`,
|
||||
background: dragOver
|
||||
? "color-mix(in srgb, var(--accent) 10%, var(--panel-2))"
|
||||
: "var(--panel-2)",
|
||||
color: "var(--ink-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-flex w-11 h-11 items-center justify-center rounded-full"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<UploadCloud size={20} />
|
||||
</span>
|
||||
<span className="text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>
|
||||
{dragOver ? "松开即可开始裁剪" : "点击选择或拖放图片"}
|
||||
</span>
|
||||
<span className="text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
支持 PNG / WebP / SVG{slot === "favicon" ? " / ICO" : ""},裁剪后统一转为 WebP,大小不超过 2MB
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={slot === "favicon" ? "image/png,image/webp,image/svg+xml,.ico,image/x-icon" : "image/png,image/webp,image/svg+xml"}
|
||||
className="hidden"
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
{/* 媒体库 */}
|
||||
{media.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<p
|
||||
className="flex items-center gap-1.5 text-[12px] font-bold tracking-[0.04em] mb-2.5"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<Images size={13} /> 我的图片(点击后进入裁剪)
|
||||
</p>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{media.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handlePick(item)}
|
||||
disabled={busy}
|
||||
className="relative group aspect-square rounded-lg overflow-hidden transition-transform hover:scale-105 disabled:cursor-default"
|
||||
style={{ boxShadow: "0 0 0 1px var(--line)" }}
|
||||
data-tip="裁剪后设为品牌图"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.url}
|
||||
alt="媒体库图片"
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ background: "color-mix(in srgb, var(--ink) 45%, transparent)", color: "#fff" }}
|
||||
>
|
||||
<Crop size={13} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 比例选择 */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>比例</span>
|
||||
{ASPECT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setAspect(opt.aspect)}
|
||||
className={`px-2.5 py-1 rounded-md text-[12px] font-medium transition-colors ${
|
||||
aspect === opt.aspect
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--panel-2)] text-[var(--ink-2)] hover:bg-[var(--panel-3)]"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 裁剪区 */}
|
||||
<div
|
||||
className="relative w-full h-72 rounded-xl overflow-hidden"
|
||||
style={{ background: cropBg }}
|
||||
>
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={aspect}
|
||||
cropShape="rect"
|
||||
showGrid={true}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 缩放 */}
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={15} /></span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
|
||||
style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }}
|
||||
aria-label="缩放"
|
||||
/>
|
||||
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={19} /></span>
|
||||
</div>
|
||||
|
||||
{/* 输出说明 + 操作 */}
|
||||
<div className="flex items-center justify-between mt-5">
|
||||
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
输出 WebP,高度 ≤ 2048px
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={backToLibrary} disabled={busy} className="btn btn-line btn-sm">
|
||||
<RotateCcw size={13.5} /> 重选
|
||||
</button>
|
||||
<button type="button" onClick={confirmUpload} disabled={busy} className="btn btn-primary btn-sm">
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
{busy ? "上传中" : "确认上传"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -36,17 +36,17 @@ function TextCover({ title }: { title: string }) {
|
||||
style={{ "--cover-h": coverHue(name) } as CSSProperties}
|
||||
aria-hidden
|
||||
>
|
||||
<span className="j13-library-title text-[19px] font-bold leading-snug text-center line-clamp-4 break-all tracking-wide">
|
||||
<span className="j13-library-title text-[17px] font-bold leading-snug text-center line-clamp-3 break-words tracking-wide">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtChip({ ext }: { ext: string }) {
|
||||
function ExtChip({ ext, className = "" }: { ext: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide"
|
||||
className={`text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide ${className}`}
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
background: "var(--accent-soft)",
|
||||
@@ -65,33 +65,45 @@ function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) {
|
||||
const excerpt = plainExcerpt(doc.description ?? "");
|
||||
return (
|
||||
<Link href={href} className="group flex flex-col min-w-0">
|
||||
{/* 竖版书形封面:书脊光影 + 悬停浮出简介 */}
|
||||
{/* 横版封面框(宽大于高):模糊底衬完整显示任意比例图 + 悬停浮出简介 */}
|
||||
<div
|
||||
className="relative aspect-[3/4] overflow-hidden rounded-[10px] shadow-[var(--shadow-sm)] transition-[transform,box-shadow] duration-200 group-hover:-translate-y-1.5 group-hover:shadow-[var(--shadow-lg)]"
|
||||
className="relative w-full aspect-[3/2] overflow-hidden rounded-[10px] shadow-[var(--shadow-sm)] transition-[transform,box-shadow] duration-200 group-hover:-translate-y-1.5 group-hover:shadow-[var(--shadow-lg)]"
|
||||
style={{ background: "var(--panel-2)" }}
|
||||
>
|
||||
{doc.cover_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={variantURL(doc.cover_url, 384)}
|
||||
srcSet={widthSrcSet(doc.cover_url, [384, 512, 768])}
|
||||
sizes="(max-width:640px) 46vw, (max-width:1024px) 30vw, (max-width:1280px) 22vw, 220px"
|
||||
alt={doc.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
<>
|
||||
{/* 底层:同图放大模糊填充,竖版/非 3:2 封面不露黑边 */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={variantURL(doc.cover_url, 384)}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="absolute inset-0 w-full h-full object-cover scale-125 blur-lg opacity-70"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
{/* 上层:完整封面居中显示 */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={variantURL(doc.cover_url, 384)}
|
||||
srcSet={widthSrcSet(doc.cover_url, [384, 512, 768])}
|
||||
sizes="(max-width:640px) 100vw, (max-width:1024px) 46vw, (max-width:1280px) 30vw, 300px"
|
||||
alt={doc.title}
|
||||
className="absolute inset-0 w-full h-full object-contain transition-transform duration-300 group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextCover title={doc.title} />
|
||||
)}
|
||||
{/* 书脊/书口光影,营造实体书体积感 */}
|
||||
{/* 边缘内阴影,营造卡片体积感 */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-[10px] pointer-events-none"
|
||||
className="absolute inset-0 rounded-[inherit] pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(0,0,0,0.16) 0%, rgba(0,0,0,0.05) 2.5%, transparent 5%, transparent 96%, rgba(0,0,0,0.05) 98.5%, rgba(0,0,0,0.1) 100%)",
|
||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06)",
|
||||
}}
|
||||
/>
|
||||
@@ -103,7 +115,7 @@ function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) {
|
||||
"linear-gradient(to top, rgba(18,20,26,0.92) 0%, rgba(18,20,26,0.55) 55%, rgba(18,20,26,0.15) 100%)",
|
||||
}}
|
||||
>
|
||||
<p className="text-[12px] leading-relaxed text-white/90 line-clamp-5">{excerpt}</p>
|
||||
<p className="text-[12px] leading-relaxed text-white/90 line-clamp-4">{excerpt}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -153,7 +165,7 @@ function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 书库目录:书架式竖版封面网格 + 书名/作者即时过滤(目录接口一次返回全部已发布
|
||||
* 书库目录:横版封面卡片网格(手机端单列全宽)+ 书名/作者即时过滤(目录接口一次返回全部已发布
|
||||
* 条目,纯客户端过滤;上限与后端 ListPublished 的 100 条一致)。
|
||||
*/
|
||||
export default function LibrarySearchGrid({
|
||||
@@ -230,7 +242,7 @@ export default function LibrarySearchGrid({
|
||||
<p className="meta text-[12.5px] mt-2">换个书名或作者关键词试试</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 grid gap-x-5 gap-y-9 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
|
||||
<div className="mt-8 grid grid-cols-1 gap-x-5 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{filtered.map((d) => (
|
||||
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
|
||||
))}
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { HideKind } from "@/lib/hideBlocks";
|
||||
import MdHideBlock from "@/components/markdown/MdHideBlock";
|
||||
import MdTimeline from "@/components/markdown/MdTimeline";
|
||||
import MdEntries from "@/components/markdown/MdEntries";
|
||||
import MdDetails from "@/components/markdown/MdDetails";
|
||||
import MdVideo from "@/components/markdown/MdVideo";
|
||||
|
||||
type MarkdownBodyProps = {
|
||||
content: string;
|
||||
@@ -75,6 +77,21 @@ export default async function MarkdownBody({
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (seg.type === "details") {
|
||||
const inner = seg.body.trim()
|
||||
? await renderMarkdownToReact(expandDyEmojiShortcodes(seg.body))
|
||||
: null;
|
||||
nodes.push(
|
||||
<MdDetails key={`details-${i}`} title={seg.title}>
|
||||
{inner}
|
||||
</MdDetails>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (seg.type === "video") {
|
||||
nodes.push(<MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />);
|
||||
continue;
|
||||
}
|
||||
const kind = seg.kind as HideKind;
|
||||
if (seg.locked) {
|
||||
nodes.push(
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { mergeMarkdownComponents } from "@/components/markdown/mergeComponents";
|
||||
import { remarkInlineShortcodes } from "@/components/markdown/remarkInlineShortcodes";
|
||||
import { splitContentSegments } from "@/lib/directiveBlocks";
|
||||
import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
|
||||
import type { HideKind } from "@/lib/hideBlocks";
|
||||
import MdHideBlock from "@/components/markdown/MdHideBlock";
|
||||
import MdTimeline from "@/components/markdown/MdTimeline";
|
||||
import MdDetails from "@/components/markdown/MdDetails";
|
||||
import MdVideo from "@/components/markdown/MdVideo";
|
||||
import { MdEntriesView } from "@/components/markdown/MdEntries";
|
||||
|
||||
type MarkdownBodyClientProps = {
|
||||
@@ -49,7 +52,11 @@ export default function MarkdownBodyClient({
|
||||
if (seg.type === "md") {
|
||||
if (!seg.text.trim()) return null;
|
||||
return (
|
||||
<ReactMarkdown key={`md-${i}`} remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
<ReactMarkdown
|
||||
key={`md-${i}`}
|
||||
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{seg.text}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
@@ -61,7 +68,10 @@ export default function MarkdownBodyClient({
|
||||
items={seg.items}
|
||||
renderBody={(it) =>
|
||||
it.body.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
|
||||
components={nestedMd}
|
||||
>
|
||||
{expandDyEmojiShortcodes(it.body)}
|
||||
</ReactMarkdown>
|
||||
) : null
|
||||
@@ -76,13 +86,33 @@ export default function MarkdownBodyClient({
|
||||
items={seg.items}
|
||||
titleIds={entriesTitleIds?.[i]}
|
||||
renderMd={(t) => (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
|
||||
components={nestedMd}
|
||||
>
|
||||
{expandDyEmojiShortcodes(t)}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (seg.type === "details") {
|
||||
return (
|
||||
<MdDetails key={`details-${i}`} title={seg.title}>
|
||||
{seg.body.trim() ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
|
||||
components={nestedMd}
|
||||
>
|
||||
{expandDyEmojiShortcodes(seg.body)}
|
||||
</ReactMarkdown>
|
||||
) : null}
|
||||
</MdDetails>
|
||||
);
|
||||
}
|
||||
if (seg.type === "video") {
|
||||
return <MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />;
|
||||
}
|
||||
const kind = seg.kind as HideKind;
|
||||
if (seg.locked) {
|
||||
return (
|
||||
@@ -100,7 +130,10 @@ export default function MarkdownBodyClient({
|
||||
return (
|
||||
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
|
||||
{seg.body.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
|
||||
components={nestedMd}
|
||||
>
|
||||
{expandDyEmojiShortcodes(seg.body)}
|
||||
</ReactMarkdown>
|
||||
) : null}
|
||||
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
Ellipsis,
|
||||
Smile,
|
||||
LayoutList,
|
||||
Highlighter,
|
||||
Keyboard,
|
||||
Palette,
|
||||
MonitorPlay,
|
||||
Pipette,
|
||||
} from "lucide-react";
|
||||
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
@@ -42,7 +47,8 @@ import ImageInsertModal from "@/components/ImageInsertModal";
|
||||
import TimelineEditor from "@/components/TimelineEditor";
|
||||
import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
|
||||
import EmojiPicker from "@/components/EmojiPicker";
|
||||
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
|
||||
import { resolveTimelineBlockForEdit, parseVideoInput } from "@/lib/directiveBlocks";
|
||||
import { isValidColorValue } from "@/lib/inlineShortcodes";
|
||||
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
|
||||
import { postPath } from "@/lib/urlStyle";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
@@ -96,7 +102,61 @@ type WrapOpts = {
|
||||
linePrefix?: string;
|
||||
};
|
||||
|
||||
type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | "emoji" | null;
|
||||
type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | "emoji" | "color" | "video" | null;
|
||||
|
||||
/** 文字颜色弹窗预设色板(均为合法短代码颜色值) */
|
||||
const COLOR_FG_PRESETS = [
|
||||
"#e03131", "#e8590c", "#f08c00", "#2f9e44", "#0c8599",
|
||||
"#1971c2", "#6741d9", "#c2255c", "#1f2328", "#5f6b7a",
|
||||
];
|
||||
const COLOR_BG_PRESETS = [
|
||||
"#fff3bf", "#ffe3e3", "#d3f9d8", "#d0ebff",
|
||||
"#e5dbff", "#ffd8a8", "#ffff8f", "#e9ecef",
|
||||
];
|
||||
|
||||
type HSV = { h: number; s: number; v: number };
|
||||
|
||||
/** #rgb/#rrggbb → HSV;非 hex(如颜色名)返回 null */
|
||||
function hexToHsv(hex: string): HSV | null {
|
||||
const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
|
||||
if (!m) return null;
|
||||
let x = m[1];
|
||||
if (x.length === 3) x = x.split("").map((c) => c + c).join("");
|
||||
const r = parseInt(x.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(x.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(x.slice(4, 6), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
let h = 0;
|
||||
if (d !== 0) {
|
||||
if (max === r) h = ((g - b) / d) % 6;
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
h *= 60;
|
||||
if (h < 0) h += 360;
|
||||
}
|
||||
return { h, s: max === 0 ? 0 : d / max, v: max };
|
||||
}
|
||||
|
||||
/** HSV → #rrggbb */
|
||||
function hsvToHex({ h, s, v }: HSV): string {
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = v - c;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) [r, g, b] = [c, x, 0];
|
||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||
else if (h < 180) [r, g, b] = [0, c, x];
|
||||
else if (h < 240) [r, g, b] = [0, x, c];
|
||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
const to = (n: number) =>
|
||||
Math.max(0, Math.min(255, Math.round((n + m) * 255)))
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `#${to(r)}${to(g)}${to(b)}`;
|
||||
}
|
||||
|
||||
const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const;
|
||||
|
||||
@@ -190,6 +250,16 @@ export default function MarkdownEditor({
|
||||
const [hidePoints, setHidePoints] = useState(10);
|
||||
const [hidePassword, setHidePassword] = useState("");
|
||||
const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu");
|
||||
/** 颜色弹窗:前景/背景独立选择,可同时设置;null=不设置该侧 */
|
||||
const [colorFg, setColorFg] = useState<string | null>("#e03131");
|
||||
const [colorBg, setColorBg] = useState<string | null>(null);
|
||||
/** 色轮/自定义输入框的当前值(始终 #hex,供 HSV 取色器使用) */
|
||||
const [colorFgWheel, setColorFgWheel] = useState("#e03131");
|
||||
const [colorBgWheel, setColorBgWheel] = useState("#fff3bf");
|
||||
/** 内联 HSV 取色器展开侧;打开期间 colorHsv 为该侧当前色 */
|
||||
const [activeWheel, setActiveWheel] = useState<"fg" | "bg" | null>(null);
|
||||
const [colorHsv, setColorHsv] = useState<HSV>({ h: 0, s: 1, v: 1 });
|
||||
const [videoInput, setVideoInput] = useState("");
|
||||
const [toolNarrow, setToolNarrow] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [modKey, setModKey] = useState("Ctrl");
|
||||
@@ -504,6 +574,14 @@ export default function MarkdownEditor({
|
||||
setLinkAdvanced(false);
|
||||
}
|
||||
if (kind === "code") setCodeQuery("");
|
||||
if (kind === "color") {
|
||||
setColorFg("#e03131");
|
||||
setColorBg(null);
|
||||
setColorFgWheel("#e03131");
|
||||
setColorBgWheel("#fff3bf");
|
||||
setActiveWheel(null);
|
||||
}
|
||||
if (kind === "video") setVideoInput("");
|
||||
if (kind === "hide") {
|
||||
setHideStep("menu");
|
||||
setHidePoints(10);
|
||||
@@ -614,6 +692,176 @@ export default function MarkdownEditor({
|
||||
setPopover(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 颜色短代码插入:前景/背景可独立或组合。
|
||||
* 两侧都有 → [color=前景 bg=背景]…[/color];仅一侧 → [color] / [bg]。
|
||||
*/
|
||||
const applyColor = () => {
|
||||
const fg = colorFg?.trim();
|
||||
const bg = colorBg?.trim();
|
||||
if (!fg && !bg) {
|
||||
toast("请至少选择前景色或背景色", "error");
|
||||
return;
|
||||
}
|
||||
if ((fg && !isValidColorValue(fg)) || (bg && !isValidColorValue(bg))) {
|
||||
toast("颜色仅支持 #RGB/#RRGGBB/#RRGGBBAA 或英文颜色名", "error");
|
||||
return;
|
||||
}
|
||||
restoreSel();
|
||||
const el = taRef.current;
|
||||
const start = el?.selectionStart ?? 0;
|
||||
const end = el?.selectionEnd ?? 0;
|
||||
const selected = value.slice(start, end);
|
||||
const body = selected || "彩色文字";
|
||||
let md: string;
|
||||
if (fg && bg) md = `[color=${fg} bg=${bg}]${body}[/color]`;
|
||||
else if (fg) md = `[color=${fg}]${body}[/color]`;
|
||||
else md = `[bg=${bg}]${body}[/bg]`;
|
||||
const next = value.slice(0, start) + md + value.slice(end);
|
||||
onChange(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const caret = start + md.length;
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
setPopover(null);
|
||||
};
|
||||
|
||||
/** 内联 HSV 取色器开关:打开时以该侧当前 hex(或上次色轮值)初始化 */
|
||||
const toggleColorWheel = (side: "fg" | "bg") => {
|
||||
if (activeWheel === side) {
|
||||
setActiveWheel(null);
|
||||
return;
|
||||
}
|
||||
const wheelHex = side === "fg" ? colorFgWheel : colorBgWheel;
|
||||
setColorHsv(hexToHsv(wheelHex) ?? { h: 0, s: 1, v: 1 });
|
||||
setActiveWheel(side);
|
||||
};
|
||||
|
||||
/** HSV 拖动 → 同步该侧 wheel hex 与选中色 */
|
||||
const commitHsv = (next: HSV) => {
|
||||
setColorHsv(next);
|
||||
const hex = hsvToHex(next);
|
||||
if (activeWheel === "fg") {
|
||||
setColorFgWheel(hex);
|
||||
setColorFg(hex);
|
||||
} else if (activeWheel === "bg") {
|
||||
setColorBgWheel(hex);
|
||||
setColorBg(hex);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 饱和度/明度面板拖拽:坐标以真实渲染元素 rect 映射(pointer capture 保证移出仍跟手)。
|
||||
* 注意:必须在同步阶段取出 currentTarget 存入 el——React 在事件派发结束后会把
|
||||
* e.currentTarget 置空,延迟回调里再读会拿到 null 导致拖拽中断。
|
||||
*/
|
||||
const onSvPointer = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
const update = (clientX: number, clientY: number) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const s = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
const v = 1 - Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
setColorHsv((prev) => {
|
||||
const next = { ...prev, s, v };
|
||||
const hex = hsvToHex(next);
|
||||
if (activeWheel === "fg") {
|
||||
setColorFgWheel(hex);
|
||||
setColorFg(hex);
|
||||
} else if (activeWheel === "bg") {
|
||||
setColorBgWheel(hex);
|
||||
setColorBg(hex);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
update(e.clientX, e.clientY);
|
||||
const onMove = (ev: PointerEvent) => update(ev.clientX, ev.clientY);
|
||||
const cleanup = () => {
|
||||
el.removeEventListener("pointermove", onMove);
|
||||
el.removeEventListener("pointerup", cleanup);
|
||||
el.removeEventListener("pointercancel", cleanup);
|
||||
};
|
||||
el.addEventListener("pointermove", onMove);
|
||||
el.addEventListener("pointerup", cleanup);
|
||||
el.addEventListener("pointercancel", cleanup);
|
||||
};
|
||||
|
||||
/** 色相条拖拽(水平);同样在同步阶段缓存元素 */
|
||||
const onHuePointer = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
const update = (clientX: number) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const h = Math.max(0, Math.min(360, ((clientX - rect.left) / rect.width) * 360));
|
||||
commitHsv({ ...colorHsv, h });
|
||||
};
|
||||
update(e.clientX);
|
||||
const onMove = (ev: PointerEvent) => update(ev.clientX);
|
||||
const cleanup = () => {
|
||||
el.removeEventListener("pointermove", onMove);
|
||||
el.removeEventListener("pointerup", cleanup);
|
||||
el.removeEventListener("pointercancel", cleanup);
|
||||
};
|
||||
el.addEventListener("pointermove", onMove);
|
||||
el.addEventListener("pointerup", cleanup);
|
||||
el.addEventListener("pointercancel", cleanup);
|
||||
};
|
||||
|
||||
/** [bilibili]/[youtube] 整行插入:表单裸输入统一走 parseVideoInput */
|
||||
const applyVideo = () => {
|
||||
const parsed = parseVideoInput(videoInput);
|
||||
if (!parsed) {
|
||||
toast("请输入 B站 BV 号/链接 或 YouTube 视频 ID/链接", "error");
|
||||
return;
|
||||
}
|
||||
restoreSel();
|
||||
const el = taRef.current;
|
||||
const start = el?.selectionStart ?? 0;
|
||||
const end = el?.selectionEnd ?? 0;
|
||||
const tag = `[${parsed.provider}]${parsed.id}[/${parsed.provider}]`;
|
||||
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
|
||||
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
|
||||
const inserted = padBefore + tag + padAfter;
|
||||
const next = value.slice(0, start) + inserted + value.slice(end);
|
||||
onChange(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const caret = start + inserted.length;
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
setPopover(null);
|
||||
};
|
||||
|
||||
/** [details 标题]…[/details] 折叠面板:包住选区或占位正文 */
|
||||
const insertDetails = () => {
|
||||
restoreSel();
|
||||
const el = taRef.current;
|
||||
const start = el?.selectionStart ?? 0;
|
||||
const end = el?.selectionEnd ?? 0;
|
||||
const selected = value.slice(start, end);
|
||||
const body = selected || "折叠内容";
|
||||
const open = "[details 点击展开]";
|
||||
const block = `${open}\n${body}\n[/details]`;
|
||||
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
|
||||
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
|
||||
const inserted = padBefore + block + padAfter;
|
||||
const next = value.slice(0, start) + inserted + value.slice(end);
|
||||
onChange(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const bodyStart = start + padBefore.length + open.length + 1;
|
||||
el.setSelectionRange(bodyStart, bodyStart + body.length);
|
||||
});
|
||||
setPopover(null);
|
||||
};
|
||||
|
||||
const openTimelineEditor = () => {
|
||||
rememberSel();
|
||||
const el = taRef.current;
|
||||
@@ -923,6 +1171,164 @@ export default function MarkdownEditor({
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// 文字颜色弹层:前景/背景两行独立选择,可同时设置;每行含预设、内联 HSV 取色器与自定义输入。
|
||||
// 用普通函数调用而非 <ColorPickRow/> 组件:避免内联组件每次渲染重挂载导致输入框失焦。
|
||||
const renderColorRow = (
|
||||
side: "fg" | "bg",
|
||||
label: string,
|
||||
presets: readonly string[],
|
||||
picked: string | null
|
||||
) => {
|
||||
const setPicked = (v: string | null) =>
|
||||
side === "fg" ? setColorFg(v) : setColorBg(v);
|
||||
const wheelOpen = activeWheel === side;
|
||||
return (
|
||||
<div className="j13-md-color-row">
|
||||
<span className="j13-md-color-row-label">{label}</span>
|
||||
<div className="j13-md-color-row-items">
|
||||
<button
|
||||
type="button"
|
||||
className={`j13-md-color-none${picked === null ? " is-active" : ""}`}
|
||||
onClick={() => setPicked(null)}
|
||||
>
|
||||
无
|
||||
</button>
|
||||
{presets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`j13-md-color-dot${picked?.toLowerCase() === c.toLowerCase() ? " is-active" : ""}`}
|
||||
style={{ background: c }}
|
||||
data-tip={c}
|
||||
aria-label={`使用 ${c}`}
|
||||
onClick={() => setPicked(c)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`j13-md-color-wheel${wheelOpen ? " is-active" : ""}`}
|
||||
data-tip="自定义颜色"
|
||||
aria-label="自定义颜色"
|
||||
aria-expanded={wheelOpen}
|
||||
onClick={() => toggleColorWheel(side)}
|
||||
>
|
||||
<Pipette size={12} />
|
||||
</button>
|
||||
<input
|
||||
className="j13-md-color-hex j13-md-popover-input"
|
||||
value={picked ?? ""}
|
||||
placeholder="#e03131 或 tomato"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setPicked(v);
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(v)) {
|
||||
if (side === "fg") setColorFgWheel(v);
|
||||
else setColorBgWheel(v);
|
||||
if (wheelOpen) setColorHsv(hexToHsv(v) ?? colorHsv);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
applyColor();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{wheelOpen && (
|
||||
<div className="j13-md-hsv">
|
||||
<div
|
||||
className="j13-md-hsv-sv"
|
||||
style={{
|
||||
background: `linear-gradient(to top, #000, rgba(0,0,0,0)), linear-gradient(to right, #fff, ${hsvToHex({ h: colorHsv.h, s: 1, v: 1 })})`,
|
||||
}}
|
||||
onPointerDown={onSvPointer}
|
||||
>
|
||||
<span
|
||||
className="j13-md-hsv-sv-thumb"
|
||||
style={{
|
||||
left: `${colorHsv.s * 100}%`,
|
||||
top: `${(1 - colorHsv.v) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="j13-md-hsv-hue" onPointerDown={onHuePointer}>
|
||||
<span
|
||||
className="j13-md-hsv-hue-thumb"
|
||||
style={{ left: `${(colorHsv.h / 360) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const colorPopover =
|
||||
popover === "color" ? (
|
||||
<div
|
||||
className={`j13-md-popover j13-md-popover-form j13-md-color-popover${toolNarrow ? " j13-md-popover-center" : ""}`}
|
||||
role="dialog"
|
||||
aria-label="文字颜色"
|
||||
>
|
||||
<div className="j13-md-color-preview">
|
||||
<span
|
||||
style={{
|
||||
color: colorFg ?? "inherit",
|
||||
backgroundColor: colorBg ?? "transparent",
|
||||
}}
|
||||
>
|
||||
Aa 示例文字
|
||||
</span>
|
||||
</div>
|
||||
{renderColorRow("fg", "前景", COLOR_FG_PRESETS, colorFg)}
|
||||
{renderColorRow("bg", "背景", COLOR_BG_PRESETS, colorBg)}
|
||||
<div className="j13-md-popover-footer">
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={() => applyColor()}>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// 视频嵌入弹层:裸输入统一交给 parseVideoInput 归一化
|
||||
const videoPopover =
|
||||
popover === "video" ? (
|
||||
<div
|
||||
className={`j13-md-popover j13-md-popover-form${toolNarrow ? " j13-md-popover-center" : ""}`}
|
||||
role="dialog"
|
||||
aria-label="插入视频"
|
||||
>
|
||||
<label className="j13-md-field">
|
||||
<span>B站 / YouTube</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={videoInput}
|
||||
onChange={(e) => setVideoInput(e.target.value)}
|
||||
className="j13-md-popover-input"
|
||||
placeholder="BV1… / 视频链接 / YouTube ID"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
applyVideo();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="j13-md-popover-footer">
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={applyVideo}>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const ToolBtn = ({
|
||||
label,
|
||||
icon,
|
||||
@@ -981,6 +1387,31 @@ export default function MarkdownEditor({
|
||||
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
|
||||
/>
|
||||
)}
|
||||
{!isComment && !toolNarrow && (
|
||||
<ToolBtn
|
||||
label="高亮 =="
|
||||
icon={<Highlighter size={15} />}
|
||||
onClick={() => runWrap({ before: "==", after: "==", placeholder: "高亮" })}
|
||||
/>
|
||||
)}
|
||||
{!isComment && !toolNarrow && (
|
||||
<ToolBtn
|
||||
label="键帽"
|
||||
icon={<Keyboard size={15} />}
|
||||
onClick={() => runWrap({ before: "[kbd]", after: "[/kbd]", placeholder: "Ctrl" })}
|
||||
/>
|
||||
)}
|
||||
{!toolNarrow && (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
label="文字颜色"
|
||||
icon={<Palette size={15} />}
|
||||
active={popover === "color"}
|
||||
onClick={() => openPopover("color")}
|
||||
/>
|
||||
{popover === "color" && colorPopover}
|
||||
</div>
|
||||
)}
|
||||
{!isComment && !toolNarrow && <Sep />}
|
||||
{!isComment && (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
@@ -1217,6 +1648,9 @@ export default function MarkdownEditor({
|
||||
)}
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "*", after: "*", placeholder: "斜体" }); setPopover(null); }}>斜体</button>
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "~~", after: "~~", placeholder: "删除线" }); setPopover(null); }}>删除线</button>
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "==", after: "==", placeholder: "高亮" }); setPopover(null); }}>高亮</button>
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "[kbd]", after: "[/kbd]", placeholder: "Ctrl" }); setPopover(null); }}>键帽</button>
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("color")}>颜色…</button>
|
||||
{!isComment && (
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("emoji")}>表情</button>
|
||||
)}
|
||||
@@ -1230,12 +1664,20 @@ export default function MarkdownEditor({
|
||||
)}
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); openTimelineEditor(); }}>时间线</button>
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE); setPopover(null); }}>条目卡</button>
|
||||
{!isComment && (
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => insertDetails()}>折叠面板</button>
|
||||
)}
|
||||
{!isComment && (
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("video")}>视频…</button>
|
||||
)}
|
||||
{toolNarrow && (
|
||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{toolNarrow && codePopover}
|
||||
{toolNarrow && popover === "color" && colorPopover}
|
||||
{toolNarrow && popover === "video" && videoPopover}
|
||||
{toolNarrow && popover === "emoji" && !isComment && (
|
||||
<div className="j13-md-popover j13-md-emoji-popover j13-md-popover-center" role="dialog" aria-label="插入表情">
|
||||
<EmojiPicker onPick={pickEmoji} />
|
||||
@@ -1259,6 +1701,17 @@ export default function MarkdownEditor({
|
||||
onClick={() => insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE)}
|
||||
/>
|
||||
)}
|
||||
{!isComment && !toolNarrow && (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
label="视频"
|
||||
icon={<MonitorPlay size={15} />}
|
||||
active={popover === "video"}
|
||||
onClick={() => openPopover("video")}
|
||||
/>
|
||||
{popover === "video" && videoPopover}
|
||||
</div>
|
||||
)}
|
||||
{allowHide && !toolNarrow ? (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
<ToolBtn
|
||||
|
||||
24
frontend/components/markdown/MdDetails.tsx
Normal file
24
frontend/components/markdown/MdDetails.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
type MdDetailsProps = {
|
||||
title: string;
|
||||
/** 正文已渲染的 React 节点(markdown);空正文可不传 */
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* [details 标题]…[/details] 折叠面板:原生 details/summary,无 JS、SSR 友好。
|
||||
* 正文 markdown 由调用方(MarkdownBody / MarkdownBodyClient)渲染后传入。
|
||||
*/
|
||||
export default function MdDetails({ title, children }: MdDetailsProps) {
|
||||
return (
|
||||
<details className="j13-md-details">
|
||||
<summary>
|
||||
<ChevronDown size={15} className="j13-md-details-caret" aria-hidden />
|
||||
<span className="j13-md-details-title">{title}</span>
|
||||
</summary>
|
||||
{children ? <div className="j13-md-details-body">{children}</div> : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
35
frontend/components/markdown/MdVideo.tsx
Normal file
35
frontend/components/markdown/MdVideo.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
type VideoProvider = "bilibili" | "youtube";
|
||||
|
||||
type MdVideoProps = {
|
||||
provider: VideoProvider;
|
||||
/** bilibili 为 BV 号;youtube 为视频 ID(parseVideoLine 已校验) */
|
||||
id: string;
|
||||
};
|
||||
|
||||
function embedUrl(provider: VideoProvider, id: string): string {
|
||||
if (provider === "bilibili") {
|
||||
return `https://player.bilibili.com/player.html?bvid=${encodeURIComponent(
|
||||
id
|
||||
)}&autoplay=0&danmaku=0&high_quality=1`;
|
||||
}
|
||||
return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* [bilibili]…[/bilibili] / [youtube]…[/youtube] 视频嵌入:
|
||||
* 16:9 容器 + 懒加载 iframe(bilibili 官方播放器 / youtube 隐私域名),纯静态无 JS。
|
||||
*/
|
||||
export default function MdVideo({ provider, id }: MdVideoProps) {
|
||||
return (
|
||||
<div className="j13-md-video">
|
||||
<iframe
|
||||
src={embedUrl(provider, id)}
|
||||
title={provider === "bilibili" ? `bilibili 视频 ${id}` : `YouTube 视频 ${id}`}
|
||||
loading="lazy"
|
||||
allowFullScreen
|
||||
referrerPolicy="no-referrer"
|
||||
allow="accelerometer; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
frontend/components/markdown/remarkInlineShortcodes.ts
Normal file
58
frontend/components/markdown/remarkInlineShortcodes.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Root as MdastRoot } from "mdast";
|
||||
import type { Text } from "mdast";
|
||||
import { visit, SKIP } from "unist-util-visit";
|
||||
import { splitInlineShortcodes } from "@/lib/inlineShortcodes";
|
||||
|
||||
const QUICK_RE = /==|\[(?:kbd|color|bg)[=\]]/i;
|
||||
|
||||
type MdastParent = { children: Array<Record<string, unknown>>; type: string };
|
||||
|
||||
/** 行内短代码 → mdast 节点(mdast-util-to-hast 支持 data.hName / data.hProperties) */
|
||||
function segToNodes(
|
||||
seg: Exclude<ReturnType<typeof splitInlineShortcodes>, null>[number]
|
||||
): Record<string, unknown>[] {
|
||||
if (seg.type === "text") return [{ type: "text", value: seg.value }];
|
||||
if (seg.type === "mark" || seg.type === "kbd") {
|
||||
return [
|
||||
{
|
||||
type: "emphasis",
|
||||
data: { hName: seg.type },
|
||||
children: [{ type: "text", value: seg.value }],
|
||||
},
|
||||
];
|
||||
}
|
||||
const style = [
|
||||
seg.fg ? `color:${seg.fg}` : null,
|
||||
seg.bg ? `background-color:${seg.bg}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
return [
|
||||
{
|
||||
type: "emphasis",
|
||||
data: { hName: "span", hProperties: { style } },
|
||||
children: [{ type: "text", value: seg.value }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* remark 插件:把文本节点中的 ==高亮== / [kbd] / [color] / [bg] 展开为
|
||||
* mark/kbd/span 节点。code/inlineCode 是字面节点没有子文本,天然免疫;
|
||||
* 未命中短代码的文本原样保留。
|
||||
*/
|
||||
export function remarkInlineShortcodes() {
|
||||
return (tree: MdastRoot) => {
|
||||
visit(tree, "text", (node: Text, index, parent) => {
|
||||
const p = parent as unknown as MdastParent | null;
|
||||
if (!p || index === undefined) return;
|
||||
const value = node.value;
|
||||
if (!QUICK_RE.test(value)) return;
|
||||
const segs = splitInlineShortcodes(value);
|
||||
if (!segs) return;
|
||||
const nodes = segs.flatMap(segToNodes);
|
||||
p.children.splice(index, 1, ...nodes);
|
||||
return [SKIP, index + nodes.length];
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { Root } from "hast";
|
||||
import type { Root as MdastRoot } from "mdast";
|
||||
import { visit } from "unist-util-visit";
|
||||
import { mergeMarkdownComponents } from "./mergeComponents";
|
||||
import { remarkInlineShortcodes } from "./remarkInlineShortcodes";
|
||||
import {
|
||||
getPrettyCodeHighlighter,
|
||||
resolveShikiLang,
|
||||
@@ -54,6 +55,7 @@ export async function renderMarkdownToReact(
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkInlineShortcodes)
|
||||
.use(remarkNormalizeCodeLang)
|
||||
.use(remarkRehype)
|
||||
.use(rehypePrettyCode, { ...prettyCodeOptions });
|
||||
|
||||
@@ -4841,6 +4841,20 @@ export async function fetchAdminMediaLibrary(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 客户端管理端媒体库(带令牌刷新),品牌选图等后台场景用 */
|
||||
export async function apiAdminMediaLibrary(): Promise<{
|
||||
items: MediaLibraryItem[];
|
||||
counts: Record<string, number>;
|
||||
total: number;
|
||||
}> {
|
||||
const res = await fetchWithRefresh("/api/admin/media-library", {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "加载媒体库失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function apiAdminGetSidebar(): Promise<{
|
||||
config: SidebarConfig;
|
||||
widgets: SidebarWidgetRecord[];
|
||||
|
||||
@@ -187,9 +187,3 @@ export function brandFileOk(slot: BrandSlot, file: File): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mediaURLFitsSlot(slot: BrandSlot, url: string): boolean {
|
||||
const u = url.toLowerCase();
|
||||
if (slot === "favicon") return /\.(png|webp|svg|ico)(\?|$)/.test(u);
|
||||
return /\.(png|webp|svg)(\?|$)/.test(u);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ export const AVATAR_OUTPUT_SIZE = 320;
|
||||
// 裁剪后 WebP 文件大小上限(与后端 service.AvatarMaxBytes 保持一致)
|
||||
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// 品牌 Logo 输出高度上限(与后端 brandMaxDim 对齐)
|
||||
export const BRAND_MAX_DIM = 2048;
|
||||
// 裁剪后品牌图大小上限(与后端 BrandLogoMaxBytes 对齐)
|
||||
export const BRAND_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
@@ -18,6 +23,25 @@ function canvasToWebp(canvas: HTMLCanvasElement, quality: number): Promise<Blob
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, "image/webp", quality));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按裁剪区域导出 WebP。
|
||||
* 自动按 0.92 → 0.65 降质量,直到满足大小上限;仍超限则抛出错误。
|
||||
*/
|
||||
async function exportWebp(
|
||||
canvas: HTMLCanvasElement,
|
||||
maxBytes: number
|
||||
): Promise<Blob> {
|
||||
let last: Blob | null = null;
|
||||
for (const quality of [0.92, 0.85, 0.75, 0.65]) {
|
||||
const blob = await canvasToWebp(canvas, quality);
|
||||
if (!blob) throw new Error("当前浏览器不支持 WebP 导出,请使用最新版 Chrome/Edge/Safari");
|
||||
last = blob;
|
||||
if (blob.size <= maxBytes) return blob;
|
||||
}
|
||||
if (last && last.size <= maxBytes) return last;
|
||||
throw new Error("裁剪后的图片仍超过大小限制,请缩小图片后再试");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按裁剪区域导出正方形 WebP。
|
||||
* 自动按 0.92 → 0.65 降质量,直到满足大小上限;仍超限则抛出错误。
|
||||
@@ -47,13 +71,51 @@ export async function getCroppedAvatarWebp(
|
||||
AVATAR_OUTPUT_SIZE
|
||||
);
|
||||
|
||||
let last: Blob | null = null;
|
||||
for (const quality of [0.92, 0.85, 0.75, 0.65]) {
|
||||
const blob = await canvasToWebp(canvas, quality);
|
||||
if (!blob) throw new Error("当前浏览器不支持 WebP 导出,请使用最新版 Chrome/Edge/Safari");
|
||||
last = blob;
|
||||
if (blob.size <= AVATAR_MAX_BYTES) return blob;
|
||||
}
|
||||
if (last && last.size <= AVATAR_MAX_BYTES) return last;
|
||||
throw new Error("裁剪后的图片仍超过 2MB,请缩小图片后再试");
|
||||
return exportWebp(canvas, AVATAR_MAX_BYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按裁剪区域导出任意比例 WebP(品牌 Logo 用)。
|
||||
* 输出高度限制在 BRAND_MAX_DIM 以内,宽度按比例自适应。
|
||||
*/
|
||||
export async function getCroppedBrandWebp(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
aspect: number
|
||||
): Promise<Blob> {
|
||||
const image = await loadImage(imageSrc);
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
// 以高度为基准,宽度按纵横比计算;若超宽则按宽度反推高度
|
||||
let outH = pixelCrop.height;
|
||||
let outW = Math.round(outH * aspect);
|
||||
if (outW > BRAND_MAX_DIM) {
|
||||
outW = BRAND_MAX_DIM;
|
||||
outH = Math.round(outW / aspect);
|
||||
}
|
||||
if (outH > BRAND_MAX_DIM) {
|
||||
outH = BRAND_MAX_DIM;
|
||||
outW = Math.round(outH * aspect);
|
||||
}
|
||||
|
||||
canvas.width = outW;
|
||||
canvas.height = outH;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("当前浏览器不支持图片裁剪");
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
outW,
|
||||
outH
|
||||
);
|
||||
|
||||
return exportWebp(canvas, BRAND_MAX_BYTES);
|
||||
}
|
||||
|
||||
117
frontend/lib/directiveBlocks.test.ts
Normal file
117
frontend/lib/directiveBlocks.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
DETAILS_DEFAULT_TITLE,
|
||||
isDetailsOpen,
|
||||
parseDetailsOpen,
|
||||
parseVideoInput,
|
||||
parseVideoLine,
|
||||
splitContentSegments,
|
||||
} from "./directiveBlocks.ts";
|
||||
|
||||
describe("parseDetailsOpen / isDetailsOpen", () => {
|
||||
it("开标记整行匹配,标题缺省「点击展开」", () => {
|
||||
assert.equal(isDetailsOpen("[details]"), true);
|
||||
assert.equal(parseDetailsOpen("[details]"), DETAILS_DEFAULT_TITLE);
|
||||
assert.equal(parseDetailsOpen("[details 点击展开看结论]"), "点击展开看结论");
|
||||
assert.equal(isDetailsOpen("[details 标题] 后缀"), false);
|
||||
assert.equal(isDetailsOpen("[details]"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVideoInput", () => {
|
||||
it("BV 号与 B站链接", () => {
|
||||
assert.deepEqual(parseVideoInput("BV1GJ411x7h7"), {
|
||||
provider: "bilibili",
|
||||
id: "BV1GJ411x7h7",
|
||||
});
|
||||
assert.deepEqual(parseVideoInput("https://www.bilibili.com/video/BV1GJ411x7h7/?p=1"), {
|
||||
provider: "bilibili",
|
||||
id: "BV1GJ411x7h7",
|
||||
});
|
||||
});
|
||||
it("YouTube ID 与链接", () => {
|
||||
assert.deepEqual(parseVideoInput("dQw4w9WgXcQ"), {
|
||||
provider: "youtube",
|
||||
id: "dQw4w9WgXcQ",
|
||||
});
|
||||
assert.deepEqual(parseVideoInput("https://youtu.be/dQw4w9WgXcQ"), {
|
||||
provider: "youtube",
|
||||
id: "dQw4w9WgXcQ",
|
||||
});
|
||||
assert.deepEqual(parseVideoInput("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), {
|
||||
provider: "youtube",
|
||||
id: "dQw4w9WgXcQ",
|
||||
});
|
||||
});
|
||||
it("非法输入返回 null", () => {
|
||||
assert.equal(parseVideoInput(""), null);
|
||||
assert.equal(parseVideoInput("hello world"), null);
|
||||
assert.equal(parseVideoInput("https://evil.com/<script>"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVideoLine", () => {
|
||||
it("整行短代码匹配且大小写不敏感", () => {
|
||||
assert.deepEqual(parseVideoLine("[bilibili]BV1GJ411x7h7[/bilibili]"), {
|
||||
provider: "bilibili",
|
||||
id: "BV1GJ411x7h7",
|
||||
});
|
||||
assert.deepEqual(parseVideoLine("[BILIBILI]https://www.bilibili.com/video/BV1GJ411x7h7[/BILIBILI]"), {
|
||||
provider: "bilibili",
|
||||
id: "BV1GJ411x7h7",
|
||||
});
|
||||
assert.deepEqual(parseVideoLine("[youtube]dQw4w9WgXcQ[/youtube]"), {
|
||||
provider: "youtube",
|
||||
id: "dQw4w9WgXcQ",
|
||||
});
|
||||
});
|
||||
it("标签与平台不一致或内容非法时返回 null", () => {
|
||||
assert.equal(parseVideoLine("[bilibili]dQw4w9WgXcQ[/bilibili]"), null);
|
||||
assert.equal(parseVideoLine("[youtube]BV1GJ411x7h7[/youtube]"), null);
|
||||
assert.equal(parseVideoLine("[bilibili]随便[/bilibili]"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitContentSegments: details/video", () => {
|
||||
it("[details] 块拆分,正文保留 markdown", () => {
|
||||
const segs = splitContentSegments(
|
||||
"前文\n\n[details 结论]\n**加粗** 内容\n[/details]\n\n后文"
|
||||
);
|
||||
assert.deepEqual(
|
||||
segs.map((s) => s.type),
|
||||
["md", "details", "md"]
|
||||
);
|
||||
const d = segs[1];
|
||||
assert.equal(d.type === "details" ? d.title : "", "结论");
|
||||
assert.equal(d.type === "details" ? d.body : "", "**加粗** 内容");
|
||||
});
|
||||
|
||||
it("details 嵌套开标签:外层按 md,内层与闭合配对(与 timeline 语义一致)", () => {
|
||||
const segs = splitContentSegments("[details A]\n[details B]\n[/details]");
|
||||
assert.deepEqual(segs.map((s) => s.type), ["md", "details"]);
|
||||
});
|
||||
|
||||
it("未闭合的 details 整段按 md", () => {
|
||||
const segs = splitContentSegments("正文\n\n[details A]\n没有闭合标记");
|
||||
assert.deepEqual(segs.map((s) => s.type), ["md"]);
|
||||
});
|
||||
|
||||
it("代码围栏内伪语法不拆", () => {
|
||||
const segs = splitContentSegments("```\n[details 假的]\n[/details]\n```");
|
||||
assert.deepEqual(segs.map((s) => s.type), ["md"]);
|
||||
});
|
||||
|
||||
it("视频整行拆为 video 段;行内出现不拆", () => {
|
||||
const segs = splitContentSegments(
|
||||
"看这个:\n\n[bilibili]BV1GJ411x7h7[/bilibili]\n\n文中 [youtube]dQw4w9WgXcQ[/youtube] 不拆"
|
||||
);
|
||||
assert.deepEqual(
|
||||
segs.map((s) => s.type),
|
||||
["md", "video", "md"]
|
||||
);
|
||||
const v = segs[1];
|
||||
assert.equal(v.type === "video" ? v.provider : "", "bilibili");
|
||||
assert.equal(v.type === "video" ? v.id : "", "BV1GJ411x7h7");
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,9 @@ export type ContentSegment =
|
||||
body: string;
|
||||
}
|
||||
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
|
||||
| { type: "entries"; items: EntryItem[]; rawBody: string };
|
||||
| { type: "entries"; items: EntryItem[]; rawBody: string }
|
||||
| { type: "details"; title: string; body: string }
|
||||
| { type: "video"; provider: "bilibili" | "youtube"; id: string };
|
||||
|
||||
function isTimelineClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === "[/timeline]";
|
||||
@@ -35,6 +37,68 @@ function isTimelineOpen(trimmed: string): boolean {
|
||||
return trimmed.toLowerCase() === "[timeline]";
|
||||
}
|
||||
|
||||
/* ---------- [details] 折叠面板 ---------- */
|
||||
|
||||
const DETAILS_OPEN_RE = /^\[details(?:\s+([^\]]*))?\]$/i;
|
||||
export const DETAILS_DEFAULT_TITLE = "点击展开";
|
||||
|
||||
function isDetailsClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === "[/details]";
|
||||
}
|
||||
|
||||
/** 开标记整行匹配:[details 标题文字];标题缺省「点击展开」 */
|
||||
export function isDetailsOpen(trimmed: string): boolean {
|
||||
return DETAILS_OPEN_RE.test(trimmed);
|
||||
}
|
||||
|
||||
export function parseDetailsOpen(trimmed: string): string {
|
||||
const m = trimmed.match(DETAILS_OPEN_RE);
|
||||
const title = (m?.[1] ?? "").trim();
|
||||
return title || DETAILS_DEFAULT_TITLE;
|
||||
}
|
||||
|
||||
/* ---------- [bilibili] / [youtube] 视频嵌入(整行单标记) ---------- */
|
||||
|
||||
const VIDEO_LINE_RE = /^\[(bilibili|youtube)\]([^\[\]]+)\[\/\1\]$/i;
|
||||
const BILI_BV_RE = /^BV[0-9A-Za-z]{8,12}$/;
|
||||
const BILI_URL_BV_RE = /\/video\/(BV[0-9A-Za-z]{8,12})/;
|
||||
const YT_ID_RE = /^[0-9A-Za-z_-]{6,20}$/;
|
||||
|
||||
/**
|
||||
* 裸输入解析:BV 号 / B站视频链接 / YouTube ID / youtu.be、watch?v= 链接。
|
||||
* 短代码行与编辑器「插入视频」表单共用;解析失败返回 null。
|
||||
*/
|
||||
export function parseVideoInput(
|
||||
raw: string
|
||||
): { provider: "bilibili" | "youtube"; id: string } | null {
|
||||
const s = raw.trim();
|
||||
if (!s || /[\s<>"']/.test(s)) return null;
|
||||
const bvFromUrl = s.match(BILI_URL_BV_RE);
|
||||
if (bvFromUrl) return { provider: "bilibili", id: bvFromUrl[1] };
|
||||
if (BILI_BV_RE.test(s)) return { provider: "bilibili", id: s };
|
||||
if (/youtu\.?be/i.test(s)) {
|
||||
const m = s.match(/(?:youtu\.be\/|[?&]v=)([0-9A-Za-z_-]{6,20})/);
|
||||
return m ? { provider: "youtube", id: m[1] } : null;
|
||||
}
|
||||
if (YT_ID_RE.test(s)) return { provider: "youtube", id: s };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 整行 `[bilibili]…[/bilibili]` / `[youtube]…[/youtube]`。
|
||||
* 标签名与内容平台不一致、或内容解析失败时返回 null(该行按普通 Markdown 处理)。
|
||||
*/
|
||||
export function parseVideoLine(
|
||||
trimmed: string
|
||||
): { provider: "bilibili" | "youtube"; id: string } | null {
|
||||
const m = trimmed.match(VIDEO_LINE_RE);
|
||||
if (!m) return null;
|
||||
const provider = m[1].toLowerCase() as "bilibili" | "youtube";
|
||||
const parsed = parseVideoInput(m[2]);
|
||||
if (!parsed || parsed.provider !== provider) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
|
||||
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
|
||||
@@ -53,7 +117,11 @@ export function splitContentSegments(
|
||||
}
|
||||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const raw: Array<
|
||||
{ type: "md"; text: string } | { type: "timeline"; body: string } | { type: "entries"; body: string }
|
||||
| { type: "md"; text: string }
|
||||
| { type: "timeline"; body: string }
|
||||
| { type: "entries"; body: string }
|
||||
| { type: "details"; title: string; body: string }
|
||||
| { type: "video"; provider: "bilibili" | "youtube"; id: string }
|
||||
> = [];
|
||||
let buf: string[] = [];
|
||||
let inCode = false;
|
||||
@@ -137,6 +205,29 @@ export function splitContentSegments(
|
||||
continue;
|
||||
}
|
||||
|
||||
// [details 标题]…[/details] 折叠面板:嵌套开标签视为失败按 md 处理
|
||||
if (isDetailsOpen(trimmed)) {
|
||||
const hit = scanBlock(isDetailsOpen, isDetailsClose);
|
||||
if (!hit) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
flushMd();
|
||||
raw.push({ type: "details", title: parseDetailsOpen(trimmed), body: hit.body });
|
||||
i = hit.end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// [bilibili]…[/bilibili] / [youtube]…[/youtube] 整行视频嵌入
|
||||
const video = parseVideoLine(trimmed);
|
||||
if (video) {
|
||||
flushMd();
|
||||
raw.push({ type: "video", ...video });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
|
||||
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
|
||||
if (isEntriesOffOpen(trimmed)) {
|
||||
@@ -174,6 +265,14 @@ export function splitContentSegments(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (part.type === "details") {
|
||||
out.push({ type: "details", title: part.title, body: part.body });
|
||||
continue;
|
||||
}
|
||||
if (part.type === "video") {
|
||||
out.push({ type: "video", provider: part.provider, id: part.id });
|
||||
continue;
|
||||
}
|
||||
const hideSegs = splitHideSegments(part.text) as HideSegment[];
|
||||
for (const s of hideSegs) {
|
||||
if (s.type === "md") {
|
||||
|
||||
80
frontend/lib/inlineShortcodes.test.ts
Normal file
80
frontend/lib/inlineShortcodes.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
isValidColorValue,
|
||||
splitInlineShortcodes,
|
||||
} from "./inlineShortcodes.ts";
|
||||
|
||||
describe("isValidColorValue", () => {
|
||||
it("放行 #hex 与英文颜色名", () => {
|
||||
assert.equal(isValidColorValue("#e03131"), true);
|
||||
assert.equal(isValidColorValue("#FFF"), true);
|
||||
assert.equal(isValidColorValue("#ffffffff"), true);
|
||||
assert.equal(isValidColorValue("tomato"), true);
|
||||
});
|
||||
it("拒绝注入向量与非法值", () => {
|
||||
assert.equal(isValidColorValue("url(https://evil)", ), false);
|
||||
assert.equal(isValidColorValue("red;padding:0"), false);
|
||||
assert.equal(isValidColorValue("#GGG"), false);
|
||||
assert.equal(isValidColorValue(""), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitInlineShortcodes", () => {
|
||||
it("无短代码时返回 null", () => {
|
||||
assert.equal(splitInlineShortcodes("普通文本 == 未闭合 [kbd]未闭合"), null);
|
||||
});
|
||||
|
||||
it("==高亮==", () => {
|
||||
assert.deepEqual(splitInlineShortcodes("前==高亮==后"), [
|
||||
{ type: "text", value: "前" },
|
||||
{ type: "mark", value: "高亮" },
|
||||
{ type: "text", value: "后" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("[kbd]", () => {
|
||||
assert.deepEqual(splitInlineShortcodes("[kbd]Ctrl+C[/kbd]"), [
|
||||
{ type: "kbd", value: "Ctrl+C" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("[color=前景] 与 [bg=背景]", () => {
|
||||
assert.deepEqual(splitInlineShortcodes("[color=#e03131]红字[/color]"), [
|
||||
{ type: "color", value: "红字", fg: "#e03131" },
|
||||
]);
|
||||
assert.deepEqual(splitInlineShortcodes("[bg=#fff3bf]黄底[/bg]"), [
|
||||
{ type: "color", value: "黄底", bg: "#fff3bf" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("[color=前景 bg=背景] 组合", () => {
|
||||
assert.deepEqual(splitInlineShortcodes("[color=#fff bg=#c92a2a]白字红底[/color]"), [
|
||||
{ type: "color", value: "白字红底", fg: "#fff", bg: "#c92a2a" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("非法颜色整段回退为原文(返回 null 表示无需改写)", () => {
|
||||
assert.equal(splitInlineShortcodes("[color=url(https://evil)]x[/color]"), null);
|
||||
assert.equal(splitInlineShortcodes("[bg=red;padding:0]x[/bg]"), null);
|
||||
});
|
||||
|
||||
it("未闭合标记保持字面", () => {
|
||||
assert.equal(splitInlineShortcodes("[color=#fff]未闭合"), null);
|
||||
});
|
||||
|
||||
it("混合片段按顺序拆分", () => {
|
||||
assert.deepEqual(splitInlineShortcodes("a==b==[kbd]C[/kbd]d"), [
|
||||
{ type: "text", value: "a" },
|
||||
{ type: "mark", value: "b" },
|
||||
{ type: "kbd", value: "C" },
|
||||
{ type: "text", value: "d" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("大小写不敏感", () => {
|
||||
assert.deepEqual(splitInlineShortcodes("[KBD]Esc[/KBD]"), [
|
||||
{ type: "kbd", value: "Esc" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
75
frontend/lib/inlineShortcodes.ts
Normal file
75
frontend/lib/inlineShortcodes.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 行内短代码解析(纯函数):
|
||||
* ==高亮== / [kbd]Ctrl[/kbd] / [color=前景( bg=背景)]文字[/color] / [bg=背景]文字[/bg]
|
||||
* 颜色值白名单(#hex 或 CSS 颜色名),非法值整段按普通文本回退,防 CSS 注入。
|
||||
*/
|
||||
|
||||
export type InlineShortcodeSegment =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "mark"; value: string }
|
||||
| { type: "kbd"; value: string }
|
||||
| { type: "color"; value: string; fg?: string; bg?: string };
|
||||
|
||||
export const COLOR_VALUE_RE = /^(#[0-9a-fA-F]{3,8}|[a-zA-Z]{1,32})$/;
|
||||
|
||||
export function isValidColorValue(v: string): boolean {
|
||||
return COLOR_VALUE_RE.test(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描顺序:[kbd] → [color] → [bg] → ==…==。
|
||||
* 标签内容不含 [ 与换行,避免嵌套歧义;未闭合标记保持字面。
|
||||
* 无任何短代码命中时返回 null(调用方保留原节点)。
|
||||
*/
|
||||
const SCAN_RE =
|
||||
/\[kbd\]([^\[\]\n]+)\[\/kbd\]|\[color=([^\]\s\[]+)(?:\s+bg=([^\]\s\[]+))?\]([^\[\]\n]+)\[\/color\]|\[bg=([^\]\s\[]+)\]([^\[\]\n]+)\[\/bg\]|==([^=\n]+)==/gi;
|
||||
|
||||
export function splitInlineShortcodes(
|
||||
text: string
|
||||
): InlineShortcodeSegment[] | null {
|
||||
if (!text) return null;
|
||||
const re = new RegExp(SCAN_RE.source, "gi");
|
||||
const out: InlineShortcodeSegment[] = [];
|
||||
let last = 0;
|
||||
let changed = false;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text))) {
|
||||
const [full, kbd, colorFg, colorBg, colorBody, bgVal, bgBody, markBody] = m;
|
||||
const before = text.slice(last, m.index);
|
||||
if (before) out.push({ type: "text", value: before });
|
||||
last = m.index + full.length;
|
||||
|
||||
if (kbd !== undefined) {
|
||||
out.push({ type: "kbd", value: kbd });
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (colorFg !== undefined) {
|
||||
if (!isValidColorValue(colorFg) || (colorBg !== undefined && !isValidColorValue(colorBg))) {
|
||||
out.push({ type: "text", value: full });
|
||||
continue;
|
||||
}
|
||||
out.push(
|
||||
colorBg
|
||||
? { type: "color", value: colorBody, fg: colorFg, bg: colorBg }
|
||||
: { type: "color", value: colorBody, fg: colorFg }
|
||||
);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (bgVal !== undefined) {
|
||||
if (!isValidColorValue(bgVal)) {
|
||||
out.push({ type: "text", value: full });
|
||||
continue;
|
||||
}
|
||||
out.push({ type: "color", value: bgBody, bg: bgVal });
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
out.push({ type: "mark", value: markBody });
|
||||
changed = true;
|
||||
}
|
||||
const rest = text.slice(last);
|
||||
if (rest) out.push({ type: "text", value: rest });
|
||||
return changed ? out : null;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"postbuild": "node scripts/extract-font-css.mjs",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint",
|
||||
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/urlStyle.test.ts lib/mobileTabs.test.ts lib/headerNav.test.ts lib/widgetCode.test.ts lib/widgetLint.test.ts lib/smartDiff.test.ts lib/dyEmoji.test.ts lib/entryBlocks.test.ts lib/bookSearch.test.ts"
|
||||
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/urlStyle.test.ts lib/mobileTabs.test.ts lib/headerNav.test.ts lib/widgetCode.test.ts lib/widgetLint.test.ts lib/smartDiff.test.ts lib/dyEmoji.test.ts lib/entryBlocks.test.ts lib/bookSearch.test.ts lib/inlineShortcodes.test.ts lib/directiveBlocks.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||
|
||||
Reference in New Issue
Block a user