<!-- 资产提示词编辑器，负责资产提及输入、候选筛选和内容同步。 -->
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { X } from "@/icons";
import type { ProjectAsset } from "@/api/creative";
import LoadingMedia from "@/components/creative/LoadingMedia.vue";

const props = defineProps<{
  modelValue: string;
  assets: ProjectAsset[];
  placeholder?: string;
  expanded?: boolean;
  readonly?: boolean;
  variant?: "project" | "media" | "drama-storyboard" | "redraw";
  floatingPreview?: boolean;
}>();
const emit = defineEmits<{ "update:modelValue": [value: string]; blur: [] }>();
const editor = ref<HTMLElement>();
const mentionOpen = ref(false);
const mentionQuery = ref("");
const mentionCategory = ref("character");
const mentionLength = ref(0);
const activeIndex = ref(0);
const floatingPreview = ref<{ url: string; alt: string; left: string; top: string }>();
let mentionRange: Range | null = null;
let rendering = false;
const typeNames: Record<string, string> = { character: "角色", scene: "场景", prop: "道具", custom: "自定义" };
const projectMentionCategories = [
  { key: "character", label: "角色" },
  { key: "voice", label: "音色" },
  { key: "scene", label: "场景" },
  { key: "prop", label: "道具" },
  { key: "custom", label: "自定义" },
];
const mentionCategories = computed(() =>
  props.variant === "media"
    ? [
        { key: "character", label: "图片" },
        { key: "voice", label: "音频" },
        { key: "video", label: "视频" },
      ]
    : props.variant === "redraw"
      ? projectMentionCategories.filter(
          (item) => item.key === "character" || item.key === "scene" || item.key === "prop",
        )
      : projectMentionCategories,
);
const mentionAssets = computed(() => {
  const query = mentionQuery.value.trim().toLowerCase();
  const assets = props.assets.filter((asset) => {
    if (props.variant === "drama-storyboard") return !query || asset.name.toLowerCase().includes(query);
    const matchesCategory =
      mentionCategory.value === "voice"
        ? Boolean(asset.audio_url)
        : mentionCategory.value === "video"
          ? Boolean(asset.video_url)
          : asset.asset_type === mentionCategory.value &&
            (props.variant !== "media" || (!asset.audio_url && !asset.video_url));
    return matchesCategory && (!query || asset.name.toLowerCase().includes(query));
  });
  return props.variant === "drama-storyboard" ? assets : assets.slice(0, 12);
});

/** 判断资产是否为剧集分镜图片。 */
function isStoryboardImage(asset: ProjectAsset) {
  return props.variant === "drama-storyboard" && asset.id.startsWith("storyboard-image-");
}
/** 获取资产提及标记使用的语义类型。 */
function tokenType(asset: ProjectAsset) {
  if (isStoryboardImage(asset)) return "storyboard-image";
  return asset.audio_url && asset.asset_type === "character" ? "voice" : asset.asset_type;
}
/** 根据编辑器模式生成资产提及标记的类型文案。 */
function tokenLabel(asset: ProjectAsset) {
  if (props.variant === "media") return asset.video_url ? "视频" : asset.audio_url ? "音频" : "图片";
  if (isStoryboardImage(asset)) return "分镜图";
  if (props.variant === "drama-storyboard" && asset.asset_type === "character" && asset.audio_url) return "人物 / 音色";
  return tokenType(asset) === "voice" ? "音色" : typeNames[asset.asset_type] || "资产";
}
/** 创建可插入编辑器的资产提及节点及其预览内容。 */
function createToken(asset: ProjectAsset) {
  const token = document.createElement("span");
  token.className = `mention-token mention-token--${tokenType(asset)}`;
  token.contentEditable = "false";
  token.dataset.assetId = asset.id;
  token.dataset.assetName = asset.name;
  const showImage = Boolean(asset.image_url) && props.variant !== "media" && props.variant !== "redraw";
  const visual = showImage ? document.createElement("img") : document.createElement("b");
  if (showImage) {
    (visual as HTMLImageElement).src = asset.image_url!;
    (visual as HTMLImageElement).alt = asset.name;
    token.dataset.previewUrl = asset.image_url!;
  } else visual.textContent = tokenLabel(asset);
  const name = document.createElement("span");
  name.className = "mention-name";
  name.textContent = asset.name;
  const nodes: Node[] = [visual, name];
  if (!props.readonly) {
    const remove = document.createElement("button");
    remove.type = "button";
    remove.className = "mention-remove";
    remove.dataset.removeMention = "true";
    remove.title = `移除资产 ${asset.name}`;
    remove.textContent = "删除";
    nodes.push(remove);
  }
  if (showImage && !props.floatingPreview) {
    const preview = document.createElement("span");
    preview.className = "mention-preview";
    preview.contentEditable = "false";
    const previewImage = document.createElement("img");
    previewImage.src = asset.image_url;
    previewImage.alt = asset.name;
    preview.append(previewImage);
    nodes.push(preview);
  }
  token.append(...nodes);
  return token;
}
/** 在页面级浮层中展示资产缩略图，避免被编辑器及其父容器裁切。 */
function showFloatingPreview(event: MouseEvent) {
  if (!props.floatingPreview || !(event.target instanceof Element)) return;
  const token = event.target.closest<HTMLElement>(".mention-token[data-preview-url]");
  const root = editor.value;
  if (!token || !root || !token.dataset.previewUrl) return;
  const tokenRect = token.getBoundingClientRect();
  const previewSize = 154;
  const gap = 8;
  const viewportPadding = 8;
  const maxLeft = window.innerWidth - previewSize - viewportPadding;
  const maxTop = window.innerHeight - previewSize - viewportPadding;
  // 优先放在标签右侧，空间不足时依次尝试左侧、下方和上方，避免预览遮挡标签或编辑器外的标题。
  const candidates = [
    { left: tokenRect.right + gap, top: tokenRect.top },
    { left: tokenRect.left - previewSize - gap, top: tokenRect.top },
    { left: tokenRect.left, top: tokenRect.bottom + gap },
    { left: tokenRect.left, top: tokenRect.top - previewSize - gap },
  ];
  const placement = candidates.find(
    (candidate) =>
      candidate.left >= viewportPadding &&
      candidate.left <= maxLeft &&
      candidate.top >= viewportPadding &&
      candidate.top <= maxTop,
  ) || {
    left: Math.min(Math.max(tokenRect.left, viewportPadding), maxLeft),
    top: Math.min(Math.max(tokenRect.bottom + gap, viewportPadding), maxTop),
  };
  floatingPreview.value = {
    url: token.dataset.previewUrl,
    alt: token.dataset.assetName || "",
    left: `${placement.left}px`,
    top: `${placement.top}px`,
  };
}
/** 鼠标离开资产标签时关闭页面级缩略图浮层。 */
function hideFloatingPreview(event: MouseEvent) {
  if (!(event.target instanceof Element)) return;
  const token = event.target.closest<HTMLElement>(".mention-token[data-preview-url]");
  if (token && event.relatedTarget instanceof Node && token.contains(event.relatedTarget)) return;
  floatingPreview.value = undefined;
}
/** 在编辑器滚动或失焦时关闭缩略图浮层，避免浮层停留在旧位置。 */
function closeFloatingPreview() {
  floatingPreview.value = undefined;
}
/** 将外部字符串值解析并渲染为文本与资产提及节点。 */
function renderValue() {
  closeFloatingPreview();
  const root = editor.value;
  if (!root || document.activeElement === root) return;
  rendering = true;
  root.replaceChildren();
  const assets = [...props.assets].filter((a) => a.name.trim()).sort((a, b) => b.name.length - a.name.length);
  const byName = new Map(assets.map((a) => [a.name, a]));
  const names = assets.map((a) => a.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
  // 生成后的提示词可能直接连接中英文内容，因此提及匹配不能依赖尾随空格或标点。
  const regex = names.length ? new RegExp(`@(${names.join("|")})`, "g") : null;
  let index = 0;
  if (regex)
    for (const match of props.modelValue.matchAll(regex)) {
      const start = match.index || 0;
      if (start > index) root.append(document.createTextNode(props.modelValue.slice(index, start)));
      const asset = byName.get(match[1]);
      root.append(asset ? createToken(asset) : document.createTextNode(match[0]));
      index = start + match[0].length;
    }
  if (index < props.modelValue.length) root.append(document.createTextNode(props.modelValue.slice(index)));
  rendering = false;
}
/** 将编辑器 DOM 还原为包含资产提及语法的纯文本。 */
function rawValue() {
  const root = editor.value;
  if (!root) return "";
  /** 递归提取单个编辑器节点对应的纯文本。 */
  const walk = (node: Node): string => {
    if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
    const element = node as HTMLElement;
    if (element.classList?.contains("mention-token")) return `@${element.dataset.assetName || ""}`;
    if (element.tagName === "BR") return "\n";
    const value = Array.from(node.childNodes).map(walk).join("");
    return element.tagName === "DIV" ? `${value}\n` : value;
  };
  return Array.from(root.childNodes).map(walk).join("").replace(/\n$/, "");
}
/** 在非内部渲染阶段向父组件同步编辑结果。 */
function emitValue() {
  if (!rendering) emit("update:modelValue", rawValue());
}
/** 检测光标前的提及查询并保存候选插入位置。 */
function detectMention() {
  const root = editor.value,
    selection = window.getSelection();
  if (!root || !selection?.rangeCount || !root.contains(selection.anchorNode)) {
    mentionOpen.value = false;
    return;
  }
  const caretRange = selection.getRangeAt(0).cloneRange();
  const range = caretRange.cloneRange();
  range.selectNodeContents(root);
  range.setEnd(selection.anchorNode!, selection.anchorOffset);
  mentionRange = caretRange;
  const match = range.toString().match(/@([^@\s，。；：,.!?]*)$/);
  mentionOpen.value = Boolean(match);
  if (!match) return;
  mentionQuery.value = match[1] || "";
  mentionLength.value = match[0].length;
  activeIndex.value = 0;
}
/** 处理编辑器输入并刷新提及候选状态。 */
function input() {
  closeFloatingPreview();
  emitValue();
  detectMention();
}
/** 关闭提及面板并恢复编辑器焦点。 */
async function closeMention() {
  mentionOpen.value = false;
  mentionRange = null;
  await nextTick();
  editor.value?.focus();
}
/** 在当前提及范围插入资产标记并同步外部值。 */
async function insert(asset: ProjectAsset) {
  const root = editor.value,
    selection = window.getSelection();
  if (!root || (!selection?.rangeCount && !mentionRange)) return;
  const range = mentionRange?.cloneRange() || selection!.getRangeAt(0);
  let node = range.startContainer;
  if (node.nodeType !== Node.TEXT_NODE) {
    node = document.createTextNode("");
    range.insertNode(node);
    range.setStart(node, 0);
  }
  const text = node.textContent || "";
  const end = range.startOffset;
  const start = Math.max(0, end - mentionLength.value);
  node.textContent = text.slice(0, start) + text.slice(end);
  range.setStart(node, start);
  range.collapse(true);
  const token = createToken(asset),
    space = document.createTextNode(" ");
  range.insertNode(space);
  range.insertNode(token);
  range.setStartAfter(space);
  range.collapse(true);
  selection.removeAllRanges();
  selection.addRange(range);
  mentionOpen.value = false;
  mentionRange = null;
  emitValue();
  await nextTick();
  root.focus();
}
/** 删除用户点击的资产提及标记。 */
function removeMention(event: MouseEvent) {
  const target = event.target as HTMLElement;
  const button = target.closest<HTMLButtonElement>("[data-remove-mention]");
  if (!button) return;
  event.preventDefault();
  event.stopPropagation();
  closeFloatingPreview();
  const token = button.closest<HTMLElement>(".mention-token");
  token?.remove();
  emitValue();
  editor.value?.focus();
}
/** 编辑器失焦后延迟关闭候选面板并通知父组件。 */
function blur() {
  closeFloatingPreview();
  window.setTimeout(() => {
    if (!document.activeElement?.closest(".mention-panel")) {
      mentionOpen.value = false;
      mentionRange = null;
    }
  }, 120);
  emit("blur");
}
watch(
  () => [props.modelValue, props.assets] as const,
  () => nextTick(renderValue),
  { deep: true },
);
onMounted(renderValue);
</script>

<template>
  <div
    class="asset-prompt-editor"
    :class="{
      expanded,
      readonly,
      media: variant === 'media',
      'drama-storyboard': variant === 'drama-storyboard',
    }"
  >
    <div
      ref="editor"
      class="prompt-content"
      :contenteditable="readonly ? 'false' : 'true'"
      :data-placeholder="placeholder"
      @input="input"
      @mousedown="removeMention"
      @mouseover="showFloatingPreview"
      @mouseout="hideFloatingPreview"
      @scroll="closeFloatingPreview"
      @blur="blur"
    ></div>
    <Teleport to="body">
      <span
        v-if="floatingPreview"
        class="mention-floating-preview"
        :style="{ left: floatingPreview.left, top: floatingPreview.top }"
      >
        <img
          :src="floatingPreview.url"
          :alt="floatingPreview.alt"
        />
      </span>
    </Teleport>
    <div
      v-if="mentionOpen"
      class="mention-panel"
      @mousedown.stop
    >
      <button
        class="mention-close"
        type="button"
        title="关闭资产面板"
        @mousedown.prevent.stop="closeMention"
      >
        <X :size="16" />
      </button>
      <div
        v-if="variant === 'drama-storyboard'"
        class="mention-flat-header"
      >
        <strong>当前分镜可用资产</strong><small>{{ mentionAssets.length }} 项</small>
      </div>
      <div
        v-else
        class="mention-header"
      >
        <input
          v-if="variant !== 'media'"
          v-model="mentionQuery"
          class="mention-search"
          type="search"
          placeholder="搜索资产"
          @blur="blur"
        />
        <div class="mention-categories">
          <button
            v-for="category in mentionCategories"
            :key="category.key"
            type="button"
            :class="{ active: mentionCategory === category.key }"
            @mousedown.prevent="
              mentionCategory = category.key;
              activeIndex = 0;
            "
          >
            {{ category.label }}
          </button>
        </div>
      </div>
      <div class="mention-list">
        <button
          v-for="(asset, index) in mentionAssets"
          :key="asset.id"
          type="button"
          :class="{ active: index === activeIndex }"
          @mouseenter="activeIndex = index"
          @mousedown.prevent="insert(asset)"
        >
          <span
            ><LoadingMedia
              v-if="asset.image_url"
              type="image"
              compact
              :src="asset.image_url"
              alt=""
            /><i v-else>{{ asset.name.slice(0, 1) }}</i></span
          ><b>{{ asset.name }}</b
          ><small>{{ tokenLabel(asset) }}</small>
        </button>
        <div v-if="!mentionAssets.length">没有匹配的资产</div>
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">
.asset-prompt-editor {
  position: relative;
  min-height: 0;
  height: 100%;
  overflow: hidden;
}
.prompt-content {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  min-height: 0;
  padding: 13px 13px 28px;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-word;
  color: var(--web-text);
  background: var(--web-surface-soft);
  border: 1px solid var(--web-line-strong);
  border-radius: 7px;
  outline: none;
  font:
    13px/1.7 Arial,
    "Microsoft YaHei",
    sans-serif;
  scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track);
  scrollbar-width: thin;
}
.prompt-content:empty:before {
  content: attr(data-placeholder);
  color: var(--web-prompt-placeholder);
  pointer-events: none;
}
.prompt-content:focus {
  border-color: var(--web-line-emphasis);
  box-shadow: 0 0 0 2px var(--web-focus-ring-soft);
}
.readonly .prompt-content {
  cursor: text;
}
.expanded {
  overflow: visible;
}
.expanded .prompt-content {
  min-height: 60vh;
  padding-right: 13px;
  font-size: 14px;
}
.prompt-content :deep(.mention-token) {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  margin: 0 2px;
  padding: 1px 5px;
  border: 1px solid var(--web-mention-default-line);
  border-radius: 4px;
  background: var(--web-hover);
  color: var(--web-text);
  line-height: 1.45;
  vertical-align: middle;
  white-space: nowrap;
}
.prompt-content :deep(.mention-token b) {
  color: var(--web-accent-dark);
  font-size: 10px;
}
.prompt-content :deep(.mention-token .mention-name) {
  min-width: 0;
  max-width: min(110px, 22.5vw);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.prompt-content :deep(.mention-token .mention-remove) {
  position: absolute;
  top: 50%;
  right: 3px;
  z-index: 30;
  width: 34px;
  height: 20px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  transform: translateY(-50%);
  border: 1px solid var(--web-mention-remove-line);
  border-radius: 4px;
  background: var(--web-danger-soft);
  color: var(--web-text);
  font-size: 10px;
  font-weight: 600;
  line-height: 1;
  visibility: hidden;
  opacity: 0;
  pointer-events: none;
  cursor: pointer;
}
.prompt-content :deep(.mention-token:hover .mention-remove),
.prompt-content :deep(.mention-token:focus-within .mention-remove) {
  visibility: visible;
  opacity: 1;
  pointer-events: auto;
}
.prompt-content :deep(.mention-token .mention-remove:hover) {
  background: var(--web-mention-remove-hover);
  border-color: var(--web-mention-remove-hover-line);
}
.prompt-content :deep(.mention-token--voice) {
  background: var(--web-hover);
  border-color: var(--web-mention-voice-line);
}
.prompt-content :deep(.mention-token--scene) {
  background: var(--web-mention-scene-bg);
  border-color: var(--web-mention-scene-line);
}
.prompt-content :deep(.mention-token--prop) {
  background: var(--web-mention-prop-bg);
  border-color: var(--web-mention-prop-line);
}
.prompt-content :deep(.mention-token--custom) {
  background: var(--web-hover);
  border-color: var(--web-mention-custom-line);
}
.prompt-content :deep(.mention-token--storyboard-image) {
  background: var(--web-hover);
  border-color: var(--web-mention-storyboard-line);
}
.mention-panel {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 8;
  width: min(420px, calc(100% - 24px));
  height: 360px;
  box-sizing: border-box;
  padding: 12px;
  transform: translate(-50%, -50%);
  overflow: hidden;
  background: var(--web-surface);
  border: 1px solid var(--web-line-emphasis);
  border-radius: 10px;
  box-shadow: var(--web-shadow-mention);
}
.media .mention-panel {
  height: 300px;
}
.drama-storyboard .mention-panel {
  width: min(350px, calc(100% - 16px));
  height: auto;
  max-height: 280px;
  padding: 10px;
}
.mention-close {
  position: absolute;
  top: 9px;
  right: 9px;
  z-index: 2;
  width: 28px;
  height: 28px;
  padding: 0;
  display: grid;
  place-items: center;
  color: var(--web-text-caption);
  background: var(--web-surface);
  border: 0;
  border-radius: 6px;
  cursor: pointer;
}
.mention-close:hover,
.mention-close:focus-visible {
  color: var(--web-text);
  background: var(--web-hover);
  outline: none;
}
.mention-header {
  padding-right: 34px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.mention-flat-header {
  min-height: 34px;
  padding: 0 38px 0 4px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  border-bottom: 1px solid var(--web-line-strong);
}
.mention-flat-header strong {
  color: var(--web-text);
  font-size: 12px;
}
.mention-flat-header small {
  color: var(--web-text-tertiary);
  font-size: 10px;
  white-space: nowrap;
}
.mention-search {
  box-sizing: border-box;
  width: 100%;
  height: 34px;
  padding: 0 10px;
  color: var(--web-text);
  background: var(--web-surface-soft);
  border: 1px solid var(--web-line-strong);
  border-radius: 6px;
  outline: none;
}
.mention-search:focus {
  border-color: var(--web-search-line);
  box-shadow: 0 0 0 2px var(--web-focus-ring-search);
}
.mention-categories {
  display: flex;
  gap: 4px;
  overflow-x: auto;
  padding-bottom: 2px;
  scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track-editor);
  scrollbar-width: thin;
}
.mention-categories button {
  flex: 0 0 auto;
  padding: 5px 9px;
  color: var(--web-accent-muted);
  background: transparent;
  border: 0;
  border-radius: 5px;
  cursor: pointer;
}
.mention-categories button:hover,
.mention-categories button.active {
  color: var(--web-text);
  background: var(--web-hover);
}
.mention-list {
  height: 250px;
  margin-top: 8px;
  overflow: auto;
  scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track-editor);
  scrollbar-width: thin;
}
.media .mention-list {
  height: 232px;
}
.drama-storyboard .mention-list {
  height: auto;
  max-height: 210px;
  margin-top: 8px;
}
.mention-list button {
  width: 100%;
  height: 42px;
  padding: 5px 7px;
  display: grid;
  grid-template-columns: 30px minmax(0, 1fr) auto;
  align-items: center;
  gap: 8px;
  color: var(--web-text-secondary);
  background: transparent;
  border: 0;
  border-radius: 6px;
  text-align: left;
  cursor: pointer;
}
.mention-list button:hover,
.mention-list button.active {
  color: var(--web-text);
  background: var(--web-hover);
}
.mention-list button > span {
  position: relative;
  width: 30px;
  height: 30px;
  display: grid;
  place-items: center;
  overflow: hidden;
  background: var(--web-hover);
  border-radius: 5px;
}
.mention-list img,
.mention-list :deep(.loading-media-element) {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.mention-list i {
  color: var(--web-accent-dark);
  font-style: normal;
}
.mention-list b {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 12px;
  font-weight: 500;
}
.mention-list small {
  color: var(--web-text-tertiary);
  font-size: 10px;
}
.mention-list > div {
  padding: 14px;
  color: var(--web-text-tertiary);
  font-size: 11px;
  text-align: center;
}
</style>
<style scoped lang="scss">
.prompt-content :deep(.mention-token) {
  position: relative;
  gap: 6px;
  min-height: 26px;
  padding: 3px 8px;
  font-size: 13px;
  line-height: 1.45;
}

.prompt-content :deep(.mention-token > img) {
  width: 24px;
  height: 24px;
  flex: 0 0 24px;
  object-fit: cover;
  border-radius: 3px;
}

.prompt-content :deep(.mention-token > b) {
  font-size: 11px;
  line-height: 1;
}

.prompt-content :deep(.mention-preview) {
  position: absolute;
  left: 0;
  bottom: calc(100% + 8px);
  z-index: 40;
  display: none;
  width: 144px;
  height: 144px;
  padding: 4px;
  background: var(--web-surface);
  border: 1px solid var(--web-line-emphasis);
  border-radius: 7px;
  box-shadow: var(--web-shadow-preview);
  pointer-events: none;
}

.prompt-content :deep(.mention-token:hover .mention-preview) {
  display: block;
}

.prompt-content :deep(.mention-preview img) {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 4px;
}

.mention-floating-preview {
  box-sizing: border-box;
  width: 154px;
  height: 154px;
  position: fixed;
  z-index: calc(var(--el-index-popper) + 1);
  padding: 4px;
  background: var(--web-surface);
  border: 1px solid var(--web-line-emphasis);
  border-radius: 7px;
  box-shadow: var(--web-shadow-preview);
  pointer-events: none;
}

.mention-floating-preview img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 4px;
}
</style>
