Compare commits
19 Commits
docs
...
fc5639d7bd
| Author | SHA1 | Date | |
|---|---|---|---|
| fc5639d7bd | |||
| 17f3351cea | |||
| dd3dd4ddb2 | |||
| 60d1494d45 | |||
| 1ef5350b3f | |||
| 3521e5787b | |||
| 8d9bcdad7e | |||
| ac086db1ed | |||
| 6dff0181d2 | |||
| ad24d3a140 | |||
| 4b0f39d747 | |||
| 096cc1da94 | |||
| 2d3200ad97 | |||
| 4e82e2f6f7 | |||
| 339ed53c2e | |||
| fc7c162e2f | |||
|
|
24f1549730 | ||
| 5584a46ca2 | |||
| 4471441d6f |
@@ -1170,7 +1170,7 @@ export class Theme {
|
||||
this["type"] = ("" as ThemeType);
|
||||
}
|
||||
if (!("colors" in $$source)) {
|
||||
this["colors"] = (new ThemeColorConfig());
|
||||
this["colors"] = ({} as ThemeColorConfig);
|
||||
}
|
||||
if (!("isDefault" in $$source)) {
|
||||
this["isDefault"] = false;
|
||||
@@ -1199,303 +1199,9 @@ export class Theme {
|
||||
}
|
||||
|
||||
/**
|
||||
* ThemeColorConfig 主题颜色配置(与前端 ThemeColors 接口保持一致)
|
||||
* ThemeColorConfig 使用与前端 ThemeColors 相同的结构,存储任意主题键值
|
||||
*/
|
||||
export class ThemeColorConfig {
|
||||
/**
|
||||
* 主题基本信息
|
||||
* 主题名称
|
||||
*/
|
||||
"name": string;
|
||||
|
||||
/**
|
||||
* 是否为深色主题
|
||||
*/
|
||||
"dark": boolean;
|
||||
|
||||
/**
|
||||
* 基础色调
|
||||
* 主背景色
|
||||
*/
|
||||
"background": string;
|
||||
|
||||
/**
|
||||
* 次要背景色(用于代码块交替背景)
|
||||
*/
|
||||
"backgroundSecondary": string;
|
||||
|
||||
/**
|
||||
* 面板背景
|
||||
*/
|
||||
"surface": string;
|
||||
|
||||
/**
|
||||
* 下拉菜单背景
|
||||
*/
|
||||
"dropdownBackground": string;
|
||||
|
||||
/**
|
||||
* 下拉菜单边框
|
||||
*/
|
||||
"dropdownBorder": string;
|
||||
|
||||
/**
|
||||
* 文本颜色
|
||||
* 主文本色
|
||||
*/
|
||||
"foreground": string;
|
||||
|
||||
/**
|
||||
* 次要文本色
|
||||
*/
|
||||
"foregroundSecondary": string;
|
||||
|
||||
/**
|
||||
* 注释色
|
||||
*/
|
||||
"comment": string;
|
||||
|
||||
/**
|
||||
* 语法高亮色 - 核心
|
||||
* 关键字
|
||||
*/
|
||||
"keyword": string;
|
||||
|
||||
/**
|
||||
* 字符串
|
||||
*/
|
||||
"string": string;
|
||||
|
||||
/**
|
||||
* 函数名
|
||||
*/
|
||||
"function": string;
|
||||
|
||||
/**
|
||||
* 数字
|
||||
*/
|
||||
"number": string;
|
||||
|
||||
/**
|
||||
* 操作符
|
||||
*/
|
||||
"operator": string;
|
||||
|
||||
/**
|
||||
* 变量
|
||||
*/
|
||||
"variable": string;
|
||||
|
||||
/**
|
||||
* 类型
|
||||
*/
|
||||
"type": string;
|
||||
|
||||
/**
|
||||
* 语法高亮色 - 扩展
|
||||
* 常量
|
||||
*/
|
||||
"constant": string;
|
||||
|
||||
/**
|
||||
* 存储类型(如 static, const)
|
||||
*/
|
||||
"storage": string;
|
||||
|
||||
/**
|
||||
* 参数
|
||||
*/
|
||||
"parameter": string;
|
||||
|
||||
/**
|
||||
* 类名
|
||||
*/
|
||||
"class": string;
|
||||
|
||||
/**
|
||||
* 标题(Markdown等)
|
||||
*/
|
||||
"heading": string;
|
||||
|
||||
/**
|
||||
* 无效内容/错误
|
||||
*/
|
||||
"invalid": string;
|
||||
|
||||
/**
|
||||
* 正则表达式
|
||||
*/
|
||||
"regexp": string;
|
||||
|
||||
/**
|
||||
* 界面元素
|
||||
* 光标
|
||||
*/
|
||||
"cursor": string;
|
||||
|
||||
/**
|
||||
* 选中背景
|
||||
*/
|
||||
"selection": string;
|
||||
|
||||
/**
|
||||
* 失焦选中背景
|
||||
*/
|
||||
"selectionBlur": string;
|
||||
|
||||
/**
|
||||
* 当前行高亮
|
||||
*/
|
||||
"activeLine": string;
|
||||
|
||||
/**
|
||||
* 行号
|
||||
*/
|
||||
"lineNumber": string;
|
||||
|
||||
/**
|
||||
* 活动行号颜色
|
||||
*/
|
||||
"activeLineNumber": string;
|
||||
|
||||
/**
|
||||
* 边框和分割线
|
||||
* 边框色
|
||||
*/
|
||||
"borderColor": string;
|
||||
|
||||
/**
|
||||
* 浅色边框
|
||||
*/
|
||||
"borderLight": string;
|
||||
|
||||
/**
|
||||
* 搜索和匹配
|
||||
* 搜索匹配
|
||||
*/
|
||||
"searchMatch": string;
|
||||
|
||||
/**
|
||||
* 匹配括号
|
||||
*/
|
||||
"matchingBracket": string;
|
||||
|
||||
/** Creates a new ThemeColorConfig instance. */
|
||||
constructor($$source: Partial<ThemeColorConfig> = {}) {
|
||||
if (!("name" in $$source)) {
|
||||
this["name"] = "";
|
||||
}
|
||||
if (!("dark" in $$source)) {
|
||||
this["dark"] = false;
|
||||
}
|
||||
if (!("background" in $$source)) {
|
||||
this["background"] = "";
|
||||
}
|
||||
if (!("backgroundSecondary" in $$source)) {
|
||||
this["backgroundSecondary"] = "";
|
||||
}
|
||||
if (!("surface" in $$source)) {
|
||||
this["surface"] = "";
|
||||
}
|
||||
if (!("dropdownBackground" in $$source)) {
|
||||
this["dropdownBackground"] = "";
|
||||
}
|
||||
if (!("dropdownBorder" in $$source)) {
|
||||
this["dropdownBorder"] = "";
|
||||
}
|
||||
if (!("foreground" in $$source)) {
|
||||
this["foreground"] = "";
|
||||
}
|
||||
if (!("foregroundSecondary" in $$source)) {
|
||||
this["foregroundSecondary"] = "";
|
||||
}
|
||||
if (!("comment" in $$source)) {
|
||||
this["comment"] = "";
|
||||
}
|
||||
if (!("keyword" in $$source)) {
|
||||
this["keyword"] = "";
|
||||
}
|
||||
if (!("string" in $$source)) {
|
||||
this["string"] = "";
|
||||
}
|
||||
if (!("function" in $$source)) {
|
||||
this["function"] = "";
|
||||
}
|
||||
if (!("number" in $$source)) {
|
||||
this["number"] = "";
|
||||
}
|
||||
if (!("operator" in $$source)) {
|
||||
this["operator"] = "";
|
||||
}
|
||||
if (!("variable" in $$source)) {
|
||||
this["variable"] = "";
|
||||
}
|
||||
if (!("type" in $$source)) {
|
||||
this["type"] = "";
|
||||
}
|
||||
if (!("constant" in $$source)) {
|
||||
this["constant"] = "";
|
||||
}
|
||||
if (!("storage" in $$source)) {
|
||||
this["storage"] = "";
|
||||
}
|
||||
if (!("parameter" in $$source)) {
|
||||
this["parameter"] = "";
|
||||
}
|
||||
if (!("class" in $$source)) {
|
||||
this["class"] = "";
|
||||
}
|
||||
if (!("heading" in $$source)) {
|
||||
this["heading"] = "";
|
||||
}
|
||||
if (!("invalid" in $$source)) {
|
||||
this["invalid"] = "";
|
||||
}
|
||||
if (!("regexp" in $$source)) {
|
||||
this["regexp"] = "";
|
||||
}
|
||||
if (!("cursor" in $$source)) {
|
||||
this["cursor"] = "";
|
||||
}
|
||||
if (!("selection" in $$source)) {
|
||||
this["selection"] = "";
|
||||
}
|
||||
if (!("selectionBlur" in $$source)) {
|
||||
this["selectionBlur"] = "";
|
||||
}
|
||||
if (!("activeLine" in $$source)) {
|
||||
this["activeLine"] = "";
|
||||
}
|
||||
if (!("lineNumber" in $$source)) {
|
||||
this["lineNumber"] = "";
|
||||
}
|
||||
if (!("activeLineNumber" in $$source)) {
|
||||
this["activeLineNumber"] = "";
|
||||
}
|
||||
if (!("borderColor" in $$source)) {
|
||||
this["borderColor"] = "";
|
||||
}
|
||||
if (!("borderLight" in $$source)) {
|
||||
this["borderLight"] = "";
|
||||
}
|
||||
if (!("searchMatch" in $$source)) {
|
||||
this["searchMatch"] = "";
|
||||
}
|
||||
if (!("matchingBracket" in $$source)) {
|
||||
this["matchingBracket"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ThemeColorConfig instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): ThemeColorConfig {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new ThemeColorConfig($$parsedSource as Partial<ThemeColorConfig>);
|
||||
}
|
||||
}
|
||||
export type ThemeColorConfig = { [_: string]: any };
|
||||
|
||||
/**
|
||||
* ThemeType 主题类型枚举
|
||||
@@ -1636,6 +1342,11 @@ var $$createType6 = (function $$initCreateType6(...args): any {
|
||||
});
|
||||
const $$createType7 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType8 = HotkeyCombo.createFrom;
|
||||
const $$createType9 = ThemeColorConfig.createFrom;
|
||||
var $$createType9 = (function $$initCreateType9(...args): any {
|
||||
if ($$createType9 === $$initCreateType9) {
|
||||
$$createType9 = $$createType7;
|
||||
}
|
||||
return $$createType9(...args);
|
||||
});
|
||||
const $$createType10 = GithubConfig.createFrom;
|
||||
const $$createType11 = GiteaConfig.createFrom;
|
||||
|
||||
@@ -18,23 +18,10 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
|
||||
import * as models$0 from "../models/models.js";
|
||||
|
||||
/**
|
||||
* GetAllThemes 获取所有主题
|
||||
* GetThemeByName 通过名称获取主题覆盖,若不存在则返回 nil
|
||||
*/
|
||||
export function GetAllThemes(): Promise<(models$0.Theme | null)[]> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2425053076) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
}) as any;
|
||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
||||
return $typingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetThemeByID 根据ID或名称获取主题
|
||||
* 如果 id > 0,按ID查询;如果 id = 0,按名称查询
|
||||
*/
|
||||
export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<models$0.Theme | null> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(127385338, id, name) as any;
|
||||
export function GetThemeByName(name: string): Promise<models$0.Theme | null> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1938954770, name) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
}) as any;
|
||||
@@ -43,10 +30,10 @@ export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<model
|
||||
}
|
||||
|
||||
/**
|
||||
* ResetTheme 重置主题为预设配置
|
||||
* ResetTheme 删除指定主题的覆盖配置
|
||||
*/
|
||||
export function ResetTheme(id: number, ...name: string[]): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1806334457, id, name) as any;
|
||||
export function ResetTheme(name: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1806334457, name) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
@@ -59,7 +46,7 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup 服务启动时初始化
|
||||
* ServiceStartup 服务启动
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2915959937, options) as any;
|
||||
@@ -67,14 +54,13 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
|
||||
}
|
||||
|
||||
/**
|
||||
* UpdateTheme 更新主题
|
||||
* UpdateTheme 保存或更新主题覆盖
|
||||
*/
|
||||
export function UpdateTheme(id: number, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(70189749, id, colors) as any;
|
||||
export function UpdateTheme(name: string, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(70189749, name, colors) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = models$0.Theme.createFrom;
|
||||
const $$createType1 = $Create.Nullable($$createType0);
|
||||
const $$createType2 = $Create.Array($$createType1);
|
||||
|
||||
@@ -93,32 +93,13 @@ export default defineConfig({
|
||||
items: [
|
||||
{text: '简介', link: '/zh/guide/introduction'},
|
||||
{text: '安装', link: '/zh/guide/installation'},
|
||||
{text: '快速开始', link: '/zh/guide/getting-started'},
|
||||
{text: '界面总览', link: '/zh/guide/ui-overview'},
|
||||
{text: '块语法与结构', link: '/zh/guide/block-syntax'}
|
||||
{text: '快速开始', link: '/zh/guide/getting-started'}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '编辑与效率',
|
||||
text: '功能特性',
|
||||
items: [
|
||||
{text: '键盘快捷键', link: '/zh/guide/keyboard-shortcuts'},
|
||||
{text: '多窗口与标签页', link: '/zh/guide/multiwindow-tabs'},
|
||||
{text: '扩展与插件', link: '/zh/guide/extensions'},
|
||||
{text: 'HTTP 客户端', link: '/zh/guide/http-client'}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '个性化与数据',
|
||||
items: [
|
||||
{text: '设置与配置', link: '/zh/guide/settings'},
|
||||
{text: '主题与外观', link: '/zh/guide/themes'},
|
||||
{text: '备份与更新', link: '/zh/guide/backup-update'}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '问题处理',
|
||||
items: [
|
||||
{text: '常见问题与故障排查', link: '/zh/guide/troubleshooting'}
|
||||
{text: '功能概览', link: '/zh/guide/features'}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,60 +0,0 @@
|
||||
# 备份与更新
|
||||
|
||||

|
||||
> 替换为备份设置、推送状态、更新提示的截图。
|
||||
|
||||
## Git 备份
|
||||
`BackupService` 将 `dataPath` 转化为 Git 仓库,并提供自动/手动推送。
|
||||
|
||||
### 初始化
|
||||
1. 在设置 > 备份中开启「启用备份」。
|
||||
2. 填写远程仓库 URL(HTTPS 或 SSH)。
|
||||
3. 选择认证方式:
|
||||
- **Token**:适用于 GitHub/Gitea https 仓库。
|
||||
- **SSH Key**:指定私钥路径和 passphrase。
|
||||
- **用户名/密码**:适合自建 HTTP 仓库。
|
||||
4. 点击“测试连接”(按钮在计划中,可先在终端测试)。
|
||||
|
||||
### 自动备份
|
||||
- 勾选 “自动备份” + 设置 `BackupInterval`(分钟)。
|
||||
- 服务会创建 ticker 定时 `git add -> commit -> push`。
|
||||
- Commit 消息形如 `Auto backup <timestamp>`,包含 `voidraft.db`, `extensions.json`, `config.json`, `voidraft_data.bin`。
|
||||
|
||||
### 手动推送
|
||||
- 打开工具栏消息中心或设置页点击“立即推送”。
|
||||
- `backupStore.pushToRemote` 会显示状态气泡(成功/失败提示 3~5 秒)。
|
||||
|
||||
### 常见问题
|
||||
| 提示 | 解决 |
|
||||
| --- | --- |
|
||||
| `repository not found` | 检查 Repo URL 与权限,必要时创建空仓库 |
|
||||
| `authentication required` | 选择正确认证方式,确认 token scope(需 repo 权限) |
|
||||
| `auto backup stopped` | 查看日志,可能是网络不通或凭据失效;修改配置后服务会自动重启 |
|
||||
|
||||
## 自动更新
|
||||
`SelfUpdateService` 负责检测、下载、应用新版。
|
||||
|
||||
### 检查更新
|
||||
- 启动时若勾选 “自动更新” 会自动检查。
|
||||
- 也可在设置 > 更新点击 “检查更新” 或在工具栏更新图标处触发。
|
||||
- 服务优先访问 `primarySource`,失败时回退 `backupSource`。
|
||||
|
||||
### 下载与应用
|
||||
1. 检测到更新后,界面提示版本号与变更信息(从 Release Notes 获取)。
|
||||
2. 点击 “下载并安装” 后,后台执行下载,完成后提示“准备重启”。
|
||||
3. 选择 “立即重启” 将调用 `RestartApplication`,自动重新打开上次的文档。
|
||||
4. 若启用了 “更新前备份”,在下载前会触发一次 Git push。
|
||||
|
||||
### 失败处理
|
||||
| 场景 | 建议 |
|
||||
| --- | --- |
|
||||
| 下载失败 | 检查网络/代理,切换至备用源 |
|
||||
| 校验失败 | 删除 `%LOCALAPPDATA%/voidraft/update-cache` 再重试 |
|
||||
| 应用后无法启动 | 从 Git 备份回滚数据,下载旧版本安装包覆盖 |
|
||||
|
||||
## 发布渠道
|
||||
- 官方 GitHub Releases:`https://github.com/landaiqing/voidraft/releases`
|
||||
- 自建 Gitea:`https://git.landaiqing.cn/landaiqing/voidraft`
|
||||
- 可在设置中替换 Owner/Repo/BaseURL,以指向企业私有镜像。
|
||||
|
||||
> 建议将备份仓库设为私有,并在更新前后验证数据完整性。若要接入 S3/OSS 等备份方式,可关注 roadmap 或自行扩展。
|
||||
@@ -1,78 +0,0 @@
|
||||
# 块语法与结构
|
||||
|
||||

|
||||
> 替换为展示分隔符(`∞∞∞language`)、块内容、语言标签的截图。
|
||||
|
||||
## 结构定义
|
||||
每个块都由 **分隔符 + 内容** 组成:
|
||||
|
||||
```
|
||||
∞∞∞language[-a]\n
|
||||
<内容>
|
||||
```
|
||||
|
||||
- `language`:`lang-parser/languages.ts` 中的 token,例如 `text`、`javascript`、`python`、`md`、`http`、`math`、`sql`。
|
||||
- `-a`:可选自动检测后缀,表示忽略显式语言,由 `lang-detect/autodetect.ts` 根据内容猜测。
|
||||
- 内容允许空行;块之间无需额外空格。
|
||||
|
||||
`parser.ts` 会将块解析为:
|
||||
```ts
|
||||
{
|
||||
language: { name: "javascript", auto: false },
|
||||
delimiter: { from, to },
|
||||
content: { from, to },
|
||||
range: { from, to }
|
||||
}
|
||||
```
|
||||
这些字段供格式化、HTTP 运行、块操作等扩展示例使用。
|
||||
|
||||
## 快捷命令
|
||||
| 命令 | 默认快捷键 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| blockAddAfterCurrent | `Ctrl+Enter` | 插入新块(下方) |
|
||||
| blockAddBeforeCurrent | `Ctrl+Shift+Enter` | 插入新块(上方) |
|
||||
| blockGotoPrevious/Next | `Alt+↑ / Alt+↓` | 在块之间跳转 |
|
||||
| blockSelectAll | `Ctrl+Shift+A` | 选中当前块(含分隔符) |
|
||||
| blockDelete | `Alt+Delete` | 删除整个块 |
|
||||
| blockMoveUp/Down | `Ctrl+Shift+↑ / Ctrl+Shift+↓` | 重排块顺序 |
|
||||
| blockFormat | `Ctrl+Shift+F` | 针对当前块执行 Prettier |
|
||||
|
||||
## 语言与能力矩阵
|
||||
| 语言 | 适配特性 |
|
||||
| --- | --- |
|
||||
| `text`/`note` | 基础文本,没有特殊扩展 |
|
||||
| `md` | Markdown 预览、checkbox、高亮 |
|
||||
| `javascript`/`typescript`/`json` | Prettier 格式化、彩虹括号、折叠、颜色选择 |
|
||||
| `go`/`rust`/`python`/`java` | 高亮、折叠、自动缩进、语法跳转 |
|
||||
| `http` | HTTP DSL、变量、响应插入(详见 [HTTP 客户端](/zh/guide/http-client))|
|
||||
| `math` | `mathBlock` 运行器,支持 `prev` 引用上一次结果 |
|
||||
| `sql`/`yaml`/`toml` | 语法高亮、格式化(由 Prettier/插件支持) |
|
||||
|
||||
> 若需要不在列表中的语言,可先使用 `text` 块输入,再在工具栏搜索语言名;也可以在 `lang-parser/languages.ts` 中添加条目。
|
||||
|
||||
## 自动检测策略
|
||||
- 当分隔符以 `∞∞∞text-a` 写成时,`AUTO_DETECT_SUFFIX` 生效,`lang-detect` 会基于内容统计 + Levenshtein 距离预测语言。
|
||||
- 自动结果会写入块状态,但不会覆盖分隔符原文,因此可通过工具栏明确指定。
|
||||
|
||||
## 特殊块
|
||||
### HTTP 块
|
||||
- 语法位于 `extensions/httpclient/language`,支持 `@var/@json/@form/@multipart` 等指令。
|
||||
- 运行器会在块尾生成 `### Response`,包含状态码、耗时、headers、body。
|
||||
|
||||
### 数学块
|
||||
- 语言设为 `math`,逐行计算。
|
||||
- `prev` 变量表示上一行结果,可完成链式运算。
|
||||
- 结果挂件可点击复制,或显示格式化/定点值。
|
||||
|
||||
### Markdown 块
|
||||
- 工具栏中的 Preview 按钮会调用 `toggleMarkdownPreview`,右侧打开面板。
|
||||
- 预览状态按文档隔离,不会影响其他文档。
|
||||
|
||||
## 分隔符校验
|
||||
- `parser.ts` 暴露 `isValidDelimiter` 方法,格式错误时分隔符会以红色底纹标记。
|
||||
- 复制/剪切操作会自动扩选到整个块,确保分隔符完整。
|
||||
|
||||
## 维护建议
|
||||
- 保持每个逻辑主题占用一个块,并用 `md` 块写标题。
|
||||
- 大量多语言内容时,可用 `text` + 自动检测,待语言确定后再改分隔符。
|
||||
- 若文档出现“无法解析块”提示,可运行 `格式化文档` 或在命令面板触发“重建语法树”。
|
||||
@@ -1,42 +0,0 @@
|
||||
# 扩展与插件
|
||||
|
||||

|
||||
> 替换为展示扩展设置面板或功能合集(小地图、搜索、翻译)的截图。
|
||||
|
||||
voidraft 的扩展系统由 `internal/models/extensions.go` + 前端 `ExtensionManager` 驱动。扩展配置存储在 `%USERPROFILE%/.voidraft/data/extensions.json`,可在设置页面勾选启用或调整参数。
|
||||
|
||||
## 核心扩展
|
||||
| 扩展 ID | 功能 | 关键文件 |
|
||||
| --- | --- | --- |
|
||||
| `editor` | 基础 CodeMirror 行为、光标保护、滚轮缩放 | `frontend/src/views/editor/basic/*` |
|
||||
| `codeblock` | 块解析、拖拽、复制、格式化、数学、HTTP DSL | `extensions/codeblock` |
|
||||
| `vscodeSearch` | VSCode 风格搜索替换面板 | `extensions/vscodeSearch` |
|
||||
| `markdownPreview` | Markdown 实时预览 | `extensions/markdownPreview` |
|
||||
|
||||
## 编辑增强
|
||||
- **Rainbow Brackets (`rainbowBrackets`)**:彩虹色括号匹配。
|
||||
- **Fold (`fold`)**:代码折叠/展开,支持 `Ctrl+Alt+[`/`]`。
|
||||
- **Hyperlink (`hyperlink`)**:识别 URL/邮箱,`Ctrl+Click` 打开。
|
||||
- **Color Selector (`colorSelector`)**:悬浮配色器,支持 HEX/RGB/HSL。
|
||||
- **Checkbox (`checkbox`)**:Markdown 任务列表交互式勾选。
|
||||
- **Text Highlight (`textHighlight`)**:`Mod+Shift+H` 快速标记重点,可自定义颜色/透明度。
|
||||
|
||||
## 工具扩展
|
||||
- **Translator (`translator`)**:选区翻译;配置项包括默认翻译器、最短/最长字符数。后端集成 Bing/Google/Youdao/DeepL/TartuNLP。
|
||||
- **Minimap (`minimap`)**:右侧迷你地图,支持悬浮/常驻、显示字符/块,突出当前选区。
|
||||
- **Search (`search`)**:补充 VSCode 风格搜索,暴露命令给快捷键系统。
|
||||
- **HTTP Client (`httpclient`)**:DSL + 运行器,详见 [HTTP 客户端](/zh/guide/http-client)。
|
||||
|
||||
## 未来扩展(欢迎参与)
|
||||
- Vim / Emacs 键位层(正在计划)。
|
||||
- 自定义命令面板(Command Palette)。
|
||||
- 代码片段/模板库扩展。
|
||||
- AI 助手(文生代码/注释)。
|
||||
|
||||
## 开发者指南概述
|
||||
1. **注册扩展**:在 `extensionManager.registerFactory` 中添加自定义扩展工厂。
|
||||
2. **配置项**:在 `extensions.json` 中声明默认配置,并在设置面板暴露 UI(Vue 组件)。
|
||||
3. **热更新**:调用 `manager.updateExtensionImmediate(id, enabled, config)` 实时切换,无需刷新窗口。
|
||||
4. **后端交互**:通过 `ExtensionService.UpdateExtensionState` 将配置写入 SQLite。
|
||||
|
||||
> 如果需要编写自用扩展,可 fork 项目在 `frontend/src/views/editor/extensions` 中添加文件,再通过 PR 贡献给社区。
|
||||
@@ -1,86 +1,163 @@
|
||||
# 功能特性
|
||||
|
||||

|
||||
> 替换为展示彩虹括号、小地图、搜索工具条等扩展组合的截图。
|
||||
探索 voidraft 的强大功能,让它成为开发者的优秀工具。
|
||||
|
||||
## 1. 编辑体验
|
||||
### 块状编辑全流程
|
||||
- `∞∞∞language[-a]` 语法由 `codeblock/lang-parser` 解析,支持自动检测、分隔符校验、块范围缓存。
|
||||
- `blockState` 暴露 API(`getActiveBlock/getFirstBlock/getLastBlock`),供格式化、重排、复制、HTTP 执行等插件共享。
|
||||
- `mathBlock` 可在块尾展示计算结果,点击可复制;`CURRENCIES_LOADED` 注解在汇率更新时刷新缓存。
|
||||
## 块状编辑
|
||||
|
||||
### 语言支持
|
||||
- 内建 30+ 语言模版(`lang-parser/languages.ts`),覆盖 JS/TS/HTML/CSS/Go/Rust/Python/SQL/YAML/HTTP/Markdown/Plain/Text/Math。
|
||||
- 语言切换下拉实时更新分隔符;支持自定义别名(例如 `∞∞∞shell`)。
|
||||
voidraft 的核心功能是其块状编辑系统:
|
||||
|
||||
### 语法高亮与主题
|
||||
- `rainbowBracket`、`fold`、`hyperlink`、`colorSelector` 等扩展组合提供接近 VSCode 的体验。
|
||||
- `ThemeService` 预置 12+ 暗/亮主题,可在设置中克隆、修改 JSON 色板,并立即生效。
|
||||
- 每个块可以有不同的编程语言
|
||||
- 块之间由分隔符分隔(`∞∞∞语言`)
|
||||
- 快速在块之间导航
|
||||
- 独立格式化每个块
|
||||
|
||||
### 文本统计与滚轮缩放
|
||||
- `statsExtension` 实时统计行数、字符数和选区,展示在状态栏。
|
||||
- `wheelZoomExtension` 让 `Ctrl + 鼠标滚轮` 调整字体大小,同时同步 `configStore`。
|
||||
## 语法高亮
|
||||
|
||||
## 2. 高效工具箱
|
||||
### VSCode 式搜索替换
|
||||
- `extensions/vscodeSearch` 提供悬浮面板,支持大小写/整词/正则、向上/向下跳转、批量替换。
|
||||
- 对应快捷键:`Ctrl+F`、`Ctrl+H`、`Alt+Enter`(替换全部)。
|
||||
支持 30+ 种语言的专业语法高亮:
|
||||
|
||||
### Markdown 预览
|
||||
- `panelStore` 为每个文档维护预览状态,保证不同文档互不影响。
|
||||
- 选中 Markdown 块后点击工具栏预览按钮即可在右侧展开实时渲染面板。
|
||||
- 自动语言检测
|
||||
- 可自定义配色方案
|
||||
- 支持嵌套语言
|
||||
- 代码折叠支持
|
||||
|
||||
### HTTP 客户端
|
||||
- Request DSL + 运行器在 [专章](/zh/guide/http-client) 详细说明。
|
||||
- 支持变量、响应插入、多种请求体、定制 header、复制 cURL。
|
||||
## HTTP 客户端
|
||||
|
||||
### 翻译助手
|
||||
- `translator` 扩展监听选区,符合长度阈值后显示按钮;由 `TranslationService` 调用 Bing/Google/Youdao/DeepL/TartuNLP。
|
||||
- 支持语种缓存、复制译文、切换译文方向。
|
||||
用于 API 测试的内置 HTTP 客户端:
|
||||
|
||||
### 颜色与高亮
|
||||
- `colorSelector` 识别 `#fff/rgba/hsl`、打开取色器;`textHighlight` 用 `Mod+Shift+H` 标记重要行。
|
||||
### 请求类型
|
||||
- GET、POST、PUT、DELETE、PATCH
|
||||
- 自定义请求头
|
||||
- 多种请求体格式:JSON、FormData、URL 编码、XML、文本
|
||||
|
||||
## 3. 复杂布局能力
|
||||
### 多窗口
|
||||
- `WindowService` 允许为任意文档创建独立 WebView,URL 自动携带 `?documentId=`。
|
||||
- `WindowSnapService` 根据主窗口位置吸附子窗口(上下左右+四角),并缓存尺寸、位置。
|
||||
- 支持全局热键(默认 `Alt+X`)一键显示或隐藏所有窗口。
|
||||
### 请求变量
|
||||
定义和重用变量:
|
||||
|
||||
### 标签页
|
||||
- `tabStore` 通过 `enableTabs` 控制;支持拖拽排序、关闭其他/左侧/右侧标签。
|
||||
- 与多窗口互斥:当文档被新窗口接管后会从标签栏移除,避免重复。
|
||||
```http
|
||||
@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
token: "your-api-token"
|
||||
}
|
||||
|
||||
### 系统托盘与置顶
|
||||
- `TrayService` 控制关闭时隐藏到托盘或直接退出。
|
||||
- 工具栏提供图钉按钮,可即时切换 `AlwaysOnTop`(支持临时置顶和永久置顶)。
|
||||
GET "{{baseUrl}}/users" {
|
||||
authorization: "Bearer {{token}}"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 数据守护
|
||||
### SQLite + 自动迁移
|
||||
- `DatabaseService` 启动时执行 PRAGMA + 表结构校验,缺失字段自动 `ALTER TABLE`。
|
||||
- 默认生成 `documents/extensions/key_bindings/themes` 等表,支持软删除与锁定。
|
||||
### 响应处理
|
||||
- 查看格式化的 JSON 响应
|
||||
- 查看响应时间和大小
|
||||
- 检查响应头
|
||||
- 保存响应以供日后使用
|
||||
|
||||
### Git 备份
|
||||
- `BackupService` 将 `dataPath` 初始化为 Git 仓库,支持 Token/SSHKey/用户名密码三种方式。
|
||||
- 自动任务按分钟运行(`BackupInterval`),包括 add/commit/push;也可从 UI 触发一次性 push。
|
||||
## 代码格式化
|
||||
|
||||
### 配置快照
|
||||
- 所有设置存于 `config.json`,包含 `metadata.version/lastUpdated`,方便手工回滚。
|
||||
- `ConfigService.Watch` 为窗口吸附、托盘、热键等服务提供实时响应。
|
||||
集成 Prettier 支持:
|
||||
|
||||
### 自动更新
|
||||
- `SelfUpdateService` 先检查主源(Gitea),失败再回退到 GitHub;下载完成后可一键「重启并更新」。
|
||||
- 更新前可选自动触发 Git 备份(`backupBeforeUpdate`)。
|
||||
- 保存时格式化(可选)
|
||||
- 格式化选区或整个块
|
||||
- 支持 JavaScript、TypeScript、CSS、HTML、JSON 等
|
||||
- 可自定义格式化规则
|
||||
|
||||
## 5. 自动化与集成
|
||||
- **启动时动作**:可开启开机自启(`StartupService`)、默认最小化至托盘。
|
||||
- **HTTP 运行挂钩**:`response-inserter` 可在响应块尾部插入 `// @timestamp` 等自定义标记。
|
||||
- **Math/汇率**:`mathBlock` 可引用上一次结果 (`prev`),配合 `CURRENCIES_LOADED` 注解支撑货币换算。
|
||||
- **系统信息**:`SystemService` 暴露内存、GC、Goroutine 数量,可在调试面板查看。
|
||||
## 编辑器扩展
|
||||
|
||||
## 6. 可配置的快捷键
|
||||
- 详见 [键盘快捷键](/zh/guide/keyboard-shortcuts)。默认绑定定义在 `internal/models/key_bindings.go`,前端设置页可逐项修改、禁用。
|
||||
### VSCode 风格搜索
|
||||
- 查找和替换,支持正则表达式
|
||||
- 区分大小写和全字匹配选项
|
||||
- 跨所有块搜索
|
||||
|
||||
### 小地图
|
||||
- 文档的鸟瞰图
|
||||
- 快速导航
|
||||
- 可自定义大小和位置
|
||||
|
||||
### 彩虹括号
|
||||
- 彩色括号配对
|
||||
- 更容易匹配括号
|
||||
- 可自定义颜色
|
||||
|
||||
### 颜色选择器
|
||||
- 可视化颜色选择
|
||||
- 支持 hex、RGB、HSL
|
||||
- 实时预览
|
||||
|
||||
### 翻译工具
|
||||
- 翻译选定的文本
|
||||
- 支持多种语言
|
||||
- 快速键盘访问
|
||||
|
||||
### 文本高亮
|
||||
- 高亮重要文本
|
||||
- 多种高亮颜色
|
||||
- 持久化高亮
|
||||
|
||||
## 多窗口支持
|
||||
|
||||
高效使用多个窗口:
|
||||
|
||||
- 每个窗口都是独立的
|
||||
- 独立的文档
|
||||
- 同步的设置
|
||||
- 窗口状态持久化
|
||||
|
||||
## 主题自定义
|
||||
|
||||
完全控制编辑器外观:
|
||||
|
||||
### 内置主题
|
||||
- 深色模式
|
||||
- 浅色模式
|
||||
- 根据系统自动切换
|
||||
|
||||
### 自定义主题
|
||||
- 创建你自己的主题
|
||||
- 自定义每种颜色
|
||||
- 保存和分享主题
|
||||
- 导入社区主题
|
||||
|
||||
## 自动更新系统
|
||||
|
||||
通过自动更新保持最新:
|
||||
|
||||
- 后台更新检查
|
||||
- 新版本通知
|
||||
- 一键更新
|
||||
- 更新历史
|
||||
- 支持多个更新源(GitHub、Gitea)
|
||||
|
||||
## 数据备份
|
||||
|
||||
使用基于 Git 的备份保护你的数据:
|
||||
|
||||
- 自动备份
|
||||
- 手动触发备份
|
||||
- 支持 GitHub 和 Gitea
|
||||
- 多种认证方式(SSH、Token、密码)
|
||||
- 可配置备份间隔
|
||||
|
||||
## 键盘快捷键
|
||||
|
||||
广泛的键盘支持:
|
||||
|
||||
- 可自定义快捷键
|
||||
- Vim/Emacs 按键绑定(计划中)
|
||||
- 快速命令面板
|
||||
- 上下文感知快捷键
|
||||
|
||||
## 性能
|
||||
|
||||
专为速度而构建:
|
||||
|
||||
- 快速启动时间
|
||||
- 流畅滚动
|
||||
- 高效内存使用
|
||||
- 支持大文件
|
||||
|
||||
## 隐私与安全
|
||||
|
||||
你的数据是安全的:
|
||||
|
||||
- 本地优先存储
|
||||
- 可选云备份
|
||||
- 无遥测或跟踪
|
||||
- 开源代码库
|
||||
|
||||
## 7. 文档 & 帮助
|
||||
- 文档站以 VitePress 构建(`frontend/docs`),内置中英双语导航,可一键部署到 GitHub Pages。
|
||||
- `README` 与本文档同步介绍核心功能;建议将常用工作流截图补充到每个「图片占位」中。
|
||||
|
||||
@@ -1,82 +1,107 @@
|
||||
# 快速开始
|
||||
|
||||

|
||||
> 替换为展示块分隔符、语言标签、内容的截图,帮助读者直观理解 `∞∞∞language` 结构。
|
||||
学习使用 voidraft 的基础知识并创建你的第一个文档。
|
||||
|
||||
## 5 分钟上手流程
|
||||
1. **启动应用**:等待加载动画结束,默认会打开 `default` 文档。
|
||||
2. **新建文档**:点击工具栏的文档列表按钮,输入标题后创建;也可在设置里开启标签页,以便同时挂载多个文档。
|
||||
3. **创建首个块**:
|
||||
- 在空白处输入 `∞∞∞javascript` 并回车。
|
||||
- 输入代码或文本,`CodeBlockExtension` 会自动匹配语法高亮。
|
||||
4. **格式化与预览**:
|
||||
- 选中块后点击工具栏的「Format」或使用 `Ctrl+Shift+F`。
|
||||
- 如果块语言是 `md`,可点击「Preview」按钮开启 Markdown 侧栏。
|
||||
5. **运行 HTTP 请求**:创建 `∞∞∞http` 块,填写请求,再点击行号旁的 Run 按钮即可获取响应。
|
||||
6. **打开第二窗口**:在文档列表中右键文档 -> “在新窗口中打开”。`WindowService` 会创建无边框窗口并自动贴靠主窗口。
|
||||
## 编辑器界面
|
||||
|
||||
## 界面导览
|
||||
- **主编辑区**:CodeMirror 视图,支持鼠标滚轮 + `Ctrl` 缩放(`wheelZoomExtension`)。
|
||||
- **右侧小地图**:`extensions/minimap` 提供鸟瞰和选区同步。
|
||||
- **底部状态**:`editorStore.documentStats` 实时展示行数/字符/选区。
|
||||
- **工具栏**(`Toolbar.vue`):包含文档切换、块语言下拉、窗口置顶、格式化、Markdown 预览、更新提示、进入设置等。
|
||||
当你打开 voidraft 时,你将看到:
|
||||
|
||||
## 块的基本操作
|
||||
| 操作 | 快捷键 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 新建块(下方) | `Ctrl+Enter` | 在当前块后插入 `∞∞∞text-a` 分隔符 |
|
||||
| 新建块(上方) | `Ctrl+Shift+Enter` | 在当前块前插入 |
|
||||
| 跳到上/下一个块 | `Alt+Up / Alt+Down` | 通过 `blockGotoPrevious/Next` 命令 |
|
||||
| 删除块 | `Alt+Delete` | 仅删除块内容,不影响其他块 |
|
||||
| 块排序 | `Ctrl+Shift+↑/↓` | `moveLines` 结合块范围移动 |
|
||||
| 复制块 | `Ctrl+C`(光标在块上即可) | `copyPaste.ts` 自动扩展选区至整个块 |
|
||||
- **主编辑器**:编写和编辑的中心区域
|
||||
- **工具栏**:快速访问常用操作
|
||||
- **状态栏**:显示当前块的语言和其他信息
|
||||
|
||||
## 自动语言与格式化
|
||||
- 当分隔符写成 `∞∞∞text-a` 时,会触发语言自动检测(`lang-detect/autodetect.ts`),常用于粘贴未知代码。
|
||||
- `formatCode.ts` 调用 Prettier,自动选择 parser;若语言不支持,会提示不可格式化。
|
||||
- 块语言可在工具栏下拉中修改,列表由 `lang-parser/languages.ts` 提供。
|
||||
## 创建代码块
|
||||
|
||||
## Markdown / 待办
|
||||
1. 使用 `∞∞∞md` 分隔符。
|
||||
2. 在块内写 Markdown,点击工具栏预览按钮。
|
||||
3. 勾选/取消 Checkbox(`extensions/checkbox`)即可同步更新文本。
|
||||
voidraft 使用基于块的编辑系统。每个块可以有不同的语言:
|
||||
|
||||
## 翻译与文本标注
|
||||
- 选中文本后会浮现翻译入口(`translator` 扩展),点击即可在块内查看结果、复制、切换目标语言。
|
||||
- `textHighlight` 扩展提供 `Mod+Shift+H` 高亮当前选区,颜色可在扩展设置中调整。
|
||||
1. 按 `Ctrl+Enter` 创建新块
|
||||
2. 输入 `∞∞∞` 后跟语言名称(例如 `∞∞∞javascript`)
|
||||
3. 在该块中开始编码
|
||||
|
||||
### 支持的语言
|
||||
|
||||
voidraft 支持 30+ 种编程语言,包括:
|
||||
- JavaScript、TypeScript
|
||||
- Python、Go、Rust
|
||||
- HTML、CSS、Sass
|
||||
- SQL、YAML、JSON
|
||||
- 以及更多...
|
||||
|
||||
## 基本操作
|
||||
|
||||
### 导航
|
||||
|
||||
- `Ctrl+Up/Down`:在块之间移动
|
||||
- `Ctrl+Home/End`:跳转到第一个/最后一个块
|
||||
- `Ctrl+F`:在文档中搜索
|
||||
|
||||
### 编辑
|
||||
|
||||
- `Ctrl+D`:复制当前行
|
||||
- `Ctrl+/`:切换注释
|
||||
- `Alt+Up/Down`:向上/向下移动行
|
||||
- `Ctrl+Shift+F`:格式化代码(如果语言支持 Prettier)
|
||||
|
||||
### 块管理
|
||||
|
||||
- `Ctrl+Enter`:创建新块
|
||||
- `Ctrl+Shift+Enter`:在上方创建块
|
||||
- `Alt+Delete`:删除当前块
|
||||
|
||||
## 使用 HTTP 客户端
|
||||
|
||||
voidraft 包含用于测试 API 的内置 HTTP 客户端:
|
||||
|
||||
1. 创建一个 HTTP 语言的块
|
||||
2. 编写你的 HTTP 请求:
|
||||
|
||||
## HTTP 客户端概览
|
||||
```http
|
||||
∞∞∞http
|
||||
@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
token: "{{secrets.token}}"
|
||||
}
|
||||
|
||||
POST "{{baseUrl}}/users" {
|
||||
authorization: "Bearer {{token}}"
|
||||
POST "https://api.example.com/users" {
|
||||
content-type: "application/json"
|
||||
|
||||
|
||||
@json {
|
||||
name: "voidraft",
|
||||
role: "developer"
|
||||
name: "张三",
|
||||
email: "zhangsan@example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `parser/request-parser.ts` 会将变量与请求体解析为结构化对象。
|
||||
- 点击 gutter Run 获取响应,`response-inserter.ts` 会将结果写入 `### Response` 区块。
|
||||
|
||||
## 自动保存与版本安全
|
||||
- `editorStore` 为每个文档维护 `autoSaveTimer`,默认 2000 ms,可在设置 > 编辑 调整。
|
||||
- `documentStates` 记录每个文档的光标位置,切换文档或重启应用都会恢复。
|
||||
- 若开启 Git 备份,可在工具栏或设置中查看最近一次 `push` 是否成功。
|
||||
3. 点击运行按钮执行请求
|
||||
4. 内联查看响应
|
||||
|
||||
## 最佳实践
|
||||
- 使用 Markdown 块为每组代码加标题/注释,便于导航。
|
||||
- 重要文档启用“锁定”以避免被删除(文档右键菜单)。
|
||||
- 多窗口 + 吸附用于常驻参考资料,标签页用于在一个窗口内快速切换。
|
||||
- 善用「窗口置顶」图钉,让 voidraft 叠放在 VSCode/浏览器之上。
|
||||
## 多窗口支持
|
||||
|
||||
同时处理多个文档:
|
||||
|
||||
1. 转到 `文件 > 新建窗口`(或 `Ctrl+Shift+N`)
|
||||
2. 每个窗口都是独立的
|
||||
3. 更改会自动保存
|
||||
|
||||
## 自定义主题
|
||||
|
||||
个性化你的编辑器:
|
||||
|
||||
1. 打开设置(`Ctrl+,`)
|
||||
2. 转到外观
|
||||
3. 选择主题或创建自己的主题
|
||||
4. 根据你的偏好自定义颜色
|
||||
|
||||
## 键盘快捷键
|
||||
|
||||
学习基本快捷键:
|
||||
|
||||
| 操作 | 快捷键 |
|
||||
|-----|--------|
|
||||
| 新建窗口 | `Ctrl+Shift+N` |
|
||||
| 搜索 | `Ctrl+F` |
|
||||
| 替换 | `Ctrl+H` |
|
||||
| 格式化代码 | `Ctrl+Shift+F` |
|
||||
| 切换主题 | `Ctrl+Shift+T` |
|
||||
| 命令面板 | `Ctrl+Shift+P` |
|
||||
|
||||
## 下一步
|
||||
|
||||
现在你已经了解了基础知识:
|
||||
|
||||
- 详细探索[功能特性](/zh/guide/features)
|
||||
|
||||
接下来:
|
||||
- [界面总览](/zh/guide/ui-overview)
|
||||
- [块语法与结构](/zh/guide/block-syntax)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# HTTP 客户端
|
||||
|
||||

|
||||
> 替换为 HTTP 块 + 响应卡片的截图。
|
||||
|
||||
voidraft 将 HTTP 测试写成块(`∞∞∞http`),语法与 JetBrains Http Client 类似。解析与执行由 `frontend/src/views/editor/extensions/httpclient` 完成。
|
||||
|
||||
## 基本语法
|
||||
```http
|
||||
∞∞∞http
|
||||
GET "https://api.example.com/users" {
|
||||
accept: "application/json"
|
||||
}
|
||||
```
|
||||
- 方法 + URL 必须用双引号包裹。
|
||||
- Header 以 `key: "value"` 格式编写。
|
||||
- 请求体使用内联指令(见下文)。
|
||||
|
||||
## 变量与环境
|
||||
```http
|
||||
@var {
|
||||
baseUrl: "https://api.example.com",
|
||||
token: "{{secrets.token}}"
|
||||
}
|
||||
|
||||
GET "{{baseUrl}}/users" {
|
||||
authorization: "Bearer {{token}}"
|
||||
}
|
||||
```
|
||||
- `@var` 块使用 JSON 语法。
|
||||
- 变量在任意请求中以 `{{name}}` 引用。
|
||||
- `variable-resolver.ts` 支持嵌套、默认值、外部 secrets 映射。
|
||||
|
||||
## 请求体助手
|
||||
| 指令 | 示例 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `@json` | `@json { "name": "voidraft" }` | 自动 `content-type: application/json` 并格式化 |
|
||||
| `@form` | `@form { username: "demo" }` | 转为 `application/x-www-form-urlencoded` |
|
||||
| `@multipart` | `@multipart { file: @"C:\tmp\a.txt" }` | 读取文件、多段表单 |
|
||||
| `@text` | `@text <raw body>` | 自由文本 |
|
||||
|
||||
## 运行与响应
|
||||
1. 将光标置于 HTTP 块内。
|
||||
2. 点击行号左侧的 Run(三角形图标),或按下自定义快捷键。
|
||||
3. 运行结果会插入到块尾的 `### Response` 中,包含:
|
||||
- 状态行 + 响应时间 + 体积。
|
||||
- Headers(可折叠)。
|
||||
- 响应体(自动格式化 JSON / XML / HTML / Text)。
|
||||
- 复制、另存为、再次发送等快捷按钮。
|
||||
|
||||
## 多请求文档
|
||||
- 每个 `∞∞∞http` 块被视为独立请求。
|
||||
- `request-parser.ts` 会解析同一块内的多个请求(以 `###` 分隔)。
|
||||
- 使用 Markdown 块写注释或分组标题。
|
||||
|
||||
## 变量注入顺序
|
||||
1. 块内 `@var`。
|
||||
2. 文档级变量(计划中)。
|
||||
3. 环境变量(`EnvironmentService` 预留)。
|
||||
|
||||
## 调试技巧
|
||||
- 运行器会在控制台打印完整请求信息,可通过 `wails3 dev` 查看。
|
||||
- 如果响应过大,可右键响应块选择“折叠正文”或“导出到文件”。
|
||||
- 网络错误会在响应卡片顶部以红条展示,内容来自 `HttpClientService`。
|
||||
- 需要代理时确保系统代理已设置,voidraft 会自动继承。
|
||||
|
||||
## 与其他功能配合
|
||||
- 运行结果可直接与 Markdown/代码块混排,形成 API 使用手册。
|
||||
- 配合 Git 备份可版本化 API 调试记录。
|
||||
- 可将响应复制到其他块(例如 JSON → Prettier 之后用于 mock)。
|
||||
|
||||
> 欢迎在 Issue 中提交你希望支持的额外 DSL 指令(例如 GraphQL、WebSocket、gRPC)。
|
||||
@@ -1,77 +1,63 @@
|
||||
# 安装
|
||||
|
||||

|
||||
> 替换为安装向导或设置页截图,展示关键开关(置顶、数据目录、自动更新等)。
|
||||
本指南将帮助你在系统上安装 voidraft。
|
||||
|
||||
## 系统要求(2025.11)
|
||||
| 项目 | 最低配置 | 推荐配置 |
|
||||
| --- | --- | --- |
|
||||
| 操作系统 | Windows 10 19045 / Windows 11 21H2 | Windows 11 23H2(macOS/Linux 版本开发中) |
|
||||
| CPU | x86_64 双核 | 4 核以上 |
|
||||
| 内存 | 4 GB | ≥ 8 GB |
|
||||
| 磁盘空间 | 200 MB(含 SQLite 数据) | 1 GB 以上以保存附件/备份 |
|
||||
| 运行环境 | Go 1.21+, Node.js 18+(仅开发者编译时需要) | 同左 + pnpm 8 用于前端 |
|
||||
## 系统要求
|
||||
|
||||
## 获取发行版
|
||||
1. 打开 [GitHub Releases](https://github.com/landaiqing/voidraft/releases) 或自建 Gitea 镜像。
|
||||
2. 下载 `voidraft-windows-amd64-installer.exe`(安装版)或 `voidraft-portable.zip`(绿色版)。
|
||||
3. (可选)验证 SHA256:
|
||||
```powershell
|
||||
Get-FileHash .\voidraft-windows-amd64-installer.exe -Algorithm SHA256
|
||||
```
|
||||
4. 双击安装包,按向导完成安装;或解压绿色版至任意目录并创建快捷方式。
|
||||
- **操作系统**:Windows 10 或更高版本(macOS 和 Linux 支持计划中)
|
||||
- **内存**:最低 4GB,推荐 8GB
|
||||
- **磁盘空间**:200MB 可用空间
|
||||
|
||||
## 首次启动流程
|
||||
1. 启动后将创建数据目录:`%USERPROFILE%\.voidraft\data`(含 `voidraft.db`、`config.json`、`extensions.json`)。
|
||||
2. 默认会生成 `default` 文档和一段示例块 `∞∞∞text-a`。
|
||||
3. 若检测到旧版本数据,`ConfigMigrationService` 会自动迁移字段;`DataMigrationService` 确保表结构一致。
|
||||
4. 首次运行建议立刻打开「设置 > 备份」配置远程 Git 仓库。
|
||||
## 下载
|
||||
|
||||
## 开发者手动构建
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/landaiqing/voidraft.git
|
||||
cd voidraft
|
||||
访问[发布页面](https://github.com/landaiqing/voidraft/releases)并下载适合你平台的最新版本:
|
||||
|
||||
# 安装前端依赖
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
- **Windows**:`voidraft-windows-amd64-installer.exe`
|
||||
|
||||
# 构建/运行桌面应用
|
||||
wails3 dev # 启动调试
|
||||
wails3 package # 生成安装包(输出位于 bin/)
|
||||
```
|
||||
> 若遇到 `wails3` 未找到,请先执行 `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`。
|
||||
## 安装步骤
|
||||
|
||||
## 数据目录与可执行文件
|
||||
| 类型 | 默认位置 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 安装目录 | `C:\Program Files\voidraft` | 包含主程序与嵌入式前端资源 |
|
||||
| 数据目录 | `C:\Users\<you>\.voidraft\data` | 可在设置 > 通用修改 `dataPath`,修改后需重启 |
|
||||
| 备份仓库 | `dataPath/.git` | `BackupService` 初始化或使用现有仓库 |
|
||||
| 日志 | `%LOCALAPPDATA%/voidraft/logs/*.log` | 通过 Wails `application.Log` 输出 |
|
||||
### Windows
|
||||
|
||||
## 常用 CLI 检查
|
||||
```powershell
|
||||
# 查看版本
|
||||
& "C:\Program Files\voidraft\voidraft.exe" --version
|
||||
1. 从发布页面下载安装程序
|
||||
2. 运行 `voidraft-windows-amd64-installer.exe` 文件
|
||||
3. 按照安装向导操作
|
||||
4. 从开始菜单或桌面快捷方式启动 voidraft
|
||||
|
||||
# 清理缓存(若前端异常)
|
||||
Remove-Item "$env:APPDATA\voidraft\Cache" -Recurse -Force
|
||||
```
|
||||
## 首次启动
|
||||
|
||||
## 防火墙与代理
|
||||
- voidraft 仅在使用 HTTP 客户端、更新检测、REST 翻译器时发起网络请求。
|
||||
- 若处于企业代理,请在系统代理中放行 `voidraft.exe` 或设置环境变量 `HTTP(S)_PROXY`,HTTP 客户端会继承系统代理。
|
||||
首次启动 voidraft 时:
|
||||
|
||||
## 常见安装问题
|
||||
| 症状 | 处理方案 |
|
||||
| --- | --- |
|
||||
| 安装向导被安全策略阻止 | 使用签名哈希进行白名单设置或改用便携版 |
|
||||
| 启动后白屏 | 删除 `%APPDATA%/voidraft/Cache`,确保显卡驱动支持 WebView2 |
|
||||
| `wails3 dev` 报错缺少 WebView2 | 安装 [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) |
|
||||
| 便携版无法写入 | 检查解压目录是否具有写权限,或在设置内切换 `dataPath` 至可写分区 |
|
||||
1. 应用程序将创建一个数据目录来存储你的文档
|
||||
2. 你将看到带有欢迎块的主编辑器界面
|
||||
3. 开始输入或创建你的第一个代码块!
|
||||
|
||||
## 配置
|
||||
|
||||
voidraft 将其配置和数据存储在:
|
||||
|
||||
- **Windows**:`%APPDATA%/voidraft/`
|
||||
|
||||
你可以自定义各种设置,包括:
|
||||
- 编辑器主题(深色/浅色模式)
|
||||
- 代码格式化偏好
|
||||
- 备份设置
|
||||
- 键盘快捷键
|
||||
|
||||
## 更新
|
||||
|
||||
voidraft 包含自动更新功能,会在有新版本时通知你。你可以:
|
||||
|
||||
- 从设置中手动检查更新
|
||||
- 启用自动更新
|
||||
- 选择首选的更新源
|
||||
|
||||
## 故障排除
|
||||
|
||||
如果在安装过程中遇到任何问题:
|
||||
|
||||
1. 确保你有管理员权限
|
||||
2. 检查杀毒软件是否阻止了安装
|
||||
3. 访问我们的 [GitHub issues](https://github.com/landaiqing/voidraft/issues) 页面寻求帮助
|
||||
|
||||
下一步:[快速开始 →](/zh/guide/getting-started)
|
||||
|
||||
> 继续阅读:[快速开始](/zh/guide/getting-started)
|
||||
|
||||
@@ -1,73 +1,50 @@
|
||||
# 简介
|
||||
|
||||
> voidraft 是一款面向开发者的「块式工作台」,用 CodeMirror 6 打造 Heynote 风格的体验,并结合 Wails3 + Go 后端提供系统托盘、全局热键、自动备份等桌面级能力。
|
||||
欢迎使用 voidraft —— 一个专为开发者设计的优雅文本片段记录工具。
|
||||
|
||||

|
||||
> 将 `/img/placeholder-main-ui.png` 替换为真实的应用主界面截图,演示数据面板、工具栏和右侧小地图。
|
||||
## 什么是 voidraft?
|
||||
|
||||
## 产品定位
|
||||
- **核心诉求**:在一处快速记录代码/配置/API 响应/待办清单,并能随时重排、格式化、运行或搜索。
|
||||
- **目标用户**:需要跨项目管理零碎文本的开发者、DevOps、测试或产品技术写作者。
|
||||
- **设计理念**:所有内容都拆成可重排的块(`∞∞∞language`);每个块拥有独立语言、格式化器与扩展;多窗口/多标签保证同一份数据的不同视角。
|
||||
voidraft 是一个现代化的桌面应用程序,帮助开发者管理文本片段、代码块、API 响应、会议笔记和日常待办事项。它为开发工作流程提供了流畅而优雅的编辑体验和强大的功能。
|
||||
|
||||
## 面向场景
|
||||
1. **临时代码/脚本草稿**:支持 30+ 语言高亮、Prettier 格式化、彩虹括号、文本高亮。
|
||||
2. **API 调试台**:HTTP 块内置运行器、变量解析、响应插入;请求和响应始终和文档共存。
|
||||
3. **会议 & 需求记录**:Markdown 块 + Checkbox 扩展 + 颜色标注快速整理想法。
|
||||
4. **翻译与研究**:选中文本即可调 Bing/Google/DeepL/TartuNLP/有道翻译,结果内联呈现。
|
||||
5. **多窗口资料墙**:重要文档可弹出独立无边框窗口,依附(Snap)在主窗口侧边。
|
||||
## 核心特性
|
||||
|
||||
## 核心概念
|
||||
### 块式编辑器
|
||||
- 解析器位于 `frontend/src/views/editor/extensions/codeblock`,依赖自研 Lezer 语法树确保 `∞∞∞` 分隔符稳定。
|
||||
- 块结构(语言、是否自动检测、正文范围)存入 `blockState`,供格式化、移动、复制、HTTP 执行等扩展复用。
|
||||
- `math` 块使用 `math.js` 运行器,`http` 块调用 request parser + gutter run widget。
|
||||
### 块状编辑模式
|
||||
|
||||
### 扩展驱动
|
||||
- 后端通过 `internal/models/extensions.go` 定义扩展 ID/配置,`ExtensionService` 负责持久化。
|
||||
- 前端 `ExtensionManager` 根据扩展配置动态拼装 CodeMirror Extension pipeline(小地图、VSCode Search、Translator、Color Picker 等)。
|
||||
- 所有扩展都可在设置页热切换,立即同步到当前与所有已打开的编辑器实例。
|
||||
voidraft 使用受 Heynote 启发的独特块状编辑系统。你可以将内容分割为独立的代码块,每个块具有:
|
||||
- 不同的编程语言设置
|
||||
- 语法高亮
|
||||
- 独立格式化
|
||||
- 轻松在块之间导航
|
||||
|
||||
### 数据与安全
|
||||
- SQLite 数据保存在 `%USERPROFILE%/.voidraft/data/voidraft.db`(可在设置中自定义 dataPath)。
|
||||
- `DatabaseService` 自动迁移表结构,`DocumentService` 提供软删除/锁定机制避免误删默认草稿。
|
||||
- `BackupService` 基于 go-git(SSH/Token/用户名密码)把 dataPath git 化,可按分钟全量提交、推送到 GitHub/Gitea 等。
|
||||
- `SelfUpdateService` 同时轮询 GitHub/Gitea Release,支持自动下载 + 一键重启。
|
||||
### 开发者工具
|
||||
|
||||
## 系统架构概览
|
||||
| 层级 | 说明 | 关键路径 |
|
||||
| --- | --- | --- |
|
||||
| 桌面容器 | Wails3 + Go 1.21,负责窗口、托盘、热键、服务注入 | `main.go`, `internal/services` |
|
||||
| 后端服务 | Config/Document/Extension/Theme/Backup/Window/Hotkey/Translation 等 | `internal/services/*.go` |
|
||||
| 数据模型 | Document、Theme、KeyBinding、GitBackup、Config | `internal/models` |
|
||||
| 前端应用 | Vue 3 + Vite + Pinia + vue-router | `frontend/src` |
|
||||
| 编辑器内核 | CodeMirror 6 扩展及自研块解析、HTTP DSL、Markdown 预览 | `frontend/src/views/editor` |
|
||||
| 文档站点 | VitePress,多语言导航 | `frontend/docs` |
|
||||
- **HTTP 客户端**:直接在编辑器中测试 API
|
||||
- **代码格式化**:内置 Prettier 支持多种语言
|
||||
- **语法高亮**:支持 30+ 种编程语言
|
||||
- **自动语言检测**:自动识别代码块语言类型
|
||||
|
||||
## 模块速览
|
||||
- **文档存储**:`DocumentService` 支持创建/重命名/软删除/恢复、多窗口并发打开同一文档。
|
||||
- **编辑器实例管理**:`editorStore` 使用 LRU 缓存 + 自动保存计时器,确保在多文档切换时保留光标位置、未保存内容。
|
||||
- **HTTP 客户端**:`extensions/httpclient` 包括 Lezer 语法、变量解析、响应插入与运行 gutter;支持 JSON/FormData/GraphQL 等多体格式。
|
||||
- **Markdown 预览**:`panelStore` 管理逐文档的预览状态,可随块实时刷新。
|
||||
- **多窗口/吸附**:`WindowService` + `WindowSnapService` 根据主窗口位置智能吸附子窗口、自动记忆尺寸。
|
||||
- **全局热键**:`HotkeyService` 监听系统级组合键,切换窗口显隐(默认 Alt+X,可配置)。
|
||||
- **系统托盘**:`systray.SetupSystemTray` 注入显示/隐藏、退出、开机启动等操作。
|
||||
- **翻译生态**:`TranslationService` 聚合 Bing/Google/Youdao/DeepL/TartuNLP,前端 `translator` 扩展提供 Tooltip + 复制。
|
||||
- **主题与外观**:`ThemeService` 预置 12+ 主题,可重置/克隆;前端 `createThemeExtension` 即时应用。
|
||||
### 自定义
|
||||
|
||||
## 数据流(从键盘到持久化)
|
||||
1. 用户按键 -> CodeMirror extensions 更新文档。
|
||||
2. `contentChangeExtension` 记录脏状态并刷新 `documentStats`(行数、字符数、选区字符数)。
|
||||
3. 触发自动保存计时器(默认 2s) -> `DocumentService.UpdateDocumentContent` 写入 SQLite。
|
||||
4. 若开启 Git 自动备份,每次 Commit 会序列化数据库 + 附带 `voidraft_data.bin`。
|
||||
5. 配置变更(Pinia store)通过 `ConfigService.Set` 传回 Go,并触发观察者(如 WindowSnap/Hotkey/Backup)。
|
||||
- **自定义主题**:创建并保存你自己的编辑器主题
|
||||
- **扩展功能**:丰富的编辑器扩展,包括小地图、彩虹括号、颜色选择器等
|
||||
- **多窗口**:同时处理多个文档
|
||||
|
||||
## 版本节奏与路线图
|
||||
- ✅ 当前实现:多窗口、标签页、HTTP 客户端、Markdown Preview、数学块、彩虹括号、翻译、Git 备份、自动更新。
|
||||
- 🚧 进行中:自定义扩展导入、键位模版、Linux/macOS 原生打包。
|
||||
- 🗺️ 规划中:剪贴板历史、团队同步、云端模板市场。
|
||||
### 数据管理
|
||||
|
||||
- **Git 备份**:使用 Git 仓库自动备份
|
||||
- **云同步**:跨设备同步你的数据
|
||||
- **自动更新**:及时获取最新功能
|
||||
|
||||
## 为什么选择 voidraft?
|
||||
|
||||
- **专注开发者**:考虑开发者需求而构建
|
||||
- **现代技术栈**:使用前沿技术(Wails3、Vue 3、CodeMirror 6)
|
||||
- **跨平台**:支持 Windows(macOS 和 Linux 支持计划中)
|
||||
- **开源**:MIT 许可证,社区驱动开发
|
||||
|
||||
## 开始使用
|
||||
|
||||
准备好开始了吗?从我们的[发布页面](https://github.com/landaiqing/voidraft/releases)下载最新版本,或继续阅读文档了解更多。
|
||||
|
||||
下一步:[安装 →](/zh/guide/installation)
|
||||
|
||||
## 下一步
|
||||
- [安装 voidraft](/zh/guide/installation)
|
||||
- [界面总览](/zh/guide/ui-overview)
|
||||
- [快速开始](/zh/guide/getting-started)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# 键盘快捷键
|
||||
|
||||

|
||||
> 替换为展示快捷键设置界面或常用快捷键速查表的截图。
|
||||
|
||||
快捷键定义源自 `internal/models/key_bindings.go`,在设置 > 键位 中可以启用/禁用或改写。下表列出常用组合:
|
||||
|
||||
## 块管理
|
||||
| 功能 | 默认快捷键 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| 新建块(下方) | `Ctrl+Enter` | `blockAddAfterCurrent` |
|
||||
| 新建块(上方) | `Ctrl+Shift+Enter` | `blockAddBeforeCurrent` |
|
||||
| 跳到上/下一个块 | `Alt+↑ / Alt+↓` | `blockGotoPrevious/Next` |
|
||||
| 选择当前块 | `Ctrl+Shift+A` | `blockSelectAll` |
|
||||
| 删除块 | `Alt+Delete` | `blockDelete` |
|
||||
| 块上移/下移 | `Ctrl+Shift+↑ / Ctrl+Shift+↓` | `blockMoveUp/Down` |
|
||||
| 复制块 | `Ctrl+C`(块获得焦点) | `blockCopy` |
|
||||
| 剪切块 | `Ctrl+X` | `blockCut` |
|
||||
| 粘贴块 | `Ctrl+V` | `blockPaste` |
|
||||
|
||||
## 行与文本编辑
|
||||
| 功能 | 快捷键 |
|
||||
| --- | --- |
|
||||
| 行复制(上/下) | `Shift+Alt+↑ / Shift+Alt+↓` |
|
||||
| 行移动(上/下) | `Alt+↑ / Alt+↓`(在块内部) |
|
||||
| 插入空行 | `Ctrl+Enter`(块尾后仍可插入) |
|
||||
| 选择整行 | `Alt+L` |
|
||||
| 语法级跳转 | `Ctrl+Alt+Left / Ctrl+Alt+Right` |
|
||||
| 匹配括号 | `Shift+Ctrl+\` |
|
||||
| 注释/块注释 | `Ctrl+/` / `Shift+Alt+A` |
|
||||
| Tab 缩进/反向缩进 | `Ctrl+]` / `Ctrl+[` |
|
||||
| 删除单词(向前/向后) | `Ctrl+Backspace` / `Ctrl+Delete` |
|
||||
|
||||
## 搜索与替换
|
||||
| 功能 | 快捷键 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| 打开搜索 | `Ctrl+F` | `showSearch` |
|
||||
| 打开替换 | `Ctrl+H` | `searchShowReplace` |
|
||||
| 切换大小写/整词/正则 | `Alt+C / Alt+W / Alt+R` | `searchToggleCase/Word/Regex` |
|
||||
| 替换全部 | `Alt+Enter` | `searchReplaceAll` |
|
||||
|
||||
## Markdown/预览/格式化
|
||||
| 功能 | 快捷键 |
|
||||
| --- | --- |
|
||||
| 格式化块 | `Ctrl+Shift+F` |
|
||||
| 打开 Markdown 预览 | 工具栏按钮(建议映射到 `Ctrl+Shift+M`) |
|
||||
| 高亮文本 | `Mod+Shift+H` |
|
||||
|
||||
## 窗口与系统
|
||||
| 功能 | 快捷键 |
|
||||
| --- | --- |
|
||||
| 新建窗口 | `Ctrl+Shift+N`(命令面板) |
|
||||
| 全局显示/隐藏所有窗口 | 默认 `Alt+X`(可在设置 > 通用 > 全局热键中修改) |
|
||||
| 打开设置 | `Ctrl+,` |
|
||||
| 切换主题 | `Ctrl+Shift+T`(可自定义) |
|
||||
|
||||
## HTTP 客户端
|
||||
| 功能 | 快捷键 |
|
||||
| --- | --- |
|
||||
| 运行请求 | 点击行号旁 Run 或自定义 `Ctrl+Alt+R` |
|
||||
| 复制响应正文 | `Ctrl+Alt+C`(响应块聚焦时) |
|
||||
|
||||
## 翻译工具
|
||||
| 功能 | 快捷键 |
|
||||
| --- | --- |
|
||||
| 显示翻译浮层 | 选中 ≥ `minSelectionLength` 的文本后按 `Ctrl+'`(可自定义) |
|
||||
| 复制译文 | 在浮层中按 `Ctrl+C` |
|
||||
|
||||
## 自定义与导出
|
||||
1. 打开设置 > 键位,列表会加载来自 `ExtensionService.GetAllKeyBindings()` 的数据。
|
||||
2. 可单独禁用某个绑定或录入新组合;存储在 `%USERPROFILE%/.voidraft/data/key_bindings.json`。
|
||||
3. 需要与系统级快捷键冲突时,可勾选“忽略系统修饰键”。
|
||||
|
||||
> 建议将以上表格打印贴在工作区,或在文档中保留常用组合,方便新同事查阅。
|
||||
@@ -1,67 +0,0 @@
|
||||
# 多窗口与标签页
|
||||
|
||||

|
||||
> 替换为展示主窗口 + 侧边浮窗(子窗口)或标签页齐开的截图。
|
||||
|
||||
## 多窗口工作流
|
||||
- `WindowService.OpenDocumentWindow` 会根据文档 ID 创建新 WebView 窗口,URL 自动附加 `?documentId=<id>`。
|
||||
- `windowStore` 通过查询字符串判断当前是否为子窗口(非主窗口)。
|
||||
- 子窗口具备:
|
||||
- 独立的 CodeMirror 实例与扩展栈。
|
||||
- 与主窗口共享的 Document/Config Store,因此编辑内容实时同步(SQLite 数据库为唯一来源)。
|
||||
- `WindowSnapService` 提供吸附:拖动靠近主窗口边缘时自动贴靠;支持上下左右以及四个角。
|
||||
- 关闭时自动注销吸附状态,避免悬挂引用。
|
||||
|
||||
### 操作步骤
|
||||
1. 打开文档列表(工具栏图标或 `Ctrl+Shift+O`)。
|
||||
2. 右键目标文档 → 选择 “在新窗口中打开”。
|
||||
3. 若文档已在标签页打开,会自动从标签栏移除,防止重复。
|
||||
4. 关闭窗口:
|
||||
- 点击自定义标题栏关闭按钮。
|
||||
- 系统托盘菜单选择退出。
|
||||
|
||||
### 使用建议
|
||||
- 将参考资料或检查清单放在子窗口中,配合“窗口置顶”保持常驻。
|
||||
- 通过 Windows Snap + voidraft Snap 组合,可快速排版 2-4 个窗口。
|
||||
- 若想在多窗口之间同步滚动,可尝试启用“共享视图状态”扩展(计划中)。
|
||||
|
||||
## 标签页模式
|
||||
- 在设置 > 通用中开启“启用标签页”(`config.general.enableTabs`)。
|
||||
- `tabStore` 维护 `tabsMap` + `tabOrder`,支持:
|
||||
- 拖拽排序(拖动标签即可)。
|
||||
- 关闭单个/其他/左侧/右侧标签。
|
||||
- 检测当前文档是否已存在标签。
|
||||
- 标签栏位于主窗口顶部,紧贴工具栏下方。
|
||||
|
||||
### 常用操作
|
||||
| 操作 | 方法 |
|
||||
| --- | --- |
|
||||
| 关闭标签 | 点击标签上的叉号或中键 |
|
||||
| 关闭其他 | 右键标签 → “关闭其他标签” |
|
||||
| 关闭右侧/左侧 | 右键标签 → 选择对应菜单 |
|
||||
| 固定标签(计划中) | 将在后续版本中提供 pin 功能 |
|
||||
|
||||
### Tabs vs 窗口
|
||||
| 项目 | 标签页 | 新窗口 |
|
||||
| --- | --- | --- |
|
||||
| UI 占用 | 集成在一个窗口中 | 独立操作系统窗口 |
|
||||
| 跨屏 | 不方便 | 可拖到其他显示器 |
|
||||
| 独立置顶 | 不可单独置顶 | 每个窗口可单独置顶 |
|
||||
| 推荐场景 | 同一背景的多个文档 | 跨项目/跨显示器对比 |
|
||||
|
||||
## 系统托盘与热键
|
||||
- 勾选 “启用系统托盘” 后,关闭窗口默认隐藏至托盘。
|
||||
- 全局热键(默认 `Alt+X`)由 `HotkeyService` 控制:
|
||||
- 若 main window 可见 → 隐藏所有 voidraft 窗口。
|
||||
- 若 main window 隐藏 → Show + Restore + Focus。
|
||||
- `TrayService` 还提供“最小化到托盘”“显示主窗口”等菜单项。
|
||||
|
||||
## 常见问题
|
||||
| 问题 | 原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 新窗口无法打开 | 文档 ID 不存在或被锁定 | 在文档列表确认状态,必要时解锁 |
|
||||
| 子窗口未吸附 | WindowSnap 未启用 | 设置 > 通用 → 勾选“窗口吸附” |
|
||||
| 关闭窗口直接退出应用 | 未启用托盘模式 | 设置 > 通用 → 启用系统托盘 |
|
||||
| 标签页切换慢 | 同时开启标签 + 多窗口导致资源占用 | 关闭暂不需要的窗口或减少标签 |
|
||||
|
||||
> 如果需要更复杂的布局(如平铺窗口、快捷布局),欢迎在 Issue 中提出建议。
|
||||
@@ -1,71 +0,0 @@
|
||||
# 设置与配置
|
||||
|
||||

|
||||
> 替换为设置页截图,突出通用/编辑/外观/更新/备份等分栏。
|
||||
|
||||
所有设置都映射到 `internal/models/config.go`,持久化文件位于 `%USERPROFILE%/.voidraft/data/config.json`。前端 `configStore` 负责与后端 `ConfigService` 同步。
|
||||
|
||||
## 通用(General)
|
||||
| 选项 | 说明 | 后端键 |
|
||||
| --- | --- | --- |
|
||||
| 窗口置顶 (`alwaysOnTop`) | 永久置顶主窗口 | `general.alwaysOnTop` |
|
||||
| 数据目录 (`dataPath`) | SQLite + 备份所在目录,修改后需重启 | `general.dataPath` |
|
||||
| 系统托盘 (`enableSystemTray`) | 关闭窗口后隐藏到托盘而非退出 | `general.enableSystemTray` |
|
||||
| 开机自启 (`startAtLogin`) | 调用 `StartupService` 注册 | `general.startAtLogin` |
|
||||
| 窗口吸附 (`enableWindowSnap`) | `WindowSnapService` 是否启用 | `general.enableWindowSnap` |
|
||||
| 全局热键 (`enableGlobalHotkey` + `globalHotkey`) | 默认 Alt+X,控制显隐 | `general.globalHotkey` |
|
||||
| 标签页 (`enableTabs`) | 启用多标签界面 | `general.enableTabs` |
|
||||
| 加载动画 (`enableLoadingAnimation`) | 切换文档时显示动画 | `general.enableLoadingAnimation` |
|
||||
|
||||
## 编辑(Editing)
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| Font Size/Family/Weight/Line Height | 立即作用于所有编辑器实例 |
|
||||
| Tab Size/Tab Type/Enable Tab Indent | 映射 `tabExtension` 行为 |
|
||||
| Auto Save Delay | ms,影响 `editorStore` 自动保存周期 |
|
||||
|
||||
## 外观(Appearance)
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| Language | UI 语言(`zh-CN`/`en-US`) |
|
||||
| System Theme | 深色/浅色/跟随系统 |
|
||||
| Current Theme | 选择预设或自定义主题(详见 [主题与外观](/zh/guide/themes)) |
|
||||
|
||||
## 更新(Updates)
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| Auto Update | 启动时自动检查更新 |
|
||||
| Primary/Backup Source | `github` 或 `gitea`,对应 `UpdatesConfig` |
|
||||
| Backup Before Update | 下载更新前执行 Git 备份 |
|
||||
| Update Timeout | HTTP 请求超时 |
|
||||
| GitHub/Gitea 仓库 | owner/repo/baseURL,可指向自建镜像 |
|
||||
|
||||
## 备份(Backup)
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| Enabled | 开关 Git 备份 |
|
||||
| Repo URL | 远程仓库地址(HTTPS 或 SSH) |
|
||||
| Auth Method | `token` / `ssh_key` / `user_pass` |
|
||||
| Username/Password/Token/SSH Key Path | 根据认证方式填写 |
|
||||
| Backup Interval | 自动备份间隔(分钟) |
|
||||
| Auto Backup | 是否按间隔自动推送 |
|
||||
|
||||
## 键位(Key Bindings)
|
||||
- 列表由 `ExtensionService.GetAllKeyBindings()` 提供。可搜索命令 ID 或组合。
|
||||
- 允许将命令禁用(关闭开关)或录入新组合。
|
||||
- 更改立即影响所有编辑器实例。
|
||||
|
||||
## 扩展(Extensions)
|
||||
- 显示 `ExtensionSettings` 中的所有扩展。
|
||||
- 每项可开关并展示 JSON 配置(背景色、最小选区、最小化提示等)。
|
||||
- 修改后调用 `ExtensionService.UpdateExtensionState` 并通知 `ExtensionManager` 热更新。
|
||||
|
||||
## 配置文件备份
|
||||
- 每次修改配置都会更新 `metadata.lastUpdated`,可用 Git 备份追踪历史。
|
||||
- 若出现配置损坏,可删除 `config.json`,应用会写入 `NewDefaultAppConfig`。
|
||||
|
||||
## 导入/导出(建议)
|
||||
- 目前可手动复制 `config.json`/`extensions.json`/`key_bindings.json`。
|
||||
- 计划提供 UI 层面的导入导出按钮,便于跨设备同步。
|
||||
|
||||
> 修改高级选项(如 dataPath)后建议重启,以确保后台服务(数据库、备份、窗口吸附等)读取到最新配置。
|
||||
@@ -1,44 +0,0 @@
|
||||
# 主题与外观
|
||||
|
||||

|
||||
> 替换为主题切换界面或自定义主题编辑器的截图。
|
||||
|
||||
voidraft 的主题由后端 `ThemeService` 管理,存储在 `themes` 表。前端通过 `themeStore` + `createThemeExtension` 应用色板。
|
||||
|
||||
## 预设主题
|
||||
| 名称 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| default-dark | Dark | 默认暗色,适合低光环境 |
|
||||
| default-light | Light | 默认亮色 |
|
||||
| dracula | Dark | 高对比度紫色系 |
|
||||
| aura | Dark | 柔和霓虹风 |
|
||||
| github-dark / github-light | Dark/Light | 与 GitHub 主题接近 |
|
||||
| material-dark / light | Dark/Light | Material Design 色板 |
|
||||
| one-dark | Dark | VSCode 经典主题 |
|
||||
| solarized-dark / light | Dark/Light | Solarized 配色 |
|
||||
| tokyo-night / storm / day | Dark/Light | Tokyo Night 三件套 |
|
||||
|
||||
## 自定义主题
|
||||
1. 打开设置 > 外观,选择「创建主题」。
|
||||
2. 颜色字段对应 `ThemeColorConfig`,包含 `editor.background`, `editor.foreground`, `gutter`, `selection`, `bracket`, `keyword`, `string`, `comment`, `accent` 等。
|
||||
3. 保存后立即写入数据库,可通过 `Reset` 按钮恢复为预设值。
|
||||
4. 前端 `themeExtension` 会向 CodeMirror 注入新的 `EditorView.theme`。
|
||||
|
||||
## 动态切换
|
||||
- 切换主题会立即影响所有已打开的编辑器实例;`updateEditorTheme` 逐个更新 `EditorView`。
|
||||
- `SystemTheme` 设为 `auto` 时,voidraft 会监听操作系统深浅模式并自动切换到 `default-dark` 或 `default-light`。
|
||||
|
||||
## 字体与行高
|
||||
- 字体配置来自设置 > 编辑,`createFontExtensionFromBackend` 会同步 `fontFamily/fontSize/fontWeight/lineHeight`。
|
||||
- 可在通用设置中的“滚轮缩放”手势下临时调整字号。
|
||||
|
||||
## 小地图/装饰色
|
||||
- `minimap` 扩展读取主题中的 `accent` 颜色,用于高亮当前视区。
|
||||
- `textHighlight` 扩展的默认背景色可在扩展设置中配置。
|
||||
|
||||
## 截图建议
|
||||
- 展示暗/亮主题对比。
|
||||
- 展示主题编辑对话框,标出关键字段。
|
||||
- 展示自定义主题应用后的编辑器界面。
|
||||
|
||||
> 如果希望导入 VSCode `.json` 主题,可将颜色映射到 `ThemeColorConfig` 后写入数据库,或等待官方导入工具上线。
|
||||
@@ -1,53 +0,0 @@
|
||||
# 常见问题与故障排查
|
||||
|
||||

|
||||
> 替换为错误提示或日志查看界面的截图。
|
||||
|
||||
## 安装与启动
|
||||
| 问题 | 可能原因 | 解决步骤 |
|
||||
| --- | --- | --- |
|
||||
| 启动白屏 | WebView2 缺失或缓存损坏 | 安装 WebView2 Runtime;删除 `%APPDATA%/voidraft/Cache` 后重启 |
|
||||
| 双击无反应 | 被安全策略拦截 | 以管理员运行或使用便携版;验证 SHA256 后加入白名单 |
|
||||
| `wails3 dev` 报错 | Go/Node 版本不符或缺少 WebView2 | 确保 Go 1.21+、Node 18+,安装 WebView2 |
|
||||
|
||||
## 编辑器
|
||||
| 问题 | 原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 格式化按钮灰色 | 当前块语言无对应 Prettier parser | 更换语言或安装支持的语言扩展(未来版本) |
|
||||
| 块解析错误 | 分隔符格式不正确 | 确认 `∞∞∞language` 后跟换行;可用自动重建语法树命令 |
|
||||
| 翻译浮层不出现 | 选区过短或超过最大长度 | 在设置 > 扩展 > translator 中调整阈值 |
|
||||
| 小地图不同步 | 编辑器实例未刷新 | 切换文档或重开应用,检查扩展是否被禁用 |
|
||||
|
||||
## 窗口与多实例
|
||||
| 问题 | 原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 子窗口未吸附 | WindowSnap 未启用 | 设置 > 通用 → 勾选“窗口吸附” |
|
||||
| 全局热键无效 | 与系统/其他软件冲突 | 在设置 > 通用改用非系统占用组合,比如 `Ctrl+Alt+Space` |
|
||||
| 关闭窗口直接退出 | 未启用托盘模式 | 设置 > 通用 → 启用系统托盘 |
|
||||
|
||||
## HTTP 客户端
|
||||
| 问题 | 可能原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 发送失败,提示 `proxy` | 系统代理配置异常 | 在系统代理或环境变量中设置 HTTP(S)_PROXY,或关闭代理再试 |
|
||||
| 响应乱码 | 服务器未声明编码 | 手动在请求头中加 `accept-charset: utf-8`,或在响应视图切换编码(计划) |
|
||||
| 变量未替换 | 变量名拼写或作用域错误 | 确认 `@var` 定义位置,使用 `{{name}}` 语法 |
|
||||
|
||||
## 数据与备份
|
||||
| 问题 | 可能原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 自动备份停在 “未初始化” | Repo URL/认证缺失 | 补全备份配置或关闭自动备份 |
|
||||
| Push 失败 | Token 权限不足或网络问题 | 为 Token 开启 `repo` scope;检查代理;稍后再试 |
|
||||
| 数据目录迁移后文件缺失 | 未重启或权限不足 | 修改 `dataPath` 后重启应用;确保目标目录可写 |
|
||||
|
||||
## 更新
|
||||
| 问题 | 原因 | 解决 |
|
||||
| --- | --- | --- |
|
||||
| 检查更新超时 | 主源不可达 | 切换到备用源或关闭代理重试 |
|
||||
| 下载完成但未重启 | 权限或文件被占用 | 以管理员运行,关闭杀毒软件后重试 |
|
||||
|
||||
## 收集日志
|
||||
- Wails 日志:`%LOCALAPPDATA%/voidraft/logs/*.log`。
|
||||
- 终端调试:运行 `wails3 dev` 并观察控制台输出。
|
||||
- 若提交 Issue,请附上:系统版本、voidraft 版本、日志片段、复现步骤。
|
||||
|
||||
> 仍未解决?请到 GitHub Issues 提交反馈,并尽可能附上截图和日志。
|
||||
@@ -1,46 +0,0 @@
|
||||
# 界面总览
|
||||
|
||||

|
||||
> 替换为包含顶部工具栏、块区域、右侧小地图、底部状态栏的完整截图。
|
||||
|
||||
voidraft 的主窗口由四个区域组成:
|
||||
|
||||
| 区域 | 位置 | 作用 | 相关代码 |
|
||||
| --- | --- | --- | --- |
|
||||
| 工具栏 | 顶部浮层 | 文档切换、块语言选择、格式化、Markdown 预览、窗口置顶、更新提示、进入设置 | `frontend/src/components/toolbar/Toolbar.vue` |
|
||||
| 编辑器主体 | 中央 | CodeMirror 6 视图,承载块编辑、HTTP 运行器、翻译按钮等 | `frontend/src/views/editor/Editor.vue` + `extensions` |
|
||||
| 导航辅助 | 右侧 | 小地图、滚动条、块徽标、HTTP 运行按钮 | `extensions/minimap`, `codeblock/decorations.ts` |
|
||||
| 底部状态 | 左下角 | 行数、字符数、选区统计、文档脏状态 | `editorStore.documentStats` |
|
||||
|
||||
## 工具栏详解
|
||||
| 项 | 说明 | 快捷入口 |
|
||||
| --- | --- | --- |
|
||||
| 文档切换器 | 展开后列出全部文档,支持搜索、创建、在新窗口打开 | 同步 `DocumentService.ListAllDocumentsMeta` |
|
||||
| 块语言下拉 | 当前块语言,列表取自 `lang-parser/languages.ts`,支持搜索 | 鼠标选择或输入语言 token |
|
||||
| Pin(窗口置顶) | 临时 / 永久置顶切换,调用 `SystemService.SetWindowOnTop` 与 `config.general.alwaysOnTop` | Alt+Space(自定义) |
|
||||
| Format / Preview | 对当前块执行 Prettier 或打开 Markdown 预览 | `Ctrl+Shift+F` / 工具栏按钮 |
|
||||
| 更新提示 | 轮询 `SelfUpdateService`,有更新时显示小点,可直接“检查/下载/重启” | 设置 > 更新 |
|
||||
| 设置入口 | 跳转到 Vue Router 的 `/settings` 页面 | `Ctrl+,` |
|
||||
|
||||
## 多文档视图
|
||||
- **标签页(可选)**:在设置 > 通用中启用“标签页模式”,`tabStore` 将当前文档加入 tab bar,支持拖拽、批量关闭。
|
||||
- **多窗口**:以文档列表右键「在新窗口中打开」或命令面板为入口。`WindowService` 会根据文档 ID 命名窗口,`WindowSnapService` 自动吸附。
|
||||
- **系统托盘**:关闭窗口时默认最小化到托盘,可在托盘图标中重新唤醒或彻底退出。
|
||||
|
||||
## 面板与浮层
|
||||
- **Markdown 预览**:针对选中的 Markdown 块,面板会贴在右侧,支持实时滚动同步、关闭动画。
|
||||
- **HTTP 响应**:运行后在块底部自动插入 `### Response`,可展开查看头部/体/耗时。
|
||||
- **翻译浮层**:选中文本后自动出现按钮,点击后显示结果卡片,附带复制、语种切换。
|
||||
|
||||
## 快捷状态
|
||||
- **底部统计**:
|
||||
- `Ln`:当前块内行号。
|
||||
- `Ch`:字符数。
|
||||
- `Sel`:选区字符数。
|
||||
- **右上角加载动画**:当编辑器实例加载或切换文档时显示,遵循 `enableLoadingAnimation` 设置。
|
||||
|
||||
## 建议截图
|
||||
1. 默认深色主题 + 多块示例。
|
||||
2. 打开 Markdown 预览 + 小地图。
|
||||
3. 展示 HTTP 块运行按钮与响应卡片。
|
||||
4. 展示标签页或多窗口。
|
||||
3230
frontend/package-lock.json
generated
@@ -22,7 +22,7 @@
|
||||
"app:generate": "cd .. && wails3 generate bindings -ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.19.1",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.0",
|
||||
"@codemirror/lang-angular": "^0.1.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
@@ -50,59 +50,56 @@
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@codemirror/view": "^6.38.8",
|
||||
"@cospaia/prettier-plugin-clojure": "^0.0.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.3",
|
||||
"@mdit/plugin-katex": "^0.23.2",
|
||||
"@mdit/plugin-tasklist": "^0.22.2",
|
||||
"@lezer/lr": "^1.4.4",
|
||||
"@prettier/plugin-xml": "^3.4.2",
|
||||
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||
"@toml-tools/lexer": "^1.0.0",
|
||||
"@toml-tools/parser": "^1.0.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/katex": "^0.16.7",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-lang-elixir": "^4.0.0",
|
||||
"colors-named": "^1.0.2",
|
||||
"colors-named-hex": "^1.0.2",
|
||||
"groovy-beautify": "^0.0.17",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hsl-matcher": "^1.2.4",
|
||||
"java-parser": "^3.0.1",
|
||||
"katex": "^0.16.25",
|
||||
"linguist-languages": "^9.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.12.1",
|
||||
"npm": "^11.6.2",
|
||||
"php-parser": "^3.2.5",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.94.0",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"prettier": "^3.7.2",
|
||||
"sass": "^1.94.2",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-pick-colors": "^1.8.0",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@types/node": "^24.9.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@wailsio/runtime": "latest",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"globals": "^16.5.0",
|
||||
"happy-dom": "^20.0.10",
|
||||
"happy-dom": "^20.0.11",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"vitepress": "^2.0.0-alpha.12",
|
||||
"vitest": "^4.0.8",
|
||||
"vitest": "^4.0.14",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.1.3"
|
||||
"vue-tsc": "^3.1.5"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@latest"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* 导入所有CSS文件 */
|
||||
@import 'normalize.css';
|
||||
@import 'variables.css';
|
||||
@import 'scrollbar.css';
|
||||
@import "harmony_fonts.css";
|
||||
@import 'hack_fonts.css';
|
||||
@import 'opensans_fonts.css';
|
||||
@import "monocraft_fonts.css";
|
||||
@import "monocraft_fonts.css";
|
||||
@import 'variables.css';
|
||||
@import 'scrollbar.css';
|
||||
@import 'styles.css';
|
||||
3
frontend/src/assets/styles/styles.css
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
@@ -1,255 +1,191 @@
|
||||
:root {
|
||||
/* 编辑器区域 */
|
||||
--text-primary: #9BB586; /* 内容区域字体颜色 */
|
||||
|
||||
/* 深色主题颜色变量 */
|
||||
--dark-toolbar-bg: #2d2d2d;
|
||||
--dark-toolbar-border: #404040;
|
||||
--dark-toolbar-text: #ffffff;
|
||||
--dark-toolbar-text-secondary: #cccccc;
|
||||
--dark-toolbar-button-hover: #404040;
|
||||
--dark-tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
|
||||
--dark-bg-secondary: #0E1217;
|
||||
--dark-text-secondary: #a0aec0;
|
||||
--dark-text-muted: #666;
|
||||
--dark-border-color: #2d3748;
|
||||
--dark-settings-bg: #2a2a2a;
|
||||
--dark-settings-card-bg: #333333;
|
||||
--dark-settings-text: #ffffff;
|
||||
--dark-settings-text-secondary: #cccccc;
|
||||
--dark-settings-border: #444444;
|
||||
--dark-settings-input-bg: #3a3a3a;
|
||||
--dark-settings-input-border: #555555;
|
||||
--dark-settings-hover: #404040;
|
||||
--dark-scrollbar-track: #2a2a2a;
|
||||
--dark-scrollbar-thumb: #555555;
|
||||
--dark-scrollbar-thumb-hover: #666666;
|
||||
--dark-selection-bg: rgba(181, 206, 168, 0.1);
|
||||
--dark-selection-text: #b5cea8;
|
||||
--dark-danger-color: #ff6b6b;
|
||||
--dark-bg-primary: #1a1a1a;
|
||||
--dark-bg-hover: #2a2a2a;
|
||||
--dark-loading-bg-gradient: radial-gradient(#222922, #000500);
|
||||
--dark-loading-color: #fff;
|
||||
--dark-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
|
||||
--dark-loading-done-color: #6f6;
|
||||
--dark-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
|
||||
|
||||
/* 浅色主题颜色变量 */
|
||||
--light-toolbar-bg: #f8f9fa;
|
||||
--light-toolbar-border: #e9ecef;
|
||||
--light-toolbar-text: #212529;
|
||||
--light-toolbar-text-secondary: #495057;
|
||||
--light-toolbar-button-hover: #e9ecef;
|
||||
--light-tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--light-bg-secondary: #f7fef7;
|
||||
--light-text-secondary: #374151;
|
||||
--light-text-muted: #6b7280;
|
||||
--light-border-color: #e5e7eb;
|
||||
--light-settings-bg: #ffffff;
|
||||
--light-settings-card-bg: #f8f9fa;
|
||||
--light-settings-text: #212529;
|
||||
--light-settings-text-secondary: #6c757d;
|
||||
--light-settings-border: #dee2e6;
|
||||
--light-settings-input-bg: #ffffff;
|
||||
--light-settings-input-border: #ced4da;
|
||||
--light-settings-hover: #e9ecef;
|
||||
--light-scrollbar-track: #f1f3f4;
|
||||
--light-scrollbar-thumb: #c1c1c1;
|
||||
--light-scrollbar-thumb-hover: #a8a8a8;
|
||||
--light-selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--light-selection-text: #2563eb;
|
||||
--light-danger-color: #dc3545;
|
||||
--light-bg-primary: #ffffff;
|
||||
--light-bg-hover: #f1f3f4;
|
||||
--light-loading-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--light-loading-color: #1a3c1a;
|
||||
--light-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--light-loading-done-color: #008800;
|
||||
--light-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* 默认使用深色主题 */
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
--voidraft-mono-font: "HarmonyOS Sans Mono", monospace;
|
||||
|
||||
color-scheme: light dark;
|
||||
--voidraft-font-mono: "HarmonyOS", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
/* 监听系统深色主题 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme="auto"] {
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
}
|
||||
/* 默认/暗色主题 */
|
||||
:root,
|
||||
:root[data-theme="dark"],
|
||||
:root[data-theme="auto"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--text-primary: #ffffff;
|
||||
|
||||
--toolbar-bg: #2d2d2d;
|
||||
--toolbar-border: #404040;
|
||||
--toolbar-text: #ffffff;
|
||||
--toolbar-text-secondary: #cccccc;
|
||||
--toolbar-button-hover: #404040;
|
||||
--toolbar-separator: #404040;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
|
||||
--bg-secondary: #0e1217;
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-hover: #2a2a2a;
|
||||
|
||||
--text-secondary: #a0aec0;
|
||||
--text-muted: #666666;
|
||||
--text-danger: #ff6b6b;
|
||||
|
||||
--border-color: #2d3748;
|
||||
|
||||
--settings-bg: #2a2a2a;
|
||||
--settings-card-bg: #333333;
|
||||
--settings-text: #ffffff;
|
||||
--settings-text-secondary: #cccccc;
|
||||
--settings-border: #444444;
|
||||
--settings-input-bg: #3a3a3a;
|
||||
--settings-input-border: #555555;
|
||||
--settings-hover: #404040;
|
||||
|
||||
--scrollbar-track: #2a2a2a;
|
||||
--scrollbar-thumb: #555555;
|
||||
--scrollbar-thumb-hover: #666666;
|
||||
|
||||
--selection-bg: rgba(181, 206, 168, 0.1);
|
||||
--selection-text: #b5cea8;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#222922, #000500);
|
||||
--voidraft-loading-color: #ffffff;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
|
||||
--voidraft-loading-done-color: #66ff66;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 暗色主题 */
|
||||
--cm-codeblock-bg: rgba(46, 51, 69, 0.8);
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(28% 0.02 255);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(250, 204, 21, 0.35);
|
||||
}
|
||||
|
||||
/* 监听系统浅色主题 */
|
||||
/* 亮色主题 */
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--text-primary: #000000;
|
||||
|
||||
--toolbar-bg: #f8f9fa;
|
||||
--toolbar-border: #e9ecef;
|
||||
--toolbar-text: #212529;
|
||||
--toolbar-text-secondary: #495057;
|
||||
--toolbar-button-hover: #e9ecef;
|
||||
--toolbar-separator: #e9ecef;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--bg-secondary: #f7fef7;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-hover: #f1f3f4;
|
||||
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--text-danger: #dc3545;
|
||||
|
||||
--border-color: #e5e7eb;
|
||||
|
||||
--settings-bg: #ffffff;
|
||||
--settings-card-bg: #f8f9fa;
|
||||
--settings-text: #212529;
|
||||
--settings-text-secondary: #6c757d;
|
||||
--settings-border: #dee2e6;
|
||||
--settings-input-bg: #ffffff;
|
||||
--settings-input-border: #ced4da;
|
||||
--settings-hover: #e9ecef;
|
||||
|
||||
--scrollbar-track: #f1f3f4;
|
||||
--scrollbar-thumb: #c1c1c1;
|
||||
--scrollbar-thumb-hover: #a8a8a8;
|
||||
|
||||
--selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--selection-text: #2563eb;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--voidraft-loading-color: #1a3c1a;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--voidraft-loading-done-color: #008800;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 亮色主题 */
|
||||
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||
}
|
||||
|
||||
/* 跟随系统的浅色偏好 */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-theme="auto"] {
|
||||
--toolbar-bg: var(--light-toolbar-bg);
|
||||
--toolbar-border: var(--light-toolbar-border);
|
||||
--toolbar-text: var(--light-toolbar-text);
|
||||
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--light-toolbar-button-hover);
|
||||
--toolbar-separator: var(--light-toolbar-button-hover);
|
||||
--tab-active-line: var(--light-tab-active-line);
|
||||
--bg-secondary: var(--light-bg-secondary);
|
||||
--text-secondary: var(--light-text-secondary);
|
||||
--text-muted: var(--light-text-muted);
|
||||
--border-color: var(--light-border-color);
|
||||
--settings-bg: var(--light-settings-bg);
|
||||
--settings-card-bg: var(--light-settings-card-bg);
|
||||
--settings-text: var(--light-settings-text);
|
||||
--settings-text-secondary: var(--light-settings-text-secondary);
|
||||
--settings-border: var(--light-settings-border);
|
||||
--settings-input-bg: var(--light-settings-input-bg);
|
||||
--settings-input-border: var(--light-settings-input-border);
|
||||
--settings-hover: var(--light-settings-hover);
|
||||
--scrollbar-track: var(--light-scrollbar-track);
|
||||
--scrollbar-thumb: var(--light-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--light-selection-bg);
|
||||
--selection-text: var(--light-selection-text);
|
||||
--text-danger: var(--light-danger-color);
|
||||
--bg-primary: var(--light-bg-primary);
|
||||
--bg-hover: var(--light-bg-hover);
|
||||
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--light-loading-color);
|
||||
--voidraft-loading-glow: var(--light-loading-glow);
|
||||
--voidraft-loading-done-color: var(--light-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--light-loading-overlay);
|
||||
color-scheme: light;
|
||||
|
||||
--text-primary: #000000;
|
||||
|
||||
--toolbar-bg: #f8f9fa;
|
||||
--toolbar-border: #e9ecef;
|
||||
--toolbar-text: #212529;
|
||||
--toolbar-text-secondary: #495057;
|
||||
--toolbar-button-hover: #e9ecef;
|
||||
--toolbar-separator: #e9ecef;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--bg-secondary: #f7fef7;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-hover: #f1f3f4;
|
||||
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--text-danger: #dc3545;
|
||||
|
||||
--border-color: #e5e7eb;
|
||||
|
||||
--settings-bg: #ffffff;
|
||||
--settings-card-bg: #f8f9fa;
|
||||
--settings-text: #212529;
|
||||
--settings-text-secondary: #6c757d;
|
||||
--settings-border: #dee2e6;
|
||||
--settings-input-bg: #ffffff;
|
||||
--settings-input-border: #ced4da;
|
||||
--settings-hover: #e9ecef;
|
||||
|
||||
--scrollbar-track: #f1f3f4;
|
||||
--scrollbar-thumb: #c1c1c1;
|
||||
--scrollbar-thumb-hover: #a8a8a8;
|
||||
|
||||
--selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--selection-text: #2563eb;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--voidraft-loading-color: #1a3c1a;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--voidraft-loading-done-color: #008800;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 亮色主题 */
|
||||
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
/* 手动选择浅色主题 */
|
||||
:root[data-theme="light"] {
|
||||
--toolbar-bg: var(--light-toolbar-bg);
|
||||
--toolbar-border: var(--light-toolbar-border);
|
||||
--toolbar-text: var(--light-toolbar-text);
|
||||
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--light-toolbar-button-hover);
|
||||
--toolbar-separator: var(--light-toolbar-button-hover);
|
||||
--tab-active-line: var(--light-tab-active-line);
|
||||
--bg-secondary: var(--light-bg-secondary);
|
||||
--text-secondary: var(--light-text-secondary);
|
||||
--text-muted: var(--light-text-muted);
|
||||
--border-color: var(--light-border-color);
|
||||
--settings-bg: var(--light-settings-bg);
|
||||
--settings-card-bg: var(--light-settings-card-bg);
|
||||
--settings-text: var(--light-settings-text);
|
||||
--settings-text-secondary: var(--light-settings-text-secondary);
|
||||
--settings-border: var(--light-settings-border);
|
||||
--settings-input-bg: var(--light-settings-input-bg);
|
||||
--settings-input-border: var(--light-settings-input-border);
|
||||
--settings-hover: var(--light-settings-hover);
|
||||
--scrollbar-track: var(--light-scrollbar-track);
|
||||
--scrollbar-thumb: var(--light-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--light-selection-bg);
|
||||
--selection-text: var(--light-selection-text);
|
||||
--text-danger: var(--light-danger-color);
|
||||
--bg-primary: var(--light-bg-primary);
|
||||
--bg-hover: var(--light-bg-hover);
|
||||
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--light-loading-color);
|
||||
--voidraft-loading-glow: var(--light-loading-glow);
|
||||
--voidraft-loading-done-color: var(--light-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--light-loading-overlay);
|
||||
}
|
||||
|
||||
/* 手动选择深色主题 */
|
||||
:root[data-theme="dark"] {
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
}
|
||||
@@ -1,43 +1,19 @@
|
||||
import {
|
||||
AppConfig,
|
||||
AppearanceConfig,
|
||||
EditingConfig,
|
||||
GeneralConfig,
|
||||
AuthMethod,
|
||||
LanguageType,
|
||||
SystemThemeType,
|
||||
TabType,
|
||||
UpdatesConfig,
|
||||
UpdateSourceType,
|
||||
GitBackupConfig,
|
||||
AuthMethod
|
||||
UpdateSourceType
|
||||
} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {FONT_OPTIONS} from './fonts';
|
||||
|
||||
// 配置键映射和限制的类型定义
|
||||
export type GeneralConfigKeyMap = {
|
||||
readonly [K in keyof GeneralConfig]: string;
|
||||
};
|
||||
|
||||
export type EditingConfigKeyMap = {
|
||||
readonly [K in keyof EditingConfig]: string;
|
||||
};
|
||||
|
||||
export type AppearanceConfigKeyMap = {
|
||||
readonly [K in keyof AppearanceConfig]: string;
|
||||
};
|
||||
|
||||
export type UpdatesConfigKeyMap = {
|
||||
readonly [K in keyof UpdatesConfig]: string;
|
||||
};
|
||||
|
||||
export type BackupConfigKeyMap = {
|
||||
readonly [K in keyof GitBackupConfig]: string;
|
||||
};
|
||||
|
||||
export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight';
|
||||
export type ConfigSection = 'general' | 'editing' | 'appearance' | 'updates' | 'backup';
|
||||
|
||||
// 配置键映射
|
||||
export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
|
||||
// 统一配置键映射(平级展开)
|
||||
export const CONFIG_KEY_MAP = {
|
||||
// general
|
||||
alwaysOnTop: 'general.alwaysOnTop',
|
||||
dataPath: 'general.dataPath',
|
||||
enableSystemTray: 'general.enableSystemTray',
|
||||
@@ -47,9 +23,7 @@ export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
|
||||
enableWindowSnap: 'general.enableWindowSnap',
|
||||
enableLoadingAnimation: 'general.enableLoadingAnimation',
|
||||
enableTabs: 'general.enableTabs',
|
||||
} as const;
|
||||
|
||||
export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
||||
// editing
|
||||
fontSize: 'editing.fontSize',
|
||||
fontFamily: 'editing.fontFamily',
|
||||
fontWeight: 'editing.fontWeight',
|
||||
@@ -57,16 +31,12 @@ export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
||||
enableTabIndent: 'editing.enableTabIndent',
|
||||
tabSize: 'editing.tabSize',
|
||||
tabType: 'editing.tabType',
|
||||
autoSaveDelay: 'editing.autoSaveDelay'
|
||||
} as const;
|
||||
|
||||
export const APPEARANCE_CONFIG_KEY_MAP: AppearanceConfigKeyMap = {
|
||||
autoSaveDelay: 'editing.autoSaveDelay',
|
||||
// appearance
|
||||
language: 'appearance.language',
|
||||
systemTheme: 'appearance.systemTheme',
|
||||
currentTheme: 'appearance.currentTheme'
|
||||
} as const;
|
||||
|
||||
export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
||||
currentTheme: 'appearance.currentTheme',
|
||||
// updates
|
||||
version: 'updates.version',
|
||||
autoUpdate: 'updates.autoUpdate',
|
||||
primarySource: 'updates.primarySource',
|
||||
@@ -74,10 +44,8 @@ export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
||||
backupBeforeUpdate: 'updates.backupBeforeUpdate',
|
||||
updateTimeout: 'updates.updateTimeout',
|
||||
github: 'updates.github',
|
||||
gitea: 'updates.gitea'
|
||||
} as const;
|
||||
|
||||
export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
||||
gitea: 'updates.gitea',
|
||||
// backup
|
||||
enabled: 'backup.enabled',
|
||||
repo_url: 'backup.repo_url',
|
||||
auth_method: 'backup.auth_method',
|
||||
@@ -90,6 +58,8 @@ export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
||||
auto_backup: 'backup.auto_backup',
|
||||
} as const;
|
||||
|
||||
export type ConfigKey = keyof typeof CONFIG_KEY_MAP;
|
||||
|
||||
// 配置限制
|
||||
export const CONFIG_LIMITS = {
|
||||
fontSize: {min: 12, max: 28, default: 13},
|
||||
|
||||
1945
frontend/src/common/constant/emojies.ts
Normal file
@@ -1,45 +1,3 @@
|
||||
/**
|
||||
* 默认翻译配置
|
||||
*/
|
||||
export const DEFAULT_TRANSLATION_CONFIG = {
|
||||
minSelectionLength: 2,
|
||||
maxTranslationLength: 5000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 翻译相关的错误消息
|
||||
*/
|
||||
export const TRANSLATION_ERRORS = {
|
||||
NO_TEXT: 'no text to translate',
|
||||
TRANSLATION_FAILED: 'translation failed',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 翻译结果接口
|
||||
*/
|
||||
export interface TranslationResult {
|
||||
translatedText: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言信息接口
|
||||
*/
|
||||
export interface LanguageInfo {
|
||||
Code: string; // 语言代码
|
||||
Name: string; // 语言名称
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译器扩展配置
|
||||
*/
|
||||
export interface TranslatorConfig {
|
||||
/** 最小选择字符数才显示翻译按钮 */
|
||||
minSelectionLength: number;
|
||||
/** 最大翻译字符数 */
|
||||
maxTranslationLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译图标SVG
|
||||
*/
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
// Enclose abbreviations in <abbr> tags
|
||||
//
|
||||
import MarkdownIt, {StateBlock, StateCore, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 环境接口,包含缩写定义
|
||||
*/
|
||||
interface AbbrEnv {
|
||||
abbreviations?: { [key: string]: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-abbr 插件
|
||||
* 用于支持缩写语法
|
||||
*/
|
||||
export default function abbr_plugin(md: MarkdownIt): void {
|
||||
const escapeRE = md.utils.escapeRE;
|
||||
const arrayReplaceAt = md.utils.arrayReplaceAt;
|
||||
|
||||
// ASCII characters in Cc, Sc, Sm, Sk categories we should terminate on;
|
||||
// you can check character classes here:
|
||||
// http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
|
||||
const OTHER_CHARS = ' \r\n$+<=>^`|~';
|
||||
|
||||
const UNICODE_PUNCT_RE = md.utils.lib.ucmicro.P.source;
|
||||
const UNICODE_SPACE_RE = md.utils.lib.ucmicro.Z.source;
|
||||
|
||||
function abbr_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
let labelEnd: number;
|
||||
let pos = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
|
||||
if (pos + 2 >= max) { return false; }
|
||||
|
||||
if (state.src.charCodeAt(pos++) !== 0x2A/* * */) { return false; }
|
||||
if (state.src.charCodeAt(pos++) !== 0x5B/* [ */) { return false; }
|
||||
|
||||
const labelStart = pos;
|
||||
|
||||
for (; pos < max; pos++) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
if (ch === 0x5B /* [ */) {
|
||||
return false;
|
||||
} else if (ch === 0x5D /* ] */) {
|
||||
labelEnd = pos;
|
||||
break;
|
||||
} else if (ch === 0x5C /* \ */) {
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
if (labelEnd! < 0 || state.src.charCodeAt(labelEnd! + 1) !== 0x3A/* : */) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (silent) { return true; }
|
||||
|
||||
const label = state.src.slice(labelStart, labelEnd!).replace(/\\(.)/g, '$1');
|
||||
const title = state.src.slice(labelEnd! + 2, max).trim();
|
||||
if (label.length === 0) { return false; }
|
||||
if (title.length === 0) { return false; }
|
||||
|
||||
const env = state.env as AbbrEnv;
|
||||
if (!env.abbreviations) { env.abbreviations = {}; }
|
||||
// prepend ':' to avoid conflict with Object.prototype members
|
||||
if (typeof env.abbreviations[':' + label] === 'undefined') {
|
||||
env.abbreviations[':' + label] = title;
|
||||
}
|
||||
|
||||
state.line = startLine + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function abbr_replace(state: StateCore): void {
|
||||
const blockTokens = state.tokens;
|
||||
|
||||
const env = state.env as AbbrEnv;
|
||||
if (!env.abbreviations) { return; }
|
||||
|
||||
const regSimple = new RegExp('(?:' +
|
||||
Object.keys(env.abbreviations).map(function (x: string) {
|
||||
return x.substr(1);
|
||||
}).sort(function (a: string, b: string) {
|
||||
return b.length - a.length;
|
||||
}).map(escapeRE).join('|') +
|
||||
')');
|
||||
|
||||
const regText = '(^|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
|
||||
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])' +
|
||||
'(' + Object.keys(env.abbreviations).map(function (x: string) {
|
||||
return x.substr(1);
|
||||
}).sort(function (a: string, b: string) {
|
||||
return b.length - a.length;
|
||||
}).map(escapeRE).join('|') + ')' +
|
||||
'($|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
|
||||
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])'
|
||||
|
||||
const reg = new RegExp(regText, 'g');
|
||||
|
||||
for (let j = 0, l = blockTokens.length; j < l; j++) {
|
||||
if (blockTokens[j].type !== 'inline') { continue; }
|
||||
let tokens = blockTokens[j].children!;
|
||||
|
||||
// We scan from the end, to keep position when new tags added.
|
||||
for (let i = tokens.length - 1; i >= 0; i--) {
|
||||
const currentToken = tokens[i];
|
||||
if (currentToken.type !== 'text') { continue; }
|
||||
|
||||
let pos = 0;
|
||||
const text = currentToken.content;
|
||||
reg.lastIndex = 0;
|
||||
const nodes: Token[] = [];
|
||||
|
||||
// fast regexp run to determine whether there are any abbreviated words
|
||||
// in the current token
|
||||
if (!regSimple.test(text)) { continue; }
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = reg.exec(text))) {
|
||||
if (m.index > 0 || m[1].length > 0) {
|
||||
const token = new state.Token('text', '', 0);
|
||||
token.content = text.slice(pos, m.index + m[1].length);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
const token_o = new state.Token('abbr_open', 'abbr', 1);
|
||||
token_o.attrs = [['title', env.abbreviations[':' + m[2]]]];
|
||||
nodes.push(token_o);
|
||||
|
||||
const token_t = new state.Token('text', '', 0);
|
||||
token_t.content = m[2];
|
||||
nodes.push(token_t);
|
||||
|
||||
const token_c = new state.Token('abbr_close', 'abbr', -1);
|
||||
nodes.push(token_c);
|
||||
|
||||
reg.lastIndex -= m[3].length;
|
||||
pos = reg.lastIndex;
|
||||
}
|
||||
|
||||
if (!nodes.length) { continue; }
|
||||
|
||||
if (pos < text.length) {
|
||||
const token = new state.Token('text', '', 0);
|
||||
token.content = text.slice(pos);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
// replace current node
|
||||
blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.block.ruler.before('reference', 'abbr_def', abbr_def, { alt: ['paragraph', 'reference'] });
|
||||
|
||||
md.core.ruler.after('linkify', 'abbr_replace', abbr_replace);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
// Process definition lists
|
||||
//
|
||||
import MarkdownIt, { StateBlock, Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* markdown-it-deflist 插件
|
||||
* 用于支持定义列表语法
|
||||
*/
|
||||
export default function deflist_plugin(md: MarkdownIt): void {
|
||||
const isSpace = md.utils.isSpace;
|
||||
|
||||
// Search `[:~][\n ]`, returns next pos after marker on success
|
||||
// or -1 on fail.
|
||||
function skipMarker(state: StateBlock, line: number): number {
|
||||
let start = state.bMarks[line] + state.tShift[line];
|
||||
const max = state.eMarks[line];
|
||||
|
||||
if (start >= max) { return -1; }
|
||||
|
||||
// Check bullet
|
||||
const marker = state.src.charCodeAt(start++);
|
||||
if (marker !== 0x7E/* ~ */ && marker !== 0x3A/* : */) { return -1; }
|
||||
|
||||
const pos = state.skipSpaces(start);
|
||||
|
||||
// require space after ":"
|
||||
if (start === pos) { return -1; }
|
||||
|
||||
// no empty definitions, e.g. " : "
|
||||
if (pos >= max) { return -1; }
|
||||
|
||||
return start;
|
||||
}
|
||||
|
||||
function markTightParagraphs(state: StateBlock, idx: number): void {
|
||||
const level = state.level + 2;
|
||||
|
||||
for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) {
|
||||
if (state.tokens[i].level === level && state.tokens[i].type === 'paragraph_open') {
|
||||
state.tokens[i + 2].hidden = true;
|
||||
state.tokens[i].hidden = true;
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deflist(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
if (silent) {
|
||||
// quirk: validation mode validates a dd block only, not a whole deflist
|
||||
if (state.ddIndent < 0) { return false; }
|
||||
return skipMarker(state, startLine) >= 0;
|
||||
}
|
||||
|
||||
let nextLine = startLine + 1;
|
||||
if (nextLine >= endLine) { return false; }
|
||||
|
||||
if (state.isEmpty(nextLine)) {
|
||||
nextLine++;
|
||||
if (nextLine >= endLine) { return false; }
|
||||
}
|
||||
|
||||
if (state.sCount[nextLine] < state.blkIndent) { return false; }
|
||||
let contentStart = skipMarker(state, nextLine);
|
||||
if (contentStart < 0) { return false; }
|
||||
|
||||
// Start list
|
||||
const listTokIdx = state.tokens.length;
|
||||
let tight = true;
|
||||
|
||||
const token_dl_o: Token = state.push('dl_open', 'dl', 1);
|
||||
const listLines: [number, number] = [startLine, 0];
|
||||
token_dl_o.map = listLines;
|
||||
|
||||
//
|
||||
// Iterate list items
|
||||
//
|
||||
|
||||
let dtLine = startLine;
|
||||
let ddLine = nextLine;
|
||||
|
||||
// One definition list can contain multiple DTs,
|
||||
// and one DT can be followed by multiple DDs.
|
||||
//
|
||||
// Thus, there is two loops here, and label is
|
||||
// needed to break out of the second one
|
||||
//
|
||||
/* eslint no-labels:0,block-scoped-var:0 */
|
||||
OUTER:
|
||||
for (;;) {
|
||||
let prevEmptyEnd = false;
|
||||
|
||||
const token_dt_o: Token = state.push('dt_open', 'dt', 1);
|
||||
token_dt_o.map = [dtLine, dtLine];
|
||||
|
||||
const token_i: Token = state.push('inline', '', 0);
|
||||
token_i.map = [dtLine, dtLine];
|
||||
token_i.content = state.getLines(dtLine, dtLine + 1, state.blkIndent, false).trim();
|
||||
token_i.children = [];
|
||||
|
||||
state.push('dt_close', 'dt', -1);
|
||||
|
||||
for (;;) {
|
||||
const token_dd_o: Token = state.push('dd_open', 'dd', 1);
|
||||
const itemLines: [number, number] = [nextLine, 0];
|
||||
token_dd_o.map = itemLines;
|
||||
|
||||
let pos = contentStart;
|
||||
const max = state.eMarks[ddLine];
|
||||
let offset = state.sCount[ddLine] + contentStart - (state.bMarks[ddLine] + state.tShift[ddLine]);
|
||||
|
||||
while (pos < max) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
|
||||
if (isSpace(ch)) {
|
||||
if (ch === 0x09) {
|
||||
offset += 4 - offset % 4;
|
||||
} else {
|
||||
offset++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
contentStart = pos;
|
||||
|
||||
const oldTight = state.tight;
|
||||
const oldDDIndent = state.ddIndent;
|
||||
const oldIndent = state.blkIndent;
|
||||
const oldTShift = state.tShift[ddLine];
|
||||
const oldSCount = state.sCount[ddLine];
|
||||
const oldParentType = state.parentType;
|
||||
state.blkIndent = state.ddIndent = state.sCount[ddLine] + 2;
|
||||
state.tShift[ddLine] = contentStart - state.bMarks[ddLine];
|
||||
state.sCount[ddLine] = offset;
|
||||
state.tight = true;
|
||||
state.parentType = 'deflist' as any;
|
||||
|
||||
state.md.block.tokenize(state, ddLine, endLine);
|
||||
|
||||
// If any of list item is tight, mark list as tight
|
||||
if (!state.tight || prevEmptyEnd) {
|
||||
tight = false;
|
||||
}
|
||||
// Item become loose if finish with empty line,
|
||||
// but we should filter last element, because it means list finish
|
||||
prevEmptyEnd = (state.line - ddLine) > 1 && state.isEmpty(state.line - 1);
|
||||
|
||||
state.tShift[ddLine] = oldTShift;
|
||||
state.sCount[ddLine] = oldSCount;
|
||||
state.tight = oldTight;
|
||||
state.parentType = oldParentType;
|
||||
state.blkIndent = oldIndent;
|
||||
state.ddIndent = oldDDIndent;
|
||||
|
||||
state.push('dd_close', 'dd', -1);
|
||||
|
||||
itemLines[1] = nextLine = state.line;
|
||||
|
||||
if (nextLine >= endLine) { break OUTER; }
|
||||
|
||||
if (state.sCount[nextLine] < state.blkIndent) { break OUTER; }
|
||||
contentStart = skipMarker(state, nextLine);
|
||||
if (contentStart < 0) { break; }
|
||||
|
||||
ddLine = nextLine;
|
||||
|
||||
// go to the next loop iteration:
|
||||
// insert DD tag and repeat checking
|
||||
}
|
||||
|
||||
if (nextLine >= endLine) { break; }
|
||||
dtLine = nextLine;
|
||||
|
||||
if (state.isEmpty(dtLine)) { break; }
|
||||
if (state.sCount[dtLine] < state.blkIndent) { break; }
|
||||
|
||||
ddLine = dtLine + 1;
|
||||
if (ddLine >= endLine) { break; }
|
||||
if (state.isEmpty(ddLine)) { ddLine++; }
|
||||
if (ddLine >= endLine) { break; }
|
||||
|
||||
if (state.sCount[ddLine] < state.blkIndent) { break; }
|
||||
contentStart = skipMarker(state, ddLine);
|
||||
if (contentStart < 0) { break; }
|
||||
|
||||
// go to the next loop iteration:
|
||||
// insert DT and DD tags and repeat checking
|
||||
}
|
||||
|
||||
// Finilize list
|
||||
state.push('dl_close', 'dl', -1);
|
||||
|
||||
listLines[1] = nextLine;
|
||||
|
||||
state.line = nextLine;
|
||||
|
||||
// mark paragraphs tight if needed
|
||||
if (tight) {
|
||||
markTightParagraphs(state, listTokIdx);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
md.block.ruler.before('paragraph', 'deflist', deflist, { alt: ['paragraph', 'reference', 'blockquote'] });
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as bare } from './lib/bare';
|
||||
export { default as light } from './lib/light';
|
||||
export { default as full } from './lib/full';
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emoji_html from './render';
|
||||
import emoji_replace from './replace';
|
||||
import normalize_opts, { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Bare emoji 插件(不包含预定义的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: {},
|
||||
shortcuts: {},
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = normalize_opts(md.utils.assign({}, defaults, options || {}) as EmojiOptions);
|
||||
|
||||
md.renderer.rules.emoji = emoji_html;
|
||||
|
||||
md.core.ruler.after(
|
||||
'linkify',
|
||||
'emoji',
|
||||
emoji_replace(md, opts.defs, opts.shortcuts, opts.scanRE, opts.replaceRE)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
// Generated, don't edit
|
||||
import { EmojiDefs } from '../normalize_opts';
|
||||
|
||||
const emojies: EmojiDefs = {
|
||||
"grinning": "😀",
|
||||
"smiley": "😃",
|
||||
"smile": "😄",
|
||||
"grin": "😁",
|
||||
"laughing": "😆",
|
||||
"satisfied": "😆",
|
||||
"sweat_smile": "😅",
|
||||
"joy": "😂",
|
||||
"wink": "😉",
|
||||
"blush": "😊",
|
||||
"innocent": "😇",
|
||||
"heart_eyes": "😍",
|
||||
"kissing_heart": "😘",
|
||||
"kissing": "😗",
|
||||
"kissing_closed_eyes": "😚",
|
||||
"kissing_smiling_eyes": "😙",
|
||||
"yum": "😋",
|
||||
"stuck_out_tongue": "😛",
|
||||
"stuck_out_tongue_winking_eye": "😜",
|
||||
"stuck_out_tongue_closed_eyes": "😝",
|
||||
"neutral_face": "😐",
|
||||
"expressionless": "😑",
|
||||
"no_mouth": "😶",
|
||||
"smirk": "😏",
|
||||
"unamused": "😒",
|
||||
"relieved": "😌",
|
||||
"pensive": "😔",
|
||||
"sleepy": "😪",
|
||||
"sleeping": "😴",
|
||||
"mask": "😷",
|
||||
"dizzy_face": "😵",
|
||||
"sunglasses": "😎",
|
||||
"confused": "😕",
|
||||
"worried": "😟",
|
||||
"open_mouth": "😮",
|
||||
"hushed": "😯",
|
||||
"astonished": "😲",
|
||||
"flushed": "😳",
|
||||
"frowning": "😦",
|
||||
"anguished": "😧",
|
||||
"fearful": "😨",
|
||||
"cold_sweat": "😰",
|
||||
"disappointed_relieved": "😥",
|
||||
"cry": "😢",
|
||||
"sob": "😭",
|
||||
"scream": "😱",
|
||||
"confounded": "😖",
|
||||
"persevere": "😣",
|
||||
"disappointed": "😞",
|
||||
"sweat": "😓",
|
||||
"weary": "😩",
|
||||
"tired_face": "😫",
|
||||
"rage": "😡",
|
||||
"pout": "😡",
|
||||
"angry": "😠",
|
||||
"smiling_imp": "😈",
|
||||
"smiley_cat": "😺",
|
||||
"smile_cat": "😸",
|
||||
"joy_cat": "😹",
|
||||
"heart_eyes_cat": "😻",
|
||||
"smirk_cat": "😼",
|
||||
"kissing_cat": "😽",
|
||||
"scream_cat": "🙀",
|
||||
"crying_cat_face": "😿",
|
||||
"pouting_cat": "😾",
|
||||
"heart": "❤️",
|
||||
"hand": "✋",
|
||||
"raised_hand": "✋",
|
||||
"v": "✌️",
|
||||
"point_up": "☝️",
|
||||
"fist_raised": "✊",
|
||||
"fist": "✊",
|
||||
"monkey_face": "🐵",
|
||||
"cat": "🐱",
|
||||
"cow": "🐮",
|
||||
"mouse": "🐭",
|
||||
"coffee": "☕",
|
||||
"hotsprings": "♨️",
|
||||
"anchor": "⚓",
|
||||
"airplane": "✈️",
|
||||
"hourglass": "⌛",
|
||||
"watch": "⌚",
|
||||
"sunny": "☀️",
|
||||
"star": "⭐",
|
||||
"cloud": "☁️",
|
||||
"umbrella": "☔",
|
||||
"zap": "⚡",
|
||||
"snowflake": "❄️",
|
||||
"sparkles": "✨",
|
||||
"black_joker": "🃏",
|
||||
"mahjong": "🀄",
|
||||
"phone": "☎️",
|
||||
"telephone": "☎️",
|
||||
"envelope": "✉️",
|
||||
"pencil2": "✏️",
|
||||
"black_nib": "✒️",
|
||||
"scissors": "✂️",
|
||||
"wheelchair": "♿",
|
||||
"warning": "⚠️",
|
||||
"aries": "♈",
|
||||
"taurus": "♉",
|
||||
"gemini": "♊",
|
||||
"cancer": "♋",
|
||||
"leo": "♌",
|
||||
"virgo": "♍",
|
||||
"libra": "♎",
|
||||
"scorpius": "♏",
|
||||
"sagittarius": "♐",
|
||||
"capricorn": "♑",
|
||||
"aquarius": "♒",
|
||||
"pisces": "♓",
|
||||
"heavy_multiplication_x": "✖️",
|
||||
"heavy_plus_sign": "➕",
|
||||
"heavy_minus_sign": "➖",
|
||||
"heavy_division_sign": "➗",
|
||||
"bangbang": "‼️",
|
||||
"interrobang": "⁉️",
|
||||
"question": "❓",
|
||||
"grey_question": "❔",
|
||||
"grey_exclamation": "❕",
|
||||
"exclamation": "❗",
|
||||
"heavy_exclamation_mark": "❗",
|
||||
"wavy_dash": "〰️",
|
||||
"recycle": "♻️",
|
||||
"white_check_mark": "✅",
|
||||
"ballot_box_with_check": "☑️",
|
||||
"heavy_check_mark": "✔️",
|
||||
"x": "❌",
|
||||
"negative_squared_cross_mark": "❎",
|
||||
"curly_loop": "➰",
|
||||
"loop": "➿",
|
||||
"part_alternation_mark": "〽️",
|
||||
"eight_spoked_asterisk": "✳️",
|
||||
"eight_pointed_black_star": "✴️",
|
||||
"sparkle": "❇️",
|
||||
"copyright": "©️",
|
||||
"registered": "®️",
|
||||
"tm": "™️",
|
||||
"information_source": "ℹ️",
|
||||
"m": "Ⓜ️",
|
||||
"black_circle": "⚫",
|
||||
"white_circle": "⚪",
|
||||
"black_large_square": "⬛",
|
||||
"white_large_square": "⬜",
|
||||
"black_medium_square": "◼️",
|
||||
"white_medium_square": "◻️",
|
||||
"black_medium_small_square": "◾",
|
||||
"white_medium_small_square": "◽",
|
||||
"black_small_square": "▪️",
|
||||
"white_small_square": "▫️"
|
||||
};
|
||||
|
||||
export default emojies;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Emoticons -> Emoji mapping.
|
||||
//
|
||||
// (!) Some patterns skipped, to avoid collisions
|
||||
// without increase matcher complicity. Than can change in future.
|
||||
//
|
||||
// Places to look for more emoticons info:
|
||||
//
|
||||
// - http://en.wikipedia.org/wiki/List_of_emoticons#Western
|
||||
// - https://github.com/wooorm/emoticon/blob/master/Support.md
|
||||
// - http://factoryjoe.com/projects/emoticons/
|
||||
//
|
||||
|
||||
import { EmojiShortcuts } from '../normalize_opts';
|
||||
|
||||
const shortcuts: EmojiShortcuts = {
|
||||
angry: ['>:(', '>:-('],
|
||||
blush: [':")', ':-")'],
|
||||
broken_heart: ['</3', '<\\3'],
|
||||
// :\ and :-\ not used because of conflict with markdown escaping
|
||||
confused: [':/', ':-/'], // twemoji shows question
|
||||
cry: [":'(", ":'-(", ':,(', ':,-('],
|
||||
frowning: [':(', ':-('],
|
||||
heart: ['<3'],
|
||||
imp: [']:(', ']:-('],
|
||||
innocent: ['o:)', 'O:)', 'o:-)', 'O:-)', '0:)', '0:-)'],
|
||||
joy: [":')", ":'-)", ':,)', ':,-)', ":'D", ":'-D", ':,D', ':,-D'],
|
||||
kissing: [':*', ':-*'],
|
||||
laughing: ['x-)', 'X-)'],
|
||||
neutral_face: [':|', ':-|'],
|
||||
open_mouth: [':o', ':-o', ':O', ':-O'],
|
||||
rage: [':@', ':-@'],
|
||||
smile: [':D', ':-D'],
|
||||
smiley: [':)', ':-)'],
|
||||
smiling_imp: [']:)', ']:-)'],
|
||||
sob: [":,'(", ":,'-(", ';(', ';-('],
|
||||
stuck_out_tongue: [':P', ':-P'],
|
||||
sunglasses: ['8-)', 'B-)'],
|
||||
sweat: [',:(', ',:-('],
|
||||
sweat_smile: [',:)', ',:-)'],
|
||||
unamused: [':s', ':-S', ':z', ':-Z', ':$', ':-$'],
|
||||
wink: [';)', ';-)']
|
||||
};
|
||||
|
||||
export default shortcuts;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emojies_defs from './data/full';
|
||||
import emojies_shortcuts from './data/shortcuts';
|
||||
import bare_emoji_plugin from './bare';
|
||||
import { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Full emoji 插件(包含完整的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: emojies_defs,
|
||||
shortcuts: emojies_shortcuts,
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
|
||||
|
||||
bare_emoji_plugin(md, opts);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emojies_defs from './data/light';
|
||||
import emojies_shortcuts from './data/shortcuts';
|
||||
import bare_emoji_plugin from './bare';
|
||||
import { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Light emoji 插件(包含常用的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: emojies_defs,
|
||||
shortcuts: emojies_shortcuts,
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
|
||||
|
||||
bare_emoji_plugin(md, opts);
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Emoji 定义类型
|
||||
*/
|
||||
export interface EmojiDefs {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji 快捷方式类型
|
||||
*/
|
||||
export interface EmojiShortcuts {
|
||||
[key: string]: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入选项接口
|
||||
*/
|
||||
export interface EmojiOptions {
|
||||
defs: EmojiDefs;
|
||||
shortcuts: EmojiShortcuts;
|
||||
enabled: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化后的选项接口
|
||||
*/
|
||||
export interface NormalizedEmojiOptions {
|
||||
defs: EmojiDefs;
|
||||
shortcuts: { [key: string]: string };
|
||||
scanRE: RegExp;
|
||||
replaceRE: RegExp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义正则表达式特殊字符
|
||||
*/
|
||||
function quoteRE(str: string): string {
|
||||
return str.replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将输入选项转换为更可用的格式并编译搜索正则表达式
|
||||
*/
|
||||
export default function normalize_opts(options: EmojiOptions): NormalizedEmojiOptions {
|
||||
let emojies = options.defs;
|
||||
|
||||
// Filter emojies by whitelist, if needed
|
||||
if (options.enabled.length) {
|
||||
emojies = Object.keys(emojies).reduce((acc: EmojiDefs, key: string) => {
|
||||
if (options.enabled.indexOf(key) >= 0) acc[key] = emojies[key];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Flatten shortcuts to simple object: { alias: emoji_name }
|
||||
const shortcuts = Object.keys(options.shortcuts).reduce((acc: { [key: string]: string }, key: string) => {
|
||||
// Skip aliases for filtered emojies, to reduce regexp
|
||||
if (!emojies[key]) return acc;
|
||||
|
||||
if (Array.isArray(options.shortcuts[key])) {
|
||||
(options.shortcuts[key] as string[]).forEach((alias: string) => { acc[alias] = key; });
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[options.shortcuts[key] as string] = key;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const keys = Object.keys(emojies);
|
||||
let names: string;
|
||||
|
||||
// If no definitions are given, return empty regex to avoid replacements with 'undefined'.
|
||||
if (keys.length === 0) {
|
||||
names = '^$';
|
||||
} else {
|
||||
// Compile regexp
|
||||
names = keys
|
||||
.map((name: string) => { return `:${name}:`; })
|
||||
.concat(Object.keys(shortcuts))
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((name: string) => { return quoteRE(name); })
|
||||
.join('|');
|
||||
}
|
||||
const scanRE = RegExp(names);
|
||||
const replaceRE = RegExp(names, 'g');
|
||||
|
||||
return {
|
||||
defs: emojies,
|
||||
shortcuts,
|
||||
scanRE,
|
||||
replaceRE
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* Emoji 渲染函数
|
||||
*/
|
||||
export default function emoji_html(tokens: Token[], idx: number): string {
|
||||
return tokens[idx].content;
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import MarkdownIt, { StateCore, Token } from 'markdown-it';
|
||||
import { EmojiDefs } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Emoji 和快捷方式替换逻辑
|
||||
*
|
||||
* 注意:理论上,在内联链中解析 :smile: 并只留下快捷方式会更快。
|
||||
* 但是,谁在乎呢...
|
||||
*/
|
||||
export default function create_rule(
|
||||
md: MarkdownIt,
|
||||
emojies: EmojiDefs,
|
||||
shortcuts: { [key: string]: string },
|
||||
scanRE: RegExp,
|
||||
replaceRE: RegExp
|
||||
) {
|
||||
const arrayReplaceAt = md.utils.arrayReplaceAt;
|
||||
const ucm = md.utils.lib.ucmicro;
|
||||
const has = md.utils.has;
|
||||
const ZPCc = new RegExp([ucm.Z.source, ucm.P.source, ucm.Cc.source].join('|'));
|
||||
|
||||
function splitTextToken(text: string, level: number, TokenConstructor: any): Token[] {
|
||||
let last_pos = 0;
|
||||
const nodes: Token[] = [];
|
||||
|
||||
text.replace(replaceRE, function (match: string, offset: number, src: string): string {
|
||||
let emoji_name: string;
|
||||
// Validate emoji name
|
||||
if (has(shortcuts, match)) {
|
||||
// replace shortcut with full name
|
||||
emoji_name = shortcuts[match];
|
||||
|
||||
// Don't allow letters before any shortcut (as in no ":/" in http://)
|
||||
if (offset > 0 && !ZPCc.test(src[offset - 1])) return '';
|
||||
|
||||
// Don't allow letters after any shortcut
|
||||
if (offset + match.length < src.length && !ZPCc.test(src[offset + match.length])) {
|
||||
return '';
|
||||
}
|
||||
} else {
|
||||
emoji_name = match.slice(1, -1);
|
||||
}
|
||||
|
||||
// Add new tokens to pending list
|
||||
if (offset > last_pos) {
|
||||
const token = new TokenConstructor('text', '', 0);
|
||||
token.content = text.slice(last_pos, offset);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
const token = new TokenConstructor('emoji', '', 0);
|
||||
token.markup = emoji_name;
|
||||
token.content = emojies[emoji_name];
|
||||
nodes.push(token);
|
||||
|
||||
last_pos = offset + match.length;
|
||||
return '';
|
||||
});
|
||||
|
||||
if (last_pos < text.length) {
|
||||
const token = new TokenConstructor('text', '', 0);
|
||||
token.content = text.slice(last_pos);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return function emoji_replace(state: StateCore): void {
|
||||
let token: Token;
|
||||
const blockTokens = state.tokens;
|
||||
let autolinkLevel = 0;
|
||||
|
||||
for (let j = 0, l = blockTokens.length; j < l; j++) {
|
||||
if (blockTokens[j].type !== 'inline') { continue; }
|
||||
let tokens = blockTokens[j].children!;
|
||||
|
||||
// We scan from the end, to keep position when new tags added.
|
||||
// Use reversed logic in links start/end match
|
||||
for (let i = tokens.length - 1; i >= 0; i--) {
|
||||
token = tokens[i];
|
||||
|
||||
if (token.type === 'link_open' || token.type === 'link_close') {
|
||||
if (token.info === 'auto') { autolinkLevel -= token.nesting; }
|
||||
}
|
||||
|
||||
if (token.type === 'text' && autolinkLevel === 0 && scanRE.test(token.content)) {
|
||||
// replace current node
|
||||
blockTokens[j].children = tokens = arrayReplaceAt(
|
||||
tokens, i, splitTextToken(token.content, token.level, state.Token)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
import MarkdownIt, {Renderer, StateBlock, StateCore, StateInline, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 脚注元数据接口
|
||||
*/
|
||||
interface FootnoteMeta {
|
||||
id: number;
|
||||
subId: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 脚注列表项接口
|
||||
*/
|
||||
interface FootnoteItem {
|
||||
label?: string;
|
||||
content?: string;
|
||||
tokens?: Token[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 环境接口
|
||||
*/
|
||||
interface FootnoteEnv {
|
||||
footnotes?: {
|
||||
refs?: { [key: string]: number };
|
||||
list?: FootnoteItem[];
|
||||
};
|
||||
docId?: string;
|
||||
}
|
||||
|
||||
/// /////////////////////////////////////////////////////////////////////////////
|
||||
// Renderer partials
|
||||
|
||||
function render_footnote_anchor_name(tokens: Token[], idx: number, options: any, env: FootnoteEnv): string {
|
||||
const n = Number(tokens[idx].meta.id + 1).toString();
|
||||
let prefix = '';
|
||||
|
||||
if (typeof env.docId === 'string') prefix = `-${env.docId}-`;
|
||||
|
||||
return prefix + n;
|
||||
}
|
||||
|
||||
function render_footnote_caption(tokens: Token[], idx: number): string {
|
||||
let n = Number(tokens[idx].meta.id + 1).toString();
|
||||
|
||||
if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `[${n}]`;
|
||||
}
|
||||
|
||||
function render_footnote_ref(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
const id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
const caption = slf.rules.footnote_caption!(tokens, idx, options, env, slf);
|
||||
let refid = id;
|
||||
|
||||
if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `<sup class="footnote-ref"><a href="#fn${id}" id="fnref${refid}">${caption}</a></sup>`;
|
||||
}
|
||||
|
||||
function render_footnote_block_open(tokens: Token[], idx: number, options: any): string {
|
||||
return (options.xhtmlOut ? '<hr class="footnotes-sep" />\n' : '<hr class="footnotes-sep">\n') +
|
||||
'<section class="footnotes">\n' +
|
||||
'<ol class="footnotes-list">\n';
|
||||
}
|
||||
|
||||
function render_footnote_block_close(): string {
|
||||
return '</ol>\n</section>\n';
|
||||
}
|
||||
|
||||
function render_footnote_open(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
|
||||
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `<li id="fn${id}" class="footnote-item">`;
|
||||
}
|
||||
|
||||
function render_footnote_close(): string {
|
||||
return '</li>\n';
|
||||
}
|
||||
|
||||
function render_footnote_anchor(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
|
||||
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
/* ↩ with escape code to prevent display as Apple Emoji on iOS */
|
||||
return ` <a href="#fnref${id}" class="footnote-backref">\u21a9\uFE0E</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-footnote 插件
|
||||
* 用于支持脚注语法
|
||||
*/
|
||||
export default function footnote_plugin(md: MarkdownIt): void {
|
||||
const parseLinkLabel = md.helpers.parseLinkLabel;
|
||||
const isSpace = md.utils.isSpace;
|
||||
|
||||
md.renderer.rules.footnote_ref = render_footnote_ref;
|
||||
md.renderer.rules.footnote_block_open = render_footnote_block_open;
|
||||
md.renderer.rules.footnote_block_close = render_footnote_block_close;
|
||||
md.renderer.rules.footnote_open = render_footnote_open;
|
||||
md.renderer.rules.footnote_close = render_footnote_close;
|
||||
md.renderer.rules.footnote_anchor = render_footnote_anchor;
|
||||
|
||||
// helpers (only used in other rules, no tokens are attached to those)
|
||||
md.renderer.rules.footnote_caption = render_footnote_caption;
|
||||
md.renderer.rules.footnote_anchor_name = render_footnote_anchor_name;
|
||||
|
||||
// Process footnote block definition
|
||||
function footnote_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
const start = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
|
||||
// line should be at least 5 chars - "[^x]:"
|
||||
if (start + 4 > max) return false;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
|
||||
|
||||
let pos: number;
|
||||
|
||||
for (pos = start + 2; pos < max; pos++) {
|
||||
if (state.src.charCodeAt(pos) === 0x20) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pos === start + 2) return false; // no empty footnote labels
|
||||
if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 0x3A /* : */) return false;
|
||||
if (silent) return true;
|
||||
pos++;
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) env.footnotes = {};
|
||||
if (!env.footnotes.refs) env.footnotes.refs = {};
|
||||
const label = state.src.slice(start + 2, pos - 2);
|
||||
env.footnotes.refs[`:${label}`] = -1;
|
||||
|
||||
const token_fref_o = new state.Token('footnote_reference_open', '', 1);
|
||||
token_fref_o.meta = { label };
|
||||
token_fref_o.level = state.level++;
|
||||
state.tokens.push(token_fref_o);
|
||||
|
||||
const oldBMark = state.bMarks[startLine];
|
||||
const oldTShift = state.tShift[startLine];
|
||||
const oldSCount = state.sCount[startLine];
|
||||
const oldParentType = state.parentType;
|
||||
|
||||
const posAfterColon = pos;
|
||||
const initial = state.sCount[startLine] + pos - (state.bMarks[startLine] + state.tShift[startLine]);
|
||||
let offset = initial;
|
||||
|
||||
while (pos < max) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
|
||||
if (isSpace(ch)) {
|
||||
if (ch === 0x09) {
|
||||
offset += 4 - offset % 4;
|
||||
} else {
|
||||
offset++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
state.tShift[startLine] = pos - posAfterColon;
|
||||
state.sCount[startLine] = offset - initial;
|
||||
|
||||
state.bMarks[startLine] = posAfterColon;
|
||||
state.blkIndent += 4;
|
||||
state.parentType = 'footnote' as any;
|
||||
|
||||
if (state.sCount[startLine] < state.blkIndent) {
|
||||
state.sCount[startLine] += state.blkIndent;
|
||||
}
|
||||
|
||||
state.md.block.tokenize(state, startLine, endLine);
|
||||
|
||||
state.parentType = oldParentType;
|
||||
state.blkIndent -= 4;
|
||||
state.tShift[startLine] = oldTShift;
|
||||
state.sCount[startLine] = oldSCount;
|
||||
state.bMarks[startLine] = oldBMark;
|
||||
|
||||
const token_fref_c = new state.Token('footnote_reference_close', '', -1);
|
||||
token_fref_c.level = --state.level;
|
||||
state.tokens.push(token_fref_c);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process inline footnotes (^[...])
|
||||
function footnote_inline(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (start + 2 >= max) return false;
|
||||
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5B/* [ */) return false;
|
||||
|
||||
const labelStart = start + 2;
|
||||
const labelEnd = parseLinkLabel(state, start + 1);
|
||||
|
||||
// parser failed to find ']', so it's not a valid note
|
||||
if (labelEnd < 0) return false;
|
||||
|
||||
// We found the end of the link, and know for a fact it's a valid link;
|
||||
// so all that's left to do is to call tokenizer.
|
||||
//
|
||||
if (!silent) {
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) env.footnotes = {};
|
||||
if (!env.footnotes.list) env.footnotes.list = [];
|
||||
const footnoteId = env.footnotes.list.length;
|
||||
const tokens: Token[] = [];
|
||||
|
||||
state.md.inline.parse(
|
||||
state.src.slice(labelStart, labelEnd),
|
||||
state.md,
|
||||
state.env,
|
||||
tokens
|
||||
);
|
||||
|
||||
const token = state.push('footnote_ref', '', 0);
|
||||
token.meta = { id: footnoteId };
|
||||
|
||||
env.footnotes.list[footnoteId] = {
|
||||
content: state.src.slice(labelStart, labelEnd),
|
||||
tokens,
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
|
||||
state.pos = labelEnd + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process footnote references ([^...])
|
||||
function footnote_ref(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
// should be at least 4 chars - "[^x]"
|
||||
if (start + 3 > max) return false;
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes || !env.footnotes.refs) return false;
|
||||
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
|
||||
|
||||
let pos: number;
|
||||
|
||||
for (pos = start + 2; pos < max; pos++) {
|
||||
if (state.src.charCodeAt(pos) === 0x20) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x0A) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pos === start + 2) return false; // no empty footnote labels
|
||||
if (pos >= max) return false;
|
||||
pos++;
|
||||
|
||||
const label = state.src.slice(start + 2, pos - 1);
|
||||
if (typeof env.footnotes.refs[`:${label}`] === 'undefined') return false;
|
||||
|
||||
if (!silent) {
|
||||
if (!env.footnotes.list) env.footnotes.list = [];
|
||||
|
||||
let footnoteId: number;
|
||||
|
||||
if (env.footnotes.refs[`:${label}`] < 0) {
|
||||
footnoteId = env.footnotes.list.length;
|
||||
env.footnotes.list[footnoteId] = { label, count: 0 };
|
||||
env.footnotes.refs[`:${label}`] = footnoteId;
|
||||
} else {
|
||||
footnoteId = env.footnotes.refs[`:${label}`];
|
||||
}
|
||||
|
||||
const footnoteSubId = env.footnotes.list[footnoteId].count;
|
||||
env.footnotes.list[footnoteId].count++;
|
||||
|
||||
const token = state.push('footnote_ref', '', 0);
|
||||
token.meta = { id: footnoteId, subId: footnoteSubId, label };
|
||||
}
|
||||
|
||||
state.pos = pos;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Glue footnote tokens to end of token stream
|
||||
function footnote_tail(state: StateCore): void {
|
||||
let tokens: Token[] | null = null;
|
||||
let current: Token[];
|
||||
let currentLabel: string;
|
||||
let insideRef = false;
|
||||
const refTokens: { [key: string]: Token[] } = {};
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) { return; }
|
||||
|
||||
state.tokens = state.tokens.filter(function (tok) {
|
||||
if (tok.type === 'footnote_reference_open') {
|
||||
insideRef = true;
|
||||
current = [];
|
||||
currentLabel = tok.meta.label;
|
||||
return false;
|
||||
}
|
||||
if (tok.type === 'footnote_reference_close') {
|
||||
insideRef = false;
|
||||
// prepend ':' to avoid conflict with Object.prototype members
|
||||
refTokens[':' + currentLabel] = current;
|
||||
return false;
|
||||
}
|
||||
if (insideRef) { current.push(tok); }
|
||||
return !insideRef;
|
||||
});
|
||||
|
||||
if (!env.footnotes.list) { return; }
|
||||
const list = env.footnotes.list;
|
||||
|
||||
state.tokens.push(new state.Token('footnote_block_open', '', 1));
|
||||
|
||||
for (let i = 0, l = list.length; i < l; i++) {
|
||||
const token_fo = new state.Token('footnote_open', '', 1);
|
||||
token_fo.meta = { id: i, label: list[i].label };
|
||||
state.tokens.push(token_fo);
|
||||
|
||||
if (list[i].tokens) {
|
||||
tokens = [];
|
||||
|
||||
const token_po = new state.Token('paragraph_open', 'p', 1);
|
||||
token_po.block = true;
|
||||
tokens.push(token_po);
|
||||
|
||||
const token_i = new state.Token('inline', '', 0);
|
||||
token_i.children = list[i].tokens || null;
|
||||
token_i.content = list[i].content || '';
|
||||
tokens.push(token_i);
|
||||
|
||||
const token_pc = new state.Token('paragraph_close', 'p', -1);
|
||||
token_pc.block = true;
|
||||
tokens.push(token_pc);
|
||||
} else if (list[i].label) {
|
||||
tokens = refTokens[`:${list[i].label}`] || null;
|
||||
}
|
||||
|
||||
if (tokens) state.tokens = state.tokens.concat(tokens);
|
||||
|
||||
let lastParagraph: Token | null;
|
||||
|
||||
if (state.tokens[state.tokens.length - 1].type === 'paragraph_close') {
|
||||
lastParagraph = state.tokens.pop()!;
|
||||
} else {
|
||||
lastParagraph = null;
|
||||
}
|
||||
|
||||
const t = list[i].count > 0 ? list[i].count : 1;
|
||||
for (let j = 0; j < t; j++) {
|
||||
const token_a = new state.Token('footnote_anchor', '', 0);
|
||||
token_a.meta = { id: i, subId: j, label: list[i].label };
|
||||
state.tokens.push(token_a);
|
||||
}
|
||||
|
||||
if (lastParagraph) {
|
||||
state.tokens.push(lastParagraph);
|
||||
}
|
||||
|
||||
state.tokens.push(new state.Token('footnote_close', '', -1));
|
||||
}
|
||||
|
||||
state.tokens.push(new state.Token('footnote_block_close', '', -1));
|
||||
}
|
||||
|
||||
md.block.ruler.before('reference', 'footnote_def', footnote_def, { alt: ['paragraph', 'reference'] });
|
||||
md.inline.ruler.after('image', 'footnote_inline', footnote_inline);
|
||||
md.inline.ruler.after('footnote_inline', 'footnote_ref', footnote_ref);
|
||||
md.core.ruler.after('inline', 'footnote_tail', footnote_tail);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 分隔符接口定义
|
||||
*/
|
||||
interface Delimiter {
|
||||
marker: number;
|
||||
length: number;
|
||||
jump: number;
|
||||
token: number;
|
||||
end: number;
|
||||
open: boolean;
|
||||
close: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描结果接口定义
|
||||
*/
|
||||
interface ScanResult {
|
||||
can_open: boolean;
|
||||
can_close: boolean;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 元数据接口定义
|
||||
*/
|
||||
interface TokenMeta {
|
||||
delimiters?: Delimiter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-ins 插件
|
||||
* 用于支持插入文本语法 ++text++
|
||||
*/
|
||||
export default function ins_plugin(md: MarkdownIt): void {
|
||||
// Insert each marker as a separate text token, and add it to delimiter list
|
||||
//
|
||||
function tokenize(state: StateInline, silent: boolean): boolean {
|
||||
const start = state.pos;
|
||||
const marker = state.src.charCodeAt(start);
|
||||
|
||||
if (silent) { return false; }
|
||||
|
||||
if (marker !== 0x2B/* + */) { return false; }
|
||||
|
||||
const scanned = state.scanDelims(state.pos, true) as ScanResult;
|
||||
let len = scanned.length;
|
||||
const ch = String.fromCharCode(marker);
|
||||
|
||||
if (len < 2) { return false; }
|
||||
|
||||
if (len % 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch;
|
||||
len--;
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch + ch;
|
||||
|
||||
if (!scanned.can_open && !scanned.can_close) { continue; }
|
||||
|
||||
state.delimiters.push({
|
||||
marker,
|
||||
length: 0, // disable "rule of 3" length checks meant for emphasis
|
||||
jump: i / 2, // 1 delimiter = 2 characters
|
||||
token: state.tokens.length - 1,
|
||||
end: -1,
|
||||
open: scanned.can_open,
|
||||
close: scanned.can_close
|
||||
} as Delimiter);
|
||||
}
|
||||
|
||||
state.pos += scanned.length;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Walk through delimiter list and replace text tokens with tags
|
||||
//
|
||||
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
|
||||
let token: Token;
|
||||
const loneMarkers: number[] = [];
|
||||
const max = delimiters.length;
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const startDelim = delimiters[i];
|
||||
|
||||
if (startDelim.marker !== 0x2B/* + */) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startDelim.end === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const endDelim = delimiters[startDelim.end];
|
||||
|
||||
token = state.tokens[startDelim.token];
|
||||
token.type = 'ins_open';
|
||||
token.tag = 'ins';
|
||||
token.nesting = 1;
|
||||
token.markup = '++';
|
||||
token.content = '';
|
||||
|
||||
token = state.tokens[endDelim.token];
|
||||
token.type = 'ins_close';
|
||||
token.tag = 'ins';
|
||||
token.nesting = -1;
|
||||
token.markup = '++';
|
||||
token.content = '';
|
||||
|
||||
if (state.tokens[endDelim.token - 1].type === 'text' &&
|
||||
state.tokens[endDelim.token - 1].content === '+') {
|
||||
loneMarkers.push(endDelim.token - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If a marker sequence has an odd number of characters, it's splitted
|
||||
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
|
||||
// start of the sequence.
|
||||
//
|
||||
// So, we have to move all those markers after subsequent s_close tags.
|
||||
//
|
||||
while (loneMarkers.length) {
|
||||
const i = loneMarkers.pop()!;
|
||||
let j = i + 1;
|
||||
|
||||
while (j < state.tokens.length && state.tokens[j].type === 'ins_close') {
|
||||
j++;
|
||||
}
|
||||
|
||||
j--;
|
||||
|
||||
if (i !== j) {
|
||||
token = state.tokens[j];
|
||||
state.tokens[j] = state.tokens[i];
|
||||
state.tokens[i] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.inline.ruler.before('emphasis', 'ins', tokenize);
|
||||
md.inline.ruler2.before('emphasis', 'ins', function (state: StateInline): boolean {
|
||||
const tokens_meta = state.tokens_meta as TokenMeta[];
|
||||
const max = (state.tokens_meta || []).length;
|
||||
|
||||
postProcess(state, state.delimiters as Delimiter[]);
|
||||
|
||||
for (let curr = 0; curr < max; curr++) {
|
||||
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
|
||||
postProcess(state, tokens_meta[curr].delimiters!);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import MarkdownIt, {StateInline, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 分隔符接口定义
|
||||
*/
|
||||
interface Delimiter {
|
||||
marker: number;
|
||||
length: number;
|
||||
jump: number;
|
||||
token: number;
|
||||
end: number;
|
||||
open: boolean;
|
||||
close: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描结果接口定义
|
||||
*/
|
||||
interface ScanResult {
|
||||
can_open: boolean;
|
||||
can_close: boolean;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 元数据接口定义
|
||||
*/
|
||||
interface TokenMeta {
|
||||
delimiters?: Delimiter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-mark 插件
|
||||
* 用于支持 ==标记文本== 语法
|
||||
*/
|
||||
export default function markPlugin(md: MarkdownIt): void {
|
||||
// Insert each marker as a separate text token, and add it to delimiter list
|
||||
//
|
||||
function tokenize(state: StateInline, silent: boolean): boolean {
|
||||
const start = state.pos;
|
||||
const marker = state.src.charCodeAt(start);
|
||||
|
||||
if (silent) { return false; }
|
||||
|
||||
if (marker !== 0x3D/* = */) { return false; }
|
||||
|
||||
const scanned = state.scanDelims(state.pos, true) as ScanResult;
|
||||
let len = scanned.length;
|
||||
const ch = String.fromCharCode(marker);
|
||||
|
||||
if (len < 2) { return false; }
|
||||
|
||||
if (len % 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch;
|
||||
len--;
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch + ch;
|
||||
|
||||
if (!scanned.can_open && !scanned.can_close) { continue; }
|
||||
|
||||
state.delimiters.push({
|
||||
marker,
|
||||
length: 0, // disable "rule of 3" length checks meant for emphasis
|
||||
jump: i / 2, // 1 delimiter = 2 characters
|
||||
token: state.tokens.length - 1,
|
||||
end: -1,
|
||||
open: scanned.can_open,
|
||||
close: scanned.can_close
|
||||
} as Delimiter);
|
||||
}
|
||||
|
||||
state.pos += scanned.length;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Walk through delimiter list and replace text tokens with tags
|
||||
//
|
||||
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
|
||||
const loneMarkers: number[] = [];
|
||||
const max = delimiters.length;
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const startDelim = delimiters[i];
|
||||
|
||||
if (startDelim.marker !== 0x3D/* = */) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startDelim.end === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const endDelim = delimiters[startDelim.end];
|
||||
|
||||
const token_o = state.tokens[startDelim.token];
|
||||
token_o.type = 'mark_open';
|
||||
token_o.tag = 'mark';
|
||||
token_o.nesting = 1;
|
||||
token_o.markup = '==';
|
||||
token_o.content = '';
|
||||
|
||||
const token_c = state.tokens[endDelim.token];
|
||||
token_c.type = 'mark_close';
|
||||
token_c.tag = 'mark';
|
||||
token_c.nesting = -1;
|
||||
token_c.markup = '==';
|
||||
token_c.content = '';
|
||||
|
||||
if (state.tokens[endDelim.token - 1].type === 'text' &&
|
||||
state.tokens[endDelim.token - 1].content === '=') {
|
||||
loneMarkers.push(endDelim.token - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If a marker sequence has an odd number of characters, it's splitted
|
||||
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
|
||||
// start of the sequence.
|
||||
//
|
||||
// So, we have to move all those markers after subsequent s_close tags.
|
||||
//
|
||||
while (loneMarkers.length) {
|
||||
const i = loneMarkers.pop()!;
|
||||
let j = i + 1;
|
||||
|
||||
while (j < state.tokens.length && state.tokens[j].type === 'mark_close') {
|
||||
j++;
|
||||
}
|
||||
|
||||
j--;
|
||||
|
||||
if (i !== j) {
|
||||
const token = state.tokens[j];
|
||||
state.tokens[j] = state.tokens[i];
|
||||
state.tokens[i] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.inline.ruler.before('emphasis', 'mark', tokenize);
|
||||
md.inline.ruler2.before('emphasis', 'mark', function (state: StateInline): boolean {
|
||||
let curr: number;
|
||||
const tokens_meta = state.tokens_meta as TokenMeta[];
|
||||
const max = (state.tokens_meta || []).length;
|
||||
|
||||
postProcess(state, state.delimiters as Delimiter[]);
|
||||
|
||||
for (curr = 0; curr < max; curr++) {
|
||||
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
|
||||
postProcess(state, tokens_meta[curr].delimiters!);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import mermaid from "mermaid";
|
||||
import {genUid, hashCode, sleep} from "./utils";
|
||||
|
||||
const mermaidCache = new Map<string, HTMLElement>();
|
||||
|
||||
// 缓存计数器,用于清除缓存
|
||||
const mermaidCacheCount = new Map<string, number>();
|
||||
let count = 0;
|
||||
|
||||
|
||||
let countTmo = setTimeout(() => undefined, 0);
|
||||
const addCount = () => {
|
||||
clearTimeout(countTmo);
|
||||
countTmo = setTimeout(() => {
|
||||
count++;
|
||||
clearCache();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const clearCache = () => {
|
||||
for (const key of mermaidCacheCount.keys()) {
|
||||
const value = mermaidCacheCount.get(key)!;
|
||||
if (value + 3 < count) {
|
||||
mermaidCache.delete(key);
|
||||
mermaidCacheCount.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染 mermaid
|
||||
* @param code mermaid 代码
|
||||
* @param targetId 目标 id
|
||||
* @param count 计数器
|
||||
*/
|
||||
const renderMermaid = async (code: string, targetId: string, count: number) => {
|
||||
let limit = 100;
|
||||
while (limit-- > 0) {
|
||||
const container = document.getElementById(targetId);
|
||||
if (!container) {
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const {svg} = await mermaid.render("mermaid-svg-" + genUid(), code, container);
|
||||
container.innerHTML = svg;
|
||||
mermaidCache.set(targetId, container);
|
||||
mermaidCacheCount.set(targetId, count);
|
||||
} catch (e) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export interface MermaidItOptions {
|
||||
theme?: "default" | "dark" | "forest" | "neutral" | "base";
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 mermaid 主题
|
||||
*/
|
||||
export const updateMermaidTheme = (theme: "default" | "dark" | "forest" | "neutral" | "base") => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme
|
||||
});
|
||||
// 清空缓存,强制重新渲染
|
||||
mermaidCache.clear();
|
||||
mermaidCacheCount.clear();
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* mermaid 插件
|
||||
* @param md markdown-it
|
||||
* @param options 配置选项
|
||||
* @constructor MermaidIt
|
||||
*/
|
||||
export const MermaidIt = function (md: any, options?: MermaidItOptions): void {
|
||||
const theme = options?.theme || "default";
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme
|
||||
});
|
||||
const defaultRenderer = md.renderer.rules.fence.bind(md.renderer.rules);
|
||||
md.renderer.rules.fence = (tokens: any, idx: any, options: any, env: any, self: any) => {
|
||||
addCount();
|
||||
const token = tokens[idx];
|
||||
const info = token.info.trim();
|
||||
if (info === "mermaid") {
|
||||
const containerId = "mermaid-container-" + hashCode(token.content);
|
||||
const container = document.createElement("div");
|
||||
container.id = containerId;
|
||||
if (mermaidCache.has(containerId)) {
|
||||
container.innerHTML = mermaidCache.get(containerId)!.innerHTML;
|
||||
mermaidCacheCount.set(containerId, count);
|
||||
} else {
|
||||
renderMermaid(token.content, containerId, count).then();
|
||||
}
|
||||
return container.outerHTML;
|
||||
}
|
||||
// 使用默认的渲染规则
|
||||
return defaultRenderer(tokens, idx, options, env, self);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
/**
|
||||
* uuid 生成函数
|
||||
* @param split 分隔符
|
||||
*/
|
||||
export const genUid = (split = "") => {
|
||||
return uuidv4().split("-").join(split);
|
||||
};
|
||||
|
||||
/**
|
||||
* 一个简易的sleep函数
|
||||
*/
|
||||
export const sleep = async (ms: number) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算字符串的hash值
|
||||
* 返回一个数字
|
||||
* @param str
|
||||
*/
|
||||
export const hashCode = (str: string) => {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return hash;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
/**
|
||||
* 一个简易的阻塞函数
|
||||
*/
|
||||
export const awaitFor = async (cb: () => boolean, timeout = 0, errText = "超时暂停阻塞") => {
|
||||
const start = Date.now();
|
||||
while (true) {
|
||||
if (cb()) return true;
|
||||
if (timeout && Date.now() - start > timeout) {
|
||||
console.error("阻塞超时: " + errText);
|
||||
return false;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
// Process ~subscript~
|
||||
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
// same as UNESCAPE_MD_RE plus a space
|
||||
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
|
||||
|
||||
function subscript(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x7E/* ~ */) { return false; }
|
||||
if (silent) { return false; } // don't run any pairs in validation mode
|
||||
if (start + 2 >= max) { return false; }
|
||||
|
||||
state.pos = start + 1;
|
||||
let found = false;
|
||||
|
||||
while (state.pos < max) {
|
||||
if (state.src.charCodeAt(state.pos) === 0x7E/* ~ */) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
state.md.inline.skipToken(state);
|
||||
}
|
||||
|
||||
if (!found || start + 1 === state.pos) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = state.src.slice(start + 1, state.pos);
|
||||
|
||||
// don't allow unescaped spaces/newlines inside
|
||||
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
// found!
|
||||
state.posMax = state.pos;
|
||||
state.pos = start + 1;
|
||||
|
||||
// Earlier we checked !silent, but this implementation does not need it
|
||||
const token_so: Token = state.push('sub_open', 'sub', 1);
|
||||
token_so.markup = '~';
|
||||
|
||||
const token_t: Token = state.push('text', '', 0);
|
||||
token_t.content = content.replace(UNESCAPE_RE, '$1');
|
||||
|
||||
const token_sc: Token = state.push('sub_close', 'sub', -1);
|
||||
token_sc.markup = '~';
|
||||
|
||||
state.pos = state.posMax + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-sub 插件
|
||||
* 用于支持下标语法 ~text~
|
||||
*/
|
||||
export default function sub_plugin(md: MarkdownIt): void {
|
||||
md.inline.ruler.after('emphasis', 'sub', subscript);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// Process ^superscript^
|
||||
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
// same as UNESCAPE_MD_RE plus a space
|
||||
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
|
||||
|
||||
function superscript(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) { return false; }
|
||||
if (silent) { return false; } // don't run any pairs in validation mode
|
||||
if (start + 2 >= max) { return false; }
|
||||
|
||||
state.pos = start + 1;
|
||||
let found = false;
|
||||
|
||||
while (state.pos < max) {
|
||||
if (state.src.charCodeAt(state.pos) === 0x5E/* ^ */) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
state.md.inline.skipToken(state);
|
||||
}
|
||||
|
||||
if (!found || start + 1 === state.pos) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = state.src.slice(start + 1, state.pos);
|
||||
|
||||
// don't allow unescaped spaces/newlines inside
|
||||
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
// found!
|
||||
state.posMax = state.pos;
|
||||
state.pos = start + 1;
|
||||
|
||||
// Earlier we checked !silent, but this implementation does not need it
|
||||
const token_so: Token = state.push('sup_open', 'sup', 1);
|
||||
token_so.markup = '^';
|
||||
|
||||
const token_t: Token = state.push('text', '', 0);
|
||||
token_t.content = content.replace(UNESCAPE_RE, '$1');
|
||||
|
||||
const token_sc: Token = state.push('sup_close', 'sup', -1);
|
||||
token_sc.markup = '^';
|
||||
|
||||
state.pos = state.posMax + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-sup 插件
|
||||
* 用于支持上标语法 ^text^
|
||||
*/
|
||||
export default function sup_plugin(md: MarkdownIt): void {
|
||||
md.inline.ruler.after('emphasis', 'sup', superscript);
|
||||
}
|
||||
@@ -142,7 +142,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--voidraft-mono-font, monospace),serif;
|
||||
font-family: var(--voidraft-font-mono),serif;
|
||||
}
|
||||
|
||||
.loading-word {
|
||||
@@ -175,4 +175,4 @@ onBeforeUnmount(() => {
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -147,7 +147,7 @@ onUnmounted(() => {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.15s ease;
|
||||
gap: 8px;
|
||||
|
||||
@@ -165,7 +165,7 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
transition: color 0.15s ease;
|
||||
|
||||
.menu-item:hover & {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getActiveNoteBlock } from '@/views/editor/extensions/codeblock/state';
|
||||
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
|
||||
|
||||
const { t } = useI18n();
|
||||
const editorStore = readonly(useEditorStore());
|
||||
const editorStore = useEditorStore();
|
||||
|
||||
// 组件状态
|
||||
const showLanguageMenu = shallowRef(false);
|
||||
|
||||
@@ -13,20 +13,16 @@ import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
|
||||
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
|
||||
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
|
||||
import {usePanelStore} from '@/stores/panelStore';
|
||||
|
||||
const editorStore = readonly(useEditorStore());
|
||||
const configStore = readonly(useConfigStore());
|
||||
const updateStore = readonly(useUpdateStore());
|
||||
const windowStore = readonly(useWindowStore());
|
||||
const systemStore = readonly(useSystemStore());
|
||||
const panelStore = readonly(usePanelStore());
|
||||
const editorStore = useEditorStore();
|
||||
const configStore = useConfigStore();
|
||||
const updateStore = useUpdateStore();
|
||||
const windowStore = useWindowStore();
|
||||
const systemStore = useSystemStore();
|
||||
const {t} = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const canFormatCurrentBlock = ref(false);
|
||||
const canPreviewMarkdown = ref(false);
|
||||
const isLoaded = shallowRef(false);
|
||||
|
||||
const { documentStats } = toRefs(editorStore);
|
||||
@@ -37,10 +33,6 @@ const isCurrentWindowOnTop = computed(() => {
|
||||
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
|
||||
});
|
||||
|
||||
// 当前文档的预览是否打开
|
||||
const isCurrentBlockPreviewing = computed(() => {
|
||||
return panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing;
|
||||
});
|
||||
|
||||
// 切换窗口置顶状态
|
||||
const toggleAlwaysOnTop = async () => {
|
||||
@@ -69,22 +61,12 @@ const formatCurrentBlock = () => {
|
||||
formatBlockContent(editorStore.editorView);
|
||||
};
|
||||
|
||||
// 切换 Markdown 预览
|
||||
const { debouncedFn: debouncedTogglePreview } = createDebounce(() => {
|
||||
if (!canPreviewMarkdown.value || !editorStore.editorView) return;
|
||||
toggleMarkdownPreview(editorStore.editorView as any);
|
||||
}, { delay: 200 });
|
||||
|
||||
const togglePreview = () => {
|
||||
debouncedTogglePreview();
|
||||
};
|
||||
|
||||
// 统一更新按钮状态
|
||||
const updateButtonStates = () => {
|
||||
const view: any = editorStore.editorView;
|
||||
if (!view) {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,7 +77,6 @@ const updateButtonStates = () => {
|
||||
// 提前返回,减少不必要的计算
|
||||
if (!activeBlock) {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,11 +84,9 @@ const updateButtonStates = () => {
|
||||
const language = getLanguage(languageName as any);
|
||||
|
||||
canFormatCurrentBlock.value = Boolean(language?.prettier);
|
||||
canPreviewMarkdown.value = languageName.toLowerCase() === 'md';
|
||||
} catch (error) {
|
||||
console.warn('Error checking block capabilities:', error);
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -161,7 +140,6 @@ watch(
|
||||
cleanupListeners = setupEditorListeners(newView);
|
||||
} else {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -255,21 +233,6 @@ const statsData = computed(() => ({
|
||||
<!-- 块语言选择器 -->
|
||||
<BlockLanguageSelector/>
|
||||
|
||||
<!-- Markdown预览按钮 -->
|
||||
<div
|
||||
v-if="canPreviewMarkdown"
|
||||
class="preview-button"
|
||||
:class="{ 'active': isCurrentBlockPreviewing }"
|
||||
:title="isCurrentBlockPreviewing ? t('toolbar.closePreview') : t('toolbar.previewMarkdown')"
|
||||
@click="togglePreview"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 格式化按钮 - 支持点击操作 -->
|
||||
<div
|
||||
v-if="canFormatCurrentBlock"
|
||||
|
||||
@@ -161,53 +161,6 @@ export default {
|
||||
customThemeColors: 'Custom Theme Colors',
|
||||
resetToDefault: 'Reset to Default',
|
||||
colorValue: 'Color Value',
|
||||
themeColors: {
|
||||
basic: 'Basic Colors',
|
||||
text: 'Text Colors',
|
||||
syntax: 'Syntax Highlighting',
|
||||
interface: 'Interface Elements',
|
||||
border: 'Borders & Dividers',
|
||||
search: 'Search & Matching',
|
||||
// Base Colors
|
||||
background: 'Main Background',
|
||||
backgroundSecondary: 'Secondary Background',
|
||||
surface: 'Panel Background',
|
||||
dropdownBackground: 'Dropdown Background',
|
||||
dropdownBorder: 'Dropdown Border',
|
||||
// Text Colors
|
||||
foreground: 'Primary Text',
|
||||
foregroundSecondary: 'Secondary Text',
|
||||
comment: 'Comments',
|
||||
// Syntax Highlighting - Core
|
||||
keyword: 'Keywords',
|
||||
string: 'Strings',
|
||||
function: 'Functions',
|
||||
number: 'Numbers',
|
||||
operator: 'Operators',
|
||||
variable: 'Variables',
|
||||
type: 'Types',
|
||||
// Syntax Highlighting - Extended
|
||||
constant: 'Constants',
|
||||
storage: 'Storage Type',
|
||||
parameter: 'Parameters',
|
||||
class: 'Class Names',
|
||||
heading: 'Headings',
|
||||
invalid: 'Invalid/Error',
|
||||
regexp: 'Regular Expressions',
|
||||
// Interface Elements
|
||||
cursor: 'Cursor',
|
||||
selection: 'Selection Background',
|
||||
selectionBlur: 'Unfocused Selection',
|
||||
activeLine: 'Active Line Highlight',
|
||||
lineNumber: 'Line Numbers',
|
||||
activeLineNumber: 'Active Line Number',
|
||||
// Borders & Dividers
|
||||
borderColor: 'Border Color',
|
||||
borderLight: 'Light Border',
|
||||
// Search & Matching
|
||||
searchMatch: 'Search Match',
|
||||
matchingBracket: 'Matching Bracket'
|
||||
},
|
||||
lineHeight: 'Line Height',
|
||||
tabSettings: 'Tab Settings',
|
||||
tabSize: 'Tab Size',
|
||||
@@ -341,4 +294,4 @@ export default {
|
||||
memory: 'Memory',
|
||||
clickToClean: 'Click to clean memory'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -202,54 +202,6 @@ export default {
|
||||
customThemeColors: '自定义主题颜色',
|
||||
resetToDefault: '重置为默认',
|
||||
colorValue: '颜色值',
|
||||
themeColors: {
|
||||
basic: '基础色调',
|
||||
text: '文本颜色',
|
||||
syntax: '语法高亮',
|
||||
interface: '界面元素',
|
||||
border: '边框分割线',
|
||||
search: '搜索匹配',
|
||||
// 基础色调
|
||||
background: '主背景色',
|
||||
backgroundSecondary: '次要背景色',
|
||||
surface: '面板背景',
|
||||
dropdownBackground: '下拉菜单背景',
|
||||
dropdownBorder: '下拉菜单边框',
|
||||
// 文本颜色
|
||||
foreground: '主文本色',
|
||||
foregroundSecondary: '次要文本色',
|
||||
comment: '注释色',
|
||||
// 语法高亮 - 核心
|
||||
keyword: '关键字',
|
||||
string: '字符串',
|
||||
function: '函数名',
|
||||
number: '数字',
|
||||
operator: '操作符',
|
||||
variable: '变量',
|
||||
type: '类型',
|
||||
// 语法高亮 - 扩展
|
||||
constant: '常量',
|
||||
storage: '存储类型',
|
||||
parameter: '参数',
|
||||
class: '类名',
|
||||
heading: '标题',
|
||||
invalid: '无效内容',
|
||||
regexp: '正则表达式',
|
||||
// 界面元素
|
||||
cursor: '光标',
|
||||
selection: '选中背景',
|
||||
selectionBlur: '失焦选中背景',
|
||||
activeLine: '当前行高亮',
|
||||
lineNumber: '行号',
|
||||
activeLineNumber: '活动行号',
|
||||
// 边框和分割线
|
||||
borderColor: '边框色',
|
||||
borderLight: '浅色边框',
|
||||
// 搜索和匹配
|
||||
searchMatch: '搜索匹配',
|
||||
matchingBracket: '匹配括号'
|
||||
},
|
||||
|
||||
hotkeyPreview: '预览:',
|
||||
none: '无',
|
||||
backup: {
|
||||
@@ -344,4 +296,4 @@ export default {
|
||||
memory: '内存',
|
||||
clickToClean: '点击清理内存'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,29 +3,23 @@ import {computed, reactive} from 'vue';
|
||||
import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services';
|
||||
import {
|
||||
AppConfig,
|
||||
AppearanceConfig,
|
||||
AuthMethod,
|
||||
EditingConfig,
|
||||
GeneralConfig,
|
||||
GitBackupConfig,
|
||||
LanguageType,
|
||||
SystemThemeType,
|
||||
TabType,
|
||||
UpdatesConfig
|
||||
TabType
|
||||
} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {ConfigUtils} from '@/common/utils/configUtils';
|
||||
import {FONT_OPTIONS} from '@/common/constant/fonts';
|
||||
import {SUPPORTED_LOCALES} from '@/common/constant/locales';
|
||||
import {
|
||||
APPEARANCE_CONFIG_KEY_MAP,
|
||||
BACKUP_CONFIG_KEY_MAP,
|
||||
CONFIG_KEY_MAP,
|
||||
CONFIG_LIMITS,
|
||||
ConfigKey,
|
||||
ConfigSection,
|
||||
DEFAULT_CONFIG,
|
||||
EDITING_CONFIG_KEY_MAP,
|
||||
GENERAL_CONFIG_KEY_MAP,
|
||||
NumberConfigKey,
|
||||
UPDATES_CONFIG_KEY_MAP
|
||||
NumberConfigKey
|
||||
} from '@/common/constant/config';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
|
||||
@@ -42,86 +36,42 @@ export const useConfigStore = defineStore('config', () => {
|
||||
// Font options (no longer localized)
|
||||
const fontOptions = computed(() => FONT_OPTIONS);
|
||||
|
||||
// 计算属性 - 使用工厂函数简化
|
||||
// 计算属性
|
||||
const createLimitComputed = (key: NumberConfigKey) => computed(() => CONFIG_LIMITS[key]);
|
||||
const limits = Object.fromEntries(
|
||||
(['fontSize', 'tabSize', 'lineHeight'] as const).map(key => [key, createLimitComputed(key)])
|
||||
) as Record<NumberConfigKey, ReturnType<typeof createLimitComputed>>;
|
||||
|
||||
// 通用配置更新方法
|
||||
const updateGeneralConfig = async <K extends keyof GeneralConfig>(key: K, value: GeneralConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
// 统一配置更新方法
|
||||
const updateConfig = async <K extends ConfigKey>(key: K, value: any): Promise<void> => {
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = GENERAL_CONFIG_KEY_MAP[key];
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for general.${key.toString()}`);
|
||||
throw new Error(`No backend key mapping found for ${String(key)}`);
|
||||
}
|
||||
|
||||
// 从 backendKey 提取 section(例如 'general.alwaysOnTop' -> 'general')
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.general[key] = value;
|
||||
(state.config[section] as any)[key] = value;
|
||||
};
|
||||
|
||||
const updateEditingConfig = async <K extends keyof EditingConfig>(key: K, value: EditingConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = EDITING_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for editing.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.editing[key] = value;
|
||||
// 只更新本地状态,不保存到后端
|
||||
const updateConfigLocal = <K extends ConfigKey>(key: K, value: any): void => {
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
(state.config[section] as any)[key] = value;
|
||||
};
|
||||
|
||||
const updateAppearanceConfig = async <K extends keyof AppearanceConfig>(key: K, value: AppearanceConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = APPEARANCE_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for appearance.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.appearance[key] = value;
|
||||
};
|
||||
|
||||
const updateUpdatesConfig = async <K extends keyof UpdatesConfig>(key: K, value: UpdatesConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = UPDATES_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for updates.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.updates[key] = value;
|
||||
};
|
||||
|
||||
const updateBackupConfig = async <K extends keyof GitBackupConfig>(key: K, value: GitBackupConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = BACKUP_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for backup.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.backup[key] = value;
|
||||
// 保存指定配置到后端
|
||||
const saveConfig = async <K extends ConfigKey>(key: K): Promise<void> => {
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
await ConfigService.Set(backendKey, (state.config[section] as any)[key]);
|
||||
};
|
||||
|
||||
// 加载配置
|
||||
@@ -155,22 +105,24 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const clamp = (value: number) => ConfigUtils.clamp(value, limit.min, limit.max);
|
||||
|
||||
return {
|
||||
increase: async () => await updateEditingConfig(key, clamp(state.config.editing[key] + 1)),
|
||||
decrease: async () => await updateEditingConfig(key, clamp(state.config.editing[key] - 1)),
|
||||
set: async (value: number) => await updateEditingConfig(key, clamp(value)),
|
||||
reset: async () => await updateEditingConfig(key, limit.default)
|
||||
increase: async () => await updateConfig(key, clamp(state.config.editing[key] + 1)),
|
||||
decrease: async () => await updateConfig(key, clamp(state.config.editing[key] - 1)),
|
||||
set: async (value: number) => await updateConfig(key, clamp(value)),
|
||||
reset: async () => await updateConfig(key, limit.default),
|
||||
increaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] + 1)),
|
||||
decreaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] - 1))
|
||||
};
|
||||
};
|
||||
|
||||
const createEditingToggler = <T extends keyof EditingConfig>(key: T) =>
|
||||
async () => await updateEditingConfig(key, !state.config.editing[key] as EditingConfig[T]);
|
||||
async () => await updateConfig(key as ConfigKey, !state.config.editing[key] as EditingConfig[T]);
|
||||
|
||||
// 枚举值切换器
|
||||
const createEnumToggler = <T extends TabType>(key: 'tabType', values: readonly T[]) =>
|
||||
async () => {
|
||||
const currentIndex = values.indexOf(state.config.editing[key] as T);
|
||||
const nextIndex = (currentIndex + 1) % values.length;
|
||||
return await updateEditingConfig(key, values[nextIndex]);
|
||||
return await updateConfig(key, values[nextIndex]);
|
||||
};
|
||||
|
||||
// 重置配置
|
||||
@@ -192,21 +144,19 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
// 语言设置方法
|
||||
const setLanguage = async (language: LanguageType): Promise<void> => {
|
||||
await updateAppearanceConfig('language', language);
|
||||
|
||||
// 同步更新前端语言
|
||||
await updateConfig('language', language);
|
||||
const frontendLocale = ConfigUtils.backendLanguageToFrontend(language);
|
||||
locale.value = frontendLocale as any;
|
||||
};
|
||||
|
||||
// 系统主题设置方法
|
||||
const setSystemTheme = async (systemTheme: SystemThemeType): Promise<void> => {
|
||||
await updateAppearanceConfig('systemTheme', systemTheme);
|
||||
await updateConfig('systemTheme', systemTheme);
|
||||
};
|
||||
|
||||
// 当前主题设置方法
|
||||
const setCurrentTheme = async (themeName: string): Promise<void> => {
|
||||
await updateAppearanceConfig('currentTheme', themeName);
|
||||
await updateConfig('currentTheme', themeName);
|
||||
};
|
||||
|
||||
|
||||
@@ -238,21 +188,12 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const togglers = {
|
||||
tabIndent: createEditingToggler('enableTabIndent'),
|
||||
alwaysOnTop: async () => {
|
||||
await updateGeneralConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
||||
// 立即应用窗口置顶状态
|
||||
await updateConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
||||
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
|
||||
},
|
||||
tabType: createEnumToggler('tabType', CONFIG_LIMITS.tabType.values)
|
||||
};
|
||||
|
||||
// 字符串配置设置器
|
||||
const setters = {
|
||||
fontFamily: async (value: string) => await updateEditingConfig('fontFamily', value),
|
||||
fontWeight: async (value: string) => await updateEditingConfig('fontWeight', value),
|
||||
dataPath: async (value: string) => await updateGeneralConfig('dataPath', value),
|
||||
autoSaveDelay: async (value: number) => await updateEditingConfig('autoSaveDelay', value)
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
config: computed(() => state.config),
|
||||
@@ -281,10 +222,14 @@ export const useConfigStore = defineStore('config', () => {
|
||||
decreaseFontSize: adjusters.fontSize.decrease,
|
||||
resetFontSize: adjusters.fontSize.reset,
|
||||
setFontSize: adjusters.fontSize.set,
|
||||
// 字体大小操作
|
||||
increaseFontSizeLocal: adjusters.fontSize.increaseLocal,
|
||||
decreaseFontSizeLocal: adjusters.fontSize.decreaseLocal,
|
||||
saveFontSize: () => saveConfig('fontSize'),
|
||||
|
||||
// Tab操作
|
||||
toggleTabIndent: togglers.tabIndent,
|
||||
setEnableTabIndent: (value: boolean) => updateEditingConfig('enableTabIndent', value),
|
||||
setEnableTabIndent: (value: boolean) => updateConfig('enableTabIndent', value),
|
||||
...adjusters.tabSize,
|
||||
increaseTabSize: adjusters.tabSize.increase,
|
||||
decreaseTabSize: adjusters.tabSize.decrease,
|
||||
@@ -296,59 +241,53 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
// 窗口操作
|
||||
toggleAlwaysOnTop: togglers.alwaysOnTop,
|
||||
setAlwaysOnTop: (value: boolean) => updateGeneralConfig('alwaysOnTop', value),
|
||||
setAlwaysOnTop: (value: boolean) => updateConfig('alwaysOnTop', value),
|
||||
|
||||
// 字体操作
|
||||
setFontFamily: setters.fontFamily,
|
||||
setFontWeight: setters.fontWeight,
|
||||
setFontFamily: (value: string) => updateConfig('fontFamily', value),
|
||||
setFontWeight: (value: string) => updateConfig('fontWeight', value),
|
||||
|
||||
// 路径操作
|
||||
setDataPath: setters.dataPath,
|
||||
setDataPath: (value: string) => updateConfig('dataPath', value),
|
||||
|
||||
// 保存配置相关方法
|
||||
setAutoSaveDelay: setters.autoSaveDelay,
|
||||
setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value),
|
||||
|
||||
// 热键配置相关方法
|
||||
setEnableGlobalHotkey: (value: boolean) => updateGeneralConfig('enableGlobalHotkey', value),
|
||||
setGlobalHotkey: (hotkey: any) => updateGeneralConfig('globalHotkey', hotkey),
|
||||
setEnableGlobalHotkey: (value: boolean) => updateConfig('enableGlobalHotkey', value),
|
||||
setGlobalHotkey: (hotkey: any) => updateConfig('globalHotkey', hotkey),
|
||||
|
||||
// 系统托盘配置相关方法
|
||||
setEnableSystemTray: (value: boolean) => updateGeneralConfig('enableSystemTray', value),
|
||||
setEnableSystemTray: (value: boolean) => updateConfig('enableSystemTray', value),
|
||||
|
||||
// 开机启动配置相关方法
|
||||
setStartAtLogin: async (value: boolean) => {
|
||||
// 先更新配置文件
|
||||
await updateGeneralConfig('startAtLogin', value);
|
||||
// 再调用系统设置API
|
||||
await updateConfig('startAtLogin', value);
|
||||
await StartupService.SetEnabled(value);
|
||||
},
|
||||
|
||||
// 窗口吸附配置相关方法
|
||||
setEnableWindowSnap: async (value: boolean) => await updateGeneralConfig('enableWindowSnap', value),
|
||||
setEnableWindowSnap: (value: boolean) => updateConfig('enableWindowSnap', value),
|
||||
|
||||
// 加载动画配置相关方法
|
||||
setEnableLoadingAnimation: async (value: boolean) => await updateGeneralConfig('enableLoadingAnimation', value),
|
||||
setEnableLoadingAnimation: (value: boolean) => updateConfig('enableLoadingAnimation', value),
|
||||
|
||||
// 标签页配置相关方法
|
||||
setEnableTabs: async (value: boolean) => await updateGeneralConfig('enableTabs', value),
|
||||
setEnableTabs: (value: boolean) => updateConfig('enableTabs', value),
|
||||
|
||||
// 更新配置相关方法
|
||||
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value),
|
||||
setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value),
|
||||
|
||||
// 备份配置相关方法
|
||||
setEnableBackup: async (value: boolean) => {
|
||||
await updateBackupConfig('enabled', value);
|
||||
},
|
||||
setAutoBackup: async (value: boolean) => {
|
||||
await updateBackupConfig('auto_backup', value);
|
||||
},
|
||||
setRepoUrl: async (value: string) => await updateBackupConfig('repo_url', value),
|
||||
setAuthMethod: async (value: AuthMethod) => await updateBackupConfig('auth_method', value),
|
||||
setUsername: async (value: string) => await updateBackupConfig('username', value),
|
||||
setPassword: async (value: string) => await updateBackupConfig('password', value),
|
||||
setToken: async (value: string) => await updateBackupConfig('token', value),
|
||||
setSshKeyPath: async (value: string) => await updateBackupConfig('ssh_key_path', value),
|
||||
setSshKeyPassphrase: async (value: string) => await updateBackupConfig('ssh_key_passphrase', value),
|
||||
setBackupInterval: async (value: number) => await updateBackupConfig('backup_interval', value),
|
||||
setEnableBackup: (value: boolean) => updateConfig('enabled', value),
|
||||
setAutoBackup: (value: boolean) => updateConfig('auto_backup', value),
|
||||
setRepoUrl: (value: string) => updateConfig('repo_url', value),
|
||||
setAuthMethod: (value: AuthMethod) => updateConfig('auth_method', value),
|
||||
setUsername: (value: string) => updateConfig('username', value),
|
||||
setPassword: (value: string) => updateConfig('password', value),
|
||||
setToken: (value: string) => updateConfig('token', value),
|
||||
setSshKeyPath: (value: string) => updateConfig('ssh_key_path', value),
|
||||
setSshKeyPassphrase: (value: string) => updateConfig('ssh_key_passphrase', value),
|
||||
setBackupInterval: (value: number) => updateConfig('backup_interval', value),
|
||||
};
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import {EditorView} from '@codemirror/view';
|
||||
import {EditorState, Extension} from '@codemirror/state';
|
||||
import {useConfigStore} from './configStore';
|
||||
import {useDocumentStore} from './documentStore';
|
||||
import {usePanelStore} from './panelStore';
|
||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
|
||||
import {ensureSyntaxTree} from "@codemirror/language";
|
||||
@@ -14,6 +13,7 @@ import {getTabExtensions, updateTabConfig} from '@/views/editor/basic/tabExtensi
|
||||
import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/basic/fontExtension';
|
||||
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
|
||||
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
|
||||
import {createWheelZoomExtension} from '@/views/editor/basic/wheelZoomExtension';
|
||||
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
|
||||
import {
|
||||
createDynamicExtensions,
|
||||
@@ -29,8 +29,8 @@ import {generateContentHash} from "@/common/utils/hashUtils";
|
||||
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
||||
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
||||
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
||||
import {markdownPreviewExtension} from "@/views/editor/extensions/markdownPreview";
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
import markdownExtensions from "@/views/editor/extensions/markdown";
|
||||
|
||||
export interface DocumentStats {
|
||||
lines: number;
|
||||
@@ -242,6 +242,13 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
fontWeight: configStore.config.editing.fontWeight
|
||||
});
|
||||
|
||||
const wheelZoomExtension = createWheelZoomExtension({
|
||||
increaseFontSize: () => configStore.increaseFontSizeLocal(),
|
||||
decreaseFontSize: () => configStore.decreaseFontSizeLocal(),
|
||||
onSave: () => configStore.saveFontSize(),
|
||||
saveDelay: 500
|
||||
});
|
||||
|
||||
// 统计扩展
|
||||
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
||||
|
||||
@@ -256,8 +263,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
const httpExtension = createHttpClientExtension();
|
||||
|
||||
// Markdown预览扩展
|
||||
const previewExtension = markdownPreviewExtension();
|
||||
|
||||
// 再次检查操作有效性
|
||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||
@@ -287,12 +292,13 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
themeExtension,
|
||||
...tabExtensions,
|
||||
fontExtension,
|
||||
wheelZoomExtension,
|
||||
statsExtension,
|
||||
contentChangeExtension,
|
||||
codeBlockExtension,
|
||||
...dynamicExtensions,
|
||||
...httpExtension,
|
||||
previewExtension
|
||||
markdownExtensions
|
||||
];
|
||||
|
||||
// 创建编辑器状态
|
||||
@@ -635,6 +641,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// 应用Tab设置
|
||||
const applyTabSettings = () => {
|
||||
editorCache.values().forEach(instance => {
|
||||
@@ -687,10 +694,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
instance.view.destroy();
|
||||
});
|
||||
|
||||
// 清理 panelStore 状态(导航离开编辑器页面时)
|
||||
const panelStore = usePanelStore();
|
||||
panelStore.reset();
|
||||
|
||||
currentEditor.value = null;
|
||||
};
|
||||
|
||||
@@ -707,12 +710,15 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
// 更新前端编辑器扩展 - 应用于所有实例
|
||||
const manager = getExtensionManager();
|
||||
if (manager) {
|
||||
// 使用立即更新模式,跳过防抖
|
||||
manager.updateExtensionImmediate(id, enabled, config || {});
|
||||
// 直接更新前端扩展至所有视图
|
||||
manager.updateExtension(id, enabled, config);
|
||||
}
|
||||
|
||||
// 重新加载扩展配置
|
||||
await extensionStore.loadExtensions();
|
||||
if (manager) {
|
||||
manager.initExtensions(extensionStore.extensions);
|
||||
}
|
||||
|
||||
await applyKeymapSettings();
|
||||
};
|
||||
@@ -781,4 +787,4 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
editorView: currentEditor,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { useDocumentStore } from './documentStore';
|
||||
|
||||
/**
|
||||
* 单个文档的预览状态
|
||||
*/
|
||||
interface DocumentPreviewState {
|
||||
isOpen: boolean;
|
||||
isClosing: boolean;
|
||||
blockFrom: number;
|
||||
blockTo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板状态管理 Store
|
||||
* 管理编辑器中各种面板的显示状态(按文档ID区分)
|
||||
*/
|
||||
export const usePanelStore = defineStore('panel', () => {
|
||||
// 当前编辑器视图引用
|
||||
const editorView = ref<EditorView | null>(null);
|
||||
|
||||
// 每个文档的预览状态 Map<documentId, PreviewState>
|
||||
const documentPreviews = ref<Map<number, DocumentPreviewState>>(new Map());
|
||||
|
||||
/**
|
||||
* 获取当前文档的预览状态
|
||||
*/
|
||||
const markdownPreview = computed(() => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) {
|
||||
return {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
};
|
||||
}
|
||||
|
||||
return documentPreviews.value.get(currentDocId) || {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 设置编辑器视图
|
||||
*/
|
||||
const setEditorView = (view: EditorView | null) => {
|
||||
editorView.value = view;
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开 Markdown 预览面板
|
||||
*/
|
||||
const openMarkdownPreview = (from: number, to: number) => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
isOpen: true,
|
||||
isClosing: false,
|
||||
blockFrom: from,
|
||||
blockTo: to
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始关闭 Markdown 预览面板
|
||||
*/
|
||||
const startClosingMarkdownPreview = () => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
const state = documentPreviews.value.get(currentDocId);
|
||||
if (state?.isOpen) {
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
...state,
|
||||
isClosing: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭 Markdown 预览面板
|
||||
*/
|
||||
const closeMarkdownPreview = () => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新预览块的范围(用于实时预览)
|
||||
*/
|
||||
const updatePreviewRange = (from: number, to: number) => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
const state = documentPreviews.value.get(currentDocId);
|
||||
if (state?.isOpen) {
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
...state,
|
||||
blockFrom: from,
|
||||
blockTo: to
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查指定块是否正在预览
|
||||
*/
|
||||
const isBlockPreviewing = (from: number, to: number): boolean => {
|
||||
const preview = markdownPreview.value;
|
||||
return preview.isOpen &&
|
||||
preview.blockFrom === from &&
|
||||
preview.blockTo === to;
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置所有面板状态
|
||||
*/
|
||||
const reset = () => {
|
||||
documentPreviews.value.clear();
|
||||
editorView.value = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理指定文档的预览状态(文档关闭时调用)
|
||||
*/
|
||||
const clearDocumentPreview = (documentId: number) => {
|
||||
documentPreviews.value.delete(documentId);
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
editorView,
|
||||
markdownPreview,
|
||||
|
||||
// 方法
|
||||
setEditorView,
|
||||
openMarkdownPreview,
|
||||
startClosingMarkdownPreview,
|
||||
closeMarkdownPreview,
|
||||
updatePreviewRange,
|
||||
isBlockPreviewing,
|
||||
reset,
|
||||
clearDocumentPreview
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,195 +1,159 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import {SystemThemeType, ThemeType, Theme, ThemeColorConfig} from '@/../bindings/voidraft/internal/models/models';
|
||||
import { SystemThemeType, ThemeType, ThemeColorConfig } from '@/../bindings/voidraft/internal/models/models';
|
||||
import { ThemeService } from '@/../bindings/voidraft/internal/services';
|
||||
import { useConfigStore } from './configStore';
|
||||
import { useEditorStore } from './editorStore';
|
||||
import type { ThemeColors } from '@/views/editor/theme/types';
|
||||
import { cloneThemeColors, FALLBACK_THEME_NAME, themePresetList, themePresetMap } from '@/views/editor/theme/presets';
|
||||
|
||||
type ThemeOption = { name: string; type: ThemeType };
|
||||
|
||||
const resolveThemeName = (name?: string) =>
|
||||
name && themePresetMap[name] ? name : FALLBACK_THEME_NAME;
|
||||
|
||||
const createThemeOptions = (type: ThemeType): ThemeOption[] =>
|
||||
themePresetList
|
||||
.filter(preset => preset.type === type)
|
||||
.map(preset => ({ name: preset.name, type: preset.type }));
|
||||
|
||||
const darkThemeOptions = createThemeOptions(ThemeType.ThemeTypeDark);
|
||||
const lightThemeOptions = createThemeOptions(ThemeType.ThemeTypeLight);
|
||||
|
||||
const cloneColors = (colors: ThemeColorConfig): ThemeColors =>
|
||||
JSON.parse(JSON.stringify(colors)) as ThemeColors;
|
||||
|
||||
const getPresetColors = (name: string): ThemeColors => {
|
||||
const preset = themePresetMap[name] ?? themePresetMap[FALLBACK_THEME_NAME];
|
||||
const colors = cloneThemeColors(preset.colors);
|
||||
colors.themeName = name;
|
||||
return colors;
|
||||
};
|
||||
|
||||
const fetchThemeColors = async (themeName: string): Promise<ThemeColors> => {
|
||||
const safeName = resolveThemeName(themeName);
|
||||
try {
|
||||
const theme = await ThemeService.GetThemeByName(safeName);
|
||||
if (theme?.colors) {
|
||||
const colors = cloneColors(theme.colors);
|
||||
colors.themeName = safeName;
|
||||
return colors;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load theme override:', error);
|
||||
}
|
||||
return getPresetColors(safeName);
|
||||
};
|
||||
|
||||
/**
|
||||
* 主题管理 Store
|
||||
* 职责:管理主题状态、颜色配置和预设主题列表
|
||||
*/
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const configStore = useConfigStore();
|
||||
|
||||
// 所有主题列表
|
||||
const allThemes = ref<Theme[]>([]);
|
||||
|
||||
// 当前主题的颜色配置
|
||||
const currentColors = ref<ThemeColors | null>(null);
|
||||
|
||||
// 计算属性:当前系统主题模式
|
||||
const currentTheme = computed(() =>
|
||||
configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
||||
|
||||
const currentTheme = computed(
|
||||
() => configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
||||
);
|
||||
|
||||
// 计算属性:当前是否为深色模式
|
||||
const isDarkMode = computed(() =>
|
||||
currentTheme.value === SystemThemeType.SystemThemeDark ||
|
||||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const isDarkMode = computed(
|
||||
() =>
|
||||
currentTheme.value === SystemThemeType.SystemThemeDark ||
|
||||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
);
|
||||
|
||||
// 计算属性:根据类型获取主题列表
|
||||
const darkThemes = computed(() =>
|
||||
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeDark)
|
||||
);
|
||||
|
||||
const lightThemes = computed(() =>
|
||||
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeLight)
|
||||
);
|
||||
|
||||
// 计算属性:当前可用的主题列表
|
||||
const availableThemes = computed(() =>
|
||||
isDarkMode.value ? darkThemes.value : lightThemes.value
|
||||
const availableThemes = computed<ThemeOption[]>(() =>
|
||||
isDarkMode.value ? darkThemeOptions : lightThemeOptions
|
||||
);
|
||||
|
||||
// 应用主题到 DOM
|
||||
const applyThemeToDOM = (theme: SystemThemeType) => {
|
||||
const themeMap = {
|
||||
[SystemThemeType.SystemThemeAuto]: 'auto',
|
||||
[SystemThemeType.SystemThemeDark]: 'dark',
|
||||
[SystemThemeType.SystemThemeLight]: 'light'
|
||||
[SystemThemeType.SystemThemeLight]: 'light',
|
||||
};
|
||||
document.documentElement.setAttribute('data-theme', themeMap[theme]);
|
||||
};
|
||||
|
||||
// 从数据库加载所有主题
|
||||
const loadAllThemes = async () => {
|
||||
try {
|
||||
const themes = await ThemeService.GetAllThemes();
|
||||
allThemes.value = (themes || []).filter((t): t is Theme => t !== null);
|
||||
return allThemes.value;
|
||||
} catch (error) {
|
||||
console.error('Failed to load themes from database:', error);
|
||||
allThemes.value = [];
|
||||
return [];
|
||||
}
|
||||
const loadThemeColors = async (themeName?: string) => {
|
||||
const targetName = resolveThemeName(
|
||||
themeName || configStore.config?.appearance?.currentTheme
|
||||
);
|
||||
currentColors.value = await fetchThemeColors(targetName);
|
||||
};
|
||||
|
||||
// 初始化主题颜色
|
||||
const initializeThemeColors = async () => {
|
||||
// 加载所有主题
|
||||
await loadAllThemes();
|
||||
|
||||
// 从配置获取当前主题名称并加载
|
||||
const currentThemeName = configStore.config?.appearance?.currentTheme || 'default-dark';
|
||||
|
||||
const theme = allThemes.value.find(t => t.name === currentThemeName);
|
||||
|
||||
if (!theme) {
|
||||
console.error(`Theme not found: ${currentThemeName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 直接设置当前主题颜色
|
||||
currentColors.value = theme.colors as ThemeColors;
|
||||
};
|
||||
|
||||
// 初始化主题
|
||||
const initializeTheme = async () => {
|
||||
const theme = currentTheme.value;
|
||||
applyThemeToDOM(theme);
|
||||
await initializeThemeColors();
|
||||
applyThemeToDOM(currentTheme.value);
|
||||
await loadThemeColors();
|
||||
};
|
||||
|
||||
// 设置系统主题模式(深色/浅色/自动)
|
||||
const setTheme = async (theme: SystemThemeType) => {
|
||||
await configStore.setSystemTheme(theme);
|
||||
applyThemeToDOM(theme);
|
||||
refreshEditorTheme();
|
||||
};
|
||||
|
||||
// 切换到指定的预设主题
|
||||
|
||||
const switchToTheme = async (themeName: string) => {
|
||||
const theme = allThemes.value.find(t => t.name === themeName);
|
||||
if (!theme) {
|
||||
if (!themePresetMap[themeName]) {
|
||||
console.error('Theme not found:', themeName);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 直接设置当前主题颜色
|
||||
currentColors.value = theme.colors as ThemeColors;
|
||||
|
||||
// 持久化到配置
|
||||
await loadThemeColors(themeName);
|
||||
await configStore.setCurrentTheme(themeName);
|
||||
|
||||
// 刷新编辑器
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 更新当前主题的颜色配置
|
||||
|
||||
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
|
||||
if (!currentColors.value) return;
|
||||
Object.assign(currentColors.value, colors);
|
||||
};
|
||||
|
||||
// 保存当前主题颜色到数据库
|
||||
|
||||
const saveCurrentTheme = async () => {
|
||||
if (!currentColors.value) {
|
||||
throw new Error('No theme selected');
|
||||
}
|
||||
|
||||
const theme = allThemes.value.find(t => t.name === currentColors.value!.name);
|
||||
if (!theme) {
|
||||
throw new Error('Theme not found');
|
||||
}
|
||||
|
||||
await ThemeService.UpdateTheme(theme.id, currentColors.value as ThemeColorConfig);
|
||||
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||
currentColors.value.themeName = themeName;
|
||||
|
||||
await ThemeService.UpdateTheme(themeName, currentColors.value as unknown as ThemeColorConfig);
|
||||
|
||||
await loadThemeColors(themeName);
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 重置当前主题为预设配置
|
||||
|
||||
const resetCurrentTheme = async () => {
|
||||
if (!currentColors.value) {
|
||||
throw new Error('No theme selected');
|
||||
}
|
||||
|
||||
// 调用后端重置
|
||||
await ThemeService.ResetTheme(0, currentColors.value.name);
|
||||
|
||||
// 重新加载所有主题
|
||||
await loadAllThemes();
|
||||
|
||||
const updatedTheme = allThemes.value.find(t => t.name === currentColors.value!.name);
|
||||
|
||||
if (updatedTheme) {
|
||||
currentColors.value = updatedTheme.colors as ThemeColors;
|
||||
}
|
||||
|
||||
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||
await ThemeService.ResetTheme(themeName);
|
||||
|
||||
await loadThemeColors(themeName);
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 刷新编辑器主题
|
||||
|
||||
const refreshEditorTheme = () => {
|
||||
applyThemeToDOM(currentTheme.value);
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
editorStore?.applyThemeSettings();
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
allThemes,
|
||||
darkThemes,
|
||||
lightThemes,
|
||||
availableThemes,
|
||||
currentTheme,
|
||||
currentColors,
|
||||
isDarkMode,
|
||||
|
||||
// 方法
|
||||
setTheme,
|
||||
switchToTheme,
|
||||
initializeTheme,
|
||||
loadAllThemes,
|
||||
updateCurrentColors,
|
||||
saveCurrentTheme,
|
||||
resetCurrentTheme,
|
||||
refreshEditorTheme,
|
||||
applyThemeToDOM,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import {defineStore} from 'pinia';
|
||||
import {ref} from 'vue';
|
||||
import {TranslationService} from '@/../bindings/voidraft/internal/services';
|
||||
import {LanguageInfo, TRANSLATION_ERRORS, TranslationResult} from '@/common/constant/translation';
|
||||
/**
|
||||
* 翻译结果接口
|
||||
*/
|
||||
export interface TranslationResult {
|
||||
translatedText: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言信息接口
|
||||
*/
|
||||
export interface LanguageInfo {
|
||||
Code: string; // 语言代码
|
||||
Name: string; // 语言名称
|
||||
}
|
||||
/**
|
||||
* 翻译相关的错误消息
|
||||
*/
|
||||
export const TRANSLATION_ERRORS = {
|
||||
NO_TEXT: 'no text to translate',
|
||||
TRANSLATION_FAILED: 'translation failed',
|
||||
} as const;
|
||||
|
||||
export const useTranslationStore = defineStore('translation', () => {
|
||||
// 基础状态
|
||||
|
||||
@@ -3,11 +3,15 @@ import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
||||
import {useEditorStore} from '@/stores/editorStore';
|
||||
import {useDocumentStore} from '@/stores/documentStore';
|
||||
import {useConfigStore} from '@/stores/configStore';
|
||||
import {createWheelZoomHandler} from './basic/wheelZoomExtension';
|
||||
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
||||
import {useWindowStore} from "@/stores/windowStore";
|
||||
import {useWindowStore} from '@/stores/windowStore';
|
||||
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
||||
import {useTabStore} from "@/stores/tabStore";
|
||||
import {useTabStore} from '@/stores/tabStore';
|
||||
import ContextMenu from './contextMenu/ContextMenu.vue';
|
||||
import {contextMenuManager} from './contextMenu/manager';
|
||||
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
|
||||
import {translatorManager} from './extensions/translator/manager';
|
||||
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
const documentStore = useDocumentStore();
|
||||
@@ -19,47 +23,39 @@ const editorElement = ref<HTMLElement | null>(null);
|
||||
|
||||
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
|
||||
|
||||
// 创建滚轮缩放处理器
|
||||
const wheelHandler = createWheelZoomHandler(
|
||||
configStore.increaseFontSize,
|
||||
configStore.decreaseFontSize
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!editorElement.value) return;
|
||||
|
||||
// 从URL查询参数中获取documentId
|
||||
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
|
||||
|
||||
// 初始化文档存储,优先使用URL参数中的文档ID
|
||||
await documentStore.initialize(urlDocumentId);
|
||||
|
||||
// 设置编辑器容器
|
||||
editorStore.setEditorContainer(editorElement.value);
|
||||
|
||||
await tabStore.initializeTab();
|
||||
|
||||
// 添加滚轮事件监听
|
||||
editorElement.value.addEventListener('wheel', wheelHandler, {passive: false});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 移除滚轮事件监听
|
||||
if (editorElement.value) {
|
||||
editorElement.value.removeEventListener('wheel', wheelHandler);
|
||||
}
|
||||
editorStore.clearAllEditors();
|
||||
|
||||
contextMenuManager.destroy();
|
||||
translatorManager.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<div ref="editorElement" class="editor"></div>
|
||||
<Toolbar/>
|
||||
<!-- 加载动画 -->
|
||||
<transition name="loading-fade">
|
||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
||||
</transition>
|
||||
<!-- 编辑器区域 -->
|
||||
<div ref="editorElement" class="editor"></div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<Toolbar/>
|
||||
<!-- 右键菜单 -->
|
||||
<ContextMenu :portal-target="editorElement"/>
|
||||
<!-- 翻译器弹窗 -->
|
||||
<TranslatorDialog :portal-target="editorElement"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -74,8 +70,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +85,6 @@ onBeforeUnmount(() => {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 加载动画过渡效果
|
||||
.loading-fade-enter-active,
|
||||
.loading-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
@@ -98,4 +94,4 @@ onBeforeUnmount(() => {
|
||||
.loading-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import {EditorView, ViewPlugin, ViewUpdate} from '@codemirror/view';
|
||||
import type {Text} from '@codemirror/state';
|
||||
import {useEditorStore} from '@/stores/editorStore';
|
||||
|
||||
/**
|
||||
* 内容变化监听插件 - 集成文档和编辑器管理
|
||||
*/
|
||||
export function createContentChangePlugin() {
|
||||
return ViewPlugin.fromClass(
|
||||
class ContentChangePlugin {
|
||||
private editorStore = useEditorStore();
|
||||
private lastContent = '';
|
||||
private readonly editorStore = useEditorStore();
|
||||
private lastDoc: Text;
|
||||
private rafId: number | null = null;
|
||||
private pendingNotification = false;
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.lastContent = view.state.doc.toString();
|
||||
this.lastDoc = view.state.doc;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.docChanged) return;
|
||||
if (!update.docChanged || update.state.doc === this.lastDoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newContent = this.view.state.doc.toString();
|
||||
if (newContent === this.lastContent) return;
|
||||
|
||||
this.lastContent = newContent;
|
||||
|
||||
this.editorStore.onContentChange();
|
||||
|
||||
this.lastDoc = update.state.doc;
|
||||
this.scheduleNotification();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
this.pendingNotification = false;
|
||||
}
|
||||
|
||||
private scheduleNotification() {
|
||||
if (this.pendingNotification) return;
|
||||
|
||||
this.pendingNotification = true;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.pendingNotification = false;
|
||||
this.rafId = null;
|
||||
this.editorStore.onContentChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,61 @@
|
||||
// 处理滚轮缩放字体的事件处理函数
|
||||
export const createWheelZoomHandler = (
|
||||
increaseFontSize: () => void,
|
||||
decreaseFontSize: () => void
|
||||
) => {
|
||||
return (event: WheelEvent) => {
|
||||
// 检查是否按住了Ctrl键
|
||||
if (event.ctrlKey) {
|
||||
// 阻止默认行为(防止页面缩放)
|
||||
import {EditorView} from '@codemirror/view';
|
||||
import type {Extension} from '@codemirror/state';
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
|
||||
type FontAdjuster = () => void;
|
||||
type SaveCallback = () => Promise<void> | void;
|
||||
|
||||
export interface WheelZoomOptions {
|
||||
/** 增加字体大小的回调(立即执行) */
|
||||
increaseFontSize: FontAdjuster;
|
||||
/** 减少字体大小的回调(立即执行) */
|
||||
decreaseFontSize: FontAdjuster;
|
||||
/** 保存回调(防抖执行),在滚动结束后调用 */
|
||||
onSave?: SaveCallback;
|
||||
/** 保存防抖延迟(毫秒),默认 300ms */
|
||||
saveDelay?: number;
|
||||
}
|
||||
|
||||
export const createWheelZoomExtension = (options: WheelZoomOptions): Extension => {
|
||||
const {increaseFontSize, decreaseFontSize, onSave, saveDelay = 300} = options;
|
||||
|
||||
// 如果有 onSave 回调,创建防抖版本
|
||||
const {debouncedFn: debouncedSave} = onSave
|
||||
? createDebounce(() => {
|
||||
try {
|
||||
const result = onSave();
|
||||
if (result && typeof (result as Promise<void>).then === 'function') {
|
||||
(result as Promise<void>).catch((error) => {
|
||||
console.error('Failed to save font size:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save font size:', error);
|
||||
}
|
||||
}, {delay: saveDelay})
|
||||
: {debouncedFn: null};
|
||||
|
||||
return EditorView.domEventHandlers({
|
||||
wheel(event) {
|
||||
if (!event.ctrlKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// 根据滚轮方向增大或减小字体
|
||||
|
||||
// 立即更新字体大小
|
||||
if (event.deltaY < 0) {
|
||||
// 向上滚动,增大字体
|
||||
increaseFontSize();
|
||||
} else {
|
||||
// 向下滚动,减小字体
|
||||
} else if (event.deltaY > 0) {
|
||||
decreaseFontSize();
|
||||
}
|
||||
|
||||
// 防抖保存
|
||||
if (debouncedSave) {
|
||||
debouncedSave();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
181
frontend/src/views/editor/contextMenu/ContextMenu.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||
import { contextMenuManager } from './manager';
|
||||
import type { RenderMenuItem } from './menuSchema';
|
||||
|
||||
const props = defineProps<{
|
||||
portalTarget?: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
const menuState = contextMenuManager.useState();
|
||||
const menuRef = ref<HTMLDivElement | null>(null);
|
||||
const adjustedPosition = ref({ x: 0, y: 0 });
|
||||
|
||||
const isVisible = computed(() => menuState.value.visible);
|
||||
const items = computed(() => menuState.value.items);
|
||||
const position = computed(() => menuState.value.position);
|
||||
const teleportTarget = computed<HTMLElement | string>(() => props.portalTarget ?? 'body');
|
||||
|
||||
watch(
|
||||
position,
|
||||
(newPosition) => {
|
||||
adjustedPosition.value = { ...newPosition };
|
||||
if (isVisible.value) {
|
||||
nextTick(adjustMenuWithinViewport);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(isVisible, (visible) => {
|
||||
if (visible) {
|
||||
nextTick(adjustMenuWithinViewport);
|
||||
// 显示时添加 outside 点击监听
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
// 隐藏时移除监听
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
});
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${adjustedPosition.value.x}px`,
|
||||
top: `${adjustedPosition.value.y}px`
|
||||
}));
|
||||
|
||||
async function adjustMenuWithinViewport() {
|
||||
await nextTick();
|
||||
const menuEl = menuRef.value;
|
||||
if (!menuEl) return;
|
||||
|
||||
const rect = menuEl.getBoundingClientRect();
|
||||
let nextX = adjustedPosition.value.x;
|
||||
let nextY = adjustedPosition.value.y;
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
nextX = Math.max(0, window.innerWidth - rect.width - 8);
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
nextY = Math.max(0, window.innerHeight - rect.height - 8);
|
||||
}
|
||||
|
||||
adjustedPosition.value = { x: nextX, y: nextY };
|
||||
}
|
||||
|
||||
function handleItemClick(item: RenderMenuItem) {
|
||||
if (item.type !== "action" || item.disabled) {
|
||||
return;
|
||||
}
|
||||
contextMenuManager.runCommand(item);
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
// 如果点击在菜单内部,不关闭
|
||||
if (menuRef.value?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
contextMenuManager.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="teleportTarget">
|
||||
<template v-if="isVisible">
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="cm-context-menu show"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<template v-for="item in items" :key="item.id">
|
||||
<div v-if="item.type === 'separator'" class="cm-context-menu-divider" />
|
||||
<div
|
||||
v-else
|
||||
class="cm-context-menu-item"
|
||||
:class="{ 'is-disabled': item.disabled }"
|
||||
role="menuitem"
|
||||
:aria-disabled="item.disabled ? 'true' : 'false'"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<div class="cm-context-menu-item-label">
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<span v-if="item.shortcut" class="cm-context-menu-item-shortcut">
|
||||
{{ item.shortcut }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cm-context-menu {
|
||||
position: fixed;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
padding: 4px 0;
|
||||
border-radius: 3px;
|
||||
background-color: var(--settings-card-bg, #1c1c1e);
|
||||
color: var(--settings-text, #f6f6f6);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
transform-origin: top left;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.cm-context-menu.show {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.cm-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cm-context-menu-item:hover {
|
||||
background-color: var(--toolbar-button-hover);
|
||||
color: var(--toolbar-text, #ffffff);
|
||||
}
|
||||
|
||||
.cm-context-menu-item.is-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-shortcut {
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.cm-context-menu-divider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* 编辑器上下文菜单样式
|
||||
* 支持系统主题自动适配
|
||||
*/
|
||||
|
||||
.cm-context-menu {
|
||||
position: fixed;
|
||||
background-color: var(--settings-card-bg);
|
||||
color: var(--settings-text);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
/* 优化阴影效果,只在右下角显示自然的阴影 */
|
||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
|
||||
overflow: visible; /* 确保子菜单可以显示在外部 */
|
||||
}
|
||||
|
||||
.cm-context-menu-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
transition: all 0.1s ease;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cm-context-menu-item:hover {
|
||||
background-color: var(--toolbar-button-hover);
|
||||
color: var(--toolbar-text);
|
||||
}
|
||||
|
||||
.cm-context-menu-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-shortcut {
|
||||
opacity: 0.7;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--settings-input-bg);
|
||||
color: var(--settings-text-secondary);
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-ripple {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background-color: var(--selection-bg);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0.5;
|
||||
transform: scale(0);
|
||||
transition: transform 0.3s ease-out, opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 菜单分组标题样式 */
|
||||
.cm-context-menu-group-title {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 菜单分隔线样式 */
|
||||
.cm-context-menu-divider {
|
||||
height: 1px;
|
||||
background-color: var(--border-color);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* 子菜单样式 */
|
||||
.cm-context-submenu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-with-submenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-with-submenu::after {
|
||||
content: "›";
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
font-size: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cm-context-submenu {
|
||||
position: fixed; /* 改为fixed定位,避免受父元素影响 */
|
||||
min-width: 180px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(10px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
z-index: 10000;
|
||||
border-radius: 6px;
|
||||
background-color: var(--settings-card-bg);
|
||||
color: var(--settings-text);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 4px 0;
|
||||
/* 子菜单也使用相同的阴影效果 */
|
||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.cm-context-menu-item-with-submenu:hover .cm-context-submenu {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* 深色主题下的特殊样式 */
|
||||
:root[data-theme="dark"] .cm-context-menu {
|
||||
/* 深色主题下阴影更深,但仍然只在右下角 */
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cm-context-submenu {
|
||||
/* 深色主题下子菜单阴影 */
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cm-context-menu-divider {
|
||||
background-color: var(--dark-border-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 动画相关类 */
|
||||
.cm-context-menu.show {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.cm-context-menu.hide {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
/**
|
||||
* 上下文菜单视图实现
|
||||
*/
|
||||
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { MenuItem } from "../contextMenu";
|
||||
import "./contextMenu.css";
|
||||
|
||||
// 为Window对象添加cmSubmenus属性
|
||||
declare global {
|
||||
interface Window {
|
||||
cmSubmenus?: Map<string, HTMLElement>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单项元素池,用于复用DOM元素
|
||||
*/
|
||||
class MenuItemPool {
|
||||
private pool: HTMLElement[] = [];
|
||||
private maxPoolSize = 50; // 最大池大小
|
||||
|
||||
/**
|
||||
* 获取或创建菜单项元素
|
||||
*/
|
||||
get(): HTMLElement {
|
||||
if (this.pool.length > 0) {
|
||||
return this.pool.pop()!;
|
||||
}
|
||||
|
||||
const menuItem = document.createElement("div");
|
||||
menuItem.className = "cm-context-menu-item";
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回收菜单项元素
|
||||
*/
|
||||
release(element: HTMLElement): void {
|
||||
if (this.pool.length < this.maxPoolSize) {
|
||||
// 清理元素状态
|
||||
element.className = "cm-context-menu-item";
|
||||
element.innerHTML = "";
|
||||
element.style.cssText = "";
|
||||
|
||||
// 移除所有事件监听器(通过克隆节点)
|
||||
const cleanElement = element.cloneNode(false) as HTMLElement;
|
||||
this.pool.push(cleanElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空池
|
||||
*/
|
||||
clear(): void {
|
||||
this.pool.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文菜单管理器
|
||||
*/
|
||||
class ContextMenuManager {
|
||||
private static instance: ContextMenuManager;
|
||||
|
||||
private menuElement: HTMLElement | null = null;
|
||||
private submenuPool: Map<string, HTMLElement> = new Map();
|
||||
private menuItemPool = new MenuItemPool();
|
||||
private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private keyDownHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private currentView: EditorView | null = null;
|
||||
private activeSubmenus: Set<HTMLElement> = new Set();
|
||||
private ripplePool: HTMLElement[] = [];
|
||||
|
||||
// 事件委托处理器
|
||||
private menuClickHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private menuMouseHandler: ((e: MouseEvent) => void) | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.initializeEventHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
static getInstance(): ContextMenuManager {
|
||||
if (!ContextMenuManager.instance) {
|
||||
ContextMenuManager.instance = new ContextMenuManager();
|
||||
}
|
||||
return ContextMenuManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化事件处理器
|
||||
*/
|
||||
private initializeEventHandlers(): void {
|
||||
// 点击事件委托
|
||||
this.menuClickHandler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
|
||||
|
||||
if (menuItem && menuItem.dataset.command) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 添加点击动画
|
||||
this.addRippleEffect(menuItem, e);
|
||||
|
||||
// 执行命令
|
||||
const commandName = menuItem.dataset.command;
|
||||
const command = this.getCommandByName(commandName);
|
||||
if (command && this.currentView) {
|
||||
command(this.currentView);
|
||||
}
|
||||
|
||||
// 隐藏菜单
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标事件委托
|
||||
this.menuMouseHandler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
|
||||
|
||||
if (!menuItem) return;
|
||||
|
||||
if (e.type === 'mouseenter') {
|
||||
this.handleMenuItemMouseEnter(menuItem);
|
||||
} else if (e.type === 'mouseleave') {
|
||||
this.handleMenuItemMouseLeave(menuItem, e);
|
||||
}
|
||||
};
|
||||
|
||||
// 键盘事件处理器
|
||||
this.keyDownHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// 点击外部关闭处理器
|
||||
this.clickOutsideHandler = (e: MouseEvent) => {
|
||||
if (this.menuElement && !this.isClickInsideMenu(e.target as Node)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建主菜单元素
|
||||
*/
|
||||
private getOrCreateMenuElement(): HTMLElement {
|
||||
if (!this.menuElement) {
|
||||
this.menuElement = document.createElement("div");
|
||||
this.menuElement.className = "cm-context-menu";
|
||||
this.menuElement.style.display = "none";
|
||||
document.body.appendChild(this.menuElement);
|
||||
|
||||
// 阻止菜单内右键点击冒泡
|
||||
this.menuElement.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
|
||||
// 添加事件委托
|
||||
this.menuElement.addEventListener('click', this.menuClickHandler!);
|
||||
this.menuElement.addEventListener('mouseenter', this.menuMouseHandler!, true);
|
||||
this.menuElement.addEventListener('mouseleave', this.menuMouseHandler!, true);
|
||||
}
|
||||
return this.menuElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或获取子菜单元素
|
||||
*/
|
||||
private getOrCreateSubmenu(id: string): HTMLElement {
|
||||
if (!this.submenuPool.has(id)) {
|
||||
const submenu = document.createElement("div");
|
||||
submenu.className = "cm-context-menu cm-context-submenu";
|
||||
submenu.style.display = "none";
|
||||
document.body.appendChild(submenu);
|
||||
this.submenuPool.set(id, submenu);
|
||||
|
||||
// 阻止子菜单点击事件冒泡
|
||||
submenu.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// 添加事件委托
|
||||
submenu.addEventListener('click', this.menuClickHandler!);
|
||||
submenu.addEventListener('mouseenter', this.menuMouseHandler!, true);
|
||||
submenu.addEventListener('mouseleave', this.menuMouseHandler!, true);
|
||||
}
|
||||
return this.submenuPool.get(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建菜单项DOM元素
|
||||
*/
|
||||
private createMenuItemElement(item: MenuItem): HTMLElement {
|
||||
const menuItem = this.menuItemPool.get();
|
||||
|
||||
// 如果有子菜单,添加相应类
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
menuItem.classList.add("cm-context-menu-item-with-submenu");
|
||||
}
|
||||
|
||||
// 创建内容容器
|
||||
const contentContainer = document.createElement("div");
|
||||
contentContainer.className = "cm-context-menu-item-label";
|
||||
|
||||
// 标签文本
|
||||
const label = document.createElement("span");
|
||||
label.textContent = item.label;
|
||||
contentContainer.appendChild(label);
|
||||
menuItem.appendChild(contentContainer);
|
||||
|
||||
// 快捷键提示(如果有)
|
||||
if (item.shortcut) {
|
||||
const shortcut = document.createElement("span");
|
||||
shortcut.className = "cm-context-menu-item-shortcut";
|
||||
shortcut.textContent = item.shortcut;
|
||||
menuItem.appendChild(shortcut);
|
||||
}
|
||||
|
||||
// 存储命令信息用于事件委托
|
||||
if (item.command) {
|
||||
menuItem.dataset.command = this.registerCommand(item.command);
|
||||
}
|
||||
|
||||
// 处理子菜单
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
const submenuId = `submenu-${item.label.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
menuItem.dataset.submenuId = submenuId;
|
||||
|
||||
const submenu = this.getOrCreateSubmenu(submenuId);
|
||||
this.populateSubmenu(submenu, item.submenu);
|
||||
|
||||
// 记录子菜单
|
||||
if (!window.cmSubmenus) {
|
||||
window.cmSubmenus = new Map();
|
||||
}
|
||||
window.cmSubmenus.set(submenuId, submenu);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充子菜单内容
|
||||
*/
|
||||
private populateSubmenu(submenu: HTMLElement, items: MenuItem[]): void {
|
||||
// 清空现有内容
|
||||
while (submenu.firstChild) {
|
||||
submenu.removeChild(submenu.firstChild);
|
||||
}
|
||||
|
||||
// 添加子菜单项
|
||||
items.forEach(item => {
|
||||
const subMenuItemElement = this.createMenuItemElement(item);
|
||||
submenu.appendChild(subMenuItemElement);
|
||||
});
|
||||
|
||||
// 初始状态设置为隐藏
|
||||
submenu.style.opacity = '0';
|
||||
submenu.style.pointerEvents = 'none';
|
||||
submenu.style.visibility = 'hidden';
|
||||
submenu.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令注册和管理
|
||||
*/
|
||||
private commands: Map<string, (view: EditorView) => void> = new Map();
|
||||
private commandCounter = 0;
|
||||
|
||||
private registerCommand(command: (view: EditorView) => void): string {
|
||||
const commandId = `cmd_${this.commandCounter++}`;
|
||||
this.commands.set(commandId, command);
|
||||
return commandId;
|
||||
}
|
||||
|
||||
private getCommandByName(commandId: string): ((view: EditorView) => void) | undefined {
|
||||
return this.commands.get(commandId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单项鼠标进入事件
|
||||
*/
|
||||
private handleMenuItemMouseEnter(menuItem: HTMLElement): void {
|
||||
const submenuId = menuItem.dataset.submenuId;
|
||||
if (!submenuId) return;
|
||||
|
||||
const submenu = this.submenuPool.get(submenuId);
|
||||
if (!submenu) return;
|
||||
|
||||
const rect = menuItem.getBoundingClientRect();
|
||||
|
||||
// 计算子菜单位置
|
||||
submenu.style.left = `${rect.right}px`;
|
||||
submenu.style.top = `${rect.top}px`;
|
||||
|
||||
// 检查子菜单是否会超出屏幕
|
||||
requestAnimationFrame(() => {
|
||||
const submenuRect = submenu.getBoundingClientRect();
|
||||
if (submenuRect.right > window.innerWidth) {
|
||||
submenu.style.left = `${rect.left - submenuRect.width}px`;
|
||||
}
|
||||
|
||||
if (submenuRect.bottom > window.innerHeight) {
|
||||
const newTop = rect.top - (submenuRect.bottom - window.innerHeight);
|
||||
submenu.style.top = `${Math.max(0, newTop)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
// 显示子菜单
|
||||
submenu.style.opacity = '1';
|
||||
submenu.style.pointerEvents = 'auto';
|
||||
submenu.style.visibility = 'visible';
|
||||
submenu.style.transform = 'translateX(0)';
|
||||
|
||||
this.activeSubmenus.add(submenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单项鼠标离开事件
|
||||
*/
|
||||
private handleMenuItemMouseLeave(menuItem: HTMLElement, e: MouseEvent): void {
|
||||
const submenuId = menuItem.dataset.submenuId;
|
||||
if (!submenuId) return;
|
||||
|
||||
const submenu = this.submenuPool.get(submenuId);
|
||||
if (!submenu) return;
|
||||
|
||||
// 检查是否移动到子菜单上
|
||||
const toElement = e.relatedTarget as HTMLElement;
|
||||
if (submenu.contains(toElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideSubmenu(submenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏子菜单
|
||||
*/
|
||||
private hideSubmenu(submenu: HTMLElement): void {
|
||||
submenu.style.opacity = '0';
|
||||
submenu.style.pointerEvents = 'none';
|
||||
submenu.style.transform = 'translateX(10px)';
|
||||
|
||||
setTimeout(() => {
|
||||
if (submenu.style.opacity === '0') {
|
||||
submenu.style.visibility = 'hidden';
|
||||
}
|
||||
}, 200);
|
||||
|
||||
this.activeSubmenus.delete(submenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加点击波纹效果
|
||||
*/
|
||||
private addRippleEffect(menuItem: HTMLElement, e: MouseEvent): void {
|
||||
let ripple: HTMLElement;
|
||||
|
||||
if (this.ripplePool.length > 0) {
|
||||
ripple = this.ripplePool.pop()!;
|
||||
} else {
|
||||
ripple = document.createElement("div");
|
||||
ripple.className = "cm-context-menu-item-ripple";
|
||||
}
|
||||
|
||||
// 计算相对位置
|
||||
const rect = menuItem.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
ripple.style.left = (x - 50) + "px";
|
||||
ripple.style.top = (y - 50) + "px";
|
||||
ripple.style.transform = "scale(0)";
|
||||
ripple.style.opacity = "1";
|
||||
|
||||
menuItem.appendChild(ripple);
|
||||
|
||||
// 执行动画
|
||||
requestAnimationFrame(() => {
|
||||
ripple.style.transform = "scale(1)";
|
||||
ripple.style.opacity = "0";
|
||||
|
||||
setTimeout(() => {
|
||||
if (ripple.parentNode === menuItem) {
|
||||
menuItem.removeChild(ripple);
|
||||
this.ripplePool.push(ripple);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点击是否在菜单内
|
||||
*/
|
||||
private isClickInsideMenu(target: Node): boolean {
|
||||
if (this.menuElement && this.menuElement.contains(target)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否在子菜单内
|
||||
for (const submenu of this.activeSubmenus) {
|
||||
if (submenu.contains(target)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位菜单元素
|
||||
*/
|
||||
private positionMenu(menu: HTMLElement, clientX: number, clientY: number): void {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
let left = clientX;
|
||||
let top = clientY;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const menuWidth = menu.offsetWidth;
|
||||
const menuHeight = menu.offsetHeight;
|
||||
|
||||
if (left + menuWidth > windowWidth) {
|
||||
left = windowWidth - menuWidth - 5;
|
||||
}
|
||||
|
||||
if (top + menuHeight > windowHeight) {
|
||||
top = windowHeight - menuHeight - 5;
|
||||
}
|
||||
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示上下文菜单
|
||||
*/
|
||||
show(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
|
||||
this.currentView = view;
|
||||
|
||||
// 获取或创建菜单元素
|
||||
const menu = this.getOrCreateMenuElement();
|
||||
|
||||
// 隐藏所有子菜单
|
||||
this.hideAllSubmenus();
|
||||
|
||||
// 清空现有菜单项并回收到池中
|
||||
while (menu.firstChild) {
|
||||
const child = menu.firstChild as HTMLElement;
|
||||
if (child.classList.contains('cm-context-menu-item')) {
|
||||
this.menuItemPool.release(child);
|
||||
}
|
||||
menu.removeChild(child);
|
||||
}
|
||||
|
||||
// 清空命令注册
|
||||
this.commands.clear();
|
||||
this.commandCounter = 0;
|
||||
|
||||
// 添加主菜单项
|
||||
items.forEach(item => {
|
||||
const menuItemElement = this.createMenuItemElement(item);
|
||||
menu.appendChild(menuItemElement);
|
||||
});
|
||||
|
||||
// 显示菜单
|
||||
menu.style.display = "block";
|
||||
|
||||
// 定位菜单
|
||||
this.positionMenu(menu, clientX, clientY);
|
||||
|
||||
// 添加全局事件监听器
|
||||
document.addEventListener("click", this.clickOutsideHandler!, true);
|
||||
document.addEventListener("keydown", this.keyDownHandler!);
|
||||
|
||||
// 触发显示动画
|
||||
requestAnimationFrame(() => {
|
||||
if (menu) {
|
||||
menu.classList.add("show");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏所有子菜单
|
||||
*/
|
||||
private hideAllSubmenus(): void {
|
||||
this.activeSubmenus.forEach(submenu => {
|
||||
this.hideSubmenu(submenu);
|
||||
});
|
||||
this.activeSubmenus.clear();
|
||||
|
||||
if (window.cmSubmenus) {
|
||||
window.cmSubmenus.forEach((submenu) => {
|
||||
submenu.style.opacity = '0';
|
||||
submenu.style.pointerEvents = 'none';
|
||||
submenu.style.visibility = 'hidden';
|
||||
submenu.style.transform = 'translateX(10px)';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏上下文菜单
|
||||
*/
|
||||
hide(): void {
|
||||
// 隐藏所有子菜单
|
||||
this.hideAllSubmenus();
|
||||
|
||||
if (this.menuElement) {
|
||||
// 添加淡出动画
|
||||
this.menuElement.classList.remove("show");
|
||||
this.menuElement.classList.add("hide");
|
||||
|
||||
// 等待动画完成后隐藏
|
||||
setTimeout(() => {
|
||||
if (this.menuElement) {
|
||||
this.menuElement.style.display = "none";
|
||||
this.menuElement.classList.remove("hide");
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// 移除全局事件监听器
|
||||
if (this.clickOutsideHandler) {
|
||||
document.removeEventListener("click", this.clickOutsideHandler, true);
|
||||
}
|
||||
|
||||
if (this.keyDownHandler) {
|
||||
document.removeEventListener("keydown", this.keyDownHandler);
|
||||
}
|
||||
|
||||
this.currentView = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁管理器
|
||||
*/
|
||||
destroy(): void {
|
||||
this.hide();
|
||||
|
||||
if (this.menuElement) {
|
||||
document.body.removeChild(this.menuElement);
|
||||
this.menuElement = null;
|
||||
}
|
||||
|
||||
this.submenuPool.forEach(submenu => {
|
||||
if (submenu.parentNode) {
|
||||
document.body.removeChild(submenu);
|
||||
}
|
||||
});
|
||||
this.submenuPool.clear();
|
||||
|
||||
this.menuItemPool.clear();
|
||||
this.commands.clear();
|
||||
this.activeSubmenus.clear();
|
||||
this.ripplePool.length = 0;
|
||||
|
||||
if (window.cmSubmenus) {
|
||||
window.cmSubmenus.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单例实例
|
||||
const contextMenuManager = ContextMenuManager.getInstance();
|
||||
|
||||
/**
|
||||
* 显示上下文菜单
|
||||
*/
|
||||
export function showContextMenu(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
|
||||
contextMenuManager.show(view, clientX, clientY, items);
|
||||
}
|
||||
@@ -1,174 +1,141 @@
|
||||
/**
|
||||
* 编辑器上下文菜单实现
|
||||
* 提供基本的复制、剪切、粘贴等操作,支持动态快捷键显示
|
||||
*/
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { copyCommand, cutCommand, pasteCommand } from '../extensions/codeblock/copyPaste';
|
||||
import { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore';
|
||||
import { undo, redo } from '@codemirror/commands';
|
||||
import i18n from '@/i18n';
|
||||
import { useSystemStore } from '@/stores/systemStore';
|
||||
import { showContextMenu } from './manager';
|
||||
import {
|
||||
buildRegisteredMenu,
|
||||
createMenuContext,
|
||||
registerMenuNodes
|
||||
} from './menuSchema';
|
||||
import type { MenuSchemaNode } from './menuSchema';
|
||||
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Extension } from "@codemirror/state";
|
||||
import { copyCommand, cutCommand, pasteCommand } from "../extensions/codeblock/copyPaste";
|
||||
import { KeyBindingCommand } from "@/../bindings/voidraft/internal/models/models";
|
||||
import { useKeybindingStore } from "@/stores/keybindingStore";
|
||||
import {
|
||||
undo, redo
|
||||
} from "@codemirror/commands";
|
||||
import i18n from "@/i18n";
|
||||
import {useSystemStore} from "@/stores/systemStore";
|
||||
|
||||
/**
|
||||
* 菜单项类型定义
|
||||
*/
|
||||
export interface MenuItem {
|
||||
/** 菜单项显示文本 */
|
||||
label: string;
|
||||
|
||||
/** 点击时执行的命令 (如果有子菜单,可以为null) */
|
||||
command?: (view: EditorView) => boolean;
|
||||
|
||||
/** 快捷键提示文本 (可选) */
|
||||
shortcut?: string;
|
||||
|
||||
/** 子菜单项 (可选) */
|
||||
submenu?: MenuItem[];
|
||||
}
|
||||
|
||||
// 导入相关功能
|
||||
import { showContextMenu } from "./contextMenuView";
|
||||
|
||||
/**
|
||||
* 获取翻译文本
|
||||
* @param key 翻译键
|
||||
* @returns 翻译后的文本
|
||||
*/
|
||||
function t(key: string): string {
|
||||
return i18n.global.t(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取快捷键显示文本
|
||||
* @param command 命令ID
|
||||
* @returns 快捷键显示文本
|
||||
*/
|
||||
function getShortcutText(command: KeyBindingCommand): string {
|
||||
|
||||
function formatKeyBinding(keyBinding: string): string {
|
||||
const systemStore = useSystemStore();
|
||||
const isMac = systemStore.isMacOS;
|
||||
|
||||
return keyBinding
|
||||
.replace("Mod", isMac ? "Cmd" : "Ctrl")
|
||||
.replace("Shift", "Shift")
|
||||
.replace("Alt", isMac ? "Option" : "Alt")
|
||||
.replace("Ctrl", isMac ? "Ctrl" : "Ctrl")
|
||||
.replace(/-/g, " + ");
|
||||
}
|
||||
|
||||
const shortcutCache = new Map<KeyBindingCommand, string>();
|
||||
|
||||
|
||||
function getShortcutText(command?: KeyBindingCommand): string {
|
||||
if (command === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const cached = shortcutCache.get(command);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const keybindingStore = useKeybindingStore();
|
||||
|
||||
// 如果找到该命令的快捷键配置
|
||||
const binding = keybindingStore.keyBindings.find(kb =>
|
||||
kb.command === command && kb.enabled
|
||||
const binding = keybindingStore.keyBindings.find(
|
||||
(kb) => kb.command === command && kb.enabled
|
||||
);
|
||||
|
||||
if (binding && binding.key) {
|
||||
// 格式化快捷键显示
|
||||
return formatKeyBinding(binding.key);
|
||||
|
||||
if (binding?.key) {
|
||||
const formatted = formatKeyBinding(binding.key);
|
||||
shortcutCache.set(command, formatted);
|
||||
return formatted;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("An error occurred while getting the shortcut:", error);
|
||||
}
|
||||
|
||||
|
||||
shortcutCache.set(command, "");
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化快捷键显示
|
||||
* @param keyBinding 快捷键字符串
|
||||
* @returns 格式化后的显示文本
|
||||
*/
|
||||
function formatKeyBinding(keyBinding: string): string {
|
||||
// 获取系统信息
|
||||
const systemStore = useSystemStore();
|
||||
const isMac = systemStore.isMacOS;
|
||||
|
||||
// 替换修饰键名称为更友好的显示
|
||||
return keyBinding
|
||||
.replace("Mod", isMac ? "⌘" : "Ctrl")
|
||||
.replace("Shift", isMac ? "⇧" : "Shift")
|
||||
.replace("Alt", isMac ? "⌥" : "Alt")
|
||||
.replace("Ctrl", isMac ? "⌃" : "Ctrl")
|
||||
.replace(/-/g, " + ");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 创建编辑菜单项
|
||||
*/
|
||||
function createEditItems(): MenuItem[] {
|
||||
function getBuiltinMenuNodes(): MenuSchemaNode[] {
|
||||
return [
|
||||
{
|
||||
label: t("keybindings.commands.blockCopy"),
|
||||
id: "copy",
|
||||
labelKey: "keybindings.commands.blockCopy",
|
||||
command: copyCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockCopyCommand)
|
||||
shortcutCommand: KeyBindingCommand.BlockCopyCommand,
|
||||
enabled: (context) => context.hasSelection
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.blockCut"),
|
||||
id: "cut",
|
||||
labelKey: "keybindings.commands.blockCut",
|
||||
command: cutCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockCutCommand)
|
||||
shortcutCommand: KeyBindingCommand.BlockCutCommand,
|
||||
visible: (context) => context.isEditable,
|
||||
enabled: (context) => context.hasSelection && context.isEditable
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.blockPaste"),
|
||||
id: "paste",
|
||||
labelKey: "keybindings.commands.blockPaste",
|
||||
command: pasteCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockPasteCommand)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建历史操作菜单项
|
||||
*/
|
||||
function createHistoryItems(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: t("keybindings.commands.historyUndo"),
|
||||
command: undo,
|
||||
shortcut: getShortcutText(KeyBindingCommand.HistoryUndoCommand)
|
||||
shortcutCommand: KeyBindingCommand.BlockPasteCommand,
|
||||
visible: (context) => context.isEditable
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.historyRedo"),
|
||||
id: "undo",
|
||||
labelKey: "keybindings.commands.historyUndo",
|
||||
command: undo,
|
||||
shortcutCommand: KeyBindingCommand.HistoryUndoCommand,
|
||||
visible: (context) => context.isEditable
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
labelKey: "keybindings.commands.historyRedo",
|
||||
command: redo,
|
||||
shortcut: getShortcutText(KeyBindingCommand.HistoryRedoCommand)
|
||||
shortcutCommand: KeyBindingCommand.HistoryRedoCommand,
|
||||
visible: (context) => context.isEditable
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
let builtinMenuRegistered = false;
|
||||
|
||||
/**
|
||||
* 创建主菜单项
|
||||
*/
|
||||
function createMainMenuItems(): MenuItem[] {
|
||||
// 基本编辑操作放在主菜单
|
||||
const basicItems = createEditItems();
|
||||
|
||||
// 历史操作放在主菜单
|
||||
const historyItems = createHistoryItems();
|
||||
|
||||
// 构建主菜单
|
||||
return [
|
||||
...basicItems,
|
||||
...historyItems
|
||||
];
|
||||
function ensureBuiltinMenuRegistered(): void {
|
||||
if (builtinMenuRegistered) return;
|
||||
registerMenuNodes(getBuiltinMenuNodes());
|
||||
builtinMenuRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建编辑器上下文菜单
|
||||
*/
|
||||
|
||||
export function createEditorContextMenu(): Extension {
|
||||
// 为编辑器添加右键事件处理
|
||||
ensureBuiltinMenuRegistered();
|
||||
|
||||
return EditorView.domEventHandlers({
|
||||
contextmenu: (event, view) => {
|
||||
// 阻止默认右键菜单
|
||||
event.preventDefault();
|
||||
|
||||
// 获取菜单项
|
||||
const menuItems = createMainMenuItems();
|
||||
|
||||
// 显示上下文菜单
|
||||
|
||||
const context = createMenuContext(view, event as MouseEvent);
|
||||
const menuItems = buildRegisteredMenu(context, {
|
||||
translate: t,
|
||||
formatShortcut: getShortcutText
|
||||
});
|
||||
|
||||
if (menuItems.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
showContextMenu(view, event.clientX, event.clientY, menuItems);
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认导出
|
||||
*/
|
||||
export default createEditorContextMenu;
|
||||
export default createEditorContextMenu;
|
||||
|
||||
108
frontend/src/views/editor/contextMenu/manager.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { readonly, shallowRef, type ShallowRef } from 'vue';
|
||||
import type { RenderMenuItem } from './menuSchema';
|
||||
|
||||
interface MenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean;
|
||||
position: MenuPosition;
|
||||
items: RenderMenuItem[];
|
||||
view: EditorView | null;
|
||||
}
|
||||
|
||||
class ContextMenuManager {
|
||||
private state: ShallowRef<ContextMenuState> = shallowRef({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
items: [] as RenderMenuItem[],
|
||||
view: null as EditorView | null
|
||||
});
|
||||
|
||||
useState() {
|
||||
return readonly(this.state);
|
||||
}
|
||||
|
||||
show(view: EditorView, clientX: number, clientY: number, items: RenderMenuItem[]): void {
|
||||
const currentState = this.state.value;
|
||||
|
||||
// 如果菜单已经显示,且位置很接近(20px范围内),则只更新内容,避免闪烁
|
||||
if (currentState.visible) {
|
||||
const dx = Math.abs(currentState.position.x - clientX);
|
||||
const dy = Math.abs(currentState.position.y - clientY);
|
||||
const isSamePosition = dx < 20 && dy < 20;
|
||||
|
||||
if (isSamePosition) {
|
||||
// 只更新items和view,保持visible状态和位置
|
||||
this.state.value = {
|
||||
...currentState,
|
||||
items,
|
||||
view
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 否则正常显示菜单
|
||||
this.state.value = {
|
||||
visible: true,
|
||||
position: { x: clientX, y: clientY },
|
||||
items,
|
||||
view
|
||||
};
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (!this.state.value.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousPosition = this.state.value.position;
|
||||
const view = this.state.value.view;
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: previousPosition,
|
||||
items: [],
|
||||
view: null
|
||||
};
|
||||
|
||||
if (view) {
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
|
||||
runCommand(item: RenderMenuItem): void {
|
||||
if (item.type !== "action" || item.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { view } = this.state.value;
|
||||
if (item.command && view) {
|
||||
item.command(view);
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
items: [],
|
||||
view: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const contextMenuManager = new ContextMenuManager();
|
||||
|
||||
export function showContextMenu(
|
||||
view: EditorView,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
items: RenderMenuItem[]
|
||||
): void {
|
||||
contextMenuManager.show(view, clientX, clientY, items);
|
||||
}
|
||||
102
frontend/src/views/editor/contextMenu/menuSchema.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import type { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
|
||||
|
||||
export interface MenuContext {
|
||||
view: EditorView;
|
||||
event: MouseEvent;
|
||||
hasSelection: boolean;
|
||||
selectionText: string;
|
||||
isEditable: boolean;
|
||||
}
|
||||
|
||||
export type MenuSchemaNode =
|
||||
| {
|
||||
id: string;
|
||||
type?: "action";
|
||||
labelKey: string;
|
||||
command?: (view: EditorView) => boolean;
|
||||
shortcutCommand?: KeyBindingCommand;
|
||||
visible?: (context: MenuContext) => boolean;
|
||||
enabled?: (context: MenuContext) => boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "separator";
|
||||
visible?: (context: MenuContext) => boolean;
|
||||
};
|
||||
|
||||
export interface RenderMenuItem {
|
||||
id: string;
|
||||
type: "action" | "separator";
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
command?: (view: EditorView) => boolean;
|
||||
}
|
||||
|
||||
interface MenuBuildOptions {
|
||||
translate: (key: string) => string;
|
||||
formatShortcut: (command?: KeyBindingCommand) => string;
|
||||
}
|
||||
|
||||
const menuRegistry: MenuSchemaNode[] = [];
|
||||
|
||||
export function createMenuContext(view: EditorView, event: MouseEvent): MenuContext {
|
||||
const { state } = view;
|
||||
const hasSelection = state.selection.ranges.some((range) => !range.empty);
|
||||
const selectionText = hasSelection
|
||||
? state.sliceDoc(state.selection.main.from, state.selection.main.to)
|
||||
: "";
|
||||
const isEditable = !state.facet(EditorState.readOnly);
|
||||
|
||||
return {
|
||||
view,
|
||||
event,
|
||||
hasSelection,
|
||||
selectionText,
|
||||
isEditable
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMenuNodes(nodes: MenuSchemaNode[]): void {
|
||||
menuRegistry.push(...nodes);
|
||||
}
|
||||
|
||||
export function buildRegisteredMenu(
|
||||
context: MenuContext,
|
||||
options: MenuBuildOptions
|
||||
): RenderMenuItem[] {
|
||||
return menuRegistry
|
||||
.map((node) => convertNode(node, context, options))
|
||||
.filter((item): item is RenderMenuItem => Boolean(item));
|
||||
}
|
||||
|
||||
function convertNode(
|
||||
node: MenuSchemaNode,
|
||||
context: MenuContext,
|
||||
options: MenuBuildOptions
|
||||
): RenderMenuItem | null {
|
||||
if (node.visible && !node.visible(context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.type === "separator") {
|
||||
return {
|
||||
id: node.id,
|
||||
type: "separator"
|
||||
};
|
||||
}
|
||||
|
||||
const disabled = node.enabled ? !node.enabled(context) : false;
|
||||
const shortcut = options.formatShortcut(node.shortcutCommand);
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: "action",
|
||||
label: options.translate(node.labelKey),
|
||||
shortcut: shortcut || undefined,
|
||||
disabled,
|
||||
command: node.command
|
||||
};
|
||||
}
|
||||
@@ -115,6 +115,10 @@ const atomicNoteBlock = ViewPlugin.fromClass(
|
||||
|
||||
/**
|
||||
* 块背景层 - 修复高度计算问题
|
||||
*
|
||||
* 使用 lineBlockAt 获取行坐标,而不是 coordsAtPos 获取字符坐标。
|
||||
* 这样即使某些字符被隐藏(如 heading 的 # 标记 fontSize: 0),
|
||||
* 行的坐标也不会受影响,边界线位置正确。
|
||||
*/
|
||||
const blockLayer = layer({
|
||||
above: false,
|
||||
@@ -135,14 +139,17 @@ const blockLayer = layer({
|
||||
return;
|
||||
}
|
||||
|
||||
// view.coordsAtPos 如果编辑器不可见则返回 null
|
||||
const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from))?.top;
|
||||
let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to))?.bottom;
|
||||
const fromPos = Math.max(block.content.from, view.visibleRanges[0].from);
|
||||
const toPos = Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to);
|
||||
|
||||
if (fromCoordsTop === undefined || toCoordsBottom === undefined) {
|
||||
idx++;
|
||||
return;
|
||||
}
|
||||
// 使用 lineBlockAt 获取行的坐标,不受字符样式(如 fontSize: 0)影响
|
||||
const fromLineBlock = view.lineBlockAt(fromPos);
|
||||
const toLineBlock = view.lineBlockAt(toPos);
|
||||
|
||||
// lineBlockAt 返回的 top 是相对于内容区域的偏移
|
||||
// 转换为视口坐标进行后续计算
|
||||
const fromCoordsTop = fromLineBlock.top + view.documentTop;
|
||||
let toCoordsBottom = toLineBlock.bottom + view.documentTop;
|
||||
|
||||
// 对最后一个块进行特殊处理,让它直接延伸到底部
|
||||
if (idx === blocks.length - 1) {
|
||||
@@ -151,7 +158,7 @@ const blockLayer = layer({
|
||||
|
||||
// 让最后一个块直接延伸到编辑器底部
|
||||
if (contentBottom < editorHeight) {
|
||||
const extraHeight = editorHeight - contentBottom-10;
|
||||
const extraHeight = editorHeight - contentBottom - 10;
|
||||
toCoordsBottom += extraHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
import {jsonLanguage} from "@codemirror/lang-json";
|
||||
import {pythonLanguage} from "@codemirror/lang-python";
|
||||
import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
|
||||
import {htmlLanguage} from "@codemirror/lang-html";
|
||||
import {html, htmlLanguage} from "@codemirror/lang-html";
|
||||
import {StandardSQL} from "@codemirror/lang-sql";
|
||||
import {markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {Subscript, Superscript, Table} from "@lezer/markdown";
|
||||
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
||||
import {Insert} from "@/views/editor/extensions/markdown/syntax/insert";
|
||||
import {Math} from "@/views/editor/extensions/markdown/syntax/math";
|
||||
import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote";
|
||||
import {javaLanguage} from "@codemirror/lang-java";
|
||||
import {phpLanguage} from "@codemirror/lang-php";
|
||||
import {cssLanguage} from "@codemirror/lang-css";
|
||||
@@ -22,9 +27,9 @@ import {wastLanguage} from "@codemirror/lang-wast";
|
||||
import {sassLanguage} from "@codemirror/lang-sass";
|
||||
import {lessLanguage} from "@codemirror/lang-less";
|
||||
import {angularLanguage} from "@codemirror/lang-angular";
|
||||
import { svelteLanguage } from "@replit/codemirror-lang-svelte";
|
||||
import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language";
|
||||
import { mermaidLanguage } from '@/views/editor/language/mermaid';
|
||||
import {svelteLanguage} from "@replit/codemirror-lang-svelte";
|
||||
import {httpLanguage} from "@/views/editor/extensions/httpclient/language/http-language";
|
||||
import {mermaidLanguage} from '@/views/editor/language/mermaid';
|
||||
import {StreamLanguage} from "@codemirror/language";
|
||||
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
||||
import {shell} from "@codemirror/legacy-modes/mode/shell";
|
||||
@@ -64,6 +69,7 @@ import dartPrettierPlugin from "@/common/prettier/plugins/dart";
|
||||
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
|
||||
import webPrettierPlugin from "@/common/prettier/plugins/web";
|
||||
import * as prettierPluginEstree from "prettier/plugins/estree";
|
||||
import {languages} from "@codemirror/language-data";
|
||||
|
||||
/**
|
||||
* 语言信息类
|
||||
@@ -110,7 +116,19 @@ export const LANGUAGES: LanguageInfo[] = [
|
||||
parser: "sql",
|
||||
plugins: [sqlPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("md", "Markdown", markdownLanguage.parser, ["md"], {
|
||||
new LanguageInfo("md", "Markdown", markdown({
|
||||
base: markdownLanguage,
|
||||
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table],
|
||||
completeHTMLTags: true,
|
||||
pasteURLAsLink: true,
|
||||
htmlTagLanguage: html({
|
||||
matchClosingTags: true,
|
||||
autoCloseTags: true
|
||||
}),
|
||||
addKeymap: true,
|
||||
codeLanguages: languages,
|
||||
|
||||
}).language.parser, ["md"], {
|
||||
parser: "markdown",
|
||||
plugins: [markdownPrettierPlugin]
|
||||
}),
|
||||
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
} from '@codemirror/view';
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import * as runtime from "@wailsio/runtime";
|
||||
import { getNoteBlockFromPos } from '../codeblock/state';
|
||||
const pathStr = `<svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor"><path d="M607.934444 417.856853c-6.179746-6.1777-12.766768-11.746532-19.554358-16.910135l-0.01228 0.011256c-6.986111-6.719028-16.47216-10.857279-26.930349-10.857279-21.464871 0-38.864146 17.400299-38.864146 38.864146 0 9.497305 3.411703 18.196431 9.071609 24.947182l-0.001023 0c0.001023 0.001023 0.00307 0.00307 0.005117 0.004093 2.718925 3.242857 5.953595 6.03853 9.585309 8.251941 3.664459 3.021823 7.261381 5.997598 10.624988 9.361205l3.203972 3.204995c40.279379 40.229237 28.254507 109.539812-12.024871 149.820214L371.157763 796.383956c-40.278355 40.229237-105.761766 40.229237-146.042167 0l-3.229554-3.231601c-40.281425-40.278355-40.281425-105.809861 0-145.991002l75.93546-75.909877c9.742898-7.733125 15.997346-19.668968 15.997346-33.072233 0-23.312962-18.898419-42.211381-42.211381-42.211381-8.797363 0-16.963347 2.693342-23.725354 7.297197-0.021489-0.045025-0.044002-0.088004-0.066515-0.134053l-0.809435 0.757247c-2.989077 2.148943-5.691629 4.669346-8.025791 7.510044l-78.913281 73.841775c-74.178443 74.229608-74.178443 195.632609 0 269.758863l3.203972 3.202948c74.178443 74.127278 195.529255 74.127278 269.707698 0l171.829484-171.880649c74.076112-74.17435 80.357166-191.184297 6.282077-265.311575L607.934444 417.856853z"></path><path d="M855.61957 165.804257l-3.203972-3.203972c-74.17742-74.178443-195.528232-74.178443-269.706675 0L410.87944 334.479911c-74.178443 74.178443-78.263481 181.296089-4.085038 255.522628l3.152806 3.104711c3.368724 3.367701 6.865361 6.54302 10.434653 9.588379 2.583848 2.885723 5.618974 5.355985 8.992815 7.309476 0.025583 0.020466 0.052189 0.041956 0.077771 0.062422l0.011256-0.010233c5.377474 3.092431 11.608386 4.870938 18.257829 4.870938 20.263509 0 36.68962-16.428158 36.68962-36.68962 0-5.719258-1.309832-11.132548-3.645017-15.95846l0 0c-4.850471-10.891048-13.930267-17.521049-20.210297-23.802102l-3.15383-3.102664c-40.278355-40.278355-24.982998-98.79612 15.295358-139.074476l171.930791-171.830507c40.179095-40.280402 105.685018-40.280402 145.965419 0l3.206018 3.152806c40.279379 40.281425 40.279379 105.838513 0 146.06775l-75.686796 75.737962c-10.296507 7.628748-16.97358 19.865443-16.97358 33.662681 0 23.12365 18.745946 41.87062 41.87062 41.87062 8.048303 0 15.563464-2.275833 21.944801-6.211469 0.048095 0.081864 0.093121 0.157589 0.141216 0.240477l1.173732-1.083681c3.616364-2.421142 6.828522-5.393847 9.529027-8.792247l79.766718-73.603345C929.798013 361.334535 929.798013 239.981676 855.61957 165.804257z"></path></svg>`;
|
||||
const defaultRegexp = /\b((?:https?|ftp):\/\/[^\s/$.?#].[^\s]*)\b/gi;
|
||||
const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/gi;
|
||||
|
||||
export interface HyperLinkState {
|
||||
at: number;
|
||||
@@ -54,18 +53,8 @@ function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptio
|
||||
const from = match.index;
|
||||
const to = from + match[0].length;
|
||||
|
||||
// 检查当前位置是否在 HTTP 代码块中
|
||||
const block = getNoteBlockFromPos(view.state, from);
|
||||
if (block && block.language.name === 'http') {
|
||||
// 如果在 HTTP 代码块中,跳过超链接装饰
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkMark = Decoration.mark({
|
||||
class: 'cm-hyper-link-text',
|
||||
attributes: {
|
||||
'data-url': match[0]
|
||||
}
|
||||
class: 'cm-hyper-link-text'
|
||||
});
|
||||
widgets.push(linkMark.range(from, to));
|
||||
|
||||
@@ -91,14 +80,7 @@ const linkDecorator = (
|
||||
) =>
|
||||
new MatchDecorator({
|
||||
regexp: regexp || defaultRegexp,
|
||||
decorate: (add, from, to, match, view) => {
|
||||
// 检查当前位置是否在 HTTP 代码块中
|
||||
const block = getNoteBlockFromPos(view.state, from);
|
||||
if (block && block.language.name === 'http') {
|
||||
// 如果在 HTTP 代码块中,跳过超链接装饰
|
||||
return;
|
||||
}
|
||||
|
||||
decorate: (add, from, to, match, _view) => {
|
||||
const url = match[0];
|
||||
let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
|
||||
if (matchData && matchData[url]) {
|
||||
@@ -109,10 +91,7 @@ const linkDecorator = (
|
||||
const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor });
|
||||
|
||||
add(from, to, Decoration.mark({
|
||||
class: 'cm-hyper-link-text cm-hyper-link-underline',
|
||||
attributes: {
|
||||
'data-url': urlStr
|
||||
}
|
||||
class: 'cm-hyper-link-text cm-hyper-link-underline'
|
||||
}));
|
||||
add(start, end, Decoration.widget({ widget: linkIcon, side: 1 }));
|
||||
},
|
||||
@@ -158,7 +137,7 @@ export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = t
|
||||
export const hyperLinkStyle = EditorView.baseTheme({
|
||||
'.cm-hyper-link-text': {
|
||||
color: '#0969da',
|
||||
cursor: 'pointer',
|
||||
cursor: 'text',
|
||||
transition: 'color 0.2s ease',
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: '#0969da',
|
||||
@@ -216,24 +195,19 @@ export const hyperLinkStyle = EditorView.baseTheme({
|
||||
});
|
||||
|
||||
export const hyperLinkClickHandler = EditorView.domEventHandlers({
|
||||
click: (event, view) => {
|
||||
const target = event.target as HTMLElement;
|
||||
let urlElement = target;
|
||||
|
||||
while (urlElement && !urlElement.hasAttribute('data-url')) {
|
||||
urlElement = urlElement.parentElement as HTMLElement;
|
||||
if (!urlElement || urlElement === document.body) break;
|
||||
}
|
||||
|
||||
if (urlElement && urlElement.hasAttribute('data-url')) {
|
||||
const url = urlElement.getAttribute('data-url');
|
||||
click: (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const iconElement = target?.closest?.('.cm-hyper-link-icon') as (HTMLElement | null);
|
||||
|
||||
if (iconElement && iconElement.hasAttribute('data-url')) {
|
||||
const url = iconElement.getAttribute('data-url');
|
||||
if (url) {
|
||||
runtime.Browser.OpenURL(url);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -242,4 +216,4 @@ export const hyperLink: Extension = [
|
||||
hyperLinkExtension(),
|
||||
hyperLinkStyle,
|
||||
hyperLinkClickHandler
|
||||
];
|
||||
];
|
||||
|
||||
41
frontend/src/views/editor/extensions/markdown/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { blockquote } from './plugins/blockquote';
|
||||
import { codeblock } from './plugins/code-block';
|
||||
import { headings } from './plugins/heading';
|
||||
import { hideMarks } from './plugins/hide-mark';
|
||||
import { image } from './plugins/image';
|
||||
import { links } from './plugins/link';
|
||||
import { lists } from './plugins/list';
|
||||
import { headingSlugField } from './state/heading-slug';
|
||||
import { emoji } from './plugins/emoji';
|
||||
import { horizontalRule } from './plugins/horizontal-rule';
|
||||
import { inlineCode } from './plugins/inline-code';
|
||||
import { subscriptSuperscript } from './plugins/subscript-superscript';
|
||||
import { highlight } from './plugins/highlight';
|
||||
import { insert } from './plugins/insert';
|
||||
import { math } from './plugins/math';
|
||||
import { footnote } from './plugins/footnote';
|
||||
|
||||
/**
|
||||
* markdown extensions
|
||||
*/
|
||||
export const markdownExtensions: Extension = [
|
||||
headingSlugField,
|
||||
blockquote(),
|
||||
codeblock(),
|
||||
headings(),
|
||||
hideMarks(),
|
||||
lists(),
|
||||
links(),
|
||||
image(),
|
||||
emoji(),
|
||||
horizontalRule(),
|
||||
inlineCode(),
|
||||
subscriptSuperscript(),
|
||||
highlight(),
|
||||
insert(),
|
||||
math(),
|
||||
footnote(),
|
||||
];
|
||||
|
||||
export default markdownExtensions;
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
/**
|
||||
* Blockquote plugin.
|
||||
*
|
||||
* Features:
|
||||
* - Decorates blockquote with left border
|
||||
* - Hides quote marks (>) when cursor is outside
|
||||
* - Supports nested blockquotes
|
||||
*/
|
||||
export function blockquote() {
|
||||
return [blockQuotePlugin, baseTheme];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build blockquote decorations.
|
||||
*/
|
||||
function buildBlockQuoteDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const processedLines = new Set<number>();
|
||||
|
||||
syntaxTree(view.state).iterate({
|
||||
enter(node) {
|
||||
if (node.type.name !== 'Blockquote') return;
|
||||
|
||||
const cursorInBlockquote = isCursorInRange(view.state, [node.from, node.to]);
|
||||
|
||||
// Only add decorations when cursor is outside the blockquote
|
||||
// This allows selection highlighting to be visible when editing
|
||||
if (!cursorInBlockquote) {
|
||||
// Add line decoration for each line in the blockquote
|
||||
const startLine = view.state.doc.lineAt(node.from).number;
|
||||
const endLine = view.state.doc.lineAt(node.to).number;
|
||||
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
if (!processedLines.has(i)) {
|
||||
processedLines.add(i);
|
||||
const line = view.state.doc.line(i);
|
||||
decorations.push(
|
||||
Decoration.line({ class: 'cm-blockquote' }).range(line.from)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide quote marks when cursor is outside
|
||||
const cursor = node.node.cursor();
|
||||
cursor.iterate((child) => {
|
||||
if (child.type.name === 'QuoteMark') {
|
||||
decorations.push(
|
||||
invisibleDecoration.range(child.from, child.to)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Don't recurse into nested blockquotes (handled by outer iteration)
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blockquote plugin class.
|
||||
*/
|
||||
class BlockQuotePlugin {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildBlockQuoteDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
||||
this.decorations = buildBlockQuoteDecorations(update.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for blockquotes.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-blockquote': {
|
||||
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
|
||||
color: 'var(--cm-blockquote-color, #666)'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { isCursorInRange } from '../util';
|
||||
|
||||
/** Code block node types in syntax tree */
|
||||
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
|
||||
|
||||
/** Copy button icon SVGs (size controlled by CSS) */
|
||||
const ICON_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
|
||||
const ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||
|
||||
/** Cache for code block metadata */
|
||||
interface CodeBlockData {
|
||||
from: number;
|
||||
to: number;
|
||||
language: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block extension with language label and copy button.
|
||||
*
|
||||
* Features:
|
||||
* - Adds background styling to code blocks
|
||||
* - Shows language label + copy button when language is specified
|
||||
* - Hides markers when cursor is outside block
|
||||
* - Optimized with viewport-only rendering
|
||||
*/
|
||||
export const codeblock = (): Extension => [codeBlockPlugin, baseTheme];
|
||||
|
||||
/**
|
||||
* Widget for displaying language label and copy button.
|
||||
* Handles click events directly on the button element.
|
||||
*/
|
||||
class CodeBlockInfoWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly data: CodeBlockData,
|
||||
readonly view: EditorView
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: CodeBlockInfoWidget): boolean {
|
||||
return other.data.from === this.data.from &&
|
||||
other.data.language === this.data.language;
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const container = document.createElement('span');
|
||||
container.className = 'cm-code-block-info';
|
||||
|
||||
// Only show language label if specified
|
||||
if (this.data.language) {
|
||||
const lang = document.createElement('span');
|
||||
lang.className = 'cm-code-block-lang';
|
||||
lang.textContent = this.data.language;
|
||||
container.append(lang);
|
||||
}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'cm-code-block-copy-btn';
|
||||
btn.title = 'Copy';
|
||||
btn.innerHTML = ICON_COPY;
|
||||
|
||||
// Direct click handler - more reliable than eventHandlers
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.handleCopy(btn);
|
||||
});
|
||||
|
||||
// Prevent mousedown from affecting editor
|
||||
btn.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
container.append(btn);
|
||||
return container;
|
||||
}
|
||||
|
||||
private handleCopy(btn: HTMLButtonElement): void {
|
||||
const content = getCodeContent(this.view, this.data.from, this.data.to);
|
||||
if (!content) return;
|
||||
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
btn.innerHTML = ICON_CHECK;
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = ICON_COPY;
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// Ignore events to prevent editor focus changes
|
||||
ignoreEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract language from code block node.
|
||||
*/
|
||||
function getLanguage(view: EditorView, node: any, offset: number): string | null {
|
||||
let lang: string | null = null;
|
||||
node.toTree().iterate({
|
||||
enter: ({ type, from, to }) => {
|
||||
if (type.name === 'CodeInfo') {
|
||||
lang = view.state.doc.sliceString(offset + from, offset + to).trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
return lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract code content (without fence markers).
|
||||
*/
|
||||
function getCodeContent(view: EditorView, from: number, to: number): string {
|
||||
const lines = view.state.doc.sliceString(from, to).split('\n');
|
||||
return lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build decorations for visible code blocks.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): { decorations: DecorationSet; blocks: Map<number, CodeBlockData> } {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const blocks = new Map<number, CodeBlockData>();
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
if (!CODE_BLOCK_TYPES.includes(type.name as any)) return;
|
||||
|
||||
const key = `${nodeFrom}:${nodeTo}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
const inBlock = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
if (inBlock) return;
|
||||
|
||||
const language = getLanguage(view, node, nodeFrom);
|
||||
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||
const endLine = view.state.doc.lineAt(nodeTo);
|
||||
|
||||
for (let num = startLine.number; num <= endLine.number; num++) {
|
||||
const line = view.state.doc.line(num);
|
||||
const pos: string[] = ['cm-codeblock'];
|
||||
if (num === startLine.number) pos.push('cm-codeblock-begin');
|
||||
if (num === endLine.number) pos.push('cm-codeblock-end');
|
||||
|
||||
decorations.push(
|
||||
Decoration.line({ class: pos.join(' ') }).range(line.from)
|
||||
);
|
||||
}
|
||||
|
||||
// Info widget with copy button (always show, language label only if specified)
|
||||
const content = getCodeContent(view, nodeFrom, nodeTo);
|
||||
const data: CodeBlockData = { from: nodeFrom, to: nodeTo, language, content };
|
||||
blocks.set(nodeFrom, data);
|
||||
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new CodeBlockInfoWidget(data, view),
|
||||
side: 1
|
||||
}).range(startLine.to)
|
||||
);
|
||||
|
||||
// Hide markers
|
||||
node.toTree().iterate({
|
||||
enter: ({ type: t, from: f, to: t2 }) => {
|
||||
if (t.name === 'CodeInfo' || t.name === 'CodeMark') {
|
||||
decorations.push(Decoration.replace({}).range(nodeFrom + f, nodeFrom + t2));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { decorations: Decoration.set(decorations, true), blocks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block plugin with optimized updates.
|
||||
*/
|
||||
class CodeBlockPluginClass {
|
||||
decorations: DecorationSet;
|
||||
blocks: Map<number, CodeBlockData>;
|
||||
private lastHead = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
const result = buildDecorations(view);
|
||||
this.decorations = result.decorations;
|
||||
this.blocks = result.blocks;
|
||||
this.lastHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
const { docChanged, viewportChanged, selectionSet } = update;
|
||||
|
||||
// Skip rebuild if cursor stayed on same line
|
||||
if (selectionSet && !docChanged && !viewportChanged) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
const oldLine = update.startState.doc.lineAt(this.lastHead).number;
|
||||
const newLine = update.state.doc.lineAt(newHead).number;
|
||||
|
||||
if (oldLine === newLine) {
|
||||
this.lastHead = newHead;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (docChanged || viewportChanged || selectionSet) {
|
||||
const result = buildDecorations(update.view);
|
||||
this.decorations = result.decorations;
|
||||
this.blocks = result.blocks;
|
||||
this.lastHead = update.state.selection.main.head;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for code blocks.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-codeblock': {
|
||||
backgroundColor: 'var(--cm-codeblock-bg)'
|
||||
},
|
||||
'.cm-codeblock-begin': {
|
||||
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderTopRightRadius: 'var(--cm-codeblock-radius)',
|
||||
position: 'relative',
|
||||
boxShadow: 'inset 0 1px 0 var(--text-primary)'
|
||||
},
|
||||
'.cm-codeblock-end': {
|
||||
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
|
||||
boxShadow: 'inset 0 -1px 0 var(--text-primary)'
|
||||
},
|
||||
'.cm-code-block-info': {
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5em',
|
||||
zIndex: '5',
|
||||
opacity: '0.5',
|
||||
transition: 'opacity 0.15s'
|
||||
},
|
||||
'.cm-code-block-info:hover': {
|
||||
opacity: '1'
|
||||
},
|
||||
'.cm-code-block-lang': {
|
||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||
textTransform: 'lowercase',
|
||||
userSelect: 'none'
|
||||
},
|
||||
'.cm-code-block-copy-btn': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0.15em',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
background: 'transparent',
|
||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||
cursor: 'pointer',
|
||||
opacity: '0.7',
|
||||
transition: 'opacity 0.15s, background 0.15s'
|
||||
},
|
||||
'.cm-code-block-copy-btn:hover': {
|
||||
opacity: '1',
|
||||
background: 'rgba(128, 128, 128, 0.2)'
|
||||
},
|
||||
'.cm-code-block-copy-btn svg': {
|
||||
width: '1em',
|
||||
height: '1em'
|
||||
}
|
||||
});
|
||||
181
frontend/src/views/editor/extensions/markdown/plugins/emoji.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange } from '../util';
|
||||
import { emojies } from '@/common/constant/emojies';
|
||||
|
||||
/**
|
||||
* Emoji plugin that converts :emoji_name: to actual emoji characters.
|
||||
*
|
||||
* Features:
|
||||
* - Detects emoji patterns like :smile:, :heart:, etc.
|
||||
* - Replaces them with actual emoji characters
|
||||
* - Shows the original text when cursor is nearby
|
||||
* - Uses RangeSetBuilder for optimal performance
|
||||
* - Supports 1900+ emojis from the comprehensive emoji dictionary
|
||||
*/
|
||||
export const emoji = (): Extension => [emojiPlugin, baseTheme];
|
||||
|
||||
/**
|
||||
* Emoji regex pattern for matching :emoji_name: syntax.
|
||||
*/
|
||||
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi;
|
||||
|
||||
/**
|
||||
* Emoji widget with optimized rendering.
|
||||
*/
|
||||
class EmojiWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly emoji: string,
|
||||
readonly name: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: EmojiWidget): boolean {
|
||||
return other.emoji === this.emoji;
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-emoji';
|
||||
span.textContent = this.emoji;
|
||||
span.title = `:${this.name}:`;
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match result for emoji patterns.
|
||||
*/
|
||||
interface EmojiMatch {
|
||||
from: number;
|
||||
to: number;
|
||||
name: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emoji matches in a text range.
|
||||
*/
|
||||
function findEmojiMatches(text: string, offset: number): EmojiMatch[] {
|
||||
const matches: EmojiMatch[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Reset regex state
|
||||
EMOJI_REGEX.lastIndex = 0;
|
||||
|
||||
while ((match = EMOJI_REGEX.exec(text)) !== null) {
|
||||
const name = match[1].toLowerCase();
|
||||
const emoji = emojies[name];
|
||||
|
||||
if (emoji) {
|
||||
matches.push({
|
||||
from: offset + match.index,
|
||||
to: offset + match.index + match[0].length,
|
||||
name,
|
||||
emoji
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build emoji decorations using RangeSetBuilder.
|
||||
*/
|
||||
function buildEmojiDecorations(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const doc = view.state.doc;
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
const text = doc.sliceString(from, to);
|
||||
const matches = findEmojiMatches(text, from);
|
||||
|
||||
for (const match of matches) {
|
||||
// Skip if cursor is in this range
|
||||
if (isCursorInRange(view.state, [match.from, match.to])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.add(
|
||||
match.from,
|
||||
match.to,
|
||||
Decoration.replace({
|
||||
widget: new EmojiWidget(match.emoji, match.name)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji plugin with optimized update detection.
|
||||
*/
|
||||
class EmojiPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildEmojiDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// Always rebuild on doc or viewport change
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildEmojiDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
// For selection changes, check if we moved significantly
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
|
||||
// Only rebuild if cursor moved to a different position
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildEmojiDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for emoji.
|
||||
* Inherits font size and line height from parent element.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-emoji': {
|
||||
verticalAlign: 'middle',
|
||||
cursor: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all available emoji names.
|
||||
*/
|
||||
export function getEmojiNames(): string[] {
|
||||
return Object.keys(emojies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji by name.
|
||||
*/
|
||||
export function getEmoji(name: string): string | undefined {
|
||||
return emojies[name.toLowerCase()];
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* Footnote plugin for CodeMirror.
|
||||
*
|
||||
* Features:
|
||||
* - Renders footnote references as superscript numbers/labels
|
||||
* - Renders inline footnotes as superscript numbers with embedded content
|
||||
* - Shows footnote content on hover (tooltip)
|
||||
* - Click to jump between reference and definition
|
||||
* - Hides syntax marks when cursor is outside
|
||||
*
|
||||
* Syntax (MultiMarkdown/PHP Markdown Extra):
|
||||
* - Reference: [^id] → renders as superscript
|
||||
* - Definition: [^id]: content
|
||||
* - Inline footnote: ^[content] → renders as superscript with embedded content
|
||||
*/
|
||||
|
||||
import { Extension, Range, StateField, EditorState } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate,
|
||||
WidgetType,
|
||||
hoverTooltip,
|
||||
Tooltip,
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Information about a footnote definition.
|
||||
*/
|
||||
interface FootnoteDefinition {
|
||||
/** The footnote identifier (e.g., "1", "note") */
|
||||
id: string;
|
||||
/** The content of the footnote */
|
||||
content: string;
|
||||
/** Start position in document */
|
||||
from: number;
|
||||
/** End position in document */
|
||||
to: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a footnote reference.
|
||||
*/
|
||||
interface FootnoteReference {
|
||||
/** The footnote identifier */
|
||||
id: string;
|
||||
/** Start position in document */
|
||||
from: number;
|
||||
/** End position in document */
|
||||
to: number;
|
||||
/** Numeric index (1-based, for display) */
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about an inline footnote.
|
||||
*/
|
||||
interface InlineFootnoteInfo {
|
||||
/** The content of the inline footnote */
|
||||
content: string;
|
||||
/** Start position in document */
|
||||
from: number;
|
||||
/** End position in document */
|
||||
to: number;
|
||||
/** Numeric index (1-based, for display) */
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collected footnote data from the document.
|
||||
* Uses Maps for O(1) lookup by position and id.
|
||||
*/
|
||||
interface FootnoteData {
|
||||
definitions: Map<string, FootnoteDefinition>;
|
||||
references: FootnoteReference[];
|
||||
inlineFootnotes: InlineFootnoteInfo[];
|
||||
// Index maps for O(1) lookup
|
||||
referencesByPos: Map<number, FootnoteReference>;
|
||||
inlineByPos: Map<number, InlineFootnoteInfo>;
|
||||
firstRefById: Map<string, FootnoteReference>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Footnote Collection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Collect all footnote definitions, references, and inline footnotes from the document.
|
||||
* Builds index maps for O(1) lookup during decoration and tooltip handling.
|
||||
*/
|
||||
function collectFootnotes(state: EditorState): FootnoteData {
|
||||
const definitions = new Map<string, FootnoteDefinition>();
|
||||
const references: FootnoteReference[] = [];
|
||||
const inlineFootnotes: InlineFootnoteInfo[] = [];
|
||||
// Index maps for fast lookup
|
||||
const referencesByPos = new Map<number, FootnoteReference>();
|
||||
const inlineByPos = new Map<number, InlineFootnoteInfo>();
|
||||
const firstRefById = new Map<string, FootnoteReference>();
|
||||
const seenIds = new Map<string, number>();
|
||||
let inlineIndex = 0;
|
||||
|
||||
syntaxTree(state).iterate({
|
||||
enter: ({ type, from, to, node }) => {
|
||||
if (type.name === 'FootnoteDefinition') {
|
||||
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||
const contentNode = node.getChild('FootnoteDefinitionContent');
|
||||
|
||||
if (labelNode) {
|
||||
const id = state.sliceDoc(labelNode.from, labelNode.to);
|
||||
const content = contentNode
|
||||
? state.sliceDoc(contentNode.from, contentNode.to).trim()
|
||||
: '';
|
||||
|
||||
definitions.set(id, { id, content, from, to });
|
||||
}
|
||||
} else if (type.name === 'FootnoteReference') {
|
||||
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||
|
||||
if (labelNode) {
|
||||
const id = state.sliceDoc(labelNode.from, labelNode.to);
|
||||
|
||||
if (!seenIds.has(id)) {
|
||||
seenIds.set(id, seenIds.size + 1);
|
||||
}
|
||||
|
||||
const ref: FootnoteReference = {
|
||||
id,
|
||||
from,
|
||||
to,
|
||||
index: seenIds.get(id)!,
|
||||
};
|
||||
|
||||
references.push(ref);
|
||||
referencesByPos.set(from, ref);
|
||||
|
||||
// Track first reference for each id
|
||||
if (!firstRefById.has(id)) {
|
||||
firstRefById.set(id, ref);
|
||||
}
|
||||
}
|
||||
} else if (type.name === 'InlineFootnote') {
|
||||
const contentNode = node.getChild('InlineFootnoteContent');
|
||||
|
||||
if (contentNode) {
|
||||
const content = state.sliceDoc(contentNode.from, contentNode.to).trim();
|
||||
inlineIndex++;
|
||||
|
||||
const info: InlineFootnoteInfo = {
|
||||
content,
|
||||
from,
|
||||
to,
|
||||
index: inlineIndex,
|
||||
};
|
||||
|
||||
inlineFootnotes.push(info);
|
||||
inlineByPos.set(from, info);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
definitions,
|
||||
references,
|
||||
inlineFootnotes,
|
||||
referencesByPos,
|
||||
inlineByPos,
|
||||
firstRefById,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State Field
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* State field to track footnote data across the document.
|
||||
* This allows efficient lookup for tooltips and navigation.
|
||||
*/
|
||||
export const footnoteDataField = StateField.define<FootnoteData>({
|
||||
create(state) {
|
||||
return collectFootnotes(state);
|
||||
},
|
||||
update(value, tr) {
|
||||
if (tr.docChanged) {
|
||||
return collectFootnotes(tr.state);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Widget
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Widget to display footnote reference as superscript.
|
||||
*/
|
||||
class FootnoteRefWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly index: number,
|
||||
readonly hasDefinition: boolean
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-footnote-ref';
|
||||
span.textContent = `[${this.index}]`;
|
||||
span.dataset.footnoteId = this.id;
|
||||
|
||||
if (!this.hasDefinition) {
|
||||
span.classList.add('cm-footnote-ref-undefined');
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
eq(other: FootnoteRefWidget): boolean {
|
||||
return this.id === other.id && this.index === other.index;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget to display inline footnote as superscript.
|
||||
*/
|
||||
class InlineFootnoteWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly content: string,
|
||||
readonly index: number
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-inline-footnote-ref';
|
||||
span.textContent = `[${this.index}]`;
|
||||
span.dataset.footnoteContent = this.content;
|
||||
span.dataset.footnoteIndex = String(this.index);
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
eq(other: InlineFootnoteWidget): boolean {
|
||||
return this.content === other.content && this.index === other.index;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget to display footnote definition label.
|
||||
*/
|
||||
class FootnoteDefLabelWidget extends WidgetType {
|
||||
constructor(readonly id: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-footnote-def-label';
|
||||
span.textContent = `[${this.id}]`;
|
||||
span.dataset.footnoteId = this.id;
|
||||
return span;
|
||||
}
|
||||
|
||||
eq(other: FootnoteDefLabelWidget): boolean {
|
||||
return this.id === other.id;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decorations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build decorations for footnote references and inline footnotes.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const data = view.state.field(footnoteDataField);
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
// Handle footnote references
|
||||
if (type.name === 'FootnoteReference') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||
const marks = node.getChildren('FootnoteReferenceMark');
|
||||
|
||||
if (!labelNode || marks.length < 2) return;
|
||||
|
||||
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||
const ref = data.referencesByPos.get(nodeFrom);
|
||||
|
||||
if (!cursorInRange && ref && ref.id === id) {
|
||||
// Hide the entire syntax and show widget
|
||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||
|
||||
// Add widget at the end
|
||||
const widget = new FootnoteRefWidget(
|
||||
id,
|
||||
ref.index,
|
||||
data.definitions.has(id)
|
||||
);
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget,
|
||||
side: 1,
|
||||
}).range(nodeTo)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle footnote definitions
|
||||
if (type.name === 'FootnoteDefinition') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const marks = node.getChildren('FootnoteDefinitionMark');
|
||||
const labelNode = node.getChild('FootnoteDefinitionLabel');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2 && labelNode) {
|
||||
const id = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||
|
||||
// Hide the entire [^id]: part
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[1].to));
|
||||
|
||||
// Add widget to show [id]
|
||||
const widget = new FootnoteDefLabelWidget(id);
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget,
|
||||
side: 1,
|
||||
}).range(marks[1].to)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle inline footnotes
|
||||
if (type.name === 'InlineFootnote') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const contentNode = node.getChild('InlineFootnoteContent');
|
||||
const marks = node.getChildren('InlineFootnoteMark');
|
||||
|
||||
if (!contentNode || marks.length < 2) return;
|
||||
|
||||
const inlineNote = data.inlineByPos.get(nodeFrom);
|
||||
|
||||
if (!cursorInRange && inlineNote) {
|
||||
// Hide the entire syntax and show widget
|
||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||
|
||||
// Add widget at the end
|
||||
const widget = new InlineFootnoteWidget(
|
||||
inlineNote.content,
|
||||
inlineNote.index
|
||||
);
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget,
|
||||
side: 1,
|
||||
}).range(nodeTo)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Class
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Footnote view plugin with optimized update detection.
|
||||
*/
|
||||
class FootnotePlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const footnotePlugin = ViewPlugin.fromClass(FootnotePlugin, {
|
||||
decorations: (v) => v.decorations,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Hover Tooltip
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hover tooltip that shows footnote content.
|
||||
*/
|
||||
const footnoteHoverTooltip = hoverTooltip(
|
||||
(view, pos): Tooltip | null => {
|
||||
const data = view.state.field(footnoteDataField);
|
||||
|
||||
// Check if hovering over a footnote reference widget
|
||||
const target = document.elementFromPoint(
|
||||
view.coordsAtPos(pos)?.left ?? 0,
|
||||
view.coordsAtPos(pos)?.top ?? 0
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (target?.classList.contains('cm-footnote-ref')) {
|
||||
const id = target.dataset.footnoteId;
|
||||
if (id) {
|
||||
const def = data.definitions.get(id);
|
||||
if (def) {
|
||||
return {
|
||||
pos,
|
||||
above: true,
|
||||
arrow: true,
|
||||
create: () => createTooltipDom(id, def.content),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if hovering over an inline footnote widget
|
||||
if (target?.classList.contains('cm-inline-footnote-ref')) {
|
||||
const content = target.dataset.footnoteContent;
|
||||
const index = target.dataset.footnoteIndex;
|
||||
if (content && index) {
|
||||
return {
|
||||
pos,
|
||||
above: true,
|
||||
arrow: true,
|
||||
create: () => createInlineTooltipDom(parseInt(index), content),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if position is within a footnote reference node
|
||||
let foundId: string | null = null;
|
||||
let foundPos: number = pos;
|
||||
let foundInlineContent: string | null = null;
|
||||
let foundInlineIndex: number | null = null;
|
||||
|
||||
syntaxTree(view.state).iterate({
|
||||
from: pos,
|
||||
to: pos,
|
||||
enter: ({ type, from, to, node }) => {
|
||||
if (type.name === 'FootnoteReference') {
|
||||
const labelNode = node.getChild('FootnoteReferenceLabel');
|
||||
if (labelNode && pos >= from && pos <= to) {
|
||||
foundId = view.state.sliceDoc(labelNode.from, labelNode.to);
|
||||
foundPos = to;
|
||||
}
|
||||
} else if (type.name === 'InlineFootnote') {
|
||||
const contentNode = node.getChild('InlineFootnoteContent');
|
||||
if (contentNode && pos >= from && pos <= to) {
|
||||
foundInlineContent = view.state.sliceDoc(contentNode.from, contentNode.to);
|
||||
const inlineNote = data.inlineByPos.get(from);
|
||||
if (inlineNote) {
|
||||
foundInlineIndex = inlineNote.index;
|
||||
}
|
||||
foundPos = to;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (foundId) {
|
||||
const def = data.definitions.get(foundId);
|
||||
if (def) {
|
||||
const tooltipId = foundId;
|
||||
const tooltipPos = foundPos;
|
||||
return {
|
||||
pos: tooltipPos,
|
||||
above: true,
|
||||
arrow: true,
|
||||
create: () => createTooltipDom(tooltipId, def.content),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (foundInlineContent && foundInlineIndex !== null) {
|
||||
const tooltipContent = foundInlineContent;
|
||||
const tooltipIndex = foundInlineIndex;
|
||||
const tooltipPos = foundPos;
|
||||
return {
|
||||
pos: tooltipPos,
|
||||
above: true,
|
||||
arrow: true,
|
||||
create: () => createInlineTooltipDom(tooltipIndex, tooltipContent),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
{ hoverTime: 300 }
|
||||
);
|
||||
|
||||
/**
|
||||
* Create tooltip DOM element for regular footnote.
|
||||
*/
|
||||
function createTooltipDom(id: string, content: string): { dom: HTMLElement } {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-footnote-tooltip';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'cm-footnote-tooltip-header';
|
||||
header.textContent = `[^${id}]`;
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'cm-footnote-tooltip-body';
|
||||
body.textContent = content || '(Empty footnote)';
|
||||
|
||||
dom.appendChild(header);
|
||||
dom.appendChild(body);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tooltip DOM element for inline footnote.
|
||||
*/
|
||||
function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-footnote-tooltip';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'cm-footnote-tooltip-header';
|
||||
header.textContent = `Inline Footnote [${index}]`;
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'cm-footnote-tooltip-body';
|
||||
body.textContent = content || '(Empty footnote)';
|
||||
|
||||
dom.appendChild(header);
|
||||
dom.appendChild(body);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Click Handler
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Click handler for footnote navigation.
|
||||
* Uses mousedown to intercept before editor moves cursor.
|
||||
* - Click on reference → jump to definition
|
||||
* - Click on definition label → jump to first reference
|
||||
*/
|
||||
const footnoteClickHandler = EditorView.domEventHandlers({
|
||||
mousedown(event, view) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Handle click on footnote reference widget
|
||||
if (target.classList.contains('cm-footnote-ref')) {
|
||||
const id = target.dataset.footnoteId;
|
||||
if (id) {
|
||||
const data = view.state.field(footnoteDataField);
|
||||
const def = data.definitions.get(id);
|
||||
if (def) {
|
||||
// Prevent default to stop cursor from moving to widget position
|
||||
event.preventDefault();
|
||||
// Use setTimeout to dispatch after mousedown completes
|
||||
setTimeout(() => {
|
||||
view.dispatch({
|
||||
selection: { anchor: def.from },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click on definition label
|
||||
if (target.classList.contains('cm-footnote-def-label')) {
|
||||
const pos = view.posAtDOM(target);
|
||||
if (pos !== null) {
|
||||
const data = view.state.field(footnoteDataField);
|
||||
|
||||
// Find which definition this belongs to
|
||||
for (const [id, def] of data.definitions) {
|
||||
if (pos >= def.from && pos <= def.to) {
|
||||
// O(1) lookup for first reference
|
||||
const firstRef = data.firstRefById.get(id);
|
||||
if (firstRef) {
|
||||
event.preventDefault();
|
||||
setTimeout(() => {
|
||||
view.dispatch({
|
||||
selection: { anchor: firstRef.from },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Theme
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base theme for footnotes.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
// Footnote reference (superscript)
|
||||
'.cm-footnote-ref': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '1em',
|
||||
height: '1.2em',
|
||||
padding: '0 0.25em',
|
||||
marginLeft: '1px',
|
||||
fontSize: '0.75em',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1',
|
||||
verticalAlign: 'super',
|
||||
color: 'var(--cm-footnote-color, #1a73e8)',
|
||||
backgroundColor: 'var(--cm-footnote-bg, rgba(26, 115, 232, 0.1))',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
'.cm-footnote-ref:hover': {
|
||||
color: 'var(--cm-footnote-hover-color, #1557b0)',
|
||||
backgroundColor: 'var(--cm-footnote-hover-bg, rgba(26, 115, 232, 0.2))',
|
||||
},
|
||||
'.cm-footnote-ref-undefined': {
|
||||
color: 'var(--cm-footnote-undefined-color, #d93025)',
|
||||
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))',
|
||||
},
|
||||
|
||||
// Inline footnote reference (superscript) - uses distinct color
|
||||
'.cm-inline-footnote-ref': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '1em',
|
||||
height: '1.2em',
|
||||
padding: '0 0.25em',
|
||||
marginLeft: '1px',
|
||||
fontSize: '0.75em',
|
||||
fontWeight: '500',
|
||||
lineHeight: '1',
|
||||
verticalAlign: 'super',
|
||||
color: 'var(--cm-inline-footnote-color, #e67e22)',
|
||||
backgroundColor: 'var(--cm-inline-footnote-bg, rgba(230, 126, 34, 0.1))',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
'.cm-inline-footnote-ref:hover': {
|
||||
color: 'var(--cm-inline-footnote-hover-color, #d35400)',
|
||||
backgroundColor: 'var(--cm-inline-footnote-hover-bg, rgba(230, 126, 34, 0.2))',
|
||||
},
|
||||
|
||||
// Footnote definition label
|
||||
'.cm-footnote-def-label': {
|
||||
color: 'var(--cm-footnote-def-color, #1a73e8)',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.cm-footnote-def-label:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
|
||||
// Tooltip
|
||||
'.cm-footnote-tooltip': {
|
||||
maxWidth: '400px',
|
||||
padding: '0',
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
'.cm-footnote-tooltip-header': {
|
||||
padding: '6px 12px',
|
||||
fontSize: '0.8em',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'monospace',
|
||||
color: 'var(--cm-footnote-color, #1a73e8)',
|
||||
backgroundColor: 'var(--bg-tertiary, rgba(0, 0, 0, 0.05))',
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
},
|
||||
'.cm-footnote-tooltip-body': {
|
||||
padding: '10px 12px',
|
||||
fontSize: '0.9em',
|
||||
lineHeight: '1.5',
|
||||
color: 'var(--text-primary)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
|
||||
// Tooltip animation
|
||||
'.cm-tooltip:has(.cm-footnote-tooltip)': {
|
||||
animation: 'cm-footnote-fade-in 0.15s ease-out',
|
||||
},
|
||||
'@keyframes cm-footnote-fade-in': {
|
||||
from: { opacity: '0', transform: 'translateY(4px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Export
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Footnote extension.
|
||||
*
|
||||
* Features:
|
||||
* - Parses footnote references [^id] and definitions [^id]: content
|
||||
* - Parses inline footnotes ^[content]
|
||||
* - Renders references and inline footnotes as superscript numbers
|
||||
* - Shows definition/content on hover
|
||||
* - Click to navigate between reference and definition
|
||||
*/
|
||||
export const footnote = (): Extension => [
|
||||
footnoteDataField,
|
||||
footnotePlugin,
|
||||
footnoteHoverTooltip,
|
||||
footnoteClickHandler,
|
||||
baseTheme,
|
||||
];
|
||||
|
||||
export default footnote;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState, StateField, Range } from '@codemirror/state';
|
||||
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
* Hidden mark decoration - uses visibility: hidden to hide content
|
||||
*/
|
||||
const hiddenMarkDecoration = Decoration.mark({
|
||||
class: 'cm-heading-mark-hidden'
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if selection overlaps with a range.
|
||||
*/
|
||||
function isSelectionInRange(state: EditorState, from: number, to: number): boolean {
|
||||
return state.selection.ranges.some(
|
||||
(range) => from <= range.to && to >= range.from
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build heading decorations.
|
||||
* Hides # marks when cursor is not on the heading line.
|
||||
*/
|
||||
function buildHeadingDecorations(state: EditorState): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
syntaxTree(state).iterate({
|
||||
enter(node) {
|
||||
// Skip if cursor is in this node's range
|
||||
if (isSelectionInRange(state, node.from, node.to)) return;
|
||||
|
||||
// Handle ATX headings (# Heading)
|
||||
if (node.type.name.startsWith('ATXHeading')) {
|
||||
const header = node.node.firstChild;
|
||||
if (header && header.type.name === 'HeaderMark') {
|
||||
const from = header.from;
|
||||
// Include the space after #
|
||||
const to = Math.min(header.to + 1, node.to);
|
||||
decorations.push(hiddenMarkDecoration.range(from, to));
|
||||
}
|
||||
}
|
||||
// Handle Setext headings (underline style)
|
||||
else if (node.type.name.startsWith('SetextHeading')) {
|
||||
// Hide the underline marks (=== or ---)
|
||||
const cursor = node.node.cursor();
|
||||
cursor.iterate((child) => {
|
||||
if (child.type.name === 'HeaderMark') {
|
||||
decorations.push(
|
||||
hiddenMarkDecoration.range(child.from, child.to)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading StateField - manages # mark visibility.
|
||||
*/
|
||||
const headingField = StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
return buildHeadingDecorations(state);
|
||||
},
|
||||
|
||||
update(deco, tr) {
|
||||
if (tr.docChanged || tr.selection) {
|
||||
return buildHeadingDecorations(tr.state);
|
||||
}
|
||||
return deco.map(tr.changes);
|
||||
},
|
||||
|
||||
provide: (f) => EditorView.decorations.from(f)
|
||||
});
|
||||
|
||||
/**
|
||||
* Theme for hidden heading marks.
|
||||
*
|
||||
* Uses fontSize: 0 to hide the # mark without leaving whitespace.
|
||||
* This works correctly now because blockLayer uses lineBlockAt()
|
||||
* which calculates coordinates based on the entire line, not
|
||||
* individual characters, so fontSize: 0 doesn't affect boundaries.
|
||||
*/
|
||||
const headingTheme = EditorView.baseTheme({
|
||||
'.cm-heading-mark-hidden': {
|
||||
fontSize: '0'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Headings plugin.
|
||||
*/
|
||||
export const headings = () => [headingField, headingTheme];
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { RangeSetBuilder } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { checkRangeOverlap, isCursorInRange } from '../util';
|
||||
|
||||
/**
|
||||
* Node types that contain markers as child elements.
|
||||
*/
|
||||
const TYPES_WITH_MARKS = new Set([
|
||||
'Emphasis',
|
||||
'StrongEmphasis',
|
||||
'InlineCode',
|
||||
'Strikethrough'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Node types that are markers themselves.
|
||||
*/
|
||||
const MARK_TYPES = new Set([
|
||||
'EmphasisMark',
|
||||
'CodeMark',
|
||||
'StrikethroughMark'
|
||||
]);
|
||||
|
||||
// Export for external use
|
||||
export const typesWithMarks = Array.from(TYPES_WITH_MARKS);
|
||||
export const markTypes = Array.from(MARK_TYPES);
|
||||
|
||||
/**
|
||||
* Build mark hiding decorations using RangeSetBuilder for optimal performance.
|
||||
*/
|
||||
function buildHideMarkDecorations(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const replaceDecoration = Decoration.replace({});
|
||||
|
||||
// Track processed ranges to avoid duplicate processing of nested marks
|
||||
let currentParentRange: [number, number] | null = null;
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
if (!TYPES_WITH_MARKS.has(type.name)) return;
|
||||
|
||||
// Skip if this is a nested element within a parent we're already processing
|
||||
if (currentParentRange && checkRangeOverlap([nodeFrom, nodeTo], currentParentRange)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update current parent range
|
||||
currentParentRange = [nodeFrom, nodeTo];
|
||||
|
||||
// Skip if cursor is in this range
|
||||
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
|
||||
|
||||
// Iterate through child marks
|
||||
const innerTree = node.toTree();
|
||||
innerTree.iterate({
|
||||
enter({ type: markType, from: markFrom, to: markTo }) {
|
||||
if (!MARK_TYPES.has(markType.name)) return;
|
||||
|
||||
// Add decoration to hide the mark
|
||||
builder.add(
|
||||
nodeFrom + markFrom,
|
||||
nodeFrom + markTo,
|
||||
replaceDecoration
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide marks plugin with optimized update detection.
|
||||
*
|
||||
* This plugin:
|
||||
* - Hides emphasis marks (*, **, ~~ etc.) when cursor is outside
|
||||
* - Uses RangeSetBuilder for efficient decoration construction
|
||||
* - Optimizes selection change detection
|
||||
*/
|
||||
class HideMarkPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionRanges: string = '';
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildHideMarkDecorations(view);
|
||||
this.lastSelectionRanges = this.serializeSelection(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// Always rebuild on doc or viewport change
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildHideMarkDecorations(update.view);
|
||||
this.lastSelectionRanges = this.serializeSelection(update.view);
|
||||
return;
|
||||
}
|
||||
|
||||
// For selection changes, check if selection actually changed positions
|
||||
if (update.selectionSet) {
|
||||
const newRanges = this.serializeSelection(update.view);
|
||||
if (newRanges !== this.lastSelectionRanges) {
|
||||
this.decorations = buildHideMarkDecorations(update.view);
|
||||
this.lastSelectionRanges = newRanges;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize selection ranges for comparison.
|
||||
*/
|
||||
private serializeSelection(view: EditorView): string {
|
||||
return view.state.selection.ranges
|
||||
.map(r => `${r.from}:${r.to}`)
|
||||
.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide marks plugin.
|
||||
*
|
||||
* This plugin:
|
||||
* - Hides marks when they are not in the editor selection
|
||||
* - Supports emphasis, strong, inline code, and strikethrough
|
||||
*/
|
||||
export const hideMarks = () => [
|
||||
ViewPlugin.fromClass(HideMarkPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
})
|
||||
];
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
/**
|
||||
* Highlight plugin using syntax tree.
|
||||
*
|
||||
* Uses the custom Highlight extension to detect:
|
||||
* - Highlight: ==text== → renders as highlighted text
|
||||
*
|
||||
* Examples:
|
||||
* - This is ==important== text → This is <mark>important</mark> text
|
||||
* - Please ==review this section== carefully
|
||||
*/
|
||||
export const highlight = (): Extension => [
|
||||
highlightPlugin,
|
||||
baseTheme
|
||||
];
|
||||
|
||||
/**
|
||||
* Build decorations for highlight using syntax tree.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
// Handle Highlight nodes
|
||||
if (type.name === 'Highlight') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Get the mark nodes (the == characters)
|
||||
const marks = node.getChildren('HighlightMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Hide the opening and closing == marks
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
||||
|
||||
// Apply highlight style to the content between marks
|
||||
const contentStart = marks[0].to;
|
||||
const contentEnd = marks[marks.length - 1].from;
|
||||
if (contentStart < contentEnd) {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-highlight'
|
||||
}).range(contentStart, contentEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin class with optimized update detection.
|
||||
*/
|
||||
class HighlightPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightPlugin = ViewPlugin.fromClass(
|
||||
HighlightPlugin,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Base theme for highlight.
|
||||
* Uses mark decoration with a subtle background color.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-highlight': {
|
||||
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
|
||||
borderRadius: '2px',
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Extension, StateField, EditorState, Range } from '@codemirror/state';
|
||||
import {
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange } from '../util';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
/**
|
||||
* Horizontal rule plugin that renders beautiful horizontal lines.
|
||||
*
|
||||
* This plugin:
|
||||
* - Replaces markdown horizontal rules (---, ***, ___) with styled <hr> elements
|
||||
* - Shows the original text when cursor is on the line
|
||||
* - Uses inline widget to avoid affecting block system boundaries
|
||||
*/
|
||||
export const horizontalRule = (): Extension => [
|
||||
horizontalRuleField,
|
||||
baseTheme
|
||||
];
|
||||
|
||||
/**
|
||||
* Widget to display a horizontal rule (inline version).
|
||||
*/
|
||||
class HorizontalRuleWidget extends WidgetType {
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-horizontal-rule-widget';
|
||||
|
||||
const hr = document.createElement('hr');
|
||||
hr.className = 'cm-horizontal-rule';
|
||||
span.appendChild(hr);
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
eq(_other: HorizontalRuleWidget) {
|
||||
return true;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build horizontal rule decorations.
|
||||
* Uses Decoration.replace WITHOUT block: true to avoid affecting block system.
|
||||
*/
|
||||
function buildHorizontalRuleDecorations(state: EditorState): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
syntaxTree(state).iterate({
|
||||
enter: ({ type, from, to }) => {
|
||||
if (type.name !== 'HorizontalRule') return;
|
||||
|
||||
// Skip if cursor is on this line
|
||||
if (isCursorInRange(state, [from, to])) return;
|
||||
|
||||
// Replace the entire horizontal rule with a styled widget
|
||||
// NOTE: NOT using block: true to avoid affecting codeblock boundaries
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new HorizontalRuleWidget()
|
||||
}).range(from, to)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* StateField for horizontal rule decorations.
|
||||
*/
|
||||
const horizontalRuleField = StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
return buildHorizontalRuleDecorations(state);
|
||||
},
|
||||
update(value, tx) {
|
||||
if (tx.docChanged || tx.selection) {
|
||||
return buildHorizontalRuleDecorations(tx.state);
|
||||
}
|
||||
return value.map(tx.changes);
|
||||
},
|
||||
provide(field) {
|
||||
return EditorView.decorations.from(field);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for horizontal rules.
|
||||
* Uses inline-block display to render properly without block: true.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-horizontal-rule-widget': {
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
verticalAlign: 'middle'
|
||||
},
|
||||
'.cm-horizontal-rule': {
|
||||
width: '100%',
|
||||
height: '0',
|
||||
border: 'none',
|
||||
borderTop: '2px solid var(--cm-hr-color, rgba(128, 128, 128, 0.3))',
|
||||
margin: '0.5em 0'
|
||||
}
|
||||
});
|
||||
208
frontend/src/views/editor/extensions/markdown/plugins/html.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState, Range } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { isCursorInRange } from '../util';
|
||||
|
||||
interface EmbedBlockData {
|
||||
from: number;
|
||||
to: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all HTML blocks from the document (both HTMLBlock and HTMLTag).
|
||||
* Returns all blocks regardless of cursor position.
|
||||
*/
|
||||
function extractAllHTMLBlocks(state: EditorState): EmbedBlockData[] {
|
||||
const blocks = new Array<EmbedBlockData>();
|
||||
syntaxTree(state).iterate({
|
||||
enter({ from, to, name }) {
|
||||
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
|
||||
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
|
||||
const html = state.sliceDoc(from, to);
|
||||
const content = DOMPurify.sanitize(html);
|
||||
|
||||
// Skip empty content after sanitization
|
||||
if (!content.trim()) return;
|
||||
|
||||
blocks.push({ from, to, content });
|
||||
}
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build decorations for HTML blocks.
|
||||
* Only shows preview for blocks where cursor is not inside.
|
||||
*/
|
||||
function buildDecorations(state: EditorState, blocks: EmbedBlockData[]): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
// Skip if cursor is in range
|
||||
if (isCursorInRange(state, [block.from, block.to])) continue;
|
||||
|
||||
// Hide the original HTML source code
|
||||
decorations.push(Decoration.replace({}).range(block.from, block.to));
|
||||
|
||||
// Add the preview widget at the end
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new HTMLBlockWidget(block),
|
||||
side: 1
|
||||
}).range(block.to)
|
||||
);
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if selection affects any HTML block (cursor moved in/out of a block).
|
||||
*/
|
||||
function selectionAffectsBlocks(
|
||||
state: EditorState,
|
||||
prevState: EditorState,
|
||||
blocks: EmbedBlockData[]
|
||||
): boolean {
|
||||
for (const block of blocks) {
|
||||
const wasInRange = isCursorInRange(prevState, [block.from, block.to]);
|
||||
const isInRange = isCursorInRange(state, [block.from, block.to]);
|
||||
if (wasInRange !== isInRange) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewPlugin for HTML block preview.
|
||||
* Uses smart caching to avoid unnecessary updates during text selection.
|
||||
*/
|
||||
class HTMLBlockPlugin {
|
||||
decorations: DecorationSet;
|
||||
blocks: EmbedBlockData[];
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.blocks = extractAllHTMLBlocks(view.state);
|
||||
this.decorations = buildDecorations(view.state, this.blocks);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// If document changed, re-extract all blocks
|
||||
if (update.docChanged) {
|
||||
this.blocks = extractAllHTMLBlocks(update.state);
|
||||
this.decorations = buildDecorations(update.state, this.blocks);
|
||||
return;
|
||||
}
|
||||
|
||||
// If selection changed, only rebuild if cursor moved in/out of a block
|
||||
if (update.selectionSet) {
|
||||
if (selectionAffectsBlocks(update.state, update.startState, this.blocks)) {
|
||||
this.decorations = buildDecorations(update.state, this.blocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
class HTMLBlockWidget extends WidgetType {
|
||||
constructor(public data: EmbedBlockData) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.className = 'cm-html-block-widget';
|
||||
|
||||
// Content container
|
||||
const content = document.createElement('span');
|
||||
content.className = 'cm-html-block-content';
|
||||
// This is sanitized!
|
||||
content.innerHTML = this.data.content;
|
||||
|
||||
// Edit button
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'cm-html-block-edit-btn';
|
||||
editBtn.innerHTML = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>`;
|
||||
editBtn.title = 'Edit HTML';
|
||||
|
||||
editBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
view.dispatch({
|
||||
selection: { anchor: this.data.from }
|
||||
});
|
||||
view.focus();
|
||||
});
|
||||
|
||||
wrapper.appendChild(content);
|
||||
wrapper.appendChild(editBtn);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
eq(widget: HTMLBlockWidget): boolean {
|
||||
return JSON.stringify(widget.data) === JSON.stringify(this.data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base theme for HTML blocks.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-html-block-widget': {
|
||||
display: 'inline-block',
|
||||
position: 'relative',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
verticalAlign: 'middle'
|
||||
},
|
||||
'.cm-html-block-content': {
|
||||
display: 'inline-block'
|
||||
},
|
||||
// Ensure images are properly sized
|
||||
'.cm-html-block-content img': {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
display: 'block'
|
||||
},
|
||||
'.cm-html-block-edit-btn': {
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
padding: '4px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: 'rgba(128, 128, 128, 0.2)',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
opacity: '0',
|
||||
transition: 'opacity 0.2s, background 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: '10'
|
||||
},
|
||||
'.cm-html-block-widget:hover .cm-html-block-edit-btn': {
|
||||
opacity: '1'
|
||||
},
|
||||
'.cm-html-block-edit-btn:hover': {
|
||||
background: 'rgba(128, 128, 128, 0.4)'
|
||||
}
|
||||
});
|
||||
|
||||
// Export the extension with theme
|
||||
export const htmlBlockExtension = [htmlBlockPlugin, baseTheme];
|
||||
220
frontend/src/views/editor/extensions/markdown/plugins/image.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import {
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
WidgetType,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
hoverTooltip,
|
||||
Tooltip
|
||||
} from '@codemirror/view';
|
||||
|
||||
interface ImageInfo {
|
||||
src: string;
|
||||
from: number;
|
||||
to: number;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|apng|tiff?)(\?.*)?$/i;
|
||||
const IMAGE_ALT_RE = /(?:!\[)(.*?)(?:\])/;
|
||||
const ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
|
||||
|
||||
function isImageUrl(url: string): boolean {
|
||||
return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/');
|
||||
}
|
||||
|
||||
function extractImages(view: EditorView): ImageInfo[] {
|
||||
const result: ImageInfo[] = [];
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ name, node, from: f, to: t }) => {
|
||||
if (name !== 'Image') return;
|
||||
const urlNode = node.getChild('URL');
|
||||
if (!urlNode) return;
|
||||
const src = view.state.sliceDoc(urlNode.from, urlNode.to);
|
||||
if (!isImageUrl(src)) return;
|
||||
const text = view.state.sliceDoc(f, t);
|
||||
const alt = text.match(IMAGE_ALT_RE)?.[1] ?? '';
|
||||
result.push({ src, from: f, to: t, alt });
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class IndicatorWidget extends WidgetType {
|
||||
constructor(readonly info: ImageInfo) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'cm-image-indicator';
|
||||
el.innerHTML = ICON;
|
||||
return el;
|
||||
}
|
||||
|
||||
eq(other: IndicatorWidget): boolean {
|
||||
return this.info.from === other.info.from && this.info.src === other.info.src;
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePlugin {
|
||||
decorations: DecorationSet;
|
||||
images: ImageInfo[] = [];
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.images = extractImages(view);
|
||||
this.decorations = this.build();
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.images = extractImages(update.view);
|
||||
this.decorations = this.build();
|
||||
}
|
||||
}
|
||||
|
||||
private build(): DecorationSet {
|
||||
const deco: Range<Decoration>[] = [];
|
||||
for (const img of this.images) {
|
||||
deco.push(Decoration.widget({ widget: new IndicatorWidget(img), side: 1 }).range(img.to));
|
||||
}
|
||||
return Decoration.set(deco, true);
|
||||
}
|
||||
|
||||
getImageAt(pos: number): ImageInfo | null {
|
||||
for (const img of this.images) {
|
||||
if (pos >= img.to && pos <= img.to + 1) {
|
||||
return img;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const imagePlugin = ViewPlugin.fromClass(ImagePlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
const imageHoverTooltip = hoverTooltip(
|
||||
(view, pos): Tooltip | null => {
|
||||
const plugin = view.plugin(imagePlugin);
|
||||
if (!plugin) return null;
|
||||
|
||||
const img = plugin.getImageAt(pos);
|
||||
if (!img) return null;
|
||||
|
||||
return {
|
||||
pos: img.to,
|
||||
above: true,
|
||||
arrow: true,
|
||||
create: () => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-image-tooltip cm-image-loading';
|
||||
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'cm-image-spinner';
|
||||
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = img.src;
|
||||
imgEl.alt = img.alt;
|
||||
|
||||
imgEl.onload = () => {
|
||||
dom.classList.remove('cm-image-loading');
|
||||
};
|
||||
imgEl.onerror = () => {
|
||||
spinner.remove();
|
||||
imgEl.remove();
|
||||
dom.textContent = 'Failed to load image';
|
||||
dom.classList.remove('cm-image-loading');
|
||||
dom.classList.add('cm-image-tooltip-error');
|
||||
};
|
||||
|
||||
dom.append(spinner, imgEl);
|
||||
return { dom };
|
||||
}
|
||||
};
|
||||
},
|
||||
{ hoverTime: 300 }
|
||||
);
|
||||
|
||||
const theme = EditorView.baseTheme({
|
||||
'.cm-image-indicator': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginLeft: '4px',
|
||||
verticalAlign: 'middle',
|
||||
cursor: 'pointer',
|
||||
opacity: '0.5',
|
||||
color: 'var(--cm-link-color, #1a73e8)',
|
||||
transition: 'opacity 0.15s',
|
||||
'& svg': { width: '14px', height: '14px' }
|
||||
},
|
||||
'.cm-image-indicator:hover': { opacity: '1' },
|
||||
'.cm-image-tooltip': {
|
||||
position: 'relative',
|
||||
background: `
|
||||
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)
|
||||
`,
|
||||
backgroundColor: '#fff',
|
||||
backgroundSize: '12px 12px',
|
||||
backgroundPosition: '0 0, 0 6px, 6px -6px, -6px 0px',
|
||||
border: '1px solid var(--border-color)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
'& img': {
|
||||
display: 'block',
|
||||
maxWidth: '60vw',
|
||||
maxHeight: '50vh',
|
||||
opacity: '1',
|
||||
transition: 'opacity 0.15s ease-out'
|
||||
}
|
||||
},
|
||||
'.cm-image-loading': {
|
||||
minWidth: '48px',
|
||||
minHeight: '48px',
|
||||
'& img': { opacity: '0' }
|
||||
},
|
||||
'.cm-image-spinner': {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
marginTop: '-8px',
|
||||
marginLeft: '-8px',
|
||||
border: '2px solid #ccc',
|
||||
borderTopColor: '#666',
|
||||
borderRadius: '50%',
|
||||
animation: 'cm-spin 0.5s linear infinite'
|
||||
},
|
||||
'.cm-image-tooltip:not(.cm-image-loading) .cm-image-spinner': {
|
||||
display: 'none'
|
||||
},
|
||||
'@keyframes cm-spin': {
|
||||
to: { transform: 'rotate(360deg)' }
|
||||
},
|
||||
'.cm-image-tooltip-error': {
|
||||
padding: '16px 24px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-muted)'
|
||||
},
|
||||
'.cm-tooltip-arrow:before': {
|
||||
borderTopColor: 'var(--border-color) !important',
|
||||
borderBottomColor: 'var(--border-color) !important'
|
||||
},
|
||||
'.cm-tooltip-arrow:after': {
|
||||
borderTopColor: '#fff !important',
|
||||
borderBottomColor: '#fff !important'
|
||||
}
|
||||
});
|
||||
|
||||
export const image = (): Extension => [imagePlugin, imageHoverTooltip, theme];
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { isCursorInRange } from '../util';
|
||||
|
||||
/**
|
||||
* Inline code styling plugin.
|
||||
*
|
||||
* This plugin adds visual styling to inline code (`code`):
|
||||
* - Background color
|
||||
* - Border radius
|
||||
* - Padding effect via marks
|
||||
*/
|
||||
export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme];
|
||||
|
||||
/**
|
||||
* Build inline code decorations.
|
||||
*/
|
||||
function buildInlineCodeDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo }) => {
|
||||
if (type.name !== 'InlineCode') return;
|
||||
|
||||
const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Skip background decoration when cursor is in the code
|
||||
// This allows selection highlighting to be visible when editing
|
||||
if (cursorInCode) return;
|
||||
|
||||
// Get the actual code content (excluding backticks)
|
||||
const text = view.state.doc.sliceString(nodeFrom, nodeTo);
|
||||
|
||||
// Find backtick positions
|
||||
let codeStart = nodeFrom;
|
||||
let codeEnd = nodeTo;
|
||||
|
||||
// Skip opening backticks
|
||||
let i = 0;
|
||||
while (i < text.length && text[i] === '`') {
|
||||
codeStart++;
|
||||
i++;
|
||||
}
|
||||
|
||||
// Skip closing backticks
|
||||
let j = text.length - 1;
|
||||
while (j >= 0 && text[j] === '`') {
|
||||
codeEnd--;
|
||||
j--;
|
||||
}
|
||||
|
||||
// Only add decoration if there's actual content
|
||||
if (codeStart < codeEnd) {
|
||||
// Add mark decoration for the code content
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-inline-code'
|
||||
}).range(codeStart, codeEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline code plugin class.
|
||||
*/
|
||||
class InlineCodePlugin {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildInlineCodeDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
||||
this.decorations = buildInlineCodeDecorations(update.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
/**
|
||||
* Base theme for inline code.
|
||||
* Uses CSS variables from variables.css for consistent theming.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-inline-code': {
|
||||
backgroundColor: 'var(--cm-inline-code-bg)',
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.1rem 0.3rem',
|
||||
fontFamily: 'var(--voidraft-font-mono)'
|
||||
}
|
||||
});
|
||||
|
||||
114
frontend/src/views/editor/extensions/markdown/plugins/insert.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
/**
|
||||
* Insert plugin using syntax tree.
|
||||
*
|
||||
* Uses the custom Insert extension to detect:
|
||||
* - Insert: ++text++ → renders as inserted text (underline)
|
||||
*
|
||||
* Examples:
|
||||
* - This is ++inserted++ text → This is <ins>inserted</ins> text
|
||||
* - Please ++review this section++ carefully
|
||||
*/
|
||||
export const insert = (): Extension => [
|
||||
insertPlugin,
|
||||
baseTheme
|
||||
];
|
||||
|
||||
/**
|
||||
* Build decorations for insert using syntax tree.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
// Handle Insert nodes
|
||||
if (type.name === 'Insert') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Get the mark nodes (the ++ characters)
|
||||
const marks = node.getChildren('InsertMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Hide the opening and closing ++ marks
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
||||
|
||||
// Apply insert style to the content between marks
|
||||
const contentStart = marks[0].to;
|
||||
const contentEnd = marks[marks.length - 1].from;
|
||||
if (contentStart < contentEnd) {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-insert'
|
||||
}).range(contentStart, contentEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin class with optimized update detection.
|
||||
*/
|
||||
class InsertPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const insertPlugin = ViewPlugin.fromClass(
|
||||
InsertPlugin,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Base theme for insert.
|
||||
* Uses underline decoration for inserted text.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-insert': {
|
||||
textDecoration: 'underline',
|
||||
}
|
||||
});
|
||||
|
||||
142
frontend/src/views/editor/extensions/markdown/plugins/link.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { Range } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { checkRangeOverlap, isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
/**
|
||||
* Pattern for auto-link markers (< and >).
|
||||
*/
|
||||
const AUTO_LINK_MARK_RE = /^<|>$/g;
|
||||
|
||||
/**
|
||||
* Parent node types that should not process.
|
||||
* - Image: handled by image plugin
|
||||
* - LinkReference: reference link definitions like [label]: url should be fully visible
|
||||
*/
|
||||
const BLACKLISTED_PARENTS = new Set(['Image', 'LinkReference']);
|
||||
|
||||
/**
|
||||
* Links plugin.
|
||||
*
|
||||
* Features:
|
||||
* - Hides link markup when cursor is outside
|
||||
* - Link icons and click events are handled by hyperlink extension
|
||||
*/
|
||||
export const links = () => [goToLinkPlugin];
|
||||
|
||||
/**
|
||||
* Build link decorations.
|
||||
* Only hides markdown syntax marks, no icons added.
|
||||
* Uses array + Decoration.set() for automatic sorting.
|
||||
*/
|
||||
function buildLinkDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
const selectionRanges = view.state.selection.ranges;
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
if (type.name !== 'URL') return;
|
||||
|
||||
const parent = node.parent;
|
||||
if (!parent || BLACKLISTED_PARENTS.has(parent.name)) return;
|
||||
|
||||
const marks = parent.getChildren('LinkMark');
|
||||
const linkTitle = parent.getChild('LinkTitle');
|
||||
|
||||
// Find the ']' mark position to distinguish between link text and link target
|
||||
// Link structure: [display text](url)
|
||||
// We should only hide the URL in the () part, not in the [] part
|
||||
const closeBracketMark = marks.find((mark) => {
|
||||
const text = view.state.sliceDoc(mark.from, mark.to);
|
||||
return text === ']';
|
||||
});
|
||||
|
||||
// If URL is before ']', it's part of the display text, don't hide it
|
||||
if (closeBracketMark && nodeFrom < closeBracketMark.from) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if cursor overlaps with the link
|
||||
const cursorOverlaps = selectionRanges.some((range) =>
|
||||
checkRangeOverlap([range.from, range.to], [parent.from, parent.to])
|
||||
);
|
||||
|
||||
// Hide link marks and URL when cursor is outside
|
||||
if (!cursorOverlaps && marks.length > 0) {
|
||||
for (const mark of marks) {
|
||||
decorations.push(invisibleDecoration.range(mark.from, mark.to));
|
||||
}
|
||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||
|
||||
if (linkTitle) {
|
||||
decorations.push(invisibleDecoration.range(linkTitle.from, linkTitle.to));
|
||||
}
|
||||
}
|
||||
|
||||
// Get link content
|
||||
const linkContent = view.state.sliceDoc(nodeFrom, nodeTo);
|
||||
|
||||
// Handle auto-links with < > markers
|
||||
if (AUTO_LINK_MARK_RE.test(linkContent)) {
|
||||
if (!isCursorInRange(view.state, [node.from, node.to])) {
|
||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeFrom + 1));
|
||||
decorations.push(invisibleDecoration.range(nodeTo - 1, nodeTo));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Use Decoration.set with sort=true to handle unsorted ranges
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link plugin with optimized update detection.
|
||||
*/
|
||||
class LinkPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionRanges: string = '';
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildLinkDecorations(view);
|
||||
this.lastSelectionRanges = this.serializeSelection(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// Always rebuild on doc or viewport change
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildLinkDecorations(update.view);
|
||||
this.lastSelectionRanges = this.serializeSelection(update.view);
|
||||
return;
|
||||
}
|
||||
|
||||
// For selection changes, check if selection actually changed
|
||||
if (update.selectionSet) {
|
||||
const newRanges = this.serializeSelection(update.view);
|
||||
if (newRanges !== this.lastSelectionRanges) {
|
||||
this.decorations = buildLinkDecorations(update.view);
|
||||
this.lastSelectionRanges = newRanges;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private serializeSelection(view: EditorView): string {
|
||||
return view.state.selection.ranges
|
||||
.map((r) => `${r.from}:${r.to}`)
|
||||
.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
273
frontend/src/views/editor/extensions/markdown/plugins/list.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import { Range, StateField, Transaction } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { isCursorInRange } from '../util';
|
||||
|
||||
/**
|
||||
* Pattern for bullet list markers.
|
||||
*/
|
||||
const BULLET_LIST_MARKER_RE = /^[-+*]$/;
|
||||
|
||||
/**
|
||||
* Lists plugin.
|
||||
*
|
||||
* Features:
|
||||
* - Custom bullet mark rendering (- → •)
|
||||
* - Interactive task list checkboxes
|
||||
*/
|
||||
export const lists = () => [listBulletPlugin, taskListField, baseTheme];
|
||||
|
||||
// ============================================================================
|
||||
// List Bullet Plugin
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Widget to render list bullet mark.
|
||||
*/
|
||||
class ListBulletWidget extends WidgetType {
|
||||
constructor(readonly bullet: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: ListBulletWidget): boolean {
|
||||
return other.bullet === this.bullet;
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-list-bullet';
|
||||
span.textContent = '•';
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list bullet decorations.
|
||||
*/
|
||||
function buildListBulletDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
if (type.name !== 'ListMark') return;
|
||||
|
||||
// Skip if this is part of a task list (has Task sibling)
|
||||
const parent = node.parent;
|
||||
if (parent) {
|
||||
const task = parent.getChild('Task');
|
||||
if (task) return;
|
||||
}
|
||||
|
||||
// Skip if cursor is in this range
|
||||
if (isCursorInRange(view.state, [nodeFrom, nodeTo])) return;
|
||||
|
||||
const listMark = view.state.sliceDoc(nodeFrom, nodeTo);
|
||||
if (BULLET_LIST_MARKER_RE.test(listMark)) {
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new ListBulletWidget(listMark)
|
||||
}).range(nodeFrom, nodeTo)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* List bullet plugin.
|
||||
*/
|
||||
class ListBulletPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildListBulletDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildListBulletDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
const oldLine = update.startState.doc.lineAt(this.lastSelectionHead);
|
||||
const newLine = update.state.doc.lineAt(newHead);
|
||||
|
||||
if (oldLine.number !== newLine.number) {
|
||||
this.decorations = buildListBulletDecorations(update.view);
|
||||
}
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, {
|
||||
decorations: (v) => v.decorations
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Task List Plugin (using StateField to avoid flickering)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Widget to render checkbox for a task list item.
|
||||
*/
|
||||
class TaskCheckboxWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly checked: boolean,
|
||||
readonly pos: number // Position of the checkbox character in document
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: TaskCheckboxWidget): boolean {
|
||||
return other.checked === this.checked && other.pos === this.pos;
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const wrap = document.createElement('span');
|
||||
wrap.setAttribute('aria-hidden', 'true');
|
||||
wrap.className = 'cm-task-checkbox';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = this.checked;
|
||||
checkbox.tabIndex = -1;
|
||||
|
||||
// Handle click directly in the widget
|
||||
checkbox.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const newValue = !this.checked;
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: this.pos,
|
||||
to: this.pos + 1,
|
||||
insert: newValue ? 'x' : ' '
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
wrap.appendChild(checkbox);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build task list decorations from state.
|
||||
*/
|
||||
function buildTaskListDecorations(state: import('@codemirror/state').EditorState): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
syntaxTree(state).iterate({
|
||||
enter: ({ type, from: taskFrom, to: taskTo, node }) => {
|
||||
if (type.name !== 'Task') return;
|
||||
|
||||
const listItem = node.parent;
|
||||
if (!listItem || listItem.type.name !== 'ListItem') return;
|
||||
|
||||
const listMark = listItem.getChild('ListMark');
|
||||
const taskMarker = node.getChild('TaskMarker');
|
||||
|
||||
if (!listMark || !taskMarker) return;
|
||||
|
||||
const replaceFrom = listMark.from;
|
||||
const replaceTo = taskMarker.to;
|
||||
|
||||
// Check if cursor is in this range
|
||||
if (isCursorInRange(state, [replaceFrom, replaceTo])) return;
|
||||
|
||||
// Check if task is checked - position of x or space is taskMarker.from + 1
|
||||
const markerText = state.sliceDoc(taskMarker.from, taskMarker.to);
|
||||
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
|
||||
const checkboxPos = taskMarker.from + 1; // Position of the x or space
|
||||
|
||||
if (isChecked) {
|
||||
decorations.push(
|
||||
Decoration.mark({ class: 'cm-task-checked' }).range(taskFrom, taskTo)
|
||||
);
|
||||
}
|
||||
|
||||
// Replace "- [x]" or "- [ ]" with checkbox widget
|
||||
decorations.push(
|
||||
Decoration.replace({
|
||||
widget: new TaskCheckboxWidget(isChecked, checkboxPos)
|
||||
}).range(replaceFrom, replaceTo)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Task list StateField - uses incremental updates to avoid flickering.
|
||||
*/
|
||||
const taskListField = StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
return buildTaskListDecorations(state);
|
||||
},
|
||||
|
||||
update(value, tr: Transaction) {
|
||||
// Only rebuild when document or selection changes
|
||||
if (tr.docChanged || tr.selection) {
|
||||
return buildTaskListDecorations(tr.state);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
provide(field) {
|
||||
return EditorView.decorations.from(field);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Theme
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base theme for lists.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-list-bullet': {
|
||||
color: 'var(--cm-list-bullet-color, inherit)'
|
||||
},
|
||||
'.cm-task-checked': {
|
||||
textDecoration: 'line-through',
|
||||
opacity: '0.6'
|
||||
},
|
||||
'.cm-task-checkbox': {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'baseline'
|
||||
},
|
||||
'.cm-task-checkbox input': {
|
||||
cursor: 'pointer',
|
||||
margin: '0',
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
position: 'relative',
|
||||
top: '0.1em'
|
||||
}
|
||||
});
|
||||
358
frontend/src/views/editor/extensions/markdown/plugins/math.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Math plugin for CodeMirror using KaTeX.
|
||||
*
|
||||
* Features:
|
||||
* - Renders inline math $...$ as inline formula
|
||||
* - Renders block math $$...$$ as block formula
|
||||
* - Block math: lines remain, content hidden, formula overlays on top
|
||||
* - Shows source when cursor is inside
|
||||
*/
|
||||
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
// ============================================================================
|
||||
// Inline Math Widget
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Widget to display inline math formula.
|
||||
*/
|
||||
class InlineMathWidget extends WidgetType {
|
||||
private html: string;
|
||||
private error: string | null = null;
|
||||
|
||||
constructor(readonly latex: string) {
|
||||
super();
|
||||
try {
|
||||
this.html = katex.renderToString(latex, {
|
||||
throwOnError: true,
|
||||
displayMode: false,
|
||||
output: 'html'
|
||||
});
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : 'Render error';
|
||||
this.html = '';
|
||||
}
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-inline-math';
|
||||
|
||||
if (this.error) {
|
||||
span.textContent = this.latex;
|
||||
span.title = this.error;
|
||||
} else {
|
||||
span.innerHTML = this.html;
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
eq(other: InlineMathWidget): boolean {
|
||||
return this.latex === other.latex;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Block Math Widget
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Widget to display block math formula.
|
||||
* Uses absolute positioning to overlay on source lines.
|
||||
*/
|
||||
class BlockMathWidget extends WidgetType {
|
||||
private html: string;
|
||||
private error: string | null = null;
|
||||
|
||||
constructor(
|
||||
readonly latex: string,
|
||||
readonly lineCount: number = 1,
|
||||
readonly lineHeight: number = 22
|
||||
) {
|
||||
super();
|
||||
try {
|
||||
this.html = katex.renderToString(latex, {
|
||||
throwOnError: false,
|
||||
displayMode: true,
|
||||
output: 'html'
|
||||
});
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : 'Render error';
|
||||
this.html = '';
|
||||
}
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'cm-block-math-container';
|
||||
// Set height to cover all source lines
|
||||
const height = this.lineCount * this.lineHeight;
|
||||
container.style.height = `${height}px`;
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'cm-block-math';
|
||||
|
||||
if (this.error) {
|
||||
inner.textContent = this.latex;
|
||||
inner.title = this.error;
|
||||
} else {
|
||||
inner.innerHTML = this.html;
|
||||
}
|
||||
|
||||
container.appendChild(inner);
|
||||
return container;
|
||||
}
|
||||
|
||||
eq(other: BlockMathWidget): boolean {
|
||||
return this.latex === other.latex && this.lineCount === other.lineCount;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decorations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build decorations for math formulas.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
// Handle inline math
|
||||
if (type.name === 'InlineMath') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const marks = node.getChildren('InlineMathMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Get latex content (without $ marks)
|
||||
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
|
||||
|
||||
// Hide the entire syntax
|
||||
decorations.push(invisibleDecoration.range(nodeFrom, nodeTo));
|
||||
|
||||
// Add widget at the end
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new InlineMathWidget(latex),
|
||||
side: 1
|
||||
}).range(nodeTo)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle block math ($$...$$)
|
||||
if (type.name === 'BlockMath') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
const marks = node.getChildren('BlockMathMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Get latex content (without $$ marks)
|
||||
const latex = view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
|
||||
|
||||
// Calculate line info
|
||||
const startLine = view.state.doc.lineAt(nodeFrom);
|
||||
const endLine = view.state.doc.lineAt(nodeTo);
|
||||
const lineCount = endLine.number - startLine.number + 1;
|
||||
const lineHeight = view.defaultLineHeight;
|
||||
|
||||
// Check if block math spans multiple lines
|
||||
const hasLineBreak = lineCount > 1;
|
||||
|
||||
if (hasLineBreak) {
|
||||
// For multi-line: use line decorations to hide content
|
||||
for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) {
|
||||
const line = view.state.doc.line(lineNum);
|
||||
decorations.push(
|
||||
Decoration.line({
|
||||
class: 'cm-block-math-line'
|
||||
}).range(line.from)
|
||||
);
|
||||
}
|
||||
|
||||
// Add widget on the first line (positioned absolutely)
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new BlockMathWidget(latex, lineCount, lineHeight),
|
||||
side: -1
|
||||
}).range(startLine.from)
|
||||
);
|
||||
} else {
|
||||
// Single line: make content transparent, overlay widget
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-block-math-content-hidden'
|
||||
}).range(nodeFrom, nodeTo)
|
||||
);
|
||||
|
||||
// Add widget at the start (positioned absolutely)
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new BlockMathWidget(latex, 1, lineHeight),
|
||||
side: -1
|
||||
}).range(nodeFrom)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Math plugin with optimized update detection.
|
||||
*/
|
||||
class MathPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mathPlugin = ViewPlugin.fromClass(
|
||||
MathPlugin,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Theme
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base theme for math.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
// Inline math
|
||||
'.cm-inline-math': {
|
||||
display: 'inline',
|
||||
verticalAlign: 'baseline',
|
||||
},
|
||||
'.cm-inline-math .katex': {
|
||||
fontSize: 'inherit',
|
||||
},
|
||||
|
||||
// Block math container - absolute positioned to overlay on source
|
||||
'.cm-block-math-container': {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
right: '0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '1',
|
||||
},
|
||||
|
||||
// Block math inner
|
||||
'.cm-block-math': {
|
||||
display: 'inline-block',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
'.cm-block-math .katex-display': {
|
||||
margin: '0',
|
||||
},
|
||||
'.cm-block-math .katex': {
|
||||
fontSize: '1.1em',
|
||||
},
|
||||
|
||||
// Hidden line content for block math (text transparent but line preserved)
|
||||
// Use high specificity to override rainbow brackets and other plugins
|
||||
'.cm-line.cm-block-math-line': {
|
||||
color: 'transparent !important',
|
||||
caretColor: 'transparent',
|
||||
},
|
||||
'.cm-line.cm-block-math-line span': {
|
||||
color: 'transparent !important',
|
||||
},
|
||||
// Override rainbow brackets in hidden math lines
|
||||
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
|
||||
color: 'transparent !important',
|
||||
},
|
||||
|
||||
// Hidden content for single-line block math
|
||||
'.cm-block-math-content-hidden': {
|
||||
color: 'transparent !important',
|
||||
},
|
||||
'.cm-block-math-content-hidden span': {
|
||||
color: 'transparent !important',
|
||||
},
|
||||
'.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': {
|
||||
color: 'transparent !important',
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Export
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Math extension.
|
||||
*
|
||||
* Features:
|
||||
* - Parses inline math $...$ and block math $$...$$
|
||||
* - Renders formulas using KaTeX
|
||||
* - Block math preserves line structure, overlays rendered formula
|
||||
* - Shows source when cursor is inside
|
||||
*/
|
||||
export const math = (): Extension => [
|
||||
mathPlugin,
|
||||
baseTheme
|
||||
];
|
||||
|
||||
export default math;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange, invisibleDecoration } from '../util';
|
||||
|
||||
/**
|
||||
* Subscript and Superscript plugin using syntax tree.
|
||||
*
|
||||
* Uses lezer-markdown's Subscript and Superscript extensions to detect:
|
||||
* - Superscript: ^text^ → renders as superscript
|
||||
* - Subscript: ~text~ → renders as subscript
|
||||
*
|
||||
* Note: Inline footnotes ^[content] are handled by the Footnote extension
|
||||
* which parses InlineFootnote before Superscript in the syntax tree.
|
||||
*
|
||||
* Examples:
|
||||
* - 19^th^ → 19ᵗʰ (superscript)
|
||||
* - H~2~O → H₂O (subscript)
|
||||
*/
|
||||
export const subscriptSuperscript = (): Extension => [
|
||||
subscriptSuperscriptPlugin,
|
||||
baseTheme
|
||||
];
|
||||
|
||||
/**
|
||||
* Build decorations for subscript and superscript using syntax tree.
|
||||
*/
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const decorations: Range<Decoration>[] = [];
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
|
||||
// Handle Superscript nodes
|
||||
// Note: InlineFootnote ^[content] is parsed before Superscript,
|
||||
// so we don't need to check for bracket patterns here
|
||||
if (type.name === 'Superscript') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Get the mark nodes (the ^ characters)
|
||||
const marks = node.getChildren('SuperscriptMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Hide the opening and closing ^ marks
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
||||
|
||||
// Apply superscript style to the content between marks
|
||||
const contentStart = marks[0].to;
|
||||
const contentEnd = marks[marks.length - 1].from;
|
||||
if (contentStart < contentEnd) {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-superscript'
|
||||
}).range(contentStart, contentEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Subscript nodes
|
||||
if (type.name === 'Subscript') {
|
||||
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
|
||||
|
||||
// Get the mark nodes (the ~ characters)
|
||||
const marks = node.getChildren('SubscriptMark');
|
||||
|
||||
if (!cursorInRange && marks.length >= 2) {
|
||||
// Hide the opening and closing ~ marks
|
||||
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
|
||||
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
|
||||
|
||||
// Apply subscript style to the content between marks
|
||||
const contentStart = marks[0].to;
|
||||
const contentEnd = marks[marks.length - 1].from;
|
||||
if (contentStart < contentEnd) {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'cm-subscript'
|
||||
}).range(contentStart, contentEnd)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin class with optimized update detection.
|
||||
*/
|
||||
class SubscriptSuperscriptPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptSuperscriptPlugin = ViewPlugin.fromClass(
|
||||
SubscriptSuperscriptPlugin,
|
||||
{
|
||||
decorations: (v) => v.decorations
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Base theme for subscript and superscript.
|
||||
* Uses mark decoration instead of widget to avoid layout issues.
|
||||
* fontSize uses smaller value as subscript/superscript are naturally smaller.
|
||||
*/
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
'.cm-superscript': {
|
||||
verticalAlign: 'super',
|
||||
fontSize: '0.75em',
|
||||
color: 'var(--cm-superscript-color, inherit)'
|
||||
},
|
||||
'.cm-subscript': {
|
||||
verticalAlign: 'sub',
|
||||
fontSize: '0.75em',
|
||||
color: 'var(--cm-subscript-color, inherit)'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState, StateField } from '@codemirror/state';
|
||||
import { Slugger } from '../util';
|
||||
import {SyntaxNode} from "@lezer/common";
|
||||
|
||||
/**
|
||||
* A heading slug is a string that is used to identify/reference
|
||||
* a heading in the document. Heading slugs are URI-compatible and can be used
|
||||
* in permalinks as heading IDs.
|
||||
*/
|
||||
export interface HeadingSlug {
|
||||
slug: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin that stores the calculated slugs of the document headings in the
|
||||
* editor state. These can be useful when resolving links to headings inside
|
||||
* the document.
|
||||
*/
|
||||
export const headingSlugField = StateField.define<HeadingSlug[]>({
|
||||
create: (state) => {
|
||||
const slugs = extractSlugs(state);
|
||||
return slugs;
|
||||
},
|
||||
update: (value, tx) => {
|
||||
if (tx.docChanged) return extractSlugs(tx.state);
|
||||
return value;
|
||||
},
|
||||
compare: (a, b) =>
|
||||
a.length === b.length &&
|
||||
a.every((slug, i) => slug.slug === b[i].slug && slug.pos === b[i].pos)
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param state - The current editor state.
|
||||
* @returns An array of heading slugs.
|
||||
*/
|
||||
function extractSlugs(state: EditorState): HeadingSlug[] {
|
||||
const slugs: HeadingSlug[] = [];
|
||||
const slugger = new Slugger();
|
||||
syntaxTree(state).iterate({
|
||||
enter: ({ name, from, to, node }) => {
|
||||
// Capture ATXHeading and SetextHeading
|
||||
if (!name.includes('Heading')) return;
|
||||
const mark: SyntaxNode | null = node.getChild('HeaderMark');
|
||||
|
||||
const headerText = state.sliceDoc(from, to).split('');
|
||||
headerText.splice(mark!.from - from, mark!.to - mark!.from);
|
||||
const slug = slugger.slug(headerText.join('').trim());
|
||||
slugs.push({ slug, pos: from });
|
||||
}
|
||||
});
|
||||
return slugs;
|
||||
}
|
||||
259
frontend/src/views/editor/extensions/markdown/syntax/footnote.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Footnote extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses footnote syntax compatible with MultiMarkdown/PHP Markdown Extra.
|
||||
*
|
||||
* Syntax:
|
||||
* - Footnote reference: [^id] or [^1]
|
||||
* - Footnote definition: [^id]: content (at line start)
|
||||
* - Inline footnote: ^[content] (content is inline, no separate definition needed)
|
||||
*
|
||||
* Examples:
|
||||
* - This is text[^1] with a footnote.
|
||||
* - [^1]: This is the footnote content.
|
||||
* - This is text^[inline footnote content] with inline footnote.
|
||||
*/
|
||||
|
||||
import { MarkdownConfig, Line, BlockContext, InlineContext } from '@lezer/markdown';
|
||||
import { CharCode, isFootnoteIdChar } from '../util';
|
||||
|
||||
/**
|
||||
* Parse inline footnote ^[content].
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at ^)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseInlineFootnote(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Minimum: ^[ + content + ] = at least 4 chars
|
||||
if (end < pos + 3) return -1;
|
||||
|
||||
// Track bracket depth for nested brackets
|
||||
let bracketDepth = 1;
|
||||
let hasContent = false;
|
||||
const contentStart = pos + 2;
|
||||
|
||||
for (let i = contentStart; i < end; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Don't allow newlines
|
||||
if (char === CharCode.Newline) return -1;
|
||||
|
||||
// Track bracket depth
|
||||
if (char === CharCode.OpenBracket) {
|
||||
bracketDepth++;
|
||||
} else if (char === CharCode.CloseBracket) {
|
||||
bracketDepth--;
|
||||
if (bracketDepth === 0) {
|
||||
// Found closing bracket - must have content
|
||||
if (!hasContent) return -1;
|
||||
|
||||
// Create element with marks and content
|
||||
return cx.addElement(cx.elt('InlineFootnote', pos, i + 1, [
|
||||
cx.elt('InlineFootnoteMark', pos, contentStart),
|
||||
cx.elt('InlineFootnoteContent', contentStart, i),
|
||||
cx.elt('InlineFootnoteMark', i, i + 1)
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse footnote reference [^id].
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at [)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseFootnoteReference(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Minimum: [^ + id + ] = at least 4 chars
|
||||
if (end < pos + 3) return -1;
|
||||
|
||||
let hasValidId = false;
|
||||
const labelStart = pos + 2;
|
||||
|
||||
for (let i = labelStart; i < end; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Found closing bracket
|
||||
if (char === CharCode.CloseBracket) {
|
||||
if (!hasValidId) return -1;
|
||||
|
||||
// Create element with marks and label
|
||||
return cx.addElement(cx.elt('FootnoteReference', pos, i + 1, [
|
||||
cx.elt('FootnoteReferenceMark', pos, labelStart),
|
||||
cx.elt('FootnoteReferenceLabel', labelStart, i),
|
||||
cx.elt('FootnoteReferenceMark', i, i + 1)
|
||||
]));
|
||||
}
|
||||
|
||||
// Don't allow newlines
|
||||
if (char === CharCode.Newline) return -1;
|
||||
|
||||
// Validate id character using O(1) lookup table
|
||||
if (isFootnoteIdChar(char)) {
|
||||
hasValidId = true;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse footnote definition [^id]: content.
|
||||
*
|
||||
* @param cx - Block context
|
||||
* @param line - Current line
|
||||
* @returns True if parsed successfully
|
||||
*/
|
||||
function parseFootnoteDefinition(cx: BlockContext, line: Line): boolean {
|
||||
const text = line.text;
|
||||
const len = text.length;
|
||||
|
||||
// Minimum: [^id]: = at least 5 chars
|
||||
if (len < 5) return false;
|
||||
|
||||
// Find ]: pattern - use O(1) lookup for ID chars
|
||||
let labelEnd = 2;
|
||||
while (labelEnd < len) {
|
||||
const char = text.charCodeAt(labelEnd);
|
||||
|
||||
if (char === CharCode.CloseBracket) {
|
||||
// Check for : after ]
|
||||
if (labelEnd + 1 < len && text.charCodeAt(labelEnd + 1) === CharCode.Colon) {
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use O(1) lookup table
|
||||
if (!isFootnoteIdChar(char)) return false;
|
||||
|
||||
labelEnd++;
|
||||
}
|
||||
|
||||
// Validate ]: was found
|
||||
if (labelEnd >= len ||
|
||||
text.charCodeAt(labelEnd) !== CharCode.CloseBracket ||
|
||||
text.charCodeAt(labelEnd + 1) !== CharCode.Colon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate positions (all at once to avoid repeated arithmetic)
|
||||
const start = cx.lineStart;
|
||||
const openMarkEnd = start + 2;
|
||||
const labelEndPos = start + labelEnd;
|
||||
const closeMarkEnd = start + labelEnd + 2;
|
||||
|
||||
// Skip optional space after :
|
||||
let contentOffset = labelEnd + 2;
|
||||
if (contentOffset < len) {
|
||||
const spaceChar = text.charCodeAt(contentOffset);
|
||||
if (spaceChar === CharCode.Space || spaceChar === CharCode.Tab) {
|
||||
contentOffset++;
|
||||
}
|
||||
}
|
||||
|
||||
// Build children array
|
||||
const children = [
|
||||
cx.elt('FootnoteDefinitionMark', start, openMarkEnd),
|
||||
cx.elt('FootnoteDefinitionLabel', openMarkEnd, labelEndPos),
|
||||
cx.elt('FootnoteDefinitionMark', labelEndPos, closeMarkEnd)
|
||||
];
|
||||
|
||||
// Add content if present
|
||||
if (contentOffset < len) {
|
||||
children.push(cx.elt('FootnoteDefinitionContent', start + contentOffset, start + len));
|
||||
}
|
||||
|
||||
// Create and add block element
|
||||
cx.addElement(cx.elt('FootnoteDefinition', start, start + len, children));
|
||||
cx.nextLine();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footnote extension for Lezer Markdown.
|
||||
*
|
||||
* Defines nodes:
|
||||
* - FootnoteReference: Inline reference [^id]
|
||||
* - FootnoteReferenceMark: The [^ and ] delimiters
|
||||
* - FootnoteReferenceLabel: The id part
|
||||
* - FootnoteDefinition: Block definition [^id]: content
|
||||
* - FootnoteDefinitionMark: The [^, ]: delimiters
|
||||
* - FootnoteDefinitionLabel: The id part in definition
|
||||
* - FootnoteDefinitionContent: The content part
|
||||
* - InlineFootnote: Inline footnote ^[content]
|
||||
* - InlineFootnoteMark: The ^[ and ] delimiters
|
||||
* - InlineFootnoteContent: The content part
|
||||
*/
|
||||
export const Footnote: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
// Inline reference nodes
|
||||
{ name: 'FootnoteReference' },
|
||||
{ name: 'FootnoteReferenceMark' },
|
||||
{ name: 'FootnoteReferenceLabel' },
|
||||
// Block definition nodes
|
||||
{ name: 'FootnoteDefinition', block: true },
|
||||
{ name: 'FootnoteDefinitionMark' },
|
||||
{ name: 'FootnoteDefinitionLabel' },
|
||||
{ name: 'FootnoteDefinitionContent' },
|
||||
// Inline footnote nodes
|
||||
{ name: 'InlineFootnote' },
|
||||
{ name: 'InlineFootnoteMark' },
|
||||
{ name: 'InlineFootnoteContent' },
|
||||
],
|
||||
|
||||
parseInline: [
|
||||
{
|
||||
name: 'InlineFootnote',
|
||||
parse(cx, next, pos) {
|
||||
// Fast path: must start with ^[
|
||||
if (next !== CharCode.Caret || cx.char(pos + 1) !== CharCode.OpenBracket) {
|
||||
return -1;
|
||||
}
|
||||
return parseInlineFootnote(cx, pos);
|
||||
},
|
||||
before: 'Superscript',
|
||||
},
|
||||
{
|
||||
name: 'FootnoteReference',
|
||||
parse(cx, next, pos) {
|
||||
// Fast path: must start with [^
|
||||
if (next !== CharCode.OpenBracket || cx.char(pos + 1) !== CharCode.Caret) {
|
||||
return -1;
|
||||
}
|
||||
return parseFootnoteReference(cx, pos);
|
||||
},
|
||||
before: 'Link',
|
||||
},
|
||||
],
|
||||
|
||||
parseBlock: [
|
||||
{
|
||||
name: 'FootnoteDefinition',
|
||||
parse(cx: BlockContext, line: Line): boolean {
|
||||
// Fast path: must start with [^
|
||||
if (line.text.charCodeAt(0) !== CharCode.OpenBracket ||
|
||||
line.text.charCodeAt(1) !== CharCode.Caret) {
|
||||
return false;
|
||||
}
|
||||
return parseFootnoteDefinition(cx, line);
|
||||
},
|
||||
before: 'LinkReference',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default Footnote;
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Highlight extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses ==highlight== syntax similar to Obsidian/Mark style.
|
||||
*
|
||||
* Syntax: ==text== → renders as highlighted text
|
||||
*
|
||||
* Example:
|
||||
* - This is ==important== text → This is <mark>important</mark> text
|
||||
*/
|
||||
|
||||
import { MarkdownConfig } from '@lezer/markdown';
|
||||
import { CharCode, createPairedDelimiterParser } from '../util';
|
||||
|
||||
/**
|
||||
* Highlight extension for Lezer Markdown.
|
||||
* Defines:
|
||||
* - Highlight: The container node for highlighted content
|
||||
* - HighlightMark: The == delimiter marks
|
||||
*/
|
||||
export const Highlight: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'Highlight' },
|
||||
{ name: 'HighlightMark' }
|
||||
],
|
||||
parseInline: [
|
||||
createPairedDelimiterParser({
|
||||
name: 'Highlight',
|
||||
nodeName: 'Highlight',
|
||||
markName: 'HighlightMark',
|
||||
delimChar: CharCode.Equal,
|
||||
isDouble: true,
|
||||
after: 'Emphasis'
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
export default Highlight;
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Insert extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses ++insert++ syntax for inserted/underlined text.
|
||||
*
|
||||
* Syntax: ++text++ → renders as inserted text (underline)
|
||||
*
|
||||
* Example:
|
||||
* - This is ++inserted++ text → This is <ins>inserted</ins> text
|
||||
*/
|
||||
|
||||
import { MarkdownConfig } from '@lezer/markdown';
|
||||
import { CharCode, createPairedDelimiterParser } from '../util';
|
||||
|
||||
/**
|
||||
* Insert extension for Lezer Markdown.
|
||||
*
|
||||
* Uses optimized factory function for O(n) single-pass parsing.
|
||||
*
|
||||
* Defines:
|
||||
* - Insert: The container node for inserted content
|
||||
* - InsertMark: The ++ delimiter marks
|
||||
*/
|
||||
export const Insert: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'Insert' },
|
||||
{ name: 'InsertMark' }
|
||||
],
|
||||
parseInline: [
|
||||
createPairedDelimiterParser({
|
||||
name: 'Insert',
|
||||
nodeName: 'Insert',
|
||||
markName: 'InsertMark',
|
||||
delimChar: CharCode.Plus,
|
||||
isDouble: true,
|
||||
after: 'Emphasis'
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
export default Insert;
|
||||
146
frontend/src/views/editor/extensions/markdown/syntax/math.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Math extension for Lezer Markdown parser.
|
||||
*
|
||||
* Parses LaTeX math syntax:
|
||||
* - Inline math: $E=mc^2$ → renders as inline formula
|
||||
* - Block math: $$...$$ → renders as block formula (can be multi-line)
|
||||
*/
|
||||
|
||||
import { MarkdownConfig, InlineContext } from '@lezer/markdown';
|
||||
import { CharCode } from '../util';
|
||||
|
||||
/**
|
||||
* Parse block math ($$...$$).
|
||||
* Allows multi-line content and handles escaped $.
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at first $)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseBlockMath(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Don't match $$$ or more
|
||||
if (cx.char(pos + 2) === CharCode.Dollar) return -1;
|
||||
|
||||
// Minimum: $$ + content + $$ = at least 5 chars
|
||||
const minEnd = pos + 4;
|
||||
if (end < minEnd) return -1;
|
||||
|
||||
// Search for closing $$
|
||||
const searchEnd = end - 1;
|
||||
for (let i = pos + 2; i < searchEnd; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Skip escaped $ (backslash followed by any char)
|
||||
if (char === CharCode.Backslash) {
|
||||
i++; // Skip next char
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found potential closing $$
|
||||
if (char === CharCode.Dollar) {
|
||||
const nextChar = cx.char(i + 1);
|
||||
if (nextChar !== CharCode.Dollar) continue;
|
||||
|
||||
// Don't match $$$
|
||||
if (i + 2 < end && cx.char(i + 2) === CharCode.Dollar) continue;
|
||||
|
||||
// Ensure content exists
|
||||
if (i === pos + 2) return -1;
|
||||
|
||||
// Create element with marks
|
||||
return cx.addElement(cx.elt('BlockMath', pos, i + 2, [
|
||||
cx.elt('BlockMathMark', pos, pos + 2),
|
||||
cx.elt('BlockMathMark', i, i + 2)
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse inline math ($...$).
|
||||
* Single line only, handles escaped $.
|
||||
*
|
||||
* @param cx - Inline context
|
||||
* @param pos - Start position (at $)
|
||||
* @returns Position after element, or -1 if no match
|
||||
*/
|
||||
function parseInlineMath(cx: InlineContext, pos: number): number {
|
||||
const end = cx.end;
|
||||
|
||||
// Don't match if preceded by backslash (escaped)
|
||||
if (pos > 0 && cx.char(pos - 1) === CharCode.Backslash) return -1;
|
||||
|
||||
// Minimum: $ + content + $ = at least 3 chars
|
||||
if (end < pos + 2) return -1;
|
||||
|
||||
// Search for closing $
|
||||
for (let i = pos + 1; i < end; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Newline not allowed in inline math
|
||||
if (char === CharCode.Newline) return -1;
|
||||
|
||||
// Skip escaped $
|
||||
if (char === CharCode.Backslash && i + 1 < end && cx.char(i + 1) === CharCode.Dollar) {
|
||||
i++; // Skip next char
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found potential closing $
|
||||
if (char === CharCode.Dollar) {
|
||||
// Don't match $$
|
||||
if (i + 1 < end && cx.char(i + 1) === CharCode.Dollar) continue;
|
||||
|
||||
// Ensure content exists
|
||||
if (i === pos + 1) return -1;
|
||||
|
||||
// Create element with marks
|
||||
return cx.addElement(cx.elt('InlineMath', pos, i + 1, [
|
||||
cx.elt('InlineMathMark', pos, pos + 1),
|
||||
cx.elt('InlineMathMark', i, i + 1)
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Math extension for Lezer Markdown.
|
||||
*
|
||||
* Defines:
|
||||
* - InlineMath: Inline math formula $...$
|
||||
* - InlineMathMark: The $ delimiter marks for inline
|
||||
* - BlockMath: Block math formula $$...$$
|
||||
* - BlockMathMark: The $$ delimiter marks for block
|
||||
*/
|
||||
export const Math: MarkdownConfig = {
|
||||
defineNodes: [
|
||||
{ name: 'InlineMath' },
|
||||
{ name: 'InlineMathMark' },
|
||||
{ name: 'BlockMath' },
|
||||
{ name: 'BlockMathMark' }
|
||||
],
|
||||
parseInline: [
|
||||
{
|
||||
name: 'Math',
|
||||
parse(cx, next, pos) {
|
||||
// Fast path: must start with $
|
||||
if (next !== CharCode.Dollar) return -1;
|
||||
|
||||
// Check for $$ (block math) vs $ (inline math)
|
||||
const isBlock = cx.char(pos + 1) === CharCode.Dollar;
|
||||
|
||||
return isBlock ? parseBlockMath(cx, pos) : parseInlineMath(cx, pos);
|
||||
},
|
||||
// Parse after emphasis to avoid conflicts
|
||||
after: 'Emphasis'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default Math;
|
||||
202
frontend/src/views/editor/extensions/markdown/util.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Decoration } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import type { InlineContext, InlineParser } from '@lezer/markdown';
|
||||
|
||||
/**
|
||||
* ASCII character codes for common delimiters.
|
||||
*/
|
||||
export const enum CharCode {
|
||||
Space = 32,
|
||||
Tab = 9,
|
||||
Newline = 10,
|
||||
Backslash = 92,
|
||||
Dollar = 36, // $
|
||||
Plus = 43, // +
|
||||
Equal = 61, // =
|
||||
OpenBracket = 91, // [
|
||||
CloseBracket = 93, // ]
|
||||
Caret = 94, // ^
|
||||
Colon = 58, // :
|
||||
Hyphen = 45, // -
|
||||
Underscore = 95, // _
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-computed lookup table for footnote ID characters.
|
||||
* Valid characters: 0-9, A-Z, a-z, _, -
|
||||
* Uses Uint8Array for memory efficiency and O(1) lookup.
|
||||
*/
|
||||
const FOOTNOTE_ID_CHARS = new Uint8Array(128);
|
||||
// Initialize lookup table (0-9: 48-57, A-Z: 65-90, a-z: 97-122, _: 95, -: 45)
|
||||
for (let i = 48; i <= 57; i++) FOOTNOTE_ID_CHARS[i] = 1; // 0-9
|
||||
for (let i = 65; i <= 90; i++) FOOTNOTE_ID_CHARS[i] = 1; // A-Z
|
||||
for (let i = 97; i <= 122; i++) FOOTNOTE_ID_CHARS[i] = 1; // a-z
|
||||
FOOTNOTE_ID_CHARS[95] = 1; // _
|
||||
FOOTNOTE_ID_CHARS[45] = 1; // -
|
||||
|
||||
/**
|
||||
* O(1) check if a character is valid for footnote ID.
|
||||
* @param code - ASCII character code
|
||||
* @returns True if valid footnote ID character
|
||||
*/
|
||||
export function isFootnoteIdChar(code: number): boolean {
|
||||
return code < 128 && FOOTNOTE_ID_CHARS[code] === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for paired delimiter parser factory.
|
||||
*/
|
||||
export interface PairedDelimiterConfig {
|
||||
/** Parser name */
|
||||
name: string;
|
||||
/** Node name for the container element */
|
||||
nodeName: string;
|
||||
/** Node name for the delimiter marks */
|
||||
markName: string;
|
||||
/** First delimiter character code */
|
||||
delimChar: number;
|
||||
/** Whether delimiter is doubled (e.g., == vs =) */
|
||||
isDouble: true;
|
||||
/** Whether to allow newlines in content */
|
||||
allowNewlines?: boolean;
|
||||
/** Parse order - after which parser */
|
||||
after?: string;
|
||||
/** Parse order - before which parser */
|
||||
before?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a paired delimiter inline parser.
|
||||
* Optimized with:
|
||||
* - Fast path early return
|
||||
* - Minimal function calls in loop
|
||||
* - Pre-computed delimiter length
|
||||
*
|
||||
* @param config - Parser configuration
|
||||
* @returns InlineParser for MarkdownConfig
|
||||
*/
|
||||
export function createPairedDelimiterParser(config: PairedDelimiterConfig): InlineParser {
|
||||
const { name, nodeName, markName, delimChar, allowNewlines = false, after, before } = config;
|
||||
const delimLen = 2; // Always double delimiter for these parsers
|
||||
|
||||
return {
|
||||
name,
|
||||
parse(cx: InlineContext, next: number, pos: number): number {
|
||||
// Fast path: check first character
|
||||
if (next !== delimChar) return -1;
|
||||
|
||||
// Check second delimiter character
|
||||
if (cx.char(pos + 1) !== delimChar) return -1;
|
||||
|
||||
// Don't match triple delimiter (e.g., ===, +++)
|
||||
if (cx.char(pos + 2) === delimChar) return -1;
|
||||
|
||||
// Calculate search bounds
|
||||
const searchEnd = cx.end - 1;
|
||||
const contentStart = pos + delimLen;
|
||||
|
||||
// Look for closing delimiter
|
||||
for (let i = contentStart; i < searchEnd; i++) {
|
||||
const char = cx.char(i);
|
||||
|
||||
// Check for newline (unless allowed)
|
||||
if (!allowNewlines && char === CharCode.Newline) return -1;
|
||||
|
||||
// Found potential closing delimiter
|
||||
if (char === delimChar && cx.char(i + 1) === delimChar) {
|
||||
// Don't match triple delimiter
|
||||
if (i + 2 < cx.end && cx.char(i + 2) === delimChar) continue;
|
||||
|
||||
// Create element with marks
|
||||
return cx.addElement(cx.elt(nodeName, pos, i + delimLen, [
|
||||
cx.elt(markName, pos, contentStart),
|
||||
cx.elt(markName, i, i + delimLen)
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
},
|
||||
...(after && { after }),
|
||||
...(before && { before })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tuple representation of a range [from, to].
|
||||
*/
|
||||
export type RangeTuple = [number, number];
|
||||
|
||||
/**
|
||||
* Check if two ranges overlap (touch or intersect).
|
||||
* Based on the visual diagram on https://stackoverflow.com/a/25369187
|
||||
*
|
||||
* @param range1 - First range
|
||||
* @param range2 - Second range
|
||||
* @returns True if the ranges overlap
|
||||
*/
|
||||
export function checkRangeOverlap(
|
||||
range1: RangeTuple,
|
||||
range2: RangeTuple
|
||||
): boolean {
|
||||
return range1[0] <= range2[1] && range2[0] <= range1[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any of the editor cursors is in the given range.
|
||||
*
|
||||
* @param state - Editor state
|
||||
* @param range - Range to check
|
||||
* @returns True if the cursor is in the range
|
||||
*/
|
||||
export function isCursorInRange(
|
||||
state: EditorState,
|
||||
range: RangeTuple
|
||||
): boolean {
|
||||
return state.selection.ranges.some((selection) =>
|
||||
checkRangeOverlap(range, [selection.from, selection.to])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoration to simply hide anything (replace with nothing).
|
||||
*/
|
||||
export const invisibleDecoration = Decoration.replace({});
|
||||
|
||||
|
||||
/**
|
||||
* Class for generating unique slugs from heading contents.
|
||||
*/
|
||||
export class Slugger {
|
||||
/** Occurrences for each slug. */
|
||||
private occurrences: Map<string, number> = new Map();
|
||||
|
||||
/**
|
||||
* Generate a slug from the given content.
|
||||
*
|
||||
* @param text - Content to generate the slug from
|
||||
* @returns The generated slug
|
||||
*/
|
||||
public slug(text: string): string {
|
||||
let slug = text
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w-]+/g, '');
|
||||
|
||||
const count = this.occurrences.get(slug) || 0;
|
||||
if (count > 0) {
|
||||
slug += '-' + count;
|
||||
}
|
||||
this.occurrences.set(slug, count + 1);
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the slugger state.
|
||||
*/
|
||||
public reset(): void {
|
||||
this.occurrences.clear();
|
||||
}
|
||||
}
|
||||