mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2026-08-28 19:24:01 +08:00
Merge branch 'master' of github.com:pipipi-pikachu/PPTist
This commit is contained in:
@@ -34,13 +34,13 @@
|
||||
|
||||
|
||||
# 🧩 Project Positioning
|
||||
> This project is strictly positioned as a Web Slide Editing/Presentation Application. It is not intended to be an ~~AI PPT generator, low-code platform, or image editor~~. The following are the recommendation levels for common use cases:
|
||||
> The core positioning of this project is a **Web Slide Editing/Presentation Application**. The following are the recommendation levels for common use cases:
|
||||
|
||||
- **Low-code Platforms / H5 Editors / Image Editors / Whiteboards** (Recommendation: Not Recommended): We suggest choosing open-source projects specifically designed for those purposes.
|
||||
- **PPT File Preview Tool** (Recommendation: ⭐): The ability to import .pptx files is limited (roughly 80%+ fidelity). Unless your requirements for preview accuracy are low and you only need basic content display, this is not recommended.
|
||||
- **AI PPT Generation Tool** (Recommendation: ⭐⭐): While the project provides basic template-based AIPPT generation, it is not the core focus. As AI technology evolves, the form of AIPPT will continue to change (templates -> HTML webpages -> pure images -> SVG -> ...), and this project will not follow those trends. However, if you only want to build a template-based AIPPT generator, or are willing to extend better generation solutions yourself, PPTist's complete Schema and editing capabilities may be helpful.
|
||||
- **Office PPT Authoring Tool** (Recommendation: ⭐⭐): PPTist supports many common Office PPT features and basic local .pptx export. However, exports cannot be 100% faithful, and this scenario generally has high requirements for importing .pptx files, while the import capability mentioned above is limited. Choose cautiously unless you can accept imperfect import capabilities.
|
||||
- **Web Slide Editing/Presentation App** (Recommendation: ⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐): This is the primary use case. The core strengths of PPTist are its editing capabilities and user experience. You can build upon this project to customize or add unique nodes and features tailored to your specific needs, where Office compatibility is not the ultimate goal. **Summary: Our vision is for you to use PPTist to create a presentation product that is distinct from Microsoft Office, rather than just using it as a middleman for editing Office files.**
|
||||
- **Low-code Platforms / H5 Editors / Image Editors / Whiteboards** (Recommendation: Not Recommended): We suggest choosing open-source projects that better match those scenarios.
|
||||
- **AI PPT Generation Tool** (Recommendation: ⭐⭐): This project supports a template-based AIPPT generation approach, and also provides a data structure definition for AI to directly generate page data. See `AI_PPT_SCHEMA.md`. In theory, it supports non-template PPT generation, where AI directly generates PPTist data. Note that the final result of AIPPT depends heavily on model capabilities, prompt design, asset generation/retrieval, and other factors. PPTist is better suited as a foundation for carrying, editing, and further processing structured generation results, rather than an out-of-the-box complete AIPPT commercial solution.
|
||||
- **PPT File Preview Tool** (Recommendation: ⭐⭐): This project supports importing PPTX files and can cover many common page contents and styles (roughly 85%+ fidelity overall), making it suitable for online preview, lightweight review, and content browsing. However, because the PPTX ecosystem is complex, animations, special charts, deeply nested structures, non-standard elements, and some advanced styles may inevitably have fidelity differences. If your business requires pixel-perfect fidelity, evaluate with your actual sample files.
|
||||
- **Office PPT Authoring Tool** (Recommendation: ⭐⭐): This project supports many common Office PPT features and can import/export local PPTX files, making it usable as a foundation for online authoring and editing. Note that full Office PPT compatibility involves many details, including complex layouts, animations, charts, themes, and masters. Some details will inevitably be lost during file import/export, so 100% fidelity cannot be guaranteed. If your core goal is to fully replace Microsoft PowerPoint, evaluate and make trade-offs with your actual business documents.
|
||||
- **Web Slide Editing/Presentation App** (Recommendation: ⭐⭐⭐⭐⭐): This is the most recommended scenario. The core strengths of PPTist are its editing capabilities and editing experience. You can build upon this project to customize or add unique nodes and features according to your needs, without using import/export as the ultimate goal. After all, those customized things do not exist in Office PPT. **Summary: I hope you use PPTist to build a presentation product different from Office PPT, rather than just an editing relay station for Office PPT.**
|
||||
|
||||
|
||||
# 🚀 Installation
|
||||
|
||||
+5
-5
@@ -33,13 +33,13 @@
|
||||
|
||||
|
||||
# 🧩项目定位
|
||||
> 本项目定位为一个**Web 幻灯片编辑/演示应用**,而不是~~AIPPT生成工具、低代码平台、H5编辑器、图片编辑器 、白板应用~~等。以下是一些常见使用场景推荐度:
|
||||
> 本项目的核心定位是**Web 幻灯片编辑/演示应用**,以下是一些常见使用场景推荐度:
|
||||
|
||||
- **低代码平台/H5编辑器/图片编辑器/白板应用**(推荐度:完全不推荐):建议选择匹配度更高的开源项目。
|
||||
- **PPT文件预览工具**(推荐度:⭐):导入PPTX文件的能力有限(综合来看,大约有80%+的还原度),除非你预览样式要求不高,只追求基本的内容展示,否则不建议使用。
|
||||
- **AIPPT生成工具**(推荐度:⭐⭐):本项目虽然提供了基础的模板式AIPPT生成功能,但并非核心,随着AI技术的发展,AIPPT的形态会不断变化(模板 -> HTML网页 -> 纯图 -> SVG -> ...),本项目不会跟进。但如果你只是想做一个模板式的AIPPT生成工具,或者愿意自行扩展更好的生成方案,那么依托于PPTist完善的Schema和编辑能力,本项目可能会对你有一定的帮助。
|
||||
- **Office PPT制作工具**(推荐度:⭐⭐):本项目支持大量Office PPT常用功能,也支持基本的导出本地PPTX文件,但毕竟导出功能做不到100%还原,且这种场景下对导入PPTX文件的要求一般也非常高,而前面提到导入PPTX文件的能力有限。因此,除非你能接受不完美的导入能力,否则请谨慎选择。
|
||||
- **Web 幻灯片编辑/演示应用**(推荐度:⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐):这是最推荐的场景,编辑能力和编辑体验是PPTist最核心的优势,你可以在本项目的基础上,根据自己的需求,定制/新增一些个性化的节点/功能,不以导入导出为最终目的(毕竟这些定制化的东西在Office PPT中是不存在的)。**一句话总结:我更希望你基于PPTist打造一个不同于Office PPT的演示类产品,而不只是做一个Office PPT的编辑中转站。**
|
||||
- **AIPPT生成工具**(推荐度:⭐⭐):本项目支持模板式 AIPPT 生成思路,也提供了面向 AI 直接生成页面数据的结构定义,参考 [AI_PPT_SCHEMA](/doc/AI_PPT_SCHEMA.md),理论上支持非模板式 PPT 生成(由 AI 直接生成 PPTist 数据)。需要注意的是,AIPPT 的最终效果很大程度取决于模型能力、提示词设计、素材生成/检索等。PPTist 更适合作为结构化生成结果的承载、编辑和二次加工底座,而不是开箱即用的完整 AIPPT 商业方案。
|
||||
- **PPT文件预览工具**(推荐度:⭐⭐):本项目支持导入PPTX文件,并能覆盖较多常见页面内容和样式(综合来看,可达到85%+的还原度),可以用于在线预览、轻量校对、内容浏览等场景。但由于PPTX生态本身较复杂,动画、特殊图表、多层嵌套、非标准元素、部分高级样式等仍不可避免的存在还原差异。如果你的业务对像素级还原要求极高,建议结合实际样例进行评估。
|
||||
- **Office PPT制作工具**(推荐度:⭐⭐):本项目支持大量Office PPT常用功能,也支持导入和导出本地PPTX文件,可作为在线制作、编辑的基础方案使用。需要注意的是,Office PPT的完整兼容涉及复杂排版、动画、图表、主题、母版等大量细节,文件导入导出时必然会丢失部分细节,无法做到100%还原。因此,如果你的核心目标是完全替代Office PowerPoint,建议结合实际业务文档进行测试和取舍。
|
||||
- **Web 幻灯片编辑/演示应用**(推荐度:⭐⭐⭐⭐⭐):这是最推荐的场景,编辑能力和编辑体验是PPTist最核心的优势,你可以在本项目的基础上,根据自己的需求,定制/新增一些个性化的节点/功能,不以导入导出为最终目的(毕竟这些定制化的东西在Office PPT中是不存在的)。**一句话总结:我更希望你基于PPTist打造一个不同于Office PPT的演示类产品,而不只是做一个Office PPT的编辑中转站。**
|
||||
|
||||
|
||||
# 🚀 项目运行
|
||||
|
||||
Generated
+7
-7
@@ -24,7 +24,7 @@
|
||||
"number-precision": "^1.6.0",
|
||||
"pinia": "^3.0.2",
|
||||
"pptxgenjs": "^3.12.0",
|
||||
"pptxtojson": "^2.0.5",
|
||||
"pptxtojson": "^2.1.0",
|
||||
"prosemirror-commands": "^1.6.0",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
@@ -4568,9 +4568,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pptxtojson": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/pptxtojson/-/pptxtojson-2.0.5.tgz",
|
||||
"integrity": "sha512-FgVpAa4luKubqCMWjsUlznbHFuM2yMNxU4oYv5HlVu3L5pY9zDEt8yHFckL4u4CRymv42MkDPBqptlVvqdw8Hw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pptxtojson/-/pptxtojson-2.1.0.tgz",
|
||||
"integrity": "sha512-CGBe+QVAnxAwry1zQWUpcnBDk2VJTeHD3kogjsqCPIy3muV1J4YGrH/XnOLJvIvlT3jHym1HgXzyLZrWTBI8Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1",
|
||||
@@ -9173,9 +9173,9 @@
|
||||
}
|
||||
},
|
||||
"pptxtojson": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/pptxtojson/-/pptxtojson-2.0.5.tgz",
|
||||
"integrity": "sha512-FgVpAa4luKubqCMWjsUlznbHFuM2yMNxU4oYv5HlVu3L5pY9zDEt8yHFckL4u4CRymv42MkDPBqptlVvqdw8Hw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pptxtojson/-/pptxtojson-2.1.0.tgz",
|
||||
"integrity": "sha512-CGBe+QVAnxAwry1zQWUpcnBDk2VJTeHD3kogjsqCPIy3muV1J4YGrH/XnOLJvIvlT3jHym1HgXzyLZrWTBI8Eg==",
|
||||
"requires": {
|
||||
"jszip": "^3.10.1",
|
||||
"tinycolor2": "1.6.0",
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
"number-precision": "^1.6.0",
|
||||
"pinia": "^3.0.2",
|
||||
"pptxgenjs": "^3.12.0",
|
||||
"pptxtojson": "^2.0.5",
|
||||
"pptxtojson": "^2.1.0",
|
||||
"prosemirror-commands": "^1.6.0",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
|
||||
@@ -506,32 +506,26 @@ export const SHAPE_LIST: ShapeListItem[] = [
|
||||
{
|
||||
viewBox: [200, 200],
|
||||
path: 'M 70 0 L 70 70 L 0 70 L 0 130 L 70 130 L 70 200 L 130 200 L 130 130 L 200 130 L 200 70 L 130 70 L 130 0 L 70 0 Z',
|
||||
pptxShapeType: 'mathPlus',
|
||||
},
|
||||
{
|
||||
viewBox: [200, 200],
|
||||
path: 'M 0 70 L 200 70 L 200 130 L 0 130 Z',
|
||||
pptxShapeType: 'mathMinus',
|
||||
},
|
||||
{
|
||||
viewBox: [200, 200],
|
||||
path: 'M 40 0 L 0 40 L 60 100 L 0 160 L 40 200 L 100 140 L 160 200 L 200 160 L 140 100 L 200 40 L 160 0 L 100 60 L 40 0 Z',
|
||||
pptxShapeType: 'mathMultiply',
|
||||
},
|
||||
{
|
||||
viewBox: [200, 200],
|
||||
path: 'M 0 80 L 200 80 L 200 120 L 0 120 Z M 100 0 A 25 25 0 1 1 100 50 A 25 25 0 1 1 100 0 M 100 200 A 25 25 0 1 1 100 150 A 25 25 0 1 1 100 200',
|
||||
pptxShapeType: 'mathDivide',
|
||||
},
|
||||
{
|
||||
viewBox: [200, 200],
|
||||
path: 'M 0 30 L 200 30 L 200 80 L 0 80 Z M 0 120 L 200 120 L 200 170 L 0 170 Z',
|
||||
pptxShapeType: 'mathEqual',
|
||||
},
|
||||
{
|
||||
viewBox: [200, 200],
|
||||
path: 'M 120 0 L 170 0 L 150 40 L 200 40 L 200 80 L 130 80 L 110 120 L 200 120 L 200 160 L 90 160 L 70 200 L 20 200 L 40 160 L 0 160 L 0 120 L 60 120 L 80 80 L 0 80 L 0 40 L 100 40 Z',
|
||||
pptxShapeType: 'mathNotEqual',
|
||||
},
|
||||
{
|
||||
viewBox: [200, 200],
|
||||
|
||||
+16
-12
@@ -37,6 +37,20 @@ export default () => {
|
||||
|
||||
const exporting = ref(false)
|
||||
|
||||
const setPPTXLayout = (pptx: pptxgen) => {
|
||||
if (viewportRatio.value === 0.625) pptx.layout = 'LAYOUT_16x10'
|
||||
else if (viewportRatio.value === 0.75) pptx.layout = 'LAYOUT_4x3'
|
||||
else {
|
||||
const layoutName = 'PPTIST_CUSTOM_LAYOUT'
|
||||
pptx.defineLayout({
|
||||
name: layoutName,
|
||||
width: viewportSize.value / ratioPx2Inch.value,
|
||||
height: viewportSize.value * viewportRatio.value / ratioPx2Inch.value,
|
||||
})
|
||||
pptx.layout = layoutName
|
||||
}
|
||||
}
|
||||
|
||||
// 导出图片
|
||||
const exportImage = (domRef: HTMLElement, format: string, quality: number, ignoreWebfont = true) => {
|
||||
exporting.value = true
|
||||
@@ -69,6 +83,7 @@ export default () => {
|
||||
|
||||
setTimeout(() => {
|
||||
const pptx = new pptxgen()
|
||||
setPPTXLayout(pptx)
|
||||
|
||||
const config: ExportImageConfig = {
|
||||
quality: 1,
|
||||
@@ -469,18 +484,7 @@ export default () => {
|
||||
const exportPPTX = (_slides: Slide[], masterOverwrite: boolean, ignoreMedia: boolean) => {
|
||||
exporting.value = true
|
||||
const pptx = new pptxgen()
|
||||
|
||||
if (viewportRatio.value === 0.625) pptx.layout = 'LAYOUT_16x10'
|
||||
else if (viewportRatio.value === 0.75) pptx.layout = 'LAYOUT_4x3'
|
||||
else if (viewportRatio.value === 0.70710678) {
|
||||
pptx.defineLayout({ name: 'A3', width: 10, height: 7.0710678 })
|
||||
pptx.layout = 'A3'
|
||||
}
|
||||
else if (viewportRatio.value === 1.41421356) {
|
||||
pptx.defineLayout({ name: 'A3_V', width: 10, height: 14.1421356 })
|
||||
pptx.layout = 'A3_V'
|
||||
}
|
||||
else pptx.layout = 'LAYOUT_16x9'
|
||||
setPPTXLayout(pptx)
|
||||
|
||||
if (masterOverwrite) {
|
||||
const { color: bgColor, alpha: bgAlpha } = formatColor(theme.value.backgroundColor)
|
||||
|
||||
+67
-18
@@ -21,6 +21,7 @@ import type {
|
||||
SlideBackground,
|
||||
PPTShapeElement,
|
||||
PPTLineElement,
|
||||
LinePoint,
|
||||
PPTImageElement,
|
||||
TextAlignVertical,
|
||||
PPTTextElement,
|
||||
@@ -442,11 +443,17 @@ export default () => {
|
||||
}
|
||||
}
|
||||
|
||||
const parseLineEnd = (lineEnd?: { type?: string }): LinePoint => {
|
||||
if (!lineEnd || !lineEnd.type || lineEnd.type === 'none') return ''
|
||||
if (['triangle', 'stealth', 'arrow'].includes(lineEnd.type)) return 'arrow'
|
||||
if (['diamond', 'oval'].includes(lineEnd.type)) return 'dot'
|
||||
return ''
|
||||
}
|
||||
|
||||
const parseLineElement = (el: Shape, ratio: number) => {
|
||||
let start: [number, number] = [0, 0]
|
||||
let end: [number, number] = [0, 0]
|
||||
let rotateOffset: [number, number] = [0, 0]
|
||||
|
||||
if (!el.isFlipV && !el.isFlipH) { // 右下
|
||||
start = [0, 0]
|
||||
end = [el.width, el.height]
|
||||
@@ -474,7 +481,7 @@ export default () => {
|
||||
end,
|
||||
style: el.borderType,
|
||||
color: el.borderColor,
|
||||
points: ['', /straightConnector/.test(el.shapType) ? 'arrow' : '']
|
||||
points: [parseLineEnd(el.headEnd), parseLineEnd(el.tailEnd)]
|
||||
}
|
||||
if (el.rotate) {
|
||||
const { start, end, offset } = rotateLine(data, el.rotate)
|
||||
@@ -500,8 +507,8 @@ export default () => {
|
||||
if (!('x' in point) || !('y' in point)) return null
|
||||
if (typeof point.x !== 'number' || typeof point.y !== 'number') return null
|
||||
|
||||
let x = point.x * ratio
|
||||
let y = point.y * ratio
|
||||
let x = el.pathViewBox?.width ? point.x / el.pathViewBox.width * el.width : point.x * ratio
|
||||
let y = el.pathViewBox?.height ? point.y / el.pathViewBox.height * el.height : point.y * ratio
|
||||
|
||||
if (el.isFlipH) x = el.width - x
|
||||
if (el.isFlipV) y = el.height - y
|
||||
@@ -1058,13 +1065,32 @@ export default () => {
|
||||
}
|
||||
}
|
||||
else if (el.path && el.path.indexOf('NaN') === -1) {
|
||||
const { maxX, maxY } = getSvgPathRange(el.path)
|
||||
element.path = el.path
|
||||
if ((maxX / maxY) > (originWidth / originHeight)) {
|
||||
element.viewBox = [maxX, maxX * originHeight / originWidth]
|
||||
const fixedViewBoxPresetShapeTypes = [
|
||||
'blockArc',
|
||||
'pie',
|
||||
'pieWedge',
|
||||
'arc',
|
||||
'chord',
|
||||
'teardrop',
|
||||
'mathPlus',
|
||||
'mathMinus',
|
||||
'mathMultiply',
|
||||
'mathDivide',
|
||||
'mathEqual',
|
||||
'mathNotEqual',
|
||||
]
|
||||
if (fixedViewBoxPresetShapeTypes.includes(el.shapType) && el.pathViewBox) {
|
||||
element.viewBox = [el.pathViewBox.width, el.pathViewBox.height]
|
||||
}
|
||||
else {
|
||||
element.viewBox = [maxY * originWidth / originHeight, maxY]
|
||||
const { maxX, maxY } = getSvgPathRange(el.path)
|
||||
if ((maxX / maxY) > (originWidth / originHeight)) {
|
||||
element.viewBox = [maxX, maxX * originHeight / originWidth]
|
||||
}
|
||||
else {
|
||||
element.viewBox = [maxY * originWidth / originHeight, maxY]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (el.shapType === 'custom') {
|
||||
@@ -1111,6 +1137,12 @@ export default () => {
|
||||
const fontsize = span?.style.fontSize ? (parseInt(span?.style.fontSize) * ratio).toFixed(1) + 'px' : ''
|
||||
const fontname = span?.style.fontFamily || ''
|
||||
const color = span?.style.color || cellData.fontColor
|
||||
const fontWeight = span?.style.fontWeight || ''
|
||||
const bold = fontWeight === 'bold' || +fontWeight >= 600 || cellData.fontBold
|
||||
const em = span?.style.fontStyle === 'italic'
|
||||
const textDecoration = span?.style.textDecoration || ''
|
||||
const underline = textDecoration.includes('underline')
|
||||
const strikethrough = textDecoration.includes('line-through')
|
||||
|
||||
rowCells.push({
|
||||
id: nanoid(10),
|
||||
@@ -1124,7 +1156,10 @@ export default () => {
|
||||
fontsize,
|
||||
fontname,
|
||||
color,
|
||||
bold: cellData.fontBold,
|
||||
bold,
|
||||
em,
|
||||
underline,
|
||||
strikethrough,
|
||||
backcolor: cellData.fillColor,
|
||||
},
|
||||
})
|
||||
@@ -1136,15 +1171,29 @@ export default () => {
|
||||
const allWidth = el.colWidths.reduce((a, b) => a + b, 0)
|
||||
const colWidths: number[] = el.colWidths.map(item => item / allWidth)
|
||||
|
||||
const firstCell = el.data[0][0]
|
||||
const border = firstCell.borders.top ||
|
||||
firstCell.borders.bottom ||
|
||||
el.borders.top ||
|
||||
el.borders.bottom ||
|
||||
firstCell.borders.left ||
|
||||
firstCell.borders.right ||
|
||||
el.borders.left ||
|
||||
el.borders.right
|
||||
const isVisibleBorder = (b?: { borderColor?: string; borderWidth?: number }) => {
|
||||
if (!b || !b.borderWidth) return false
|
||||
const c = b.borderColor || ''
|
||||
if (/^#[0-9a-fA-F]{8}$/.test(c) && c.slice(-2).toLowerCase() === '00') return false
|
||||
return true
|
||||
}
|
||||
const borderCounter = new Map<string, { border: any; count: number }>()
|
||||
const collectBorders = (borders?: Record<string, any>) => {
|
||||
if (!borders) return
|
||||
for (const side of ['top', 'bottom', 'left', 'right'] as const) {
|
||||
const b = borders[side]
|
||||
if (!isVisibleBorder(b)) continue
|
||||
const key = `${b.borderColor}|${b.borderWidth}|${b.borderType}`
|
||||
const hit = borderCounter.get(key)
|
||||
if (hit) hit.count++
|
||||
else borderCounter.set(key, { border: b, count: 1 })
|
||||
}
|
||||
}
|
||||
collectBorders(el.borders)
|
||||
for (const rowCells of el.data) {
|
||||
for (const cell of rowCells) collectBorders(cell.borders)
|
||||
}
|
||||
const border = [...borderCounter.values()].sort((a, b) => b.count - a.count)[0]?.border
|
||||
const borderWidth = border?.borderWidth || 0
|
||||
const borderStyle = border?.borderType || 'solid'
|
||||
const borderColor = border?.borderColor || '#eeece1'
|
||||
|
||||
@@ -36,4 +36,14 @@ export const htmlToText = (html: string) => {
|
||||
*/
|
||||
export const isFloatEqual = (a: number, b: number, epsilon = 1e-10) => {
|
||||
return Math.abs(a - b) < epsilon
|
||||
}
|
||||
|
||||
/**
|
||||
* 保留小数转换
|
||||
*/
|
||||
export const toFixed = (num: number, fractionDigits = 1) => {
|
||||
if (num % 1 !== 0) {
|
||||
return parseFloat(num.toFixed(fractionDigits))
|
||||
}
|
||||
return Math.floor(num)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="viewport-size-setting">
|
||||
<div class="title">自定义画布尺寸</div>
|
||||
<div class="row">
|
||||
<div class="label">宽度:</div>
|
||||
<NumberInput
|
||||
v-model:value="customViewportWidth"
|
||||
:min="VIEWPORT_SIZE_MIN"
|
||||
:max="VIEWPORT_SIZE_MAX"
|
||||
style="flex: 1;"
|
||||
@enter="applyCustomViewportSize()"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">高度:</div>
|
||||
<NumberInput
|
||||
v-model:value="customViewportHeight"
|
||||
:min="VIEWPORT_SIZE_MIN"
|
||||
:max="VIEWPORT_SIZE_MAX"
|
||||
style="flex: 1;"
|
||||
@enter="applyCustomViewportSize()"
|
||||
/>
|
||||
</div>
|
||||
<div class="tip">宽高范围:500 ~ 2000</div>
|
||||
<div class="btns">
|
||||
<Button type="primary" @click="applyCustomViewportSize()">确认</Button>
|
||||
<Button style="margin-left: 10px;" @click="emit('close')">取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSlidesStore } from '@/store'
|
||||
import message from '@/utils/message'
|
||||
import { toFixed } from '@/utils/common'
|
||||
|
||||
import NumberInput from '@/components/NumberInput.vue'
|
||||
import Button from '@/components/Button.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
}>()
|
||||
|
||||
const VIEWPORT_SIZE_MIN = 500
|
||||
const VIEWPORT_SIZE_MAX = 2000
|
||||
|
||||
const slidesStore = useSlidesStore()
|
||||
const { viewportRatio, viewportSize } = storeToRefs(slidesStore)
|
||||
|
||||
const customViewportWidth = ref(toFixed(viewportSize.value))
|
||||
const customViewportHeight = ref(toFixed(viewportSize.value * viewportRatio.value))
|
||||
|
||||
const applyCustomViewportSize = () => {
|
||||
const width = customViewportWidth.value
|
||||
const height = customViewportHeight.value
|
||||
if (
|
||||
width < VIEWPORT_SIZE_MIN ||
|
||||
width > VIEWPORT_SIZE_MAX ||
|
||||
height < VIEWPORT_SIZE_MIN ||
|
||||
height > VIEWPORT_SIZE_MAX
|
||||
) return message.warning(`画布宽高需在 ${VIEWPORT_SIZE_MIN} ~ ${VIEWPORT_SIZE_MAX} 之间`)
|
||||
|
||||
slidesStore.setViewportSize(width)
|
||||
slidesStore.setViewportRatio(height / width)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
margin-bottom: 15px;
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
}
|
||||
.row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.label {
|
||||
width: 50px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tip {
|
||||
margin-bottom: 18px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.btns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -103,13 +103,14 @@
|
||||
style="width: 100%;"
|
||||
defaultLabel="自定义"
|
||||
:value="viewportRatio"
|
||||
@update:value="value => updateViewportRatio(value as number)"
|
||||
@update:value="value => updateViewportRatio(value)"
|
||||
:options="[
|
||||
{ label: '宽屏 16 : 9', value: 0.5625 },
|
||||
{ label: '宽屏 16 : 10', value: 0.625 },
|
||||
{ label: '标准 4 : 3', value: 0.75 },
|
||||
{ label: '纸张 A3 / A4', value: 0.70710678 },
|
||||
{ label: '竖向 A3 / A4', value: 1.41421356 },
|
||||
{ label: '自定义', value: 'custom' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
@@ -305,6 +306,14 @@
|
||||
>
|
||||
<ThemeColorsSetting @close="themeColorsSettingVisible = false" />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
v-model:visible="customViewportSizeVisible"
|
||||
:width="300"
|
||||
@closed="customViewportSizeVisible = false"
|
||||
>
|
||||
<ViewportSizeSetting @close="customViewportSizeVisible = false" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -326,9 +335,11 @@ import { FONTS } from '@/configs/font'
|
||||
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
|
||||
import useSlideTheme from '@/hooks/useSlideTheme'
|
||||
import { getImageDataURL } from '@/utils/image'
|
||||
import { toFixed } from '@/utils/common'
|
||||
|
||||
import ThemeStylesExtract from './ThemeStylesExtract.vue'
|
||||
import ThemeColorsSetting from './ThemeColorsSetting.vue'
|
||||
import ViewportSizeSetting from './ViewportSizeSetting.vue'
|
||||
import SVGLine from '../common/SVGLine.vue'
|
||||
import ColorButton from '@/components/ColorButton.vue'
|
||||
import ColorListButton from '@/components/ColorListButton.vue'
|
||||
@@ -350,6 +361,7 @@ const { slides, currentSlide, slideIndex, viewportRatio, viewportSize, theme } =
|
||||
const moreThemeConfigsVisible = ref(false)
|
||||
const themeStylesExtractVisible = ref(false)
|
||||
const themeColorsSettingVisible = ref(false)
|
||||
const customViewportSizeVisible = ref(false)
|
||||
const currentGradientIndex = ref(0)
|
||||
const lineStyleOptions = ref<LineStyleType[]>(['solid', 'dashed', 'dotted'])
|
||||
|
||||
@@ -462,16 +474,14 @@ const updateTheme = (themeProps: Partial<SlideTheme>) => {
|
||||
}
|
||||
|
||||
// 设置画布尺寸(宽高比例)
|
||||
const updateViewportRatio = (value: number) => {
|
||||
const updateViewportRatio = (value: string | number) => {
|
||||
if (value === 'custom') {
|
||||
customViewportSizeVisible.value = true
|
||||
return
|
||||
}
|
||||
if (typeof value !== 'number') return
|
||||
slidesStore.setViewportRatio(value)
|
||||
}
|
||||
|
||||
const toFixed = (num: number) => {
|
||||
if (num % 1 !== 0) {
|
||||
return parseFloat(num.toFixed(1))
|
||||
}
|
||||
return Math.floor(num)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -66,6 +66,8 @@ onMounted(() => {
|
||||
id?: string
|
||||
slideIndex?: number
|
||||
animationIndex?: number
|
||||
viewportSize?: number
|
||||
viewportRatio?: number
|
||||
slides?: Slide[]
|
||||
dataURL?: string
|
||||
blackboard?: boolean
|
||||
@@ -78,6 +80,8 @@ onMounted(() => {
|
||||
else if (msg.type === 'TURN_TO_ID' && msg.id !== undefined) turnSlideToId(msg.id)
|
||||
else if (msg.type === 'INIT_STATE' && msg.slideIndex !== undefined) {
|
||||
// 先用演讲者的实际幻灯片数据覆盖 mock 数据,确保动画序列等编辑内容一致
|
||||
if (msg.viewportSize !== undefined) slidesStore.setViewportSize(msg.viewportSize)
|
||||
if (msg.viewportRatio !== undefined) slidesStore.setViewportRatio(msg.viewportRatio)
|
||||
if (msg.slides) slidesStore.setSlides(msg.slides)
|
||||
turnSlideToIndex(msg.slideIndex)
|
||||
if (msg.animationIndex !== undefined) {
|
||||
|
||||
@@ -15,7 +15,7 @@ type SyncMessage =
|
||||
| { type: 'TURN_TO_INDEX'; index: number }
|
||||
| { type: 'TURN_TO_ID'; id: string }
|
||||
| { type: 'REQUEST_STATE' }
|
||||
| { type: 'INIT_STATE'; slideIndex: number; animationIndex: number; slides: Slide[] }
|
||||
| { type: 'INIT_STATE'; slideIndex: number; animationIndex: number; slides: Slide[]; viewportSize: number; viewportRatio: number }
|
||||
| { type: 'REQUEST_WRITING_BOARD' }
|
||||
| { type: 'WRITING_BOARD_UPDATE'; dataURL: string; blackboard: boolean }
|
||||
| { type: 'WRITING_BOARD_CLOSE' }
|
||||
@@ -25,7 +25,7 @@ type SyncMessage =
|
||||
|
||||
export default () => {
|
||||
const slidesStore = useSlidesStore()
|
||||
const { slides, slideIndex, formatedAnimations } = storeToRefs(slidesStore)
|
||||
const { slides, slideIndex, formatedAnimations, viewportSize, viewportRatio } = storeToRefs(slidesStore)
|
||||
|
||||
const isAudienceMode = new URLSearchParams(window.location.search).get('mode') === 'audience'
|
||||
|
||||
@@ -39,6 +39,8 @@ export default () => {
|
||||
type: 'INIT_STATE',
|
||||
slideIndex: slideIndex.value,
|
||||
animationIndex: animationIndex.value,
|
||||
viewportSize: viewportSize.value,
|
||||
viewportRatio: viewportRatio.value,
|
||||
slides: JSON.parse(JSON.stringify(slides.value)),
|
||||
} as SyncMessage)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { onMounted, onUnmounted, ref, type ShallowRef } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watch, type ShallowRef } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSlidesStore } from '@/store'
|
||||
|
||||
@@ -40,6 +40,8 @@ export default (wrapRef?: ShallowRef<HTMLElement | null>) => {
|
||||
window.removeEventListener('resize', setSlideContentSize)
|
||||
})
|
||||
|
||||
watch(viewportRatio, setSlideContentSize)
|
||||
|
||||
return {
|
||||
slideWidth,
|
||||
slideHeight,
|
||||
|
||||
Reference in New Issue
Block a user