feature: vor und nachteile

This commit is contained in:
Hikyu
2026-05-31 13:54:31 +02:00
parent 552a947a1c
commit e3dad17446
23270 changed files with 3024794 additions and 200 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2021 Anthony Fu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+103
View File
@@ -0,0 +1,103 @@
<br>
<p align="center">
<a href="https://sli.dev" target="_blank">
<img src="https://sli.dev/logo-title.png" alt="Slidev" height="250" width="250"/>
</a>
</p>
<p align="center">
Presentation <b>slide</b>s for <b>dev</b>elopers 🧑‍💻👩‍💻👨‍💻
</p>
<p align="center">
<a href="https://www.npmjs.com/package/@slidev/cli" target="__blank"><img src="https://img.shields.io/npm/v/@slidev/cli?color=2B90B6&label=" alt="NPM version"></a>
<a href="https://www.npmjs.com/package/@slidev/cli" target="__blank"><img alt="NPM Downloads" src="https://img.shields.io/npm/dm/@slidev/cli?color=349dbe&label="></a>
<a href="https://sli.dev/" target="__blank"><img src="https://img.shields.io/static/v1?label=&message=docs%20%26%20demos&color=45b8cd" alt="Docs & Demos"></a>
<a href="https://sli.dev/resources/theme-gallery" target="__blank"><img src="https://img.shields.io/static/v1?label=&message=themes&color=4ec5d4" alt="Themes"></a>
<br>
<a href="https://github.com/slidevjs/slidev" target="__blank"><img alt="GitHub stars" src="https://img.shields.io/github/stars/slidevjs/slidev?style=social"></a>
</p>
<p align="center">
<a href="https://twitter.com/antfu7/status/1389604687502995457">Video Preview</a> | <a href="https://sli.dev">Documentation</a>
</p>
<div align="center">
<table>
<tbody>
<td align="center">
<img width="2000" height="0" alt="" aria-hiden><br>
<sub>Made possible by my <a href="https://github.com/sponsors/antfu">Sponsor Program 💖</a></sub><br>
<img width="2000" height="0" alt="" aria-hiden>
</td>
</tbody>
</table>
</div>
## Features
- 📝 [**Markdown-based**](https://sli.dev/guide/syntax) - foucus on content and use your favorite editor
- 🧑‍💻 [**Developer Friendly**](https://sli.dev/guide/syntax#code-blocks) - built-in code highlighting, live coding, etc.
- 🎨 [**Themable**](https://sli.dev/resources/theme-gallery) - theme can be shared and used with npm packages
- 🌈 [**Stylish**](https://sli.dev/guide/syntax#embedded-styles) - on-demand utilities via [UnoCSS](https://github.com/unocss/unocss).
- 🤹 [**Interactive**](https://sli.dev/custom/directory-structure#components) - embedding Vue components seamlessly
- 🎙 [**Presenter Mode**](https://sli.dev/guide/ui#presenter-mode) - use another window, or even your phone to control your slides
- 🎨 [**Drawing**](https://sli.dev/features/drawing) - draw and annotate on your slides
- 🧮 [**LaTeX**](https://sli.dev/features/latex) - built-in LaTeX math equations support
- 📰 [**Diagrams**](https://sli.dev/guide/syntax#diagrams) - creates diagrams using textual descriptions with [Mermaid.js](https://mermaid.js.org/)
- 🌟 [**Icons**](https://sli.dev/features/icons) - access to icons from any icon set directly
- 💻 [**Editor**](https://sli.dev/guide/index#editor) - integrated editor, or the [VSCode extension](https://sli.dev/features/vscode-extension)
- 🎥 [**Recording**](https://sli.dev/features/recording) - built-in recording and camera view
- 📤 [**Portable**](https://sli.dev/guide/exporting) - export into PDF, PNGs, or PPTX
- ⚡️ [**Fast**](https://vitejs.dev) - instant reloading powered by [Vite](https://vitejs.dev)
- 🛠 [**Hackable**](https://sli.dev/custom/) - using Vite plugins, Vue components, or any npm packages
## Getting Started
### Try it Online ⚡️
[sli.dev/new](https://sli.dev/new)
[![](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://sli.dev/new)
### Init Project Locally
Install [Node.js >=18](https://nodejs.org/) and run the following command:
```bash
npm init slidev
```
Documentation:
**[English](https://sli.dev)** | [中文文档](https://cn.sli.dev) | [Français](https://fr.sli.dev) | [Español](https://es.sli.dev) | [Русский](https://ru.sli.dev) | [Português-BR](https://br.sli.dev)
Discord: [chat.sli.dev](https://chat.sli.dev)
For a full example, you can check the [demo](https://github.com/slidevjs/slidev/blob/main/demo) folder, which is also the source file for [my previous talk](https://antfu.me/posts/composable-vue-vueday-2021).
## Tech Stack
- [Vite](https://vitejs.dev) - An extremely fast frontend tooling
- [Vue 3](https://v3.vuejs.org/) powered [Markdown](https://daringfireball.net/projects/markdown/syntax) - Focus on the content while having the power of HTML and Vue components whenever needed
- [UnoCSS](https://github.com/unocss/unocss) - On-demand utility-first CSS engine, style your slides at ease
- [Shiki](https://github.com/shikijs/shiki), [Monaco Editor](https://github.com/Microsoft/monaco-editor) - First-class code snippets support with live coding capability
- [RecordRTC](https://recordrtc.org) - Built-in recording and camera view
- [VueUse](https://vueuse.org) family - [`@vueuse/core`](https://github.com/vueuse/vueuse), [`@vueuse/motion`](https://github.com/vueuse/motion), etc.
- [Iconify](https://iconify.design/) - Icon sets collection.
- [Drauu](https://github.com/antfu/drauu) - Drawing and annotations support
- [KaTeX](https://katex.org/) - LaTeX math rendering.
- [Mermaid](https://mermaid-js.github.io/mermaid) - Textual Diagrams.
## Sponsors
This project is made possible by all the sponsors supporting my work:
<p align="center">
<a href="https://github.com/sponsors/antfu">
<img src='https://cdn.jsdelivr.net/gh/antfu/static/sponsors.svg' alt="Logos from Sponsors" />
</a>
</p>
## License
MIT License © 2021 [Anthony Fu](https://github.com/antfu)
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
'use strict'
import ('../dist/cli.js')
+76
View File
@@ -0,0 +1,76 @@
import {
resolveViteConfigs
} from "./chunk-7E4FQJTV.js";
import "./chunk-UNQ5DBLZ.js";
// node/commands/build.ts
import http from "node:http";
import { resolve } from "node:path";
import connect from "connect";
import fs from "fs-extra";
import sirv from "sirv";
import { build as viteBuild } from "vite";
async function build(options, viteConfig = {}, args) {
const indexPath = resolve(options.userRoot, "index.html");
let originalIndexHTML;
if (fs.existsSync(indexPath))
originalIndexHTML = await fs.readFile(indexPath, "utf-8");
await fs.writeFile(indexPath, options.utils.indexHtml, "utf-8");
let config = void 0;
try {
const inlineConfig = await resolveViteConfigs(
options,
{
plugins: [
{
name: "resolve-config",
configResolved(_config) {
config = _config;
}
}
],
build: {
chunkSizeWarningLimit: 2e3
}
},
viteConfig,
"build"
);
await viteBuild(inlineConfig);
} finally {
if (originalIndexHTML != null)
await fs.writeFile(indexPath, originalIndexHTML, "utf-8");
else
await fs.unlink(indexPath);
}
const outDir = resolve(options.userRoot, config.build.outDir);
await fs.copyFile(resolve(outDir, "index.html"), resolve(outDir, "404.html"));
const redirectsPath = resolve(outDir, "_redirects");
if (!fs.existsSync(redirectsPath))
await fs.writeFile(redirectsPath, `${config.base}* ${config.base}index.html 200
`, "utf-8");
if ([true, "true", "auto"].includes(options.data.config.download)) {
const { exportSlides, getExportOptions } = await import("./export-I2C456L6.js");
const port = 12445;
const app = connect();
const server = http.createServer(app);
app.use(
config.base,
sirv(outDir, {
etag: true,
single: true,
dev: true
})
);
server.listen(port);
await exportSlides({
port,
base: config.base,
...getExportOptions(args, options, outDir, "slidev-exported.pdf")
});
server.close();
}
}
export {
build
};
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
// node/resolver.ts
import * as fs from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { parseNi, run } from "@antfu/ni";
import { ensurePrefix, slash } from "@antfu/utils";
import globalDirs from "global-directory";
import { underline, yellow } from "kolorist";
import { resolvePath } from "mlly";
import prompts from "prompts";
import { resolveGlobal } from "resolve-global";
import { findClosestPkgJsonPath, findDepPkgJsonPath } from "vitefu";
var cliRoot = fileURLToPath(new URL("..", import.meta.url));
var isInstalledGlobally = {};
async function resolveImportUrl(id) {
return toAtFS(await resolveImportPath(id, true));
}
function toAtFS(path) {
return `/@fs${ensurePrefix("/", slash(path))}`;
}
async function resolveImportPath(importName, ensure = false) {
try {
return await resolvePath(importName, {
url: import.meta.url
});
} catch {
}
if (isInstalledGlobally.value) {
try {
return resolveGlobal(importName);
} catch {
}
}
if (ensure)
throw new Error(`Failed to resolve package "${importName}"`);
}
async function findPkgRoot(dep, parent, ensure = false) {
const pkgJsonPath = await findDepPkgJsonPath(dep, parent);
const path = pkgJsonPath ? dirname(pkgJsonPath) : isInstalledGlobally.value ? await findGlobalPkgRoot(dep, false) : void 0;
if (ensure && !path)
throw new Error(`Failed to resolve package "${dep}"`);
return path;
}
async function findGlobalPkgRoot(name, ensure = false) {
const yarnPath = join(globalDirs.yarn.packages, name);
if (fs.existsSync(`${yarnPath}/package.json`))
return yarnPath;
const npmPath = join(globalDirs.npm.packages, name);
if (fs.existsSync(`${npmPath}/package.json`))
return npmPath;
if (ensure)
throw new Error(`Failed to resolve global package "${name}"`);
}
async function resolveEntry(entryRaw) {
if (!fs.existsSync(entryRaw) && !entryRaw.endsWith(".md") && !/[/\\]/.test(entryRaw))
entryRaw += ".md";
const entry = resolve(entryRaw);
if (!fs.existsSync(entry)) {
const { create } = await prompts({
name: "create",
type: "confirm",
initial: "Y",
message: `Entry file ${yellow(`"${entry}"`)} does not exist, do you want to create it?`
});
if (create)
fs.copyFileSync(resolve(cliRoot, "template.md"), entry);
else
process.exit(0);
}
return slash(entry);
}
function createResolver(type, officials) {
async function promptForInstallation(pkgName) {
const { confirm } = await prompts({
name: "confirm",
initial: "Y",
type: "confirm",
message: `The ${type} "${pkgName}" was not found ${underline(isInstalledGlobally.value ? "globally" : "in your project")}, do you want to install it now?`
});
if (!confirm)
process.exit(1);
if (isInstalledGlobally.value)
await run(parseNi, ["-g", pkgName]);
else
await run(parseNi, [pkgName]);
}
return async function(name, importer) {
const { userRoot } = await getRoots();
if (name === "none")
return ["", null];
if (name[0] === "/")
return [name, name];
if (name.startsWith("@/"))
return [name, resolve(userRoot, name.slice(2))];
if (name[0] === "." || name[0] !== "@" && name.includes("/"))
return [name, resolve(dirname(importer), name)];
if (name.startsWith(`@slidev/${type}-`) || name.startsWith(`slidev-${type}-`)) {
const pkgRoot = await findPkgRoot(name, importer);
if (!pkgRoot)
await promptForInstallation(name);
return [name, await findPkgRoot(name, importer, true)];
}
{
const possiblePkgNames = [
`@slidev/${type}-${name}`,
`slidev-${type}-${name}`,
name
];
for (const pkgName2 of possiblePkgNames) {
const pkgRoot = await findPkgRoot(pkgName2, importer);
if (pkgRoot)
return [pkgName2, pkgRoot];
}
}
const pkgName = officials[name] ?? (name[0] === "@" ? name : `slidev-${type}-${name}`);
await promptForInstallation(pkgName);
return [pkgName, await findPkgRoot(pkgName, importer, true)];
};
}
function getUserPkgJson(userRoot) {
const path = resolve(userRoot, "package.json");
if (fs.existsSync(path))
return JSON.parse(fs.readFileSync(path, "utf-8"));
return {};
}
function hasWorkspacePackageJSON(root) {
const path = join(root, "package.json");
try {
fs.accessSync(path, fs.constants.R_OK);
} catch {
return false;
}
const content = JSON.parse(fs.readFileSync(path, "utf-8")) || {};
return !!content.workspaces;
}
function hasRootFile(root) {
const ROOT_FILES = [
// '.git',
// https://pnpm.js.org/workspaces/
"pnpm-workspace.yaml"
// https://rushjs.io/pages/advanced/config_files/
// 'rush.json',
// https://nx.dev/latest/react/getting-started/nx-setup
// 'workspace.json',
// 'nx.json'
];
return ROOT_FILES.some((file) => fs.existsSync(join(root, file)));
}
function searchForWorkspaceRoot(current, root = current) {
if (hasRootFile(current))
return current;
if (hasWorkspacePackageJSON(current))
return current;
const dir = dirname(current);
if (!dir || dir === current)
return root;
return searchForWorkspaceRoot(dir, root);
}
var rootsInfo = null;
async function getRoots(entry) {
if (rootsInfo)
return rootsInfo;
if (!entry)
throw new Error("[slidev] Cannot find roots without entry");
const userRoot = dirname(entry);
isInstalledGlobally.value = slash(relative(userRoot, process.argv[1])).includes("/.pnpm/") || (await import("is-installed-globally")).default;
const clientRoot = await findPkgRoot("@slidev/client", cliRoot, true);
const closestPkgRoot = dirname(await findClosestPkgJsonPath(userRoot) || userRoot);
const userPkgJson = getUserPkgJson(closestPkgRoot);
const userWorkspaceRoot = searchForWorkspaceRoot(closestPkgRoot);
rootsInfo = {
cliRoot,
clientRoot,
userRoot,
userPkgJson,
userWorkspaceRoot
};
return rootsInfo;
}
export {
isInstalledGlobally,
resolveImportUrl,
toAtFS,
resolveImportPath,
resolveEntry,
createResolver,
getRoots
};
+29
View File
@@ -0,0 +1,29 @@
import {
resolveViteConfigs
} from "./chunk-7E4FQJTV.js";
// node/commands/serve.ts
import { join } from "node:path";
import process from "node:process";
import { createServer as createViteServer } from "vite";
async function createServer(options, viteConfig = {}, serverOptions) {
process.env.EDITOR = process.env.EDITOR || "code";
const inlineConfig = await resolveViteConfigs(
options,
{
optimizeDeps: {
entries: [
join(options.clientRoot, "main.ts")
]
}
},
viteConfig,
"serve",
serverOptions
);
return await createViteServer(inlineConfig);
}
export {
createServer
};
+2
View File
@@ -0,0 +1,2 @@
export { }
+602
View File
@@ -0,0 +1,602 @@
import {
createServer
} from "./chunk-UVOSJW3S.js";
import {
getThemeMeta,
loadSetups,
parser,
resolveAddons,
resolveOptions,
resolveTheme,
version
} from "./chunk-7E4FQJTV.js";
import {
getRoots,
isInstalledGlobally,
resolveEntry
} from "./chunk-UNQ5DBLZ.js";
// node/cli.ts
import { exec } from "node:child_process";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import * as readline from "node:readline";
import { verifyConfig } from "@slidev/parser";
import equal from "fast-deep-equal";
import fs from "fs-extra";
import { getPort } from "get-port-please";
import { blue, bold, cyan, dim, gray, green, lightCyan, underline, yellow } from "kolorist";
import openBrowser from "open";
import yargs from "yargs";
// node/setups/preparser.ts
import { uniq } from "@antfu/utils";
import { injectPreparserExtensionLoader } from "@slidev/parser/fs";
function setupPreparser() {
injectPreparserExtensionLoader(async (headmatter, filepath, mode) => {
const addons = Array.isArray(headmatter?.addons) ? headmatter.addons : [];
const { userRoot } = await getRoots();
const roots = uniq([
...await resolveAddons(addons),
userRoot
]);
const returns = await loadSetups(roots, "preparser.ts", [{ filepath, headmatter, mode }]);
return returns.flat();
});
}
// node/cli.ts
var CONFIG_RESTART_FIELDS = [
"monaco",
"routerMode",
"fonts",
"css",
"mdc",
"editor",
"theme"
];
var FILES_CREATE_RESTART = [
"global-bottom.vue",
"global-top.vue",
"uno.config.js",
"uno.config.ts",
"unocss.config.js",
"unocss.config.ts"
];
var FILES_CHANGE_RESTART = [
"setup/shiki.ts",
"setup/katex.ts",
"setup/preparser.ts"
];
setupPreparser();
var cli = yargs(process.argv.slice(2)).scriptName("slidev").usage("$0 [args]").version(version).strict().showHelpOnFail(false).alias("h", "help").alias("v", "version");
cli.command(
"* [entry]",
"Start a local server for Slidev",
(args) => commonOptions(args).option("port", {
alias: "p",
type: "number",
describe: "port"
}).option("open", {
alias: "o",
default: false,
type: "boolean",
describe: "open in browser"
}).option("remote", {
type: "string",
describe: "listen public host and enable remote control"
}).option("tunnel", {
default: false,
type: "boolean",
describe: "open a Cloudflare Quick Tunnel to make Slidev available on the internet"
}).option("log", {
default: "warn",
type: "string",
choices: ["error", "warn", "info", "silent"],
describe: "log level"
}).option("inspect", {
default: false,
type: "boolean",
describe: "enable the inspect plugin for debugging"
}).option("force", {
alias: "f",
default: false,
type: "boolean",
describe: "force the optimizer to ignore the cache and re-bundle "
}).option("bind", {
type: "string",
default: "0.0.0.0",
describe: "specify which IP addresses the server should listen on in remote mode"
}).strict().help(),
async ({ entry, theme, port: userPort, open, log, remote, tunnel, force, inspect, bind }) => {
let server;
let port = 3030;
let lastRemoteUrl;
let restartTimer;
function restartServer() {
clearTimeout(restartTimer);
restartTimer = setTimeout(() => {
console.log(yellow("\n restarting...\n"));
initServer();
}, 500);
}
async function initServer() {
if (server)
await server.close();
const options = await resolveOptions({ entry, remote, theme, inspect }, "dev");
const host = remote !== void 0 ? bind : "localhost";
port = userPort || await getPort({
port: 3030,
random: false,
portRange: [3030, 4e3],
host
});
server = await createServer(
options,
{
server: {
port,
strictPort: true,
open,
host,
// @ts-expect-error Vite <= 4
force
},
optimizeDeps: {
// Vite 5
force
},
logLevel: log
},
{
async loadData(loadedSource) {
const { data: oldData, entry: entry2 } = options;
const loaded = await parser.load(options.userRoot, entry2, loadedSource, "dev");
const themeRaw = theme || loaded.headmatter.theme || "default";
if (options.themeRaw !== themeRaw) {
console.log(yellow("\n restarting on theme change\n"));
restartServer();
return false;
}
const themeMeta = options.themeRoots[0] ? await getThemeMeta(themeRaw, options.themeRoots[0]) : void 0;
const newData = {
...loaded,
themeMeta,
config: parser.resolveConfig(loaded.headmatter, themeMeta, entry2)
};
if (CONFIG_RESTART_FIELDS.some((i) => !equal(newData.config[i], oldData.config[i]))) {
console.log(yellow("\n restarting on config change\n"));
restartServer();
return false;
}
if (newData.features.katex && !oldData.features.katex || newData.features.monaco && !oldData.features.monaco) {
console.log(yellow("\n restarting on feature change\n"));
restartServer();
return false;
}
return newData;
}
}
);
await server.listen();
let tunnelUrl = "";
if (tunnel) {
if (remote != null)
tunnelUrl = await openTunnel(port);
else
console.log(yellow("\n --remote is required for tunneling, Cloudflare Quick Tunnel is not enabled.\n"));
}
let publicIp;
if (remote)
publicIp = await import("public-ip").then((r) => r.publicIpv4());
lastRemoteUrl = printInfo(options, port, remote, tunnelUrl, publicIp);
}
async function openTunnel(port2) {
const { startTunnel } = await import("untun");
const tunnel2 = await startTunnel({
port: port2,
acceptCloudflareNotice: true
});
return await tunnel2?.getURL() ?? "";
}
const SHORTCUTS = [
{
name: "r",
fullname: "restart",
action() {
initServer();
}
},
{
name: "o",
fullname: "open",
action() {
openBrowser(`http://localhost:${port}`);
}
},
{
name: "e",
fullname: "edit",
action() {
exec(`code "${entry}"`);
}
},
{
name: "q",
fullname: "quit",
action() {
try {
server?.close();
} finally {
process.exit();
}
}
},
{
name: "c",
fullname: "qrcode",
async action() {
if (!lastRemoteUrl)
return;
await import("uqr").then(async (r) => {
const code = r.renderUnicodeCompact(lastRemoteUrl);
console.log(`
${dim(" QR Code for remote control: ")}
${blue(lastRemoteUrl)}
`);
console.log(code.split("\n").map((i) => ` ${i}`).join("\n"));
const publicIp = await import("public-ip").then((r2) => r2.publicIpv4());
if (publicIp)
console.log(`
${dim(" Public IP: ")} ${blue(publicIp)}
`);
});
}
}
];
function bindShortcut() {
process.stdin.resume();
process.stdin.setEncoding("utf8");
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY)
process.stdin.setRawMode(true);
process.stdin.on("keypress", (str, key) => {
if (key.ctrl && key.name === "c") {
process.exit();
} else {
const [sh] = SHORTCUTS.filter((item) => item.name === str);
if (sh) {
try {
sh.action();
} catch (err) {
console.error(`Failed to execute shortcut ${sh.fullname}`, err);
}
}
}
});
}
initServer();
bindShortcut();
const { watch } = await import("chokidar");
const watcher = watch([
...FILES_CREATE_RESTART,
...FILES_CHANGE_RESTART
], {
ignored: ["node_modules", ".git"],
ignoreInitial: true
});
watcher.on("unlink", (file) => {
console.log(yellow(`
file ${file} removed, restarting...
`));
restartServer();
});
watcher.on("add", (file) => {
console.log(yellow(`
file ${file} added, restarting...
`));
restartServer();
});
watcher.on("change", (file) => {
if (typeof file !== "string")
return;
if (FILES_CREATE_RESTART.includes(file))
return;
console.log(yellow(`
file ${file} changed, restarting...
`));
restartServer();
});
}
);
cli.command(
"build [entry..]",
"Build hostable SPA",
(args) => exportOptions(commonOptions(args)).option("out", {
alias: "o",
type: "string",
default: "dist",
describe: "output dir"
}).option("base", {
type: "string",
describe: "output base"
}).option("download", {
alias: "d",
type: "boolean",
describe: "allow download as PDF"
}).option("inspect", {
default: false,
type: "boolean",
describe: "enable the inspect plugin for debugging"
}).strict().help(),
async (args) => {
const { entry, theme, base, download, out, inspect } = args;
const { build } = await import("./build-N6BEUQKX.js");
for (const entryFile of entry) {
const options = await resolveOptions({ entry: entryFile, theme, inspect, download }, "build");
printInfo(options);
await build(
options,
{
base,
build: {
outDir: entry.length === 1 ? out : path.join(out, path.basename(entryFile, ".md"))
}
},
{ ...args, entry: entryFile }
);
}
}
);
cli.command(
"format [entry..]",
"Format the markdown file",
(args) => commonOptions(args).strict().help(),
async ({ entry }) => {
for (const entryFile of entry) {
const md = await parser.parse(await fs.readFile(entryFile, "utf-8"), entryFile);
parser.prettify(md);
await parser.save(md);
}
}
);
cli.command(
"theme [subcommand]",
"Theme related operations",
(command) => {
return command.command(
"eject",
"Eject current theme into local file system",
(args) => commonOptions(args).option("dir", {
type: "string",
default: "theme"
}),
async ({ entry: entryRaw, dir, theme: themeInput }) => {
const entry = await resolveEntry(entryRaw);
const roots = await getRoots(entry);
const data = await parser.load(roots.userRoot, entry);
let themeRaw = themeInput || data.headmatter.theme;
themeRaw = themeRaw === null ? "none" : themeRaw || "default";
if (themeRaw === "none") {
console.error('Cannot eject theme "none"');
process.exit(1);
}
if ("/.".includes(themeRaw[0]) || themeRaw[0] !== "@" && themeRaw.includes("/")) {
console.error("Theme is already ejected");
process.exit(1);
}
const [name, root] = await resolveTheme(themeRaw, entry);
await fs.copy(root, path.resolve(dir), {
filter: (i) => !/node_modules|.git/.test(path.relative(root, i))
});
const dirPath = `./${dir}`;
const firstSlide = data.entry.slides[0];
firstSlide.frontmatter.theme = dirPath;
parser.prettifySlide(firstSlide);
await parser.save(data.entry);
console.log(`Theme "${name}" ejected successfully to "${dirPath}"`);
}
);
},
() => {
cli.showHelp();
process.exit(1);
}
);
cli.command(
"export [entry..]",
"Export slides to PDF",
(args) => exportOptions(commonOptions(args)).strict().help(),
async (args) => {
const { entry, theme } = args;
const { exportSlides, getExportOptions } = await import("./export-I2C456L6.js");
const port = await getPort(12445);
let warned = false;
for (const entryFile of entry) {
const options = await resolveOptions({ entry: entryFile, theme }, "export");
if (options.data.config.browserExporter !== false && !warned) {
warned = true;
console.log(lightCyan("[Slidev] Try the new browser exporter!"));
console.log(
lightCyan("You can use the browser exporter instead by starting the dev server as normal and visit"),
`${blue("localhost:")}${dim("<port>")}${blue("/export")}
`
);
}
const server = await createServer(
options,
{
server: { port },
clearScreen: false
}
);
await server.listen(port);
printInfo(options);
const result = await exportSlides({
port,
...getExportOptions({ ...args, entry: entryFile }, options)
});
console.log(`${green(" \u2713 ")}${dim("exported to ")}./${result}
`);
server.close();
}
process.exit(0);
}
);
cli.command(
"export-notes [entry..]",
"Export slide notes to PDF",
(args) => args.positional("entry", {
default: "slides.md",
type: "string",
describe: "path to the slides markdown entry"
}).option("output", {
type: "string",
describe: "path to the output"
}).option("timeout", {
default: 3e4,
type: "number",
describe: "timeout for rendering the print page"
}).option("wait", {
default: 0,
type: "number",
describe: "wait for the specified ms before exporting"
}).strict().help(),
async ({
entry,
output,
timeout,
wait
}) => {
const { exportNotes } = await import("./export-I2C456L6.js");
const port = await getPort(12445);
for (const entryFile of entry) {
const options = await resolveOptions({ entry: entryFile }, "export");
const server = await createServer(
options,
{
server: { port },
clearScreen: false
}
);
await server.listen(port);
printInfo(options);
const result = await exportNotes({
port,
output: output || (options.data.config.exportFilename ? `${options.data.config.exportFilename}-notes` : `${path.basename(entryFile, ".md")}-export-notes`),
timeout,
wait
});
console.log(`${green(" \u2713 ")}${dim("exported to ")}./${result}
`);
server.close();
}
process.exit(0);
}
);
cli.help().parse();
function commonOptions(args) {
return args.positional("entry", {
default: "slides.md",
type: "string",
describe: "path to the slides markdown entry"
}).option("theme", {
alias: "t",
type: "string",
describe: "override theme"
});
}
function exportOptions(args) {
return args.option("output", {
type: "string",
describe: "path to the output"
}).option("format", {
type: "string",
choices: ["pdf", "png", "pptx", "md"],
describe: "output format"
}).option("timeout", {
type: "number",
describe: "timeout for rendering the print page"
}).option("wait", {
type: "number",
describe: "wait for the specified ms before exporting"
}).option("wait-until", {
type: "string",
choices: ["networkidle", "load", "domcontentloaded", "none"],
describe: "wait until the specified event before exporting each slide"
}).option("range", {
type: "string",
describe: 'page ranges to export, for example "1,4-5,6"'
}).option("dark", {
type: "boolean",
describe: "export as dark theme"
}).option("with-clicks", {
alias: "c",
type: "boolean",
describe: "export pages for every clicks"
}).option("executable-path", {
type: "string",
describe: "executable to override playwright bundled browser"
}).option("with-toc", {
type: "boolean",
describe: "export pages with outline"
}).option("per-slide", {
type: "boolean",
describe: "slide slides slide by slide. Works better with global components, but will break cross slide links and TOC in PDF"
}).option("scale", {
type: "number",
describe: "scale factor for image export"
}).option("omit-background", {
type: "boolean",
describe: "export png pages without the default browser background"
});
}
function printInfo(options, port, remote, tunnelUrl, publicIp) {
console.log();
console.log();
console.log(` ${cyan("\u25CF") + blue("\u25A0") + yellow("\u25B2")}`);
console.log(`${bold(" Slidev")} ${blue(`v${version}`)} ${isInstalledGlobally.value ? yellow("(global)") : ""}`);
console.log();
verifyConfig(options.data.config, options.data.themeMeta, (v) => console.warn(yellow(` ! ${v}`)));
console.log(dim(" theme ") + (options.theme ? green(options.theme) : gray("none")));
console.log(dim(" css engine ") + blue("unocss"));
console.log(dim(" entry ") + dim(path.normalize(path.dirname(options.entry)) + path.sep) + path.basename(options.entry));
if (port) {
const query = remote ? `?password=${remote}` : "";
const presenterPath = `${options.data.config.routerMode === "hash" ? "/#/" : "/"}presenter/${query}`;
const entryPath = `${options.data.config.routerMode === "hash" ? "/#/" : "/"}entry${query}/`;
const overviewPath = `${options.data.config.routerMode === "hash" ? "/#/" : "/"}overview${query}/`;
console.log();
console.log(`${dim(" public slide show ")} > ${cyan(`http://localhost:${bold(port)}/`)}`);
if (query)
console.log(`${dim(" private slide show ")} > ${cyan(`http://localhost:${bold(port)}/${query}`)}`);
if (options.utils.define.__SLIDEV_FEATURE_PRESENTER__)
console.log(`${dim(" presenter mode ")} > ${blue(`http://localhost:${bold(port)}${presenterPath}`)}`);
console.log(`${dim(" slides overview ")} > ${blue(`http://localhost:${bold(port)}${overviewPath}`)}`);
if (options.utils.define.__SLIDEV_FEATURE_BROWSER_EXPORTER__)
console.log(`${dim(" export slides")} > ${blue(`http://localhost:${bold(port)}/export/`)}`);
if (options.inspect)
console.log(`${dim(" vite inspector")} > ${yellow(`http://localhost:${bold(port)}/__inspect/`)}`);
let lastRemoteUrl = "";
if (remote !== void 0) {
Object.values(os.networkInterfaces()).forEach((v) => (v || []).filter((details) => String(details.family).slice(-1) === "4" && !details.address.includes("127.0.0.1")).forEach(({ address }) => {
lastRemoteUrl = `http://${address}:${port}${entryPath}`;
console.log(`${dim(" remote control ")} > ${blue(lastRemoteUrl)}`);
}));
if (publicIp) {
lastRemoteUrl = `http://${publicIp}:${port}${entryPath}`;
console.log(`${dim(" remote control ")} > ${blue(lastRemoteUrl)}`);
}
if (tunnelUrl) {
lastRemoteUrl = `${tunnelUrl}${entryPath}`;
console.log(`${dim(" remote via tunnel")} > ${yellow(lastRemoteUrl)}`);
}
} else {
console.log(`${dim(" remote control ")} > ${dim("pass --remote to enable")}`);
}
console.log();
console.log(`${dim(" shortcuts ")} > ${underline("r")}${dim("estart | ")}${underline("o")}${dim("pen | ")}${underline("e")}${dim("dit | ")}${underline("q")}${dim("uit")}${lastRemoteUrl ? ` | ${dim("qr")}${underline("c")}${dim("ode")}` : ""}`);
return lastRemoteUrl;
}
}
+527
View File
@@ -0,0 +1,527 @@
import {
getRoots
} from "./chunk-UNQ5DBLZ.js";
// node/commands/export.ts
import { Buffer } from "node:buffer";
import path from "node:path";
import process from "node:process";
import { clearUndefined, slash } from "@antfu/utils";
import { outlinePdfFactory } from "@lillallol/outline-pdf";
import { parseRangeString } from "@slidev/parser/core";
import { Presets, SingleBar } from "cli-progress";
import fs from "fs-extra";
import { blue, cyan, dim, green, yellow } from "kolorist";
import { resolve } from "mlly";
import * as pdfLib from "pdf-lib";
import { PDFDocument } from "pdf-lib";
function addToTree(tree, info, slideIndexes, level = 1) {
const titleLevel = info.level;
if (titleLevel && titleLevel > level && tree.length > 0 && tree[tree.length - 1].titleLevel < titleLevel) {
addToTree(tree[tree.length - 1].children, info, slideIndexes, level + 1);
} else {
tree.push({
no: info.index,
children: [],
level,
titleLevel: titleLevel ?? level,
path: String(slideIndexes[info.index + 1]),
hideInToc: Boolean(info.frontmatter?.hideInToc),
title: info.title
});
}
}
function makeOutline(tree) {
return tree.map(({ title, path: path2, level, children }) => {
const rootOutline = title ? `${path2}|${"-".repeat(level - 1)}|${title}` : null;
const childrenOutline = makeOutline(children);
return childrenOutline.length > 0 ? `${rootOutline}
${childrenOutline}` : rootOutline;
}).filter((outline) => !!outline).join("\n");
}
function createSlidevProgress(indeterminate = false) {
function getSpinner(n = 0) {
return [cyan("\u25CF"), green("\u25C6"), blue("\u25A0"), yellow("\u25B2")][n % 4];
}
let current = 0;
let spinner = 0;
let timer;
const progress = new SingleBar({
clearOnComplete: true,
hideCursor: true,
format: ` {spin} ${yellow("rendering")}${indeterminate ? dim(yellow("...")) : " {bar} {value}/{total}"}`,
linewrap: false,
barsize: 30
}, Presets.shades_grey);
return {
bar: progress,
start(total) {
progress.start(total, 0, { spin: getSpinner(spinner) });
timer = setInterval(() => {
spinner += 1;
progress.update(current, { spin: getSpinner(spinner) });
}, 200);
},
update(v) {
current = v;
progress.update(v, { spin: getSpinner(spinner) });
},
stop() {
clearInterval(timer);
progress.stop();
}
};
}
async function exportNotes({
port = 18724,
base = "/",
output = "notes",
timeout = 3e4,
wait = 0
}) {
const { chromium } = await importPlaywright();
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const progress = createSlidevProgress(true);
progress.start(1);
if (!output.endsWith(".pdf"))
output = `${output}.pdf`;
await page.goto(`http://localhost:${port}${base}presenter/print`, { waitUntil: "networkidle", timeout });
await page.waitForLoadState("networkidle");
await page.emulateMedia({ media: "screen" });
if (wait)
await page.waitForTimeout(wait);
await page.pdf({
path: output,
margin: {
left: 0,
top: 0,
right: 0,
bottom: 0
},
printBackground: true,
preferCSSPageSize: true
});
progress.stop();
browser.close();
return output;
}
async function exportSlides({
port = 18724,
total = 0,
range,
format = "pdf",
output = "slides",
slides,
base = "/",
timeout = 3e4,
wait = 0,
dark = false,
routerMode = "history",
width = 1920,
height = 1080,
withClicks = false,
executablePath = void 0,
withToc = false,
perSlide = false,
scale = 1,
waitUntil,
omitBackground = false
}) {
const pages = parseRangeString(total, range);
const { chromium } = await importPlaywright();
const browser = await chromium.launch({
executablePath
});
const context = await browser.newContext({
viewport: {
width,
// Calculate height for every slides to be in the viewport to trigger the rendering of iframes (twitter, youtube...)
height: perSlide ? height : height * pages.length
},
deviceScaleFactor: scale
});
const page = await context.newPage();
const progress = createSlidevProgress(!perSlide);
async function go(no, clicks) {
const query = new URLSearchParams();
if (withClicks)
query.set("print", "clicks");
else
query.set("print", "true");
if (range)
query.set("range", range);
if (clicks)
query.set("clicks", clicks);
const url = routerMode === "hash" ? `http://localhost:${port}${base}?${query}#${no}` : `http://localhost:${port}${base}${no}?${query}`;
await page.goto(url, {
waitUntil,
timeout
});
if (waitUntil)
await page.waitForLoadState(waitUntil);
await page.emulateMedia({ colorScheme: dark ? "dark" : "light", media: "screen" });
const slide = no === "print" ? page.locator("body") : page.locator(`[data-slidev-no="${no}"]`);
await slide.waitFor();
{
const elements = slide.locator(".slidev-slide-loading");
const count = await elements.count();
for (let index = 0; index < count; index++)
await elements.nth(index).waitFor({ state: "detached" });
}
{
const elements = slide.locator("[data-waitfor]");
const count = await elements.count();
for (let index = 0; index < count; index++) {
const element = elements.nth(index);
const attribute = await element.getAttribute("data-waitfor");
if (attribute) {
await element.locator(attribute).waitFor({ state: "visible" }).catch((e) => {
console.error(e);
process.exitCode = 1;
});
}
}
}
{
const frames = page.frames();
await Promise.all(frames.map((frame) => frame.waitForLoadState()));
}
{
const container = slide.locator("#mermaid-rendering-container");
const count = await container.count();
if (count > 0) {
while (true) {
const element = container.locator("div").first();
if (await element.count() === 0)
break;
await element.waitFor({ state: "detached" });
}
await container.evaluate((node) => node.style.display = "none");
}
}
{
const elements = slide.locator(".monaco-aria-container");
const count = await elements.count();
for (let index = 0; index < count; index++) {
const element = elements.nth(index);
await element.evaluate((node) => node.style.display = "none");
}
}
if (wait)
await page.waitForTimeout(wait);
}
async function getSlidesIndex() {
const clicksBySlide = {};
const slides2 = page.locator(".print-slide-container");
const count = await slides2.count();
for (let i = 0; i < count; i++) {
const id = await slides2.nth(i).getAttribute("id") || "";
const path2 = Number(id.split("-")[0]);
clicksBySlide[path2] = (clicksBySlide[path2] || 0) + 1;
}
const slideIndexes = Object.fromEntries(Object.entries(clicksBySlide).reduce((acc, [path2, clicks], i) => {
acc.push([path2, clicks + (acc[i - 1]?.[1] ?? 0)]);
return acc;
}, []));
return slideIndexes;
}
function getClicksFromUrl(url) {
return url.match(/clicks=([1-9]\d*)/)?.[1];
}
async function genPageWithClicks(fn, no, clicks) {
await fn(no, clicks);
if (withClicks) {
await page.keyboard.press("ArrowRight", { delay: 100 });
const _clicks = getClicksFromUrl(page.url());
if (_clicks && clicks !== _clicks)
await genPageWithClicks(fn, no, _clicks);
}
}
async function genPagePdfPerSlide() {
const buffers = [];
const genPdfBuffer = async (i, clicks) => {
await go(i, clicks);
const pdf = await page.pdf({
width,
height,
margin: {
left: 0,
top: 0,
right: 0,
bottom: 0
},
pageRanges: "1",
printBackground: true,
preferCSSPageSize: true
});
buffers.push(pdf);
};
let idx = 0;
for (const i of pages) {
await genPageWithClicks(genPdfBuffer, i);
progress.update(++idx);
}
let mergedPdf = await PDFDocument.create({});
for (const pdfBytes of buffers) {
const pdf = await PDFDocument.load(pdfBytes);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach((page2) => {
mergedPdf.addPage(page2);
});
}
addPdfMetadata(mergedPdf);
if (withToc)
mergedPdf = await addTocToPdf(mergedPdf);
const buffer = await mergedPdf.save();
await fs.writeFile(output, buffer);
}
async function genPagePdfOnePiece() {
await go("print");
await page.pdf({
path: output,
width,
height,
margin: {
left: 0,
top: 0,
right: 0,
bottom: 0
},
printBackground: true,
preferCSSPageSize: true
});
let pdfData = await fs.readFile(output);
let pdf = await PDFDocument.load(pdfData);
addPdfMetadata(pdf);
if (withToc)
pdf = await addTocToPdf(pdf);
pdfData = Buffer.from(await pdf.save());
await fs.writeFile(output, pdfData);
}
async function genPagePngOnePiece(writeToDisk) {
const result = [];
await go("print");
await fs.emptyDir(output);
const slideContainers = page.locator(".print-slide-container");
const count = await slideContainers.count();
for (let i = 0; i < count; i++) {
progress.update(i + 1);
const id = await slideContainers.nth(i).getAttribute("id") || "";
const slideNo = +id.split("-")[0];
const buffer = await slideContainers.nth(i).screenshot({
omitBackground
});
result.push({ slideIndex: slideNo - 1, buffer });
if (writeToDisk)
await fs.writeFile(path.join(output, `${withClicks ? id : slideNo}.png`), buffer);
}
return result;
}
async function genPagePngPerSlide(writeToDisk) {
const result = [];
const genScreenshot = async (no, clicks) => {
await go(no, clicks);
const buffer = await page.screenshot({
omitBackground
});
result.push({ slideIndex: no - 1, buffer });
if (writeToDisk) {
await fs.writeFile(
path.join(output, `${no.toString().padStart(2, "0")}${clicks ? `-${clicks}` : ""}.png`),
buffer
);
}
};
for (const no of pages)
await genPageWithClicks(genScreenshot, no);
return result;
}
function genPagePdf() {
if (!output.endsWith(".pdf"))
output = `${output}.pdf`;
return perSlide ? genPagePdfPerSlide() : genPagePdfOnePiece();
}
function genPagePng(writeToDisk = true) {
return perSlide ? genPagePngPerSlide(writeToDisk) : genPagePngOnePiece(writeToDisk);
}
async function genPageMd() {
const files = await fs.readdir(output);
const mds = files.map((file, i, files2) => {
const slideIndex = getSlideIndex(file);
const mdImg = `![${slides[slideIndex]?.title}](./${slash(path.join(output, file))})
`;
if ((i + 1 === files2.length || getSlideIndex(files2[i + 1]) !== slideIndex) && slides[slideIndex]?.note)
return `${mdImg}${slides[slideIndex]?.note}
`;
return mdImg;
});
if (!output.endsWith(".md"))
output = `${output}.md`;
await fs.writeFile(output, mds.join(""));
}
async function genPagePptx(pngs) {
const { default: PptxGenJS } = await import("pptxgenjs");
const pptx = new PptxGenJS();
const layoutName = `${width}x${height}`;
pptx.defineLayout({
name: layoutName,
width: width / 96,
height: height / 96
});
pptx.layout = layoutName;
const titleSlide = slides[0];
pptx.author = titleSlide?.frontmatter?.author;
pptx.company = "Created using Slidev";
if (titleSlide?.title)
pptx.title = titleSlide?.title;
if (titleSlide?.frontmatter?.info)
pptx.subject = titleSlide?.frontmatter?.info;
pngs.forEach(({ slideIndex, buffer: buffer2 }) => {
const slide = pptx.addSlide();
slide.background = {
data: `data:image/png;base64,${buffer2.toString("base64")}`
};
const note = slides[slideIndex].note;
if (note)
slide.addNotes(note);
});
const buffer = await pptx.write({
outputType: "nodebuffer"
});
if (!output.endsWith(".pptx"))
output = `${output}.pptx`;
await fs.writeFile(output, buffer);
}
function getSlideIndex(file) {
const slideId = file.substring(0, file.indexOf(".")).split("-")[0];
return Number(slideId) - 1;
}
function addPdfMetadata(pdf) {
const titleSlide = slides[0];
if (titleSlide?.title)
pdf.setTitle(titleSlide.title);
if (titleSlide?.frontmatter?.info)
pdf.setSubject(titleSlide.frontmatter.info);
if (titleSlide?.frontmatter?.author)
pdf.setAuthor(titleSlide.frontmatter.author);
if (titleSlide?.frontmatter?.keywords) {
if (Array.isArray(titleSlide?.frontmatter?.keywords))
pdf.setKeywords(titleSlide?.frontmatter?.keywords);
else
pdf.setKeywords(titleSlide?.frontmatter?.keywords.split(","));
}
}
async function addTocToPdf(pdf) {
const outlinePdf = outlinePdfFactory(pdfLib);
const slideIndexes = await getSlidesIndex();
const tocTree = slides.filter((slide) => slide.title).reduce((acc, slide) => {
addToTree(acc, slide, slideIndexes);
return acc;
}, []);
const outline = makeOutline(tocTree);
return await outlinePdf({ outline, pdf });
}
progress.start(pages.length);
if (format === "pdf") {
await genPagePdf();
} else if (format === "png") {
await genPagePng();
} else if (format === "md") {
await genPagePng();
await genPageMd();
} else if (format === "pptx") {
const buffers = await genPagePng(false);
await genPagePptx(buffers);
} else {
throw new Error(`Unsupported exporting format "${format}"`);
}
progress.stop();
browser.close();
return output;
}
function getExportOptions(args, options, outDir, outFilename) {
const config = {
...options.data.config.export,
...args,
...clearUndefined({
waitUntil: args["wait-until"],
withClicks: args["with-clicks"],
executablePath: args["executable-path"],
withToc: args["with-toc"],
perSlide: args["per-slide"],
omitBackground: args["omit-background"]
})
};
const {
entry,
output,
format,
timeout,
wait,
waitUntil,
range,
dark,
withClicks,
executablePath,
withToc,
perSlide,
scale,
omitBackground
} = config;
outFilename = output || options.data.config.exportFilename || outFilename || `${path.basename(entry, ".md")}-export`;
if (outDir)
outFilename = path.join(outDir, outFilename);
return {
output: outFilename,
slides: options.data.slides,
total: options.data.slides.length,
range,
format: format || "pdf",
timeout: timeout ?? 3e4,
wait: wait ?? 0,
waitUntil: waitUntil === "none" ? void 0 : waitUntil ?? "networkidle",
dark: dark || options.data.config.colorSchema === "dark",
routerMode: options.data.config.routerMode,
width: options.data.config.canvasWidth,
height: Math.round(options.data.config.canvasWidth / options.data.config.aspectRatio),
withClicks: withClicks ?? format === "pptx",
executablePath,
withToc: withToc || false,
perSlide: perSlide || false,
scale: scale || 2,
omitBackground: omitBackground ?? false
};
}
async function importPlaywright() {
const { userRoot, userWorkspaceRoot } = await getRoots();
try {
return await import(await resolve("playwright-chromium", { url: userRoot }));
} catch {
}
if (userWorkspaceRoot !== userRoot) {
try {
return await import(await resolve("playwright-chromium", { url: userWorkspaceRoot }));
} catch {
}
}
const { resolveGlobal } = await import("resolve-global");
try {
const imported = await import(resolveGlobal("playwright-chromium"));
return imported.default ?? imported;
} catch {
}
try {
return await import("playwright-chromium");
} catch {
}
throw new Error("The exporting for Slidev is powered by Playwright, please install it via `npm i -D playwright-chromium`");
}
export {
exportNotes,
exportSlides,
getExportOptions
};
+14
View File
@@ -0,0 +1,14 @@
import * as vite from 'vite';
import { InlineConfig, PluginOption } from 'vite';
import { ResolvedSlidevOptions, SlidevServerOptions, SlidevEntryOptions, ResolvedSlidevUtils, SlidevPluginOptions } from '@slidev/types';
import * as fs from '@slidev/parser/fs';
export { fs as parser };
declare function createServer(options: ResolvedSlidevOptions, viteConfig?: InlineConfig, serverOptions?: SlidevServerOptions): Promise<vite.ViteDevServer>;
declare function resolveOptions(entryOptions: SlidevEntryOptions, mode: ResolvedSlidevOptions['mode']): Promise<ResolvedSlidevOptions>;
declare function createDataUtils(resolved: Omit<ResolvedSlidevOptions, 'utils'>): Promise<ResolvedSlidevUtils>;
declare function ViteSlidevPlugin(options: ResolvedSlidevOptions, pluginOptions?: SlidevPluginOptions, serverOptions?: SlidevServerOptions): Promise<PluginOption[]>;
export { ViteSlidevPlugin, createDataUtils, createServer, resolveOptions };
+17
View File
@@ -0,0 +1,17 @@
import {
createServer
} from "./chunk-UVOSJW3S.js";
import {
ViteSlidevPlugin,
createDataUtils,
parser,
resolveOptions
} from "./chunk-7E4FQJTV.js";
import "./chunk-UNQ5DBLZ.js";
export {
ViteSlidevPlugin,
createDataUtils,
createServer,
parser,
resolveOptions
};
+126
View File
@@ -0,0 +1,126 @@
{
"name": "@slidev/cli",
"type": "module",
"version": "0.50.0",
"description": "Presentation slides for developers",
"author": "antfu <anthonyfu117@hotmail.com>",
"license": "MIT",
"funding": "https://github.com/sponsors/antfu",
"homepage": "https://sli.dev",
"repository": {
"type": "git",
"url": "https://github.com/slidevjs/slidev"
},
"bugs": "https://github.com/slidevjs/slidev/issues",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./*": "./*"
},
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"slidev": "./bin/slidev.mjs"
},
"files": [
"bin",
"dist",
"template.md"
],
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"playwright-chromium": "^1.10.0"
},
"peerDependenciesMeta": {
"playwright-chromium": {
"optional": true
}
},
"dependencies": {
"@antfu/ni": "^0.23.1",
"@antfu/utils": "^0.7.10",
"@iconify-json/carbon": "^1.2.5",
"@iconify-json/ph": "^1.2.2",
"@iconify-json/svg-spinners": "^1.2.2",
"@lillallol/outline-pdf": "^4.0.0",
"@shikijs/markdown-it": "^1.24.2",
"@shikijs/twoslash": "^1.24.2",
"@shikijs/vitepress-twoslash": "^1.24.2",
"@unocss/extractor-mdc": "^0.65.1",
"@unocss/reset": "^0.65.1",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"chokidar": "^4.0.2",
"cli-progress": "^3.12.0",
"connect": "^3.7.0",
"debug": "^4.4.0",
"fast-deep-equal": "^3.1.3",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
"get-port-please": "^3.1.2",
"global-directory": "^4.0.1",
"htmlparser2": "^9.1.0",
"is-installed-globally": "^1.0.0",
"jiti": "^2.4.2",
"katex": "^0.16.17",
"kolorist": "^1.8.0",
"local-pkg": "^0.5.1",
"lz-string": "^1.5.0",
"magic-string": "^0.30.17",
"magic-string-stack": "^0.1.1",
"markdown-it": "^14.1.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-mdc": "^0.2.5",
"micromatch": "^4.0.8",
"mlly": "^1.7.3",
"monaco-editor": "0.51.0",
"open": "^10.1.0",
"pdf-lib": "^1.17.1",
"plantuml-encoder": "^1.4.0",
"postcss-nested": "^7.0.2",
"pptxgenjs": "^3.12.0",
"prompts": "^2.4.2",
"public-ip": "^7.0.1",
"resolve-from": "^5.0.0",
"resolve-global": "^2.0.0",
"semver": "^7.6.3",
"shiki": "^1.24.2",
"shiki-magic-move": "^0.5.2",
"sirv": "^3.0.0",
"source-map-js": "^1.2.1",
"typescript": "5.6.3",
"unocss": "^0.65.1",
"unplugin-icons": "^0.22.0",
"unplugin-vue-components": "^0.28.0",
"unplugin-vue-markdown": "^0.28.0",
"untun": "^0.1.3",
"uqr": "^0.1.2",
"vite": "^6.0.3",
"vite-plugin-inspect": "^0.10.3",
"vite-plugin-remote-assets": "^0.6.0",
"vite-plugin-static-copy": "^2.2.0",
"vite-plugin-vue-server-ref": "^0.4.2",
"vitefu": "^1.0.4",
"vue": "^3.5.13",
"yaml": "^2.6.1",
"yargs": "^17.7.2",
"@slidev/client": "0.50.0",
"@slidev/parser": "0.50.0",
"@slidev/types": "0.50.0"
},
"devDependencies": {
"@hedgedoc/markdown-it-plugins": "^2.1.4",
"@types/micromatch": "^4.0.9",
"@types/plantuml-encoder": "^1.4.2"
},
"scripts": {
"build": "rimraf dist && tsup node/index.ts node/cli.ts",
"dev": "nr build --watch",
"start": "esno node/cli.ts"
}
}
+433
View File
@@ -0,0 +1,433 @@
---
# try also 'default' to start simple
theme: seriph
# random image from a curated Unsplash collection by Anthony
# like them? see https://unsplash.com/collections/94734566/slidev
background: https://cover.sli.dev
# apply any unocss classes to the current slide
class: 'text-center'
# some information about the slides, markdown enabled
info: |
## Slidev Starter Template
Presentation slides for developers.
Learn more at [Sli.dev](https://sli.dev)
transition: slide-left
title: Welcome to Slidev
mdc: true
---
# Welcome to Slidev
Presentation slides for developers
<div class="pt-12">
<span @click="$slidev.nav.next" class="px-2 py-1 rounded cursor-pointer" flex="~ justify-center items-center gap-2" hover="bg-white bg-opacity-10">
Press Space for next page <div class="i-carbon:arrow-right inline-block"/>
</span>
</div>
<div class="abs-br m-6 flex gap-2">
<button @click="$slidev.nav.openInEditor()" title="Open in Editor" class="text-xl slidev-icon-btn opacity-50 !border-none !hover:text-white">
<div class="i-carbon:edit" />
</button>
<a href="https://github.com/slidevjs/slidev" target="_blank" alt="GitHub" title="Open in GitHub"
class="text-xl slidev-icon-btn opacity-50 !border-none !hover:text-white">
<carbon-logo-github />
</a>
</div>
<!--
The last comment block of each slide will be treated as slide notes. It will be visible and editable in Presenter Mode along with the slide. [Read more in the docs](https://sli.dev/guide/syntax.html#notes)
-->
---
transition: fade-out
---
# What is Slidev?
Slidev is a slide maker and accompanying presentation tool designed for developers. It consists of the following features:
- 📝 **Text-based** - focus on the content with Markdown, and apply styles later
- 🎨 **Themable** - themes can be shared and used as npm packages
- 🧑‍💻 **Developer Friendly** - code highlighting, live coding with autocompletion
- 🤹 **Interactive** - embedding Vue components to enhance your slides
- 🎥 **Recording** - built-in recording and camera view
- 📤 **Portable** - export to PDF, PPTX, PNGs, or even a hostable SPA
- 🛠 **Hackable** - virtually anything that's possible on a webpage is possible in Slidev
<br>
<br>
Read more about Slidev in [Why Slidev?](https://sli.dev/guide/why)
<!--
You can have `style` tags in markdown to override the style for the current page.
Learn more: https://sli.dev/guide/syntax#embedded-styles
-->
<style>
h1 {
background-color: #2B90B6;
background-image: linear-gradient(45deg, #4EC5D4 10%, #146b8c 20%);
background-size: 100%;
-webkit-background-clip: text;
-moz-background-clip: text;
-webkit-text-fill-color: transparent;
-moz-text-fill-color: transparent;
}
</style>
<!--
Here is another comment.
-->
---
layout: default
---
# Table of contents
```html
<Toc minDepth="1" maxDepth="1"></Toc>
```
<Toc maxDepth="1"></Toc>
---
transition: slide-up
level: 2
---
# Navigation
Hover on the bottom-left corner to see the navigation's control panel, [learn more](https://sli.dev/guide/navigation.html)
## Keyboard Shortcuts
| | |
| --- | --- |
| <kbd>right</kbd> / <kbd>space</kbd>| next animation or slide |
| <kbd>left</kbd> / <kbd>shift</kbd><kbd>space</kbd> | previous animation or slide |
| <kbd>up</kbd> | previous slide |
| <kbd>down</kbd> | next slide |
<!-- https://sli.dev/guide/animations.html#click-animation -->
<img
v-click
class="absolute -bottom-9 -left-7 w-80 opacity-50"
src="https://sli.dev/assets/arrow-bottom-left.svg"
alt=""
/>
<p v-after class="absolute bottom-23 left-45 opacity-30 transform -rotate-10">Here!</p>
---
layout: image-right
image: https://cover.sli.dev
---
# Code
Use code snippets and get automatic highlighting, and even types hover![^1]
```ts {all|5|7|7-8|10|all} twoslash
// TwoSlash enables TypeScript hover information
// and errors in markdown code blocks
// More at https://shiki.style/packages/twoslash
import { computed, ref } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
doubled.value = 2
```
<arrow v-click="[4, 5]" x1="350" y1="310" x2="195" y2="334" color="#953" width="2" arrowSize="1" />
<!-- This allow you to embed external code blocks -->
<!-- <<< @/snippets/external.ts#snippet -->
<!-- Footer -->
[^1]: [Learn More](https://sli.dev/guide/syntax.html#line-highlighting)
<!-- Inline style -->
<style>
.footnotes-sep {
@apply mt-5 opacity-10;
}
.footnotes {
@apply text-sm opacity-75;
}
.footnote-backref {
display: none;
}
</style>
---
# Components
<div grid="~ cols-2 gap-4">
<div>
You can use Vue components directly inside your slides.
We have provided a few built-in components like `<Tweet/>` and `<Youtube/>` that you can use directly. Adding your own custom components is also super easy.
```html
<Counter :count="10" />
```
<!-- ./components/Counter.vue -->
<Counter :count="10" m="t-4" />
Check out [the guides](https://sli.dev/builtin/components.html) for more.
</div>
<div>
```html
<Tweet id="1390115482657726468" />
```
<Tweet id="1390115482657726468" scale="0.65" />
</div>
</div>
<!--
Presenter notes with **bold**, *italic*, and ~~strike~~ text.
Also, HTML elements are valid:
<div class="flex w-full">
<span style="flex-grow: 1;">Left content</span>
<span>Right content</span>
</div>
-->
---
class: px-20
---
# Themes
Slidev comes with powerful theming support. Themes can provide styles, layouts, components, or even configurations for tools. Switch between themes on a per-slide basis with just **one change** in your frontmatter:
<div grid="~ cols-2 gap-2" m="t-2">
```yaml
---
theme: default
---
```
```yaml
---
theme: seriph
---
```
<img border="rounded" src="https://github.com/slidevjs/themes/blob/main/screenshots/theme-default/01.png?raw=true" alt="">
<img border="rounded" src="https://github.com/slidevjs/themes/blob/main/screenshots/theme-seriph/01.png?raw=true" alt="">
</div>
Read more about [How to use a theme](https://sli.dev/guide/theme-addon#use-theme) and
check out the [Awesome Themes Gallery](https://sli.dev/resources/theme-gallery).
---
preload: false
---
# Animations
Animations are powered by [@vueuse/motion](https://motion.vueuse.org/).
```html
<div
v-motion
:initial="{ x: -80 }"
:enter="{ x: 0 }">
Slidev
</div>
```
<div class="w-60 relative mt-6">
<div class="relative w-40 h-40">
<img
v-motion
:initial="{ x: 800, y: -100, scale: 1.5, rotate: -50 }"
:enter="final"
class="absolute top-0 left-0 right-0 bottom-0"
src="https://sli.dev/logo-square.png"
alt=""
/>
<img
v-motion
:initial="{ y: 500, x: -100, scale: 2 }"
:enter="final"
class="absolute top-0 left-0 right-0 bottom-0"
src="https://sli.dev/logo-circle.png"
alt=""
/>
<img
v-motion
:initial="{ x: 600, y: 400, scale: 2, rotate: 100 }"
:enter="final"
class="absolute top-0 left-0 right-0 bottom-0"
src="https://sli.dev/logo-triangle.png"
alt=""
/>
</div>
<div
class="text-5xl absolute top-14 left-40 text-[#2B90B6] -z-1"
v-motion
:initial="{ x: -80, opacity: 0}"
:enter="{ x: 0, opacity: 1, transition: { delay: 2000, duration: 1000 } }">
Slidev
</div>
</div>
<!-- vue script setup scripts can be directly used in markdown, and will only affect the current page -->
<script setup lang="ts">
const final = {
x: 0,
y: 0,
rotate: 0,
scale: 1,
transition: {
type: 'spring',
damping: 10,
stiffness: 20,
mass: 2
}
}
</script>
<div
v-motion
:initial="{ x:35, y: 40, opacity: 0}"
:enter="{ y: 0, opacity: 1, transition: { delay: 3500 } }">
[Learn More](https://sli.dev/guide/animations.html#motion)
</div>
---
# LaTeX
LaTeX is supported out-of-box powered by [KaTeX](https://katex.org/).
<br>
Inline $\sqrt{3x-1}+(1+x)^2$
Block
$$ {1|3|all}
\begin{array}{c}
\nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} &
= \frac{4\pi}{c}\vec{\mathbf{j}} \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\
\nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\
\nabla \cdot \vec{\mathbf{B}} & = 0
\end{array}
$$
<br>
[Learn more](https://sli.dev/guide/syntax#latex)
---
# Diagrams
You can create diagrams / graphs from textual descriptions, directly in your Markdown.
<div class="grid grid-cols-4 gap-5 pt-4 -mb-6">
```mermaid {scale: 0.5, alt: 'A simple sequence diagram'}
sequenceDiagram
Alice->John: Hello John, how are you?
Note over Alice,John: A typical interaction
```
```mermaid {theme: 'neutral', scale: 0.8}
graph TD
B[Text] --> C{Decision}
C -->|One| D[Result 1]
C -->|Two| E[Result 2]
```
```mermaid
mindmap
root((mindmap))
Origins
Long history
::icon(fa fa-book)
Popularisation
British popular psychology author Tony Buzan
Research
On effectivness<br/>and features
On Automatic creation
Uses
Creative techniques
Strategic planning
Argument mapping
Tools
Pen and paper
Mermaid
```
```plantuml {scale: 0.7}
@startuml
package "Some Group" {
HTTP - [First Component]
[Another Component]
}
node "Other Groups" {
FTP - [Second Component]
[First Component] --> FTP
}
cloud {
[Example 1]
}
database "MySql" {
folder "This is my folder" {
[Folder 3]
}
frame "Foo" {
[Frame 4]
}
}
[Another Component] --> [Example 1]
[Example 1] --> [Folder 3]
[Folder 3] --> [Frame 4]
@enduml
```
</div>
[Learn More](https://sli.dev/guide/syntax.html#diagrams)
---
layout: center
class: text-center
---
# Learn More
[Documentation](https://sli.dev) · [GitHub](https://github.com/slidevjs/slidev) · [Showcases](https://sli.dev/showcases.html)
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { watchEffect } from 'vue'
import { themeVars } from './env'
import setupRoot from './setup/root'
setupRoot()
watchEffect(() => {
for (const [key, value] of Object.entries(themeVars.value))
document.body.style.setProperty(key, value.toString())
})
</script>
<template>
<RouterView />
</template>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-PRESENT Anthony Fu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+9
View File
@@ -0,0 +1,9 @@
# @slidev/client
[![NPM version](https://img.shields.io/npm/v/@slidev/client?color=3AB9D4&label=)](https://www.npmjs.com/package/@slidev/client)
Client code for [Slidev](https://sli.dev). Shipped with [`@slidev/cli`](https://www.npmjs.com/package/@slidev/cli).
## License
MIT License © 2021 [Anthony Fu](https://github.com/antfu)
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+60
View File
@@ -0,0 +1,60 @@
<svg width="115" height="115" viewBox="0 0 115 115" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<g filter="url(#filter0_d)">
<rect x="14" y="14" width="90" height="90" rx="15" fill="url(#paint0_linear)"/>
</g>
<g filter="url(#filter1_d)">
<rect width="90" height="90" rx="45" fill="url(#paint1_linear)"/>
</g>
<g filter="url(#filter2_d)">
<path d="M56.127 63.925C54.9501 59.5327 54.3617 57.3366 54.9439 55.8199C55.4517 54.497 56.497 53.4517 57.8199 52.9439C59.3366 52.3617 61.5327 52.9501 65.925 54.127L87.7563 59.9767C92.1485 61.1536 94.3446 61.742 95.367 63.0046C96.2588 64.1058 96.6414 65.5338 96.4197 66.9334C96.1656 68.5379 94.5579 70.1456 91.3426 73.3609L75.3609 89.3426C72.1456 92.5579 70.5379 94.1656 68.9333 94.4197C67.5337 94.6414 66.1058 94.2588 65.0046 93.367C63.742 92.3446 63.1536 90.1485 61.9767 85.7563L56.127 63.925Z" fill="url(#paint2_linear)"/>
</g>
</g>
<defs>
<filter id="filter0_d" x="8" y="8" width="110" height="110" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="4" dy="4"/>
<feGaussianBlur stdDeviation="5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter1_d" x="-6" y="-6" width="110" height="110" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="4" dy="4"/>
<feGaussianBlur stdDeviation="5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter2_d" x="34.8833" y="32.8833" width="77.6614" height="77.6614" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="4" dy="4"/>
<feGaussianBlur stdDeviation="5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<linearGradient id="paint0_linear" x1="14" y1="6" x2="104" y2="104" gradientUnits="userSpaceOnUse">
<stop stop-color="#3ACBD4"/>
<stop offset="1" stop-color="#2988B1"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="-9.5" y1="-11" x2="76.0825" y2="90" gradientUnits="userSpaceOnUse">
<stop stop-color="#95F0CF"/>
<stop offset="1" stop-color="#3AB9D5"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="54.6616" y1="49.3453" x2="59.8793" y2="96.3585" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEB83"/>
<stop offset="0.0001" stop-color="#FFEB83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="0.601773" stop-color="#FFBB13"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
<clipPath id="clip0">
<rect width="115" height="115" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+72
View File
@@ -0,0 +1,72 @@
<!--
Simple Arrow
<arrow x1="10" y1="20" x2="100" y2="200" color="green" width="3" />
<arrow v-bind="{ x1:10, y1:10, x2:200, y2:200 }"/>
-->
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { ref } from 'vue'
import { makeId } from '../logic/utils'
defineProps<{
x1: number | string
y1: number | string
x2: number | string
y2: number | string
width?: number | string
color?: string
twoWay?: boolean
}>()
const emit = defineEmits(['dblclick', 'clickOutside'])
const id = makeId()
const markerAttrs = {
markerUnits: 'strokeWidth',
markerHeight: 7,
refY: 3.5,
orient: 'auto',
}
const clickArea = ref<HTMLElement>()
onClickOutside(clickArea, () => emit('clickOutside'))
</script>
<template>
<svg
class="absolute left-0 top-0"
:width="Math.max(+x1, +x2) + 50"
:height="Math.max(+y1, +y2) + 50"
>
<defs>
<marker :id="id" markerWidth="10" refX="9" v-bind="markerAttrs">
<polygon points="0 0, 10 3.5, 0 7" :fill="color || 'currentColor'" @dblclick="emit('dblclick')" />
</marker>
<marker v-if="twoWay" :id="`${id}-rev`" markerWidth="20" refX="11" v-bind="markerAttrs">
<polygon points="20 0, 10 3.5, 20 7" :fill="color || 'currentColor'" @dblclick="emit('dblclick')" />
</marker>
</defs>
<line
:x1 :y1 :x2 :y2
:stroke="color || 'currentColor'"
:stroke-width="width || 2"
:marker-end="`url(#${id})`"
:marker-start="twoWay ? `url(#${id}-rev)` : 'none'"
@dblclick="emit('dblclick')"
/>
<line
ref="clickArea"
:x1 :y1 :x2 :y2
stroke="transparent"
stroke-linecap="round"
:stroke-width="20"
@dblclick="emit('dblclick')"
/>
</svg>
</template>
+88
View File
@@ -0,0 +1,88 @@
<!--
[Experimental]
Think this component as the TextBox you that will see
in PowerPoint or Keynote. It will automatically resize
the font size based on it's content to fit them in.
Usage:
<AutoFitText modelValue="text"/>
or
<AutoFitText :max="80" :min="100" v-model="text"/>
-->
<script setup lang="ts">
import { useElementSize, useVModel } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
const props = defineProps({
modelValue: {
default: '',
},
max: {
default: 100,
},
min: {
default: 30,
},
})
const emit = defineEmits<{
(e: any): void
}>()
const container = ref<HTMLDivElement>()
const inner = ref<HTMLDivElement>()
const size = ref(100)
const fontSize = computed(() => `${size.value}px`)
const value = useVModel(props, 'modelValue', emit)
const containerSize = useElementSize(container)
const innerSize = useElementSize(inner)
const wrapLen = ref(0)
const wrap = ref('nowrap')
watch([container, value, containerSize.width, innerSize.width], async () => {
if (!container.value || innerSize.width.value <= 0)
return
const ratio = containerSize.width.value / innerSize.width.value
if (Number.isNaN(ratio) || ratio <= 0)
return
let newSize = size.value * (containerSize.width.value / innerSize.width.value)
if (newSize < props.min) {
wrapLen.value = value.value.length
wrap.value = ''
}
else {
if (value.value.length < wrapLen.value)
wrap.value = 'nowrap'
}
newSize = Math.max(props.min, Math.min(props.max, newSize))
size.value = newSize
})
</script>
<template>
<div ref="container" class="slidev-auto-fit-text">
<div ref="inner" class="slidev-auto-fit-text-inner">
<slot>
{{ value }}
</slot>
</div>
</div>
</template>
<style scoped>
.slidev-auto-fit-text {
overflow: auto;
font-size: v-bind(fontSize);
white-space: v-bind(wrap);
}
.slidev-auto-fit-text-inner {
display: inline-block;
}
</style>
+143
View File
@@ -0,0 +1,143 @@
<!--
Line highlighting for code blocks.
(auto transformed, you don't need to use this component directly)
Usage:
```ts {1,3-5|2,4}
const your_code = 'here'
```
Learn more: https://sli.dev/guide/syntax.html#line-highlighting
-->
<script setup lang="ts">
import type { PropType } from 'vue'
import { useClipboard } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { CLASS_VCLICK_HIDDEN, CLICKS_MAX } from '../constants'
import { useSlideContext } from '../context'
import { configs } from '../env'
import { makeId, updateCodeHighlightRange } from '../logic/utils'
const props = defineProps({
ranges: {
type: Array as PropType<string[]>,
default: () => [],
},
finally: {
type: [String, Number],
default: 'last',
},
startLine: {
type: Number,
default: 1,
},
lines: {
type: Boolean,
default: configs.lineNumbers,
},
at: {
type: [String, Number],
default: '+1',
},
maxHeight: {
type: String,
default: undefined,
},
})
const { $clicksContext: clicks } = useSlideContext()
const el = ref<HTMLDivElement>()
const id = makeId()
onUnmounted(() => {
clicks!.unregister(id)
})
watchEffect(() => {
el.value?.classList.toggle('slidev-code-line-numbers', props.lines)
})
onMounted(() => {
if (!clicks || !props.ranges?.length)
return
const clicksInfo = clicks.calculateSince(props.at, props.ranges.length - 1)
clicks.register(id, clicksInfo)
const index = computed(() => clicksInfo ? Math.max(0, clicks.current - clicksInfo.start + 1) : CLICKS_MAX)
const finallyRange = computed(() => {
return props.finally === 'last' ? props.ranges.at(-1) : props.finally.toString()
})
watchEffect(() => {
if (!el.value)
return
let rangeStr = props.ranges[index.value] ?? finallyRange.value
const hide = rangeStr === 'hide'
el.value.classList.toggle(CLASS_VCLICK_HIDDEN, hide)
if (hide)
rangeStr = props.ranges[index.value + 1] ?? finallyRange.value
const pre = el.value.querySelector('.shiki')!
const lines = Array.from(pre.querySelectorAll('code > .line'))
const linesCount = lines.length
updateCodeHighlightRange(
rangeStr,
linesCount,
props.startLine,
no => [lines[no]],
)
// Scroll to the highlighted line if `maxHeight` is set
if (props.maxHeight) {
const highlightedEls = Array.from(pre.querySelectorAll('.line.highlighted')) as HTMLElement[]
const height = highlightedEls.reduce((acc, el) => el.offsetHeight + acc, 0)
if (height > el.value.offsetHeight) {
highlightedEls[0].scrollIntoView({ behavior: 'smooth', block: 'start' })
}
else if (highlightedEls.length > 0) {
const middleEl = highlightedEls[Math.round((highlightedEls.length - 1) / 2)]
middleEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
})
})
const { copied, copy } = useClipboard()
function copyCode() {
const code = el.value?.querySelector('.slidev-code')?.textContent
if (code)
copy(code)
}
</script>
<template>
<div
ref="el"
class="slidev-code-wrapper relative group"
:class="{
'slidev-code-line-numbers': props.lines,
}"
:style="{
'max-height': props.maxHeight,
'overflow-y': props.maxHeight ? 'scroll' : undefined,
'--start': props.startLine,
}"
>
<slot />
<button
v-if="configs.codeCopy"
class="slidev-code-copy absolute top-0 right-0 transition opacity-0 group-hover:opacity-20 hover:!opacity-100"
:title="copied ? 'Copied' : 'Copy'" @click="copyCode()"
>
<ph-check-circle v-if="copied" class="p-2 w-8 h-8" />
<ph-clipboard v-else class="p-2 w-8 h-8" />
</button>
</div>
</template>
+119
View File
@@ -0,0 +1,119 @@
<!--
Line highlighting for KaTex blocks/
(auto transformed, you don't need to use this component directly)
Usage:
$$ {1|3|all}
\begin{array}{c}
\nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} &
= \frac{4\pi}{c}\vec{\mathbf{j}} \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\
\nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\
\nabla \cdot \vec{\mathbf{B}} & = 0
\end{array}
$$
Learn more: https://sli.dev/guide/syntax.html#latex-line-highlighting
-->
<script setup lang="ts">
import type { PropType } from 'vue'
import { parseRangeString } from '@slidev/parser/utils'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { CLASS_VCLICK_HIDDEN, CLASS_VCLICK_TARGET, CLICKS_MAX } from '../constants'
import { useSlideContext } from '../context'
import { makeId } from '../logic/utils'
const props = defineProps({
ranges: {
type: Array as PropType<string[]>,
default: () => [],
},
finally: {
type: [String, Number],
default: 'last',
},
startLine: {
type: Number,
default: 1,
},
at: {
type: [String, Number],
default: '+1',
},
})
const { $clicksContext: clicks } = useSlideContext()
const el = ref<HTMLDivElement>()
const id = makeId()
onUnmounted(() => {
clicks!.unregister(id)
})
onMounted(() => {
if (!clicks || !props.ranges?.length)
return
const clicksInfo = clicks.calculateSince(props.at, props.ranges.length - 1)
clicks.register(id, clicksInfo)
const index = computed(() => clicksInfo ? Math.max(0, clicks.current - clicksInfo.start + 1) : CLICKS_MAX)
const finallyRange = computed(() => {
return props.finally === 'last' ? props.ranges.at(-1) : props.finally.toString()
})
watchEffect(() => {
if (!el.value)
return
let rangeStr = props.ranges[index.value] ?? finallyRange.value
const hide = rangeStr === 'hide'
el.value.classList.toggle(CLASS_VCLICK_HIDDEN, hide)
if (hide)
rangeStr = props.ranges[index.value + 1] ?? finallyRange.value
// KaTeX equations have col-align-XXX as parent
const equationParents = el.value.querySelectorAll('.mtable > [class*=col-align]')
if (!equationParents)
return
// For each row we extract the individual equation rows
const equationRowsOfEachParent = Array.from(equationParents)
.map(item => Array.from(item.querySelectorAll(':scope > .vlist-t > .vlist-r > .vlist > span > .mord')))
// This list maps rows from different parents to line them up
const lines: Element[][] = []
for (const equationRowParent of equationRowsOfEachParent) {
equationRowParent.forEach((equationRow, idx) => {
if (!equationRow)
return
if (Array.isArray(lines[idx]))
lines[idx].push(equationRow)
else
lines[idx] = [equationRow]
})
}
const startLine = props.startLine
const highlights: number[] = parseRangeString(lines.length + startLine - 1, rangeStr)
lines.forEach((line, idx) => {
const highlighted = highlights.includes(idx + startLine)
line.forEach((node) => {
node.classList.toggle(CLASS_VCLICK_TARGET, true)
node.classList.toggle('highlighted', highlighted)
node.classList.toggle('dishonored', !highlighted)
})
})
})
})
</script>
<template>
<div ref="el" class="slidev-katex-wrapper">
<slot />
</div>
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import { isDark } from '../logic/dark'
</script>
<template>
<slot v-if="isDark" name="dark" />
<slot v-else name="light" />
</template>
+28
View File
@@ -0,0 +1,28 @@
<!--
Create a link in the presentation
Usage:
<Link :to="5" >Go to slide 5</Link>
<Link :to="5" title="Go to slide 5" />
-->
<script setup lang="ts">
import { useNav } from '../composables/useNav'
defineProps<{
to: number | string
title?: string
}>()
const { isPrintMode } = useNav()
</script>
<template>
<RouterLink v-if="!isPrintMode && title" :to="String(to)" @click="$event.target.blur()" v-html="title" />
<RouterLink v-else-if="!isPrintMode && !title" :to="String(to)" @click="$event.target.blur()">
<slot />
</RouterLink>
<a v-else-if="isPrintMode && title" :href="`#${to}`" v-html="title" />
<a v-else :href="`#${to}`"><slot /></a>
</template>
+82
View File
@@ -0,0 +1,82 @@
<!--
Mermaid
(auto transformed, you don't need to use this component directly)
Usage:
```mermaid
pie
"Dogs" : 386
"Cats" : 85
"Rats" : 15
```
-->
<script setup lang="ts">
import { getCurrentInstance, ref, watch, watchEffect } from 'vue'
import ShadowRoot from '../internals/ShadowRoot.vue'
import { isDark } from '../logic/dark'
import { renderMermaid } from '../modules/mermaid'
const props = defineProps<{
codeLz: string
scale?: number
theme?: string
}>()
const vm = getCurrentInstance()
const el = ref<ShadowRoot>()
const error = ref<string | null>(null)
const html = ref('')
watchEffect(async (onCleanup) => {
let disposed = false
onCleanup(() => {
disposed = true
})
error.value = null
try {
const svg = await renderMermaid(
props.codeLz || '',
{
theme: props.theme || (isDark.value ? 'dark' : undefined),
...vm!.attrs,
},
)
if (!disposed)
html.value = svg
}
catch (e) {
error.value = `${e}`
console.warn(e)
}
})
const actualHeight = ref<number>()
watch(html, () => {
actualHeight.value = undefined
})
watchEffect(() => {
const svgEl = el.value?.children?.[0] as SVGElement | undefined
if (svgEl && svgEl.hasAttribute('viewBox') && actualHeight.value == null) {
const v = Number.parseFloat(svgEl.getAttribute('viewBox')?.split(' ')[3] || '')
actualHeight.value = Number.isNaN(v) ? undefined : v
}
}, { flush: 'post' })
watchEffect(() => {
const svgEl = el.value?.children?.[0] as SVGElement | undefined
if (svgEl != null && props.scale != null && actualHeight.value != null) {
svgEl.setAttribute('height', `${actualHeight.value * props.scale}`)
svgEl.removeAttribute('width')
svgEl.removeAttribute('style')
}
}, { flush: 'post' })
</script>
<template>
<pre v-if="error" border="1 red rounded" class="pa-3 text-wrap">{{ error }}</pre>
<ShadowRoot v-else class="mermaid" :inner-html="html" @shadow="el = $event" />
</template>
+253
View File
@@ -0,0 +1,253 @@
<!--
Monaco Editor
(auto transformed, you don't need to use this component directly)
Usage:
```ts {monaco}
const your_code = 'here'
```
Learn more: https://sli.dev/guide/syntax.html#monaco-editor
-->
<script setup lang="ts">
import type { RawAtValue } from '@slidev/types'
import type * as monaco from 'monaco-editor'
import { debounce } from '@antfu/utils'
import { whenever } from '@vueuse/core'
import lz from 'lz-string'
import { computed, defineAsyncComponent, nextTick, onMounted, ref } from 'vue'
import { useNav } from '../composables/useNav'
import { useSlideContext } from '../context'
import { makeId } from '../logic/utils'
const props = withDefaults(defineProps<{
codeLz: string
diffLz?: string
lang?: string
readonly?: boolean
lineNumbers?: 'on' | 'off' | 'relative' | 'interval'
height?: number | string // Posible values: 'initial', 'auto', '100%', '200px', etc.
editorOptions?: monaco.editor.IEditorOptions
ata?: boolean
runnable?: boolean
writable?: string
autorun?: boolean | 'once'
showOutputAt?: RawAtValue
outputHeight?: string
highlightOutput?: boolean
runnerOptions?: Record<string, unknown>
}>(), {
codeLz: '',
lang: 'typescript',
readonly: false,
lineNumbers: 'off',
height: 'initial',
ata: true,
runnable: false,
autorun: true,
highlightOutput: true,
})
const CodeRunner = defineAsyncComponent(() => import('../internals/CodeRunner.vue').then(r => r.default))
const code = ref(lz.decompressFromBase64(props.codeLz).trimEnd())
const diff = props.diffLz && ref(lz.decompressFromBase64(props.diffLz).trimEnd())
const isWritable = computed(() => props.writable && !props.readonly && __DEV__)
const langMap: Record<string, string> = {
ts: 'typescript',
js: 'javascript',
}
const lang = langMap[props.lang] ?? props.lang
const extMap: Record<string, string> = {
typescript: 'mts',
javascript: 'mjs',
ts: 'mts',
js: 'mjs',
}
const ext = extMap[props.lang] ?? props.lang
const outer = ref<HTMLDivElement>()
const container = ref<HTMLDivElement>()
const contentHeight = ref(0)
const initialHeight = ref<number>()
const height = computed(() => {
if (props.height === 'auto')
return `${contentHeight.value}px`
if (props.height === 'initial')
return `${initialHeight.value}px`
return props.height
})
const loadTypes = ref<() => void>()
const { $page: thisSlideNo, $renderContext: renderContext } = useSlideContext()
const { currentSlideNo } = useNav()
const stopWatchTypesLoading = whenever(
() => Math.abs(thisSlideNo.value - currentSlideNo.value) <= 1 && loadTypes.value,
(loadTypes) => {
if (['slide', 'presenter'].includes(renderContext.value))
loadTypes()
else
setTimeout(loadTypes, 5000)
},
)
onMounted(async () => {
// Lazy load monaco, so it will be bundled in async chunk
const { default: setup } = await import('../setup/monaco')
const { ata, monaco, editorOptions } = await setup()
const model = monaco.editor.createModel(code.value, lang, monaco.Uri.parse(`file:///${makeId()}.${ext}`))
model.onDidChangeContent(() => code.value = model.getValue())
const commonOptions = {
automaticLayout: true,
readOnly: props.readonly,
lineNumbers: props.lineNumbers,
minimap: { enabled: false },
overviewRulerBorder: false,
overviewRulerLanes: 0,
padding: { top: 10, bottom: 10 },
lineNumbersMinChars: 3,
bracketPairColorization: { enabled: false },
tabSize: 2,
fontSize: 11.5,
fontFamily: 'var(--slidev-code-font-family)',
scrollBeyondLastLine: false,
useInlineViewWhenSpaceIsLimited: false,
...editorOptions,
...props.editorOptions,
} satisfies monaco.editor.IStandaloneEditorConstructionOptions & monaco.editor.IDiffEditorConstructionOptions
let editableEditor: monaco.editor.IStandaloneCodeEditor
if (diff) {
const diffModel = monaco.editor.createModel(diff.value, lang, monaco.Uri.parse(`file:///${makeId()}.${ext}`))
diffModel.onDidChangeContent(() => code.value = model.getValue())
const editor = monaco.editor.createDiffEditor(container.value!, {
renderOverviewRuler: false,
...commonOptions,
})
editor.setModel({
original: model,
modified: diffModel,
})
const originalEditor = editor.getOriginalEditor()
const modifiedEditor = editor.getModifiedEditor()
const onContentSizeChange = () => {
const newHeight = Math.max(originalEditor.getContentHeight(), modifiedEditor.getContentHeight()) + 4
initialHeight.value ??= newHeight
contentHeight.value = newHeight
nextTick(() => editor.layout())
}
originalEditor.onDidContentSizeChange(onContentSizeChange)
modifiedEditor.onDidContentSizeChange(onContentSizeChange)
editableEditor = modifiedEditor
}
else {
const editor = monaco.editor.create(container.value!, {
model,
lineDecorationsWidth: 0,
...commonOptions,
})
editor.onDidContentSizeChange((e) => {
const newHeight = e.contentHeight + 4
initialHeight.value ??= newHeight
contentHeight.value = newHeight
nextTick(() => editableEditor.layout())
})
editableEditor = editor
}
loadTypes.value = () => {
stopWatchTypesLoading()
import('#slidev/monaco-types')
if (props.ata) {
ata(editableEditor.getValue())
editableEditor.onDidChangeModelContent(debounce(1000, () => {
ata(editableEditor.getValue())
}))
}
}
const originalLayoutContentWidget = editableEditor.layoutContentWidget.bind(editableEditor)
editableEditor.layoutContentWidget = (widget: any) => {
originalLayoutContentWidget(widget)
const id = widget.getId()
if (id === 'editor.contrib.resizableContentHoverWidget') {
widget._resizableNode.domNode.style.transform = widget._positionPreference === 1
? /* ABOVE */ `translateY(calc(100% * (var(--slidev-slide-scale) - 1)))`
: /* BELOW */ `` // reset
}
}
editableEditor.addAction({
id: 'slidev-save',
label: 'Save',
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
run: () => {
if (!isWritable.value || !import.meta.hot?.send) {
console.warn('[Slidev] this monaco editor is not writable, save action is ignored.')
return
}
import.meta.hot.send('slidev:monaco-write', {
file: props.writable!,
content: editableEditor.getValue(),
})
},
})
nextTick(() => monaco.editor.remeasureFonts())
setTimeout(() => monaco.editor.remeasureFonts(), 1000)
})
</script>
<template>
<div class="relative slidev-monaco-container">
<div ref="outer" class="relative slidev-monaco-container-inner" :style="{ height }">
<div ref="container" class="absolute inset-0.5" />
</div>
<CodeRunner
v-if="props.runnable"
v-model="code"
:lang="lang"
:autorun="props.autorun"
:show-output-at="props.showOutputAt"
:height="props.outputHeight"
:highlight-output="props.highlightOutput"
:runner-options="props.runnerOptions"
/>
</div>
</template>
<style>
div[widgetid='messageoverlay'] {
transform: translateY(calc(100% * (var(--slidev-slide-scale) - 1)));
}
.slidev-monaco-container {
position: relative;
margin: var(--slidev-code-margin);
line-height: var(--slidev-code-line-height);
border-radius: var(--slidev-code-radius);
background: var(--slidev-code-background);
}
.slidev-monaco-container-inner {
padding: var(--slidev-code-padding);
}
.slidev-monaco-container .monaco-editor {
--monaco-monospace-font: var(--slidev-code-font-family);
--vscode-editor-background: var(--slidev-code-background);
--vscode-editorGutter-background: var(--slidev-code-background);
}
/** Revert styles */
.slidev-monaco-container .monaco-editor a {
border-bottom: none;
}
.slidev-monaco-container .monaco-editor a:hover {
border-bottom: none;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<!--
PlantUML
(auto transformed, you don't need to use this component directly)
Usage:
```plantuml
@startuml
Alice -> Bob : Hello!
@enduml
```
-->
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
code: string
server: string
scale?: number
alt?: string
}>(), {
alt: 'PlantUML diagram',
})
const uri = computed(() => `${props.server}/svg/${props.code}`)
</script>
<template>
<img :src="uri" :style="{ scale }" :alt="alt">
</template>
@@ -0,0 +1,8 @@
<template>
<span inline-flex items-center>
<span text-main>Powered by</span>
<a href="https://sli.dev" class="!border-none">
<img alt="Slidev logo" src="../assets/logo-title-horizontal.png" h-1.5em>
</a>
</span>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import type { RenderContext } from '@slidev/types'
import { useElementVisibility } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useNav } from '../composables/useNav'
import { useSlideContext } from '../context'
type Context = 'main' | 'visible' | 'print' | RenderContext
const props = defineProps<{
context: Context | Context[]
}>()
const { context } = props
const target = ref(null)
const targetVisible = useElementVisibility(target)
// When context has `visible`, we need to wrap the content with a div to track the visibility
const needsDomWrapper = Array.isArray(context) ? context.includes('visible') : context === 'visible'
const { $renderContext: currentContext } = useSlideContext()
const { isPrintMode } = useNav()
const shouldRender = computed(() => {
const anyContext = Array.isArray(context) ? context.some(contextMatch) : contextMatch(context)
const allConditions = Array.isArray(context) ? context.every(conditionsMatch) : conditionsMatch(context)
return anyContext && allConditions
})
function contextMatch(context: Context) {
if (context === currentContext?.value)
return true
if (context === 'main' && (currentContext?.value === 'slide' || currentContext?.value === 'presenter'))
return true
if (context === 'visible')
return true
if (context === 'print' && isPrintMode.value)
return true
return false
}
function conditionsMatch(context: Context) {
if (context === 'visible')
return targetVisible.value
return true
}
</script>
<template>
<div v-if="needsDomWrapper" ref="target">
<slot v-if="shouldRender" />
<slot v-else name="fallback" />
</div>
<slot v-else-if="shouldRender" />
<slot v-else name="fallback" />
</template>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import type { KeyedTokensInfo } from 'shiki-magic-move/types'
import type { PropType } from 'vue'
import { sleep } from '@antfu/utils'
import lz from 'lz-string'
import { ShikiMagicMovePrecompiled } from 'shiki-magic-move/vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useNav } from '../composables/useNav'
import { CLICKS_MAX } from '../constants'
import { useSlideContext } from '../context'
import { configs } from '../env'
import { makeId, updateCodeHighlightRange } from '../logic/utils'
const props = defineProps({
at: {
type: [String, Number],
default: '+1',
},
stepsLz: {
type: String,
required: true,
},
stepRanges: {
type: Array as PropType<string[][]>,
required: true,
},
lines: {
type: Boolean,
default: configs.lineNumbers,
},
})
const steps = JSON.parse(lz.decompressFromBase64(props.stepsLz)) as KeyedTokensInfo[]
const { $clicksContext: clicks, $scale: scale, $zoom: zoom } = useSlideContext()
const { isPrintMode } = useNav()
const id = makeId()
const stepIndex = ref(0)
const container = ref<HTMLElement>()
// Normalized the ranges, to at least have one range
const ranges = computed(() => props.stepRanges.map(i => i.length ? i : ['all']))
onUnmounted(() => {
clicks?.unregister(id)
})
onMounted(() => {
if (!clicks)
return
if (ranges.value.length !== steps.length)
throw new Error('[slidev] The length of stepRanges does not match the length of steps, this is an internal error.')
const clickCounts = ranges.value.map(s => s.length).reduce((a, b) => a + b, 0)
const clickInfo = clicks.calculateSince(props.at, clickCounts - 1)
clicks.register(id, clickInfo)
watch(
() => clicks.current,
() => {
// Calculate the step and rangeStr based on the current click count
const clickCount = clickInfo ? clicks.current - clickInfo.start : CLICKS_MAX
let step = steps.length - 1
let currentClickSum = 0
let rangeStr = 'all'
for (let i = 0; i < ranges.value.length; i++) {
const current = ranges.value[i]
if (clickCount < currentClickSum + current.length - 1) {
step = i
rangeStr = current[clickCount - currentClickSum + 1]
break
}
currentClickSum += current.length || 1
}
nextTick(async () => {
stepIndex.value = step
await sleep(0)
const pre = container.value?.querySelector('.shiki') as HTMLElement
if (!pre)
return
const children = (Array.from(pre.children) as HTMLElement[])
.slice(1) // Remove the first anchor
.filter(i => !i.className.includes('shiki-magic-move-leave')) // Filter the leaving elements
// Group to lines between `<br>`
const lines = children.reduce((acc, el) => {
if (el.tagName === 'BR')
acc.push([])
else
acc[acc.length - 1].push(el)
return acc
}, [[]] as HTMLElement[][])
// Update highlight range
updateCodeHighlightRange(
rangeStr,
lines.length,
1,
no => lines[no],
)
})
},
{ immediate: true },
)
})
</script>
<template>
<div ref="container" class="slidev-code-wrapper slidev-code-magic-move relative">
<ShikiMagicMovePrecompiled
class="slidev-code relative shiki overflow-visible"
:steps="steps"
:step="stepIndex"
:animate="!isPrintMode"
:options="{
globalScale: scale * zoom,
// TODO: make this configurable later
duration: 800,
stagger: 1,
}"
/>
</div>
</template>
<style>
.slidev-code-magic-move .shiki-magic-move-enter-from,
.slidev-code-magic-move .shiki-magic-move-leave-to {
opacity: 0;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { useSlideContext } from '../context'
const { $page } = useSlideContext()
</script>
<template>
<span>{{ $page }}</span>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { useSlideContext } from '../context'
const { $nav } = useSlideContext()
</script>
<template>
<span>{{ $nav.total }}</span>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { and } from '@vueuse/math'
import { computed, onMounted, ref, watch } from 'vue'
import { useNav } from '../composables/useNav'
import { useSlideContext } from '../context'
import { resolvedClickMap } from '../modules/v-click'
const props = defineProps<{
autoplay?: boolean | 'once'
autoreset?: 'slide' | 'click'
poster?: string
printPoster?: string
timestamp?: string | number
printTimestamp?: string | number | 'last'
controls?: boolean
}>()
const printPoster = computed(() => props.printPoster ?? props.poster)
const printTimestamp = computed(() => props.printTimestamp ?? props.timestamp ?? 0)
const { $slidev, $renderContext, $route } = useSlideContext()
const { isPrintMode } = useNav()
const noPlay = computed(() => isPrintMode.value || !['slide', 'presenter'].includes($renderContext.value))
const video = ref<HTMLMediaElement>()
const played = ref(false)
onMounted(() => {
if (noPlay.value)
return
const timestamp = +(props.timestamp ?? 0)
video.value!.currentTime = timestamp
const matchRoute = computed(() => !!$route && $route.no === $slidev?.nav.currentSlideNo)
const matchClick = computed(() => !!video.value && (resolvedClickMap.get(video.value)?.isShown?.value ?? true))
const matchRouteAndClick = and(matchRoute, matchClick)
watch(matchRouteAndClick, () => {
if (matchRouteAndClick.value) {
if (props.autoplay === true || (props.autoplay === 'once' && !played.value))
video.value!.play()
}
else {
video.value!.pause()
if (props.autoreset === 'click' || (props.autoreset === 'slide' && !matchRoute.value))
video.value!.currentTime = timestamp
}
}, { immediate: true })
})
function onLoadedMetadata(ev: Event) {
// The video may be loaded before component mounted
const element = ev.target as HTMLMediaElement
if (noPlay.value && (!printPoster.value || props.printTimestamp)) {
element.currentTime = printTimestamp.value === 'last'
? element.duration
: +printTimestamp.value
}
}
</script>
<template>
<video
ref="video"
:poster="noPlay ? printPoster : props.poster"
:controls="!noPlay && props.controls"
@play="played = true"
@loadedmetadata="onLoadedMetadata"
>
<slot />
</video>
</template>
+100
View File
@@ -0,0 +1,100 @@
<!--
Table Of content
`mode` can be either 'all', 'onlyCurrentTree' or 'onlySiblings'
Usage:
<Toc columns='2' maxDepth='3' mode='onlySiblings'/>
-->
<script setup lang='ts'>
import type { TocItem } from '@slidev/types'
import { computed } from 'vue'
import { useSlideContext } from '../context'
import TocList from './TocList.vue'
const props = withDefaults(
defineProps<{
columns?: string | number
listClass?: string | string[]
start?: string | number
listStyle?: string | string[]
maxDepth?: string | number
minDepth?: string | number
mode?: 'all' | 'onlyCurrentTree' | 'onlySiblings'
}>(),
{
columns: 1,
listClass: '',
start: 1,
listStyle: '',
maxDepth: Number.POSITIVE_INFINITY,
minDepth: 1,
mode: 'all',
},
)
const { $slidev } = useSlideContext()
function filterTreeDepth(tree: TocItem[], level = 1): TocItem[] {
if (level > Number(props.maxDepth)) {
return []
}
else if (level < Number(props.minDepth)) {
const activeItem = tree.find((item: TocItem) => item.active || item.activeParent)
return activeItem ? filterTreeDepth(activeItem.children, level + 1) : []
}
return tree
.map((item: TocItem) => ({
...item,
children: filterTreeDepth(item.children, level + 1),
}))
}
function filterOnlyCurrentTree(tree: TocItem[]): TocItem[] {
return tree
.filter(
(item: TocItem) => item.active || item.activeParent || item.hasActiveParent,
)
.map((item: TocItem) => ({
...item,
children: filterOnlyCurrentTree(item.children),
}))
}
function filterOnlySiblings(tree: TocItem[]): TocItem[] {
const treehasActiveItem = tree.some(
(item: TocItem) => item.active || item.activeParent || item.hasActiveParent,
)
return tree
.filter(() => treehasActiveItem)
.map((item: TocItem) => ({
...item,
children: filterOnlySiblings(item.children),
}))
}
const toc = computed(() => {
const tree = $slidev?.nav.tocTree
if (!tree)
return []
let tocTree = filterTreeDepth(tree)
if (props.mode === 'onlyCurrentTree')
tocTree = filterOnlyCurrentTree(tocTree)
else if (props.mode === 'onlySiblings')
tocTree = filterOnlySiblings(tocTree)
return tocTree
})
</script>
<template>
<div class="slidev-toc" :style="`column-count:${columns}`">
<TocList
:level="1"
:start="start"
:list-style="listStyle"
:list="toc"
:list-class="listClass"
/>
</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
<!--
TOC list
(used by Toc component, you don't need to use this component directly)
Usage:
<TocList :list="list"/>
-->
<script setup lang="ts">
import type { TocItem } from '@slidev/types'
import TitleRenderer from '#slidev/title-renderer'
import { toArray } from '@antfu/utils'
import { computed } from 'vue'
import { useNav } from '../composables/useNav'
const props = withDefaults(defineProps<{
level: number
start?: string | number
listStyle?: string | string[]
list: TocItem[]
listClass?: string | string[]
}>(), { level: 1 })
const { isPresenter } = useNav()
const classes = computed(() => {
return [
...toArray(props.listClass || []),
'slidev-toc-list',
`slidev-toc-list-level-${props.level}`,
]
})
const styles = computed(() => {
return [
...toArray(props.listStyle || []),
]
})
</script>
<template>
<ol
v-if="list && list.length > 0"
:class="classes"
:start="level === 1 ? start : undefined"
:style="styles.length >= level ? `list-style:${styles[level - 1]}` : undefined"
>
<li
v-for="item of list"
:key="item.path" class="slidev-toc-item"
:class="[{ 'slidev-toc-item-active': item.active }, { 'slidev-toc-item-parent-active': item.activeParent }]"
>
<Link :to="isPresenter ? `/presenter${item.path}` : item.path">
<TitleRenderer :no="item.no" />
</Link>
<TocList
v-if="item.children.length > 0"
:level="level + 1"
:list-style="listStyle"
:list="item.children"
:list-class="listClass"
/>
</li>
</ol>
</template>
<style>
.slidev-layout .slidev-toc-item p {
margin: 0;
}
.slidev-layout .slidev-toc-item div,
.slidev-layout .slidev-toc-item div p {
display: initial;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<!--
Apply scaling or transforming to elements.
Usage:
<Transform :scale="0.5">
<YourElements />
</Transform>
-->
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
scale?: number | string
origin?: string
}>()
const style = computed(() => {
const transforms = []
if (props.scale != null)
transforms.push(`scale(${props.scale || 1})`)
return {
'transform': transforms.join(' '),
'transform-origin': props.origin || 'top left',
}
})
</script>
<template>
<div :style="style">
<slot />
</div>
</template>
+71
View File
@@ -0,0 +1,71 @@
<!--
A simple wrapper for embedded Tweet
Usage:
<Tweet id="20" />
-->
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { isDark } from '../logic/dark'
const props = defineProps<{
id: string | number
scale?: string | number
conversation?: string
cards?: 'hidden' | 'visible'
}>()
const tweet = ref<HTMLElement | null>()
const loaded = ref(false)
const tweetNotFound = ref(false)
async function create(retries = 10) {
// @ts-expect-error global
if (!window.twttr?.widgets?.createTweet) {
if (retries <= 0)
return console.error('Failed to load Twitter widget after 10 retries.')
setTimeout(() => create(retries - 1), 1000)
return
}
// @ts-expect-error global
const element = await window.twttr.widgets.createTweet(
props.id.toString(),
tweet.value,
{
theme: isDark.value ? 'dark' : 'light',
conversation: props.conversation || 'none',
cards: props.cards,
},
)
loaded.value = true
if (element === undefined)
tweetNotFound.value = true
}
onMounted(() => {
create()
})
</script>
<template>
<Transform :scale="scale || 1">
<div ref="tweet" class="tweet slidev-tweet">
<div v-if="!loaded || tweetNotFound" class="w-30 h-30 my-10px bg-gray-400 bg-opacity-10 rounded-lg flex opacity-50">
<div class="m-auto animate-pulse text-4xl">
<div class="i-carbon:logo-twitter" />
<span v-if="tweetNotFound">Could not load tweet with id="{{ props.id }}"</span>
</div>
</div>
</div>
</Transform>
</template>
<style>
.slidev-tweet iframe {
border-radius: 12px;
overflow: hidden;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
/**
* <v-after/> click animations component
*
* Learn more: https://sli.dev/guide/animations.html#click-animation
*/
import type { Directive, VNode } from 'vue'
import { toArray } from '@antfu/utils'
import { defineComponent, h, resolveDirective, withDirectives } from 'vue'
export default defineComponent({
render() {
const after = resolveDirective('after')!
function applyDirective(node: VNode, directive: Directive) {
return withDirectives(node, [[directive]])
}
let defaults = this.$slots.default?.()
if (!defaults)
return
defaults = toArray(defaults)
return defaults.map(i => applyDirective(h(i), after))
},
})
+51
View File
@@ -0,0 +1,51 @@
/**
* <v-click/> click animations component
*
* Learn more: https://sli.dev/guide/animations.html#click-animation
*/
import type { PropType, VNode } from 'vue'
import { defineComponent, h, Text } from 'vue'
import { CLICKS_MAX } from '../constants'
import VClicks from './VClicks'
export default defineComponent({
props: {
at: {
type: [Number, String],
default: '+1',
},
hide: {
type: Boolean,
default: false,
},
fade: {
type: Boolean,
default: false,
},
wrapText: {
type: Function as PropType<(text: VNode) => VNode>,
default: (text: VNode) => h('span', text),
},
},
render() {
return h(
VClicks,
{
every: CLICKS_MAX,
at: this.at,
hide: this.hide,
fade: this.fade,
handleSpecialElements: false,
},
{
default: () =>
this.$slots.default?.().map(v =>
v.type === Text
? this.wrapText(v)
: v,
),
},
)
},
})
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { Fragment, onMounted, onUnmounted } from 'vue'
import { useSlideContext } from '../context'
import { makeId } from '../logic/utils'
const props = defineProps({
size: {
type: [String, Number],
default: 1,
},
})
const { $clicksContext: clicks } = useSlideContext()
const id = makeId()
let delta = +props.size
if (Number.isNaN(delta)) {
console.warn(`[slidev] Invalid size for VClickGap: ${props.size}`)
delta = 1
}
onMounted(() => {
const max = clicks.currentOffset + delta - 1
clicks.register(id, { max, delta })
})
onUnmounted(() => {
clicks.unregister(id)
})
</script>
<template>
<Fragment />
</template>
+149
View File
@@ -0,0 +1,149 @@
/**
* <v-clicks/> click animations component
*
* Learn more: https://sli.dev/guide/animations.html#click-animation
*/
import type { VNode, VNodeArrayChildren } from 'vue'
import { toArray } from '@antfu/utils'
import { Comment, createVNode, defineComponent, h, isVNode, resolveDirective, withDirectives } from 'vue'
import { normalizeSingleAtValue } from '../composables/useClicks'
import VClickGap from './VClickGap.vue'
const listTags = ['ul', 'ol']
export default defineComponent({
props: {
depth: {
type: [Number, String],
default: 1,
},
every: {
type: [Number, String],
default: 1,
},
at: {
type: [Number, String],
default: '+1',
},
hide: {
type: Boolean,
default: false,
},
fade: {
type: Boolean,
default: false,
},
handleSpecialElements: {
type: Boolean,
default: true,
},
},
render() {
const every = +this.every
const at = normalizeSingleAtValue(this.at)
const isRelative = typeof at === 'string'
if (!at) {
console.warn('[slidev] Invalid at prop for v-clicks component:', at)
return
}
const click = resolveDirective('click')!
const applyDirective = (node: VNode, value: number | string) => {
return withDirectives(node, [[
click,
value,
'',
{
hide: this.hide,
fade: this.fade,
},
]])
}
const openAllTopLevelSlots = <T extends VNodeArrayChildren | VNode[]>(children: T): T => {
return children.flatMap((i) => {
if (isVNode(i) && typeof i.type === 'symbol' && Array.isArray(i.children))
return openAllTopLevelSlots(i.children)
else
return [i]
}) as T
}
let elements = this.$slots.default?.()
if (!elements)
return
elements = openAllTopLevelSlots(toArray(elements))
const mapSubList = (children: VNodeArrayChildren, depth = 1): VNodeArrayChildren => {
const vNodes = openAllTopLevelSlots(children).map((i) => {
if (!isVNode(i))
return i
if (listTags.includes(i.type as string) && Array.isArray(i.children)) {
// eslint-disable-next-line ts/no-use-before-define
const vNodes = mapChildren(i.children, depth + 1)
return h(i, {}, vNodes)
}
return h(i)
})
return vNodes
}
let globalIdx = 1
let execIdx = 0
const mapChildren = (children: VNodeArrayChildren, depth = 1): VNodeArrayChildren => {
const vNodes = openAllTopLevelSlots(children).map((i) => {
if (!isVNode(i) || i.type === Comment)
return i
const thisShowIdx = +at + Math.ceil(globalIdx++ / every) - 1
let vNode
if (depth < +this.depth && Array.isArray(i.children))
vNode = h(i, {}, mapSubList(i.children, depth))
else
vNode = h(i)
const delta = thisShowIdx - execIdx
execIdx = thisShowIdx
return applyDirective(
vNode,
isRelative
? delta >= 0 ? `+${delta}` : `${delta}`
: thisShowIdx,
)
})
return vNodes
}
const lastGap = () => createVNode(VClickGap, {
size: +at + Math.ceil((globalIdx - 1) / every) - 1 - execIdx,
})
if (this.handleSpecialElements) {
// handle ul, ol list
if (elements.length === 1 && listTags.includes(elements[0].type as string) && Array.isArray(elements[0].children))
return h(elements[0], {}, [...mapChildren(elements[0].children), lastGap()])
if (elements.length === 1 && elements[0].type as string === 'table') {
const tableNode = elements[0]
if (Array.isArray(tableNode.children)) {
return h(tableNode, {}, tableNode.children.map((i) => {
if (!isVNode(i))
return i
else if (i.type === 'tbody' && Array.isArray(i.children))
return h(i, {}, [...mapChildren(i.children), lastGap()])
else
return h(i)
}))
}
}
}
return [...mapChildren(elements), lastGap()]
},
})
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { DragElementMarkdownSource } from '../composables/useDragElements'
import { onMounted, onUnmounted } from 'vue'
import { useDragElement } from '../composables/useDragElements'
const props = defineProps<{
pos?: string
markdownSource?: DragElementMarkdownSource
}>()
const { dragId, container, containerStyle, mounted, unmounted, startDragging } = useDragElement(null, props.pos, props.markdownSource)
onMounted(mounted)
onUnmounted(unmounted)
</script>
<template>
<div
ref="container"
:data-drag-id="dragId"
:style="containerStyle"
class="p-1"
@dblclick="startDragging"
>
<slot />
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { DragElementMarkdownSource } from '../composables/useDragElements'
import { computed, onMounted, onUnmounted } from 'vue'
import { useDragElement } from '../composables/useDragElements'
import Arrow from './Arrow.vue'
const props = defineProps<{
pos?: string
markdownSource?: DragElementMarkdownSource
width?: number | string
color?: string
twoWay?: boolean
}>()
const { dragId, mounted, unmounted, startDragging, stopDragging, x0, y0, width, height } = useDragElement(null, props.pos ?? '100,100,300,300', props.markdownSource, true)
onMounted(mounted)
onUnmounted(unmounted)
const x1 = computed(() => x0.value - width.value / 2)
const y1 = computed(() => y0.value - height.value / 2)
const x2 = computed(() => x0.value + width.value / 2)
const y2 = computed(() => y0.value + height.value / 2)
</script>
<template>
<Arrow
:x1 :y1 :x2 :y2
:data-drag-id="dragId"
:width="props.width"
:color="props.color"
:two-way="props.twoWay"
@dblclick="startDragging"
@click-outside="stopDragging"
/>
</template>
+114
View File
@@ -0,0 +1,114 @@
import type { PropType, Ref, Slot, TransitionGroupProps, VNode } from 'vue'
import { recomputeAllPoppers } from 'floating-vue'
import { defineComponent, h, onMounted, onUnmounted, ref, TransitionGroup, watchEffect } from 'vue'
import { CLASS_VCLICK_CURRENT, CLASS_VCLICK_DISPLAY_NONE, CLASS_VCLICK_PRIOR, CLASS_VCLICK_TARGET, CLICKS_MAX } from '../constants'
import { useSlideContext } from '../context'
import { skipTransition } from '../logic/hmr'
import { resolveTransition } from '../logic/transition'
import { makeId } from '../logic/utils'
export default defineComponent({
props: {
at: {
type: [Number, String],
default: '+1',
},
/**
* unmount or hide the content when it's not visible
*/
unmount: {
type: Boolean,
default: false,
},
transition: {
type: [Object, String, Boolean] as PropType<TransitionGroupProps | string | false>,
default: false,
},
tag: {
type: String,
default: 'div',
},
childTag: {
type: String,
default: 'div',
},
},
setup({ at, unmount, transition, tag, childTag }, { slots }) {
const slotEntries = Object.entries(slots).sort((a, b) => -a[0].split('-')[0] + +b[0].split('-')[0])
const contents: [start: number, end: number, slot: Slot<any> | undefined, elRef: Ref<HTMLElement | undefined>][] = []
let lastStart: number | undefined
for (const [range, slot] of slotEntries) {
const elRef = ref<HTMLElement>()
if (Number.isFinite(+range)) {
contents.push([+range, lastStart ?? +range + 1, slot, elRef])
lastStart = +range
}
else {
const [start, end] = range.split('-').map(Number)
if (!Number.isFinite(start) || !Number.isFinite(end))
throw new Error(`Invalid range for v-switch: ${range}`)
contents.push([start, end, slot, elRef])
lastStart = start
}
}
const size = Math.max(...contents.map(c => c[1])) - 1
const id = makeId()
const offset = ref(0)
const { $clicksContext: clicks, $nav: nav } = useSlideContext()
onMounted(() => {
const clicksInfo = clicks.calculateSince(at, size)
if (!clicksInfo) {
offset.value = CLICKS_MAX
return
}
clicks.register(id, clicksInfo)
watchEffect(() => {
offset.value = clicksInfo.currentOffset.value + 1
})
})
onUnmounted(() => {
clicks.unregister(id)
})
function onAfterLeave() {
// Refer to SlidesShow.vue
skipTransition.value = true
recomputeAllPoppers()
}
const transitionProps = transition && {
...resolveTransition(transition, nav.value.navDirection < 0),
tag,
onAfterLeave,
}
return () => {
const children: VNode[] = []
for (let i = contents.length - 1; i >= 0; i--) {
const [start, end, slot, ref] = contents[i]
const visible = start <= offset.value && offset.value < end
if (unmount && !visible)
continue
children.push(h(childTag, {
'key': i,
ref,
'class': [
CLASS_VCLICK_TARGET,
offset.value === start && CLASS_VCLICK_CURRENT,
offset.value >= end && CLASS_VCLICK_PRIOR,
!visible && CLASS_VCLICK_DISPLAY_NONE,
].filter(Boolean),
'data-slidev-clicks-start': start,
'data-slidev-clicks-end': end,
}, slot?.()))
}
return transitionProps
? h(TransitionGroup, skipTransition.value ? {} : transitionProps, () => children)
: h(tag, children)
}
},
})
+28
View File
@@ -0,0 +1,28 @@
<!--
A simple wrapper for embeded YouTube videos
Usage:
<Youtube id="luoMHjh-XcQ" />
-->
<script setup lang="ts">
defineProps<{
id: string
width?: number
height?: number
}>()
</script>
<template>
<iframe
class="youtube"
:width="width"
:height="height"
:src="`https://www.youtube.com/embed/${id}`"
title="YouTube"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
</template>
+172
View File
@@ -0,0 +1,172 @@
import type { ClicksContext, NormalizedRangeClickValue, NormalizedSingleClickValue, RawAtValue, RawSingleAtValue, SlideRoute } from '@slidev/types'
import type { MaybeRefOrGetter, Ref } from 'vue'
import { clamp, sum } from '@antfu/utils'
import { computed, isReadonly, onMounted, onUnmounted, ref, shallowReactive, toValue } from 'vue'
export function normalizeSingleAtValue(at: RawSingleAtValue): NormalizedSingleClickValue {
if (at === false || at === 'false')
return null
if (at == null || at === true || at === 'true')
return '+1'
if (typeof at === 'string' && '+-'.includes(at[0]))
return at
const v = +at
if (Number.isNaN(v)) {
console.error(`Invalid "at" prop value: ${at}`)
return null
}
return v
}
export function normalizeRangeAtValue(at: RawAtValue): NormalizedRangeClickValue {
if (Array.isArray(at))
return [normalizeSingleAtValue(at[0])!, normalizeSingleAtValue(at[1])!]
return null
}
export function createClicksContextBase(
current: Ref<number>,
clicksStart = 0,
clicksTotalOverrides?: number,
): ClicksContext {
const isMounted = ref(false)
let relativeSizeMap: ClicksContext['relativeSizeMap'] = new Map()
let maxMap: ClicksContext['maxMap'] = new Map()
const context: ClicksContext = {
get current() {
return clamp(+current.value, clicksStart, context.total)
},
set current(value) {
current.value = isMounted.value
? clamp(value, clicksStart, context.total)
: value /* context.total is not available yet */
},
clicksStart,
get relativeSizeMap() {
if (__DEV__ && isMounted.value)
console.warn('[slidev] ClicksContext: Unexpected access to relativeSizeMap after mounted')
return relativeSizeMap
},
get maxMap() {
return maxMap
},
get isMounted() {
return isMounted.value
},
setup() {
onMounted(() => {
isMounted.value = true
// Convert maxMap to reactive
maxMap = shallowReactive(maxMap)
// Make sure the query is not greater than the total
if (!isReadonly(current))
context.current = current.value
})
onUnmounted(() => {
isMounted.value = false
relativeSizeMap = new Map()
maxMap = new Map()
})
},
calculateSince(rawAt, size = 1) {
const at = normalizeSingleAtValue(rawAt)
if (at == null)
return null
let start: number, max: number, delta: number
if (typeof at === 'string') {
const offset = context.currentOffset
const value = +at
start = offset + value
max = offset + value + size - 1
delta = value + size - 1
}
else {
start = at
max = at + size - 1
delta = 0
}
return {
start,
end: +Number.POSITIVE_INFINITY,
max,
delta,
currentOffset: computed(() => context.current - start),
isCurrent: computed(() => context.current === start),
isActive: computed(() => context.current >= start),
}
},
calculateRange(rawAt) {
const at = normalizeRangeAtValue(rawAt)
if (at == null)
return null
const [a, b] = at
let start: number, end: number, delta: number
if (typeof a === 'string') {
const offset = context.currentOffset
start = offset + +a
delta = +a
}
else {
start = a
delta = 0
}
if (typeof b === 'string') {
end = start + +b
delta += +b
}
else {
end = b
}
return {
start,
end,
max: end,
delta,
currentOffset: computed(() => context.current - start),
isCurrent: computed(() => context.current === start),
isActive: computed(() => start <= context.current && context.current < end),
}
},
calculate(at) {
if (Array.isArray(at))
return context.calculateRange(at)
return context.calculateSince(at)
},
register(el, info) {
if (!info)
return
if (__DEV__ && isMounted.value)
console.warn('[slidev] ClicksContext: Unexpected register after mounted')
const { delta, max } = info
relativeSizeMap.set(el, delta)
maxMap.set(el, max)
},
unregister(el) {
relativeSizeMap.delete(el)
maxMap.delete(el)
},
get currentOffset() {
return sum(...relativeSizeMap.values())
},
get total() {
return clicksTotalOverrides
?? (isMounted.value
? Math.max(0, ...maxMap.values())
: 0 /* fallback value */
)
},
}
return context
}
export function createFixedClicks(
route?: SlideRoute | undefined,
currentInit: MaybeRefOrGetter<number> = 0,
): ClicksContext {
const clicksStart = route?.meta.slide?.frontmatter.clicksStart ?? 0
return createClicksContextBase(
computed(() => Math.max(toValue(currentInit), clicksStart)),
clicksStart,
route?.meta?.clicks,
)
}
+9
View File
@@ -0,0 +1,9 @@
import { isColorSchemaConfigured, isDark, toggleDark } from '../logic/dark'
export function useDarkMode() {
return {
isColorSchemaConfigured,
isDark,
toggleDark,
}
}
@@ -0,0 +1,300 @@
import type { SlidePatch } from '@slidev/types'
import type { CSSProperties, DirectiveBinding, InjectionKey, WatchStopHandle } from 'vue'
import { debounce, ensureSuffix } from '@antfu/utils'
import { injectLocal, onClickOutside, useWindowFocus } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { injectionCurrentPage, injectionFrontmatter, injectionRenderContext, injectionSlideElement, injectionSlideScale, injectionSlideZoom } from '../constants'
import { makeId } from '../logic/utils'
import { activeDragElement } from '../state'
import { directiveInject } from '../utils'
import { useNav } from './useNav'
import { useSlideBounds } from './useSlideBounds'
import { useDynamicSlideInfo } from './useSlideInfo'
export type DragElementDataSource = 'frontmatter' | 'prop' | 'directive'
/**
* Markdown source position, injected by markdown-it plugin
*/
export type DragElementMarkdownSource = [startLine: number, endLine: number, index: number]
export type DragElementsUpdater = (id: string, posStr: string, type: DragElementDataSource, markdownSource?: DragElementMarkdownSource) => void
const map: Record<number, DragElementsUpdater> = {}
export function useDragElementsUpdater(no: number) {
if (!(__DEV__ && __SLIDEV_FEATURE_EDITOR__))
return () => {}
if (map[no])
return map[no]
const { info, update } = useDynamicSlideInfo(no)
let newPatch: SlidePatch | null = null
async function save() {
if (newPatch) {
await update({
...newPatch,
skipHmr: true,
})
newPatch = null
}
}
const debouncedSave = debounce(500, save)
return map[no] = (id, posStr, type, markdownSource) => {
if (!info.value)
return
if (type === 'frontmatter') {
const frontmatter = info.value.frontmatter
frontmatter.dragPos ||= {}
if (frontmatter.dragPos[id] === posStr)
return
frontmatter.dragPos[id] = posStr
newPatch = {
frontmatter: {
dragPos: frontmatter.dragPos,
},
}
}
else {
if (!markdownSource)
throw new Error(`[Slidev] VDrag Element ${id} is missing markdown source`)
const [startLine, endLine, idx] = markdownSource
const lines = info.value.content.split(/\r?\n/g)
let section = lines.slice(startLine, endLine).join('\n')
let replaced = false
section = type === 'prop'
// eslint-disable-next-line regexp/no-super-linear-backtracking
? section.replace(/<(v-?drag-?\w*)(.*?)(\/)?>/gi, (full, tag, attrs, selfClose = '', index) => {
if (index === idx) {
replaced = true
const posMatch = attrs.match(/pos=".*?"/)
if (!posMatch)
return `<${tag}${ensureSuffix(' ', attrs)}pos="${posStr}"${selfClose}>`
const start = posMatch.index
const end = start + posMatch[0].length
return `<${tag}${attrs.slice(0, start)}pos="${posStr}"${attrs.slice(end)}${selfClose}>`
}
return full
})
: section.replace(/(?<![</\w])v-drag(?:=".*?")?/gi, (full, index) => {
if (index === idx) {
replaced = true
return `v-drag="${posStr}"`
}
return full
})
if (!replaced)
throw new Error(`[Slidev] VDrag Element ${id} is not found in the markdown source`)
lines.splice(
startLine,
endLine - startLine,
section,
)
const newContent = lines.join('\n')
if (info.value.content === newContent)
return
newPatch = {
content: newContent,
}
info.value = {
...info.value,
content: newContent,
}
}
debouncedSave()
}
}
export function useDragElement(directive: DirectiveBinding | null, posRaw?: string | number | number[], markdownSource?: DragElementMarkdownSource, isArrow = false) {
function inject<T>(key: InjectionKey<T> | string): T | undefined {
return directive
? directiveInject(directive, key)
: injectLocal(key)
}
const renderContext = inject(injectionRenderContext)!
const frontmatter = inject(injectionFrontmatter) ?? {}
const page = inject(injectionCurrentPage)!
const updater = computed(() => useDragElementsUpdater(page.value))
const scale = inject(injectionSlideScale) ?? ref(1)
const zoom = inject(injectionSlideZoom) ?? ref(1)
const { left: slideLeft, top: slideTop, stop: stopWatchBounds } = useSlideBounds(inject(injectionSlideElement) ?? ref())
const { isPrintMode } = useNav()
const enabled = ['slide', 'presenter'].includes(renderContext.value) && !isPrintMode.value
let dataSource: DragElementDataSource = directive ? 'directive' : 'prop'
let dragId: string = makeId()
let pos: number[] | undefined
if (Array.isArray(posRaw)) {
pos = posRaw
}
else if (typeof posRaw === 'string' && posRaw.includes(',')) {
pos = posRaw.split(',').map(Number)
}
else if (posRaw != null) {
dataSource = 'frontmatter'
dragId = `${posRaw}`
posRaw = frontmatter?.dragPos?.[dragId]
pos = (posRaw as string)?.split(',').map(Number)
}
if (dataSource !== 'frontmatter' && !markdownSource)
throw new Error('[Slidev] Can not identify the source position of the v-drag element, please provide an explicit `id` prop.')
const watchStopHandles: WatchStopHandle[] = [stopWatchBounds]
const autoHeight = !isArrow && posRaw != null && !Number.isFinite(pos?.[3])
pos ??= [Number.NaN, Number.NaN, 0]
const width = ref(pos[2])
const x0 = ref(pos[0] + pos[2] / 2)
const rotate = ref(isArrow ? 0 : (pos[4] ?? 0))
const rotateRad = computed(() => rotate.value * Math.PI / 180)
const rotateSin = computed(() => Math.sin(rotateRad.value))
const rotateCos = computed(() => Math.cos(rotateRad.value))
const container = ref<HTMLElement>()
const bounds = ref({ left: 0, top: 0, width: 0, height: 0 })
const actualHeight = ref(0)
function updateBounds() {
if (!container.value)
return
const rect = container.value.getBoundingClientRect()
bounds.value = {
left: rect.left / zoom.value,
top: rect.top / zoom.value,
width: rect.width / zoom.value,
height: rect.height / zoom.value,
}
actualHeight.value = ((bounds.value.width + bounds.value.height) / scale.value / (Math.abs(rotateSin.value) + Math.abs(rotateCos.value)) - width.value)
}
watchStopHandles.push(watch(width, updateBounds, { flush: 'post' }))
const configuredHeight = ref(pos[3] ?? 0)
const height = autoHeight
? computed({
get: () => (autoHeight ? actualHeight.value : configuredHeight.value) || 0,
set: v => !autoHeight && (configuredHeight.value = v),
})
: configuredHeight
const configuredY0 = autoHeight ? ref(pos[1]) : ref(pos[1] + pos[3] / 2)
const y0 = autoHeight
? computed({
get: () => configuredY0.value + height.value / 2,
set: v => configuredY0.value = v - height.value / 2,
})
: configuredY0
const containerStyle = computed(() => {
return Number.isFinite(x0.value)
? {
position: 'absolute',
zIndex: 100,
left: `${x0.value - width.value / 2}px`,
top: `${y0.value - height.value / 2}px`,
width: `${width.value}px`,
height: autoHeight ? undefined : `${height.value}px`,
transformOrigin: 'center center',
transform: `rotate(${rotate.value}deg)`,
} satisfies CSSProperties
: {
position: 'absolute',
zIndex: 100,
} satisfies CSSProperties
})
watchStopHandles.push(
watch(
[x0, y0, width, height, rotate],
([x0, y0, w, h, r]) => {
let posStr = [x0 - w / 2, y0 - h / 2, w].map(Math.round).join()
if (autoHeight)
posStr += dataSource === 'directive' ? ',NaN' : ',_'
else
posStr += `,${Math.round(h)}`
if (Math.round(r) !== 0)
posStr += `,${Math.round(r)}`
if (dataSource === 'directive')
posStr = `[${posStr}]`
updater.value(dragId, posStr, dataSource, markdownSource)
},
),
)
const state = {
dragId,
dataSource,
markdownSource,
isArrow,
zoom,
autoHeight,
x0,
y0,
width,
height,
rotate,
container,
containerStyle,
watchStopHandles,
dragging: computed((): boolean => activeDragElement.value === state),
mounted() {
if (!enabled)
return
updateBounds()
if (!posRaw) {
setTimeout(() => {
updateBounds()
x0.value = (bounds.value.left + bounds.value.width / 2 - slideLeft.value) / scale.value
y0.value = (bounds.value.top - slideTop.value) / scale.value
width.value = bounds.value.width / scale.value
height.value = bounds.value.height / scale.value
}, 100)
}
},
unmounted() {
if (!enabled)
return
state.stopDragging()
},
startDragging(): void {
if (!enabled)
return
updateBounds()
activeDragElement.value = state
},
stopDragging(): void {
if (!enabled)
return
if (activeDragElement.value === state)
activeDragElement.value = null
},
}
watchStopHandles.push(
onClickOutside(container, (ev) => {
const container = document.querySelector('#drag-control-container')
if (container && ev.target && container.contains(ev.target as HTMLElement))
return
state.stopDragging()
}),
watch(useWindowFocus(), (focused) => {
if (!focused)
state.stopDragging()
}),
)
return state
}
export type DragElementState = ReturnType<typeof useDragElement>
+181
View File
@@ -0,0 +1,181 @@
import type { Brush, Options as DrauuOptions, DrawingMode } from 'drauu'
import { createSharedComposable, toReactive, useLocalStorage } from '@vueuse/core'
import { createDrauu } from 'drauu'
import { computed, markRaw, nextTick, reactive, ref, watch } from 'vue'
import { configs } from '../env'
import { isInputting } from '../state'
import { drawingState, onPatchDrawingState, patchDrawingState } from '../state/drawings'
import { useNav } from './useNav'
export const useDrawings = createSharedComposable(() => {
const { currentSlideNo, isPresenter } = useNav()
const brushColors = [
'#ff595e',
'#ffca3a',
'#8ac926',
'#1982c4',
'#6a4c93',
'#ffffff',
'#000000',
]
const drawingEnabled = useLocalStorage('slidev-drawing-enabled', false)
const drawingPinned = useLocalStorage('slidev-drawing-pinned', false)
const brush = toReactive(useLocalStorage<Brush>('slidev-drawing-brush', {
color: brushColors[0],
size: 4,
mode: 'stylus',
}))
const isDrawing = ref(false)
const canUndo = ref(false)
const canRedo = ref(false)
const canClear = ref(false)
const _mode = ref<DrawingMode | 'arrow'>('stylus')
const syncUp = computed(() => configs.drawings.syncAll || isPresenter.value)
let disableDump = false
const drawingMode = computed({
get() {
return _mode.value
},
set(v: DrawingMode | 'arrow') {
_mode.value = v
if (v === 'arrow') {
// eslint-disable-next-line ts/no-use-before-define
drauu.mode = 'line'
brush.arrowEnd = true
}
else {
// eslint-disable-next-line ts/no-use-before-define
drauu.mode = v
brush.arrowEnd = false
}
},
})
const drauuOptions: DrauuOptions = reactive({
brush,
acceptsInputTypes: computed(() => (drawingEnabled.value && (!configs.drawings.presenterOnly || isPresenter.value)) ? undefined : ['pen' as const]),
coordinateTransform: false,
})
const drauu = markRaw(createDrauu(drauuOptions))
function clearDrauu() {
drauu.clear()
if (syncUp.value)
patchDrawingState(currentSlideNo.value, '')
}
function updateState() {
canRedo.value = drauu.canRedo()
canUndo.value = drauu.canUndo()
canClear.value = !!drauu.el?.children.length
}
function loadCanvas(page?: number) {
disableDump = true
const data = drawingState[page || currentSlideNo.value]
if (data != null)
drauu.load(data)
else
drauu.clear()
updateState()
disableDump = false
}
drauu.on('changed', () => {
updateState()
if (!disableDump) {
const dump = drauu.dump()
const key = currentSlideNo.value
if ((drawingState[key] || '') !== dump && syncUp.value)
patchDrawingState(key, drauu.dump())
}
})
onPatchDrawingState((state) => {
disableDump = true
if (state[currentSlideNo.value] != null)
drauu.load(state[currentSlideNo.value] || '')
disableDump = false
updateState()
})
nextTick(() => {
watch(currentSlideNo, () => {
if (!drauu.mounted)
return
loadCanvas()
}, { immediate: true })
})
drauu.on('start', () => isDrawing.value = true)
drauu.on('end', () => isDrawing.value = false)
window.addEventListener('keydown', (e) => {
if (!drawingEnabled.value || isInputting.value)
return
const noModifier = !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey
let handled = true
if (e.code === 'KeyZ' && (e.ctrlKey || e.metaKey)) {
if (e.shiftKey)
drauu.redo()
else
drauu.undo()
}
else if (e.code === 'Escape') {
drawingEnabled.value = false
}
else if (e.code === 'KeyL' && noModifier) {
drawingMode.value = 'line'
}
else if (e.code === 'KeyA' && noModifier) {
drawingMode.value = 'arrow'
}
else if (e.code === 'KeyS' && noModifier) {
drawingMode.value = 'stylus'
}
else if (e.code === 'KeyR' && noModifier) {
drawingMode.value = 'rectangle'
}
else if (e.code === 'KeyE' && noModifier) {
drawingMode.value = 'ellipse'
}
else if (e.code === 'KeyC' && noModifier) {
clearDrauu()
}
else if (e.code.startsWith('Digit') && noModifier && +e.code[5] <= brushColors.length) {
brush.color = brushColors[+e.code[5] - 1]
}
else {
handled = false
}
if (handled) {
e.preventDefault()
e.stopPropagation()
}
}, false)
return {
brush,
brushColors,
canClear,
canRedo,
canUndo,
clear: clearDrauu,
drauu,
drauuOptions,
drawingEnabled,
drawingMode,
drawingPinned,
drawingState,
isDrawing,
loadCanvas,
}
})
@@ -0,0 +1,56 @@
import { throttledWatch } from '@vueuse/core'
import { useNav } from '../composables/useNav'
import { isDark } from '../logic/dark'
export function useEmbeddedControl() {
const nav = useNav()
const clientId = `${Date.now()}`
window.addEventListener('message', ({ data }) => {
if (data && data.target === 'slidev') {
if (data.type === 'navigate') {
if (data.no || data.clicks) {
nav.go(+data.no, +data.clicks || 0)
}
else if (typeof data.operation === 'string') {
const fn = nav[data.operation as keyof typeof nav]
if (typeof fn === 'function')
(fn as any)(...(data.args ?? []))
}
}
else if (data.type === 'css-vars') {
const root = document.documentElement
for (const [key, value] of Object.entries(data.vars || {}))
root.style.setProperty(key, value as any)
}
else if (data.type === 'color-schema') {
isDark.value = data.color === 'dark'
}
}
})
if (nav.isEmbedded.value) {
throttledWatch(
[nav.currentSlideNo, nav.clicks, nav.hasNext, nav.hasPrev],
([no, clicks, hasNext, hasPrev]) => {
window.parent.postMessage(
{
target: 'slidev',
clientId,
navState: {
no,
clicks,
hasNext,
hasPrev,
},
},
'*',
)
},
{
throttle: 300,
immediate: true,
},
)
}
}
+400
View File
@@ -0,0 +1,400 @@
import type { ClicksContext, SlideRoute, TocItem } from '@slidev/types'
import type { ComputedRef, Ref, TransitionGroupProps, WritableComputedRef } from 'vue'
import type { RouteLocationNormalized, Router } from 'vue-router'
import { slides } from '#slidev/slides'
import { clamp } from '@antfu/utils'
import { parseRangeString } from '@slidev/parser/utils'
import { createSharedComposable } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CLICKS_MAX } from '../constants'
import { configs } from '../env'
import { skipTransition } from '../logic/hmr'
import { useRouteQuery } from '../logic/route'
import { getSlide, getSlidePath } from '../logic/slides'
import { getCurrentTransition } from '../logic/transition'
import { createClicksContextBase } from './useClicks'
import { useTocTree } from './useTocTree'
export interface SlidevContextNav {
slides: Ref<SlideRoute[]>
total: ComputedRef<number>
currentPath: ComputedRef<string>
currentPage: ComputedRef<number>
currentSlideNo: ComputedRef<number>
currentSlideRoute: ComputedRef<SlideRoute>
currentTransition: ComputedRef<TransitionGroupProps | undefined>
currentLayout: ComputedRef<string>
nextRoute: ComputedRef<SlideRoute>
prevRoute: ComputedRef<SlideRoute>
hasNext: ComputedRef<boolean>
hasPrev: ComputedRef<boolean>
clicksContext: ComputedRef<ClicksContext>
clicks: ComputedRef<number>
clicksStart: ComputedRef<number>
clicksTotal: ComputedRef<number>
/** The table of content tree */
tocTree: ComputedRef<TocItem[]>
/** The direction of the navigation, 1 for forward, -1 for backward */
navDirection: Ref<number>
/** The direction of the clicks, 1 for forward, -1 for backward */
clicksDirection: Ref<number>
/** Utility function for open file in editor, only avaible in dev mode */
openInEditor: (url?: string) => Promise<boolean>
/** Go to next click */
next: () => Promise<void>
/** Go to previous click */
prev: () => Promise<void>
/** Go to next slide */
nextSlide: (lastClicks?: boolean) => Promise<void>
/** Go to previous slide */
prevSlide: (lastClicks?: boolean) => Promise<void>
/** Go to slide */
go: (no: number | string, clicks?: number, force?: boolean) => Promise<void>
/** Go to the first slide */
goFirst: () => Promise<void>
/** Go to the last slide */
goLast: () => Promise<void>
/** Enter presenter mode */
enterPresenter: () => void
/** Exit presenter mode */
exitPresenter: () => void
}
export interface SlidevContextNavState {
router: Router
currentRoute: ComputedRef<RouteLocationNormalized>
isPrintMode: ComputedRef<boolean>
isPrintWithClicks: Ref<boolean>
isEmbedded: ComputedRef<boolean>
isPlaying: ComputedRef<boolean>
isPresenter: ComputedRef<boolean>
isNotesViewer: ComputedRef<boolean>
isPresenterAvailable: ComputedRef<boolean>
hasPrimarySlide: ComputedRef<boolean>
currentSlideNo: ComputedRef<number>
currentSlideRoute: ComputedRef<SlideRoute>
clicksContext: ComputedRef<ClicksContext>
queryClicksRaw: Ref<string>
queryClicks: WritableComputedRef<number>
printRange: Ref<number[]>
getPrimaryClicks: (route: SlideRoute) => ClicksContext
}
export interface SlidevContextNavFull extends SlidevContextNav, SlidevContextNavState { }
export function useNavBase(
currentSlideRoute: ComputedRef<SlideRoute>,
clicksContext: ComputedRef<ClicksContext>,
queryClicks: Ref<number> = ref(0),
isPresenter: Ref<boolean>,
isPrint: Ref<boolean>,
router?: Router,
): SlidevContextNav {
const total = computed(() => slides.value.length)
const navDirection = ref(0)
const clicksDirection = ref(0)
const currentPath = computed(() => getSlidePath(currentSlideRoute.value, isPresenter.value))
const currentSlideNo = computed(() => currentSlideRoute.value.no)
const currentLayout = computed(() => currentSlideRoute.value.meta?.layout || (currentSlideNo.value === 1 ? 'cover' : 'default'))
const clicks = computed(() => clicksContext.value.current)
const clicksStart = computed(() => clicksContext.value.clicksStart)
const clicksTotal = computed(() => clicksContext.value.total)
const nextRoute = computed(() => slides.value[Math.min(slides.value.length, currentSlideNo.value + 1) - 1])
const prevRoute = computed(() => slides.value[Math.max(1, currentSlideNo.value - 1) - 1])
const hasNext = computed(() => currentSlideNo.value < slides.value.length || clicks.value < clicksTotal.value)
const hasPrev = computed(() => currentSlideNo.value > 1 || clicks.value > 0)
const currentTransition = computed(() => isPrint.value ? undefined : getCurrentTransition(navDirection.value, currentSlideRoute.value, prevRoute.value))
watch(currentSlideRoute, (next, prev) => {
navDirection.value = next.no - prev.no
})
async function openInEditor(url?: string) {
if (!__DEV__)
return false
if (url == null) {
const slide = currentSlideRoute.value?.meta?.slide
if (!slide)
return false
url = `${slide.filepath}:${slide.start}`
}
await fetch(`/__open-in-editor?file=${encodeURIComponent(url)}`)
return true
}
const tocTree = useTocTree(
slides,
currentSlideNo,
currentSlideRoute,
)
async function next() {
clicksDirection.value = 1
if (clicksTotal.value <= queryClicks.value)
await nextSlide()
else
queryClicks.value += 1
}
async function prev() {
clicksDirection.value = -1
if (queryClicks.value <= clicksStart.value)
await prevSlide(true)
else
queryClicks.value -= 1
}
async function nextSlide(lastClicks = false) {
clicksDirection.value = 1
if (currentSlideNo.value < slides.value.length) {
await go(
currentSlideNo.value + 1,
lastClicks && !isPrint.value ? CLICKS_MAX : undefined,
)
}
}
async function prevSlide(lastClicks = false) {
clicksDirection.value = -1
if (currentSlideNo.value > 1) {
await go(
currentSlideNo.value - 1,
lastClicks && !isPrint.value ? CLICKS_MAX : undefined,
)
}
}
function goFirst() {
return go(1)
}
function goLast() {
return go(total.value)
}
async function go(no: number | string, clicks: number = 0, force = false) {
skipTransition.value = false
const pageChanged = currentSlideNo.value !== no
const clicksChanged = clicks !== queryClicks.value
const meta = getSlide(no)?.meta
const clicksStart = meta?.slide?.frontmatter.clicksStart ?? 0
clicks = clamp(clicks, clicksStart, meta?.__clicksContext?.total ?? CLICKS_MAX)
if (force || pageChanged || clicksChanged) {
await router?.push({
path: getSlidePath(no, isPresenter.value, router.currentRoute.value.name === 'export'),
query: {
...router.currentRoute.value.query,
clicks: clicks === 0 ? undefined : clicks.toString(),
embedded: location.search.includes('embedded') ? 'true' : undefined,
},
})
}
}
function enterPresenter() {
router?.push({
path: getSlidePath(currentSlideNo.value, true),
query: { ...router.currentRoute.value.query },
})
}
function exitPresenter() {
router?.push({
path: getSlidePath(currentSlideNo.value, false),
query: { ...router.currentRoute.value.query },
})
}
return {
slides,
total,
currentPath,
currentSlideNo,
currentPage: currentSlideNo,
currentSlideRoute,
currentLayout,
currentTransition,
clicksDirection,
nextRoute,
prevRoute,
clicksContext,
clicks,
clicksStart,
clicksTotal,
hasNext,
hasPrev,
tocTree,
navDirection,
openInEditor,
next,
prev,
go,
goLast,
goFirst,
nextSlide,
prevSlide,
enterPresenter,
exitPresenter,
}
}
export function useFixedNav(
currentSlideRoute: SlideRoute,
clicksContext: ClicksContext,
): SlidevContextNav {
const noop = async () => { }
return {
...useNavBase(
computed(() => currentSlideRoute),
computed(() => clicksContext),
ref(CLICKS_MAX),
ref(false),
ref(false),
),
next: noop,
prev: noop,
nextSlide: noop,
prevSlide: noop,
goFirst: noop,
goLast: noop,
go: noop,
}
}
const useNavState = createSharedComposable((): SlidevContextNavState => {
const router = useRouter()
const currentRoute = useRoute()
const query = computed(() => {
// eslint-disable-next-line ts/no-unused-expressions
router.currentRoute.value.query
return new URLSearchParams(location.search)
})
const isPrintMode = computed(() => query.value.has('print') || currentRoute.name === 'export')
const isPrintWithClicks = ref(query.value.get('print') === 'clicks')
const isEmbedded = computed(() => query.value.has('embedded'))
const isPlaying = computed(() => currentRoute.name === 'play')
const isPresenter = computed(() => currentRoute.name === 'presenter')
const isNotesViewer = computed(() => currentRoute.name === 'notes')
const isPresenterAvailable = computed(() => !isPresenter.value && (!configs.remote || query.value.get('password') === configs.remote))
const hasPrimarySlide = computed(() => !!currentRoute.params.no)
const currentSlideNo = computed(() => hasPrimarySlide.value ? getSlide(currentRoute.params.no as string)?.no ?? 1 : 1)
const currentSlideRoute = computed(() => slides.value[currentSlideNo.value - 1])
const printRange = ref(parseRangeString(slides.value.length, currentRoute.query.range as string | undefined))
const queryClicksRaw = useRouteQuery<string>('clicks', '0')
const clicksContext = computed(() => getPrimaryClicks(currentSlideRoute.value))
const queryClicks = computed({
get() {
let v = +(queryClicksRaw.value || 0)
if (Number.isNaN(v))
v = 0
return v
},
set(v) {
skipTransition.value = false
queryClicksRaw.value = v.toString()
},
})
function getPrimaryClicks(
route: SlideRoute,
): ClicksContext {
if (route?.meta?.__clicksContext)
return route.meta.__clicksContext
const thisNo = route.no
const context = createClicksContextBase(
computed({
get() {
if (currentSlideNo.value === thisNo)
return Math.max(+(queryClicksRaw.value ?? 0), context.clicksStart)
else if (currentSlideNo.value > thisNo)
return CLICKS_MAX
else
return context.clicksStart
},
set(v) {
if (currentSlideNo.value === thisNo)
queryClicksRaw.value = v.toString()
},
}),
route?.meta.slide?.frontmatter.clicksStart ?? 0,
route?.meta.clicks,
)
if (route?.meta)
route.meta.__clicksContext = context
return context
}
return {
router,
currentRoute: computed(() => currentRoute),
isPrintMode,
isPrintWithClicks,
isEmbedded,
isPlaying,
isPresenter,
isNotesViewer,
isPresenterAvailable,
hasPrimarySlide,
currentSlideNo,
currentSlideRoute,
clicksContext,
queryClicksRaw,
queryClicks,
printRange,
getPrimaryClicks,
}
})
export const useNav = createSharedComposable((): SlidevContextNavFull => {
const state = useNavState()
const router = useRouter()
const nav = useNavBase(
state.currentSlideRoute,
state.clicksContext,
state.queryClicks,
state.isPresenter,
state.isPrintMode,
router,
)
watch(
[nav.total, state.currentRoute],
async () => {
const no = state.currentRoute.value.params.no as string
if (state.hasPrimarySlide.value && !getSlide(no)) {
if (no && no !== 'index.html') {
// The current slide may has been removed. Redirect to the last slide.
await nav.go(nav.total.value, 0, true)
}
else {
// Redirect to the first slide
await nav.go(1, 0, true)
}
}
},
{ flush: 'pre', immediate: true },
)
return {
...nav,
...state,
}
})
@@ -0,0 +1,28 @@
import { useStyleTag } from '@vueuse/core'
import { computed } from 'vue'
import { slideHeight, slideWidth } from '../env'
import { useNav } from './useNav'
export function usePrintStyles() {
const { isPrintMode } = useNav()
useStyleTag(computed(() => isPrintMode.value
? `
@page {
size: ${slideWidth.value}px ${slideHeight.value}px;
margin: 0px;
}
* {
transition: none !important;
transition-duration: 0s !important;
}`
: ''))
}
// Monaco uses `<style media="screen" class="monaco-colors">` to apply colors, which will be ignored in print mode.
export function patchMonacoColors() {
document.querySelectorAll<HTMLStyleElement>('style.monaco-colors').forEach((el) => {
el.media = ''
})
}
@@ -0,0 +1,30 @@
import { useElementBounding } from '@vueuse/core'
import { inject, ref, watch } from 'vue'
import { injectionSlideElement } from '../constants'
import { editorHeight, editorWidth, isEditorVertical, showEditor, slideScale, windowSize } from '../state'
export function useSlideBounds(slideElement = inject(injectionSlideElement, ref())) {
const bounding = useElementBounding(slideElement)
const stop = watch(
[
showEditor,
isEditorVertical,
editorWidth,
editorHeight,
slideScale,
windowSize.width,
windowSize.height,
],
() => {
setTimeout(bounding.update, 300)
},
{
flush: 'post',
immediate: true,
},
)
return {
...bounding,
stop,
}
}
+80
View File
@@ -0,0 +1,80 @@
import type { SlideInfo, SlidePatch } from '@slidev/types'
import type { MaybeRef } from '@vueuse/core'
import type { Ref } from 'vue'
import { useFetch } from '@vueuse/core'
import { computed, ref, unref } from 'vue'
import { getSlide } from '../logic/slides'
export interface UseSlideInfo {
info: Ref<SlideInfo | null>
update: (data: SlidePatch) => Promise<SlideInfo | void>
}
export function useSlideInfo(no: number): UseSlideInfo {
if (!__SLIDEV_HAS_SERVER__) {
return {
info: ref(getSlide(no)?.meta.slide ?? null) as Ref<SlideInfo | null>,
update: async () => {},
}
}
const url = `/__slidev/slides/${no}.json`
const { data: info, execute } = useFetch(url).json<SlideInfo>().get()
execute()
const update = async (data: SlidePatch) => {
return await fetch(
url,
{
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
},
).then(r => r.json())
}
if (__DEV__) {
import.meta.hot?.on('slidev:update-slide', (payload) => {
if (payload.no === no)
info.value = payload.data
})
import.meta.hot?.on('slidev:update-note', (payload) => {
if (payload.no === no && info.value && info.value.note?.trim() !== payload.note?.trim())
info.value = { ...info.value, ...payload }
})
}
return {
info,
update,
}
}
const map: Record<number, UseSlideInfo> = {}
export function useDynamicSlideInfo(no: MaybeRef<number>) {
function get(no: number) {
return map[no] ??= useSlideInfo(no)
}
return {
info: computed({
get() {
return get(unref(no)).info.value
},
set(newInfo) {
get(unref(no)).info.value = newInfo
},
}),
update: async (data: SlidePatch, newId?: number) => {
const info = get(newId ?? unref(no))
const newData = await info.update(data)
if (newData)
info.info.value = newData
return newData
},
}
}
@@ -0,0 +1,43 @@
import type { Ref } from 'vue'
import { timestamp, usePointerSwipe } from '@vueuse/core'
import { ref } from 'vue'
import { useNav } from '../composables/useNav'
import { useDrawings } from './useDrawings'
export function useSwipeControls(root: Ref<HTMLElement | undefined>) {
const { next, nextSlide, prev, prevSlide } = useNav()
const { isDrawing } = useDrawings()
const swipeBegin = ref(0)
const { direction, distanceX, distanceY } = usePointerSwipe(root, {
pointerTypes: ['touch'],
onSwipeStart() {
if (isDrawing.value)
return
swipeBegin.value = timestamp()
},
onSwipeEnd() {
if (!swipeBegin.value)
return
if (isDrawing.value)
return
const x = Math.abs(distanceX.value)
const y = Math.abs(distanceY.value)
if (x / window.innerWidth > 0.3 || x > 75) {
if (direction.value === 'left')
next()
else
prev()
}
else if (y / window.innerHeight > 0.4 || y > 200) {
if (direction.value === 'down')
prevSlide()
else
nextSlide()
}
},
})
}
+20
View File
@@ -0,0 +1,20 @@
import { useInterval } from '@vueuse/core'
import { computed } from 'vue'
export function useTimer() {
const { counter, isActive, reset, pause, resume } = useInterval(1000, { controls: true })
const timer = computed(() => {
const passed = counter.value
const sec = Math.floor(passed % 60).toString().padStart(2, '0')
const min = Math.floor(passed / 60).toString().padStart(2, '0')
return `${min}:${sec}`
})
return {
timer,
isTimerAvctive: isActive,
resetTimer: reset,
toggleTimer: () => (isActive.value ? pause() : resume()),
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { SlideRoute, TocItem } from '@slidev/types'
import type { ComputedRef, Ref } from 'vue'
import { computed } from 'vue'
import { getSlidePath } from '../logic/slides'
function addToTree(tree: TocItem[], route: SlideRoute, level = 1) {
const titleLevel = route.meta.slide.level ?? level
if (titleLevel && titleLevel > level && tree.length > 0) {
addToTree(tree[tree.length - 1].children, route, level + 1)
}
else {
tree.push({
no: route.no,
children: [],
level,
titleLevel,
path: getSlidePath(route.meta.slide?.frontmatter?.routeAlias ?? route.no, false),
hideInToc: Boolean(route.meta?.slide?.frontmatter?.hideInToc),
title: route.meta?.slide?.title,
})
}
}
function getTreeWithActiveStatuses(
tree: TocItem[],
currentRoute?: SlideRoute,
hasActiveParent = false,
parent?: TocItem,
currentSlideNo?: Ref<number>,
): TocItem[] {
return tree.map((item: TocItem) => {
const clone = {
...item,
active: item.no === currentSlideNo?.value,
hasActiveParent,
}
if (clone.children.length > 0) {
clone.children = getTreeWithActiveStatuses(
clone.children,
currentRoute,
clone.active || clone.hasActiveParent,
clone,
currentSlideNo,
)
}
if (parent && (clone.active || clone.activeParent))
parent.activeParent = true
return clone
})
}
function filterTree(tree: TocItem[], level = 1): TocItem[] {
return tree
.filter((item: TocItem) => !item.hideInToc)
.map((item: TocItem) => ({
...item,
children: filterTree(item.children, level + 1),
}))
}
export function useTocTree(
slides: Ref<SlideRoute[]>,
currentSlideNo: Ref<number>,
currentSlideRoute: Ref<SlideRoute>,
): ComputedRef<TocItem[]> {
const rawTree = computed(() => slides.value
.filter((route: SlideRoute) => route.meta?.slide?.title)
.reduce((acc: TocItem[], route: SlideRoute) => {
addToTree(acc, route)
return acc
}, []))
const treeWithActiveStatuses = computed(() => getTreeWithActiveStatuses(
rawTree.value,
currentSlideRoute.value,
undefined,
undefined,
currentSlideNo,
))
return computed(() => filterTree(treeWithActiveStatuses.value))
}
@@ -0,0 +1,61 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { configs } from '../env'
import { getSlide } from '../logic/slides'
export function useViewTransition() {
const router = useRouter()
const isViewTransition = ref(false)
let viewTransitionFinish: undefined | (() => void)
let viewTransitionAbort: undefined | (() => void)
const supportViewTransition = typeof document !== 'undefined' && 'startViewTransition' in document
router.beforeResolve((to, from) => {
const fromMeta = getSlide(from.params.no as string)?.meta
const toMeta = getSlide(to.params.no as string)?.meta
const fromNo = fromMeta?.slide?.no
const toNo = toMeta?.slide?.no
const transitionType = fromNo != null && toNo != null && fromNo !== toNo
&& ((fromNo < toNo ? fromMeta?.transition : toMeta?.transition) ?? configs.transition)
if (transitionType !== 'view-transition') {
isViewTransition.value = false
return
}
if (!supportViewTransition) {
isViewTransition.value = false
console.warn('View transition is not supported in your browser, fallback to normal transition.')
return
}
isViewTransition.value = true
const promise = new Promise<void>((resolve, reject) => {
viewTransitionFinish = resolve
viewTransitionAbort = reject
})
let changeRoute: () => void
const ready = new Promise<void>(resolve => (changeRoute = resolve))
// Wait for `TransitionGroup` to become normal `div`
setTimeout(() => {
document.startViewTransition(() => {
changeRoute()
return promise
})
}, 50)
return ready
})
if (supportViewTransition) {
router.afterEach(() => {
viewTransitionFinish?.()
viewTransitionAbort?.()
})
}
return isViewTransition
}
+14
View File
@@ -0,0 +1,14 @@
import { useWakeLock as useVueUseWakeLock } from '@vueuse/core'
import { watch } from 'vue'
import { wakeLockEnabled } from '../state'
export function useWakeLock() {
const { request, release } = useVueUseWakeLock()
watch(wakeLockEnabled, (enabled) => {
if (enabled)
request('screen')
else
release()
}, { immediate: true })
}
+87
View File
@@ -0,0 +1,87 @@
import type { ClicksContext, RenderContext, SlideRoute } from '@slidev/types'
import type { ComputedRef, InjectionKey, Ref, UnwrapNestedRefs } from 'vue'
import type { SlidevContext } from './modules/context'
// Here we use string literal instead of symbols to make HMR more stable
// The value of the injections keys are implementation details, you should always use them with the reference to the constant instead of the value
export const injectionClicksContext = '$$slidev-clicks-context' as unknown as InjectionKey<Ref<ClicksContext>>
export const injectionCurrentPage = '$$slidev-page' as unknown as InjectionKey<Ref<number>>
export const injectionSlideElement = '$$slidev-slide-element' as unknown as InjectionKey<Ref<HTMLElement | null>>
export const injectionSlideScale = '$$slidev-slide-scale' as unknown as InjectionKey<ComputedRef<number>>
export const injectionSlidevContext = '$$slidev-context' as unknown as InjectionKey<UnwrapNestedRefs<SlidevContext>>
export const injectionRoute = '$$slidev-route' as unknown as InjectionKey<SlideRoute>
export const injectionRenderContext = '$$slidev-render-context' as unknown as InjectionKey<Ref<RenderContext>>
export const injectionFrontmatter = '$$slidev-fontmatter' as unknown as InjectionKey<Record<string, any>>
export const injectionSlideZoom = '$$slidev-slide-zoom' as unknown as InjectionKey<ComputedRef<number>>
export const CLASS_VCLICK_TARGET = 'slidev-vclick-target'
export const CLASS_VCLICK_HIDDEN = 'slidev-vclick-hidden'
export const CLASS_VCLICK_FADE = 'slidev-vclick-fade'
export const CLASS_VCLICK_GONE = 'slidev-vclick-gone'
export const CLASS_VCLICK_HIDDEN_EXP = 'slidev-vclick-hidden-explicitly'
export const CLASS_VCLICK_CURRENT = 'slidev-vclick-current'
export const CLASS_VCLICK_PRIOR = 'slidev-vclick-prior'
export const CLASS_VCLICK_DISPLAY_NONE = 'slidev-vclick-display-none'
export const CLICKS_MAX = 999999
export const TRUST_ORIGINS = [
'localhost',
'127.0.0.1',
]
export const FRONTMATTER_FIELDS = [
'clicks',
'clicksStart',
'disabled',
'hide',
'hideInToc',
'layout',
'level',
'preload',
'routeAlias',
'src',
'title',
'transition',
'zoom',
'dragPos',
'lang',
]
export const HEADMATTER_FIELDS = [
...FRONTMATTER_FIELDS,
'theme',
'titleTemplate',
'info',
'author',
'keywords',
'presenter',
'browserExporter',
'download',
'exportFilename',
'export',
'highlighter',
'lineNumbers',
'monaco',
'monacoTypesSource',
'monacoTypesAdditionalPackages',
'monacoRunAdditionalDeps',
'remoteAssets',
'selectable',
'record',
'colorSchema',
'routerMode',
'aspectRatio',
'canvasWidth',
'themeConfig',
'favicon',
'plantUmlServer',
'fonts',
'defaults',
'drawings',
'htmlAttrs',
'mdc',
'contextMenu',
'wakeLock',
'overviewSnapshots',
]
+58
View File
@@ -0,0 +1,58 @@
import { injectLocal, objectOmit } from '@vueuse/core'
import { computed, ref, toRef } from 'vue'
import {
FRONTMATTER_FIELDS,
HEADMATTER_FIELDS,
injectionClicksContext,
injectionCurrentPage,
injectionFrontmatter,
injectionRenderContext,
injectionRoute,
injectionSlideScale,
injectionSlidevContext,
injectionSlideZoom,
} from './constants'
/**
* Get the current slide context, should be called inside the setup function of a component inside slide
*/
export function useSlideContext() {
const $slidev = injectLocal(injectionSlidevContext)!
const $nav = toRef($slidev, 'nav')
const $clicksContext = injectLocal(injectionClicksContext)!.value
const $clicks = toRef($clicksContext, 'current')
const $page = injectLocal(injectionCurrentPage)!
const $renderContext = injectLocal(injectionRenderContext)!
const $frontmatter = injectLocal(injectionFrontmatter, {})
const $route = injectLocal(injectionRoute, undefined)
const $scale = injectLocal(injectionSlideScale, ref(1))
const $zoom = injectLocal(injectionSlideZoom, computed(() => 1))
return {
$slidev,
$nav,
$clicksContext,
$clicks,
$page,
$route,
$renderContext,
$frontmatter,
$scale,
$zoom,
}
}
export type SlideContext = ReturnType<typeof useSlideContext>
/**
* Convert frontmatter options to props for v-bind
* It removes known options fields, and expose an extra `frontmatter` field that contains full frontmatter
*
* @internal
*/
export function frontmatterToProps(frontmatter: Record<string, any>, pageNo: number) {
return {
...objectOmit(frontmatter, pageNo === 0 ? HEADMATTER_FIELDS : FRONTMATTER_FIELDS),
frontmatter,
}
}
+22
View File
@@ -0,0 +1,22 @@
import configs from '#slidev/configs'
import { objectMap } from '@antfu/utils'
import { computed } from 'vue'
export { configs }
export const mode = __DEV__ ? 'dev' : 'build'
export const slideAspect = computed(() => configs.aspectRatio)
export const slideWidth = computed(() => configs.canvasWidth)
// To honor the aspect ratio more as possible, we need to approximate the height to the next integer.
// Doing this, we will prevent on print, to create an additional empty white page after each page.
export const slideHeight = computed(() => Math.ceil(slideWidth.value / slideAspect.value))
export const themeVars = computed(() => {
return objectMap(configs.themeConfig || {}, (k, v) => [`--slidev-theme-${k}`, v])
})
export const slidesTitle = configs.slidesTitle
export const pathPrefix = import.meta.env.BASE_URL + (__SLIDEV_HASH_ROUTE__ ? '#/' : '')
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- head -->
</head>
<body>
<div id="app"></div>
<script type="module" src="__ENTRY__"></script>
<div id="mermaid-rendering-container"></div>
<!-- body -->
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
/**
* This file is the public APIs that you might use in your app.
*
* The other files despite they are accessable, are not meant to be used directly, breaking changes might happen.
*/
export { useDarkMode } from './composables/useDarkMode'
export { useDrawings } from './composables/useDrawings'
export { useNav } from './composables/useNav'
export { useSlideContext } from './context'
export * from './env'
export * from './layoutHelper'
export {
onSlideEnter,
onSlideLeave,
useIsSlideActive,
} from './logic/slides'
export type { DrawingsState } from './state/drawings'
export { drawingState, onDrawingUpdate } from './state/drawings'
export type { SharedState } from './state/shared'
export { onSharedUpdate, sharedState } from './state/shared'
export {
addSyncMethod,
createSyncState,
disableBuiltinSync,
} from './state/syncState'
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { ClicksContext } from '@slidev/types'
import { clamp, range } from '@antfu/utils'
import { computed } from 'vue'
import { CLICKS_MAX } from '../constants'
const props = withDefaults(defineProps<{
clicksContext: ClicksContext
readonly?: boolean
active?: boolean
}>(), {
active: true,
})
const total = computed(() => props.clicksContext.total)
const start = computed(() => clamp(0, props.clicksContext.clicksStart, total.value))
const length = computed(() => total.value - start.value + 1)
const current = computed({
get() {
return props.clicksContext.current > total.value ? -1 : props.clicksContext.current
},
set(value: number) {
// eslint-disable-next-line vue/no-mutating-props
props.clicksContext.current = value
},
})
const clicksRange = computed(() => range(start.value, total.value + 1))
function onMousedown() {
if (props.readonly)
return
if (current.value < 0 || current.value > total.value)
current.value = 0
}
</script>
<template>
<div
class="flex gap-1 items-center select-none"
:title="`Clicks in this slide: ${length}`"
:class="length && props.clicksContext.isMounted ? '' : 'op50'"
>
<div class="flex gap-0.2 items-center min-w-16 font-mono mr1">
<div class="i-carbon:cursor-1 text-sm op50" />
<template v-if="current >= 0 && current !== CLICKS_MAX && active">
<div flex-auto />
<span text-primary>{{ current }}</span>
<span op25 text-sm>/</span>
<span op50 text-sm>{{ total }}</span>
</template>
<div
v-else
op50 flex-auto pl1
>
{{ total }}
</div>
</div>
<div
relative flex-auto h5 font-mono flex="~"
>
<div
v-for="i of clicksRange" :key="i"
border="y main" of-hidden relative
:class="[
i === 0 ? 'rounded-l border-l' : '',
i === total ? 'rounded-r border-r' : '',
]"
:style="{ width: length > 0 ? `${1 / length * 100}%` : '100%' }"
>
<div
absolute inset-0
:class="(i <= current && active) ? 'bg-primary op15' : ''"
/>
<div
:class="[
(+i === +current && active) ? 'text-primary font-bold op100 border-primary' : 'op30 border-main',
i === 0 ? 'rounded-l' : '',
i === total ? 'rounded-r' : 'border-r-2',
]"
w-full h-full text-xs flex items-center justify-center z-1
>
{{ i }}
</div>
</div>
<input
v-model="current"
class="range"
type="range" :min="start" :max="total" :step="1"
absolute inset-0 z-label op0
:class="readonly ? 'pointer-events-none' : ''"
:style="{ '--thumb-width': `${1 / (length + 1) * 100}%` }"
@mousedown="onMousedown"
@focus="event => (event.currentTarget as HTMLElement)?.blur()"
>
</div>
</div>
</template>
<style scoped>
.range {
-webkit-appearance: none;
appearance: none;
background: transparent;
}
.range::-webkit-slider-thumb {
-webkit-appearance: none;
height: 100%;
width: var(--thumb-width, 0.5rem);
}
.range::-moz-range-thumb {
height: 100%;
width: var(--thumb-width, 0.5rem);
}
</style>
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
import type { CodeRunnerOutputs, RawAtValue } from '@slidev/types'
import { debounce, toArray } from '@antfu/utils'
import { useVModel } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref, shallowRef, toValue, watch, watchSyncEffect } from 'vue'
import { useNav } from '../composables/useNav'
import { useSlideContext } from '../context'
import { makeId } from '../logic/utils'
import setupCodeRunners from '../setup/code-runners'
import DomElement from './DomElement.vue'
import IconButton from './IconButton.vue'
const props = defineProps<{
modelValue: string
lang: string
autorun: boolean | 'once'
height?: string
showOutputAt?: RawAtValue
highlightOutput: boolean
runnerOptions?: Record<string, unknown>
}>()
const emit = defineEmits(['update:modelValue'])
const { isPrintMode } = useNav()
const code = useVModel(props, 'modelValue', emit)
const { $renderContext, $clicksContext } = useSlideContext()
const disabled = computed(() => !['slide', 'presenter'].includes($renderContext.value))
const autorun = isPrintMode.value ? 'once' : props.autorun
const isRunning = ref(autorun)
const outputs = shallowRef<CodeRunnerOutputs>()
const runCount = ref(0)
const highlightFn = ref<(code: string, lang: string) => string>()
const hidden = ref(props.showOutputAt)
if (props.showOutputAt) {
const id = makeId()
onMounted(() => {
const info = $clicksContext.calculate(props.showOutputAt)
if (info) {
$clicksContext.register(id, info)
watchSyncEffect(() => {
hidden.value = !info.isActive.value
})
}
else {
hidden.value = false
}
})
onUnmounted(() => $clicksContext.unregister(id))
}
const triggerRun = debounce(200, async () => {
if (disabled.value)
return
const { highlight, run } = await setupCodeRunners()
highlightFn.value = highlight
const setAsRunning = setTimeout(() => {
isRunning.value = true
}, 500)
outputs.value = await run(code.value, props.lang, props.runnerOptions ?? {})
runCount.value += 1
isRunning.value = false
clearTimeout(setAsRunning)
})
if (autorun === 'once')
triggerRun()
else if (autorun)
watch(code, triggerRun, { immediate: true })
</script>
<template>
<div
v-show="!hidden"
class="relative flex flex-col rounded-b border-t border-main"
:style="{ height: props.height }"
data-waitfor=".slidev-runner-output"
>
<div v-if="disabled" class="text-sm text-center opacity-50">
Code is disabled in the "{{ $renderContext }}" mode
</div>
<div v-else-if="isRunning" class="text-sm text-center opacity-50">
Running...
</div>
<div v-else-if="!outputs" class="text-sm text-center opacity-50">
Click the play button to run the code
</div>
<div v-else :key="`run-${runCount}`" class="slidev-runner-output">
<template v-for="output, _idx1 of toArray(toValue(outputs))" :key="_idx1">
<div v-if="'html' in output" v-html="output.html" />
<div v-else-if="'error' in output" class="text-red-500">
{{ output.error }}
</div>
<DomElement v-else-if="'element' in output" :element="output.element" />
<div v-else class="output-line">
<template
v-for="item, idx2 in toArray(output)"
:key="idx2"
>
<span
v-if="item.highlightLang && highlightFn"
class="highlighted"
v-html="highlightFn(item.text, item.highlightLang)"
/>
<span v-else :class="item.class">{{ item.text }}</span>
<span v-if="idx2 < toArray(output).length - 1" class="separator">,</span>
</template>
</div>
</template>
</div>
</div>
<div v-if="code.trim()" class="absolute right-1 top-1 max-h-full flex gap-1">
<IconButton class="w-8 h-8 max-h-full flex justify-center items-center" title="Run code" @click="triggerRun">
<div class="i-carbon:play" />
</IconButton>
</div>
</template>
<style lang="postcss">
.slidev-runner-output {
@apply px-5 py-3 flex-grow text-xs leading-[.8rem] font-$slidev-code-font-family select-text;
}
.slidev-runner-output .log-type {
@apply font-bold op-70;
&.DBG {
@apply text-gray-500;
}
&.LOG {
@apply text-blue-500;
}
&.WRN {
@apply text-orange-500;
}
&.ERR {
@apply text-red-500;
}
}
.slidev-runner-output .output-line {
@apply flex my-1 w-full;
}
.slidev-runner-output .separator {
@apply op-40 mr-1;
}
.slidev-runner-output .highlighted > pre {
@apply inline text-wrap !bg-transparent;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { onClickOutside, useElementBounding, useEventListener, useWindowFocus } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useDynamicSlideInfo } from '../composables/useSlideInfo'
import { configs } from '../env'
import { closeContextMenu, currentContextMenu } from '../logic/contextMenu'
import { windowSize } from '../state'
const container = ref<HTMLElement>()
onClickOutside(container, closeContextMenu)
useEventListener(document, 'mousedown', (ev) => {
if (ev.buttons & 2)
closeContextMenu()
}, {
passive: true,
capture: true,
})
const isExplicitEnabled = computed(() => configs.contextMenu != null)
const windowFocus = useWindowFocus()
watch(windowFocus, (hasFocus) => {
if (!hasFocus)
closeContextMenu()
})
const firstSlide = useDynamicSlideInfo(1)
function disableContextMenu() {
const info = firstSlide.info.value
if (!info)
return
firstSlide.update({
frontmatter: {
contextMenu: false,
},
})
}
const { width, height } = useElementBounding(container)
const left = computed(() => {
const x = currentContextMenu.value?.x
if (!x)
return 0
if (x + width.value > windowSize.width.value)
return windowSize.width.value - width.value
return x
})
const top = computed(() => {
const y = currentContextMenu.value?.y
if (!y)
return 0
if (y + height.value > windowSize.height.value)
return windowSize.height.value - height.value
return y
})
</script>
<template>
<div
v-if="currentContextMenu"
ref="container"
:style="`left:${left}px;top:${top}px`"
class="slidev-glass-effect fixed z-context-menu w-60 flex flex-wrap justify-items-start p-1 animate-fade-in animate-duration-100 rounded-md shadow overflow-hidden select-none"
@contextmenu.prevent=""
@click="closeContextMenu"
>
<template v-for="item, index of currentContextMenu.items.value" :key="index">
<div v-if="item === 'separator'" :key="index" class="w-full my1 border-t border-main" />
<div
v-else-if="item.small"
class="p-2 w-[40px] h-[40px] inline-block text-center cursor-pointer rounded flex"
:class="item.disabled ? `op40` : `hover:bg-active`"
:title="(item.label as string)"
@click="item.action"
>
<div v-if="typeof item.icon === 'string'" :class="item.icon" class="text-1.2em ma" />
<component :is="item.icon" v-else />
</div>
<div
v-else
class="w-full grid grid-cols-[35px_1fr] p-2 pl-0 cursor-pointer rounded"
:class="item.disabled ? `op40` : `hover:bg-active`"
@click="item.action"
>
<div class="mx-auto flex">
<div v-if="typeof item.icon === 'string'" :class="item.icon" class="text-1.2em ma" />
<component :is="item.icon" v-else />
</div>
<div v-if="typeof item.label === 'string'">
{{ item.label }}
</div>
<component :is="item.label" v-else />
</div>
</template>
<template v-if="!isExplicitEnabled">
<div class="w-full my1 border-t border-main" />
<div class="w-full text-xs p2">
<div class="text-main text-opacity-50!">
Hold <kbd class="border px1 py0.5 border-main rounded text-primary">Shift</kbd> and right click to open the native context menu
<button
v-if="__DEV__"
class="underline op50 hover:op100 mt1 block"
@click="disableContextMenu()"
>
Disable custom context menu
</button>
</div>
</div>
</template>
</div>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { shallowRef } from 'vue'
import { useNav } from '../composables/useNav'
import { configs } from '../env'
import { showInfoDialog, showRecordingDialog } from '../state'
import ContextMenu from './ContextMenu.vue'
import Goto from './Goto.vue'
import InfoDialog from './InfoDialog.vue'
import QuickOverview from './QuickOverview.vue'
const { isEmbedded } = useNav()
const drawingEnabled = __SLIDEV_FEATURE_DRAWINGS__ && !configs.drawings.presenterOnly && !isEmbedded.value
const DrawingControls = shallowRef<any>()
if (drawingEnabled)
import('../internals/DrawingControls.vue').then(v => DrawingControls.value = v.default)
const WebCamera = shallowRef<any>()
const RecordingDialog = shallowRef<any>()
if (__SLIDEV_FEATURE_RECORD__) {
import('./WebCamera.vue').then(v => WebCamera.value = v.default)
import('./RecordingDialog.vue').then(v => RecordingDialog.value = v.default)
}
</script>
<template>
<DrawingControls v-if="DrawingControls" />
<QuickOverview />
<Goto />
<WebCamera v-if="WebCamera" />
<RecordingDialog v-if="RecordingDialog" v-model="showRecordingDialog" />
<InfoDialog v-if="configs.info" v-model="showInfoDialog" />
<ContextMenu />
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { SelectionItem } from './types'
import { computed } from 'vue'
import {
cameras,
ensureDevicesListPermissions,
microphones,
mimeExtMap,
mimeType,
supportedMimeTypes,
} from '../logic/recording'
import { currentCamera, currentMic } from '../state'
import SelectList from './SelectList.vue'
const camerasItems = computed<SelectionItem<string>[]>(() => [
{
value: 'none',
display: 'None',
},
...cameras.value.map(i => ({
value: i.deviceId,
display: i.label,
})),
])
const microphonesItems = computed<SelectionItem<string>[]>(() => [
{
value: 'none',
display: 'None',
},
...microphones.value.map(i => ({
value: i.deviceId,
display: i.label,
})),
])
const mimeTypeItems = supportedMimeTypes.map(mime => ({
value: mime,
display: mimeExtMap[mime].toUpperCase(),
}))
ensureDevicesListPermissions()
</script>
<template>
<div class="text-sm">
<SelectList v-model="currentCamera" title="Camera" :items="camerasItems" />
<SelectList v-model="currentMic" title="Microphone" :items="microphonesItems" />
<SelectList
v-if="mimeTypeItems.length"
v-model="mimeType"
title="mimeType"
:items="mimeTypeItems"
/>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
const props = defineProps<{
element: HTMLElement
}>()
const container = ref<HTMLElement>()
watchEffect(() => {
if (container.value)
container.value.appendChild(props.element)
})
</script>
<template>
<div ref="container" />
</template>
+405
View File
@@ -0,0 +1,405 @@
<script setup lang="ts">
import type { Pausable } from '@vueuse/core'
import type { DragElementState } from '../composables/useDragElements'
import { clamp } from '@antfu/utils'
import { useIntervalFn } from '@vueuse/core'
import { computed, inject, ref, watchEffect } from 'vue'
import { useSlideBounds } from '../composables/useSlideBounds'
import { injectionSlideScale } from '../constants'
import { slideHeight, slideWidth } from '../env'
import { magicKeys } from '../state'
const { data } = defineProps<{ data: DragElementState }>()
const { dragId, zoom, autoHeight, x0, y0, width, height, rotate, isArrow } = data
const slideScale = inject(injectionSlideScale, ref(1))
const scale = computed(() => slideScale.value * zoom.value)
const { left: slideLeft, top: slideTop } = useSlideBounds()
const ctrlSize = 10
const minSize = isArrow ? Number.NEGATIVE_INFINITY : 40
const minRemain = 10
const rotateRad = computed(() => rotate.value * Math.PI / 180)
const rotateSin = computed(() => Math.sin(rotateRad.value))
const rotateCos = computed(() => Math.cos(rotateRad.value))
const boundingWidth = computed(() => width.value * rotateCos.value + height.value * rotateSin.value)
const boundingHeight = computed(() => width.value * rotateSin.value + height.value * rotateCos.value)
const boundingLeft = computed(() => x0.value - boundingWidth.value / 2)
const boundingTop = computed(() => y0.value - boundingHeight.value / 2)
const boundingRight = computed(() => x0.value + boundingWidth.value / 2)
const boundingBottom = computed(() => y0.value + boundingHeight.value / 2)
const arrowRevX = computed(() => isArrow && width.value < 0)
const arrowRevY = computed(() => isArrow && height.value < 0)
let currentDrag: {
x0: number
y0: number
width: number
height: number
rotate: number
dx0: number
dy0: number
ltx: number
lty: number
rtx: number
rty: number
lbx: number
lby: number
rbx: number
rby: number
} | null = null
function onPointerdown(ev: PointerEvent) {
if (ev.buttons !== 1)
return
ev.preventDefault()
ev.stopPropagation()
const el = ev.target as HTMLElement
const elBounds = el.getBoundingClientRect()
const cross1x = width.value * rotateCos.value - height.value * rotateSin.value
const cross1y = width.value * rotateSin.value + height.value * rotateCos.value
const cross2x = width.value * rotateCos.value + height.value * rotateSin.value
const cross2y = -width.value * rotateSin.value + height.value * rotateCos.value
currentDrag = {
x0: x0.value,
y0: y0.value,
width: width.value,
height: height.value,
rotate: rotate.value,
dx0: ev.clientX - (elBounds.left + elBounds.right) / 2,
dy0: ev.clientY - (elBounds.top + elBounds.bottom) / 2,
ltx: x0.value - cross1x / 2,
lty: y0.value - cross1y / 2,
rtx: x0.value + cross2x / 2,
rty: y0.value - cross2y / 2,
lbx: x0.value - cross2x / 2,
lby: y0.value + cross2y / 2,
rbx: x0.value + cross1x / 2,
rby: y0.value + cross1y / 2,
};
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId)
}
function onPointermove(ev: PointerEvent) {
if (!currentDrag || ev.buttons !== 1)
return
ev.preventDefault()
ev.stopPropagation()
const x = (ev.clientX - slideLeft.value - currentDrag.dx0) / scale.value
const y = (ev.clientY - slideTop.value - currentDrag.dy0) / scale.value
x0.value = clamp(x, -boundingWidth.value / 2 + minRemain, slideWidth.value + boundingWidth.value / 2 - minRemain)
y0.value = clamp(y, -boundingHeight.value / 2 + minRemain, slideHeight.value + boundingHeight.value / 2 - minRemain)
}
function onPointerup(ev: PointerEvent) {
if (!currentDrag)
return
ev.preventDefault()
ev.stopPropagation()
currentDrag = null
}
const ctrlClasses = `absolute border border-gray bg-gray dark:border-gray-500 dark:bg-gray-800 bg-opacity-30 `
function getCornerProps(isLeft: boolean, isTop: boolean) {
return {
onPointerdown,
onPointermove: (ev: PointerEvent) => {
if (!currentDrag || ev.buttons !== 1)
return
ev.preventDefault()
ev.stopPropagation()
let x = (ev.clientX - slideLeft.value) / scale.value
let y = (ev.clientY - slideTop.value) / scale.value
const { ltx, lty, rtx, rty, lbx, lby, rbx, rby } = currentDrag
const ratio = currentDrag.width / currentDrag.height
const wMin = Math.max(minSize, minSize * ratio)
function getSize(w1: number, h1: number) {
if (ev.shiftKey) {
const w = Math.max(w1, h1 * ratio, wMin)
const h = w / ratio
return { w, h }
}
else {
return { w: Math.max(w1, minSize), h: Math.max(h1, minSize) }
}
}
if (isLeft) {
if (isTop) {
const w1 = (rbx - x) * rotateCos.value + (rby - y) * rotateSin.value
const h1 = -(rbx - x) * rotateSin.value + (rby - y) * rotateCos.value
const { w, h } = getSize(w1, h1)
x = rbx - w * rotateCos.value + h * rotateSin.value
y = rby - w * rotateSin.value - h * rotateCos.value
}
else {
const w1 = (rtx - x) * rotateCos.value - (y - rty) * rotateSin.value
const h1 = (rtx - x) * rotateSin.value + (y - rty) * rotateCos.value
const { w, h } = getSize(w1, h1)
x = rtx - w * rotateCos.value - h * rotateSin.value
y = rty - w * rotateSin.value + h * rotateCos.value
}
}
else {
if (isTop) {
const w1 = (x - lbx) * rotateCos.value - (lby - y) * rotateSin.value
const h1 = (x - lbx) * rotateSin.value + (lby - y) * rotateCos.value
const { w, h } = getSize(w1, h1)
x = lbx + w * rotateCos.value + h * rotateSin.value
y = lby + w * rotateSin.value - h * rotateCos.value
}
else {
const w1 = (x - ltx) * rotateCos.value + (y - lty) * rotateSin.value
const h1 = -(x - ltx) * rotateSin.value + (y - lty) * rotateCos.value
const { w, h } = getSize(w1, h1)
x = ltx + w * rotateCos.value - h * rotateSin.value
y = lty + w * rotateSin.value + h * rotateCos.value
}
}
if (isLeft) {
if (isTop) {
x0.value = (x + rbx) / 2
y0.value = (y + rby) / 2
width.value = (rbx - x) * rotateCos.value + (rby - y) * rotateSin.value
height.value = -(rbx - x) * rotateSin.value + (rby - y) * rotateCos.value
}
else {
x0.value = (x + rtx) / 2
y0.value = (y + rty) / 2
width.value = (rtx - x) * rotateCos.value - (y - rty) * rotateSin.value
height.value = (rtx - x) * rotateSin.value + (y - rty) * rotateCos.value
}
}
else {
if (isTop) {
x0.value = (x + lbx) / 2
y0.value = (y + lby) / 2
width.value = (x - lbx) * rotateCos.value - (lby - y) * rotateSin.value
height.value = (x - lbx) * rotateSin.value + (lby - y) * rotateCos.value
}
else {
x0.value = (x + ltx) / 2
y0.value = (y + lty) / 2
width.value = (x - ltx) * rotateCos.value + (y - lty) * rotateSin.value
height.value = -(x - ltx) * rotateSin.value + (y - lty) * rotateCos.value
}
}
},
onPointerup,
style: {
width: `${ctrlSize}px`,
height: `${ctrlSize}px`,
margin: `-${ctrlSize / 2}px`,
left: isLeft !== arrowRevX.value ? '0' : undefined,
right: isLeft !== arrowRevX.value ? undefined : '0',
top: isTop !== arrowRevY.value ? '0' : undefined,
bottom: isTop !== arrowRevY.value ? undefined : '0',
cursor: isArrow ? 'move' : +isLeft + +isTop === 1 ? 'nesw-resize' : 'nwse-resize',
borderRadius: isArrow ? '50%' : undefined,
},
class: ctrlClasses,
}
}
function getBorderProps(dir: 'l' | 'r' | 't' | 'b') {
return {
onPointerdown,
onPointermove: (ev: PointerEvent) => {
if (!currentDrag || ev.buttons !== 1)
return
ev.preventDefault()
ev.stopPropagation()
const x = (ev.clientX - slideLeft.value) / scale.value
const y = (ev.clientY - slideTop.value) / scale.value
const { ltx, lty, rtx, rty, lbx, lby, rbx, rby } = currentDrag
if (dir === 'l') {
const rx = (rtx + rbx) / 2
const ry = (rty + rby) / 2
width.value = Math.max((rx - x) * rotateCos.value + (ry - y) * rotateSin.value, minSize)
x0.value = rx - width.value * rotateCos.value / 2
y0.value = ry - width.value * rotateSin.value / 2
}
else if (dir === 'r') {
const lx = (ltx + lbx) / 2
const ly = (lty + lby) / 2
width.value = Math.max((x - lx) * rotateCos.value + (y - ly) * rotateSin.value, minSize)
x0.value = lx + width.value * rotateCos.value / 2
y0.value = ly + width.value * rotateSin.value / 2
}
else if (dir === 't') {
const bx = (lbx + rbx) / 2
const by = (lby + rby) / 2
height.value = Math.max((by - y) * rotateCos.value - (bx - x) * rotateSin.value, minSize)
x0.value = bx + height.value * rotateSin.value / 2
y0.value = by - height.value * rotateCos.value / 2
}
else if (dir === 'b') {
const tx = (ltx + rtx) / 2
const ty = (lty + rty) / 2
height.value = Math.max((y - ty) * rotateCos.value - (x - tx) * rotateSin.value, minSize)
x0.value = tx - height.value * rotateSin.value / 2
y0.value = ty + height.value * rotateCos.value / 2
}
},
onPointerup,
style: {
width: `${ctrlSize}px`,
height: `${ctrlSize}px`,
margin: `-${ctrlSize / 2}px`,
left: dir === 'l' ? '0' : dir === 'r' ? `100%` : `50%`,
top: dir === 't' ? '0' : dir === 'b' ? `100%` : `50%`,
cursor: 'lr'.includes(dir) ? 'ew-resize' : 'ns-resize',
borderRadius: '50%',
},
class: ctrlClasses,
}
}
function getRotateProps() {
return {
onPointerdown,
onPointermove: (ev: PointerEvent) => {
if (!currentDrag || ev.buttons !== 1)
return
ev.preventDefault()
ev.stopPropagation()
const x = (ev.clientX - slideLeft.value - currentDrag.dx0) / scale.value - ctrlSize / 4
const y = (ev.clientY - slideTop.value - currentDrag.dy0) / scale.value - ctrlSize / 4
let angle = Math.atan2(y - y0.value, x - x0.value) * 180 / Math.PI + 90
const commonAngles = [0, 90, 180, 270, 360]
for (const a of commonAngles) {
if (Math.abs(angle - a) < 5) {
angle = a % 360
break
}
}
rotate.value = angle
},
onPointerup,
style: {
width: `${ctrlSize}px`,
height: `${ctrlSize}px`,
margin: `-${ctrlSize / 2}px`,
left: '50%',
top: '-20px',
cursor: 'grab',
borderRadius: '50%',
},
class: ctrlClasses,
}
}
const moveInterval = 20
const intervalFnOptions = {
immediate: false,
immediateCallback: false,
}
const moveLeft = useIntervalFn(() => {
if (boundingRight.value <= minRemain)
return
x0.value--
}, moveInterval, intervalFnOptions)
const moveRight = useIntervalFn(() => {
if (boundingLeft.value >= slideWidth.value - minRemain)
return
x0.value++
}, moveInterval, intervalFnOptions)
const moveUp = useIntervalFn(() => {
if (boundingBottom.value <= minRemain)
return
y0.value--
}, moveInterval, intervalFnOptions)
const moveDown = useIntervalFn(() => {
if (boundingTop.value >= slideHeight.value - minRemain)
return
y0.value++
}, moveInterval, intervalFnOptions)
watchEffect(() => {
function shortcut(key: string, fn: Pausable) {
if (magicKeys[key].value)
fn.resume()
else fn.pause()
}
shortcut('left', moveLeft)
shortcut('right', moveRight)
shortcut('up', moveUp)
shortcut('down', moveDown)
})
</script>
<template>
<div
v-if="Number.isFinite(x0)"
id="drag-control-container"
:data-drag-id="dragId"
:style="{
position: 'absolute',
zIndex: 100,
left: `${zoom * (x0 - Math.abs(width) / 2)}px`,
top: `${zoom * (y0 - Math.abs(height) / 2)}px`,
width: `${zoom * Math.abs(width)}px`,
height: `${zoom * Math.abs(height)}px`,
transformOrigin: 'center center',
transform: `rotate(${rotate}deg)`,
}"
@pointerdown="onPointerdown"
@pointermove="onPointermove"
@pointerup="onPointerup"
>
<div class="absolute inset-0 z-nav dark:b-gray-400" :class="isArrow ? '' : 'b b-dark'">
<template v-if="!autoHeight">
<div v-bind="getCornerProps(true, true)" />
<div v-bind="getCornerProps(false, false)" />
<template v-if="!isArrow">
<div v-bind="getCornerProps(true, false)" />
<div v-bind="getCornerProps(false, true)" />
</template>
</template>
<template v-if="!isArrow">
<div v-bind="getBorderProps('l')" />
<div v-bind="getBorderProps('r')" />
<template v-if="!autoHeight">
<div v-bind="getBorderProps('t')" />
<div v-bind="getBorderProps('b')" />
</template>
<div v-bind="getRotateProps()" />
<div
class="absolute -top-15px w-0 b b-dashed b-dark dark:b-gray-400"
:style="{
left: 'calc(50% - 1px)',
height: autoHeight ? '14px' : '10px',
}"
/>
</template>
</div>
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { useDraggable, useLocalStorage } from '@vueuse/core'
import { ref } from 'vue'
const props = defineProps<{
storageKey?: string
initial?: { x: number, y: number }
}>()
const el = ref<HTMLElement | null>(null)
const initial = props.initial ?? { x: 0, y: 0 }
const point = props.storageKey
? useLocalStorage(props.storageKey, initial)
: ref(initial)
const { style } = useDraggable(el, { initialValue: point })
</script>
<template>
<div ref="el" class="fixed" :style="style">
<slot />
</div>
</template>
+140
View File
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { Menu } from 'floating-vue'
import { useDrawings } from '../composables/useDrawings'
import Draggable from './Draggable.vue'
import IconButton from './IconButton.vue'
import VerticalDivider from './VerticalDivider.vue'
const {
brush,
canClear,
canRedo,
canUndo,
clear,
drauu,
drawingEnabled,
drawingMode,
drawingPinned,
brushColors,
} = useDrawings()
function undo() {
drauu.undo()
}
function redo() {
drauu.redo()
}
let lastDrawingMode: typeof drawingMode.value = 'stylus'
function setDrawingMode(mode: typeof drawingMode.value) {
drawingMode.value = mode
drawingEnabled.value = true
if (mode !== 'eraseLine')
lastDrawingMode = mode
}
function setBrushColor(color: typeof brush.color) {
brush.color = color
drawingEnabled.value = true
drawingMode.value = lastDrawingMode
}
</script>
<template>
<Draggable
v-if="drawingEnabled || drawingPinned"
class="flex flex-wrap text-xl p-2 gap-1 rounded-md bg-main shadow transition-opacity duration-200 z-nav border border-main"
:class="!drawingEnabled && drawingPinned ? 'opacity-40 hover:opacity-90' : ''"
storage-key="slidev-drawing-pos"
:initial-x="10"
:initial-y="10"
>
<IconButton title="Draw with stylus" :class="{ shallow: drawingMode !== 'stylus' }" @click="setDrawingMode('stylus')">
<div class="i-carbon:pen" />
</IconButton>
<IconButton title="Draw a line" :class="{ shallow: drawingMode !== 'line' }" @click="setDrawingMode('line')">
<svg width="1em" height="1em" class="-mt-0.5" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24">
<path d="M21.71 3.29a1 1 0 0 0-1.42 0l-18 18a1 1 0 0 0 0 1.42a1 1 0 0 0 1.42 0l18-18a1 1 0 0 0 0-1.42z" fill="currentColor" />
</svg>
</IconButton>
<IconButton title="Draw an arrow" :class="{ shallow: drawingMode !== 'arrow' }" @click="setDrawingMode('arrow')">
<div class="i-carbon:arrow-up-right" />
</IconButton>
<IconButton title="Draw an ellipse" :class="{ shallow: drawingMode !== 'ellipse' }" @click="setDrawingMode('ellipse')">
<div class="i-carbon:radio-button" />
</IconButton>
<IconButton title="Draw a rectangle" :class="{ shallow: drawingMode !== 'rectangle' }" @click="setDrawingMode('rectangle')">
<div class="i-carbon:checkbox" />
</IconButton>
<IconButton title="Erase" :class="{ shallow: drawingMode !== 'eraseLine' }" @click="setDrawingMode('eraseLine')">
<div class="i-carbon:erase" />
</IconButton>
<VerticalDivider />
<Menu>
<IconButton title="Adjust stroke width" :class="{ shallow: drawingMode === 'eraseLine' }">
<svg viewBox="0 0 32 32" width="1.2em" height="1.2em">
<line x1="2" y1="15" x2="22" y2="4" stroke="currentColor" stroke-width="1" stroke-linecap="round" />
<line x1="2" y1="24" x2="28" y2="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="7" y1="31" x2="29" y2="19" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
</svg>
</IconButton>
<template #popper>
<div class="flex bg-main p-2">
<div class="inline-block w-7 text-center">
{{ brush.size }}
</div>
<div class="pt-.5">
<input v-model="brush.size" type="range" min="1" max="15" @change="drawingMode = lastDrawingMode">
</div>
</div>
</template>
</Menu>
<IconButton
v-for="color of brushColors"
:key="color"
title="Set brush color"
:class="brush.color === color && drawingMode !== 'eraseLine' ? 'active' : 'shallow'"
@click="setBrushColor(color)"
>
<div
class="w-6 h-6 transition-all transform border"
:class="brush.color !== color ? 'rounded-1/2 scale-85 border-white' : 'rounded-md border-gray-300/50'"
:style="drawingEnabled ? { background: color } : { borderColor: color }"
/>
</IconButton>
<VerticalDivider />
<IconButton title="Undo" :class="{ disabled: !canUndo }" @click="undo()">
<div class="i-carbon:undo" />
</IconButton>
<IconButton title="Redo" :class="{ disabled: !canRedo }" @click="redo()">
<div class="i-carbon:redo" />
</IconButton>
<IconButton title="Delete" :class="{ disabled: !canClear }" @click="clear()">
<div class="i-carbon:trash-can" />
</IconButton>
<VerticalDivider />
<IconButton :title="drawingPinned ? 'Unpin drawing' : 'Pin drawing'" :class="{ shallow: !drawingPinned }" @click="drawingPinned = !drawingPinned">
<div v-show="drawingPinned" class="i-carbon:pin-filled transform -rotate-45" />
<div v-show="!drawingPinned" class="i-carbon:pin" />
</IconButton>
<IconButton
v-if="drawingEnabled"
:title="drawingPinned ? 'Drawing pinned' : 'Drawing unpinned'"
:class="{ shallow: !drawingEnabled }"
@click="drawingEnabled = !drawingEnabled"
>
<div v-show="drawingPinned" class="i-carbon:error" />
<div v-show="!drawingPinned" class="i-carbon:close-outline" />
</IconButton>
</Draggable>
</template>
<style>
.v-popper--theme-menu .v-popper__arrow-inner {
--uno: border-main;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useDrawings } from '../composables/useDrawings'
import { useSlideContext } from '../context'
const { drauu, drawingEnabled, loadCanvas } = useDrawings()
const scale = useSlideContext().$scale
const svg = ref<SVGSVGElement>()
onMounted(() => {
drauu.mount(svg.value!, svg.value!.parentElement!)
watch(scale, scale => drauu.options.coordinateScale = 1 / scale, { immediate: true })
loadCanvas()
})
onBeforeUnmount(() => {
drauu.unmount()
})
</script>
<template>
<svg
ref="svg"
class="w-full h-full absolute top-0"
:class="{ 'pointer-events-none': !drawingEnabled, 'touch-none': drawingEnabled }"
/>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useDrawings } from '../composables/useDrawings'
defineProps<{ page: number }>()
const { drawingState } = useDrawings()
</script>
<template>
<svg
v-if="drawingState[page]"
class="w-full h-full absolute top-0 pointer-events-none"
v-html="drawingState[page]"
/>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { skipExportPdfTip } from '../state'
import Modal from './Modal.vue'
const props = defineProps({
modelValue: {
default: false,
},
})
const emit = defineEmits(['update:modelValue', 'print'])
const value = useVModel(props, 'modelValue', emit)
function print() {
value.value = false
emit('print')
}
</script>
<template>
<Modal v-model="value" class="px-6 py-4 flex flex-col gap-2">
<div class="flex gap-2 text-xl">
<div class="i-carbon:information my-auto" /> Tips
</div>
<div>
Slidev will open your browser's built-in print dialog to export the slides as PDF. <br>
In the print dialog, please:
<ul class="list-disc my-4 pl-4">
<li>
Choose "Save as PDF" as the Destination.
<span class="op-70 text-xs"> (Not "Microsoft Print to PDF") </span>
</li>
<li> Choose "Default" as the Margin. </li>
<li> Toggle on "Print backgrounds". </li>
</ul>
<div class="mb-2 op-70 text-sm">
If you're encountering problems, please try
<a href="https://sli.dev/builtin/cli#export"> the CLI </a>
or
<a href="https://github.com/slidevjs/slidev/issues/new"> open an issue</a>.
</div>
<div class="form-check op-70">
<input
v-model="skipExportPdfTip"
name="record-camera"
type="checkbox"
>
<label for="record-camera" @click="skipExportPdfTip = !skipExportPdfTip">Don't show this dialog next time.</label>
</div>
</div>
<div class="flex my-1">
<button class="cancel" @click="value = false">
Cancel
</button>
<div class="flex-auto" />
<button @click="print">
Start
</button>
</div>
</Modal>
</template>
<style scoped>
button {
@apply bg-blue-400 text-white px-4 py-1 rounded border-b-2 border-blue-600;
@apply hover:(bg-blue-500 border-blue-700);
}
button.cancel {
@apply bg-gray-400 bg-opacity-50 text-white px-4 py-1 rounded border-b-2 border-main;
@apply hover:(bg-opacity-75 border-opacity-75);
}
a {
@apply border-current border-b border-dashed hover:text-primary hover:border-solid;
}
.form-check {
@apply leading-5;
* {
@apply my-auto align-middle;
}
label {
@apply ml-1 text-sm select-none;
}
}
</style>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{
disabled?: boolean
}>()
const value = defineModel<boolean>('modelValue', {
type: Boolean,
})
</script>
<template>
<div border="~ main rounded" flex="~ gap-2 items-center" relative h-5 w-5 p0.5 hover:bg-active p1>
<div i-ri-check-line :class="value ? '' : 'op0'" />
<input v-model="value" type="checkbox" absolute inset-0 opacity-0.1 :disabled="disabled">
</div>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { Tooltip } from 'floating-vue'
defineProps<{
title: string
nested?: boolean | number
div?: boolean
description?: string
}>()
const emit = defineEmits<{
(event: 'reset'): void
}>()
function reset() {
emit('reset')
}
</script>
<template>
<component :is="div ? 'div' : 'label'" flex="~ row gap-2 items-center" select-none>
<div w-30 h-10 flex="~ gap-1 items-center">
<div
v-if="nested" i-ri-corner-down-right-line op40
:style="typeof nested === 'number' ? { marginLeft: `${nested * 0.5 + 0.5}rem` } : { marginLeft: '0.25rem' }"
/>
<div v-if="!description" op75 @dblclick="reset">
{{ title }}
</div>
<Tooltip v-else distance="10">
<div op75 text-right @dblclick="reset">
{{ title }}
</div>
<template #popper>
<div text-sm min-w-90 v-html="description" />
</template>
</Tooltip>
</div>
<slot />
</component>
</template>
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import TitleRenderer from '#slidev/title-renderer'
import Fuse from 'fuse.js'
import { computed, ref, watch } from 'vue'
import { useNav } from '../composables/useNav'
import { activeElement, showGotoDialog } from '../state'
const container = ref<HTMLDivElement>()
const input = ref<HTMLInputElement>()
const list = ref<HTMLUListElement>()
const items = ref<HTMLLIElement[]>()
const text = ref('')
const selectedIndex = ref(0)
const { go, slides } = useNav()
function notNull<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined
}
const fuse = computed(() => new Fuse(slides.value.map(i => i.meta?.slide).filter(notNull), {
keys: ['no', 'title'],
threshold: 0.3,
shouldSort: true,
minMatchCharLength: 1,
}))
const path = computed(() => text.value.startsWith('/') ? text.value.substring(1) : text.value)
const result = computed(() => fuse.value.search(path.value).map(result => result.item))
const valid = computed(() => !!result.value.length)
function goTo() {
if (valid.value) {
const item = result.value.at(selectedIndex.value || 0)
if (item)
go(item.no)
}
close()
}
function close() {
text.value = ''
showGotoDialog.value = false
}
function focusDown(event: Event) {
event.preventDefault()
selectedIndex.value++
if (selectedIndex.value >= result.value.length)
selectedIndex.value = 0
scroll()
}
function focusUp(event: Event) {
event.preventDefault()
selectedIndex.value--
if (selectedIndex.value <= -2)
selectedIndex.value = result.value.length - 1
scroll()
}
function scroll() {
const item = items.value?.[selectedIndex.value]
if (item && list.value) {
if (item.offsetTop + item.offsetHeight > list.value.offsetHeight + list.value.scrollTop) {
list.value.scrollTo({
behavior: 'smooth',
top: item.offsetTop + item.offsetHeight - list.value.offsetHeight + 1,
})
}
else if (item.offsetTop < list.value.scrollTop) {
list.value.scrollTo({
behavior: 'smooth',
top: item.offsetTop,
})
}
}
}
function updateText(event: Event) {
selectedIndex.value = 0
text.value = (event.target as HTMLInputElement).value
}
function select(no: number) {
go(no)
close()
}
watch(showGotoDialog, async (show) => {
if (show) {
text.value = ''
selectedIndex.value = 0
// delay the focus to avoid the g character coming from the key that triggered showGotoDialog
setTimeout(() => input.value?.focus(), 0)
}
else {
input.value?.blur()
}
})
watch(activeElement, () => {
if (!container.value?.contains(activeElement.value as Node))
close()
})
</script>
<template>
<div
id="slidev-goto-dialog"
ref="container"
class="fixed right-5 transition-all"
w-90 max-w-90 min-w-90
:class="showGotoDialog ? 'top-5' : '-top-20'"
>
<div
class="bg-main transform"
shadow="~"
p="x-4 y-2"
border="~ transparent rounded dark:main"
>
<input
id="slidev-goto-input"
ref="input"
:value="text"
type="text"
:disabled="!showGotoDialog"
class="outline-none bg-transparent"
placeholder="Goto..."
:class="{ 'text-red-400': !valid && text }"
@keydown.enter="goTo"
@keydown.escape="close"
@keydown.down="focusDown"
@keydown.up="focusUp"
@input="updateText"
>
</div>
<div
v-if="result.length > 0"
ref="list"
class="autocomplete-list"
shadow="~"
border="~ transparent rounded dark:main"
>
<ul table w-full border-collapse>
<li
v-for="(item, index) of result"
ref="items"
:key="item.id"
role="button"
tabindex="0"
cursor-pointer
hover="op100"
table-row
items-center
:border="index === 0 ? undefined : 't main'"
:class="selectedIndex === index ? 'bg-active op100' : 'op80'"
@click.stop.prevent="select(item.no)"
>
<div text-right op50 text-sm table-cell py-2 pl-4 pr-3 vertical-middle>
{{ item.no }}
</div>
<TitleRenderer table-cell py-2 pr-4 w-full :no="item.no" />
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.autocomplete-list {
--uno: bg-main mt-1;
overflow: auto;
max-height: calc(100vh - 100px);
}
.autocomplete {
cursor: pointer;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
title: string
icon?: string
as?: string
to?: string
}>()
const type = computed(() => props.as || (props.to ? 'router-link' : 'button'))
</script>
<template>
<component :is="type" class="slidev-icon-btn" :title="title" :to="to">
<span class="sr-only">{{ title }}</span>
<slot>
<div :class="icon" />
</slot>
</component>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { computed } from 'vue'
import { configs } from '../env'
import Modal from './Modal.vue'
const props = defineProps({
modelValue: {
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const value = useVModel(props, 'modelValue', emit)
const hasInfo = computed(() => typeof configs.info === 'string')
</script>
<template>
<Modal v-model="value" class="px-6 py-4">
<div class="slidev-info-dialog slidev-layout flex flex-col gap-4 text-base">
<div
v-if="hasInfo"
class="mb-4"
v-html="configs.info"
/>
<a
href="https://github.com/slidevjs/slidev"
target="_blank"
class="!opacity-100 !border-none !text-current"
>
<div class="flex gap-1 children:my-auto">
<div class="opacity-50 text-sm mr-2">Powered by</div>
<img
class="w-5 h-5"
src="../assets/logo.png"
alt="Slidev logo"
>
<div style="color: #2082A6">
<b>Sli</b>dev
</div>
</div>
</a>
</div>
</Modal>
</template>
<style lang="postcss">
.slidev-info-dialog {
@apply !p-4 max-w-150;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onClickOutside, useVModel } from '@vueuse/core'
import { ref } from 'vue'
const props = defineProps({
modelValue: {
default: false,
},
disabled: {
default: false,
},
})
const emit = defineEmits<{
(e: any): void
}>()
const value = useVModel(props, 'modelValue', emit, { passive: true })
const el = ref<HTMLDivElement>()
onClickOutside(el, () => {
value.value = false
})
</script>
<template>
<div ref="el" class="flex relative">
<button :class="{ disabled }" @click="value = !value">
<slot name="button" :class="{ disabled }" />
</button>
<KeepAlive>
<div
v-if="value"
class="rounded-md bg-main text-main shadow-xl absolute bottom-10 left-0 z-menu"
dark:border="~ main"
>
<slot name="menu" />
</div>
</KeepAlive>
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
const props = defineProps({
modelValue: {
default: false,
},
class: {
default: '',
},
})
const emit = defineEmits(['update:modelValue'])
const value = useVModel(props, 'modelValue', emit)
function onClick() {
value.value = false
}
</script>
<template>
<KeepAlive>
<div v-if="value" class="fixed top-0 bottom-0 left-0 right-0 grid z-modal">
<div
bg="black opacity-80"
class="absolute top-0 bottom-0 left-0 right-0 -z-1"
@click="onClick()"
/>
<div
class="m-auto rounded-md bg-main shadow"
dark:border="~ main"
:class="props.class"
>
<slot />
</div>
</div>
</KeepAlive>
</template>
+184
View File
@@ -0,0 +1,184 @@
<script setup lang="ts">
import CustomNavControls from '#slidev/custom-nav-controls'
import { computed, ref, shallowRef } from 'vue'
import { useDrawings } from '../composables/useDrawings'
import { useNav } from '../composables/useNav'
import { configs } from '../env'
import { isColorSchemaConfigured, isDark, toggleDark } from '../logic/dark'
import { activeElement, breakpoints, fullscreen, presenterLayout, showEditor, showInfoDialog, showPresenterCursor, toggleOverview, togglePresenterLayout } from '../state'
import { downloadPDF } from '../utils'
import IconButton from './IconButton.vue'
import MenuButton from './MenuButton.vue'
import Settings from './Settings.vue'
import VerticalDivider from './VerticalDivider.vue'
const props = defineProps({
persist: {
default: false,
},
})
const {
currentSlideNo,
hasNext,
hasPrev,
isEmbedded,
isPresenter,
isPresenterAvailable,
next,
prev,
total,
enterPresenter,
exitPresenter,
} = useNav()
const {
brush,
drawingEnabled,
} = useDrawings()
const md = breakpoints.smaller('md')
const { isFullscreen, toggle: toggleFullscreen } = fullscreen
const root = ref<HTMLDivElement>()
function onMouseLeave() {
if (root.value && activeElement.value && root.value.contains(activeElement.value))
activeElement.value.blur()
}
const barStyle = computed(() => props.persist
? 'text-$slidev-controls-foreground bg-transparent'
: 'rounded-md bg-main shadow-xl dark:border dark:border-main')
const RecordingControls = shallowRef<any>()
if (__SLIDEV_FEATURE_RECORD__)
import('./RecordingControls.vue').then(v => RecordingControls.value = v.default)
</script>
<template>
<nav ref="root" class="flex flex-col">
<div
class="flex flex-wrap-reverse text-xl gap-0.5 p-1 lg:p-2"
:class="barStyle"
@mouseleave="onMouseLeave"
>
<IconButton v-if="!isEmbedded" :title="isFullscreen ? 'Close fullscreen' : 'Enter fullscreen'" @click="toggleFullscreen">
<div v-if="isFullscreen" class="i-carbon:minimize" />
<div v-else class="i-carbon:maximize" />
</IconButton>
<IconButton :class="{ disabled: !hasPrev }" title="Go to previous slide" @click="prev">
<div class="i-carbon:arrow-left" />
</IconButton>
<IconButton :class="{ disabled: !hasNext }" title="Go to next slide" @click="next">
<div class="i-carbon:arrow-right" />
</IconButton>
<IconButton v-if="!isEmbedded" title="Show slide overview" @click="toggleOverview()">
<div class="i-carbon:apps" />
</IconButton>
<IconButton
v-if="!isColorSchemaConfigured"
:title="isDark ? 'Switch to light mode theme' : 'Switch to dark mode theme'"
@click="toggleDark()"
>
<carbon-moon v-if="isDark" />
<carbon-sun v-else />
</IconButton>
<VerticalDivider />
<template v-if="!isEmbedded">
<template v-if="!isPresenter && !md && RecordingControls">
<RecordingControls />
<VerticalDivider />
</template>
<IconButton
v-if="isPresenter"
:title="showPresenterCursor ? 'Hide presenter cursor' : 'Show presenter cursor'"
@click="showPresenterCursor = !showPresenterCursor"
>
<ph-cursor-fill v-if="showPresenterCursor" />
<ph-cursor-duotone v-else />
</IconButton>
</template>
<template v-if="__SLIDEV_FEATURE_DRAWINGS__ && (!configs.drawings.presenterOnly || isPresenter) && !isEmbedded">
<IconButton class="relative" :title="drawingEnabled ? 'Hide drawing toolbar' : 'Show drawing toolbar'" @click="drawingEnabled = !drawingEnabled">
<div class="i-carbon:pen" />
<div
v-if="drawingEnabled"
class="absolute left-1 right-1 bottom-0 h-0.7 rounded-full"
:style="{ background: brush.color }"
/>
</IconButton>
<VerticalDivider />
</template>
<template v-if="!isEmbedded">
<IconButton v-if="isPresenter" title="Play Mode" @click="exitPresenter">
<div class="i-carbon:presentation-file" />
</IconButton>
<IconButton v-if="__SLIDEV_FEATURE_PRESENTER__ && isPresenterAvailable" title="Presenter Mode" @click="enterPresenter">
<div class="i-carbon:user-speaker" />
</IconButton>
<IconButton
v-if="__DEV__ && __SLIDEV_FEATURE_EDITOR__"
:title="showEditor ? 'Hide editor' : 'Show editor'"
class="lt-md:hidden"
@click="showEditor = !showEditor"
>
<div class="i-carbon:text-annotation-toggle" />
</IconButton>
<IconButton v-if="isPresenter" title="Toggle Presenter Layout" class="aspect-ratio-initial flex items-center" @click="togglePresenterLayout">
<div class="i-carbon:template" />
{{ presenterLayout }}
</IconButton>
</template>
<template v-if="!__DEV__">
<IconButton v-if="configs.download" title="Download as PDF" @click="downloadPDF">
<div class="i-carbon:download" />
</IconButton>
</template>
<template v-if="__SLIDEV_FEATURE_BROWSER_EXPORTER__">
<IconButton title="Browser Exporter" to="/export">
<div class="i-carbon:document-pdf" />
</IconButton>
</template>
<IconButton
v-if="!isPresenter && configs.info && !isEmbedded"
title="Show info"
@click="showInfoDialog = !showInfoDialog"
>
<div class="i-carbon:information" />
</IconButton>
<template v-if="!isPresenter && !isEmbedded">
<MenuButton>
<template #button>
<IconButton title="More Options">
<div class="i-carbon:settings-adjust" />
</IconButton>
</template>
<template #menu>
<Settings />
</template>
</MenuButton>
</template>
<VerticalDivider v-if="!isEmbedded" />
<div class="h-40px flex" p="l-1 t-0.5 r-2" text="sm leading-2">
<div class="my-auto">
{{ currentSlideNo }}
<span class="opacity-50">/ {{ total }}</span>
</div>
</div>
<CustomNavControls />
</div>
</nav>
</template>
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
import type { ClicksContext } from '@slidev/types'
import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue'
import { CLICKS_MAX } from '../constants'
const props = withDefaults(
defineProps<{
class?: string | string[]
noteHtml?: string
note?: string
highlight?: boolean
placeholder?: string
clicksContext?: ClicksContext
autoScroll?: boolean
}>(),
{
highlight: true,
},
)
const emit = defineEmits<{
(type: 'markerDblclick', e: MouseEvent, clicks: number): void
(type: 'markerClick', e: MouseEvent, clicks: number): void
}>()
const withClicks = computed(() => props.clicksContext != null && props.noteHtml?.includes('slidev-note-click-mark'))
const noteDisplay = ref<HTMLElement | null>(null)
const CLASS_FADE = 'slidev-note-fade'
const CLASS_MARKER = 'slidev-note-click-mark'
function processNote() {
if (!noteDisplay.value || !withClicks.value)
return
const markers = Array.from(noteDisplay.value.querySelectorAll(`.${CLASS_MARKER}`)) as HTMLElement[]
const markersMap = new Map<HTMLElement, number>()
const parentsMap = new Map<HTMLElement, [divider: HTMLElement | null, dividerClicks: number][]>()
let lastClicks = 0
for (const marker of markers) {
const clicks = Number(marker.dataset!.clicks)
markersMap.set(marker, clicks)
// Set parent clicks map
let n = marker
let p = marker.parentElement
while (p && n !== noteDisplay.value) {
if (!parentsMap.has(p))
parentsMap.set(p, [[null, lastClicks]])
parentsMap.get(p)!.push([n, clicks])
n = p
p = p.parentElement
}
lastClicks = clicks
}
const siblingsMap = new Map<HTMLElement, number>()
for (const [parent, dividers] of parentsMap) {
let hasPrefix = false
let dividerIdx = 0
for (const sibling of Array.from(parent.childNodes)) {
let skip = false
while (sibling === dividers[dividerIdx + 1]?.[0]) {
skip = true
dividerIdx++
}
if (skip)
continue
// Convert sibling text nodes to spans
let siblingEl = sibling as HTMLElement
if (sibling.nodeType === 3 /* text node */) {
if (!sibling.textContent?.trim())
continue
siblingEl = document.createElement('span')
siblingEl.textContent = sibling.textContent
parent.insertBefore(siblingEl, sibling)
sibling.remove()
}
hasPrefix ||= dividerIdx === 0
siblingsMap.set(siblingEl, dividers[dividerIdx][1])
}
if (!hasPrefix)
dividers[0][1] = -1
}
// Apply
return (current: number) => {
const enabled = props.highlight
for (const [parent, clicks] of parentsMap)
parent.classList.toggle(CLASS_FADE, enabled && !clicks.some(([_, c]) => c === current))
for (const [parent, clicks] of siblingsMap)
parent.classList.toggle(CLASS_FADE, enabled && clicks !== current)
for (const [marker, clicks] of markersMap) {
marker.classList.remove(CLASS_FADE)
marker.classList.toggle(`${CLASS_MARKER}-past`, enabled && clicks < current)
marker.classList.toggle(`${CLASS_MARKER}-active`, enabled && clicks === current)
marker.classList.toggle(`${CLASS_MARKER}-next`, enabled && clicks === current + 1)
marker.classList.toggle(`${CLASS_MARKER}-future`, enabled && clicks > current + 1)
marker.ondblclick = enabled
? (e) => {
emit('markerDblclick', e, clicks)
if (e.defaultPrevented)
return
props.clicksContext!.current = clicks
e.stopPropagation()
e.stopImmediatePropagation()
}
: null
marker.onclick = enabled
? (e) => {
emit('markerClick', e, clicks)
}
: null
if (enabled && props.autoScroll && clicks === current)
marker.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
}
}
const applyHighlight = ref<ReturnType<typeof processNote>>()
watch(
() => [props.noteHtml, props.highlight],
() => {
nextTick(() => {
applyHighlight.value = processNote()
})
},
{ immediate: true },
)
onMounted(() => {
processNote()
})
watchEffect(() => {
const current = props.clicksContext?.current ?? CLICKS_MAX
applyHighlight.value?.(current)
})
</script>
<template>
<div
v-if="noteHtml"
ref="noteDisplay"
class="prose overflow-auto outline-none slidev-note"
:class="[props.class, withClicks ? 'slidev-note-with-clicks' : '']"
v-html="noteHtml"
/>
<div
v-else-if="note"
class="prose overflow-auto outline-none slidev-note"
:class="props.class"
>
<p v-text="note" />
</div>
<div
v-else
class="prose overflow-auto outline-none opacity-50 italic select-none slidev-note"
:class="props.class"
>
<p v-text="props.placeholder || 'No notes.'" />
</div>
</template>
<style>
.slidev-note :first-child {
margin-top: 0;
}
</style>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import type { ClicksContext } from '@slidev/types'
import type { PropType } from 'vue'
import { ignorableWatch, onClickOutside, useVModel } from '@vueuse/core'
import { nextTick, ref, toRef, watch, watchEffect } from 'vue'
import { useDynamicSlideInfo } from '../composables/useSlideInfo'
import NoteDisplay from './NoteDisplay.vue'
const props = defineProps({
no: {
type: Number,
required: true,
},
class: {
default: '',
},
editing: {
default: false,
},
style: {
default: () => ({}),
},
placeholder: {
default: 'No notes for this slide',
},
clicksContext: {
type: Object as PropType<ClicksContext>,
},
highlight: {
default: true,
},
autoHeight: {
default: false,
},
})
const emit = defineEmits<{
(type: 'update:editing', value: boolean): void
(type: 'markerDblclick', e: MouseEvent, clicks: number): void
(type: 'markerClick', e: MouseEvent, clicks: number): void
}>()
const editing = useVModel(props, 'editing', emit, { passive: true })
const { info, update } = useDynamicSlideInfo(toRef(props, 'no'))
const note = ref('')
let timer: any
// Send back the note on changes
const { ignoreUpdates } = ignorableWatch(
note,
(v) => {
if (!editing.value)
return
const id = props.no
clearTimeout(timer)
timer = setTimeout(() => {
update({ note: v }, id)
}, 500)
},
)
// Update note value when info changes
watch(
() => info.value?.note,
(value = '') => {
if (editing.value)
return
clearTimeout(timer)
ignoreUpdates(() => {
note.value = value
})
},
{ immediate: true, flush: 'sync' },
)
const inputEl = ref<HTMLTextAreaElement>()
const inputHeight = ref<number | null>()
watchEffect(() => {
if (editing.value)
inputEl.value?.focus()
})
onClickOutside(inputEl, () => {
editing.value = false
})
function calculateEditorHeight() {
if (!props.autoHeight || !inputEl.value || !editing.value)
return
if (inputEl.value.scrollHeight > inputEl.value.clientHeight)
inputEl.value.style.height = `${inputEl.value.scrollHeight}px`
}
function onKeyDown(e: KeyboardEvent) {
// Override save shortcut on editing mode
if (editing.value && e.metaKey && e.key === 's') {
e.preventDefault()
update({ note: note.value }, props.no)
}
}
watch(
[note, editing],
() => {
nextTick(() => {
calculateEditorHeight()
})
},
{ flush: 'post', immediate: true },
)
</script>
<template>
<NoteDisplay
v-if="!editing"
class="border-transparent border-2"
:class="[props.class, note ? '' : 'opacity-25 italic select-none']"
:style="props.style"
:note="note || placeholder"
:note-html="info?.noteHTML"
:clicks-context="clicksContext"
:auto-scroll="!autoHeight"
:highlight="props.highlight"
@marker-click="(e, clicks) => emit('markerClick', e, clicks)"
@marker-dblclick="(e, clicks) => emit('markerDblclick', e, clicks)"
/>
<textarea
v-else
ref="inputEl"
v-model="note"
class="prose resize-none overflow-auto outline-none bg-transparent block border-primary border-2"
style="line-height: 1.75;"
:style="[props.style, inputHeight != null ? { height: `${inputHeight}px` } : {}]"
:class="props.class"
:placeholder="placeholder"
@keydown.esc="editing = false"
@keydown="onKeyDown"
/>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ClicksContext } from '@slidev/types'
import { useSlideInfo } from '../composables/useSlideInfo'
import NoteDisplay from './NoteDisplay.vue'
const props = defineProps<{
no: number
class?: string
clicksContext?: ClicksContext
}>()
const { info } = useSlideInfo(props.no)
</script>
<template>
<NoteDisplay
:class="props.class"
:note="info?.note"
:note-html="info?.noteHTML"
:clicks-context="clicksContext"
/>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { sharedState } from '../state/shared'
</script>
<template>
<div
v-if="sharedState.cursor"
class="absolute top-0 left-0 right-0 bottom-0 pointer-events-none text-xl"
>
<ph-cursor-fill
class="absolute stroke-white dark:stroke-black"
:style="{
left: `${sharedState.cursor.x}%`,
top: `${sharedState.cursor.y}%`,
strokeWidth: 16,
}"
/>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { provideLocal } from '@vueuse/core'
import { computed } from 'vue'
import { useNav } from '../composables/useNav'
import { injectionSlideScale } from '../constants'
import { configs, slideAspect, slideWidth } from '../env'
import PrintSlide from './PrintSlide.vue'
const props = defineProps<{
width: number
}>()
const { slides, printRange } = useNav()
const width = computed(() => props.width)
const height = computed(() => props.width / slideAspect.value)
const screenAspect = computed(() => width.value / height.value)
const scale = computed(() => {
if (screenAspect.value < slideAspect.value)
return width.value / slideWidth.value
return (height.value * slideAspect.value) / slideWidth.value
})
const className = computed(() => ({
'select-none': !configs.selectable,
}))
provideLocal(injectionSlideScale, scale)
</script>
<template>
<div id="print-container" :class="className">
<div id="print-content">
<PrintSlide v-for="no of printRange" :key="no" :route="slides[no - 1]" />
</div>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { SlideRoute } from '@slidev/types'
import { createFixedClicks } from '../composables/useClicks'
import { useFixedNav, useNav } from '../composables/useNav'
import { CLICKS_MAX } from '../constants'
import PrintSlideClick from './PrintSlideClick.vue'
const { route } = defineProps<{ hidden?: boolean, route: SlideRoute }>()
const { isPrintWithClicks } = useNav()
const clicks0 = createFixedClicks(route, () => isPrintWithClicks.value ? 0 : CLICKS_MAX)
</script>
<template>
<PrintSlideClick
v-show="!hidden"
:nav="useFixedNav(route, clicks0)"
/>
<template v-if="isPrintWithClicks">
<!--
clicks0.total can be any number >=0 when rendering.
So total-clicksStart can be negative in intermediate states.
-->
<PrintSlideClick
v-for="i in Math.max(0, clicks0.total - clicks0.clicksStart)"
v-show="!hidden"
:key="i"
:nav="useFixedNav(route, createFixedClicks(route, i + clicks0.clicksStart))"
/>
</template>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { SlidevContextNav } from '../composables/useNav'
import { GlobalBottom, GlobalTop } from '#slidev/global-layers'
import { provideLocal } from '@vueuse/core'
import { computed, reactive, shallowRef, useTemplateRef } from 'vue'
import { injectionSlideElement, injectionSlidevContext } from '../constants'
import { configs, slideHeight, slideWidth } from '../env'
import { getSlideClass } from '../utils'
import SlideWrapper from './SlideWrapper.vue'
const { nav } = defineProps<{
nav: SlidevContextNav
}>()
const route = computed(() => nav.currentSlideRoute.value)
const style = computed(() => ({
height: `${slideHeight.value}px`,
width: `${slideWidth.value}px`,
}))
const DrawingPreview = shallowRef<any>()
if (__SLIDEV_FEATURE_DRAWINGS__ || __SLIDEV_FEATURE_DRAWINGS_PERSIST__)
import('./DrawingPreview.vue').then(v => (DrawingPreview.value = v.default))
const id = computed(() =>
`${route.value.no.toString().padStart(3, '0')}-${(nav.clicks.value + 1).toString().padStart(2, '0')}`,
)
provideLocal(injectionSlidevContext, reactive({
nav,
configs,
themeConfigs: computed(() => configs.themeConfig),
}))
provideLocal(injectionSlideElement, useTemplateRef('slide-element'))
</script>
<template>
<div :id="id" ref="slide-element" class="print-slide-container" :style="style">
<GlobalBottom />
<SlideWrapper
:clicks-context="nav.clicksContext.value"
:class="getSlideClass(route)"
:route="route"
/>
<template
v-if="
(__SLIDEV_FEATURE_DRAWINGS__
|| __SLIDEV_FEATURE_DRAWINGS_PERSIST__)
&& DrawingPreview
"
>
<DrawingPreview :page="route.no" />
</template>
<GlobalTop />
</div>
</template>
<style scoped lang="postcss">
.print-slide-container {
@apply relative overflow-hidden break-after-page translate-0 bg-main;
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, ref, watchEffect } from 'vue'
import { createFixedClicks } from '../composables/useClicks'
import { useNav } from '../composables/useNav'
import { CLICKS_MAX } from '../constants'
import { configs, pathPrefix } from '../env'
import { currentOverviewPage, overviewRowCount } from '../logic/overview'
import { breakpoints, showOverview, windowSize } from '../state'
import DrawingPreview from './DrawingPreview.vue'
import IconButton from './IconButton.vue'
import SlideContainer from './SlideContainer.vue'
import SlideWrapper from './SlideWrapper.vue'
const { currentSlideNo, go: goSlide, slides } = useNav()
function close() {
showOverview.value = false
}
function go(page: number) {
goSlide(page)
close()
}
function focus(page: number) {
if (page === currentOverviewPage.value)
return true
return false
}
const xs = breakpoints.smaller('xs')
const sm = breakpoints.smaller('sm')
const padding = 4 * 16 * 2
const gap = 2 * 16
const cardWidth = computed(() => {
if (xs.value)
return windowSize.width.value - padding
else if (sm.value)
return (windowSize.width.value - padding - gap) / 2
return 300
})
const rowCount = computed(() => {
return Math.floor((windowSize.width.value - padding) / (cardWidth.value + gap))
})
const keyboardBuffer = ref<string>('')
useEventListener('keypress', (e) => {
if (!showOverview.value) {
keyboardBuffer.value = ''
return
}
if (e.key === 'Enter') {
e.preventDefault()
if (keyboardBuffer.value) {
go(+keyboardBuffer.value)
keyboardBuffer.value = ''
}
else {
go(currentOverviewPage.value)
}
return
}
const num = Number.parseInt(e.key.replace(/\D/g, ''))
if (Number.isNaN(num)) {
keyboardBuffer.value = ''
return
}
if (!keyboardBuffer.value && num === 0)
return
keyboardBuffer.value += String(num)
// beyond the number of slides, reset
if (+keyboardBuffer.value >= slides.value.length) {
keyboardBuffer.value = ''
return
}
const extactMatch = slides.value.findIndex(i => `/${i.no}` === keyboardBuffer.value)
if (extactMatch !== -1)
currentOverviewPage.value = extactMatch + 1
// When the input number is the largest at the number of digits, we go to that page directly.
if (+keyboardBuffer.value * 10 > slides.value.length) {
go(+keyboardBuffer.value)
keyboardBuffer.value = ''
}
})
watchEffect(() => {
// Watch currentPage, make sure every time we open overview,
// we focus on the right page.
currentOverviewPage.value = currentSlideNo.value
// Watch rowCount, make sure up and down shortcut work correctly.
overviewRowCount.value = rowCount.value
})
</script>
<template>
<Transition
enter-active-class="duration-150 ease-out"
enter-from-class="opacity-0 scale-102 !backdrop-blur-0px"
leave-active-class="duration-200 ease-in"
leave-to-class="opacity-0 scale-102 !backdrop-blur-0px"
>
<div
v-if="showOverview"
class="fixed left-0 right-0 top-0 h-[calc(var(--vh,1vh)*100)] z-modal bg-main !bg-opacity-75 p-16 py-20 overflow-y-auto backdrop-blur-5px select-none"
@click="close"
>
<div
class="grid gap-y-4 gap-x-8 w-full"
:style="`grid-template-columns: repeat(auto-fit,minmax(${cardWidth}px,1fr))`"
>
<div
v-for="(route, idx) of slides"
:key="route.no"
class="relative"
>
<div
class="inline-block border rounded overflow-hidden bg-main hover:border-primary transition"
:class="(focus(idx + 1) || currentOverviewPage === idx + 1) ? 'border-primary' : 'border-main'"
@click="go(route.no)"
>
<SlideContainer
:key="route.no"
:no="route.no"
:use-snapshot="configs.overviewSnapshots"
:width="cardWidth"
class="pointer-events-none"
>
<SlideWrapper
:clicks-context="createFixedClicks(route, CLICKS_MAX)"
:route="route"
render-context="overview"
/>
<DrawingPreview :page="route.no" />
</SlideContainer>
</div>
<div
class="absolute top-0"
:style="`left: ${cardWidth + 5}px`"
>
<template v-if="keyboardBuffer && String(idx + 1).startsWith(keyboardBuffer)">
<span class="text-green font-bold">{{ keyboardBuffer }}</span>
<span class="opacity-50">{{ String(idx + 1).slice(keyboardBuffer.length) }}</span>
</template>
<span v-else class="opacity-50">
{{ idx + 1 }}
</span>
</div>
</div>
</div>
</div>
</Transition>
<div v-if="showOverview" class="fixed top-4 right-4 z-modal text-gray-400 flex flex-col items-center gap-2">
<IconButton title="Close" class="text-2xl" @click="close">
<div class="i-carbon:close" />
</IconButton>
<IconButton
v-if="__SLIDEV_FEATURE_PRESENTER__"
as="a"
title="Slides Overview"
target="_blank"
:href="`${pathPrefix}overview`"
tab-index="-1"
class="text-2xl"
>
<div class="i-carbon:list-boxes" />
</IconButton>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
# Internal Components
Components for built-in UIs.
Not exposed to auto-importing. Will need to be imported to use in other components.
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { onMounted, watch } from 'vue'
import { recorder } from '../logic/recording'
import { currentCamera, showRecordingDialog } from '../state'
import DevicesList from './DevicesList.vue'
import IconButton from './IconButton.vue'
import MenuButton from './MenuButton.vue'
const {
recording,
showAvatar,
streamCamera,
stopRecording,
toggleAvatar,
} = recorder
const previousAvatar = useLocalStorage('slidev-webcam-show', false)
watch(showAvatar, () => {
previousAvatar.value = showAvatar.value
})
function toggleRecording() {
if (recording.value)
stopRecording()
else
showRecordingDialog.value = true
}
onMounted(() => {
if (previousAvatar.value && !showAvatar.value)
toggleAvatar()
})
</script>
<template>
<IconButton
v-if="currentCamera !== 'none'"
class="<md:hidden"
:class="{ 'text-green-500': Boolean(showAvatar && streamCamera) }"
title="Toggle camera view"
@click="toggleAvatar"
>
<div class="i-carbon:user-avatar" />
</IconButton>
<IconButton
:class="{ 'text-red-500': recording }"
:title="recording ? 'Stop record video' : 'Record video'"
@click="toggleRecording"
>
<div v-if="recording" class="i-carbon:stop-outline" />
<div v-else class="i-carbon:video" />
</IconButton>
<MenuButton :disabled="recording">
<template #button>
<IconButton title="Select recording device" class="h-full !text-sm !px-0 aspect-initial">
<div class="i-carbon:chevron-up opacity-50" />
</IconButton>
</template>
<template #menu>
<DevicesList />
</template>
</MenuButton>
</template>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { nextTick } from 'vue'
import { getFilename, mimeType, recordCamera, recorder, recordingName } from '../logic/recording'
import DevicesList from './DevicesList.vue'
import Modal from './Modal.vue'
const props = defineProps({
modelValue: {
default: false,
},
})
const emit = defineEmits<{
(e: any): void
}>()
const value = useVModel(props, 'modelValue', emit)
const { startRecording } = recorder
function close() {
value.value = false
}
async function start() {
close()
await nextTick()
startRecording({
mimeType: mimeType.value,
})
}
</script>
<template>
<Modal v-model="value" class="px-6 py-4 recording-dialog flex flex-col gap-2">
<div class="flex gap-2 text-xl">
<div class="i-carbon:video my-auto" />Recording
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2 py-2">
<div class="form-text">
<label for="title">Recording Name</label>
<input
v-model="recordingName"
class="bg-transparent text-current"
name="title"
type="text"
placeholder="Enter the title..."
>
<div class="text-xs w-full opacity-50 py-2">
<div>This will be used in the output filename that might <br>help you better organize your recording chips.</div>
</div>
</div>
<div class="form-check">
<input
v-model="recordCamera"
name="record-camera"
type="checkbox"
>
<label for="record-camera" @click="recordCamera = !recordCamera">Record camera separately</label>
</div>
<div class="text-xs w-full opacity-50">
<div class="mt-2 opacity-50">
Enumerated filenames
</div>
<div class="font-mono">
{{ getFilename('screen', mimeType) }}
</div>
<div v-if="recordCamera" class="font-mono">
{{ getFilename('camera', mimeType) }}
</div>
</div>
</div>
<DevicesList />
</div>
<div class="flex my-1">
<button class="cancel" @click="close">
Cancel
</button>
<div class="flex-auto" />
<button @click="start">
Start
</button>
</div>
</Modal>
</template>
<style lang="postcss">
.recording-dialog {
.form-text {
@apply flex flex-col;
label {
@apply text-xs uppercase opacity-50 tracking-widest py-1;
}
}
.form-check {
@apply leading-5;
* {
@apply my-auto align-middle;
}
label {
@apply ml-1 text-sm select-none;
}
}
input[type='text'] {
@apply border border-main rounded px-2 py-1;
}
button {
@apply bg-orange-400 text-white px-4 py-1 rounded border-b-2 border-orange-600;
@apply hover:(bg-orange-500 border-orange-700);
}
button.cancel {
@apply bg-gray-400 bg-opacity-50 text-white px-4 py-1 rounded border-b-2 border-main;
@apply hover:(bg-opacity-75 border-opacity-75);
}
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { PropType } from 'vue'
import type { SelectionItem } from './types'
import { useVModel } from '@vueuse/core'
const props = defineProps({
modelValue: {
type: [Object, String, Number, Boolean] as PropType<any>,
},
title: {
type: String,
},
items: {
type: Array as PropType<SelectionItem<any>[]>,
},
})
const emit = defineEmits<{
(e: any): void
}>()
const value = useVModel(props, 'modelValue', emit, { passive: true })
</script>
<template>
<div class="select-list">
<div class="title">
{{ title }}
</div>
<div class="items">
<div
v-for="item of items"
:key="item.value"
class="item"
:class="{ active: value === item.value }"
@click="() => { value = item.value; item.onClick?.() }"
>
<div class="i-carbon:checkmark text-green-500 mya" :class="{ 'opacity-0': value !== item.value }" />
<div :class="{ 'opacity-50': value !== item.value }">
{{ item.display || item.value }}
</div>
</div>
</div>
</div>
</template>
<style lang="postcss" scoped>
.select-list {
@apply my-2;
}
.item {
@apply flex rounded whitespace-nowrap py-1 gap-1 px-2 cursor-default hover:bg-gray-400 hover:bg-opacity-10;
svg {
@apply mr-1 -ml-2 my-auto;
}
}
.title {
@apply text-xs uppercase opacity-50 tracking-widest px-7 py-1 select-none text-nowrap;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { SelectionItem } from './types'
import { useWakeLock } from '@vueuse/core'
import { slideScale, wakeLockEnabled } from '../state'
import SelectList from './SelectList.vue'
const scaleItems: SelectionItem<number>[] = [
{
display: 'Fit',
value: 0,
},
{
display: '1:1',
value: 1,
},
]
const { isSupported } = useWakeLock()
const wakeLockItems: SelectionItem<boolean>[] = [
{
display: 'Enabled',
value: true,
},
{
display: 'Disabled',
value: false,
},
]
</script>
<template>
<div class="text-sm select-none mb-2">
<SelectList v-model="slideScale" title="Scale" :items="scaleItems" />
<SelectList
v-if="__SLIDEV_FEATURE_WAKE_LOCK__ && isSupported"
v-model="wakeLockEnabled" title="Wake lock" :items="wakeLockItems"
/>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More