43 Commits

Author SHA1 Message Date
272227e4e3 Merge pull request #20 from landaiqing/dev
Added code block export image extension
2025-12-23 20:53:43 +08:00
ec8f8c1e2d ⬆️ Upgrade dependencies 2025-12-23 20:47:06 +08:00
1c14092068 🐛 Resolved a selection conflict issue during IME input method combination input 2025-12-23 00:37:10 +08:00
00bdafc621 Merge pull request #19 from landaiqing/issue-template
Update issue templates
2025-12-22 19:05:14 +08:00
78422899e4 Update issue templates 2025-12-22 19:02:07 +08:00
c47f7de5b8 Added code block export image extension 2025-12-22 00:13:55 +08:00
37aae9e03c Merge pull request #18 from landaiqing/dev
♻️ Refactor keybinding service
2025-12-20 17:21:13 +08:00
fa134d31d6 📝 Update README.md 2025-12-20 17:05:49 +08:00
d035dcd531 Merge branch 'master' into dev 2025-12-20 17:04:39 +08:00
c50bf452ca Merge pull request #16 from landaiqing/snyk-upgrade-84db4245b76a139f9893542518e26f7c
[Snyk] Upgrade @toml-tools/lexer from 1.0.0 to 1.0.1
2025-12-20 16:46:48 +08:00
dace5ce2b0 Merge branch 'master' into snyk-upgrade-84db4245b76a139f9893542518e26f7c 2025-12-20 16:46:29 +08:00
ef145169aa Merge pull request #17 from landaiqing/snyk-upgrade-d49a610d736beff91ab0d19edbc6eead
[Snyk] Upgrade @toml-tools/parser from 1.0.0 to 1.0.1
2025-12-20 16:44:52 +08:00
7b746155f7 ♻️ Refactor keybinding service 2025-12-20 16:43:04 +08:00
snyk-bot
b289f4054d fix: upgrade @toml-tools/parser from 1.0.0 to 1.0.1
Snyk has created this PR to upgrade @toml-tools/parser from 1.0.0 to 1.0.1.

See this package in npm:
@toml-tools/parser

See this project in Snyk:
https://app.snyk.io/org/landaiqing/project/27ce8f71-d823-4dce-84c2-bf6a1cf5aa6a?utm_source=github&utm_medium=referral&page=upgrade-pr
2025-12-19 11:27:17 +00:00
snyk-bot
bdee1fdf84 fix: upgrade @toml-tools/lexer from 1.0.0 to 1.0.1
Snyk has created this PR to upgrade @toml-tools/lexer from 1.0.0 to 1.0.1.

See this package in npm:
@toml-tools/lexer

See this project in Snyk:
https://app.snyk.io/org/landaiqing/project/27ce8f71-d823-4dce-84c2-bf6a1cf5aa6a?utm_source=github&utm_medium=referral&page=upgrade-pr
2025-12-19 11:27:13 +00:00
541e4e96cf Merge pull request #14 from landaiqing/dev
♻️ Refactor cursor position cache
2025-12-17 23:30:20 +08:00
401eb3ab39 ⬆️ Upgrade dependencies 2025-12-17 23:19:50 +08:00
d3eba96a29 🐛 Fixed assignment issues 2025-12-17 22:55:52 +08:00
81c02db00d Merge pull request #15 from fossabot/add-license-scan-badge
Add license scan report and status
2025-12-17 10:58:59 +08:00
fossabot
9cb2ccbb4e Add license scan report and status
Signed off by: fossabot <badges@fossa.com>
2025-12-16 21:39:50 -05:00
8a10b8fe0f ♻️ Refactor cursor position cache 2025-12-17 00:12:59 +08:00
8fce8bdca4 ♻️ Refactor backup service complete.
Some checks failed
CodeQL Advanced / Analyze (go) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (c-cpp) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
CodeQL Advanced / Analyze (rust) (push) Has been cancelled
2025-12-16 23:20:40 +08:00
1ab934cee9 Merge pull request #13 from landaiqing/alert-autofix-1
Some checks failed
CodeQL Advanced / Analyze (go) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (c-cpp) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
CodeQL Advanced / Analyze (rust) (push) Has been cancelled
Potential fix for code scanning alert no. 1: Workflow does not contain permissions
2025-12-16 15:20:27 +08:00
6659ac6fad 🐛 Potential fix for code scanning alert no. 1: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-16 15:15:36 +08:00
3a5ab1c614 Merge pull request #12 from landaiqing/alert-autofix-2
Potential fix for code scanning alert no. 2: Workflow does not contain permissions
2025-12-16 14:44:06 +08:00
1e07e1f833 🐛 Potential fix for code scanning alert no. 2: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-16 14:36:57 +08:00
e1e91a3683 👷 Change build mode for C/C++ in CodeQL workflow 2025-12-16 14:28:00 +08:00
c30d95a3e0 🐛 Potential fix for code scanning alert no. 3: Replacement of a substring with itself
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-16 14:14:52 +08:00
97f6fa843c 👷 Add CodeQL analysis workflow configuration 2025-12-16 14:01:02 +08:00
f43fc47539 Merge pull request #11 from landaiqing/dependabot/npm_and_yarn/frontend/mdast-util-to-hast-13.2.1
⬆️ Bump mdast-util-to-hast from 13.2.0 to 13.2.1 in /frontend
2025-12-16 13:48:54 +08:00
dependabot[bot]
c330de52fa ⬆️ Bump mdast-util-to-hast from 13.2.0 to 13.2.1 in /frontend
Bumps [mdast-util-to-hast](https://github.com/syntax-tree/mdast-util-to-hast) from 13.2.0 to 13.2.1.
- [Release notes](https://github.com/syntax-tree/mdast-util-to-hast/releases)
- [Commits](https://github.com/syntax-tree/mdast-util-to-hast/compare/13.2.0...13.2.1)

---
updated-dependencies:
- dependency-name: mdast-util-to-hast
  dependency-version: 13.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-16 05:46:59 +00:00
67d35626cb 🚧 Refactor backup service 2025-12-14 23:48:52 +08:00
cc4c2189dc 🚧 Refactor basic services 2025-12-14 02:19:50 +08:00
d16905c0a3 🐛 Fixed some known issues 2025-12-12 22:56:22 +08:00
4e611db349 Optimize minmap extension completed 2025-12-11 23:59:06 +08:00
7e9fc0ac3f Optimize minmap extension 2025-12-10 22:25:19 +08:00
ff072d1a93 Optimize minmap performance 2025-12-10 00:41:24 +08:00
a9c81c878e 🚚 2025-12-08 23:28:36 +08:00
3660d13d7d ♻️ Refactor search 2025-12-08 23:20:37 +08:00
281f53c049 Optimized markdown preview performance 2025-12-07 00:09:52 +08:00
71ca541f78 🚧 Added support for markdown preview table 2025-12-04 00:47:51 +08:00
91f4f4afac Merge branch 'markdown'
# Conflicts:
#	frontend/package-lock.json
2025-12-03 00:46:17 +08:00
dependabot[bot]
6668c11846 ⬆️ Bump mdast-util-to-hast from 13.2.0 to 13.2.1 in /frontend
Bumps [mdast-util-to-hast](https://github.com/syntax-tree/mdast-util-to-hast) from 13.2.0 to 13.2.1.
- [Release notes](https://github.com/syntax-tree/mdast-util-to-hast/releases)
- [Commits](https://github.com/syntax-tree/mdast-util-to-hast/compare/13.2.0...13.2.1)

---
updated-dependencies:
- dependency-name: mdast-util-to-hast
  dependency-version: 13.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 04:14:35 +00:00
227 changed files with 29324 additions and 11944 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -28,6 +28,7 @@ env:
jobs:
# 准备构建配置
prepare:
permissions: {}
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
@@ -84,6 +85,8 @@ jobs:
fi
build:
permissions:
contents: read
needs: prepare
if: ${{ fromJson(needs.prepare.outputs.matrix).include[0] != null }}
strategy:

109
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,109 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: '29 8 * * 3'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: c-cpp
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
- language: rust
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name: Run manual build steps
if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"

View File

@@ -61,5 +61,3 @@ export class ServiceOptions {
return new ServiceOptions($$parsedSource as Partial<ServiceOptions>);
}
}
export type Window = any;

View File

@@ -0,0 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export * from "./models.js";

View File

@@ -0,0 +1,17 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
/**
* CancelFunc 取消订阅函数
* 调用此函数可以取消对配置的监听
*/
export type CancelFunc = any;
/**
* ObserverCallback 观察者回调函数
*/
export type ObserverCallback = any;

View File

@@ -0,0 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export * from "./models.js";

View File

@@ -0,0 +1,370 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as theme$0 from "./theme/models.js";
/**
* Document is the model entity for the Document schema.
*/
export class Document {
/**
* ID of the ent.
*/
"id"?: number;
/**
* UUID for cross-device sync (UUIDv7)
*/
"uuid": string;
/**
* creation time
*/
"created_at": string;
/**
* update time
*/
"updated_at": string;
/**
* deleted at
*/
"deleted_at"?: string | null;
/**
* document title
*/
"title": string;
/**
* document content
*/
"content": string;
/**
* document locked status
*/
"locked": boolean;
/** Creates a new Document instance. */
constructor($$source: Partial<Document> = {}) {
if (!("uuid" in $$source)) {
this["uuid"] = "";
}
if (!("created_at" in $$source)) {
this["created_at"] = "";
}
if (!("updated_at" in $$source)) {
this["updated_at"] = "";
}
if (!("title" in $$source)) {
this["title"] = "";
}
if (!("content" in $$source)) {
this["content"] = "";
}
if (!("locked" in $$source)) {
this["locked"] = false;
}
Object.assign(this, $$source);
}
/**
* Creates a new Document instance from a string or object.
*/
static createFrom($$source: any = {}): Document {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Document($$parsedSource as Partial<Document>);
}
}
/**
* Extension is the model entity for the Extension schema.
*/
export class Extension {
/**
* ID of the ent.
*/
"id"?: number;
/**
* UUID for cross-device sync (UUIDv7)
*/
"uuid": string;
/**
* creation time
*/
"created_at": string;
/**
* update time
*/
"updated_at": string;
/**
* deleted at
*/
"deleted_at"?: string | null;
/**
* extension name
*/
"name": string;
/**
* extension enabled or not
*/
"enabled": boolean;
/**
* extension config
*/
"config": { [_: string]: any };
/** Creates a new Extension instance. */
constructor($$source: Partial<Extension> = {}) {
if (!("uuid" in $$source)) {
this["uuid"] = "";
}
if (!("created_at" in $$source)) {
this["created_at"] = "";
}
if (!("updated_at" in $$source)) {
this["updated_at"] = "";
}
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("enabled" in $$source)) {
this["enabled"] = false;
}
if (!("config" in $$source)) {
this["config"] = {};
}
Object.assign(this, $$source);
}
/**
* Creates a new Extension instance from a string or object.
*/
static createFrom($$source: any = {}): Extension {
const $$createField7_0 = $$createType0;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("config" in $$parsedSource) {
$$parsedSource["config"] = $$createField7_0($$parsedSource["config"]);
}
return new Extension($$parsedSource as Partial<Extension>);
}
}
/**
* KeyBinding is the model entity for the KeyBinding schema.
*/
export class KeyBinding {
/**
* ID of the ent.
*/
"id"?: number;
/**
* UUID for cross-device sync (UUIDv7)
*/
"uuid": string;
/**
* creation time
*/
"created_at": string;
/**
* update time
*/
"updated_at": string;
/**
* deleted at
*/
"deleted_at"?: string | null;
/**
* command identifier
*/
"name": string;
/**
* keybinding type: standard or emacs
*/
"type": string;
/**
* universal keybinding (cross-platform)
*/
"key"?: string;
/**
* macOS specific keybinding
*/
"macos"?: string;
/**
* Windows specific keybinding
*/
"windows"?: string;
/**
* Linux specific keybinding
*/
"linux"?: string;
/**
* extension name (functional category)
*/
"extension": string;
/**
* whether this keybinding is enabled
*/
"enabled": boolean;
/**
* prevent browser default behavior
*/
"preventDefault": boolean;
/**
* keybinding scope (default: editor)
*/
"scope"?: string;
/** Creates a new KeyBinding instance. */
constructor($$source: Partial<KeyBinding> = {}) {
if (!("uuid" in $$source)) {
this["uuid"] = "";
}
if (!("created_at" in $$source)) {
this["created_at"] = "";
}
if (!("updated_at" in $$source)) {
this["updated_at"] = "";
}
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("type" in $$source)) {
this["type"] = "";
}
if (!("extension" in $$source)) {
this["extension"] = "";
}
if (!("enabled" in $$source)) {
this["enabled"] = false;
}
if (!("preventDefault" in $$source)) {
this["preventDefault"] = false;
}
Object.assign(this, $$source);
}
/**
* Creates a new KeyBinding instance from a string or object.
*/
static createFrom($$source: any = {}): KeyBinding {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new KeyBinding($$parsedSource as Partial<KeyBinding>);
}
}
/**
* Theme is the model entity for the Theme schema.
*/
export class Theme {
/**
* ID of the ent.
*/
"id"?: number;
/**
* UUID for cross-device sync (UUIDv7)
*/
"uuid": string;
/**
* creation time
*/
"created_at": string;
/**
* update time
*/
"updated_at": string;
/**
* deleted at
*/
"deleted_at"?: string | null;
/**
* theme name
*/
"name": string;
/**
* theme type
*/
"type": theme$0.Type;
/**
* theme colors
*/
"colors": { [_: string]: any };
/** Creates a new Theme instance. */
constructor($$source: Partial<Theme> = {}) {
if (!("uuid" in $$source)) {
this["uuid"] = "";
}
if (!("created_at" in $$source)) {
this["created_at"] = "";
}
if (!("updated_at" in $$source)) {
this["updated_at"] = "";
}
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("type" in $$source)) {
this["type"] = ("" as theme$0.Type);
}
if (!("colors" in $$source)) {
this["colors"] = {};
}
Object.assign(this, $$source);
}
/**
* Creates a new Theme instance from a string or object.
*/
static createFrom($$source: any = {}): Theme {
const $$createField7_0 = $$createType0;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("colors" in $$parsedSource) {
$$parsedSource["colors"] = $$createField7_0($$parsedSource["colors"]);
}
return new Theme($$parsedSource as Partial<Theme>);
}
}
// Private type creation functions
const $$createType0 = $Create.Map($Create.Any, $Create.Any);

View File

@@ -0,0 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export * from "./models.js";

View File

@@ -0,0 +1,22 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
/**
* Type defines the type for the "type" enum field.
*/
export enum Type {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Type values.
*/
TypeDark = "dark",
TypeLight = "light",
};

View File

@@ -193,58 +193,6 @@ export class ConfigMetadata {
}
}
/**
* Document represents a document in the system
*/
export class Document {
"id": number;
"title": string;
"content": string;
"createdAt": string;
"updatedAt": string;
"is_deleted": boolean;
/**
* 锁定标志,锁定的文档无法被删除
*/
"is_locked": boolean;
/** Creates a new Document instance. */
constructor($$source: Partial<Document> = {}) {
if (!("id" in $$source)) {
this["id"] = 0;
}
if (!("title" in $$source)) {
this["title"] = "";
}
if (!("content" in $$source)) {
this["content"] = "";
}
if (!("createdAt" in $$source)) {
this["createdAt"] = "";
}
if (!("updatedAt" in $$source)) {
this["updatedAt"] = "";
}
if (!("is_deleted" in $$source)) {
this["is_deleted"] = false;
}
if (!("is_locked" in $$source)) {
this["is_locked"] = false;
}
Object.assign(this, $$source);
}
/**
* Creates a new Document instance from a string or object.
*/
static createFrom($$source: any = {}): Document {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Document($$parsedSource as Partial<Document>);
}
}
/**
* EditingConfig 编辑设置配置
*/
@@ -286,6 +234,12 @@ export class EditingConfig {
*/
"tabType": TabType;
/**
* 快捷键模式
* 快捷键模式standard 或 emacs
*/
"keymapMode": KeyBindingType;
/**
* 保存选项
* 自动保存延迟(毫秒)
@@ -315,6 +269,9 @@ export class EditingConfig {
if (!("tabType" in $$source)) {
this["tabType"] = ("" as TabType);
}
if (!("keymapMode" in $$source)) {
this["keymapMode"] = ("" as KeyBindingType);
}
if (!("autoSaveDelay" in $$source)) {
this["autoSaveDelay"] = 0;
}
@@ -332,40 +289,21 @@ export class EditingConfig {
}
/**
* Extension 单个扩展配置
* Extension 扩展配置
*/
export class Extension {
/**
* 扩展唯一标识
*/
"id": ExtensionID;
/**
* 是否启用
*/
"key": ExtensionName;
"enabled": boolean;
/**
* 是否为默认扩展
*/
"isDefault": boolean;
/**
* 扩展配置项
*/
"config": ExtensionConfig;
/** Creates a new Extension instance. */
constructor($$source: Partial<Extension> = {}) {
if (!("id" in $$source)) {
this["id"] = ("" as ExtensionID);
if (!("key" in $$source)) {
this["key"] = ("" as ExtensionName);
}
if (!("enabled" in $$source)) {
this["enabled"] = false;
}
if (!("isDefault" in $$source)) {
this["isDefault"] = false;
}
if (!("config" in $$source)) {
this["config"] = ({} as ExtensionConfig);
}
@@ -377,10 +315,10 @@ export class Extension {
* Creates a new Extension instance from a string or object.
*/
static createFrom($$source: any = {}): Extension {
const $$createField3_0 = $$createType6;
const $$createField2_0 = $$createType6;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("config" in $$parsedSource) {
$$parsedSource["config"] = $$createField3_0($$parsedSource["config"]);
$$parsedSource["config"] = $$createField2_0($$parsedSource["config"]);
}
return new Extension($$parsedSource as Partial<Extension>);
}
@@ -392,59 +330,83 @@ export class Extension {
export type ExtensionConfig = { [_: string]: any };
/**
* ExtensionID 扩展标识符
* ExtensionName 扩展标识符
*/
export enum ExtensionID {
export enum ExtensionName {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* 编辑增强扩展
* 彩虹括号
*/
ExtensionRainbowBrackets = "rainbowBrackets",
RainbowBrackets = "rainbowBrackets",
/**
* 超链接
*/
ExtensionHyperlink = "hyperlink",
Hyperlink = "hyperlink",
/**
* 颜色选择器
*/
ExtensionColorSelector = "colorSelector",
ExtensionFold = "fold",
ExtensionTextHighlight = "textHighlight",
ColorSelector = "colorSelector",
/**
* 选择框
* 代码折叠
*/
ExtensionCheckbox = "checkbox",
Fold = "fold",
/**
* 划词翻译
*/
ExtensionTranslator = "translator",
Translator = "translator",
/**
* Markdown渲染
*/
Markdown = "markdown",
/**
* 显示空白字符
*/
HighlightWhitespace = "highlightWhitespace",
/**
* 高亮行尾空白
*/
HighlightTrailingWhitespace = "highlightTrailingWhitespace",
/**
* UI增强扩展
* 小地图
*/
ExtensionMinimap = "minimap",
Minimap = "minimap",
/**
* 行号显示
*/
LineNumbers = "lineNumbers",
/**
* 上下文菜单
*/
ContextMenu = "contextMenu",
/**
* 工具扩展
* 搜索功能
*/
ExtensionSearch = "search",
Search = "search",
/**
* 核心扩展
* 编辑器核心功能
* HTTP 客户端
*/
ExtensionEditor = "editor",
HttpClient = "httpClient",
/**
* 代码块导出图片
*/
BlockImage = "blockImage",
};
/**
@@ -736,19 +698,39 @@ export class HotkeyCombo {
*/
export class KeyBinding {
/**
* 快捷键动作
* 命令唯一标识符
*/
"command": KeyBindingCommand;
"name": KeyBindingName;
/**
* 快捷键类型standard 或 "emacs"
*/
"type": KeyBindingType;
/**
* 通用快捷键(跨平台)
*/
"key"?: string;
/**
* macOS 专用快捷键
*/
"macos"?: string;
/**
* windows 专用快捷键
*/
"win"?: string;
/**
* Linux 专用快捷键
*/
"linux"?: string;
/**
* 所属扩展
*/
"extension": ExtensionID;
/**
* 快捷键组合(如 "Mod-f", "Ctrl-Shift-p"
*/
"key": string;
"extension": ExtensionName;
/**
* 是否启用
@@ -756,26 +738,31 @@ export class KeyBinding {
"enabled": boolean;
/**
* 是否为默认快捷键
* 阻止浏览器默认行为
*/
"isDefault": boolean;
"preventDefault": boolean;
/**
* 作用域(默认 "editor"
*/
"scope"?: string;
/** Creates a new KeyBinding instance. */
constructor($$source: Partial<KeyBinding> = {}) {
if (!("command" in $$source)) {
this["command"] = ("" as KeyBindingCommand);
if (!("name" in $$source)) {
this["name"] = ("" as KeyBindingName);
}
if (!("type" in $$source)) {
this["type"] = ("" as KeyBindingType);
}
if (!("extension" in $$source)) {
this["extension"] = ("" as ExtensionID);
}
if (!("key" in $$source)) {
this["key"] = "";
this["extension"] = ("" as ExtensionName);
}
if (!("enabled" in $$source)) {
this["enabled"] = false;
}
if (!("isDefault" in $$source)) {
this["isDefault"] = false;
if (!("preventDefault" in $$source)) {
this["preventDefault"] = false;
}
Object.assign(this, $$source);
@@ -791,294 +778,425 @@ export class KeyBinding {
}
/**
* KeyBindingCommand 快捷键命令
* KeyBindingName 快捷键命令标识符
*/
export enum KeyBindingCommand {
export enum KeyBindingName {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* 搜索扩展相关
* 显示搜索
*/
ShowSearchCommand = "showSearch",
ShowSearch = "showSearch",
/**
* 隐藏搜索
*/
HideSearchCommand = "hideSearch",
HideSearch = "hideSearch",
/**
* 搜索切换大小写
*/
SearchToggleCaseCommand = "searchToggleCase",
/**
* 搜索切换整词
*/
SearchToggleWordCommand = "searchToggleWord",
/**
* 搜索切换正则
*/
SearchToggleRegexCommand = "searchToggleRegex",
/**
* 显示替换
*/
SearchShowReplaceCommand = "searchShowReplace",
/**
* 替换全部
*/
SearchReplaceAllCommand = "searchReplaceAll",
/**
* 代码块扩展相关
* 块内选择全部
*/
BlockSelectAllCommand = "blockSelectAll",
BlockSelectAll = "blockSelectAll",
/**
* 在当前块后添加新块
*/
BlockAddAfterCurrentCommand = "blockAddAfterCurrent",
BlockAddAfterCurrent = "blockAddAfterCurrent",
/**
* 在最后添加新块
*/
BlockAddAfterLastCommand = "blockAddAfterLast",
BlockAddAfterLast = "blockAddAfterLast",
/**
* 在当前块前添加新块
*/
BlockAddBeforeCurrentCommand = "blockAddBeforeCurrent",
BlockAddBeforeCurrent = "blockAddBeforeCurrent",
/**
* 跳转到上一个块
*/
BlockGotoPreviousCommand = "blockGotoPrevious",
BlockGotoPrevious = "blockGotoPrevious",
/**
* 跳转到下一个块
*/
BlockGotoNextCommand = "blockGotoNext",
BlockGotoNext = "blockGotoNext",
/**
* 选择上一个块
*/
BlockSelectPreviousCommand = "blockSelectPrevious",
BlockSelectPrevious = "blockSelectPrevious",
/**
* 选择下一个块
*/
BlockSelectNextCommand = "blockSelectNext",
BlockSelectNext = "blockSelectNext",
/**
* 删除当前块
*/
BlockDeleteCommand = "blockDelete",
BlockDelete = "blockDelete",
/**
* 向上移动当前块
*/
BlockMoveUpCommand = "blockMoveUp",
BlockMoveUp = "blockMoveUp",
/**
* 向下移动当前块
*/
BlockMoveDownCommand = "blockMoveDown",
BlockMoveDown = "blockMoveDown",
/**
* 删除行
*/
BlockDeleteLineCommand = "blockDeleteLine",
BlockDeleteLine = "blockDeleteLine",
/**
* 向上移动行
*/
BlockMoveLineUpCommand = "blockMoveLineUp",
BlockMoveLineUp = "blockMoveLineUp",
/**
* 向下移动行
*/
BlockMoveLineDownCommand = "blockMoveLineDown",
BlockMoveLineDown = "blockMoveLineDown",
/**
* 字符转置
*/
BlockTransposeCharsCommand = "blockTransposeChars",
BlockTransposeChars = "blockTransposeChars",
/**
* 格式化代码块
*/
BlockFormatCommand = "blockFormat",
BlockFormat = "blockFormat",
/**
* 复制
*/
BlockCopyCommand = "blockCopy",
BlockCopy = "blockCopy",
/**
* 剪切
*/
BlockCutCommand = "blockCut",
BlockCut = "blockCut",
/**
* 粘贴
*/
BlockPasteCommand = "blockPaste",
BlockPaste = "blockPaste",
/**
* 代码折叠扩展相关
* 折叠代码
*/
FoldCodeCommand = "foldCode",
FoldCode = "foldCode",
/**
* 展开代码
*/
UnfoldCodeCommand = "unfoldCode",
UnfoldCode = "unfoldCode",
/**
* 折叠全部
*/
FoldAllCommand = "foldAll",
FoldAll = "foldAll",
/**
* 展开全部
*/
UnfoldAllCommand = "unfoldAll",
UnfoldAll = "unfoldAll",
/**
* 通用编辑扩展相关
* 光标按语法左移
*/
CursorSyntaxLeftCommand = "cursorSyntaxLeft",
CursorSyntaxLeft = "cursorSyntaxLeft",
/**
* 光标按语法右移
*/
CursorSyntaxRightCommand = "cursorSyntaxRight",
CursorSyntaxRight = "cursorSyntaxRight",
/**
* 按语法选择左侧
*/
SelectSyntaxLeftCommand = "selectSyntaxLeft",
SelectSyntaxLeft = "selectSyntaxLeft",
/**
* 按语法选择右侧
*/
SelectSyntaxRightCommand = "selectSyntaxRight",
SelectSyntaxRight = "selectSyntaxRight",
/**
* 向上复制行
*/
CopyLineUpCommand = "copyLineUp",
CopyLineUp = "copyLineUp",
/**
* 向下复制行
*/
CopyLineDownCommand = "copyLineDown",
CopyLineDown = "copyLineDown",
/**
* 插入空行
*/
InsertBlankLineCommand = "insertBlankLine",
InsertBlankLine = "insertBlankLine",
/**
* 选择行
*/
SelectLineCommand = "selectLine",
SelectLine = "selectLine",
/**
* 选择父级语法
*/
SelectParentSyntaxCommand = "selectParentSyntax",
SelectParentSyntax = "selectParentSyntax",
/**
* 简化选择
*/
SimplifySelection = "simplifySelection",
/**
* 在上方添加光标
*/
AddCursorAbove = "addCursorAbove",
/**
* 在下方添加光标
*/
AddCursorBelow = "addCursorBelow",
/**
* 光标按单词左移
*/
CursorGroupLeft = "cursorGroupLeft",
/**
* 光标按单词右移
*/
CursorGroupRight = "cursorGroupRight",
/**
* 按单词选择左侧
*/
SelectGroupLeft = "selectGroupLeft",
/**
* 按单词选择右侧
*/
SelectGroupRight = "selectGroupRight",
/**
* 删除到行尾
*/
DeleteToLineEnd = "deleteToLineEnd",
/**
* 删除到行首
*/
DeleteToLineStart = "deleteToLineStart",
/**
* 移动到行首
*/
CursorLineStart = "cursorLineStart",
/**
* 移动到行尾
*/
CursorLineEnd = "cursorLineEnd",
/**
* 选择到行首
*/
SelectLineStart = "selectLineStart",
/**
* 选择到行尾
*/
SelectLineEnd = "selectLineEnd",
/**
* 跳转到文档开头
*/
CursorDocStart = "cursorDocStart",
/**
* 跳转到文档结尾
*/
CursorDocEnd = "cursorDocEnd",
/**
* 选择到文档开头
*/
SelectDocStart = "selectDocStart",
/**
* 选择到文档结尾
*/
SelectDocEnd = "selectDocEnd",
/**
* 选择到匹配括号
*/
SelectMatchingBracket = "selectMatchingBracket",
/**
* 分割行
*/
SplitLine = "splitLine",
/**
* 光标左移一个字符
*/
CursorCharLeft = "cursorCharLeft",
/**
* 光标右移一个字符
*/
CursorCharRight = "cursorCharRight",
/**
* 光标上移一行
*/
CursorLineUp = "cursorLineUp",
/**
* 光标下移一行
*/
CursorLineDown = "cursorLineDown",
/**
* 向上翻页
*/
CursorPageUp = "cursorPageUp",
/**
* 向下翻页
*/
CursorPageDown = "cursorPageDown",
/**
* 选择左移一个字符
*/
SelectCharLeft = "selectCharLeft",
/**
* 选择右移一个字符
*/
SelectCharRight = "selectCharRight",
/**
* 选择上移一行
*/
SelectLineUp = "selectLineUp",
/**
* 选择下移一行
*/
SelectLineDown = "selectLineDown",
/**
* 减少缩进
*/
IndentLessCommand = "indentLess",
IndentLess = "indentLess",
/**
* 增加缩进
*/
IndentMoreCommand = "indentMore",
IndentMore = "indentMore",
/**
* 缩进选择
*/
IndentSelectionCommand = "indentSelection",
IndentSelection = "indentSelection",
/**
* 光标到匹配括号
*/
CursorMatchingBracketCommand = "cursorMatchingBracket",
CursorMatchingBracket = "cursorMatchingBracket",
/**
* 切换注释
*/
ToggleCommentCommand = "toggleComment",
ToggleComment = "toggleComment",
/**
* 切换块注释
*/
ToggleBlockCommentCommand = "toggleBlockComment",
ToggleBlockComment = "toggleBlockComment",
/**
* 插入新行并缩进
*/
InsertNewlineAndIndentCommand = "insertNewlineAndIndent",
InsertNewlineAndIndent = "insertNewlineAndIndent",
/**
* 向后删除字符
*/
DeleteCharBackwardCommand = "deleteCharBackward",
DeleteCharBackward = "deleteCharBackward",
/**
* 向前删除字符
*/
DeleteCharForwardCommand = "deleteCharForward",
DeleteCharForward = "deleteCharForward",
/**
* 向后删除组
*/
DeleteGroupBackwardCommand = "deleteGroupBackward",
DeleteGroupBackward = "deleteGroupBackward",
/**
* 向前删除组
*/
DeleteGroupForwardCommand = "deleteGroupForward",
DeleteGroupForward = "deleteGroupForward",
/**
* 历史记录扩展相关
* 撤销
*/
HistoryUndoCommand = "historyUndo",
HistoryUndo = "historyUndo",
/**
* 重做
*/
HistoryRedoCommand = "historyRedo",
HistoryRedo = "historyRedo",
/**
* 撤销选择
*/
HistoryUndoSelectionCommand = "historyUndoSelection",
HistoryUndoSelection = "historyUndoSelection",
/**
* 重做选择
*/
HistoryRedoSelectionCommand = "historyRedoSelection",
HistoryRedoSelection = "historyRedoSelection",
/**
* 文本高亮扩展相关
* 切换文本高亮
* 复制块为图片
*/
TextHighlightToggleCommand = "textHighlightToggle",
CopyBlockImage = "copyBlockImage",
};
export enum KeyBindingType {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* standard 标准快捷键
*/
Standard = "standard",
/**
* emacs 快捷键
*/
Emacs = "emacs",
};
/**
@@ -1146,76 +1264,6 @@ export enum TabType {
TabTypeTab = "tab",
};
/**
* Theme 主题数据库模型
*/
export class Theme {
"id": number;
"name": string;
"type": ThemeType;
"colors": ThemeColorConfig;
"isDefault": boolean;
"createdAt": string;
"updatedAt": string;
/** Creates a new Theme instance. */
constructor($$source: Partial<Theme> = {}) {
if (!("id" in $$source)) {
this["id"] = 0;
}
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("type" in $$source)) {
this["type"] = ("" as ThemeType);
}
if (!("colors" in $$source)) {
this["colors"] = ({} as ThemeColorConfig);
}
if (!("isDefault" in $$source)) {
this["isDefault"] = false;
}
if (!("createdAt" in $$source)) {
this["createdAt"] = "";
}
if (!("updatedAt" in $$source)) {
this["updatedAt"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new Theme instance from a string or object.
*/
static createFrom($$source: any = {}): Theme {
const $$createField3_0 = $$createType9;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("colors" in $$parsedSource) {
$$parsedSource["colors"] = $$createField3_0($$parsedSource["colors"]);
}
return new Theme($$parsedSource as Partial<Theme>);
}
}
/**
* ThemeColorConfig 使用与前端 ThemeColors 相同的结构,存储任意主题键值
*/
export type ThemeColorConfig = { [_: string]: any };
/**
* ThemeType 主题类型枚举
*/
export enum ThemeType {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
ThemeTypeDark = "dark",
ThemeTypeLight = "light",
};
/**
* UpdateSourceType 更新源类型
*/
@@ -1314,8 +1362,8 @@ export class UpdatesConfig {
* Creates a new UpdatesConfig instance from a string or object.
*/
static createFrom($$source: any = {}): UpdatesConfig {
const $$createField6_0 = $$createType10;
const $$createField7_0 = $$createType11;
const $$createField6_0 = $$createType9;
const $$createField7_0 = $$createType10;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("github" in $$parsedSource) {
$$parsedSource["github"] = $$createField6_0($$parsedSource["github"]);
@@ -1342,11 +1390,5 @@ var $$createType6 = (function $$initCreateType6(...args): any {
});
const $$createType7 = $Create.Map($Create.Any, $Create.Any);
const $$createType8 = HotkeyCombo.createFrom;
var $$createType9 = (function $$initCreateType9(...args): any {
if ($$createType9 === $$initCreateType9) {
$$createType9 = $$createType7;
}
return $$createType9(...args);
});
const $$createType10 = GithubConfig.createFrom;
const $$createType11 = GiteaConfig.createFrom;
const $$createType9 = GithubConfig.createFrom;
const $$createType10 = GiteaConfig.createFrom;

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT
/**
* BackupService 提供基于Git的备份功能
* BackupService 提供基于Git的备份同步功能
* @module
*/
@@ -18,7 +18,7 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
import * as models$0 from "../models/models.js";
/**
* HandleConfigChange 处理备份配置变更
* HandleConfigChange 处理配置变更
*/
export function HandleConfigChange(config: models$0.GitBackupConfig | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(395287784, config) as any;
@@ -34,15 +34,7 @@ export function Initialize(): Promise<void> & { cancel(): void } {
}
/**
* PushToRemote 推送本地更改到远程仓库
*/
export function PushToRemote(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(262644139) as any;
return $resultPromise;
}
/**
* Reinitialize 重新初始化备份服务,用于响应配置变更
* Reinitialize 重新初始化
*/
export function Reinitialize(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(301562543) as any;
@@ -50,7 +42,7 @@ export function Reinitialize(): Promise<void> & { cancel(): void } {
}
/**
* ServiceShutdown 服务关闭时的清理工作
* ServiceShutdown 服务关闭
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(422131801) as any;
@@ -63,7 +55,7 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
}
/**
* StartAutoBackup 启动自动备份定时器
* StartAutoBackup 启动自动备份
*/
export function StartAutoBackup(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3035755449) as any;
@@ -77,3 +69,11 @@ export function StopAutoBackup(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2641894021) as any;
return $resultPromise;
}
/**
* Sync 执行完整的同步流程:导出 -> commit -> pull -> 解决冲突 -> push -> 导入
*/
export function Sync(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3420383211) as any;
return $resultPromise;
}

View File

@@ -15,11 +15,10 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
import * as helper$0 from "../common/helper/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
import * as models$0 from "../models/models.js";
/**
* Get 获取配置项
@@ -50,7 +49,7 @@ export function MigrateConfig(): Promise<void> & { cancel(): void } {
}
/**
* ResetConfig 强制重置所有配置为默认值
* ResetConfig 重置所有配置为默认值
*/
export function ResetConfig(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3593047389) as any;
@@ -66,7 +65,7 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
}
/**
* ServiceStartup initializes the service when the application starts
* ServiceStartup 服务启动时初始化
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3311949428, options) as any;
@@ -84,7 +83,7 @@ export function Set(key: string, value: any): Promise<void> & { cancel(): void }
/**
* Watch 注册配置变更监听器
*/
export function Watch(path: string, callback: $models.ObserverCallback): Promise<$models.CancelFunc> & { cancel(): void } {
export function Watch(path: string, callback: helper$0.ObserverCallback): Promise<helper$0.CancelFunc> & { cancel(): void } {
let $resultPromise = $Call.ByID(1143583035, path, callback) as any;
return $resultPromise;
}
@@ -92,7 +91,7 @@ export function Watch(path: string, callback: $models.ObserverCallback): Promise
/**
* WatchWithContext 使用 Context 注册监听器
*/
export function WatchWithContext(path: string, callback: $models.ObserverCallback): Promise<void> & { cancel(): void } {
export function WatchWithContext(path: string, callback: helper$0.ObserverCallback): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1454973098, path, callback) as any;
return $resultPromise;
}

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT
/**
* DatabaseService provides shared database functionality
* DatabaseService 数据库服务
* @module
*/
@@ -15,15 +15,7 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
/**
* RegisterModel 注册模型与表的映射关系
*/
export function RegisterModel(tableName: string, model: any): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(175397515, tableName, model) as any;
return $resultPromise;
}
/**
* ServiceShutdown shuts down the service when the application closes
* ServiceShutdown 服务关闭
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3907893632) as any;
@@ -31,7 +23,7 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
}
/**
* ServiceStartup initializes the service when the application starts
* ServiceStartup 服务启动
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2067840771, options) as any;

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT
/**
* DocumentService provides document management functionality
* DocumentService 文档服务
* @module
*/
@@ -15,12 +15,12 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
import * as ent$0 from "../models/ent/models.js";
/**
* CreateDocument creates a new document and returns the created document with ID
* CreateDocument 创建文档
*/
export function CreateDocument(title: string): Promise<models$0.Document | null> & { cancel(): void } {
export function CreateDocument(title: string): Promise<ent$0.Document | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(3360680842, title) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
@@ -30,7 +30,7 @@ export function CreateDocument(title: string): Promise<models$0.Document | null>
}
/**
* DeleteDocument marks a document as deleted (default document with ID=1 cannot be deleted)
* DeleteDocument 删除文档
*/
export function DeleteDocument(id: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(412287269, id) as any;
@@ -38,9 +38,9 @@ export function DeleteDocument(id: number): Promise<void> & { cancel(): void } {
}
/**
* GetDocumentByID gets a document by ID
* GetDocumentByID 根据ID获取文档
*/
export function GetDocumentByID(id: number): Promise<models$0.Document | null> & { cancel(): void } {
export function GetDocumentByID(id: number): Promise<ent$0.Document | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(3468193232, id) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
@@ -50,9 +50,9 @@ export function GetDocumentByID(id: number): Promise<models$0.Document | null> &
}
/**
* ListAllDocumentsMeta lists all active (non-deleted) document metadata
* ListAllDocumentsMeta 列出所有文档
*/
export function ListAllDocumentsMeta(): Promise<(models$0.Document | null)[]> & { cancel(): void } {
export function ListAllDocumentsMeta(): Promise<(ent$0.Document | null)[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(3073950297) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType2($result);
@@ -62,19 +62,7 @@ export function ListAllDocumentsMeta(): Promise<(models$0.Document | null)[]> &
}
/**
* ListDeletedDocumentsMeta lists all deleted document metadata
*/
export function ListDeletedDocumentsMeta(): Promise<(models$0.Document | null)[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(490143787) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType2($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* LockDocument 锁定文档,防止删除
* LockDocument 锁定文档
*/
export function LockDocument(id: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1889494473, id) as any;
@@ -82,15 +70,7 @@ export function LockDocument(id: number): Promise<void> & { cancel(): void } {
}
/**
* RestoreDocument restores a deleted document
*/
export function RestoreDocument(id: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(784200778, id) as any;
return $resultPromise;
}
/**
* ServiceStartup initializes the service when the application starts
* ServiceStartup 服务启动
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1474135487, options) as any;
@@ -106,7 +86,7 @@ export function UnlockDocument(id: number): Promise<void> & { cancel(): void } {
}
/**
* UpdateDocumentContent updates the content of a document
* UpdateDocumentContent 更新文档内容
*/
export function UpdateDocumentContent(id: number, content: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3251897116, id, content) as any;
@@ -114,7 +94,7 @@ export function UpdateDocumentContent(id: number, content: string): Promise<void
}
/**
* UpdateDocumentTitle updates the title of a document
* UpdateDocumentTitle 更新文档标题
*/
export function UpdateDocumentTitle(id: number, title: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2045530459, id, title) as any;
@@ -122,6 +102,6 @@ export function UpdateDocumentTitle(id: number, title: string): Promise<void> &
}
// Private type creation functions
const $$createType0 = models$0.Document.createFrom;
const $$createType0 = ent$0.Document.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Array($$createType1);

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT
/**
* ExtensionService 扩展管理服务
* ExtensionService 扩展服务
* @module
*/
@@ -16,12 +16,15 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as ent$0 from "../models/ent/models.js";
/**
* GetAllExtensions 获取所有扩展配置
* GetDefaultExtensions 获取默认扩展配置(用于前端绑定生成)
*/
export function GetAllExtensions(): Promise<models$0.Extension[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(3094292124) as any;
export function GetDefaultExtensions(): Promise<models$0.Extension[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(4036328166) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
@@ -30,23 +33,51 @@ export function GetAllExtensions(): Promise<models$0.Extension[]> & { cancel():
}
/**
* ResetAllExtensionsToDefault 重置所有扩展到默认状态
* GetExtensionByID 根据ID获取扩展
*/
export function ResetAllExtensionsToDefault(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(270611949) as any;
export function GetExtensionByID(id: number): Promise<ent$0.Extension | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(1521424252, id) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType3($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetExtensionConfig 获取扩展配置
*/
export function GetExtensionConfig(id: number): Promise<{ [_: string]: any }> & { cancel(): void } {
let $resultPromise = $Call.ByID(1629559882, id) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType4($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetExtensions 获取所有扩展
*/
export function GetExtensions(): Promise<(ent$0.Extension | null)[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(3179289021) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType5($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* ResetExtensionConfig 重置单个扩展到默认状态
*/
export function ResetExtensionConfig(id: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3990780299, id) as any;
return $resultPromise;
}
/**
* ResetExtensionToDefault 重置扩展到默认状态
*/
export function ResetExtensionToDefault(id: models$0.ExtensionID): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(868308101, id) as any;
return $resultPromise;
}
/**
* ServiceStartup 启动时调用
* ServiceStartup 服务启动
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(40324057, options) as any;
@@ -54,21 +85,33 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
}
/**
* UpdateExtensionEnabled 更新扩展启用状态
* SyncExtensions 同步扩展配置
*/
export function UpdateExtensionEnabled(id: models$0.ExtensionID, enabled: boolean): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1067300094, id, enabled) as any;
export function SyncExtensions(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(167560004) as any;
return $resultPromise;
}
/**
* UpdateExtensionState 更新扩展状态
* UpdateExtensionConfig 更新扩展配置
*/
export function UpdateExtensionState(id: models$0.ExtensionID, enabled: boolean, config: models$0.ExtensionConfig): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2917946454, id, enabled, config) as any;
export function UpdateExtensionConfig(id: number, config: { [_: string]: any }): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3184142503, id, config) as any;
return $resultPromise;
}
/**
* UpdateExtensionEnabled 更新扩展启用状态
*/
export function UpdateExtensionEnabled(id: number, enabled: boolean): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1067300094, id, enabled) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = models$0.Extension.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = ent$0.Extension.createFrom;
const $$createType3 = $Create.Nullable($$createType2);
const $$createType4 = $Create.Map($Create.Any, $Create.Any);
const $$createType5 = $Create.Array($$createType3);

View File

@@ -18,12 +18,12 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
import * as models$0 from "../models/models.js";
/**
* GetCurrentHotkey 获取当前热键
* GetSupportedKeys 返回系统支持的快捷键列表
*/
export function GetCurrentHotkey(): Promise<models$0.HotkeyCombo | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(2572811187) as any;
export function GetSupportedKeys(): Promise<string[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(1511528650) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
return $$createType0($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
@@ -86,5 +86,4 @@ export function UpdateHotkey(enable: boolean, combo: models$0.HotkeyCombo | null
}
// Private type creation functions
const $$createType0 = models$0.HotkeyCombo.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType0 = $Create.Array($Create.Any);

View File

@@ -17,7 +17,6 @@ import * as SystemService from "./systemservice.js";
import * as TestService from "./testservice.js";
import * as ThemeService from "./themeservice.js";
import * as TranslationService from "./translationservice.js";
import * as TrayService from "./trayservice.js";
import * as WindowService from "./windowservice.js";
export {
BackupService,
@@ -36,7 +35,6 @@ export {
TestService,
ThemeService,
TranslationService,
TrayService,
WindowService
};

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT
/**
* KeyBindingService 快捷键管理服务
* KeyBindingService 快捷键服务
* @module
*/
@@ -16,12 +16,15 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as ent$0 from "../models/ent/models.js";
/**
* GetAllKeyBindings 获取所有快捷键配置
* GetDefaultKeyBindings 获取默认快捷键配置
*/
export function GetAllKeyBindings(): Promise<models$0.KeyBinding[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(1633502882) as any;
export function GetDefaultKeyBindings(): Promise<models$0.KeyBinding[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(3843471588) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
@@ -30,13 +33,72 @@ export function GetAllKeyBindings(): Promise<models$0.KeyBinding[]> & { cancel()
}
/**
* ServiceStartup 启动时调用
* GetKeyBindingByID 根据ID获取快捷键
*/
export function GetKeyBindingByID(id: number): Promise<ent$0.KeyBinding | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(1578192526, id) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType3($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetKeyBindings 根据类型获取快捷键
*/
export function GetKeyBindings(kbType: models$0.KeyBindingType): Promise<(ent$0.KeyBinding | null)[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(4253885163, kbType) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType4($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* ResetKeyBindings 重置所有快捷键到默认值
*/
export function ResetKeyBindings(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(4251626010) as any;
return $resultPromise;
}
/**
* ServiceStartup 服务启动
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2057121990, options) as any;
return $resultPromise;
}
/**
* SyncKeyBindings 同步快捷键配置
*/
export function SyncKeyBindings(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1522202638) as any;
return $resultPromise;
}
/**
* UpdateKeyBindingEnabled 更新快捷键启用状态
*/
export function UpdateKeyBindingEnabled(id: number, enabled: boolean): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(843626124, id, enabled) as any;
return $resultPromise;
}
/**
* UpdateKeyBindingKeys 更新快捷键绑定(根据操作系统自动判断更新哪个字段)
*/
export function UpdateKeyBindingKeys(id: number, key: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3432755175, id, key) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = models$0.KeyBinding.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = ent$0.KeyBinding.createFrom;
const $$createType3 = $Create.Nullable($$createType2);
const $$createType4 = $Create.Array($$createType3);

View File

@@ -12,12 +12,6 @@ import * as http$0 from "../../../net/http/models.js";
// @ts-ignore: Unused imports
import * as time$0 from "../../../time/models.js";
/**
* CancelFunc 取消订阅函数
* 调用此函数可以取消对配置的监听
*/
export type CancelFunc = any;
/**
* HttpRequest HTTP请求结构
*/
@@ -191,15 +185,14 @@ export class MemoryStats {
* MigrationProgress 迁移进度信息
*/
export class MigrationProgress {
"status": MigrationStatus;
/**
* 0-100
*/
"progress": number;
"error"?: string;
/** Creates a new MigrationProgress instance. */
constructor($$source: Partial<MigrationProgress> = {}) {
if (!("status" in $$source)) {
this["status"] = ("" as MigrationStatus);
}
if (!("progress" in $$source)) {
this["progress"] = 0;
}
@@ -216,20 +209,6 @@ export class MigrationProgress {
}
}
/**
* MigrationStatus 迁移状态
*/
export enum MigrationStatus {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
MigrationStatusMigrating = "migrating",
MigrationStatusCompleted = "completed",
MigrationStatusFailed = "failed",
};
/**
* OSInfo 操作系统信息
*/
@@ -266,11 +245,6 @@ export class OSInfo {
}
}
/**
* ObserverCallback 观察者回调函数
*/
export type ObserverCallback = any;
/**
* SelfUpdateResult 自我更新结果
*/

View File

@@ -15,12 +15,12 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
import * as ent$0 from "../models/ent/models.js";
/**
* GetThemeByName 通过名称获取主题覆盖,若不存在则返回 nil
* GetThemeByName 根据Key获取主题
*/
export function GetThemeByName(name: string): Promise<models$0.Theme | null> & { cancel(): void } {
export function GetThemeByName(name: string): Promise<ent$0.Theme | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(1938954770, name) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
@@ -30,18 +30,10 @@ export function GetThemeByName(name: string): Promise<models$0.Theme | null> & {
}
/**
* ResetTheme 删除指定主题的覆盖配置
* ResetTheme 删除主题
*/
export function ResetTheme(name: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1806334457, name) as any;
return $resultPromise;
}
/**
* ServiceShutdown 服务关闭
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1676749034) as any;
export function ResetTheme(key: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1806334457, key) as any;
return $resultPromise;
}
@@ -54,13 +46,13 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
}
/**
* UpdateTheme 保存或更新主题覆盖
* UpdateTheme 保存或更新主题
*/
export function UpdateTheme(name: string, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(70189749, name, colors) as any;
export function UpdateTheme(key: string, colors: { [_: string]: any }): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(70189749, key, colors) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = models$0.Theme.createFrom;
const $$createType0 = ent$0.Theme.createFrom;
const $$createType1 = $Create.Nullable($$createType0);

View File

@@ -1,59 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* TrayService 系统托盘服务
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
/**
* AutoShowHide 自动显示/隐藏主窗口
*/
export function AutoShowHide(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(4044219428) as any;
return $resultPromise;
}
/**
* HandleWindowClose 处理窗口关闭事件
*/
export function HandleWindowClose(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1824247204) as any;
return $resultPromise;
}
/**
* HandleWindowMinimize 处理窗口最小化事件
*/
export function HandleWindowMinimize(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(178686624) as any;
return $resultPromise;
}
/**
* MinimizeButtonClicked 处理标题栏最小化按钮点击
*/
export function MinimizeButtonClicked(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2477618539) as any;
return $resultPromise;
}
/**
* ShouldMinimizeToTray 检查是否应该最小化到托盘
*/
export function ShouldMinimizeToTray(): Promise<boolean> & { cancel(): void } {
let $resultPromise = $Call.ByID(3403884012) as any;
return $resultPromise;
}
/**
* ShowWindow 显示主窗口
*/
export function ShowWindow(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1315913255) as any;
return $resultPromise;
}

View File

@@ -14,18 +14,6 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
/**
* GetOpenWindows 获取所有打开的文档窗口
*/
export function GetOpenWindows(): Promise<application$0.Window[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(1464997251) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType0($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* IsDocumentWindowOpen 检查指定文档的窗口是否已打开
*/
@@ -57,6 +45,3 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
let $resultPromise = $Call.ByID(2432987694, options) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = $Create.Array($Create.Any);

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.0",
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
@@ -34,7 +34,7 @@
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-lezer": "^6.0.2",
"@codemirror/lang-liquid": "^6.3.0",
"@codemirror/lang-liquid": "^6.3.1",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
@@ -44,62 +44,63 @@
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.3",
"@codemirror/language": "^6.12.1",
"@codemirror/language-data": "^6.5.2",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/lint": "^6.9.2",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.8",
"@codemirror/state": "^6.5.3",
"@codemirror/view": "^6.39.6",
"@cospaia/prettier-plugin-clojure": "^0.0.2",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.4",
"@lezer/lr": "^1.4.5",
"@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",
"@toml-tools/lexer": "^1.0.1",
"@toml-tools/parser": "^1.0.1",
"@types/katex": "^0.16.7",
"@zumer/snapdom": "^2.0.1",
"codemirror": "^6.0.2",
"codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.2",
"colors-named-hex": "^1.0.2",
"colors-named": "^1.0.4",
"colors-named-hex": "^1.0.3",
"groovy-beautify": "^0.0.17",
"hsl-matcher": "^1.2.4",
"java-parser": "^3.0.1",
"katex": "^0.16.25",
"linguist-languages": "^9.1.0",
"katex": "^0.16.27",
"linguist-languages": "^9.1.11",
"marked": "^17.0.1",
"mermaid": "^11.12.1",
"mermaid": "^11.12.2",
"php-parser": "^3.2.5",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"prettier": "^3.7.2",
"sass": "^1.94.2",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"prettier": "^3.7.4",
"sass": "^1.97.1",
"vue": "^3.5.26",
"vue-i18n": "^11.2.7",
"vue-pick-colors": "^1.8.0",
"vue-router": "^4.6.3"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@eslint/js": "^9.39.2",
"@lezer/generator": "^1.8.0",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@wailsio/runtime": "latest",
"@types/node": "^25.0.3",
"@vitejs/plugin-vue": "^6.0.3",
"@wailsio/runtime": "^3.0.0-alpha.77",
"cross-env": "^10.1.0",
"eslint": "^9.39.1",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0",
"happy-dom": "^20.0.11",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0",
"typescript-eslint": "^8.50.1",
"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.14",
"vitest": "^4.0.16",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.1.5"
"vue-tsc": "^3.2.1"
},
"overrides": {
"vite": "npm:rolldown-vite@latest"

View File

@@ -19,13 +19,13 @@ onBeforeMount(async () => {
// 并行初始化配置、系统信息和快捷键配置
await Promise.all([
configStore.initConfig(),
systemStore.initializeSystemInfo(),
systemStore.initSystemInfo(),
keybindingStore.loadKeyBindings(),
]);
// 初始化语言和主题
await configStore.initializeLanguage();
await themeStore.initializeTheme();
await configStore.initLanguage();
await themeStore.initTheme();
await translationStore.loadTranslators();
// 启动时检查更新

View File

@@ -64,6 +64,31 @@
/* Markdown 高亮样式 */
--cm-highlight-background: rgba(250, 204, 21, 0.35);
/* Markdown 表格样式 - 暗色主题 */
--cm-table-bg: rgba(35, 40, 52, 0.5);
--cm-table-header-bg: rgba(46, 51, 69, 0.7);
--cm-table-border: rgba(75, 85, 99, 0.35);
--cm-table-row-hover: rgba(55, 62, 78, 0.5);
/* Search Panel - Dark Theme */
--search-panel-bg: #252526;
--search-panel-text: #cccccc;
--search-panel-border: #454545;
--search-input-bg: #3c3c3c;
--search-input-text: #cccccc;
--search-input-border: #3c3c3c;
--search-focus-border: #0078d4;
--search-btn-hover: rgba(255, 255, 255, 0.1);
--search-btn-active-bg: rgba(0, 120, 212, 0.4);
--search-btn-active-text: #ffffff;
--search-error-border: #f14c4c;
--search-error-bg: #5a1d1d;
/* Search Match Highlight - Dark Theme (VSCode style) */
--search-match-bg: rgba(250, 220, 81, 0.85);
--search-match-selected-bg: rgba(81, 175, 255, 0.5);
--search-match-selected-border: #74b0f4;
}
/* 亮色主题 */
@@ -113,7 +138,7 @@
--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-bg: #f3f3f3;
--cm-codeblock-radius: 0.4rem;
/* Markdown 内联代码样式 */
@@ -125,6 +150,31 @@
/* Markdown 高亮样式 */
--cm-highlight-background: rgba(253, 224, 71, 0.45);
/* Markdown 表格样式 - 亮色主题 */
--cm-table-bg: oklch(97.5% 0.006 255);
--cm-table-header-bg: oklch(94% 0.01 255);
--cm-table-border: oklch(88% 0.008 255);
--cm-table-row-hover: oklch(95% 0.008 255);
/* Search Panel - Light Theme */
--search-panel-bg: #f3f3f3;
--search-panel-text: #616161;
--search-panel-border: #c8c8c8;
--search-input-bg: #ffffff;
--search-input-text: #616161;
--search-input-border: #cecece;
--search-focus-border: #0078d4;
--search-btn-hover: rgba(0, 0, 0, 0.1);
--search-btn-active-bg: rgba(0, 120, 212, 0.2);
--search-btn-active-text: #0078d4;
--search-error-border: #e51400;
--search-error-bg: #fdeceb;
/* Search Match Highlight - Light Theme (VSCode style) */
--search-match-bg: rgba(250, 220, 81, 0.85);
--search-match-selected-bg: rgba(38, 143, 255, 0.3);
--search-match-selected-border: #268fff;
}
/* 跟随系统的浅色偏好 */
@@ -187,5 +237,30 @@
/* Markdown 高亮样式 */
--cm-highlight-background: rgba(253, 224, 71, 0.45);
/* Markdown 表格样式 - 亮色主题 */
--cm-table-bg: oklch(97.5% 0.006 255);
--cm-table-header-bg: oklch(94% 0.01 255);
--cm-table-border: oklch(88% 0.008 255);
--cm-table-row-hover: oklch(95% 0.008 255);
/* Search Panel - Light Theme (auto) */
--search-panel-bg: #f3f3f3;
--search-panel-text: #616161;
--search-panel-border: #c8c8c8;
--search-input-bg: #ffffff;
--search-input-text: #616161;
--search-input-border: #cecece;
--search-focus-border: #0078d4;
--search-btn-hover: rgba(0, 0, 0, 0.1);
--search-btn-active-bg: rgba(0, 120, 212, 0.2);
--search-btn-active-text: #0078d4;
--search-error-border: #e51400;
--search-error-bg: #fdeceb;
/* Search Match Highlight - Light Theme auto (VSCode style) */
--search-match-bg: rgba(250, 220, 81, 0.85);
--search-match-selected-bg: rgba(38, 143, 255, 0.3);
--search-match-selected-border: #268fff;
}
}

View File

@@ -1,6 +1,7 @@
import {
AppConfig,
AuthMethod,
KeyBindingType,
LanguageType,
SystemThemeType,
TabType,
@@ -31,6 +32,7 @@ export const CONFIG_KEY_MAP = {
enableTabIndent: 'editing.enableTabIndent',
tabSize: 'editing.tabSize',
tabType: 'editing.tabType',
keymapMode: 'editing.keymapMode',
autoSaveDelay: 'editing.autoSaveDelay',
// appearance
language: 'appearance.language',
@@ -95,11 +97,12 @@ export const DEFAULT_CONFIG: AppConfig = {
enableTabIndent: true,
tabSize: CONFIG_LIMITS.tabSize.default,
tabType: CONFIG_LIMITS.tabType.default,
keymapMode: KeyBindingType.Standard,
autoSaveDelay: 5000
},
appearance: {
language: LanguageType.LangZhCN,
systemTheme: SystemThemeType.SystemThemeAuto,
systemTheme: SystemThemeType.SystemThemeDark,
currentTheme: 'default-dark'
},
updates: {

View File

@@ -1,7 +0,0 @@
/**
* 翻译图标SVG
*/
export const TRANSLATION_ICON_SVG = `
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M599.68 485.056h-8l30.592 164.672c20.352-7.04 38.72-17.344 54.912-31.104a271.36 271.36 0 0 1-40.704-64.64l32.256-4.032c8.896 17.664 19.072 33.28 30.592 46.72 23.872-27.968 42.24-65.152 55.04-111.744l-154.688 0.128z m121.92 133.76c18.368 15.36 39.36 26.56 62.848 33.472l14.784 4.416-8.64 30.336-14.72-4.352a205.696 205.696 0 0 1-76.48-41.728c-20.672 17.92-44.928 31.552-71.232 40.064l20.736 110.912H519.424l-9.984 72.512h385.152c18.112 0 32.704-14.144 32.704-31.616V295.424a32.128 32.128 0 0 0-32.704-31.552H550.528l35.2 189.696h79.424v-31.552h61.44v31.552h102.4v31.616h-42.688c-14.272 55.488-35.712 100.096-64.64 133.568zM479.36 791.68H193.472c-36.224 0-65.472-28.288-65.472-63.168V191.168C128 156.16 157.312 128 193.472 128h327.68l20.544 104.32h352.832c36.224 0 65.472 28.224 65.472 63.104v537.408c0 34.944-29.312 63.168-65.472 63.168H468.608l10.688-104.32zM337.472 548.352v-33.28H272.768v-48.896h60.16V433.28h-60.16v-41.728h64.704v-32.896h-102.4v189.632h102.4z m158.272 0V453.76c0-17.216-4.032-30.272-12.16-39.488-8.192-9.152-20.288-13.696-36.032-13.696a55.04 55.04 0 0 0-24.768 5.376 39.04 39.04 0 0 0-17.088 15.936h-1.984l-5.056-18.56h-28.352V548.48h37.12V480c0-17.088 2.304-29.376 6.912-36.736 4.608-7.424 12.16-11.072 22.528-11.072 7.616 0 13.248 2.56 16.64 7.872 3.52 5.248 5.312 13.056 5.312 23.488v84.736h36.928z" fill="currentColor"></path>
</svg>`;

View File

@@ -0,0 +1,65 @@
/**
* Formatter utility functions
*/
export interface DateTimeFormatOptions {
locale?: string;
includeTime?: boolean;
hour12?: boolean;
}
/**
* Format date time string to localized format
* @param dateString - ISO date string or null
* @param options - Formatting options
* @returns Formatted date string or error message
*/
export const formatDateTime = (
dateString: string | null,
options: DateTimeFormatOptions = {}
): string => {
const {
locale = 'en-US',
includeTime = true,
hour12 = false
} = options;
if (!dateString) {
return 'Unknown time';
}
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return 'Invalid date';
}
const formatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
};
if (includeTime) {
formatOptions.hour = '2-digit';
formatOptions.minute = '2-digit';
formatOptions.hour12 = hour12;
}
return date.toLocaleString(locale, formatOptions);
} catch {
return 'Time error';
}
};
/**
* Truncate string with ellipsis
* @param str - String to truncate
* @param maxLength - Maximum length before truncation
* @returns Truncated string with ellipsis if needed
*/
export const truncateString = (str: string, maxLength: number): string => {
if (!str) return '';
return str.length > maxLength ? str.substring(0, maxLength) + '...' : str;
};

View File

@@ -0,0 +1,42 @@
/**
* Validation utility functions
*/
/**
* Validate document title
* @param title - The title to validate
* @param maxLength - Maximum allowed length (default: 50)
* @returns Error message if invalid, null if valid
*/
export const validateDocumentTitle = (title: string, maxLength: number = 50): string | null => {
const trimmed = title.trim();
if (!trimmed) {
return 'Document name cannot be empty';
}
if (trimmed.length > maxLength) {
return `Document name cannot exceed ${maxLength} characters`;
}
return null;
};
/**
* Check if a string is empty or whitespace only
* @param value - The string to check
* @returns true if empty or whitespace only
*/
export const isEmpty = (value: string | null | undefined): boolean => {
return !value || value.trim().length === 0;
};
/**
* Check if a string exceeds max length
* @param value - The string to check
* @param maxLength - Maximum allowed length
* @returns true if exceeds max length
*/
export const exceedsMaxLength = (value: string, maxLength: number): boolean => {
return value.trim().length > maxLength;
};

View File

@@ -142,7 +142,7 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--voidraft-font-mono),serif;
font-family: Menlo, monospace,serif;
}
.loading-word {

View File

@@ -1,35 +1,40 @@
<template>
<div
v-if="visible && canClose"
class="tab-context-menu"
:style="{
left: position.x + 'px',
top: position.y + 'px'
}"
@click.stop
v-if="visible && canClose"
v-click-outside="handleClose"
class="tab-context-menu"
:style="{
left: position.x + 'px',
top: position.y + 'px'
}"
@click.stop
>
<div v-if="canClose" class="menu-item" @click="handleMenuClick('close')">
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
<span class="menu-text">{{ t('tabs.contextMenu.closeTab') }}</span>
</div>
<div v-if="hasOtherTabs" class="menu-item" @click="handleMenuClick('closeOthers')">
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" 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"/>
<path d="M9 9l6 6M15 9l-6 6"/>
</svg>
<span class="menu-text">{{ t('tabs.contextMenu.closeOthers') }}</span>
</div>
<div v-if="hasTabsToLeft" class="menu-item" @click="handleMenuClick('closeLeft')">
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
<path d="M9 18l-6-6 6-6"/>
</svg>
<span class="menu-text">{{ t('tabs.contextMenu.closeLeft') }}</span>
</div>
<div v-if="hasTabsToRight" class="menu-item" @click="handleMenuClick('closeRight')">
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18l6-6-6-6"/>
<path d="M15 18l6-6-6-6"/>
</svg>
@@ -39,9 +44,9 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTabStore } from '@/stores/tabStore';
import {computed, onMounted, onUnmounted} from 'vue';
import {useI18n} from 'vue-i18n';
import {useTabStore} from '@/stores/tabStore';
interface Props {
visible: boolean;
@@ -54,7 +59,7 @@ const emit = defineEmits<{
close: [];
}>();
const { t } = useI18n();
const {t} = useI18n();
const tabStore = useTabStore();
// 计算属性
@@ -79,6 +84,9 @@ const hasTabsToLeft = computed(() => {
return index > 0;
});
const handleClose = () => {
emit('close');
};
// 处理菜单项点击
const handleMenuClick = (action: string) => {
if (!props.targetDocumentId) return;
@@ -97,34 +105,9 @@ const handleMenuClick = (action: string) => {
tabStore.closeTabsToRight(props.targetDocumentId);
break;
}
emit('close');
handleClose();
};
// 处理外部点击
const handleClickOutside = (_event: MouseEvent) => {
if (props.visible) {
emit('close');
}
};
// 处理ESC键
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.visible) {
emit('close');
}
};
// 生命周期
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleEscapeKey);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleEscapeKey);
});
</script>
<style scoped lang="scss">
@@ -150,12 +133,12 @@ onUnmounted(() => {
color: var(--text-primary);
transition: all 0.15s ease;
gap: 8px;
&:hover {
background-color: var(--toolbar-button-hover);
color: var(--text-primary);
}
&:active {
background-color: var(--border-color);
}
@@ -167,7 +150,7 @@ onUnmounted(() => {
height: 12px;
color: var(--text-primary);
transition: color 0.15s ease;
.menu-item:hover & {
color: var(--text-primary);
}
@@ -178,4 +161,4 @@ onUnmounted(() => {
font-weight: 400;
flex: 1;
}
</style>
</style>

View File

@@ -1,13 +1,15 @@
<template>
<div class="linux-titlebar" style="--wails-draggable:drag" @contextmenu.prevent>
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
<div class="titlebar-content" @dblclick="handleToggleMaximize" @contextmenu.prevent>
<div class="titlebar-icon">
<img src="/appicon.png" alt="voidraft"/>
</div>
<div v-if="!tabStore.isTabsEnabled && !isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
<div v-if="!tabStore.isTabsEnabled && !isInSettings" class="titlebar-title" :title="fullTitleText">
{{ titleText }}
</div>
<!-- 标签页容器区域 -->
<div class="titlebar-tabs" v-if="tabStore.isTabsEnabled && !isInSettings" style="--wails-draggable:drag">
<TabContainer />
<TabContainer/>
</div>
<!-- 设置页面标题 -->
<div v-if="isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
@@ -26,7 +28,7 @@
<button
class="titlebar-button maximize-button"
@click="toggleMaximize"
@click="handleToggleMaximize"
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
>
<svg width="16" height="16" viewBox="0 0 16 16" v-if="!isMaximized">
@@ -55,81 +57,43 @@
import {computed, onMounted, ref} from 'vue';
import {useI18n} from 'vue-i18n';
import {useRoute} from 'vue-router';
import * as runtime from '@wailsio/runtime';
import {useDocumentStore} from '@/stores/documentStore';
import {useTabStore} from '@/stores/tabStore';
import TabContainer from '@/components/tabs/TabContainer.vue';
import {useTabStore} from "@/stores/tabStore";
import {
minimizeWindow,
toggleMaximize,
closeWindow,
getMaximizedState,
generateTitleText,
generateFullTitleText
} from './index';
const tabStore = useTabStore();
const {t} = useI18n();
const route = useRoute();
const isMaximized = ref(false);
const tabStore = useTabStore();
const documentStore = useDocumentStore();
// 判断是否在设置页面
const isMaximized = ref(false);
const isInSettings = computed(() => route.path.startsWith('/settings'));
// 计算标题文本
const titleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
if (currentDoc) {
// 限制文档标题长度,避免标题栏换行
const maxTitleLength = 30;
const truncatedTitle = currentDoc.title.length > maxTitleLength
? currentDoc.title.substring(0, maxTitleLength) + '...'
: currentDoc.title;
return `voidraft - ${truncatedTitle}`;
}
return 'voidraft';
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
return generateTitleText(documentStore.currentDocument?.title);
});
// 计算完整标题文本用于tooltip
const fullTitleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
return generateFullTitleText(documentStore.currentDocument?.title);
});
const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
console.error(error);
}
};
const toggleMaximize = async () => {
try {
await runtime.Window.ToggleMaximise();
await checkMaximizedState();
} catch (error) {
console.error(error);
}
};
const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
console.error(error);
}
};
const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
console.error(error);
}
const handleToggleMaximize = async () => {
await toggleMaximize();
isMaximized.value = await getMaximizedState();
};
onMounted(async () => {
await checkMaximizedState();
isMaximized.value = await getMaximizedState();
});
</script>
@@ -160,7 +124,7 @@ onMounted(async () => {
font-size: 13px;
font-weight: 500;
cursor: default;
min-width: 0; /* 允许内容收缩 */
min-width: 0;
-webkit-context-menu: none;
-moz-context-menu: none;
@@ -310,4 +274,4 @@ onMounted(async () => {
}
}
}
</style>
</style>

View File

@@ -1,10 +1,10 @@
<template>
<div class="macos-titlebar" style="--wails-draggable:drag" @contextmenu.prevent>
<div class="titlebar-controls" style="--wails-draggable:no-drag" @contextmenu.prevent>
<button
class="titlebar-button close-button"
@click="closeWindow"
:title="t('titlebar.close')"
<button
class="titlebar-button close-button"
@click="closeWindow"
:title="t('titlebar.close')"
>
<div class="button-icon">
<svg width="6" height="6" viewBox="0 0 6 6" v-show="showControlIcons">
@@ -12,11 +12,11 @@
</svg>
</div>
</button>
<button
class="titlebar-button minimize-button"
@click="minimizeWindow"
:title="t('titlebar.minimize')"
<button
class="titlebar-button minimize-button"
@click="minimizeWindow"
:title="t('titlebar.minimize')"
>
<div class="button-icon">
<svg width="8" height="1" viewBox="0 0 8 1" v-show="showControlIcons">
@@ -24,11 +24,11 @@
</svg>
</div>
</button>
<button
class="titlebar-button maximize-button"
@click="toggleMaximize"
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
<button
class="titlebar-button maximize-button"
@click="handleToggleMaximize"
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
>
<div class="button-icon">
<svg width="6" height="6" viewBox="0 0 6 6" v-show="showControlIcons && !isMaximized">
@@ -42,98 +42,61 @@
</div>
</button>
</div>
<!-- 标签页容器区域 -->
<div class="titlebar-tabs" v-if="tabStore.isTabsEnabled && !isInSettings" style="--wails-draggable:drag">
<TabContainer />
<TabContainer/>
</div>
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent v-if="!tabStore.isTabsEnabled || isInSettings">
<div class="titlebar-content" @dblclick="handleToggleMaximize" @contextmenu.prevent
v-if="!tabStore.isTabsEnabled || isInSettings">
<div class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import * as runtime from '@wailsio/runtime';
import { useDocumentStore } from '@/stores/documentStore';
import {computed, onMounted, ref} from 'vue';
import {useI18n} from 'vue-i18n';
import {useRoute} from 'vue-router';
import {useDocumentStore} from '@/stores/documentStore';
import {useTabStore} from '@/stores/tabStore';
import TabContainer from '@/components/tabs/TabContainer.vue';
import { useTabStore } from "@/stores/tabStore";
import {
minimizeWindow,
toggleMaximize,
closeWindow,
getMaximizedState,
generateTitleText,
generateFullTitleText
} from './index';
const tabStore = useTabStore();
const { t } = useI18n();
const {t} = useI18n();
const route = useRoute();
const isMaximized = ref(false);
const showControlIcons = ref(false);
const tabStore = useTabStore();
const documentStore = useDocumentStore();
// 判断是否在设置页面
const isMaximized = ref(false);
const showControlIcons = ref(false);
const isInSettings = computed(() => route.path.startsWith('/settings'));
const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
console.error(error);
}
};
const toggleMaximize = async () => {
try {
await runtime.Window.ToggleMaximise();
await checkMaximizedState();
} catch (error) {
console.error(error);
}
};
const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
console.error(error);
}
};
const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
console.error(error);
}
};
// 计算标题文本
const titleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
if (currentDoc) {
// 限制文档标题长度,避免标题栏换行
const maxTitleLength = 30;
const truncatedTitle = currentDoc.title.length > maxTitleLength
? currentDoc.title.substring(0, maxTitleLength) + '...'
: currentDoc.title;
return `voidraft - ${truncatedTitle}`;
}
return 'voidraft';
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
return generateTitleText(documentStore.currentDocument?.title);
});
// 计算完整标题文本用于tooltip
const fullTitleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
return generateFullTitleText(documentStore.currentDocument?.title);
});
const handleToggleMaximize = async () => {
await toggleMaximize();
isMaximized.value = await getMaximizedState();
};
onMounted(async () => {
await checkMaximizedState();
isMaximized.value = await getMaximizedState();
});
</script>
@@ -147,11 +110,11 @@ onMounted(async () => {
-webkit-user-select: none;
width: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
&:hover {
.titlebar-button {
.button-icon {
@@ -168,7 +131,7 @@ onMounted(async () => {
padding-left: 8px;
gap: 8px;
flex-shrink: 0;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
@@ -187,7 +150,7 @@ onMounted(async () => {
padding: 0;
margin: 0;
position: relative;
.button-icon {
opacity: 0;
transition: opacity 0.2s ease;
@@ -198,7 +161,7 @@ onMounted(async () => {
height: 100%;
color: rgba(0, 0, 0, 0.7);
}
&:hover .button-icon {
opacity: 1;
}
@@ -206,11 +169,11 @@ onMounted(async () => {
.close-button {
background: #ff5f57;
&:hover {
background: #ff453a;
}
&:active {
background: #d7463f;
}
@@ -218,11 +181,11 @@ onMounted(async () => {
.minimize-button {
background: #ffbd2e;
&:hover {
background: #ffb524;
}
&:active {
background: #e6a220;
}
@@ -230,11 +193,11 @@ onMounted(async () => {
.maximize-button {
background: #28ca42;
&:hover {
background: #1ebe36;
}
&:active {
background: #1ba932;
}
@@ -247,7 +210,7 @@ onMounted(async () => {
flex: 1;
cursor: default;
min-width: 0;
-webkit-context-menu: none;
-moz-context-menu: none;
context-menu: none;
@@ -261,34 +224,32 @@ onMounted(async () => {
margin-left: 8px;
margin-right: 8px;
min-width: 0;
overflow: visible; /* 允许TabContainer内部处理滚动 */
/* 确保TabContainer能够正确处理滚动 */
overflow: visible;
:deep(.tab-container) {
width: 100%;
height: 100%;
}
:deep(.tab-bar) {
width: 100%;
height: 100%;
}
:deep(.tab-scroll-wrapper) {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
/* 确保底部线条能够正确显示 */
:deep(.tab-item) {
position: relative;
&::after {
content: '';
position: absolute;
@@ -319,13 +280,13 @@ onMounted(async () => {
background: var(--toolbar-bg, #2d2d2d);
border-bottom-color: var(--toolbar-border, rgba(255, 255, 255, 0.1));
}
.titlebar-title {
color: var(--toolbar-text, #fff);
}
.titlebar-button .button-icon {
color: rgba(255, 255, 255, 0.8);
}
}
</style>
</style>

View File

@@ -1,13 +1,15 @@
<template>
<div class="windows-titlebar" style="--wails-draggable:drag">
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
<div class="titlebar-content" @dblclick="handleToggleMaximize" @contextmenu.prevent>
<div class="titlebar-icon">
<img src="/appicon.png" alt="voidraft"/>
</div>
<div v-if="!tabStore.isTabsEnabled && !isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
<div v-if="!tabStore.isTabsEnabled && !isInSettings" class="titlebar-title" :title="fullTitleText">
{{ titleText }}
</div>
<!-- 标签页容器区域 -->
<div class="titlebar-tabs" v-if="tabStore.isTabsEnabled && !isInSettings" style="--wails-draggable:drag">
<TabContainer />
<TabContainer/>
</div>
<!-- 设置页面标题 -->
<div v-if="isInSettings" class="titlebar-title" :title="fullTitleText">{{ titleText }}</div>
@@ -24,7 +26,7 @@
<button
class="titlebar-button maximize-button"
@click="toggleMaximize"
@click="handleToggleMaximize"
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
>
<span class="titlebar-icon" v-html="maximizeIcon"></span>
@@ -45,84 +47,44 @@
import {computed, onMounted, ref} from 'vue';
import {useI18n} from 'vue-i18n';
import {useRoute} from 'vue-router';
import * as runtime from '@wailsio/runtime';
import {useDocumentStore} from '@/stores/documentStore';
import {useTabStore} from '@/stores/tabStore';
import TabContainer from '@/components/tabs/TabContainer.vue';
import {useTabStore} from "@/stores/tabStore";
import {
minimizeWindow,
toggleMaximize,
closeWindow,
getMaximizedState,
generateTitleText,
generateFullTitleText
} from './index';
const tabStore = useTabStore();
const {t} = useI18n();
const route = useRoute();
const isMaximized = ref(false);
const tabStore = useTabStore();
const documentStore = useDocumentStore();
// 计算属性用于图标,减少重复渲染
const isMaximized = ref(false);
const maximizeIcon = computed(() => isMaximized.value ? '&#xE923;' : '&#xE922;');
// 判断是否在设置页面
const isInSettings = computed(() => route.path.startsWith('/settings'));
// 计算标题文本
const titleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
if (currentDoc) {
// 限制文档标题长度,避免标题栏换行
const maxTitleLength = 30;
const truncatedTitle = currentDoc.title.length > maxTitleLength
? currentDoc.title.substring(0, maxTitleLength) + '...'
: currentDoc.title;
return `voidraft - ${truncatedTitle}`;
}
return 'voidraft';
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
return generateTitleText(documentStore.currentDocument?.title);
});
// 计算完整标题文本用于tooltip
const fullTitleText = computed(() => {
if (isInSettings.value) {
return `voidraft - ` + t('settings.title');
}
const currentDoc = documentStore.currentDocument;
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
if (isInSettings.value) return `voidraft - ${t('settings.title')}`;
return generateFullTitleText(documentStore.currentDocument?.title);
});
const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
console.error(error);
}
};
const toggleMaximize = async () => {
try {
await runtime.Window.ToggleMaximise();
await checkMaximizedState();
} catch (error) {
console.error(error);
}
};
const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
console.error(error);
}
};
const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
console.error(error);
}
const handleToggleMaximize = async () => {
await toggleMaximize();
isMaximized.value = await getMaximizedState();
};
onMounted(async () => {
await checkMaximizedState();
isMaximized.value = await getMaximizedState();
});
</script>
@@ -152,7 +114,7 @@ onMounted(async () => {
font-size: 12px;
font-weight: 400;
cursor: default;
min-width: 0; /* 允许内容收缩 */
min-width: 0;
-webkit-context-menu: none;
-moz-context-menu: none;
@@ -178,7 +140,6 @@ onMounted(async () => {
overflow: hidden;
margin-left: 8px;
min-width: 0;
//margin-right: 8px;
}
.titlebar-controls {
@@ -254,4 +215,4 @@ onMounted(async () => {
opacity: 1;
}
}
</style>
</style>

View File

@@ -0,0 +1,60 @@
import * as runtime from '@wailsio/runtime';
/**
* Titlebar utility functions
*/
// Window control functions
export const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
console.error('Failed to minimize window:', error);
}
};
export const toggleMaximize = async () => {
try {
await runtime.Window.ToggleMaximise();
} catch (error) {
console.error('Failed to toggle maximize:', error);
}
};
export const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
console.error('Failed to close window:', error);
}
};
export const getMaximizedState = async (): Promise<boolean> => {
try {
return await runtime.Window.IsMaximised();
} catch (error) {
console.error('Failed to check maximized state:', error);
return false;
}
};
/**
* Generate title text with optional truncation
*/
export const generateTitleText = (
title: string | undefined,
maxLength: number = 30
): string => {
if (!title) return 'voidraft';
const truncated = title.length > maxLength
? title.substring(0, maxLength) + '...'
: title;
return `voidraft - ${truncated}`;
};
/**
* Generate full title text (no truncation)
*/
export const generateFullTitleText = (title: string | undefined): string => {
return title ? `voidraft - ${title}` : 'voidraft';
};

View File

@@ -1,49 +1,63 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useDocumentStore } from '@/stores/documentStore';
import { useTabStore } from '@/stores/tabStore';
import { useWindowStore } from '@/stores/windowStore';
import { useI18n } from 'vue-i18n';
import type { Document } from '@/../bindings/voidraft/internal/models/models';
import {computed, nextTick, reactive, ref, watch} from 'vue';
import {useDocumentStore} from '@/stores/documentStore';
import {useTabStore} from '@/stores/tabStore';
import {useWindowStore} from '@/stores/windowStore';
import {useI18n} from 'vue-i18n';
import {useConfirm} from '@/composables';
import {validateDocumentTitle} from '@/common/utils/validation';
import {formatDateTime, truncateString} from '@/common/utils/formatter';
import type {Document} from '@/../bindings/voidraft/internal/models/ent/models';
// 类型定义
interface DocumentItem extends Document {
isCreateOption?: boolean;
}
const documentStore = useDocumentStore();
const tabStore = useTabStore();
const windowStore = useWindowStore();
const { t } = useI18n();
const {t} = useI18n();
// DOM 引用
const inputRef = ref<HTMLInputElement>();
const editInputRef = ref<HTMLInputElement>();
// 组件状态
const inputValue = ref('');
const inputRef = ref<HTMLInputElement>();
const editingId = ref<number | null>(null);
const editingTitle = ref('');
const editInputRef = ref<HTMLInputElement>();
const deleteConfirmId = ref<number | null>(null);
const state = reactive({
isLoaded: false,
searchQuery: '',
editing: {
id: null as number | null,
title: ''
}
});
// 常量
const MAX_TITLE_LENGTH = 50;
const DELETE_CONFIRM_TIMEOUT = 3000;
// 计算属性
const currentDocName = computed(() => {
if (!documentStore.currentDocument) return t('toolbar.selectDocument');
const title = documentStore.currentDocument.title;
return title.length > 12 ? title.substring(0, 12) + '...' : title;
return truncateString(documentStore.currentDocument.title || '', 12);
});
const filteredItems = computed(() => {
const filteredItems = computed<DocumentItem[]>(() => {
const docs = documentStore.documentList;
const query = inputValue.value.trim();
const query = state.searchQuery.trim();
if (!query) return docs;
const filtered = docs.filter(doc =>
doc.title.toLowerCase().includes(query.toLowerCase())
(doc.title || '').toLowerCase().includes(query.toLowerCase())
);
// 如果输入的不是已存在文档的完整标题,添加创建选项
const exactMatch = docs.some(doc => doc.title.toLowerCase() === query.toLowerCase());
const exactMatch = docs.some(doc => (doc.title || '').toLowerCase() === query.toLowerCase());
if (!exactMatch && query.length > 0) {
return [
{ id: -1, title: t('toolbar.createDocument') + ` "${query}"`, isCreateOption: true } as any,
{id: -1, title: t('toolbar.createDocument') + ` "${query}"`, isCreateOption: true} as DocumentItem,
...filtered
];
}
@@ -51,53 +65,32 @@ const filteredItems = computed(() => {
return filtered;
});
// 工具函数
const validateTitle = (title: string): string | null => {
if (!title.trim()) return t('toolbar.documentNameRequired');
if (title.trim().length > MAX_TITLE_LENGTH) {
return t('toolbar.documentNameTooLong', { max: MAX_TITLE_LENGTH });
}
return null;
};
const formatTime = (dateString: string | null) => {
if (!dateString) return t('toolbar.unknownTime');
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return t('toolbar.invalidDate');
const locale = t('locale') === 'zh-CN' ? 'zh-CN' : 'en-US';
return date.toLocaleString(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
} catch {
return t('toolbar.timeError');
}
};
// 核心操作
const openMenu = async () => {
documentStore.openDocumentSelector();
await documentStore.getDocumentMetaList();
documentStore.openDocumentSelector();
state.isLoaded = true;
await nextTick();
inputRef.value?.focus();
};
// 删除确认
const {isConfirming: isDeleting, startConfirm: startDeleteConfirm, reset: resetDeleteConfirm} = useConfirm({
timeout: DELETE_CONFIRM_TIMEOUT
});
const closeMenu = () => {
state.isLoaded = false;
documentStore.closeDocumentSelector();
inputValue.value = '';
editingId.value = null;
editingTitle.value = '';
deleteConfirmId.value = null;
state.searchQuery = '';
state.editing.id = null;
state.editing.title = '';
resetDeleteConfirm();
};
const selectDoc = async (doc: Document) => {
if (doc.id === undefined) return;
// 如果选择的就是当前文档,直接关闭菜单
if (documentStore.currentDocument?.id === doc.id) {
closeMenu();
@@ -121,7 +114,7 @@ const selectDoc = async (doc: Document) => {
const createDoc = async (title: string) => {
const trimmedTitle = title.trim();
const error = validateTitle(trimmedTitle);
const error = validateDocumentTitle(trimmedTitle, MAX_TITLE_LENGTH);
if (error) return;
try {
@@ -132,20 +125,28 @@ const createDoc = async (title: string) => {
}
};
const selectItem = async (item: any) => {
const selectDocItem = async (item: any) => {
if (item.isCreateOption) {
await createDoc(inputValue.value.trim());
await createDoc(state.searchQuery.trim());
} else {
await selectDoc(item);
}
};
// 搜索框回车处理
const handleSearchEnter = () => {
const query = state.searchQuery.trim();
if (query && filteredItems.value.length > 0) {
selectDocItem(filteredItems.value[0]);
}
};
// 编辑操作
const startRename = (doc: Document, event: Event) => {
const renameDoc = (doc: Document, event: Event) => {
event.stopPropagation();
editingId.value = doc.id;
editingTitle.value = doc.title;
deleteConfirmId.value = null;
state.editing.id = doc.id ?? null;
state.editing.title = doc.title || '';
resetDeleteConfirm();
nextTick(() => {
editInputRef.value?.focus();
editInputRef.value?.select();
@@ -153,35 +154,41 @@ const startRename = (doc: Document, event: Event) => {
};
const saveEdit = async () => {
if (!editingId.value || !editingTitle.value.trim()) {
editingId.value = null;
editingTitle.value = '';
if (!state.editing.id || !state.editing.title.trim()) {
state.editing.id = null;
state.editing.title = '';
return;
}
const trimmedTitle = editingTitle.value.trim();
const error = validateTitle(trimmedTitle);
const trimmedTitle = state.editing.title.trim();
const error = validateDocumentTitle(trimmedTitle, MAX_TITLE_LENGTH);
if (error) return;
try {
await documentStore.updateDocumentMetadata(editingId.value, trimmedTitle);
await documentStore.updateDocumentMetadata(state.editing.id, trimmedTitle);
await documentStore.getDocumentMetaList();
// 如果tabs功能开启且该文档有标签页更新标签页标题
if (tabStore.isTabsEnabled && tabStore.hasTab(editingId.value)) {
tabStore.updateTabTitle(editingId.value, trimmedTitle);
if (tabStore.isTabsEnabled && tabStore.hasTab(state.editing.id)) {
tabStore.updateTabTitle(state.editing.id, trimmedTitle);
}
} catch (error) {
console.error('Failed to update document:', error);
} finally {
editingId.value = null;
editingTitle.value = '';
state.editing.id = null;
state.editing.title = '';
}
};
const cancelEdit = () => {
state.editing.id = null;
state.editing.title = '';
};
// 其他操作
const openInNewWindow = async (doc: Document, event: Event) => {
event.stopPropagation();
if (doc.id === undefined) return;
try {
await documentStore.openDocumentInNewWindow(doc.id);
} catch (error) {
@@ -191,13 +198,14 @@ const openInNewWindow = async (doc: Document, event: Event) => {
const handleDelete = async (doc: Document, event: Event) => {
event.stopPropagation();
if (doc.id === undefined) return;
if (deleteConfirmId.value === doc.id) {
if (isDeleting(doc.id)) {
// 确认删除前检查文档是否在其他窗口打开
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
if (hasOpen) {
documentStore.setError(doc.id, t('toolbar.alreadyOpenInNewWindow'));
deleteConfirmId.value = null;
resetDeleteConfirm();
return;
}
@@ -210,228 +218,181 @@ const handleDelete = async (doc: Document, event: Event) => {
if (firstDoc) await selectDoc(firstDoc);
}
}
deleteConfirmId.value = null;
resetDeleteConfirm();
} else {
// 进入确认状态
deleteConfirmId.value = doc.id;
editingId.value = null;
// 3秒后自动取消确认状态
setTimeout(() => {
if (deleteConfirmId.value === doc.id) {
deleteConfirmId.value = null;
}
}, 3000);
startDeleteConfirm(doc.id);
state.editing.id = null;
}
};
// 键盘事件处理
const createKeyHandler = (handlers: Record<string, () => void>) => (event: KeyboardEvent) => {
const handler = handlers[event.key];
if (handler) {
event.preventDefault();
event.stopPropagation();
handler();
}
};
const handleGlobalKeydown = createKeyHandler({
Escape: () => {
if (editingId.value) {
editingId.value = null;
editingTitle.value = '';
} else if (deleteConfirmId.value) {
deleteConfirmId.value = null;
} else {
closeMenu();
}
}
});
const handleInputKeydown = createKeyHandler({
Enter: () => {
const query = inputValue.value.trim();
if (query && filteredItems.value.length > 0) {
selectItem(filteredItems.value[0]);
}
},
Escape: closeMenu
});
const handleEditKeydown = createKeyHandler({
Enter: saveEdit,
Escape: () => {
editingId.value = null;
editingTitle.value = '';
}
});
// 点击外部关闭
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
if (!target.closest('.document-selector')) {
// 切换菜单
const toggleMenu = () => {
if (documentStore.showDocumentSelector) {
closeMenu();
} else {
openMenu();
}
};
// 生命周期
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleGlobalKeydown);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleGlobalKeydown);
});
// 监听菜单状态变化
watch(() => documentStore.showDocumentSelector, (isOpen) => {
if (isOpen) {
if (isOpen && !state.isLoaded) {
openMenu();
}
});
</script>
<template>
<div class="document-selector">
<div class="document-selector" v-click-outside="closeMenu">
<!-- 选择器按钮 -->
<button class="doc-btn" @click="documentStore.toggleDocumentSelector">
<button class="doc-btn" @click="toggleMenu">
<span class="doc-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</span>
<span class="doc-name">{{ currentDocName }}</span>
<span class="arrow" :class="{ open: documentStore.showDocumentSelector }"></span>
<span class="arrow" :class="{ open: state.isLoaded }"></span>
</button>
<!-- 菜单 -->
<div v-if="documentStore.showDocumentSelector" class="doc-menu">
<!-- 输入框 -->
<div class="input-box">
<input
ref="inputRef"
v-model="inputValue"
type="text"
class="main-input"
:placeholder="t('toolbar.searchOrCreateDocument')"
:maxlength="MAX_TITLE_LENGTH"
@keydown="handleInputKeydown"
/>
<svg class="input-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<Transition name="slide-up">
<div v-if="state.isLoaded" class="doc-menu">
<!-- 输入框 -->
<div class="input-box">
<input
ref="inputRef"
v-model="state.searchQuery"
type="text"
class="main-input"
:placeholder="t('toolbar.searchOrCreateDocument')"
:maxlength="MAX_TITLE_LENGTH"
@keydown.enter="handleSearchEnter"
@keydown.esc="closeMenu"
/>
<svg class="input-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<!-- 项目列表 -->
<div class="item-list">
<div
v-for="item in filteredItems"
:key="item.id"
class="list-item"
:class="{
'active': !item.isCreateOption && documentStore.currentDocument?.id === item.id,
'create-item': item.isCreateOption
}"
@click="selectItem(item)"
>
<!-- 创建选项 -->
<div v-if="item.isCreateOption" class="create-option">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
<span>{{ item.title }}</span>
</div>
<!-- 文档项 -->
<div v-else class="doc-item-content">
<!-- 普通显示 -->
<div v-if="editingId !== item.id" class="doc-info">
<div class="doc-title">{{ item.title }}</div>
<!-- 根据状态显示错误信息或时间 -->
<div v-if="documentStore.selectorError?.docId === item.id" class="doc-error">
{{ documentStore.selectorError?.message }}
</div>
<div v-else class="doc-date">{{ formatTime(item.updatedAt) }}</div>
<!-- 项目列表 -->
<div class="item-list">
<div
v-for="item in filteredItems"
:key="item.id"
class="list-item"
:class="{
'active': !item.isCreateOption && documentStore.currentDocument?.id === item.id,
'create-item': item.isCreateOption
}"
@click="selectDocItem(item)"
>
<!-- 创建选项 -->
<div v-if="item.isCreateOption" class="create-option">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
<span>{{ item.title }}</span>
</div>
<!-- 编辑状态 -->
<div v-else class="doc-edit">
<input
:ref="el => editInputRef = el as HTMLInputElement"
v-model="editingTitle"
type="text"
class="edit-input"
:maxlength="MAX_TITLE_LENGTH"
@keydown="handleEditKeydown"
@blur="saveEdit"
@click.stop
/>
</div>
<!-- 文档项 -->
<div v-else class="doc-item-content">
<!-- 普通显示 -->
<div v-if="state.editing.id !== item.id" class="doc-info">
<div class="doc-title">{{ item.title }}</div>
<!-- 根据状态显示错误信息或时间 -->
<div v-if="documentStore.selectorError?.docId === item.id" class="doc-error">
{{ documentStore.selectorError?.message }}
</div>
<div v-else class="doc-date">{{ formatDateTime(item.updated_at) }}</div>
</div>
<!-- 操作按钮 -->
<div v-if="editingId !== item.id" class="doc-actions">
<!-- 只有非当前文档才显示在新窗口打开按钮 -->
<button
v-if="documentStore.currentDocument?.id !== item.id"
class="action-btn"
@click="openInNewWindow(item, $event)"
:title="t('toolbar.openInNewWindow')"
>
<svg width="12" height="12" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path
d="M172.8 1017.6c-89.6 0-166.4-70.4-166.4-166.4V441.6c0-89.6 70.4-166.4 166.4-166.4h416c89.6 0 166.4 70.4 166.4 166.4v416c0 89.6-70.4 166.4-166.4 166.4l-416-6.4z m0-659.2c-51.2 0-89.6 38.4-89.6 89.6v416c0 51.2 38.4 89.6 89.6 89.6h416c51.2 0 89.6-38.4 89.6-89.6V441.6c0-51.2-38.4-89.6-89.6-89.6H172.8z"></path>
<path
d="M851.2 19.2H435.2C339.2 19.2 268.8 96 268.8 185.6v25.6h70.4v-25.6c0-51.2 38.4-89.6 89.6-89.6h409.6c51.2 0 89.6 38.4 89.6 89.6v409.6c0 51.2-38.4 89.6-89.6 89.6h-38.4V768h51.2c96 0 166.4-76.8 166.4-166.4V185.6c0-96-76.8-166.4-166.4-166.4z"></path>
</svg>
</button>
<button class="action-btn" @click="startRename(item, $event)" :title="t('toolbar.rename')">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path>
</svg>
</button>
<button
v-if="documentStore.documentList.length > 1 && item.id !== 1"
class="action-btn delete-btn"
:class="{ 'delete-confirm': deleteConfirmId === item.id }"
@click="handleDelete(item, $event)"
:title="deleteConfirmId === item.id ? t('toolbar.confirmDelete') : t('toolbar.delete')"
>
<svg v-if="deleteConfirmId !== item.id" xmlns="http://www.w3.org/2000/svg" width="12" height="12"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<polyline points="3,6 5,6 21,6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
<span v-else class="confirm-text">{{ t('toolbar.confirm') }}</span>
</button>
<!-- 编辑状态 -->
<div v-else class="doc-edit">
<input
:ref="el => editInputRef = el as HTMLInputElement"
v-model="state.editing.title"
type="text"
class="edit-input"
:maxlength="MAX_TITLE_LENGTH"
@keydown.enter="saveEdit"
@keydown.esc="cancelEdit"
@blur="saveEdit"
@click.stop
/>
</div>
<!-- 操作按钮 -->
<div v-if="state.editing.id !== item.id" class="doc-actions">
<!-- 只有非当前文档才显示在新窗口打开按钮 -->
<button
v-if="documentStore.currentDocument?.id !== item.id"
class="action-btn"
@click="openInNewWindow(item, $event)"
:title="t('toolbar.openInNewWindow')"
>
<svg width="12" height="12" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path
d="M172.8 1017.6c-89.6 0-166.4-70.4-166.4-166.4V441.6c0-89.6 70.4-166.4 166.4-166.4h416c89.6 0 166.4 70.4 166.4 166.4v416c0 89.6-70.4 166.4-166.4 166.4l-416-6.4z m0-659.2c-51.2 0-89.6 38.4-89.6 89.6v416c0 51.2 38.4 89.6 89.6 89.6h416c51.2 0 89.6-38.4 89.6-89.6V441.6c0-51.2-38.4-89.6-89.6-89.6H172.8z"></path>
<path
d="M851.2 19.2H435.2C339.2 19.2 268.8 96 268.8 185.6v25.6h70.4v-25.6c0-51.2 38.4-89.6 89.6-89.6h409.6c51.2 0 89.6 38.4 89.6 89.6v409.6c0 51.2-38.4 89.6-89.6 89.6h-38.4V768h51.2c96 0 166.4-76.8 166.4-166.4V185.6c0-96-76.8-166.4-166.4-166.4z"></path>
</svg>
</button>
<button class="action-btn" @click="renameDoc(item, $event)" :title="t('toolbar.rename')">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path>
</svg>
</button>
<button
v-if="documentStore.documentList.length > 1 && item.id !== 1"
class="action-btn delete-btn"
:class="{ 'delete-confirm': isDeleting(item.id!) }"
@click="handleDelete(item, $event)"
:title="isDeleting(item.id!) ? t('toolbar.confirmDelete') : t('toolbar.delete')"
>
<svg v-if="!isDeleting(item.id!)" xmlns="http://www.w3.org/2000/svg" width="12" height="12"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<polyline points="3,6 5,6 21,6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
<span v-else class="confirm-text">{{ t('toolbar.confirm') }}</span>
</button>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredItems.length === 0" class="empty">
{{ t('toolbar.noDocumentFound') }}
</div>
<!-- 加载状态 -->
<div v-if="documentStore.isLoading" class="loading">
{{ t('toolbar.loading') }}
<!-- 空状态 -->
<div v-if="filteredItems.length === 0" class="empty">
{{ t('toolbar.noDocumentFound') }}
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="scss">
.slide-up-enter-active,
.slide-up-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(8px);
}
.document-selector {
position: relative;
@@ -483,8 +444,8 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
border: 1px solid var(--border-color);
border-radius: 3px;
margin-bottom: 4px;
width: 260px;
max-height: calc(100vh - 40px); // 限制最大高度留出titlebar空间(32px)和一些边距
width: 300px;
max-height: calc(100vh - 40px);
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
overflow: hidden;
@@ -527,7 +488,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
}
.item-list {
max-height: calc(100vh - 100px); // 为输入框和边距预留空间
max-height: calc(100vh - 100px);
overflow-y: auto;
flex: 1;
@@ -594,7 +555,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
color: var(--text-muted);
opacity: 0.6;
}
.doc-error {
font-size: 10px;
color: var(--text-danger);
@@ -669,7 +630,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
}
}
.empty, .loading {
.empty {
padding: 16px 8px;
text-align: center;
font-size: 11px;
@@ -680,9 +641,17 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
}
@keyframes fadeInOut {
0% { opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { opacity: 0; }
0% {
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
opacity: 0;
}
}
</style>
</style>

View File

@@ -0,0 +1,5 @@
export { useConfirm } from './useConfirm';
export type { UseConfirmOptions } from './useConfirm';
export { usePolling } from './usePolling';
export type { UsePollingOptions, UsePollingReturn } from './usePolling';

View File

@@ -0,0 +1,174 @@
import { ref, readonly, onUnmounted, type Ref, type DeepReadonly } from 'vue';
export interface UseConfirmOptions<T extends string | number = string | number> {
/** Auto cancel timeout in ms (default: 3000, set 0 to disable) */
timeout?: number;
/** Callback when confirmed */
onConfirm?: (id: T) => void | Promise<void>;
/** Callback when cancelled (timeout or manual) */
onCancel?: (id: T) => void;
}
export interface UseConfirmReturn<T extends string | number = string | number> {
/** Current confirming id (readonly) */
confirmId: DeepReadonly<Ref<T | null>>;
/** Whether confirm action is executing */
isPending: DeepReadonly<Ref<boolean>>;
/** Check if a specific id is in confirming state */
isConfirming: (id: T) => boolean;
/** Start confirming state (with auto timeout) */
startConfirm: (id: T) => void;
/** Request confirmation (toggle between request and execute) */
requestConfirm: (id: T) => Promise<boolean>;
/** Manually confirm current id */
confirm: () => Promise<void>;
/** Cancel confirmation */
cancel: () => void;
/** Reset without triggering callbacks */
reset: () => void;
}
/**
* Composable for handling confirm actions (e.g., delete confirmation)
*
* @example
* ```ts
* // Basic usage
* const { isConfirming, requestConfirm } = useConfirm({
* timeout: 3000,
* onConfirm: async (id) => { await deleteItem(id) }
* })
*
* // In template
* <button @click="requestConfirm('delete')">
* {{ isConfirming('delete') ? 'Confirm?' : 'Delete' }}
* </button>
*
* // With loading state
* const { isPending, requestConfirm } = useConfirm({ ... })
* <button :disabled="isPending" @click="requestConfirm('id')">
* {{ isPending ? 'Processing...' : 'Delete' }}
* </button>
* ```
*/
export function useConfirm<T extends string | number = string | number>(
options: UseConfirmOptions<T> = {}
): UseConfirmReturn<T> {
const { timeout = 3000, onConfirm, onCancel } = options;
const confirmId = ref<T | null>(null) as Ref<T | null>;
const isPending = ref(false);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const clearTimer = (): void => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
/**
* Check if a specific id is in confirming state
*/
const isConfirming = (id: T): boolean => {
return confirmId.value === id;
};
/**
* Start confirming state for an id (with auto timeout)
*/
const startConfirm = (id: T): void => {
clearTimer();
confirmId.value = id;
// Auto cancel after timeout (0 = disabled)
if (timeout > 0) {
timeoutId = setTimeout(() => {
if (confirmId.value === id) {
confirmId.value = null;
onCancel?.(id);
}
}, timeout);
}
};
/**
* Request confirmation for an id
* - First click: enter confirming state
* - Second click: execute confirm action
* @returns true if confirmed, false if entered confirming state
*/
const requestConfirm = async (id: T): Promise<boolean> => {
// Prevent action while pending
if (isPending.value) return false;
if (confirmId.value === id) {
// Already confirming, execute action
clearTimer();
isPending.value = true;
try {
await onConfirm?.(id);
return true;
} finally {
confirmId.value = null;
isPending.value = false;
}
} else {
// Enter confirming state
startConfirm(id);
return false;
}
};
/**
* Manually confirm the current id
*/
const confirm = async (): Promise<void> => {
if (confirmId.value === null || isPending.value) return;
clearTimer();
const id = confirmId.value;
isPending.value = true;
try {
await onConfirm?.(id);
} finally {
confirmId.value = null;
isPending.value = false;
}
};
/**
* Cancel the confirming state
*/
const cancel = (): void => {
if (confirmId.value === null) return;
const id = confirmId.value;
clearTimer();
confirmId.value = null;
onCancel?.(id);
};
/**
* Reset state without triggering callbacks
*/
const reset = (): void => {
clearTimer();
confirmId.value = null;
isPending.value = false;
};
// Cleanup on unmount
onUnmounted(clearTimer);
return {
confirmId: readonly(confirmId),
isPending: readonly(isPending),
isConfirming,
startConfirm,
requestConfirm,
confirm,
cancel,
reset
};
}

View File

@@ -0,0 +1,147 @@
import { ref, readonly, onUnmounted, type Ref, type DeepReadonly } from 'vue';
export interface UsePollingOptions<T> {
/** Polling interval in ms (default: 500) */
interval?: number;
/** Execute immediately when started (default: true) */
immediate?: boolean;
/** Auto-stop condition, return true to stop polling */
shouldStop?: (data: T) => boolean;
/** Callback on each successful poll */
onSuccess?: (data: T) => void;
/** Callback when error occurs */
onError?: (error: unknown) => void;
/** Callback when polling stops (either manual or auto) */
onStop?: () => void;
}
export interface UsePollingReturn<T> {
/** Latest fetched data (readonly) */
data: DeepReadonly<Ref<T | null>>;
/** Error message if any (readonly) */
error: DeepReadonly<Ref<string>>;
/** Whether polling is active (readonly) */
isActive: DeepReadonly<Ref<boolean>>;
/** Start polling */
start: () => void;
/** Stop polling */
stop: () => void;
/** Reset all state (also stops polling) */
reset: () => void;
}
/**
* Composable for polling async operations with auto-stop support
*
* @example
* ```ts
* // Basic usage
* const { data, isActive, start, stop } = usePolling(
* () => api.getProgress(),
* {
* interval: 200,
* shouldStop: (d) => d.progress >= 100 || !!d.error,
* onSuccess: (d) => console.log('Progress:', d.progress)
* }
* )
*
* // Start polling
* start()
*
* // With reactive data binding
* <div>{{ isActive ? `${data?.progress}%` : 'Idle' }}</div>
* ```
*/
export function usePolling<T>(
fetcher: () => Promise<T>,
options: UsePollingOptions<T> = {}
): UsePollingReturn<T> {
const {
interval = 500,
immediate = true,
shouldStop,
onSuccess,
onError,
onStop
} = options;
const data = ref<T | null>(null) as Ref<T | null>;
const error = ref('');
const isActive = ref(false);
let timerId = 0;
const clearTimer = (): void => {
if (timerId) {
clearInterval(timerId);
timerId = 0;
}
};
const poll = async (): Promise<void> => {
try {
const result = await fetcher();
data.value = result;
error.value = '';
onSuccess?.(result);
// Check auto-stop condition
if (shouldStop?.(result)) {
stop();
}
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
onError?.(e);
stop();
}
};
/**
* Start polling
*/
const start = (): void => {
if (isActive.value) return;
isActive.value = true;
error.value = '';
// Execute immediately if configured
if (immediate) {
poll();
}
timerId = window.setInterval(poll, interval);
};
/**
* Stop polling
*/
const stop = (): void => {
if (!isActive.value) return;
clearTimer();
isActive.value = false;
onStop?.();
};
/**
* Reset all state to initial values
*/
const reset = (): void => {
clearTimer();
data.value = null;
error.value = '';
isActive.value = false;
};
// Cleanup on unmount
onUnmounted(clearTimer);
return {
data: readonly(data),
error: readonly(error),
isActive: readonly(isActive),
start,
stop,
reset
};
}

View File

@@ -0,0 +1,37 @@
import type { Directive, DirectiveBinding } from 'vue';
type ClickOutsideHandler = (event: MouseEvent) => void;
interface ClickOutsideElement extends HTMLElement {
_clickOutsideHandler?: (event: MouseEvent) => void;
}
/**
* v-click-outside directive
* Triggers a callback when clicking outside the element
*
* Usage:
* <div v-click-outside="handleClickOutside">...</div>
*/
export const clickOutside: Directive<ClickOutsideElement, ClickOutsideHandler> = {
mounted(el: ClickOutsideElement, binding: DirectiveBinding<ClickOutsideHandler>) {
const handler = (event: MouseEvent) => {
const target = event.target as Node;
if (el && !el.contains(target)) {
binding.value(event);
}
};
el._clickOutsideHandler = handler;
document.addEventListener('click', handler);
},
unmounted(el: ClickOutsideElement) {
if (el._clickOutsideHandler) {
document.removeEventListener('click', el._clickOutsideHandler);
delete el._clickOutsideHandler;
}
}
};
export default clickOutside;

View File

@@ -0,0 +1,13 @@
import type { App } from 'vue';
import { clickOutside } from './clickOutside';
export { clickOutside };
/**
* Register all custom directives
*/
export function registerDirectives(app: App) {
app.directive('click-outside', clickOutside);
}
export default registerDirectives;

View File

@@ -33,13 +33,6 @@ export default {
confirmDelete: 'Click again to confirm delete',
openInNewWindow: 'Open in New Window',
alreadyOpenInNewWindow: 'Already open in another window',
documentNameTooLong: 'Document name cannot exceed {max} characters',
documentNameRequired: 'Document name cannot be empty',
cannotDeleteLastDocument: 'Cannot delete the last document',
cannotDeleteDefaultDocument: 'Cannot delete the default document',
unknownTime: 'Unknown time',
invalidDate: 'Invalid date',
timeError: 'Time error',
},
languages: {
'zh-CN': 'Chinese',
@@ -51,11 +44,18 @@ export default {
auto: 'Follow System'
},
keybindings: {
keymapMode: 'Keymap Mode',
modes: {
standard: 'Standard Mode',
emacs: 'Emacs Mode'
},
headers: {
shortcut: 'Shortcut',
category: 'Category',
extension: 'Extension',
description: 'Description'
},
resetToDefault: 'Reset to Default',
confirmReset: 'Confirm Reset?',
commands: {
showSearch: 'Show search panel',
hideSearch: 'Hide search panel',
@@ -83,6 +83,7 @@ export default {
blockCopy: 'Copy',
blockCut: 'Cut',
blockPaste: 'Paste',
copyBlockImage: 'Copy block image',
historyUndo: 'Undo',
historyRedo: 'Redo',
historyUndoSelection: 'Undo selection',
@@ -100,6 +101,25 @@ export default {
insertBlankLine: 'Insert blank line',
selectLine: 'Select line',
selectParentSyntax: 'Select parent syntax',
simplifySelection: 'Simplify selection',
addCursorAbove: 'Add cursor above',
addCursorBelow: 'Add cursor below',
cursorGroupLeft: 'Cursor word left',
cursorGroupRight: 'Cursor word right',
selectGroupLeft: 'Select word left',
selectGroupRight: 'Select word right',
deleteToLineEnd: 'Delete to line end',
deleteToLineStart: 'Delete to line start',
cursorLineStart: 'Cursor to line start',
cursorLineEnd: 'Cursor to line end',
selectLineStart: 'Select to line start',
selectLineEnd: 'Select to line end',
cursorDocStart: 'Cursor to document start',
cursorDocEnd: 'Cursor to document end',
selectDocStart: 'Select to document start',
selectDocEnd: 'Select to document end',
selectMatchingBracket: 'Select to matching bracket',
splitLine: 'Split line',
indentLess: 'Indent less',
indentMore: 'Indent more',
indentSelection: 'Indent selection',
@@ -111,7 +131,18 @@ export default {
deleteCharForward: 'Delete character forward',
deleteGroupBackward: 'Delete group backward',
deleteGroupForward: 'Delete group forward',
textHighlightToggle: 'Toggle text highlight',
// Emacs mode additional basic navigation commands
cursorCharLeft: 'Cursor left one character',
cursorCharRight: 'Cursor right one character',
cursorLineUp: 'Cursor up one line',
cursorLineDown: 'Cursor down one line',
cursorPageUp: 'Page up',
cursorPageDown: 'Page down',
selectCharLeft: 'Select left one character',
selectCharRight: 'Select right one character',
selectLineUp: 'Select up one line',
selectLineDown: 'Select down one line',
}
},
tabs: {
@@ -235,14 +266,10 @@ export default {
sshKeyPassphrase: 'SSH Key Passphrase',
sshKeyPassphrasePlaceholder: 'Enter SSH key passphrase',
backupOperations: 'Backup Operations',
pushToRemote: 'Push to Remote',
pushing: 'Pushing...',
syncToRemote: 'Sync to Remote',
syncing: 'Syncing...',
actions: {
push: 'Push',
},
status: {
success: 'Success',
failed: 'Failed'
sync: 'Sync',
}
},
},
@@ -257,7 +284,7 @@ export default {
},
colorSelector: {
name: 'Color Selector',
description: 'Visual color picker and color value display'
description: 'CSS code block visual color picker and color value display'
},
translator: {
name: 'Text Translator',
@@ -275,19 +302,38 @@ export default {
name: 'Code Folding',
description: 'Collapse and expand code sections for better readability'
},
textHighlight: {
name: 'Text Highlight',
description: 'Highlight selected text content (Ctrl+Shift+H to toggle highlight)',
backgroundColor: 'Background Color',
opacity: 'Opacity'
},
checkbox: {
name: 'Checkbox',
description: 'Render [x] and [ ] as interactive checkboxes'
markdown: {
name: 'Markdown Renderer',
description: 'Render Markdown elements, "what you see is what you get"'
},
codeblock: {
name: 'Code Block',
description: 'Code block related functionality'
},
lineNumbers: {
name: 'Line Numbers',
description: 'Display line numbers on the left side of the editor and highlight the current line'
},
contextMenu: {
name: 'Context Menu',
description: 'Show context menu when right-clicking in the editor'
},
highlightWhitespace: {
name: 'Highlight Whitespace',
description: 'Display whitespace characters such as spaces and tabs in the editor'
},
highlightTrailingWhitespace: {
name: 'Highlight Trailing Whitespace',
description: 'Highlight trailing whitespace at the end of lines'
},
httpClient: {
name: 'HTTP Client',
description: 'Send HTTP requests directly in the editor and view responses'
},
blockImage: {
name: 'Block Image Export',
description: 'Render the current code block to an image and copy it to the clipboard',
copyMenu: 'Copy block as image'
}
},
monitor: {

View File

@@ -33,13 +33,6 @@ export default {
confirmDelete: '再次点击确认删除',
openInNewWindow: '在新窗口中打开',
alreadyOpenInNewWindow: '已在新窗口中打开',
documentNameTooLong: '文档名称不能超过{max}个字符',
documentNameRequired: '文档名称不能为空',
cannotDeleteLastDocument: '无法删除最后一个文档',
cannotDeleteDefaultDocument: '无法删除默认文档',
unknownTime: '未知时间',
invalidDate: '无效日期',
timeError: '时间错误',
},
languages: {
'zh-CN': '简体中文',
@@ -51,11 +44,18 @@ export default {
auto: '跟随系统'
},
keybindings: {
keymapMode: '快捷键模式',
modes: {
standard: '标准模式',
emacs: 'Emacs 模式'
},
headers: {
shortcut: '快捷键',
category: '分类',
extension: '扩展',
description: '描述'
},
resetToDefault: '重置为默认',
confirmReset: '确认重置?',
commands: {
showSearch: '显示搜索面板',
hideSearch: '隐藏搜索面板',
@@ -83,6 +83,7 @@ export default {
blockCopy: '复制',
blockCut: '剪切',
blockPaste: '粘贴',
copyBlockImage: '复制块图片',
historyUndo: '撤销',
historyRedo: '重做',
historyUndoSelection: '撤销选择',
@@ -100,6 +101,25 @@ export default {
insertBlankLine: '插入空行',
selectLine: '选择行',
selectParentSyntax: '选择父级语法',
simplifySelection: '简化选择',
addCursorAbove: '在上方添加光标',
addCursorBelow: '在下方添加光标',
cursorGroupLeft: '光标按单词左移',
cursorGroupRight: '光标按单词右移',
selectGroupLeft: '按单词选择左侧',
selectGroupRight: '按单词选择右侧',
deleteToLineEnd: '删除到行尾',
deleteToLineStart: '删除到行首',
cursorLineStart: '移动到行首',
cursorLineEnd: '移动到行尾',
selectLineStart: '选择到行首',
selectLineEnd: '选择到行尾',
cursorDocStart: '跳转到文档开头',
cursorDocEnd: '跳转到文档结尾',
selectDocStart: '选择到文档开头',
selectDocEnd: '选择到文档结尾',
selectMatchingBracket: '选择到匹配括号',
splitLine: '分割行',
indentLess: '减少缩进',
indentMore: '增加缩进',
indentSelection: '缩进选择',
@@ -111,7 +131,18 @@ export default {
deleteCharForward: '向前删除字符',
deleteGroupBackward: '向后删除组',
deleteGroupForward: '向前删除组',
textHighlightToggle: '切换文本高亮',
// Emacs 模式额外的基础导航命令
cursorCharLeft: '光标左移一个字符',
cursorCharRight: '光标右移一个字符',
cursorLineUp: '光标上移一行',
cursorLineDown: '光标下移一行',
cursorPageUp: '向上翻页',
cursorPageDown: '向下翻页',
selectCharLeft: '选择左移一个字符',
selectCharRight: '选择右移一个字符',
selectLineUp: '选择上移一行',
selectLineDown: '选择下移一行',
}
},
tabs: {
@@ -237,14 +268,10 @@ export default {
sshKeyPassphrase: 'SSH密钥密码',
sshKeyPassphrasePlaceholder: '请输入SSH密钥密码',
backupOperations: '备份操作',
pushToRemote: '推送到远程',
pushing: '推送中...',
syncToRemote: '同步到远程',
syncing: '同步中...',
actions: {
push: '推送',
},
status: {
success: '成功',
failed: '失败'
sync: '同步',
}
},
},
@@ -259,7 +286,7 @@ export default {
},
colorSelector: {
name: '颜色选择器',
description: '颜色值的可视化和选择'
description: 'CSS代码块颜色值的可视化和选择'
},
translator: {
name: '划词翻译',
@@ -277,19 +304,38 @@ export default {
name: '代码折叠',
description: '折叠和展开代码段以提高代码可读性'
},
textHighlight: {
name: '文本高亮',
description: '高亮选中的文本内容 (Ctrl+Shift+H 切换高亮)',
backgroundColor: '背景颜色',
opacity: '透明度'
},
checkbox: {
name: '选择框',
description: '将 [x] 和 [ ] 渲染为可交互的选择框'
markdown: {
name: 'Markdown 渲染',
description: '渲染 Markdown 元素,“所见即所得”'
},
codeblock: {
name: '代码块',
description: '代码块相关功能'
},
lineNumbers: {
name: '行号显示',
description: '在编辑器左侧显示行号,并高亮当前行'
},
contextMenu: {
name: '上下文菜单',
description: '在编辑器中右键点击时显示上下文菜单'
},
highlightWhitespace: {
name: '显示空白字符',
description: '在编辑器中显示空格和制表符等空白字符'
},
highlightTrailingWhitespace: {
name: '高亮行尾空白',
description: '高亮显示行尾的多余空白字符'
},
httpClient: {
name: 'HTTP 客户端',
description: '在编辑器中直接发送 HTTP 请求并查看响应'
},
blockImage: {
name: '代码块导出图片',
description: '将当前代码块渲染为图片并复制到剪贴板',
copyMenu: '复制块为图片'
}
},
monitor: {

View File

@@ -1,15 +1,21 @@
import {createApp} from 'vue';
import { createApp } from 'vue';
import App from './App.vue';
import '@/assets/styles/index.css';
import {createPinia} from 'pinia';
import { createPinia } from 'pinia';
import i18n from './i18n';
import router from './router';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import { registerDirectives } from './directives';
import {EditorView} from "@codemirror/view";
(EditorView as any).EDIT_CONTEXT = false;
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
const app = createApp(App);
app.use(pinia)
app.use(pinia);
app.use(i18n);
app.use(router);
registerDirectives(app);
app.mount('#app');

View File

@@ -1,49 +1,31 @@
import { defineStore } from 'pinia';
import { ref, onScopeDispose } from 'vue';
import { ref } from 'vue';
import { BackupService } from '@/../bindings/voidraft/internal/services';
import { useConfigStore } from '@/stores/configStore';
import { createTimerManager } from '@/common/utils/timerUtils';
export const useBackupStore = defineStore('backup', () => {
const isPushing = ref(false);
const message = ref<string | null>(null);
const isError = ref(false);
const timer = createTimerManager();
const configStore = useConfigStore();
const isSyncing = ref(false);
const error = ref<string | null>(null);
onScopeDispose(() => timer.clear());
const pushToRemote = async () => {
const isConfigured = Boolean(configStore.config.backup.repo_url?.trim());
if (isPushing.value || !isConfigured) {
const sync = async (): Promise<void> => {
if (isSyncing.value) {
return;
}
isSyncing.value = true;
error.value = null;
try {
isPushing.value = true;
message.value = null;
timer.clear();
await BackupService.PushToRemote();
isError.value = false;
message.value = 'push successful';
timer.set(() => { message.value = null; }, 3000);
} catch (error) {
isError.value = true;
message.value = error instanceof Error ? error.message : 'backup operation failed';
timer.set(() => { message.value = null; }, 5000);
await BackupService.Sync();
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
isPushing.value = false;
isSyncing.value = false;
}
};
return {
isPushing,
message,
isError,
pushToRemote
isSyncing,
error,
sync
};
});

View File

@@ -161,7 +161,7 @@ export const useConfigStore = defineStore('config', () => {
// 初始化语言设置
const initializeLanguage = async (): Promise<void> => {
const initLanguage = async (): Promise<void> => {
try {
// 如果配置未加载,先加载配置
if (!state.configLoaded) {
@@ -210,7 +210,7 @@ export const useConfigStore = defineStore('config', () => {
// 语言相关方法
setLanguage,
initializeLanguage,
initLanguage,
// 主题相关方法
setSystemTheme,
@@ -248,7 +248,7 @@ export const useConfigStore = defineStore('config', () => {
setFontWeight: (value: string) => updateConfig('fontWeight', value),
// 路径操作
setDataPath: (value: string) => updateConfig('dataPath', value),
setDataPath: (value: string) => updateConfigLocal('dataPath', value),
// 保存配置相关方法
setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value),
@@ -275,6 +275,9 @@ export const useConfigStore = defineStore('config', () => {
// 标签页配置相关方法
setEnableTabs: (value: boolean) => updateConfig('enableTabs', value),
// 快捷键模式配置相关方法
setKeymapMode: (value: any) => updateConfig('keymapMode', value),
// 更新配置相关方法
setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value),

View File

@@ -2,12 +2,11 @@ import {defineStore} from 'pinia';
import {computed, ref} from 'vue';
import {DocumentService} from '@/../bindings/voidraft/internal/services';
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
import {Document} from '@/../bindings/voidraft/internal/models/models';
import {Document} from '@/../bindings/voidraft/internal/models/ent/models';
import {useTabStore} from "@/stores/tabStore";
import type {EditorViewState} from '@/stores/editorStore';
export const useDocumentStore = defineStore('document', () => {
const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID
// === 核心状态 ===
const documents = ref<Record<number, Document>>({});
@@ -15,7 +14,6 @@ export const useDocumentStore = defineStore('document', () => {
const currentDocument = ref<Document | null>(null);
// === 编辑器状态持久化 ===
// 修复:使用统一的 EditorViewState 类型定义
const documentStates = ref<Record<number, EditorViewState>>({});
// === UI状态 ===
@@ -26,15 +24,18 @@ export const useDocumentStore = defineStore('document', () => {
// === 计算属性 ===
const documentList = computed(() =>
Object.values(documents.value).sort((a, b) => {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
const timeA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const timeB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return timeB - timeA;
})
);
// === 私有方法 ===
const setDocuments = (docs: Document[]) => {
documents.value = {};
docs.forEach(doc => {
documents.value[doc.id] = doc;
if (doc.id !== undefined) {
documents.value[doc.id] = doc;
}
});
};
@@ -64,25 +65,16 @@ export const useDocumentStore = defineStore('document', () => {
clearError();
};
const toggleDocumentSelector = () => {
if (showDocumentSelector.value) {
closeDocumentSelector();
} else {
openDocumentSelector();
}
};
// === 文档操作方法 ===
// 在新窗口中打开文档
const openDocumentInNewWindow = async (docId: number): Promise<boolean> => {
try {
await OpenDocumentWindow(docId);
const tabStore = useTabStore();
if (tabStore.isTabsEnabled && tabStore.hasTab(docId)) {
tabStore.closeTab(docId);
}
await OpenDocumentWindow(docId);
return true;
} catch (error) {
console.error('Failed to open document in new window:', error);
@@ -94,7 +86,7 @@ export const useDocumentStore = defineStore('document', () => {
const createNewDocument = async (title: string): Promise<Document | null> => {
try {
const doc = await DocumentService.CreateDocument(title);
if (doc) {
if (doc && doc.id !== undefined) {
documents.value[doc.id] = doc;
return doc;
}
@@ -123,8 +115,6 @@ export const useDocumentStore = defineStore('document', () => {
// 打开文档
const openDocument = async (docId: number): Promise<boolean> => {
try {
closeDocumentSelector();
// 获取完整文档数据
const doc = await DocumentService.GetDocumentByID(docId);
if (!doc) {
@@ -150,14 +140,18 @@ export const useDocumentStore = defineStore('document', () => {
const doc = documents.value[docId];
if (doc) {
doc.title = title;
doc.updatedAt = new Date().toISOString();
doc.updated_at = new Date().toISOString();
}
if (currentDocument.value?.id === docId) {
currentDocument.value.title = title;
currentDocument.value.updatedAt = new Date().toISOString();
currentDocument.value.updated_at = new Date().toISOString();
}
// 同步更新标签页标题
const tabStore = useTabStore();
tabStore.updateTabTitle(docId, title);
return true;
} catch (error) {
console.error('Failed to update document metadata:', error);
@@ -168,20 +162,21 @@ export const useDocumentStore = defineStore('document', () => {
// 删除文档
const deleteDocument = async (docId: number): Promise<boolean> => {
try {
// 检查是否是默认文档使用ID判断
if (docId === DEFAULT_DOCUMENT_ID.value) {
return false;
}
await DocumentService.DeleteDocument(docId);
// 更新本地状态
delete documents.value[docId];
// 同步清理标签页
const tabStore = useTabStore();
if (tabStore.hasTab(docId)) {
tabStore.closeTab(docId);
}
// 如果删除的是当前文档,切换到第一个可用文档
if (currentDocumentId.value === docId) {
const availableDocs = Object.values(documents.value);
if (availableDocs.length > 0) {
if (availableDocs.length > 0 && availableDocs[0].id !== undefined) {
await openDocument(availableDocs[0].id);
} else {
currentDocumentId.value = null;
@@ -208,8 +203,10 @@ export const useDocumentStore = defineStore('document', () => {
// 如果URL中没有指定文档ID则使用持久化的文档ID
await openDocument(currentDocumentId.value);
} else {
// 否则打开默认文档
await openDocument(DEFAULT_DOCUMENT_ID.value);
// 否则打开第一个文档
if (documentList.value[0].id) {
await openDocument(documentList.value[0].id);
}
}
} catch (error) {
console.error('Failed to initialize document store:', error);
@@ -217,7 +214,6 @@ export const useDocumentStore = defineStore('document', () => {
};
return {
DEFAULT_DOCUMENT_ID,
// 状态
documents,
documentList,
@@ -237,7 +233,6 @@ export const useDocumentStore = defineStore('document', () => {
deleteDocument,
openDocumentSelector,
closeDocumentSelector,
toggleDocumentSelector,
setError,
clearError,
initialize,

View File

@@ -4,7 +4,6 @@ import {EditorView} from '@codemirror/view';
import {EditorState, Extension} from '@codemirror/state';
import {useConfigStore} from './configStore';
import {useDocumentStore} from './documentStore';
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
import {ensureSyntaxTree} from "@codemirror/language";
import {createBasicSetup} from '@/views/editor/basic/basicSetup';
@@ -14,6 +13,7 @@ import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/b
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
import {createWheelZoomExtension} from '@/views/editor/basic/wheelZoomExtension';
import {createCursorPositionExtension, scrollToCursor} from '@/views/editor/basic/cursorPositionExtension';
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
import {
createDynamicExtensions,
@@ -22,15 +22,14 @@ import {
setExtensionManagerView
} from '@/views/editor/manager';
import {useExtensionStore} from './extensionStore';
import createCodeBlockExtension, {blockState} from "@/views/editor/extensions/codeblock";
import createCodeBlockExtension from "@/views/editor/extensions/codeblock";
import {LruCache} from '@/common/utils/lruCache';
import {AsyncManager} from '@/common/utils/asyncManager';
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 {createDebounce} from '@/common/utils/debounce';
import markdownExtensions from "@/views/editor/extensions/markdown";
import {useKeybindingStore} from "@/stores/keybindingStore";
export interface DocumentStats {
lines: number;
@@ -38,7 +37,7 @@ export interface DocumentStats {
selectedCharacters: number;
}
// 修复:只保存光标位置,恢复时自动滚动到光标处(更简单可靠)
// 修复:只保存光标位置,恢复时自动滚动到光标处
export interface EditorViewState {
cursorPos: number;
}
@@ -94,84 +93,6 @@ export const useEditorStore = defineStore('editor', () => {
}
}, { delay: 500 }); // 500ms 内的多次输入只清理一次
// === 私有方法 ===
/**
* 检查位置是否在代码块分隔符区域内
*/
const isPositionInDelimiter = (view: EditorView, pos: number): boolean => {
try {
const blocks = view.state.field(blockState, false);
if (!blocks) return false;
for (const block of blocks) {
if (pos >= block.delimiter.from && pos < block.delimiter.to) {
return true;
}
}
return false;
} catch {
return false;
}
};
/**
* 调整光标位置到有效的内容区域
* 如果位置在分隔符内,移动到该块的内容开始位置
*/
const adjustCursorPosition = (view: EditorView, pos: number): number => {
try {
const blocks = view.state.field(blockState, false);
if (!blocks || blocks.length === 0) return pos;
// 如果位置在分隔符内,移动到该块的内容开始位置
for (const block of blocks) {
if (pos >= block.delimiter.from && pos < block.delimiter.to) {
return block.content.from;
}
}
return pos;
} catch {
return pos;
}
};
/**
* 恢复编辑器的光标位置(自动滚动到光标处)
*/
const restoreEditorState = (instance: EditorInstance, documentId: number): void => {
const savedState = instance.editorState;
if (savedState) {
// 有保存的状态,恢复光标位置
let pos = Math.min(savedState.cursorPos, instance.view.state.doc.length);
// 确保位置不在分隔符上
if (isPositionInDelimiter(instance.view, pos)) {
pos = adjustCursorPosition(instance.view, pos);
}
// 修复:设置光标位置并居中滚动(更好的用户体验)
instance.view.dispatch({
selection: {anchor: pos, head: pos},
effects: EditorView.scrollIntoView(pos, {
y: "center", // 垂直居中显示
yMargin: 100 // 上下留一些边距
})
});
} else {
// 首次打开或没有记录,光标在文档末尾
const docLength = instance.view.state.doc.length;
instance.view.dispatch({
selection: {anchor: docLength, head: docLength},
effects: EditorView.scrollIntoView(docLength, {
y: "center",
yMargin: 100
})
});
}
};
// 缓存化的语法树确保方法
const ensureSyntaxTreeCached = (view: EditorView, documentId: number): void => {
@@ -246,7 +167,7 @@ export const useEditorStore = defineStore('editor', () => {
increaseFontSize: () => configStore.increaseFontSizeLocal(),
decreaseFontSize: () => configStore.decreaseFontSizeLocal(),
onSave: () => configStore.saveFontSize(),
saveDelay: 500
saveDelay: 1000
});
// 统计扩展
@@ -261,8 +182,8 @@ export const useEditorStore = defineStore('editor', () => {
enableAutoDetection: true
});
const httpExtension = createHttpClientExtension();
// 光标位置持久化扩展
const cursorPositionExtension = createCursorPositionExtension(documentId);
// 再次检查操作有效性
if (!operationManager.isOperationValid(operationId, documentId)) {
@@ -278,7 +199,7 @@ export const useEditorStore = defineStore('editor', () => {
}
// 动态扩展传递文档ID以便扩展管理器可以预初始化
const dynamicExtensions = await createDynamicExtensions(documentId);
const dynamicExtensions = await createDynamicExtensions();
// 最终检查操作有效性
if (!operationManager.isOperationValid(operationId, documentId)) {
@@ -296,15 +217,23 @@ export const useEditorStore = defineStore('editor', () => {
statsExtension,
contentChangeExtension,
codeBlockExtension,
cursorPositionExtension,
...dynamicExtensions,
...httpExtension,
markdownExtensions
];
// 创建编辑器状态
// 获取保存的光标位置
const savedState = documentStore.documentStates[documentId];
const docLength = content.length;
const initialCursorPos = savedState?.cursorPos !== undefined
? Math.min(savedState.cursorPos, docLength)
: docLength;
// 创建编辑器状态,设置初始光标位置
const state = EditorState.create({
doc: content,
extensions
extensions,
selection: { anchor: initialCursorPos, head: initialCursorPos }
});
return new EditorView({
@@ -322,7 +251,6 @@ export const useEditorStore = defineStore('editor', () => {
lastModified: new Date(),
autoSaveTimer: createTimerManager(),
syntaxTreeCache: null,
// 修复:创建实例时从 documentStore 读取持久化的编辑器状态
editorState: documentStore.documentStates[documentId]
};
@@ -403,8 +331,8 @@ export const useEditorStore = defineStore('editor', () => {
//使用 nextTick + requestAnimationFrame 确保 DOM 完全渲染
nextTick(() => {
requestAnimationFrame(() => {
// 恢复编辑器状态(光标位置和滚动位置)
restoreEditorState(instance, documentId);
// 滚动到当前光标位置
scrollToCursor(instance.view);
// 聚焦编辑器
instance.view.focus();
@@ -435,7 +363,6 @@ export const useEditorStore = defineStore('editor', () => {
instance.isDirty = false;
instance.lastModified = new Date();
}
// 如果内容在保存期间被修改了,保持 isDirty 状态
return true;
} catch (error) {
@@ -464,15 +391,14 @@ export const useEditorStore = defineStore('editor', () => {
}, getAutoSaveDelay());
};
// === 公共API ===
// 设置编辑器容器
const setEditorContainer = (container: HTMLElement | null) => {
containerElement.value = container;
// 如果设置容器时已有当前文档,立即加载编辑器
if (container && documentStore.currentDocument) {
loadEditor(documentStore.currentDocument.id, documentStore.currentDocument.content);
if (container && documentStore.currentDocument && documentStore.currentDocument.id !== undefined) {
loadEditor(documentStore.currentDocument.id, documentStore.currentDocument.content || '');
}
};
@@ -579,15 +505,6 @@ export const useEditorStore = defineStore('editor', () => {
await saveEditorContent(documentId);
}
// 保存光标位置
if (instance.view && instance.view.state) {
const currentState: EditorViewState = {
cursorPos: instance.view.state.selection.main.head
};
// 保存到 documentStore 用于持久化
documentStore.documentStates[documentId] = currentState;
}
// 清除自动保存定时器
instance.autoSaveTimer.clear();
@@ -670,22 +587,10 @@ export const useEditorStore = defineStore('editor', () => {
operationManager.cancelAllOperations();
editorCache.clear((_documentId, instance) => {
// 修复:清空前只保存光标位置
if (instance.view) {
const currentState: EditorViewState = {
cursorPos: instance.view.state.selection.main.head
};
// 同时保存到实例和 documentStore
instance.editorState = currentState;
documentStore.documentStates[instance.documentId] = currentState;
}
// 清除自动保存定时器
instance.autoSaveTimer.clear();
// 从扩展管理器移除
removeExtensionManagerView(instance.documentId);
// 移除DOM元素
if (instance.view.dom.parentElement) {
instance.view.dom.remove();
@@ -698,50 +603,39 @@ export const useEditorStore = defineStore('editor', () => {
};
// 更新扩展
const updateExtension = async (id: ExtensionID, enabled: boolean, config?: any) => {
// 如果只是更新启用状态
if (config === undefined) {
await ExtensionService.UpdateExtensionEnabled(id, enabled);
} else {
// 如果需要更新配置
await ExtensionService.UpdateExtensionState(id, enabled, config);
const updateExtension = async (id: number, enabled: boolean, config?: any) => {
// 更新启用状态
await ExtensionService.UpdateExtensionEnabled(id, enabled);
// 如果需要更新配置
if (config !== undefined) {
await ExtensionService.UpdateExtensionConfig(id, config);
}
// 重新加载扩展配置
await extensionStore.loadExtensions();
// 获取更新后的扩展名称
const extension = extensionStore.extensions.find(ext => ext.id === id);
if (!extension) return;
// 更新前端编辑器扩展 - 应用于所有实例
const manager = getExtensionManager();
if (manager) {
// 直接更新前端扩展至所有视图
manager.updateExtension(id, enabled, config);
}
// 重新加载扩展配置
await extensionStore.loadExtensions();
if (manager) {
manager.initExtensions(extensionStore.extensions);
manager.updateExtension(extension.name, enabled, config);
}
await useKeybindingStore().loadKeyBindings();
await applyKeymapSettings();
};
// 监听文档切换
watch(() => documentStore.currentDocument, async (newDoc, oldDoc) => {
if (newDoc && containerElement.value) {
// 修复:在切换到新文档前,只保存旧文档的光标位置
if (oldDoc && oldDoc.id !== newDoc.id && currentEditor.value) {
const oldInstance = editorCache.get(oldDoc.id);
if (oldInstance) {
const currentState: EditorViewState = {
cursorPos: currentEditor.value.state.selection.main.head
};
// 同时保存到实例和 documentStore
oldInstance.editorState = currentState;
documentStore.documentStates[oldDoc.id] = currentState;
}
}
if (newDoc && newDoc.id !== undefined && containerElement.value) {
// 等待 DOM 更新完成,再加载新文档的编辑器
await nextTick();
loadEditor(newDoc.id, newDoc.content);
loadEditor(newDoc.id, newDoc.content || '');
}
});

View File

@@ -1,28 +1,19 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { Extension, ExtensionID } from '@/../bindings/voidraft/internal/models/models';
import { Extension } from '@/../bindings/voidraft/internal/models/ent/models';
import { ExtensionService } from '@/../bindings/voidraft/internal/services';
export const useExtensionStore = defineStore('extension', () => {
// 扩展配置数据
const extensions = ref<Extension[]>([]);
// 获取启用的扩展
const enabledExtensions = computed(() =>
extensions.value.filter(ext => ext.enabled)
);
// 获取启用的扩展ID列表
const enabledExtensionIds = computed(() =>
enabledExtensions.value.map(ext => ext.id)
);
/**
* 从后端加载扩展配置
*/
const loadExtensions = async (): Promise<void> => {
try {
extensions.value = await ExtensionService.GetAllExtensions();
const result = await ExtensionService.GetExtensions();
extensions.value = result.filter((ext): ext is Extension => ext !== null);
} catch (err) {
console.error('[ExtensionStore] Failed to load extensions:', err);
}
@@ -31,17 +22,19 @@ export const useExtensionStore = defineStore('extension', () => {
/**
* 获取扩展配置
*/
const getExtensionConfig = (id: ExtensionID): any => {
const extension = extensions.value.find(ext => ext.id === id);
return extension?.config ?? {};
const getExtensionConfig = async (id: number): Promise<any> => {
try {
const config = await ExtensionService.GetExtensionConfig(id);
return config ?? {};
} catch (err) {
console.error('[ExtensionStore] Failed to get extension config:', err);
return {};
}
};
return {
// 状态
extensions,
enabledExtensions,
enabledExtensionIds,
// 方法
loadExtensions,
getExtensionConfig,

View File

@@ -1,82 +1,38 @@
import {defineStore} from 'pinia';
import {computed, ref} from 'vue';
import {ExtensionID, KeyBinding, KeyBindingCommand} from '@/../bindings/voidraft/internal/models/models';
import {GetAllKeyBindings} from '@/../bindings/voidraft/internal/services/keybindingservice';
import {KeyBinding} from '@/../bindings/voidraft/internal/models/ent/models';
import {KeyBindingService} from '@/../bindings/voidraft/internal/services';
import {KeyBindingType} from '@/../bindings/voidraft/internal/models/models';
import {useConfigStore} from './configStore';
export const useKeybindingStore = defineStore('keybinding', () => {
const configStore = useConfigStore();
// 快捷键配置数据
const keyBindings = ref<KeyBinding[]>([]);
// 获取启用的快捷键
const enabledKeyBindings = computed(() =>
keyBindings.value.filter(kb => kb.enabled)
);
// 按扩展分组的快捷键
const keyBindingsByExtension = computed(() => {
const groups = new Map<ExtensionID, KeyBinding[]>();
for (const binding of keyBindings.value) {
if (!groups.has(binding.extension)) {
groups.set(binding.extension, []);
}
groups.get(binding.extension)!.push(binding);
}
return groups;
});
// 获取指定扩展的快捷键
const getKeyBindingsByExtension = computed(() =>
(extension: ExtensionID) =>
keyBindings.value.filter(kb => kb.extension === extension)
);
// 按命令获取快捷键
const getKeyBindingByCommand = computed(() =>
(command: KeyBindingCommand) =>
keyBindings.value.find(kb => kb.command === command)
);
/**
* 从后端加载快捷键配置
* 从后端加载快捷键配置(根据当前配置的模式)
*/
const loadKeyBindings = async (): Promise<void> => {
keyBindings.value = await GetAllKeyBindings();
const keymapMode = configStore.config.editing.keymapMode || KeyBindingType.Standard;
const result = await KeyBindingService.GetKeyBindings(keymapMode);
keyBindings.value = result.filter((kb): kb is KeyBinding => kb !== null);
};
/**
* 检查是否存在指定命令的快捷键
* 更新快捷键绑定
*/
const hasCommand = (command: KeyBindingCommand): boolean => {
return keyBindings.value.some(kb => kb.command === command && kb.enabled);
const updateKeyBinding = async (id: number, key: string): Promise<void> => {
await KeyBindingService.UpdateKeyBindingKeys(id, key);
await loadKeyBindings();
};
/**
* 获取扩展相关的所有扩展ID
*/
const getAllExtensionIds = computed(() => {
const extensionIds = new Set<ExtensionID>();
for (const binding of keyBindings.value) {
extensionIds.add(binding.extension);
}
return Array.from(extensionIds);
});
return {
// 状态
keyBindings,
enabledKeyBindings,
keyBindingsByExtension,
getAllExtensionIds,
// 计算属性
getKeyBindingByCommand,
getKeyBindingsByExtension,
// 方法
loadKeyBindings,
hasCommand,
updateKeyBinding,
};
});

View File

@@ -38,7 +38,7 @@ export const useSystemStore = defineStore('system', () => {
});
// 初始化系统信息
const initializeSystemInfo = async (): Promise<void> => {
const initSystemInfo = async (): Promise<void> => {
if (isLoading.value) return;
isLoading.value = true;
@@ -102,7 +102,7 @@ export const useSystemStore = defineStore('system', () => {
titleBarHeight,
// 方法
initializeSystemInfo,
initSystemInfo,
setWindowOnTop,
toggleWindowOnTop,
resetWindowOnTop,

View File

@@ -2,7 +2,7 @@ import {defineStore} from 'pinia';
import {computed, readonly, ref} from 'vue';
import {useConfigStore} from './configStore';
import {useDocumentStore} from './documentStore';
import type {Document} from '@/../bindings/voidraft/internal/models/models';
import type {Document} from '@/../bindings/voidraft/internal/models/ent/models';
export interface Tab {
documentId: number; // 直接使用文档ID作为唯一标识
@@ -15,7 +15,7 @@ export const useTabStore = defineStore('tab', () => {
const documentStore = useDocumentStore();
// === 核心状态 ===
const tabsMap = ref<Map<number, Tab>>(new Map());
const tabsMap = ref<Record<number, Tab>>({});
const tabOrder = ref<number[]>([]); // 维护标签页顺序
const draggedTabId = ref<number | null>(null);
@@ -28,21 +28,21 @@ export const useTabStore = defineStore('tab', () => {
// 按顺序返回标签页数组用于UI渲染
const tabs = computed(() => {
return tabOrder.value
.map(documentId => tabsMap.value.get(documentId))
.map(documentId => tabsMap.value[documentId])
.filter(tab => tab !== undefined) as Tab[];
});
// === 私有方法 ===
const hasTab = (documentId: number): boolean => {
return tabsMap.value.has(documentId);
return documentId in tabsMap.value;
};
const getTab = (documentId: number): Tab | undefined => {
return tabsMap.value.get(documentId);
return tabsMap.value[documentId];
};
const updateTabTitle = (documentId: number, title: string) => {
const tab = tabsMap.value.get(documentId);
const tab = tabsMap.value[documentId];
if (tab) {
tab.title = title;
}
@@ -55,6 +55,7 @@ export const useTabStore = defineStore('tab', () => {
*/
const addOrActivateTab = (document: Document) => {
const documentId = document.id;
if (documentId === undefined) return;
if (hasTab(documentId)) {
// 标签页已存在,无需重复添加
@@ -64,10 +65,10 @@ export const useTabStore = defineStore('tab', () => {
// 创建新标签页
const newTab: Tab = {
documentId,
title: document.title
title: document.title || ''
};
tabsMap.value.set(documentId, newTab);
tabsMap.value[documentId] = newTab;
tabOrder.value.push(documentId);
};
@@ -81,7 +82,7 @@ export const useTabStore = defineStore('tab', () => {
if (tabIndex === -1) return;
// 从映射和顺序数组中移除
tabsMap.value.delete(documentId);
delete tabsMap.value[documentId];
tabOrder.value.splice(tabIndex, 1);
// 如果关闭的是当前文档,需要切换到其他文档
@@ -111,7 +112,7 @@ export const useTabStore = defineStore('tab', () => {
if (tabIndex === -1) return;
// 从映射和顺序数组中移除
tabsMap.value.delete(documentId);
delete tabsMap.value[documentId];
tabOrder.value.splice(tabIndex, 1);
});
};
@@ -121,12 +122,12 @@ export const useTabStore = defineStore('tab', () => {
*/
const switchToTabAndDocument = (documentId: number) => {
if (!hasTab(documentId)) return;
// 如果点击的是当前已激活的文档,不需要重复请求
if (documentStore.currentDocumentId === documentId) {
return;
}
documentStore.openDocument(documentId);
};
@@ -150,10 +151,31 @@ export const useTabStore = defineStore('tab', () => {
return tabOrder.value.indexOf(documentId);
};
/**
* 验证并清理无效的标签页
*/
const validateTabs = () => {
const validDocIds = Object.keys(documentStore.documents).map(Number);
// 找出无效的标签页(文档已被删除)
const invalidTabIds = tabOrder.value.filter(docId => !validDocIds.includes(docId));
if (invalidTabIds.length > 0) {
// 批量清理无效标签页
invalidTabIds.forEach(docId => {
delete tabsMap.value[docId];
});
tabOrder.value = tabOrder.value.filter(docId => validDocIds.includes(docId));
}
};
/**
* 初始化标签页(当前文档)
*/
const initializeTab = () => {
// 先验证并清理无效的标签页(处理持久化的脏数据)
validateTabs();
if (isTabsEnabled.value) {
const currentDoc = documentStore.currentDocument;
if (currentDoc) {
@@ -169,13 +191,13 @@ export const useTabStore = defineStore('tab', () => {
*/
const closeOtherTabs = (keepDocumentId: number) => {
if (!hasTab(keepDocumentId)) return;
// 获取所有其他标签页的ID
const otherTabIds = tabOrder.value.filter(id => id !== keepDocumentId);
// 批量关闭其他标签页
closeTabs(otherTabIds);
// 如果当前打开的文档在被关闭的标签中,需要切换到保留的文档
if (otherTabIds.includes(documentStore.currentDocumentId!)) {
switchToTabAndDocument(keepDocumentId);
@@ -188,13 +210,13 @@ export const useTabStore = defineStore('tab', () => {
const closeTabsToRight = (documentId: number) => {
const index = getTabIndex(documentId);
if (index === -1) return;
// 获取右侧所有标签页的ID
const rightTabIds = tabOrder.value.slice(index + 1);
// 批量关闭右侧标签页
closeTabs(rightTabIds);
// 如果当前打开的文档在被关闭的右侧标签中,需要切换到指定的文档
if (rightTabIds.includes(documentStore.currentDocumentId!)) {
switchToTabAndDocument(documentId);
@@ -207,13 +229,13 @@ export const useTabStore = defineStore('tab', () => {
const closeTabsToLeft = (documentId: number) => {
const index = getTabIndex(documentId);
if (index <= 0) return;
// 获取左侧所有标签页的ID
const leftTabIds = tabOrder.value.slice(0, index);
// 批量关闭左侧标签页
closeTabs(leftTabIds);
// 如果当前打开的文档在被关闭的左侧标签中,需要切换到指定的文档
if (leftTabIds.includes(documentStore.currentDocumentId!)) {
switchToTabAndDocument(documentId);
@@ -224,12 +246,15 @@ export const useTabStore = defineStore('tab', () => {
* 清空所有标签页
*/
const clearAllTabs = () => {
tabsMap.value.clear();
tabsMap.value = {};
tabOrder.value = [];
};
// === 公共API ===
return {
tabsMap,
tabOrder,
// 状态
tabs: readonly(tabs),
draggedTabId,
@@ -251,9 +276,12 @@ export const useTabStore = defineStore('tab', () => {
initializeTab,
clearAllTabs,
updateTabTitle,
validateTabs,
// 工具方法
hasTab,
getTab
};
});
}, {
persist: false,
});

View File

@@ -1,159 +1,170 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
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';
import {defineStore} from 'pinia';
import {computed, ref} from 'vue';
import {SystemThemeType} from '@/../bindings/voidraft/internal/models/models';
import {Type as ThemeType} from '@/../bindings/voidraft/internal/models/ent/theme/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 };
// 类型定义
type ThemeOption = {name: string; type: ThemeType};
const resolveThemeName = (name?: string) =>
name && themePresetMap[name] ? name : FALLBACK_THEME_NAME;
// 解析主题名称,确保返回有效的主题
const resolveThemeName = (name?: string): 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);
};
themePresetList
.filter(preset => preset.type === type)
.map(preset => ({name: preset.name, type: preset.type}));
export const useThemeStore = defineStore('theme', () => {
const configStore = useConfigStore();
const currentColors = ref<ThemeColors | null>(null);
const configStore = useConfigStore();
const currentColors = ref<ThemeColors | null>(null);
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 availableThemes = computed<ThemeOption[]>(() =>
isDarkMode.value ? darkThemeOptions : lightThemeOptions
);
const applyThemeToDOM = (theme: SystemThemeType) => {
const themeMap = {
[SystemThemeType.SystemThemeAuto]: 'auto',
[SystemThemeType.SystemThemeDark]: 'dark',
[SystemThemeType.SystemThemeLight]: 'light',
};
document.documentElement.setAttribute('data-theme', themeMap[theme]);
};
const loadThemeColors = async (themeName?: string) => {
const targetName = resolveThemeName(
themeName || configStore.config?.appearance?.currentTheme
const currentTheme = computed(
() => configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
);
currentColors.value = await fetchThemeColors(targetName);
};
const initializeTheme = async () => {
applyThemeToDOM(currentTheme.value);
await loadThemeColors();
};
const isDarkMode = computed(
() =>
currentTheme.value === SystemThemeType.SystemThemeDark ||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
);
const setTheme = async (theme: SystemThemeType) => {
await configStore.setSystemTheme(theme);
applyThemeToDOM(theme);
refreshEditorTheme();
};
// 根据当前模式动态计算可用主题列表
const availableThemes = computed<ThemeOption[]>(() =>
createThemeOptions(isDarkMode.value ? ThemeType.TypeDark : ThemeType.TypeLight)
);
const switchToTheme = async (themeName: string) => {
if (!themePresetMap[themeName]) {
console.error('Theme not found:', themeName);
return false;
}
// 应用主题到 DOM
const applyThemeToDOM = (theme: SystemThemeType) => {
const themeMap = {
[SystemThemeType.SystemThemeAuto]: 'auto',
[SystemThemeType.SystemThemeDark]: 'dark',
[SystemThemeType.SystemThemeLight]: 'light',
};
document.documentElement.setAttribute('data-theme', themeMap[theme]);
};
await loadThemeColors(themeName);
await configStore.setCurrentTheme(themeName);
refreshEditorTheme();
return true;
};
// 获取预设主题颜色
const getPresetColors = (name: string): ThemeColors => {
const preset = themePresetMap[name] ?? themePresetMap[FALLBACK_THEME_NAME];
const colors = cloneThemeColors(preset.colors);
colors.themeName = name;
return colors;
};
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
if (!currentColors.value) return;
Object.assign(currentColors.value, colors);
};
// 从服务器获取主题颜色
const fetchThemeColors = async (themeName: string): Promise<ThemeColors> => {
const safeName = resolveThemeName(themeName);
try {
const theme = await ThemeService.GetThemeByName(safeName);
if (theme?.colors) {
const colors = cloneThemeColors(theme.colors as ThemeColors);
colors.themeName = safeName;
return colors;
}
} catch (error) {
console.error('Failed to load theme override:', error);
}
return getPresetColors(safeName);
};
const saveCurrentTheme = async () => {
if (!currentColors.value) {
throw new Error('No theme selected');
}
// 加载主题颜色
const loadThemeColors = async (themeName?: string) => {
const targetName = resolveThemeName(
themeName || configStore.config?.appearance?.currentTheme
);
currentColors.value = await fetchThemeColors(targetName);
};
const themeName = resolveThemeName(currentColors.value.themeName);
currentColors.value.themeName = themeName;
// 初始化主题
const initTheme = async () => {
applyThemeToDOM(currentTheme.value);
await loadThemeColors();
refreshEditorTheme();
};
await ThemeService.UpdateTheme(themeName, currentColors.value as unknown as ThemeColorConfig);
// 设置系统主题
const setTheme = async (theme: SystemThemeType) => {
await configStore.setSystemTheme(theme);
applyThemeToDOM(theme);
refreshEditorTheme();
};
await loadThemeColors(themeName);
refreshEditorTheme();
return true;
};
// 切换到指定主题
const switchToTheme = async (themeName: string) => {
if (!themePresetMap[themeName]) {
console.error('Theme not found:', themeName);
return false;
}
const resetCurrentTheme = async () => {
if (!currentColors.value) {
throw new Error('No theme selected');
}
await loadThemeColors(themeName);
await configStore.setCurrentTheme(themeName);
refreshEditorTheme();
return true;
};
const themeName = resolveThemeName(currentColors.value.themeName);
await ThemeService.ResetTheme(themeName);
// 更新当前主题颜色
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
if (!currentColors.value) return;
Object.assign(currentColors.value, colors);
};
await loadThemeColors(themeName);
refreshEditorTheme();
return true;
};
// 保存当前主题
const saveCurrentTheme = async () => {
if (!currentColors.value) {
throw new Error('No theme selected');
}
const refreshEditorTheme = () => {
applyThemeToDOM(currentTheme.value);
const editorStore = useEditorStore();
editorStore?.applyThemeSettings();
};
const themeName = resolveThemeName(currentColors.value.themeName);
currentColors.value.themeName = themeName;
return {
availableThemes,
currentTheme,
currentColors,
isDarkMode,
setTheme,
switchToTheme,
initializeTheme,
updateCurrentColors,
saveCurrentTheme,
resetCurrentTheme,
refreshEditorTheme,
applyThemeToDOM,
};
await ThemeService.UpdateTheme(themeName, currentColors.value);
await loadThemeColors(themeName);
refreshEditorTheme();
return true;
};
// 重置当前主题到默认值
const resetCurrentTheme = async () => {
if (!currentColors.value) {
throw new Error('No theme selected');
}
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 {
availableThemes,
currentTheme,
currentColors,
isDarkMode,
setTheme,
switchToTheme,
initTheme,
updateCurrentColors,
saveCurrentTheme,
resetCurrentTheme,
refreshEditorTheme,
applyThemeToDOM,
};
});

View File

@@ -7,8 +7,8 @@ import Toolbar from '@/components/toolbar/Toolbar.vue';
import {useWindowStore} from '@/stores/windowStore';
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
import {useTabStore} from '@/stores/tabStore';
import ContextMenu from './contextMenu/ContextMenu.vue';
import {contextMenuManager} from './contextMenu/manager';
import ContextMenu from '@/views/editor/extensions/contextMenu/ContextMenu.vue';
import {contextMenuManager} from '@/views/editor/extensions/contextMenu/manager';
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
import {translatorManager} from './extensions/translator/manager';

View File

@@ -5,30 +5,20 @@ import {
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
rectangularSelection,
scrollPastEnd
} from '@codemirror/view';
import {
bracketMatching,
defaultHighlightStyle,
foldGutter,
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import {bracketMatching, defaultHighlightStyle, indentOnInput, syntaxHighlighting,} from '@codemirror/language';
import {history} from '@codemirror/commands';
import {highlightSelectionMatches} from '@codemirror/search';
import {autocompletion, closeBrackets, closeBracketsKeymap} from '@codemirror/autocomplete';
import createEditorContextMenu from '../contextMenu';
import {closeBrackets, closeBracketsKeymap} from '@codemirror/autocomplete';
// 基本编辑器设置
export const createBasicSetup = (): Extension[] => {
return [
// 基础UI
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
dropCursor(),
EditorView.lineWrapping,
@@ -36,9 +26,6 @@ export const createBasicSetup = (): Extension[] => {
// 历史记录
history(),
// 代码折叠
foldGutter(),
// 选择与高亮
drawSelection(),
highlightActiveLine(),
@@ -52,11 +39,7 @@ export const createBasicSetup = (): Extension[] => {
bracketMatching(),
closeBrackets(),
// 自动完成
autocompletion(),
// 上下文菜单
createEditorContextMenu(),
scrollPastEnd(),
// 键盘映射
keymap.of([

View File

@@ -0,0 +1,68 @@
import {EditorView, ViewPlugin, ViewUpdate} from '@codemirror/view';
import {useDocumentStore} from '@/stores/documentStore';
import {createDebounce} from '@/common/utils/debounce';
/**
* 光标位置持久化扩展
* 实时监听光标位置变化并持久化到 documentStore
*/
export function createCursorPositionExtension(documentId: number) {
return ViewPlugin.fromClass(
class CursorPositionPlugin {
private readonly documentStore = useDocumentStore();
private readonly debouncedSave;
constructor(private view: EditorView) {
const {debouncedFn, flush} = createDebounce(
() => this.saveCursorPosition(),
{delay: 1000}
);
this.debouncedSave = {fn: debouncedFn, flush};
// 初始化时保存一次光标位置
this.saveCursorPosition();
}
update(update: ViewUpdate) {
// 只在选择变化时触发
if (!update.selectionSet) {
return;
}
// 防抖保存光标位置
this.debouncedSave.fn();
}
destroy() {
// 销毁时立即执行待保存的操作
this.debouncedSave.flush();
// 再保存一次确保最新状态
this.saveCursorPosition();
}
private saveCursorPosition() {
const cursorPos = this.view.state.selection.main.head;
if (!this.documentStore.documentStates[documentId]) {
this.documentStore.documentStates[documentId] = {cursorPos};
} else {
this.documentStore.documentStates[documentId].cursorPos = cursorPos;
}
}
}
);
}
/**
* 滚动到当前光标位置(视口中心)
* @param view 编辑器视图
*/
export function scrollToCursor(view: EditorView) {
const cursorPos = view.state.selection.main.head;
view.dispatch({
effects: EditorView.scrollIntoView(cursorPos, {
y: 'center',
x: 'center'
})
});
}

View File

@@ -1,141 +0,0 @@
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';
function t(key: string): string {
return i18n.global.t(key);
}
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
);
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 "";
}
function getBuiltinMenuNodes(): MenuSchemaNode[] {
return [
{
id: "copy",
labelKey: "keybindings.commands.blockCopy",
command: copyCommand,
shortcutCommand: KeyBindingCommand.BlockCopyCommand,
enabled: (context) => context.hasSelection
},
{
id: "cut",
labelKey: "keybindings.commands.blockCut",
command: cutCommand,
shortcutCommand: KeyBindingCommand.BlockCutCommand,
visible: (context) => context.isEditable,
enabled: (context) => context.hasSelection && context.isEditable
},
{
id: "paste",
labelKey: "keybindings.commands.blockPaste",
command: pasteCommand,
shortcutCommand: KeyBindingCommand.BlockPasteCommand,
visible: (context) => context.isEditable
},
{
id: "undo",
labelKey: "keybindings.commands.historyUndo",
command: undo,
shortcutCommand: KeyBindingCommand.HistoryUndoCommand,
visible: (context) => context.isEditable
},
{
id: "redo",
labelKey: "keybindings.commands.historyRedo",
command: redo,
shortcutCommand: KeyBindingCommand.HistoryRedoCommand,
visible: (context) => context.isEditable
}
];
}
let builtinMenuRegistered = false;
function ensureBuiltinMenuRegistered(): void {
if (builtinMenuRegistered) return;
registerMenuNodes(getBuiltinMenuNodes());
builtinMenuRegistered = true;
}
export function createEditorContextMenu(): Extension {
ensureBuiltinMenuRegistered();
return EditorView.domEventHandlers({
contextmenu: (event, view) => {
event.preventDefault();
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;

View File

@@ -0,0 +1,19 @@
import type {MenuSchemaNode} from '../contextMenu/menuSchema';
import {getActiveNoteBlock} from '../codeblock/state';
import {blockImageEnabledFacet, copyBlockImageCommand} from './index';
export const blockImageMenuNodes: MenuSchemaNode[] = [
{
id: 'copy-block-image',
labelKey: 'extensions.blockImage.copyMenu',
command: copyBlockImageCommand,
visible: context =>
context.view.state.facet(blockImageEnabledFacet) &&
Boolean(getActiveNoteBlock(context.view.state)),
enabled: context =>
context.view.state.facet(blockImageEnabledFacet) &&
Boolean(getActiveNoteBlock(context.view.state)),
},
];

View File

@@ -0,0 +1,319 @@
import {snapdom} from '@zumer/snapdom';
import {syntaxTree, highlightingFor} from '@codemirror/language';
import {Highlighter, highlightTree} from '@lezer/highlight';
import {Facet, type Extension} from '@codemirror/state';
import {EditorView, Command} from '@codemirror/view';
import type {Block} from '../codeblock/types';
import {blockState, getActiveNoteBlock} from '../codeblock/state';
/**
* 高亮片段信息
*/
interface HighlightSpan {
from: number;
to: number;
cssClass: string;
}
/**
* 从语法树获取指定范围的高亮信息
*/
function getHighlights(view: EditorView, from: number, to: number): HighlightSpan[] {
const tree = syntaxTree(view.state);
const highlights: HighlightSpan[] = [];
if (tree.length === 0) {
return highlights;
}
const highlighter: Highlighter = {
style: tags => highlightingFor(view.state, tags),
};
highlightTree(
tree,
highlighter,
(hlFrom, hlTo, cssClass) => {
if (hlFrom < to && hlTo > from) {
highlights.push({
from: Math.max(hlFrom, from),
to: Math.min(hlTo, to),
cssClass: cssClass || '',
});
}
},
from,
to,
);
return highlights;
}
/**
* 构建带高亮的单行元素
*/
function createHighlightedLine(
lineText: string,
lineFrom: number,
lineTo: number,
highlights: HighlightSpan[],
): HTMLElement {
const lineElement = document.createElement('div');
lineElement.className = 'cm-line';
lineElement.style.whiteSpace = 'pre';
if (highlights.length === 0 || lineText.length === 0) {
lineElement.textContent = lineText || ' ';
return lineElement;
}
const spans: Array<{text: string; cssClass: string}> = [];
let pos = lineFrom;
const lineHighlights = highlights
.filter(h => h.from < lineTo && h.to > lineFrom)
.sort((a, b) => a.from - b.from);
for (const hl of lineHighlights) {
if (hl.from > pos) {
spans.push({
text: lineText.slice(pos - lineFrom, hl.from - lineFrom),
cssClass: '',
});
}
const hlStart = Math.max(hl.from, lineFrom);
const hlEnd = Math.min(hl.to, lineTo);
spans.push({
text: lineText.slice(hlStart - lineFrom, hlEnd - lineFrom),
cssClass: hl.cssClass,
});
pos = hlEnd;
}
if (pos < lineTo) {
spans.push({
text: lineText.slice(pos - lineFrom),
cssClass: '',
});
}
for (const span of spans) {
if (span.cssClass) {
const spanElement = document.createElement('span');
spanElement.className = span.cssClass;
spanElement.textContent = span.text;
lineElement.appendChild(spanElement);
} else {
lineElement.appendChild(document.createTextNode(span.text));
}
}
return lineElement;
}
/**
* 构建用于截图的块 DOM
*/
function inlineStyle(style: CSSStyleDeclaration, props: string[]): string {
return props
.map(prop => {
const val = style.getPropertyValue(prop);
return val ? `${prop}:${val};` : '';
})
.join('');
}
function getBlockDomElement(view: EditorView, block: Block): HTMLElement | null {
try {
const blocks = view.state.field(blockState, false);
if (!blocks) return null;
const blockIndex = blocks.indexOf(block);
const isEvenBlock = blockIndex % 2 === 0;
const blockLayerElem = view.dom.querySelector(
`.code-blocks-layer .${isEvenBlock ? 'block-even' : 'block-odd'}`,
) as HTMLElement | null;
const backgroundColor =
blockLayerElem?.ownerDocument
? getComputedStyle(blockLayerElem).backgroundColor
: isEvenBlock
? '#252B37'
: '#213644';
const contentDom = view.dom.querySelector('.cm-content') as HTMLElement | null;
const sourceStyle = contentDom ? getComputedStyle(contentDom) : getComputedStyle(view.dom);
const container = document.createElement('div');
container.className = 'cm-editor cm-focused block-export-wrapper';
container.style.cssText = `
padding: 18px 22px;
background-color: ${backgroundColor};
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
display: inline-block;
min-width: 360px;
max-width: 960px;
color: ${sourceStyle.color};
font-family: ${sourceStyle.fontFamily};
font-size: ${sourceStyle.fontSize};
line-height: ${sourceStyle.lineHeight};
position: relative;
`;
const contentWrapper = document.createElement('div');
contentWrapper.className = 'cm-content';
contentWrapper.style.whiteSpace = 'pre';
contentWrapper.style.cssText += inlineStyle(sourceStyle, [
'color',
'font-family',
'font-size',
'font-weight',
'font-style',
'line-height',
'letter-spacing',
'tab-size',
'text-rendering',
'background',
'background-color',
'text-shadow',
]);
const highlights = getHighlights(view, block.content.from, block.content.to);
const fromLine = view.state.doc.lineAt(block.content.from);
const toLine = view.state.doc.lineAt(block.content.to);
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum++) {
const line = view.state.doc.line(lineNum);
const lineElement = createHighlightedLine(line.text, line.from, line.to, highlights);
contentWrapper.appendChild(lineElement);
}
if (block.language.name && block.language.name !== 'text') {
const langLabel = document.createElement('div');
langLabel.className = 'block-language-label';
langLabel.textContent = block.language.name;
langLabel.style.cssText = `
position: absolute;
top: 6px;
right: 10px;
padding: 3px 8px;
background-color: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 0.85);
font-size: 11px;
font-family: system-ui, -apple-system, sans-serif;
font-weight: 600;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
pointer-events: none;
`;
container.appendChild(langLabel);
}
container.appendChild(contentWrapper);
return container;
} catch (error) {
console.error('[blockImage] Failed to build block DOM:', error);
return null;
}
}
/**
* 将 Canvas 转换为 PNG Blob
*/
function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Canvas toBlob returned null'));
}
}, 'image/png');
});
}
/**
* 写入剪贴板PNG
*/
async function writeImageToClipboard(blob: Blob): Promise<void> {
const ClipboardItemCtor = (window as any).ClipboardItem;
if (ClipboardItemCtor && navigator.clipboard?.write) {
const item = new ClipboardItemCtor({'image/png': blob});
await navigator.clipboard.write([item]);
return;
}
}
/**
* 将当前活动块导出为图片并复制到剪贴板
*/
async function copyActiveBlockAsImage(view: EditorView): Promise<boolean> {
const activeBlock = getActiveNoteBlock(view.state);
if (!activeBlock) {
console.warn('[blockImage] No active block found');
return false;
}
const targetDom = view.scrollDOM || document.body;
const prevCursor = (targetDom as HTMLElement).style.cursor;
(targetDom as HTMLElement).style.cursor = 'progress';
const blockDom = getBlockDomElement(view, activeBlock);
if (!blockDom) {
console.warn('[blockImage] Cannot create block DOM');
(targetDom as HTMLElement).style.cursor = prevCursor;
return false;
}
// 将节点挂到文档外层,确保样式可用
const mount = document.createElement('div');
mount.style.cssText = 'position: fixed; left: -10000px; top: -10000px; pointer-events: none; z-index: -1;';
mount.appendChild(blockDom);
document.body.appendChild(mount);
try {
const canvas = await snapdom.toCanvas(blockDom, {
scale: 2,
dpr: window.devicePixelRatio || 1,
cache: 'auto',
backgroundColor: getComputedStyle(blockDom).backgroundColor,
outerShadows: false,
});
const blob = await canvasToPngBlob(canvas);
await writeImageToClipboard(blob);
return true;
} catch (error) {
console.error('[blockImage] Failed to copy block image:', error);
return false;
} finally {
mount.remove();
(targetDom as HTMLElement).style.cursor = prevCursor;
}
}
/**
* 命令:复制当前块为图片
*/
export const copyBlockImageCommand: Command = view => {
void copyActiveBlockAsImage(view);
return true;
};
export const blockImageEnabledFacet = Facet.define<boolean, boolean>({
combine: values => values.some(Boolean),
});
/**
* BlockImage 扩展入口
*/
export function createBlockImageExtension(): Extension {
return [
blockImageEnabledFacet.of(true),
];
}
export default createBlockImageExtension;

View File

@@ -1,194 +0,0 @@
import { EditorView, Decoration } from "@codemirror/view";
import { WidgetType } from "@codemirror/view";
import { ViewUpdate, ViewPlugin, DecorationSet } from "@codemirror/view";
import { Extension, StateEffect } from "@codemirror/state";
// 创建字体变化效果
const fontChangeEffect = StateEffect.define<void>();
/**
* 复选框小部件类
*/
class CheckboxWidget extends WidgetType {
constructor(readonly checked: boolean) {
super();
}
eq(other: CheckboxWidget) {
return other.checked == this.checked;
}
toDOM() {
const wrap = document.createElement("span");
wrap.setAttribute("aria-hidden", "true");
wrap.className = "cm-checkbox-toggle";
const box = document.createElement("input");
box.type = "checkbox";
box.checked = this.checked;
box.tabIndex = -1;
box.style.margin = "0";
box.style.padding = "0";
box.style.cursor = "pointer";
box.style.position = "relative";
box.style.top = "0.1em";
box.style.marginRight = "0.5em";
// 设置相对单位,让复选框跟随字体大小变化
box.style.width = "1em";
box.style.height = "1em";
wrap.appendChild(box);
return wrap;
}
ignoreEvent() {
return false;
}
}
/**
* 查找并创建复选框装饰
*/
function findCheckboxes(view: EditorView) {
const widgets: any = [];
const doc = view.state.doc;
for (const { from, to } of view.visibleRanges) {
// 使用正则表达式查找 [x] 或 [ ] 模式
const text = doc.sliceString(from, to);
const checkboxRegex = /\[([ x])\]/gi;
let match;
while ((match = checkboxRegex.exec(text)) !== null) {
const matchPos = from + match.index;
const matchEnd = matchPos + match[0].length;
// 检查前面是否有 "- " 模式
const beforeTwoChars = matchPos >= 2 ? doc.sliceString(matchPos - 2, matchPos) : "";
const afterChar = matchEnd < doc.length ? doc.sliceString(matchEnd, matchEnd + 1) : "";
// 只有当前面是 "- " 且后面跟空格或行尾时才渲染
if (beforeTwoChars === "- " &&
(afterChar === "" || afterChar === " " || afterChar === "\t" || afterChar === "\n")) {
const isChecked = match[1].toLowerCase() === "x";
const deco = Decoration.replace({
widget: new CheckboxWidget(isChecked),
inclusive: false,
});
// 替换整个 "- [ ]" 或 "- [x]" 模式,包括前面的 "- "
widgets.push(deco.range(matchPos - 2, matchEnd));
}
}
}
return Decoration.set(widgets);
}
/**
* 切换复选框状态
*/
function toggleCheckbox(view: EditorView, pos: number) {
const doc = view.state.doc;
// 查找当前位置附近的复选框模式(需要前面有 "- "
for (let offset = -5; offset <= 0; offset++) {
const checkPos = pos + offset;
if (checkPos >= 2 && checkPos + 3 <= doc.length) {
// 检查是否有 "- " 前缀
const prefix = doc.sliceString(checkPos - 2, checkPos);
const text = doc.sliceString(checkPos, checkPos + 3).toLowerCase();
if (prefix === "- ") {
let change;
if (text === "[x]") {
// 替换整个 "- [x]" 为 "- [ ]"
change = { from: checkPos - 2, to: checkPos + 3, insert: "- [ ]" };
} else if (text === "[ ]") {
// 替换整个 "- [ ]" 为 "- [x]"
change = { from: checkPos - 2, to: checkPos + 3, insert: "- [x]" };
}
if (change) {
view.dispatch({ changes: change });
return true;
}
}
}
}
return false;
}
// 创建字体变化效果的便捷函数
export const triggerFontChange = (view: EditorView) => {
view.dispatch({
effects: fontChangeEffect.of(undefined)
});
};
/**
* 创建复选框扩展
*/
export function createCheckboxExtension(): Extension {
return [
// 主要的复选框插件
ViewPlugin.fromClass(class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = findCheckboxes(view);
}
update(update: ViewUpdate) {
// 检查是否需要重新渲染复选框
const shouldUpdate = update.docChanged ||
update.viewportChanged ||
update.geometryChanged ||
update.transactions.some(tr => tr.effects.some(e => e.is(fontChangeEffect)));
if (shouldUpdate) {
this.decorations = findCheckboxes(update.view);
}
}
}, {
decorations: v => v.decorations,
eventHandlers: {
mousedown: (e, view) => {
const target = e.target as HTMLElement;
if (target.nodeName == "INPUT" && target.parentElement!.classList.contains("cm-checkbox-toggle")) {
const pos = view.posAtDOM(target);
return toggleCheckbox(view, pos);
}
}
}
}),
// 复选框样式
EditorView.theme({
".cm-checkbox-toggle": {
display: "inline-block",
verticalAlign: "baseline",
},
".cm-checkbox-toggle input[type=checkbox]": {
margin: "0",
padding: "0",
verticalAlign: "baseline",
cursor: "pointer",
// 确保复选框大小跟随字体
fontSize: "inherit",
}
})
];
}
// 默认导出
export const checkboxExtension = createCheckboxExtension();
// 导出类型和工具函数
export {
CheckboxWidget,
toggleCheckbox,
findCheckboxes
};

View File

@@ -7,6 +7,9 @@ import { StateField, RangeSetBuilder, EditorState, Transaction } from "@codemirr
import { blockState } from "./state";
import { codeBlockEvent, USER_EVENTS } from "./annotation";
// IME 输入状态
let isComposing = false;
/**
* 块开始装饰组件
*/
@@ -150,15 +153,19 @@ const blockLayer = layer({
// 转换为视口坐标进行后续计算
const fromCoordsTop = fromLineBlock.top + view.documentTop;
let toCoordsBottom = toLineBlock.bottom + view.documentTop;
// 对最后一个块进行特殊处理,让它直接延伸到底部
if (idx === blocks.length - 1) {
const editorHeight = view.dom.clientHeight;
const contentBottom = toCoordsBottom - view.documentTop + view.documentPadding.top;
// 计算需要添加到最后一个块的额外高度,以覆盖 scrollPastEnd 添加的额外滚动空间
// scrollPastEnd 会在文档底部添加相当于 scrollDOM.clientHeight 的额外空间
// 当滚动到最底部时顶部仍会显示一行defaultLineHeight需要减去这部分
const editorHeight = view.scrollDOM.clientHeight;
const extraHeight = editorHeight - (
view.defaultLineHeight + // 当滚动到最底部时,顶部仍显示一行
view.documentPadding.top +
8 // 额外的边距调整
);
// 让最后一个块直接延伸到编辑器底部
if (contentBottom < editorHeight) {
const extraHeight = editorHeight - contentBottom - 10;
if (extraHeight > 0) {
toCoordsBottom += extraHeight;
}
}
@@ -218,9 +225,10 @@ const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any)
/**
* 防止选择在第一个块之前
* 使用 transactionFilter 来确保选择不会在第一个块之前
*/
const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr: any) => {
if (isComposing) return tr;
if (tr.annotation(codeBlockEvent)) {
return tr;
}
@@ -252,6 +260,24 @@ const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr: a
return tr;
});
// IME 状态同步
const imeStateSynchronizer = ViewPlugin.fromClass(
class {
constructor(view: EditorView) {
isComposing = view.composing || view.compositionStarted;
}
update(update: any) {
const view = update.view as EditorView;
isComposing = view.composing || view.compositionStarted;
}
destroy() {
isComposing = false;
}
}
);
/**
* 获取块装饰扩展 - 简化选项
*/
@@ -267,6 +293,7 @@ export function getBlockDecorationExtensions(options: {
atomicNoteBlock,
preventFirstBlockFromBeingDeleted,
preventSelectionBeforeFirstBlock,
imeStateSynchronizer,
];
if (showBackground) {

View File

@@ -79,6 +79,7 @@ const blockLineNumbers = lineNumbers({
/**
* 创建代码块扩展
* 注意blockLineNumbers 已移至动态扩展管理,通过 ExtensionLineNumbers 控制
*/
export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extension {
const {
@@ -91,9 +92,6 @@ export function createCodeBlockExtension(options: CodeBlockOptions = {}): Extens
// 核心状态管理
blockState,
// 块内行号
blockLineNumbers,
// 语言解析支持
...getCodeBlockLanguageExtension(),

View File

@@ -13,6 +13,7 @@ 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 {Emoji} from "@/views/editor/extensions/markdown/syntax/emoji";
import {javaLanguage} from "@codemirror/lang-java";
import {phpLanguage} from "@codemirror/lang-php";
import {cssLanguage} from "@codemirror/lang-css";
@@ -118,7 +119,7 @@ export const LANGUAGES: LanguageInfo[] = [
}),
new LanguageInfo("md", "Markdown", markdown({
base: markdownLanguage,
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table],
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table, Emoji],
completeHTMLTags: true,
pasteURLAsLink: true,
htmlTagLanguage: html({

View File

@@ -0,0 +1,137 @@
import {EditorView} from '@codemirror/view';
import {Extension} from '@codemirror/state';
import {copyCommand, cutCommand, pasteCommand} from '../codeblock/copyPaste';
import {KeyBindingName} from '@/../bindings/voidraft/internal/models/models';
import {useKeybindingStore} from '@/stores/keybindingStore';
import {redo, undo} from '@codemirror/commands';
import i18n from '@/i18n';
import {useSystemStore} from '@/stores/systemStore';
import {showContextMenu} from './manager';
import type {MenuSchemaNode} from './menuSchema';
import {buildRegisteredMenu, createMenuContext, registerMenuNodes} from './menuSchema';
import {blockImageMenuNodes} from '../blockImage/contextMenu';
function t(key: string): string {
return i18n.global.t(key);
}
function formatKeyBinding(keyBinding: string): string {
const systemStore = useSystemStore();
const isMac = systemStore.isMacOS;
return keyBinding
.replace("Mod", isMac ? "Cmd" : "Ctrl")
.replace("Alt", isMac ? "Option" : "Alt")
.replace(/-/g, " + ");
}
const shortcutCache = new Map<KeyBindingName, string>();
function getShortcutText(keyBindingKey?: KeyBindingName): string {
if (keyBindingKey === undefined) {
return "";
}
const cached = shortcutCache.get(keyBindingKey);
if (cached !== undefined) {
return cached;
}
try {
const keybindingStore = useKeybindingStore();
// binding.key 是命令标识符binding.command 是快捷键组合
const binding = keybindingStore.keyBindings.find(
(kb) => kb.key === keyBindingKey && kb.enabled
);
if (binding?.key) {
const formatted = formatKeyBinding(binding.key);
shortcutCache.set(keyBindingKey, formatted);
return formatted;
}
} catch (error) {
console.warn("An error occurred while getting the shortcut:", error);
}
shortcutCache.set(keyBindingKey, "");
return "";
}
function builtinMenuNodes(): MenuSchemaNode[] {
return [
{
id: "copy",
labelKey: "keybindings.commands.blockCopy",
command: copyCommand,
keyBindingName: KeyBindingName.BlockCopy,
enabled: (context) => context.hasSelection
},
{
id: "cut",
labelKey: "keybindings.commands.blockCut",
command: cutCommand,
keyBindingName: KeyBindingName.BlockCut,
visible: (context) => context.isEditable,
enabled: (context) => context.hasSelection && context.isEditable
},
{
id: "paste",
labelKey: "keybindings.commands.blockPaste",
command: pasteCommand,
keyBindingName: KeyBindingName.BlockPaste,
visible: (context) => context.isEditable
},
{
id: "undo",
labelKey: "keybindings.commands.historyUndo",
command: undo,
keyBindingName: KeyBindingName.HistoryUndo,
visible: (context) => context.isEditable
},
{
id: "redo",
labelKey: "keybindings.commands.historyRedo",
command: redo,
keyBindingName: KeyBindingName.HistoryRedo,
visible: (context) => context.isEditable
}
];
}
let builtinMenuRegistered = false;
function ensureBuiltinMenuRegistered(): void {
if (builtinMenuRegistered) return;
registerMenuNodes([...builtinMenuNodes(), ...blockImageMenuNodes]);
builtinMenuRegistered = true;
}
export function createEditorContextMenu(): Extension {
ensureBuiltinMenuRegistered();
return EditorView.domEventHandlers({
contextmenu: (event, view) => {
event.preventDefault();
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;

View File

@@ -1,6 +1,6 @@
import type { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import type { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
import { KeyBindingName } from '@/../bindings/voidraft/internal/models/models';
export interface MenuContext {
view: EditorView;
@@ -16,7 +16,7 @@ export type MenuSchemaNode =
type?: "action";
labelKey: string;
command?: (view: EditorView) => boolean;
shortcutCommand?: KeyBindingCommand;
keyBindingName?: KeyBindingName;
visible?: (context: MenuContext) => boolean;
enabled?: (context: MenuContext) => boolean;
}
@@ -37,7 +37,7 @@ export interface RenderMenuItem {
interface MenuBuildOptions {
translate: (key: string) => string;
formatShortcut: (command?: KeyBindingCommand) => string;
formatShortcut: (keyBindingKey?: KeyBindingName) => string;
}
const menuRegistry: MenuSchemaNode[] = [];
@@ -89,7 +89,7 @@ function convertNode(
}
const disabled = node.enabled ? !node.enabled(context) : false;
const shortcut = options.formatShortcut(node.shortcutCommand);
const shortcut = options.formatShortcut(node.keyBindingName);
return {
id: node.id,

View File

@@ -1,37 +0,0 @@
import {foldService} from '@codemirror/language';
export const foldingOnIndent = foldService.of((state, from, to) => {
const line = state.doc.lineAt(from); // First line
const lines = state.doc.lines; // Number of lines in the document
const indent = line.text.search(/\S|$/); // Indent level of the first line
let foldStart = from; // Start of the fold
let foldEnd = to; // End of the fold
// Check the next line if it is on a deeper indent level
// If it is, check the next line and so on
// If it is not, go on with the foldEnd
let nextLine = line;
while (nextLine.number < lines) {
nextLine = state.doc.line(nextLine.number + 1); // Next line
const nextIndent = nextLine.text.search(/\S|$/); // Indent level of the next line
// If the next line is on a deeper indent level, add it to the fold
if (nextIndent > indent) {
foldEnd = nextLine.to; // Set the fold end to the end of the next line
} else {
break; // If the next line is not on a deeper indent level, stop
}
}
// If the fold is only one line, don't fold it
if (state.doc.lineAt(foldStart).number === state.doc.lineAt(foldEnd).number) {
return null;
}
// Set the fold start to the end of the first line
// With this, the fold will not include the first line
foldStart = line.to;
// Return a fold that covers the entire indent level
return {from: foldStart, to: foldEnd};
});

View File

@@ -3,14 +3,106 @@ import {
EditorView,
Decoration,
DecorationSet,
MatchDecorator,
WidgetType,
ViewUpdate,
} from '@codemirror/view';
import { Extension, Range } from '@codemirror/state';
import { Extension, ChangeSet } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import * as runtime from "@wailsio/runtime";
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(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/gi;
const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/g;
/** Stored hyperlink info for incremental updates */
interface HyperLinkInfo {
url: string;
from: number;
to: number;
}
/**
* Check if document changes affect any of the given link regions.
*/
function changesAffectLinks(changes: ChangeSet, links: HyperLinkInfo[]): boolean {
if (links.length === 0) return true;
let affected = false;
changes.iterChanges((fromA, toA) => {
if (affected) return;
for (const link of links) {
// Check if change overlaps with link region (with some buffer for insertions)
if (fromA <= link.to && toA >= link.from) {
affected = true;
return;
}
}
});
return affected;
}
// Markdown link parent nodes that should be excluded from hyperlink decoration
const MARKDOWN_LINK_PARENTS = new Set(['Link', 'Image', 'URL']);
/**
* Check if a position is inside a markdown link syntax node.
* This prevents hyperlink decorations from conflicting with markdown rendering.
*/
function isInMarkdownLink(view: EditorView, from: number, to: number): boolean {
const tree = syntaxTree(view.state);
let inLink = false;
tree.iterate({
from,
to,
enter: (node) => {
if (MARKDOWN_LINK_PARENTS.has(node.name)) {
inLink = true;
return false; // Stop iteration
}
}
});
return inLink;
}
/**
* Extract hyperlinks from visible ranges only.
* This is the key optimization - we only scan what's visible.
*/
function extractVisibleLinks(view: EditorView): HyperLinkInfo[] {
const result: HyperLinkInfo[] = [];
const seen = new Set<string>(); // Dedupe by position key
for (const { from, to } of view.visibleRanges) {
// Get the text for this visible range
const rangeText = view.state.sliceDoc(from, to);
// Reset regex lastIndex for each range
const regex = new RegExp(defaultRegexp.source, 'gi');
let match;
while ((match = regex.exec(rangeText)) !== null) {
const linkFrom = from + match.index;
const linkTo = linkFrom + match[0].length;
const key = `${linkFrom}:${linkTo}`;
// Skip duplicates
if (seen.has(key)) continue;
seen.add(key);
// Skip URLs inside markdown link syntax
if (isInMarkdownLink(view, linkFrom, linkTo)) continue;
result.push({
url: match[0],
from: linkFrom,
to: linkTo
});
}
}
return result;
}
export interface HyperLinkState {
at: number;
@@ -44,86 +136,80 @@ class HyperLinkIcon extends WidgetType {
}
}
function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptions['anchor']) {
const widgets: Array<Range<Decoration>> = [];
const doc = view.state.doc.toString();
let match;
while ((match = defaultRegexp.exec(doc)) !== null) {
const from = match.index;
const to = from + match[0].length;
const linkMark = Decoration.mark({
/**
* Build decorations from extracted link info.
*/
function buildDecorations(links: HyperLinkInfo[], anchor?: HyperLinkExtensionOptions['anchor']): DecorationSet {
const decorations: ReturnType<Decoration['range']>[] = [];
for (const link of links) {
// Add text decoration
decorations.push(Decoration.mark({
class: 'cm-hyper-link-text'
});
widgets.push(linkMark.range(from, to));
}).range(link.from, link.to));
const widget = Decoration.widget({
// Add icon widget
decorations.push(Decoration.widget({
widget: new HyperLinkIcon({
at: to,
url: match[0],
at: link.to,
url: link.url,
anchor,
}),
side: 1,
});
widgets.push(widget.range(to));
}).range(link.to));
}
return Decoration.set(widgets);
return Decoration.set(decorations, true);
}
const linkDecorator = (
regexp?: RegExp,
matchData?: Record<string, string>,
matchFn?: (str: string, input: string, from: number, to: number) => string,
anchor?: HyperLinkExtensionOptions['anchor'],
) =>
new MatchDecorator({
regexp: regexp || defaultRegexp,
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]) {
urlStr = matchData[url];
}
const start = to,
end = to;
const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor });
add(from, to, Decoration.mark({
class: 'cm-hyper-link-text cm-hyper-link-underline'
}));
add(start, end, Decoration.widget({ widget: linkIcon, side: 1 }));
},
});
export type HyperLinkExtensionOptions = {
regexp?: RegExp;
match?: Record<string, string>;
handle?: (value: string, input: string, from: number, to: number) => string;
/** Custom anchor element transformer */
anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement;
showIcon?: boolean;
};
export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = true }: HyperLinkExtensionOptions = {}) {
/**
* Optimized hyperlink extension with visible-range-only scanning.
*
* Performance optimizations:
* 1. Only scans visible ranges (not the entire document)
* 2. Incremental updates: maps positions when changes don't affect links
* 3. Caches link info to avoid redundant re-extraction
*/
export function hyperLinkExtension({ anchor }: HyperLinkExtensionOptions = {}) {
return ViewPlugin.fromClass(
class HyperLinkView {
decorator?: MatchDecorator;
decorations: DecorationSet;
links: HyperLinkInfo[] = [];
constructor(view: EditorView) {
if (regexp) {
this.decorator = linkDecorator(regexp, match, handle, anchor);
this.decorations = this.decorator.createDeco(view);
} else {
this.decorations = hyperLinkDecorations(view, anchor);
}
this.links = extractVisibleLinks(view);
this.decorations = buildDecorations(this.links, anchor);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
if (regexp && this.decorator) {
this.decorations = this.decorator.updateDeco(update, this.decorations);
// Always rebuild on viewport change (new content visible)
if (update.viewportChanged) {
this.links = extractVisibleLinks(update.view);
this.decorations = buildDecorations(this.links, anchor);
return;
}
// For document changes, check if they affect link regions
if (update.docChanged) {
const needsRebuild = changesAffectLinks(update.changes, this.links);
if (needsRebuild) {
// Changes affect links, full rebuild
this.links = extractVisibleLinks(update.view);
this.decorations = buildDecorations(this.links, anchor);
} else {
this.decorations = hyperLinkDecorations(update.view, anchor);
// Just update positions of existing decorations
this.decorations = this.decorations.map(update.changes);
this.links = this.links.map(link => ({
...link,
from: update.changes.mapPos(link.from),
to: update.changes.mapPos(link.to)
}));
}
}
}
@@ -171,8 +257,8 @@ export const hyperLinkStyle = EditorView.baseTheme({
'.cm-hyper-link-icon svg': {
display: 'block',
width: '14px',
height: '14px',
width: 'inherit',
height: 'inherit',
},
'.cm-editor.cm-focused .cm-hyper-link-text': {

View File

@@ -1,41 +1,19 @@
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';
import {html} from './plugins/html';
import { render } from './plugins/render';
import { Theme } from './plugins/theme';
/**
* markdown extensions
* Markdown extensions.
*/
export const markdownExtensions: Extension = [
headingSlugField,
blockquote(),
codeblock(),
headings(),
hideMarks(),
lists(),
links(),
render(),
Theme,
image(),
emoji(),
horizontalRule(),
inlineCode(),
subscriptSuperscript(),
highlight(),
insert(),
math(),
footnote(),
html()
];
export default markdownExtensions;

View File

@@ -1,100 +1,56 @@
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 handler and theme.
*/
import { Decoration, EditorView } from '@codemirror/view';
import { invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
const DECO_BLOCKQUOTE_LINE = Decoration.line({ class: 'cm-blockquote' });
/**
* Blockquote plugin.
*
* Features:
* - Decorates blockquote with left border
* - Hides quote marks (>) when cursor is outside
* - Supports nested blockquotes
* Handle Blockquote node.
*/
export function blockquote() {
return [blockQuotePlugin, baseTheme];
}
export function handleBlockquote(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): boolean {
if (ctx.seen.has(nf)) return false;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return false;
/**
* 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 startLine = ctx.view.state.doc.lineAt(nf).number;
const endLine = ctx.view.state.doc.lineAt(nt).number;
for (let i = startLine; i <= endLine; i++) {
if (!ctx.processedLines.has(i)) {
ctx.processedLines.add(i);
ctx.items.push({ from: ctx.view.state.doc.line(i).from, to: ctx.view.state.doc.line(i).from, deco: DECO_BLOCKQUOTE_LINE });
}
}
// Use TreeCursor to traverse all descendant QuoteMarks
// getChildren() only returns direct children, but QuoteMarks may be nested
// deeper in the syntax tree (e.g., in nested blockquotes for empty lines)
// cursor.next() is the official Lezer API for depth-first tree traversal
const cursor = node.cursor();
while (cursor.next() && cursor.to <= nt) {
if (cursor.name === 'QuoteMark') {
ctx.items.push({ from: cursor.from, to: cursor.to, deco: invisibleDecoration });
}
}
return false;
}
const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for blockquotes.
* Theme for blockquotes.
*/
const baseTheme = EditorView.baseTheme({
export const blockquoteTheme = EditorView.baseTheme({
'.cm-blockquote': {
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
color: 'var(--cm-blockquote-color, #666)'

View File

@@ -1,257 +1,113 @@
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 handler and theme.
*/
/** Code block node types in syntax tree */
const CODE_BLOCK_TYPES = ['FencedCode', 'CodeBlock'] as const;
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
const DECO_CODEBLOCK_LINE = Decoration.line({ class: 'cm-codeblock' });
const DECO_CODEBLOCK_BEGIN = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin' });
const DECO_CODEBLOCK_END = Decoration.line({ class: 'cm-codeblock cm-codeblock-end' });
const DECO_CODEBLOCK_SINGLE = Decoration.line({ class: 'cm-codeblock cm-codeblock-begin cm-codeblock-end' });
/** 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 {
constructor(readonly from: number, readonly to: number, readonly language: string | null) { super(); }
eq(other: CodeBlockInfoWidget) { return other.from === this.from && other.language === this.language; }
toDOM(view: EditorView): HTMLElement {
const container = document.createElement('span');
container.className = 'cm-code-block-info';
// Only show language label if specified
if (this.data.language) {
if (this.language) {
const lang = document.createElement('span');
lang.className = 'cm-code-block-lang';
lang.textContent = this.data.language;
lang.textContent = this.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));
}
}
const text = view.state.doc.sliceString(this.from, this.to);
const lines = text.split('\n');
const content = lines.length >= 2 ? lines.slice(1, -1).join('\n') : '';
if (content) {
navigator.clipboard.writeText(content).then(() => {
btn.innerHTML = ICON_CHECK;
setTimeout(() => { btn.innerHTML = ICON_COPY; }, 1500);
});
}
});
btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
container.append(btn);
return container;
}
return { decorations: Decoration.set(decorations, true), blocks };
ignoreEvent() { return true; }
}
/**
* Code block plugin with optimized updates.
* Handle FencedCode / CodeBlock node.
*/
class CodeBlockPluginClass {
decorations: DecorationSet;
blocks: Map<number, CodeBlockData>;
private lastHead = -1;
export function handleCodeBlock(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
// When cursor/selection is in this code block, don't add any decorations
// This allows the selection background to be visible
if (inCursor) return;
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 startLine = ctx.view.state.doc.lineAt(nf);
const endLine = ctx.view.state.doc.lineAt(nt);
// Add background decorations for each line
for (let num = startLine.number; num <= endLine.number; num++) {
const line = ctx.view.state.doc.line(num);
let deco = DECO_CODEBLOCK_LINE;
if (startLine.number === endLine.number) deco = DECO_CODEBLOCK_SINGLE;
else if (num === startLine.number) deco = DECO_CODEBLOCK_BEGIN;
else if (num === endLine.number) deco = DECO_CODEBLOCK_END;
ctx.items.push({ from: line.from, to: line.from, deco });
}
// Add language info widget and hide code marks
const codeInfo = node.getChild('CodeInfo');
const codeMarks = node.getChildren('CodeMark');
const language = codeInfo ? ctx.view.state.doc.sliceString(codeInfo.from, codeInfo.to).trim() : null;
ctx.items.push({ from: startLine.to, to: startLine.to, deco: Decoration.widget({ widget: new CodeBlockInfoWidget(nf, nt, language), side: 1 }), priority: 1 });
if (codeInfo) ctx.items.push({ from: codeInfo.from, to: codeInfo.to, deco: invisibleDecoration });
for (const mark of codeMarks) ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration });
}
const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPluginClass, {
decorations: (v) => v.decorations
});
/**
* Base theme for code blocks.
* Theme for code blocks.
*/
const baseTheme = EditorView.baseTheme({
export const codeBlockTheme = EditorView.baseTheme({
'.cm-codeblock': {
backgroundColor: 'var(--cm-codeblock-bg)'
backgroundColor: 'var(--cm-codeblock-bg)',
fontFamily: 'inherit'
},
'.cm-codeblock-begin': {
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
borderTopRightRadius: 'var(--cm-codeblock-radius)',
position: 'relative',
boxShadow: 'inset 0 1px 0 var(--text-primary)'
position: 'relative'
},
'.cm-codeblock-end': {
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
borderBottomRightRadius: 'var(--cm-codeblock-radius)',
boxShadow: 'inset 0 -1px 0 var(--text-primary)'
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
},
'.cm-code-block-info': {
position: 'absolute',
@@ -265,9 +121,7 @@ const baseTheme = EditorView.baseTheme({
opacity: '0.5',
transition: 'opacity 0.15s'
},
'.cm-code-block-info:hover': {
opacity: '1'
},
'.cm-code-block-info:hover': { opacity: '1' },
'.cm-code-block-lang': {
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
textTransform: 'lowercase',
@@ -286,12 +140,6 @@ const baseTheme = EditorView.baseTheme({
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'
}
'.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' }
});

View File

@@ -1,47 +1,16 @@
import { Extension, RangeSetBuilder } from '@codemirror/state';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { isCursorInRange } from '../util';
/**
* Emoji handler and theme.
*/
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
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;
}
constructor(readonly emoji: string, readonly name: string) { super(); }
eq(other: EmojiWidget) { return other.emoji === this.emoji; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-emoji';
@@ -52,130 +21,37 @@ class EmojiWidget extends WidgetType {
}
/**
* Match result for emoji patterns.
* Handle Emoji node (:emoji:).
*/
interface EmojiMatch {
from: number;
to: number;
name: string;
emoji: string;
}
export function handleEmoji(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
/**
* 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 nameNode = node.getChild('EmojiName');
if (!nameNode) return;
const name = ctx.view.state.sliceDoc(nameNode.from, nameNode.to).toLowerCase();
const emojiChar = emojies[name];
if (emojiChar) {
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new EmojiWidget(emojiChar, name) }) });
}
}
const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for emoji.
* Inherits font size and line height from parent element.
* Theme for emoji.
*/
const baseTheme = EditorView.baseTheme({
export const emojiTheme = EditorView.baseTheme({
'.cm-emoji': {
verticalAlign: 'middle',
cursor: 'default'
cursor: 'default',
fontSize: 'inherit',
lineHeight: 'inherit'
}
});
/**
* 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()];
}

View File

@@ -1,661 +1,152 @@
/**
* 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
* Footnote handlers and theme.
* Handles: FootnoteDefinition, FootnoteReference, InlineFootnote
*/
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';
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
// ============================================================================
// 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;
/** Extended context for footnotes */
export interface FootnoteContext extends BuildContext {
definitionIds: Set<string>;
pendingRefs: { from: number; to: number; id: string; index: number }[];
pendingInlines: { from: number; to: number; index: number }[];
seenIds: Map<string, number>;
inlineFootnoteIdx: 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();
}
constructor(readonly index: number, readonly hasDefinition: boolean) { super(); }
eq(other: FootnoteRefWidget) { return this.index === other.index && this.hasDefinition === other.hasDefinition; }
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');
}
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;
}
ignoreEvent() { return false; }
}
/**
* Widget to display inline footnote as superscript.
*/
class InlineFootnoteWidget extends WidgetType {
constructor(
readonly content: string,
readonly index: number
) {
super();
}
constructor(readonly index: number) { super(); }
eq(other: InlineFootnoteWidget) { return this.index === other.index; }
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;
}
ignoreEvent() { return false; }
}
/**
* Widget to display footnote definition label.
*/
class FootnoteDefLabelWidget extends WidgetType {
constructor(readonly id: string) {
super();
}
constructor(readonly id: string) { super(); }
eq(other: FootnoteDefLabelWidget) { return this.id === other.id; }
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 };
ignoreEvent() { return false; }
}
/**
* Create tooltip DOM element for inline footnote.
* Handle FootnoteDefinition node.
*/
function createInlineTooltipDom(index: number, content: string): { dom: HTMLElement } {
const dom = document.createElement('div');
dom.className = 'cm-footnote-tooltip';
export function handleFootnoteDefinition(
ctx: FootnoteContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
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 };
const marks = node.getChildren('FootnoteDefinitionMark');
const labelNode = node.getChild('FootnoteDefinitionLabel');
if (marks.length >= 2 && labelNode) {
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
ctx.definitionIds.add(id);
ctx.items.push({ from: marks[0].from, to: marks[1].to, deco: invisibleDecoration });
ctx.items.push({ from: marks[1].to, to: marks[1].to, deco: Decoration.widget({ widget: new FootnoteDefLabelWidget(id), side: 1 }), priority: 1 });
}
}
// ============================================================================
// Click Handler
// ============================================================================
/**
* Handle FootnoteReference node.
*/
export function handleFootnoteReference(
ctx: FootnoteContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const labelNode = node.getChild('FootnoteReferenceLabel');
const marks = node.getChildren('FootnoteReferenceMark');
if (labelNode && marks.length >= 2) {
const id = ctx.view.state.sliceDoc(labelNode.from, labelNode.to);
if (!ctx.seenIds.has(id)) ctx.seenIds.set(id, ctx.seenIds.size + 1);
ctx.pendingRefs.push({ from: nf, to: nt, id, index: ctx.seenIds.get(id)! });
}
}
/**
* 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
* Handle InlineFootnote node.
*/
const footnoteClickHandler = EditorView.domEventHandlers({
mousedown(event, view) {
const target = event.target as HTMLElement;
export function handleInlineFootnote(
ctx: FootnoteContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
// 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
// ============================================================================
const contentNode = node.getChild('InlineFootnoteContent');
const marks = node.getChildren('InlineFootnoteMark');
if (contentNode && marks.length >= 2) {
ctx.inlineFootnoteIdx++;
ctx.pendingInlines.push({ from: nf, to: nt, index: ctx.inlineFootnoteIdx });
}
}
/**
* Base theme for footnotes.
* Process pending footnote refs after all definitions are collected.
*/
const baseTheme = EditorView.baseTheme({
// Footnote reference (superscript)
export function processPendingFootnotes(ctx: FootnoteContext): void {
for (const ref of ctx.pendingRefs) {
ctx.items.push({ from: ref.from, to: ref.to, deco: invisibleDecoration });
ctx.items.push({ from: ref.to, to: ref.to, deco: Decoration.widget({ widget: new FootnoteRefWidget(ref.index, ctx.definitionIds.has(ref.id)), side: 1 }), priority: 1 });
}
for (const inline of ctx.pendingInlines) {
ctx.items.push({ from: inline.from, to: inline.to, deco: invisibleDecoration });
ctx.items.push({ from: inline.to, to: inline.to, deco: Decoration.widget({ widget: new InlineFootnoteWidget(inline.index), side: 1 }), priority: 1 });
}
}
/**
* Theme for footnotes.
*/
export const footnoteTheme = EditorView.baseTheme({
'.cm-footnote-ref': {
display: 'inline-flex',
alignItems: 'center',
@@ -670,21 +161,12 @@ const baseTheme = EditorView.baseTheme({
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))',
borderRadius: '3px'
},
'.cm-footnote-ref-undefined': {
color: 'var(--cm-footnote-undefined-color, #d93025)',
backgroundColor: 'var(--cm-footnote-undefined-bg, rgba(217, 48, 37, 0.1))',
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',
@@ -699,84 +181,10 @@ const baseTheme = EditorView.baseTheme({
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',
borderRadius: '3px'
},
'.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)' },
},
fontWeight: '600'
}
});
// ============================================================================
// 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;

View File

@@ -1,96 +1,63 @@
import { syntaxTree } from '@codemirror/language';
import { EditorState, StateField, Range } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
/**
* Heading handler and theme.
*/
import { Decoration, EditorView } from '@codemirror/view';
import { RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
const DECO_HEADING_HIDDEN = Decoration.mark({ class: 'cm-heading-mark-hidden' });
/**
* Hidden mark decoration - uses visibility: hidden to hide content
* Handle ATXHeading node (# Heading).
*/
const hiddenMarkDecoration = Decoration.mark({
class: 'cm-heading-mark-hidden'
});
export function handleATXHeading(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
/**
* 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
);
const header = node.firstChild;
if (header && header.type.name === 'HeaderMark') {
ctx.items.push({ from: header.from, to: Math.min(header.to + 1, nt), deco: DECO_HEADING_HIDDEN });
}
/**
* 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.
* Handle SetextHeading node (underline style).
*/
const headingField = StateField.define<DecorationSet>({
create(state) {
return buildHeadingDecorations(state);
},
export function handleSetextHeading(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
update(deco, tr) {
if (tr.docChanged || tr.selection) {
return buildHeadingDecorations(tr.state);
}
return deco.map(tr.changes);
},
provide: (f) => EditorView.decorations.from(f)
});
const headerMarks = node.getChildren('HeaderMark');
for (const mark of headerMarks) {
ctx.items.push({ from: mark.from, to: mark.to, deco: DECO_HEADING_HIDDEN });
}
}
/**
* 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.
* Theme for headings.
*/
const headingTheme = EditorView.baseTheme({
export const headingTheme = EditorView.baseTheme({
'.cm-heading-mark-hidden': {
fontSize: '0'
}
});
/**
* Headings plugin.
*/
export const headings = () => [headingField, headingTheme];

View File

@@ -1,140 +0,0 @@
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
})
];

View File

@@ -1,115 +0,0 @@
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',
}
});

View File

@@ -1,100 +1,48 @@
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
* Horizontal rule handler and theme.
*/
export const horizontalRule = (): Extension => [
horizontalRuleField,
baseTheme
];
/**
* Widget to display a horizontal rule (inline version).
*/
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { RangeTuple } from '../util';
import { BuildContext } from './types';
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() { return true; }
ignoreEvent() { return false; }
}
eq(_other: HorizontalRuleWidget) {
return true;
}
const hrWidget = new HorizontalRuleWidget();
ignoreEvent(): boolean {
return false;
/**
* Handle HorizontalRule node.
*/
export function handleHorizontalRule(
ctx: BuildContext,
nf: number,
nt: number,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (!inCursor) {
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: hrWidget }) });
}
}
/**
* Build horizontal rule decorations.
* Uses Decoration.replace WITHOUT block: true to avoid affecting block system.
* Theme for horizontal rules.
*/
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({
export const horizontalRuleTheme = EditorView.baseTheme({
'.cm-horizontal-rule-widget': {
display: 'inline-block',
width: '100%',

View File

@@ -1,208 +1,348 @@
/**
* HTML plugin for CodeMirror.
*
* Features:
* - Identifies HTML blocks and tags (excluding those inside tables)
* - Shows indicator icon at the end
* - Click to preview rendered HTML
*/
import { syntaxTree } from '@codemirror/language';
import { EditorState, Range } from '@codemirror/state';
import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
import {
Decoration,
DecorationSet,
Decoration,
WidgetType,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType
showTooltip,
Tooltip
} from '@codemirror/view';
import DOMPurify from 'dompurify';
import { isCursorInRange } from '../util';
import { LruCache } from '@/common/utils/lruCache';
interface EmbedBlockData {
interface HTMLBlockInfo {
from: number;
to: number;
content: string;
sanitized: 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;
}
// HTML5 official logo
const HTML_ICON = `<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M89.088 59.392l62.464 803.84c1.024 12.288 9.216 22.528 20.48 25.6L502.784 993.28c6.144 2.048 12.288 2.048 18.432 0l330.752-104.448c11.264-4.096 19.456-14.336 20.48-25.6l62.464-803.84c1.024-17.408-12.288-31.744-29.696-31.744H118.784c-17.408 0-31.744 14.336-29.696 31.744z" fill="#FC490B"/><path d="M774.144 309.248h-409.6l12.288 113.664h388.096l-25.6 325.632-227.328 71.68-227.328-71.68-13.312-169.984h118.784v82.944l124.928 33.792 123.904-33.792 10.24-132.096H267.264L241.664 204.8h540.672z" fill="#FFFFFF"/></svg>`;
/**
* Build decorations for HTML blocks.
* Only shows preview for blocks where cursor is not inside.
* LRU cache for DOMPurify sanitize results.
*/
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)
);
const sanitizeCache = new LruCache<string, string>(100);
/**
* Sanitize HTML content with caching for performance.
*/
function sanitizeHTML(html: string): string {
const cached = sanitizeCache.get(html);
if (cached !== undefined) {
return cached;
}
return Decoration.set(decorations, true);
const sanitized = DOMPurify.sanitize(html, {
ADD_TAGS: ['img'],
ADD_ATTR: ['src', 'alt', 'width', 'height', 'style', 'class', 'loading'],
ALLOW_DATA_ATTR: true
});
sanitizeCache.set(html, sanitized);
return sanitized;
}
/**
* Check if selection affects any HTML block (cursor moved in/out of a block).
* Check if document changes affect any of the given regions.
*/
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;
function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean {
if (regions.length === 0) return true;
let affected = false;
changes.iterChanges((fromA, toA) => {
if (affected) return;
for (const region of regions) {
if (fromA <= region.to && toA >= region.from) {
affected = true;
return;
}
}
});
return affected;
}
/**
* Check if a node is inside a table.
*/
function isInsideTable(node: { parent: { type: { name: string }; parent: unknown } | null }): boolean {
let current = node.parent;
while (current) {
const name = current.type.name;
if (name === 'Table' || name === 'TableHeader' || name === 'TableRow' || name === 'TableCell') {
return true;
}
current = current.parent as typeof current;
}
return false;
}
/**
* ViewPlugin for HTML block preview.
* Uses smart caching to avoid unnecessary updates during text selection.
* Extract all HTML blocks from visible ranges.
* Excludes HTML inside tables (tables have their own rendering).
*/
function extractHTMLBlocks(view: EditorView): HTMLBlockInfo[] {
const result: HTMLBlockInfo[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: (nodeRef) => {
const { name, from: f, to: t, node } = nodeRef;
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
// Skip HTML inside tables
if (isInsideTable(node)) return;
const content = view.state.sliceDoc(f, t);
const sanitized = sanitizeHTML(content);
// Skip empty content after sanitization
if (!sanitized.trim()) return;
result.push({ from: f, to: t, content, sanitized });
}
});
}
return result;
}
/** Effect to toggle tooltip visibility */
const toggleHTMLTooltip = StateEffect.define<HTMLBlockInfo | null>();
/** Effect to close tooltip */
const closeHTMLTooltip = StateEffect.define<null>();
/** StateField to track active tooltip */
const htmlTooltipState = StateField.define<HTMLBlockInfo | null>({
create: () => null,
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(toggleHTMLTooltip)) {
// Toggle: if same block, close; otherwise open new
if (value && effect.value && value.from === effect.value.from) {
return null;
}
return effect.value;
}
if (effect.is(closeHTMLTooltip)) {
return null;
}
}
// Close tooltip on document changes
if (tr.docChanged) {
return null;
}
return value;
},
provide: (field) =>
showTooltip.from(field, (block): Tooltip | null => {
if (!block) return null;
return {
pos: block.to,
above: true,
create: () => {
const dom = document.createElement('div');
dom.className = 'cm-html-tooltip';
dom.innerHTML = block.sanitized;
// Prevent clicks inside tooltip from closing it
dom.addEventListener('click', (e) => {
e.stopPropagation();
});
return { dom };
}
};
})
});
/**
* Indicator widget shown at the end of HTML blocks.
* Clicking toggles the tooltip.
*/
class HTMLIndicatorWidget extends WidgetType {
constructor(readonly info: HTMLBlockInfo) {
super();
}
toDOM(view: EditorView): HTMLElement {
const el = document.createElement('span');
el.className = 'cm-html-indicator';
el.innerHTML = HTML_ICON;
el.title = 'Click to preview HTML';
// Click handler to toggle tooltip
el.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
view.dispatch({
effects: toggleHTMLTooltip.of(this.info)
});
});
return el;
}
eq(other: HTMLIndicatorWidget): boolean {
return this.info.from === other.info.from && this.info.content === other.info.content;
}
ignoreEvent(): boolean {
return false;
}
}
/**
* Plugin to manage HTML block decorations.
* Optimized with incremental updates when changes don't affect HTML regions.
*/
class HTMLBlockPlugin {
decorations: DecorationSet;
blocks: EmbedBlockData[];
blocks: HTMLBlockInfo[] = [];
constructor(view: EditorView) {
this.blocks = extractAllHTMLBlocks(view.state);
this.decorations = buildDecorations(view.state, this.blocks);
this.blocks = extractHTMLBlocks(view);
this.decorations = this.build();
}
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);
// Always rebuild on viewport change
if (update.viewportChanged) {
this.blocks = extractHTMLBlocks(update.view);
this.decorations = this.build();
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);
// For document changes, only rebuild if changes affect HTML regions
if (update.docChanged) {
const needsRebuild = changesAffectRegions(update.changes, this.blocks);
if (needsRebuild) {
this.blocks = extractHTMLBlocks(update.view);
this.decorations = this.build();
} else {
// Just update positions of existing decorations
this.decorations = this.decorations.map(update.changes);
this.blocks = this.blocks.map(block => ({
...block,
from: update.changes.mapPos(block.from),
to: update.changes.mapPos(block.to)
}));
}
}
}
private build(): DecorationSet {
const deco: Range<Decoration>[] = [];
for (const block of this.blocks) {
deco.push(
Decoration.widget({
widget: new HTMLIndicatorWidget(block),
side: 1
}).range(block.to)
);
}
return Decoration.set(deco, true);
}
}
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.
* Close tooltip when clicking outside.
*/
const baseTheme = EditorView.baseTheme({
'.cm-html-block-widget': {
display: 'inline-block',
position: 'relative',
maxWidth: '100%',
overflow: 'auto',
verticalAlign: 'middle'
const clickOutsideHandler = EditorView.domEventHandlers({
click(event, view) {
const target = event.target as HTMLElement;
// Don't close if clicking on indicator or inside tooltip
if (target.closest('.cm-html-indicator') || target.closest('.cm-html-tooltip')) {
return false;
}
// Close tooltip if one is open
const currentTooltip = view.state.field(htmlTooltipState);
if (currentTooltip) {
view.dispatch({
effects: closeHTMLTooltip.of(null)
});
}
return false;
}
});
const theme = EditorView.baseTheme({
// Indicator icon
'.cm-html-indicator': {
display: 'inline-flex',
alignItems: 'center',
marginLeft: '4px',
verticalAlign: 'middle',
cursor: 'pointer',
opacity: '0.5',
color: 'var(--cm-html-color, #e44d26)',
transition: 'opacity 0.15s',
'& svg': { width: '14px', height: '14px' }
},
'.cm-html-block-content': {
display: 'inline-block'
'.cm-html-indicator:hover': {
opacity: '1'
},
// Ensure images are properly sized
'.cm-html-block-content img': {
// Tooltip content
'.cm-html-tooltip': {
padding: '8px 12px',
maxWidth: '60vw',
maxHeight: '50vh',
overflow: 'auto'
},
// Images inside tooltip
'.cm-html-tooltip img': {
maxWidth: '100%',
height: 'auto',
display: 'block'
},
'.cm-html-block-edit-btn': {
position: 'absolute',
top: '4px',
right: '4px',
padding: '4px',
border: 'none',
// Style the parent tooltip container
'.cm-tooltip:has(.cm-html-tooltip)': {
background: 'var(--bg-primary, #fff)',
border: '1px solid var(--border-color, #ddd)',
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)'
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
}
});
// Export the extension with theme
export const htmlBlockExtension = [htmlBlockPlugin, baseTheme];
/**
* HTML block extension.
*
* Features:
* - Identifies HTML blocks and tags (excluding those inside tables)
* - Shows indicator icon at the end
* - Click to preview rendered HTML
*/
export const html = (): Extension => [
htmlBlockPlugin,
htmlTooltipState,
clickOutsideHandler,
theme
];

View File

@@ -1,5 +1,14 @@
/**
* Image plugin for CodeMirror.
*
* Features:
* - Identifies markdown images
* - Shows indicator icon at the end
* - Click to preview image
*/
import { syntaxTree } from '@codemirror/language';
import { Extension, Range } from '@codemirror/state';
import { Extension, Range, StateField, StateEffect, ChangeSet } from '@codemirror/state';
import {
DecorationSet,
Decoration,
@@ -7,7 +16,7 @@ import {
EditorView,
ViewPlugin,
ViewUpdate,
hoverTooltip,
showTooltip,
Tooltip
} from '@codemirror/view';
@@ -26,6 +35,25 @@ function isImageUrl(url: string): boolean {
return IMAGE_EXT_RE.test(url) || url.startsWith('data:image/');
}
/**
* Check if document changes affect any of the given regions.
*/
function changesAffectRegions(changes: ChangeSet, regions: { from: number; to: number }[]): boolean {
if (regions.length === 0) return true;
let affected = false;
changes.iterChanges((fromA, toA) => {
if (affected) return;
for (const region of regions) {
if (fromA <= region.to && toA >= region.from) {
affected = true;
return;
}
}
});
return affected;
}
function extractImages(view: EditorView): ImageInfo[] {
const result: ImageInfo[] = [];
for (const { from, to } of view.visibleRanges) {
@@ -47,23 +75,115 @@ function extractImages(view: EditorView): ImageInfo[] {
return result;
}
/** Effect to toggle tooltip visibility */
const toggleImageTooltip = StateEffect.define<ImageInfo | null>();
/** Effect to close tooltip */
const closeImageTooltip = StateEffect.define<null>();
/** StateField to track active tooltip */
const imageTooltipState = StateField.define<ImageInfo | null>({
create: () => null,
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(toggleImageTooltip)) {
// Toggle: if same image, close; otherwise open new
if (value && effect.value && value.from === effect.value.from) {
return null;
}
return effect.value;
}
if (effect.is(closeImageTooltip)) {
return null;
}
}
// Close tooltip on document changes
if (tr.docChanged) {
return null;
}
return value;
},
provide: (field) =>
showTooltip.from(field, (img): Tooltip | null => {
if (!img) return null;
return {
pos: img.to,
above: 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);
// Prevent clicks inside tooltip from closing it
dom.addEventListener('click', (e) => {
e.stopPropagation();
});
return { dom };
}
};
})
});
/**
* Indicator widget shown at the end of images.
* Clicking toggles the tooltip.
*/
class IndicatorWidget extends WidgetType {
constructor(readonly info: ImageInfo) {
super();
}
toDOM(): HTMLElement {
toDOM(view: EditorView): HTMLElement {
const el = document.createElement('span');
el.className = 'cm-image-indicator';
el.innerHTML = ICON;
el.title = 'Click to preview image';
// Click handler to toggle tooltip
el.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
view.dispatch({
effects: toggleImageTooltip.of(this.info)
});
});
return el;
}
eq(other: IndicatorWidget): boolean {
return this.info.from === other.info.from && this.info.src === other.info.src;
}
ignoreEvent(): boolean {
return false;
}
}
/**
* Plugin to manage image decorations.
* Optimized with incremental updates when changes don't affect image regions.
*/
class ImagePlugin {
decorations: DecorationSet;
images: ImageInfo[] = [];
@@ -74,9 +194,29 @@ class ImagePlugin {
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
// Always rebuild on viewport change
if (update.viewportChanged) {
this.images = extractImages(update.view);
this.decorations = this.build();
return;
}
// For document changes, only rebuild if changes affect image regions
if (update.docChanged) {
const needsRebuild = changesAffectRegions(update.changes, this.images);
if (needsRebuild) {
this.images = extractImages(update.view);
this.decorations = this.build();
} else {
// Just update positions of existing decorations
this.decorations = this.decorations.map(update.changes);
this.images = this.images.map(img => ({
...img,
from: update.changes.mapPos(img.from),
to: update.changes.mapPos(img.to)
}));
}
}
}
@@ -87,62 +227,35 @@ class ImagePlugin {
}
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;
/**
* Close tooltip when clicking outside.
*/
const clickOutsideHandler = EditorView.domEventHandlers({
click(event, view) {
const target = event.target as HTMLElement;
const img = plugin.getImageAt(pos);
if (!img) return null;
// Don't close if clicking on indicator or inside tooltip
if (target.closest('.cm-image-indicator') || target.closest('.cm-image-tooltip')) {
return false;
}
return {
pos: img.to,
above: true,
arrow: true,
create: () => {
const dom = document.createElement('div');
dom.className = 'cm-image-tooltip cm-image-loading';
// Close tooltip if one is open
const currentTooltip = view.state.field(imageTooltipState);
if (currentTooltip) {
view.dispatch({
effects: closeImageTooltip.of(null)
});
}
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 }
);
return false;
}
});
const theme = EditorView.baseTheme({
'.cm-image-indicator': {
@@ -157,6 +270,7 @@ const theme = EditorView.baseTheme({
'& svg': { width: '14px', height: '14px' }
},
'.cm-image-indicator:hover': { opacity: '1' },
'.cm-image-tooltip': {
position: 'relative',
background: `
@@ -205,16 +319,13 @@ const theme = EditorView.baseTheme({
'.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'
color: 'red'
}
});
export const image = (): Extension => [imagePlugin, imageHoverTooltip, theme];
export const image = (): Extension => [
imagePlugin,
imageTooltipState,
clickOutsideHandler,
theme
];

View File

@@ -1,111 +0,0 @@
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)'
}
});

View File

@@ -0,0 +1,181 @@
/**
* Inline styles handlers and theme.
* Handles: Highlight, InlineCode, Emphasis, StrongEmphasis, Strikethrough, Insert, Superscript, Subscript
*/
import { Decoration, EditorView } from '@codemirror/view';
import { invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
const DECO_HIGHLIGHT = Decoration.mark({ class: 'cm-highlight' });
const DECO_INLINE_CODE = Decoration.mark({ class: 'cm-inline-code' });
const DECO_INSERT = Decoration.mark({ class: 'cm-insert' });
const DECO_SUPERSCRIPT = Decoration.mark({ class: 'cm-superscript' });
const DECO_SUBSCRIPT = Decoration.mark({ class: 'cm-subscript' });
const MARK_TYPES: Record<string, string> = {
'Emphasis': 'EmphasisMark',
'StrongEmphasis': 'EmphasisMark',
'Strikethrough': 'StrikethroughMark'
};
/**
* Handle Highlight node (==text==).
*/
export function handleHighlight(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const marks = node.getChildren('HighlightMark');
if (marks.length >= 2) {
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
if (marks[0].to < marks[marks.length - 1].from) {
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_HIGHLIGHT });
}
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
}
}
/**
* Handle InlineCode node (`code`).
*/
export function handleInlineCode(
ctx: BuildContext,
nf: number,
nt: number,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const text = ctx.view.state.doc.sliceString(nf, nt);
let i = 0; while (i < text.length && text[i] === '`') i++;
let j = text.length - 1; while (j >= 0 && text[j] === '`') j--;
const codeStart = nf + i, codeEnd = nf + j + 1;
if (nf < codeStart) ctx.items.push({ from: nf, to: codeStart, deco: invisibleDecoration });
if (codeStart < codeEnd) ctx.items.push({ from: codeStart, to: codeEnd, deco: DECO_INLINE_CODE });
if (codeEnd < nt) ctx.items.push({ from: codeEnd, to: nt, deco: invisibleDecoration });
}
/**
* Handle Emphasis, StrongEmphasis, Strikethrough nodes.
*/
export function handleEmphasis(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
typeName: string,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const markType = MARK_TYPES[typeName];
if (markType) {
const marks = node.getChildren(markType);
for (const mark of marks) {
ctx.items.push({ from: mark.from, to: mark.to, deco: invisibleDecoration });
}
}
}
/**
* Handle Insert node (++text++).
*/
export function handleInsert(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const marks = node.getChildren('InsertMark');
if (marks.length >= 2) {
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
if (marks[0].to < marks[marks.length - 1].from) {
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: DECO_INSERT });
}
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
}
}
/**
* Handle Superscript / Subscript nodes.
*/
export function handleScript(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
typeName: string,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const isSuper = typeName === 'Superscript';
const markName = isSuper ? 'SuperscriptMark' : 'SubscriptMark';
const marks = node.getChildren(markName);
if (marks.length >= 2) {
ctx.items.push({ from: marks[0].from, to: marks[0].to, deco: invisibleDecoration });
if (marks[0].to < marks[marks.length - 1].from) {
ctx.items.push({ from: marks[0].to, to: marks[marks.length - 1].from, deco: isSuper ? DECO_SUPERSCRIPT : DECO_SUBSCRIPT });
}
ctx.items.push({ from: marks[marks.length - 1].from, to: marks[marks.length - 1].to, deco: invisibleDecoration });
}
}
/**
* Theme for inline styles.
*/
export const inlineStylesTheme = EditorView.baseTheme({
'.cm-highlight': {
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
borderRadius: '2px'
},
'.cm-inline-code': {
backgroundColor: 'var(--cm-inline-code-bg)',
borderRadius: '0.25rem',
padding: '0.1rem 0.3rem',
fontFamily: 'var(--voidraft-font-mono)'
},
'.cm-insert': {
textDecoration: 'underline'
},
'.cm-superscript': {
verticalAlign: 'super',
fontSize: '0.75em',
color: 'inherit'
},
'.cm-subscript': {
verticalAlign: 'sub',
fontSize: '0.75em',
color: 'inherit'
}
});

View File

@@ -1,114 +0,0 @@
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',
}
});

View File

@@ -1,142 +1,111 @@
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';
/**
* Link handler with underline and clickable icon.
*/
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
import * as runtime from "@wailsio/runtime";
const BLACKLISTED_LINK_PARENTS = new Set(['Image', 'LinkReference']);
/** Link text decoration with underline */
const linkTextDecoration = Decoration.mark({ class: 'cm-md-link-text' });
/** Link icon widget - clickable to open URL */
class LinkIconWidget extends WidgetType {
constructor(readonly url: string) { super(); }
eq(other: LinkIconWidget) { return this.url === other.url; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-md-link-icon';
span.textContent = '🔗';
span.title = this.url;
span.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
runtime.Browser.OpenURL(this.url);
};
return span;
}
ignoreEvent(e: Event) { return e.type === 'mousedown'; }
}
/**
* Pattern for auto-link markers (< and >).
* Handle URL node (within Link).
*/
const AUTO_LINK_MARK_RE = /^<|>$/g;
export function handleURL(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
ranges: RangeTuple[]
): void {
const parent = node.parent;
if (!parent || BLACKLISTED_LINK_PARENTS.has(parent.name)) return;
if (ctx.seen.has(parent.from)) return;
ctx.seen.add(parent.from);
ranges.push([parent.from, parent.to]);
if (checkRangeOverlap([parent.from, parent.to], ctx.selRange)) return;
/**
* 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']);
// Get link text node (content between first [ and ])
const linkText = parent.getChild('LinkLabel');
const marks = parent.getChildren('LinkMark');
const linkTitle = parent.getChild('LinkTitle');
const closeBracket = marks.find(m => ctx.view.state.sliceDoc(m.from, m.to) === ']');
if (closeBracket && nf < closeBracket.from) return;
/**
* Links plugin.
*
* Features:
* - Hides link markup when cursor is outside
* - Link icons and click events are handled by hyperlink extension
*/
export const links = () => [goToLinkPlugin];
// Get URL for the icon
const url = ctx.view.state.sliceDoc(nf, nt);
/**
* 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;
// Add underline decoration to link text
if (linkText) {
ctx.items.push({ from: linkText.from, to: linkText.to, deco: linkTextDecoration });
}
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;
// Hide markdown syntax marks
for (const m of marks) {
ctx.items.push({ from: m.from, to: m.to, deco: invisibleDecoration });
}
// Hide URL
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
// Hide link title if present
if (linkTitle) {
ctx.items.push({ from: linkTitle.from, to: linkTitle.to, deco: invisibleDecoration });
}
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));
}
}
}
// Add clickable icon widget after link text (at close bracket position)
if (closeBracket) {
ctx.items.push({
from: closeBracket.from,
to: closeBracket.from,
deco: Decoration.widget({ widget: new LinkIconWidget(url), side: 1 }),
priority: 1
});
}
// Use Decoration.set with sort=true to handle unsorted ranges
return Decoration.set(decorations, true);
}
/**
* Link plugin with optimized update detection.
* Theme for markdown links.
*/
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;
}
export const linkTheme = EditorView.baseTheme({
'.cm-md-link-text': {
color: 'var(--cm-link-color, #0969da)',
textDecoration: 'underline',
textUnderlineOffset: '2px',
cursor: 'text'
},
'.cm-md-link-icon': {
cursor: 'pointer',
marginLeft: '0.2em',
opacity: '0.7',
transition: 'opacity 0.15s ease',
'&:hover': {
opacity: '1'
}
}
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
});

View File

@@ -1,45 +1,18 @@
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.
* List handlers and theme.
* Handles: ListMark (bullets), Task (checkboxes)
*/
const BULLET_LIST_MARKER_RE = /^[-+*]$/;
/**
* Lists plugin.
*
* Features:
* - Custom bullet mark rendering (- → •)
* - Interactive task list checkboxes
*/
export const lists = () => [listBulletPlugin, taskListField, baseTheme];
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { checkRangeOverlap, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
// ============================================================================
// List Bullet Plugin
// ============================================================================
const BULLET_RE = /^[-+*]$/;
/**
* Widget to render list bullet mark.
*/
class ListBulletWidget extends WidgetType {
constructor(readonly bullet: string) {
super();
}
eq(other: ListBulletWidget): boolean {
return other.bullet === this.bullet;
}
constructor(readonly bullet: string) { super(); }
eq(other: ListBulletWidget) { return other.bullet === this.bullet; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-list-bullet';
@@ -48,209 +21,84 @@ class ListBulletWidget extends WidgetType {
}
}
/**
* 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;
}
constructor(readonly checked: boolean, readonly pos: number) { super(); }
eq(other: TaskCheckboxWidget) { 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' : ' '
}
});
view.dispatch({ changes: { from: this.pos, to: this.pos + 1, insert: this.checked ? ' ' : 'x' } });
});
wrap.appendChild(checkbox);
return wrap;
}
ignoreEvent() { return false; }
}
ignoreEvent(): boolean {
return false;
/**
* Handle ListMark node (bullet markers).
*/
export function handleListMark(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
const parent = node.parent;
if (parent?.getChild('Task')) return;
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const bullet = ctx.view.state.sliceDoc(nf, nt);
if (BULLET_RE.test(bullet)) {
ctx.items.push({ from: nf, to: nt, deco: Decoration.replace({ widget: new ListBulletWidget(bullet) }) });
}
}
/**
* Build task list decorations from state.
* Handle Task node (checkboxes).
*/
function buildTaskListDecorations(state: import('@codemirror/state').EditorState): DecorationSet {
const decorations: Range<Decoration>[] = [];
export function handleTask(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
ranges: RangeTuple[]
): void {
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;
if (ctx.seen.has(listMark.from)) return;
ctx.seen.add(listMark.from);
ranges.push([listMark.from, taskMarker.to]);
if (checkRangeOverlap([listMark.from, taskMarker.to], ctx.selRange)) return;
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);
const markerText = ctx.view.state.sliceDoc(taskMarker.from, taskMarker.to);
const isChecked = markerText.length >= 2 && 'xX'.includes(markerText[1]);
if (isChecked) {
ctx.items.push({ from: nf, to: nt, deco: Decoration.mark({ class: 'cm-task-checked' }), priority: 0 });
}
ctx.items.push({ from: listMark.from, to: taskMarker.to, deco: Decoration.replace({ widget: new TaskCheckboxWidget(isChecked, taskMarker.from + 1) }), priority: 1 });
}
/**
* Task list StateField - uses incremental updates to avoid flickering.
* Theme for lists.
*/
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({
export const listTheme = EditorView.baseTheme({
'.cm-list-bullet': {
color: 'var(--cm-list-bullet-color, inherit)'
},

View File

@@ -1,291 +1,125 @@
/**
* 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
* Math handlers and theme.
* Handles: InlineMath, BlockMath
*/
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { invisibleDecoration, RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
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 = '';
}
}
constructor(readonly latex: string) { super(); }
eq(other: InlineMathWidget) { return this.latex === other.latex; }
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-inline-math';
if (this.error) {
try {
span.innerHTML = katex.renderToString(this.latex, { throwOnError: true, displayMode: false, output: 'html' });
} catch (e) {
span.textContent = this.latex;
span.title = this.error;
} else {
span.innerHTML = this.html;
span.title = e instanceof Error ? e.message : 'Render error';
}
return span;
}
eq(other: InlineMathWidget): boolean {
return this.latex === other.latex;
}
ignoreEvent(): boolean {
return false;
}
ignoreEvent() { 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 = '';
}
}
constructor(readonly latex: string, readonly lineCount: number, readonly lineHeight: number) { super(); }
eq(other: BlockMathWidget) { return this.latex === other.latex && this.lineCount === other.lineCount; }
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`;
container.style.height = `${this.lineCount * this.lineHeight}px`;
const inner = document.createElement('div');
inner.className = 'cm-block-math';
if (this.error) {
try {
inner.innerHTML = katex.renderToString(this.latex, { throwOnError: false, displayMode: true, output: 'html' });
} catch (e) {
inner.textContent = this.latex;
inner.title = this.error;
} else {
inner.innerHTML = this.html;
inner.title = e instanceof Error ? e.message : 'Render error';
}
container.appendChild(inner);
return container;
}
ignoreEvent() { return false; }
}
eq(other: BlockMathWidget): boolean {
return this.latex === other.latex && this.lineCount === other.lineCount;
}
const DECO_BLOCK_MATH_LINE = Decoration.line({ class: 'cm-block-math-line' });
const DECO_BLOCK_MATH_HIDDEN = Decoration.mark({ class: 'cm-block-math-content-hidden' });
ignoreEvent(): boolean {
return false;
/**
* Handle InlineMath node ($...$).
*/
export function handleInlineMath(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
const marks = node.getChildren('InlineMathMark');
if (marks.length >= 2) {
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from);
ctx.items.push({ from: nf, to: nt, deco: invisibleDecoration });
ctx.items.push({ from: nt, to: nt, deco: Decoration.widget({ widget: new InlineMathWidget(latex), side: 1 }), priority: 1 });
}
}
// ============================================================================
// Decorations
// ============================================================================
/**
* Build decorations for math formulas.
* Handle BlockMath node ($$...$$).
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
export function handleBlockMath(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (inCursor) return;
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 marks = node.getChildren('BlockMathMark');
if (marks.length >= 2) {
const latex = ctx.view.state.sliceDoc(marks[0].to, marks[marks.length - 1].from).trim();
const startLine = ctx.view.state.doc.lineAt(nf);
const endLine = ctx.view.state.doc.lineAt(nt);
const lineCount = endLine.number - startLine.number + 1;
if (lineCount > 1) {
for (let num = startLine.number; num <= endLine.number; num++) {
ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_BLOCK_MATH_LINE });
}
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new BlockMathWidget(latex, lineCount, ctx.lineHeight), side: -1 }), priority: -1 });
} else {
ctx.items.push({ from: nf, to: nt, deco: DECO_BLOCK_MATH_HIDDEN });
ctx.items.push({ from: nf, to: nf, deco: Decoration.widget({ widget: new BlockMathWidget(latex, 1, ctx.lineHeight), side: -1 }), priority: -1 });
}
}
}
const mathPlugin = ViewPlugin.fromClass(
MathPlugin,
{
decorations: (v) => v.decorations
}
);
// ============================================================================
// Theme
// ============================================================================
/**
* Base theme for math.
* Theme for math.
*/
const baseTheme = EditorView.baseTheme({
// Inline math
export const mathTheme = EditorView.baseTheme({
'.cm-inline-math': {
display: 'inline',
verticalAlign: 'baseline',
verticalAlign: 'baseline'
},
'.cm-inline-math .katex': {
fontSize: 'inherit',
fontSize: 'inherit'
},
// Block math container - absolute positioned to overlay on source
'.cm-block-math-container': {
position: 'absolute',
left: '0',
@@ -294,65 +128,36 @@ const baseTheme = EditorView.baseTheme({
justifyContent: 'center',
alignItems: 'center',
pointerEvents: 'none',
zIndex: '1',
zIndex: '1'
},
// Block math inner
'.cm-block-math': {
display: 'inline-block',
textAlign: 'center',
pointerEvents: 'auto',
pointerEvents: 'auto'
},
'.cm-block-math .katex-display': {
margin: '0',
margin: '0'
},
'.cm-block-math .katex': {
fontSize: '1.1em',
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',
caretColor: 'transparent'
},
'.cm-line.cm-block-math-line span': {
color: 'transparent !important',
color: 'transparent !important'
},
// Override rainbow brackets in hidden math lines
'.cm-line.cm-block-math-line [class*="cm-rainbow-bracket"]': {
color: 'transparent !important',
color: 'transparent !important'
},
// Hidden content for single-line block math
'.cm-block-math-content-hidden': {
color: 'transparent !important',
color: 'transparent !important'
},
'.cm-block-math-content-hidden span': {
color: 'transparent !important',
color: 'transparent !important'
},
'.cm-block-math-content-hidden [class*="cm-rainbow-bracket"]': {
color: 'transparent !important',
},
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;

View File

@@ -0,0 +1,253 @@
import { Extension } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { SyntaxNodeRef } from '@lezer/common';
import { checkRangeOverlap, invisibleDecoration, RangeTuple } from '../util';
import { DecoItem } from './types';
import { blockState } from '@/views/editor/extensions/codeblock/state';
import { Block } from '@/views/editor/extensions/codeblock/types';
import { handleBlockquote } from './blockquote';
import { handleCodeBlock } from './code-block';
import { handleATXHeading, handleSetextHeading } from './heading';
import { handleHorizontalRule } from './horizontal-rule';
import { handleHighlight, handleInlineCode, handleEmphasis, handleInsert, handleScript } from './inline-styles';
import { handleURL } from './link';
import { handleListMark, handleTask } from './list';
import { handleFootnoteDefinition, handleFootnoteReference, handleInlineFootnote, processPendingFootnotes, FootnoteContext } from './footnote';
import { handleInlineMath, handleBlockMath } from './math';
import { handleEmoji } from './emoji';
import { handleTable } from './table';
interface BuildResult {
decorations: DecorationSet;
trackedRanges: RangeTuple[];
}
/**
* Get markdown block ranges from visible ranges.
* Only returns ranges that are within 'md' language blocks.
*/
function getMdBlockRanges(view: EditorView): { from: number; to: number }[] {
const blocks = view.state.field(blockState, false);
if (!blocks || blocks.length === 0) {
// No blocks, treat entire document as md
return view.visibleRanges.map(r => ({ from: r.from, to: r.to }));
}
// Filter md blocks
const mdBlocks = blocks.filter((b: Block) => b.language.name === 'md');
if (mdBlocks.length === 0) return [];
// Intersect visible ranges with md block content ranges
const result: { from: number; to: number }[] = [];
for (const { from, to } of view.visibleRanges) {
for (const block of mdBlocks) {
const intersectFrom = Math.max(from, block.content.from);
const intersectTo = Math.min(to, block.content.to);
if (intersectFrom < intersectTo) {
result.push({ from: intersectFrom, to: intersectTo });
}
}
}
return result;
}
function buildDecorationsAndRanges(view: EditorView): BuildResult {
const { from: selFrom, to: selTo } = view.state.selection.main;
// Create context with footnote extensions
const ctx: FootnoteContext = {
view,
items: [],
selRange: [selFrom, selTo],
seen: new Set(),
processedLines: new Set(),
contentWidth: view.contentDOM.clientWidth - 10,
lineHeight: view.defaultLineHeight,
// Footnote state
definitionIds: new Set(),
pendingRefs: [],
pendingInlines: [],
seenIds: new Map(),
inlineFootnoteIdx: 0
};
const trackedRanges: RangeTuple[] = [];
// Only traverse md blocks (not other language blocks like js, py, etc.)
const mdRanges = getMdBlockRanges(view);
// Single traversal - dispatch to all handlers
for (const { from, to } of mdRanges) {
syntaxTree(view.state).iterate({
from, to,
enter: (nodeRef: SyntaxNodeRef) => {
const { type, from: nf, to: nt, node } = nodeRef;
const typeName = type.name;
const inCursor = checkRangeOverlap([nf, nt], ctx.selRange);
// Dispatch to handlers
if (typeName === 'Blockquote') return handleBlockquote(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'FencedCode' || typeName === 'CodeBlock') return handleCodeBlock(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName.startsWith('ATXHeading')) return handleATXHeading(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName.startsWith('SetextHeading')) return handleSetextHeading(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'HorizontalRule') return handleHorizontalRule(ctx, nf, nt, inCursor, trackedRanges);
if (typeName === 'Highlight') return handleHighlight(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'InlineCode') return handleInlineCode(ctx, nf, nt, inCursor, trackedRanges);
if (typeName === 'Emphasis' || typeName === 'StrongEmphasis' || typeName === 'Strikethrough') return handleEmphasis(ctx, nf, nt, node, typeName, inCursor, trackedRanges);
if (typeName === 'Insert') return handleInsert(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'Superscript' || typeName === 'Subscript') return handleScript(ctx, nf, nt, node, typeName, inCursor, trackedRanges);
if (typeName === 'URL') return handleURL(ctx, nf, nt, node, trackedRanges);
if (typeName === 'ListMark') return handleListMark(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'Task') return handleTask(ctx, nf, nt, node, trackedRanges);
if (typeName === 'FootnoteDefinition') return handleFootnoteDefinition(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'FootnoteReference') return handleFootnoteReference(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'InlineFootnote') return handleInlineFootnote(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'InlineMath') return handleInlineMath(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'BlockMath') return handleBlockMath(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'Emoji') return handleEmoji(ctx, nf, nt, node, inCursor, trackedRanges);
if (typeName === 'Table') return handleTable(ctx, nf, nt, node, inCursor, trackedRanges);
}
});
}
// Process pending footnotes
processPendingFootnotes(ctx);
// Sort and filter
ctx.items.sort((a, b) => {
if (a.from !== b.from) return a.from - b.from;
if (a.to !== b.to) return a.to - b.to;
return (a.priority || 0) - (b.priority || 0);
});
const result: DecoItem[] = [];
let replaceMaxTo = -1;
for (const item of ctx.items) {
const isReplace = item.deco.spec?.widget !== undefined || item.deco === invisibleDecoration;
if (item.from === item.to) {
result.push(item);
} else if (isReplace) {
if (item.from >= replaceMaxTo) {
result.push(item);
replaceMaxTo = item.to;
}
} else {
result.push(item);
}
}
return {
decorations: Decoration.set(result.map(r => r.deco.range(r.from, r.to)), true),
trackedRanges
};
}
class MarkdownRenderPlugin {
decorations: DecorationSet;
private trackedRanges: RangeTuple[] = [];
private lastSelFrom = -1;
private lastSelTo = -1;
private lastWidth = 0;
constructor(view: EditorView) {
const result = buildDecorationsAndRanges(view);
this.decorations = result.decorations;
this.trackedRanges = result.trackedRanges;
const { from, to } = view.state.selection.main;
this.lastSelFrom = from;
this.lastSelTo = to;
this.lastWidth = view.contentDOM.clientWidth;
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet, geometryChanged } = update;
const widthChanged = Math.abs(update.view.contentDOM.clientWidth - this.lastWidth) > 1;
if (widthChanged) this.lastWidth = update.view.contentDOM.clientWidth;
// Full rebuild for structural changes
if (docChanged || viewportChanged || geometryChanged || widthChanged) {
const result = buildDecorationsAndRanges(update.view);
this.decorations = result.decorations;
this.trackedRanges = result.trackedRanges;
const { from, to } = update.state.selection.main;
this.lastSelFrom = from;
this.lastSelTo = to;
return;
}
// Selection change handling with fine-grained detection
if (selectionSet) {
const { from, to } = update.state.selection.main;
const isPointCursor = from === to;
const wasPointCursor = this.lastSelFrom === this.lastSelTo;
// Optimization: Point cursor moving within same tracked range - no rebuild needed
if (isPointCursor && wasPointCursor) {
const oldRange = this.findContainingRange(this.lastSelFrom);
const newRange = this.findContainingRange(from);
if (this.rangeSame(oldRange, newRange)) {
this.lastSelFrom = from;
this.lastSelTo = to;
return;
}
}
// Check if overlapping ranges changed
const oldOverlaps = this.getOverlappingRanges(this.lastSelFrom, this.lastSelTo);
const newOverlaps = this.getOverlappingRanges(from, to);
this.lastSelFrom = from;
this.lastSelTo = to;
if (!this.rangesSame(oldOverlaps, newOverlaps)) {
const result = buildDecorationsAndRanges(update.view);
this.decorations = result.decorations;
this.trackedRanges = result.trackedRanges;
}
}
}
private findContainingRange(pos: number): RangeTuple | null {
for (const range of this.trackedRanges) {
if (pos >= range[0] && pos <= range[1]) return range;
}
return null;
}
private rangeSame(a: RangeTuple | null, b: RangeTuple | null): boolean {
if (a === null && b === null) return true;
if (a === null || b === null) return false;
return a[0] === b[0] && a[1] === b[1];
}
private getOverlappingRanges(from: number, to: number): RangeTuple[] {
const selRange: RangeTuple = [from, to];
return this.trackedRanges.filter(r => checkRangeOverlap(r, selRange));
}
private rangesSame(a: RangeTuple[], b: RangeTuple[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
}
return true;
}
}
const renderPlugin = ViewPlugin.fromClass(MarkdownRenderPlugin, {
decorations: (v) => v.decorations
});
export const render = (): Extension => [renderPlugin];

View File

@@ -1,152 +0,0 @@
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)'
}
});

View File

@@ -0,0 +1,251 @@
/**
* Table handler and theme.
*/
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { foldedRanges } from '@codemirror/language';
import { RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
import { BuildContext } from './types';
import DOMPurify from 'dompurify';
type CellAlign = 'left' | 'center' | 'right';
interface TableData { headers: string[]; alignments: CellAlign[]; rows: string[][]; }
const DECO_TABLE_LINE_HIDDEN = Decoration.line({ class: 'cm-table-line-hidden' });
const BOLD_STAR_RE = /\*\*(.+?)\*\*/g;
const BOLD_UNDER_RE = /__(.+?)__/g;
const ITALIC_STAR_RE = /\*([^*]+)\*/g;
const ITALIC_UNDER_RE = /(?<![a-zA-Z])_([^_]+)_(?![a-zA-Z])/g;
const CODE_RE = /`([^`]+)`/g;
const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
const STRIKE_RE = /~~(.+?)~~/g;
const HTML_TAG_RE = /<[a-zA-Z][^>]*>|<\/[a-zA-Z][^>]*>/;
function renderInlineMarkdown(text: string): string {
let html = text;
if (HTML_TAG_RE.test(text)) {
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>').replace(ITALIC_UNDER_RE, '<em>$1</em>');
if (!html.includes('<code>')) html = html.replace(CODE_RE, '<code>$1</code>');
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
html = DOMPurify.sanitize(html, { ADD_TAGS: ['code', 'strong', 'em', 'del', 'a'], ADD_ATTR: ['href', 'target'] });
} else {
html = html.replace(/</g, '&lt;').replace(/>/g, '&gt;');
html = html.replace(BOLD_STAR_RE, '<strong>$1</strong>').replace(BOLD_UNDER_RE, '<strong>$1</strong>');
html = html.replace(ITALIC_STAR_RE, '<em>$1</em>').replace(ITALIC_UNDER_RE, '<em>$1</em>');
html = html.replace(CODE_RE, '<code>$1</code>');
html = html.replace(LINK_RE, '<a href="$2" target="_blank">$1</a>').replace(STRIKE_RE, '<del>$1</del>');
}
return html;
}
function parseRowText(rowText: string): string[] {
const cells: string[] = [];
let start = 0, end = rowText.length;
while (start < end && rowText.charCodeAt(start) <= 32) start++;
while (end > start && rowText.charCodeAt(end - 1) <= 32) end--;
if (start < end && rowText.charCodeAt(start) === 124) start++;
if (end > start && rowText.charCodeAt(end - 1) === 124) end--;
let cellStart = start;
for (let i = start; i <= end; i++) {
if (i === end || rowText.charCodeAt(i) === 124) {
let cs = cellStart, ce = i;
while (cs < ce && rowText.charCodeAt(cs) <= 32) cs++;
while (ce > cs && rowText.charCodeAt(ce - 1) <= 32) ce--;
cells.push(rowText.substring(cs, ce));
cellStart = i + 1;
}
}
return cells;
}
function parseAlignment(text: string): CellAlign {
const len = text.length;
if (len === 0) return 'left';
let start = 0, end = len - 1;
while (start < len && text.charCodeAt(start) === 32) start++;
while (end > start && text.charCodeAt(end) === 32) end--;
if (start > end) return 'left';
const hasLeft = text.charCodeAt(start) === 58;
const hasRight = text.charCodeAt(end) === 58;
if (hasLeft && hasRight) return 'center';
if (hasRight) return 'right';
return 'left';
}
class TableWidget extends WidgetType {
constructor(readonly data: TableData, readonly lineCount: number, readonly visualHeight: number, readonly contentWidth: number) { super(); }
eq(other: TableWidget) {
if (this.visualHeight !== other.visualHeight || this.contentWidth !== other.contentWidth) return false;
if (this.data === other.data) return true;
if (this.data.headers.length !== other.data.headers.length || this.data.rows.length !== other.data.rows.length) return false;
for (let i = 0; i < this.data.headers.length; i++) if (this.data.headers[i] !== other.data.headers[i]) return false;
for (let i = 0; i < this.data.rows.length; i++) {
if (this.data.rows[i].length !== other.data.rows[i].length) return false;
for (let j = 0; j < this.data.rows[i].length; j++) if (this.data.rows[i][j] !== other.data.rows[i][j]) return false;
}
return true;
}
toDOM(): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-table-container';
container.style.height = `${this.visualHeight}px`;
const wrapper = document.createElement('div');
wrapper.className = 'cm-table-wrapper';
wrapper.style.maxWidth = `${this.contentWidth}px`;
wrapper.style.maxHeight = `${this.visualHeight}px`;
const headerRatio = 2 / this.lineCount, dataRowRatio = 1 / this.lineCount;
const headerHeight = this.visualHeight * headerRatio, dataRowHeight = this.visualHeight * dataRowRatio;
const headerCells = this.data.headers.map((h, i) => `<th class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${h.replace(/"/g, '&quot;')}">${renderInlineMarkdown(h)}</th>`).join('');
const bodyRows = this.data.rows.map(row => `<tr style="height:${dataRowHeight}px">${row.map((c, i) => `<td class="cm-table-align-${this.data.alignments[i] || 'left'}" title="${c.replace(/"/g, '&quot;')}">${renderInlineMarkdown(c)}</td>`).join('')}</tr>`).join('');
wrapper.innerHTML = `<table class="cm-table"><thead><tr style="height:${headerHeight}px">${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
container.appendChild(wrapper);
return container;
}
ignoreEvent() { return false; }
}
function isInFoldedRange(view: EditorView, from: number, to: number): boolean {
const folded = foldedRanges(view.state);
const cursor = folded.iter();
while (cursor.value) {
if (cursor.from < to && cursor.to > from) return true;
cursor.next();
}
return false;
}
/**
* Handle Table node.
*/
export function handleTable(
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
): void {
if (ctx.seen.has(nf)) return;
ctx.seen.add(nf);
ranges.push([nf, nt]);
if (isInFoldedRange(ctx.view, nf, nt) || inCursor) return;
const headerNode = node.getChild('TableHeader');
if (!headerNode) return;
const headers: string[] = [];
const alignments: CellAlign[] = [];
const rows: string[][] = [];
const headerCells = headerNode.getChildren('TableCell');
if (headerCells.length > 0) {
for (const cell of headerCells) headers.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim());
} else {
headers.push(...parseRowText(ctx.view.state.sliceDoc(headerNode.from, headerNode.to)));
}
if (headers.length === 0) return;
let child = node.firstChild;
while (child) {
if (child.type.name === 'TableDelimiter') {
const delimText = ctx.view.state.sliceDoc(child.from, child.to);
if (delimText.includes('-')) {
for (const part of parseRowText(delimText)) if (part.includes('-')) alignments.push(parseAlignment(part));
break;
}
}
child = child.nextSibling;
}
while (alignments.length < headers.length) alignments.push('left');
for (const rowNode of node.getChildren('TableRow')) {
const rowData: string[] = [];
const cells = rowNode.getChildren('TableCell');
if (cells.length > 0) { for (const cell of cells) rowData.push(ctx.view.state.sliceDoc(cell.from, cell.to).trim()); }
else { rowData.push(...parseRowText(ctx.view.state.sliceDoc(rowNode.from, rowNode.to))); }
while (rowData.length < headers.length) rowData.push('');
rows.push(rowData);
}
const startLine = ctx.view.state.doc.lineAt(nf);
const endLine = ctx.view.state.doc.lineAt(nt);
const lineCount = endLine.number - startLine.number + 1;
const startBlock = ctx.view.lineBlockAt(nf);
const endBlock = ctx.view.lineBlockAt(nt);
const visualHeight = endBlock.bottom - startBlock.top;
for (let num = startLine.number; num <= endLine.number; num++) {
ctx.items.push({ from: ctx.view.state.doc.line(num).from, to: ctx.view.state.doc.line(num).from, deco: DECO_TABLE_LINE_HIDDEN });
}
ctx.items.push({ from: startLine.from, to: startLine.from, deco: Decoration.widget({ widget: new TableWidget({ headers, alignments, rows }, lineCount, visualHeight, ctx.contentWidth), side: -1 }), priority: -1 });
}
/**
* Theme for tables.
*/
export const tableTheme = EditorView.baseTheme({
'.cm-table-container': {
position: 'absolute',
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'flex-start',
pointerEvents: 'none',
zIndex: '2',
overflow: 'hidden'
},
'.cm-table-wrapper': {
display: 'inline-block',
pointerEvents: 'auto',
backgroundColor: 'var(--bg-primary)',
overflowX: 'auto',
overflowY: 'auto'
},
'.cm-table': {
borderCollapse: 'separate',
borderSpacing: '0',
fontSize: 'inherit',
fontFamily: 'inherit',
lineHeight: 'inherit',
backgroundColor: 'var(--cm-table-bg)',
border: 'none',
boxShadow: 'inset 0 0 0 1px var(--cm-table-border)',
color: 'var(--text-primary) !important'
},
'.cm-table th, .cm-table td': {
padding: '0 8px',
border: 'none',
color: 'inherit !important',
verticalAlign: 'middle',
boxSizing: 'border-box',
fontSize: 'inherit',
fontFamily: 'inherit',
lineHeight: 'inherit',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '300px'
},
'.cm-table td': { boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)' },
'.cm-table td:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
'.cm-table tbody tr:last-child td': { boxShadow: '-1px 0 0 var(--cm-table-border)' },
'.cm-table tbody tr:last-child td:first-child': { boxShadow: 'none' },
'.cm-table th': {
backgroundColor: 'var(--cm-table-header-bg)',
fontWeight: '600',
boxShadow: '-1px 0 0 var(--cm-table-border), 0 1px 0 var(--cm-table-border)'
},
'.cm-table th:first-child': { boxShadow: '0 1px 0 var(--cm-table-border)' },
'.cm-table tbody tr:hover': { backgroundColor: 'var(--cm-table-row-hover)' },
'.cm-table th.cm-table-align-left, .cm-table td.cm-table-align-left': { textAlign: 'left' },
'.cm-table th.cm-table-align-center, .cm-table td.cm-table-align-center': { textAlign: 'center' },
'.cm-table th.cm-table-align-right, .cm-table td.cm-table-align-right': { textAlign: 'right' },
'.cm-table code': {
backgroundColor: 'var(--cm-inline-code-bg, var(--bg-hover))',
padding: '1px 4px',
borderRadius: '3px',
fontSize: 'inherit',
fontFamily: 'var(--voidraft-font-mono)'
},
'.cm-table a': { color: 'var(--selection-text)', textDecoration: 'none' },
'.cm-table a:hover': { textDecoration: 'underline' },
'.cm-line.cm-table-line-hidden': { color: 'transparent !important', caretColor: 'transparent' },
'.cm-line.cm-table-line-hidden span': { color: 'transparent !important' },
'.cm-line.cm-table-line-hidden [class*="cm-rainbow-bracket"]': { color: 'transparent !important' }
});

View File

@@ -0,0 +1,33 @@
/**
* Unified theme - combines all markdown plugin themes.
*/
import { Extension } from '@codemirror/state';
import { blockquoteTheme } from './blockquote';
import { codeBlockTheme } from './code-block';
import { headingTheme } from './heading';
import { horizontalRuleTheme } from './horizontal-rule';
import { inlineStylesTheme } from './inline-styles';
import { linkTheme } from './link';
import { listTheme } from './list';
import { footnoteTheme } from './footnote';
import { mathTheme } from './math';
import { emojiTheme } from './emoji';
import { tableTheme } from './table';
/**
* All markdown themes combined.
*/
export const Theme: Extension = [
blockquoteTheme,
codeBlockTheme,
headingTheme,
horizontalRuleTheme,
inlineStylesTheme,
linkTheme,
listTheme,
footnoteTheme,
mathTheme,
emojiTheme,
tableTheme
];

View File

@@ -0,0 +1,36 @@
/**
* Shared types for unified markdown plugin handlers.
*/
import { Decoration, EditorView } from '@codemirror/view';
import { RangeTuple } from '../util';
import { SyntaxNode } from '@lezer/common';
/** Decoration item to be added */
export interface DecoItem {
from: number;
to: number;
deco: Decoration;
priority?: number;
}
/** Shared build context passed to all handlers */
export interface BuildContext {
view: EditorView;
items: DecoItem[];
selRange: RangeTuple;
seen: Set<number>;
processedLines: Set<number>;
contentWidth: number;
lineHeight: number;
}
/** Handler function type */
export type NodeHandler = (
ctx: BuildContext,
nf: number,
nt: number,
node: SyntaxNode,
inCursor: boolean,
ranges: RangeTuple[]
) => void | boolean;

View File

@@ -0,0 +1,127 @@
/**
* Emoji extension for Lezer Markdown parser.
*
* Parses :emoji_name: syntax for emoji shortcodes.
*
* Syntax: :emoji_name: → renders as actual emoji character
*
* Examples:
* - :smile: → 😄
* - :heart: → ❤️
* - :+1: → 👍
*/
import { MarkdownConfig, InlineContext } from '@lezer/markdown';
import { CharCode } from '../util';
import { emojies } from '@/common/constant/emojies';
/**
* Pre-computed lookup table for emoji name characters.
* Valid characters: a-z, 0-9, _, +, -
* Uses Uint8Array for memory efficiency and O(1) lookup.
*/
const EMOJI_NAME_CHARS = new Uint8Array(128);
// Initialize lookup table
for (let i = 48; i <= 57; i++) EMOJI_NAME_CHARS[i] = 1; // 0-9
for (let i = 97; i <= 122; i++) EMOJI_NAME_CHARS[i] = 1; // a-z
EMOJI_NAME_CHARS[95] = 1; // _
EMOJI_NAME_CHARS[43] = 1; // +
EMOJI_NAME_CHARS[45] = 1; // -
/**
* O(1) check if a character is valid for emoji name.
* @param code - ASCII character code
* @returns True if valid emoji name character
*/
function isEmojiNameChar(code: number): boolean {
return code < 128 && EMOJI_NAME_CHARS[code] === 1;
}
/**
* Parse emoji :name: syntax.
*
* @param cx - Inline context
* @param pos - Start position (at :)
* @returns Position after element, or -1 if no match
*/
function parseEmoji(cx: InlineContext, pos: number): number {
const end = cx.end;
// Minimum: : + name + : = at least 3 chars, name must be non-empty
if (end < pos + 2) return -1;
// Track content for validation
let hasContent = false;
const contentStart = pos + 1;
// Search for closing :
for (let i = contentStart; i < end; i++) {
const char = cx.char(i);
// Found closing :
if (char === CharCode.Colon) {
// Must have content
if (!hasContent) return -1;
// Extract and validate emoji name
const name = cx.slice(contentStart, i).toLowerCase();
// Check if this is a valid emoji
if (!emojies[name]) return -1;
// Create element with marks and name
return cx.addElement(cx.elt('Emoji', pos, i + 1, [
cx.elt('EmojiMark', pos, contentStart),
cx.elt('EmojiName', contentStart, i),
cx.elt('EmojiMark', i, i + 1)
]));
}
// Newline not allowed in emoji
if (char === CharCode.Newline) return -1;
// Space not allowed in emoji name
if (char === CharCode.Space || char === CharCode.Tab) return -1;
// Validate name character using O(1) lookup table
// Also check for uppercase A-Z (65-90) and convert mentally
const lowerChar = char >= 65 && char <= 90 ? char + 32 : char;
if (isEmojiNameChar(lowerChar)) {
hasContent = true;
} else {
return -1;
}
}
return -1;
}
/**
* Emoji extension for Lezer Markdown.
*
* Defines:
* - Emoji: The container node for emoji shortcode
* - EmojiMark: The : delimiter marks
* - EmojiName: The emoji name part
*/
export const Emoji: MarkdownConfig = {
defineNodes: [
{ name: 'Emoji' },
{ name: 'EmojiMark' },
{ name: 'EmojiName' }
],
parseInline: [
{
name: 'Emoji',
parse(cx, next, pos) {
// Fast path: must start with :
if (next !== CharCode.Colon) return -1;
return parseEmoji(cx, pos);
},
// Parse after emphasis to avoid conflicts with other syntax
after: 'Emphasis'
}
]
};
export default Emoji;

View File

@@ -0,0 +1,572 @@
import { EditorView } from '@codemirror/view';
import { EditorState, ChangeSet } from '@codemirror/state';
import { syntaxTree, highlightingFor } from '@codemirror/language';
import { Highlighter, highlightTree } from '@lezer/highlight';
import { Scale } from './config';
import { LinesState, getLinesSnapshot } from './linesState';
import {
ToWorkerMessage,
ToMainMessage,
BlockRequest,
Highlight,
LineSpan,
FontInfo,
UpdateFontInfoRequest,
} from './worker/protocol';
import crelt from 'crelt';
const BLOCK_LINES = 50;
const MAX_BLOCKS = 20;
interface Block {
index: number;
startLine: number;
endLine: number;
bitmap: ImageBitmap | null;
dirty: boolean;
rendering: boolean;
requestId: number;
lastUsed: number; // LRU 时间戳
// 高亮缓存
cachedHighlights: Highlight[] | null;
cachedLines: LineSpan[][] | null;
cachedTextSlice: string | null;
cachedTextOffset: number;
}
export class BlockManager {
private worker: Worker | null = null;
private blocks = new Map<number, Block>();
private fontInfoMap = new Map<string, FontInfo>();
private fontDirty = true;
private fontVersion = 0;
private sentFontTags = new Set<string>(); // 已发送给 Worker 的字体标签
private measureCache: { charWidth: number; lineHeight: number; version: number } | null = null;
private displayText: 'blocks' | 'characters' = 'characters';
private themeClasses: Set<string>;
private requestId = 0;
private onBlockReady: (() => void) | null = null;
// 批量处理块完成事件
private pendingBlockReadyHandle: ReturnType<typeof setTimeout> | null = null;
private renderingCount = 0;
constructor(private view: EditorView) {
this.themeClasses = new Set(Array.from(view.dom.classList));
this.initWorker();
}
private initWorker(): void {
this.worker = new Worker(
new URL('./worker/block.worker.ts', import.meta.url),
{ type: 'module' }
);
this.worker.onmessage = (e: MessageEvent<ToMainMessage>) => {
this.handleWorkerMessage(e.data);
};
this.worker.onerror = (e) => {
console.error('[BlockManager] Worker error:', e);
};
this.worker.postMessage({ type: 'init' } as ToWorkerMessage);
}
private handleWorkerMessage(msg: ToMainMessage): void {
switch (msg.type) {
case 'ready':
break;
case 'blockComplete': {
const block = this.blocks.get(msg.blockIndex);
if (block && block.requestId === msg.blockId) {
block.bitmap = msg.bitmap;
block.dirty = false;
block.rendering = false;
this.renderingCount = Math.max(0, this.renderingCount - 1);
this.scheduleBlockReady();
}
break;
}
case 'error':
console.error('[BlockManager] Worker error:', msg.message);
break;
}
}
// 批量触发块就绪回调
// 策略:等 30ms让多个块完成事件合并
private scheduleBlockReady(): void {
if (this.pendingBlockReadyHandle !== null) {
clearTimeout(this.pendingBlockReadyHandle);
}
this.pendingBlockReadyHandle = setTimeout(() => {
this.pendingBlockReadyHandle = null;
if (this.renderingCount === 0) {
this.onBlockReady?.();
}
}, 30);
}
setOnBlockReady(callback: () => void): void {
this.onBlockReady = callback;
}
setDisplayText(mode: 'blocks' | 'characters'): void {
if (this.displayText !== mode) {
this.displayText = mode;
this.markAllDirty();
}
}
checkThemeChange(): boolean {
const nowClasses = Array.from(this.view.dom.classList);
const now = new Set(nowClasses);
const prev = this.themeClasses;
this.themeClasses = now;
if (!prev) {
this.fontDirty = true;
this.markAllDirty();
return true;
}
const prevSet = new Set(prev);
const nowSet = new Set(now);
prevSet.delete('cm-focused');
nowSet.delete('cm-focused');
if (prevSet.size !== nowSet.size) {
this.fontDirty = true;
this.markAllDirty();
return true;
}
for (const cls of prevSet) {
if (!nowSet.has(cls)) {
this.fontDirty = true;
this.markAllDirty();
return true;
}
}
return false;
}
markAllDirty(): void {
for (const block of this.blocks.values()) {
block.dirty = true;
// 清除缓存,强制重新收集数据
block.cachedHighlights = null;
block.cachedLines = null;
block.cachedTextSlice = null;
}
}
handleDocChange(state: EditorState, changes: ChangeSet, oldLineCount: number): void {
const totalLines = state.doc.lines;
const maxIndex = Math.ceil(totalLines / BLOCK_LINES) - 1;
// 找出变化影响的块
const affectedBlocks = new Set<number>();
// 正确检测行数变化:比较新旧文档总行数
const hasLineCountChange = totalLines !== oldLineCount;
changes.iterChanges((fromA, toA, fromB, toB) => {
// 找出新文档中受影响的行范围
const startLine = state.doc.lineAt(fromB).number;
const endLine = state.doc.lineAt(Math.min(toB, state.doc.length)).number;
const startBlock = Math.floor((startLine - 1) / BLOCK_LINES);
const endBlock = Math.floor((endLine - 1) / BLOCK_LINES);
for (let i = startBlock; i <= endBlock; i++) {
affectedBlocks.add(i);
}
});
// 如果行数变化,后续所有块都需要标记为 dirty
let markRest = false;
for (const [index, block] of this.blocks) {
if (index > maxIndex) {
block.bitmap?.close();
this.blocks.delete(index);
} else if (affectedBlocks.has(index)) {
block.dirty = true;
// 清除缓存
block.cachedHighlights = null;
block.cachedLines = null;
block.cachedTextSlice = null;
if (hasLineCountChange) {
markRest = true; // 从这个块开始,后续块都需要更新
}
} else if (markRest) {
block.dirty = true;
// 清除缓存
block.cachedHighlights = null;
block.cachedLines = null;
block.cachedTextSlice = null;
}
}
}
/**
* 计算可见范围(提取公共计算逻辑)
*/
private getVisibleRange(
canvasHeight: number,
lineHeight: number,
scrollInfo: { scrollTop: number; clientHeight: number; scrollHeight: number }
): {
valid: boolean;
totalLines: number;
scaledPTop: number;
canvasTop: number;
startBlock: number;
endBlock: number;
} | null {
const totalLines = this.view.state.field(LinesState).length;
if (totalLines === 0 || canvasHeight <= 0) {
return null;
}
const { top: pTop, bottom: pBottom } = this.view.documentPadding;
const scaledPTop = pTop / Scale.SizeRatio;
const scaledPBottom = pBottom / Scale.SizeRatio;
const totalHeight = scaledPTop + scaledPBottom + totalLines * lineHeight;
const { scrollTop, clientHeight, scrollHeight } = scrollInfo;
const scrollPercent = Math.max(0, Math.min(1, scrollTop / (scrollHeight - clientHeight))) || 0;
const canvasTop = Math.max(0, scrollPercent * (totalHeight - canvasHeight));
const visibleStart = Math.max(1, Math.floor((canvasTop - scaledPTop) / lineHeight) + 1);
const visibleEnd = Math.min(totalLines, Math.ceil((canvasTop + canvasHeight - scaledPTop) / lineHeight));
if (visibleEnd < visibleStart) {
return null;
}
return {
valid: true,
totalLines,
scaledPTop,
canvasTop,
startBlock: Math.floor((visibleStart - 1) / BLOCK_LINES),
endBlock: Math.floor((visibleEnd - 1) / BLOCK_LINES),
};
}
render(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
scrollInfo: { scrollTop: number; clientHeight: number; scrollHeight: number }
): void {
if (this.fontDirty) {
this.refreshFontCache();
}
const { charWidth, lineHeight } = this.measure(ctx);
const range = this.getVisibleRange(canvas.height, lineHeight, scrollInfo);
if (!range) return;
const { totalLines, scaledPTop, canvasTop, startBlock, endBlock } = range;
const state = this.view.state;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = startBlock; i <= endBlock; i++) {
const block = this.getOrCreateBlock(i, state, totalLines);
if (!block) continue;
if (block.dirty && !block.rendering) {
this.requestBlockRender(block, state, charWidth, lineHeight);
}
if (block.bitmap) {
const blockY = scaledPTop + (block.startLine - 1) * lineHeight - canvasTop;
ctx.drawImage(block.bitmap, 0, blockY);
}
}
this.fontDirty = false;
this.evictOldBlocks();
}
/**
* 只绘制缓存的块,不请求新渲染(用于 overlay-only 更新)
*/
drawCachedBlocks(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
scrollInfo: { scrollTop: number; clientHeight: number; scrollHeight: number }
): void {
const { lineHeight } = this.measure(ctx);
const range = this.getVisibleRange(canvas.height, lineHeight, scrollInfo);
if (!range) return;
const { scaledPTop, canvasTop, startBlock, endBlock } = range;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = startBlock; i <= endBlock; i++) {
const block = this.blocks.get(i);
if (block?.bitmap) {
const blockY = scaledPTop + (block.startLine - 1) * lineHeight - canvasTop;
ctx.drawImage(block.bitmap, 0, blockY);
}
}
}
private getOrCreateBlock(index: number, state: EditorState, totalLines: number): Block | null {
const startLine = index * BLOCK_LINES + 1;
if (startLine > totalLines) return null;
const endLine = Math.min((index + 1) * BLOCK_LINES, totalLines);
const now = performance.now();
let block = this.blocks.get(index);
if (!block) {
block = {
index,
startLine,
endLine,
bitmap: null,
dirty: true,
rendering: false,
requestId: 0,
lastUsed: now,
cachedHighlights: null,
cachedLines: null,
cachedTextSlice: null,
cachedTextOffset: 0,
};
this.blocks.set(index, block);
} else {
block.startLine = startLine;
block.endLine = endLine;
block.lastUsed = now; // 更新 LRU 时间戳
}
return block;
}
private requestBlockRender(
block: Block,
state: EditorState,
charWidth: number,
lineHeight: number
): void {
if (!this.worker) return;
block.rendering = true;
block.requestId = ++this.requestId;
this.renderingCount++;
const { startLine, endLine } = block;
let highlights: Highlight[];
let lines: LineSpan[][];
let textSlice: string;
let textOffset: number;
// 只有当块是 dirty 时才重新收集数据,否则使用缓存
if (block.dirty || !block.cachedHighlights) {
const linesSnapshot = getLinesSnapshot(state);
const tree = syntaxTree(state);
// Collect highlights
highlights = [];
if (tree.length > 0 && startLine <= state.doc.lines) {
const highlighter: Highlighter = {
style: (tags) => highlightingFor(state, tags),
};
const startPos = state.doc.line(startLine).from;
const endPos = state.doc.line(Math.min(endLine, state.doc.lines)).to;
highlightTree(tree, highlighter, (from, to, tags) => {
highlights.push({ from, to, tags });
}, startPos, endPos);
}
// Extract relevant lines
const startIdx = startLine - 1;
const endIdx = Math.min(endLine, linesSnapshot.length);
lines = linesSnapshot.slice(startIdx, endIdx).map(line =>
line.map(span => ({ from: span.from, to: span.to, folded: span.folded }))
);
// Get text slice
textOffset = 0;
let textEnd = 0;
if (lines.length > 0 && lines[0].length > 0) {
textOffset = lines[0][0].from;
const lastLine = lines[lines.length - 1];
if (lastLine.length > 0) {
textEnd = lastLine[lastLine.length - 1].to;
}
}
textSlice = state.doc.sliceString(textOffset, textEnd);
// 缓存数据
block.cachedHighlights = highlights;
block.cachedLines = lines;
block.cachedTextSlice = textSlice;
block.cachedTextOffset = textOffset;
} else {
// 使用缓存的数据
highlights = block.cachedHighlights;
lines = block.cachedLines!;
textSlice = block.cachedTextSlice!;
textOffset = block.cachedTextOffset;
}
// 确保字体信息已发送给 Worker
this.ensureFontInfoSent(highlights);
const blockLines = endLine - startLine + 1;
const request: BlockRequest = {
type: 'renderBlock',
blockId: block.requestId,
blockIndex: block.index,
startLine,
endLine,
width: Math.ceil(Scale.MaxWidth * Scale.PixelMultiplier),
height: Math.ceil(blockLines * lineHeight),
highlights,
lines,
textSlice,
textOffset,
displayText: this.displayText,
charWidth,
lineHeight,
gutterOffset: 0,
};
this.worker.postMessage(request);
}
/**
* 确保字体信息已发送给 Worker
* 增量发送:只发送新的标签
*/
private ensureFontInfoSent(highlights: Highlight[]): void {
if (!this.worker) return;
// 收集新的标签
const newTags: string[] = [];
for (const hl of highlights) {
if (!this.sentFontTags.has(hl.tags)) {
newTags.push(hl.tags);
}
}
// 默认字体标签
if (!this.sentFontTags.has('')) {
newTags.push('');
}
// 如果没有新标签,不需要发送
if (newTags.length === 0) return;
// 构建新标签的字体信息
const fontInfoMap: Record<string, FontInfo> = {};
for (const tag of newTags) {
fontInfoMap[tag] = this.getFontInfo(tag);
this.sentFontTags.add(tag);
}
const updateRequest: UpdateFontInfoRequest = {
type: 'updateFontInfo',
fontInfoMap,
defaultFont: this.getFontInfo(''),
};
this.worker.postMessage(updateRequest);
}
private evictOldBlocks(): void {
if (this.blocks.size <= MAX_BLOCKS) return;
// LRU 驱逐:按 lastUsed 升序排序,驱逐最久未使用的块
const sorted = Array.from(this.blocks.entries())
.filter(([, b]) => !b.rendering)
.sort((a, b) => a[1].lastUsed - b[1].lastUsed);
const toRemove = sorted.slice(0, this.blocks.size - MAX_BLOCKS);
for (const [index, block] of toRemove) {
block.bitmap?.close();
this.blocks.delete(index);
}
}
private refreshFontCache(): void {
this.fontInfoMap.clear();
this.measureCache = null;
this.sentFontTags.clear(); // 需要重新发送字体信息给 Worker
// 注意fontDirty 在成功渲染块后才设为 false
this.fontVersion++;
this.markAllDirty();
}
measure(ctx: CanvasRenderingContext2D): { charWidth: number; lineHeight: number } {
const info = this.getFontInfo('');
ctx.textBaseline = 'ideographic';
ctx.fillStyle = info.color;
ctx.font = info.font;
if (this.measureCache?.version === this.fontVersion) {
return { charWidth: this.measureCache.charWidth, lineHeight: this.measureCache.lineHeight };
}
const charWidth = ctx.measureText('_').width;
this.measureCache = { charWidth, lineHeight: info.lineHeight, version: this.fontVersion };
return { charWidth, lineHeight: info.lineHeight };
}
private getFontInfo(tags: string): FontInfo {
const cached = this.fontInfoMap.get(tags);
if (cached) return cached;
const mockToken = crelt('span', { class: tags });
const mockLine = crelt('div', { class: 'cm-line', style: 'display: none' }, mockToken);
this.view.contentDOM.appendChild(mockLine);
const style = window.getComputedStyle(mockToken);
// 获取字体大小(用于渲染字符)
const fontSize = parseFloat(style.fontSize) || this.view.defaultLineHeight;
const scaledFontSize = Math.max(1, fontSize / Scale.SizeRatio);
// 获取行高(用于行间距)
const rawLineHeight = parseFloat(style.lineHeight);
const resolvedLineHeight = Number.isFinite(rawLineHeight) && rawLineHeight > 0 ? rawLineHeight : fontSize;
const lineHeight = Math.max(1, resolvedLineHeight / Scale.SizeRatio);
const result: FontInfo = {
color: style.color,
font: `${style.fontStyle} ${style.fontWeight} ${scaledFontSize}px ${style.fontFamily}`,
lineHeight,
};
this.view.contentDOM.removeChild(mockLine);
this.fontInfoMap.set(tags, result);
return result;
}
destroy(): void {
if (this.pendingBlockReadyHandle !== null) {
clearTimeout(this.pendingBlockReadyHandle);
}
for (const block of this.blocks.values()) {
block.bitmap?.close();
}
this.blocks.clear();
this.worker?.postMessage({ type: 'destroy' } as ToWorkerMessage);
this.worker?.terminate();
this.worker = null;
}
}

View File

@@ -8,12 +8,21 @@ import {
import { LineBasedState } from "./linebasedstate";
import { DrawContext } from "./types";
import { Lines, LinesState, foldsChanged } from "./linesState";
import { Config } from "./config";
import { LinesState, foldsChanged } from "./linesState";
import { Config, Scale } from "./config";
import { lineLength, lineNumberAt, offsetWithinLine } from "./lineGeometry";
type Severity = Diagnostic["severity"];
type DiagnosticRange = { from: number; to: number };
type LineDiagnostics = {
severity: Severity;
ranges: Array<DiagnosticRange>;
};
const MIN_PIXEL_WIDTH = 1 / Scale.PixelMultiplier;
const snapToDevice = (value: number) =>
Math.round(value * Scale.PixelMultiplier) / Scale.PixelMultiplier;
export class DiagnosticState extends LineBasedState<Severity> {
export class DiagnosticState extends LineBasedState<LineDiagnostics> {
private count: number | undefined = undefined;
public constructor(view: EditorView) {
@@ -63,70 +72,74 @@ export class DiagnosticState extends LineBasedState<Severity> {
this.count = diagnosticCount(update.state);
forEachDiagnostic(update.state, (diagnostic, from, to) => {
// Find the start and end lines for the diagnostic
const lineStart = this.findLine(from, lines);
const lineEnd = this.findLine(to, lines);
const lineStart = lineNumberAt(lines, from);
const lineEnd = lineNumberAt(lines, to);
if (lineStart <= 0 || lineEnd <= 0) {
return;
}
// Populate each line in the range with the highest severity diagnostic
let severity = diagnostic.severity;
for (let i = lineStart; i <= lineEnd; i++) {
const previous = this.get(i);
if (previous) {
severity = [severity, previous]
.sort(this.sort.bind(this))
.slice(0, 1)[0];
for (let lineNumber = lineStart; lineNumber <= lineEnd; lineNumber++) {
const spans = lines[lineNumber - 1];
if (!spans || spans.length === 0) {
continue;
}
this.set(i, severity);
const length = lineLength(spans);
const startOffset =
lineNumber === lineStart
? offsetWithinLine(from, spans)
: 0;
const endOffset =
lineNumber === lineEnd ? offsetWithinLine(to, spans) : length;
const fromOffset = Math.max(0, Math.min(length, startOffset));
let toOffset = Math.max(fromOffset, Math.min(length, endOffset));
if (toOffset === fromOffset) {
toOffset = Math.min(length, fromOffset + 1);
}
this.pushRange(lineNumber, diagnostic.severity, {
from: fromOffset,
to: toOffset,
});
}
});
this.mergeRanges();
}
public drawLine(ctx: DrawContext, lineNumber: number) {
const { context, lineHeight, offsetX, offsetY } = ctx;
const severity = this.get(lineNumber);
if (!severity) {
const diagnostics = this.get(lineNumber);
if (!diagnostics) {
return;
}
// Draw the full line width rectangle in the background
context.globalAlpha = 0.65;
context.beginPath();
context.rect(
offsetX,
offsetY /* TODO Scaling causes anti-aliasing in rectangles */,
context.canvas.width - offsetX,
lineHeight
);
context.fillStyle = this.color(severity);
context.fill();
const { context, lineHeight, charWidth, offsetX, offsetY } = ctx;
const color = this.color(diagnostics.severity);
const snappedY = snapToDevice(offsetY);
const snappedHeight =
Math.max(MIN_PIXEL_WIDTH, snapToDevice(offsetY + lineHeight) - snappedY) ||
MIN_PIXEL_WIDTH;
// Draw diagnostic range rectangle in the foreground
// TODO: We need to update the state to have specific ranges
// context.globalAlpha = 1;
// context.beginPath();
// context.rect(offsetX, offsetY, textWidth, lineHeight);
// context.fillStyle = this.color(severity);
// context.fill();
}
context.fillStyle = color;
for (const range of diagnostics.ranges) {
const startX = offsetX + range.from * charWidth;
const width = Math.max(
MIN_PIXEL_WIDTH,
(range.to - range.from) * charWidth
);
const snappedX = snapToDevice(startX);
const snappedWidth =
Math.max(MIN_PIXEL_WIDTH, snapToDevice(startX + width) - snappedX) ||
MIN_PIXEL_WIDTH;
/**
* Given a position and a set of line ranges, return
* the line number the position falls within
*/
private findLine(pos: number, lines: Lines) {
const index = lines.findIndex((spans) => {
const start = spans.slice(0, 1)[0];
const end = spans.slice(-1)[0];
if (!start || !end) {
return false;
}
return start.from <= pos && pos <= end.to;
});
// Line numbers begin at 1
return index + 1;
context.globalAlpha = 0.65;
context.beginPath();
context.rect(snappedX, snappedY, snappedWidth, snappedHeight);
context.fill();
}
context.globalAlpha = 1;
}
/**
@@ -141,12 +154,6 @@ export class DiagnosticState extends LineBasedState<Severity> {
: "#999";
}
/** Sorts severity from most to least severe */
private sort(a: Severity, b: Severity) {
return this.score(b) - this.score(a);
}
/** Assigns a score to severity, with most severe being the highest */
private score(s: Severity) {
switch (s) {
case "error": {
@@ -160,6 +167,47 @@ export class DiagnosticState extends LineBasedState<Severity> {
}
}
}
private pushRange(
lineNumber: number,
severity: Severity,
range: DiagnosticRange
) {
let entry = this.get(lineNumber);
if (!entry) {
entry = { severity, ranges: [range] };
this.set(lineNumber, entry);
return;
}
if (this.score(severity) > this.score(entry.severity)) {
entry.severity = severity;
}
entry.ranges.push(range);
}
private mergeRanges() {
for (const entry of this.map.values()) {
if (entry.ranges.length <= 1) {
continue;
}
entry.ranges.sort((a, b) => a.from - b.from);
const merged: Array<DiagnosticRange> = [];
for (const range of entry.ranges) {
const last = merged[merged.length - 1];
if (last && range.from <= last.to) {
last.to = Math.max(last.to, range.to);
} else {
merged.push({ ...range });
}
}
entry.ranges = merged;
}
}
}
export function diagnostics(view: EditorView): DiagnosticState {

View File

@@ -1,27 +1,37 @@
import { Facet } from "@codemirror/state";
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { Overlay } from "./overlay";
import { Config, Options, Scale } from "./config";
import { DiagnosticState, diagnostics } from "./diagnostics";
import { SelectionState, selections } from "./selections";
import { TextState, text } from "./text";
import { LinesState } from "./linesState";
import crelt from "crelt";
import { GUTTER_WIDTH, drawLineGutter } from "./gutters";
/**
* Minimap Extension Entry
* Uses block rendering for visible area only
*/
import { Facet } from '@codemirror/state';
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { Overlay } from './overlay';
import { Config, Options, Scale } from './config';
import { DiagnosticState, diagnostics } from './diagnostics';
import { SelectionState, selections } from './selections';
import { LinesState, foldsChanged } from './linesState';
import { BlockManager } from './blockManager';
import crelt from 'crelt';
import { GUTTER_WIDTH, drawLineGutter } from './gutters';
import { createDebounce } from '@/common/utils/debounce';
const Theme = EditorView.theme({
"&": {
height: "100%",
overflowY: "auto",
'&': {
height: '100%',
overflowY: 'auto',
},
"& .cm-minimap-gutter": {
'& .cm-minimap-gutter': {
borderRight: 0,
flexShrink: 0,
left: "unset",
position: "sticky",
left: 'unset',
position: 'sticky',
right: 0,
top: 0,
},
// 初始化时隐藏,避免宽度未设置时闪烁
'& .cm-minimap-initializing': {
opacity: 0,
},
'& .cm-minimap-autohide': {
opacity: 0.0,
transition: 'opacity 0.3s',
@@ -29,62 +39,130 @@ const Theme = EditorView.theme({
'& .cm-minimap-autohide:hover': {
opacity: 1.0,
},
"& .cm-minimap-inner": {
height: "100%",
position: "absolute",
'& .cm-minimap-inner': {
height: '100%',
position: 'absolute',
right: 0,
top: 0,
overflowY: "hidden",
"& canvas": {
display: "block",
overflowY: 'hidden',
'& canvas': {
display: 'block',
willChange: 'transform, opacity',
},
},
"& .cm-minimap-box-shadow": {
boxShadow: "12px 0px 20px 5px #6c6c6c",
'& .cm-minimap-box-shadow': {
boxShadow: '12px 0px 20px 5px #6c6c6c',
},
});
const WIDTH_RATIO = 6;
type RenderReason = 'scroll' | 'data';
// 渲染类型blocks=块内容变化需要重渲染, overlays=只需重绘选区等覆盖层
type RenderType = 'blocks' | 'overlays';
const minimapClass = ViewPlugin.fromClass(
class {
private dom: HTMLElement | undefined;
private inner: HTMLElement | undefined;
private canvas: HTMLCanvasElement | undefined;
private renderHandle: number | ReturnType<typeof setTimeout> | null = null;
private cancelRender: (() => void) | null = null;
private pendingScrollTop: number | null = null;
private lastRenderedScrollTop: number = -1;
private pendingRenderReason: RenderReason | null = null;
private pendingRenderType: RenderType | null = null;
public text: TextState;
// 块管理器Worker 渲染)
private blockManager: BlockManager;
public selection: SelectionState;
public diagnostic: DiagnosticState;
// 等待滚动位置稳定
private initialRenderDelay: ReturnType<typeof setTimeout> | null = null;
private isInitialized = false;
private hasRenderedOnce = false; // 是否已首次渲染
private lastScrollTop = -1;
private scrollStableCount = 0;
// 块渲染防抖500ms
private debouncedBlockRender: ReturnType<typeof createDebounce>['debouncedFn'];
private cancelDebounce: () => void;
public constructor(private view: EditorView) {
this.text = text(view);
this.blockManager = new BlockManager(view);
this.selection = selections(view);
this.diagnostic = diagnostics(view);
// 创建防抖的块渲染函数
const { debouncedFn, cancel } = createDebounce(() => {
this.requestRender('data', 'blocks');
}, { delay: 1000 });
this.debouncedBlockRender = debouncedFn;
this.cancelDebounce = cancel;
// 当块渲染完成时,请求重新渲染(只渲染 overlays
this.blockManager.setOnBlockReady(() => {
if (this.isInitialized) {
this.requestRender('data', 'overlays');
}
});
if (view.state.facet(showMinimapFacet)) {
this.create(view);
this.waitForScrollStable();
}
}
// 等待滚动位置稳定后再渲染
private waitForScrollStable(): void {
const check = () => {
const scrollTop = this.view.scrollDOM.scrollTop;
if (scrollTop === this.lastScrollTop) {
this.scrollStableCount++;
// 连续 3 次检测位置不变,认为稳定
if (this.scrollStableCount >= 3) {
this.isInitialized = true;
this.initialRenderDelay = null;
this.requestRender('data', 'blocks');
return;
}
} else {
this.scrollStableCount = 0;
this.lastScrollTop = scrollTop;
}
// 每 20ms 检测一次,最多等待 200ms
if (this.scrollStableCount < 10) {
this.initialRenderDelay = setTimeout(check, 20);
} else {
this.isInitialized = true;
this.initialRenderDelay = null;
this.requestRender('data', 'blocks');
}
};
this.initialRenderDelay = setTimeout(check, 20);
}
private create(view: EditorView) {
const config = view.state.facet(showMinimapFacet);
if (!config) {
throw Error("Expected nonnull");
throw Error('Expected nonnull');
}
this.inner = crelt("div", { class: "cm-minimap-inner" });
this.canvas = crelt("canvas") as HTMLCanvasElement;
this.inner = crelt('div', { class: 'cm-minimap-inner' });
this.canvas = crelt('canvas') as HTMLCanvasElement;
this.dom = config.create(view).dom;
this.dom.classList.add("cm-gutters");
this.dom.classList.add("cm-minimap-gutter");
this.dom.classList.add('cm-gutters');
this.dom.classList.add('cm-minimap-gutter');
this.dom.classList.add('cm-minimap-initializing'); // 初始隐藏
this.inner.appendChild(this.canvas);
this.dom.appendChild(this.inner);
// For now let's keep this same behavior. We might want to change
// this in the future and have the extension figure out how to mount.
// Or expose some more generic right gutter api and use that
this.view.scrollDOM.insertBefore(
this.dom,
this.view.contentDOM.nextSibling
@@ -97,35 +175,23 @@ const minimapClass = ViewPlugin.fromClass(
}
}
// 阻止小地图上的右键菜单
this.dom.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
// 阻止小地图内部元素和画布上的右键菜单
this.inner.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
this.canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
return false;
});
if (config.autohide) {
this.dom.classList.add('cm-minimap-autohide');
}
// 设置显示模式
this.blockManager.setDisplayText(view.state.facet(Config).displayText);
}
private remove() {
this.cancelRenderRequest();
if (this.dom) {
this.dom.remove();
}
this.dom = undefined;
this.inner = undefined;
this.canvas = undefined;
this.hasRenderedOnce = false; // 重置首次渲染标记
}
update(update: ViewUpdate) {
@@ -142,6 +208,9 @@ const minimapClass = ViewPlugin.fromClass(
}
if (now) {
let needBlockRender = false;
let needOverlayRender = false;
if (prev && this.dom && prev.autohide !== now.autohide) {
if (now.autohide) {
this.dom.classList.add('cm-minimap-autohide');
@@ -150,10 +219,45 @@ const minimapClass = ViewPlugin.fromClass(
}
}
this.text.update(update);
// Check theme change
if (this.blockManager.checkThemeChange()) {
needBlockRender = true;
}
// Check config change
const prevConfig = update.startState.facet(Config);
const nowConfig = update.state.facet(Config);
if (prevConfig.displayText !== nowConfig.displayText) {
this.blockManager.setDisplayText(nowConfig.displayText);
needBlockRender = true;
}
// Check doc change
if (update.docChanged) {
const oldLineCount = update.startState.doc.lines;
this.blockManager.handleDocChange(update.state, update.changes, oldLineCount);
needBlockRender = true;
}
// Check fold change
if (foldsChanged(update.transactions)) {
this.blockManager.markAllDirty();
needBlockRender = true;
}
// Update selection and diagnostics
this.selection.update(update);
this.diagnostic.update(update);
this.render();
if (update.selectionSet) {
needOverlayRender = true;
}
// 根据变化类型决定渲染方式
if (needBlockRender) {
this.debouncedBlockRender();
} else if (needOverlayRender) {
this.requestRender('data', 'overlays');
}
}
}
@@ -167,85 +271,182 @@ const minimapClass = ViewPlugin.fromClass(
}
render() {
// If we don't have elements to draw to exit early
if (!this.dom || !this.canvas || !this.inner) {
return;
}
this.text.beforeDraw();
const effectiveScrollTop = this.pendingScrollTop ?? this.view.scrollDOM.scrollTop;
const renderType = this.pendingRenderType ?? 'blocks';
this.pendingScrollTop = null;
this.pendingRenderReason = null;
this.pendingRenderType = null;
this.lastRenderedScrollTop = effectiveScrollTop;
this.updateBoxShadow();
this.dom.style.width = this.getWidth() + "px";
this.canvas.style.maxWidth = this.getWidth() + "px";
this.canvas.width = this.getWidth() * Scale.PixelMultiplier;
// Set canvas size
const width = this.getWidth();
this.dom.style.width = width + 'px';
this.canvas.style.maxWidth = width + 'px';
this.canvas.width = width * Scale.PixelMultiplier;
const domHeight = this.view.dom.getBoundingClientRect().height;
this.inner.style.minHeight = domHeight + "px";
this.inner.style.minHeight = domHeight + 'px';
this.canvas.height = domHeight * Scale.PixelMultiplier;
this.canvas.style.height = domHeight + "px";
this.canvas.style.height = domHeight + 'px';
const context = this.canvas.getContext("2d");
const context = this.canvas.getContext('2d');
if (!context) {
return;
}
context.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Get scroll info
const scrollInfo = {
scrollTop: effectiveScrollTop,
clientHeight: this.view.scrollDOM.clientHeight,
scrollHeight: this.view.scrollDOM.scrollHeight,
};
/* We need to get the correct font dimensions before this to measure characters */
const { charWidth, lineHeight } = this.text.measure(context);
// 渲染块
if (renderType === 'blocks') {
this.blockManager.render(this.canvas, context, scrollInfo);
} else {
this.blockManager.drawCachedBlocks(this.canvas, context, scrollInfo);
}
let { startIndex, endIndex, offsetY } = this.canvasStartAndEndIndex(
// Render overlays (gutters, selections, diagnostics)
const gutters = this.view.state.facet(Config).gutters;
this.renderOverlays(context, effectiveScrollTop, gutters);
// 首次渲染完成后显示 minimap
if (!this.hasRenderedOnce && this.dom) {
this.hasRenderedOnce = true;
this.dom.classList.remove('cm-minimap-initializing');
}
}
/**
* 渲染覆盖层gutters、选区、诊断
*/
private renderOverlays(
context: CanvasRenderingContext2D,
scrollTop: number,
gutters: Required<Options>['gutters']
) {
const { charWidth, lineHeight } = this.blockManager.measure(context);
const { startIndex, endIndex, offsetY: initialOffsetY } = this.canvasStartAndEndIndex(
context,
lineHeight
lineHeight,
scrollTop
);
const gutters = this.view.state.facet(Config).gutters;
const lines = this.view.state.field(LinesState);
let offsetY = initialOffsetY;
for (let i = startIndex; i < endIndex; i++) {
const lines = this.view.state.field(LinesState);
if (i >= lines.length) break;
const drawContext = {
offsetX: 0,
offsetY,
context,
lineHeight,
charWidth,
};
let offsetX = 0;
const lineNumber = i + 1;
// 渲染 gutters
if (gutters.length) {
/* Small leading buffer */
drawContext.offsetX += 2;
offsetX += 2;
for (const gutter of gutters) {
drawLineGutter(gutter, drawContext, i + 1);
drawContext.offsetX += GUTTER_WIDTH;
drawLineGutter(gutter, { offsetX, offsetY, context, lineHeight, charWidth }, lineNumber);
offsetX += GUTTER_WIDTH;
}
/* Small trailing buffer */
drawContext.offsetX += 2;
offsetX += 2;
}
this.text.drawLine(drawContext, i + 1);
this.selection.drawLine(drawContext, i + 1);
this.diagnostic.drawLine(drawContext, i + 1);
// 渲染选区
this.selection.drawLine({ offsetX, offsetY, context, lineHeight, charWidth }, lineNumber);
// 渲染诊断
if (this.diagnostic.has(lineNumber)) {
this.diagnostic.drawLine({ offsetX, offsetY, context, lineHeight, charWidth }, lineNumber);
}
offsetY += lineHeight;
}
}
context.restore();
requestRender(reason: RenderReason = 'data', type: RenderType = 'blocks') {
if (!this.isInitialized) {
return;
}
if (reason === 'scroll') {
const scrollTop = this.view.scrollDOM.scrollTop;
if (this.lastRenderedScrollTop === scrollTop && !this.pendingRenderReason) {
return;
}
if (
this.pendingRenderReason === 'scroll' &&
this.pendingScrollTop === scrollTop
) {
return;
}
this.pendingScrollTop = scrollTop;
} else {
this.pendingScrollTop = null;
}
if (reason === 'data' || this.pendingRenderReason === null) {
this.pendingRenderReason = reason;
}
// 合并渲染类型blocks > overlays
if (this.pendingRenderType === null || type === 'blocks') {
this.pendingRenderType = type;
}
if (this.renderHandle !== null) {
return;
}
if (typeof requestAnimationFrame === 'function') {
const handle = requestAnimationFrame(() => {
this.renderHandle = null;
this.cancelRender = null;
this.render();
});
this.renderHandle = handle;
this.cancelRender = () => cancelAnimationFrame(handle);
return;
}
const handle = setTimeout(() => {
this.renderHandle = null;
this.cancelRender = null;
this.render();
}, 16);
this.renderHandle = handle;
this.cancelRender = () => clearTimeout(handle);
}
cancelRenderRequest() {
if (!this.cancelRender) {
return;
}
this.cancelRender();
this.renderHandle = null;
this.cancelRender = null;
this.pendingScrollTop = null;
this.pendingRenderReason = null;
}
private canvasStartAndEndIndex(
context: CanvasRenderingContext2D,
lineHeight: number
lineHeight: number,
scrollTopOverride?: number
) {
let { top: pTop, bottom: pBottom } = this.view.documentPadding;
(pTop /= Scale.SizeRatio), (pBottom /= Scale.SizeRatio);
const canvasHeight = context.canvas.height;
const { clientHeight, scrollHeight, scrollTop } = this.view.scrollDOM;
const { clientHeight, scrollHeight } = this.view.scrollDOM;
const scrollTop = scrollTopOverride ?? this.view.scrollDOM.scrollTop;
let scrollPercent = scrollTop / (scrollHeight - clientHeight);
if (isNaN(scrollPercent)) {
scrollPercent = 0;
@@ -278,20 +479,26 @@ const minimapClass = ViewPlugin.fromClass(
const { clientWidth, scrollWidth, scrollLeft } = this.view.scrollDOM;
if (clientWidth + scrollLeft < scrollWidth) {
this.canvas.classList.add("cm-minimap-box-shadow");
this.canvas.classList.add('cm-minimap-box-shadow');
} else {
this.canvas.classList.remove("cm-minimap-box-shadow");
this.canvas.classList.remove('cm-minimap-box-shadow');
}
}
destroy() {
if (this.initialRenderDelay) {
clearTimeout(this.initialRenderDelay);
this.initialRenderDelay = null;
}
this.cancelDebounce();
this.blockManager.destroy();
this.remove();
}
},
{
eventHandlers: {
scroll() {
requestAnimationFrame(() => this.render());
this.requestRender('scroll', 'blocks');
},
},
provide: (plugin) => {
@@ -307,8 +514,7 @@ const minimapClass = ViewPlugin.fromClass(
}
);
// 使用type定义
export type MinimapConfig = Omit<Options, "enabled"> & {
export type MinimapConfig = Omit<Options, 'enabled'> & {
/**
* A function that creates the element that contains the minimap
*/
@@ -318,43 +524,35 @@ export type MinimapConfig = Omit<Options, "enabled"> & {
/**
* Facet used to show a minimap in the right gutter of the editor using the
* provided configuration.
*
* If you return `null`, a minimap will not be shown.
*/
const showMinimapFacet = Facet.define<MinimapConfig | null, MinimapConfig | null>({
combine: (c) => c.find((o) => o !== null) ?? null,
enables: (f) => {
return [
[
Config.compute([f], (s) => s.facet(f)),
Theme,
LinesState,
minimapClass, // TODO, codemirror-ify this one better
Overlay,
],
];
},
});
/**
* 创建默认的minimap DOM元素
* 创建默认的 minimap DOM 元素
*/
const defaultCreateFn = (view: EditorView) => {
const defaultCreateFn = (_view: EditorView) => {
const dom = document.createElement('div');
return { dom };
};
/**
* 添加minimap到编辑器
* @param options Minimap配置项
* @returns
* 添加 minimap 到编辑器
* @param options Minimap 配置项
*/
export function minimap(options: Partial<Omit<MinimapConfig, 'create'>> = {}) {
return showMinimapFacet.of({
const config: MinimapConfig = {
create: defaultCreateFn,
...options
});
}
...options,
};
// 保持原始接口兼容性
export { showMinimapFacet as showMinimap };
return [
showMinimapFacet.of(config),
Config.compute([showMinimapFacet], (s) => s.facet(showMinimapFacet)),
Theme,
LinesState,
minimapClass,
Overlay,
];
}

View File

@@ -0,0 +1,85 @@
import { Lines } from "./linesState";
const DEFAULT_LINE_NUMBER = 0;
function lineBoundary(spans: Lines[number]) {
if (!spans || spans.length === 0) {
return { start: 0, end: 0 };
}
const start = spans[0].from;
const end = spans[spans.length - 1].to;
return { start, end };
}
export function lineNumberAt(lines: Lines, position: number): number {
if (!lines.length) {
return DEFAULT_LINE_NUMBER;
}
const first = lineBoundary(lines[0]);
const last = lineBoundary(lines[lines.length - 1]);
let target = position;
if (target < first.start) {
target = first.start;
} else if (target > last.end) {
target = last.end;
}
let low = 0;
let high = lines.length - 1;
while (low <= high) {
const mid = (low + high) >> 1;
const spans = lines[mid];
const { start, end } = lineBoundary(spans);
if (target < start) {
high = mid - 1;
continue;
}
if (target > end) {
low = mid + 1;
continue;
}
return mid + 1;
}
return Math.max(1, Math.min(lines.length, low + 1));
}
export function lineLength(spans: Lines[number]) {
if (!spans) {
return 1;
}
let length = 0;
for (const span of spans) {
length += span.folded ? 1 : Math.max(0, span.to - span.from);
}
return Math.max(1, length);
}
export function offsetWithinLine(pos: number, spans: Lines[number]) {
if (!spans) {
return 0;
}
let offset = 0;
for (const span of spans) {
const spanLength = span.folded ? 1 : Math.max(0, span.to - span.from);
if (!span.folded && pos < span.to) {
return offset + Math.max(0, pos - span.from);
}
if (span.folded && pos <= span.to) {
return offset;
}
offset += spanLength;
}
return offset;
}

View File

@@ -14,6 +14,10 @@ export abstract class LineBasedState<TValue> {
return this.map.get(lineNumber);
}
public has(lineNumber: number): boolean {
return this.map.has(lineNumber);
}
protected set(lineNumber: number, value: TValue) {
this.map.set(lineNumber, value);
}

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