Showing preview only (1,036K chars total). Download the full file or copy to clipboard to get everything.
Repository: doocs/md
Branch: main
Commit: ae303d58dec7
Files: 389
Total size: 939.4 KB
Directory structure:
gitextract_apmp53u3/
├── .editorconfig
├── .github/
│ ├── copilot-instructions.md
│ ├── dependabot.yml
│ ├── secret_scanning.yml
│ └── workflows/
│ ├── cloudflare-preview-cleanup.yml
│ ├── cloudflare-preview.yml
│ ├── deploy-gitee.yml
│ ├── deploy.yml
│ ├── docker.yml
│ ├── release-cli.yml
│ ├── release.yml
│ ├── stale-bot.yml
│ ├── surge-preview-build.yml
│ └── surge-preview-deploy.yml
├── .gitignore
├── .husky/
│ └── pre-commit
├── .npmrc
├── .nvmrc
├── .vscode/
│ ├── extensions.json
│ ├── launch.json
│ ├── settings.json
│ └── tasks.json
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── USERS.md
├── apps/
│ ├── utools/
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── plugin.json
│ │ └── preload.js
│ ├── vscode/
│ │ ├── .gitignore
│ │ ├── .npmrc
│ │ ├── .vscodeignore
│ │ ├── CHANGELOG.md
│ │ ├── LICENSE
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── css/
│ │ │ │ └── index.ts
│ │ │ ├── extension.ts
│ │ │ ├── styleChoices.ts
│ │ │ └── treeDataProvider.ts
│ │ ├── tsconfig.json
│ │ └── webpack.config.mjs
│ └── web/
│ ├── components.json
│ ├── index.html
│ ├── netlify.toml
│ ├── package.json
│ ├── plugins/
│ │ └── vite-plugin-utools-local-assets.ts
│ ├── postcss.config.js
│ ├── public/
│ │ └── upload/
│ │ └── .gitkeep
│ ├── src/
│ │ ├── App.vue
│ │ ├── assets/
│ │ │ ├── example/
│ │ │ │ └── markdown.md
│ │ │ ├── index.css
│ │ │ └── less/
│ │ │ ├── app.less
│ │ │ └── theme.less
│ │ ├── components/
│ │ │ ├── AppSplash.vue
│ │ │ ├── ai/
│ │ │ │ ├── SidebarAIToolbar.vue
│ │ │ │ ├── chat-box/
│ │ │ │ │ ├── AIAssistantPanel.vue
│ │ │ │ │ ├── AIConfig.vue
│ │ │ │ │ └── QuickCommandManager.vue
│ │ │ │ ├── image-generator/
│ │ │ │ │ ├── AIImageConfig.vue
│ │ │ │ │ ├── AIImageGeneratorPanel.vue
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── tool-box/
│ │ │ │ ├── ToolBoxPopover.vue
│ │ │ │ └── index.ts
│ │ │ ├── editor/
│ │ │ │ ├── CssEditor.vue
│ │ │ │ ├── CustomUploadForm.vue
│ │ │ │ ├── EditorContextMenu.vue
│ │ │ │ ├── EditorStateDialog.vue
│ │ │ │ ├── FloatingToc.vue
│ │ │ │ ├── FolderSourcePanel.vue
│ │ │ │ ├── FolderTree.vue
│ │ │ │ ├── Footer.vue
│ │ │ │ ├── FormItem.vue
│ │ │ │ ├── ImportMarkdownDialog.vue
│ │ │ │ ├── InsertFormDialog.vue
│ │ │ │ ├── InsertMpCardDialog.vue
│ │ │ │ ├── RightSlider.vue
│ │ │ │ ├── TemplateDialog.vue
│ │ │ │ ├── ThemeCustomizer.vue
│ │ │ │ ├── UploadImgDialog.vue
│ │ │ │ ├── editor-header/
│ │ │ │ │ ├── AboutDialog.vue
│ │ │ │ │ ├── EditDropdown.vue
│ │ │ │ │ ├── FileDropdown.vue
│ │ │ │ │ ├── FormatDropdown.vue
│ │ │ │ │ ├── FundDialog.vue
│ │ │ │ │ ├── HelpDropdown.vue
│ │ │ │ │ ├── InsertDropdown.vue
│ │ │ │ │ ├── PostInfo.vue
│ │ │ │ │ ├── PostTaskDialog.vue
│ │ │ │ │ ├── StyleDropdown.vue
│ │ │ │ │ ├── StyleOptionMenu.vue
│ │ │ │ │ ├── ViewDropdown.vue
│ │ │ │ │ └── index.vue
│ │ │ │ └── post-slider/
│ │ │ │ ├── PostItem.vue
│ │ │ │ └── index.vue
│ │ │ └── ui/
│ │ │ ├── alert/
│ │ │ │ ├── Alert.vue
│ │ │ │ ├── AlertDescription.vue
│ │ │ │ ├── AlertTitle.vue
│ │ │ │ └── index.ts
│ │ │ ├── alert-dialog/
│ │ │ │ ├── AlertDialog.vue
│ │ │ │ ├── AlertDialogAction.vue
│ │ │ │ ├── AlertDialogCancel.vue
│ │ │ │ ├── AlertDialogContent.vue
│ │ │ │ ├── AlertDialogDescription.vue
│ │ │ │ ├── AlertDialogFooter.vue
│ │ │ │ ├── AlertDialogHeader.vue
│ │ │ │ ├── AlertDialogTitle.vue
│ │ │ │ ├── AlertDialogTrigger.vue
│ │ │ │ └── index.ts
│ │ │ ├── back-top/
│ │ │ │ ├── BackTop.vue
│ │ │ │ └── index.ts
│ │ │ ├── button/
│ │ │ │ ├── Button.vue
│ │ │ │ └── index.ts
│ │ │ ├── context-menu/
│ │ │ │ ├── ContextMenu.vue
│ │ │ │ ├── ContextMenuCheckboxItem.vue
│ │ │ │ ├── ContextMenuContent.vue
│ │ │ │ ├── ContextMenuGroup.vue
│ │ │ │ ├── ContextMenuItem.vue
│ │ │ │ ├── ContextMenuLabel.vue
│ │ │ │ ├── ContextMenuPortal.vue
│ │ │ │ ├── ContextMenuRadioGroup.vue
│ │ │ │ ├── ContextMenuRadioItem.vue
│ │ │ │ ├── ContextMenuSeparator.vue
│ │ │ │ ├── ContextMenuShortcut.vue
│ │ │ │ ├── ContextMenuSub.vue
│ │ │ │ ├── ContextMenuSubContent.vue
│ │ │ │ ├── ContextMenuSubTrigger.vue
│ │ │ │ ├── ContextMenuTrigger.vue
│ │ │ │ └── index.ts
│ │ │ ├── dialog/
│ │ │ │ ├── Dialog.vue
│ │ │ │ ├── DialogClose.vue
│ │ │ │ ├── DialogContent.vue
│ │ │ │ ├── DialogDescription.vue
│ │ │ │ ├── DialogFooter.vue
│ │ │ │ ├── DialogHeader.vue
│ │ │ │ ├── DialogScrollContent.vue
│ │ │ │ ├── DialogTitle.vue
│ │ │ │ ├── DialogTrigger.vue
│ │ │ │ └── index.ts
│ │ │ ├── dropdown-menu/
│ │ │ │ ├── DropdownMenu.vue
│ │ │ │ ├── DropdownMenuCheckboxItem.vue
│ │ │ │ ├── DropdownMenuContent.vue
│ │ │ │ ├── DropdownMenuGroup.vue
│ │ │ │ ├── DropdownMenuItem.vue
│ │ │ │ ├── DropdownMenuLabel.vue
│ │ │ │ ├── DropdownMenuRadioGroup.vue
│ │ │ │ ├── DropdownMenuRadioItem.vue
│ │ │ │ ├── DropdownMenuSeparator.vue
│ │ │ │ ├── DropdownMenuShortcut.vue
│ │ │ │ ├── DropdownMenuSub.vue
│ │ │ │ ├── DropdownMenuSubContent.vue
│ │ │ │ ├── DropdownMenuSubTrigger.vue
│ │ │ │ ├── DropdownMenuTrigger.vue
│ │ │ │ └── index.ts
│ │ │ ├── hover-card/
│ │ │ │ ├── HoverCard.vue
│ │ │ │ ├── HoverCardContent.vue
│ │ │ │ ├── HoverCardTrigger.vue
│ │ │ │ └── index.ts
│ │ │ ├── input/
│ │ │ │ ├── Input.vue
│ │ │ │ └── index.ts
│ │ │ ├── label/
│ │ │ │ ├── Label.vue
│ │ │ │ └── index.ts
│ │ │ ├── menubar/
│ │ │ │ ├── Menubar.vue
│ │ │ │ ├── MenubarCheckboxItem.vue
│ │ │ │ ├── MenubarContent.vue
│ │ │ │ ├── MenubarGroup.vue
│ │ │ │ ├── MenubarItem.vue
│ │ │ │ ├── MenubarLabel.vue
│ │ │ │ ├── MenubarMenu.vue
│ │ │ │ ├── MenubarRadioGroup.vue
│ │ │ │ ├── MenubarRadioItem.vue
│ │ │ │ ├── MenubarSeparator.vue
│ │ │ │ ├── MenubarShortcut.vue
│ │ │ │ ├── MenubarSub.vue
│ │ │ │ ├── MenubarSubContent.vue
│ │ │ │ ├── MenubarSubTrigger.vue
│ │ │ │ ├── MenubarTrigger.vue
│ │ │ │ └── index.ts
│ │ │ ├── number-field/
│ │ │ │ ├── NumberField.vue
│ │ │ │ ├── NumberFieldContent.vue
│ │ │ │ ├── NumberFieldDecrement.vue
│ │ │ │ ├── NumberFieldIncrement.vue
│ │ │ │ ├── NumberFieldInput.vue
│ │ │ │ └── index.ts
│ │ │ ├── password-input/
│ │ │ │ ├── PasswordInput.vue
│ │ │ │ └── index.ts
│ │ │ ├── popover/
│ │ │ │ ├── Popover.vue
│ │ │ │ ├── PopoverContent.vue
│ │ │ │ ├── PopoverTrigger.vue
│ │ │ │ └── index.ts
│ │ │ ├── progress/
│ │ │ │ ├── Progress.vue
│ │ │ │ └── index.ts
│ │ │ ├── radio-group/
│ │ │ │ ├── RadioGroup.vue
│ │ │ │ ├── RadioGroupItem.vue
│ │ │ │ └── index.ts
│ │ │ ├── resizable/
│ │ │ │ ├── ResizableHandle.vue
│ │ │ │ ├── ResizablePanelGroup.vue
│ │ │ │ └── index.ts
│ │ │ ├── search-tab/
│ │ │ │ ├── SearchTab.vue
│ │ │ │ └── index.ts
│ │ │ ├── select/
│ │ │ │ ├── Select.vue
│ │ │ │ ├── SelectContent.vue
│ │ │ │ ├── SelectGroup.vue
│ │ │ │ ├── SelectItem.vue
│ │ │ │ ├── SelectItemText.vue
│ │ │ │ ├── SelectLabel.vue
│ │ │ │ ├── SelectScrollDownButton.vue
│ │ │ │ ├── SelectScrollUpButton.vue
│ │ │ │ ├── SelectSeparator.vue
│ │ │ │ ├── SelectTrigger.vue
│ │ │ │ ├── SelectValue.vue
│ │ │ │ └── index.ts
│ │ │ ├── separator/
│ │ │ │ ├── Separator.vue
│ │ │ │ └── index.ts
│ │ │ ├── sonner/
│ │ │ │ ├── Sonner.vue
│ │ │ │ └── index.ts
│ │ │ ├── switch/
│ │ │ │ ├── Switch.vue
│ │ │ │ └── index.ts
│ │ │ ├── tabs/
│ │ │ │ ├── Tabs.vue
│ │ │ │ ├── TabsContent.vue
│ │ │ │ ├── TabsList.vue
│ │ │ │ ├── TabsTrigger.vue
│ │ │ │ └── index.ts
│ │ │ ├── textarea/
│ │ │ │ ├── Textarea.vue
│ │ │ │ └── index.ts
│ │ │ └── tooltip/
│ │ │ ├── Tooltip.vue
│ │ │ ├── TooltipContent.vue
│ │ │ ├── TooltipProvider.vue
│ │ │ ├── TooltipTrigger.vue
│ │ │ └── index.ts
│ │ ├── composables/
│ │ │ ├── index.ts
│ │ │ ├── useEditorFormat.ts
│ │ │ ├── useFolderFileSync.ts
│ │ │ └── useImageUploader.ts
│ │ ├── entrypoints/
│ │ │ ├── appmsg.content.ts
│ │ │ ├── background.ts
│ │ │ ├── injected.ts
│ │ │ └── popup/
│ │ │ ├── App.vue
│ │ │ ├── index.html
│ │ │ └── popup.ts
│ │ ├── lib/
│ │ │ └── utils.ts
│ │ ├── main.ts
│ │ ├── modules/
│ │ │ └── build-extension.ts
│ │ ├── sidepanel.ts
│ │ ├── stores/
│ │ │ ├── aiConfig.ts
│ │ │ ├── aiImageConfig.ts
│ │ │ ├── cssEditor.ts
│ │ │ ├── editor.ts
│ │ │ ├── export.ts
│ │ │ ├── folderSource.ts
│ │ │ ├── post.ts
│ │ │ ├── quickCommands.ts
│ │ │ ├── render.ts
│ │ │ ├── template.ts
│ │ │ ├── theme.ts
│ │ │ └── ui.ts
│ │ ├── types/
│ │ │ └── global.d.ts
│ │ ├── utils/
│ │ │ ├── clipboard.ts
│ │ │ ├── file.ts
│ │ │ ├── index.ts
│ │ │ ├── setup-components.ts
│ │ │ ├── storage.ts
│ │ │ └── toast/
│ │ │ └── index.ts
│ │ ├── views/
│ │ │ └── CodemirrorEditor.vue
│ │ └── vite-env.d.ts
│ ├── tailwind.config.cjs
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── tsconfig.worker.json
│ ├── vite.config.ts
│ ├── web-ext.config.ts.example
│ ├── worker/
│ │ └── index.ts
│ ├── wrangler.jsonc
│ └── wxt.config.ts
├── docker/
│ └── latest/
│ ├── Dockerfile.base
│ ├── Dockerfile.nginx
│ ├── Dockerfile.standalone
│ ├── Dockerfile.static
│ └── server/
│ └── main.go
├── docs/
│ ├── custom-upload.md
│ ├── mp-card.md
│ └── telegram-usage.md
├── eslint.config.mjs
├── package.json
├── packages/
│ ├── config/
│ │ ├── package.json
│ │ ├── tsconfig.base.json
│ │ └── tsconfig.node.json
│ ├── core/
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── extensions/
│ │ │ │ ├── alert.ts
│ │ │ │ ├── footnotes.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── infographic.ts
│ │ │ │ ├── katex.ts
│ │ │ │ ├── markup.ts
│ │ │ │ ├── mermaid.ts
│ │ │ │ ├── plantuml.ts
│ │ │ │ ├── ruby.ts
│ │ │ │ ├── slider.ts
│ │ │ │ └── toc.ts
│ │ │ ├── index.ts
│ │ │ ├── renderer/
│ │ │ │ ├── index.ts
│ │ │ │ └── renderer-impl.ts
│ │ │ ├── theme/
│ │ │ │ ├── cssProcessor.ts
│ │ │ │ ├── cssScopeWrapper.ts
│ │ │ │ ├── cssVariables.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── selectorMapping.ts
│ │ │ │ ├── themeApplicator.ts
│ │ │ │ ├── themeExporter.ts
│ │ │ │ └── themeInjector.ts
│ │ │ └── utils/
│ │ │ ├── basicHelpers.ts
│ │ │ ├── index.ts
│ │ │ ├── initializeMermaid.ts
│ │ │ ├── languages.ts
│ │ │ └── markdownHelpers.ts
│ │ └── tsconfig.json
│ ├── example/
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── worker.js
│ │ └── wrangler.toml
│ ├── md-cli/
│ │ ├── .gitignore
│ │ ├── .npmignore
│ │ ├── .npmrc
│ │ ├── README.md
│ │ ├── index.js
│ │ ├── package.json
│ │ ├── public/
│ │ │ └── upload/
│ │ │ └── .gitkeep
│ │ ├── server.js
│ │ └── util.js
│ └── shared/
│ ├── README.md
│ ├── package.json
│ ├── src/
│ │ ├── assets/
│ │ │ ├── default-custom-theme.txt
│ │ │ └── index.ts
│ │ ├── configs/
│ │ │ ├── ai-service-options.ts
│ │ │ ├── api.ts
│ │ │ ├── index.ts
│ │ │ ├── prefix.ts
│ │ │ ├── shortcut-key.ts
│ │ │ ├── store.ts
│ │ │ ├── style.ts
│ │ │ ├── theme-css/
│ │ │ │ ├── base.css
│ │ │ │ ├── default.css
│ │ │ │ ├── grace.css
│ │ │ │ ├── index.ts
│ │ │ │ └── simple.css
│ │ │ └── theme.ts
│ │ ├── constants/
│ │ │ ├── ai-config.ts
│ │ │ └── index.ts
│ │ ├── editor/
│ │ │ ├── basicSetup.ts
│ │ │ ├── css.ts
│ │ │ ├── format.ts
│ │ │ ├── index.ts
│ │ │ ├── javascript.ts
│ │ │ ├── markdown.ts
│ │ │ └── themes.ts
│ │ ├── global.d.ts
│ │ ├── index.ts
│ │ ├── types/
│ │ │ ├── ai-services-types.ts
│ │ │ ├── common.ts
│ │ │ ├── index.ts
│ │ │ ├── raw-imports.d.ts
│ │ │ ├── renderer-types.ts
│ │ │ └── template.ts
│ │ └── utils/
│ │ ├── basicHelpers.ts
│ │ ├── fetch.ts
│ │ ├── fileHelpers.ts
│ │ ├── index.ts
│ │ ├── readingTime.ts
│ │ └── tokenTools.ts
│ └── tsconfig.json
├── patches/
│ └── @codemirror__view@6.40.0.patch
├── pnpm-workspace.yaml
├── scripts/
│ ├── build-base-image.sh
│ ├── build-multiarch.sh
│ ├── build-nginx.sh
│ ├── build-standalone.sh
│ ├── build-static.sh
│ ├── download-utools-libs.mjs
│ ├── package-utools.mjs
│ ├── push-images.sh
│ └── release.js
├── tsconfig.json
└── zbpack.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 80
trim_trailing_whitespace = true
[*.md]
max_line_length = 0
trim_trailing_whitespace = false
[COMMIT_EDITMSG]
max_line_length = 0
================================================
FILE: .github/copilot-instructions.md
================================================
# Copilot Instructions
This repository is a pnpm monorepo containing a Vue 3 web application, a VSCode extension, and a core markdown rendering library.
## Build, Test, and Lint
### Global Commands
- **Install Dependencies:** `pnpm install`
- **Lint (ESLint + Prettier):** `pnpm run lint`
- **Type Check (Vue/TS):** `pnpm run type-check`
### Web App (@md/web)
- **Development Server:** `pnpm web dev`
- **Build for Production:** `pnpm web build`
- **Build Browser Extension:** `pnpm web ext:zip` (uses WXT)
### VSCode Extension (@md/vscode)
- **Development:** `pnpm vscode`
### CLI (@doocs/md-cli)
- **Build CLI:** `pnpm run build:cli`
## High-Level Architecture
### Monorepo Structure
- **apps/web**: The main application. Built with Vue 3, Vite, Pinia, and Tailwind CSS. It functions as both a web app and a browser extension (via WXT).
- **packages/core**: The markdown rendering engine. It wraps `marked` and implements custom extensions (Mermaid, PlantUML, Ruby, etc.) and theme injection.
- **packages/shared**: Shared utilities and configurations.
- **packages/md-cli**: A CLI wrapper that serves the built web application.
### Key Technologies
- **Frontend Framework:** Vue 3 (Composition API)
- **Build System:** Vite
- **State Management:** Pinia
- **Styling:** Tailwind CSS + Custom CSS Variables for themes.
- **Markdown Parsing:** `marked` (in `@md/core`)
- **Editor Component:** CodeMirror 6
- **Extension Framework:** WXT (Web Extension Tools)
## Key Conventions
### Development Patterns
- **Direct TypeScript Imports:** The `@md/core` and `@md/shared` packages export TypeScript source files directly (`src/index.ts`). Do not attempt to build these packages separately; they are compiled by the consumer's build tool (Vite).
- **UI Components:** The project uses Shadcn-Vue style components located in `apps/web/src/components/ui`. Prefer using these over raw HTML/CSS.
- **Store Structure:** State is divided into domain-specific Pinia stores (e.g., `useEditorStore`, `useThemeStore`, `useUiStore`) located in `apps/web/src/stores`.
### Styling & Theming
- **Theme Injection:** Theming is handled by `@md/core/src/theme`. Themes are applied by injecting CSS variables into the DOM.
- **CSS processing:** Uses PostCSS and Tailwind. Global styles are in `apps/web/src/assets`.
### Markdown Extensions
- **Implementation:** New markdown features should be implemented as extensions in `@md/core/src/extensions`.
- **Registration:** Extensions must be registered in the renderer configuration.
### Git Conventions
- **Commit Messages:** Follow Conventional Commits (`feat`, `fix`, `docs`, `chore`, etc.).
- **Branch Naming:** `feat/description`, `fix/description`.
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: tuesday
time: '14:00'
timezone: Asia/Shanghai
target-branch: main
ignore:
- dependency-name: vue
- dependency-name: vite
open-pull-requests-limit: 100
groups:
minor-and-patch:
applies-to: version-updates
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily
target-branch: main
open-pull-requests-limit: 100
================================================
FILE: .github/secret_scanning.yml
================================================
paths-ignore:
- "src/**"
================================================
FILE: .github/workflows/cloudflare-preview-cleanup.yml
================================================
name: Cleanup Cloudflare Preview
on:
pull_request:
types: [closed]
jobs:
cleanup-preview:
runs-on: ubuntu-latest
if: github.repository == 'doocs/md'
permissions:
pull-requests: write
steps:
- name: Delete preview deployment
id: delete
continue-on-error: true
run: |
WORKER_NAME="md-pr-${{ github.event.pull_request.number }}"
echo "Attempting to delete $WORKER_NAME"
RESPONSE=$(curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/workers/scripts/$WORKER_NAME" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
echo "API Response: $RESPONSE"
if echo "$RESPONSE" | jq -e '.success == true' > /dev/null 2>&1; then
echo "Successfully deleted $WORKER_NAME"
echo "status=success" >> $GITHUB_OUTPUT
else
echo "Failed to delete $WORKER_NAME or worker doesn't exist"
echo "status=failed" >> $GITHUB_OUTPUT
fi
- name: Comment on PR
if: steps.delete.outputs.status == 'success'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const prNumber = context.issue.number;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: '🗑️ Cloudflare Workers preview deployment has been cleaned up.'
});
================================================
FILE: .github/workflows/cloudflare-preview.yml
================================================
name: Cloudflare Workers Preview
on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
concurrency:
group: cloudflare-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
deploy-preview:
runs-on: ubuntu-latest
if: github.repository == 'doocs/md' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
permissions:
contents: read
deployments: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up node
uses: actions/setup-node@v6
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10
- name: Install dependencies
run: pnpm install
- name: Build for Cloudflare Workers
run: pnpm web build:h5-netlify
env:
CF_WORKERS: 1
- name: Deploy to Cloudflare Workers
id: deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --name md-pr-${{ github.event.pull_request.number }}
workingDirectory: apps/web
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Get deployment URL
id: deployment-url
run: |
PREVIEW_URL="https://md-pr-${{ github.event.pull_request.number }}.doocs.workers.dev"
echo "url=$PREVIEW_URL" >> $GITHUB_OUTPUT
echo "Preview URL: $PREVIEW_URL"
- name: Comment PR with preview link
uses: actions-cool/maintain-one-comment@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
body: |
🚀 Cloudflare Workers Preview has been successfully deployed!
**Preview URL:** ${{ steps.deployment-url.outputs.url }}
<sub>Built with commit ${{ github.event.pull_request.head.sha }}</sub>
<!-- Cloudflare Preview Comment -->
body-include: '<!-- Cloudflare Preview Comment -->'
number: ${{ github.event.pull_request.number }}
================================================
FILE: .github/workflows/deploy-gitee.yml
================================================
name: Build and Deploy to Gitee Pages
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-gitee-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
if: github.repository == 'doocs/md'
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # 获取完整历史,便于推送到新分支
- name: Set up node
uses: actions/setup-node@v6
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10
- name: Install dependencies
run: pnpm install
- name: Build project
run: pnpm web build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./apps/web/dist
publish_branch: dist
force_orphan: true
user_name: 'github-actions[bot]'
user_email: 'github-actions[bot]@users.noreply.github.com'
commit_message: 'Deploy: ${{ github.sha }}'
- name: Sync to Gitee
id: gitee-sync
uses: Yikun/hub-mirror-action@master
continue-on-error: true
with:
src: github/doocs
dst: gitee/doocs
dst_key: ${{ secrets.GITEE_RSA_PRIVATE_KEY }}
dst_token: ${{ secrets.GITEE_TOKEN }}
static_list: "md"
force_update: true
debug: true
- name: Deploy Gitee Pages
id: gitee-deploy
if: steps.gitee-sync.outcome == 'success'
continue-on-error: true
uses: yanglbme/gitee-pages-action@main
with:
gitee-username: ${{ secrets.GITEE_USERNAME }}
gitee-password: ${{ secrets.GITEE_PASSWORD }}
gitee-repo: doocs/md
branch: dist
- name: Deployment Summary
run: |
echo "✅ Build completed successfully!"
echo "📦 Artifacts pushed to dist branch"
if [ "${{ steps.gitee-sync.outcome }}" == "success" ]; then
echo "🔄 Synced to Gitee repository"
if [ "${{ steps.gitee-deploy.outcome }}" == "success" ]; then
echo "🚀 Gitee Pages deployed"
echo ""
echo "Gitee Pages: https://doocs.gitee.io/md/"
else
echo "⚠️ Gitee Pages deployment failed or skipped"
fi
else
echo "⚠️ Gitee sync failed or skipped"
fi
================================================
FILE: .github/workflows/deploy.yml
================================================
name: Build and Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }} - ${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
if: github.repository == 'doocs/md'
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up node
uses: actions/setup-node@v6
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10
- name: Install dependencies
run: pnpm install
- name: Determine build command
id: build-command
run: |
if [[ "${{ secrets.BUILD_COMMAND }}" == "build:h5-netlify" ]]; then
echo "BUILD_COMMAND=build:h5-netlify" >> $GITHUB_ENV
else
echo "BUILD_COMMAND=build" >> $GITHUB_ENV
fi
- name: Run build
run: pnpm web ${{ env.BUILD_COMMAND }}
- name: Generate CNAME
run: |
mkdir -p apps/web/dist
echo "md.doocs.org" > apps/web/dist/CNAME
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: apps/web/dist
deploy-pages:
needs: build
permissions:
pages: write
id-token: write
environment:
name: github_pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
deploy-cloudflare:
needs: build
runs-on: ubuntu-latest
if: github.repository == 'doocs/md'
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up node
uses: actions/setup-node@v6
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10
- name: Install dependencies
run: pnpm install
- name: Build for Cloudflare Workers
run: pnpm web build:h5-netlify
env:
CF_WORKERS: 1
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --name md
workingDirectory: apps/web
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Deployment Summary
run: |
echo "🚀 Deployed to Cloudflare Workers!"
echo "📍 URL: https://md.doocs.workers.dev"
================================================
FILE: .github/workflows/docker.yml
================================================
name: Build and Push Docker Images
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
if: github.repository == 'doocs/md'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push multi-arch images
run: |
chmod +x scripts/build-multiarch.sh
bash scripts/build-multiarch.sh
================================================
FILE: .github/workflows/release-cli.yml
================================================
name: Create Cli Release
on:
push:
tags:
- 'cli-v*'
permissions:
id-token: write # Required for OIDC trusted publishing
contents: read
jobs:
build:
runs-on: ubuntu-latest
if: github.repository == 'doocs/md'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
registry-url: https://registry.npmjs.org/
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10
- name: Update npm
run: npm install -g npm@latest
- run: pnpm install --frozen-lockfile
- run: pnpm run build:cli
- run: cd packages/md-cli && npm publish --registry=https://registry.npmjs.org/
================================================
FILE: .github/workflows/release.yml
================================================
name: Create Release
on:
push:
tags:
- "v*"
jobs:
release:
name: Create GitHub Release
runs-on: ubuntu-latest
if: github.repository == 'doocs/md'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Extract Changelog for Tag
id: changelog
run: |
TAG_NAME="${GITHUB_REF##*/}"
echo "Extracting changelog for $TAG_NAME"
# 提取 CHANGELOG.md 中对应版本块的内容
CHANGELOG=$(awk "/^## \\[$TAG_NAME\\]/ {flag=1; next} /^## \\[/ {flag=0} flag" CHANGELOG.md)
# 如果为空就设置默认信息
if [ -z "$CHANGELOG" ]; then
CHANGELOG="No changelog entry found for $TAG_NAME."
fi
echo "changelog<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
body: |
# 微信 Markdown 编辑器 ${{ github.ref_name }} 发布🎈
[](https://github.com/doocs/md/releases) [](https://gitee.com/doocs/md/releases) [](https://gitcode.com/doocs/md/releases)
> Markdown 文档自动即时渲染为微信图文,让你不再为微信内容排版而发愁!
${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false
================================================
FILE: .github/workflows/stale-bot.yml
================================================
name: Stale Bot
on:
schedule:
- cron: "0 6 * * *" # 每天北京时间 14:00 运行
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Issue 设置
days-before-stale: 60 # 超过 60 天无活动 -> 标记 stale
days-before-close: 7 # 被标记后 7 天仍无活动 -> 关闭
stale-issue-message: "此 Issue 因长期无回复而被标记为过期,如果 7 天内无回复将自动关闭。"
close-issue-message: "此 Issue 已因无长期无回复自动关闭。"
days-before-pr-stale: -1 # 不处理 PR
days-before-pr-close: -1
# 以下标签不会被标记为 stale
exempt-issue-labels: "help wanted, good first issue, never gets stale, enhancement, bug, issue: author provided repro"
stale-needs-author-feedback:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# 仅处理包含 "needs: author feedback" 标签的 Issue
any-of-labels: 'needs: author feedback'
# Issue 设置
days-before-stale: 24
days-before-close: 7
stale-issue-message: "此 Issue 已等待作者反馈 24 天,请提供所需信息,否则 7 天后将自动关闭。"
close-issue-message: "此 Issue 因作者未在 7 天内提供所需反馈而自动关闭。"
days-before-pr-stale: -1 # 不处理 PR
days-before-pr-close: -1
# 以下标签不会被标记为 stale
exempt-issue-labels: "help wanted, good first issue, never gets stale, enhancement, bug, issue: author provided repro"
================================================
FILE: .github/workflows/surge-preview-build.yml
================================================
name: Surge Preview Build
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
build-preview:
runs-on: ubuntu-latest
if: github.repository == 'doocs/md'
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up node
uses: actions/setup-node@v6
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10
- name: Build
run: |
pnpm install
pnpm web build:h5-netlify
- name: Upload dist artifact
uses: actions/upload-artifact@v7
with:
name: dist
path: apps/web/dist
retention-days: 5
- name: Save PR number
if: ${{ always() }}
run: echo ${{ github.event.number }} > ./pr-id.txt
- name: Upload PR number
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: pr
path: ./pr-id.txt
================================================
FILE: .github/workflows/surge-preview-deploy.yml
================================================
name: Surge Preview Deploy
on:
workflow_run:
workflows: ["Surge Preview Build"]
types:
- completed
jobs:
deploy:
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' && github.repository == 'doocs/md'
steps:
- name: Download PR artifact
uses: dawidd6/action-download-artifact@v19
with:
workflow: ${{ github.event.workflow_run.workflow_id }}
run_id: ${{ github.event.workflow_run.id }}
name: pr
- name: Save PR id
id: pr
run: |
pr_id=$(<pr-id.txt)
if ! [[ "$pr_id" =~ ^[0-9]+$ ]]; then
echo "Error: pr-id.txt does not contain a valid numeric PR id. Please check."
exit 1
fi
echo "id=$pr_id" >> $GITHUB_OUTPUT
- name: Download dist artifact
uses: dawidd6/action-download-artifact@v19
with:
workflow: ${{ github.event.workflow_run.workflow_id }}
run_id: ${{ github.event.workflow_run.id }}
workflow_conclusion: success
name: dist
- name: Upload surge service
id: deploy
run: |
export DEPLOY_DOMAIN=https://doocs-md-preview-pr-${{ steps.pr.outputs.id }}.surge.sh
npx surge --project ./ --domain $DEPLOY_DOMAIN --token ${{ secrets.SURGE_TOKEN }}
- name: Comment PR with preview link
uses: actions-cool/maintain-one-comment@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
body: |
🚀 Surge Preview has been successfully deployed!
**Preview URL:** https://doocs-md-preview-pr-${{ steps.pr.outputs.id }}.surge.sh
<sub>Built with commit ${{ github.event.workflow_run.head_sha }}</sub>
<!-- Surge Preview Comment -->
body-include: '<!-- Surge Preview Comment -->'
number: ${{ steps.pr.outputs.id }}
- name: Deploy failed
if: ${{ failure() }}
uses: actions-cool/maintain-one-comment@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
body: |
😭 Surge Preview deployment failed.
Please check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.
<!-- Surge Preview Comment -->
body-include: '<!-- Surge Preview Comment -->'
number: ${{ steps.pr.outputs.id }}
failed:
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure' && github.repository == 'doocs/md'
steps:
- name: Download PR artifact
uses: dawidd6/action-download-artifact@v19
with:
workflow: ${{ github.event.workflow_run.workflow_id }}
run_id: ${{ github.event.workflow_run.id }}
name: pr
- name: Save PR id
id: pr
run: |
pr_id=$(<pr-id.txt)
if ! [[ "$pr_id" =~ ^[0-9]+$ ]]; then
echo "Error: pr-id.txt does not contain a valid numeric PR id. Please check."
exit 1
fi
echo "id=$pr_id" >> $GITHUB_OUTPUT
- name: Comment PR with build failure
uses: actions-cool/maintain-one-comment@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
body: |
😭 Surge Preview build failed.
Please check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}) for details.
<!-- Surge Preview Comment -->
body-include: '<!-- Surge Preview Comment -->'
number: ${{ steps.pr.outputs.id }}
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
dist
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# mockm
httpData
public/upload/**
!public/upload/*.gitkeep
.history
# Package manager lock file
package-lock.json
yarn.lock
# pnpm-lock.yaml
auto-imports.d.ts
components.d.ts
.wxt
.output
web-ext.config.ts
.wrangler
# vite-plugin-pwa dev output
dev-dist
# uTools build artifacts
apps/utools/dist
apps/utools/release
# uTools local libs (只在打包时需要,不提交到仓库)
apps/web/public/static/libs/mathjax
apps/web/public/static/libs/mermaid
apps/web/public/static/libs/article-syncjs
================================================
FILE: .husky/pre-commit
================================================
#!/bin/sh
if [ "$SKIP_SIMPLE_GIT_HOOKS" = "1" ]; then
echo "[INFO] SKIP_SIMPLE_GIT_HOOKS is set to 1, skipping hook."
exit 0
fi
if [ -f "$SIMPLE_GIT_HOOKS_RC" ]; then
. "$SIMPLE_GIT_HOOKS_RC"
fi
npx lint-staged
================================================
FILE: .npmrc
================================================
registry=https://registry.npmmirror.com
================================================
FILE: .nvmrc
================================================
v22.16.0
================================================
FILE: .vscode/extensions.json
================================================
{
"recommendations": ["Vue.volar"]
}
================================================
FILE: .vscode/launch.json
================================================
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"trace": true,
"sourceMaps": true,
"cwd": "${workspaceFolder}/apps/vscode",
"args": ["--extensionDevelopmentPath=${workspaceFolder}/apps/vscode", "--enable-proposed-api=vscode.vscode-js-debug"],
"runtimeExecutable": "${execPath}",
"outFiles": ["${workspaceFolder}/apps/vscode/dist/*.js"],
"preLaunchTask": "compile extension"
}
]
}
================================================
FILE: .vscode/settings.json
================================================
{
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off" },
{ "rule": "format/*", "severity": "off" },
{ "rule": "*-indent", "severity": "off" },
{ "rule": "*-spacing", "severity": "off" },
{ "rule": "*-spaces", "severity": "off" },
{ "rule": "*-order", "severity": "off" },
{ "rule": "*-dangle", "severity": "off" },
{ "rule": "*-newline", "severity": "off" },
{ "rule": "*quotes", "severity": "off" },
{ "rule": "*semi", "severity": "off" }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"css",
"less",
"scss",
"pcss",
"postcss"
]
}
================================================
FILE: .vscode/tasks.json
================================================
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "compile",
"path": "apps/vscode",
"group": "build",
"problemMatcher": [],
"label": "compile extension",
"detail": "webpack"
}
]
}
================================================
FILE: CHANGELOG.md
================================================
## [v2.1.0] - 2025-10-17
### ✨ 新特性
- **AI 助手侧边栏 & 文生图**:新增独立的 AI 助手侧边栏,支持智能对话、文本生成与 AI 图像生成功能,让创作更高效便捷。
- **PlantUML & Ruby 语法支持**:支持 PlantUML 图表渲染和 Ruby 文本标注(注音符号),扩展图表与多语言表达能力。
- **图片压缩 & 上传进度条**:上传图片时自动压缩并显示进度条,优化存储空间与用户体验。
- **移动端适配增强**:进一步优化移动端显示与交互体验,支持更流畅的移动端创作。
### 🏗️ 架构与部署
- **CodeMirror 升级至 v6**:编辑器核心组件全面升级,提升性能与稳定性。
- **Vite 升级至 v7**:构建工具全面升级,显著提升构建速度与开发体验。
- **pnpm Monorepo 重构**:项目重构为 pnpm monorepo 架构,提升代码组织与维护效率。
- **Cloudflare Workers 部署**:支持 Cloudflare Workers 部署与自动化 CI/CD,增强全球访问性能。
### 👏 贡献者
感谢以下贡献者的杰出贡献:
@yanglbme @YangFong @zeevenn @honwhy @xxxxxxxjy @lihuacai168 @simonzhangs 等
> 感谢所有贡献者的努力!🚀
> **doocs/md** 将持续为打造更流畅、更强大的 Markdown 创作体验不断前行!
## [v2.0.4] - 2025-06-20
### ✨ 新特性
- **编辑器搜索与替换**:编辑器新增搜索和替换快捷键,提升文本编辑效率。
- **标题快捷键支持**:新增 `Ctrl/Cmd + 1~6` 快捷键,可快速插入对应级别的 Markdown 标题。
- **VSCode 插件初步集成**:开始支持 VSCode 插件,为桌面端用户提供另一种创作体验。
- **浏览器插件扩展 SitePanel 支持**:浏览器插件支持 SitePanel 集成,增强页面侧边栏能力。
### 🛠 功能优化与问题修复
- **重置问题修复**:修复重置编辑器文档时未正确清空的问题。
- **XSS 漏洞修复**:修复潜在的跨站脚本攻击问题,提升系统安全性。
- **Docker 镜像问题修复**:解决构建与运行中的镜像异常问题,增强部署稳定性。
### 👏 贡献者
感谢以下贡献者的杰出贡献:
@syhxzzz @yanglbme @YangFong @honwhy @bygsn @lurenyang418 等
更强大的 Markdown 创作体验不断前行!
> 感谢所有贡献者的努力!🚀
> **doocs/md** 将持续为打造更流畅、更强大的 Markdown 创作体验不断前行!
## [v2.0.3] - 2025-05-25
### ✨ 新特性
- **AI 引用全文 & 快捷指令**:AI 现在可直接引用整篇文档并支持自定义快捷指令,编辑器与 AI 能力深度融合,提升交互效率。
- **一键清空文档**:新增“清空”按钮,支持一键删除全部内容,快速重置编辑环境。
- **公众号名片插入**:支持在 Markdown 中便捷插入公众号名片,丰富文章展示形式。
- **Telegram & Cloudinary 图床**:新增 Telegram、Cloudinary 图床选项,进一步扩充多图床生态。
### 🛠 功能优化与问题修复
- **AI Prompt 优化**:改进提示词生成策略,使 AI 回答更精准、上下文衔接更自然。
- **文件名修复**:解决保存与导出时偶发的文件名错误问题。
- **行内公式样式优化**:调整行内公式渲染样式,提升可读性与排版一致性。
- **编辑器快捷键优化**:重新梳理常用快捷键映射,操作更顺手。
### 👏 贡献者
@yanglbme @syhxzzz @YangFong @Nefelibata-Zhu @biggerboy @SiZV200 @XAihan @zzydannyer @quiet-river
> 感谢所有贡献者的努力!🚀
> **doocs/md** 将持续为打造更流畅、更强大的 Markdown 创作体验不断前行!
## [v2.0.2] - 2025-05-06
### ✨ 新特性
- **AI 工具箱**:支持智能优化文本、翻译、文本纠错、内容总结等功能,进一步提升内容创作效率。
- **配置导入与导出**:支持导出、导入配置,实现跨设备同步,简化配置管理。
- **又拍云图床支持**:新增又拍云图床,丰富图床选择。
- **多预览模式**:支持移动端和电脑端两种预览模式,适配不同设备的阅读和编辑体验。
- **AI 推理过程展示**:AI 对话功能支持展示推理过程,提升对话透明度与可解释性。
- **脚注支持**:支持 Markdown 脚注功能,方便用户为文档添加注释与引用。
### 🛠 功能优化与问题修复
- **修复消息错乱**:修复 AI 对话过程中删除消息导致消息错乱的问题。
- **排序问题修复**:修复内容管理模块中排序方式失效的问题。
### 👏 贡献者
感谢以下贡献者的杰出贡献:
@yanglbme @YangFong @honwhy @wNing50 @zzydannyer @XAihan @dodolalorc @codedogQBY @quiet-river @acbin
> 感谢所有贡献者的努力!🚀
> **doocs/md** 将持续为打造更流畅、更强大的 Markdown 创作体验不断前行!
## [v2.0.1] - 2025-04-28
### ✨ 新特性
- **AI 能力增强**:支持多种主流 AI 模型,包括 DeepSeek、OpenAI、通讯千问、腾讯混元、智谱 AI、百川智能、月之暗面等。内置默认 AI 服务,用户无需配置 sk,即可免费使用智能助手功能,提升内容创作与处理体验。
- **公众号图片上传体验优化**:通过引入 Cloudflare Functions & Pages,进一步优化了公众号图床的配置与上传体验。
- **内容管理功能提升**:支持自定义内容排序方式,帮助用户更灵活地管理和查找内容。
- **支持导出为 PNG**:可将文档内容一键导出为 PNG 图片,方便快速分享与保存。
- **初步适配移动端**:针对移动端进行了初步适配,优化了浏览与编辑体验,为不同设备使用场景打下基础。
### 🛠 功能优化与问题修复
- **主题样式修复**:修复了部分主题存在的样式问题,提升整体界面一致性和视觉体验。
- **编辑界面优化**:优化了编辑器的界面布局与交互体验,提升用户操作的流畅性。
### 👏 贡献者
感谢以下贡献者的杰出贡献:
@honwhy @YangFong @ting772 @yanglbme @acbin @chinenkai @wNing50
> 感谢所有贡献者的努力!🚀
> **doocs/md** 将持续为打造更流畅、更强大的 Markdown 创作体验不断前行!
## [v2.0.0] - 2025-04-18
### 1. 新特性亮点
- **数学公式与 Mermaid 流程图支持**:全面支持 Markdown 基础语法、数学公式、Mermaid 图表等,提升内容表达能力。
- **自定义样式面板**:新增样式自定义面板,支持主题色和 CSS 定制,适配浅/暗模式。
- **本地内容管理**:支持一键导入导出和自动草稿保存,提升编辑效率与安全性。
- **图床支持扩展**:新增公众号与 Cloudflare R2 图床支持,灵活的上传逻辑配置。
- **插件支持**:新增浏览器扩展插件,支持 Chrome、Edge、Firefox 等主流浏览器。
- **AI 助手集成**:集成智能 AI 助手功能,支持与主流 AI 模型(如 DeepSeek、OpenAI、通义千问)进行自然语言对话,辅助内容创作、语法优化、格式转换等场景,极大提升写作效率。
### 2. 框架、镜像升级
- **Node.js 20+ 与 Vue3 + Vite**:全面升级依赖,基于 Vue3 和 Vite,显著提升性能与兼容性。
- **Docker 多架构镜像**:支持 `linux/arm64` 和 `linux/amd64` 多架构镜像。
### 3. 贡献者
@YangFong @yanglbme @honwhy @bravekingzhang @dribble-njr @lurenyang418 @chensirup @wll8 @thinkasany @arunsathiya @realskyrin @rwecho
## [v1.6.0] - 2023-12-05
### 1. 新特性亮点
- **Mac 风格代码块样式支持**:增加 Mac 风格的代码块渲染样式,提升视觉一致性与可读性。
- **LATEX 数学公式支持**:引入 LATEX 编辑与渲染能力,支持科学公式表达,适用于技术写作与学术场景。
### 2. 功能优化与修复
- **组件重构与性能优化**:对部分组件结构进行重构与优化,提升整体性能与维护性。
- **Bug 修复**:修复部分用户反馈的问题,提升使用稳定性与用户体验。
### 3. 框架与部署支持
- **Node 版本升级**:升级 Node.js 版本以增强兼容性和构建性能。
- **Docker 镜像同步推送**:更新版本已同步发布至 Docker Hub,可通过以下命令快速启动本地实例 `docker run -d -p 8080:80 doocs/md:latest`
### 4. 贡献者
@YangFong @yanglbme @bravekingzhang @DandelionCloud
================================================
FILE: CONTRIBUTING.md
================================================
# 贡献指南
感谢你对 **doocs/md** 的兴趣!我们欢迎任何形式的贡献,包括但不限于报告缺陷、改进文档、提交新特性或修复 Bug。本指南旨在帮助你快速地为项目做出贡献。
## 目录
- [贡献指南](#贡献指南)
- [目录](#目录)
- [前置条件](#前置条件)
- [快速开始](#快速开始)
- [开发流程](#开发流程)
- [代码规范](#代码规范)
- [提交规范](#提交规范)
- [Branch 命名](#branch-命名)
- [Pull Request 标题](#pull-request-标题)
- [Pull Request 流程](#pull-request-流程)
- [Issue 报告](#issue-报告)
- [行为准则](#行为准则)
- [沟通渠道](#沟通渠道)
## 前置条件
- **Node.js ≥ 22**
- **pnpm ≥ 10**
## 快速开始
该项目为 pnpm monorepo 项目,使用 pnpm 管理依赖。
项目结构如下:
```shell
- apps
- web # 网页及浏览器插件
- vscode # VSCode 插件
- packages
- config # 项目级别配置
- core # 核心 markdown 渲染器
- shared # 共享的配置、常量、类型和工具函数
- example # 公众号 openapi 接口代理服务示例
- md-cli # 命令行工具
```
以开发 `@md/web` 为例:
```bash
# 1. Fork 本仓库并克隆
git clone https://github.com/<你的用户名>/md.git
cd md
# 2. 配置上游仓库
git remote add upstream https://github.com/doocs/md.git
# 3. 安装依赖
pnpm install
# 4. 启动本地开发
pnpm web dev
```
## 开发流程
1. 从 `main` 分支拉取最新代码:
```bash
git checkout main
git pull upstream main
```
2. 基于 `main` 创建功能分支:
```bash
git checkout -b feat/awesome-feature
```
3. 编码 & 编写/更新测试。
4. 运行检查:
```bash
pnpm run lint # ESLint + Prettier
pnpm run type-check # TypeScript 类型检查
pnpm run web build # 产物验证
```
5. 提交并推送:
```bash
git add .
git commit -m "feat: awesome feature"
git push origin feat/awesome-feature
```
6. 在 GitHub 页面发起 **Pull Request**。
> [!TIP]
> 开发时可在 `apps/web` 目录下新建 `.env.local` 文件,配置 `VITE_LAUNCH_EDITOR` 为 `code` (默认值)或其他 [支持的编辑器](https://github.com/yyx990803/launch-editor?tab=readme-ov-file#supported-editors),方便调试。
>
> 例如:
>
> ```
> VITE_LAUNCH_EDITOR=cursor
> ```
## 代码规范
- 遵循项目自带的 **ESLint**、**Prettier** 与 **Stylelint** 配置。
- 所有提交必须通过 `pnpm run lint` 检查,无警告、无错误。
- 推荐在 IDE 中启用 **ESLint** 与 **Prettier** 自动修复。
## 提交规范
| 类型 | 说明 |
| -------- | -------------------------- |
| feat | 新功能 |
| fix | Bug 修复 |
| docs | 文档变更 |
| style | 代码格式(不影响逻辑) |
| refactor | 重构(非修复亦非新增功能) |
| perf | 性能优化 |
| test | 测试相关 |
| build | 构建系统或依赖变动 |
| chore | 其他辅助变动 |
### Branch 命名
```
feat/<简要描述>
fix/<简要描述>
docs/<简要描述>
```
### Pull Request 标题
保持与首条 commit message 一致,建议附带影响范围(Scope)与简要描述,例如:
```
feat(editor): 支持自定义快捷键
```
## Pull Request 流程
1. **描述清晰**:在 PR 模板中说明变更动机、相关 Issue、实现方案及影响范围。
2. **保持小而聚焦**:一个 PR 只做一件事,方便审阅。
3. **确保测试**:新增/变更功能需自测,确保没问题。
4. **更新文档**:公共 API 或行为变更必须同步更新文档。
5. **CI 通过**:PR 必须通过所有 CI 检查(类型、lint、单测、构建)。
6. **等待审核**:维护者会在 1 ~ 3 个工作日内回复。请耐心等待并根据建议进行修订。
## Issue 报告
- 先 **搜索** 已有 Issue,避免重复。
- 提供 **可复现仓库 / 代码片段 / 截图 / 终端输出**。
- 说明 **期望行为** 与 **实际行为**。
- 指明 **运行环境**(操作系统、浏览器、Node 版本等)。
- Bug 标签由维护者分配,请勿自行指定。
## 行为准则
我们遵循 [Contributor Covenant](https://www.contributor-covenant.org/) v2.1。
任何违反行为准则的行为都可能导致暂时或永久的禁言、封号。请保持友善。
## 沟通渠道
- **GitHub Discussions**:[https://github.com/doocs/md/discussions](https://github.com/doocs/md/discussions)
- **Issues**:仅限缺陷反馈和功能需求
- **微信群**:添加项目维护者微信,备注 `md`,拉你进群
---
❤️ 感谢每一位贡献者!让我们一起让 **doocs/md** 变得更好。
================================================
FILE: LICENSE
================================================
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2025 Doocs <admin@doocs.org>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
================================================
FILE: README.md
================================================
<div align="center">
[](https://github.com/doocs/md)
</div>
<h1 align="center">微信 Markdown 编辑器</h1>
<div align="center">
[](https://github.com/doocs/md/actions) [](https://nodejs.org/en/about/previous-releases) [](https://github.com/doocs/md/pulls) [](https://github.com/doocs/md/stargazers) [](https://github.com/doocs/md)<br> [](https://github.com/doocs/md/releases) [](https://www.npmjs.com/package/@doocs/md-cli) [](https://hub.docker.com/r/doocs/md)
</div>
## 🎯 赞助商
<div align="center">
[](https://share.302.ai/ftIXIE)
</div>
> **[302.AI](https://share.302.ai/ftIXIE)** 是一个按用量付费的企业级 AI 资源平台,提供市场上最新、最全面的 AI 模型和 API,以及多种开箱即用的在线 AI 应用。
## 📝 项目介绍
**Markdown 文档自动即时渲染为微信图文**,让你不再为微信内容排版而发愁!只要你会基本的 Markdown 语法(现在有了 AI,你甚至不需要会 Markdown),就能做出一篇样式简洁而又美观大方的微信图文。
**如果这个项目对你有帮助,请给我们点个 Star ⭐️**,我们会持续更新和维护!
## 🌐 在线编辑器地址
[https://md.doocs.org](https://md.doocs.org)
> **推荐使用 Chrome 浏览器**,效果最佳。
## 🤔 为何开发这款编辑器
现有的开源微信 Markdown 编辑器样式繁杂,排版过程中往往需要额外调整,影响使用效率。为了解决这一问题,我们打造了一款更加**简洁、优雅**的编辑器,提供更流畅的排版体验。
欢迎各位朋友随时提交 PR,让这款微信 Markdown 编辑器变得更好!如果你有新的想法,也欢迎在 [💬 Discussions 讨论区](https://github.com/doocs/md/discussions)反馈。
## ✨ 功能特性
### 🎨 核心功能
- ✅ **完整 Markdown 支持** - 支持所有基础语法、数学公式
- ✅ **图表渲染** - 支持 Mermaid 图表和 [GFM 警告块](https://github.com/orgs/community/discussions/16925)
- ✅ **PlantUML 支持** - 强大的 UML 图表渲染
- ✅ **Ruby 注音扩展** - 支持 `[文字]{注音}`、`[文字]^(注音)` 格式,支持多种分隔符
### 🎯 编辑体验
- ✅ **代码高亮** - 丰富的代码块高亮主题,提升代码可读性
- ✅ **自定义样式** - 允许自定义主题色和 CSS 样式,灵活定制展示效果
- ✅ **草稿保存** - 内置本地内容管理功能,支持草稿自动保存
### 🚀 高级功能
- ✅ **多图床支持** - 提供多种图床选择,便捷的图片上传功能
- ✅ **文件管理** - 便捷的文件导入、导出功能,提升工作效率
- ✅ **AI 集成** - 集成主流 AI 模型(DeepSeek、OpenAI、通义千问、腾讯混元、火山方舟、302.AI 等),智能辅助内容创作
## 🖼️ 支持的图床服务
| # | 图床 | 使用时是否需要配置 | 备注 |
| --- | ------------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| 1 | 默认 | 否 | - |
| 2 | [GitHub](https://github.com) | 配置 `Repo`、`Token` 参数 | [如何获取 GitHub token?](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) |
| 3 | [阿里云](https://www.aliyun.com/product/oss) | 配置 `AccessKey ID`、`AccessKey Secret`、`Bucket`、`Region` 参数 | [如何使用阿里云 OSS?](https://help.aliyun.com/document_detail/31883.html) |
| 4 | [腾讯云](https://cloud.tencent.com/act/pro/cos) | 配置 `SecretId`、`SecretKey`、`Bucket`、`Region` 参数 | [如何使用腾讯云 COS?](https://cloud.tencent.com/document/product/436/38484) |
| 5 | [七牛云](https://www.qiniu.com/products/kodo) | 配置 `AccessKey`、`SecretKey`、`Bucket`、`Domain`、`Region` 参数 | [如何使用七牛云 Kodo?](https://developer.qiniu.com/kodo) |
| 6 | [MinIO](https://min.io/) | 配置 `Endpoint`、`Port`、`UseSSL`、`Bucket`、`AccessKey`、`SecretKey` 参数 | [如何使用 MinIO?](http://docs.minio.org.cn/docs/master/) |
| 7 | [S3 协议](https://aws.amazon.com/s3/) | 配置 `Endpoint`、`Region`、`Bucket`、`AccessKey`、`SecretKey` 参数 | 支持 AWS S3、Oracle、DigitalOcean 等兼容 S3 的存储服务 |
| 8 | [公众号](https://mp.weixin.qq.com/) | 配置 `appID`、`appsecret`、`代理域名` 参数 | [如何使用公众号图床?](https://md-pages.doocs.org/tutorial) |
| 9 | [Cloudflare R2](https://developers.cloudflare.com/r2/) | 配置 `AccountId`、`AccessKey`、`SecretKey`、`Bucket`、`Domain` 参数 | [如何使用 S3 API 操作 R2?](https://developers.cloudflare.com/r2/api/s3/api/) |
| 10 | [又拍云](https://www.upyun.com/) | 配置 `Bucket`、`Operator`、`Password`、`Domain` 参数 | [如何使用 又拍云?](https://help.upyun.com/) |
| 11 | [Telegram](https://core.telegram.org/api) | 配置 `Bot Token`、`Chat ID` 参数 | [如何使用 Telegram 图床?](https://github.com/doocs/md/blob/main/docs/telegram-usage.md) |
| 12 | [Cloudinary](https://cloudinary.com/) | 配置 `Cloud Name`、`API Key`、`API Secret` 参数 | [如何使用 Cloudinary?](https://cloudinary.com/documentation/upload_images) |
| 13 | 自定义上传 | 是 | [如何自定义上传?](/docs/custom-upload.md) |
## 🎬 产品演示
<div align="center">
| 🎨 主题切换 | 🖼️ 图片上传 |
| :-----------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------: |
|  |  |
| 📝 样式扩展 | 🤖 一键排版 |
| :-----------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------: |
|  |  |
</div>
## 🛠️ 开发与部署
```sh
# 安装 node 版本
nvm i && nvm use
# 安装依赖
pnpm i
# 启动开发模式
pnpm web dev
# 访问 http://localhost:5173/md/
# 部署在 /md 目录
pnpm web build
# 部署在根目录
pnpm web build:h5-netlify
# Chrome 插件启动及调试
pnpm web ext:dev
# 访问 chrome://extensions/ 打开开发者模式,加载已解压的扩展程序,选择 apps/web/.output/chrome-mv3-dev 目录
# Chrome 插件打包
pnpm web ext:zip
# Firefox 扩展打包(how to build Firefox addon)
pnpm web firefox:zip # output zip file at in apps/web/.output/md-{version}-firefox.zip
# uTools 插件打包
pnpm utools:package # output zip file at apps/utools/release/md-utools-v{version}.zip
# cloudflare workers
pnpm web wrangler:dev # cloudflare workers dev 模式
pnpm web wrangler:deploy # cloudflare workers 部署命令
```
## 🚀 快速搭建私有服务
### 📦 方式 1. 使用 npm cli
通过我们的 npm cli 你可以轻易搭建属于自己的微信 Markdown 编辑器。
```sh
# 安装
npm i -g @doocs/md-cli
# 启动
md-cli
# 访问
open http://127.0.0.1:8800
# 启动并指定端口
md-cli port=8899
# 访问
open http://127.0.0.1:8899
```
md-cli 支持以下命令行参数:
- `port` 指定端口号,默认 8800,如果被占用会随机使用一个新端口。
- `spaceId` dcloud 服务空间配置
- `clientSecret` dcloud 服务空间配置
### 🐳 方式 2. 使用 Docker 镜像
如果你是 Docker 用户,也可以直接使用一条命令,启动**完全属于你的、私有化运行的实例**。
```sh
docker run -d -p 8080:80 doocs/md:latest
```
容器运行起来之后,打开浏览器,访问 http://localhost:8080 即可。
关于本项目 Docker 镜像的更多详细信息,可以关注 https://github.com/doocs/docker-md
## 👥 谁在使用
请查看 [📋 USERS.md](USERS.md) 文件,了解使用本项目的公众号。
## 🤝 贡献指南
我们欢迎任何形式的贡献!请查看 [📖 CONTRIBUTING.md](./CONTRIBUTING.md) 获取提交 PR、Issue 的流程与规范。
## ☕ 支持我们
如果本项目对你有所帮助,可以通过以下方式支持我们的持续开发。
<table style="margin: 0 auto">
<tbody>
<tr>
<td align="center" style="width: 260px">
<img
src="https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/support1.jpg"
style="width: 200px"
/><br />
</td>
<td align="center" style="width: 260px">
<img
src="https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/support2.jpg"
style="width: 200px"
/><br />
</td>
</tr>
</tbody>
</table>
## 💬 反馈与交流
如果你在使用过程中遇到问题,或者有好的建议,欢迎在 [🐛 Issues](https://github.com/doocs/md/issues) 中反馈。你也可以加入我们的交流群,和我们一起讨论,若群二维码失效,请添加好友,备注 `md`,我们会拉你进群。
<table style="margin: 0 auto">
<tbody>
<tr>
<td align="center" style="width: 260px">
<img
src="https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/doocs-md-wechat-group.jpg"
style="width: 200px"
/><br />
</td>
<td align="center" style="width: 260px">
<img
src="https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/wechat-ylb.jpg"
style="width: 200px"
/><br />
</td>
</tr>
</tbody>
</table>
================================================
FILE: USERS.md
================================================
## 谁在使用
- [Doocs](https://mp.weixin.qq.com/s/RNKDCK2KoyeuMeEs6GUrow)
- [ApachePulsar](https://mp.weixin.qq.com/s/udU2ZICg60HbspgWTQdYpg)
- [码云 Gitee](https://mp.weixin.qq.com/s/bnlWqzCarDlR4F27HHXNUg)
- [掘墓人的小铲子](https://mp.weixin.qq.com/s/FpGIX9viQR6Z9iSCEPH86g)
- [全网重点](https://mp.weixin.qq.com/s/yB3ZH3jmcF_LbzuKmnR9BQ)
- [爱码士的内心独白](https://mp.weixin.qq.com/s/oc5Z2t9ykbu_Dezjnw5mfQ)
- [乐玩 nodejs npm 工具库](https://mp.weixin.qq.com/s/SFde8OsZ8FzNGMHwpmDtrg)
- [简静慢](https://mp.weixin.qq.com/s/7UG24ZugfI5ZnhUpo8vfvQ)
- [编程图解](https://mp.weixin.qq.com/s/7bfpKACg7HP-PhBrkpM9IQ)
- [好酸一柠檬](https://mp.weixin.qq.com/s/CVqmcu_OGG8TQO4FViAQ3w)
- [不知所云 Hub](https://mp.weixin.qq.com/s/leDCdpvnfk8eZRPRRHwg5w)
- [柯宁申的叙事屋](https://mp.weixin.qq.com/s/AHHrxu7aIYBpvn3PpVHE_Q)
- [我的 Beta 世界](https://mp.weixin.qq.com/s/6BO977YG5e_4qYxL4oVQJw)
- [生化环材](https://mp.weixin.qq.com/s/fqNxIRxTkn6QEPmi4atW9w)
- [秀宇笔记](https://mp.weixin.qq.com/s/VUlOBFA93eiqZ5ZYGmXzmQ)
- [IT 王小二](https://mp.weixin.qq.com/s/UU3cH8LvpO_3aeAkkYvZZQ)
- [小二来碗饭](https://mp.weixin.qq.com/s/49wUuhOEYG-OZPbFc6_NrQ)
- [青年技术宅](https://mp.weixin.qq.com/s/YDUZ0t_spzeqXiE_Idv3OA)
- [路引科研](https://mp.weixin.qq.com/s/oinGHCmer1vNE6Hg2OsH1g)
- [凯文有事找你](https://mp.weixin.qq.com/s/ap_JhwgmfxgqFAIcTF3nKQ)
- [软件部落库](https://mp.weixin.qq.com/s/itkJtMY-1IkZjIn5fWtShw)
- [网文小密圈](https://mp.weixin.qq.com/s/_44Ya309DeQzemXLnJUNdQ)
- [潇洒哥和黑大帅](https://mp.weixin.qq.com/s/k9WbW0zmxl0S2WX2CXQ6cQ)
- [云原生指北](https://mp.weixin.qq.com/s/qFQBBpjUoqdfnmCeOGqRJQ)
- [全栈民工](https://mp.weixin.qq.com/s/i7hTPuuJAtcK9G55tep0Uw)
- [睡不醒的鲤鱼](https://mp.weixin.qq.com/s/14HNDbDIvfDnV7ePEfbyuQ)
- [Dmego](https://mp.weixin.qq.com/s/4QeZsTL84lbN_HO3kCwEwg)
- [红岸](https://mp.weixin.qq.com/s/_cNyKqRr8E1ENg9r7IO70Q)
- [HelloCoder](https://mp.weixin.qq.com/s/ekCoyhT-JjbYsysKBgdJzQ)
- [前端黑板报](https://mp.weixin.qq.com/s/bnZebWPd5-TgiXgQVUKdaQ)
- [Web3HackerWorld](https://mp.weixin.qq.com/s/eLuC6e93RR1zCD3w2FgpVA)
- [StruggleYang](https://mp.weixin.qq.com/s/fKKQrsatC9en3PwWiCL-KQ)
- [比心技术](https://mp.weixin.qq.com/s/DYzzci2paf10CgW22pkyUQ)
- [Pyvan](https://mp.weixin.qq.com/s/YeIev850YlFLFrmzxwUcdg)
- [CloudberryDB](https://mp.weixin.qq.com/s/8-YRch1U4DiXbpbUHQ1rWQ)
- [也无言](https://mp.weixin.qq.com/s/pxykYtxQtvG1SAFz9SO5gw)
- [易学历史](https://mp.weixin.qq.com/s/ICOb210BFzuyP49Zf5kj0A)
- [小盒子的技术分享](https://mp.weixin.qq.com/s/ilKtA4c3_xQK5ZjwrCZIFw)
- [Code365](https://mp.weixin.qq.com/s/WXBZTqkK1JvYlMg5GWyPhA)
- [IT 智行](https://mp.weixin.qq.com/s/4eSGBiUX6aC-f6rG5xBq7g)
- [哪里不会点哪里](https://mp.weixin.qq.com/s/dDe3pyziFjFMbiFO249U4g)
- [AI 思维车间订阅号](https://mp.weixin.qq.com/s/f3Z0kWtEa5qjNDl8s_wArA)
- [肖恩聊技术](https://mp.weixin.qq.com/s/hzZHwjKH5IE6H0yNXVhDPQ)
- [极客范](https://mp.weixin.qq.com/s/AjOTuwY9Cz5Ir7iOVxLn8Q)
- [AI 决策者洞察](https://mp.weixin.qq.com/s/8To24gWM5RFEZZ7SIHu46w)
- [小墨是前端](https://mp.weixin.qq.com/s/G7Nw9uBadRGbvTUtv2OtrA)
- [豆福 AI 笔记](https://mp.weixin.qq.com/s/b_OqX__jVeqgi8QCT9qMBA)
- [运维前沿](https://mp.weixin.qq.com/s/X6x2ziLZGjCelJgXECdhPg)
- [鱼 da 王](https://mp.weixin.qq.com/s/DdxK3j31TUWLNVhZtWTuVA)
- [程序员小宋](https://mp.weixin.qq.com/s/llgdqSN3AIXMlEbBuPkKNQ)
- [架构师修行之路](https://mp.weixin.qq.com/s/-HWx7VZC6NthROGBaATcLA)
- [前端徐徐](https://mp.weixin.qq.com/s/OQriNzz3LrheOWgchKpvrw)
- [科妙知行](https://mp.weixin.qq.com/s/smcivd8MNAbo0MtXdoVKaw)
- [西建大 iOS 众创空间俱乐部](https://mp.weixin.qq.com/s/YQooBjWoAg4WFIp5A4k9tw)
- [AMC 真题库](https://mp.weixin.qq.com/s/LOzNVEXtlRv_3vIDhYjyFg)
- [不止于 python](https://mp.weixin.qq.com/s/0zd3t7k9CYcwTLevh0KFHw)
- [Daily 词语仓](https://mp.weixin.qq.com/s/3SPtQuvC3ohmQICtg4tbAw)
- [没事学点 AI 小知识](https://mp.weixin.qq.com/s/rV3eNxWsJbAs93azg9q74Q)
- [攻城狮成长日记](https://mp.weixin.qq.com/s/PqtqTCWAlDsInjamND94Jw)
- [口袋狗](https://mp.weixin.qq.com/s/YZzhUjDIhF5JD_ierQc5Ng)
- [原来开源](https://mp.weixin.qq.com/s/BYXUaF9xK8aTjTSYSkl89g)
- [Jackywine](https://mp.weixin.qq.com/s/6ZT_oUQMDVskdHdA6T1gQA)
- [轱辘凯 glookai](https://mp.weixin.qq.com/s/d-CFbMnX4ABEWB-abd2p_A)
- [小竹读研在养鱼](https://mp.weixin.qq.com/s/NJ_GpCBjQzZIZTbZz3btTg)
注:如果你使用了本 Markdown 编辑器进行内容排版,并且希望在本项目 README 中展示你的公众号,请到 [#5](https://github.com/doocs/md/discussions/5) 留言。
================================================
FILE: apps/utools/README.md
================================================
# uTools 插件打包指引
该目录包含将微信 Markdown 编辑器打包为 [uTools](https://u.tools) 插件所需的脚本与配置。
## 快速开始
```sh
pnpm utools:package
```
该命令将完成以下动作:
1. **下载本地资源**:下载 MathJax、Mermaid、WeChat Sync 等库文件到 `apps/web/public/static/libs`(仅 uTools 打包需要,已添加到 `.gitignore`)。
2. 调用 `pnpm --filter @md/web run build:utools` 构建前端资源至 `apps/utools/dist`,该构建将自动使用相对路径,确保在 uTools 的 `file://` 协议下能够正常加载。构建过程中会自动将远程 CDN 资源替换为本地资源路径。
3. 将仓库根目录的版本号写入 `apps/utools/plugin.json`,保持与主项目同步。
4. 从 `public/mpmd/icon-256.png` 拷贝插件图标至 `apps/utools/logo.png`。
5. 生成形如 `apps/utools/release/md-utools-vX.Y.Z.zip` 的安装包,可直接导入到 uTools。
> 注意:命令执行前请确认已安装 pnpm 10+ 与 Node.js 22+,并在仓库根目录执行 `pnpm install` 安装依赖。
## 本地资源说明
uTools 审核要求插件不能加载远程资源。打包时会自动下载以下库文件到本地(这些文件不会提交到 Git 仓库):
- **MathJax** - 数学公式渲染库
- **Mermaid** - 流程图渲染库
- **WeChat Sync** - 文章同步脚本
构建时,Vite 插件会自动将 HTML 中的 CDN 链接替换为本地资源路径,确保插件可以离线运行。
### 手动下载资源
如需单独下载资源文件:
```sh
node scripts/download-utools-libs.mjs
```
## 手动导入调试
1. 运行 `pnpm --filter @md/web run build:utools`。
2. 打开 uTools,进入插件面板中的「开发者工具」。
3. 选择「载入本地插件」,指向 `apps/utools` 目录即可。
## 目录说明
- `plugin.json`:uTools 插件的清单文件。
- `preload.js`:在 uTools 渲染进程和 Web 前端之间建立通信的脚本,用于处理插件唤起事件。
- `package.json`:将此目录标记为 CommonJS 模块以兼容 uTools。
- `dist/`:由 Vite 构建输出的静态资源目录。
- `release/`:运行打包命令后生成的插件安装包。
================================================
FILE: apps/utools/package.json
================================================
{
"type": "commonjs",
"private": true
}
================================================
FILE: apps/utools/plugin.json
================================================
{
"pluginName": "微信 Markdown 编辑器",
"description": "Markdown 文档自动排版为微信图文,随时在 uTools 中打开使用",
"version": "2.1.0",
"author": "doocs",
"homepage": "https://github.com/doocs/md",
"pluginType": "tool",
"platform": [
"win32",
"darwin",
"linux"
],
"logo": "logo.png",
"main": "dist/index.html",
"preload": "preload.js",
"features": [
{
"code": "wechat-md",
"explain": "打开微信 Markdown 编辑器",
"cmds": [
"微信排版",
"Markdown 编辑器",
"doocs md"
]
}
]
}
================================================
FILE: apps/utools/preload.js
================================================
(() => {
if (typeof window === `undefined`)
return
// 标识当前环境为 uTools
window.__MD_UTOOLS__ = true
/**
* 安全调用 uTools API 方法
* @param {string} method - 方法名
* @param {...any} args - 方法参数
*/
const safeCall = (method, ...args) => {
try {
if (typeof window.utools?.[method] === `function`) {
window.utools[method](...args)
}
}
catch (error) {
console.warn(`[md][utools] ${method} failed:`, error)
}
}
/**
* 插件进入回调
* @param {object} action - 插件动作参数
*/
const enter = (action) => {
// 配置 uTools 窗口行为
safeCall(`hideMainWindowWhenBlur`, false)
safeCall(`showMainWindow`)
safeCall(`setExpendHeight`, 680)
// 通知前端应用
window.postMessage({ type: `utools:enter`, payload: action }, `*`)
}
/**
* 插件退出回调
*/
const leave = () => {
window.postMessage({ type: `utools:leave` }, `*`)
}
// 注册生命周期回调
safeCall(`onPluginEnter`, enter)
safeCall(`onPluginOut`, leave)
// 导出插件配置
window.exports = {
'wechat-md': {
mode: `none`,
args: {
enter,
leave,
},
},
}
})()
================================================
FILE: apps/vscode/.gitignore
================================================
out
dist
node_modules
.vscode-test/
*.vsix
================================================
FILE: apps/vscode/.npmrc
================================================
registry=https://registry.npmmirror.com
================================================
FILE: apps/vscode/.vscodeignore
================================================
.vscode/**
.vscode-test/**
out/**
node_modules/**
src/**
.gitignore
.yarnrc
webpack.config.js
vsc-extension-quickstart.md
**/tsconfig.json
**/eslint.config.mjs
**/*.map
**/*.ts
**/.vscode-test.*
.npmrc
================================================
FILE: apps/vscode/CHANGELOG.md
================================================
# doocs-md changelog
## [Unreleased] - 2025-06-04
### ✨ Features
- 侧边栏Markdown预览视图功能
- 支持微信图文特有的样式渲染
- 可自定义字体和字体大小
- 支持自定义文本主题颜色和主题样式
- 显示字数统计状态栏
- 支持 Mac 风格代码块切换
================================================
FILE: apps/vscode/LICENSE
================================================
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2025 Doocs <admin@doocs.org>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
================================================
FILE: apps/vscode/README.md
================================================
# doocs-md VS Code Extension
为 doocs-md 提供的 VS Code 扩展,支持在编辑器内实时预览 Markdown 渲染效果。
## 功能特性
- 侧边栏 Markdown 预览视图
- 支持微信图文特有的样式渲染
- 可自定义字体
- 支持自定义字体大小
- 支持自定义文本主题颜色
- 支持自定义主题样式
- 显示字数统计状态
- 支持 Mac 风格代码块切换
## 使用方法
1. 安装扩展后,打开 Markdown 文件
2. 点击活动栏中的 doocs-md 的 icon 图标
3. 在侧边栏查看实时渲染效果
## 命令
- `markdown.preview`: 打开 Markdown 预览
- `markdown.setFontFamily`: 设置预览字体
- `markdown.toggleCountStatus`: 切换字数统计显示
- `markdown.toggleMacCodeBlock`: 切换 Mac 风格代码块
## 与主项目的关系
本扩展是[doocs-md](https://github.com/doocs/md)的配套工具,使用相同的渲染方式,确保预览效果与最终微信图文完全一致。
## 开发
- **Node.js ≥ 22**
```sh
# 安装依赖
npm install
# 开发模式
npm run watch
# 打包
npm run build
```
================================================
FILE: apps/vscode/package.json
================================================
{
"publisher": "doocs",
"name": "doocs-md",
"displayName": "doocs-md",
"version": "0.0.1",
"description": "",
"repository": {
"type": "git",
"url": "https://github.com/doocs/md"
},
"categories": [
"Other"
],
"main": "./dist/extension.js",
"engines": {
"vscode": "^1.91.0"
},
"activationEvents": [],
"contributes": {
"viewsContainers": {
"activitybar": [
{
"id": "markdown-sidebar",
"title": "Markdown Preview",
"icon": "./public/icon-256-gray.png"
}
]
},
"views": {
"markdown-sidebar": [
{
"id": "markdown.preview.view",
"name": "Preview",
"icon": "./public/icon-256-gray.png"
}
]
},
"commands": [
{
"command": "markdown.preview",
"title": "Open Markdown Preview",
"icon": {
"light": "./public/icon-256-gray.png",
"dark": "./public/icon-256-gray.png"
}
},
{
"command": "markdown.setFontFamily",
"title": "Set Font Family",
"category": "Markdown Preview"
},
{
"command": "markdown.toggleCountStatus",
"title": "Toggle Count Status",
"category": "Markdown Preview"
},
{
"command": "markdown.toggleMacCodeBlock",
"title": "Toggle Mac Code Block",
"category": "Markdown Preview"
}
],
"menus": {
"editor/title": [
{
"command": "markdown.preview",
"group": "navigation",
"when": "editorLangId == markdown"
}
]
}
},
"scripts": {
"vscode:prepublish": "npm run build",
"compile": "webpack",
"watch": "webpack --watch",
"build": "webpack --mode production --devtool hidden-source-map",
"package": "vsce package --no-dependencies --allow-package-secrets github"
},
"dependencies": {
"@md/core": "workspace:*",
"@md/shared": "workspace:*",
"@types/webpack": "^5.28.5",
"isomorphic-dompurify": "^3.5.1",
"postcss": "^8.5.8",
"ts-loader": "^9.5.4",
"tsconfig-paths-webpack-plugin": "^4.2.0"
},
"devDependencies": {
"@types/vscode": "^1.110.0",
"@vscode/vsce": "^3.7.1",
"webpack-cli": "^7.0.2"
}
}
================================================
FILE: apps/vscode/src/css/index.ts
================================================
export const css = `
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--blockquote-background: #f7f7f7;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--blockquote-background: #212121;
}
`
================================================
FILE: apps/vscode/src/extension.ts
================================================
import type { ThemeName } from '@md/shared'
import { initRenderer } from '@md/core/renderer'
import { generateCSSVariables } from '@md/core/theme'
import { modifyHtmlContent } from '@md/core/utils'
import { baseCSSContent, themeMap } from '@md/shared'
import * as vscode from 'vscode'
import { css } from './css'
import { MarkdownTreeDataProvider } from './treeDataProvider'
let activePanel: vscode.WebviewPanel | undefined
export function activate(context: vscode.ExtensionContext) {
// Register TreeDataProvider
const treeDataProvider = new MarkdownTreeDataProvider(context)
vscode.window.registerTreeDataProvider(`markdown.preview.view`, treeDataProvider)
// Command for registering style settings
context.subscriptions.push(
vscode.commands.registerCommand(`markdown.setFontSize`, (size: string) => {
treeDataProvider.updateFontSize(size)
}),
vscode.commands.registerCommand(`markdown.setTheme`, (theme: ThemeName) => {
treeDataProvider.updateTheme(theme)
}),
vscode.commands.registerCommand(`markdown.setPrimaryColor`, (color: string) => {
treeDataProvider.updatePrimaryColor(color)
}),
vscode.commands.registerCommand(`markdown.setFontFamily`, (font: string) => {
treeDataProvider.updateFontFamily(font)
}),
vscode.commands.registerCommand(`markdown.toggleCountStatus`, () => {
treeDataProvider.updateCountStatus(!treeDataProvider.getCurrentCountStatus())
}),
vscode.commands.registerCommand(`markdown.toggleMacCodeBlock`, () => {
treeDataProvider.updateMacCodeBlock(!treeDataProvider.getCurrentMacCodeBlock())
}),
)
const disposable = vscode.commands.registerCommand(`markdown.preview`, () => {
const editor = vscode.window.activeTextEditor
if (!editor || editor.document.languageId !== `markdown`) {
return
}
// 如果已有面板且未关闭,则直接显示
if (activePanel) {
activePanel.reveal(vscode.ViewColumn.Two)
return
}
// Create and display a new webview panel
const panel = vscode.window.createWebviewPanel(
`markdownPreview`, // 视图类型
`Markdown Preview - ${editor.document.fileName}`, // 面板标题
vscode.ViewColumn.Two, // 在第二栏显示
{
enableScripts: true, // 启用JS
retainContextWhenHidden: true, // 保持状态
},
)
activePanel = panel
panel.onDidDispose(() => {
activePanel = undefined
})
treeDataProvider.onDidChangeTreeData(updateWebview)
function updateWebview() {
if (!editor)
return
// 使用新主题系统
const renderer = initRenderer({
countStatus: treeDataProvider.getCurrentCountStatus(),
isMacCodeBlock: treeDataProvider.getCurrentMacCodeBlock(),
legend: `none`,
})
const markdownContent = editor.document.getText()
const html = modifyHtmlContent(markdownContent, renderer)
// 生成主题 CSS
const variables = generateCSSVariables({
primaryColor: treeDataProvider.getCurrentPrimaryColor(),
fontFamily: treeDataProvider.getCurrentFontFamily(),
fontSize: treeDataProvider.getCurrentFontSize(),
isUseIndent: false,
isUseJustify: false,
})
const themeCSS = themeMap[treeDataProvider.getCurrentTheme() as ThemeName]
const completeCss = `${variables}\n\n${baseCSSContent}\n\n${themeCSS}\n\n${css}`
panel.webview.html = wrapHtmlTag(html, completeCss)
}
// render first webview
updateWebview()
// Monitor the changes of documents
const changeSubscription = vscode.workspace.onDidChangeTextDocument((e: vscode.TextDocumentChangeEvent) => {
if (e.document === editor.document) {
updateWebview()
}
})
// Cancel the subscription when the panel is closed
panel.onDidDispose(() => {
changeSubscription.dispose()
})
})
context.subscriptions.push(disposable)
// When the Markdown file is opened, the preview button is displayed in the status bar.
vscode.window.onDidChangeActiveTextEditor((editor: vscode.TextEditor | undefined) => {
if (editor && editor.document.languageId === `markdown`) {
vscode.commands.executeCommand(`setContext`, `markdownFileActive`, true)
}
else {
vscode.commands.executeCommand(`setContext`, `markdownFileActive`, false)
}
})
}
function wrapHtmlTag(html: string, css: string) {
return `<html><head><meta charset="utf-8" /><style>${css}</style></head><body><div style="width: 375px; margin: auto;padding:20px;background:white;position: relative;min-height: 100%;margin: 0 auto;padding: 20px;font-size: 14px;box-sizing: border-box;outline: none;transition: all 300ms ease-in-out;word-wrap: break-word;">${html}</div></body></html>`
}
================================================
FILE: apps/vscode/src/styleChoices.ts
================================================
import { codeBlockThemeOptions, colorOptions, fontFamilyOptions, fontSizeOptions, legendOptions, themeOptions } from '@md/shared/configs'
export {
codeBlockThemeOptions,
colorOptions,
fontFamilyOptions,
fontSizeOptions,
legendOptions,
themeOptions,
}
================================================
FILE: apps/vscode/src/treeDataProvider.ts
================================================
import type { ThemeName } from '@md/shared/configs'
import * as vscode from 'vscode'
import { colorOptions, fontFamilyOptions, fontSizeOptions, themeOptions } from './styleChoices'
export class MarkdownTreeDataProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
private _onDidChangeTreeData: vscode.EventEmitter<vscode.TreeItem | undefined> = new vscode.EventEmitter<vscode.TreeItem | undefined>()
readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined> = this._onDidChangeTreeData.event
private currentFontSize: string
private currentTheme: ThemeName
private currentPrimaryColor: string
private currentFontFamily: string
private countStatus: boolean
private isMacCodeBlock: boolean
private context: vscode.ExtensionContext
constructor(context: vscode.ExtensionContext) {
this.context = context
this.currentFontSize = this.context.workspaceState.get(`markdownPreview.fontSize`, fontSizeOptions[0].value)
this.currentTheme = this.context.workspaceState.get(`markdownPreview.theme`, themeOptions[0].value)
this.currentPrimaryColor = this.context.workspaceState.get(`markdownPreview.primaryColor`, colorOptions[0].value)
this.currentFontFamily = this.context.workspaceState.get(`markdownPreview.fontFamily`, fontFamilyOptions[0].value)
this.countStatus = this.context.workspaceState.get(`markdownPreview.countStatus`, false)
this.isMacCodeBlock = this.context.workspaceState.get(`markdownPreview.isMacCodeBlock`, false)
}
getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
return element
}
updateCountStatus(status: boolean): void {
this.countStatus = status
this.context.workspaceState.update(`markdownPreview.countStatus`, status)
this._onDidChangeTreeData.fire(undefined)
}
updateMacCodeBlock(status: boolean): void {
this.isMacCodeBlock = status
this.context.workspaceState.update(`markdownPreview.isMacCodeBlock`, status)
this._onDidChangeTreeData.fire(undefined)
}
getCurrentMacCodeBlock(): boolean {
return this.isMacCodeBlock
}
getCurrentCountStatus(): boolean {
return this.countStatus
}
getChildren(element?: vscode.TreeItem): Thenable<vscode.TreeItem[]> {
if (!element) {
return Promise.resolve([
new vscode.TreeItem(`字号`, vscode.TreeItemCollapsibleState.Expanded),
new vscode.TreeItem(`字体`, vscode.TreeItemCollapsibleState.Expanded),
new vscode.TreeItem(`主题`, vscode.TreeItemCollapsibleState.Expanded),
new vscode.TreeItem(`主题色`, vscode.TreeItemCollapsibleState.Expanded),
new vscode.TreeItem(`计数状态`, vscode.TreeItemCollapsibleState.None),
new vscode.TreeItem(`Mac代码块`, vscode.TreeItemCollapsibleState.None),
].map((item) => {
if (item.label === `计数状态`) {
item.command = {
command: `markdown.toggleCountStatus`,
title: `Toggle Count Status`,
arguments: [],
}
if (this.countStatus) {
item.iconPath = new vscode.ThemeIcon(`check`)
}
}
else if (item.label === `Mac代码块`) {
item.command = {
command: `markdown.toggleMacCodeBlock`,
title: `Toggle Mac Code Block`,
arguments: [],
}
if (this.isMacCodeBlock) {
item.iconPath = new vscode.ThemeIcon(`check`)
}
}
return item
}))
}
else if (element.label === `字号`) {
return Promise.resolve(fontSizeOptions.map((option) => {
const size = option.value
const label = option.label
const desc = option.desc
const item = new vscode.TreeItem(`${label} ${desc}`)
item.command = {
command: `markdown.setFontSize`,
title: `Set Font Size`,
arguments: [size],
}
if (size === this.currentFontSize) {
item.iconPath = new vscode.ThemeIcon(`check`)
}
return item
}))
}
else if (element.label === `字体`) {
return Promise.resolve(fontFamilyOptions.map((option) => {
const font = option.value
const label = option.label
const desc = option.desc
const item = new vscode.TreeItem(`${label} ${desc}`)
item.command = {
command: `markdown.setFontFamily`,
title: `Set Font Family`,
arguments: [font],
}
if (font === this.currentFontFamily) {
item.iconPath = new vscode.ThemeIcon(`check`)
}
return item
}))
}
else if (element.label === `主题`) {
return Promise.resolve(themeOptions.map((option) => {
const theme = option.value
const label = option.label
const desc = option.desc
const item = new vscode.TreeItem(`${label} ${desc}`)
item.command = {
command: `markdown.setTheme`,
title: `Set Theme`,
arguments: [theme],
}
if (theme === this.currentTheme) {
item.iconPath = new vscode.ThemeIcon(`check`)
}
return item
}))
}
else if (element.label === `主题色`) {
return Promise.resolve(colorOptions.map((option) => {
const color = option.value
const label = option.label
const desc = option.desc
const item = new vscode.TreeItem(`${label} ${desc}`)
item.command = {
command: `markdown.setPrimaryColor`,
title: `Set Primary Color`,
arguments: [color],
}
if (color === this.currentPrimaryColor) {
item.iconPath = new vscode.ThemeIcon(`check`)
}
return item
}))
}
return Promise.resolve([])
}
updateFontSize(size: string) {
this.currentFontSize = size
this.context.workspaceState.update(`markdownPreview.fontSize`, size)
this._onDidChangeTreeData.fire(undefined)
}
updateTheme(theme: ThemeName) {
this.currentTheme = theme
this.context.workspaceState.update(`markdownPreview.theme`, theme)
this._onDidChangeTreeData.fire(undefined)
}
updatePrimaryColor(color: string) {
this.currentPrimaryColor = color
this.context.workspaceState.update(`markdownPreview.primaryColor`, color)
this._onDidChangeTreeData.fire(undefined)
}
updateFontFamily(font: string) {
this.currentFontFamily = font
this.context.workspaceState.update(`markdownPreview.fontFamily`, font)
this._onDidChangeTreeData.fire(undefined)
}
getCurrentFontSize() {
return this.currentFontSize
}
getCurrentFontSizeNumber() {
return Number(this.currentFontSize.replace(`px`, ``))
}
getCurrentTheme(): ThemeName {
return this.currentTheme
}
getCurrentPrimaryColor() {
return this.currentPrimaryColor
}
getCurrentFontFamily() {
return this.currentFontFamily
}
}
================================================
FILE: apps/vscode/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "esnext",
"moduleResolution": "bundler",
"paths": {
"@/*": ["../*"]
},
"types": ["vscode"],
"strict": true,
"sourceMap": true,
"esModuleInterop": true
},
"include": [
"src/**/*",
"../../packages/shared/src/global.d.ts"
],
"exclude": ["node_modules", "dist"]
}
================================================
FILE: apps/vscode/webpack.config.mjs
================================================
'use strict'
import path from 'node:path'
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'
const currentDir = import.meta.dirname
// @ts-check
/** @typedef {import('webpack').Configuration} WebpackConfig */
/** @type WebpackConfig */
export default function config() {
return {
target: `node`,
mode: `none`,
entry: `./src/extension.ts`,
output: {
path: path.resolve(currentDir, `dist`),
filename: `extension.js`,
libraryTarget: `commonjs2`,
},
externals: {
vscode: `commonjs vscode`,
},
resolve: {
extensions: [`.ts`, `.js`],
fallback: {
'bufferutil': false,
'utf-8-validate': false,
'canvas': false,
},
plugins: [new TsconfigPathsPlugin({ configFile: path.resolve(currentDir, `tsconfig.json`) })],
},
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
use: [
{
loader: `ts-loader`,
},
],
},
{
test: /\.(css|txt)$/,
type: 'asset/source',
},
],
},
devtool: `nosources-source-map`,
infrastructureLogging: {
level: `log`,
},
optimization: {
usedExports: true,
sideEffects: true,
},
}
}
================================================
FILE: apps/web/components.json
================================================
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.cjs",
"css": "src/assets/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}
================================================
FILE: apps/web/index.html
================================================
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="keywords" content="md,markdown,markdown-editor,wechat,official-account,yanglbme,doocs" />
<meta name="description" content="Wechat Markdown Editor | 一款高度简洁的微信 Markdown 编辑器" />
<meta
name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
/>
<title>微信 Markdown 编辑器 | Doocs 开源社区</title>
<link rel="shortcut icon" href="https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/favicon.png" />
<link
rel="apple-touch-icon-precomposed"
href="https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/1648303220922-7e14aefa-816e-44c1-8604-ade709ca1c69.png"
/>
<style>
.loading {
position: fixed;
top: 0;
left: 0;
z-index: 99999;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100vw;
height: 100vh;
font-size: 18px;
}
.loading::before {
content: url('/src/assets/images/favicon.png');
width: 100px;
height: 100px;
margin-bottom: 26px;
}
.loading.dark {
color: #ffffff;
background-color: #141414;
}
.loading .txt {
position: absolute;
bottom: 10%;
}
.loading .txt::after {
content: '...';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0% {
content: ' ';
}
25% {
content: '.';
}
50% {
content: '..';
}
75% {
content: '...';
}
}
</style>
</head>
<body>
<noscript>
<strong>Please enable JavaScript to continue.</strong>
</noscript>
<div id="app">
<div class="loading">
<strong>致力于让 Markdown 编辑更简单</strong>
<p class="txt">正在加载编辑器</p>
<p class="timeout-tip" style="display: none; color: #e53935; font-size: 16px; margin-top: 2em">
加载时间过长,请尝试刷新页面或按 F12 查看控制台是否有异常信息
</p>
</div>
</div>
<script>
const theme = localStorage.getItem('vueuse-color-scheme')
if (theme === 'dark' || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.querySelector('.loading').classList.add('dark')
}
setTimeout(() => {
const tip = document.querySelector('.loading .timeout-tip')
if (tip) {
tip.style.display = 'block'
}
}, 30000)
</script>
<script>
window.MathJax = {
tex: { tags: 'ams' },
svg: { fontCache: 'none' },
}
</script>
<script
id="MathJax-script"
src="https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/mathjax@3/es5/tex-svg.js"
></script>
<script src="https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/mermaid@11/dist/mermaid.min.js"></script>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/sidepanel.ts"></script>
</body>
<script src="https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/wechatsync/article-syncjs@latest/dist/main.js"></script>
</html>
================================================
FILE: apps/web/netlify.toml
================================================
[build]
command = "pnpm run build:h5-netlify"
publish = "dist"
# 设置重定向规则,确保SPA路由正常工作
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
================================================
FILE: apps/web/package.json
================================================
{
"name": "@md/web",
"type": "module",
"private": true,
"engines": {
"node": ">=22.16.0"
},
"scripts": {
"start": "pnpm run dev",
"dev": "vite",
"build": "run-p type-check \"build:only {@}\" --",
"build:only": "vite build",
"build:h5-netlify": "run-p type-check \"build:h5-netlify:only {@}\" --",
"build:h5-netlify:only": "cross-env SERVER_ENV=NETLIFY vite build",
"build:utools": "cross-env SERVER_ENV=UTOOLS vite build --outDir ../utools/dist --emptyOutDir",
"build:cli": "pnpm run build && npx shx rm -rf packages/md-cli/dist && npx shx rm -rf dist/**/*.map && npx shx cp -r dist packages/md-cli/ && cd packages/md-cli && npm pack",
"build:analyze": "cross-env ANALYZE=true vite build",
"compile:extension": "pnpm --prefix ./src/extension run compile",
"preview": "pnpm run build && vite preview",
"wrangler:dev": "cross-env CF_WORKERS=1 vite --host",
"wrangler:deploy": "cross-env CF_WORKERS=1 pnpm run build:h5-netlify && wrangler deploy",
"release:cli": "node ./scripts/release.js",
"ext:dev": "wxt",
"ext:zip": "cross-env NODE_OPTIONS=--max-old-space-size=4096 wxt zip",
"firefox:dev": "wxt -b firefox",
"firefox:zip": "cross-env NODE_OPTIONS=--max-old-space-size=4096 wxt zip -b firefox",
"type-check": "vue-tsc --build --force",
"postinstall": "wxt prepare",
"package:extension": "pnpm --prefix ./src/extension run package"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1013.0",
"@aws-sdk/s3-request-presigner": "^3.1013.0",
"@exercism/highlightjs-gdscript": "^0.0.1",
"@md/core": "workspace:*",
"@md/shared": "workspace:*",
"@ssttevee/multipart-parser": "^0.1.9",
"@vee-validate/yup": "^4.15.1",
"@vueuse/core": "^14.2.1",
"browser-image-compression": "^2.0.2",
"buffer-from": "^1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"crypto-js": "^4.2.0",
"es-toolkit": "^1.45.1",
"html-to-image": "^1.11.13",
"juice": "11.0.3",
"lucide-vue-next": "^0.577.0",
"marked": "^17.0.4",
"pinia": "^3.0.4",
"qiniu-js": "^3.4.3",
"radix-vue": "^1.9.17",
"reka-ui": "^2.9.2",
"spark-md5": "3.0.2",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0",
"vee-validate": "^4.15.1",
"vue": "^3.5.30",
"vue-pick-colors": "^1.8.0",
"vue-sonner": "^2.0.9",
"yup": "^1.7.1"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
"@md/config": "workspace:*",
"@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/vite": "^4.2.2",
"@types/buffer-from": "^1.1.3",
"@types/crypto-js": "^4.2.2",
"@types/spark-md5": "3.0.5",
"@vitejs/plugin-vue": "^6.0.5",
"less": "^4.6.4",
"linkedom": "^0.18.12",
"ohash": "^2.0.11",
"postcss": "^8.5.8",
"rollup": "^4.59.0",
"rollup-plugin-visualizer": "^7.0.1",
"shx": "^0.4.0",
"tailwindcss": "^4.2.2",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^8.0.1",
"vite-plugin-radar": "^0.10.1",
"vite-plugin-vue-devtools": "^8.1.0",
"vue-tsc": "^3.2.6",
"wrangler": "^4.75.0",
"wxt": "^0.20.20"
}
}
================================================
FILE: apps/web/plugins/vite-plugin-utools-local-assets.ts
================================================
import type { Plugin } from 'vite'
import process from 'node:process'
/**
* Vite 插件:在 uTools 构建时将远程资源替换为本地资源
*/
export function utoolsLocalAssetsPlugin(): Plugin {
const isUTools = process.env.SERVER_ENV === `UTOOLS`
return {
name: `vite-plugin-utools-local-assets`,
apply: `build`,
transformIndexHtml: {
order: `post`,
handler(html) {
if (!isUTools)
return html
// 替换 favicon
html = html.replace(
/https:\/\/cdn-doocs\.oss-cn-shenzhen\.aliyuncs\.com\/gh\/doocs\/md\/images\/favicon\.png/g,
`./src/assets/images/favicon.png`,
)
// 替换 apple-touch-icon
html = html.replace(
/https:\/\/cdn-doocs\.oss-cn-shenzhen\.aliyuncs\.com\/gh\/doocs\/md\/images\/1648303220922-7e14aefa-816e-44c1-8604-ade709ca1c69\.png/g,
`./src/assets/images/favicon.png`,
)
// 替换 MathJax
html = html.replace(
/https:\/\/cdn-doocs\.oss-cn-shenzhen\.aliyuncs\.com\/npm\/mathjax@3\/es5\/tex-svg\.js/g,
`./static/libs/mathjax/tex-svg.js`,
)
// 替换 Mermaid
html = html.replace(
/https:\/\/cdn-doocs\.oss-cn-shenzhen\.aliyuncs\.com\/npm\/mermaid@11\/dist\/mermaid\.min\.js/g,
`./static/libs/mermaid/mermaid.min.js`,
)
// 替换 WeChat Sync
html = html.replace(
/https:\/\/cdn-doocs\.oss-cn-shenzhen\.aliyuncs\.com\/gh\/wechatsync\/article-syncjs@latest\/dist\/main\.js/g,
`./static/libs/article-syncjs/main.js`,
)
return html
},
},
}
}
================================================
FILE: apps/web/postcss.config.js
================================================
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
================================================
FILE: apps/web/public/upload/.gitkeep
================================================
================================================
FILE: apps/web/src/App.vue
================================================
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Toaster } from '@/components/ui/sonner'
import { useUIStore } from '@/stores/ui'
import CodemirrorEditor from '@/views/CodemirrorEditor.vue'
const uiStore = useUIStore()
const { isDark } = storeToRefs(uiStore)
const isUtools = ref(false)
onMounted(() => {
// 检测是否为 Utools 环境
isUtools.value = !!(window as any).__MD_UTOOLS__
if (isUtools.value) {
document.documentElement.classList.add(`is-utools`)
}
// 若 URL 带有 open 参数(Markdown 链接),打开导入对话框并自动导入
const params = new URLSearchParams(window.location.search)
const openUrl = params.get(`open`)
if (openUrl && URL.canParse(openUrl) && /^https?:\/\//i.test(openUrl)) {
uiStore.importMdOpenUrl = openUrl
uiStore.isShowImportMdDialog = true
params.delete(`open`)
const newSearch = params.toString()
const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : ``) + window.location.hash
window.history.replaceState({}, ``, newUrl)
}
})
</script>
<template>
<AppSplash />
<CodemirrorEditor />
<Toaster
rich-colors
position="top-center"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
<style lang="less">
html,
body,
#app {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
}
// 抵消下拉菜单开启时带来的样式
body {
pointer-events: initial !important;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
background-color: rgba(243, 244, 247, 0.5);
}
::-webkit-scrollbar-track {
border-radius: 6px;
background-color: rgba(200, 200, 200, 0.3);
}
::-webkit-scrollbar-thumb {
border-radius: 6px;
background-color: rgba(144, 146, 152, 0.5);
}
// Utools 模式下隐藏所有滚动条
.is-utools {
::-webkit-scrollbar {
display: none;
}
// Firefox
* {
scrollbar-width: none;
}
// IE and Edge
* {
-ms-overflow-style: none;
}
}
/* CSS-hints */
.CodeMirror-hints {
position: absolute;
z-index: 10;
overflow-y: auto;
margin: 0;
padding: 2px;
border-radius: 4px;
max-height: 20em;
min-width: 200px;
font-size: 12px;
font-family: monospace;
color: #333333;
background-color: #ffffff;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.12), 0 2px 4px 0 rgba(0, 0, 0, 0.08);
}
.CodeMirror-hint {
margin-top: 10px;
padding: 4px 6px;
border-radius: 2px;
white-space: pre;
color: #000000;
cursor: pointer;
&:first-of-type {
margin-top: 0;
}
&:hover {
background: #f0f0f0;
}
}
.search-match {
background-color: #ffeb3b; /* 所有匹配项颜色 */
}
.current-match {
background-color: #ff5722; /* 当前匹配项更鲜艳的颜色 */
}
</style>
================================================
FILE: apps/web/src/assets/example/markdown.md
================================================
# 探索 Markdown 的奇妙世界
欢迎来到 Markdown 的奇妙世界!无论你是写作爱好者、开发者、博主,还是想要简单记录点什么的人,Markdown 都能成为你新的好伙伴。它不仅让写作变得简单明了,还能轻松地将内容转化为漂亮的网页格式。今天,我们将全面探讨 Markdown 的基础和进阶语法,让你在这个过程中充分享受写作的乐趣!
Markdown 是一种轻量级标记语言,用于格式化纯文本。它以简单、直观的语法而著称,可以快速地生成 HTML。Markdown 是写作与代码的完美结合,既简单又强大。
## Markdown 基础语法
### 1. 标题:让你的内容层次分明
用 `#` 号来创建标题。标题从 `#` 开始,`#` 的数量表示标题的级别。
```markdown
# 一级标题
## 二级标题
### 三级标题
#### 四级标题
```
以上代码将渲染出一组层次分明的标题,使你的内容井井有条。
### 2. 段落与换行:自然流畅
Markdown 中的段落就是一行接一行的文本。要创建新段落,只需在两行文本之间空一行。
### 3. 字体样式:强调你的文字
- **粗体**:用两个星号或下划线包裹文字,如 `**粗体**` 或 `__粗体__`。
- _斜体_:用一个星号或下划线包裹文字,如 `*斜体*` 或 `_斜体_`。
- ~~删除线~~:用两个波浪线包裹文字,如 `~~删除线~~`。
- ==高亮==:用两个等号包裹文字,如 `==高亮==`。
- ++下划线++:用两个加号包裹文字,如 `++下划线++`。
- ~波浪线~:用一个波浪线包裹文字,如 `~波浪线~`。
这些简单的标记可以让你的内容更有层次感和重点突出。
### 4. 列表:整洁有序
- **无序列表**:用 `-`、`*` 或 `+` 加空格开始一行。
- **有序列表**:使用数字加点号(`1.`、`2.`)开始一行。
在列表中嵌套其他内容?只需缩进即可实现嵌套效果。
- 无序列表项 1
1. 嵌套有序列表项 1
2. 嵌套有序列表项 2
- 无序列表项 2
1. 有序列表项 1
2. 有序列表项 2
### 5. 链接与图片:丰富内容
- **链接**:用方括号和圆括号创建链接 `[显示文本](链接地址)`。
- **图片**:和链接类似,只需在前面加上 `!`,如 ``。
[访问 Doocs](https://github.com/doocs)

轻松实现富媒体内容展示!
> 因微信公众号平台不支持除公众号内容以外的链接,故其他平台的链接,会呈现链接样式但无法点击跳转。
> 对于这些链接请注意明文书写,或点击左上角「格式->微信外链接转底部引用」开启引用,这样就可以在底部观察到链接指向。
另外,使用 `<,>` 语法可以创建横屏滑动幻灯片,支持微信公众号平台。建议使用相似尺寸的图片以获得最佳显示效果。
### 6. 引用:引用名言或引人深思的句子
使用 `>` 来创建引用,只需在文本前面加上它。多层引用?在前一层 `>` 后再加一个就行。
> 这是一个引用
>
> > 这是一个嵌套引用
这让你的引用更加富有层次感。
### 7. 代码块:展示你的代码
- **行内代码**:用反引号包裹,如 `code`。
- **代码块**:用三个反引号包裹,并指定语言,如:
```js
console.log(`Hello, Doocs!`)
```
语法高亮让你的代码更易读。
### 8. 分割线:分割内容
用三个或更多的 `-`、`*` 或 `_` 来创建分割线。
---
为你的内容添加视觉分隔。
### 9. 表格:清晰展示数据
Markdown 支持简单的表格,用 `|` 和 `-` 分隔单元格和表头。
| 项目人员 | 邮箱 | 微信号 |
| ------------------------------------------- | ---------------------- | ------------ |
| [yanglbme](https://github.com/yanglbme) | contact@yanglibin.info | YLB0109 |
| [YangFong](https://github.com/YangFong) | yangfong2022@gmail.com | yq2419731931 |
| [thinkasany](https://github.com/thinkasany) | thinkasany@gmail.com | thinkasany |
这样的表格让数据展示更为清爽!
> 手动编写标记太麻烦?我们提供了便捷方式。左上方点击「编辑->插入表格」,即可快速实现表格渲染。
## Markdown 进阶技巧
### 1. LaTeX 公式:完美展示数学表达式
Markdown 允许嵌入 LaTeX 语法展示数学公式:
- **行内公式**:用 `$` 包裹公式,如 $E = mc^2$。
- **块级公式**:用 `$$` 包裹公式,如:
$$
\begin{aligned}
d_{i, j} &\leftarrow d_{i, j} + 1 \\
d_{i, y + 1} &\leftarrow d_{i, y + 1} - 1 \\
d_{x + 1, j} &\leftarrow d_{x + 1, j} - 1 \\
d_{x + 1, y + 1} &\leftarrow d_{x + 1, y + 1} + 1
\end{aligned}
$$
现在还支持 **LaTeX 标准格式**:
- **行内公式**:用 `\(...\)` 包裹公式,如 \(x^2 + y^2 = z^2\)。
- **块级公式**:用 `\[...\]` 包裹公式,如:
\[
\int\_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
\]
混合使用示例:传统格式 $a + b = c$ 和 LaTeX 格式 \(d + e = f\) 可以在同一段落中共存。
1. 列表内块公式 1
$$
\chi^2 = \sum \frac{(O - E)^2}{E}
$$
2. 列表内块公式 2
$$
\chi^2 = \sum \frac{(|O - E| - 0.5)^2}{E}
$$
这是展示复杂数学表达的利器!
### 2. Mermaid 流程图:可视化流程
Mermaid 是强大的可视化工具,可以在 Markdown 中创建流程图、时序图等。
```mermaid
graph LR
A[GraphCommand] --> B[update]
A --> C[goto]
A --> D[send]
B --> B1[更新状态]
C --> C1[流程控制]
D --> D1[消息传递]
```
```mermaid
graph TD;
A-->B;
A-->C;
B-->D;
C-->D;
```
```mermaid
pie
title Key elements in Product X
"Calcium" : 42.96
"Potassium" : 50.05
"Magnesium" : 10.01
"Iron" : 5
```
```mermaid
pie
title 为什么总是宅在家里?
"喜欢宅" : 45
"天气太热" : 70
"穷" : 500
"没人约" : 95
```
这种方式不仅能直观展示流程,还能提升文档的专业性。
> 更多用法,参见:[Mermaid User Guide](https://mermaid.js.org/intro/getting-started.html)。
### 3. PlantUML 流程图:可视化流程
PlantUML 是强大的可视化工具,可以在 Markdown 中创建流程图、时序图等。
```plantuml
@startuml
participant Participant as Foo
actor Actor as Foo1
boundary Boundary as Foo2
control Control as Foo3
entity Entity as Foo4
database Database as Foo5
collections Collections as Foo6
queue Queue as Foo7
Foo -> Foo1 : To actor
Foo -> Foo2 : To boundary
Foo -> Foo3 : To control
Foo -> Foo4 : To entity
Foo -> Foo5 : To database
Foo -> Foo6 : To collections
Foo -> Foo7: To queue
@enduml
```
> 更多用法,参见:[PlantUML 主页](https://plantuml.com/zh/)。
### 4. Infographic 信息图:可视化数据
新一代信息图可视化引擎,让文字信息栩栩如生!
```infographic
infographic list-row-horizontal-icon-arrow
data
title 客户增长引擎
desc 多渠道触达与复购提升
items
- label 线索获取
value 18.6
desc 渠道投放与内容获客
icon rocket-launch
- label 转化提效
value 12.4
desc 线索评分与自动跟进
icon progress-check
- label 复购提升
value 9.8
desc 会员体系与权益运营
icon account-sync
- label 口碑传播
value 6.2
desc 社群激励与推荐裂变
icon account-group
```
> 更多用法,参见:[AntV Infographic Gallery](https://infographic.antv.vision/gallery)。
### 5. Ruby 注音:注音标注
支持两种格式:
```md
1. [文字]{注音}
2. [文字]^(注音)
```
渲染效果如下:
[你好]{nǐ hǎo} [世界]{shì jiè}
支持四种分隔符: `・`(中点)、`.` (全角句点)、`。` (中文句号)、`-` (英文减号)
示例:
```md
[你好世界]{nǐ・hǎo・shì・jiè}
[小夜時雨]^(さ・よ・しぐれ)
```
[你好世界]{nǐ・hǎo・shì・jiè}
[小夜時雨]^(さ・よ・しぐれ)
当字符串数量与分隔符数量不匹配时,会自动匹配到最合适的分隔符。
```md
[小夜時雨]{さ・よ・しぐれ}
[小夜時雨]{さ・よ}
[小夜]{さ・よ・しぐれ}
[小夜時雨]{さ・よ・しぐれ・extra}
```
[小夜時雨]{さ・よ・しぐれ}
[小夜時雨]{さ・よ}
[小夜]{さ・よ・しぐれ}
[小夜時雨]{さ・よ・しぐれ・extra}
## 结语
Markdown 是一种简单、强大且易于掌握的标记语言,通过学习基础和进阶语法,你可以快速创作内容并有效传达信息。无论是技术文档、个人博客还是项目说明,Markdown 都是你的得力助手。希望这篇内容能够带你全面了解 Markdown 的潜力,让你的写作更加丰富多彩!
现在,拿起 Markdown 编辑器,开始创作吧!探索 Markdown 的世界,你会发现它远比想象中更精彩!
#### 推荐阅读
- [阿里又一个 20k+ stars 开源项目诞生,恭喜 fastjson!](https://mp.weixin.qq.com/s/RNKDCK2KoyeuMeEs6GUrow)
- [刷掉 90% 候选人的互联网大厂海量数据面试题(附题解 + 方法总结)](https://mp.weixin.qq.com/s/rjGqxUvrEqJNlo09GrT1Dw)
- [好用!期待已久的文本块功能究竟如何在 Java 13 中发挥作用?](https://mp.weixin.qq.com/s/kalGv5T8AZGxTnLHr2wDsA)
- [2019 GitHub 开源贡献排行榜新鲜出炉!微软谷歌领头,阿里跻身前 12!](https://mp.weixin.qq.com/s/_q812aGD1b9QvZ2WFI0Qgw)
---
<center>
<img src="https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/gh/doocs/md/images/1648303220922-7e14aefa-816e-44c1-8604-ade709ca1c69.png" style="width: 100px;">
</center>
================================================
FILE: apps/web/src/assets/index.css
================================================
@import 'tailwindcss';
@config '../../tailwind.config.cjs';
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--blockquote-background: #f7f7f7;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--blockquote-background: #212121;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
================================================
FILE: apps/web/src/assets/less/app.less
================================================
//* {
// box-sizing: border-box;
// margin: 0;
// padding: 0;
//}
html,
body {
height: 100%;
font-family: 'PingFang SC', BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif;
}
input,
button,
textarea {
font-family: inherit;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: normal;
}
em {
font-style: normal !important;
}
section {
height: 100%;
}
.web-title {
margin: 0 15px 0 5px;
}
.web-icon {
width: auto;
height: 1.5rem;
vertical-align: middle;
}
#editor {
display: block;
height: 100%;
width: 100%;
border: none;
}
.ctrl {
flex-basis: 60px;
flex-grow: 1;
flex-shrink: 1;
display: flex;
align-items: center;
}
.preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
overflow-y: scroll;
width: 100%;
}
.hint {
opacity: 0.6;
margin: 20px 0;
}
.preview {
position: relative;
// margin: 0 -20px;
// width: 375px;
min-height: 100%;
margin: 0 auto;
padding: 20px;
font-size: 14px;
box-sizing: border-box;
outline: none;
transition: all 300ms ease-in-out;
word-wrap: break-word;
}
.preview table {
margin-bottom: 10px;
border-collapse: collapse;
display: table;
width: 100% !important;
}
================================================
FILE: apps/web/src/assets/less/theme.less
================================================
@nightPreviewColor: #191919;
@nightCodeMirrorColor: #191919;
@nightActiveCodeMirrorColor: gray;
@nightFontColor: gray;
@nightLinkColor: #8e9eb9;
@nightLinkTextColor: #84868b;
@nightLineColor: #84868b;
.dark {
.container {
// CodeMirror v6 兼容
.cm-editor,
.CodeMirror-wrap {
background-color: @nightCodeMirrorColor;
}
.output_night {
.preview {
background-color: @nightPreviewColor;
box-shadow: 0 0 70px rgba(0, 0, 0, 0.3);
}
.preview-wrapper {
background-color: @nightCodeMirrorColor;
box-shadow: inset 0 0 0 1px rgba(233, 231, 231, 0.102);
}
.code-snippet__fix {
background-color: rgb(238, 238, 238);
}
}
::-webkit-scrollbar {
background-color: @nightCodeMirrorColor;
}
}
}
// CodeMirror v5 兼容样式
.CodeMirror {
padding-bottom: 0;
height: 100% !important;
font-size: 16px;
font-family: Consolas, 'Courier New', monospace !important;
}
.CodeMirror-vscrollbar:focus {
outline: none;
}
.CodeMirror-vscrollbar {
width: 0px;
height: 0px;
}
.CodeMirror-wrap {
padding-top: 20px;
padding-bottom: 20px;
box-sizing: border-box;
}
// CodeMirror v6 样式
.cm-editor {
height: 100% !important;
font-size: 16px;
font-family: Consolas, 'Courier New', monospace !important;
.cm-scroller {
overflow-x: auto !important;
overflow-y: auto !important;
// 只隐藏 x 方向的滚动条
&::-webkit-scrollbar:horizontal {
display: none; /* Chrome/Safari/Webkit - 横向滚动条 */
}
}
.cm-content {
padding-bottom: 20px;
}
&.cm-focused {
outline: none;
}
}
.codemirror-container {
height: 100%;
width: 100%;
.cm-scroller {
padding: 10px;
}
}
.cssEditor-wrapper {
.CodeMirror-scroll,
.cm-scroller {
margin-right: 0;
}
}
.cm-em {
font-style: normal;
}
.cm-comment {
font-style: normal !important;
}
================================================
FILE: apps/web/src/components/AppSplash.vue
================================================
<script setup lang="ts">
const loading = ref(true)
onMounted(() => {
setTimeout(() => {
loading.value = false
}, 100)
})
</script>
<template>
<transition name="fade">
<div
v-if="loading"
class="loading"
>
<strong>致力于让 Markdown 编辑更简单</strong>
</div>
</transition>
</template>
<style lang="less" scoped>
.loading {
position: fixed;
top: 0;
left: 0;
z-index: 99999;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100vw;
height: 100vh;
font-size: 18px;
background-color: hsl(var(--background));
&::before {
content: url('../assets/images/favicon.png');
width: 100px;
height: 100px;
margin-bottom: 26px;
}
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave {
opacity: 1;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 1s;
}
</style>
================================================
FILE: apps/web/src/components/ai/SidebarAIToolbar.vue
================================================
<script setup lang="ts">
import { Bot, Image as ImageIcon, Settings2, Wand2 } from 'lucide-vue-next'
import { useEditorStore } from '@/stores/editor'
import { useUIStore } from '@/stores/ui'
import AIAssistantPanel from './chat-box/AIAssistantPanel.vue'
import AIImageGeneratorPanel from './image-generator/AIImageGeneratorPanel.vue'
import { AIPolishPopover } from './tool-box'
defineProps<{
isMobile: boolean
showEditor: boolean
}>()
const uiStore = useUIStore()
const { aiDialogVisible, aiImageDialogVisible } = storeToRefs(uiStore)
const { toggleAIDialog, toggleAIImageDialog } = uiStore
const editorStore = useEditorStore()
const { editor } = storeToRefs(editorStore)
const { hasShownAIToolboxHint } = storeToRefs(uiStore)
// 工具栏状态:false=默认(只显示贴边栏), true=展开(显示AI图标)
const isExpanded = ref(false) // 默认收起状态
// AI 工具箱相关状态
const toolBoxVisible = ref(false)
// 是否显示选中文本提示动画
const showSelectionHint = ref(false)
let selectionHintTimer: NodeJS.Timeout | null = null
let selectionCheckInterval: NodeJS.Timeout | null = null
let lastSelectedText = ``
// 检查选中文本的函数
function getSelectedText() {
try {
if (!editor.value)
return ``
const selection = editor.value.state.selection.main
return editor.value.state.doc.sliceString(selection.from, selection.to).trim()
}
catch {
return ``
}
}
// 检查并更新选中文本提示
function checkSelectionAndUpdateHint() {
// 如果已经显示过提示,就不再显示
if (hasShownAIToolboxHint.value) {
return
}
const selected = getSelectedText()
// 如果选中状态发生变化
if (selected !== lastSelectedText) {
lastSelectedText = selected
// 清除之前的定时器
if (selectionHintTimer) {
clearTimeout(selectionHintTimer)
selectionHintTimer = null
}
// 如果有选中文本且工具栏未展开
if (selected && !isExpanded.value) {
showSelectionHint.value = true
// 标记已经显示过提示
hasShownAIToolboxHint.value = true
// 3秒后自动隐藏提示
selectionHintTimer = setTimeout(() => {
showSelectionHint.value = false
}, 3000)
}
else {
showSelectionHint.value = false
}
}
}
// 动态计算是否有选中文本
const hasSelectedText = computed(() => {
if (!editor.value || !isExpanded.value)
return false
return getSelectedText().length > 0
})
// 当打开工具箱时,获取当前选中的文本
const currentSelectedText = computed(() => {
return toolBoxVisible.value ? getSelectedText() : ``
})
// 切换展开/收起状态
function toggleExpanded() {
isExpanded.value = !isExpanded.value
// 展开后隐藏提示
if (isExpanded.value) {
showSelectionHint.value = false
if (selectionHintTimer) {
clearTimeout(selectionHintTimer)
selectionHintTimer = null
}
}
}
// 打开AI助手
function openAIChat() {
toggleAIDialog(true)
}
// 打开AI文生图
function openAIImageGenerator() {
toggleAIImageDialog(true)
}
// 打开AI工具箱
function openAIToolBox() {
toolBoxVisible.value = true
}
// 监听编辑区点击,自动收起工具栏
onMounted(() => {
// 启动定时检查选中文本
selectionCheckInterval = setInterval(() => {
checkSelectionAndUpdateHint()
}, 300) // 每300ms检查一次
const handleInteraction = (e: Event) => {
// 只有在展开状态才需要处理
if (!isExpanded.value)
return
const target = e.target as Element
if (!target)
return
const toolbar = document.querySelector(`.editor-ai-toolbar`)
// 如果点击的是工具栏及其子元素,不处理
if (toolbar && toolbar.contains(target))
return
// 排除不应该收起的区域
const excludeSelectors = [
`dialog`,
`.popover`,
`.modal`,
`[role="dialog"]`,
`nav`,
`.menu`,
`.dropdown`,
`.tooltip`,
`.floating`,
`.ai-assistant-panel`,
`.ai-image-generator-panel`,
]
const shouldNotCollapse = excludeSelectors.some(selector => target.closest(selector))
if (!shouldNotCollapse) {
isExpanded.value = false
}
}
// 同时监听点击和触摸事件,覆盖桌面端和移动端
document.addEventListener(`click`, handleInteraction, true)
document.addEventListener(`touchstart`, handleInteraction, true)
onUnmounted(() => {
document.removeEventListener(`click`, handleInteraction, true)
document.removeEventListener(`touchstart`, handleInteraction, true)
// 清理定时器
if (selectionHintTimer) {
clearTimeout(selectionHintTimer)
selectionHintTimer = null
}
// 清理轮询
if (selectionCheckInterval) {
clearInterval(selectionCheckInterval)
selectionCheckInterval = null
}
})
})
</script>
<template>
<!-- 编辑区内侧AI工具栏 -->
<div
v-if="(!isMobile || (isMobile && showEditor))"
class="editor-ai-toolbar absolute top-1/2 -translate-y-1/2 right-0 z-30 transition-all duration-300 ease-out"
>
<!-- 默认状态:贴边栏 -->
<div
v-if="!isExpanded"
class="w-5 h-16 bg-gradient-to-b from-blue-500/90 to-purple-500/90 hover:from-blue-600/95 hover:to-purple-600/95 dark:from-blue-400/90 dark:to-purple-400/90 dark:hover:from-blue-500/95 dark:hover:to-purple-500/95 backdrop-blur-lg border-l border-y border-blue-300/50 dark:border-blue-600/50 cursor-pointer transition-all duration-200 flex items-center justify-center rounded-l-lg shadow-lg group utools-sidebar-edge"
:class="{ 'animate-pulse-hint': showSelectionHint }"
title="展开AI工具栏"
@click="toggleExpanded"
>
<Settings2 class="h-4 w-4 text-white drop-shadow-sm group-hover:scale-110 transition-transform duration-200" />
<!-- 选中文本提示气泡 -->
<Transition name="hint-fade">
<div
v-if="showSelectionHint"
class="hint-bubble absolute right-full mr-3 px-4 py-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white text-sm font-medium rounded-lg shadow-xl whitespace-nowrap pointer-events-none animate-bounce-gentle z-50"
style="top: 50%; transform: translateY(-50%);"
>
<div class="relative flex items-center gap-2">
<Wand2 class="h-4 w-4" />
<span>点击打开 AI 工具箱</span>
<!-- 箭头 -->
<div class="hint-arrow absolute top-1/2 -right-2 -translate-y-1/2 w-0 h-0 border-t-[6px] border-b-[6px] border-l-[6px] border-transparent border-l-purple-500" />
</div>
</div>
</Transition>
</div>
<!-- 展开状态:显示AI图标 -->
<div
v-else
class="bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg border-l border-gray-200/50 dark:border-gray-700/50 shadow-lg overflow-hidden transition-all duration-300 w-12 rounded-l-md"
:style="{ height: 'auto' }"
>
<!-- 展开状态的AI按钮 -->
<div class="flex flex-col py-2 gap-2">
<!-- AI助手按钮 -->
<div class="flex flex-col items-center gap-1 px-1">
<button
class="group relative w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transform hover:scale-105 active:scale-95 transition-all duration-200 flex items-center justify-center utools-ai-button"
title="AI助手"
@click="openAIChat"
>
<Bot class="h-4 w-4" />
</button>
<!-- 标签 -->
<span class="text-[9px] text-gray-500 dark:text-gray-400 font-medium text-center leading-tight">
助手
</span>
</div>
<!-- 分割线 -->
<div class="mx-1.5">
<div class="h-px bg-gray-200/50 dark:bg-gray-700/50" />
</div>
<!-- AI文生图按钮 -->
<div class="flex flex-col items-center gap-1 px-1">
<button
class="group relative w-7 h-7 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white shadow-md hover:shadow-lg transform hover:scale-105 active:scale-95 transition-all duration-200 flex items-center justify-center utools-ai-button"
title="AI文生图"
@click="openAIImageGenerator"
>
<ImageIcon class="h-4 w-4" />
</button>
<!-- 标签 -->
<span class="text-[9px] text-gray-500 dark:text-gray-400 font-medium text-center leading-tight">
文生图
</span>
</div>
<!-- 分割线 -->
<div v-if="hasSelectedText && isExpanded" class="mx-1.5">
<div class="h-px bg-gray-200/50 dark:bg-gray-700/50" />
</div>
<!-- AI工具箱按钮 (只有选中文本且展开时才显示) -->
<div v-if="hasSelectedText && isExpanded" class="flex flex-col items-center gap-1 px-1">
<button
class="group relative w-7 h-7 rounded-lg bg-gradient-to-br from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white shadow-md hover:shadow-lg transform hover:scale-105 active:scale-95 transition-all duration-200 flex items-center justify-center utools-ai-button"
title="AI工具箱"
@click="openAIToolBox"
>
<Wand2 class="h-4 w-4" />
</button>
<!-- 标签 -->
<span class="text-[9px] text-gray-500 dark:text-gray-400 font-medium text-center leading-tight">
工具箱
</span>
</div>
</div>
</div>
<!-- AI面板组件 -->
<AIAssistantPanel v-model:open="aiDialogVisible" />
<AIImageGeneratorPanel v-model:open="aiImageDialogVisible" />
<!-- AI工具箱弹窗 -->
<AIPolishPopover
v-model:open="toolBoxVisible"
:selected-text="currentSelectedText"
:is-mobile="isMobile"
/>
</div>
</template>
<style scoped>
/* 确保工具栏与编辑器完美集成 */
.editor-ai-toolbar {
z-index: 30;
contain: layout style;
pointer-events: auto;
max-width: calc(100% - 0.5rem);
}
/* 工具栏自适应高度 */
.editor-ai-toolbar > div {
height: auto;
min-height: fit-content;
}
/* 确保按钮悬浮提示正确显示 */
.editor-ai-toolbar .absolute {
overflow: visible;
}
/* 选中文本时的脉冲动画 */
@keyframes pulse-hint {
0%,
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
}
50% {
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
}
}
.animate-pulse-hint {
animation: pulse-hint 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* 提示气泡的轻微弹跳动画 */
@keyframes bounce-gentle {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(-4px);
}
}
.animate-bounce-gentle {
animation: bounce-gentle 1s ease-in-out infinite;
}
/* 提示气泡淡入淡出过渡 */
.hint-fade-enter-active,
.hint-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.hint-fade-enter-from {
opacity: 0;
transform: translateX(8px);
}
.hint-fade-leave-to {
opacity: 0;
transform: translateX(8px);
}
.hint-fade-enter-to,
.hint-fade-leave-from {
opacity: 1;
transform: translateX(0);
}
/* 响应式调整 */
@media (max-width: 768px) {
.editor-ai-toolbar {
transform: translateY(-50%);
transform-origin: right center;
}
/* 移动端图标稍微再大一点 */
.editor-ai-toolbar .lucide {
width: 1.125rem !important; /* h-4.5 w-4.5 */
height: 1.125rem !important;
}
}
/* 提高可访问性 */
@media (prefers-reduced-motion: reduce) {
.transition-all,
.transform {
transition: none !important;
}
.hover\:scale-105:hover,
.active\:scale-95:active {
transform: none !important;
}
.backdrop-blur-lg {
backdrop-filter: none;
}
}
/* 确保在小屏幕上不会遮挡内容 */
@media (max-height: 500px) {
.min-h-\[120px\] {
min-height: 80px;
}
.min-h-\[80px\] {
min-height: 60px;
}
}
/* 毛玻璃效果优化 */
@supports (backdrop-filter: blur(16px)) {
.backdrop-blur-lg {
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
}
/* 确保渐变按钮在深色模式下显示正确 */
.bg-gradient-to-br {
background-attachment: fixed;
}
/* 悬浮提示样式优化 */
.group:hover > div {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* uTools 插件模式下使用黑白风格 */
.is-utools .utools-sidebar-edge {
background: rgb(0 0 0 / 0.9) !important;
border-color: rgb(0 0 0 / 0.5) !important;
}
.is-utools .utools-sidebar-edge:hover {
background: rgb(0 0 0 / 0.95) !important;
}
.is-utools.dark .utools-sidebar-edge {
background: rgb(255 255 255 / 0.9) !important;
border-color: rgb(255 255 255 / 0.5) !important;
}
.is-utools.dark .utools-sidebar-edge:hover {
background: rgb(255 255 255 / 0.95) !important;
}
.is-utools.dark .utools-sidebar-edge .lucide {
color: rgb(0 0 0) !important;
}
/* uTools 模式下提示气泡使用黑白风格 */
.is-utools .hint-bubble {
background: rgb(0 0 0 / 0.9) !important;
background-image: none !important;
color: rgb(255 255 255) !important;
}
.is-utools.dark .hint-bubble {
background: rgb(255 255 255 / 0.9) !important;
background-image: none !important;
color: rgb(0 0 0) !important;
}
.is-utools .hint-arrow {
border-left-color: rgb(0 0 0 / 0.9) !important;
}
.is-utools.dark .hint-arrow {
border-left-color: rgb(255 255 255 / 0.9) !important;
}
/* uTools 模式下脉冲动画使用黑白风格 */
@keyframes pulse-hint-utools {
0%,
100% {
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.7);
}
50% {
box-shadow: 0 0 0 8px rgba(0, 0, 0, 0);
}
}
@keyframes pulse-hint-utools-dark {
0%,
100% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
}
50% {
box-shadow: 0 0 0 8px rgba(255, 255, 255, 0);
}
}
.is-utools .animate-pulse-hint {
animation: pulse-hint-utools 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.is-utools.dark .animate-pulse-hint {
animation: pulse-hint-utools-dark 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* uTools 模式下 AI 按钮使用黑白风格 */
.is-utools .utools-ai-button {
background: rgb(0 0 0 / 0.85) !important;
background-image: none !important;
}
.is-utools .utools-ai-button:hover {
background: rgb(0 0 0 / 0.95) !important;
background-image: none !important;
}
.is-utools.dark .utools-ai-button {
background: rgb(255 255 255 / 0.85) !important;
background-image: none !important;
color: rgb(0 0 0) !important;
}
.is-utools.dark .utools-ai-button:hover {
background: rgb(255 255 255 / 0.95) !important;
background-image: none !important;
}
.is-utools.dark .utools-ai-button .lucide {
color: rgb(0 0 0) !important;
}
</style>
================================================
FILE: apps/web/src/components/ai/chat-box/AIAssistantPanel.vue
================================================
<script setup lang="ts">
import type { QuickCommandRuntime } from '@/stores/quickCommands'
import {
Check,
Copy,
FolderOpen,
Image as ImageIcon,
MessageCircle,
Pause,
Plus,
RefreshCcw,
Send,
Settings,
Trash2,
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Textarea } from '@/components/ui/textarea'
import useAIConfigStore from '@/stores/aiConfig'
import { useEditorStore } from '@/stores/editor'
import { useQuickCommands } from '@/stores/quickCommands'
import { useUIStore } from '@/stores/ui'
import { copyPlain } from '@/utils/clipboard'
import { store } from '@/utils/storage'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits([`update:open`])
const editorStore = useEditorStore()
const { editor } = storeToRefs(editorStore)
const uiStore = useUIStore()
const { toggleAIImageDialog } = uiStore
const dialogVisible = ref(props.open)
watch(() => props.open, val => (dialogVisible.value = val))
watch(dialogVisible, (val) => {
emit(`update:open`, val)
if (val)
scrollToBottom(true)
})
const input = ref<string>(``)
const inputHistory = ref<string[]>([])
const historyIndex = ref<number | null>(null)
const configVisible = ref(false)
const loading = ref(false)
const fetchController = ref<AbortController | null>(null)
const copiedIndex = ref<number | null>(null)
const memoryKey = `ai_memory_context`
const isQuoteAllContent = ref(false)
const cmdMgrOpen = ref(false)
const conversationListKey = `ai_conversation_list`
const currentConversationId = ref<string | null>(null)
const conversationList = ref<Array<{ id: string, name: string, timestamp: number }>>([])
interface ChatMessage {
role: `user` | `assistant` | `system`
content: string
reasoning?: string
done?: boolean
id?: string
}
const messages = ref<ChatMessage[]>([])
const AIConfigStore = useAIConfigStore()
const { apiKey, endpoint, model, temperature, maxToken, type } = storeToRefs(AIConfigStore)
const quickCmdStore = useQuickCommands()
function getSelectedText(): string {
try {
const cm: any = editor.value
if (!cm)
return ``
if (typeof cm.getSelection === `function`)
return cm.getSelection() || ``
return ``
}
catch (e) {
console.warn(`获取选中文本失败`, e)
return ``
}
}
function applyQuickCommand(cmd: QuickCommandRuntime) {
const selected = getSelectedText()
input.value = cmd.buildPrompt(selected)
historyIndex.value = null
nextTick(() => {
const textarea = document.querySelector(
`textarea[placeholder*="说些什么" ]`,
) as HTMLTextAreaElement | null
textarea?.focus()
if (textarea) {
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
}
})
}
onMounted(async () => {
const savedList = await store.get(conversationListKey)
if (savedList) {
conversationList.value = JSON.parse(savedList)
}
const saved = await store.get(memoryKey)
messages.value = saved
? JSON.parse(saved).map((msg: ChatMessage) => ({
...msg,
id: msg.id || crypto.randomUUID(),
}))
: getDefaultMessages()
await scrollToBottom(true)
})
function getDefaultMessages(): ChatMessage[] {
return [{ role: `assistant`, content: `你好,我是 AI 助手,有什么可以帮你的?`, id: crypto.randomUUID() }]
}
function generateConversationTitle(): string {
const firstUserMessage = messages.value.find(m => m.role === `user`)
if (!firstUserMessage)
return `对话 ${new Date().toLocaleString()}`
let title = firstUserMessage.content.trim()
if (title.length > 20) {
title = `${title.substring(0, 20)}...`
}
return title
}
async function autoSaveCurrentConversation() {
if (messages.value.length <= 1)
return
if (!currentConversationId.value) {
currentConversationId.value = crypto.randomUUID()
const conversation = {
id: currentConversationId.value,
name: generateConversationTitle(),
timestamp: Date.now(),
}
conversationList.value.unshift(conversation)
await store.setJSON(conversationListKey, conversationList.value)
}
else {
const conv = conversationList.value.find(c => c.id === currentConversationId.value)
if (conv) {
conv.timestamp = Date.now()
await store.setJSON(conversationListKey, conversationList.value)
}
}
await store.setJSON(`ai_conversation_${currentConversationId.value}`, messages.value)
}
async function createNewConversation() {
await autoSaveCurrentConversation()
currentConversationId.value = null
messages.value = getDefaultMessages()
await store.setJSON(memoryKey, messages.value)
await scrollToBottom(true)
toast.success(`已创建新会话`)
}
async function loadConversation(id: string) {
await autoSaveCurrentConversation()
const saved = await store.getJSON<ChatMessage[]>(`ai_conversation_${id}`, [])
if (saved.length > 0) {
messages.value = saved.map(msg => ({
...msg,
id: msg.id || crypto.randomUUID(),
}))
currentConversationId.value = id
await store.setJSON(memoryKey, messages.value)
await scrollToBottom(true)
toast.success(`对话已加载`)
}
}
async function deleteConversation(id: string) {
conversationList.value = conversationList.value.filter(c => c.id !== id)
await store.setJSON(conversationListKey, conversationList.value)
await store.remove(`ai_conversation_${id}`)
if (currentConversationId.value === id) {
currentConversationId.value = null
messages.value = getDefaultMessages()
await store.setJSON(memoryKey, messages.value)
}
toast.success(`对话已删除`)
}
function handleConfigSaved() {
configVisible.value = false
scrollToBottom(true)
}
function switchToImageGenerator() {
emit(`update:open`, false)
setTimeout(() => {
toggleAIImageDialog(true)
}, 100)
}
function handleKeydown(e: KeyboardEvent) {
if (e.isComposing || e.keyCode === 229)
return
if (e.key === `Enter` && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
else if (e.key === `ArrowUp`) {
e.preventDefault()
if (inputHistory.value.length === 0)
return
if (historyIndex.value === null) {
historyIndex.value = inputHistory.value.length - 1
}
else if (historyIndex.value > 0) {
historyIndex.value--
}
input.value = inputHistory.value[historyIndex.value] || ``
}
else if (e.key === `ArrowDown`) {
e.preventDefault()
if (historyIndex.value === null)
return
if (historyIndex.value < inputHistory.value.length - 1) {
historyIndex.value++
input.value = inputHistory.value[historyIndex.value] || ``
}
else {
historyIndex.value = null
input.value = ``
}
}
}
async function copyToClipboard(text: string, index: number) {
copyPlain(text)
copiedIndex.value = index
setTimeout(() => (copiedIndex.value = null), 1500)
}
async function resetMessages() {
if (fetchController.value) {
fetchController.value.abort()
fetchController.value = null
}
if (currentConversationId.value) {
conversationList.value = conversationList.value.filter(c => c.id !== currentConversationId.value)
await store.setJSON(conversationListKey, conversationList.value)
await store.remove(`ai_conversation_${currentConversationId.value}`)
currentConversationId.value = null
}
messages.value = getDefaultMessages()
await store.setJSON(memoryKey, messages.value)
scrollToBottom(true)
toast.success(`会话已清空`)
}
function pauseStreaming() {
if (fetchController.value) {
fetchController.value.abort()
fetchController.value = null
}
loading.value = false
const last = messages.value[messages.value.length - 1]
if (last?.role === `assistant`)
last.done = true
scrollToBottom(true)
}
async function scrollToBottom(force = false) {
await nextTick()
const container = document.querySelector(`.chat-container`)
if (container) {
const isNearBottom = (container.scrollTop + container.clientHeight)
>= (container.scrollHeight - 50)
if (force || isNearBottom) {
container.scrollTop = container.scrollHeight
await new Promise(resolve => setTimeout(resolve, 50))
}
}
}
function quoteAllContent() {
isQuoteAllContent.value = !isQuoteAllContent.value
}
async function regenerateLast() {
if (loading.value)
return
const lastAssistantIdx = messages.value.length - 1
if (lastAssistantIdx < 0 || messages.value[lastAssistantIdx].role !== `assistant`)
return
messages.value.splice(lastAssistantIdx, 1)
loading.value = true
const replyMessage: ChatMessage = { role: `assistant`, content: ``, reasoning: ``, done: false }
messages.value.push(replyMessage)
const replyMessageProxy = messages.value[messages.value.length - 1]
await scrollToBottom(true)
await streamResponse(replyMessageProxy)
}
async function streamResponse(replyMessageProxy: ChatMessage) {
const allHistory = messages.value
.slice(-12)
.filter((msg, idx, arr) =>
!(idx === arr.length - 1 && msg.role === `assistant` && !msg.done)
&& !(idx === 0 && msg.role === `assistant`),
)
let contextHistory: ChatMessage[]
if (isQuoteAllContent.value) {
const latest: ChatMessage[] = []
for (let i = allHistory.length - 1; i >= 0 && latest.length < 2; i--) {
const m = allHistory[i]
if (latest.length === 0 || m.role === `user`)
latest.unshift(m)
else if (m.role === `assistant`)
latest.unshift(m)
}
contextHistory = latest
}
else {
contextHistory = allHistory.slice(-10)
}
const quoteMessages: ChatMessage[] = isQuoteAllContent.value
? [{
role: `system`,
content:
`下面是一篇 Markdown 文章全文,请严格以此为主完成后续指令:\n\n${editor.value?.state.doc.toString()}`,
}]
: []
const payloadMessages: ChatMessage[] = [
{
role: `system`,
content: `你是一个专业的 Markdown 编辑器助手,请用简洁中文回答。`,
},
...quoteMessages,
...contextHistory,
]
const payload = {
model: model.value,
messages: payloadMessages,
temperature: temperature.value,
max_tokens: maxToken.value,
stream: true,
}
const headers: Record<string, string> = { 'Content-Type': `application/json` }
if (apiKey.value && type.value !== `default`)
headers.Authorization = `Bearer ${apiKey.value}`
fetchController.value = new AbortController()
const signal = fetchController.value.signal
try {
const url = new URL(endpoint.value)
if (!url.pathname.endsWith(`/chat/completions`))
url.pathname = url.pathname.replace(/\/?$/, `/chat/completions`)
const res = await window.fetch(url.toString(), {
method: `POST`,
headers,
body: JSON.stringify(payload),
signal,
})
if (!res.ok || !res.body)
throw new Error(`响应错误:${res.status} ${res.statusText}`)
const reader = res.body.getReader()
const decoder = new TextDecoder(`utf-8`)
let buffer = ``
while (true) {
const { value, done } = await reader.read()
if (done) {
const last = messages.value[messages.value.length - 1]
if (last.role === `assistant`) {
last.done = true
await scrollToBottom(true)
}
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split(`\n`)
buffer = lines.pop() || ``
for (const line of lines) {
if (!line.trim() || line.trim() === `data: [DONE]`)
continue
try {
const json = JSON.parse(line.replace(/^data: /, ``))
const delta = json.choices?.[0]?.delta || {}
const last = messages.value[messages.value.length - 1]
if (last !== replyMessageProxy)
return
if (delta.content)
last.content += delta.content
else if (delta.reasoning_content)
last.reasoning = (last.reasoning || ``) + delta.reasoning_content
await scrollToBottom()
}
catch {
}
}
}
}
catch (e) {
if ((e as Error).name !== `AbortError`) {
messages.value[messages.value.length - 1].content
= `❌ 请求失败: ${(e as Error).message}`
}
await scrollToBottom(true)
}
finally {
await store.setJSON(memoryKey, messages.value)
await autoSaveCurrentConversation()
loading.value = false
fetchController.value = null
}
}
async function sendMessage() {
if (!input.value.trim() || loading.value)
return
inputHistory.value.push(input.value.trim())
historyIndex.value = null
loading.value = true
const userInput = input.value.trim()
messages.value.push({ role: `user`, content: userInput })
input.value = ``
const replyMessage: ChatMessage = { role: `assistant`, content: ``, reasoning: ``, done: false }
messages.value.push(replyMessage)
const replyMessageProxy = messages.value[messages.value.length - 1]
await scrollToBottom(true)
await streamResponse(replyMessageProxy)
}
</script>
<template>
<Dialog v-model:open="dialogVisible">
<DialogContent
class="bg-card text-card-foreground h-dvh max-h-dvh w-full flex flex-col rounded-none shadow-xl sm:max-h-[80vh] sm:max-w-2xl sm:rounded-xl"
>
<!-- ============ 头部 ============ -->
<DialogHeader class="space-y-1 flex flex-col items-start">
<div class="space-x-1 flex items-center">
<DialogTitle>AI 对话</DialogTitle>
<Button
:title="configVisible ? 'AI 对话' : '配置参数'"
:aria-label="configVisible ? 'AI 对话' : '配置参数'"
variant="ghost"
size="icon"
@click="configVisible = !configVisible"
>
<MessageCircle v-if="configVisible" class="h-4 w-4" />
<Settings v-else class="h-4 w-4" />
</Button>
<Button
title="AI 文生图"
aria-label="AI 文生图"
variant="ghost"
size="icon"
@click="switchToImageGenerator()"
>
<ImageIcon class="h-4 w-4" />
</Button>
<Button
title="新建会话"
aria-label="新建会话"
variant="ghost"
size="icon"
@click="createNewConversation"
>
<Plus class="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
title="加载对话"
aria-label="加载对话"
variant="ghost"
size="icon"
>
<FolderOpen class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="max-h-64 overflow-y-auto w-64 z-[9999]">
<DropdownMenuItem
v-if="conversationList.length === 0"
disabled
class="text-muted-foreground text-sm"
>
暂无保存的对话
</DropdownMenuItem>
<DropdownMenuItem
v-for="conv in conversationList"
:key="conv.id"
class="flex items-center justify-between gap-2 cursor-pointer"
@click="loadConversation(conv.id)"
>
<span class="flex-1 truncate">
{{ conv.name }}
</span>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 flex-shrink-0"
@click.stop="deleteConversation(conv.id)"
>
<Trash2 class="h-3 w-3" />
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
title="清空对话内容"
aria-label="清空对话内容"
variant="ghost"
size="icon"
@click="resetMessages"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
<DialogDescription class="text-muted-foreground text-sm">
使用 AI 助手帮助您编写和优化内容
</DialogDescription>
</DialogHeader>
<!-- ============ 快捷指令 ============ -->
<div
v-if="!configVisible"
class="mb-3 flex flex-wrap gap-2 overflow-x-auto pb-1"
>
<template v-if="quickCmdStore.commands.length">
<Button
v-for="cmd in quickCmdStore.commands"
:key="cmd.id"
variant="secondary"
size="sm"
class="text-xs"
@click="applyQuickCommand(cmd)"
>
{{ cmd.label }}
</Button>
</template>
<template v-else>
<div
class="text-muted-foreground flex items-center gap-2 border rounded-md border-dashed px-3 py-1 text-xs"
>
还没有任何快捷指令,点击右侧添加
</div>
</template>
<Button
variant="ghost"
size="sm"
title="管理指令"
@click="cmdMgrOpen = true"
>
<Plus class="h-4 w-4" />
</Button>
<!-- 指令管理弹窗 -->
<QuickCommandManager v-model:open="cmdMgrOpen" />
</div>
<!-- ============ 参数配置面板 ============ -->
<AIConfig
v-if="configVisible"
class="mb-4 w-full border rounded-md p-4"
@saved="handleConfigSaved"
/>
<!-- ============ 聊天内容 ============ -->
<div
v-if="!configVisible"
class="custom-scroll space-y-3 chat-container mb-4 flex-1 overflow-y-auto pr-2"
>
<div
v-for="(msg, index) in messages"
:key="msg.id || index"
class="relative flex"
:class="msg.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="ring-border/20 max-w-[75%] rounded-2xl px-4 py-2 text-sm leading-relaxed shadow-xs ring-1"
:class="msg.role === 'user'
? 'bg-black text-white dark:bg-primary dark:text-primary-foreground'
: 'bg-gray-100 text-gray-800 dark:bg-muted/60 dark:text-muted-foreground'"
>
<!-- reasoning -->
<div v-if="msg.reasoning" class="text-muted-foreground mb-1 italic">
{{ msg.reasoning }}
</div>
<!-- 消息内容 -->
<div
class="whitespace-pre-wrap"
:class="msg.content ? '' : 'animate-pulse text-muted-foreground'"
>
{{
msg.content
|| (msg.role === 'assistant' && !msg.done ? '思考中…' : '')
}}
</div>
<!-- 工具按钮 -->
<div
class="mt-1 flex"
:class="msg.role === 'user' ? 'justify-end' : 'justify-start'"
>
<Button
v-if="index > 0 && !(msg.role === 'assistant' && index === messages.length - 1 && !msg.done)"
variant="ghost"
size="icon"
class="ml-0 h-5 w-5 p-1"
aria-label="复制内容"
@click="copyToClipboard(msg.content, index)"
>
<Check
v-if="copiedIndex === index"
class="h-3 w-3 text-green-600"
/>
<Copy v-else class="text-muted-foreground h-3 w-3" />
</Button>
<Button
v-if="msg.role === 'assistant' && msg.done && index === messages.length - 1"
variant="ghost"
size="icon"
class="ml-1 h-5 w-5 p-1"
aria-label="重新生成"
@click="regenerateLast"
>
<RefreshCcw class="text-muted-foreground h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>
<!-- ============ 输入框 ============ -->
<div v-if="!configVisible" class="relative mt-2">
<div
class="bg-background border-border flex flex-col items-baseline gap-2 border rounded-xl px-3 py-2 pr-12 shadow-inner"
>
<Textarea
v-model="input"
placeholder="说些什么… (Enter 发送,Shift+Enter 换行)"
rows="2"
class="custom-scroll min-h-16 w-full resize-none border-none bg-transparent p-0 focus-visible:outline-hidden focus:outline-hidden focus-visible:ring-0 focus:ring-0 focus-visible:ring-offset-0 focus:ring-offset-0 focus-visible:ring-transparent focus:ring-transparent"
@keydown="handleKeydown"
/>
<!-- 引用全文按钮 -->
<Button
size="sm"
variant="outline"
class="h-8 flex items-center gap-1 rounded-md px-3 font-medium transition-colors duration-150"
:class="[
isQuoteAllContent
? 'bg-primary text-white border-primary dark:bg-white dark:text-black dark:border-white'
: 'bg-background text-muted-foreground border-border hover:text-foreground hover:border-foreground dark:bg-muted dark:text-gray-400 dark:hover:text-white dark:hover:border-white/60',
]"
aria-label="引用全文"
@click="quoteAllContent"
>
<component :is="isQuoteAllContent ? Check : Copy" class="h-4 w-4" />
<span class="text-xs">引用全文</span>
</Button>
<!-- 发送 / 暂停按钮 -->
<Button
:disabled="!input.trim() && !loading"
size="icon"
:class="[
// eslint-disable-next-line vue/prefer-separate-static-class
'absolute bottom-3 right-3 rounded-full disabled:opacity-40',
// eslint-disable-next-line vue/prefer-separate-static-class
'bg-primary hover:bg-primary/90 text-primary-foreground',
]"
:aria-label="loading ? '暂停' : '发送'"
@click="loading ? pauseStreaming() : sendMessage()"
>
<Pause v-if="loading" class="h-4 w-4" />
<Send v-else class="h-4 w-4" />
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<style scoped>
@reference 'tailwindcss';
:root {
--safe-bottom: env(safe-area-inset-bottom);
}
/* 聊天容器底部内边距,适配安全区 */
.chat-container {
padding-bottom: calc(1rem + var(--safe-bottom));
}
/* 让代码块可横向滚动 */
.chat-container pre {
overflow-x: auto;
}
/* highlight.js 暗黑主题适配 */
.dark .hljs {
background: #0d1117 !important;
color: #c9d1d9 !important;
}
/* 自定义滚动条 */
@media (pointer: coarse) {
.custom-scroll::-webkit-scrollbar {
width: 3px;
}
}
.custom-scroll::-webkit-scrollbar {
width: 6px;
}
.custom-scroll::-webkit-scrollbar-thumb {
@apply rounded-full bg-gray-400/40 hover:bg-gray-400/60;
@apply dark:bg-gray-500/40 dark:hover:bg-gray-500/70;
}
.custom-scroll {
scrollbar-width: thin;
scrollbar-color: rgb(156 163 175 / 0.4) transparent;
}
.dark .custom-scroll {
scrollbar-color: rgb(107 114 128 / 0.4) transparent;
}
</style>
================================================
FILE: apps/web/src/components/ai/chat-box/AIConfig.vue
================================================
<script setup lang="ts">
import { serviceOptions } from '@md/shared/configs'
import { DEFAULT_SERVICE_TYPE } from '@md/shared/constants'
import { Info } from 'lucide-vue-next'
import { PasswordInput } from '@/components/ui/password-input'
import useAIConfigStore from '@/stores/aiConfig'
/* -------------------------- 基础数据 -------------------------- */
const emit = defineEmits([`saved`])
const AIConfigStore = useAIConfigStore()
const { type, endpoint, model, apiKey, temperature, maxToken } = storeToRefs(AIConfigStore)
/** UI 状态 */
const loading = ref(false)
const testResult = ref(``)
/** 当前服务信息 */
const currentService = computed(
() => serviceOptions.find(s => s.value === type.value) || serviceOptions[0],
)
/* -------------------------- 监听 -------------------------- */
// 监听服务类型变化,清空测试结果
watch(type, () => {
testResult.value = ``
})
// 监听模型变化,清空测试结果
watch(model, () => {
testResult.value = ``
})
/* -------------------------- 操作 -------------------------- */
function saveConfig(emitEvent = true) {
if (emitEvent) {
testResult.value = `✅ 配置已保存`
emit(`saved`)
}
}
function clearConfig() {
AIConfigStore.reset()
testResult.value = `🗑️ 当前 AI 配置已清除`
}
async function testConnection() {
testResult.value = ``
loading.value = true
const headers: Record<string, string> = { 'Content-Type': `application/json` }
if (apiKey.value && type.value !== DEFAULT_SERVICE_TYPE)
headers.Authorization = `Bearer ${apiKey.value}`
try {
const url = new URL(endpoint.value)
if (!url.pathname.endsWith(`/chat/completions`))
url.pathname = url.pathname.replace(/\/?$/, `/chat/completions`)
const payload = {
model: model.value,
messages: [{ role: `user`, content: `ping` }],
temperature: 0,
max_tokens: 1,
stream: false,
}
const res = await window.fetch(url.toString(), {
method: `POST`,
headers,
body: JSON.stringify(payload),
})
if (res.ok) {
testResult.value = `✅ 测试成功,/chat/completions 可用`
saveConfig(false)
}
else {
const text = await res.text()
try {
const { error } = JSON.parse(text)
if (
res.status === 404
&& (error?.code === `ModelNotOpen`
|| /not activated|未开通/i.test(error?.message))
) {
testResult.value = `⚠️ 测试成功,但当前模型未开通:${model.value}`
saveConfig(false)
return
}
}
catch {}
testResult.value = `❌ 测试失败:${res.status} ${res.statusText},${text}`
}
}
catch (err) {
testResult.value = `❌ 测试失败:${(err as Error).message}`
}
finally {
loading.value = false
}
}
</script>
<template>
<div class="custom-scroll space-y-4 max-h-[calc(100dvh-10rem)] overflow-y-auto pr-1 text-xs sm:max-h-none sm:text-sm">
<div class="font-medium">
AI 配置
</div>
<!-- 服务类型 -->
<div>
<Label class="mb-1 block text-sm font-medium">服务类型</Label>
<Select v-model="type">
<SelectTrigger class="w-full">
<SelectValue>
{{ currentService.label }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="service in serviceOptions"
:key="service.value"
:value="service.value"
>
{{ service.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- API 端点 -->
<div v-if="type !== DEFAULT_SERVICE_TYPE">
<Label class="mb-1 block text-sm font-medium">API 端点</Label>
<Input
v-model="endpoint"
placeholder="输入 API 端点 URL"
class="focus:border-gray-400 focus:ring-1 focus:ring-gray-300"
/>
</div>
<!-- API 密钥,仅非 default 显示 -->
<div v-if="type !== DEFAULT_SERVICE_TYPE">
<Label class="mb-1 block text-sm font-medium">API 密钥</Label>
<PasswordInput
v-model="apiKey"
placeholder="sk-..."
class="focus:border-gray-400 focus:ring-1 focus:ring-gray-300"
/>
</div>
<!-- 模型名称 -->
<div>
<Label class="mb-1 block text-sm font-medium">模型名称</Label>
<Select v-if="currentService.models.length > 0" v-model="model">
<SelectTrigger class="w-full">
<SelectValue>
{{ model || '请选择模型' }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="_model in currentService.models"
:key="_model"
:value="_model"
>
{{ _model }}
</SelectItem>
</SelectContent>
</Select>
<Input
v-else
v-model="model"
placeholder="输入模型名称"
class="focus:border-gray-400 focus:ring-1 focus:ring-gray-300"
/>
</div>
<!-- 温度 temperature -->
<div>
<Label class="mb-1 flex items-center gap-1 text-sm font-medium">
温度
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Info class="text-gray-500" :size="16" />
</TooltipTrigger>
<TooltipContent side="top" class="z-[250]">
<div>控制输出的随机性:较小值使输出更确定,较大值使其更随机。</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input
v-model.number="temperature"
type="number"
step="0.1"
min="0"
max="2"
placeholder="0 ~ 2,默认 1"
class="focus:border-gray-400 focus:ring-1 focus:ring-gray-300"
/>
</div>
<!-- 最大 Token 数 -->
<div>
<Label class="mb-1 block text-sm font-medium">最大 Token 数</Label>
<Input
v-model.number="maxToken"
type="number"
min="1"
max="32768"
placeholder="比如 1024"
class="focus:border-gray-400 focus:ring-1 focus:ring-gray-300"
/>
</div>
<!-- 操作按钮区域 -->
<div class="mt-2 flex flex-col gap-2 sm:flex-row">
<Button size="sm" @click="saveConfig">
保存
</Button>
<Button size="sm" variant="ghost" @click="clearConfig">
清空
</Button>
<Button
size="sm"
variant="outline"
:disabled="loading"
@click="testConnection"
>
{{ loading ? '测试中...' : '测试连接' }}
</Button>
</div>
<!-- 测试结果显示 -->
<div v-if="testResult" class="mt-1 text-xs text-gray-500">
{{ testResult }}
</div>
</div>
</template>
<style scoped>
@reference 'tailwindcss';
.custom-scroll::-webkit-scrollbar {
width: 6px;
}
@media (pointer: coarse) {
/* 触屏设备更细 */
.custom-scroll::-webkit-scrollbar {
width: 3px;
}
}
.custom-scroll::-webkit-scrollbar-thumb {
@apply rounded-full bg-gray-400/40 hover:bg-gray-400/60;
@apply dark:bg-gray-500/40 dark:hover:bg-gray-500/70;
}
.custom-scroll {
scrollbar-width: thin;
scrollbar-color: rgb(156 163 175 / 0.4) transparent;
}
.dark .custom-scroll {
scrollbar-color: rgb(107 114 128 / 0.4) transparent;
}
</style>
================================================
FILE: apps/web/src/components/ai/chat-box/QuickCommandManager.vue
================================================
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { useQuickCommands } from '@/stores/quickCommands'
/* ---------- 弹窗开关 ---------- */
const props = defineProps<{ open: boolean }>()
const emit = defineEmits([`update:open`])
const dialogOpen = ref(props.open)
watch(() => props.open, v => (dialogOpen.value = v))
watch(dialogOpen, v => emit(`update:open`, v))
/* ---------- store & 新增 ---------- */
const store = useQuickCommands()
const label = ref(``)
const template = ref(``)
function addCmd() {
if (!label.value.trim() || !template.value.trim())
return
store.add(label.value.trim(), template.value.trim())
label.value = ``
template.value = ``
}
/* ---------- 编辑 ---------- */
const editingId = ref<string | null>(null)
const editLabel = ref(``)
const editTemplate = ref(``)
function beginEdit(cmd: { id: string, label: string, template: string }) {
editingId.value = cmd.id
editLabel.value = cmd.label
editTemplate.value = cmd.template
}
function cancelEdit() {
editingId.value = null
}
function saveEdit() {
if (!editLabel.value.trim() || !editTemplate.value.trim())
return
store.update(editingId.value!, editLabel.value.trim(), editTemplate.value.trim())
editingId.value = null
}
</script>
<template>
<Dialog v-model:open="dialogOpen">
<DialogContent
class="max-h-[90vh] w-[92vw] flex flex-col sm:max-w-lg"
>
<DialogHeader>
<DialogTitle>管理快捷指令</DialogTitle>
</DialogHeader>
<!-- 列表:独立滚动区域 -->
<div class="space-y-4 flex-1 overflow-y-auto pr-1">
<div
v-for="cmd in store.commands"
:key="cmd.id"
class="flex flex-col gap-2 border rounded-md p-3"
>
<!-- 编辑态 -->
<template v-if="editingId === cmd.id">
<Input v-model="editLabel" placeholder="指令名称" />
<Textarea
v-model="editTemplate"
rows="2"
placeholder="模板内容,支持 {{sel}} 占位"
/>
<div class="flex justify-end gap-2">
<Button size="xs" @click="saveEdit">
保存
</Button>
<Button variant="ghost" size="xs" @click="cancelEdit">
取消
</Button>
</div>
</template>
<!-- 查看态 -->
<template v-else>
<div class="flex items-center justify-between">
<span class="break-all text-sm">{{ cmd.label }}</span>
<div class="flex gap-1">
<Button variant="ghost" size="xs" @click="beginEdit(cmd)">
编辑
</Button>
<Button variant="outline" size="xs" @click="store.remove(cmd.id)">
删除
</Button>
</div>
</div>
</template>
</div>
</div>
<!-- 新增表单:固定在滚动区下方 -->
<div class="space-y-2 mt-4 border rounded-md p-3">
<Input v-model="label" placeholder="指令名称 (如:改写为 SEO 文案)" />
<Textarea
v-model="template"
rows="2"
placeholder="模板,可用 {{sel}} 占位,例如:\n请把以下文字改写为 SEO 友好的标题:\n\n{{sel}}"
/>
<Button class="w-full" @click="addCmd">
添加
</Button>
</div>
</DialogContent>
</Dialog>
</template>
================================================
FILE: apps/web/src/components/ai/image-generator/AIImageConfig.vue
================================================
<script setup lang="ts">
import { imageServiceOptions } from '@md/shared/configs'
import { DEFAULT_SERVICE_TYPE } from '@md/shared/constants'
import { Info } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { PasswordInput } from '@/components/ui/password-input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import useAIImageConfigStore from '@/stores/aiImageConfig'
/* -------------------------- 基础数据 -------------------------- */
const emit = defineEmits([`saved`])
const AIImageConfigStore = useAIImageConfigStore()
const { type, endpoint, model, apiKey, size, quality, style } = storeToRefs(AIImageConfigStore)
/** UI 状态 */
const loading = ref(false)
const testResult = ref(``)
/** 当前服务信息 */
const currentService = computed(
() => imageServiceOptions.find(s => s.value === type.value) || imageServiceOptions[0],
)
/* -------------------------- 监听 -------------------------- */
// 监听服务类型变化,清空测试结果
watch(type, () => {
testResult.value = ``
})
// 监听模型变化,清空测试结果
watch(model, () => {
testResult.value = ``
})
// 监听端点变化,清空测试结果
watch(endpoint, () => {
testResult.value = ``
})
/* -------------------------- 表单提交 -------------------------- */
function saveConfig() {
if (!endpoint.value.trim() || !model.value.trim()) {
testResult.value = `❌ 请检查配置项是否完整`
return
}
if (type.value !== DEFAULT_SERVICE_TYPE && !apiKey.value.trim()) {
testResult.value = `❌ 请输入 API Key`
return
}
try {
// eslint-disable-next-line no-new
new URL(endpoint.value)
}
catch {
testResult.value = `❌ 端点格式有误`
return
}
testResult.value = `✅ 配置已保存`
emit(`saved`)
}
function clearConfig() {
AIImageConfigStore.reset()
testResult.value = `🗑️ 当前 AI 图像配置已清除`
}
async function testConnection() {
testResult.value = ``
loading.value = true
const headers: Record<string, string> = { 'Content-Type': `application/json` }
if (apiKey.value && type.value !== DEFAULT_SERVICE_TYPE)
headers.Authorization = `Bearer ${apiKey.value}`
try {
const url = new URL(endpoint.value)
if (!url.pathname.includes(`/images/`) && !url.pathname.endsWith(`/images/generations`)) {
url.pathname = url.pathname.replace(/\/?$/, `/images/generations`)
}
const payload = {
model: model.value,
prompt: `test connection`,
size: size.value,
quality: quality.value,
style: style.value,
n: 1,
}
const res = await window.fetch(url.toString(), {
method: `POST`,
headers,
body: JSON.stringify(payload),
})
if (res.ok) {
testResult.value = `✅ 连接成功`
}
else {
const errorText = await res.text()
testResult.value = `❌ 连接失败:${res.status} ${errorText}`
}
}
catch (error) {
testResult.value = `❌ 连接失败:${(error as Error).message}`
}
finally {
loading.value = false
}
}
/* -------------------------- 图像尺寸选项 -------------------------- */
const sizeOptions = [
{ label: `正方形 (1024x1024)`, value: `1024x1024` },
{ label: `横版 (1792x1024)`, value: `1792x1024` },
{ label: `竖版 (1024x1792)`, value: `1024x1792` },
]
const qualityOptions = [
{ label: `标准`, value: `standard` },
{ label: `高清`, value: `hd` },
]
const styleOptions = [
{ label: `自然`, value: `natural` },
{ label: `鲜明`, value: `vivid` },
]
</script>
<template>
<div class="space-y-4 max-w-full">
<div class="text-lg font-semibold border-b pb-2">
AI 图像生成配置
</div>
<!-- 服务商选择 -->
<div>
<Label class="mb-1 block text-sm font-medium">服务商</Label>
<Select v-model="type">
<SelectTrigger class="w-full">
<SelectValue>
{{ currentService.label }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in imageServiceOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 端点配置 -->
<div>
<Label class="mb-1 block text-sm font-medium">API 端点</Label>
<input
v-model="endpoint"
type="url"
class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
placeholder="https://api.openai.com/v1"
:readonly="type !== 'custom'"
>
</div>
<!-- API Key -->
<div v-if="type !== 'default'">
<Label class="mb-1 block text-sm font-medium">API Key</Label>
<PasswordInput
v-model="apiKey"
class="w-full mt-1 focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
placeholder="sk-..."
/>
</div>
<!-- 模型选择 -->
<div>
<Label class="mb-1 block text-sm font-medium">模型</Label>
<Select v-if="type !== 'custom' && currentService.models.length > 0" v-model="model">
<SelectTrigger class="w-full">
<SelectValue>
{{ model || '请选择模型' }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="modelName in currentService.models"
:key="modelName"
:value="modelName"
>
{{ modelName }}
</SelectItem>
</SelectContent>
</Select>
<input
v-else
v-model="model"
type="text"
class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
placeholder="输入模型名称,如:dall-e-3"
>
</div>
<!-- 图像尺寸 -->
<div>
<Label class="mb-1 block text-sm font-medium">图像尺寸</Label>
<Select v-model="size">
<SelectTrigger class="w-full">
<SelectValue>
{{ sizeOptions.find(opt => opt.value === size)?.label || size }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in sizeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 图像质量 -->
<div v-if="model.includes('dall-e')">
<Label class="mb-1 block text-sm font-medium">图像质量</Label>
<Select v-model="quality">
<SelectTrigger class="w-full">
<SelectValue>
{{ qualityOptions.find(opt => opt.value === quality)?.label || quality }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in qualityOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 图像风格 -->
<div v-if="model.includes('dall-e')">
<Label class="mb-1 block text-sm font-medium">图像风格</Label>
<Select v-model="style">
<SelectTrigger class="w-full">
<SelectValue>
{{ styleOptions.find(opt => opt.value === style)?.label || style }}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in styleOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 说明 -->
<div v-if="type === 'default'" class="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-950/30 rounded-md text-sm">
<Info class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div class="text-blue-700 dark:text-blue-300">
<p class="font-medium">
默认图像服务
</p>
<p>免费使用,无需配置 API Key,支持 Kwai-Kolors/Kolors 模型。</p>
</div>
</div>
<!-- 自定义服务说明 -->
<div v-else-if="type === 'custom'" class="flex items-start gap-2 p-3 bg-orange-50 dark:bg-orange-950/30 rounded-md text-sm">
<Info class="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<div class="text-orange-700 dark:text-orange-300">
<p class="font-medium">
自定义服务
</p>
<p>可配置任何兼容 OpenAI 图像生成 API 的服务,如自建的 API 代理或其他第三方服务。</p>
<p class="mt-1 text-xs">
端点格式示例:https://your-api.com/v1
</p>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex flex-wrap gap-2">
<Button
type="button"
class="flex-1 min-w-[100px]"
@click="saveConfig"
>
保存配置
</Button>
<Button
variant="outline"
type="button"
class="flex-1 min-w-[80px]"
@click="clearConfig"
>
清空
</Button>
<Button
size="sm"
variant="outline"
class="flex-1 min-w-[100px]"
:disabled="loading"
@click="testConnection"
>
{{ loading ? '测试中...' : '测试连接' }}
</Button>
</div>
<!-- 测试结果显示 -->
<div v-if="testResult" class="mt-1 text-xs text-gray-500">
{{ testResult }}
</div>
</div>
</template>
================================================
FILE: apps/web/src/components/ai/image-generator/AIImageGeneratorPanel.vue
================================================
<script setup lang="ts">
import {
Copy,
Download,
Image as ImageIcon,
Loader2,
MessageCircle,
RefreshCcw,
Settings,
Trash2,
} from 'lucide-vue-next'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import useAIImageConfigStore from '@/stores/aiImageConfig'
import { useEditorStore } from '@/stores/editor'
import { useUIStore } from '@/stores/ui'
import { copyPlain } from '@/utils/clipboard'
import { store } from '@/utils/storage'
import AIImageConfig from './AIImageConfig.vue'
/* ---------- 组件属性 ---------- */
const props = defineProps<{ open: boolean }>()
const emit = defineEmits([`update:open`])
/* ---------- 编辑器引用 ---------- */
const editorStore = useEditorStore()
const { editor } = storeToRefs(editorStore)
const uiStore = useUIStore()
const { toggleAIDialog } = uiStore
/* ---------- 弹窗开关 ---------- */
const dialogVisible = ref(props.open)
watch(() => props.open, (val) => {
dialogVisible.value = val
// 每次打开面板时检查并清理过期图片
if (val) {
cleanExpiredImages()
}
})
watch(dialogVisible, val => emit(`update:open`, val))
/* ---------- 状态管理 ---------- */
const configVisible = ref(false)
const loading = ref(false)
const prompt = ref<string>(``)
const lastUsedPrompt = ref<string>(``) // 存储最后一次使用的提示词,用于重新生成
const generatedImages = ref<string[]>([])
const imagePrompts = ref<string[]>([]) // 存储每张图片对应的prompt
const imageTimestamps = ref<number[]>([]) // 存储每张图片的生成时间戳
const abortController = ref<AbortController | null>(null)
const currentImageIndex = ref(0)
const timeUpdateInterval = ref<NodeJS.Timeout | null>(null)
/* ---------- AI 配置 ---------- */
const AIImageConfigStore = useAIImageConfigStore()
const { apiKey, endpoint, model, type, size, quality, style } = storeToRefs(AIImageConfigStore)
/* ---------- 过期检查函数 ---------- */
function isImageExpired(timestamp: number): boolean {
const EXPIRY_TIME = 60 * 60 * 1000 // 1小时,单位毫秒
const now = Date.now()
return now - timestamp > EXPIRY_TIME
}
async function cleanExpiredImages() {
const savedImages = await store.get(`ai_generated_images`)
const savedTimestamps = await store.get(`ai_image_timestamps`)
if (!savedImages) {
return
}
const images = await store.getJSON(`ai_generated_images`, [])
const prompts = await store.getJSON(`ai_image_prompts`, [])
const timestamps = await store.getJSON(`ai_image_timestamps`, [])
// 如果没有时间戳数据,说明是旧版本,默认清除所有数据
if (!savedTimestamps || timestamps.length === 0) {
console.log(`🧹 检测到旧版本数据,清除所有过期图片`)
generatedImages.value = []
imagePrompts.value = []
imageTimestamps.value = []
await store.remove(`ai_generated_images`)
await store.remove(`ai_image_prompts`)
await store.remove(`ai_image_timestamps`)
return
}
// 过滤掉过期的图片
const validIndices: number[] = []
timestamps.forEach((timestamp: number, index: number) => {
if (!isImageExpired(timestamp)) {
validIndices.push(index)
}
})
const validImages = validIndices.map(i => images[i]).filter(Boolean)
const validPrompts = validIndices.map(i => prompts[i] || ``).filter((_, index) => validImages[index])
const validTimestamps = validIndices.map(i => timestamps[i]).filter(Boolean)
// 更新数据
generatedImages.value = validImages
imagePrompts.value = validPrompts
imageTimestamps.value = validTimestamps
// 如果有数据被清除,更新存储
if (validImages.length < images.length) {
console.log(`🧹 清除了 ${images.length - validImages.length} 张过期图片`)
if (validImages.length > 0) {
await store.setJSON(`ai_generated_images`, validImages)
await store.setJSON(`ai_image_prompts`, validPrompts)
await store.setJSON(`ai_image_timestamps`, validTimestamps)
}
else {
await store.remove(`ai_generated_images`)
await store.remove(`ai_image_prompts`)
await store.remove(`ai_image_timestamps`)
}
}
console.log(`📊 过期检查完成,有效图片数量:`, validImages.length)
}
/* ---------- 初始数据 ---------- */
onMounted(async () => {
// 先进行过期检查和清理
await cleanExpiredImages()
// 确保数组长度一致
const imagesLength = generatedImages.value.length
const promptsLength = imagePrompts.value.length
const timestampsLength = imageTimestamps.value.length
const maxLength = Math.max(imagesLength, promptsLength, timestampsLength)
if (imagesLength < maxLength) {
// 如果图片少于其他数组,说明数据不一致,清除所有数据
console.warn(`⚠️ 数据不一致,清除所有数据`)
generatedImages.value = []
imagePrompts.value = []
imageTimestamps.value = []
await store.remove(`ai_generated_images`)
await store.remove(`ai_image_prompts`)
await store.remove(`ai_image_timestamps`)
}
else {
// 补齐较短的数组
if (promptsLength < imagesLength) {
imagePrompts.value = [...imagePrompts.value, ...Array.from({ length: imagesLength - promptsLength }, () => ``)]
}
if (timestampsLength < imagesLength) {
imageTimestamps.value = [...imageTimestamps.value, ...Array.from({ length: imagesLength - timestampsLength }, () => Date.now())]
}
}
// 启动定时器,每30秒检查一次过期图片并更新时间显示
timeUpdateInterval.value = setInterval(() => {
// 检查并清理过期图片
if (generatedImages.value.length > 0) {
cleanExpiredImages()
}
}, 30000) // 30秒
})
onBeforeUnmount(() => {
// 清除定时器
if (timeUpdateInterval.value) {
clearInterval(timeUpdateInterval.value)
timeUpdateInterval.value = null
}
})
/* ---------- 事件处理 ---------- */
function handleConfigSaved() {
configVisible.value = false
}
function switchToChat() {
// 先关闭当前文生图对话框
emit(`update:open`, false)
// 然后打开聊天对话框
setTimeout(() => {
toggleAIDialog(true)
}, 100)
}
function handleKeydown(e: KeyboardEvent) {
if (e.isComposing || e.keyCode === 229)
return
if (e.key === `Enter` && !e.shiftKey) {
e.preventDefault()
generateImage()
}
}
/* ---------- 生成图像 ---------- */
async function generateImage() {
if (!prompt.value.trim() || loading.value)
return
// 保存当前提示词用于重新生成
const currentPrompt = prompt.value.trim()
lastUsedPrompt.value = currentPrompt
loading.value = true
abortController.value = new AbortController()
const headers: Record<string, string> = { 'Content-Type': `application/json` }
if (apiKey.value && type.value !== `default`)
headers.Authorization = `Bearer ${apiKey.value}`
try {
const url = new URL(endpoint.value)
if (!url.pathname.includes(`/images/`) && !url.pathname.endsWith(`/images/generations`)) {
url.pathname = url.pathname.replace(/\/?$/, `/images/generations`)
}
const payload: any = {
model: model.value,
prompt: currentPrompt,
size: size.value,
n: 1,
}
// 只对 DALL-E 模型添加额外参数
if (model.value.includes(`dall-e`)) {
payload.quality = quality.value
payload.style = style.value
}
const res = await window.fetch(url.toString(), {
method: `POST`,
headers,
body: JSON.stringify(payload),
signal: abortController.value.signal,
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`${res.status}: ${errorText}`)
}
const data = await res.json()
if (data.data && data.data.length > 0) {
const imageUrl = data.data[0].url || data.data[0].b64_json
if (imageUrl) {
// 如果是 base64 格式,转换为 data URL
const finalUrl = imageUrl.startsWith(`data:`) || imageUrl.startsWith(`http`)
? imageUrl
: `data:image/png;base64,${imageUrl}`
const currentTimestamp = Date.now()
generatedImages.value.unshift(finalUrl)
imagePrompts.value.unshift(currentPrompt) // 保存对应的prompt
imageTimestamps.value.unshift(currentTimestamp) // 保存生成时间戳
currentImageIndex.value = 0
// 限制存储的图片数量,避免占用过多存储空间
if (generatedImages.value.length > 20) {
generatedImages.value = generatedImages.value.slice(0, 20)
imagePrompts.value = imagePrompts.value.slice(0, 20)
imageTimestamps.value = imageTimestamps.value.slice(0, 20)
}
await store.setJSON(`ai_generated_images`, generatedImages.value)
await store.setJSON(`ai_image_prompts`, imagePrompts.value)
await store.setJSON(`ai_image_timestamps`, imageTimestamps.value)
// 清空输入框
prompt.value = ``
}
}
else {
throw new Error(`未收到有效的图像数据`)
}
}
catch (e) {
if ((e as Error).name === `AbortError`) {
console.log(`图像生成请求中止`)
}
else {
console.error(`图像生成失败:`, e)
// 可以在这里添加错误提示
}
}
finally {
loading.value = false
abortController.value = null
}
}
/* ---------- 取消生成 ---------- */
function cancelGeneration() {
if (abortController.value) {
abortController.value.abort()
abortController.value = null
}
loading.value = false
}
/* ---------- 清空图像 ---------- */
async function clearImages() {
generatedImages.value = []
imagePrompts.value = []
imageTimestamps.value = []
currentImageIndex.value = 0
await store.remove(`ai_generated_images`)
await store.remove(`ai_image_prompts`)
await store.remove(`ai_image_timestamps`)
}
/* ---------- 下载图像 ---------- */
async function downloadImage(imageUrl: string, index: number) {
try {
const response = await fetch(imageUrl)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement(`a`)
a.href = url
// 生成包含prompt信息的文件名
const relatedPrompt = imagePrompts.value[index] || ``
const promptPart = relatedPrompt
? relatedPrompt.substring(0, 20).replace(/[^\w\s-]/g, ``).replace(/\s+/g, `-`)
: `no-prompt`
a.download = `ai-image-${index + 1}-${promptPart}.png`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}
catch (error) {
console.error(`下载图像失败:`, error)
}
}
/* ---------- 复制图像URL ---------- */
async function copyImageUrl(imageUrl: string) {
try {
await copyPlain(imageUrl)
console.log(`✅ 图片链接已复制到剪贴板`)
if (typeof toast !== `undefined`) {
toast.success(`图片链接已复制到剪贴板`)
}
}
catch (error) {
console.error(`❌ 复制失败:`, error)
if (typeof toast !== `undefined`) {
toast.error(`复制失败,请重试`)
}
}
}
/* ---------- 重新生成 ---------- */
function regenerateImage() {
// 使用当前图片对应的prompt
const currentPrompt = imagePrompts.value[currentImageIndex.value]
if (currentPrompt) {
console.log(`🔄 重新生成图像,使用当前图片的prompt:`, currentPrompt)
// 直接使用当前图片的prompt生成,不修改输入框内容
regenerateWithPrompt(currentPrompt)
}
else {
console.warn(`⚠️ 没有找到当前图片的prompt`)
}
}
/* ---------- 使用指定prompt重新生成 ---------- */
async function regenerateWithPrompt(promptText: string) {
if (!promptText.trim() || loading.value)
return
loading.value = true
abortController.value = new AbortController()
const headers: Record<string, string> = { 'Content-Type': `application/json` }
if (apiKey.value && type.value !== `default`)
headers.Authorization = `Bearer ${apiKey.value}`
try {
const url = new URL(endpoint.value)
if (!url.pathname.includes(`/images/`) && !url.pathname.endsWith(`/images/generations`)) {
url.pathname = url.pathname.replace(/\/?$/, `/images/generations`)
}
const payload: any = {
model: model.value,
prompt: promptText.trim(),
size: size.value,
n: 1,
}
// 只对 DALL-E 模型添加额外参数
if (model.value.includes(`dall-e`)) {
payload.quality = quality.value
payload.style = style.value
}
const res = await window.fetch(url.toString(), {
method: `POST`,
headers,
body: JSON.stringify(payload),
signal: abortController.value.signal,
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`${res.status}: ${errorText}`)
}
const data = await res.json()
if (data.data && data.data.length > 0) {
const imageUrl = data.data[0].url || data.data[0].b64_json
if (imageUrl) {
// 如果是 base64 格式,转换为 data URL
const finalUrl = imageUrl.startsWith(`data:`) || imageUrl.startsWith(`http`)
? imageUrl
: `data:image/png;base64,${imageUrl}`
const currentTimestamp = Date.now()
generatedImages.value.unshift(finalUrl)
imagePrompts.value.unshift(promptText.trim()) // 保存对应的prompt
imageTimestamps.value.unshift(currentTimestamp) // 保存生成时间戳
currentImageIndex.value = 0
// 限制存储的图片数量,避免占用过多存储空间
if (generatedImages.value.length > 20) {
generatedImages.value = generatedImages.value.slice(0, 20)
imagePrompts.value = imagePrompts.value.slice(0, 20)
imageTimestamps.value = imageTimestamps.value.slice(0, 20)
}
await store.setJSON(`ai_generated_images`, generatedImages.value)
await store.setJSON(`ai_image_prompts`, imagePrompts.value)
await store.setJSON(`ai_image_timestamps`, imageTimestamps.value)
}
}
else {
throw new Error(`未收到有效的图像数据`)
}
}
catch (e) {
if ((e as Error).name === `AbortError`) {
console.log(`图像生成请求中止`)
}
else {
console.error(`图像生成失败:`, e)
}
}
finally {
loading.value = false
abortController.value = null
}
}
/* ---------- 切换图像 ---------- */
function previousImage() {
if (currentImageIndex.value > 0) {
currentImageIndex.value--
}
}
function nextImage() {
if (currentImageIndex.value < generatedImages.value.length - 1) {
currentImageIndex.value++
}
}
/* ---------- 插入图像到光标位置 ---------- */
function insertImageToCursor(imageUrl: string) {
if (!editor.value) {
console.warn(`编辑器未初始化`)
return
}
try {
// 获取当前图片对应的prompt
const imagePrompt = imagePrompts.value[currentImageIndex.value] || ``
console.log(`🔗 插入图片,使用关联的prompt:`, imagePrompt)
// 生成简洁的alt文本
const altText = imagePrompt.trim()
? imagePrompt.trim().substring(0, 30).replace(/\n/g, ` `)
: `AI生成的图像`
// 生成Markdown图片语法
const markdownImage = ``
// 获取当前光标位置并插入
const pos = editor.value.state.selection.main.head
editor.value.dispatch({
changes: { from: pos, insert: markdownImage },
selection: { anchor: pos + markdownImage.length },
})
// 聚焦编辑器
editor.value.focus()
// 关闭弹窗
dialogVisible.value = false
console.log(`✅ 图像已成功插入到光标位置`)
}
catch (error) {
console.error(`❌ 插入图像到光标位置失败:`, error)
}
}
/* ---------- 查看大图 ---------- */
function viewFullImage(imageUrl: string) {
console.log(`🔍 点击查看大图:`, imageUrl)
if (!imageUrl) {
console.error(`❌ 图片URL为空`)
return
}
try {
// 在新窗口中打开图片
const newWindow = window.open(imageUrl, `_blank`, `width=800,height=600,scrollbars=yes,resizable=yes`)
if (!newWindow) {
console.error(`❌ 无法打开新窗口,可能被浏览器阻止`)
// 备用方案:在当前标签页打开
window.open(imageUrl, `_blank`)
}
}
catch (error) {
console.error(`❌ 打开图片失败:`, error)
}
}
/* ---------- 时间相关函数 ---------- */
const currentTime = ref(Date.now())
// 每秒更新当前时间,用于实时显示剩余时间
onMounted(() => {
const updateTime = () => {
currentTime.value = Date.now()
}
// 启动定时器更新时间显示
const timeDisplayInterval = setInterval(updateTime, 1000)
// 组件卸载时清理定时器
onBeforeUnmount(() => {
clearInterval(timeDisplayInterval)
})
})
function getTimeRemaining(index: number): string {
if (!imageTimestamps.value[index]) {
return `未知`
}
const EXPIRY_TIME = 60 * 60 * 1000 // 1小时
const timestamp = imageTimestamps.value[index]
const elapsed = currentTime.value - timestamp
const remaining = EXPIRY_TIME - elapsed
if (remaining <= 0) {
return `已过期`
}
const minutes = Math.floor(remaining / (60 * 1000))
const seconds = Math.floor((remaining % (60 * 1000)) / 1000)
if (minutes > 0) {
return `${minutes}分${seconds}秒`
}
else {
return `${seconds}秒`
}
}
function getTimeRemainingClass(index: number): string {
if (!imageTimestamps.value[index]) {
return `text-muted-foreground`
}
const EXPIRY_TIME = 60 * 60 * 1000 // 1小时
const timestamp = imageTimestamps.value[index]
const elapsed = currentTime.value - timestamp
const remaining = EXPIRY_TIME - elapsed
if (remaining <= 0) {
return `text-red-500 font-medium`
}
else if (remaining < 10 * 60 * 1000) { // 少于10分钟
return `text-orange-500 font-medium`
}
else if (remaining < 30 * 60 * 1000) { // 少于30分钟
return `text-yellow-600`
}
else {
return `text-green-600`
}
}
</script>
<template>
<Dialog v-model:open="dialogVisible">
<DialogContent
class="bg-card text-card-foreground flex flex-col w-[95vw] max-h-[90vh] sm:max-h-[85vh] sm:max-w-4xl overflow-y-auto"
>
<!-- ============ 头部 ============ -->
<DialogHeader class="space-y-1 flex flex-col items-start">
<div class="space-x-1 flex items-center">
<DialogTitle>AI 文生图</DialogTitle>
<Button
:title="configVisible ? 'AI 文生图' : '配置参数'"
:aria-label="configVisible ? 'AI 文生图' : '配置参数'"
variant="ghost"
size="icon"
@click="configVisible = !configVisible"
>
<ImageIcon v-if="configVisible" class="h-4 w-4" />
<Settings v-else class="h-4 w-4" />
</Button>
<Button
title="AI 对话"
aria-label="AI 对话"
variant="ghost"
size="icon"
@click="switchToChat()"
>
<MessageCircle class="h-4 w-4" />
</Button>
<Button
title="清空图像"
aria-label="清空图像"
variant="ghost"
size="icon"
@click="clearImages"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
<DialogDescription class="text-muted-foreground text-sm">
使用 AI 根据文字描述生成图像
</DialogDescription>
</DialogHeader>
<!-- ============ 参数配置面板 ============ -->
<div
v-if="configVisible"
class="mb-4 w-full border rounded-md p-4 max-h-[60vh] overflow-y-auto flex-shrink-0"
>
<AIImageConfig @saved="handleConfigSaved" />
</div>
<!-- ============ 图像展示区域 ============ -->
<div
v-if="!configVisible && (loading || generatedImages.length > 0)"
class="flex flex-col space-y-4 flex-shrink-0"
>
<!-- 图像显示 -->
<div class="flex items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg min-h-[250px] sm:min-h-[300px]">
<div v-if="loading" class="flex flex-col items-center gap-4">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p class="text-sm text-muted-foreground">
正在生成图像...
</p>
<Button
variant="outline"
size="sm"
@click="cancelGeneration"
>
取消生成
</Button>
</div>
<div v-else-if="generatedImages.length > 0" class="w-full flex flex-col space-y-3">
<!-- 图像导航 -->
<div v-if="generatedImages.length > 1" class="flex items-center justify-between p-2 bg-muted/20 rounded">
<Button
variant="outline"
size="sm"
:disabled="currentImageIndex <= 0"
@click="previousImage"
>
上一张
</Button>
<span class="text-sm text-muted-foreground">
{{ currentImageIndex + 1 }} / {{ generatedImages.length }}
</span>
<Button
variant="outline"
size="sm"
:disabled="currentImageIndex >= generatedImages.length - 1"
@click="nextImage"
>
下一张
</Button>
</div>
<!-- 图像显示 -->
<div class="flex items-center justify-center p-2 sm:p-4">
<div class="relative group cursor-pointer w-full max-w-sm" @click="viewFullImage(generatedImages[currentImageIndex])">
<img
:src="generatedImages[currentImageIndex]"
:alt="`生成的图像 ${currentImageIndex + 1}`"
class="w-full h-auto max-h-[300px] sm:max-h-[350px] object-contain rounded-lg shadow-lg border border-border transition-transform hover:scale-105"
>
<!-- 点击查看大图提示 -->
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<div class="bg-black/70 text-white px-3 py-1 rounded-md text-sm">
点击查看大图
</div>
</div>
</div>
</div>
<!-- 图像信息 -->
<div class="px-2 sm:px-4 py-2 bg-muted/10 rounded space-y-1">
<p class="text-xs text-muted-foreground text-center">
尺寸: {{ size }}
</p>
<!-- 提示词 -->
<div class="text-xs text-muted-foreground break-words text-center">
<span class="font-medium">提示词:</span>
<span class="ml-1">{{ imagePrompts[currentImageIndex] || '无关联提示词' }}</span>
</div>
<div class="text-xs text-muted-foreground text-center">
<span class="font-medium">剩余有效期:</span>
<span class="ml-1" :class="getTimeRemainingClass(currentImageIndex)">
{{ getTimeRemaining(currentImageIndex) }}
</span>
<span class="font-medium">,请及时下载保存</span>
</div>
</div>
<!-- 图像操作按钮 -->
<div class="flex flex-wrap justify-center gap-2 p-2 sm:p-4 bg-muted/20 border-t border-border rounded-b-lg">
<Button
variant="outline"
size="sm"
class="flex-shrink-0 bg-background text-xs sm:text-sm"
@click="insertImageToCursor(generatedImages[currentImageIndex])"
>
<ImageIcon class="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
插入
</Button>
<Button
variant="outline"
size="sm"
class="flex-shrink-0 bg-background text-xs sm:text-sm"
@click="downloadImage(generatedImages[currentImageIndex], currentImageIndex)"
>
<Download class="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
下载
</Button>
<Button
variant="outline"
size="sm"
class="flex-shrink-0 bg-background text-xs sm:text-sm"
@click="copyImageUrl(generatedImages[currentImageIndex])"
>
<Copy class="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
复制
</Button>
<Button
variant="outline"
size="sm"
class="flex-shrink-0 bg-background text-xs sm:text-sm"
@click="regenerateImage"
>
<RefreshCcw class="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
重新生成
</Button>
</div>
</div>
</div>
</div>
<!-- ============ 输入框 ============ -->
<div v-if="!configVisible" class="relative flex-shrink-0 mt-auto">
<div
class="bg-background border-border flex flex-col items-baseline gap-2 border rounded-xl px-3 py-2 pr-12 shadow-inner"
>
<Textarea
v-model="prompt"
placeholder="描述你想要生成的图像... (Enter 生成,Shift+Enter 换行)"
rows="2"
class="custom-scroll min-h-16 w-full resize-none border-none bg-transparent p-0 focus-visible:outline-hidden focus:outline-hidden focus-visible:ring-0 focus:ring-0 focus-visible:ring-offset-0 focus:ring-offset-0 focus-visible:ring-transparent focus:ring-transparent"
@keydown="handleKeydown"
/>
<!-- 生成按钮 -->
<Button
:disabled="!prompt.trim() && !loading"
size="icon"
:class="[
// eslint-disable-next-line vue/prefer-separate-static-class
'absolute bottom-3 right-3 rounded-full disabled:opacity-40',
// eslint-disable-next-line vue/prefer-separate-static-class
'bg-primary hover:bg-primary/90 text-primary-foreground',
]"
:aria-label="loading ? '取消' : '生成'"
@click="loading ? cancelGeneration() : generateImage()"
>
<Loader2 v-if="loading" class="h-4 w-4 animate-spin" />
<ImageIcon v-else class="h-4 w-4" />
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<style scoped>
.custom-scroll::-webkit-scrollbar {
width: 6px;
}
@media (pointer: coarse) {
/* 触屏设备更细 */
.custom-scroll::-webkit-scrollbar {
width: 3px;
}
}
.custom-scroll::-webkit-scrollbar-thumb {
border-radius: 9999px;
background-color: rgba(156, 163, 175, 0.4);
}
.custom-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.6);
}
html.dark .custom-scroll::-webkit-scrollbar-thumb {
background-color: rgba(107, 114, 128, 0.4);
}
html.dark .custom-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.7);
}
.custom-scroll {
scrollbar-width: thin;
}
</style>
================================================
FILE: apps/web/src/components/ai/image-generator/index.ts
================================================
export { default as AIImageConfig } from './AIImageConfig.vue'
export { default as AIImageGeneratorPanel } from './AIImageGeneratorPanel.vue'
================================================
FILE: apps/web/src/components/ai/index.ts
================================================
export * from './image-generator'
export { default as SidebarAIToolbar } from './SidebarAIToolbar.vue'
export * from './tool-box'
================================================
FILE: apps/web/src/components/ai/tool-box/ToolBoxPopover.vue
================================================
<script setup lang="ts">
import { Pause, Settings, Wand2, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import useAIConfigStore from '@/stores/aiConfig'
import { useEditorStore } from '@/stores/editor'
/* -------------------- props / emits -------------------- */
const props = defineProps<{
open: boolean
selectedText: string
isMobile: boolean
}>()
const emit = defineEmits([`update:open`])
/* -------------------- reactive state -------------------- */
const configVisible = ref(false)
const dialogVisible = ref(props.open)
const message = ref(``)
const loading = ref(false)
const abortController = ref<AbortController | null>(null)
const customPrompts = ref<string[]>([])
const hasResult = ref(false)
const selectedAction = ref<
`optimize` | `summarize` | `spellcheck` | `translate-zh` | `translate-en` | `custom`
>(`optimize`)
const currentText = ref(``)
const error = ref(``)
/* -------------------- store & refs -------------------- */
const editorStore = useEditorStore()
const resultContainer = ref<HTMLElement | null>(null)
/* -------------------- dialog state sync -------------------- */
watch(() => props.open, (val) => {
dialogVisible.value = val
if (val && props.selectedText.trim()) {
currentText.value = props.selectedText
resetState()
}
})
watch(dialogVisible, val => emit(`update:open`, val))
/* -------------------- AI config -------------------- */
const AIConfigStore = useAIConfigStore()
const { apiKey, endpoint, model, temperature, maxToken, type }
= storeToRefs(AIConfigStore)
/* -------------------- action options -------------------- */
interface ActionOption {
value: string
label: string
defaultPrompt: string
}
const actionOptions: ActionOption[] = [
{
value: `optimize`,
label: `优化文本`,
defaultPrompt: `请优化文本,使其更通顺易读。`,
},
{
value: `summarize`,
label: `文章总结`,
defaultPrompt: `请对文本进行摘要,输出主要观点和结论。`,
},
{
value: `spellcheck`,
label: `错别字纠正`,
defaultPrompt: `请找出并纠正文本中的错别字、标点和语法错误。`,
},
{
value: `translate-zh`,
label: `翻译为中文`,
defaultPrompt: `请将文本翻译为地道的中文。`,
},
{
value: `translate-en`,
label: `翻译为英文`,
defaultPrompt: `请将文本翻译为自然流畅的英文。`,
},
{ value: `custom`, label: `自定义`, defaultPrompt: `` },
]
/* -------------------- watchers -------------------- */
watch(message, async () => {
await nextTick()
resultContainer.value?.scrollTo({ top: resultContainer.value.scrollHeight })
})
watch(selectedAction, (val) => {
if (val !== `custom`)
customPrompts.value = []
})
// 当 dialogVisible 且 props.selectedText 变更时,更新原文并重置状态
watch(
() => props.selectedText,
(val) => {
if (dialogVisible.value) {
currentText.value = val
resetState()
}
},
)
/* -------------------- prompt handlers -------------------- */
function addPrompt(e: KeyboardEvent) {
const input = e.target as HTMLInputElement
const prompt = input.value.trim()
if (prompt && !customPrompts.value.includes(prompt)) {
customPrompts.value.push(prompt)
}
input.value = ``
}
function removePrompt(index: number) {
customPrompts.value.splice(index, 1)
}
function resetState() {
message.value = ``
loading.value = false
hasResult.value = false
error.value = ``
abortController.value?.abort()
abortController.value = null
}
/* -------------------- AI call -------------------- */
async function runAIAction() {
const text = currentText.value.trim()
if (!text || loading.value)
return
resetState()
loading.value = true
abortController.value = new AbortController()
const systemPrompt
= `你是一名专业的多语言文本助手,请根据用户的指令处理下列内容。在输出时,不要输出任何额外的信息,只输出处理后的文本。`
const picked = actionOptions.find(o => o.value === selectedAction.value)!
const parts: string[] = []
if (picked.defaultPrompt)
parts.push(picked.defaultPrompt)
if (customPrompts.value.length)
parts.push(`请同时满足以下要求:${customPrompts.value.join(`、`)}。`)
if (!parts.length)
parts.push(`请根据最佳实践优化文本。`)
const userCommand = parts.join(` `)
const messages = [
{ role: `system`, content: systemPrompt },
{ role: `user`, content: `${userCommand}\n\n待处理文本:\n${text}` },
]
const payload = {
model: model.value,
messages,
temperature: temperature.value,
max_tokens: maxToken.value,
stream: true,
}
const headers: Record<string, string> = {
'Content-Type': `application/json`,
}
if (apiKey.value && type.value !== `default`) {
headers.Authorization = `Bearer ${apiKey.value}`
}
try {
const url = new URL(endpoint.value)
if (!url.pathname.endsWith(`/chat/completions`)) {
url.pathname = url.pathname.replace(/\/?$/, `/chat/completions`)
}
const res = await window.fetch(url.toString(), {
method: `POST`,
headers,
body: JSON.stringify(payload),
signal: abortController.value!.signal,
})
if (!res.ok || !res.body)
throw new Error(`响应错误:${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder(`utf-8`)
let buffer = ``
while (true) {
const { value, done } = await reader.read()
if (done)
break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split(`\n`)
buffer = lines.pop() || ``
for (const line of lines) {
if (!line.trim() || line.trim() === `data: [DONE]`)
continue
try {
const json = JSON.parse(line.replace(/^data: /, ``))
const delta = json.choices?.[0]?.delta?.content
if (delta?.trim()) {
message.value += delta
hasResult.value = true
}
}
catch {}
}
}
}
catch (e: any) {
if (e.name === `AbortError`) {
console.log(`Request aborted by user.`)
}
else {
console.error(`请求失败:`, e)
error.value = e.message || `请求失败`
}
}
finally {
loading.value = false
}
}
/* -------------------- abort handler -------------------- */
function stopAI() {
if (loading.value && abortController.value) {
abortController.value.abort()
loading.value = false
}
}
/* -------------------- actions -------------------- */
function replaceText() {
const editorView = toRaw(editorStore.editor!)!
const selection = editorView.state.selection.main
editorView.dispatch(editorView.state.replaceSelection(message.value))
// 选中替换后的文本
const newSelection = editorView.state.selection.main
editorView.dispatch({
selection: { anchor: selection.from, head: newSelection.head },
})
editorView.focus()
currentText.value = message.value
resetState()
}
function show() {
dialogVisible.value = true
}
function close() {
dialogVisible.value = false
customPrompts.value = []
selectedAction.value = `optimize`
resetState()
}
defineExpose({ dialogVisible, runAIAction, replaceText, show, close, stopAI })
</script>
<template>
<Dialog v-model:open="dialogVisible">
<DialogContent
class="bg-card text-card-foreground flex flex-col w-[95vw] max-h-[90vh] sm:max-h-[85vh] sm:max-w-2xl overflow-hidden p-0"
>
<!-- ============ 头部 ============ -->
<DialogHeader class="space-y-1 flex flex-col items-start px-6 pt-6 pb-4">
<div class="space-x-1 flex items-center">
<DialogTitle>AI 工具箱</DialogTitle>
<Button
:title="configVisible ? 'AI 工具箱' : '配置参数'"
:aria-label="configVisible ? 'AI 工具箱' : '配置参数'"
variant="ghost"
size="icon"
@click="configVisible = !configVisible"
>
<Wand2 v-if="configVisible" class="h-4 w-4" />
<Settings v-else class="h-4 w-4" />
</Button>
</div>
</DialogHeader>
<!-- ============ 内容区域 ============ -->
<!-- config panel -->
<AIConfig
v-if="configVisible"
class="border-border mx-6 mb-4 w-auto border rounded-md p-4"
@saved="() => (configVisible = false)"
/>
<!-- main content -->
<div v-else class="custom-scroll space-y-3 flex-1 overflow-y-auto px-6 pb-3">
<!-- action selector -->
<div>
<div class="mb-1.5 text-sm font-medium">
选择操作
</div>
<Select v-model="selectedAction">
<SelectTrigger class="w-full">
<SelectValue placeholder="请选择要执行的操作" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="opt in actionOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<!-- original text -->
<div>
<div class="mb-1.5 text-sm font-medium">
原文
</div>
<div
class="border-border custom-scroll bg-muted/20 text-muted-foreground max-h-32 overflow-y-auto whitespace-pre-line border rounded px-3 py-2 text-sm"
>
{{ currentText }}
</div>
</div>
<!-- custom prompts -->
<div v-if="selectedAction === 'custom'">
<div class="mb-1.5 text-sm font-medium">
自定义提示词(可选)
</div>
<div
class="custom-scroll border-border max-h-24 min-h-[40px] flex flex-wrap gap-2 overflow-y-auto border rounded px-2 py-1"
>
<template v-for="(prompt, index) in customPrompts" :key="index">
<div
class="text-muted-foreground bg-muted flex items-center gap-1 rounded-full px-2 py-1 text-sm"
>
<span>{{ prompt }}</span>
<button
class="hover:bg-muted/60 h-4 w-4 flex items-center justify-center rounded-full"
@click="removePrompt(index)"
>
<X class="h-3 w-3" />
</button>
</div>
</template>
<input
class="min-w-[100px] flex-1 bg-transparent py-1 text-sm focus:outline-hidden"
placeholder="输入提示词后按回车"
@keydown.enter="addPrompt"
>
</div>
</div>
<!-- error -->
<div v-if="error" class="min-h-[20px] flex items-center text-xs text-red-500">
{{ error }}
</div>
<!-- result -->
<div v-if="message">
<div class="mb-1.5 text-sm font-medium">
处理结果
</div>
<div
ref="resultContainer"
class="custom-scroll border-border bg-background max-h-40 min-h-[60px] overflow-y-auto whitespace-pre-line border rounded px-3 py-2 text-sm"
>
{{ message }}
</div>
</div>
</div>
<!-- ============ 底部按钮 ============ -->
<div v-if="!configVisible" class="flex justify-end gap-2 px-6 py-3.5 mt-auto">
<Button v-if="loading" variant="secondary" @click="stopAI">
<Pause class="mr-1 h-4 w-4" /> 终止
</Button>
<Button
v-if="hasResult && !loading"
variant="default"
@click="replaceText"
>
接受
</Button>
<Button
v-if="!loading"
variant="outline"
:disabled="!hasResult && !!message"
@click="runAIAction"
>
{{ hasResult ? '重试' : 'AI 处理' }}
</Button>
</div>
</DialogContent>
</Dialog>
</template>
<style scoped>
.custom-scroll::-webkit-scrollbar {
width: 6px;
}
.custom-scroll::-webkit-scrollbar-thumb {
/* Tailwind @apply in <style> needs explicit classes when using <style scoped> */
background-color: rgba(156, 163, 175, 0.4);
border-radius: 9999px;
}
.custom-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.6);
}
.custom-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.4) transparent;
}
:deep(.dark) .custom-scroll {
scrollbar-color: rgba(107, 114, 128, 0.4) transparent;
}
@media (pointer: coarse) {
.custom-scroll::-webkit-scrollbar {
width: 3px;
}
}
</style>
================================================
FILE: apps/web/src/components/ai/tool-box/index.ts
================================================
import type { EditorView } from '@codemirror/view'
import AIPolishPopover from './ToolBoxPopover.vue'
/* ---------- 简化的组合式函数 ---------- */
function useAIPolish() {
// 现在工具箱已移到侧边栏,不再需要复杂的位置计算和事件监听
// 保留最基本的功能以维持兼容性
const selectedText = ref(``)
// 获取当前编辑器选中文本的简单函数
function getCurrentSelection(editor: EditorView | null): string {
try {
if (!editor)
return ``
const selection = editor.state.selection.main
return editor.state.doc.sliceString(selection.from, selection.to).trim()
}
catch {
return ``
}
}
/* =============== 简化的对外导出 =============== */
return {
selectedText,
getCurrentSelection,
}
}
export { AIPolishPopover, useAIPolish }
================================================
FILE: apps/web/src/components/editor/CssEditor.vue
================================================
<script setup lang="ts">
import { exportMergedTheme } from '@md/core'
import { themeMap, themeOptionsMap } from '@md/shared'
import { Download, Edit3, Eye, Plus, X } from 'lucide-vue-next'
import { useCssEditorStore } from '@/stores/cssEditor'
import { useEditorStore } from '@/stores/editor'
import { useRenderStore } from '@/stores/render'
import { useThemeStore } from '@/stores/theme'
import { useUIStore } from '@/stores/ui'
import { copyPlain } from '@/utils/clipboard'
const cssEditorStore = useCssEditorStore()
const uiStore = useUIStore()
const renderStore = useRenderStore()
const editorStore = useEditorStore()
const themeStore = useThemeStore()
const { isMobile } = storeToRefs(uiStore)
const { cssContentConfig } = storeToRefs(cssEditorStore)
// 控制是否启用动画
const enableAnimation = ref(false)
// 监听 CssEditor 开关状态变化
watch(() => uiStore.isShowCssEditor, () => {
if (isMobile.value) {
// 在移动端,用户操作时启用动画
enableAnimation.value = true
}
})
// 监听设备类型变化,重置动画状态
watch(() => isMobile.value, () => {
enableAnimation.value = false
})
const isOpenEditDialog = ref(false)
const editInputVal = ref(``)
// 滚动到活跃的 tab
async function scrollToActiveTab() {
await nextTick()
// 使用 data-state="active" 查找活跃的 tab
const activeTab = document.querySelector('[role="tab"][data-state="active"]')
if (activeTab) {
activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
}
}
function rename(name: string) {
editInputVal.value = name
isOpenEditDialog.value = true
}
function editTabName() {
if (!(editInputVal.value).trim()) {
toast.error(`新建失败,方案名不可为空`)
return
}
if (!cssEditorStore.validatorTabName(editInputVal.value)) {
toast.error(`不能与现有方案重名`)
return
}
cssEditorStore.renameTab(editInputVal.value)
isOpenEditDialog.value = false
toast.success(`修改成功`)
}
const isOpenAddDialog = ref(false)
const addInputVal = ref(``)
// 新建方案时选择的基础主题
const baseThemeForNew = ref<'blank' | 'default' | 'grace' | 'simple'>('blank')
async function addTab() {
if (!(addInputVal.value).trim()) {
toast.error(`新建失败,方案名不可为空`)
return
}
if (!cssEditorStore.validatorTabName(addInputVal.value)) {
toast.error(`不能与现有方案重名`)
return
}
// 根据选择的基础主题来初始化内容
let initialContent = ''
if (baseThemeForNew.value === 'blank') {
initialContent = '' // 空白方案
}
else {
// 基于内置主题
initialContent = themeMap[baseThemeForNew.value]
}
const newTabName = addInputVal.value
// addCssContentTab 会自动设置 active 并触发回调
cssEditorStore.addCssContentTab(newTabName, initialContent)
isOpenAddDialog.value = false
toast.success(`新建成功`)
// 重置为空白
baseThemeForNew.value = 'blank'
// 滚动到新创建的 tab
scroll
gitextract_apmp53u3/ ├── .editorconfig ├── .github/ │ ├── copilot-instructions.md │ ├── dependabot.yml │ ├── secret_scanning.yml │ └── workflows/ │ ├── cloudflare-preview-cleanup.yml │ ├── cloudflare-preview.yml │ ├── deploy-gitee.yml │ ├── deploy.yml │ ├── docker.yml │ ├── release-cli.yml │ ├── release.yml │ ├── stale-bot.yml │ ├── surge-preview-build.yml │ └── surge-preview-deploy.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .npmrc ├── .nvmrc ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── USERS.md ├── apps/ │ ├── utools/ │ │ ├── README.md │ │ ├── package.json │ │ ├── plugin.json │ │ └── preload.js │ ├── vscode/ │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── .vscodeignore │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── css/ │ │ │ │ └── index.ts │ │ │ ├── extension.ts │ │ │ ├── styleChoices.ts │ │ │ └── treeDataProvider.ts │ │ ├── tsconfig.json │ │ └── webpack.config.mjs │ └── web/ │ ├── components.json │ ├── index.html │ ├── netlify.toml │ ├── package.json │ ├── plugins/ │ │ └── vite-plugin-utools-local-assets.ts │ ├── postcss.config.js │ ├── public/ │ │ └── upload/ │ │ └── .gitkeep │ ├── src/ │ │ ├── App.vue │ │ ├── assets/ │ │ │ ├── example/ │ │ │ │ └── markdown.md │ │ │ ├── index.css │ │ │ └── less/ │ │ │ ├── app.less │ │ │ └── theme.less │ │ ├── components/ │ │ │ ├── AppSplash.vue │ │ │ ├── ai/ │ │ │ │ ├── SidebarAIToolbar.vue │ │ │ │ ├── chat-box/ │ │ │ │ │ ├── AIAssistantPanel.vue │ │ │ │ │ ├── AIConfig.vue │ │ │ │ │ └── QuickCommandManager.vue │ │ │ │ ├── image-generator/ │ │ │ │ │ ├── AIImageConfig.vue │ │ │ │ │ ├── AIImageGeneratorPanel.vue │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ └── tool-box/ │ │ │ │ ├── ToolBoxPopover.vue │ │ │ │ └── index.ts │ │ │ ├── editor/ │ │ │ │ ├── CssEditor.vue │ │ │ │ ├── CustomUploadForm.vue │ │ │ │ ├── EditorContextMenu.vue │ │ │ │ ├── EditorStateDialog.vue │ │ │ │ ├── FloatingToc.vue │ │ │ │ ├── FolderSourcePanel.vue │ │ │ │ ├── FolderTree.vue │ │ │ │ ├── Footer.vue │ │ │ │ ├── FormItem.vue │ │ │ │ ├── ImportMarkdownDialog.vue │ │ │ │ ├── InsertFormDialog.vue │ │ │ │ ├── InsertMpCardDialog.vue │ │ │ │ ├── RightSlider.vue │ │ │ │ ├── TemplateDialog.vue │ │ │ │ ├── ThemeCustomizer.vue │ │ │ │ ├── UploadImgDialog.vue │ │ │ │ ├── editor-header/ │ │ │ │ │ ├── AboutDialog.vue │ │ │ │ │ ├── EditDropdown.vue │ │ │ │ │ ├── FileDropdown.vue │ │ │ │ │ ├── FormatDropdown.vue │ │ │ │ │ ├── FundDialog.vue │ │ │ │ │ ├── HelpDropdown.vue │ │ │ │ │ ├── InsertDropdown.vue │ │ │ │ │ ├── PostInfo.vue │ │ │ │ │ ├── PostTaskDialog.vue │ │ │ │ │ ├── StyleDropdown.vue │ │ │ │ │ ├── StyleOptionMenu.vue │ │ │ │ │ ├── ViewDropdown.vue │ │ │ │ │ └── index.vue │ │ │ │ └── post-slider/ │ │ │ │ ├── PostItem.vue │ │ │ │ └── index.vue │ │ │ └── ui/ │ │ │ ├── alert/ │ │ │ │ ├── Alert.vue │ │ │ │ ├── AlertDescription.vue │ │ │ │ ├── AlertTitle.vue │ │ │ │ └── index.ts │ │ │ ├── alert-dialog/ │ │ │ │ ├── AlertDialog.vue │ │ │ │ ├── AlertDialogAction.vue │ │ │ │ ├── AlertDialogCancel.vue │ │ │ │ ├── AlertDialogContent.vue │ │ │ │ ├── AlertDialogDescription.vue │ │ │ │ ├── AlertDialogFooter.vue │ │ │ │ ├── AlertDialogHeader.vue │ │ │ │ ├── AlertDialogTitle.vue │ │ │ │ ├── AlertDialogTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── back-top/ │ │ │ │ ├── BackTop.vue │ │ │ │ └── index.ts │ │ │ ├── button/ │ │ │ │ ├── Button.vue │ │ │ │ └── index.ts │ │ │ ├── context-menu/ │ │ │ │ ├── ContextMenu.vue │ │ │ │ ├── ContextMenuCheckboxItem.vue │ │ │ │ ├── ContextMenuContent.vue │ │ │ │ ├── ContextMenuGroup.vue │ │ │ │ ├── ContextMenuItem.vue │ │ │ │ ├── ContextMenuLabel.vue │ │ │ │ ├── ContextMenuPortal.vue │ │ │ │ ├── ContextMenuRadioGroup.vue │ │ │ │ ├── ContextMenuRadioItem.vue │ │ │ │ ├── ContextMenuSeparator.vue │ │ │ │ ├── ContextMenuShortcut.vue │ │ │ │ ├── ContextMenuSub.vue │ │ │ │ ├── ContextMenuSubContent.vue │ │ │ │ ├── ContextMenuSubTrigger.vue │ │ │ │ ├── ContextMenuTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── dialog/ │ │ │ │ ├── Dialog.vue │ │ │ │ ├── DialogClose.vue │ │ │ │ ├── DialogContent.vue │ │ │ │ ├── DialogDescription.vue │ │ │ │ ├── DialogFooter.vue │ │ │ │ ├── DialogHeader.vue │ │ │ │ ├── DialogScrollContent.vue │ │ │ │ ├── DialogTitle.vue │ │ │ │ ├── DialogTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── dropdown-menu/ │ │ │ │ ├── DropdownMenu.vue │ │ │ │ ├── DropdownMenuCheckboxItem.vue │ │ │ │ ├── DropdownMenuContent.vue │ │ │ │ ├── DropdownMenuGroup.vue │ │ │ │ ├── DropdownMenuItem.vue │ │ │ │ ├── DropdownMenuLabel.vue │ │ │ │ ├── DropdownMenuRadioGroup.vue │ │ │ │ ├── DropdownMenuRadioItem.vue │ │ │ │ ├── DropdownMenuSeparator.vue │ │ │ │ ├── DropdownMenuShortcut.vue │ │ │ │ ├── DropdownMenuSub.vue │ │ │ │ ├── DropdownMenuSubContent.vue │ │ │ │ ├── DropdownMenuSubTrigger.vue │ │ │ │ ├── DropdownMenuTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── hover-card/ │ │ │ │ ├── HoverCard.vue │ │ │ │ ├── HoverCardContent.vue │ │ │ │ ├── HoverCardTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── input/ │ │ │ │ ├── Input.vue │ │ │ │ └── index.ts │ │ │ ├── label/ │ │ │ │ ├── Label.vue │ │ │ │ └── index.ts │ │ │ ├── menubar/ │ │ │ │ ├── Menubar.vue │ │ │ │ ├── MenubarCheckboxItem.vue │ │ │ │ ├── MenubarContent.vue │ │ │ │ ├── MenubarGroup.vue │ │ │ │ ├── MenubarItem.vue │ │ │ │ ├── MenubarLabel.vue │ │ │ │ ├── MenubarMenu.vue │ │ │ │ ├── MenubarRadioGroup.vue │ │ │ │ ├── MenubarRadioItem.vue │ │ │ │ ├── MenubarSeparator.vue │ │ │ │ ├── MenubarShortcut.vue │ │ │ │ ├── MenubarSub.vue │ │ │ │ ├── MenubarSubContent.vue │ │ │ │ ├── MenubarSubTrigger.vue │ │ │ │ ├── MenubarTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── number-field/ │ │ │ │ ├── NumberField.vue │ │ │ │ ├── NumberFieldContent.vue │ │ │ │ ├── NumberFieldDecrement.vue │ │ │ │ ├── NumberFieldIncrement.vue │ │ │ │ ├── NumberFieldInput.vue │ │ │ │ └── index.ts │ │ │ ├── password-input/ │ │ │ │ ├── PasswordInput.vue │ │ │ │ └── index.ts │ │ │ ├── popover/ │ │ │ │ ├── Popover.vue │ │ │ │ ├── PopoverContent.vue │ │ │ │ ├── PopoverTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── progress/ │ │ │ │ ├── Progress.vue │ │ │ │ └── index.ts │ │ │ ├── radio-group/ │ │ │ │ ├── RadioGroup.vue │ │ │ │ ├── RadioGroupItem.vue │ │ │ │ └── index.ts │ │ │ ├── resizable/ │ │ │ │ ├── ResizableHandle.vue │ │ │ │ ├── ResizablePanelGroup.vue │ │ │ │ └── index.ts │ │ │ ├── search-tab/ │ │ │ │ ├── SearchTab.vue │ │ │ │ └── index.ts │ │ │ ├── select/ │ │ │ │ ├── Select.vue │ │ │ │ ├── SelectContent.vue │ │ │ │ ├── SelectGroup.vue │ │ │ │ ├── SelectItem.vue │ │ │ │ ├── SelectItemText.vue │ │ │ │ ├── SelectLabel.vue │ │ │ │ ├── SelectScrollDownButton.vue │ │ │ │ ├── SelectScrollUpButton.vue │ │ │ │ ├── SelectSeparator.vue │ │ │ │ ├── SelectTrigger.vue │ │ │ │ ├── SelectValue.vue │ │ │ │ └── index.ts │ │ │ ├── separator/ │ │ │ │ ├── Separator.vue │ │ │ │ └── index.ts │ │ │ ├── sonner/ │ │ │ │ ├── Sonner.vue │ │ │ │ └── index.ts │ │ │ ├── switch/ │ │ │ │ ├── Switch.vue │ │ │ │ └── index.ts │ │ │ ├── tabs/ │ │ │ │ ├── Tabs.vue │ │ │ │ ├── TabsContent.vue │ │ │ │ ├── TabsList.vue │ │ │ │ ├── TabsTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── textarea/ │ │ │ │ ├── Textarea.vue │ │ │ │ └── index.ts │ │ │ └── tooltip/ │ │ │ ├── Tooltip.vue │ │ │ ├── TooltipContent.vue │ │ │ ├── TooltipProvider.vue │ │ │ ├── TooltipTrigger.vue │ │ │ └── index.ts │ │ ├── composables/ │ │ │ ├── index.ts │ │ │ ├── useEditorFormat.ts │ │ │ ├── useFolderFileSync.ts │ │ │ └── useImageUploader.ts │ │ ├── entrypoints/ │ │ │ ├── appmsg.content.ts │ │ │ ├── background.ts │ │ │ ├── injected.ts │ │ │ └── popup/ │ │ │ ├── App.vue │ │ │ ├── index.html │ │ │ └── popup.ts │ │ ├── lib/ │ │ │ └── utils.ts │ │ ├── main.ts │ │ ├── modules/ │ │ │ └── build-extension.ts │ │ ├── sidepanel.ts │ │ ├── stores/ │ │ │ ├── aiConfig.ts │ │ │ ├── aiImageConfig.ts │ │ │ ├── cssEditor.ts │ │ │ ├── editor.ts │ │ │ ├── export.ts │ │ │ ├── folderSource.ts │ │ │ ├── post.ts │ │ │ ├── quickCommands.ts │ │ │ ├── render.ts │ │ │ ├── template.ts │ │ │ ├── theme.ts │ │ │ └── ui.ts │ │ ├── types/ │ │ │ └── global.d.ts │ │ ├── utils/ │ │ │ ├── clipboard.ts │ │ │ ├── file.ts │ │ │ ├── index.ts │ │ │ ├── setup-components.ts │ │ │ ├── storage.ts │ │ │ └── toast/ │ │ │ └── index.ts │ │ ├── views/ │ │ │ └── CodemirrorEditor.vue │ │ └── vite-env.d.ts │ ├── tailwind.config.cjs │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── tsconfig.worker.json │ ├── vite.config.ts │ ├── web-ext.config.ts.example │ ├── worker/ │ │ └── index.ts │ ├── wrangler.jsonc │ └── wxt.config.ts ├── docker/ │ └── latest/ │ ├── Dockerfile.base │ ├── Dockerfile.nginx │ ├── Dockerfile.standalone │ ├── Dockerfile.static │ └── server/ │ └── main.go ├── docs/ │ ├── custom-upload.md │ ├── mp-card.md │ └── telegram-usage.md ├── eslint.config.mjs ├── package.json ├── packages/ │ ├── config/ │ │ ├── package.json │ │ ├── tsconfig.base.json │ │ └── tsconfig.node.json │ ├── core/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── extensions/ │ │ │ │ ├── alert.ts │ │ │ │ ├── footnotes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── infographic.ts │ │ │ │ ├── katex.ts │ │ │ │ ├── markup.ts │ │ │ │ ├── mermaid.ts │ │ │ │ ├── plantuml.ts │ │ │ │ ├── ruby.ts │ │ │ │ ├── slider.ts │ │ │ │ └── toc.ts │ │ │ ├── index.ts │ │ │ ├── renderer/ │ │ │ │ ├── index.ts │ │ │ │ └── renderer-impl.ts │ │ │ ├── theme/ │ │ │ │ ├── cssProcessor.ts │ │ │ │ ├── cssScopeWrapper.ts │ │ │ │ ├── cssVariables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── selectorMapping.ts │ │ │ │ ├── themeApplicator.ts │ │ │ │ ├── themeExporter.ts │ │ │ │ └── themeInjector.ts │ │ │ └── utils/ │ │ │ ├── basicHelpers.ts │ │ │ ├── index.ts │ │ │ ├── initializeMermaid.ts │ │ │ ├── languages.ts │ │ │ └── markdownHelpers.ts │ │ └── tsconfig.json │ ├── example/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── worker.js │ │ └── wrangler.toml │ ├── md-cli/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── .npmrc │ │ ├── README.md │ │ ├── index.js │ │ ├── package.json │ │ ├── public/ │ │ │ └── upload/ │ │ │ └── .gitkeep │ │ ├── server.js │ │ └── util.js │ └── shared/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── assets/ │ │ │ ├── default-custom-theme.txt │ │ │ └── index.ts │ │ ├── configs/ │ │ │ ├── ai-service-options.ts │ │ │ ├── api.ts │ │ │ ├── index.ts │ │ │ ├── prefix.ts │ │ │ ├── shortcut-key.ts │ │ │ ├── store.ts │ │ │ ├── style.ts │ │ │ ├── theme-css/ │ │ │ │ ├── base.css │ │ │ │ ├── default.css │ │ │ │ ├── grace.css │ │ │ │ ├── index.ts │ │ │ │ └── simple.css │ │ │ └── theme.ts │ │ ├── constants/ │ │ │ ├── ai-config.ts │ │ │ └── index.ts │ │ ├── editor/ │ │ │ ├── basicSetup.ts │ │ │ ├── css.ts │ │ │ ├── format.ts │ │ │ ├── index.ts │ │ │ ├── javascript.ts │ │ │ ├── markdown.ts │ │ │ └── themes.ts │ │ ├── global.d.ts │ │ ├── index.ts │ │ ├── types/ │ │ │ ├── ai-services-types.ts │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── raw-imports.d.ts │ │ │ ├── renderer-types.ts │ │ │ └── template.ts │ │ └── utils/ │ │ ├── basicHelpers.ts │ │ ├── fetch.ts │ │ ├── fileHelpers.ts │ │ ├── index.ts │ │ ├── readingTime.ts │ │ └── tokenTools.ts │ └── tsconfig.json ├── patches/ │ └── @codemirror__view@6.40.0.patch ├── pnpm-workspace.yaml ├── scripts/ │ ├── build-base-image.sh │ ├── build-multiarch.sh │ ├── build-nginx.sh │ ├── build-standalone.sh │ ├── build-static.sh │ ├── download-utools-libs.mjs │ ├── package-utools.mjs │ ├── push-images.sh │ └── release.js ├── tsconfig.json └── zbpack.json
SYMBOL INDEX (340 symbols across 78 files)
FILE: apps/vscode/src/extension.ts
function activate (line 12) | function activate(context: vscode.ExtensionContext) {
function wrapHtmlTag (line 127) | function wrapHtmlTag(html: string, css: string) {
FILE: apps/vscode/src/treeDataProvider.ts
class MarkdownTreeDataProvider (line 5) | class MarkdownTreeDataProvider implements vscode.TreeDataProvider<vscode...
method constructor (line 16) | constructor(context: vscode.ExtensionContext) {
method getTreeItem (line 26) | getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
method updateCountStatus (line 30) | updateCountStatus(status: boolean): void {
method updateMacCodeBlock (line 36) | updateMacCodeBlock(status: boolean): void {
method getCurrentMacCodeBlock (line 42) | getCurrentMacCodeBlock(): boolean {
method getCurrentCountStatus (line 46) | getCurrentCountStatus(): boolean {
method getChildren (line 50) | getChildren(element?: vscode.TreeItem): Thenable<vscode.TreeItem[]> {
method updateFontSize (line 154) | updateFontSize(size: string) {
method updateTheme (line 160) | updateTheme(theme: ThemeName) {
method updatePrimaryColor (line 166) | updatePrimaryColor(color: string) {
method updateFontFamily (line 172) | updateFontFamily(font: string) {
method getCurrentFontSize (line 178) | getCurrentFontSize() {
method getCurrentFontSizeNumber (line 182) | getCurrentFontSizeNumber() {
method getCurrentTheme (line 186) | getCurrentTheme(): ThemeName {
method getCurrentPrimaryColor (line 190) | getCurrentPrimaryColor() {
method getCurrentFontFamily (line 194) | getCurrentFontFamily() {
FILE: apps/vscode/webpack.config.mjs
function config (line 13) | function config() {
FILE: apps/web/plugins/vite-plugin-utools-local-assets.ts
function utoolsLocalAssetsPlugin (line 7) | function utoolsLocalAssetsPlugin(): Plugin {
FILE: apps/web/src/components/ai/tool-box/index.ts
function useAIPolish (line 5) | function useAIPolish() {
FILE: apps/web/src/components/ui/alert/index.ts
type AlertVariants (line 24) | type AlertVariants = VariantProps<typeof alertVariants>
FILE: apps/web/src/components/ui/button/index.ts
type ButtonVariants (line 36) | type ButtonVariants = VariantProps<typeof buttonVariants>
FILE: apps/web/src/composables/useEditorFormat.ts
function useEditorFormat (line 19) | function useEditorFormat<T extends { value: any }>(editor: T) {
FILE: apps/web/src/composables/useFolderFileSync.ts
function useFolderFileSync (line 8) | function useFolderFileSync() {
FILE: apps/web/src/composables/useImageUploader.ts
constant STORAGE_KEY (line 6) | const STORAGE_KEY = 'uploaded_image_map'
function useImageUploader (line 8) | function useImageUploader() {
FILE: apps/web/src/entrypoints/appmsg.content.ts
method main (line 5) | async main() {
FILE: apps/web/src/entrypoints/background.ts
method main (line 5) | main() {
FILE: apps/web/src/lib/utils.ts
function cn (line 5) | function cn(...inputs: ClassValue[]) {
FILE: apps/web/src/modules/build-extension.ts
type AddedViteConfig (line 13) | type AddedViteConfig = ReturnType<Parameters<typeof addViteConfig>[1]>
type AddedVitePlugins (line 14) | type AddedVitePlugins = NonNullable<NonNullable<AddedViteConfig>['plugin...
method setup (line 17) | async setup(wxt) {
constant SCRIPT_FILE_NAME_REGEX (line 71) | const SCRIPT_FILE_NAME_REGEX = /\/([^/]+)\.js$/
function isDefined (line 73) | function isDefined<T>(value: T | undefined): value is T {
function toWxtPluginOptions (line 77) | function toWxtPluginOptions(
function htmlScriptToVirtual (line 83) | function htmlScriptToVirtual(
function htmlScriptToLocal (line 163) | function htmlScriptToLocal(
function vueDevtoolsHack (line 234) | function vueDevtoolsHack(
function doFetch (line 262) | async function doFetch(
FILE: apps/web/src/sidepanel.ts
type Tab (line 7) | interface Tab {
function getCurrentTab (line 19) | async function getCurrentTab() {
FILE: apps/web/src/stores/aiConfig.ts
method get (line 46) | get() {
method set (line 50) | set(val: string) {
FILE: apps/web/src/stores/aiImageConfig.ts
method get (line 46) | get() {
method set (line 50) | set(val: string) {
method get (line 76) | get() {
method set (line 80) | set(val: string) {
FILE: apps/web/src/stores/cssEditor.ts
constant DEFAULT_CSS_CONTENT (line 8) | const DEFAULT_CSS_CONTENT = DEFAULT_CUSTOM_THEME
type CssContentConfig (line 13) | interface CssContentConfig {
FILE: apps/web/src/stores/folderSource.ts
type FileSystemNode (line 4) | interface FileSystemNode {
type RuntimeFolderInfo (line 15) | interface RuntimeFolderInfo {
function selectFolder (line 83) | async function selectFolder() {
function closeFolder (line 148) | function closeFolder() {
function removeFolder (line 157) | function removeFolder(folderId: string) {
function loadFileTree (line 169) | async function loadFileTree(handle: FileSystemDirectoryHandle): Promise<...
function buildFileTree (line 183) | async function buildFileTree(
function readFile (line 234) | async function readFile(filePath: string): Promise<string> {
function writeFile (line 264) | async function writeFile(filePath: string, content: string): Promise<voi...
function findNodeByPath (line 304) | function findNodeByPath(nodes: FileSystemNode[], path: string): FileSyst...
function getAllMarkdownFiles (line 321) | function getAllMarkdownFiles(nodes: FileSystemNode[] = fileTree.value): ...
function generateFolderId (line 337) | function generateFolderId(): string {
FILE: apps/web/src/stores/post.ts
type Post (line 9) | interface Post {
FILE: apps/web/src/stores/quickCommands.ts
type QuickCommandPersisted (line 4) | interface QuickCommandPersisted {
type QuickCommandRuntime (line 10) | interface QuickCommandRuntime extends QuickCommandPersisted {
constant STORAGE_KEY (line 14) | const STORAGE_KEY = `quick_commands`
function hydrate (line 17) | function hydrate(cmd: QuickCommandPersisted): QuickCommandRuntime {
constant DEFAULT_COMMANDS (line 26) | const DEFAULT_COMMANDS: QuickCommandPersisted[] = [
function save (line 38) | async function save() {
function load (line 45) | async function load() {
function add (line 65) | function add(label: string, template: string) {
function update (line 70) | function update(id: string, label: string, template: string) {
function remove (line 76) | function remove(id: string) {
FILE: apps/web/src/stores/template.ts
function createTemplate (line 28) | function createTemplate(params: CreateTemplateParams): Template {
function getTemplateById (line 48) | function getTemplateById(id: string): Template | undefined {
function updateTemplate (line 55) | function updateTemplate(id: string, params: UpdateTemplateParams): boole...
function deleteTemplate (line 75) | function deleteTemplate(id: string): boolean {
function searchTemplates (line 91) | function searchTemplates(keyword: string): Template[] {
function deleteTemplates (line 109) | function deleteTemplates(ids: string[]): number {
function clearAllTemplates (line 129) | function clearAllTemplates(): void {
function exportTemplates (line 138) | function exportTemplates(): string {
function importTemplates (line 145) | function importTemplates(jsonData: string): boolean {
FILE: apps/web/src/stores/ui.ts
function toggleAIDialog (line 83) | function toggleAIDialog(value?: boolean) {
function toggleAIImageDialog (line 87) | function toggleAIImageDialog(value?: boolean) {
function openSearchTab (line 94) | function openSearchTab(searchWord: string = '', showReplace: boolean = f...
function clearSearchTabRequest (line 98) | function clearSearchTabRequest() {
function handleResize (line 104) | function handleResize() {
FILE: apps/web/src/types/global.d.ts
type Window (line 1) | interface Window {
type FileSystemDirectoryHandle (line 19) | interface FileSystemDirectoryHandle {
FILE: apps/web/src/utils/clipboard.ts
function legacyCopy (line 1) | function legacyCopy(text: string): Promise<void> {
function copyPlain (line 23) | async function copyPlain(text: string): Promise<void> {
function copyHtml (line 35) | async function copyHtml(html: string, fallback?: string): Promise<void> {
FILE: apps/web/src/utils/file.ts
function getConfig (line 14) | async function getConfig(useDefault: boolean, platform: string) {
function getDir (line 53) | function getDir() {
function getDateFilename (line 66) | function getDateFilename(filename: string) {
function ghFileUpload (line 77) | async function ghFileUpload(content: string, filename: string) {
function giteeUpload (line 122) | async function giteeUpload(content: any, filename: string) {
function getQiniuToken (line 157) | function getQiniuToken(accessKey: string, secretKey: string, putPolicy: {
function qiniuUpload (line 168) | async function qiniuUpload(file: File) {
function aliOSSFileUpload (line 197) | async function aliOSSFileUpload(file: File) {
function txCOSFileUpload (line 261) | async function txCOSFileUpload(file: File) {
function minioFileUpload (line 326) | async function minioFileUpload(file: File) {
constant PROTOCOL_REGEX (line 360) | const PROTOCOL_REGEX = /^https?:\/\//
function s3Upload (line 362) | async function s3Upload(file: File) {
type MpResponse (line 441) | interface MpResponse {
function getMpToken (line 447) | async function getMpToken(appID: string, appsecret: string, proxyOrigin:...
function mpFileUpload (line 481) | async function mpFileUpload(file: File) {
function r2Upload (line 529) | async function r2Upload(file: File) {
function upyunUpload (line 554) | async function upyunUpload(file: File) {
function telegramUpload (line 587) | async function telegramUpload(file: File): Promise<string> {
function cloudinaryUpload (line 645) | async function cloudinaryUpload(file: File): Promise<string> {
function formCustomUpload (line 713) | async function formCustomUpload(content: string, file: File) {
function fileUpload (line 754) | async function fileUpload(content: string, file: File) {
FILE: apps/web/src/utils/index.ts
function addPrefix (line 41) | function addPrefix(str: string) {
function downloadMD (line 50) | function downloadMD(doc: string, title: string = `untitled`) {
function getHtmlContent (line 59) | function getHtmlContent(): string {
function exportHTML (line 67) | async function exportHTML(title: string = `untitled`) {
function generatePureHTML (line 93) | async function generatePureHTML(raw: string): Promise<string> {
function exportPureHTML (line 108) | async function exportPureHTML(raw: string, title: string = `untitled`) {
function exportPDF (line 120) | async function exportPDF(title: string = `untitled`) {
function solveWeChatImage (line 192) | function solveWeChatImage() {
function getHljsStyles (line 214) | async function getHljsStyles(): Promise<string> {
function getThemeStyles (line 230) | function getThemeStyles(): string {
function mergeCss (line 254) | function mergeCss(html: string): string {
function modifyHtmlStructure (line 264) | function modifyHtmlStructure(htmlString: string): string {
function createEmptyNode (line 276) | function createEmptyNode(): HTMLElement {
function getStylesToAdd (line 289) | async function getStylesToAdd(): Promise<string> {
function processClipboardContent (line 295) | async function processClipboardContent(primaryColor: string) {
FILE: apps/web/src/utils/setup-components.ts
class MpCommonProfile (line 1) | class MpCommonProfile extends HTMLElement {
method constructor (line 2) | constructor() {
method connectedCallback (line 7) | connectedCallback() {
function setupComponents (line 55) | function setupComponents() {
FILE: apps/web/src/utils/storage.ts
type StorageEngine (line 12) | interface StorageEngine {
class LocalStorageEngine (line 24) | class LocalStorageEngine implements StorageEngine {
method get (line 25) | async get(key: string): Promise<string | null> {
method set (line 35) | async set(key: string, value: string): Promise<void> {
method remove (line 45) | async remove(key: string): Promise<void> {
method has (line 54) | async has(key: string): Promise<boolean> {
method clear (line 63) | async clear(): Promise<void> {
method keys (line 72) | async keys(): Promise<string[]> {
class RestfulStorageEngine (line 85) | class RestfulStorageEngine implements StorageEngine {
method constructor (line 86) | constructor(
method request (line 91) | private async request(method: string, endpoint: string, data?: any): P...
method get (line 114) | async get(key: string): Promise<string | null> {
method set (line 124) | async set(key: string, value: string): Promise<void> {
method remove (line 128) | async remove(key: string): Promise<void> {
method has (line 132) | async has(key: string): Promise<boolean> {
method clear (line 142) | async clear(): Promise<void> {
method keys (line 146) | async keys(): Promise<string[]> {
class StorageManager (line 155) | class StorageManager {
method setEngine (line 161) | setEngine(engine: StorageEngine): void {
method getEngine (line 168) | getEngine(): StorageEngine {
method get (line 175) | async get(key: string): Promise<string | null> {
method set (line 182) | async set(key: string, value: string): Promise<void> {
method getJSON (line 191) | async getJSON<T>(key: string, defaultValue?: T): Promise<T | null> {
method setJSON (line 209) | async setJSON<T>(key: string, value: T): Promise<void> {
method remove (line 223) | async remove(key: string): Promise<void> {
method has (line 230) | async has(key: string): Promise<boolean> {
method clear (line 237) | async clear(): Promise<void> {
method keys (line 244) | async keys(): Promise<string[]> {
method reactive (line 254) | reactive<T>(key: string, defaultValue: T): Ref<T> {
method customReactive (line 311) | customReactive<T>(
method parseJSON (line 348) | private parseJSON<T>(value: string, fallback: T): T {
FILE: apps/web/vite.config.ts
constant PKG_NAME_SPECIAL_CHARS (line 23) | const PKG_NAME_SPECIAL_CHARS = /[^\w-]/g
method manualChunks (line 65) | manualChunks(id) {
FILE: apps/web/worker/index.ts
constant MP_HOST (line 3) | const MP_HOST = `https://api.weixin.qq.com`
method fetch (line 6) | async fetch(request: Request): Promise<Response> {
FILE: apps/web/wxt.config.ts
function getRootPackageVersion (line 7) | function getRootPackageVersion() {
FILE: docker/latest/server/main.go
function main (line 13) | func main() {
FILE: packages/core/src/extensions/alert.ts
function markedAlert (line 10) | function markedAlert(options: AlertOptions = {}): MarkedExtension {
function resolveVariants (line 249) | function resolveVariants(variants: AlertVariantItem[]) {
function createSyntaxPattern (line 267) | function createSyntaxPattern(type: string) {
FILE: packages/core/src/extensions/footnotes.ts
type MapContent (line 11) | interface MapContent {
function markedFootnotes (line 17) | function markedFootnotes(): MarkedExtension {
FILE: packages/core/src/extensions/infographic.ts
type InfographicOptions (line 4) | interface InfographicOptions {
constant RE_INFOGRAPHIC_START (line 13) | const RE_INFOGRAPHIC_START = /^```infographic/m
constant RE_INFOGRAPHIC_BLOCK (line 14) | const RE_INFOGRAPHIC_BLOCK = /^```infographic\r?\n([\s\S]*?)\r?\n```/
function renderInfographic (line 16) | async function renderInfographic(containerId: string, code: string, cach...
function markedInfographic (line 93) | function markedInfographic(options?: InfographicOptions): MarkedExtension {
FILE: packages/core/src/extensions/katex.ts
type MarkedKatexOptions (line 3) | interface MarkedKatexOptions {
function createRenderer (line 16) | function createRenderer(defaultDisplay: boolean, withStyle: boolean = tr...
function inlineKatex (line 48) | function inlineKatex(options: MarkedKatexOptions | undefined, renderer: ...
function blockKatex (line 90) | function blockKatex(_options: MarkedKatexOptions | undefined, renderer: ...
function inlineLatexKatex (line 109) | function inlineLatexKatex(_options: MarkedKatexOptions | undefined, rend...
function blockLatexKatex (line 132) | function blockLatexKatex(_options: MarkedKatexOptions | undefined, rende...
function MDKatex (line 155) | function MDKatex(options: MarkedKatexOptions | undefined, withStyle: boo...
FILE: packages/core/src/extensions/markup.ts
function markedMarkup (line 9) | function markedMarkup(): MarkedExtension {
FILE: packages/core/src/extensions/mermaid.ts
function renderMermaid (line 9) | function renderMermaid(id: string, code: string, cacheKey: string) {
function markedMermaid (line 47) | function markedMermaid(): MarkedExtension {
FILE: packages/core/src/extensions/plantuml.ts
type PlantUMLOptions (line 10) | interface PlantUMLOptions {
function encode6bit (line 43) | function encode6bit(b: number): string {
function append3bytes (line 69) | function append3bytes(b1: number, b2: number, b3: number): string {
function encode64 (line 86) | function encode64(data: string): string {
function performDeflate (line 106) | function performDeflate(input: string): string {
function encodePlantUML (line 128) | function encodePlantUML(plantumlCode: string): string {
function generatePlantUMLUrl (line 148) | function generatePlantUMLUrl(code: string, options: Required<PlantUMLOpt...
function renderPlantUMLDiagram (line 157) | function renderPlantUMLDiagram(token: Tokens.Code, options: Required<Pla...
function fetchSvgContent (line 204) | async function fetchSvgContent(svgUrl: string): Promise<string> {
function createPlantUMLHTML (line 229) | function createPlantUMLHTML(imageUrl: string, options: Required<PlantUML...
function markedPlantUML (line 252) | function markedPlantUML(options: PlantUMLOptions = {}): MarkedExtension {
FILE: packages/core/src/extensions/ruby.ts
function markedRuby (line 18) | function markedRuby(): MarkedExtension {
FILE: packages/core/src/extensions/slider.ts
function markedSlider (line 7) | function markedSlider(): MarkedExtension {
FILE: packages/core/src/extensions/toc.ts
function markedToc (line 6) | function markedToc(): MarkedExtension {
FILE: packages/core/src/renderer/renderer-impl.ts
constant AMPERSAND_REGEX (line 33) | const AMPERSAND_REGEX = /&/g
constant LESS_THAN_REGEX (line 34) | const LESS_THAN_REGEX = /</g
constant GREATER_THAN_REGEX (line 35) | const GREATER_THAN_REGEX = />/g
constant DOUBLE_QUOTE_REGEX (line 36) | const DOUBLE_QUOTE_REGEX = /"/g
constant SINGLE_QUOTE_REGEX (line 37) | const SINGLE_QUOTE_REGEX = /'/g
constant BACKTICK_REGEX (line 38) | const BACKTICK_REGEX = /`/g
constant UNDERSCORE_REGEX (line 39) | const UNDERSCORE_REGEX = /_/g
constant HEADING_TAG_REGEX (line 40) | const HEADING_TAG_REGEX = /^h\d$/
constant PARAGRAPH_WRAPPER_REGEX (line 41) | const PARAGRAPH_WRAPPER_REGEX = /^<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/
constant MP_WEIXIN_LINK_REGEX (line 42) | const MP_WEIXIN_LINK_REGEX = /^https?:\/\/mp\.weixin\.qq\.com/
function escapeHtml (line 44) | function escapeHtml(text: string): string {
function buildAddition (line 54) | function buildAddition(): string {
function buildFootnoteArray (line 73) | function buildFootnoteArray(footnotes: [number, string, string][]): stri...
function extractFileName (line 83) | function extractFileName(href: string): string {
function transform (line 98) | function transform(legend: string, text: string | null, title: string | ...
type ParseResult (line 125) | interface ParseResult {
function parseFrontMatterAndContent (line 131) | function parseFrontMatterAndContent(markdownText: string): ParseResult {
function initRenderer (line 155) | function initRenderer(opts: IOpts = {}): RendererAPI {
FILE: packages/core/src/theme/cssProcessor.ts
function processCSS (line 19) | async function processCSS(css: string): Promise<string> {
FILE: packages/core/src/theme/cssScopeWrapper.ts
function wrapCSSWithScope (line 14) | function wrapCSSWithScope(css: string, scope: string = `#output`): string {
FILE: packages/core/src/theme/cssVariables.ts
type CSSVariableConfig (line 8) | interface CSSVariableConfig {
function generateCSSVariables (line 22) | function generateCSSVariables(config: CSSVariableConfig): string {
function generateHeadingStyles (line 42) | function generateHeadingStyles(config: CSSVariableConfig): string {
function generateHeadingStylesCSS (line 49) | function generateHeadingStylesCSS(headingStyles?: HeadingStyles): string {
function generateHeadingCSS (line 70) | function generateHeadingCSS(level: HeadingLevel, style: HeadingStyleType...
FILE: packages/core/src/theme/selectorMapping.ts
constant SELECTOR_MAPPING (line 11) | const SELECTOR_MAPPING: Record<string, string> = {
FILE: packages/core/src/theme/themeApplicator.ts
type ThemeConfig (line 14) | interface ThemeConfig {
function applyTheme (line 24) | async function applyTheme(config: ThemeConfig): Promise<void> {
FILE: packages/core/src/theme/themeExporter.ts
function exportMergedTheme (line 17) | function exportMergedTheme(
FILE: packages/core/src/theme/themeInjector.ts
class ThemeInjector (line 9) | class ThemeInjector {
method inject (line 17) | inject(cssContent: string): void {
method remove (line 29) | remove(): void {
method isInjected (line 39) | isInjected(): boolean {
function getThemeInjector (line 50) | function getThemeInjector(): ThemeInjector {
FILE: packages/core/src/utils/basicHelpers.ts
function escapeHtml (line 4) | function escapeHtml(text: string): string {
function ucfirst (line 16) | function ucfirst(str: string) {
function simpleHash (line 20) | function simpleHash(str: string): string {
FILE: packages/core/src/utils/initializeMermaid.ts
function initializeMermaid (line 1) | async function initializeMermaid() {
FILE: packages/core/src/utils/languages.ts
constant COMMON_LANGUAGES (line 39) | const COMMON_LANGUAGES: Record<string, LanguageFn> = {
constant HLJS_VERSION (line 79) | const HLJS_VERSION = `11.11.1`
constant HLJS_CDN_BASE (line 80) | const HLJS_CDN_BASE = `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/np...
function grammarUrlFor (line 88) | function grammarUrlFor(language: string): string {
function loadAndRegisterLanguage (line 97) | async function loadAndRegisterLanguage(language: string, hljs: any): Pro...
function formatHighlightedCode (line 131) | function formatHighlightedCode(html: string, preserveNewlines = false): ...
function highlightAndFormatCode (line 159) | function highlightAndFormatCode(text: string, language: string, hljs: an...
function highlightCodeBlock (line 191) | function highlightCodeBlock(codeBlock: Element, language: string, hljs: ...
function highlightPendingBlocks (line 214) | function highlightPendingBlocks(hljs: any, container: Document | Element...
FILE: packages/core/src/utils/markdownHelpers.ts
constant INFOGRAPHIC_PLACEHOLDER_REGEX (line 6) | const INFOGRAPHIC_PLACEHOLDER_REGEX = /<!--infographic-start-->[\s\S]*?<...
constant MERMAID_PLACEHOLDER_REGEX (line 7) | const MERMAID_PLACEHOLDER_REGEX = /<!--mermaid-start-->[\s\S]*?<!--merma...
constant PROTECTED_SPAN_REGEX (line 8) | const PROTECTED_SPAN_REGEX = /<span data-md-protected="(\d+)"><\/span>/g
function sanitizeHtml (line 17) | function sanitizeHtml(html: string): string {
function renderMarkdown (line 56) | function renderMarkdown(raw: string, renderer: RendererAPI) {
function postProcessHtml (line 74) | function postProcessHtml(baseHtml: string, reading: ReadTimeResults, ren...
function modifyHtmlContent (line 108) | function modifyHtmlContent(content: string, renderer: RendererAPI): stri...
FILE: packages/example/worker.js
method fetch (line 8) | async fetch(request, _env, _ctx) {
function setCorsHeaders (line 27) | function setCorsHeaders(headers) {
FILE: packages/md-cli/index.js
function startServer (line 15) | async function startServer() {
FILE: packages/md-cli/server.js
function createServer (line 29) | function createServer(port = 8800) {
FILE: packages/md-cli/util.js
function colors (line 11) | function colors() {
function parseArgv (line 40) | function parseArgv(arr) {
function dcloud (line 59) | function dcloud(spaceInfo) {
FILE: packages/shared/src/configs/ai-service-options.ts
constant DEFAULT_SERVICE_MODEL (line 315) | const DEFAULT_SERVICE_MODEL = serviceOptions[0].models[0]
constant DEFAULT_IMAGE_MODEL (line 420) | const DEFAULT_IMAGE_MODEL = imageServiceOptions[0].models[0]
FILE: packages/shared/src/configs/style.ts
type HeadingLevel (line 221) | type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
type HeadingStyleType (line 222) | type HeadingStyleType = 'default' | 'color-only' | 'border-bottom' | 'bo...
type HeadingStyles (line 224) | type HeadingStyles = {
FILE: packages/shared/src/configs/theme-css/index.ts
type ThemeName (line 25) | type ThemeName = keyof typeof themeMap
FILE: packages/shared/src/constants/ai-config.ts
constant DEFAULT_SERVICE_ENDPOINT (line 1) | const DEFAULT_SERVICE_ENDPOINT = `https://proxy-ai.doocs.org/v1`
constant DEFAULT_SERVICE_TEMPERATURE (line 2) | const DEFAULT_SERVICE_TEMPERATURE = 1
constant DEFAULT_SERVICE_MAX_TOKEN (line 3) | const DEFAULT_SERVICE_MAX_TOKEN = 1024
constant DEFAULT_SERVICE_TYPE (line 4) | const DEFAULT_SERVICE_TYPE = `default`
constant DEFAULT_SERVICE_KEY (line 5) | const DEFAULT_SERVICE_KEY = ``
FILE: packages/shared/src/editor/css.ts
function formatCSS (line 9) | async function formatCSS(view: EditorView) {
function cssSetup (line 30) | function cssSetup() {
FILE: packages/shared/src/editor/format.ts
type ToggleFormatOptions (line 4) | interface ToggleFormatOptions {
function toggleFormat (line 14) | function toggleFormat(
function applyHeading (line 51) | function applyHeading(view: EditorView, level: number) {
function formatBold (line 90) | function formatBold(view: EditorView) {
function formatItalic (line 99) | function formatItalic(view: EditorView) {
function formatStrikethrough (line 108) | function formatStrikethrough(view: EditorView) {
function formatLink (line 117) | function formatLink(view: EditorView) {
function formatCode (line 126) | function formatCode(view: EditorView) {
function formatColor (line 138) | function formatColor(view: EditorView, color: string) {
function formatUnorderedList (line 162) | function formatUnorderedList(view: EditorView) {
function formatOrderedList (line 173) | function formatOrderedList(view: EditorView) {
function undoAction (line 187) | function undoAction(view: EditorView): boolean {
function redoAction (line 191) | function redoAction(view: EditorView): boolean {
FILE: packages/shared/src/editor/javascript.ts
function formatJavaScript (line 10) | async function formatJavaScript(view: EditorView) {
function javascriptSetup (line 30) | function javascriptSetup() {
FILE: packages/shared/src/editor/markdown.ts
function formatMarkdown (line 15) | async function formatMarkdown(view: EditorView) {
function insertTabAtCursor (line 26) | function insertTabAtCursor(view: EditorView): boolean {
type MarkdownKeymapOptions (line 36) | interface MarkdownKeymapOptions {
function markdownKeymap (line 47) | function markdownKeymap(options?: MarkdownKeymapOptions) {
function markdownSetup (line 99) | function markdownSetup(options?: MarkdownKeymapOptions) {
FILE: packages/shared/src/editor/themes.ts
function lightTheme (line 14) | function lightTheme() {
function darkTheme (line 18) | function darkTheme() {
function theme (line 23) | function theme(isDark: boolean) {
FILE: packages/shared/src/types/ai-services-types.ts
type ServiceOption (line 1) | interface ServiceOption {
type ImageServiceOption (line 8) | interface ImageServiceOption {
FILE: packages/shared/src/types/common.ts
type IOpts (line 8) | interface IOpts {
type IConfigOption (line 17) | interface IConfigOption<VT = string> {
type AlertOptions (line 26) | interface AlertOptions {
type AlertVariantItem (line 35) | interface AlertVariantItem {
type Alert (line 45) | interface Alert {
type PostAccount (line 59) | interface PostAccount {
type Post (line 75) | interface Post {
FILE: packages/shared/src/types/renderer-types.ts
type RendererAPI (line 4) | interface RendererAPI {
FILE: packages/shared/src/types/template.ts
type Template (line 8) | interface Template {
type CreateTemplateParams (line 28) | interface CreateTemplateParams {
type UpdateTemplateParams (line 42) | interface UpdateTemplateParams {
FILE: packages/shared/src/utils/basicHelpers.ts
function sanitizeTitle (line 6) | function sanitizeTitle(title: string) {
function removeLeft (line 29) | function removeLeft(str: string) {
function checkImage (line 45) | function checkImage(file: File) {
FILE: packages/shared/src/utils/fileHelpers.ts
function downloadFile (line 16) | function downloadFile(content: string, filename: string, mimeType: strin...
function toBase64 (line 52) | function toBase64(file: Blob): Promise<string> {
function createTable (line 69) | function createTable({ data, rows, cols }: {
function formatDoc (line 95) | async function formatDoc(content: string, type: `markdown` | `css` | `ja...
FILE: packages/shared/src/utils/readingTime.ts
type ReadingTimeOptions (line 1) | interface ReadingTimeOptions {
type ReadTimeResults (line 6) | interface ReadTimeResults {
type IOptions (line 13) | type IOptions = ReadingTimeOptions
type IReadTimeResults (line 14) | type IReadTimeResults = ReadTimeResults
function codeIsInRanges (line 16) | function codeIsInRanges(number: number, ranges: ReadonlyArray<readonly [...
function isCJK (line 20) | function isCJK(char: string | undefined): boolean {
function isAnsiWordBound (line 34) | function isAnsiWordBound(char: string | undefined): boolean {
function isPunctuation (line 38) | function isPunctuation(char: string | undefined): boolean {
function readingTime (line 54) | function readingTime(text: string, options: ReadingTimeOptions = {}): Re...
FILE: packages/shared/src/utils/tokenTools.ts
function utf16to8 (line 1) | function utf16to8(str: string) {
function utf8to16 (line 25) | function utf8to16(str: string) {
function base64encode (line 199) | function base64encode(str: string) {
function base64decode (line 235) | function base64decode(str: string) {
function safe64 (line 286) | function safe64(base64: string) {
FILE: scripts/download-utools-libs.mjs
function downloadFile (line 34) | async function downloadFile(url, outputPath) {
function main (line 68) | async function main() {
FILE: scripts/package-utools.mjs
function run (line 19) | function run(command, args, options = {}) {
function ensureFileExists (line 37) | async function ensureFileExists(filePath, friendlyName) {
function main (line 46) | async function main() {
Condensed preview — 389 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,032K chars).
[
{
"path": ".editorconfig",
"chars": 294,
"preview": "# https://editorconfig.org\nroot = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 2\nindent_style = space\ninsert"
},
{
"path": ".github/copilot-instructions.md",
"chars": 2692,
"preview": "# Copilot Instructions\n\nThis repository is a pnpm monorepo containing a Vue 3 web application, a VSCode extension, and a"
},
{
"path": ".github/dependabot.yml",
"chars": 579,
"preview": "version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n day: tuesday\n"
},
{
"path": ".github/secret_scanning.yml",
"chars": 27,
"preview": "paths-ignore:\n - \"src/**\"\n"
},
{
"path": ".github/workflows/cloudflare-preview-cleanup.yml",
"chars": 1654,
"preview": "name: Cleanup Cloudflare Preview\n\non:\n pull_request:\n types: [closed]\n\njobs:\n cleanup-preview:\n runs-on: ubuntu-"
},
{
"path": ".github/workflows/cloudflare-preview.yml",
"chars": 2377,
"preview": "name: Cloudflare Workers Preview\n\non:\n pull_request:\n types: [opened, synchronize, reopened]\n workflow_dispatch:\n\nc"
},
{
"path": ".github/workflows/deploy-gitee.yml",
"chars": 2520,
"preview": "name: Build and Deploy to Gitee Pages\n\non:\n push:\n branches: [main]\n workflow_dispatch:\n\nconcurrency:\n group: depl"
},
{
"path": ".github/workflows/deploy.yml",
"chars": 2737,
"preview": "name: Build and Deploy\n\non:\n push:\n branches: [main]\n workflow_dispatch:\n\nconcurrency:\n group: ${{ github.workflow"
},
{
"path": ".github/workflows/docker.yml",
"chars": 711,
"preview": "name: Build and Push Docker Images\n\non:\n push:\n branches:\n - main\n workflow_dispatch:\n\njobs:\n build:\n runs"
},
{
"path": ".github/workflows/release-cli.yml",
"chars": 738,
"preview": "name: Create Cli Release\n\non:\n push:\n tags:\n - 'cli-v*'\n\npermissions:\n id-token: write # Required for OIDC tr"
},
{
"path": ".github/workflows/release.yml",
"chars": 1592,
"preview": "name: Create Release\n\non:\n push:\n tags:\n - \"v*\"\n\njobs:\n release:\n name: Create GitHub Release\n runs-on: "
},
{
"path": ".github/workflows/stale-bot.yml",
"chars": 1598,
"preview": "name: Stale Bot\n\non:\n schedule:\n - cron: \"0 6 * * *\" # 每天北京时间 14:00 运行\n\njobs:\n stale:\n runs-on: ubuntu-latest\n "
},
{
"path": ".github/workflows/surge-preview-build.yml",
"chars": 1042,
"preview": "name: Surge Preview Build\n\non:\n pull_request:\n types: [opened, synchronize, reopened]\n\njobs:\n build-preview:\n ru"
},
{
"path": ".github/workflows/surge-preview-deploy.yml",
"chars": 3726,
"preview": "name: Surge Preview Deploy\n\non:\n workflow_run:\n workflows: [\"Surge Preview Build\"]\n types:\n - completed\n\njob"
},
{
"path": ".gitignore",
"chars": 863,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\nnode_modules\n/.pnp"
},
{
"path": ".husky/pre-commit",
"chars": 225,
"preview": "#!/bin/sh\n\nif [ \"$SKIP_SIMPLE_GIT_HOOKS\" = \"1\" ]; then\n echo \"[INFO] SKIP_SIMPLE_GIT_HOOKS is set to 1, skipping hook"
},
{
"path": ".npmrc",
"chars": 39,
"preview": "registry=https://registry.npmmirror.com"
},
{
"path": ".nvmrc",
"chars": 9,
"preview": "v22.16.0\n"
},
{
"path": ".vscode/extensions.json",
"chars": 39,
"preview": "{\n \"recommendations\": [\"Vue.volar\"]\n}\n"
},
{
"path": ".vscode/launch.json",
"chars": 521,
"preview": "{\n \"version\": \"0.2.0\",\n \"configurations\": [\n {\n \"name\": \"Run Extension\",\n \"type\": \"extensionHost\",\n "
},
{
"path": ".vscode/settings.json",
"chars": 1179,
"preview": "{\n // Disable the default formatter, use eslint instead\n \"prettier.enable\": false,\n \"editor.formatOnSave\": false,\n\n "
},
{
"path": ".vscode/tasks.json",
"chars": 246,
"preview": "{\n \"version\": \"2.0.0\",\n \"tasks\": [\n {\n \"type\": \"npm\",\n \"script\": \"compile\",\n \"path\": \"apps/vscode\",\n"
},
{
"path": "CHANGELOG.md",
"chars": 4288,
"preview": "## [v2.1.0] - 2025-10-17\n\n### ✨ 新特性\n\n- **AI 助手侧边栏 & 文生图**:新增独立的 AI 助手侧边栏,支持智能对话、文本生成与 AI 图像生成功能,让创作更高效便捷。 \n- **PlantUML"
},
{
"path": "CONTRIBUTING.md",
"chars": 3190,
"preview": "# 贡献指南\n\n感谢你对 **doocs/md** 的兴趣!我们欢迎任何形式的贡献,包括但不限于报告缺陷、改进文档、提交新特性或修复 Bug。本指南旨在帮助你快速地为项目做出贡献。\n\n## 目录\n\n- [贡献指南](#贡献指南)\n - ["
},
{
"path": "LICENSE",
"chars": 477,
"preview": " DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE\n Version 2, December 2004\n\n Copyright (C) 202"
},
{
"path": "README.md",
"chars": 10153,
"preview": "<div align=\"center\">\n\n[](https:"
},
{
"path": "USERS.md",
"chars": 4133,
"preview": "## 谁在使用\n\n- [Doocs](https://mp.weixin.qq.com/s/RNKDCK2KoyeuMeEs6GUrow)\n- [ApachePulsar](https://mp.weixin.qq.com/s/udU2ZI"
},
{
"path": "apps/utools/README.md",
"chars": 1264,
"preview": "# uTools 插件打包指引\n\n该目录包含将微信 Markdown 编辑器打包为 [uTools](https://u.tools) 插件所需的脚本与配置。\n\n## 快速开始\n\n```sh\npnpm utools:package\n```\n"
},
{
"path": "apps/utools/package.json",
"chars": 44,
"preview": "{\n \"type\": \"commonjs\",\n \"private\": true\n}\n"
},
{
"path": "apps/utools/plugin.json",
"chars": 527,
"preview": "{\n \"pluginName\": \"微信 Markdown 编辑器\",\n \"description\": \"Markdown 文档自动排版为微信图文,随时在 uTools 中打开使用\",\n \"version\": \"2.1.0\",\n \""
},
{
"path": "apps/utools/preload.js",
"chars": 1116,
"preview": "(() => {\n if (typeof window === `undefined`)\n return\n\n // 标识当前环境为 uTools\n window.__MD_UTOOLS__ = true\n\n /**\n * "
},
{
"path": "apps/vscode/.gitignore",
"chars": 43,
"preview": "out\ndist\nnode_modules\n.vscode-test/\n*.vsix\n"
},
{
"path": "apps/vscode/.npmrc",
"chars": 39,
"preview": "registry=https://registry.npmmirror.com"
},
{
"path": "apps/vscode/.vscodeignore",
"chars": 201,
"preview": ".vscode/**\n.vscode-test/**\nout/**\nnode_modules/**\nsrc/**\n.gitignore\n.yarnrc\nwebpack.config.js\nvsc-extension-quickstart.m"
},
{
"path": "apps/vscode/CHANGELOG.md",
"chars": 166,
"preview": "# doocs-md changelog\n\n## [Unreleased] - 2025-06-04\n\n### ✨ Features\n\n- 侧边栏Markdown预览视图功能\n- 支持微信图文特有的样式渲染\n- 可自定义字体和字体大小\n- "
},
{
"path": "apps/vscode/LICENSE",
"chars": 478,
"preview": "\n DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE\n Version 2, December 2004\n\n Copyright (C) 20"
},
{
"path": "apps/vscode/README.md",
"chars": 641,
"preview": "# doocs-md VS Code Extension\n\n为 doocs-md 提供的 VS Code 扩展,支持在编辑器内实时预览 Markdown 渲染效果。\n\n## 功能特性\n\n- 侧边栏 Markdown 预览视图\n- 支持微信图"
},
{
"path": "apps/vscode/package.json",
"chars": 2280,
"preview": "{\n \"publisher\": \"doocs\",\n \"name\": \"doocs-md\",\n \"displayName\": \"doocs-md\",\n \"version\": \"0.0.1\",\n \"description\": \"\",\n"
},
{
"path": "apps/vscode/src/css/index.ts",
"chars": 1224,
"preview": "export const css = `\n:root {\n --background: 0 0% 100%;\n --foreground: 0 0% 3.9%;\n\n --card: 0 0% 100%;\n --card-foregr"
},
{
"path": "apps/vscode/src/extension.ts",
"chars": 4701,
"preview": "import type { ThemeName } from '@md/shared'\nimport { initRenderer } from '@md/core/renderer'\nimport { generateCSSVariabl"
},
{
"path": "apps/vscode/src/styleChoices.ts",
"chars": 264,
"preview": "import { codeBlockThemeOptions, colorOptions, fontFamilyOptions, fontSizeOptions, legendOptions, themeOptions } from '@m"
},
{
"path": "apps/vscode/src/treeDataProvider.ts",
"chars": 6815,
"preview": "import type { ThemeName } from '@md/shared/configs'\nimport * as vscode from 'vscode'\nimport { colorOptions, fontFamilyOp"
},
{
"path": "apps/vscode/tsconfig.json",
"chars": 396,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"lib\": [\"ES2022\"],\n \"module\": \"esnext\",\n \"moduleResolution\": "
},
{
"path": "apps/vscode/webpack.config.mjs",
"chars": 1317,
"preview": "'use strict'\n\nimport path from 'node:path'\nimport { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'\n\nconst cu"
},
{
"path": "apps/web/components.json",
"chars": 456,
"preview": "{\n \"$schema\": \"https://shadcn-vue.com/schema.json\",\n \"style\": \"new-york\",\n \"typescript\": true,\n \"tailwind\": {\n \"c"
},
{
"path": "apps/web/index.html",
"chars": 3351,
"preview": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n <head>\n <meta charset=\"utf-8\" />\n <meta http-equiv=\"X-UA-Compatible\" content"
},
{
"path": "apps/web/netlify.toml",
"chars": 144,
"preview": "[build]\ncommand = \"pnpm run build:h5-netlify\"\npublish = \"dist\"\n\n# 设置重定向规则,确保SPA路由正常工作\n[[redirects]]\nfrom = \"/*\"\nto = \"/i"
},
{
"path": "apps/web/package.json",
"chars": 3301,
"preview": "{\n \"name\": \"@md/web\",\n \"type\": \"module\",\n \"private\": true,\n \"engines\": {\n \"node\": \">=22.16.0\"\n },\n \"scripts\": {"
},
{
"path": "apps/web/plugins/vite-plugin-utools-local-assets.ts",
"chars": 1592,
"preview": "import type { Plugin } from 'vite'\nimport process from 'node:process'\n\n/**\n * Vite 插件:在 uTools 构建时将远程资源替换为本地资源\n */\nexpor"
},
{
"path": "apps/web/postcss.config.js",
"chars": 69,
"preview": "export default {\n plugins: {\n '@tailwindcss/postcss': {},\n },\n}\n"
},
{
"path": "apps/web/public/upload/.gitkeep",
"chars": 0,
"preview": ""
},
{
"path": "apps/web/src/App.vue",
"chars": 2563,
"preview": "<script setup lang=\"ts\">\nimport { onMounted, ref } from 'vue'\nimport { Toaster } from '@/components/ui/sonner'\nimport { "
},
{
"path": "apps/web/src/assets/example/markdown.md",
"chars": 5899,
"preview": "# 探索 Markdown 的奇妙世界\n\n欢迎来到 Markdown 的奇妙世界!无论你是写作爱好者、开发者、博主,还是想要简单记录点什么的人,Markdown 都能成为你新的好伙伴。它不仅让写作变得简单明了,还能轻松地将内容转化为漂亮的网"
},
{
"path": "apps/web/src/assets/index.css",
"chars": 1983,
"preview": "@import 'tailwindcss';\n@config '../../tailwind.config.cjs';\n\n/*\n The default border color has changed to `currentcolor`"
},
{
"path": "apps/web/src/assets/less/app.less",
"chars": 1217,
"preview": "//* {\n// box-sizing: border-box;\n// margin: 0;\n// padding: 0;\n//}\n\nhtml,\nbody {\n height: 100%;\n font-family: 'PingF"
},
{
"path": "apps/web/src/assets/less/theme.less",
"chars": 1884,
"preview": "@nightPreviewColor: #191919;\n@nightCodeMirrorColor: #191919;\n@nightActiveCodeMirrorColor: gray;\n@nightFontColor: gray;\n@"
},
{
"path": "apps/web/src/components/AppSplash.vue",
"chars": 911,
"preview": "<script setup lang=\"ts\">\nconst loading = ref(true)\n\nonMounted(() => {\n setTimeout(() => {\n loading.value = false\n }"
},
{
"path": "apps/web/src/components/ai/SidebarAIToolbar.vue",
"chars": 13814,
"preview": "<script setup lang=\"ts\">\nimport { Bot, Image as ImageIcon, Settings2, Wand2 } from 'lucide-vue-next'\nimport { useEditorS"
},
{
"path": "apps/web/src/components/ai/chat-box/AIAssistantPanel.vue",
"chars": 23034,
"preview": "<script setup lang=\"ts\">\nimport type { QuickCommandRuntime } from '@/stores/quickCommands'\nimport {\n Check,\n Copy,\n F"
},
{
"path": "apps/web/src/components/ai/chat-box/AIConfig.vue",
"chars": 6953,
"preview": "<script setup lang=\"ts\">\nimport { serviceOptions } from '@md/shared/configs'\nimport { DEFAULT_SERVICE_TYPE } from '@md/s"
},
{
"path": "apps/web/src/components/ai/chat-box/QuickCommandManager.vue",
"chars": 3515,
"preview": "<script setup lang=\"ts\">\nimport { ref, watch } from 'vue'\nimport { Button } from '@/components/ui/button'\nimport { Dialo"
},
{
"path": "apps/web/src/components/ai/image-generator/AIImageConfig.vue",
"chars": 9173,
"preview": "<script setup lang=\"ts\">\nimport { imageServiceOptions } from '@md/shared/configs'\nimport { DEFAULT_SERVICE_TYPE } from '"
},
{
"path": "apps/web/src/components/ai/image-generator/AIImageGeneratorPanel.vue",
"chars": 25617,
"preview": "<script setup lang=\"ts\">\nimport {\n Copy,\n Download,\n Image as ImageIcon,\n Loader2,\n MessageCircle,\n RefreshCcw,\n "
},
{
"path": "apps/web/src/components/ai/image-generator/index.ts",
"chars": 142,
"preview": "export { default as AIImageConfig } from './AIImageConfig.vue'\nexport { default as AIImageGeneratorPanel } from './AIIma"
},
{
"path": "apps/web/src/components/ai/index.ts",
"chars": 130,
"preview": "export * from './image-generator'\nexport { default as SidebarAIToolbar } from './SidebarAIToolbar.vue'\nexport * from './"
},
{
"path": "apps/web/src/components/ai/tool-box/ToolBoxPopover.vue",
"chars": 12412,
"preview": "<script setup lang=\"ts\">\nimport { Pause, Settings, Wand2, X } from 'lucide-vue-next'\nimport { Button } from '@/component"
},
{
"path": "apps/web/src/components/ai/tool-box/index.ts",
"chars": 714,
"preview": "import type { EditorView } from '@codemirror/view'\nimport AIPolishPopover from './ToolBoxPopover.vue'\n\n/* ---------- 简化的"
},
{
"path": "apps/web/src/components/editor/CssEditor.vue",
"chars": 14859,
"preview": "<script setup lang=\"ts\">\nimport { exportMergedTheme } from '@md/core'\nimport { themeMap, themeOptionsMap } from '@md/sha"
},
{
"path": "apps/web/src/components/editor/CustomUploadForm.vue",
"chars": 2383,
"preview": "<script setup lang='ts'>\nimport { Compartment } from '@codemirror/state'\nimport { EditorView } from '@codemirror/view'\ni"
},
{
"path": "apps/web/src/components/editor/EditorContextMenu.vue",
"chars": 9730,
"preview": "<script setup lang='ts'>\nimport { altSign, headingLevels as baseHeadingLevels, ctrlKey, ctrlSign, shiftSign } from '@md/"
},
{
"path": "apps/web/src/components/editor/EditorStateDialog.vue",
"chars": 16417,
"preview": "<script setup lang=\"ts\">\nimport { storeLabels } from '@md/shared/configs'\nimport { Expand, UploadCloud } from 'lucide-vu"
},
{
"path": "apps/web/src/components/editor/FloatingToc.vue",
"chars": 1217,
"preview": "<script setup lang='ts'>\nimport { List } from 'lucide-vue-next'\nimport { useRenderStore } from '@/stores/render'\nimport "
},
{
"path": "apps/web/src/components/editor/FolderSourcePanel.vue",
"chars": 5421,
"preview": "<script setup lang=\"ts\">\nimport {\n FolderClosed,\n FolderOpen,\n FolderPlus,\n FolderTree as FolderTreeIcon,\n Loader2,"
},
{
"path": "apps/web/src/components/editor/FolderTree.vue",
"chars": 3681,
"preview": "<script setup lang=\"ts\">\nimport type { FileSystemNode } from '@/stores/folderSource'\nimport { ChevronDown, ChevronRight,"
},
{
"path": "apps/web/src/components/editor/Footer.vue",
"chars": 485,
"preview": "<script setup lang=\"ts\">\nimport { useRenderStore } from '@/stores/render'\n\nconst renderStore = useRenderStore()\nconst { "
},
{
"path": "apps/web/src/components/editor/FormItem.vue",
"chars": 1082,
"preview": "<script setup lang=\"ts\">\nconst props = defineProps<{\n label?: string\n required?: boolean\n error?: string\n width?: nu"
},
{
"path": "apps/web/src/components/editor/ImportMarkdownDialog.vue",
"chars": 7956,
"preview": "<script setup lang=\"ts\">\nimport { FileText, Globe, Loader2, Upload } from 'lucide-vue-next'\nimport { useEditorStore } fr"
},
{
"path": "apps/web/src/components/editor/InsertFormDialog.vue",
"chars": 2486,
"preview": "<script setup lang=\"ts\">\nimport { useEditorStore } from '@/stores/editor'\nimport { useUIStore } from '@/stores/ui'\nimpor"
},
{
"path": "apps/web/src/components/editor/InsertMpCardDialog.vue",
"chars": 6541,
"preview": "<script setup lang=\"ts\">\nimport { toTypedSchema } from '@vee-validate/yup'\nimport { Field, Form } from 'vee-validate'\nim"
},
{
"path": "apps/web/src/components/editor/RightSlider.vue",
"chars": 13460,
"preview": "<script setup lang=\"ts\">\nimport type {\n HeadingLevel,\n HeadingStyleType,\n themeMap,\n} from '@md/shared/configs'\nimpor"
},
{
"path": "apps/web/src/components/editor/TemplateDialog.vue",
"chars": 11165,
"preview": "<script setup lang=\"ts\">\nimport type { Template } from '@md/shared'\nimport { Calendar, Clock, FileDown, FileInput, FileT"
},
{
"path": "apps/web/src/components/editor/ThemeCustomizer.vue",
"chars": 4273,
"preview": "<script setup lang=\"ts\">\nimport { widthOptions } from '@md/shared/configs'\nimport { Moon, Sun } from 'lucide-vue-next'\ni"
},
{
"path": "apps/web/src/components/editor/UploadImgDialog.vue",
"chars": 47045,
"preview": "<script setup lang=\"ts\">\nimport { toTypedSchema } from '@vee-validate/yup'\nimport { UploadCloud } from 'lucide-vue-next'"
},
{
"path": "apps/web/src/components/editor/editor-header/AboutDialog.vue",
"chars": 1362,
"preview": "<script setup lang=\"ts\">\nconst props = defineProps({\n visible: {\n type: Boolean,\n default: false,\n },\n})\n\nconst "
},
{
"path": "apps/web/src/components/editor/editor-header/EditDropdown.vue",
"chars": 9174,
"preview": "<script setup lang=\"ts\">\nimport type { EditorView } from '@codemirror/view'\nimport { altSign, ctrlSign, shiftSign } from"
},
{
"path": "apps/web/src/components/editor/editor-header/FileDropdown.vue",
"chars": 6047,
"preview": "<script setup lang=\"ts\">\nimport { Download, FileCode, FileCog, FileText, FolderKanban, FolderOpen, Package, Upload } fro"
},
{
"path": "apps/web/src/components/editor/editor-header/FormatDropdown.vue",
"chars": 10564,
"preview": "<script setup lang=\"ts\">\nimport type { EditorView } from '@codemirror/view'\nimport type { Format } from 'vue-pick-colors"
},
{
"path": "apps/web/src/components/editor/editor-header/FundDialog.vue",
"chars": 1392,
"preview": "<script setup lang=\"ts\">\nconst props = defineProps({\n visible: {\n type: Boolean,\n default: false,\n },\n})\n\nconst "
},
{
"path": "apps/web/src/components/editor/editor-header/HelpDropdown.vue",
"chars": 1910,
"preview": "<script setup lang=\"ts\">\nimport { Heart, HelpCircle, MessageSquare, Tag } from 'lucide-vue-next'\n\nconst props = withDefa"
},
{
"path": "apps/web/src/components/editor/editor-header/InsertDropdown.vue",
"chars": 1592,
"preview": "<script setup lang=\"ts\">\nimport { Contact, Image, Table } from 'lucide-vue-next'\nimport { useUIStore } from '@/stores/ui"
},
{
"path": "apps/web/src/components/editor/editor-header/PostInfo.vue",
"chars": 15957,
"preview": "<script setup lang=\"ts\">\nimport type { Post, PostAccount } from '@md/shared/types'\nimport { Check, ChevronDown, ChevronR"
},
{
"path": "apps/web/src/components/editor/editor-header/PostTaskDialog.vue",
"chars": 3251,
"preview": "<script setup lang=\"ts\">\nimport type { Post } from '@md/shared/types'\nimport { Dialog, DialogContent, DialogHeader, Dial"
},
{
"path": "apps/web/src/components/editor/editor-header/StyleDropdown.vue",
"chars": 7833,
"preview": "<script setup lang=\"ts\">\nimport type {\n themeMap,\n} from '@md/shared/configs'\nimport type { Format } from 'vue-pick-col"
},
{
"path": "apps/web/src/components/editor/editor-header/StyleOptionMenu.vue",
"chars": 1287,
"preview": "<script setup lang=\"ts\">\nimport type { IConfigOption } from '@md/shared/types'\nimport type { Component } from 'vue'\n\ncon"
},
{
"path": "apps/web/src/components/editor/editor-header/ViewDropdown.vue",
"chars": 7046,
"preview": "<script setup lang=\"ts\">\nimport { widthOptions } from '@md/shared/configs'\nimport { FileCode, Monitor, Moon, Palette, Pa"
},
{
"path": "apps/web/src/components/editor/editor-header/index.vue",
"chars": 11134,
"preview": "<script setup lang=\"ts\">\nimport { Copy, Menu, Palette } from 'lucide-vue-next'\nimport { useEditorStore } from '@/stores/"
},
{
"path": "apps/web/src/components/editor/post-slider/PostItem.vue",
"chars": 6291,
"preview": "<script setup lang=\"ts\">\nimport {\n ChevronRight,\n Edit3,\n Ellipsis,\n FileInput,\n History,\n Package,\n PlusSquare,\n"
},
{
"path": "apps/web/src/components/editor/post-slider/index.vue",
"chars": 14795,
"preview": "<script setup lang=\"ts\">\nimport { ArrowUpNarrowWide, ChevronsDownUp, ChevronsUpDown, PlusSquare, X } from 'lucide-vue-ne"
},
{
"path": "apps/web/src/components/ui/alert/Alert.vue",
"chars": 408,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport type { AlertVariants } from '.'\nimport { cn } "
},
{
"path": "apps/web/src/components/ui/alert/AlertDescription.vue",
"chars": 290,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defin"
},
{
"path": "apps/web/src/components/ui/alert/AlertTitle.vue",
"chars": 303,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defin"
},
{
"path": "apps/web/src/components/ui/alert/index.ts",
"chars": 828,
"preview": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\nexport { de"
},
{
"path": "apps/web/src/components/ui/alert-dialog/AlertDialog.vue",
"chars": 413,
"preview": "<script setup lang=\"ts\">\nimport type { AlertDialogEmits, AlertDialogProps } from 'radix-vue'\nimport { AlertDialogRoot, u"
},
{
"path": "apps/web/src/components/ui/alert-dialog/AlertDialogAction.vue",
"chars": 615,
"preview": "<script setup lang=\"ts\">\nimport type { AlertDialogActionProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vu"
},
{
"path": "apps/web/src/components/ui/alert-dialog/AlertDialogCancel.vue",
"chars": 689,
"preview": "<script setup lang=\"ts\">\nimport type { AlertDialogCancelProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vu"
},
{
"path": "apps/web/src/components/ui/alert-dialog/AlertDialogContent.vue",
"chars": 1568,
"preview": "<script setup lang=\"ts\">\nimport type { AlertDialogContentEmits, AlertDialogContentProps } from 'radix-vue'\nimport type {"
},
{
"path": "apps/web/src/components/ui/alert-dialog/AlertDialogDescription.vue",
"chars": 614,
"preview": "<script setup lang=\"ts\">\nimport type { AlertDialogDescriptionProps } from 'radix-vue'\nimport type { HTMLAttributes } fro"
},
{
"path": "apps/web/src/components/ui/alert-dialog/AlertDialogFooter.vue",
"chars": 364,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defin"
},
{
"path": "apps/web/src/components/ui/alert-dialog/AlertDialogHeader.vue",
"chars": 314,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defin"
},
{
"path": "apps/web/src/components/ui/alert-dialog/AlertDialogTitle.vue",
"chars": 572,
"preview": "<script setup lang=\"ts\">\nimport type { AlertDialogTitleProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue"
},
{
"path": "apps/web/src/components/ui/alert-dialog/AlertDialogTrigger.vue",
"chars": 292,
"preview": "<script setup lang=\"ts\">\nimport type { AlertDialogTriggerProps } from 'radix-vue'\nimport { AlertDialogTrigger } from 'ra"
},
{
"path": "apps/web/src/components/ui/alert-dialog/index.ts",
"chars": 639,
"preview": "export { default as AlertDialog } from './AlertDialog.vue'\nexport { default as AlertDialogAction } from './AlertDialogAc"
},
{
"path": "apps/web/src/components/ui/back-top/BackTop.vue",
"chars": 1480,
"preview": "<script setup lang=\"ts\">\nimport { throttle } from 'es-toolkit'\nimport { ArrowUpFromLine } from 'lucide-vue-next'\n\ntype T"
},
{
"path": "apps/web/src/components/ui/back-top/index.ts",
"chars": 51,
"preview": "export { default as BackTop } from './BackTop.vue'\n"
},
{
"path": "apps/web/src/components/ui/button/Button.vue",
"chars": 678,
"preview": "<script setup lang=\"ts\">\nimport type { PrimitiveProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimpor"
},
{
"path": "apps/web/src/components/ui/button/index.ts",
"chars": 1379,
"preview": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\nexport { de"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenu.vue",
"chars": 429,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuRootEmits, ContextMenuRootProps } from 'radix-vue'\nimport { ContextMen"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuCheckboxItem.vue",
"chars": 1248,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'radix-vue'\nimp"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuContent.vue",
"chars": 1283,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuContentEmits, ContextMenuContentProps } from 'radix-vue'\nimport type {"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuGroup.vue",
"chars": 282,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuGroupProps } from 'radix-vue'\nimport { ContextMenuGroup } from 'radix-"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuItem.vue",
"chars": 966,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuItemEmits, ContextMenuItemProps } from 'radix-vue'\nimport type { HTMLA"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuLabel.vue",
"chars": 658,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuLabelProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuPortal.vue",
"chars": 287,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuPortalProps } from 'radix-vue'\nimport { ContextMenuPortal } from 'radi"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuRadioGroup.vue",
"chars": 477,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from 'radix-vue'\nimport "
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuRadioItem.vue",
"chars": 1242,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'radix-vue'\nimport ty"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuSeparator.vue",
"chars": 552,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuSeparatorProps } from 'radix-vue'\nimport type { HTMLAttributes } from "
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuShortcut.vue",
"chars": 316,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defin"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuSub.vue",
"chars": 428,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuSubEmits, ContextMenuSubProps } from 'radix-vue'\nimport {\n ContextMen"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuSubContent.vue",
"chars": 1220,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'radix-vue'\nimpor"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuSubTrigger.vue",
"chars": 1010,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuSubTriggerProps } from 'radix-vue'\nimport type { HTMLAttributes } from"
},
{
"path": "apps/web/src/components/ui/context-menu/ContextMenuTrigger.vue",
"chars": 365,
"preview": "<script setup lang=\"ts\">\nimport type { ContextMenuTriggerProps } from 'radix-vue'\nimport { ContextMenuTrigger, useForwar"
},
{
"path": "apps/web/src/components/ui/context-menu/index.ts",
"chars": 1024,
"preview": "export { default as ContextMenu } from './ContextMenu.vue'\nexport { default as ContextMenuCheckboxItem } from './Context"
},
{
"path": "apps/web/src/components/ui/dialog/Dialog.vue",
"chars": 394,
"preview": "<script setup lang=\"ts\">\nimport type { DialogRootEmits, DialogRootProps } from 'radix-vue'\nimport { DialogRoot, useForwa"
},
{
"path": "apps/web/src/components/ui/dialog/DialogClose.vue",
"chars": 257,
"preview": "<script setup lang=\"ts\">\nimport type { DialogCloseProps } from 'radix-vue'\nimport { DialogClose } from 'radix-vue'\n\ncons"
},
{
"path": "apps/web/src/components/ui/dialog/DialogContent.vue",
"chars": 1977,
"preview": "<script setup lang=\"ts\">\nimport type { DialogContentEmits, DialogContentProps } from 'radix-vue'\nimport type { HTMLAttri"
},
{
"path": "apps/web/src/components/ui/dialog/DialogDescription.vue",
"chars": 658,
"preview": "<script setup lang=\"ts\">\nimport type { DialogDescriptionProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vu"
},
{
"path": "apps/web/src/components/ui/dialog/DialogFooter.vue",
"chars": 362,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defin"
},
{
"path": "apps/web/src/components/ui/dialog/DialogHeader.vue",
"chars": 316,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defin"
},
{
"path": "apps/web/src/components/ui/dialog/DialogScrollContent.vue",
"chars": 1831,
"preview": "<script setup lang=\"ts\">\nimport type { DialogContentEmits, DialogContentProps } from 'radix-vue'\nimport type { HTMLAttri"
},
{
"path": "apps/web/src/components/ui/dialog/DialogTitle.vue",
"chars": 685,
"preview": "<script setup lang=\"ts\">\nimport type { DialogTitleProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimp"
},
{
"path": "apps/web/src/components/ui/dialog/DialogTrigger.vue",
"chars": 267,
"preview": "<script setup lang=\"ts\">\nimport type { DialogTriggerProps } from 'radix-vue'\nimport { DialogTrigger } from 'radix-vue'\n\n"
},
{
"path": "apps/web/src/components/ui/dialog/index.ts",
"chars": 561,
"preview": "export { default as Dialog } from './Dialog.vue'\nexport { default as DialogClose } from './DialogClose.vue'\nexport { def"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenu.vue",
"chars": 436,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'radix-vue'\nimport { Dropdown"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue",
"chars": 1277,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'radix-vue'\ni"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuContent.vue",
"chars": 1293,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuContentEmits, DropdownMenuContentProps } from 'radix-vue'\nimport type"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuGroup.vue",
"chars": 287,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuGroupProps } from 'radix-vue'\nimport { DropdownMenuGroup } from 'radi"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuItem.vue",
"chars": 904,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuItemProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuLabel.vue",
"chars": 696,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuLabelProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vu"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue",
"chars": 484,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from 'radix-vue'\nimpor"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue",
"chars": 1271,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'radix-vue'\nimport "
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue",
"chars": 557,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuSeparatorProps } from 'radix-vue'\nimport type { HTMLAttributes } from"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue",
"chars": 305,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defin"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuSub.vue",
"chars": 435,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubEmits, DropdownMenuSubProps } from 'radix-vue'\nimport {\n Dropdown"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue",
"chars": 1186,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'radix-vue'\nimpor"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue",
"chars": 905,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuSubTriggerProps } from 'radix-vue'\nimport type { HTMLAttributes } fro"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue",
"chars": 393,
"preview": "<script setup lang=\"ts\">\nimport type { DropdownMenuTriggerProps } from 'radix-vue'\nimport { DropdownMenuTrigger, useForw"
},
{
"path": "apps/web/src/components/ui/dropdown-menu/index.ts",
"chars": 1100,
"preview": "export { default as DropdownMenu } from './DropdownMenu.vue'\n\nexport { default as DropdownMenuCheckboxItem } from './Dro"
},
{
"path": "apps/web/src/components/ui/hover-card/HoverCard.vue",
"chars": 415,
"preview": "<script setup lang=\"ts\">\nimport type { HoverCardRootEmits, HoverCardRootProps } from 'radix-vue'\nimport { HoverCardRoot,"
},
{
"path": "apps/web/src/components/ui/hover-card/HoverCardContent.vue",
"chars": 1224,
"preview": "<script setup lang=\"ts\">\nimport type { HoverCardContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue"
},
{
"path": "apps/web/src/components/ui/hover-card/HoverCardTrigger.vue",
"chars": 282,
"preview": "<script setup lang=\"ts\">\nimport type { HoverCardTriggerProps } from 'radix-vue'\nimport { HoverCardTrigger } from 'radix-"
},
{
"path": "apps/web/src/components/ui/hover-card/index.ts",
"chars": 193,
"preview": "export { default as HoverCard } from './HoverCard.vue'\nexport { default as HoverCardContent } from './HoverCardContent.v"
},
{
"path": "apps/web/src/components/ui/input/Input.vue",
"chars": 1280,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { useVModel } from '@vueuse/core'\nimport { cn "
},
{
"path": "apps/web/src/components/ui/input/index.ts",
"chars": 47,
"preview": "export { default as Input } from './Input.vue'\n"
},
{
"path": "apps/web/src/components/ui/label/Label.vue",
"chars": 623,
"preview": "<script setup lang=\"ts\">\nimport type { LabelProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nimport { "
},
{
"path": "apps/web/src/components/ui/label/index.ts",
"chars": 47,
"preview": "export { default as Label } from './Label.vue'\n"
},
{
"path": "apps/web/src/components/ui/menubar/Menubar.vue",
"chars": 779,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarRootEmits, MenubarRootProps } from 'radix-vue'\nimport type { HTMLAttribute"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarCheckboxItem.vue",
"chars": 1208,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarCheckboxItemEmits, MenubarCheckboxItemProps } from 'radix-vue'\nimport type"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarContent.vue",
"chars": 1222,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarContentProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\n"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarGroup.vue",
"chars": 262,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarGroupProps } from 'radix-vue'\nimport { MenubarGroup } from 'radix-vue'\n\nco"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarItem.vue",
"chars": 939,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarItemEmits, MenubarItemProps } from 'radix-vue'\nimport type { HTMLAttribute"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarLabel.vue",
"chars": 456,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarLabelProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\nim"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarMenu.vue",
"chars": 257,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarMenuProps } from 'radix-vue'\nimport { MenubarMenu } from 'radix-vue'\n\ncons"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarRadioGroup.vue",
"chars": 450,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarRadioGroupEmits, MenubarRadioGroupProps } from 'radix-vue'\nimport {\n Menu"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarRadioItem.vue",
"chars": 1202,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarRadioItemEmits, MenubarRadioItemProps } from 'radix-vue'\nimport type { HTM"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarSeparator.vue",
"chars": 605,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarSeparatorProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarShortcut.vue",
"chars": 316,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defin"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarSub.vue",
"chars": 457,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarSubEmits } from 'radix-vue'\nimport { MenubarSub, useForwardPropsEmits } fr"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarSubContent.vue",
"chars": 1255,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarSubContentEmits, MenubarSubContentProps } from 'radix-vue'\nimport type { H"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarSubTrigger.vue",
"chars": 984,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarSubTriggerProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vu"
},
{
"path": "apps/web/src/components/ui/menubar/MenubarTrigger.vue",
"chars": 883,
"preview": "<script setup lang=\"ts\">\nimport type { MenubarTriggerProps } from 'radix-vue'\nimport type { HTMLAttributes } from 'vue'\n"
},
{
"path": "apps/web/src/components/ui/menubar/index.ts",
"chars": 971,
"preview": "export { default as Menubar } from './Menubar.vue'\nexport { default as MenubarCheckboxItem } from './MenubarCheckboxItem"
},
{
"path": "apps/web/src/components/ui/number-field/NumberField.vue",
"chars": 699,
"preview": "<script setup lang=\"ts\">\nimport type { NumberFieldRootEmits, NumberFieldRootProps } from 'radix-vue'\nimport type { HTMLA"
},
{
"path": "apps/web/src/components/ui/number-field/NumberFieldContent.vue",
"chars": 371,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = defin"
},
{
"path": "apps/web/src/components/ui/number-field/NumberFieldDecrement.vue",
"chars": 819,
"preview": "<script setup lang=\"ts\">\nimport type { NumberFieldDecrementProps } from 'radix-vue'\nimport type { HTMLAttributes } from "
},
{
"path": "apps/web/src/components/ui/number-field/NumberFieldIncrement.vue",
"chars": 818,
"preview": "<script setup lang=\"ts\">\nimport type { NumberFieldIncrementProps } from 'radix-vue'\nimport type { HTMLAttributes } from "
},
{
"path": "apps/web/src/components/ui/number-field/NumberFieldInput.vue",
"chars": 620,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { NumberFieldInput } from 'radix-vue'\nimport {"
},
{
"path": "apps/web/src/components/ui/number-field/index.ts",
"chars": 355,
"preview": "export { default as NumberField } from './NumberField.vue'\nexport { default as NumberFieldContent } from './NumberFieldC"
},
{
"path": "apps/web/src/components/ui/password-input/PasswordInput.vue",
"chars": 1526,
"preview": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { useVModel } from '@vueuse/core'\nimport { Eye"
},
{
"path": "apps/web/src/components/ui/password-input/index.ts",
"chars": 63,
"preview": "export { default as PasswordInput } from './PasswordInput.vue'\n"
},
{
"path": "apps/web/src/components/ui/popover/Popover.vue",
"chars": 401,
"preview": "<script setup lang=\"ts\">\nimport type { PopoverRootEmits, PopoverRootProps } from 'radix-vue'\nimport { PopoverRoot, useFo"
},
{
"path": "apps/web/src/components/ui/popover/PopoverContent.vue",
"chars": 1368,
"preview": "<script setup lang=\"ts\">\nimport type { PopoverContentEmits, PopoverContentProps } from 'radix-vue'\nimport type { HTMLAtt"
},
{
"path": "apps/web/src/components/ui/popover/PopoverTrigger.vue",
"chars": 272,
"preview": "<script setup lang=\"ts\">\nimport type { PopoverTriggerProps } from 'radix-vue'\nimport { PopoverTrigger } from 'radix-vue'"
},
{
"path": "apps/web/src/components/ui/popover/index.ts",
"chars": 181,
"preview": "export { default as Popover } from './Popover.vue'\nexport { default as PopoverContent } from './PopoverContent.vue'\nexpo"
},
{
"path": "apps/web/src/components/ui/progress/Progress.vue",
"chars": 718,
"preview": "<script setup lang=\"ts\">\nimport type { ProgressRootProps } from 'radix-vue'\nimport { ProgressIndicator, ProgressRoot } f"
},
{
"path": "apps/web/src/components/ui/progress/index.ts",
"chars": 53,
"preview": "export { default as progress } from './Progress.vue'\n"
},
{
"path": "apps/web/src/components/ui/radio-group/RadioGroup.vue",
"chars": 739,
"preview": "<script setup lang=\"ts\">\r\nimport type { RadioGroupRootEmits, RadioGroupRootProps } from 'reka-ui'\r\nimport type { HTMLAtt"
},
{
"path": "apps/web/src/components/ui/radio-group/RadioGroupItem.vue",
"chars": 1407,
"preview": "<script setup lang=\"ts\">\r\nimport type { RadioGroupItemProps } from 'reka-ui'\r\nimport type { HTMLAttributes } from 'vue'\r"
},
{
"path": "apps/web/src/components/ui/radio-group/index.ts",
"chars": 122,
"preview": "export { default as RadioGroup } from './RadioGroup.vue'\nexport { default as RadioGroupItem } from './RadioGroupItem.vue"
},
{
"path": "apps/web/src/components/ui/resizable/ResizableHandle.vue",
"chars": 1540,
"preview": "<script setup lang=\"ts\">\nimport type { SplitterResizeHandleEmits, SplitterResizeHandleProps } from 'reka-ui'\nimport type"
},
{
"path": "apps/web/src/components/ui/resizable/ResizablePanelGroup.vue",
"chars": 724,
"preview": "<script setup lang=\"ts\">\nimport type { SplitterGroupEmits, SplitterGroupProps } from 'reka-ui'\nimport type { HTMLAttribu"
}
]
// ... and 189 more files (download for full content)
About this extraction
This page contains the full source code of the doocs/md GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 389 files (939.4 KB), approximately 299.6k tokens, and a symbol index with 340 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.