Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ apps/docs/.vitepress/cache/
.artifacts/
auth-state.json
/.obsidian
/.reasonix
/.worktrees
/reasonix.toml
56 changes: 55 additions & 1 deletion apps/website/src/components/MarkdownEditor.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ubbEmotionDisplayName, type UbbEmotionDescriptor } from "@cc98/ubb";
import { CrepeBuilder } from "@milkdown/crepe/builder";
import { blockEdit } from "@milkdown/crepe/feature/block-edit";
import { codeMirror } from "@milkdown/crepe/feature/code-mirror";
Expand All @@ -17,10 +18,21 @@ import { clipboard } from "@milkdown/kit/plugin/clipboard";
import { uploadConfig } from "@milkdown/kit/plugin/upload";
import { insertImageCommand, insertImageInputRule } from "@milkdown/kit/preset/commonmark";
import { callCommand, replaceAll } from "@milkdown/kit/utils";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { onClickOutside } from "@vueuse/core";
import {
computed,
onBeforeUnmount,
onMounted,
ref,
watch,
type ComponentPublicInstance,
} from "vue";
import { normalizeApiError } from "../lib/api-error";
import { createLogger } from "../lib/logger";
import { useThemeStore } from "../stores/theme";
import { appendMarkdownBlock, createAttachmentMarkdown } from "./markdown-editor";
import EmojiPanel from "./markdown-editor/EmojiPanel.vue";
import { emojiButtonIcon, resolveEmotionDisplaySrc } from "./markdown-editor/emoji-data";
import UiButton from "./ui/Button.vue";

const props = withDefaults(
Expand All @@ -46,6 +58,7 @@ const emit = defineEmits<{
}>();

const logger = createLogger("markdown-editor");
const theme = useThemeStore();
const editorRoot = ref<HTMLDivElement | null>(null);
const imageInput = ref<HTMLInputElement | null>(null);
const attachmentInput = ref<HTMLInputElement | null>(null);
Expand All @@ -56,6 +69,8 @@ const imageUploading = ref(false);
const attachmentUploading = ref(false);
const uploadError = ref("");
const currentMarkdown = ref(props.modelValue);
const emojiPanelOpen = ref(false);
const emojiPanel = ref<ComponentPublicInstance | null>(null);
const overLimit = computed(() => currentMarkdown.value.length > props.maxLength);
const topBarLabels = [
"加粗",
Expand All @@ -71,6 +86,7 @@ const topBarLabels = [
"公式",
"引用",
"分隔线",
"表情",
] as const;
const selectionToolbarLabels = [
"加粗",
Expand Down Expand Up @@ -107,6 +123,27 @@ function replaceEditorMarkdown(markdown: string) {
editor.value?.action(replaceAll(markdown));
}

function insertEmoji(emotion: UbbEmotionDescriptor) {
emojiPanelOpen.value = false;
if (props.disabled || !editor.value) return;
const src = resolveEmotionDisplaySrc(emotion, theme.effectiveMode === "dark");
editor.value.action((ctx) => {
const view = ctx.get(editorViewCtx);
const node = view.state.schema.nodes.image?.createAndFill({
src,
alt: ubbEmotionDisplayName(emotion),
});
if (!node) return;
view.focus();
view.dispatch(view.state.tr.replaceSelectionWith(node));
});
}

// 表情按钮在 topBar 内(面板之外),它的点击由 buildTopBar 的 onRun 负责开合,这里忽略。
onClickOutside(emojiPanel, () => (emojiPanelOpen.value = false), {
ignore: [".milkdown-top-bar"],
});

async function uploadImageFiles(files: File[]): Promise<string[]> {
uploadError.value = "";
if (props.disabled) return [];
Expand Down Expand Up @@ -229,6 +266,16 @@ onMounted(async () => {
{ label: "标题 2", level: 2 },
{ label: "标题 3", level: 3 },
],
buildTopBar: (builder) => {
builder.addGroup("emoji", "表情").addItem("emoji", {
icon: emojiButtonIcon,
active: () => false,
onRun: () => {
if (props.disabled) return;
emojiPanelOpen.value = !emojiPanelOpen.value;
},
});
},
});

instance.setReadonly(props.disabled);
Expand Down Expand Up @@ -299,6 +346,7 @@ watch(
watch(
() => props.disabled,
(disabled) => {
if (disabled) emojiPanelOpen.value = false;
crepe.value?.setReadonly(disabled);
},
);
Expand All @@ -318,6 +366,8 @@ onBeforeUnmount(() => {
<p v-if="!editorReady && !uploadError" class="markdown-editor__loading">编辑器加载中…</p>
</div>

<EmojiPanel v-if="emojiPanelOpen" ref="emojiPanel" @select="insertEmoji" />

<div class="markdown-editor__actions">
<div class="flex flex-wrap items-center gap-1">
<UiButton
Expand Down Expand Up @@ -364,6 +414,10 @@ onBeforeUnmount(() => {
</template>

<style scoped>
.markdown-editor {
position: relative;
}

.markdown-editor__shell {
position: relative;
overflow: hidden;
Expand Down
149 changes: 149 additions & 0 deletions apps/website/src/components/markdown-editor/EmojiPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { listUbbEmotions, ubbEmotionDisplayName, type UbbEmotionDescriptor } from "@cc98/ubb";
import { computed, ref } from "vue";
import { useThemeStore } from "../../stores/theme";
import { emotionGroups, resolveEmotionDisplaySrc } from "./emoji-data";

const emit = defineEmits<{
select: [emotion: UbbEmotionDescriptor];
}>();

const theme = useThemeStore();
const activeGroup = ref(emotionGroups[0]);
const emotions = computed(() => listUbbEmotions(activeGroup.value.family));
</script>

<template>
<div class="emoji-panel" role="dialog" aria-label="选择表情">
<div class="emoji-panel__tabs" role="tablist">
<button
v-for="group in emotionGroups"
:key="group.family"
type="button"
class="emoji-panel__tab"
:class="{ 'emoji-panel__tab--active': group.family === activeGroup.family }"
role="tab"
:aria-selected="group.family === activeGroup.family"
@click="activeGroup = group"
>
{{ group.label }}
</button>
</div>
<div class="emoji-panel__body">
<div class="emoji-panel__grid" :style="{ '--emoji-cell': activeGroup.cell }">
<button
v-for="emotion in emotions"
:key="emotion.src"
type="button"
class="emoji-panel__item"
:title="ubbEmotionDisplayName(emotion)"
@click="emit('select', emotion)"
>
<img
:src="resolveEmotionDisplaySrc(emotion, theme.effectiveMode === 'dark')"
:alt="ubbEmotionDisplayName(emotion)"
loading="lazy"
/>
</button>
</div>
</div>
</div>
</template>

<style scoped>
/* 小框浮层:不铺满编辑器宽度,右对齐到右上角的表情按钮,从按钮方向弹出。 */
.emoji-panel {
position: absolute;
/* 对齐编辑器 shell 顶部 1px 边框 + topBar 高度 2.75rem */
top: calc(2.75rem + 1px);
right: 0.5rem;
z-index: 20;
width: min(22rem, calc(100% - 1rem));
transform-origin: top right;
animation: emoji-pop-in 160ms ease;
display: flex;
flex-direction: column;
max-height: 18rem;
border: 1px solid var(--cc98-color-border);
border-radius: var(--cc98-radius-sm);
background: var(--cc98-color-surface);
box-shadow: var(--crepe-shadow-2);
}

@keyframes emoji-pop-in {
from {
opacity: 0;
transform: translateY(-0.375rem) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

.emoji-panel__tabs {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.5rem;
border-bottom: 1px solid var(--cc98-color-border);
}

.emoji-panel__tab {
padding: 0.25rem 0.625rem;
border: none;
border-radius: var(--cc98-radius-sm);
background: none;
color: var(--cc98-color-text-muted);
font-size: 0.8125rem;
cursor: pointer;
}

.emoji-panel__tab:hover {
background: var(--cc98-color-surface-subtle);
color: var(--cc98-color-text);
}

/* 亮色下 primary-soft 与 primary 同色,激活态用 on-primary 白字保证对比度,hover 时保持。 */
.emoji-panel__tab--active,
.emoji-panel__tab--active:hover {
background: var(--cc98-color-primary-soft);
color: var(--cc98-color-on-primary);
}

.emoji-panel__body {
overflow-y: auto;
padding: 0.625rem;
}

/* 格子尺寸沿用原 UBB 编辑器的习惯:AC 娘与 CC98/雀魂大图,麻将脸小图,其余适中。 */
.emoji-panel__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--emoji-cell), 1fr));
gap: 0.375rem;
}

.emoji-panel__item {
display: flex;
align-items: center;
justify-content: center;
width: var(--emoji-cell);
height: var(--emoji-cell);
padding: 0.25rem;
border: 1px solid transparent;
border-radius: var(--cc98-radius-sm);
background: none;
cursor: pointer;
}

.emoji-panel__item:hover {
background: var(--cc98-color-surface-subtle);
border-color: var(--cc98-color-border);
}

.emoji-panel__item img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>
36 changes: 36 additions & 0 deletions apps/website/src/components/markdown-editor/emoji-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { UbbEmotionDescriptor, UbbEmotionFamily } from "@cc98/ubb";

/**
* 表情面板的分类配置。表情数据(编号、资源地址、展示名)来自 packages/ubb,
* 这里只描述 UI 呈现:tab 顺序、tab 文案、格子边长(按各分类图片实际尺寸取)。
*/
interface EmotionGroup {
family: UbbEmotionFamily;
label: string;
cell: string;
}

export const emotionGroups: EmotionGroup[] = [
{ family: "cc98", label: "CC98", cell: "4rem" },
{ family: "ac", label: "AC娘", cell: "4.75rem" },
{ family: "mahjong-animal", label: "麻将 动物", cell: "2.5rem" },
{ family: "mahjong-cartoon", label: "麻将 卡通", cell: "2.5rem" },
{ family: "mahjong-face", label: "麻将 脸", cell: "2.5rem" },
{ family: "tb", label: "贴吧", cell: "3rem" },
{ family: "ms", label: "雀魂", cell: "4rem" },
{ family: "em", label: "经典", cell: "3rem" },
];

/** 表情按钮图标,取自 heroicons:face-smile-solid,风格与 Crepe 内置图标一致。 */
export const emojiButtonIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-2.625 6c-.54 0-.828.419-.936.634a1.96 1.96 0 0 0-.189.866c0 .298.059.605.189.866c.108.215.395.634.936.634c.54 0 .828-.419.936-.634c.13-.26.189-.568.189-.866s-.059-.605-.189-.866c-.108-.215-.395-.634-.936-.634m4.314.634c.108-.215.395-.634.936-.634c.54 0 .828.419.936.634c.13.26.189.568.189.866s-.059.605-.189.866c-.108.215-.395.634-.936.634c-.54 0-.828-.419-.936-.634a1.96 1.96 0 0 1-.189-.866c0-.298.059-.605.189-.866m2.023 6.828a.75.75 0 1 0-1.06-1.06a3.75 3.75 0 0 1-5.304 0a.75.75 0 0 0-1.06 1.06a5.25 5.25 0 0 0 7.424 0" clip-rule="evenodd"/></svg>`;

/**
* 按当前主题解析 AC 娘资源的显示/插入地址:暗色模式使用 ac-dark 目录。
* 面板展示与编辑器插入共用,保证所见即所得;插入内容即当前主题版本。
*/
export function resolveEmotionDisplaySrc(emotion: UbbEmotionDescriptor, isDark: boolean): string {
if (emotion.family === "ac" && isDark) {
return emotion.src.replace("/ac/", "/ac-dark/");
}
return emotion.src;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { defineComponent, h, type PropType } from "vue";
import type { RichContentOptions } from "../types";
import UniverseRoot from "../universe/UniverseRoot.vue";
import { useThemeStore } from "../../../stores/theme";
import { parseMarkdown } from "./remark";
import { renderMarkdownRoot } from "./renderMarkdownNode";

Expand All @@ -18,9 +19,14 @@ export default defineComponent({
},
},
setup(props) {
const theme = useThemeStore();
return () =>
h(UniverseRoot, { contentType: "markdown", preserveWhitespace: false }, () =>
renderMarkdownRoot(parseMarkdown(props.content), props.options),
renderMarkdownRoot(
parseMarkdown(props.content),
props.options,
theme.effectiveMode === "dark",
),
);
},
});
Expand Down
Loading