新增: viewer 批注展示与分享剥离演讲者备注;优化: resolveColor 迁至 bg 纯函数层

This commit is contained in:
lxy
2026-08-29 01:43:34 +08:00
parent eca5b11ea4
commit dde53378c3
6 changed files with 279 additions and 25 deletions
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# =====================================================================
# publish-viewer.sh — u-ppt 查看器一键发布
# 流程: 构建 → 全量上传七牛 bucket → 可达性核对 → CDN 缓存刷新
# 用法: bash scripts/publish-viewer.sh [--check-only]
# 配套技能: publish-uppt-viewer (踩坑记录见技能文档)
# =====================================================================
set -e
PROJECT="/e/wk-lab/u-ppt"
BUCKET="u-res"
CDN="https://img.1216.top"
cd "$PROJECT"
if [ "$1" != "--check-only" ]; then
echo "== [1/4] 类型检查 + 构建 =="
npx vue-tsc -b || { echo "vue-tsc 失败,中止"; exit 1; }
npm run build || { echo "构建失败,中止"; exit 1; }
cd dist
echo "== [2/4] 全量上传 (viewer.html + assets/*) =="
qshell rput "$BUCKET" viewer.html viewer.html --overwrite > /dev/null 2>&1 || echo "上传 viewer.html 失败"
for f in $(ls assets/); do
qshell rput "$BUCKET" "assets/$f" "assets/$f" --overwrite > /dev/null 2>&1 || echo "上传 assets/$f 失败"
done
else
cd dist
echo "== check-only: 跳过构建上传 =="
fi
echo "== [3/4] 可达性核对 =="
FAIL=0
for f in viewer.html $(ls assets/ | sed 's|^|assets/|'); do
code=$(curl -s -o /dev/null -w "%{http_code}" "$CDN/$f")
if [ "$code" != "200" ]; then echo "FAIL $code $f"; FAIL=1; fi
done
[ "$FAIL" = "0" ] && echo "ALL-200"
echo "== [4/4] 刷新 CDN 缓存 =="
echo "$CDN/viewer.html" > /tmp/uppt-refresh.txt
echo "$CDN/index.html" >> /tmp/uppt-refresh.txt
qshell cdnrefresh -i /tmp/uppt-refresh.txt 2>&1 | grep -o "Code: [0-9]*"
echo "== 完成: $CDN/viewer.html =="
+21
View File
@@ -70,3 +70,24 @@ export function resolveBg(key: string): string {
if (key === 'g-soft') return 'linear-gradient(135deg, ' + (t.panel || '#f8fafc') + ' 0%, ' + (t.bg || '#ffffff') + ' 100%)' if (key === 'g-soft') return 'linear-gradient(135deg, ' + (t.panel || '#f8fafc') + ' 0%, ' + (t.bg || '#ffffff') + ' 100%)'
return t[key] || '#ffffff' return t[key] || '#ffffff'
} }
/**
* 颜色解析:dark=true 时把深色文字键反相为浅色,确保深底可见。
* 安全:非主题键、非合法 hex 一律回退,杜绝任意 CSS/HTML 经 color 注入
*/
export function resolveColor(key: string | undefined, dark: boolean): string {
const t = (getAllThemes()[getTheme()] || {}) as unknown as Record<string, string>
if (!key) return ''
if (key.charAt(0) === '#') {
if (!isValidHex(key)) return dark ? '#ffffff' : (t.text || '#1e293b')
if (dark && isDarkHex(key)) return '#ffffff'
return key
}
if (dark) {
if (key === 'text') return '#ffffff'
if (key === 'muted') return 'rgba(255,255,255,0.72)'
if (key === 'primary') return '#ffffff'
return t[key] || '#ffffff'
}
return t[key] || t.text || '#1e293b'
}
+6 -2
View File
@@ -7,7 +7,7 @@
import { store } from './store' import { store } from './store'
import { ossUpload, isTauri } from './bridge' import { ossUpload, isTauri } from './bridge'
import { canUploadNow, isOssEnabled, buildUploadReq } from './assets' import { canUploadNow, isOssEnabled, buildUploadReq } from './assets'
import type { OssCfg } from './types' import type { OssCfg, Deck } from './types'
/** 分享发布前置校验:不满足时返回中文原因 */ /** 分享发布前置校验:不满足时返回中文原因 */
export function shareReady(): { ok: boolean; reason?: string } { export function shareReady(): { ok: boolean; reason?: string } {
@@ -97,7 +97,11 @@ export async function publishDeck(): Promise<{ url: string; jsonUrl: string }> {
const shareId = genShareId() const shareId = genShareId()
const key = shareKey(cfg, shareId) const key = shareKey(cfg, shareId)
const dataBase64 = btoa(unescape(encodeURIComponent(JSON.stringify(store.getDeck())))) // 深拷贝后剥离演讲者备注(slide.note),批注(el.style.annotations)保留照常渲染;
// 置 undefined 使 JSON.stringify 不输出该字段
const deck = JSON.parse(JSON.stringify(store.getDeck())) as Deck
for (const slide of deck.slides || []) slide.note = undefined
const dataBase64 = btoa(unescape(encodeURIComponent(JSON.stringify(deck))))
const url = await ossUpload(buildUploadReq(cfg, key, dataBase64, 'application/json')) const url = await ossUpload(buildUploadReq(cfg, key, dataBase64, 'application/json'))
if (!url) throw new Error('上传分享数据失败:ossUpload 未返回 URL') if (!url) throw new Error('上传分享数据失败:ossUpload 未返回 URL')
const jsonUrl = url.split('#')[0] const jsonUrl = url.split('#')[0]
+2 -23
View File
@@ -236,33 +236,12 @@ function syncActiveLibItem() {
} }
/* ---------- 颜色/背景解析:纯函数已抽至 ./bg,此处再导出保持调用方 import 路径不变 ---------- */ /* ---------- 颜色/背景解析:纯函数已抽至 ./bg,此处再导出保持调用方 import 路径不变 ---------- */
export { isDarkBg, resolveBg, isDarkHex, isValidHex } from './bg' export { isDarkBg, resolveBg, isDarkHex, isValidHex, resolveColor, getAllThemes } from './bg'
import { isDarkHex, isValidHex, getAllThemes, registerBgThemeGetter, CUSTOM_THEME_KEY } from './bg' import { getAllThemes, registerBgThemeGetter, CUSTOM_THEME_KEY } from './bg'
// 注入当前主题 getterbg.ts 解析背景时读取(viewer 侧无需引入本 store // 注入当前主题 getterbg.ts 解析背景时读取(viewer 侧无需引入本 store
registerBgThemeGetter(() => state.deck.theme) registerBgThemeGetter(() => state.deck.theme)
/**
* 颜色解析:dark=true 时把深色文字键反相为浅色,确保深底可见。
* 安全:非主题键、非合法 hex 一律回退,杜绝任意 CSS/HTML 经 color 注入
*/
export function resolveColor(key: string | undefined, dark: boolean): string {
const t = (getAllThemes()[state.deck.theme] || {}) as unknown as Record<string, string>
if (!key) return ''
if (key.charAt(0) === '#') {
if (!isValidHex(key)) return dark ? '#ffffff' : (t.text || '#1e293b')
if (dark && isDarkHex(key)) return '#ffffff'
return key
}
if (dark) {
if (key === 'text') return '#ffffff'
if (key === 'muted') return 'rgba(255,255,255,0.72)'
if (key === 'primary') return '#ffffff'
return t[key] || '#ffffff'
}
return t[key] || t.text || '#1e293b'
}
/* ---------- 内部:执行 Op 并推入历史 ---------- */ /* ---------- 内部:执行 Op 并推入历史 ---------- */
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */ /** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
+204
View File
@@ -0,0 +1,204 @@
<!-- =====================================================================
ViewerAnnotations.vue 分享查看器批注层只读
结构/几何与编辑器 AnnotationLayer 一致SVG 连线1280×720+ DOM 气泡百分比定位
整层 pointer-events:none观众不可交互也不挡翻页点击空文本批注不渲染气泡
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
import type { Annotation, Slide, SlideElement } from '../core/types'
import { isDarkBg, resolveColor } from '../core/bg'
import { markdownToSegments, segmentsToHtml } from '../core/richtext'
const props = defineProps<{ slide: Slide }>()
const CANVAS_W = 1280
const CANVAS_H = 720
/** 当前页背景是否深色 → 文字/连线反相 */
const dark = computed(() => isDarkBg(props.slide.background || ''))
/** 扁平列表:{ el, anno },供连线与气泡渲染 */
const items = computed(() => {
const out: { el: SlideElement; anno: Annotation }[] = []
for (const el of props.slide.elements || []) {
const list = el.style?.annotations
if (list?.length) for (const anno of list) out.push({ el, anno })
}
return out
})
/**
* 射线-矩形边框求交:从矩形中心 (cx,cy) 朝目标 (tx,ty) 方向,
* 取与矩形边框(半宽 hw、半高 hh)的交点。
*/
function edgePoint(cx: number, cy: number, hw: number, hh: number, tx: number, ty: number) {
const dx = tx - cx, dy = ty - cy
if (!dx && !dy) return { x: cx, y: cy }
const sx = dx ? hw / Math.abs(dx) : Infinity
const sy = dy ? hh / Math.abs(dy) : Infinity
const s = Math.min(sx, sy)
return { x: cx + dx * s, y: cy + dy * s }
}
/** 连线几何:起点=图片边缘、终点=气泡边缘,坐标转 1280×720 用户单位;框内/重叠返回 null */
function lineGeom(el: SlideElement, anno: Annotation) {
const ex = el.x / 100 * CANVAS_W, ey = el.y / 100 * CANVAS_H
const ew = el.w / 100 * CANVAS_W, eh = el.h / 100 * CANVAS_H
const bx = anno.bx / 100 * CANVAS_W, by = anno.by / 100 * CANVAS_H
const bw = anno.bw / 100 * CANVAS_W, bh = anno.bh / 100 * CANVAS_H
const icx = ex + (anno.ax ?? 50) / 100 * ew
const icy = ey + (anno.ay ?? 50) / 100 * eh
const bcx = bx + bw / 2, bcy = by + bh / 2
if (bcx >= ex && bcx <= ex + ew && bcy >= ey && bcy <= ey + eh) return null
const dist = Math.hypot(bcx - icx, bcy - icy)
const s = edgePoint(icx, icy, ew / 2, eh / 2, bcx, bcy)
const t = edgePoint(bcx, bcy, bw / 2, bh / 2, icx, icy)
const sd = Math.hypot(s.x - icx, s.y - icy)
const td = Math.hypot(t.x - bcx, t.y - bcy)
if (sd + td >= dist) return null
return { x1: s.x, y1: s.y, x2: t.x, y2: t.y }
}
function lineColor(anno: Annotation) {
return resolveColor(anno.line?.color, dark.value) || (dark.value ? '#ffffff' : '#64748b')
}
function lineWidth(anno: Annotation) {
return (anno.line?.width ?? 0.5) * 2
}
function dash(anno: Annotation) {
return anno.line?.style === 'dashed' ? '10 8' : undefined
}
/** cap → marker urlnone 返回 undefined */
function markerUrl(cap: string | undefined, kind: 'start' | 'end') {
if (cap === 'arrow') return 'url(#anno-arrow-' + kind + ')'
if (cap === 'dot') return 'url(#anno-dot)'
return undefined
}
/** 气泡文字样式 */
function bubbleTextStyle(anno: Annotation) {
return {
fontSize: (anno.fontSize || 14) + 'px',
color: resolveColor(anno.color, false) || '#1e293b',
fontWeight: anno.bold ? 700 : 400,
fontStyle: anno.italic ? 'italic' : 'normal',
textAlign: anno.align || 'left'
} as Record<string, string | number>
}
/** 气泡文字 → md 渲染 HTML(与编辑器一致:行内格式 + # 标题块级语法);空文本不渲染 */
function hasText(anno: Annotation): boolean {
return !!(anno.text || '').trim()
}
function renderText(anno: Annotation): string {
const t = anno.text || ''
const base = anno.fontSize || 14
const HSIZE = [1.8, 1.5, 1.25]
return t.split('\n').map(line => {
const h = /^(#{1,3})\s+(.*)$/.exec(line)
if (h) {
const level = h[1].length
const inner = segmentsToHtml(markdownToSegments(h[2]))
const size = Math.round(base * HSIZE[level - 1])
return `<span style="display:block;font-size:${size}px;font-weight:700;line-height:1.25">${inner}</span>`
}
return segmentsToHtml(markdownToSegments(line))
}).join('<br>')
}
</script>
<template>
<div class="anno-layer">
<!-- 连线层 -->
<svg class="anno-svg" viewBox="0 0 1280 720" preserveAspectRatio="none">
<defs>
<marker
id="anno-arrow-end" markerUnits="userSpaceOnUse"
markerWidth="16" markerHeight="16" refX="12" refY="6" orient="auto"
>
<path d="M0,0 L12,6 L0,12 Z" fill="context-stroke" />
</marker>
<marker
id="anno-arrow-start" markerUnits="userSpaceOnUse"
markerWidth="16" markerHeight="16" refX="0" refY="6" orient="auto"
>
<path d="M12,0 L0,6 L12,12 Z" fill="context-stroke" />
</marker>
<marker
id="anno-dot" markerUnits="userSpaceOnUse"
markerWidth="12" markerHeight="12" refX="5" refY="5"
>
<circle cx="5" cy="5" r="4" fill="context-stroke" />
</marker>
</defs>
<template v-for="{ el, anno } in items" :key="'ln-' + el.id + anno.id">
<line
v-if="lineGeom(el, anno)"
v-bind="lineGeom(el, anno)!"
:stroke="lineColor(anno)"
:stroke-width="lineWidth(anno)"
:stroke-dasharray="dash(anno)"
stroke-linecap="round"
:marker-start="markerUrl(anno.line?.startCap, 'start')"
:marker-end="markerUrl(anno.line?.endCap, 'end')"
/>
</template>
</svg>
<!-- 气泡层空文本不渲染 -->
<div
v-for="{ el, anno } in items"
v-show="hasText(anno)"
:key="'bb-' + el.id + anno.id"
class="anno-bubble"
:style="{
left: anno.bx + '%',
top: anno.by + '%',
width: anno.bw + '%',
height: anno.bh + '%',
...bubbleTextStyle(anno)
}"
>
<span class="anno-bubble-text" v-html="renderText(anno)"></span>
</div>
</div>
</template>
<style scoped>
.anno-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.anno-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.anno-svg * {
pointer-events: none;
}
.anno-bubble {
position: absolute;
box-sizing: border-box;
padding: 8px 10px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 10px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.1);
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
}
.anno-bubble-text {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
+2
View File
@@ -10,6 +10,7 @@ import { parseShareHash, shareJsonUrlOf } from '../core/share'
import { resolveBg, registerBgThemeGetter } from '../core/bg' import { resolveBg, registerBgThemeGetter } from '../core/bg'
import { CANVAS_W, CANVAS_H } from '../core/sample' import { CANVAS_W, CANVAS_H } from '../core/sample'
import ElementView from '../components/editor/ElementView.vue' import ElementView from '../components/editor/ElementView.vue'
import ViewerAnnotations from './ViewerAnnotations.vue'
type Status = 'loading' | 'ready' | 'error' | 'invalid' type Status = 'loading' | 'ready' | 'error' | 'invalid'
@@ -151,6 +152,7 @@ onUnmounted(unbindEvents)
:el="el" :el="el"
:bg="currentSlide.background" :bg="currentSlide.background"
/> />
<ViewerAnnotations :slide="currentSlide" />
</div> </div>
</div> </div>
<div class="viewer-hud">{{ (index + 1) + ' / ' + total }}</div> <div class="viewer-hud">{{ (index + 1) + ' / ' + total }}</div>