Compare commits
41 Commits
docs
...
8fce8bdca4
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fce8bdca4 | |||
| 1ab934cee9 | |||
| 6659ac6fad | |||
| 3a5ab1c614 | |||
| 1e07e1f833 | |||
| e1e91a3683 | |||
| c30d95a3e0 | |||
| 97f6fa843c | |||
| f43fc47539 | |||
|
|
c330de52fa | ||
| 67d35626cb | |||
| cc4c2189dc | |||
| d16905c0a3 | |||
| 4e611db349 | |||
| 7e9fc0ac3f | |||
| ff072d1a93 | |||
| a9c81c878e | |||
| 3660d13d7d | |||
| 281f53c049 | |||
| 71ca541f78 | |||
| 91f4f4afac | |||
| fc5639d7bd | |||
|
|
6668c11846 | ||
| 17f3351cea | |||
| dd3dd4ddb2 | |||
| 60d1494d45 | |||
| 1ef5350b3f | |||
| 3521e5787b | |||
| 8d9bcdad7e | |||
| ac086db1ed | |||
| 6dff0181d2 | |||
| ad24d3a140 | |||
| 4b0f39d747 | |||
| 096cc1da94 | |||
| 2d3200ad97 | |||
| 4e82e2f6f7 | |||
| 339ed53c2e | |||
| fc7c162e2f | |||
|
|
24f1549730 | ||
| 5584a46ca2 | |||
| 4471441d6f |
3
.github/workflows/build-release.yml
vendored
3
.github/workflows/build-release.yml
vendored
@@ -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
109
.github/workflows/codeql.yml
vendored
Normal 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}}"
|
||||
@@ -61,5 +61,3 @@ export class ServiceOptions {
|
||||
return new ServiceOptions($$parsedSource as Partial<ServiceOptions>);
|
||||
}
|
||||
}
|
||||
|
||||
export type Window = any;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export * from "./models.js";
|
||||
17
frontend/bindings/voidraft/internal/common/helper/models.ts
Normal file
17
frontend/bindings/voidraft/internal/common/helper/models.ts
Normal 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;
|
||||
4
frontend/bindings/voidraft/internal/models/ent/index.ts
Normal file
4
frontend/bindings/voidraft/internal/models/ent/index.ts
Normal 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";
|
||||
334
frontend/bindings/voidraft/internal/models/ent/models.ts
Normal file
334
frontend/bindings/voidraft/internal/models/ent/models.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
// 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 | null;
|
||||
|
||||
/**
|
||||
* 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"] = null;
|
||||
}
|
||||
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 | null;
|
||||
|
||||
/**
|
||||
* creation time
|
||||
*/
|
||||
"created_at": string;
|
||||
|
||||
/**
|
||||
* update time
|
||||
*/
|
||||
"updated_at": string;
|
||||
|
||||
/**
|
||||
* deleted at
|
||||
*/
|
||||
"deleted_at"?: string | null;
|
||||
|
||||
/**
|
||||
* extension key
|
||||
*/
|
||||
"key": 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"] = null;
|
||||
}
|
||||
if (!("created_at" in $$source)) {
|
||||
this["created_at"] = "";
|
||||
}
|
||||
if (!("updated_at" in $$source)) {
|
||||
this["updated_at"] = "";
|
||||
}
|
||||
if (!("key" in $$source)) {
|
||||
this["key"] = "";
|
||||
}
|
||||
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 | null;
|
||||
|
||||
/**
|
||||
* creation time
|
||||
*/
|
||||
"created_at": string;
|
||||
|
||||
/**
|
||||
* update time
|
||||
*/
|
||||
"updated_at": string;
|
||||
|
||||
/**
|
||||
* deleted at
|
||||
*/
|
||||
"deleted_at"?: string | null;
|
||||
|
||||
/**
|
||||
* key binding key
|
||||
*/
|
||||
"key": string;
|
||||
|
||||
/**
|
||||
* key binding command
|
||||
*/
|
||||
"command": string;
|
||||
|
||||
/**
|
||||
* key binding extension
|
||||
*/
|
||||
"extension"?: string;
|
||||
|
||||
/**
|
||||
* key binding enabled
|
||||
*/
|
||||
"enabled": boolean;
|
||||
|
||||
/** Creates a new KeyBinding instance. */
|
||||
constructor($$source: Partial<KeyBinding> = {}) {
|
||||
if (!("uuid" in $$source)) {
|
||||
this["uuid"] = null;
|
||||
}
|
||||
if (!("created_at" in $$source)) {
|
||||
this["created_at"] = "";
|
||||
}
|
||||
if (!("updated_at" in $$source)) {
|
||||
this["updated_at"] = "";
|
||||
}
|
||||
if (!("key" in $$source)) {
|
||||
this["key"] = "";
|
||||
}
|
||||
if (!("command" in $$source)) {
|
||||
this["command"] = "";
|
||||
}
|
||||
if (!("enabled" in $$source)) {
|
||||
this["enabled"] = 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 | null;
|
||||
|
||||
/**
|
||||
* creation time
|
||||
*/
|
||||
"created_at": string;
|
||||
|
||||
/**
|
||||
* update time
|
||||
*/
|
||||
"updated_at": string;
|
||||
|
||||
/**
|
||||
* deleted at
|
||||
*/
|
||||
"deleted_at"?: string | null;
|
||||
|
||||
/**
|
||||
* theme key
|
||||
*/
|
||||
"key": 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"] = null;
|
||||
}
|
||||
if (!("created_at" in $$source)) {
|
||||
this["created_at"] = "";
|
||||
}
|
||||
if (!("updated_at" in $$source)) {
|
||||
this["updated_at"] = "";
|
||||
}
|
||||
if (!("key" in $$source)) {
|
||||
this["key"] = "";
|
||||
}
|
||||
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);
|
||||
@@ -0,0 +1,4 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export * from "./models.js";
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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 编辑设置配置
|
||||
*/
|
||||
@@ -332,40 +280,21 @@ export class EditingConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension 单个扩展配置
|
||||
* Extension 扩展配置
|
||||
*/
|
||||
export class Extension {
|
||||
/**
|
||||
* 扩展唯一标识
|
||||
*/
|
||||
"id": ExtensionID;
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
*/
|
||||
"key": ExtensionKey;
|
||||
"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 ExtensionKey);
|
||||
}
|
||||
if (!("enabled" in $$source)) {
|
||||
this["enabled"] = false;
|
||||
}
|
||||
if (!("isDefault" in $$source)) {
|
||||
this["isDefault"] = false;
|
||||
}
|
||||
if (!("config" in $$source)) {
|
||||
this["config"] = ({} as ExtensionConfig);
|
||||
}
|
||||
@@ -377,10 +306,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,9 +321,9 @@ export class Extension {
|
||||
export type ExtensionConfig = { [_: string]: any };
|
||||
|
||||
/**
|
||||
* ExtensionID 扩展标识符
|
||||
* ExtensionKey 扩展标识符
|
||||
*/
|
||||
export enum ExtensionID {
|
||||
export enum ExtensionKey {
|
||||
/**
|
||||
* The Go zero value for the underlying type of the enum.
|
||||
*/
|
||||
@@ -415,13 +344,11 @@ export enum ExtensionID {
|
||||
* 颜色选择器
|
||||
*/
|
||||
ExtensionColorSelector = "colorSelector",
|
||||
ExtensionFold = "fold",
|
||||
ExtensionTextHighlight = "textHighlight",
|
||||
|
||||
/**
|
||||
* 选择框
|
||||
* 代码折叠
|
||||
*/
|
||||
ExtensionCheckbox = "checkbox",
|
||||
ExtensionFold = "fold",
|
||||
|
||||
/**
|
||||
* 划词翻译
|
||||
@@ -429,22 +356,44 @@ export enum ExtensionID {
|
||||
ExtensionTranslator = "translator",
|
||||
|
||||
/**
|
||||
* UI增强扩展
|
||||
* Markdown渲染
|
||||
*/
|
||||
ExtensionMarkdown = "markdown",
|
||||
|
||||
/**
|
||||
* 显示空白字符
|
||||
*/
|
||||
ExtensionHighlightWhitespace = "highlightWhitespace",
|
||||
|
||||
/**
|
||||
* 高亮行尾空白
|
||||
*/
|
||||
ExtensionHighlightTrailingWhitespace = "highlightTrailingWhitespace",
|
||||
|
||||
/**
|
||||
* 小地图
|
||||
*/
|
||||
ExtensionMinimap = "minimap",
|
||||
|
||||
/**
|
||||
* 工具扩展
|
||||
* 行号显示
|
||||
*/
|
||||
ExtensionLineNumbers = "lineNumbers",
|
||||
|
||||
/**
|
||||
* 上下文菜单
|
||||
*/
|
||||
ExtensionContextMenu = "contextMenu",
|
||||
|
||||
/**
|
||||
* 搜索功能
|
||||
*/
|
||||
ExtensionSearch = "search",
|
||||
|
||||
/**
|
||||
* 核心扩展
|
||||
* 编辑器核心功能
|
||||
* HTTP 客户端
|
||||
*/
|
||||
ExtensionEditor = "editor",
|
||||
ExtensionHttpClient = "httpClient",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -735,48 +684,25 @@ export class HotkeyCombo {
|
||||
* KeyBinding 单个快捷键绑定
|
||||
*/
|
||||
export class KeyBinding {
|
||||
/**
|
||||
* 快捷键动作
|
||||
*/
|
||||
"command": KeyBindingCommand;
|
||||
|
||||
/**
|
||||
* 所属扩展
|
||||
*/
|
||||
"extension": ExtensionID;
|
||||
|
||||
/**
|
||||
* 快捷键组合(如 "Mod-f", "Ctrl-Shift-p")
|
||||
*/
|
||||
"key": string;
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
*/
|
||||
"key": KeyBindingKey;
|
||||
"command": string;
|
||||
"extension": ExtensionKey;
|
||||
"enabled": boolean;
|
||||
|
||||
/**
|
||||
* 是否为默认快捷键
|
||||
*/
|
||||
"isDefault": boolean;
|
||||
|
||||
/** Creates a new KeyBinding instance. */
|
||||
constructor($$source: Partial<KeyBinding> = {}) {
|
||||
if (!("key" in $$source)) {
|
||||
this["key"] = ("" as KeyBindingKey);
|
||||
}
|
||||
if (!("command" in $$source)) {
|
||||
this["command"] = ("" as KeyBindingCommand);
|
||||
this["command"] = "";
|
||||
}
|
||||
if (!("extension" in $$source)) {
|
||||
this["extension"] = ("" as ExtensionID);
|
||||
}
|
||||
if (!("key" in $$source)) {
|
||||
this["key"] = "";
|
||||
this["extension"] = ("" as ExtensionKey);
|
||||
}
|
||||
if (!("enabled" in $$source)) {
|
||||
this["enabled"] = false;
|
||||
}
|
||||
if (!("isDefault" in $$source)) {
|
||||
this["isDefault"] = false;
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
@@ -791,294 +717,258 @@ export class KeyBinding {
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyBindingCommand 快捷键命令
|
||||
* KeyBindingKey 快捷键命令
|
||||
*/
|
||||
export enum KeyBindingCommand {
|
||||
export enum KeyBindingKey {
|
||||
/**
|
||||
* The Go zero value for the underlying type of the enum.
|
||||
*/
|
||||
$zero = "",
|
||||
|
||||
/**
|
||||
* 搜索扩展相关
|
||||
* 显示搜索
|
||||
*/
|
||||
ShowSearchCommand = "showSearch",
|
||||
ShowSearchKeyBindingKey = "showSearch",
|
||||
|
||||
/**
|
||||
* 隐藏搜索
|
||||
*/
|
||||
HideSearchCommand = "hideSearch",
|
||||
HideSearchKeyBindingKey = "hideSearch",
|
||||
|
||||
/**
|
||||
* 搜索切换大小写
|
||||
*/
|
||||
SearchToggleCaseCommand = "searchToggleCase",
|
||||
|
||||
/**
|
||||
* 搜索切换整词
|
||||
*/
|
||||
SearchToggleWordCommand = "searchToggleWord",
|
||||
|
||||
/**
|
||||
* 搜索切换正则
|
||||
*/
|
||||
SearchToggleRegexCommand = "searchToggleRegex",
|
||||
|
||||
/**
|
||||
* 显示替换
|
||||
*/
|
||||
SearchShowReplaceCommand = "searchShowReplace",
|
||||
|
||||
/**
|
||||
* 替换全部
|
||||
*/
|
||||
SearchReplaceAllCommand = "searchReplaceAll",
|
||||
|
||||
/**
|
||||
* 代码块扩展相关
|
||||
* 块内选择全部
|
||||
*/
|
||||
BlockSelectAllCommand = "blockSelectAll",
|
||||
BlockSelectAllKeyBindingKey = "blockSelectAll",
|
||||
|
||||
/**
|
||||
* 在当前块后添加新块
|
||||
*/
|
||||
BlockAddAfterCurrentCommand = "blockAddAfterCurrent",
|
||||
BlockAddAfterCurrentKeyBindingKey = "blockAddAfterCurrent",
|
||||
|
||||
/**
|
||||
* 在最后添加新块
|
||||
*/
|
||||
BlockAddAfterLastCommand = "blockAddAfterLast",
|
||||
BlockAddAfterLastKeyBindingKey = "blockAddAfterLast",
|
||||
|
||||
/**
|
||||
* 在当前块前添加新块
|
||||
*/
|
||||
BlockAddBeforeCurrentCommand = "blockAddBeforeCurrent",
|
||||
BlockAddBeforeCurrentKeyBindingKey = "blockAddBeforeCurrent",
|
||||
|
||||
/**
|
||||
* 跳转到上一个块
|
||||
*/
|
||||
BlockGotoPreviousCommand = "blockGotoPrevious",
|
||||
BlockGotoPreviousKeyBindingKey = "blockGotoPrevious",
|
||||
|
||||
/**
|
||||
* 跳转到下一个块
|
||||
*/
|
||||
BlockGotoNextCommand = "blockGotoNext",
|
||||
BlockGotoNextKeyBindingKey = "blockGotoNext",
|
||||
|
||||
/**
|
||||
* 选择上一个块
|
||||
*/
|
||||
BlockSelectPreviousCommand = "blockSelectPrevious",
|
||||
BlockSelectPreviousKeyBindingKey = "blockSelectPrevious",
|
||||
|
||||
/**
|
||||
* 选择下一个块
|
||||
*/
|
||||
BlockSelectNextCommand = "blockSelectNext",
|
||||
BlockSelectNextKeyBindingKey = "blockSelectNext",
|
||||
|
||||
/**
|
||||
* 删除当前块
|
||||
*/
|
||||
BlockDeleteCommand = "blockDelete",
|
||||
BlockDeleteKeyBindingKey = "blockDelete",
|
||||
|
||||
/**
|
||||
* 向上移动当前块
|
||||
*/
|
||||
BlockMoveUpCommand = "blockMoveUp",
|
||||
BlockMoveUpKeyBindingKey = "blockMoveUp",
|
||||
|
||||
/**
|
||||
* 向下移动当前块
|
||||
*/
|
||||
BlockMoveDownCommand = "blockMoveDown",
|
||||
BlockMoveDownKeyBindingKey = "blockMoveDown",
|
||||
|
||||
/**
|
||||
* 删除行
|
||||
*/
|
||||
BlockDeleteLineCommand = "blockDeleteLine",
|
||||
BlockDeleteLineKeyBindingKey = "blockDeleteLine",
|
||||
|
||||
/**
|
||||
* 向上移动行
|
||||
*/
|
||||
BlockMoveLineUpCommand = "blockMoveLineUp",
|
||||
BlockMoveLineUpKeyBindingKey = "blockMoveLineUp",
|
||||
|
||||
/**
|
||||
* 向下移动行
|
||||
*/
|
||||
BlockMoveLineDownCommand = "blockMoveLineDown",
|
||||
BlockMoveLineDownKeyBindingKey = "blockMoveLineDown",
|
||||
|
||||
/**
|
||||
* 字符转置
|
||||
*/
|
||||
BlockTransposeCharsCommand = "blockTransposeChars",
|
||||
BlockTransposeCharsKeyBindingKey = "blockTransposeChars",
|
||||
|
||||
/**
|
||||
* 格式化代码块
|
||||
*/
|
||||
BlockFormatCommand = "blockFormat",
|
||||
BlockFormatKeyBindingKey = "blockFormat",
|
||||
|
||||
/**
|
||||
* 复制
|
||||
*/
|
||||
BlockCopyCommand = "blockCopy",
|
||||
BlockCopyKeyBindingKey = "blockCopy",
|
||||
|
||||
/**
|
||||
* 剪切
|
||||
*/
|
||||
BlockCutCommand = "blockCut",
|
||||
BlockCutKeyBindingKey = "blockCut",
|
||||
|
||||
/**
|
||||
* 粘贴
|
||||
*/
|
||||
BlockPasteCommand = "blockPaste",
|
||||
BlockPasteKeyBindingKey = "blockPaste",
|
||||
|
||||
/**
|
||||
* 代码折叠扩展相关
|
||||
* 折叠代码
|
||||
*/
|
||||
FoldCodeCommand = "foldCode",
|
||||
FoldCodeKeyBindingKey = "foldCode",
|
||||
|
||||
/**
|
||||
* 展开代码
|
||||
*/
|
||||
UnfoldCodeCommand = "unfoldCode",
|
||||
UnfoldCodeKeyBindingKey = "unfoldCode",
|
||||
|
||||
/**
|
||||
* 折叠全部
|
||||
*/
|
||||
FoldAllCommand = "foldAll",
|
||||
FoldAllKeyBindingKey = "foldAll",
|
||||
|
||||
/**
|
||||
* 展开全部
|
||||
*/
|
||||
UnfoldAllCommand = "unfoldAll",
|
||||
UnfoldAllKeyBindingKey = "unfoldAll",
|
||||
|
||||
/**
|
||||
* 通用编辑扩展相关
|
||||
* 光标按语法左移
|
||||
*/
|
||||
CursorSyntaxLeftCommand = "cursorSyntaxLeft",
|
||||
CursorSyntaxLeftKeyBindingKey = "cursorSyntaxLeft",
|
||||
|
||||
/**
|
||||
* 光标按语法右移
|
||||
*/
|
||||
CursorSyntaxRightCommand = "cursorSyntaxRight",
|
||||
CursorSyntaxRightKeyBindingKey = "cursorSyntaxRight",
|
||||
|
||||
/**
|
||||
* 按语法选择左侧
|
||||
*/
|
||||
SelectSyntaxLeftCommand = "selectSyntaxLeft",
|
||||
SelectSyntaxLeftKeyBindingKey = "selectSyntaxLeft",
|
||||
|
||||
/**
|
||||
* 按语法选择右侧
|
||||
*/
|
||||
SelectSyntaxRightCommand = "selectSyntaxRight",
|
||||
SelectSyntaxRightKeyBindingKey = "selectSyntaxRight",
|
||||
|
||||
/**
|
||||
* 向上复制行
|
||||
*/
|
||||
CopyLineUpCommand = "copyLineUp",
|
||||
CopyLineUpKeyBindingKey = "copyLineUp",
|
||||
|
||||
/**
|
||||
* 向下复制行
|
||||
*/
|
||||
CopyLineDownCommand = "copyLineDown",
|
||||
CopyLineDownKeyBindingKey = "copyLineDown",
|
||||
|
||||
/**
|
||||
* 插入空行
|
||||
*/
|
||||
InsertBlankLineCommand = "insertBlankLine",
|
||||
InsertBlankLineKeyBindingKey = "insertBlankLine",
|
||||
|
||||
/**
|
||||
* 选择行
|
||||
*/
|
||||
SelectLineCommand = "selectLine",
|
||||
SelectLineKeyBindingKey = "selectLine",
|
||||
|
||||
/**
|
||||
* 选择父级语法
|
||||
*/
|
||||
SelectParentSyntaxCommand = "selectParentSyntax",
|
||||
SelectParentSyntaxKeyBindingKey = "selectParentSyntax",
|
||||
|
||||
/**
|
||||
* 减少缩进
|
||||
*/
|
||||
IndentLessCommand = "indentLess",
|
||||
IndentLessKeyBindingKey = "indentLess",
|
||||
|
||||
/**
|
||||
* 增加缩进
|
||||
*/
|
||||
IndentMoreCommand = "indentMore",
|
||||
IndentMoreKeyBindingKey = "indentMore",
|
||||
|
||||
/**
|
||||
* 缩进选择
|
||||
*/
|
||||
IndentSelectionCommand = "indentSelection",
|
||||
IndentSelectionKeyBindingKey = "indentSelection",
|
||||
|
||||
/**
|
||||
* 光标到匹配括号
|
||||
*/
|
||||
CursorMatchingBracketCommand = "cursorMatchingBracket",
|
||||
CursorMatchingBracketKeyBindingKey = "cursorMatchingBracket",
|
||||
|
||||
/**
|
||||
* 切换注释
|
||||
*/
|
||||
ToggleCommentCommand = "toggleComment",
|
||||
ToggleCommentKeyBindingKey = "toggleComment",
|
||||
|
||||
/**
|
||||
* 切换块注释
|
||||
*/
|
||||
ToggleBlockCommentCommand = "toggleBlockComment",
|
||||
ToggleBlockCommentKeyBindingKey = "toggleBlockComment",
|
||||
|
||||
/**
|
||||
* 插入新行并缩进
|
||||
*/
|
||||
InsertNewlineAndIndentCommand = "insertNewlineAndIndent",
|
||||
InsertNewlineAndIndentKeyBindingKey = "insertNewlineAndIndent",
|
||||
|
||||
/**
|
||||
* 向后删除字符
|
||||
*/
|
||||
DeleteCharBackwardCommand = "deleteCharBackward",
|
||||
DeleteCharBackwardKeyBindingKey = "deleteCharBackward",
|
||||
|
||||
/**
|
||||
* 向前删除字符
|
||||
*/
|
||||
DeleteCharForwardCommand = "deleteCharForward",
|
||||
DeleteCharForwardKeyBindingKey = "deleteCharForward",
|
||||
|
||||
/**
|
||||
* 向后删除组
|
||||
*/
|
||||
DeleteGroupBackwardCommand = "deleteGroupBackward",
|
||||
DeleteGroupBackwardKeyBindingKey = "deleteGroupBackward",
|
||||
|
||||
/**
|
||||
* 向前删除组
|
||||
*/
|
||||
DeleteGroupForwardCommand = "deleteGroupForward",
|
||||
DeleteGroupForwardKeyBindingKey = "deleteGroupForward",
|
||||
|
||||
/**
|
||||
* 历史记录扩展相关
|
||||
* 撤销
|
||||
*/
|
||||
HistoryUndoCommand = "historyUndo",
|
||||
HistoryUndoKeyBindingKey = "historyUndo",
|
||||
|
||||
/**
|
||||
* 重做
|
||||
*/
|
||||
HistoryRedoCommand = "historyRedo",
|
||||
HistoryRedoKeyBindingKey = "historyRedo",
|
||||
|
||||
/**
|
||||
* 撤销选择
|
||||
*/
|
||||
HistoryUndoSelectionCommand = "historyUndoSelection",
|
||||
HistoryUndoSelectionKeyBindingKey = "historyUndoSelection",
|
||||
|
||||
/**
|
||||
* 重做选择
|
||||
*/
|
||||
HistoryRedoSelectionCommand = "historyRedoSelection",
|
||||
|
||||
/**
|
||||
* 文本高亮扩展相关
|
||||
* 切换文本高亮
|
||||
*/
|
||||
TextHighlightToggleCommand = "textHighlightToggle",
|
||||
HistoryRedoSelectionKeyBindingKey = "historyRedoSelection",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1146,370 +1036,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"] = (new 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 class ThemeColorConfig {
|
||||
/**
|
||||
* 主题基本信息
|
||||
* 主题名称
|
||||
*/
|
||||
"name": string;
|
||||
|
||||
/**
|
||||
* 是否为深色主题
|
||||
*/
|
||||
"dark": boolean;
|
||||
|
||||
/**
|
||||
* 基础色调
|
||||
* 主背景色
|
||||
*/
|
||||
"background": string;
|
||||
|
||||
/**
|
||||
* 次要背景色(用于代码块交替背景)
|
||||
*/
|
||||
"backgroundSecondary": string;
|
||||
|
||||
/**
|
||||
* 面板背景
|
||||
*/
|
||||
"surface": string;
|
||||
|
||||
/**
|
||||
* 下拉菜单背景
|
||||
*/
|
||||
"dropdownBackground": string;
|
||||
|
||||
/**
|
||||
* 下拉菜单边框
|
||||
*/
|
||||
"dropdownBorder": string;
|
||||
|
||||
/**
|
||||
* 文本颜色
|
||||
* 主文本色
|
||||
*/
|
||||
"foreground": string;
|
||||
|
||||
/**
|
||||
* 次要文本色
|
||||
*/
|
||||
"foregroundSecondary": string;
|
||||
|
||||
/**
|
||||
* 注释色
|
||||
*/
|
||||
"comment": string;
|
||||
|
||||
/**
|
||||
* 语法高亮色 - 核心
|
||||
* 关键字
|
||||
*/
|
||||
"keyword": string;
|
||||
|
||||
/**
|
||||
* 字符串
|
||||
*/
|
||||
"string": string;
|
||||
|
||||
/**
|
||||
* 函数名
|
||||
*/
|
||||
"function": string;
|
||||
|
||||
/**
|
||||
* 数字
|
||||
*/
|
||||
"number": string;
|
||||
|
||||
/**
|
||||
* 操作符
|
||||
*/
|
||||
"operator": string;
|
||||
|
||||
/**
|
||||
* 变量
|
||||
*/
|
||||
"variable": string;
|
||||
|
||||
/**
|
||||
* 类型
|
||||
*/
|
||||
"type": string;
|
||||
|
||||
/**
|
||||
* 语法高亮色 - 扩展
|
||||
* 常量
|
||||
*/
|
||||
"constant": string;
|
||||
|
||||
/**
|
||||
* 存储类型(如 static, const)
|
||||
*/
|
||||
"storage": string;
|
||||
|
||||
/**
|
||||
* 参数
|
||||
*/
|
||||
"parameter": string;
|
||||
|
||||
/**
|
||||
* 类名
|
||||
*/
|
||||
"class": string;
|
||||
|
||||
/**
|
||||
* 标题(Markdown等)
|
||||
*/
|
||||
"heading": string;
|
||||
|
||||
/**
|
||||
* 无效内容/错误
|
||||
*/
|
||||
"invalid": string;
|
||||
|
||||
/**
|
||||
* 正则表达式
|
||||
*/
|
||||
"regexp": string;
|
||||
|
||||
/**
|
||||
* 界面元素
|
||||
* 光标
|
||||
*/
|
||||
"cursor": string;
|
||||
|
||||
/**
|
||||
* 选中背景
|
||||
*/
|
||||
"selection": string;
|
||||
|
||||
/**
|
||||
* 失焦选中背景
|
||||
*/
|
||||
"selectionBlur": string;
|
||||
|
||||
/**
|
||||
* 当前行高亮
|
||||
*/
|
||||
"activeLine": string;
|
||||
|
||||
/**
|
||||
* 行号
|
||||
*/
|
||||
"lineNumber": string;
|
||||
|
||||
/**
|
||||
* 活动行号颜色
|
||||
*/
|
||||
"activeLineNumber": string;
|
||||
|
||||
/**
|
||||
* 边框和分割线
|
||||
* 边框色
|
||||
*/
|
||||
"borderColor": string;
|
||||
|
||||
/**
|
||||
* 浅色边框
|
||||
*/
|
||||
"borderLight": string;
|
||||
|
||||
/**
|
||||
* 搜索和匹配
|
||||
* 搜索匹配
|
||||
*/
|
||||
"searchMatch": string;
|
||||
|
||||
/**
|
||||
* 匹配括号
|
||||
*/
|
||||
"matchingBracket": string;
|
||||
|
||||
/** Creates a new ThemeColorConfig instance. */
|
||||
constructor($$source: Partial<ThemeColorConfig> = {}) {
|
||||
if (!("name" in $$source)) {
|
||||
this["name"] = "";
|
||||
}
|
||||
if (!("dark" in $$source)) {
|
||||
this["dark"] = false;
|
||||
}
|
||||
if (!("background" in $$source)) {
|
||||
this["background"] = "";
|
||||
}
|
||||
if (!("backgroundSecondary" in $$source)) {
|
||||
this["backgroundSecondary"] = "";
|
||||
}
|
||||
if (!("surface" in $$source)) {
|
||||
this["surface"] = "";
|
||||
}
|
||||
if (!("dropdownBackground" in $$source)) {
|
||||
this["dropdownBackground"] = "";
|
||||
}
|
||||
if (!("dropdownBorder" in $$source)) {
|
||||
this["dropdownBorder"] = "";
|
||||
}
|
||||
if (!("foreground" in $$source)) {
|
||||
this["foreground"] = "";
|
||||
}
|
||||
if (!("foregroundSecondary" in $$source)) {
|
||||
this["foregroundSecondary"] = "";
|
||||
}
|
||||
if (!("comment" in $$source)) {
|
||||
this["comment"] = "";
|
||||
}
|
||||
if (!("keyword" in $$source)) {
|
||||
this["keyword"] = "";
|
||||
}
|
||||
if (!("string" in $$source)) {
|
||||
this["string"] = "";
|
||||
}
|
||||
if (!("function" in $$source)) {
|
||||
this["function"] = "";
|
||||
}
|
||||
if (!("number" in $$source)) {
|
||||
this["number"] = "";
|
||||
}
|
||||
if (!("operator" in $$source)) {
|
||||
this["operator"] = "";
|
||||
}
|
||||
if (!("variable" in $$source)) {
|
||||
this["variable"] = "";
|
||||
}
|
||||
if (!("type" in $$source)) {
|
||||
this["type"] = "";
|
||||
}
|
||||
if (!("constant" in $$source)) {
|
||||
this["constant"] = "";
|
||||
}
|
||||
if (!("storage" in $$source)) {
|
||||
this["storage"] = "";
|
||||
}
|
||||
if (!("parameter" in $$source)) {
|
||||
this["parameter"] = "";
|
||||
}
|
||||
if (!("class" in $$source)) {
|
||||
this["class"] = "";
|
||||
}
|
||||
if (!("heading" in $$source)) {
|
||||
this["heading"] = "";
|
||||
}
|
||||
if (!("invalid" in $$source)) {
|
||||
this["invalid"] = "";
|
||||
}
|
||||
if (!("regexp" in $$source)) {
|
||||
this["regexp"] = "";
|
||||
}
|
||||
if (!("cursor" in $$source)) {
|
||||
this["cursor"] = "";
|
||||
}
|
||||
if (!("selection" in $$source)) {
|
||||
this["selection"] = "";
|
||||
}
|
||||
if (!("selectionBlur" in $$source)) {
|
||||
this["selectionBlur"] = "";
|
||||
}
|
||||
if (!("activeLine" in $$source)) {
|
||||
this["activeLine"] = "";
|
||||
}
|
||||
if (!("lineNumber" in $$source)) {
|
||||
this["lineNumber"] = "";
|
||||
}
|
||||
if (!("activeLineNumber" in $$source)) {
|
||||
this["activeLineNumber"] = "";
|
||||
}
|
||||
if (!("borderColor" in $$source)) {
|
||||
this["borderColor"] = "";
|
||||
}
|
||||
if (!("borderLight" in $$source)) {
|
||||
this["borderLight"] = "";
|
||||
}
|
||||
if (!("searchMatch" in $$source)) {
|
||||
this["searchMatch"] = "";
|
||||
}
|
||||
if (!("matchingBracket" in $$source)) {
|
||||
this["matchingBracket"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ThemeColorConfig instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): ThemeColorConfig {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new ThemeColorConfig($$parsedSource as Partial<ThemeColorConfig>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ThemeType 主题类型枚举
|
||||
*/
|
||||
export enum ThemeType {
|
||||
/**
|
||||
* The Go zero value for the underlying type of the enum.
|
||||
*/
|
||||
$zero = "",
|
||||
|
||||
ThemeTypeDark = "dark",
|
||||
ThemeTypeLight = "light",
|
||||
};
|
||||
|
||||
/**
|
||||
* UpdateSourceType 更新源类型
|
||||
*/
|
||||
@@ -1608,8 +1134,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"]);
|
||||
@@ -1636,6 +1162,5 @@ var $$createType6 = (function $$initCreateType6(...args): any {
|
||||
});
|
||||
const $$createType7 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType8 = HotkeyCombo.createFrom;
|
||||
const $$createType9 = ThemeColorConfig.createFrom;
|
||||
const $$createType10 = GithubConfig.createFrom;
|
||||
const $$createType11 = GiteaConfig.createFrom;
|
||||
const $$createType9 = GithubConfig.createFrom;
|
||||
const $$createType10 = GiteaConfig.createFrom;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* ExtensionService 扩展管理服务
|
||||
* ExtensionService 扩展服务
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -16,12 +16,39 @@ 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 获取所有扩展配置
|
||||
* GetAllExtensions 获取所有扩展
|
||||
*/
|
||||
export function GetAllExtensions(): Promise<models$0.Extension[]> & { cancel(): void } {
|
||||
export function GetAllExtensions(): Promise<(ent$0.Extension | null)[]> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3094292124) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
}) as any;
|
||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
||||
return $typingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetDefaultExtensions 获取默认扩展配置(用于前端绑定生成)
|
||||
*/
|
||||
export function GetDefaultExtensions(): Promise<models$0.Extension[]> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(4036328166) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType4($result);
|
||||
}) as any;
|
||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
||||
return $typingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetExtensionByKey 根据Key获取扩展
|
||||
*/
|
||||
export function GetExtensionByKey(key: string): Promise<ent$0.Extension | null> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2551065776, key) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
}) as any;
|
||||
@@ -30,23 +57,15 @@ export function GetAllExtensions(): Promise<models$0.Extension[]> & { cancel():
|
||||
}
|
||||
|
||||
/**
|
||||
* ResetAllExtensionsToDefault 重置所有扩展到默认状态
|
||||
* ResetExtensionConfig 重置单个扩展到默认状态
|
||||
*/
|
||||
export function ResetAllExtensionsToDefault(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(270611949) as any;
|
||||
export function ResetExtensionConfig(key: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3990780299, key) 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 +73,32 @@ 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(key: string, config: { [_: string]: any }): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3184142503, key, config) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* UpdateExtensionEnabled 更新扩展启用状态
|
||||
*/
|
||||
export function UpdateExtensionEnabled(key: string, enabled: boolean): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1067300094, key, enabled) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = models$0.Extension.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType0 = ent$0.Extension.createFrom;
|
||||
const $$createType1 = $Create.Nullable($$createType0);
|
||||
const $$createType2 = $Create.Array($$createType1);
|
||||
const $$createType3 = models$0.Extension.createFrom;
|
||||
const $$createType4 = $Create.Array($$createType3);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* KeyBindingService 快捷键管理服务
|
||||
* KeyBindingService 快捷键服务
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -16,12 +16,39 @@ 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 获取所有快捷键配置
|
||||
* GetAllKeyBindings 获取所有快捷键
|
||||
*/
|
||||
export function GetAllKeyBindings(): Promise<models$0.KeyBinding[]> & { cancel(): void } {
|
||||
export function GetAllKeyBindings(): Promise<(ent$0.KeyBinding | null)[]> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1633502882) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
}) as any;
|
||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
||||
return $typingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetDefaultKeyBindings 获取默认快捷键配置
|
||||
*/
|
||||
export function GetDefaultKeyBindings(): Promise<models$0.KeyBinding[]> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3843471588) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType4($result);
|
||||
}) as any;
|
||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
||||
return $typingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetKeyBindingByKey 根据Key获取快捷键
|
||||
*/
|
||||
export function GetKeyBindingByKey(key: string): Promise<ent$0.KeyBinding | null> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(852938650, key) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
}) as any;
|
||||
@@ -30,13 +57,40 @@ export function GetAllKeyBindings(): Promise<models$0.KeyBinding[]> & { cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup 启动时调用
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* UpdateKeyBindingCommand 更新快捷键命令
|
||||
*/
|
||||
export function UpdateKeyBindingCommand(key: string, command: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1293670628, key, command) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* UpdateKeyBindingEnabled 更新快捷键启用状态
|
||||
*/
|
||||
export function UpdateKeyBindingEnabled(key: string, enabled: boolean): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(843626124, key, enabled) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = models$0.KeyBinding.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType0 = ent$0.KeyBinding.createFrom;
|
||||
const $$createType1 = $Create.Nullable($$createType0);
|
||||
const $$createType2 = $Create.Array($$createType1);
|
||||
const $$createType3 = models$0.KeyBinding.createFrom;
|
||||
const $$createType4 = $Create.Array($$createType3);
|
||||
|
||||
@@ -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 自我更新结果
|
||||
*/
|
||||
|
||||
@@ -15,26 +15,13 @@ 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";
|
||||
|
||||
/**
|
||||
* GetAllThemes 获取所有主题
|
||||
* GetThemeByKey 根据Key获取主题
|
||||
*/
|
||||
export function GetAllThemes(): Promise<(models$0.Theme | null)[]> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2425053076) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
}) as any;
|
||||
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
|
||||
return $typingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetThemeByID 根据ID或名称获取主题
|
||||
* 如果 id > 0,按ID查询;如果 id = 0,按名称查询
|
||||
*/
|
||||
export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<models$0.Theme | null> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(127385338, id, name) as any;
|
||||
export function GetThemeByKey(key: string): Promise<ent$0.Theme | null> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(808794256, key) as any;
|
||||
let $typingPromise = $resultPromise.then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
}) as any;
|
||||
@@ -43,23 +30,15 @@ export function GetThemeByIdOrName(id: number, ...name: string[]): Promise<model
|
||||
}
|
||||
|
||||
/**
|
||||
* ResetTheme 重置主题为预设配置
|
||||
* ResetTheme 删除主题
|
||||
*/
|
||||
export function ResetTheme(id: number, ...name: string[]): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1806334457, id, name) as any;
|
||||
export function ResetTheme(key: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1806334457, key) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceShutdown 服务关闭
|
||||
*/
|
||||
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1676749034) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup 服务启动时初始化
|
||||
* ServiceStartup 服务启动
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2915959937, options) as any;
|
||||
@@ -67,14 +46,13 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
|
||||
}
|
||||
|
||||
/**
|
||||
* UpdateTheme 更新主题
|
||||
* UpdateTheme 保存或更新主题
|
||||
*/
|
||||
export function UpdateTheme(id: number, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(70189749, id, colors) as any;
|
||||
export function UpdateTheme(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);
|
||||
const $$createType2 = $Create.Array($$createType1);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
3649
frontend/package-lock.json
generated
3649
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@
|
||||
"app:generate": "cd .. && wails3 generate bindings -ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.19.1",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.0",
|
||||
"@codemirror/lang-angular": "^0.1.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
@@ -50,59 +50,56 @@
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@codemirror/view": "^6.38.8",
|
||||
"@cospaia/prettier-plugin-clojure": "^0.0.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.3",
|
||||
"@mdit/plugin-katex": "^0.23.2",
|
||||
"@mdit/plugin-tasklist": "^0.22.2",
|
||||
"@lezer/lr": "^1.4.4",
|
||||
"@prettier/plugin-xml": "^3.4.2",
|
||||
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||
"@toml-tools/lexer": "^1.0.0",
|
||||
"@toml-tools/parser": "^1.0.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/katex": "^0.16.7",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-lang-elixir": "^4.0.0",
|
||||
"colors-named": "^1.0.2",
|
||||
"colors-named-hex": "^1.0.2",
|
||||
"groovy-beautify": "^0.0.17",
|
||||
"highlight.js": "^11.11.1",
|
||||
"hsl-matcher": "^1.2.4",
|
||||
"java-parser": "^3.0.1",
|
||||
"katex": "^0.16.25",
|
||||
"linguist-languages": "^9.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.12.1",
|
||||
"npm": "^11.6.2",
|
||||
"php-parser": "^3.2.5",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.94.0",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"prettier": "^3.7.2",
|
||||
"sass": "^1.94.2",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-pick-colors": "^1.8.0",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@types/node": "^24.9.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@wailsio/runtime": "latest",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"globals": "^16.5.0",
|
||||
"happy-dom": "^20.0.10",
|
||||
"happy-dom": "^20.0.11",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"vitepress": "^2.0.0-alpha.12",
|
||||
"vitest": "^4.0.8",
|
||||
"vitest": "^4.0.14",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.1.3"
|
||||
"vue-tsc": "^3.1.5"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@latest"
|
||||
|
||||
@@ -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();
|
||||
|
||||
// 启动时检查更新
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* 导入所有CSS文件 */
|
||||
@import 'normalize.css';
|
||||
@import 'variables.css';
|
||||
@import 'scrollbar.css';
|
||||
@import "harmony_fonts.css";
|
||||
@import 'hack_fonts.css';
|
||||
@import 'opensans_fonts.css';
|
||||
@import "monocraft_fonts.css";
|
||||
@import 'variables.css';
|
||||
@import 'scrollbar.css';
|
||||
@import 'styles.css';
|
||||
3
frontend/src/assets/styles/styles.css
Normal file
3
frontend/src/assets/styles/styles.css
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
@@ -1,255 +1,266 @@
|
||||
:root {
|
||||
/* 编辑器区域 */
|
||||
--text-primary: #9BB586; /* 内容区域字体颜色 */
|
||||
|
||||
/* 深色主题颜色变量 */
|
||||
--dark-toolbar-bg: #2d2d2d;
|
||||
--dark-toolbar-border: #404040;
|
||||
--dark-toolbar-text: #ffffff;
|
||||
--dark-toolbar-text-secondary: #cccccc;
|
||||
--dark-toolbar-button-hover: #404040;
|
||||
--dark-tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
|
||||
--dark-bg-secondary: #0E1217;
|
||||
--dark-text-secondary: #a0aec0;
|
||||
--dark-text-muted: #666;
|
||||
--dark-border-color: #2d3748;
|
||||
--dark-settings-bg: #2a2a2a;
|
||||
--dark-settings-card-bg: #333333;
|
||||
--dark-settings-text: #ffffff;
|
||||
--dark-settings-text-secondary: #cccccc;
|
||||
--dark-settings-border: #444444;
|
||||
--dark-settings-input-bg: #3a3a3a;
|
||||
--dark-settings-input-border: #555555;
|
||||
--dark-settings-hover: #404040;
|
||||
--dark-scrollbar-track: #2a2a2a;
|
||||
--dark-scrollbar-thumb: #555555;
|
||||
--dark-scrollbar-thumb-hover: #666666;
|
||||
--dark-selection-bg: rgba(181, 206, 168, 0.1);
|
||||
--dark-selection-text: #b5cea8;
|
||||
--dark-danger-color: #ff6b6b;
|
||||
--dark-bg-primary: #1a1a1a;
|
||||
--dark-bg-hover: #2a2a2a;
|
||||
--dark-loading-bg-gradient: radial-gradient(#222922, #000500);
|
||||
--dark-loading-color: #fff;
|
||||
--dark-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
|
||||
--dark-loading-done-color: #6f6;
|
||||
--dark-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
|
||||
|
||||
/* 浅色主题颜色变量 */
|
||||
--light-toolbar-bg: #f8f9fa;
|
||||
--light-toolbar-border: #e9ecef;
|
||||
--light-toolbar-text: #212529;
|
||||
--light-toolbar-text-secondary: #495057;
|
||||
--light-toolbar-button-hover: #e9ecef;
|
||||
--light-tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--light-bg-secondary: #f7fef7;
|
||||
--light-text-secondary: #374151;
|
||||
--light-text-muted: #6b7280;
|
||||
--light-border-color: #e5e7eb;
|
||||
--light-settings-bg: #ffffff;
|
||||
--light-settings-card-bg: #f8f9fa;
|
||||
--light-settings-text: #212529;
|
||||
--light-settings-text-secondary: #6c757d;
|
||||
--light-settings-border: #dee2e6;
|
||||
--light-settings-input-bg: #ffffff;
|
||||
--light-settings-input-border: #ced4da;
|
||||
--light-settings-hover: #e9ecef;
|
||||
--light-scrollbar-track: #f1f3f4;
|
||||
--light-scrollbar-thumb: #c1c1c1;
|
||||
--light-scrollbar-thumb-hover: #a8a8a8;
|
||||
--light-selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--light-selection-text: #2563eb;
|
||||
--light-danger-color: #dc3545;
|
||||
--light-bg-primary: #ffffff;
|
||||
--light-bg-hover: #f1f3f4;
|
||||
--light-loading-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--light-loading-color: #1a3c1a;
|
||||
--light-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--light-loading-done-color: #008800;
|
||||
--light-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* 默认使用深色主题 */
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
--voidraft-mono-font: "HarmonyOS Sans Mono", monospace;
|
||||
|
||||
color-scheme: light dark;
|
||||
--voidraft-font-mono: "HarmonyOS", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
/* 监听系统深色主题 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme="auto"] {
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
}
|
||||
/* 默认/暗色主题 */
|
||||
:root,
|
||||
:root[data-theme="dark"],
|
||||
:root[data-theme="auto"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--text-primary: #ffffff;
|
||||
|
||||
--toolbar-bg: #2d2d2d;
|
||||
--toolbar-border: #404040;
|
||||
--toolbar-text: #ffffff;
|
||||
--toolbar-text-secondary: #cccccc;
|
||||
--toolbar-button-hover: #404040;
|
||||
--toolbar-separator: #404040;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #007acc 0%, #0099ff 100%);
|
||||
--bg-secondary: #0e1217;
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-hover: #2a2a2a;
|
||||
|
||||
--text-secondary: #a0aec0;
|
||||
--text-muted: #666666;
|
||||
--text-danger: #ff6b6b;
|
||||
|
||||
--border-color: #2d3748;
|
||||
|
||||
--settings-bg: #2a2a2a;
|
||||
--settings-card-bg: #333333;
|
||||
--settings-text: #ffffff;
|
||||
--settings-text-secondary: #cccccc;
|
||||
--settings-border: #444444;
|
||||
--settings-input-bg: #3a3a3a;
|
||||
--settings-input-border: #555555;
|
||||
--settings-hover: #404040;
|
||||
|
||||
--scrollbar-track: #2a2a2a;
|
||||
--scrollbar-thumb: #555555;
|
||||
--scrollbar-thumb-hover: #666666;
|
||||
|
||||
--selection-bg: rgba(181, 206, 168, 0.1);
|
||||
--selection-text: #b5cea8;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#222922, #000500);
|
||||
--voidraft-loading-color: #ffffff;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5);
|
||||
--voidraft-loading-done-color: #66ff66;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 暗色主题 */
|
||||
--cm-codeblock-bg: rgba(46, 51, 69, 0.8);
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(28% 0.02 255);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(250, 204, 21, 0.35);
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 监听系统浅色主题 */
|
||||
/* 亮色主题 */
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--text-primary: #000000;
|
||||
|
||||
--toolbar-bg: #f8f9fa;
|
||||
--toolbar-border: #e9ecef;
|
||||
--toolbar-text: #212529;
|
||||
--toolbar-text-secondary: #495057;
|
||||
--toolbar-button-hover: #e9ecef;
|
||||
--toolbar-separator: #e9ecef;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--bg-secondary: #f7fef7;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-hover: #f1f3f4;
|
||||
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--text-danger: #dc3545;
|
||||
|
||||
--border-color: #e5e7eb;
|
||||
|
||||
--settings-bg: #ffffff;
|
||||
--settings-card-bg: #f8f9fa;
|
||||
--settings-text: #212529;
|
||||
--settings-text-secondary: #6c757d;
|
||||
--settings-border: #dee2e6;
|
||||
--settings-input-bg: #ffffff;
|
||||
--settings-input-border: #ced4da;
|
||||
--settings-hover: #e9ecef;
|
||||
|
||||
--scrollbar-track: #f1f3f4;
|
||||
--scrollbar-thumb: #c1c1c1;
|
||||
--scrollbar-thumb-hover: #a8a8a8;
|
||||
|
||||
--selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--selection-text: #2563eb;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--voidraft-loading-color: #1a3c1a;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--voidraft-loading-done-color: #008800;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 亮色主题 */
|
||||
--cm-codeblock-bg: #f3f3f3;
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 跟随系统的浅色偏好 */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-theme="auto"] {
|
||||
--toolbar-bg: var(--light-toolbar-bg);
|
||||
--toolbar-border: var(--light-toolbar-border);
|
||||
--toolbar-text: var(--light-toolbar-text);
|
||||
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--light-toolbar-button-hover);
|
||||
--toolbar-separator: var(--light-toolbar-button-hover);
|
||||
--tab-active-line: var(--light-tab-active-line);
|
||||
--bg-secondary: var(--light-bg-secondary);
|
||||
--text-secondary: var(--light-text-secondary);
|
||||
--text-muted: var(--light-text-muted);
|
||||
--border-color: var(--light-border-color);
|
||||
--settings-bg: var(--light-settings-bg);
|
||||
--settings-card-bg: var(--light-settings-card-bg);
|
||||
--settings-text: var(--light-settings-text);
|
||||
--settings-text-secondary: var(--light-settings-text-secondary);
|
||||
--settings-border: var(--light-settings-border);
|
||||
--settings-input-bg: var(--light-settings-input-bg);
|
||||
--settings-input-border: var(--light-settings-input-border);
|
||||
--settings-hover: var(--light-settings-hover);
|
||||
--scrollbar-track: var(--light-scrollbar-track);
|
||||
--scrollbar-thumb: var(--light-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--light-selection-bg);
|
||||
--selection-text: var(--light-selection-text);
|
||||
--text-danger: var(--light-danger-color);
|
||||
--bg-primary: var(--light-bg-primary);
|
||||
--bg-hover: var(--light-bg-hover);
|
||||
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--light-loading-color);
|
||||
--voidraft-loading-glow: var(--light-loading-glow);
|
||||
--voidraft-loading-done-color: var(--light-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--light-loading-overlay);
|
||||
color-scheme: light;
|
||||
|
||||
--text-primary: #000000;
|
||||
|
||||
--toolbar-bg: #f8f9fa;
|
||||
--toolbar-border: #e9ecef;
|
||||
--toolbar-text: #212529;
|
||||
--toolbar-text-secondary: #495057;
|
||||
--toolbar-button-hover: #e9ecef;
|
||||
--toolbar-separator: #e9ecef;
|
||||
|
||||
--tab-active-line: linear-gradient(90deg, #0066cc 0%, #0088ff 100%);
|
||||
--bg-secondary: #f7fef7;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-hover: #f1f3f4;
|
||||
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--text-danger: #dc3545;
|
||||
|
||||
--border-color: #e5e7eb;
|
||||
|
||||
--settings-bg: #ffffff;
|
||||
--settings-card-bg: #f8f9fa;
|
||||
--settings-text: #212529;
|
||||
--settings-text-secondary: #6c757d;
|
||||
--settings-border: #dee2e6;
|
||||
--settings-input-bg: #ffffff;
|
||||
--settings-input-border: #ced4da;
|
||||
--settings-hover: #e9ecef;
|
||||
|
||||
--scrollbar-track: #f1f3f4;
|
||||
--scrollbar-thumb: #c1c1c1;
|
||||
--scrollbar-thumb-hover: #a8a8a8;
|
||||
|
||||
--selection-bg: rgba(59, 130, 246, 0.15);
|
||||
--selection-text: #2563eb;
|
||||
|
||||
--voidraft-bg-gradient: radial-gradient(#f0f6f0, #e5efe5);
|
||||
--voidraft-loading-color: #1a3c1a;
|
||||
--voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2);
|
||||
--voidraft-loading-done-color: #008800;
|
||||
--voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%);
|
||||
|
||||
/* Markdown 代码块样式 - 亮色主题 */
|
||||
--cm-codeblock-bg: oklch(92.9% 0.013 255.508);
|
||||
--cm-codeblock-radius: 0.4rem;
|
||||
|
||||
/* Markdown 内联代码样式 */
|
||||
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
|
||||
|
||||
/* Markdown 上标/下标样式 */
|
||||
--cm-superscript-color: inherit;
|
||||
--cm-subscript-color: inherit;
|
||||
|
||||
/* Markdown 高亮样式 */
|
||||
--cm-highlight-background: rgba(253, 224, 71, 0.45);
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手动选择浅色主题 */
|
||||
:root[data-theme="light"] {
|
||||
--toolbar-bg: var(--light-toolbar-bg);
|
||||
--toolbar-border: var(--light-toolbar-border);
|
||||
--toolbar-text: var(--light-toolbar-text);
|
||||
--toolbar-text-secondary: var(--light-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--light-toolbar-button-hover);
|
||||
--toolbar-separator: var(--light-toolbar-button-hover);
|
||||
--tab-active-line: var(--light-tab-active-line);
|
||||
--bg-secondary: var(--light-bg-secondary);
|
||||
--text-secondary: var(--light-text-secondary);
|
||||
--text-muted: var(--light-text-muted);
|
||||
--border-color: var(--light-border-color);
|
||||
--settings-bg: var(--light-settings-bg);
|
||||
--settings-card-bg: var(--light-settings-card-bg);
|
||||
--settings-text: var(--light-settings-text);
|
||||
--settings-text-secondary: var(--light-settings-text-secondary);
|
||||
--settings-border: var(--light-settings-border);
|
||||
--settings-input-bg: var(--light-settings-input-bg);
|
||||
--settings-input-border: var(--light-settings-input-border);
|
||||
--settings-hover: var(--light-settings-hover);
|
||||
--scrollbar-track: var(--light-scrollbar-track);
|
||||
--scrollbar-thumb: var(--light-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--light-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--light-selection-bg);
|
||||
--selection-text: var(--light-selection-text);
|
||||
--text-danger: var(--light-danger-color);
|
||||
--bg-primary: var(--light-bg-primary);
|
||||
--bg-hover: var(--light-bg-hover);
|
||||
--voidraft-bg-gradient: var(--light-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--light-loading-color);
|
||||
--voidraft-loading-glow: var(--light-loading-glow);
|
||||
--voidraft-loading-done-color: var(--light-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--light-loading-overlay);
|
||||
}
|
||||
|
||||
/* 手动选择深色主题 */
|
||||
:root[data-theme="dark"] {
|
||||
--toolbar-bg: var(--dark-toolbar-bg);
|
||||
--toolbar-border: var(--dark-toolbar-border);
|
||||
--toolbar-text: var(--dark-toolbar-text);
|
||||
--toolbar-text-secondary: var(--dark-toolbar-text-secondary);
|
||||
--toolbar-button-hover: var(--dark-toolbar-button-hover);
|
||||
--toolbar-separator: var(--dark-toolbar-button-hover);
|
||||
--tab-active-line: var(--dark-tab-active-line);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
--settings-bg: var(--dark-settings-bg);
|
||||
--settings-card-bg: var(--dark-settings-card-bg);
|
||||
--settings-text: var(--dark-settings-text);
|
||||
--settings-text-secondary: var(--dark-settings-text-secondary);
|
||||
--settings-border: var(--dark-settings-border);
|
||||
--settings-input-bg: var(--dark-settings-input-bg);
|
||||
--settings-input-border: var(--dark-settings-input-border);
|
||||
--settings-hover: var(--dark-settings-hover);
|
||||
--scrollbar-track: var(--dark-scrollbar-track);
|
||||
--scrollbar-thumb: var(--dark-scrollbar-thumb);
|
||||
--scrollbar-thumb-hover: var(--dark-scrollbar-thumb-hover);
|
||||
--selection-bg: var(--dark-selection-bg);
|
||||
--selection-text: var(--dark-selection-text);
|
||||
--text-danger: var(--dark-danger-color);
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-hover: var(--dark-bg-hover);
|
||||
--voidraft-bg-gradient: var(--dark-loading-bg-gradient);
|
||||
--voidraft-loading-color: var(--dark-loading-color);
|
||||
--voidraft-loading-glow: var(--dark-loading-glow);
|
||||
--voidraft-loading-done-color: var(--dark-loading-done-color);
|
||||
--voidraft-loading-overlay: var(--dark-loading-overlay);
|
||||
}
|
||||
@@ -1,43 +1,19 @@
|
||||
import {
|
||||
AppConfig,
|
||||
AppearanceConfig,
|
||||
EditingConfig,
|
||||
GeneralConfig,
|
||||
AuthMethod,
|
||||
LanguageType,
|
||||
SystemThemeType,
|
||||
TabType,
|
||||
UpdatesConfig,
|
||||
UpdateSourceType,
|
||||
GitBackupConfig,
|
||||
AuthMethod
|
||||
UpdateSourceType
|
||||
} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {FONT_OPTIONS} from './fonts';
|
||||
|
||||
// 配置键映射和限制的类型定义
|
||||
export type GeneralConfigKeyMap = {
|
||||
readonly [K in keyof GeneralConfig]: string;
|
||||
};
|
||||
|
||||
export type EditingConfigKeyMap = {
|
||||
readonly [K in keyof EditingConfig]: string;
|
||||
};
|
||||
|
||||
export type AppearanceConfigKeyMap = {
|
||||
readonly [K in keyof AppearanceConfig]: string;
|
||||
};
|
||||
|
||||
export type UpdatesConfigKeyMap = {
|
||||
readonly [K in keyof UpdatesConfig]: string;
|
||||
};
|
||||
|
||||
export type BackupConfigKeyMap = {
|
||||
readonly [K in keyof GitBackupConfig]: string;
|
||||
};
|
||||
|
||||
export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight';
|
||||
export type ConfigSection = 'general' | 'editing' | 'appearance' | 'updates' | 'backup';
|
||||
|
||||
// 配置键映射
|
||||
export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
|
||||
// 统一配置键映射(平级展开)
|
||||
export const CONFIG_KEY_MAP = {
|
||||
// general
|
||||
alwaysOnTop: 'general.alwaysOnTop',
|
||||
dataPath: 'general.dataPath',
|
||||
enableSystemTray: 'general.enableSystemTray',
|
||||
@@ -47,9 +23,7 @@ export const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
|
||||
enableWindowSnap: 'general.enableWindowSnap',
|
||||
enableLoadingAnimation: 'general.enableLoadingAnimation',
|
||||
enableTabs: 'general.enableTabs',
|
||||
} as const;
|
||||
|
||||
export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
||||
// editing
|
||||
fontSize: 'editing.fontSize',
|
||||
fontFamily: 'editing.fontFamily',
|
||||
fontWeight: 'editing.fontWeight',
|
||||
@@ -57,16 +31,12 @@ export const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
|
||||
enableTabIndent: 'editing.enableTabIndent',
|
||||
tabSize: 'editing.tabSize',
|
||||
tabType: 'editing.tabType',
|
||||
autoSaveDelay: 'editing.autoSaveDelay'
|
||||
} as const;
|
||||
|
||||
export const APPEARANCE_CONFIG_KEY_MAP: AppearanceConfigKeyMap = {
|
||||
autoSaveDelay: 'editing.autoSaveDelay',
|
||||
// appearance
|
||||
language: 'appearance.language',
|
||||
systemTheme: 'appearance.systemTheme',
|
||||
currentTheme: 'appearance.currentTheme'
|
||||
} as const;
|
||||
|
||||
export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
||||
currentTheme: 'appearance.currentTheme',
|
||||
// updates
|
||||
version: 'updates.version',
|
||||
autoUpdate: 'updates.autoUpdate',
|
||||
primarySource: 'updates.primarySource',
|
||||
@@ -74,10 +44,8 @@ export const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
|
||||
backupBeforeUpdate: 'updates.backupBeforeUpdate',
|
||||
updateTimeout: 'updates.updateTimeout',
|
||||
github: 'updates.github',
|
||||
gitea: 'updates.gitea'
|
||||
} as const;
|
||||
|
||||
export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
||||
gitea: 'updates.gitea',
|
||||
// backup
|
||||
enabled: 'backup.enabled',
|
||||
repo_url: 'backup.repo_url',
|
||||
auth_method: 'backup.auth_method',
|
||||
@@ -90,6 +58,8 @@ export const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
|
||||
auto_backup: 'backup.auto_backup',
|
||||
} as const;
|
||||
|
||||
export type ConfigKey = keyof typeof CONFIG_KEY_MAP;
|
||||
|
||||
// 配置限制
|
||||
export const CONFIG_LIMITS = {
|
||||
fontSize: {min: 12, max: 28, default: 13},
|
||||
|
||||
1945
frontend/src/common/constant/emojies.ts
Normal file
1945
frontend/src/common/constant/emojies.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* 默认翻译配置
|
||||
*/
|
||||
export const DEFAULT_TRANSLATION_CONFIG = {
|
||||
minSelectionLength: 2,
|
||||
maxTranslationLength: 5000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 翻译相关的错误消息
|
||||
*/
|
||||
export const TRANSLATION_ERRORS = {
|
||||
NO_TEXT: 'no text to translate',
|
||||
TRANSLATION_FAILED: 'translation failed',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 翻译结果接口
|
||||
*/
|
||||
export interface TranslationResult {
|
||||
translatedText: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言信息接口
|
||||
*/
|
||||
export interface LanguageInfo {
|
||||
Code: string; // 语言代码
|
||||
Name: string; // 语言名称
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译器扩展配置
|
||||
*/
|
||||
export interface TranslatorConfig {
|
||||
/** 最小选择字符数才显示翻译按钮 */
|
||||
minSelectionLength: number;
|
||||
/** 最大翻译字符数 */
|
||||
maxTranslationLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译图标SVG
|
||||
*/
|
||||
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>`;
|
||||
@@ -1,159 +0,0 @@
|
||||
// Enclose abbreviations in <abbr> tags
|
||||
//
|
||||
import MarkdownIt, {StateBlock, StateCore, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 环境接口,包含缩写定义
|
||||
*/
|
||||
interface AbbrEnv {
|
||||
abbreviations?: { [key: string]: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-abbr 插件
|
||||
* 用于支持缩写语法
|
||||
*/
|
||||
export default function abbr_plugin(md: MarkdownIt): void {
|
||||
const escapeRE = md.utils.escapeRE;
|
||||
const arrayReplaceAt = md.utils.arrayReplaceAt;
|
||||
|
||||
// ASCII characters in Cc, Sc, Sm, Sk categories we should terminate on;
|
||||
// you can check character classes here:
|
||||
// http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
|
||||
const OTHER_CHARS = ' \r\n$+<=>^`|~';
|
||||
|
||||
const UNICODE_PUNCT_RE = md.utils.lib.ucmicro.P.source;
|
||||
const UNICODE_SPACE_RE = md.utils.lib.ucmicro.Z.source;
|
||||
|
||||
function abbr_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
let labelEnd: number;
|
||||
let pos = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
|
||||
if (pos + 2 >= max) { return false; }
|
||||
|
||||
if (state.src.charCodeAt(pos++) !== 0x2A/* * */) { return false; }
|
||||
if (state.src.charCodeAt(pos++) !== 0x5B/* [ */) { return false; }
|
||||
|
||||
const labelStart = pos;
|
||||
|
||||
for (; pos < max; pos++) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
if (ch === 0x5B /* [ */) {
|
||||
return false;
|
||||
} else if (ch === 0x5D /* ] */) {
|
||||
labelEnd = pos;
|
||||
break;
|
||||
} else if (ch === 0x5C /* \ */) {
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
if (labelEnd! < 0 || state.src.charCodeAt(labelEnd! + 1) !== 0x3A/* : */) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (silent) { return true; }
|
||||
|
||||
const label = state.src.slice(labelStart, labelEnd!).replace(/\\(.)/g, '$1');
|
||||
const title = state.src.slice(labelEnd! + 2, max).trim();
|
||||
if (label.length === 0) { return false; }
|
||||
if (title.length === 0) { return false; }
|
||||
|
||||
const env = state.env as AbbrEnv;
|
||||
if (!env.abbreviations) { env.abbreviations = {}; }
|
||||
// prepend ':' to avoid conflict with Object.prototype members
|
||||
if (typeof env.abbreviations[':' + label] === 'undefined') {
|
||||
env.abbreviations[':' + label] = title;
|
||||
}
|
||||
|
||||
state.line = startLine + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function abbr_replace(state: StateCore): void {
|
||||
const blockTokens = state.tokens;
|
||||
|
||||
const env = state.env as AbbrEnv;
|
||||
if (!env.abbreviations) { return; }
|
||||
|
||||
const regSimple = new RegExp('(?:' +
|
||||
Object.keys(env.abbreviations).map(function (x: string) {
|
||||
return x.substr(1);
|
||||
}).sort(function (a: string, b: string) {
|
||||
return b.length - a.length;
|
||||
}).map(escapeRE).join('|') +
|
||||
')');
|
||||
|
||||
const regText = '(^|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
|
||||
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])' +
|
||||
'(' + Object.keys(env.abbreviations).map(function (x: string) {
|
||||
return x.substr(1);
|
||||
}).sort(function (a: string, b: string) {
|
||||
return b.length - a.length;
|
||||
}).map(escapeRE).join('|') + ')' +
|
||||
'($|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
|
||||
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])'
|
||||
|
||||
const reg = new RegExp(regText, 'g');
|
||||
|
||||
for (let j = 0, l = blockTokens.length; j < l; j++) {
|
||||
if (blockTokens[j].type !== 'inline') { continue; }
|
||||
let tokens = blockTokens[j].children!;
|
||||
|
||||
// We scan from the end, to keep position when new tags added.
|
||||
for (let i = tokens.length - 1; i >= 0; i--) {
|
||||
const currentToken = tokens[i];
|
||||
if (currentToken.type !== 'text') { continue; }
|
||||
|
||||
let pos = 0;
|
||||
const text = currentToken.content;
|
||||
reg.lastIndex = 0;
|
||||
const nodes: Token[] = [];
|
||||
|
||||
// fast regexp run to determine whether there are any abbreviated words
|
||||
// in the current token
|
||||
if (!regSimple.test(text)) { continue; }
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = reg.exec(text))) {
|
||||
if (m.index > 0 || m[1].length > 0) {
|
||||
const token = new state.Token('text', '', 0);
|
||||
token.content = text.slice(pos, m.index + m[1].length);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
const token_o = new state.Token('abbr_open', 'abbr', 1);
|
||||
token_o.attrs = [['title', env.abbreviations[':' + m[2]]]];
|
||||
nodes.push(token_o);
|
||||
|
||||
const token_t = new state.Token('text', '', 0);
|
||||
token_t.content = m[2];
|
||||
nodes.push(token_t);
|
||||
|
||||
const token_c = new state.Token('abbr_close', 'abbr', -1);
|
||||
nodes.push(token_c);
|
||||
|
||||
reg.lastIndex -= m[3].length;
|
||||
pos = reg.lastIndex;
|
||||
}
|
||||
|
||||
if (!nodes.length) { continue; }
|
||||
|
||||
if (pos < text.length) {
|
||||
const token = new state.Token('text', '', 0);
|
||||
token.content = text.slice(pos);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
// replace current node
|
||||
blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.block.ruler.before('reference', 'abbr_def', abbr_def, { alt: ['paragraph', 'reference'] });
|
||||
|
||||
md.core.ruler.after('linkify', 'abbr_replace', abbr_replace);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
// Process definition lists
|
||||
//
|
||||
import MarkdownIt, { StateBlock, Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* markdown-it-deflist 插件
|
||||
* 用于支持定义列表语法
|
||||
*/
|
||||
export default function deflist_plugin(md: MarkdownIt): void {
|
||||
const isSpace = md.utils.isSpace;
|
||||
|
||||
// Search `[:~][\n ]`, returns next pos after marker on success
|
||||
// or -1 on fail.
|
||||
function skipMarker(state: StateBlock, line: number): number {
|
||||
let start = state.bMarks[line] + state.tShift[line];
|
||||
const max = state.eMarks[line];
|
||||
|
||||
if (start >= max) { return -1; }
|
||||
|
||||
// Check bullet
|
||||
const marker = state.src.charCodeAt(start++);
|
||||
if (marker !== 0x7E/* ~ */ && marker !== 0x3A/* : */) { return -1; }
|
||||
|
||||
const pos = state.skipSpaces(start);
|
||||
|
||||
// require space after ":"
|
||||
if (start === pos) { return -1; }
|
||||
|
||||
// no empty definitions, e.g. " : "
|
||||
if (pos >= max) { return -1; }
|
||||
|
||||
return start;
|
||||
}
|
||||
|
||||
function markTightParagraphs(state: StateBlock, idx: number): void {
|
||||
const level = state.level + 2;
|
||||
|
||||
for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) {
|
||||
if (state.tokens[i].level === level && state.tokens[i].type === 'paragraph_open') {
|
||||
state.tokens[i + 2].hidden = true;
|
||||
state.tokens[i].hidden = true;
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deflist(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
if (silent) {
|
||||
// quirk: validation mode validates a dd block only, not a whole deflist
|
||||
if (state.ddIndent < 0) { return false; }
|
||||
return skipMarker(state, startLine) >= 0;
|
||||
}
|
||||
|
||||
let nextLine = startLine + 1;
|
||||
if (nextLine >= endLine) { return false; }
|
||||
|
||||
if (state.isEmpty(nextLine)) {
|
||||
nextLine++;
|
||||
if (nextLine >= endLine) { return false; }
|
||||
}
|
||||
|
||||
if (state.sCount[nextLine] < state.blkIndent) { return false; }
|
||||
let contentStart = skipMarker(state, nextLine);
|
||||
if (contentStart < 0) { return false; }
|
||||
|
||||
// Start list
|
||||
const listTokIdx = state.tokens.length;
|
||||
let tight = true;
|
||||
|
||||
const token_dl_o: Token = state.push('dl_open', 'dl', 1);
|
||||
const listLines: [number, number] = [startLine, 0];
|
||||
token_dl_o.map = listLines;
|
||||
|
||||
//
|
||||
// Iterate list items
|
||||
//
|
||||
|
||||
let dtLine = startLine;
|
||||
let ddLine = nextLine;
|
||||
|
||||
// One definition list can contain multiple DTs,
|
||||
// and one DT can be followed by multiple DDs.
|
||||
//
|
||||
// Thus, there is two loops here, and label is
|
||||
// needed to break out of the second one
|
||||
//
|
||||
/* eslint no-labels:0,block-scoped-var:0 */
|
||||
OUTER:
|
||||
for (;;) {
|
||||
let prevEmptyEnd = false;
|
||||
|
||||
const token_dt_o: Token = state.push('dt_open', 'dt', 1);
|
||||
token_dt_o.map = [dtLine, dtLine];
|
||||
|
||||
const token_i: Token = state.push('inline', '', 0);
|
||||
token_i.map = [dtLine, dtLine];
|
||||
token_i.content = state.getLines(dtLine, dtLine + 1, state.blkIndent, false).trim();
|
||||
token_i.children = [];
|
||||
|
||||
state.push('dt_close', 'dt', -1);
|
||||
|
||||
for (;;) {
|
||||
const token_dd_o: Token = state.push('dd_open', 'dd', 1);
|
||||
const itemLines: [number, number] = [nextLine, 0];
|
||||
token_dd_o.map = itemLines;
|
||||
|
||||
let pos = contentStart;
|
||||
const max = state.eMarks[ddLine];
|
||||
let offset = state.sCount[ddLine] + contentStart - (state.bMarks[ddLine] + state.tShift[ddLine]);
|
||||
|
||||
while (pos < max) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
|
||||
if (isSpace(ch)) {
|
||||
if (ch === 0x09) {
|
||||
offset += 4 - offset % 4;
|
||||
} else {
|
||||
offset++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
contentStart = pos;
|
||||
|
||||
const oldTight = state.tight;
|
||||
const oldDDIndent = state.ddIndent;
|
||||
const oldIndent = state.blkIndent;
|
||||
const oldTShift = state.tShift[ddLine];
|
||||
const oldSCount = state.sCount[ddLine];
|
||||
const oldParentType = state.parentType;
|
||||
state.blkIndent = state.ddIndent = state.sCount[ddLine] + 2;
|
||||
state.tShift[ddLine] = contentStart - state.bMarks[ddLine];
|
||||
state.sCount[ddLine] = offset;
|
||||
state.tight = true;
|
||||
state.parentType = 'deflist' as any;
|
||||
|
||||
state.md.block.tokenize(state, ddLine, endLine);
|
||||
|
||||
// If any of list item is tight, mark list as tight
|
||||
if (!state.tight || prevEmptyEnd) {
|
||||
tight = false;
|
||||
}
|
||||
// Item become loose if finish with empty line,
|
||||
// but we should filter last element, because it means list finish
|
||||
prevEmptyEnd = (state.line - ddLine) > 1 && state.isEmpty(state.line - 1);
|
||||
|
||||
state.tShift[ddLine] = oldTShift;
|
||||
state.sCount[ddLine] = oldSCount;
|
||||
state.tight = oldTight;
|
||||
state.parentType = oldParentType;
|
||||
state.blkIndent = oldIndent;
|
||||
state.ddIndent = oldDDIndent;
|
||||
|
||||
state.push('dd_close', 'dd', -1);
|
||||
|
||||
itemLines[1] = nextLine = state.line;
|
||||
|
||||
if (nextLine >= endLine) { break OUTER; }
|
||||
|
||||
if (state.sCount[nextLine] < state.blkIndent) { break OUTER; }
|
||||
contentStart = skipMarker(state, nextLine);
|
||||
if (contentStart < 0) { break; }
|
||||
|
||||
ddLine = nextLine;
|
||||
|
||||
// go to the next loop iteration:
|
||||
// insert DD tag and repeat checking
|
||||
}
|
||||
|
||||
if (nextLine >= endLine) { break; }
|
||||
dtLine = nextLine;
|
||||
|
||||
if (state.isEmpty(dtLine)) { break; }
|
||||
if (state.sCount[dtLine] < state.blkIndent) { break; }
|
||||
|
||||
ddLine = dtLine + 1;
|
||||
if (ddLine >= endLine) { break; }
|
||||
if (state.isEmpty(ddLine)) { ddLine++; }
|
||||
if (ddLine >= endLine) { break; }
|
||||
|
||||
if (state.sCount[ddLine] < state.blkIndent) { break; }
|
||||
contentStart = skipMarker(state, ddLine);
|
||||
if (contentStart < 0) { break; }
|
||||
|
||||
// go to the next loop iteration:
|
||||
// insert DT and DD tags and repeat checking
|
||||
}
|
||||
|
||||
// Finilize list
|
||||
state.push('dl_close', 'dl', -1);
|
||||
|
||||
listLines[1] = nextLine;
|
||||
|
||||
state.line = nextLine;
|
||||
|
||||
// mark paragraphs tight if needed
|
||||
if (tight) {
|
||||
markTightParagraphs(state, listTokIdx);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
md.block.ruler.before('paragraph', 'deflist', deflist, { alt: ['paragraph', 'reference', 'blockquote'] });
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as bare } from './lib/bare';
|
||||
export { default as light } from './lib/light';
|
||||
export { default as full } from './lib/full';
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emoji_html from './render';
|
||||
import emoji_replace from './replace';
|
||||
import normalize_opts, { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Bare emoji 插件(不包含预定义的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: {},
|
||||
shortcuts: {},
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = normalize_opts(md.utils.assign({}, defaults, options || {}) as EmojiOptions);
|
||||
|
||||
md.renderer.rules.emoji = emoji_html;
|
||||
|
||||
md.core.ruler.after(
|
||||
'linkify',
|
||||
'emoji',
|
||||
emoji_replace(md, opts.defs, opts.shortcuts, opts.scanRE, opts.replaceRE)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,158 +0,0 @@
|
||||
// Generated, don't edit
|
||||
import { EmojiDefs } from '../normalize_opts';
|
||||
|
||||
const emojies: EmojiDefs = {
|
||||
"grinning": "😀",
|
||||
"smiley": "😃",
|
||||
"smile": "😄",
|
||||
"grin": "😁",
|
||||
"laughing": "😆",
|
||||
"satisfied": "😆",
|
||||
"sweat_smile": "😅",
|
||||
"joy": "😂",
|
||||
"wink": "😉",
|
||||
"blush": "😊",
|
||||
"innocent": "😇",
|
||||
"heart_eyes": "😍",
|
||||
"kissing_heart": "😘",
|
||||
"kissing": "😗",
|
||||
"kissing_closed_eyes": "😚",
|
||||
"kissing_smiling_eyes": "😙",
|
||||
"yum": "😋",
|
||||
"stuck_out_tongue": "😛",
|
||||
"stuck_out_tongue_winking_eye": "😜",
|
||||
"stuck_out_tongue_closed_eyes": "😝",
|
||||
"neutral_face": "😐",
|
||||
"expressionless": "😑",
|
||||
"no_mouth": "😶",
|
||||
"smirk": "😏",
|
||||
"unamused": "😒",
|
||||
"relieved": "😌",
|
||||
"pensive": "😔",
|
||||
"sleepy": "😪",
|
||||
"sleeping": "😴",
|
||||
"mask": "😷",
|
||||
"dizzy_face": "😵",
|
||||
"sunglasses": "😎",
|
||||
"confused": "😕",
|
||||
"worried": "😟",
|
||||
"open_mouth": "😮",
|
||||
"hushed": "😯",
|
||||
"astonished": "😲",
|
||||
"flushed": "😳",
|
||||
"frowning": "😦",
|
||||
"anguished": "😧",
|
||||
"fearful": "😨",
|
||||
"cold_sweat": "😰",
|
||||
"disappointed_relieved": "😥",
|
||||
"cry": "😢",
|
||||
"sob": "😭",
|
||||
"scream": "😱",
|
||||
"confounded": "😖",
|
||||
"persevere": "😣",
|
||||
"disappointed": "😞",
|
||||
"sweat": "😓",
|
||||
"weary": "😩",
|
||||
"tired_face": "😫",
|
||||
"rage": "😡",
|
||||
"pout": "😡",
|
||||
"angry": "😠",
|
||||
"smiling_imp": "😈",
|
||||
"smiley_cat": "😺",
|
||||
"smile_cat": "😸",
|
||||
"joy_cat": "😹",
|
||||
"heart_eyes_cat": "😻",
|
||||
"smirk_cat": "😼",
|
||||
"kissing_cat": "😽",
|
||||
"scream_cat": "🙀",
|
||||
"crying_cat_face": "😿",
|
||||
"pouting_cat": "😾",
|
||||
"heart": "❤️",
|
||||
"hand": "✋",
|
||||
"raised_hand": "✋",
|
||||
"v": "✌️",
|
||||
"point_up": "☝️",
|
||||
"fist_raised": "✊",
|
||||
"fist": "✊",
|
||||
"monkey_face": "🐵",
|
||||
"cat": "🐱",
|
||||
"cow": "🐮",
|
||||
"mouse": "🐭",
|
||||
"coffee": "☕",
|
||||
"hotsprings": "♨️",
|
||||
"anchor": "⚓",
|
||||
"airplane": "✈️",
|
||||
"hourglass": "⌛",
|
||||
"watch": "⌚",
|
||||
"sunny": "☀️",
|
||||
"star": "⭐",
|
||||
"cloud": "☁️",
|
||||
"umbrella": "☔",
|
||||
"zap": "⚡",
|
||||
"snowflake": "❄️",
|
||||
"sparkles": "✨",
|
||||
"black_joker": "🃏",
|
||||
"mahjong": "🀄",
|
||||
"phone": "☎️",
|
||||
"telephone": "☎️",
|
||||
"envelope": "✉️",
|
||||
"pencil2": "✏️",
|
||||
"black_nib": "✒️",
|
||||
"scissors": "✂️",
|
||||
"wheelchair": "♿",
|
||||
"warning": "⚠️",
|
||||
"aries": "♈",
|
||||
"taurus": "♉",
|
||||
"gemini": "♊",
|
||||
"cancer": "♋",
|
||||
"leo": "♌",
|
||||
"virgo": "♍",
|
||||
"libra": "♎",
|
||||
"scorpius": "♏",
|
||||
"sagittarius": "♐",
|
||||
"capricorn": "♑",
|
||||
"aquarius": "♒",
|
||||
"pisces": "♓",
|
||||
"heavy_multiplication_x": "✖️",
|
||||
"heavy_plus_sign": "➕",
|
||||
"heavy_minus_sign": "➖",
|
||||
"heavy_division_sign": "➗",
|
||||
"bangbang": "‼️",
|
||||
"interrobang": "⁉️",
|
||||
"question": "❓",
|
||||
"grey_question": "❔",
|
||||
"grey_exclamation": "❕",
|
||||
"exclamation": "❗",
|
||||
"heavy_exclamation_mark": "❗",
|
||||
"wavy_dash": "〰️",
|
||||
"recycle": "♻️",
|
||||
"white_check_mark": "✅",
|
||||
"ballot_box_with_check": "☑️",
|
||||
"heavy_check_mark": "✔️",
|
||||
"x": "❌",
|
||||
"negative_squared_cross_mark": "❎",
|
||||
"curly_loop": "➰",
|
||||
"loop": "➿",
|
||||
"part_alternation_mark": "〽️",
|
||||
"eight_spoked_asterisk": "✳️",
|
||||
"eight_pointed_black_star": "✴️",
|
||||
"sparkle": "❇️",
|
||||
"copyright": "©️",
|
||||
"registered": "®️",
|
||||
"tm": "™️",
|
||||
"information_source": "ℹ️",
|
||||
"m": "Ⓜ️",
|
||||
"black_circle": "⚫",
|
||||
"white_circle": "⚪",
|
||||
"black_large_square": "⬛",
|
||||
"white_large_square": "⬜",
|
||||
"black_medium_square": "◼️",
|
||||
"white_medium_square": "◻️",
|
||||
"black_medium_small_square": "◾",
|
||||
"white_medium_small_square": "◽",
|
||||
"black_small_square": "▪️",
|
||||
"white_small_square": "▫️"
|
||||
};
|
||||
|
||||
export default emojies;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Emoticons -> Emoji mapping.
|
||||
//
|
||||
// (!) Some patterns skipped, to avoid collisions
|
||||
// without increase matcher complicity. Than can change in future.
|
||||
//
|
||||
// Places to look for more emoticons info:
|
||||
//
|
||||
// - http://en.wikipedia.org/wiki/List_of_emoticons#Western
|
||||
// - https://github.com/wooorm/emoticon/blob/master/Support.md
|
||||
// - http://factoryjoe.com/projects/emoticons/
|
||||
//
|
||||
|
||||
import { EmojiShortcuts } from '../normalize_opts';
|
||||
|
||||
const shortcuts: EmojiShortcuts = {
|
||||
angry: ['>:(', '>:-('],
|
||||
blush: [':")', ':-")'],
|
||||
broken_heart: ['</3', '<\\3'],
|
||||
// :\ and :-\ not used because of conflict with markdown escaping
|
||||
confused: [':/', ':-/'], // twemoji shows question
|
||||
cry: [":'(", ":'-(", ':,(', ':,-('],
|
||||
frowning: [':(', ':-('],
|
||||
heart: ['<3'],
|
||||
imp: [']:(', ']:-('],
|
||||
innocent: ['o:)', 'O:)', 'o:-)', 'O:-)', '0:)', '0:-)'],
|
||||
joy: [":')", ":'-)", ':,)', ':,-)', ":'D", ":'-D", ':,D', ':,-D'],
|
||||
kissing: [':*', ':-*'],
|
||||
laughing: ['x-)', 'X-)'],
|
||||
neutral_face: [':|', ':-|'],
|
||||
open_mouth: [':o', ':-o', ':O', ':-O'],
|
||||
rage: [':@', ':-@'],
|
||||
smile: [':D', ':-D'],
|
||||
smiley: [':)', ':-)'],
|
||||
smiling_imp: [']:)', ']:-)'],
|
||||
sob: [":,'(", ":,'-(", ';(', ';-('],
|
||||
stuck_out_tongue: [':P', ':-P'],
|
||||
sunglasses: ['8-)', 'B-)'],
|
||||
sweat: [',:(', ',:-('],
|
||||
sweat_smile: [',:)', ',:-)'],
|
||||
unamused: [':s', ':-S', ':z', ':-Z', ':$', ':-$'],
|
||||
wink: [';)', ';-)']
|
||||
};
|
||||
|
||||
export default shortcuts;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emojies_defs from './data/full';
|
||||
import emojies_shortcuts from './data/shortcuts';
|
||||
import bare_emoji_plugin from './bare';
|
||||
import { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Full emoji 插件(包含完整的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: emojies_defs,
|
||||
shortcuts: emojies_shortcuts,
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
|
||||
|
||||
bare_emoji_plugin(md, opts);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import emojies_defs from './data/light';
|
||||
import emojies_shortcuts from './data/shortcuts';
|
||||
import bare_emoji_plugin from './bare';
|
||||
import { EmojiOptions } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Light emoji 插件(包含常用的 emoji 数据)
|
||||
*/
|
||||
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
|
||||
const defaults: EmojiOptions = {
|
||||
defs: emojies_defs,
|
||||
shortcuts: emojies_shortcuts,
|
||||
enabled: []
|
||||
};
|
||||
|
||||
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
|
||||
|
||||
bare_emoji_plugin(md, opts);
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Emoji 定义类型
|
||||
*/
|
||||
export interface EmojiDefs {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji 快捷方式类型
|
||||
*/
|
||||
export interface EmojiShortcuts {
|
||||
[key: string]: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入选项接口
|
||||
*/
|
||||
export interface EmojiOptions {
|
||||
defs: EmojiDefs;
|
||||
shortcuts: EmojiShortcuts;
|
||||
enabled: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化后的选项接口
|
||||
*/
|
||||
export interface NormalizedEmojiOptions {
|
||||
defs: EmojiDefs;
|
||||
shortcuts: { [key: string]: string };
|
||||
scanRE: RegExp;
|
||||
replaceRE: RegExp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义正则表达式特殊字符
|
||||
*/
|
||||
function quoteRE(str: string): string {
|
||||
return str.replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将输入选项转换为更可用的格式并编译搜索正则表达式
|
||||
*/
|
||||
export default function normalize_opts(options: EmojiOptions): NormalizedEmojiOptions {
|
||||
let emojies = options.defs;
|
||||
|
||||
// Filter emojies by whitelist, if needed
|
||||
if (options.enabled.length) {
|
||||
emojies = Object.keys(emojies).reduce((acc: EmojiDefs, key: string) => {
|
||||
if (options.enabled.indexOf(key) >= 0) acc[key] = emojies[key];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Flatten shortcuts to simple object: { alias: emoji_name }
|
||||
const shortcuts = Object.keys(options.shortcuts).reduce((acc: { [key: string]: string }, key: string) => {
|
||||
// Skip aliases for filtered emojies, to reduce regexp
|
||||
if (!emojies[key]) return acc;
|
||||
|
||||
if (Array.isArray(options.shortcuts[key])) {
|
||||
(options.shortcuts[key] as string[]).forEach((alias: string) => { acc[alias] = key; });
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[options.shortcuts[key] as string] = key;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const keys = Object.keys(emojies);
|
||||
let names: string;
|
||||
|
||||
// If no definitions are given, return empty regex to avoid replacements with 'undefined'.
|
||||
if (keys.length === 0) {
|
||||
names = '^$';
|
||||
} else {
|
||||
// Compile regexp
|
||||
names = keys
|
||||
.map((name: string) => { return `:${name}:`; })
|
||||
.concat(Object.keys(shortcuts))
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((name: string) => { return quoteRE(name); })
|
||||
.join('|');
|
||||
}
|
||||
const scanRE = RegExp(names);
|
||||
const replaceRE = RegExp(names, 'g');
|
||||
|
||||
return {
|
||||
defs: emojies,
|
||||
shortcuts,
|
||||
scanRE,
|
||||
replaceRE
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* Emoji 渲染函数
|
||||
*/
|
||||
export default function emoji_html(tokens: Token[], idx: number): string {
|
||||
return tokens[idx].content;
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import MarkdownIt, { StateCore, Token } from 'markdown-it';
|
||||
import { EmojiDefs } from './normalize_opts';
|
||||
|
||||
/**
|
||||
* Emoji 和快捷方式替换逻辑
|
||||
*
|
||||
* 注意:理论上,在内联链中解析 :smile: 并只留下快捷方式会更快。
|
||||
* 但是,谁在乎呢...
|
||||
*/
|
||||
export default function create_rule(
|
||||
md: MarkdownIt,
|
||||
emojies: EmojiDefs,
|
||||
shortcuts: { [key: string]: string },
|
||||
scanRE: RegExp,
|
||||
replaceRE: RegExp
|
||||
) {
|
||||
const arrayReplaceAt = md.utils.arrayReplaceAt;
|
||||
const ucm = md.utils.lib.ucmicro;
|
||||
const has = md.utils.has;
|
||||
const ZPCc = new RegExp([ucm.Z.source, ucm.P.source, ucm.Cc.source].join('|'));
|
||||
|
||||
function splitTextToken(text: string, level: number, TokenConstructor: any): Token[] {
|
||||
let last_pos = 0;
|
||||
const nodes: Token[] = [];
|
||||
|
||||
text.replace(replaceRE, function (match: string, offset: number, src: string): string {
|
||||
let emoji_name: string;
|
||||
// Validate emoji name
|
||||
if (has(shortcuts, match)) {
|
||||
// replace shortcut with full name
|
||||
emoji_name = shortcuts[match];
|
||||
|
||||
// Don't allow letters before any shortcut (as in no ":/" in http://)
|
||||
if (offset > 0 && !ZPCc.test(src[offset - 1])) return '';
|
||||
|
||||
// Don't allow letters after any shortcut
|
||||
if (offset + match.length < src.length && !ZPCc.test(src[offset + match.length])) {
|
||||
return '';
|
||||
}
|
||||
} else {
|
||||
emoji_name = match.slice(1, -1);
|
||||
}
|
||||
|
||||
// Add new tokens to pending list
|
||||
if (offset > last_pos) {
|
||||
const token = new TokenConstructor('text', '', 0);
|
||||
token.content = text.slice(last_pos, offset);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
const token = new TokenConstructor('emoji', '', 0);
|
||||
token.markup = emoji_name;
|
||||
token.content = emojies[emoji_name];
|
||||
nodes.push(token);
|
||||
|
||||
last_pos = offset + match.length;
|
||||
return '';
|
||||
});
|
||||
|
||||
if (last_pos < text.length) {
|
||||
const token = new TokenConstructor('text', '', 0);
|
||||
token.content = text.slice(last_pos);
|
||||
nodes.push(token);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return function emoji_replace(state: StateCore): void {
|
||||
let token: Token;
|
||||
const blockTokens = state.tokens;
|
||||
let autolinkLevel = 0;
|
||||
|
||||
for (let j = 0, l = blockTokens.length; j < l; j++) {
|
||||
if (blockTokens[j].type !== 'inline') { continue; }
|
||||
let tokens = blockTokens[j].children!;
|
||||
|
||||
// We scan from the end, to keep position when new tags added.
|
||||
// Use reversed logic in links start/end match
|
||||
for (let i = tokens.length - 1; i >= 0; i--) {
|
||||
token = tokens[i];
|
||||
|
||||
if (token.type === 'link_open' || token.type === 'link_close') {
|
||||
if (token.info === 'auto') { autolinkLevel -= token.nesting; }
|
||||
}
|
||||
|
||||
if (token.type === 'text' && autolinkLevel === 0 && scanRE.test(token.content)) {
|
||||
// replace current node
|
||||
blockTokens[j].children = tokens = arrayReplaceAt(
|
||||
tokens, i, splitTextToken(token.content, token.level, state.Token)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
import MarkdownIt, {Renderer, StateBlock, StateCore, StateInline, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 脚注元数据接口
|
||||
*/
|
||||
interface FootnoteMeta {
|
||||
id: number;
|
||||
subId: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 脚注列表项接口
|
||||
*/
|
||||
interface FootnoteItem {
|
||||
label?: string;
|
||||
content?: string;
|
||||
tokens?: Token[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 环境接口
|
||||
*/
|
||||
interface FootnoteEnv {
|
||||
footnotes?: {
|
||||
refs?: { [key: string]: number };
|
||||
list?: FootnoteItem[];
|
||||
};
|
||||
docId?: string;
|
||||
}
|
||||
|
||||
/// /////////////////////////////////////////////////////////////////////////////
|
||||
// Renderer partials
|
||||
|
||||
function render_footnote_anchor_name(tokens: Token[], idx: number, options: any, env: FootnoteEnv): string {
|
||||
const n = Number(tokens[idx].meta.id + 1).toString();
|
||||
let prefix = '';
|
||||
|
||||
if (typeof env.docId === 'string') prefix = `-${env.docId}-`;
|
||||
|
||||
return prefix + n;
|
||||
}
|
||||
|
||||
function render_footnote_caption(tokens: Token[], idx: number): string {
|
||||
let n = Number(tokens[idx].meta.id + 1).toString();
|
||||
|
||||
if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `[${n}]`;
|
||||
}
|
||||
|
||||
function render_footnote_ref(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
const id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
const caption = slf.rules.footnote_caption!(tokens, idx, options, env, slf);
|
||||
let refid = id;
|
||||
|
||||
if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `<sup class="footnote-ref"><a href="#fn${id}" id="fnref${refid}">${caption}</a></sup>`;
|
||||
}
|
||||
|
||||
function render_footnote_block_open(tokens: Token[], idx: number, options: any): string {
|
||||
return (options.xhtmlOut ? '<hr class="footnotes-sep" />\n' : '<hr class="footnotes-sep">\n') +
|
||||
'<section class="footnotes">\n' +
|
||||
'<ol class="footnotes-list">\n';
|
||||
}
|
||||
|
||||
function render_footnote_block_close(): string {
|
||||
return '</ol>\n</section>\n';
|
||||
}
|
||||
|
||||
function render_footnote_open(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
|
||||
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
return `<li id="fn${id}" class="footnote-item">`;
|
||||
}
|
||||
|
||||
function render_footnote_close(): string {
|
||||
return '</li>\n';
|
||||
}
|
||||
|
||||
function render_footnote_anchor(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
|
||||
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
|
||||
|
||||
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
|
||||
|
||||
/* ↩ with escape code to prevent display as Apple Emoji on iOS */
|
||||
return ` <a href="#fnref${id}" class="footnote-backref">\u21a9\uFE0E</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-footnote 插件
|
||||
* 用于支持脚注语法
|
||||
*/
|
||||
export default function footnote_plugin(md: MarkdownIt): void {
|
||||
const parseLinkLabel = md.helpers.parseLinkLabel;
|
||||
const isSpace = md.utils.isSpace;
|
||||
|
||||
md.renderer.rules.footnote_ref = render_footnote_ref;
|
||||
md.renderer.rules.footnote_block_open = render_footnote_block_open;
|
||||
md.renderer.rules.footnote_block_close = render_footnote_block_close;
|
||||
md.renderer.rules.footnote_open = render_footnote_open;
|
||||
md.renderer.rules.footnote_close = render_footnote_close;
|
||||
md.renderer.rules.footnote_anchor = render_footnote_anchor;
|
||||
|
||||
// helpers (only used in other rules, no tokens are attached to those)
|
||||
md.renderer.rules.footnote_caption = render_footnote_caption;
|
||||
md.renderer.rules.footnote_anchor_name = render_footnote_anchor_name;
|
||||
|
||||
// Process footnote block definition
|
||||
function footnote_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
|
||||
const start = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
|
||||
// line should be at least 5 chars - "[^x]:"
|
||||
if (start + 4 > max) return false;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
|
||||
|
||||
let pos: number;
|
||||
|
||||
for (pos = start + 2; pos < max; pos++) {
|
||||
if (state.src.charCodeAt(pos) === 0x20) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pos === start + 2) return false; // no empty footnote labels
|
||||
if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 0x3A /* : */) return false;
|
||||
if (silent) return true;
|
||||
pos++;
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) env.footnotes = {};
|
||||
if (!env.footnotes.refs) env.footnotes.refs = {};
|
||||
const label = state.src.slice(start + 2, pos - 2);
|
||||
env.footnotes.refs[`:${label}`] = -1;
|
||||
|
||||
const token_fref_o = new state.Token('footnote_reference_open', '', 1);
|
||||
token_fref_o.meta = { label };
|
||||
token_fref_o.level = state.level++;
|
||||
state.tokens.push(token_fref_o);
|
||||
|
||||
const oldBMark = state.bMarks[startLine];
|
||||
const oldTShift = state.tShift[startLine];
|
||||
const oldSCount = state.sCount[startLine];
|
||||
const oldParentType = state.parentType;
|
||||
|
||||
const posAfterColon = pos;
|
||||
const initial = state.sCount[startLine] + pos - (state.bMarks[startLine] + state.tShift[startLine]);
|
||||
let offset = initial;
|
||||
|
||||
while (pos < max) {
|
||||
const ch = state.src.charCodeAt(pos);
|
||||
|
||||
if (isSpace(ch)) {
|
||||
if (ch === 0x09) {
|
||||
offset += 4 - offset % 4;
|
||||
} else {
|
||||
offset++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
state.tShift[startLine] = pos - posAfterColon;
|
||||
state.sCount[startLine] = offset - initial;
|
||||
|
||||
state.bMarks[startLine] = posAfterColon;
|
||||
state.blkIndent += 4;
|
||||
state.parentType = 'footnote' as any;
|
||||
|
||||
if (state.sCount[startLine] < state.blkIndent) {
|
||||
state.sCount[startLine] += state.blkIndent;
|
||||
}
|
||||
|
||||
state.md.block.tokenize(state, startLine, endLine);
|
||||
|
||||
state.parentType = oldParentType;
|
||||
state.blkIndent -= 4;
|
||||
state.tShift[startLine] = oldTShift;
|
||||
state.sCount[startLine] = oldSCount;
|
||||
state.bMarks[startLine] = oldBMark;
|
||||
|
||||
const token_fref_c = new state.Token('footnote_reference_close', '', -1);
|
||||
token_fref_c.level = --state.level;
|
||||
state.tokens.push(token_fref_c);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process inline footnotes (^[...])
|
||||
function footnote_inline(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (start + 2 >= max) return false;
|
||||
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5B/* [ */) return false;
|
||||
|
||||
const labelStart = start + 2;
|
||||
const labelEnd = parseLinkLabel(state, start + 1);
|
||||
|
||||
// parser failed to find ']', so it's not a valid note
|
||||
if (labelEnd < 0) return false;
|
||||
|
||||
// We found the end of the link, and know for a fact it's a valid link;
|
||||
// so all that's left to do is to call tokenizer.
|
||||
//
|
||||
if (!silent) {
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) env.footnotes = {};
|
||||
if (!env.footnotes.list) env.footnotes.list = [];
|
||||
const footnoteId = env.footnotes.list.length;
|
||||
const tokens: Token[] = [];
|
||||
|
||||
state.md.inline.parse(
|
||||
state.src.slice(labelStart, labelEnd),
|
||||
state.md,
|
||||
state.env,
|
||||
tokens
|
||||
);
|
||||
|
||||
const token = state.push('footnote_ref', '', 0);
|
||||
token.meta = { id: footnoteId };
|
||||
|
||||
env.footnotes.list[footnoteId] = {
|
||||
content: state.src.slice(labelStart, labelEnd),
|
||||
tokens,
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
|
||||
state.pos = labelEnd + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process footnote references ([^...])
|
||||
function footnote_ref(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
// should be at least 4 chars - "[^x]"
|
||||
if (start + 3 > max) return false;
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes || !env.footnotes.refs) return false;
|
||||
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
|
||||
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
|
||||
|
||||
let pos: number;
|
||||
|
||||
for (pos = start + 2; pos < max; pos++) {
|
||||
if (state.src.charCodeAt(pos) === 0x20) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x0A) return false;
|
||||
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pos === start + 2) return false; // no empty footnote labels
|
||||
if (pos >= max) return false;
|
||||
pos++;
|
||||
|
||||
const label = state.src.slice(start + 2, pos - 1);
|
||||
if (typeof env.footnotes.refs[`:${label}`] === 'undefined') return false;
|
||||
|
||||
if (!silent) {
|
||||
if (!env.footnotes.list) env.footnotes.list = [];
|
||||
|
||||
let footnoteId: number;
|
||||
|
||||
if (env.footnotes.refs[`:${label}`] < 0) {
|
||||
footnoteId = env.footnotes.list.length;
|
||||
env.footnotes.list[footnoteId] = { label, count: 0 };
|
||||
env.footnotes.refs[`:${label}`] = footnoteId;
|
||||
} else {
|
||||
footnoteId = env.footnotes.refs[`:${label}`];
|
||||
}
|
||||
|
||||
const footnoteSubId = env.footnotes.list[footnoteId].count;
|
||||
env.footnotes.list[footnoteId].count++;
|
||||
|
||||
const token = state.push('footnote_ref', '', 0);
|
||||
token.meta = { id: footnoteId, subId: footnoteSubId, label };
|
||||
}
|
||||
|
||||
state.pos = pos;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Glue footnote tokens to end of token stream
|
||||
function footnote_tail(state: StateCore): void {
|
||||
let tokens: Token[] | null = null;
|
||||
let current: Token[];
|
||||
let currentLabel: string;
|
||||
let insideRef = false;
|
||||
const refTokens: { [key: string]: Token[] } = {};
|
||||
|
||||
const env = state.env as FootnoteEnv;
|
||||
if (!env.footnotes) { return; }
|
||||
|
||||
state.tokens = state.tokens.filter(function (tok) {
|
||||
if (tok.type === 'footnote_reference_open') {
|
||||
insideRef = true;
|
||||
current = [];
|
||||
currentLabel = tok.meta.label;
|
||||
return false;
|
||||
}
|
||||
if (tok.type === 'footnote_reference_close') {
|
||||
insideRef = false;
|
||||
// prepend ':' to avoid conflict with Object.prototype members
|
||||
refTokens[':' + currentLabel] = current;
|
||||
return false;
|
||||
}
|
||||
if (insideRef) { current.push(tok); }
|
||||
return !insideRef;
|
||||
});
|
||||
|
||||
if (!env.footnotes.list) { return; }
|
||||
const list = env.footnotes.list;
|
||||
|
||||
state.tokens.push(new state.Token('footnote_block_open', '', 1));
|
||||
|
||||
for (let i = 0, l = list.length; i < l; i++) {
|
||||
const token_fo = new state.Token('footnote_open', '', 1);
|
||||
token_fo.meta = { id: i, label: list[i].label };
|
||||
state.tokens.push(token_fo);
|
||||
|
||||
if (list[i].tokens) {
|
||||
tokens = [];
|
||||
|
||||
const token_po = new state.Token('paragraph_open', 'p', 1);
|
||||
token_po.block = true;
|
||||
tokens.push(token_po);
|
||||
|
||||
const token_i = new state.Token('inline', '', 0);
|
||||
token_i.children = list[i].tokens || null;
|
||||
token_i.content = list[i].content || '';
|
||||
tokens.push(token_i);
|
||||
|
||||
const token_pc = new state.Token('paragraph_close', 'p', -1);
|
||||
token_pc.block = true;
|
||||
tokens.push(token_pc);
|
||||
} else if (list[i].label) {
|
||||
tokens = refTokens[`:${list[i].label}`] || null;
|
||||
}
|
||||
|
||||
if (tokens) state.tokens = state.tokens.concat(tokens);
|
||||
|
||||
let lastParagraph: Token | null;
|
||||
|
||||
if (state.tokens[state.tokens.length - 1].type === 'paragraph_close') {
|
||||
lastParagraph = state.tokens.pop()!;
|
||||
} else {
|
||||
lastParagraph = null;
|
||||
}
|
||||
|
||||
const t = list[i].count > 0 ? list[i].count : 1;
|
||||
for (let j = 0; j < t; j++) {
|
||||
const token_a = new state.Token('footnote_anchor', '', 0);
|
||||
token_a.meta = { id: i, subId: j, label: list[i].label };
|
||||
state.tokens.push(token_a);
|
||||
}
|
||||
|
||||
if (lastParagraph) {
|
||||
state.tokens.push(lastParagraph);
|
||||
}
|
||||
|
||||
state.tokens.push(new state.Token('footnote_close', '', -1));
|
||||
}
|
||||
|
||||
state.tokens.push(new state.Token('footnote_block_close', '', -1));
|
||||
}
|
||||
|
||||
md.block.ruler.before('reference', 'footnote_def', footnote_def, { alt: ['paragraph', 'reference'] });
|
||||
md.inline.ruler.after('image', 'footnote_inline', footnote_inline);
|
||||
md.inline.ruler.after('footnote_inline', 'footnote_ref', footnote_ref);
|
||||
md.core.ruler.after('inline', 'footnote_tail', footnote_tail);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 分隔符接口定义
|
||||
*/
|
||||
interface Delimiter {
|
||||
marker: number;
|
||||
length: number;
|
||||
jump: number;
|
||||
token: number;
|
||||
end: number;
|
||||
open: boolean;
|
||||
close: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描结果接口定义
|
||||
*/
|
||||
interface ScanResult {
|
||||
can_open: boolean;
|
||||
can_close: boolean;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 元数据接口定义
|
||||
*/
|
||||
interface TokenMeta {
|
||||
delimiters?: Delimiter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-ins 插件
|
||||
* 用于支持插入文本语法 ++text++
|
||||
*/
|
||||
export default function ins_plugin(md: MarkdownIt): void {
|
||||
// Insert each marker as a separate text token, and add it to delimiter list
|
||||
//
|
||||
function tokenize(state: StateInline, silent: boolean): boolean {
|
||||
const start = state.pos;
|
||||
const marker = state.src.charCodeAt(start);
|
||||
|
||||
if (silent) { return false; }
|
||||
|
||||
if (marker !== 0x2B/* + */) { return false; }
|
||||
|
||||
const scanned = state.scanDelims(state.pos, true) as ScanResult;
|
||||
let len = scanned.length;
|
||||
const ch = String.fromCharCode(marker);
|
||||
|
||||
if (len < 2) { return false; }
|
||||
|
||||
if (len % 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch;
|
||||
len--;
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch + ch;
|
||||
|
||||
if (!scanned.can_open && !scanned.can_close) { continue; }
|
||||
|
||||
state.delimiters.push({
|
||||
marker,
|
||||
length: 0, // disable "rule of 3" length checks meant for emphasis
|
||||
jump: i / 2, // 1 delimiter = 2 characters
|
||||
token: state.tokens.length - 1,
|
||||
end: -1,
|
||||
open: scanned.can_open,
|
||||
close: scanned.can_close
|
||||
} as Delimiter);
|
||||
}
|
||||
|
||||
state.pos += scanned.length;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Walk through delimiter list and replace text tokens with tags
|
||||
//
|
||||
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
|
||||
let token: Token;
|
||||
const loneMarkers: number[] = [];
|
||||
const max = delimiters.length;
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const startDelim = delimiters[i];
|
||||
|
||||
if (startDelim.marker !== 0x2B/* + */) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startDelim.end === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const endDelim = delimiters[startDelim.end];
|
||||
|
||||
token = state.tokens[startDelim.token];
|
||||
token.type = 'ins_open';
|
||||
token.tag = 'ins';
|
||||
token.nesting = 1;
|
||||
token.markup = '++';
|
||||
token.content = '';
|
||||
|
||||
token = state.tokens[endDelim.token];
|
||||
token.type = 'ins_close';
|
||||
token.tag = 'ins';
|
||||
token.nesting = -1;
|
||||
token.markup = '++';
|
||||
token.content = '';
|
||||
|
||||
if (state.tokens[endDelim.token - 1].type === 'text' &&
|
||||
state.tokens[endDelim.token - 1].content === '+') {
|
||||
loneMarkers.push(endDelim.token - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If a marker sequence has an odd number of characters, it's splitted
|
||||
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
|
||||
// start of the sequence.
|
||||
//
|
||||
// So, we have to move all those markers after subsequent s_close tags.
|
||||
//
|
||||
while (loneMarkers.length) {
|
||||
const i = loneMarkers.pop()!;
|
||||
let j = i + 1;
|
||||
|
||||
while (j < state.tokens.length && state.tokens[j].type === 'ins_close') {
|
||||
j++;
|
||||
}
|
||||
|
||||
j--;
|
||||
|
||||
if (i !== j) {
|
||||
token = state.tokens[j];
|
||||
state.tokens[j] = state.tokens[i];
|
||||
state.tokens[i] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.inline.ruler.before('emphasis', 'ins', tokenize);
|
||||
md.inline.ruler2.before('emphasis', 'ins', function (state: StateInline): boolean {
|
||||
const tokens_meta = state.tokens_meta as TokenMeta[];
|
||||
const max = (state.tokens_meta || []).length;
|
||||
|
||||
postProcess(state, state.delimiters as Delimiter[]);
|
||||
|
||||
for (let curr = 0; curr < max; curr++) {
|
||||
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
|
||||
postProcess(state, tokens_meta[curr].delimiters!);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import MarkdownIt, {StateInline, Token} from 'markdown-it';
|
||||
|
||||
/**
|
||||
* 分隔符接口定义
|
||||
*/
|
||||
interface Delimiter {
|
||||
marker: number;
|
||||
length: number;
|
||||
jump: number;
|
||||
token: number;
|
||||
end: number;
|
||||
open: boolean;
|
||||
close: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描结果接口定义
|
||||
*/
|
||||
interface ScanResult {
|
||||
can_open: boolean;
|
||||
can_close: boolean;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 元数据接口定义
|
||||
*/
|
||||
interface TokenMeta {
|
||||
delimiters?: Delimiter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-mark 插件
|
||||
* 用于支持 ==标记文本== 语法
|
||||
*/
|
||||
export default function markPlugin(md: MarkdownIt): void {
|
||||
// Insert each marker as a separate text token, and add it to delimiter list
|
||||
//
|
||||
function tokenize(state: StateInline, silent: boolean): boolean {
|
||||
const start = state.pos;
|
||||
const marker = state.src.charCodeAt(start);
|
||||
|
||||
if (silent) { return false; }
|
||||
|
||||
if (marker !== 0x3D/* = */) { return false; }
|
||||
|
||||
const scanned = state.scanDelims(state.pos, true) as ScanResult;
|
||||
let len = scanned.length;
|
||||
const ch = String.fromCharCode(marker);
|
||||
|
||||
if (len < 2) { return false; }
|
||||
|
||||
if (len % 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch;
|
||||
len--;
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i += 2) {
|
||||
const token: Token = state.push('text', '', 0);
|
||||
token.content = ch + ch;
|
||||
|
||||
if (!scanned.can_open && !scanned.can_close) { continue; }
|
||||
|
||||
state.delimiters.push({
|
||||
marker,
|
||||
length: 0, // disable "rule of 3" length checks meant for emphasis
|
||||
jump: i / 2, // 1 delimiter = 2 characters
|
||||
token: state.tokens.length - 1,
|
||||
end: -1,
|
||||
open: scanned.can_open,
|
||||
close: scanned.can_close
|
||||
} as Delimiter);
|
||||
}
|
||||
|
||||
state.pos += scanned.length;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Walk through delimiter list and replace text tokens with tags
|
||||
//
|
||||
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
|
||||
const loneMarkers: number[] = [];
|
||||
const max = delimiters.length;
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const startDelim = delimiters[i];
|
||||
|
||||
if (startDelim.marker !== 0x3D/* = */) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startDelim.end === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const endDelim = delimiters[startDelim.end];
|
||||
|
||||
const token_o = state.tokens[startDelim.token];
|
||||
token_o.type = 'mark_open';
|
||||
token_o.tag = 'mark';
|
||||
token_o.nesting = 1;
|
||||
token_o.markup = '==';
|
||||
token_o.content = '';
|
||||
|
||||
const token_c = state.tokens[endDelim.token];
|
||||
token_c.type = 'mark_close';
|
||||
token_c.tag = 'mark';
|
||||
token_c.nesting = -1;
|
||||
token_c.markup = '==';
|
||||
token_c.content = '';
|
||||
|
||||
if (state.tokens[endDelim.token - 1].type === 'text' &&
|
||||
state.tokens[endDelim.token - 1].content === '=') {
|
||||
loneMarkers.push(endDelim.token - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If a marker sequence has an odd number of characters, it's splitted
|
||||
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
|
||||
// start of the sequence.
|
||||
//
|
||||
// So, we have to move all those markers after subsequent s_close tags.
|
||||
//
|
||||
while (loneMarkers.length) {
|
||||
const i = loneMarkers.pop()!;
|
||||
let j = i + 1;
|
||||
|
||||
while (j < state.tokens.length && state.tokens[j].type === 'mark_close') {
|
||||
j++;
|
||||
}
|
||||
|
||||
j--;
|
||||
|
||||
if (i !== j) {
|
||||
const token = state.tokens[j];
|
||||
state.tokens[j] = state.tokens[i];
|
||||
state.tokens[i] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.inline.ruler.before('emphasis', 'mark', tokenize);
|
||||
md.inline.ruler2.before('emphasis', 'mark', function (state: StateInline): boolean {
|
||||
let curr: number;
|
||||
const tokens_meta = state.tokens_meta as TokenMeta[];
|
||||
const max = (state.tokens_meta || []).length;
|
||||
|
||||
postProcess(state, state.delimiters as Delimiter[]);
|
||||
|
||||
for (curr = 0; curr < max; curr++) {
|
||||
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
|
||||
postProcess(state, tokens_meta[curr].delimiters!);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import mermaid from "mermaid";
|
||||
import {genUid, hashCode, sleep} from "./utils";
|
||||
|
||||
const mermaidCache = new Map<string, HTMLElement>();
|
||||
|
||||
// 缓存计数器,用于清除缓存
|
||||
const mermaidCacheCount = new Map<string, number>();
|
||||
let count = 0;
|
||||
|
||||
|
||||
let countTmo = setTimeout(() => undefined, 0);
|
||||
const addCount = () => {
|
||||
clearTimeout(countTmo);
|
||||
countTmo = setTimeout(() => {
|
||||
count++;
|
||||
clearCache();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const clearCache = () => {
|
||||
for (const key of mermaidCacheCount.keys()) {
|
||||
const value = mermaidCacheCount.get(key)!;
|
||||
if (value + 3 < count) {
|
||||
mermaidCache.delete(key);
|
||||
mermaidCacheCount.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染 mermaid
|
||||
* @param code mermaid 代码
|
||||
* @param targetId 目标 id
|
||||
* @param count 计数器
|
||||
*/
|
||||
const renderMermaid = async (code: string, targetId: string, count: number) => {
|
||||
let limit = 100;
|
||||
while (limit-- > 0) {
|
||||
const container = document.getElementById(targetId);
|
||||
if (!container) {
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const {svg} = await mermaid.render("mermaid-svg-" + genUid(), code, container);
|
||||
container.innerHTML = svg;
|
||||
mermaidCache.set(targetId, container);
|
||||
mermaidCacheCount.set(targetId, count);
|
||||
} catch (e) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export interface MermaidItOptions {
|
||||
theme?: "default" | "dark" | "forest" | "neutral" | "base";
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 mermaid 主题
|
||||
*/
|
||||
export const updateMermaidTheme = (theme: "default" | "dark" | "forest" | "neutral" | "base") => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme
|
||||
});
|
||||
// 清空缓存,强制重新渲染
|
||||
mermaidCache.clear();
|
||||
mermaidCacheCount.clear();
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* mermaid 插件
|
||||
* @param md markdown-it
|
||||
* @param options 配置选项
|
||||
* @constructor MermaidIt
|
||||
*/
|
||||
export const MermaidIt = function (md: any, options?: MermaidItOptions): void {
|
||||
const theme = options?.theme || "default";
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme
|
||||
});
|
||||
const defaultRenderer = md.renderer.rules.fence.bind(md.renderer.rules);
|
||||
md.renderer.rules.fence = (tokens: any, idx: any, options: any, env: any, self: any) => {
|
||||
addCount();
|
||||
const token = tokens[idx];
|
||||
const info = token.info.trim();
|
||||
if (info === "mermaid") {
|
||||
const containerId = "mermaid-container-" + hashCode(token.content);
|
||||
const container = document.createElement("div");
|
||||
container.id = containerId;
|
||||
if (mermaidCache.has(containerId)) {
|
||||
container.innerHTML = mermaidCache.get(containerId)!.innerHTML;
|
||||
mermaidCacheCount.set(containerId, count);
|
||||
} else {
|
||||
renderMermaid(token.content, containerId, count).then();
|
||||
}
|
||||
return container.outerHTML;
|
||||
}
|
||||
// 使用默认的渲染规则
|
||||
return defaultRenderer(tokens, idx, options, env, self);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
/**
|
||||
* uuid 生成函数
|
||||
* @param split 分隔符
|
||||
*/
|
||||
export const genUid = (split = "") => {
|
||||
return uuidv4().split("-").join(split);
|
||||
};
|
||||
|
||||
/**
|
||||
* 一个简易的sleep函数
|
||||
*/
|
||||
export const sleep = async (ms: number) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算字符串的hash值
|
||||
* 返回一个数字
|
||||
* @param str
|
||||
*/
|
||||
export const hashCode = (str: string) => {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return hash;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
/**
|
||||
* 一个简易的阻塞函数
|
||||
*/
|
||||
export const awaitFor = async (cb: () => boolean, timeout = 0, errText = "超时暂停阻塞") => {
|
||||
const start = Date.now();
|
||||
while (true) {
|
||||
if (cb()) return true;
|
||||
if (timeout && Date.now() - start > timeout) {
|
||||
console.error("阻塞超时: " + errText);
|
||||
return false;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
// Process ~subscript~
|
||||
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
// same as UNESCAPE_MD_RE plus a space
|
||||
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
|
||||
|
||||
function subscript(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x7E/* ~ */) { return false; }
|
||||
if (silent) { return false; } // don't run any pairs in validation mode
|
||||
if (start + 2 >= max) { return false; }
|
||||
|
||||
state.pos = start + 1;
|
||||
let found = false;
|
||||
|
||||
while (state.pos < max) {
|
||||
if (state.src.charCodeAt(state.pos) === 0x7E/* ~ */) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
state.md.inline.skipToken(state);
|
||||
}
|
||||
|
||||
if (!found || start + 1 === state.pos) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = state.src.slice(start + 1, state.pos);
|
||||
|
||||
// don't allow unescaped spaces/newlines inside
|
||||
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
// found!
|
||||
state.posMax = state.pos;
|
||||
state.pos = start + 1;
|
||||
|
||||
// Earlier we checked !silent, but this implementation does not need it
|
||||
const token_so: Token = state.push('sub_open', 'sub', 1);
|
||||
token_so.markup = '~';
|
||||
|
||||
const token_t: Token = state.push('text', '', 0);
|
||||
token_t.content = content.replace(UNESCAPE_RE, '$1');
|
||||
|
||||
const token_sc: Token = state.push('sub_close', 'sub', -1);
|
||||
token_sc.markup = '~';
|
||||
|
||||
state.pos = state.posMax + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-sub 插件
|
||||
* 用于支持下标语法 ~text~
|
||||
*/
|
||||
export default function sub_plugin(md: MarkdownIt): void {
|
||||
md.inline.ruler.after('emphasis', 'sub', subscript);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// Process ^superscript^
|
||||
|
||||
import MarkdownIt, { StateInline, Token } from 'markdown-it';
|
||||
|
||||
// same as UNESCAPE_MD_RE plus a space
|
||||
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
|
||||
|
||||
function superscript(state: StateInline, silent: boolean): boolean {
|
||||
const max = state.posMax;
|
||||
const start = state.pos;
|
||||
|
||||
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) { return false; }
|
||||
if (silent) { return false; } // don't run any pairs in validation mode
|
||||
if (start + 2 >= max) { return false; }
|
||||
|
||||
state.pos = start + 1;
|
||||
let found = false;
|
||||
|
||||
while (state.pos < max) {
|
||||
if (state.src.charCodeAt(state.pos) === 0x5E/* ^ */) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
state.md.inline.skipToken(state);
|
||||
}
|
||||
|
||||
if (!found || start + 1 === state.pos) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = state.src.slice(start + 1, state.pos);
|
||||
|
||||
// don't allow unescaped spaces/newlines inside
|
||||
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
|
||||
state.pos = start;
|
||||
return false;
|
||||
}
|
||||
|
||||
// found!
|
||||
state.posMax = state.pos;
|
||||
state.pos = start + 1;
|
||||
|
||||
// Earlier we checked !silent, but this implementation does not need it
|
||||
const token_so: Token = state.push('sup_open', 'sup', 1);
|
||||
token_so.markup = '^';
|
||||
|
||||
const token_t: Token = state.push('text', '', 0);
|
||||
token_t.content = content.replace(UNESCAPE_RE, '$1');
|
||||
|
||||
const token_sc: Token = state.push('sup_close', 'sup', -1);
|
||||
token_sc.markup = '^';
|
||||
|
||||
state.pos = state.posMax + 1;
|
||||
state.posMax = max;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it-sup 插件
|
||||
* 用于支持上标语法 ^text^
|
||||
*/
|
||||
export default function sup_plugin(md: MarkdownIt): void {
|
||||
md.inline.ruler.after('emphasis', 'sup', superscript);
|
||||
}
|
||||
65
frontend/src/common/utils/formatter.ts
Normal file
65
frontend/src/common/utils/formatter.ts
Normal 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;
|
||||
};
|
||||
42
frontend/src/common/utils/validation.ts
Normal file
42
frontend/src/common/utils/validation.ts
Normal 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;
|
||||
};
|
||||
@@ -142,7 +142,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--voidraft-mono-font, monospace),serif;
|
||||
font-family: Menlo, monospace,serif;
|
||||
}
|
||||
|
||||
.loading-word {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible && canClose"
|
||||
v-click-outside="handleClose"
|
||||
class="tab-context-menu"
|
||||
:style="{
|
||||
left: position.x + 'px',
|
||||
@@ -9,27 +10,31 @@
|
||||
@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;
|
||||
@@ -98,33 +106,8 @@ const handleMenuClick = (action: string) => {
|
||||
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">
|
||||
@@ -147,7 +130,7 @@ onUnmounted(() => {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.15s ease;
|
||||
gap: 8px;
|
||||
|
||||
@@ -165,7 +148,7 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
transition: color 0.15s ease;
|
||||
|
||||
.menu-item:hover & {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<button
|
||||
class="titlebar-button maximize-button"
|
||||
@click="toggleMaximize"
|
||||
@click="handleToggleMaximize"
|
||||
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
|
||||
>
|
||||
<div class="button-icon">
|
||||
@@ -45,95 +45,58 @@
|
||||
|
||||
<!-- 标签页容器区域 -->
|
||||
<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>
|
||||
|
||||
@@ -261,9 +224,8 @@ onMounted(async () => {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
min-width: 0;
|
||||
overflow: visible; /* 允许TabContainer内部处理滚动 */
|
||||
overflow: visible;
|
||||
|
||||
/* 确保TabContainer能够正确处理滚动 */
|
||||
:deep(.tab-container) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -285,7 +247,6 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保底部线条能够正确显示 */
|
||||
:deep(.tab-item) {
|
||||
position: relative;
|
||||
|
||||
|
||||
@@ -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 ? '' : '');
|
||||
|
||||
// 判断是否在设置页面
|
||||
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 {
|
||||
|
||||
60
frontend/src/components/titlebar/index.ts
Normal file
60
frontend/src/components/titlebar/index.ts
Normal 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';
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { getActiveNoteBlock } from '@/views/editor/extensions/codeblock/state';
|
||||
import { changeCurrentBlockLanguage } from '@/views/editor/extensions/codeblock/commands';
|
||||
|
||||
const { t } = useI18n();
|
||||
const editorStore = readonly(useEditorStore());
|
||||
const editorStore = useEditorStore();
|
||||
|
||||
// 组件状态
|
||||
const showLanguageMenu = shallowRef(false);
|
||||
|
||||
@@ -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,115 +218,60 @@ 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;
|
||||
// 切换菜单
|
||||
const toggleMenu = () => {
|
||||
if (documentStore.showDocumentSelector) {
|
||||
closeMenu();
|
||||
} 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')) {
|
||||
closeMenu();
|
||||
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">
|
||||
<Transition name="slide-up">
|
||||
<div v-if="state.isLoaded" class="doc-menu">
|
||||
<!-- 输入框 -->
|
||||
<div class="input-box">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
v-model="state.searchQuery"
|
||||
type="text"
|
||||
class="main-input"
|
||||
:placeholder="t('toolbar.searchOrCreateDocument')"
|
||||
:maxlength="MAX_TITLE_LENGTH"
|
||||
@keydown="handleInputKeydown"
|
||||
@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">
|
||||
@@ -337,7 +290,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
'active': !item.isCreateOption && documentStore.currentDocument?.id === item.id,
|
||||
'create-item': item.isCreateOption
|
||||
}"
|
||||
@click="selectItem(item)"
|
||||
@click="selectDocItem(item)"
|
||||
>
|
||||
<!-- 创建选项 -->
|
||||
<div v-if="item.isCreateOption" class="create-option">
|
||||
@@ -352,31 +305,32 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
<!-- 文档项 -->
|
||||
<div v-else class="doc-item-content">
|
||||
<!-- 普通显示 -->
|
||||
<div v-if="editingId !== item.id" class="doc-info">
|
||||
<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">{{ formatTime(item.updatedAt) }}</div>
|
||||
<div v-else class="doc-date">{{ formatDateTime(item.updated_at) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑状态 -->
|
||||
<div v-else class="doc-edit">
|
||||
<input
|
||||
:ref="el => editInputRef = el as HTMLInputElement"
|
||||
v-model="editingTitle"
|
||||
v-model="state.editing.title"
|
||||
type="text"
|
||||
class="edit-input"
|
||||
:maxlength="MAX_TITLE_LENGTH"
|
||||
@keydown="handleEditKeydown"
|
||||
@keydown.enter="saveEdit"
|
||||
@keydown.esc="cancelEdit"
|
||||
@blur="saveEdit"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="editingId !== item.id" class="doc-actions">
|
||||
<div v-if="state.editing.id !== item.id" class="doc-actions">
|
||||
<!-- 只有非当前文档才显示在新窗口打开按钮 -->
|
||||
<button
|
||||
v-if="documentStore.currentDocument?.id !== item.id"
|
||||
@@ -392,7 +346,7 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
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')">
|
||||
<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>
|
||||
@@ -401,11 +355,11 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
<button
|
||||
v-if="documentStore.documentList.length > 1 && item.id !== 1"
|
||||
class="action-btn delete-btn"
|
||||
:class="{ 'delete-confirm': deleteConfirmId === item.id }"
|
||||
:class="{ 'delete-confirm': isDeleting(item.id!) }"
|
||||
@click="handleDelete(item, $event)"
|
||||
:title="deleteConfirmId === item.id ? t('toolbar.confirmDelete') : t('toolbar.delete')"
|
||||
:title="isDeleting(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"
|
||||
<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>
|
||||
@@ -421,17 +375,24 @@ watch(() => documentStore.showDocumentSelector, (isOpen) => {
|
||||
<div v-if="filteredItems.length === 0" class="empty">
|
||||
{{ t('toolbar.noDocumentFound') }}
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="documentStore.isLoading" class="loading">
|
||||
{{ t('toolbar.loading') }}
|
||||
</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;
|
||||
|
||||
@@ -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>
|
||||
@@ -13,20 +13,16 @@ import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
|
||||
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
|
||||
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
|
||||
import {usePanelStore} from '@/stores/panelStore';
|
||||
|
||||
const editorStore = readonly(useEditorStore());
|
||||
const configStore = readonly(useConfigStore());
|
||||
const updateStore = readonly(useUpdateStore());
|
||||
const windowStore = readonly(useWindowStore());
|
||||
const systemStore = readonly(useSystemStore());
|
||||
const panelStore = readonly(usePanelStore());
|
||||
const editorStore = useEditorStore();
|
||||
const configStore = useConfigStore();
|
||||
const updateStore = useUpdateStore();
|
||||
const windowStore = useWindowStore();
|
||||
const systemStore = useSystemStore();
|
||||
const {t} = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const canFormatCurrentBlock = ref(false);
|
||||
const canPreviewMarkdown = ref(false);
|
||||
const isLoaded = shallowRef(false);
|
||||
|
||||
const { documentStats } = toRefs(editorStore);
|
||||
@@ -37,10 +33,6 @@ const isCurrentWindowOnTop = computed(() => {
|
||||
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
|
||||
});
|
||||
|
||||
// 当前文档的预览是否打开
|
||||
const isCurrentBlockPreviewing = computed(() => {
|
||||
return panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing;
|
||||
});
|
||||
|
||||
// 切换窗口置顶状态
|
||||
const toggleAlwaysOnTop = async () => {
|
||||
@@ -69,22 +61,12 @@ const formatCurrentBlock = () => {
|
||||
formatBlockContent(editorStore.editorView);
|
||||
};
|
||||
|
||||
// 切换 Markdown 预览
|
||||
const { debouncedFn: debouncedTogglePreview } = createDebounce(() => {
|
||||
if (!canPreviewMarkdown.value || !editorStore.editorView) return;
|
||||
toggleMarkdownPreview(editorStore.editorView as any);
|
||||
}, { delay: 200 });
|
||||
|
||||
const togglePreview = () => {
|
||||
debouncedTogglePreview();
|
||||
};
|
||||
|
||||
// 统一更新按钮状态
|
||||
const updateButtonStates = () => {
|
||||
const view: any = editorStore.editorView;
|
||||
if (!view) {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,7 +77,6 @@ const updateButtonStates = () => {
|
||||
// 提前返回,减少不必要的计算
|
||||
if (!activeBlock) {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,11 +84,9 @@ const updateButtonStates = () => {
|
||||
const language = getLanguage(languageName as any);
|
||||
|
||||
canFormatCurrentBlock.value = Boolean(language?.prettier);
|
||||
canPreviewMarkdown.value = languageName.toLowerCase() === 'md';
|
||||
} catch (error) {
|
||||
console.warn('Error checking block capabilities:', error);
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -161,7 +140,6 @@ watch(
|
||||
cleanupListeners = setupEditorListeners(newView);
|
||||
} else {
|
||||
canFormatCurrentBlock.value = false;
|
||||
canPreviewMarkdown.value = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -255,21 +233,6 @@ const statsData = computed(() => ({
|
||||
<!-- 块语言选择器 -->
|
||||
<BlockLanguageSelector/>
|
||||
|
||||
<!-- Markdown预览按钮 -->
|
||||
<div
|
||||
v-if="canPreviewMarkdown"
|
||||
class="preview-button"
|
||||
:class="{ 'active': isCurrentBlockPreviewing }"
|
||||
:title="isCurrentBlockPreviewing ? t('toolbar.closePreview') : t('toolbar.previewMarkdown')"
|
||||
@click="togglePreview"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 格式化按钮 - 支持点击操作 -->
|
||||
<div
|
||||
v-if="canFormatCurrentBlock"
|
||||
|
||||
5
frontend/src/composables/index.ts
Normal file
5
frontend/src/composables/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useConfirm } from './useConfirm';
|
||||
export type { UseConfirmOptions } from './useConfirm';
|
||||
|
||||
export { usePolling } from './usePolling';
|
||||
export type { UsePollingOptions, UsePollingReturn } from './usePolling';
|
||||
174
frontend/src/composables/useConfirm.ts
Normal file
174
frontend/src/composables/useConfirm.ts
Normal 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
|
||||
};
|
||||
}
|
||||
147
frontend/src/composables/usePolling.ts
Normal file
147
frontend/src/composables/usePolling.ts
Normal 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
|
||||
};
|
||||
}
|
||||
37
frontend/src/directives/clickOutside.ts
Normal file
37
frontend/src/directives/clickOutside.ts
Normal 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;
|
||||
13
frontend/src/directives/index.ts
Normal file
13
frontend/src/directives/index.ts
Normal 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;
|
||||
@@ -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',
|
||||
@@ -53,7 +46,7 @@ export default {
|
||||
keybindings: {
|
||||
headers: {
|
||||
shortcut: 'Shortcut',
|
||||
category: 'Category',
|
||||
extension: 'Extension',
|
||||
description: 'Description'
|
||||
},
|
||||
commands: {
|
||||
@@ -111,7 +104,6 @@ export default {
|
||||
deleteCharForward: 'Delete character forward',
|
||||
deleteGroupBackward: 'Delete group backward',
|
||||
deleteGroupForward: 'Delete group forward',
|
||||
textHighlightToggle: 'Toggle text highlight',
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
@@ -161,53 +153,6 @@ export default {
|
||||
customThemeColors: 'Custom Theme Colors',
|
||||
resetToDefault: 'Reset to Default',
|
||||
colorValue: 'Color Value',
|
||||
themeColors: {
|
||||
basic: 'Basic Colors',
|
||||
text: 'Text Colors',
|
||||
syntax: 'Syntax Highlighting',
|
||||
interface: 'Interface Elements',
|
||||
border: 'Borders & Dividers',
|
||||
search: 'Search & Matching',
|
||||
// Base Colors
|
||||
background: 'Main Background',
|
||||
backgroundSecondary: 'Secondary Background',
|
||||
surface: 'Panel Background',
|
||||
dropdownBackground: 'Dropdown Background',
|
||||
dropdownBorder: 'Dropdown Border',
|
||||
// Text Colors
|
||||
foreground: 'Primary Text',
|
||||
foregroundSecondary: 'Secondary Text',
|
||||
comment: 'Comments',
|
||||
// Syntax Highlighting - Core
|
||||
keyword: 'Keywords',
|
||||
string: 'Strings',
|
||||
function: 'Functions',
|
||||
number: 'Numbers',
|
||||
operator: 'Operators',
|
||||
variable: 'Variables',
|
||||
type: 'Types',
|
||||
// Syntax Highlighting - Extended
|
||||
constant: 'Constants',
|
||||
storage: 'Storage Type',
|
||||
parameter: 'Parameters',
|
||||
class: 'Class Names',
|
||||
heading: 'Headings',
|
||||
invalid: 'Invalid/Error',
|
||||
regexp: 'Regular Expressions',
|
||||
// Interface Elements
|
||||
cursor: 'Cursor',
|
||||
selection: 'Selection Background',
|
||||
selectionBlur: 'Unfocused Selection',
|
||||
activeLine: 'Active Line Highlight',
|
||||
lineNumber: 'Line Numbers',
|
||||
activeLineNumber: 'Active Line Number',
|
||||
// Borders & Dividers
|
||||
borderColor: 'Border Color',
|
||||
borderLight: 'Light Border',
|
||||
// Search & Matching
|
||||
searchMatch: 'Search Match',
|
||||
matchingBracket: 'Matching Bracket'
|
||||
},
|
||||
lineHeight: 'Line Height',
|
||||
tabSettings: 'Tab Settings',
|
||||
tabSize: 'Tab Size',
|
||||
@@ -282,14 +227,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',
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -304,7 +245,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',
|
||||
@@ -322,19 +263,33 @@ 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'
|
||||
}
|
||||
},
|
||||
monitor: {
|
||||
|
||||
@@ -33,13 +33,6 @@ export default {
|
||||
confirmDelete: '再次点击确认删除',
|
||||
openInNewWindow: '在新窗口中打开',
|
||||
alreadyOpenInNewWindow: '已在新窗口中打开',
|
||||
documentNameTooLong: '文档名称不能超过{max}个字符',
|
||||
documentNameRequired: '文档名称不能为空',
|
||||
cannotDeleteLastDocument: '无法删除最后一个文档',
|
||||
cannotDeleteDefaultDocument: '无法删除默认文档',
|
||||
unknownTime: '未知时间',
|
||||
invalidDate: '无效日期',
|
||||
timeError: '时间错误',
|
||||
},
|
||||
languages: {
|
||||
'zh-CN': '简体中文',
|
||||
@@ -53,7 +46,7 @@ export default {
|
||||
keybindings: {
|
||||
headers: {
|
||||
shortcut: '快捷键',
|
||||
category: '分类',
|
||||
extension: '扩展',
|
||||
description: '描述'
|
||||
},
|
||||
commands: {
|
||||
@@ -111,7 +104,6 @@ export default {
|
||||
deleteCharForward: '向前删除字符',
|
||||
deleteGroupBackward: '向后删除组',
|
||||
deleteGroupForward: '向前删除组',
|
||||
textHighlightToggle: '切换文本高亮',
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
@@ -202,54 +194,6 @@ export default {
|
||||
customThemeColors: '自定义主题颜色',
|
||||
resetToDefault: '重置为默认',
|
||||
colorValue: '颜色值',
|
||||
themeColors: {
|
||||
basic: '基础色调',
|
||||
text: '文本颜色',
|
||||
syntax: '语法高亮',
|
||||
interface: '界面元素',
|
||||
border: '边框分割线',
|
||||
search: '搜索匹配',
|
||||
// 基础色调
|
||||
background: '主背景色',
|
||||
backgroundSecondary: '次要背景色',
|
||||
surface: '面板背景',
|
||||
dropdownBackground: '下拉菜单背景',
|
||||
dropdownBorder: '下拉菜单边框',
|
||||
// 文本颜色
|
||||
foreground: '主文本色',
|
||||
foregroundSecondary: '次要文本色',
|
||||
comment: '注释色',
|
||||
// 语法高亮 - 核心
|
||||
keyword: '关键字',
|
||||
string: '字符串',
|
||||
function: '函数名',
|
||||
number: '数字',
|
||||
operator: '操作符',
|
||||
variable: '变量',
|
||||
type: '类型',
|
||||
// 语法高亮 - 扩展
|
||||
constant: '常量',
|
||||
storage: '存储类型',
|
||||
parameter: '参数',
|
||||
class: '类名',
|
||||
heading: '标题',
|
||||
invalid: '无效内容',
|
||||
regexp: '正则表达式',
|
||||
// 界面元素
|
||||
cursor: '光标',
|
||||
selection: '选中背景',
|
||||
selectionBlur: '失焦选中背景',
|
||||
activeLine: '当前行高亮',
|
||||
lineNumber: '行号',
|
||||
activeLineNumber: '活动行号',
|
||||
// 边框和分割线
|
||||
borderColor: '边框色',
|
||||
borderLight: '浅色边框',
|
||||
// 搜索和匹配
|
||||
searchMatch: '搜索匹配',
|
||||
matchingBracket: '匹配括号'
|
||||
},
|
||||
|
||||
hotkeyPreview: '预览:',
|
||||
none: '无',
|
||||
backup: {
|
||||
@@ -285,14 +229,10 @@ export default {
|
||||
sshKeyPassphrase: 'SSH密钥密码',
|
||||
sshKeyPassphrasePlaceholder: '请输入SSH密钥密码',
|
||||
backupOperations: '备份操作',
|
||||
pushToRemote: '推送到远程',
|
||||
pushing: '推送中...',
|
||||
syncToRemote: '同步到远程',
|
||||
syncing: '同步中...',
|
||||
actions: {
|
||||
push: '推送',
|
||||
},
|
||||
status: {
|
||||
success: '成功',
|
||||
failed: '失败'
|
||||
sync: '同步',
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -307,7 +247,7 @@ export default {
|
||||
},
|
||||
colorSelector: {
|
||||
name: '颜色选择器',
|
||||
description: '颜色值的可视化和选择'
|
||||
description: 'CSS代码块颜色值的可视化和选择'
|
||||
},
|
||||
translator: {
|
||||
name: '划词翻译',
|
||||
@@ -325,19 +265,33 @@ 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 请求并查看响应'
|
||||
}
|
||||
},
|
||||
monitor: {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
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';
|
||||
|
||||
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');
|
||||
@@ -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 isSyncing = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const timer = createTimerManager();
|
||||
const configStore = useConfigStore();
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
@@ -3,29 +3,23 @@ import {computed, reactive} from 'vue';
|
||||
import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services';
|
||||
import {
|
||||
AppConfig,
|
||||
AppearanceConfig,
|
||||
AuthMethod,
|
||||
EditingConfig,
|
||||
GeneralConfig,
|
||||
GitBackupConfig,
|
||||
LanguageType,
|
||||
SystemThemeType,
|
||||
TabType,
|
||||
UpdatesConfig
|
||||
TabType
|
||||
} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {ConfigUtils} from '@/common/utils/configUtils';
|
||||
import {FONT_OPTIONS} from '@/common/constant/fonts';
|
||||
import {SUPPORTED_LOCALES} from '@/common/constant/locales';
|
||||
import {
|
||||
APPEARANCE_CONFIG_KEY_MAP,
|
||||
BACKUP_CONFIG_KEY_MAP,
|
||||
CONFIG_KEY_MAP,
|
||||
CONFIG_LIMITS,
|
||||
ConfigKey,
|
||||
ConfigSection,
|
||||
DEFAULT_CONFIG,
|
||||
EDITING_CONFIG_KEY_MAP,
|
||||
GENERAL_CONFIG_KEY_MAP,
|
||||
NumberConfigKey,
|
||||
UPDATES_CONFIG_KEY_MAP
|
||||
NumberConfigKey
|
||||
} from '@/common/constant/config';
|
||||
import * as runtime from '@wailsio/runtime';
|
||||
|
||||
@@ -42,86 +36,42 @@ export const useConfigStore = defineStore('config', () => {
|
||||
// Font options (no longer localized)
|
||||
const fontOptions = computed(() => FONT_OPTIONS);
|
||||
|
||||
// 计算属性 - 使用工厂函数简化
|
||||
// 计算属性
|
||||
const createLimitComputed = (key: NumberConfigKey) => computed(() => CONFIG_LIMITS[key]);
|
||||
const limits = Object.fromEntries(
|
||||
(['fontSize', 'tabSize', 'lineHeight'] as const).map(key => [key, createLimitComputed(key)])
|
||||
) as Record<NumberConfigKey, ReturnType<typeof createLimitComputed>>;
|
||||
|
||||
// 通用配置更新方法
|
||||
const updateGeneralConfig = async <K extends keyof GeneralConfig>(key: K, value: GeneralConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
// 统一配置更新方法
|
||||
const updateConfig = async <K extends ConfigKey>(key: K, value: any): Promise<void> => {
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = GENERAL_CONFIG_KEY_MAP[key];
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for general.${key.toString()}`);
|
||||
throw new Error(`No backend key mapping found for ${String(key)}`);
|
||||
}
|
||||
|
||||
// 从 backendKey 提取 section(例如 'general.alwaysOnTop' -> 'general')
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.general[key] = value;
|
||||
(state.config[section] as any)[key] = value;
|
||||
};
|
||||
|
||||
const updateEditingConfig = async <K extends keyof EditingConfig>(key: K, value: EditingConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = EDITING_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for editing.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.editing[key] = value;
|
||||
// 只更新本地状态,不保存到后端
|
||||
const updateConfigLocal = <K extends ConfigKey>(key: K, value: any): void => {
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
(state.config[section] as any)[key] = value;
|
||||
};
|
||||
|
||||
const updateAppearanceConfig = async <K extends keyof AppearanceConfig>(key: K, value: AppearanceConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = APPEARANCE_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for appearance.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.appearance[key] = value;
|
||||
};
|
||||
|
||||
const updateUpdatesConfig = async <K extends keyof UpdatesConfig>(key: K, value: UpdatesConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = UPDATES_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for updates.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.updates[key] = value;
|
||||
};
|
||||
|
||||
const updateBackupConfig = async <K extends keyof GitBackupConfig>(key: K, value: GitBackupConfig[K]): Promise<void> => {
|
||||
// 确保配置已加载
|
||||
if (!state.configLoaded && !state.isLoading) {
|
||||
await initConfig();
|
||||
}
|
||||
|
||||
const backendKey = BACKUP_CONFIG_KEY_MAP[key];
|
||||
if (!backendKey) {
|
||||
throw new Error(`No backend key mapping found for backup.${key.toString()}`);
|
||||
}
|
||||
|
||||
await ConfigService.Set(backendKey, value);
|
||||
state.config.backup[key] = value;
|
||||
// 保存指定配置到后端
|
||||
const saveConfig = async <K extends ConfigKey>(key: K): Promise<void> => {
|
||||
const backendKey = CONFIG_KEY_MAP[key];
|
||||
const section = backendKey.split('.')[0] as ConfigSection;
|
||||
await ConfigService.Set(backendKey, (state.config[section] as any)[key]);
|
||||
};
|
||||
|
||||
// 加载配置
|
||||
@@ -155,22 +105,24 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const clamp = (value: number) => ConfigUtils.clamp(value, limit.min, limit.max);
|
||||
|
||||
return {
|
||||
increase: async () => await updateEditingConfig(key, clamp(state.config.editing[key] + 1)),
|
||||
decrease: async () => await updateEditingConfig(key, clamp(state.config.editing[key] - 1)),
|
||||
set: async (value: number) => await updateEditingConfig(key, clamp(value)),
|
||||
reset: async () => await updateEditingConfig(key, limit.default)
|
||||
increase: async () => await updateConfig(key, clamp(state.config.editing[key] + 1)),
|
||||
decrease: async () => await updateConfig(key, clamp(state.config.editing[key] - 1)),
|
||||
set: async (value: number) => await updateConfig(key, clamp(value)),
|
||||
reset: async () => await updateConfig(key, limit.default),
|
||||
increaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] + 1)),
|
||||
decreaseLocal: () => updateConfigLocal(key, clamp(state.config.editing[key] - 1))
|
||||
};
|
||||
};
|
||||
|
||||
const createEditingToggler = <T extends keyof EditingConfig>(key: T) =>
|
||||
async () => await updateEditingConfig(key, !state.config.editing[key] as EditingConfig[T]);
|
||||
async () => await updateConfig(key as ConfigKey, !state.config.editing[key] as EditingConfig[T]);
|
||||
|
||||
// 枚举值切换器
|
||||
const createEnumToggler = <T extends TabType>(key: 'tabType', values: readonly T[]) =>
|
||||
async () => {
|
||||
const currentIndex = values.indexOf(state.config.editing[key] as T);
|
||||
const nextIndex = (currentIndex + 1) % values.length;
|
||||
return await updateEditingConfig(key, values[nextIndex]);
|
||||
return await updateConfig(key, values[nextIndex]);
|
||||
};
|
||||
|
||||
// 重置配置
|
||||
@@ -192,26 +144,24 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
// 语言设置方法
|
||||
const setLanguage = async (language: LanguageType): Promise<void> => {
|
||||
await updateAppearanceConfig('language', language);
|
||||
|
||||
// 同步更新前端语言
|
||||
await updateConfig('language', language);
|
||||
const frontendLocale = ConfigUtils.backendLanguageToFrontend(language);
|
||||
locale.value = frontendLocale as any;
|
||||
};
|
||||
|
||||
// 系统主题设置方法
|
||||
const setSystemTheme = async (systemTheme: SystemThemeType): Promise<void> => {
|
||||
await updateAppearanceConfig('systemTheme', systemTheme);
|
||||
await updateConfig('systemTheme', systemTheme);
|
||||
};
|
||||
|
||||
// 当前主题设置方法
|
||||
const setCurrentTheme = async (themeName: string): Promise<void> => {
|
||||
await updateAppearanceConfig('currentTheme', themeName);
|
||||
await updateConfig('currentTheme', themeName);
|
||||
};
|
||||
|
||||
|
||||
// 初始化语言设置
|
||||
const initializeLanguage = async (): Promise<void> => {
|
||||
const initLanguage = async (): Promise<void> => {
|
||||
try {
|
||||
// 如果配置未加载,先加载配置
|
||||
if (!state.configLoaded) {
|
||||
@@ -238,21 +188,12 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const togglers = {
|
||||
tabIndent: createEditingToggler('enableTabIndent'),
|
||||
alwaysOnTop: async () => {
|
||||
await updateGeneralConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
||||
// 立即应用窗口置顶状态
|
||||
await updateConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
|
||||
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
|
||||
},
|
||||
tabType: createEnumToggler('tabType', CONFIG_LIMITS.tabType.values)
|
||||
};
|
||||
|
||||
// 字符串配置设置器
|
||||
const setters = {
|
||||
fontFamily: async (value: string) => await updateEditingConfig('fontFamily', value),
|
||||
fontWeight: async (value: string) => await updateEditingConfig('fontWeight', value),
|
||||
dataPath: async (value: string) => await updateGeneralConfig('dataPath', value),
|
||||
autoSaveDelay: async (value: number) => await updateEditingConfig('autoSaveDelay', value)
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
config: computed(() => state.config),
|
||||
@@ -269,7 +210,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
// 语言相关方法
|
||||
setLanguage,
|
||||
initializeLanguage,
|
||||
initLanguage,
|
||||
|
||||
// 主题相关方法
|
||||
setSystemTheme,
|
||||
@@ -281,10 +222,14 @@ export const useConfigStore = defineStore('config', () => {
|
||||
decreaseFontSize: adjusters.fontSize.decrease,
|
||||
resetFontSize: adjusters.fontSize.reset,
|
||||
setFontSize: adjusters.fontSize.set,
|
||||
// 字体大小操作
|
||||
increaseFontSizeLocal: adjusters.fontSize.increaseLocal,
|
||||
decreaseFontSizeLocal: adjusters.fontSize.decreaseLocal,
|
||||
saveFontSize: () => saveConfig('fontSize'),
|
||||
|
||||
// Tab操作
|
||||
toggleTabIndent: togglers.tabIndent,
|
||||
setEnableTabIndent: (value: boolean) => updateEditingConfig('enableTabIndent', value),
|
||||
setEnableTabIndent: (value: boolean) => updateConfig('enableTabIndent', value),
|
||||
...adjusters.tabSize,
|
||||
increaseTabSize: adjusters.tabSize.increase,
|
||||
decreaseTabSize: adjusters.tabSize.decrease,
|
||||
@@ -296,59 +241,53 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
// 窗口操作
|
||||
toggleAlwaysOnTop: togglers.alwaysOnTop,
|
||||
setAlwaysOnTop: (value: boolean) => updateGeneralConfig('alwaysOnTop', value),
|
||||
setAlwaysOnTop: (value: boolean) => updateConfig('alwaysOnTop', value),
|
||||
|
||||
// 字体操作
|
||||
setFontFamily: setters.fontFamily,
|
||||
setFontWeight: setters.fontWeight,
|
||||
setFontFamily: (value: string) => updateConfig('fontFamily', value),
|
||||
setFontWeight: (value: string) => updateConfig('fontWeight', value),
|
||||
|
||||
// 路径操作
|
||||
setDataPath: setters.dataPath,
|
||||
setDataPath: (value: string) => updateConfigLocal('dataPath', value),
|
||||
|
||||
// 保存配置相关方法
|
||||
setAutoSaveDelay: setters.autoSaveDelay,
|
||||
setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value),
|
||||
|
||||
// 热键配置相关方法
|
||||
setEnableGlobalHotkey: (value: boolean) => updateGeneralConfig('enableGlobalHotkey', value),
|
||||
setGlobalHotkey: (hotkey: any) => updateGeneralConfig('globalHotkey', hotkey),
|
||||
setEnableGlobalHotkey: (value: boolean) => updateConfig('enableGlobalHotkey', value),
|
||||
setGlobalHotkey: (hotkey: any) => updateConfig('globalHotkey', hotkey),
|
||||
|
||||
// 系统托盘配置相关方法
|
||||
setEnableSystemTray: (value: boolean) => updateGeneralConfig('enableSystemTray', value),
|
||||
setEnableSystemTray: (value: boolean) => updateConfig('enableSystemTray', value),
|
||||
|
||||
// 开机启动配置相关方法
|
||||
setStartAtLogin: async (value: boolean) => {
|
||||
// 先更新配置文件
|
||||
await updateGeneralConfig('startAtLogin', value);
|
||||
// 再调用系统设置API
|
||||
await updateConfig('startAtLogin', value);
|
||||
await StartupService.SetEnabled(value);
|
||||
},
|
||||
|
||||
// 窗口吸附配置相关方法
|
||||
setEnableWindowSnap: async (value: boolean) => await updateGeneralConfig('enableWindowSnap', value),
|
||||
setEnableWindowSnap: (value: boolean) => updateConfig('enableWindowSnap', value),
|
||||
|
||||
// 加载动画配置相关方法
|
||||
setEnableLoadingAnimation: async (value: boolean) => await updateGeneralConfig('enableLoadingAnimation', value),
|
||||
setEnableLoadingAnimation: (value: boolean) => updateConfig('enableLoadingAnimation', value),
|
||||
|
||||
// 标签页配置相关方法
|
||||
setEnableTabs: async (value: boolean) => await updateGeneralConfig('enableTabs', value),
|
||||
setEnableTabs: (value: boolean) => updateConfig('enableTabs', value),
|
||||
|
||||
// 更新配置相关方法
|
||||
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value),
|
||||
setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value),
|
||||
|
||||
// 备份配置相关方法
|
||||
setEnableBackup: async (value: boolean) => {
|
||||
await updateBackupConfig('enabled', value);
|
||||
},
|
||||
setAutoBackup: async (value: boolean) => {
|
||||
await updateBackupConfig('auto_backup', value);
|
||||
},
|
||||
setRepoUrl: async (value: string) => await updateBackupConfig('repo_url', value),
|
||||
setAuthMethod: async (value: AuthMethod) => await updateBackupConfig('auth_method', value),
|
||||
setUsername: async (value: string) => await updateBackupConfig('username', value),
|
||||
setPassword: async (value: string) => await updateBackupConfig('password', value),
|
||||
setToken: async (value: string) => await updateBackupConfig('token', value),
|
||||
setSshKeyPath: async (value: string) => await updateBackupConfig('ssh_key_path', value),
|
||||
setSshKeyPassphrase: async (value: string) => await updateBackupConfig('ssh_key_passphrase', value),
|
||||
setBackupInterval: async (value: number) => await updateBackupConfig('backup_interval', value),
|
||||
setEnableBackup: (value: boolean) => updateConfig('enabled', value),
|
||||
setAutoBackup: (value: boolean) => updateConfig('auto_backup', value),
|
||||
setRepoUrl: (value: string) => updateConfig('repo_url', value),
|
||||
setAuthMethod: (value: AuthMethod) => updateConfig('auth_method', value),
|
||||
setUsername: (value: string) => updateConfig('username', value),
|
||||
setPassword: (value: string) => updateConfig('password', value),
|
||||
setToken: (value: string) => updateConfig('token', value),
|
||||
setSshKeyPath: (value: string) => updateConfig('ssh_key_path', value),
|
||||
setSshKeyPassphrase: (value: string) => updateConfig('ssh_key_passphrase', value),
|
||||
setBackupInterval: (value: number) => updateConfig('backup_interval', value),
|
||||
};
|
||||
});
|
||||
@@ -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 => {
|
||||
if (doc.id !== undefined) {
|
||||
documents.value[doc.id] = doc;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -64,15 +65,7 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
clearError();
|
||||
};
|
||||
|
||||
const toggleDocumentSelector = () => {
|
||||
if (showDocumentSelector.value) {
|
||||
closeDocumentSelector();
|
||||
} else {
|
||||
openDocumentSelector();
|
||||
}
|
||||
};
|
||||
|
||||
// === 文档操作方法 ===
|
||||
|
||||
// 在新窗口中打开文档
|
||||
const openDocumentInNewWindow = async (docId: number): Promise<boolean> => {
|
||||
@@ -94,7 +87,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 +116,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 +141,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 +163,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 +204,10 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
// 如果URL中没有指定文档ID,则使用持久化的文档ID
|
||||
await openDocument(currentDocumentId.value);
|
||||
} else {
|
||||
// 否则打开默认文档
|
||||
await openDocument(DEFAULT_DOCUMENT_ID.value);
|
||||
// 否则打开第一个文档
|
||||
if (documents.value[0].id) {
|
||||
await openDocument(documents.value[0].id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize document store:', error);
|
||||
@@ -217,7 +215,6 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
};
|
||||
|
||||
return {
|
||||
DEFAULT_DOCUMENT_ID,
|
||||
// 状态
|
||||
documents,
|
||||
documentList,
|
||||
@@ -237,7 +234,6 @@ export const useDocumentStore = defineStore('document', () => {
|
||||
deleteDocument,
|
||||
openDocumentSelector,
|
||||
closeDocumentSelector,
|
||||
toggleDocumentSelector,
|
||||
setError,
|
||||
clearError,
|
||||
initialize,
|
||||
|
||||
@@ -4,8 +4,6 @@ import {EditorView} from '@codemirror/view';
|
||||
import {EditorState, Extension} from '@codemirror/state';
|
||||
import {useConfigStore} from './configStore';
|
||||
import {useDocumentStore} from './documentStore';
|
||||
import {usePanelStore} from './panelStore';
|
||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {DocumentService, ExtensionService} from '@/../bindings/voidraft/internal/services';
|
||||
import {ensureSyntaxTree} from "@codemirror/language";
|
||||
import {createBasicSetup} from '@/views/editor/basic/basicSetup';
|
||||
@@ -14,6 +12,7 @@ import {getTabExtensions, updateTabConfig} from '@/views/editor/basic/tabExtensi
|
||||
import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/basic/fontExtension';
|
||||
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
|
||||
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
|
||||
import {createWheelZoomExtension} from '@/views/editor/basic/wheelZoomExtension';
|
||||
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
|
||||
import {
|
||||
createDynamicExtensions,
|
||||
@@ -28,8 +27,6 @@ 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 {markdownPreviewExtension} from "@/views/editor/extensions/markdownPreview";
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
|
||||
export interface DocumentStats {
|
||||
@@ -94,84 +91,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 => {
|
||||
@@ -242,6 +161,13 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
fontWeight: configStore.config.editing.fontWeight
|
||||
});
|
||||
|
||||
const wheelZoomExtension = createWheelZoomExtension({
|
||||
increaseFontSize: () => configStore.increaseFontSizeLocal(),
|
||||
decreaseFontSize: () => configStore.decreaseFontSizeLocal(),
|
||||
onSave: () => configStore.saveFontSize(),
|
||||
saveDelay: 1000
|
||||
});
|
||||
|
||||
// 统计扩展
|
||||
const statsExtension = createStatsUpdateExtension(updateDocumentStats);
|
||||
|
||||
@@ -254,11 +180,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
enableAutoDetection: true
|
||||
});
|
||||
|
||||
const httpExtension = createHttpClientExtension();
|
||||
|
||||
// Markdown预览扩展
|
||||
const previewExtension = markdownPreviewExtension();
|
||||
|
||||
// 再次检查操作有效性
|
||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||
throw new Error('Operation cancelled');
|
||||
@@ -273,7 +194,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
}
|
||||
|
||||
// 动态扩展,传递文档ID以便扩展管理器可以预初始化
|
||||
const dynamicExtensions = await createDynamicExtensions(documentId);
|
||||
const dynamicExtensions = await createDynamicExtensions();
|
||||
|
||||
// 最终检查操作有效性
|
||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||
@@ -287,12 +208,11 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
themeExtension,
|
||||
...tabExtensions,
|
||||
fontExtension,
|
||||
wheelZoomExtension,
|
||||
statsExtension,
|
||||
contentChangeExtension,
|
||||
codeBlockExtension,
|
||||
...dynamicExtensions,
|
||||
...httpExtension,
|
||||
previewExtension
|
||||
];
|
||||
|
||||
// 创建编辑器状态
|
||||
@@ -316,7 +236,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
lastModified: new Date(),
|
||||
autoSaveTimer: createTimerManager(),
|
||||
syntaxTreeCache: null,
|
||||
// 修复:创建实例时从 documentStore 读取持久化的编辑器状态
|
||||
editorState: documentStore.documentStates[documentId]
|
||||
};
|
||||
|
||||
@@ -397,9 +316,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
//使用 nextTick + requestAnimationFrame 确保 DOM 完全渲染
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// 恢复编辑器状态(光标位置和滚动位置)
|
||||
restoreEditorState(instance, documentId);
|
||||
|
||||
// 聚焦编辑器
|
||||
instance.view.focus();
|
||||
|
||||
@@ -429,7 +345,6 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
instance.isDirty = false;
|
||||
instance.lastModified = new Date();
|
||||
}
|
||||
// 如果内容在保存期间被修改了,保持 isDirty 状态
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -458,15 +373,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 || '');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -635,6 +549,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// 应用Tab设置
|
||||
const applyTabSettings = () => {
|
||||
editorCache.values().forEach(instance => {
|
||||
@@ -687,55 +602,41 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
instance.view.destroy();
|
||||
});
|
||||
|
||||
// 清理 panelStore 状态(导航离开编辑器页面时)
|
||||
const panelStore = usePanelStore();
|
||||
panelStore.reset();
|
||||
|
||||
currentEditor.value = null;
|
||||
};
|
||||
|
||||
// 更新扩展
|
||||
const updateExtension = async (id: ExtensionID, enabled: boolean, config?: any) => {
|
||||
// 如果只是更新启用状态
|
||||
if (config === undefined) {
|
||||
await ExtensionService.UpdateExtensionEnabled(id, enabled);
|
||||
} else {
|
||||
const updateExtension = async (key: string, enabled: boolean, config?: any) => {
|
||||
// 更新启用状态
|
||||
await ExtensionService.UpdateExtensionEnabled(key, enabled);
|
||||
|
||||
// 如果需要更新配置
|
||||
await ExtensionService.UpdateExtensionState(id, enabled, config);
|
||||
if (config !== undefined) {
|
||||
await ExtensionService.UpdateExtensionConfig(key, config);
|
||||
}
|
||||
|
||||
// 更新前端编辑器扩展 - 应用于所有实例
|
||||
const manager = getExtensionManager();
|
||||
if (manager) {
|
||||
// 使用立即更新模式,跳过防抖
|
||||
manager.updateExtensionImmediate(id, enabled, config || {});
|
||||
// 直接更新前端扩展至所有视图
|
||||
manager.updateExtension(key, enabled, config);
|
||||
}
|
||||
|
||||
// 重新加载扩展配置
|
||||
await extensionStore.loadExtensions();
|
||||
if (manager) {
|
||||
manager.initExtensions(extensionStore.extensions);
|
||||
}
|
||||
|
||||
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 || '');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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', () => {
|
||||
@@ -12,9 +12,9 @@ export const useExtensionStore = defineStore('extension', () => {
|
||||
extensions.value.filter(ext => ext.enabled)
|
||||
);
|
||||
|
||||
// 获取启用的扩展ID列表
|
||||
// 获取启用的扩展ID列表 (key)
|
||||
const enabledExtensionIds = computed(() =>
|
||||
enabledExtensions.value.map(ext => ext.id)
|
||||
enabledExtensions.value.map(ext => ext.key).filter((k): k is string => k !== undefined)
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -22,7 +22,8 @@ export const useExtensionStore = defineStore('extension', () => {
|
||||
*/
|
||||
const loadExtensions = async (): Promise<void> => {
|
||||
try {
|
||||
extensions.value = await ExtensionService.GetAllExtensions();
|
||||
const result = await ExtensionService.GetAllExtensions();
|
||||
extensions.value = result.filter((ext): ext is Extension => ext !== null);
|
||||
} catch (err) {
|
||||
console.error('[ExtensionStore] Failed to load extensions:', err);
|
||||
}
|
||||
@@ -31,8 +32,8 @@ export const useExtensionStore = defineStore('extension', () => {
|
||||
/**
|
||||
* 获取扩展配置
|
||||
*/
|
||||
const getExtensionConfig = (id: ExtensionID): any => {
|
||||
const extension = extensions.value.find(ext => ext.id === id);
|
||||
const getExtensionConfig = (key: string): any => {
|
||||
const extension = extensions.value.find(ext => ext.key === key);
|
||||
return extension?.config ?? {};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {defineStore} from 'pinia';
|
||||
import {computed, ref} from 'vue';
|
||||
import {ExtensionID, KeyBinding, KeyBindingCommand} from '@/../bindings/voidraft/internal/models/models';
|
||||
import {KeyBinding} from '@/../bindings/voidraft/internal/models/ent/models';
|
||||
import {GetAllKeyBindings} from '@/../bindings/voidraft/internal/services/keybindingservice';
|
||||
|
||||
export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
@@ -14,13 +14,14 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
|
||||
// 按扩展分组的快捷键
|
||||
const keyBindingsByExtension = computed(() => {
|
||||
const groups = new Map<ExtensionID, KeyBinding[]>();
|
||||
const groups = new Map<string, KeyBinding[]>();
|
||||
|
||||
for (const binding of keyBindings.value) {
|
||||
if (!groups.has(binding.extension)) {
|
||||
groups.set(binding.extension, []);
|
||||
const ext = binding.extension || '';
|
||||
if (!groups.has(ext)) {
|
||||
groups.set(ext, []);
|
||||
}
|
||||
groups.get(binding.extension)!.push(binding);
|
||||
groups.get(ext)!.push(binding);
|
||||
}
|
||||
|
||||
return groups;
|
||||
@@ -28,13 +29,13 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
|
||||
// 获取指定扩展的快捷键
|
||||
const getKeyBindingsByExtension = computed(() =>
|
||||
(extension: ExtensionID) =>
|
||||
(extension: string) =>
|
||||
keyBindings.value.filter(kb => kb.extension === extension)
|
||||
);
|
||||
|
||||
// 按命令获取快捷键
|
||||
const getKeyBindingByCommand = computed(() =>
|
||||
(command: KeyBindingCommand) =>
|
||||
(command: string) =>
|
||||
keyBindings.value.find(kb => kb.command === command)
|
||||
);
|
||||
|
||||
@@ -42,13 +43,14 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
* 从后端加载快捷键配置
|
||||
*/
|
||||
const loadKeyBindings = async (): Promise<void> => {
|
||||
keyBindings.value = await GetAllKeyBindings();
|
||||
const result = await GetAllKeyBindings();
|
||||
keyBindings.value = result.filter((kb): kb is KeyBinding => kb !== null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否存在指定命令的快捷键
|
||||
*/
|
||||
const hasCommand = (command: KeyBindingCommand): boolean => {
|
||||
const hasCommand = (command: string): boolean => {
|
||||
return keyBindings.value.some(kb => kb.command === command && kb.enabled);
|
||||
};
|
||||
|
||||
@@ -57,10 +59,12 @@ export const useKeybindingStore = defineStore('keybinding', () => {
|
||||
* 获取扩展相关的所有扩展ID
|
||||
*/
|
||||
const getAllExtensionIds = computed(() => {
|
||||
const extensionIds = new Set<ExtensionID>();
|
||||
const extensionIds = new Set<string>();
|
||||
for (const binding of keyBindings.value) {
|
||||
if (binding.extension) {
|
||||
extensionIds.add(binding.extension);
|
||||
}
|
||||
}
|
||||
return Array.from(extensionIds);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { useDocumentStore } from './documentStore';
|
||||
|
||||
/**
|
||||
* 单个文档的预览状态
|
||||
*/
|
||||
interface DocumentPreviewState {
|
||||
isOpen: boolean;
|
||||
isClosing: boolean;
|
||||
blockFrom: number;
|
||||
blockTo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板状态管理 Store
|
||||
* 管理编辑器中各种面板的显示状态(按文档ID区分)
|
||||
*/
|
||||
export const usePanelStore = defineStore('panel', () => {
|
||||
// 当前编辑器视图引用
|
||||
const editorView = ref<EditorView | null>(null);
|
||||
|
||||
// 每个文档的预览状态 Map<documentId, PreviewState>
|
||||
const documentPreviews = ref<Map<number, DocumentPreviewState>>(new Map());
|
||||
|
||||
/**
|
||||
* 获取当前文档的预览状态
|
||||
*/
|
||||
const markdownPreview = computed(() => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) {
|
||||
return {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
};
|
||||
}
|
||||
|
||||
return documentPreviews.value.get(currentDocId) || {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 设置编辑器视图
|
||||
*/
|
||||
const setEditorView = (view: EditorView | null) => {
|
||||
editorView.value = view;
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开 Markdown 预览面板
|
||||
*/
|
||||
const openMarkdownPreview = (from: number, to: number) => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
isOpen: true,
|
||||
isClosing: false,
|
||||
blockFrom: from,
|
||||
blockTo: to
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始关闭 Markdown 预览面板
|
||||
*/
|
||||
const startClosingMarkdownPreview = () => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
const state = documentPreviews.value.get(currentDocId);
|
||||
if (state?.isOpen) {
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
...state,
|
||||
isClosing: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭 Markdown 预览面板
|
||||
*/
|
||||
const closeMarkdownPreview = () => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
isOpen: false,
|
||||
isClosing: false,
|
||||
blockFrom: 0,
|
||||
blockTo: 0
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新预览块的范围(用于实时预览)
|
||||
*/
|
||||
const updatePreviewRange = (from: number, to: number) => {
|
||||
const documentStore = useDocumentStore();
|
||||
const currentDocId = documentStore.currentDocumentId;
|
||||
|
||||
if (currentDocId === null) return;
|
||||
|
||||
const state = documentPreviews.value.get(currentDocId);
|
||||
if (state?.isOpen) {
|
||||
documentPreviews.value.set(currentDocId, {
|
||||
...state,
|
||||
blockFrom: from,
|
||||
blockTo: to
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查指定块是否正在预览
|
||||
*/
|
||||
const isBlockPreviewing = (from: number, to: number): boolean => {
|
||||
const preview = markdownPreview.value;
|
||||
return preview.isOpen &&
|
||||
preview.blockFrom === from &&
|
||||
preview.blockTo === to;
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置所有面板状态
|
||||
*/
|
||||
const reset = () => {
|
||||
documentPreviews.value.clear();
|
||||
editorView.value = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理指定文档的预览状态(文档关闭时调用)
|
||||
*/
|
||||
const clearDocumentPreview = (documentId: number) => {
|
||||
documentPreviews.value.delete(documentId);
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
editorView,
|
||||
markdownPreview,
|
||||
|
||||
// 方法
|
||||
setEditorView,
|
||||
openMarkdownPreview,
|
||||
startClosingMarkdownPreview,
|
||||
closeMarkdownPreview,
|
||||
updatePreviewRange,
|
||||
isBlockPreviewing,
|
||||
reset,
|
||||
clearDocumentPreview
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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) {
|
||||
@@ -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,16 @@ export const useTabStore = defineStore('tab', () => {
|
||||
initializeTab,
|
||||
clearAllTabs,
|
||||
updateTabTitle,
|
||||
validateTabs,
|
||||
|
||||
// 工具方法
|
||||
hasTab,
|
||||
getTab
|
||||
};
|
||||
}, {
|
||||
persist: {
|
||||
key: 'voidraft-tabs',
|
||||
storage: localStorage,
|
||||
pick: ['tabsMap', 'tabOrder'],
|
||||
},
|
||||
});
|
||||
@@ -1,191 +1,157 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import {SystemThemeType, ThemeType, Theme, 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 {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 ThemeColorConfig = { [_: string]: any };
|
||||
type ThemeOption = { name: string; type: ThemeType };
|
||||
|
||||
const resolveThemeName = (name?: string) =>
|
||||
name && themePresetMap[name] ? name : FALLBACK_THEME_NAME;
|
||||
|
||||
const createThemeOptions = (type: ThemeType): ThemeOption[] =>
|
||||
themePresetList
|
||||
.filter(preset => preset.type === type)
|
||||
.map(preset => ({name: preset.name, type: preset.type}));
|
||||
|
||||
const darkThemeOptions = createThemeOptions(ThemeType.TypeDark);
|
||||
const lightThemeOptions = createThemeOptions(ThemeType.TypeLight);
|
||||
|
||||
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.GetThemeByKey(safeName);
|
||||
if (theme?.colors) {
|
||||
const colors = cloneColors(theme.colors);
|
||||
colors.themeName = safeName;
|
||||
return colors;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load theme override:', error);
|
||||
}
|
||||
return getPresetColors(safeName);
|
||||
};
|
||||
|
||||
/**
|
||||
* 主题管理 Store
|
||||
* 职责:管理主题状态、颜色配置和预设主题列表
|
||||
*/
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const configStore = useConfigStore();
|
||||
|
||||
// 所有主题列表
|
||||
const allThemes = ref<Theme[]>([]);
|
||||
|
||||
// 当前主题的颜色配置
|
||||
const currentColors = ref<ThemeColors | null>(null);
|
||||
|
||||
// 计算属性:当前系统主题模式
|
||||
const currentTheme = computed(() =>
|
||||
configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
||||
const currentTheme = computed(
|
||||
() => configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
|
||||
);
|
||||
|
||||
// 计算属性:当前是否为深色模式
|
||||
const isDarkMode = computed(() =>
|
||||
const isDarkMode = computed(
|
||||
() =>
|
||||
currentTheme.value === SystemThemeType.SystemThemeDark ||
|
||||
(currentTheme.value === SystemThemeType.SystemThemeAuto &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
);
|
||||
|
||||
// 计算属性:根据类型获取主题列表
|
||||
const darkThemes = computed(() =>
|
||||
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeDark)
|
||||
const availableThemes = computed<ThemeOption[]>(() =>
|
||||
isDarkMode.value ? darkThemeOptions : lightThemeOptions
|
||||
);
|
||||
|
||||
const lightThemes = computed(() =>
|
||||
allThemes.value.filter(t => t.type === ThemeType.ThemeTypeLight)
|
||||
);
|
||||
|
||||
// 计算属性:当前可用的主题列表
|
||||
const availableThemes = computed(() =>
|
||||
isDarkMode.value ? darkThemes.value : lightThemes.value
|
||||
);
|
||||
|
||||
// 应用主题到 DOM
|
||||
const applyThemeToDOM = (theme: SystemThemeType) => {
|
||||
const themeMap = {
|
||||
[SystemThemeType.SystemThemeAuto]: 'auto',
|
||||
[SystemThemeType.SystemThemeDark]: 'dark',
|
||||
[SystemThemeType.SystemThemeLight]: 'light'
|
||||
[SystemThemeType.SystemThemeLight]: 'light',
|
||||
};
|
||||
document.documentElement.setAttribute('data-theme', themeMap[theme]);
|
||||
};
|
||||
|
||||
// 从数据库加载所有主题
|
||||
const loadAllThemes = async () => {
|
||||
try {
|
||||
const themes = await ThemeService.GetAllThemes();
|
||||
allThemes.value = (themes || []).filter((t): t is Theme => t !== null);
|
||||
return allThemes.value;
|
||||
} catch (error) {
|
||||
console.error('Failed to load themes from database:', error);
|
||||
allThemes.value = [];
|
||||
return [];
|
||||
}
|
||||
const loadThemeColors = async (themeName?: string) => {
|
||||
const targetName = resolveThemeName(
|
||||
themeName || configStore.config?.appearance?.currentTheme
|
||||
);
|
||||
currentColors.value = await fetchThemeColors(targetName);
|
||||
};
|
||||
|
||||
// 初始化主题颜色
|
||||
const initializeThemeColors = async () => {
|
||||
// 加载所有主题
|
||||
await loadAllThemes();
|
||||
|
||||
// 从配置获取当前主题名称并加载
|
||||
const currentThemeName = configStore.config?.appearance?.currentTheme || 'default-dark';
|
||||
|
||||
const theme = allThemes.value.find(t => t.name === currentThemeName);
|
||||
|
||||
if (!theme) {
|
||||
console.error(`Theme not found: ${currentThemeName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 直接设置当前主题颜色
|
||||
currentColors.value = theme.colors as ThemeColors;
|
||||
const initTheme = async () => {
|
||||
applyThemeToDOM(currentTheme.value);
|
||||
await loadThemeColors();
|
||||
};
|
||||
|
||||
// 初始化主题
|
||||
const initializeTheme = async () => {
|
||||
const theme = currentTheme.value;
|
||||
applyThemeToDOM(theme);
|
||||
await initializeThemeColors();
|
||||
};
|
||||
|
||||
// 设置系统主题模式(深色/浅色/自动)
|
||||
const setTheme = async (theme: SystemThemeType) => {
|
||||
await configStore.setSystemTheme(theme);
|
||||
applyThemeToDOM(theme);
|
||||
refreshEditorTheme();
|
||||
};
|
||||
|
||||
// 切换到指定的预设主题
|
||||
const switchToTheme = async (themeName: string) => {
|
||||
const theme = allThemes.value.find(t => t.name === themeName);
|
||||
if (!theme) {
|
||||
if (!themePresetMap[themeName]) {
|
||||
console.error('Theme not found:', themeName);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 直接设置当前主题颜色
|
||||
currentColors.value = theme.colors as ThemeColors;
|
||||
|
||||
// 持久化到配置
|
||||
await loadThemeColors(themeName);
|
||||
await configStore.setCurrentTheme(themeName);
|
||||
|
||||
// 刷新编辑器
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 更新当前主题的颜色配置
|
||||
const updateCurrentColors = (colors: Partial<ThemeColors>) => {
|
||||
if (!currentColors.value) return;
|
||||
Object.assign(currentColors.value, colors);
|
||||
};
|
||||
|
||||
// 保存当前主题颜色到数据库
|
||||
const saveCurrentTheme = async () => {
|
||||
if (!currentColors.value) {
|
||||
throw new Error('No theme selected');
|
||||
}
|
||||
|
||||
const theme = allThemes.value.find(t => t.name === currentColors.value!.name);
|
||||
if (!theme) {
|
||||
throw new Error('Theme not found');
|
||||
}
|
||||
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||
currentColors.value.themeName = themeName;
|
||||
|
||||
await ThemeService.UpdateTheme(theme.id, currentColors.value as ThemeColorConfig);
|
||||
await ThemeService.UpdateTheme(themeName, currentColors.value as ThemeColorConfig);
|
||||
|
||||
await loadThemeColors(themeName);
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 重置当前主题为预设配置
|
||||
const resetCurrentTheme = async () => {
|
||||
if (!currentColors.value) {
|
||||
throw new Error('No theme selected');
|
||||
}
|
||||
|
||||
// 调用后端重置
|
||||
await ThemeService.ResetTheme(0, currentColors.value.name);
|
||||
|
||||
// 重新加载所有主题
|
||||
await loadAllThemes();
|
||||
|
||||
const updatedTheme = allThemes.value.find(t => t.name === currentColors.value!.name);
|
||||
|
||||
if (updatedTheme) {
|
||||
currentColors.value = updatedTheme.colors as ThemeColors;
|
||||
}
|
||||
const themeName = resolveThemeName(currentColors.value.themeName);
|
||||
await ThemeService.ResetTheme(themeName);
|
||||
|
||||
await loadThemeColors(themeName);
|
||||
refreshEditorTheme();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 刷新编辑器主题
|
||||
const refreshEditorTheme = () => {
|
||||
applyThemeToDOM(currentTheme.value);
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
editorStore?.applyThemeSettings();
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
allThemes,
|
||||
darkThemes,
|
||||
lightThemes,
|
||||
availableThemes,
|
||||
currentTheme,
|
||||
currentColors,
|
||||
isDarkMode,
|
||||
|
||||
// 方法
|
||||
setTheme,
|
||||
switchToTheme,
|
||||
initializeTheme,
|
||||
loadAllThemes,
|
||||
initTheme,
|
||||
updateCurrentColors,
|
||||
saveCurrentTheme,
|
||||
resetCurrentTheme,
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import {defineStore} from 'pinia';
|
||||
import {ref} from 'vue';
|
||||
import {TranslationService} from '@/../bindings/voidraft/internal/services';
|
||||
import {LanguageInfo, TRANSLATION_ERRORS, TranslationResult} from '@/common/constant/translation';
|
||||
/**
|
||||
* 翻译结果接口
|
||||
*/
|
||||
export interface TranslationResult {
|
||||
translatedText: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言信息接口
|
||||
*/
|
||||
export interface LanguageInfo {
|
||||
Code: string; // 语言代码
|
||||
Name: string; // 语言名称
|
||||
}
|
||||
/**
|
||||
* 翻译相关的错误消息
|
||||
*/
|
||||
export const TRANSLATION_ERRORS = {
|
||||
NO_TEXT: 'no text to translate',
|
||||
TRANSLATION_FAILED: 'translation failed',
|
||||
} as const;
|
||||
|
||||
export const useTranslationStore = defineStore('translation', () => {
|
||||
// 基础状态
|
||||
|
||||
@@ -3,11 +3,15 @@ import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
||||
import {useEditorStore} from '@/stores/editorStore';
|
||||
import {useDocumentStore} from '@/stores/documentStore';
|
||||
import {useConfigStore} from '@/stores/configStore';
|
||||
import {createWheelZoomHandler} from './basic/wheelZoomExtension';
|
||||
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
||||
import {useWindowStore} from "@/stores/windowStore";
|
||||
import {useWindowStore} from '@/stores/windowStore';
|
||||
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
||||
import {useTabStore} from "@/stores/tabStore";
|
||||
import {useTabStore} from '@/stores/tabStore';
|
||||
import ContextMenu from '@/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';
|
||||
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
const documentStore = useDocumentStore();
|
||||
@@ -19,47 +23,39 @@ const editorElement = ref<HTMLElement | null>(null);
|
||||
|
||||
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
|
||||
|
||||
// 创建滚轮缩放处理器
|
||||
const wheelHandler = createWheelZoomHandler(
|
||||
configStore.increaseFontSize,
|
||||
configStore.decreaseFontSize
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!editorElement.value) return;
|
||||
|
||||
// 从URL查询参数中获取documentId
|
||||
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
|
||||
|
||||
// 初始化文档存储,优先使用URL参数中的文档ID
|
||||
await documentStore.initialize(urlDocumentId);
|
||||
|
||||
// 设置编辑器容器
|
||||
editorStore.setEditorContainer(editorElement.value);
|
||||
|
||||
await tabStore.initializeTab();
|
||||
|
||||
// 添加滚轮事件监听
|
||||
editorElement.value.addEventListener('wheel', wheelHandler, {passive: false});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 移除滚轮事件监听
|
||||
if (editorElement.value) {
|
||||
editorElement.value.removeEventListener('wheel', wheelHandler);
|
||||
}
|
||||
editorStore.clearAllEditors();
|
||||
|
||||
contextMenuManager.destroy();
|
||||
translatorManager.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<div ref="editorElement" class="editor"></div>
|
||||
<Toolbar/>
|
||||
<!-- 加载动画 -->
|
||||
<transition name="loading-fade">
|
||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
||||
</transition>
|
||||
<!-- 编辑器区域 -->
|
||||
<div ref="editorElement" class="editor"></div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<Toolbar/>
|
||||
<!-- 右键菜单 -->
|
||||
<ContextMenu :portal-target="editorElement"/>
|
||||
<!-- 翻译器弹窗 -->
|
||||
<TranslatorDialog :portal-target="editorElement"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -74,8 +70,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +85,6 @@ onBeforeUnmount(() => {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 加载动画过渡效果
|
||||
.loading-fade-enter-active,
|
||||
.loading-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -1,33 +1,47 @@
|
||||
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import {EditorView, ViewPlugin, ViewUpdate} from '@codemirror/view';
|
||||
import type {Text} from '@codemirror/state';
|
||||
import {useEditorStore} from '@/stores/editorStore';
|
||||
|
||||
/**
|
||||
* 内容变化监听插件 - 集成文档和编辑器管理
|
||||
*/
|
||||
export function createContentChangePlugin() {
|
||||
return ViewPlugin.fromClass(
|
||||
class ContentChangePlugin {
|
||||
private editorStore = useEditorStore();
|
||||
private lastContent = '';
|
||||
private readonly editorStore = useEditorStore();
|
||||
private lastDoc: Text;
|
||||
private rafId: number | null = null;
|
||||
private pendingNotification = false;
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.lastContent = view.state.doc.toString();
|
||||
this.lastDoc = view.state.doc;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.docChanged) return;
|
||||
|
||||
const newContent = this.view.state.doc.toString();
|
||||
if (newContent === this.lastContent) return;
|
||||
|
||||
this.lastContent = newContent;
|
||||
|
||||
this.editorStore.onContentChange();
|
||||
if (!update.docChanged || update.state.doc === this.lastDoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastDoc = update.state.doc;
|
||||
this.scheduleNotification();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
this.pendingNotification = false;
|
||||
}
|
||||
|
||||
private scheduleNotification() {
|
||||
if (this.pendingNotification) return;
|
||||
|
||||
this.pendingNotification = true;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.pendingNotification = false;
|
||||
this.rafId = null;
|
||||
this.editorStore.onContentChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,22 +1,61 @@
|
||||
// 处理滚轮缩放字体的事件处理函数
|
||||
export const createWheelZoomHandler = (
|
||||
increaseFontSize: () => void,
|
||||
decreaseFontSize: () => void
|
||||
) => {
|
||||
return (event: WheelEvent) => {
|
||||
// 检查是否按住了Ctrl键
|
||||
if (event.ctrlKey) {
|
||||
// 阻止默认行为(防止页面缩放)
|
||||
import {EditorView} from '@codemirror/view';
|
||||
import type {Extension} from '@codemirror/state';
|
||||
import {createDebounce} from '@/common/utils/debounce';
|
||||
|
||||
type FontAdjuster = () => void;
|
||||
type SaveCallback = () => Promise<void> | void;
|
||||
|
||||
export interface WheelZoomOptions {
|
||||
/** 增加字体大小的回调(立即执行) */
|
||||
increaseFontSize: FontAdjuster;
|
||||
/** 减少字体大小的回调(立即执行) */
|
||||
decreaseFontSize: FontAdjuster;
|
||||
/** 保存回调(防抖执行),在滚动结束后调用 */
|
||||
onSave?: SaveCallback;
|
||||
/** 保存防抖延迟(毫秒),默认 300ms */
|
||||
saveDelay?: number;
|
||||
}
|
||||
|
||||
export const createWheelZoomExtension = (options: WheelZoomOptions): Extension => {
|
||||
const {increaseFontSize, decreaseFontSize, onSave, saveDelay = 300} = options;
|
||||
|
||||
// 如果有 onSave 回调,创建防抖版本
|
||||
const {debouncedFn: debouncedSave} = onSave
|
||||
? createDebounce(() => {
|
||||
try {
|
||||
const result = onSave();
|
||||
if (result && typeof (result as Promise<void>).then === 'function') {
|
||||
(result as Promise<void>).catch((error) => {
|
||||
console.error('Failed to save font size:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save font size:', error);
|
||||
}
|
||||
}, {delay: saveDelay})
|
||||
: {debouncedFn: null};
|
||||
|
||||
return EditorView.domEventHandlers({
|
||||
wheel(event) {
|
||||
if (!event.ctrlKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// 根据滚轮方向增大或减小字体
|
||||
// 立即更新字体大小
|
||||
if (event.deltaY < 0) {
|
||||
// 向上滚动,增大字体
|
||||
increaseFontSize();
|
||||
} else {
|
||||
// 向下滚动,减小字体
|
||||
} else if (event.deltaY > 0) {
|
||||
decreaseFontSize();
|
||||
}
|
||||
|
||||
// 防抖保存
|
||||
if (debouncedSave) {
|
||||
debouncedSave();
|
||||
}
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* 编辑器上下文菜单样式
|
||||
* 支持系统主题自动适配
|
||||
*/
|
||||
|
||||
.cm-context-menu {
|
||||
position: fixed;
|
||||
background-color: var(--settings-card-bg);
|
||||
color: var(--settings-text);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
/* 优化阴影效果,只在右下角显示自然的阴影 */
|
||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
|
||||
overflow: visible; /* 确保子菜单可以显示在外部 */
|
||||
}
|
||||
|
||||
.cm-context-menu-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
transition: all 0.1s ease;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cm-context-menu-item:hover {
|
||||
background-color: var(--toolbar-button-hover);
|
||||
color: var(--toolbar-text);
|
||||
}
|
||||
|
||||
.cm-context-menu-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-shortcut {
|
||||
opacity: 0.7;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--settings-input-bg);
|
||||
color: var(--settings-text-secondary);
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-ripple {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background-color: var(--selection-bg);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0.5;
|
||||
transform: scale(0);
|
||||
transition: transform 0.3s ease-out, opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 菜单分组标题样式 */
|
||||
.cm-context-menu-group-title {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 菜单分隔线样式 */
|
||||
.cm-context-menu-divider {
|
||||
height: 1px;
|
||||
background-color: var(--border-color);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* 子菜单样式 */
|
||||
.cm-context-submenu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-with-submenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-with-submenu::after {
|
||||
content: "›";
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
font-size: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cm-context-submenu {
|
||||
position: fixed; /* 改为fixed定位,避免受父元素影响 */
|
||||
min-width: 180px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(10px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
z-index: 10000;
|
||||
border-radius: 6px;
|
||||
background-color: var(--settings-card-bg);
|
||||
color: var(--settings-text);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 4px 0;
|
||||
/* 子菜单也使用相同的阴影效果 */
|
||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.cm-context-menu-item-with-submenu:hover .cm-context-submenu {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* 深色主题下的特殊样式 */
|
||||
:root[data-theme="dark"] .cm-context-menu {
|
||||
/* 深色主题下阴影更深,但仍然只在右下角 */
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cm-context-submenu {
|
||||
/* 深色主题下子菜单阴影 */
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cm-context-menu-divider {
|
||||
background-color: var(--dark-border-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 动画相关类 */
|
||||
.cm-context-menu.show {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.cm-context-menu.hide {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
/**
|
||||
* 上下文菜单视图实现
|
||||
*/
|
||||
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { MenuItem } from "../contextMenu";
|
||||
import "./contextMenu.css";
|
||||
|
||||
// 为Window对象添加cmSubmenus属性
|
||||
declare global {
|
||||
interface Window {
|
||||
cmSubmenus?: Map<string, HTMLElement>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单项元素池,用于复用DOM元素
|
||||
*/
|
||||
class MenuItemPool {
|
||||
private pool: HTMLElement[] = [];
|
||||
private maxPoolSize = 50; // 最大池大小
|
||||
|
||||
/**
|
||||
* 获取或创建菜单项元素
|
||||
*/
|
||||
get(): HTMLElement {
|
||||
if (this.pool.length > 0) {
|
||||
return this.pool.pop()!;
|
||||
}
|
||||
|
||||
const menuItem = document.createElement("div");
|
||||
menuItem.className = "cm-context-menu-item";
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回收菜单项元素
|
||||
*/
|
||||
release(element: HTMLElement): void {
|
||||
if (this.pool.length < this.maxPoolSize) {
|
||||
// 清理元素状态
|
||||
element.className = "cm-context-menu-item";
|
||||
element.innerHTML = "";
|
||||
element.style.cssText = "";
|
||||
|
||||
// 移除所有事件监听器(通过克隆节点)
|
||||
const cleanElement = element.cloneNode(false) as HTMLElement;
|
||||
this.pool.push(cleanElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空池
|
||||
*/
|
||||
clear(): void {
|
||||
this.pool.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文菜单管理器
|
||||
*/
|
||||
class ContextMenuManager {
|
||||
private static instance: ContextMenuManager;
|
||||
|
||||
private menuElement: HTMLElement | null = null;
|
||||
private submenuPool: Map<string, HTMLElement> = new Map();
|
||||
private menuItemPool = new MenuItemPool();
|
||||
private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private keyDownHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private currentView: EditorView | null = null;
|
||||
private activeSubmenus: Set<HTMLElement> = new Set();
|
||||
private ripplePool: HTMLElement[] = [];
|
||||
|
||||
// 事件委托处理器
|
||||
private menuClickHandler: ((e: MouseEvent) => void) | null = null;
|
||||
private menuMouseHandler: ((e: MouseEvent) => void) | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.initializeEventHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
static getInstance(): ContextMenuManager {
|
||||
if (!ContextMenuManager.instance) {
|
||||
ContextMenuManager.instance = new ContextMenuManager();
|
||||
}
|
||||
return ContextMenuManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化事件处理器
|
||||
*/
|
||||
private initializeEventHandlers(): void {
|
||||
// 点击事件委托
|
||||
this.menuClickHandler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
|
||||
|
||||
if (menuItem && menuItem.dataset.command) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 添加点击动画
|
||||
this.addRippleEffect(menuItem, e);
|
||||
|
||||
// 执行命令
|
||||
const commandName = menuItem.dataset.command;
|
||||
const command = this.getCommandByName(commandName);
|
||||
if (command && this.currentView) {
|
||||
command(this.currentView);
|
||||
}
|
||||
|
||||
// 隐藏菜单
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标事件委托
|
||||
this.menuMouseHandler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
|
||||
|
||||
if (!menuItem) return;
|
||||
|
||||
if (e.type === 'mouseenter') {
|
||||
this.handleMenuItemMouseEnter(menuItem);
|
||||
} else if (e.type === 'mouseleave') {
|
||||
this.handleMenuItemMouseLeave(menuItem, e);
|
||||
}
|
||||
};
|
||||
|
||||
// 键盘事件处理器
|
||||
this.keyDownHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// 点击外部关闭处理器
|
||||
this.clickOutsideHandler = (e: MouseEvent) => {
|
||||
if (this.menuElement && !this.isClickInsideMenu(e.target as Node)) {
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建主菜单元素
|
||||
*/
|
||||
private getOrCreateMenuElement(): HTMLElement {
|
||||
if (!this.menuElement) {
|
||||
this.menuElement = document.createElement("div");
|
||||
this.menuElement.className = "cm-context-menu";
|
||||
this.menuElement.style.display = "none";
|
||||
document.body.appendChild(this.menuElement);
|
||||
|
||||
// 阻止菜单内右键点击冒泡
|
||||
this.menuElement.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
|
||||
// 添加事件委托
|
||||
this.menuElement.addEventListener('click', this.menuClickHandler!);
|
||||
this.menuElement.addEventListener('mouseenter', this.menuMouseHandler!, true);
|
||||
this.menuElement.addEventListener('mouseleave', this.menuMouseHandler!, true);
|
||||
}
|
||||
return this.menuElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或获取子菜单元素
|
||||
*/
|
||||
private getOrCreateSubmenu(id: string): HTMLElement {
|
||||
if (!this.submenuPool.has(id)) {
|
||||
const submenu = document.createElement("div");
|
||||
submenu.className = "cm-context-menu cm-context-submenu";
|
||||
submenu.style.display = "none";
|
||||
document.body.appendChild(submenu);
|
||||
this.submenuPool.set(id, submenu);
|
||||
|
||||
// 阻止子菜单点击事件冒泡
|
||||
submenu.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// 添加事件委托
|
||||
submenu.addEventListener('click', this.menuClickHandler!);
|
||||
submenu.addEventListener('mouseenter', this.menuMouseHandler!, true);
|
||||
submenu.addEventListener('mouseleave', this.menuMouseHandler!, true);
|
||||
}
|
||||
return this.submenuPool.get(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建菜单项DOM元素
|
||||
*/
|
||||
private createMenuItemElement(item: MenuItem): HTMLElement {
|
||||
const menuItem = this.menuItemPool.get();
|
||||
|
||||
// 如果有子菜单,添加相应类
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
menuItem.classList.add("cm-context-menu-item-with-submenu");
|
||||
}
|
||||
|
||||
// 创建内容容器
|
||||
const contentContainer = document.createElement("div");
|
||||
contentContainer.className = "cm-context-menu-item-label";
|
||||
|
||||
// 标签文本
|
||||
const label = document.createElement("span");
|
||||
label.textContent = item.label;
|
||||
contentContainer.appendChild(label);
|
||||
menuItem.appendChild(contentContainer);
|
||||
|
||||
// 快捷键提示(如果有)
|
||||
if (item.shortcut) {
|
||||
const shortcut = document.createElement("span");
|
||||
shortcut.className = "cm-context-menu-item-shortcut";
|
||||
shortcut.textContent = item.shortcut;
|
||||
menuItem.appendChild(shortcut);
|
||||
}
|
||||
|
||||
// 存储命令信息用于事件委托
|
||||
if (item.command) {
|
||||
menuItem.dataset.command = this.registerCommand(item.command);
|
||||
}
|
||||
|
||||
// 处理子菜单
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
const submenuId = `submenu-${item.label.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
menuItem.dataset.submenuId = submenuId;
|
||||
|
||||
const submenu = this.getOrCreateSubmenu(submenuId);
|
||||
this.populateSubmenu(submenu, item.submenu);
|
||||
|
||||
// 记录子菜单
|
||||
if (!window.cmSubmenus) {
|
||||
window.cmSubmenus = new Map();
|
||||
}
|
||||
window.cmSubmenus.set(submenuId, submenu);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充子菜单内容
|
||||
*/
|
||||
private populateSubmenu(submenu: HTMLElement, items: MenuItem[]): void {
|
||||
// 清空现有内容
|
||||
while (submenu.firstChild) {
|
||||
submenu.removeChild(submenu.firstChild);
|
||||
}
|
||||
|
||||
// 添加子菜单项
|
||||
items.forEach(item => {
|
||||
const subMenuItemElement = this.createMenuItemElement(item);
|
||||
submenu.appendChild(subMenuItemElement);
|
||||
});
|
||||
|
||||
// 初始状态设置为隐藏
|
||||
submenu.style.opacity = '0';
|
||||
submenu.style.pointerEvents = 'none';
|
||||
submenu.style.visibility = 'hidden';
|
||||
submenu.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令注册和管理
|
||||
*/
|
||||
private commands: Map<string, (view: EditorView) => void> = new Map();
|
||||
private commandCounter = 0;
|
||||
|
||||
private registerCommand(command: (view: EditorView) => void): string {
|
||||
const commandId = `cmd_${this.commandCounter++}`;
|
||||
this.commands.set(commandId, command);
|
||||
return commandId;
|
||||
}
|
||||
|
||||
private getCommandByName(commandId: string): ((view: EditorView) => void) | undefined {
|
||||
return this.commands.get(commandId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单项鼠标进入事件
|
||||
*/
|
||||
private handleMenuItemMouseEnter(menuItem: HTMLElement): void {
|
||||
const submenuId = menuItem.dataset.submenuId;
|
||||
if (!submenuId) return;
|
||||
|
||||
const submenu = this.submenuPool.get(submenuId);
|
||||
if (!submenu) return;
|
||||
|
||||
const rect = menuItem.getBoundingClientRect();
|
||||
|
||||
// 计算子菜单位置
|
||||
submenu.style.left = `${rect.right}px`;
|
||||
submenu.style.top = `${rect.top}px`;
|
||||
|
||||
// 检查子菜单是否会超出屏幕
|
||||
requestAnimationFrame(() => {
|
||||
const submenuRect = submenu.getBoundingClientRect();
|
||||
if (submenuRect.right > window.innerWidth) {
|
||||
submenu.style.left = `${rect.left - submenuRect.width}px`;
|
||||
}
|
||||
|
||||
if (submenuRect.bottom > window.innerHeight) {
|
||||
const newTop = rect.top - (submenuRect.bottom - window.innerHeight);
|
||||
submenu.style.top = `${Math.max(0, newTop)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
// 显示子菜单
|
||||
submenu.style.opacity = '1';
|
||||
submenu.style.pointerEvents = 'auto';
|
||||
submenu.style.visibility = 'visible';
|
||||
submenu.style.transform = 'translateX(0)';
|
||||
|
||||
this.activeSubmenus.add(submenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单项鼠标离开事件
|
||||
*/
|
||||
private handleMenuItemMouseLeave(menuItem: HTMLElement, e: MouseEvent): void {
|
||||
const submenuId = menuItem.dataset.submenuId;
|
||||
if (!submenuId) return;
|
||||
|
||||
const submenu = this.submenuPool.get(submenuId);
|
||||
if (!submenu) return;
|
||||
|
||||
// 检查是否移动到子菜单上
|
||||
const toElement = e.relatedTarget as HTMLElement;
|
||||
if (submenu.contains(toElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideSubmenu(submenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏子菜单
|
||||
*/
|
||||
private hideSubmenu(submenu: HTMLElement): void {
|
||||
submenu.style.opacity = '0';
|
||||
submenu.style.pointerEvents = 'none';
|
||||
submenu.style.transform = 'translateX(10px)';
|
||||
|
||||
setTimeout(() => {
|
||||
if (submenu.style.opacity === '0') {
|
||||
submenu.style.visibility = 'hidden';
|
||||
}
|
||||
}, 200);
|
||||
|
||||
this.activeSubmenus.delete(submenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加点击波纹效果
|
||||
*/
|
||||
private addRippleEffect(menuItem: HTMLElement, e: MouseEvent): void {
|
||||
let ripple: HTMLElement;
|
||||
|
||||
if (this.ripplePool.length > 0) {
|
||||
ripple = this.ripplePool.pop()!;
|
||||
} else {
|
||||
ripple = document.createElement("div");
|
||||
ripple.className = "cm-context-menu-item-ripple";
|
||||
}
|
||||
|
||||
// 计算相对位置
|
||||
const rect = menuItem.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
ripple.style.left = (x - 50) + "px";
|
||||
ripple.style.top = (y - 50) + "px";
|
||||
ripple.style.transform = "scale(0)";
|
||||
ripple.style.opacity = "1";
|
||||
|
||||
menuItem.appendChild(ripple);
|
||||
|
||||
// 执行动画
|
||||
requestAnimationFrame(() => {
|
||||
ripple.style.transform = "scale(1)";
|
||||
ripple.style.opacity = "0";
|
||||
|
||||
setTimeout(() => {
|
||||
if (ripple.parentNode === menuItem) {
|
||||
menuItem.removeChild(ripple);
|
||||
this.ripplePool.push(ripple);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点击是否在菜单内
|
||||
*/
|
||||
private isClickInsideMenu(target: Node): boolean {
|
||||
if (this.menuElement && this.menuElement.contains(target)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否在子菜单内
|
||||
for (const submenu of this.activeSubmenus) {
|
||||
if (submenu.contains(target)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位菜单元素
|
||||
*/
|
||||
private positionMenu(menu: HTMLElement, clientX: number, clientY: number): void {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
let left = clientX;
|
||||
let top = clientY;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const menuWidth = menu.offsetWidth;
|
||||
const menuHeight = menu.offsetHeight;
|
||||
|
||||
if (left + menuWidth > windowWidth) {
|
||||
left = windowWidth - menuWidth - 5;
|
||||
}
|
||||
|
||||
if (top + menuHeight > windowHeight) {
|
||||
top = windowHeight - menuHeight - 5;
|
||||
}
|
||||
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示上下文菜单
|
||||
*/
|
||||
show(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
|
||||
this.currentView = view;
|
||||
|
||||
// 获取或创建菜单元素
|
||||
const menu = this.getOrCreateMenuElement();
|
||||
|
||||
// 隐藏所有子菜单
|
||||
this.hideAllSubmenus();
|
||||
|
||||
// 清空现有菜单项并回收到池中
|
||||
while (menu.firstChild) {
|
||||
const child = menu.firstChild as HTMLElement;
|
||||
if (child.classList.contains('cm-context-menu-item')) {
|
||||
this.menuItemPool.release(child);
|
||||
}
|
||||
menu.removeChild(child);
|
||||
}
|
||||
|
||||
// 清空命令注册
|
||||
this.commands.clear();
|
||||
this.commandCounter = 0;
|
||||
|
||||
// 添加主菜单项
|
||||
items.forEach(item => {
|
||||
const menuItemElement = this.createMenuItemElement(item);
|
||||
menu.appendChild(menuItemElement);
|
||||
});
|
||||
|
||||
// 显示菜单
|
||||
menu.style.display = "block";
|
||||
|
||||
// 定位菜单
|
||||
this.positionMenu(menu, clientX, clientY);
|
||||
|
||||
// 添加全局事件监听器
|
||||
document.addEventListener("click", this.clickOutsideHandler!, true);
|
||||
document.addEventListener("keydown", this.keyDownHandler!);
|
||||
|
||||
// 触发显示动画
|
||||
requestAnimationFrame(() => {
|
||||
if (menu) {
|
||||
menu.classList.add("show");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏所有子菜单
|
||||
*/
|
||||
private hideAllSubmenus(): void {
|
||||
this.activeSubmenus.forEach(submenu => {
|
||||
this.hideSubmenu(submenu);
|
||||
});
|
||||
this.activeSubmenus.clear();
|
||||
|
||||
if (window.cmSubmenus) {
|
||||
window.cmSubmenus.forEach((submenu) => {
|
||||
submenu.style.opacity = '0';
|
||||
submenu.style.pointerEvents = 'none';
|
||||
submenu.style.visibility = 'hidden';
|
||||
submenu.style.transform = 'translateX(10px)';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏上下文菜单
|
||||
*/
|
||||
hide(): void {
|
||||
// 隐藏所有子菜单
|
||||
this.hideAllSubmenus();
|
||||
|
||||
if (this.menuElement) {
|
||||
// 添加淡出动画
|
||||
this.menuElement.classList.remove("show");
|
||||
this.menuElement.classList.add("hide");
|
||||
|
||||
// 等待动画完成后隐藏
|
||||
setTimeout(() => {
|
||||
if (this.menuElement) {
|
||||
this.menuElement.style.display = "none";
|
||||
this.menuElement.classList.remove("hide");
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// 移除全局事件监听器
|
||||
if (this.clickOutsideHandler) {
|
||||
document.removeEventListener("click", this.clickOutsideHandler, true);
|
||||
}
|
||||
|
||||
if (this.keyDownHandler) {
|
||||
document.removeEventListener("keydown", this.keyDownHandler);
|
||||
}
|
||||
|
||||
this.currentView = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁管理器
|
||||
*/
|
||||
destroy(): void {
|
||||
this.hide();
|
||||
|
||||
if (this.menuElement) {
|
||||
document.body.removeChild(this.menuElement);
|
||||
this.menuElement = null;
|
||||
}
|
||||
|
||||
this.submenuPool.forEach(submenu => {
|
||||
if (submenu.parentNode) {
|
||||
document.body.removeChild(submenu);
|
||||
}
|
||||
});
|
||||
this.submenuPool.clear();
|
||||
|
||||
this.menuItemPool.clear();
|
||||
this.commands.clear();
|
||||
this.activeSubmenus.clear();
|
||||
this.ripplePool.length = 0;
|
||||
|
||||
if (window.cmSubmenus) {
|
||||
window.cmSubmenus.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单例实例
|
||||
const contextMenuManager = ContextMenuManager.getInstance();
|
||||
|
||||
/**
|
||||
* 显示上下文菜单
|
||||
*/
|
||||
export function showContextMenu(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
|
||||
contextMenuManager.show(view, clientX, clientY, items);
|
||||
}
|
||||
@@ -1,174 +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";
|
||||
|
||||
/**
|
||||
* 菜单项类型定义
|
||||
*/
|
||||
export interface MenuItem {
|
||||
/** 菜单项显示文本 */
|
||||
label: string;
|
||||
|
||||
/** 点击时执行的命令 (如果有子菜单,可以为null) */
|
||||
command?: (view: EditorView) => boolean;
|
||||
|
||||
/** 快捷键提示文本 (可选) */
|
||||
shortcut?: string;
|
||||
|
||||
/** 子菜单项 (可选) */
|
||||
submenu?: MenuItem[];
|
||||
}
|
||||
|
||||
// 导入相关功能
|
||||
import { showContextMenu } from "./contextMenuView";
|
||||
|
||||
/**
|
||||
* 获取翻译文本
|
||||
* @param key 翻译键
|
||||
* @returns 翻译后的文本
|
||||
*/
|
||||
function t(key: string): string {
|
||||
return i18n.global.t(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取快捷键显示文本
|
||||
* @param command 命令ID
|
||||
* @returns 快捷键显示文本
|
||||
*/
|
||||
function getShortcutText(command: KeyBindingCommand): string {
|
||||
try {
|
||||
const keybindingStore = useKeybindingStore();
|
||||
|
||||
// 如果找到该命令的快捷键配置
|
||||
const binding = keybindingStore.keyBindings.find(kb =>
|
||||
kb.command === command && kb.enabled
|
||||
);
|
||||
|
||||
if (binding && binding.key) {
|
||||
// 格式化快捷键显示
|
||||
return formatKeyBinding(binding.key);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("An error occurred while getting the shortcut:", error);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化快捷键显示
|
||||
* @param keyBinding 快捷键字符串
|
||||
* @returns 格式化后的显示文本
|
||||
*/
|
||||
function formatKeyBinding(keyBinding: string): string {
|
||||
// 获取系统信息
|
||||
const systemStore = useSystemStore();
|
||||
const isMac = systemStore.isMacOS;
|
||||
|
||||
// 替换修饰键名称为更友好的显示
|
||||
return keyBinding
|
||||
.replace("Mod", isMac ? "⌘" : "Ctrl")
|
||||
.replace("Shift", isMac ? "⇧" : "Shift")
|
||||
.replace("Alt", isMac ? "⌥" : "Alt")
|
||||
.replace("Ctrl", isMac ? "⌃" : "Ctrl")
|
||||
.replace(/-/g, " + ");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 创建编辑菜单项
|
||||
*/
|
||||
function createEditItems(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: t("keybindings.commands.blockCopy"),
|
||||
command: copyCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockCopyCommand)
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.blockCut"),
|
||||
command: cutCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockCutCommand)
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.blockPaste"),
|
||||
command: pasteCommand,
|
||||
shortcut: getShortcutText(KeyBindingCommand.BlockPasteCommand)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建历史操作菜单项
|
||||
*/
|
||||
function createHistoryItems(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: t("keybindings.commands.historyUndo"),
|
||||
command: undo,
|
||||
shortcut: getShortcutText(KeyBindingCommand.HistoryUndoCommand)
|
||||
},
|
||||
{
|
||||
label: t("keybindings.commands.historyRedo"),
|
||||
command: redo,
|
||||
shortcut: getShortcutText(KeyBindingCommand.HistoryRedoCommand)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 创建主菜单项
|
||||
*/
|
||||
function createMainMenuItems(): MenuItem[] {
|
||||
// 基本编辑操作放在主菜单
|
||||
const basicItems = createEditItems();
|
||||
|
||||
// 历史操作放在主菜单
|
||||
const historyItems = createHistoryItems();
|
||||
|
||||
// 构建主菜单
|
||||
return [
|
||||
...basicItems,
|
||||
...historyItems
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建编辑器上下文菜单
|
||||
*/
|
||||
export function createEditorContextMenu(): Extension {
|
||||
// 为编辑器添加右键事件处理
|
||||
return EditorView.domEventHandlers({
|
||||
contextmenu: (event, view) => {
|
||||
// 阻止默认右键菜单
|
||||
event.preventDefault();
|
||||
|
||||
// 获取菜单项
|
||||
const menuItems = createMainMenuItems();
|
||||
|
||||
// 显示上下文菜单
|
||||
showContextMenu(view, event.clientX, event.clientY, menuItems);
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认导出
|
||||
*/
|
||||
export default createEditorContextMenu;
|
||||
@@ -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
|
||||
};
|
||||
@@ -115,6 +115,10 @@ const atomicNoteBlock = ViewPlugin.fromClass(
|
||||
|
||||
/**
|
||||
* 块背景层 - 修复高度计算问题
|
||||
*
|
||||
* 使用 lineBlockAt 获取行坐标,而不是 coordsAtPos 获取字符坐标。
|
||||
* 这样即使某些字符被隐藏(如 heading 的 # 标记 fontSize: 0),
|
||||
* 行的坐标也不会受影响,边界线位置正确。
|
||||
*/
|
||||
const blockLayer = layer({
|
||||
above: false,
|
||||
@@ -135,23 +139,30 @@ const blockLayer = layer({
|
||||
return;
|
||||
}
|
||||
|
||||
// view.coordsAtPos 如果编辑器不可见则返回 null
|
||||
const fromCoordsTop = view.coordsAtPos(Math.max(block.content.from, view.visibleRanges[0].from))?.top;
|
||||
let toCoordsBottom = view.coordsAtPos(Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to))?.bottom;
|
||||
const fromPos = Math.max(block.content.from, view.visibleRanges[0].from);
|
||||
const toPos = Math.min(block.content.to, view.visibleRanges[view.visibleRanges.length - 1].to);
|
||||
|
||||
if (fromCoordsTop === undefined || toCoordsBottom === undefined) {
|
||||
idx++;
|
||||
return;
|
||||
}
|
||||
// 使用 lineBlockAt 获取行的坐标,不受字符样式(如 fontSize: 0)影响
|
||||
const fromLineBlock = view.lineBlockAt(fromPos);
|
||||
const toLineBlock = view.lineBlockAt(toPos);
|
||||
|
||||
// lineBlockAt 返回的 top 是相对于内容区域的偏移
|
||||
// 转换为视口坐标进行后续计算
|
||||
const fromCoordsTop = fromLineBlock.top + view.documentTop;
|
||||
let toCoordsBottom = toLineBlock.bottom + view.documentTop;
|
||||
|
||||
// 对最后一个块进行特殊处理,让它直接延伸到底部
|
||||
if (idx === blocks.length - 1) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
import {jsonLanguage} from "@codemirror/lang-json";
|
||||
import {pythonLanguage} from "@codemirror/lang-python";
|
||||
import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
|
||||
import {htmlLanguage} from "@codemirror/lang-html";
|
||||
import {html, htmlLanguage} from "@codemirror/lang-html";
|
||||
import {StandardSQL} from "@codemirror/lang-sql";
|
||||
import {markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {Subscript, Superscript, Table} from "@lezer/markdown";
|
||||
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
|
||||
import {Insert} from "@/views/editor/extensions/markdown/syntax/insert";
|
||||
import {Math} from "@/views/editor/extensions/markdown/syntax/math";
|
||||
import {Footnote} from "@/views/editor/extensions/markdown/syntax/footnote";
|
||||
import {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";
|
||||
@@ -22,9 +28,9 @@ import {wastLanguage} from "@codemirror/lang-wast";
|
||||
import {sassLanguage} from "@codemirror/lang-sass";
|
||||
import {lessLanguage} from "@codemirror/lang-less";
|
||||
import {angularLanguage} from "@codemirror/lang-angular";
|
||||
import { svelteLanguage } from "@replit/codemirror-lang-svelte";
|
||||
import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language";
|
||||
import { mermaidLanguage } from '@/views/editor/language/mermaid';
|
||||
import {svelteLanguage} from "@replit/codemirror-lang-svelte";
|
||||
import {httpLanguage} from "@/views/editor/extensions/httpclient/language/http-language";
|
||||
import {mermaidLanguage} from '@/views/editor/language/mermaid';
|
||||
import {StreamLanguage} from "@codemirror/language";
|
||||
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
||||
import {shell} from "@codemirror/legacy-modes/mode/shell";
|
||||
@@ -64,6 +70,7 @@ import dartPrettierPlugin from "@/common/prettier/plugins/dart";
|
||||
import luaPrettierPlugin from "@/common/prettier/plugins/lua";
|
||||
import webPrettierPlugin from "@/common/prettier/plugins/web";
|
||||
import * as prettierPluginEstree from "prettier/plugins/estree";
|
||||
import {languages} from "@codemirror/language-data";
|
||||
|
||||
/**
|
||||
* 语言信息类
|
||||
@@ -110,7 +117,19 @@ export const LANGUAGES: LanguageInfo[] = [
|
||||
parser: "sql",
|
||||
plugins: [sqlPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("md", "Markdown", markdownLanguage.parser, ["md"], {
|
||||
new LanguageInfo("md", "Markdown", markdown({
|
||||
base: markdownLanguage,
|
||||
extensions: [Subscript, Superscript, Highlight, Insert, Math, Footnote, Table, Emoji],
|
||||
completeHTMLTags: true,
|
||||
pasteURLAsLink: true,
|
||||
htmlTagLanguage: html({
|
||||
matchClosingTags: true,
|
||||
autoCloseTags: true
|
||||
}),
|
||||
addKeymap: true,
|
||||
codeLanguages: languages,
|
||||
|
||||
}).language.parser, ["md"], {
|
||||
parser: "markdown",
|
||||
plugins: [markdownPrettierPlugin]
|
||||
}),
|
||||
|
||||
181
frontend/src/views/editor/extensions/contextMenu/ContextMenu.vue
Normal file
181
frontend/src/views/editor/extensions/contextMenu/ContextMenu.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||
import { contextMenuManager } from './manager';
|
||||
import type { RenderMenuItem } from './menuSchema';
|
||||
|
||||
const props = defineProps<{
|
||||
portalTarget?: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
const menuState = contextMenuManager.useState();
|
||||
const menuRef = ref<HTMLDivElement | null>(null);
|
||||
const adjustedPosition = ref({ x: 0, y: 0 });
|
||||
|
||||
const isVisible = computed(() => menuState.value.visible);
|
||||
const items = computed(() => menuState.value.items);
|
||||
const position = computed(() => menuState.value.position);
|
||||
const teleportTarget = computed<HTMLElement | string>(() => props.portalTarget ?? 'body');
|
||||
|
||||
watch(
|
||||
position,
|
||||
(newPosition) => {
|
||||
adjustedPosition.value = { ...newPosition };
|
||||
if (isVisible.value) {
|
||||
nextTick(adjustMenuWithinViewport);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(isVisible, (visible) => {
|
||||
if (visible) {
|
||||
nextTick(adjustMenuWithinViewport);
|
||||
// 显示时添加 outside 点击监听
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
// 隐藏时移除监听
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
});
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${adjustedPosition.value.x}px`,
|
||||
top: `${adjustedPosition.value.y}px`
|
||||
}));
|
||||
|
||||
async function adjustMenuWithinViewport() {
|
||||
await nextTick();
|
||||
const menuEl = menuRef.value;
|
||||
if (!menuEl) return;
|
||||
|
||||
const rect = menuEl.getBoundingClientRect();
|
||||
let nextX = adjustedPosition.value.x;
|
||||
let nextY = adjustedPosition.value.y;
|
||||
|
||||
if (rect.right > window.innerWidth) {
|
||||
nextX = Math.max(0, window.innerWidth - rect.width - 8);
|
||||
}
|
||||
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
nextY = Math.max(0, window.innerHeight - rect.height - 8);
|
||||
}
|
||||
|
||||
adjustedPosition.value = { x: nextX, y: nextY };
|
||||
}
|
||||
|
||||
function handleItemClick(item: RenderMenuItem) {
|
||||
if (item.type !== "action" || item.disabled) {
|
||||
return;
|
||||
}
|
||||
contextMenuManager.runCommand(item);
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
// 如果点击在菜单内部,不关闭
|
||||
if (menuRef.value?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
contextMenuManager.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="teleportTarget">
|
||||
<template v-if="isVisible">
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="cm-context-menu show"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<template v-for="item in items" :key="item.id">
|
||||
<div v-if="item.type === 'separator'" class="cm-context-menu-divider" />
|
||||
<div
|
||||
v-else
|
||||
class="cm-context-menu-item"
|
||||
:class="{ 'is-disabled': item.disabled }"
|
||||
role="menuitem"
|
||||
:aria-disabled="item.disabled ? 'true' : 'false'"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<div class="cm-context-menu-item-label">
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<span v-if="item.shortcut" class="cm-context-menu-item-shortcut">
|
||||
{{ item.shortcut }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cm-context-menu {
|
||||
position: fixed;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
padding: 4px 0;
|
||||
border-radius: 3px;
|
||||
background-color: var(--settings-card-bg, #1c1c1e);
|
||||
color: var(--settings-text, #f6f6f6);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
transform-origin: top left;
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.cm-context-menu.show {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.cm-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cm-context-menu-item:hover {
|
||||
background-color: var(--toolbar-button-hover);
|
||||
color: var(--toolbar-text, #ffffff);
|
||||
}
|
||||
|
||||
.cm-context-menu-item.is-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cm-context-menu-item-shortcut {
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.cm-context-menu-divider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
</style>
|
||||
140
frontend/src/views/editor/extensions/contextMenu/index.ts
Normal file
140
frontend/src/views/editor/extensions/contextMenu/index.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { copyCommand, cutCommand, pasteCommand } from '../codeblock/copyPaste';
|
||||
import { KeyBindingKey } 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("Alt", isMac ? "Option" : "Alt")
|
||||
.replace(/-/g, " + ");
|
||||
}
|
||||
|
||||
const shortcutCache = new Map<KeyBindingKey, string>();
|
||||
|
||||
|
||||
function getShortcutText(keyBindingKey?: KeyBindingKey): 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?.command) {
|
||||
const formatted = formatKeyBinding(binding.command);
|
||||
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,
|
||||
keyBindingKey: KeyBindingKey.BlockCopyKeyBindingKey,
|
||||
enabled: (context) => context.hasSelection
|
||||
},
|
||||
{
|
||||
id: "cut",
|
||||
labelKey: "keybindings.commands.blockCut",
|
||||
command: cutCommand,
|
||||
keyBindingKey: KeyBindingKey.BlockCutKeyBindingKey,
|
||||
visible: (context) => context.isEditable,
|
||||
enabled: (context) => context.hasSelection && context.isEditable
|
||||
},
|
||||
{
|
||||
id: "paste",
|
||||
labelKey: "keybindings.commands.blockPaste",
|
||||
command: pasteCommand,
|
||||
keyBindingKey: KeyBindingKey.BlockPasteKeyBindingKey,
|
||||
visible: (context) => context.isEditable
|
||||
},
|
||||
{
|
||||
id: "undo",
|
||||
labelKey: "keybindings.commands.historyUndo",
|
||||
command: undo,
|
||||
keyBindingKey: KeyBindingKey.HistoryUndoKeyBindingKey,
|
||||
visible: (context) => context.isEditable
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
labelKey: "keybindings.commands.historyRedo",
|
||||
command: redo,
|
||||
keyBindingKey: KeyBindingKey.HistoryRedoKeyBindingKey,
|
||||
visible: (context) => context.isEditable
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
let builtinMenuRegistered = false;
|
||||
|
||||
function ensureBuiltinMenuRegistered(): void {
|
||||
if (builtinMenuRegistered) return;
|
||||
registerMenuNodes(builtinMenuNodes());
|
||||
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;
|
||||
108
frontend/src/views/editor/extensions/contextMenu/manager.ts
Normal file
108
frontend/src/views/editor/extensions/contextMenu/manager.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { readonly, shallowRef, type ShallowRef } from 'vue';
|
||||
import type { RenderMenuItem } from './menuSchema';
|
||||
|
||||
interface MenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean;
|
||||
position: MenuPosition;
|
||||
items: RenderMenuItem[];
|
||||
view: EditorView | null;
|
||||
}
|
||||
|
||||
class ContextMenuManager {
|
||||
private state: ShallowRef<ContextMenuState> = shallowRef({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
items: [] as RenderMenuItem[],
|
||||
view: null as EditorView | null
|
||||
});
|
||||
|
||||
useState() {
|
||||
return readonly(this.state);
|
||||
}
|
||||
|
||||
show(view: EditorView, clientX: number, clientY: number, items: RenderMenuItem[]): void {
|
||||
const currentState = this.state.value;
|
||||
|
||||
// 如果菜单已经显示,且位置很接近(20px范围内),则只更新内容,避免闪烁
|
||||
if (currentState.visible) {
|
||||
const dx = Math.abs(currentState.position.x - clientX);
|
||||
const dy = Math.abs(currentState.position.y - clientY);
|
||||
const isSamePosition = dx < 20 && dy < 20;
|
||||
|
||||
if (isSamePosition) {
|
||||
// 只更新items和view,保持visible状态和位置
|
||||
this.state.value = {
|
||||
...currentState,
|
||||
items,
|
||||
view
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 否则正常显示菜单
|
||||
this.state.value = {
|
||||
visible: true,
|
||||
position: { x: clientX, y: clientY },
|
||||
items,
|
||||
view
|
||||
};
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (!this.state.value.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousPosition = this.state.value.position;
|
||||
const view = this.state.value.view;
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: previousPosition,
|
||||
items: [],
|
||||
view: null
|
||||
};
|
||||
|
||||
if (view) {
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
|
||||
runCommand(item: RenderMenuItem): void {
|
||||
if (item.type !== "action" || item.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { view } = this.state.value;
|
||||
if (item.command && view) {
|
||||
item.command(view);
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.state.value = {
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
items: [],
|
||||
view: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const contextMenuManager = new ContextMenuManager();
|
||||
|
||||
export function showContextMenu(
|
||||
view: EditorView,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
items: RenderMenuItem[]
|
||||
): void {
|
||||
contextMenuManager.show(view, clientX, clientY, items);
|
||||
}
|
||||
102
frontend/src/views/editor/extensions/contextMenu/menuSchema.ts
Normal file
102
frontend/src/views/editor/extensions/contextMenu/menuSchema.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { KeyBindingKey } from '@/../bindings/voidraft/internal/models/models';
|
||||
|
||||
export interface MenuContext {
|
||||
view: EditorView;
|
||||
event: MouseEvent;
|
||||
hasSelection: boolean;
|
||||
selectionText: string;
|
||||
isEditable: boolean;
|
||||
}
|
||||
|
||||
export type MenuSchemaNode =
|
||||
| {
|
||||
id: string;
|
||||
type?: "action";
|
||||
labelKey: string;
|
||||
command?: (view: EditorView) => boolean;
|
||||
keyBindingKey?: KeyBindingKey;
|
||||
visible?: (context: MenuContext) => boolean;
|
||||
enabled?: (context: MenuContext) => boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "separator";
|
||||
visible?: (context: MenuContext) => boolean;
|
||||
};
|
||||
|
||||
export interface RenderMenuItem {
|
||||
id: string;
|
||||
type: "action" | "separator";
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
command?: (view: EditorView) => boolean;
|
||||
}
|
||||
|
||||
interface MenuBuildOptions {
|
||||
translate: (key: string) => string;
|
||||
formatShortcut: (keyBindingKey?: KeyBindingKey) => string;
|
||||
}
|
||||
|
||||
const menuRegistry: MenuSchemaNode[] = [];
|
||||
|
||||
export function createMenuContext(view: EditorView, event: MouseEvent): MenuContext {
|
||||
const { state } = view;
|
||||
const hasSelection = state.selection.ranges.some((range) => !range.empty);
|
||||
const selectionText = hasSelection
|
||||
? state.sliceDoc(state.selection.main.from, state.selection.main.to)
|
||||
: "";
|
||||
const isEditable = !state.facet(EditorState.readOnly);
|
||||
|
||||
return {
|
||||
view,
|
||||
event,
|
||||
hasSelection,
|
||||
selectionText,
|
||||
isEditable
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMenuNodes(nodes: MenuSchemaNode[]): void {
|
||||
menuRegistry.push(...nodes);
|
||||
}
|
||||
|
||||
export function buildRegisteredMenu(
|
||||
context: MenuContext,
|
||||
options: MenuBuildOptions
|
||||
): RenderMenuItem[] {
|
||||
return menuRegistry
|
||||
.map((node) => convertNode(node, context, options))
|
||||
.filter((item): item is RenderMenuItem => Boolean(item));
|
||||
}
|
||||
|
||||
function convertNode(
|
||||
node: MenuSchemaNode,
|
||||
context: MenuContext,
|
||||
options: MenuBuildOptions
|
||||
): RenderMenuItem | null {
|
||||
if (node.visible && !node.visible(context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.type === "separator") {
|
||||
return {
|
||||
id: node.id,
|
||||
type: "separator"
|
||||
};
|
||||
}
|
||||
|
||||
const disabled = node.enabled ? !node.enabled(context) : false;
|
||||
const shortcut = options.formatShortcut(node.keyBindingKey);
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: "action",
|
||||
label: options.translate(node.labelKey),
|
||||
shortcut: shortcut || undefined,
|
||||
disabled,
|
||||
command: node.command
|
||||
};
|
||||
}
|
||||
@@ -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};
|
||||
});
|
||||
@@ -3,15 +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";
|
||||
import { getNoteBlockFromPos } from '../codeblock/state';
|
||||
|
||||
const pathStr = `<svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor"><path d="M607.934444 417.856853c-6.179746-6.1777-12.766768-11.746532-19.554358-16.910135l-0.01228 0.011256c-6.986111-6.719028-16.47216-10.857279-26.930349-10.857279-21.464871 0-38.864146 17.400299-38.864146 38.864146 0 9.497305 3.411703 18.196431 9.071609 24.947182l-0.001023 0c0.001023 0.001023 0.00307 0.00307 0.005117 0.004093 2.718925 3.242857 5.953595 6.03853 9.585309 8.251941 3.664459 3.021823 7.261381 5.997598 10.624988 9.361205l3.203972 3.204995c40.279379 40.229237 28.254507 109.539812-12.024871 149.820214L371.157763 796.383956c-40.278355 40.229237-105.761766 40.229237-146.042167 0l-3.229554-3.231601c-40.281425-40.278355-40.281425-105.809861 0-145.991002l75.93546-75.909877c9.742898-7.733125 15.997346-19.668968 15.997346-33.072233 0-23.312962-18.898419-42.211381-42.211381-42.211381-8.797363 0-16.963347 2.693342-23.725354 7.297197-0.021489-0.045025-0.044002-0.088004-0.066515-0.134053l-0.809435 0.757247c-2.989077 2.148943-5.691629 4.669346-8.025791 7.510044l-78.913281 73.841775c-74.178443 74.229608-74.178443 195.632609 0 269.758863l3.203972 3.202948c74.178443 74.127278 195.529255 74.127278 269.707698 0l171.829484-171.880649c74.076112-74.17435 80.357166-191.184297 6.282077-265.311575L607.934444 417.856853z"></path><path d="M855.61957 165.804257l-3.203972-3.203972c-74.17742-74.178443-195.528232-74.178443-269.706675 0L410.87944 334.479911c-74.178443 74.178443-78.263481 181.296089-4.085038 255.522628l3.152806 3.104711c3.368724 3.367701 6.865361 6.54302 10.434653 9.588379 2.583848 2.885723 5.618974 5.355985 8.992815 7.309476 0.025583 0.020466 0.052189 0.041956 0.077771 0.062422l0.011256-0.010233c5.377474 3.092431 11.608386 4.870938 18.257829 4.870938 20.263509 0 36.68962-16.428158 36.68962-36.68962 0-5.719258-1.309832-11.132548-3.645017-15.95846l0 0c-4.850471-10.891048-13.930267-17.521049-20.210297-23.802102l-3.15383-3.102664c-40.278355-40.278355-24.982998-98.79612 15.295358-139.074476l171.930791-171.830507c40.179095-40.280402 105.685018-40.280402 145.965419 0l3.206018 3.152806c40.279379 40.281425 40.279379 105.838513 0 146.06775l-75.686796 75.737962c-10.296507 7.628748-16.97358 19.865443-16.97358 33.662681 0 23.12365 18.745946 41.87062 41.87062 41.87062 8.048303 0 15.563464-2.275833 21.944801-6.211469 0.048095 0.081864 0.093121 0.157589 0.141216 0.240477l1.173732-1.083681c3.616364-2.421142 6.828522-5.393847 9.529027-8.792247l79.766718-73.603345C929.798013 361.334535 929.798013 239.981676 855.61957 165.804257z"></path></svg>`;
|
||||
const defaultRegexp = /\b((?:https?|ftp):\/\/[^\s/$.?#].[^\s]*)\b/gi;
|
||||
const defaultRegexp = /\b(([a-zA-Z][\w+\-.]*):\/\/[^\s/$.?#].[^\s]*)\b/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;
|
||||
@@ -45,106 +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;
|
||||
/**
|
||||
* Build decorations from extracted link info.
|
||||
*/
|
||||
function buildDecorations(links: HyperLinkInfo[], anchor?: HyperLinkExtensionOptions['anchor']): DecorationSet {
|
||||
const decorations: ReturnType<Decoration['range']>[] = [];
|
||||
|
||||
while ((match = defaultRegexp.exec(doc)) !== null) {
|
||||
const from = match.index;
|
||||
const to = from + match[0].length;
|
||||
for (const link of links) {
|
||||
// Add text decoration
|
||||
decorations.push(Decoration.mark({
|
||||
class: 'cm-hyper-link-text'
|
||||
}).range(link.from, link.to));
|
||||
|
||||
// 检查当前位置是否在 HTTP 代码块中
|
||||
const block = getNoteBlockFromPos(view.state, from);
|
||||
if (block && block.language.name === 'http') {
|
||||
// 如果在 HTTP 代码块中,跳过超链接装饰
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkMark = Decoration.mark({
|
||||
class: 'cm-hyper-link-text',
|
||||
attributes: {
|
||||
'data-url': match[0]
|
||||
}
|
||||
});
|
||||
widgets.push(linkMark.range(from, 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) => {
|
||||
// 检查当前位置是否在 HTTP 代码块中
|
||||
const block = getNoteBlockFromPos(view.state, from);
|
||||
if (block && block.language.name === 'http') {
|
||||
// 如果在 HTTP 代码块中,跳过超链接装饰
|
||||
export type HyperLinkExtensionOptions = {
|
||||
/** Custom anchor element transformer */
|
||||
anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
decorations: DecorationSet;
|
||||
links: HyperLinkInfo[] = [];
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.links = extractVisibleLinks(view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// Always rebuild on viewport change (new content visible)
|
||||
if (update.viewportChanged) {
|
||||
this.links = extractVisibleLinks(update.view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
return;
|
||||
}
|
||||
|
||||
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 });
|
||||
// For document changes, check if they affect link regions
|
||||
if (update.docChanged) {
|
||||
const needsRebuild = changesAffectLinks(update.changes, this.links);
|
||||
|
||||
add(from, to, Decoration.mark({
|
||||
class: 'cm-hyper-link-text cm-hyper-link-underline',
|
||||
attributes: {
|
||||
'data-url': urlStr
|
||||
}
|
||||
if (needsRebuild) {
|
||||
// Changes affect links, full rebuild
|
||||
this.links = extractVisibleLinks(update.view);
|
||||
this.decorations = buildDecorations(this.links, anchor);
|
||||
} else {
|
||||
// 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)
|
||||
}));
|
||||
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;
|
||||
anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement;
|
||||
showIcon?: boolean;
|
||||
};
|
||||
|
||||
export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = true }: HyperLinkExtensionOptions = {}) {
|
||||
return ViewPlugin.fromClass(
|
||||
class HyperLinkView {
|
||||
decorator?: MatchDecorator;
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
if (regexp) {
|
||||
this.decorator = linkDecorator(regexp, match, handle, anchor);
|
||||
this.decorations = this.decorator.createDeco(view);
|
||||
} else {
|
||||
this.decorations = hyperLinkDecorations(view, anchor);
|
||||
}
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
if (regexp && this.decorator) {
|
||||
this.decorations = this.decorator.updateDeco(update, this.decorations);
|
||||
} else {
|
||||
this.decorations = hyperLinkDecorations(update.view, anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,7 +223,7 @@ export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = t
|
||||
export const hyperLinkStyle = EditorView.baseTheme({
|
||||
'.cm-hyper-link-text': {
|
||||
color: '#0969da',
|
||||
cursor: 'pointer',
|
||||
cursor: 'text',
|
||||
transition: 'color 0.2s ease',
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: '#0969da',
|
||||
@@ -192,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': {
|
||||
@@ -216,17 +281,12 @@ export const hyperLinkStyle = EditorView.baseTheme({
|
||||
});
|
||||
|
||||
export const hyperLinkClickHandler = EditorView.domEventHandlers({
|
||||
click: (event, view) => {
|
||||
const target = event.target as HTMLElement;
|
||||
let urlElement = target;
|
||||
click: (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const iconElement = target?.closest?.('.cm-hyper-link-icon') as (HTMLElement | null);
|
||||
|
||||
while (urlElement && !urlElement.hasAttribute('data-url')) {
|
||||
urlElement = urlElement.parentElement as HTMLElement;
|
||||
if (!urlElement || urlElement === document.body) break;
|
||||
}
|
||||
|
||||
if (urlElement && urlElement.hasAttribute('data-url')) {
|
||||
const url = urlElement.getAttribute('data-url');
|
||||
if (iconElement && iconElement.hasAttribute('data-url')) {
|
||||
const url = iconElement.getAttribute('data-url');
|
||||
if (url) {
|
||||
runtime.Browser.OpenURL(url);
|
||||
event.preventDefault();
|
||||
|
||||
19
frontend/src/views/editor/extensions/markdown/index.ts
Normal file
19
frontend/src/views/editor/extensions/markdown/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { image } from './plugins/image';
|
||||
import { headingSlugField } from './state/heading-slug';
|
||||
import {html} from './plugins/html';
|
||||
import { render } from './plugins/render';
|
||||
import { Theme } from './plugins/theme';
|
||||
|
||||
/**
|
||||
* Markdown extensions.
|
||||
*/
|
||||
export const markdownExtensions: Extension = [
|
||||
headingSlugField,
|
||||
render(),
|
||||
Theme,
|
||||
image(),
|
||||
html()
|
||||
];
|
||||
|
||||
export default markdownExtensions;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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' });
|
||||
|
||||
/**
|
||||
* Handle Blockquote node.
|
||||
*/
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for blockquotes.
|
||||
*/
|
||||
export const blockquoteTheme = EditorView.baseTheme({
|
||||
'.cm-blockquote': {
|
||||
borderLeft: '4px solid var(--cm-blockquote-border, #ccc)',
|
||||
color: 'var(--cm-blockquote-color, #666)'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Code block handler and theme.
|
||||
*/
|
||||
|
||||
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' });
|
||||
|
||||
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>`;
|
||||
|
||||
class CodeBlockInfoWidget extends WidgetType {
|
||||
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';
|
||||
if (this.language) {
|
||||
const lang = document.createElement('span');
|
||||
lang.className = 'cm-code-block-lang';
|
||||
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;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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;
|
||||
}
|
||||
ignoreEvent() { return true; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle FencedCode / CodeBlock node.
|
||||
*/
|
||||
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]);
|
||||
|
||||
const startLine = ctx.view.state.doc.lineAt(nf);
|
||||
const endLine = ctx.view.state.doc.lineAt(nt);
|
||||
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 });
|
||||
}
|
||||
if (!inCursor) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme for code blocks.
|
||||
*/
|
||||
export const codeBlockTheme = EditorView.baseTheme({
|
||||
'.cm-codeblock': {
|
||||
backgroundColor: 'var(--cm-codeblock-bg)',
|
||||
fontFamily: 'inherit'
|
||||
},
|
||||
'.cm-codeblock-begin': {
|
||||
borderTopLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderTopRightRadius: 'var(--cm-codeblock-radius)',
|
||||
position: 'relative'
|
||||
},
|
||||
'.cm-codeblock-end': {
|
||||
borderBottomLeftRadius: 'var(--cm-codeblock-radius)',
|
||||
borderBottomRightRadius: 'var(--cm-codeblock-radius)'
|
||||
},
|
||||
'.cm-code-block-info': {
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5em',
|
||||
zIndex: '5',
|
||||
opacity: '0.5',
|
||||
transition: 'opacity 0.15s'
|
||||
},
|
||||
'.cm-code-block-info:hover': { opacity: '1' },
|
||||
'.cm-code-block-lang': {
|
||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||
textTransform: 'lowercase',
|
||||
userSelect: 'none'
|
||||
},
|
||||
'.cm-code-block-copy-btn': {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0.15em',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
background: 'transparent',
|
||||
color: 'var(--cm-codeblock-lang, var(--cm-foreground))',
|
||||
cursor: 'pointer',
|
||||
opacity: '0.7',
|
||||
transition: 'opacity 0.15s, background 0.15s'
|
||||
},
|
||||
'.cm-code-block-copy-btn:hover': { opacity: '1', background: 'rgba(128, 128, 128, 0.2)' },
|
||||
'.cm-code-block-copy-btn svg': { width: '1em', height: '1em' }
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user