├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── TRANSLATING_ZHTW.md ├── esbuild.config.mjs ├── main.ts ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── adapters │ ├── obsidian-excalidraw-plugin │ │ ├── index.ts │ │ └── types │ │ │ └── ExcalidrawAutomate.d.ts │ └── obsidian │ │ ├── index.ts │ │ └── types │ │ ├── PortalType.ts │ │ ├── TransactionRange.ts │ │ ├── canvas.d.ts │ │ └── obsidian.d.ts ├── dragUpdate.ts ├── file.ts ├── images │ ├── CardNoteCanvas.gif │ ├── CardNoteExcalidraw.gif │ ├── CardNoteFoldable.gif │ ├── CardNoteSearchView.gif │ └── CardNoteSection.gif ├── ui.ts ├── ui │ └── linkSettings.ts ├── utility.ts └── view │ ├── Index.svelte │ ├── cardSearchView.ts │ └── components │ ├── ButtonGroups.svelte │ ├── Card.svelte │ ├── ComputeLayout.svelte │ ├── Search.svelte │ ├── obsidian │ ├── obsidianMarkdown.svelte │ └── useComponent.ts │ └── searchInput.svelte ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "18.x" 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install --legacy-peer-deps 23 | npm run build 24 | 25 | - name: Create release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | tag="${GITHUB_REF#refs/tags/}" 30 | 31 | gh release create "$tag" \ 32 | --title="$tag" \ 33 | --draft \ 34 | main.js manifest.json styles.css -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Chen Yi Chen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | CardNote 3 |

4 | 5 |

6 | GitHub star count 7 | Open issues on GitHub 8 | List of contributors 9 |
10 | 11 | MIT license 12 |

13 |

⚡ Extract your thoughts more quickly

14 |

15 | 16 | This tool help you create a new note and insert its link into the canvas. Let you quickly build visualized notes on [Obsidian Canvas](https://obsidian.md/canvas) and the [obsidian-excalidraw-plugin](https://github.com/zsviczian/obsidian-excalidraw-plugin) by *"Drag and Drop"*. 17 | 18 | 19 | # Features 20 | - By creating a new note, referencing blocks, or cutting, quickly excerpt the blocks you want to extract, and draw links on the canvas. 21 | - Dragging the first line drags the entire block. 22 | 23 | Support foldable blocks, markdown syntax, 24 | 25 | e.g. heading, paragraph, bullet list ... 26 | 27 | # Translate 28 | [繁體中文](https://github.com/cycsd/obsidian-card-note/blob/main/TRANSLATING_ZHTW.md) 29 | 30 | # Example 31 | ## Obsidian Canvas 32 | ![ExampleCanvas](src/images/CardNoteCanvas.gif) 33 | ## Excalidraw 34 | ![ExampleExcalidraw](src/images/CardNoteExcalidraw.gif) 35 | ## Extract selections 36 | ![ExampleSelection](src/images/CardNoteSection.gif) 37 | ## Extract foldable range 38 | ![ExampleFoldable](src/images/CardNoteFoldable.gif) 39 | 40 | ## Cards View 41 | ![Crads View](src/images/CardNoteSearchView.gif) 42 | 43 | # Alternative 44 | ### Drag-and-Drop 45 | You could replace drag-and-drop feature by steps below 46 | 1. Right-click to open the tools menu in the editor 47 | 2. Use Obsidian Note Composer to extract your thoughts into a new note 48 | 3. In [Obsidian Canvas](https://obsidian.md/canvas) or [obsidian-excalidraw-plugin](https://github.com/zsviczian/obsidian-excalidraw-plugin), Right-click to open the tools menu. 49 | 4. Insert markdown file to canvas. 50 | 5. You can replace Right-click by setting Note Composer hotkey, and replace steps 3 ~ 4 by dragging link in editor. 51 | 52 | ### Cards View 53 | [obsidian-cards-view-plugin](https://github.com/jillro/obsidian-cards-view-plugin): display a beautiful masonry layout for your notes. -------------------------------------------------------------------------------- /TRANSLATING_ZHTW.md: -------------------------------------------------------------------------------- 1 |

2 | CardNote 3 |

4 | 5 |

6 | GitHub star count 7 | Open issues on GitHub 8 | List of contributors 9 |
10 | 11 | MIT license 12 |

13 |

⚡ Extract your thoughts more quickly

14 |

15 | 16 | 此工具將創建筆記及在圖像化工具中插入筆記的功能整合為一, 17 | 藉由滑鼠拖拉施放協助你更快速的在[Obsidian Canvas](https://obsidian.md/canvas) 及 [obsidian-excalidraw-plugin](https://github.com/zsviczian/obsidian-excalidraw-plugin)上建立圖像化的筆記 18 | 19 | # 功能 20 | - 藉由創建新筆記,參考段落或剪取,快速的節錄你想要參考區塊,並將連結繪製在畫布上 21 | - 拖動第一行即拖動整個區塊 22 | 23 | 可摺疊區塊、markdown語法, 24 | 25 | e.g. 標題、段落、清單列表... 26 | 27 | # 範例 28 | ## Obsidian Canvas 29 | ![ExampleCanvas](src/images/CardNoteCanvas.gif) 30 | ## Excalidraw 31 | ![ExampleExcalidraw](src/images/CardNoteExcalidraw.gif) 32 | ## Extract selections 33 | ![ExampleSelection](src/images/CardNoteSection.gif) 34 | ## Extract foldable range 35 | ![ExampleFoldable](src/images/CardNoteFoldable.gif) 36 | 37 | # 可替代的 38 | ### Drag-and-Drop 39 | 你可以解由下方的步驟替代此插件的Drag-and-Drop功能 40 | 1. 選擇想要整理的段落,並在編輯器上右鍵開啟工具菜單 41 | 2. 點選擷取目前內容至其他檔案 42 | 3. 在 [Obsidian Canvas](https://obsidian.md/canvas) 或 [obsidian-excalidraw-plugin](https://github.com/zsviczian/obsidian-excalidraw-plugin)點選右鍵開啟工具菜單 43 | 4. 點選插入檔案,將剛剛創建的檔案插入至畫布中 44 | 5. 可以藉由拖曳連結取代 3~4 步驟,且藉由設定 Note Composer快速鍵取代右鍵操作 45 | 46 | ### Cards View 47 | [obsidian-cards-view-plugin](https://github.com/jillro/obsidian-cards-view-plugin): 一個可將你的筆記變成美觀的卡片插件 -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import esbuildSvelte from "esbuild-svelte"; 5 | import sveltePreprocess from "svelte-preprocess"; 6 | 7 | const banner = 8 | `/* 9 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 10 | if you want to view the source, please visit the github repository of this plugin 11 | */ 12 | `; 13 | 14 | const prod = (process.argv[2] === "production"); 15 | 16 | const context = await esbuild.context({ 17 | banner: { 18 | js: banner, 19 | }, 20 | entryPoints: ["main.ts"], 21 | bundle: true, 22 | external: [ 23 | "obsidian", 24 | "electron", 25 | "@codemirror/autocomplete", 26 | "@codemirror/collab", 27 | "@codemirror/commands", 28 | "@codemirror/language", 29 | "@codemirror/lint", 30 | "@codemirror/search", 31 | "@codemirror/state", 32 | "@codemirror/view", 33 | "@lezer/common", 34 | "@lezer/highlight", 35 | "@lezer/lr", 36 | ...builtins], 37 | format: "cjs", 38 | target: "es2018", 39 | logLevel: "info", 40 | sourcemap: prod ? false : "inline", 41 | treeShaking: true, 42 | outfile: "main.js", 43 | plugins: [ 44 | esbuildSvelte({ 45 | compilerOptions: { css: true }, 46 | preprocess: sveltePreprocess(), 47 | }), 48 | ], 49 | }); 50 | 51 | // esbuild 52 | // .build({ 53 | // plugins: [ 54 | // esbuildSvelte({ 55 | // compilerOptions: { css: true }, 56 | // preprocess: sveltePreprocess(), 57 | // }), 58 | // ], 59 | // // ... 60 | // }).catch(() => process.exit(1)); 61 | 62 | if (prod) { 63 | await context.rebuild(); 64 | process.exit(0); 65 | } else { 66 | await context.watch(); 67 | } -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import type { BlockCache, CacheItem, HeadingCache, LinkCache, OpenViewState, WorkspaceLeaf, } from "obsidian" 2 | import { 3 | App, 4 | MarkdownRenderer, 5 | Plugin, 6 | PluginSettingTab, 7 | Setting, 8 | TFile, 9 | TextFileView, 10 | WorkspaceSplit, 11 | normalizePath, 12 | } from "obsidian"; 13 | import type { LinkFilePath, LinkPath } from "src/dragUpdate"; 14 | import { dragExtension } from "src/dragUpdate"; 15 | import { getOffset, isCanvasFileNode, isCanvasEditorNode, isObsidianCanvasView } from "src/adapters/obsidian"; 16 | import type { FileInfo, LinkInfo, RequiredProperties, } from "src/utility"; 17 | import { FILENAMEREPLACE, HEADINGREPLACE, LinkToChanges, createFullPath } from "src/utility"; 18 | import type { CanvasData, CanvasFileData, AllCanvasNodeData } from "obsidian/canvas"; 19 | import { isExcalidrawView } from "src/adapters/obsidian-excalidraw-plugin"; 20 | import { CardSearchView, VIEW_TYPE_CARDNOTESEARCH } from "src/view/cardSearchView"; 21 | import { LinkSettingModel } from "src/ui/linkSettings"; 22 | 23 | 24 | 25 | interface CardNoteSettings { 26 | dragSymbol: string, 27 | dragSymbolSize?: number, 28 | defaultFolder: string, 29 | columnWidth: number, 30 | rowHeight: number, 31 | autoLink: boolean, 32 | arrowTo: 'from' | 'end' | 'both' | 'none', 33 | defaultLinkLabel?: string, 34 | query: string, 35 | useRegex: boolean, 36 | matchCase: boolean, 37 | showSearchDetail: boolean, 38 | include: string, 39 | exclude: string, 40 | } 41 | 42 | const DEFAULT_SETTINGS: CardNoteSettings = { 43 | dragSymbol: "💔", 44 | dragSymbolSize: 18, 45 | defaultFolder: "", 46 | columnWidth: 250, 47 | rowHeight: 250, 48 | autoLink: false, 49 | arrowTo: 'end', 50 | query: "string", 51 | useRegex: false, 52 | matchCase: false, 53 | showSearchDetail: false, 54 | include: "", 55 | exclude: "", 56 | }; 57 | export default class CardNote extends Plugin { 58 | settings: CardNoteSettings = DEFAULT_SETTINGS; 59 | 60 | async onload() { 61 | await this.loadSettings(); 62 | this.registerEditorExtension(dragExtension(this)); 63 | this.registerView( 64 | VIEW_TYPE_CARDNOTESEARCH, 65 | (leaf) => new CardSearchView(leaf, this) 66 | ); 67 | this.addRibbonIcon("scan-search", 68 | "Search Notes", 69 | () => this.activateView()); 70 | this.addCommands(); 71 | // This adds a settings tab so the user can configure various aspects of the plugin 72 | this.addSettingTab(new CardNoteTab(this.app, this)); 73 | } 74 | addCommands() { 75 | this.addCommand({ 76 | id: 'set-label', 77 | name: 'Set Default Label', 78 | callback: () => { 79 | new LinkSettingModel(this, (value) => { 80 | this.settings.defaultLinkLabel = value; 81 | this.saveSettings(); 82 | }).open() 83 | } 84 | }) 85 | this.addCommand({ 86 | id: 'auto-link', 87 | name: 'Enable Auto Link', 88 | checkCallback: this.changeAutoLinkSettings( 89 | () => !this.settings.autoLink, 90 | () => { 91 | this.settings.autoLink = true 92 | } 93 | ) 94 | }) 95 | this.addCommand({ 96 | id: 'cancel-auto-link', 97 | name: 'Disable Auto Link', 98 | checkCallback: this.changeAutoLinkSettings( 99 | () => this.settings.autoLink, 100 | () => { 101 | this.settings.autoLink = false 102 | }) 103 | }) 104 | this.addCommand({ 105 | id: 'arrow-to-from', 106 | name: 'Arrow to From', 107 | checkCallback: this.changeAutoLinkSettings( 108 | () => this.settings.arrowTo !== 'from', 109 | () => { 110 | this.settings.arrowTo = 'from' 111 | }) 112 | }) 113 | this.addCommand({ 114 | id: 'arrow-to-end', 115 | name: 'Arrow to End', 116 | checkCallback: this.changeAutoLinkSettings( 117 | () => this.settings.arrowTo !== 'end', 118 | () => { 119 | this.settings.arrowTo = 'end' 120 | }) 121 | }) 122 | this.addCommand({ 123 | id: 'arrow-to-both', 124 | name: 'Arrow to Both', 125 | checkCallback: this.changeAutoLinkSettings( 126 | () => this.settings.arrowTo !== 'both', 127 | () => { 128 | this.settings.arrowTo = 'both' 129 | }) 130 | }) 131 | this.addCommand({ 132 | id: 'arrow-to-none', 133 | name: 'Arrow to None', 134 | checkCallback: this.changeAutoLinkSettings( 135 | () => this.settings.arrowTo !== 'none', 136 | () => { 137 | this.settings.arrowTo = 'none' 138 | }) 139 | }) 140 | 141 | } 142 | changeAutoLinkSettings(check: () => boolean, action: () => void) { 143 | return (checking: boolean) => { 144 | if (check()) { 145 | if (!checking) { 146 | action(); 147 | this.saveSettings(); 148 | } 149 | return true 150 | } 151 | return false 152 | } 153 | } 154 | async activateView() { 155 | const { workspace } = this.app; 156 | const leaves = workspace.getLeavesOfType(VIEW_TYPE_CARDNOTESEARCH), 157 | createNewLeaf = async () => { 158 | const newLeaf = workspace.getLeaf('split'); 159 | await newLeaf?.setViewState({ 160 | type: VIEW_TYPE_CARDNOTESEARCH, 161 | active: true, 162 | }) 163 | return newLeaf 164 | }; 165 | 166 | 167 | let leaf = leaves.length > 0 168 | ? leaves[0] 169 | : await createNewLeaf() 170 | 171 | workspace.revealLeaf(leaf!) 172 | 173 | } 174 | onunload() { } 175 | 176 | async loadSettings() { 177 | this.settings = Object.assign( 178 | {}, 179 | DEFAULT_SETTINGS, 180 | await this.loadData() 181 | ); 182 | } 183 | 184 | async saveSettings() { 185 | await this.saveData(this.settings); 186 | } 187 | createPath(file: TFile, subpath?: string, displayText?: string) { 188 | return this.app.metadataCache.fileToLinktext( 189 | file, 190 | file.path, 191 | file.extension === 'md' 192 | ) 193 | } 194 | createLinkText(file: TFile, subpath?: string, displayText?: string): RequiredProperties { 195 | const fileLinkPath = this.createPath(file); 196 | const sub = subpath ?? ''; 197 | const fullLinkPath = `${fileLinkPath}${sub}` 198 | const useMarkdownLink = this.app.vault.getConfig("useMarkdownLinks"); 199 | const markdownLink = () => { 200 | const display = displayText ?? fullLinkPath; 201 | return `[${display}](${fullLinkPath.replace(' ', '%20')})`; 202 | } 203 | const wikiLink = () => { 204 | const display = displayText ? `|${displayText}` : ''; 205 | return `[[${fullLinkPath}${display}]]`; 206 | } 207 | //this.app.fileManager.generateMarkdownLink() 208 | 209 | const linkText = useMarkdownLink ? markdownLink() : wikiLink(); 210 | return { 211 | path: fileLinkPath, 212 | subpath, 213 | file, 214 | text: linkText, 215 | displayText, 216 | } 217 | 218 | } 219 | getActiveEditorFile() { 220 | const view = this.app.workspace.getActiveViewOfType(TextFileView); 221 | 222 | if (view) { 223 | //return canvas node because excalidraw also use canvas node and need to get narrow to block offset 224 | if (isObsidianCanvasView(view)) { 225 | const [selectNode] = view.canvas.selection; 226 | // selection is canvas node not the json canvas node data, 227 | // so property type = 'file' | 'text' | 'group' is in the unknownData property 228 | //const file = selectNode?.file as TFile | undefined; 229 | return isCanvasEditorNode(selectNode) 230 | ? { fileEditor: selectNode, offset: getOffset(selectNode) } 231 | : undefined 232 | } 233 | if (isExcalidrawView(view)) { 234 | const excalidrawShape = view.getViewSelectedElements().first() 235 | const embeddable = view.getActiveEmbeddable(); 236 | 237 | return isCanvasEditorNode(embeddable?.node) 238 | ? { 239 | fileEditor: { 240 | ...embeddable.node, 241 | id: excalidrawShape?.id ?? embeddable.node.id, 242 | }, offset: getOffset(embeddable.node) 243 | } : undefined 244 | } 245 | 246 | } 247 | return { fileEditor: this.app.workspace.activeEditor, offset: 0 } 248 | } 249 | 250 | async checkFileName(file: FileInfo): Promise { 251 | const fileName = file.fileName; 252 | if (fileName.length === 0) { 253 | return new Error("File Name can not be empty!"); 254 | } 255 | else if (fileName.endsWith(" ")) { 256 | return new Error("File Name can not end with white space!"); 257 | } 258 | else { 259 | const matchInvalidSymbol = FILENAMEREPLACE().exec(fileName); 260 | if (matchInvalidSymbol) { 261 | return new Error(`File Name can not contains symbols [!"#$%&()*+,.:;<=>?@^\`{|}~/[]\r\n]`); 262 | } 263 | } 264 | const filePathUncheck = createFullPath(file) 265 | const normalFilePath = normalizePath(filePathUncheck); 266 | this.app.vault.checkPath(normalFilePath) 267 | if (await this.app.vault.adapter.exists(normalFilePath)) { 268 | return new Error("File exist!"); 269 | } 270 | return { ...file, fileName: normalFilePath }; 271 | } 272 | updateInternalLinks(linkMap: Map, newPath: (link: LinkInfo) => string) { 273 | const changes = LinkToChanges(linkMap, newPath); 274 | //can not update canvas 275 | this.app.fileManager.updateInternalLinks(changes); 276 | } 277 | renameCanvasSubpath(origin: LinkFilePath, newFile: LinkFilePath) { 278 | const canvasUpdater = this.app.fileManager.linkUpdaters.canvas; 279 | if (origin.file.path === newFile.file.path && origin.subpath !== newFile.subpath) { 280 | canvasUpdater.renameSubpath(origin.file, origin.subpath ?? "", newFile.subpath ?? ""); 281 | } 282 | } 283 | getCanvas(filter?: (canvasPath: string, embed: { file?: string, subpath?: string }) => boolean) { 284 | const canvasUpdater = this.app.fileManager.linkUpdaters.canvas; 285 | const canvases = canvasUpdater.canvas.index.getAll(); 286 | const queue: string[] = []; 287 | for (const canvasFilePath in canvases) { 288 | const canvasCache = canvases[canvasFilePath]; 289 | const find = canvasCache.embeds.find(embed => filter?.(canvasFilePath, embed) ?? true); 290 | if (find) { 291 | queue.push(canvasFilePath); 292 | } 293 | } 294 | return queue 295 | } 296 | updateCanvasNodes(canvasPath: string, newNode: (node: AllCanvasNodeData) => AllCanvasNodeData) { 297 | const canvasFile = this.app.vault.getAbstractFileByPath(canvasPath); 298 | if (canvasFile instanceof TFile && canvasFile.extension === 'canvas') { 299 | return this.app.vault.process(canvasFile, data => { 300 | const canvasData = JSON.parse(data) as CanvasData; 301 | const nodeUpdate = canvasData.nodes.map(newNode) 302 | const newData: CanvasData = { 303 | edges: canvasData.edges, 304 | nodes: nodeUpdate, 305 | } 306 | return JSON.stringify(newData); 307 | }) 308 | } 309 | } 310 | updateCanvasLinks( 311 | canvasPathSet: string[], 312 | map: (node: CanvasFileData) => CanvasFileData 313 | ) { 314 | const result = canvasPathSet.map(canvasPath => this.updateCanvasNodes(canvasPath, node => { 315 | if (node.type === 'file') { 316 | return map(node) 317 | } 318 | return node 319 | })) 320 | return Promise.all(result) 321 | } 322 | findLinkBlocks(file: TFile, from: number, to: number): [BlockCache[], HeadingCache[]] { 323 | const cache = this.app.metadataCache.getFileCache(file); 324 | const blocks = cache?.blocks; 325 | const inRange = (item: CacheItem) => { 326 | //const start = item.position.start; 327 | const end = item.position.end; 328 | //return from <= start.offset && end.offset <= to 329 | return end.offset > from 330 | && end.offset <= to 331 | } 332 | const blocksInRange: BlockCache[] = []; 333 | for (const blockName in blocks) { 334 | const blockInfo = blocks[blockName]; 335 | if (inRange(blockInfo)) { 336 | blocksInRange.push(blockInfo) 337 | } 338 | } 339 | const headingInRange: HeadingCache[] = cache?.headings?.filter(inRange) ?? []; 340 | return [blocksInRange, headingInRange] 341 | } 342 | createLinkInfo(cache: LinkCache): LinkInfo { 343 | const normalizeLink = cache.link.replace(/\u00A0/, '').normalize(); 344 | const path = normalizeLink.split('#')[0]; 345 | const subpath = normalizeLink.substring(path.length); 346 | return { 347 | path, 348 | subpath, 349 | link: cache, 350 | } 351 | } 352 | findLinks(targetFile: TFile, match: (link: LinkPath) => boolean): Promise<[LinkInfo[] | undefined, Map]> { 353 | return new Promise(res => { 354 | const cache = this.app.metadataCache; 355 | const fileManger = this.app.fileManager; 356 | const linkMap = new Map(); 357 | fileManger.iterateAllRefs((fileName, linkCache) => { 358 | fileName.normalize() 359 | //linkPath = link target (file Name) 360 | const linkInfo = this.createLinkInfo(linkCache); 361 | const { path, subpath } = linkInfo; 362 | //getFirstLinkpathDest: 得到來源檔名中此link path連結到哪個file 363 | if (match({ path, subpath, file: cache.getFirstLinkpathDest(path, fileName) ?? undefined })) { 364 | const links = linkMap.get(fileName); 365 | if (links) { 366 | links.push(linkInfo); 367 | } 368 | else { 369 | linkMap.set(fileName, [linkInfo]); 370 | } 371 | } 372 | }) 373 | const selfLink = linkMap.get(targetFile.path); 374 | linkMap.delete(targetFile.path); 375 | res([selfLink, linkMap]); 376 | }) 377 | } 378 | normalizeHeadingToLinkText(heading: string) { 379 | //const useMarkdownLink = this.app.vault.getConfig("useMarkdownLinks"), 380 | const path = heading.replace(HEADINGREPLACE(), ' ').replace(/\s+/g, ' '); 381 | 382 | return path 383 | } 384 | replaceSpaceInLinkText(link: string) { 385 | const useMarkdownLink = this.app.vault.getConfig("useMarkdownLinks"); 386 | return useMarkdownLink 387 | ? link.replace(' ', '%20') 388 | : link 389 | } 390 | createRandomHexString(length = 6) { 391 | const id = [...Array(length).keys()] 392 | .map(_ => (16 * Math.random() | 0).toString(16)).join('') 393 | return id 394 | } 395 | listenDragAndDrop(e: DragEvent, content: string, dropEvent: (e: DragEvent) => void) { 396 | const trim = content.trim(), 397 | display = trim.length > 600 ? trim.substring(0, 600).concat(' ...') : trim; 398 | 399 | const floatingSplits = this.app.workspace.floatingSplit as WorkspaceSplit, 400 | popoutWindows = floatingSplits.children.map(win => win.containerEl), 401 | allWindows = [this.app.workspace.containerEl].concat(popoutWindows), 402 | eventListeners = allWindows.map(container => this.createDraggingAndDropEvent(e, container, display, dropEvent)) 403 | 404 | return { 405 | reset: () => eventListeners.forEach(listen => listen.reset()), 406 | } 407 | } 408 | createDraggingAndDropEvent( 409 | e: DragEvent, 410 | container: HTMLElement, 411 | content: string, 412 | dropEvent: (e: DragEvent) => void) { 413 | 414 | const dragContentEle = document.createElement('div'); 415 | //plugin's css style doesn't apply to popup window 416 | //so assign style via js in this place. 417 | dragContentEle.hide(); 418 | dragContentEle.style.transform = `translate(${e.clientX}px,${e.clientY}px)`; 419 | dragContentEle.style.width = '300px'; 420 | dragContentEle.style.height = 'min-content'; 421 | //dragContentEle.style.minHeight = '200px'; 422 | dragContentEle.style.position = 'absolute'; 423 | dragContentEle.style.padding = '5px 25px'; 424 | dragContentEle.style.borderWidth = '3px'; 425 | dragContentEle.style.borderRadius = '10px'; 426 | dragContentEle.style.border = 'solid'; 427 | //https://stackoverflow.com/questions/55095367/while-drag-over-the-absolute-element-drag-leave-event-has-been-trigger-continuo 428 | //closing pointer events can prevent dragbackground's dragleave event from being triggerd when the mouse move quickly from background to dragcontent element. 429 | dragContentEle.style.pointerEvents = 'none'; 430 | 431 | const dragoverBackground = document.createElement('div'); 432 | 433 | dragoverBackground.setCssStyles({ 434 | opacity: '0', 435 | width: '100%', 436 | height: '100%', 437 | position: 'fixed' 438 | }) 439 | 440 | MarkdownRenderer.render( 441 | this.app, 442 | content, 443 | dragContentEle, 444 | '', 445 | this 446 | ) 447 | //need to add dragoverBackground to the container, 448 | //let your dragover event be triggerd correctly when your mouse move over the embedded iframe. 449 | container.appendChild(dragoverBackground); 450 | container.appendChild(dragContentEle); 451 | 452 | const showDragContent = (e: DragEvent) => { 453 | dragContentEle.show(); 454 | //e.preventDefault(); 455 | } 456 | const moveDragContent = (e: DragEvent) => { 457 | const x = e.clientX, 458 | y = e.clientY; 459 | dragContentEle.style.transform = `translate(${x}px,${y}px)`; 460 | //https://stackoverflow.com/questions/27361925/unable-to-detect-the-drop-event-in-chrome-extension-when-dropped-a-file 461 | //use preventDefault here then the drop event will trigger correctly 462 | e.preventDefault(); 463 | } 464 | const hideDragContent = (e: DragEvent) => { 465 | //e.preventDefault(); 466 | // the dragleave event will be triggerd by child elements in the container not only the background element. 467 | if (e.target === dragoverBackground) { 468 | dragContentEle.hide(); 469 | } 470 | } 471 | 472 | container.addEventListener('dragenter', showDragContent); 473 | container.addEventListener('dragover', moveDragContent); 474 | container.addEventListener('dragleave', hideDragContent); 475 | container.addEventListener('drop', dropEvent) 476 | 477 | return { 478 | reset: () => { 479 | container.removeChild(dragContentEle); 480 | container.removeChild(dragoverBackground); 481 | container.removeEventListener('drop', dropEvent) 482 | container.removeEventListener('dragover', moveDragContent); 483 | container.removeEventListener('dragenter', showDragContent); 484 | container.removeEventListener('dragleave', hideDragContent); 485 | } 486 | } 487 | } 488 | 489 | getDropView(e: DragEvent) { 490 | const locate = this.app.workspace.getDropLocation(e), 491 | target = locate.children.find(child => child.tabHeaderEl.className.contains("active")), 492 | drawView = target?.view; 493 | return drawView 494 | } 495 | onClickOpenFile(e: MouseEvent, file: TFile, openState?: OpenViewState) { 496 | const isFnKey = () => e.ctrlKey || e.metaKey; 497 | this.app.workspace.getLeaf( 498 | isFnKey() && e.shiftKey && e.altKey ? 'window' 499 | : isFnKey() && e.altKey ? 'split' 500 | : isFnKey() ? 'tab' 501 | : false 502 | ) 503 | .openFile(file, openState) 504 | } 505 | arrowToFrom() { 506 | return this.settings.arrowTo == 'both' || this.settings.arrowTo == 'from' 507 | } 508 | arrowToEnd() { 509 | return this.settings.arrowTo == 'both' || this.settings.arrowTo == 'end' 510 | } 511 | } 512 | 513 | class CardNoteTab extends PluginSettingTab { 514 | plugin: CardNote; 515 | 516 | constructor(app: App, plugin: CardNote) { 517 | super(app, plugin); 518 | this.plugin = plugin; 519 | } 520 | 521 | display(): void { 522 | const { containerEl } = this; 523 | containerEl.empty(); 524 | new Setting(containerEl) 525 | .setName("Drag symbol") 526 | .setDesc("You can set your prefer drag symbol here") 527 | .addText((text) => 528 | text 529 | .setPlaceholder("Enter your drag symbol here") 530 | .setValue(this.plugin.settings.dragSymbol) 531 | .onChange(async (value) => { 532 | this.plugin.settings.dragSymbol = value; 533 | await this.plugin.saveSettings(); 534 | }) 535 | ); 536 | this.addSizeSetting(); 537 | new Setting(containerEl) 538 | .setName("Default folder") 539 | .setDesc("Default loction for new note. if empty, new note will be created in the vault root.") 540 | .addText((text) => 541 | text 542 | .setPlaceholder("/sub folder name") 543 | .setValue(this.plugin.settings.defaultFolder) 544 | .onChange(async (value) => { 545 | this.plugin.settings.defaultFolder = value; 546 | await this.plugin.saveSettings(); 547 | }) 548 | ); 549 | 550 | } 551 | addSizeSetting() { 552 | const desc = (value?: number) => { 553 | return `Change your symbol size. Current size is ${value ?? this.plugin.settings.dragSymbolSize}.(min=1 max=100)`; 554 | } 555 | const sizeSetting = new Setting(this.containerEl) 556 | .setName("Symbol size (px)") 557 | .setDesc(desc()) 558 | .addSlider(slider => { 559 | slider 560 | .setLimits(1, 100, 1) 561 | .setValue(this.plugin.settings.dragSymbolSize ?? 18) 562 | .onChange(async (value) => { 563 | sizeSetting.setDesc(desc(value)); 564 | this.plugin.settings.dragSymbolSize = value; 565 | await this.plugin.saveSettings(); 566 | }) 567 | .setDynamicTooltip() 568 | } 569 | ); 570 | } 571 | 572 | } 573 | 574 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "card-note", 3 | "name": "CardNote", 4 | "version": "1.2.0", 5 | "minAppVersion": "1.5.11", 6 | "description": "Help you quickly extract your thoughts in the Canvas and Excalidraw", 7 | "author": "cycsd", 8 | "authorUrl": "https://github.com/cycsd", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "card-note-plugin", 3 | "version": "1.2.0", 4 | "description": "Help you quickly extract your thought in the Canvas and Excalidraw", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@tsconfig/svelte": "^5.0.2", 16 | "@types/lodash": "^4.17.6", 17 | "@types/node": "^16.11.6", 18 | "@typescript-eslint/eslint-plugin": "5.29.0", 19 | "@typescript-eslint/parser": "5.29.0", 20 | "builtin-modules": "3.3.0", 21 | "esbuild": "0.17.3", 22 | "esbuild-svelte": "^0.8.0", 23 | "obsidian": "latest", 24 | "obsidian-excalidraw-plugin": "latest", 25 | "svelte": "^4.2.12", 26 | "svelte-preprocess": "^5.1.3", 27 | "svelte-virtualized-auto-sizer": "^1.0.0", 28 | "svelte-window": "^1.2.5", 29 | "tslib": "2.4.0", 30 | "typescript": "5.4.3" 31 | }, 32 | "dependencies": { 33 | "codemirror": "^6.0.1", 34 | "lodash": "^4.17.21" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/adapters/obsidian-excalidraw-plugin/index.ts: -------------------------------------------------------------------------------- 1 | //import { ExcalidrawLib } from 'obsidian-excalidraw-plugin/lib/typings/ExcalidrawLib'; 2 | import CardNote from "main"; 3 | import "obsidian"; 4 | import { TFile, WorkspaceLeaf } from "obsidian"; 5 | export type { ExcalidrawBindableElement, ExcalidrawElement, FileId, FillStyle, StrokeRoundness, StrokeStyle } from "@zsviczian/excalidraw/types/element/types"; 6 | export type { ExcalidrawImperativeAPI, Point } from "@zsviczian/excalidraw/types/types"; 7 | import { getEA as excalidrawGetEA } from "obsidian-excalidraw-plugin"; 8 | import { ExcalidrawAutomate } from "obsidian-excalidraw-plugin/lib/ExcalidrawAutomate" 9 | //import { ExcalidrawView } from "obsidian-excalidraw-plugin/lib/ExcalidrawView"; 10 | import type { ExcalidrawView } from 'obsidian-excalidraw-plugin/lib/ExcalidrawView'; 11 | //import type { ObsidianCanvasNode } from "obsidian-excalidraw-plugin/lib/utils/CanvasNodeFactory"; 12 | import type { ObsidianMarkdownEmbeded } from "./types/ExcalidrawAutomate"; 13 | //no export in source file 14 | //import { ExcalidrawLibs } from 'src/adapters/obsidian-excalidraw-plugin/types/ExcalidrawLib' 15 | 16 | 17 | 18 | 19 | export const VIEW_TYPE_EXCALIDRAW = "excalidraw"; 20 | export function getEA(view?: any): ExcalidrawAutomate { 21 | return excalidrawGetEA(view); 22 | } 23 | export function isExcalidrawView(view: any): view is ExcalidrawView { 24 | return view.getViewType() === VIEW_TYPE_EXCALIDRAW; 25 | } 26 | export function isObsidianMarkdownEmbeded(value: any): value is Required { 27 | // leaf: WorkspaceLeaf; 28 | // node ?: ObsidianCanvasNode; 29 | return 'leaf' in value && 'node' in value 30 | } 31 | const MAX_IMAGE_SIZE = 500; 32 | export async function insertEmbeddableOnDrawing(event: DragEvent, view: ExcalidrawView, fileLink: string, file: TFile, plugin: CardNote) { 33 | try { 34 | const ea = getEA(); 35 | const eaView = ea.setView(view); 36 | //@ts-ignore 37 | const pos = ExcalidrawLib.viewportCoordsToSceneCoords({ 38 | clientX: event.clientX, 39 | clientY: event.clientY 40 | }, eaView.excalidrawAPI.getAppState()) 41 | const id = ea.addEmbeddable( 42 | pos.x, 43 | pos.y, 44 | MAX_IMAGE_SIZE, 45 | MAX_IMAGE_SIZE, 46 | fileLink, 47 | file 48 | ) 49 | await ea.addElementsToView(false, true, true); 50 | ea.selectElementsInView([id]); 51 | return id 52 | //const eb = ExcalidrawLib; 53 | //const api = ea.getExcalidrawAPI(); 54 | //const appState = api.getAppState(); 55 | //const { width, height, offsetLeft, offsetTop } = appState; 56 | //console.log("getViewState", appState); 57 | //@ts-ignore 58 | // const position = excalidrawLib.sceneCoordsToViewportCoords({ 59 | // clientX: width / 2 + offsetLeft, 60 | // clientY: height / 2 + offsetTop, 61 | // }, appState); 62 | //insertEmbeddableToView() 63 | } catch (error) { 64 | console.log(error); 65 | } 66 | 67 | } 68 | 69 | export async function createTextOnDrawing(event: DragEvent, view: ExcalidrawView, text: string, plugin: CardNote) { 70 | try { 71 | const ea = getEA(); 72 | const eaView = ea.setView(view); 73 | const appState = view.excalidrawAPI.getAppState(); 74 | ea.style.strokeColor = appState.currentItemStrokeColor ?? "black"; 75 | ea.style.opacity = appState.currentItemOpacity ?? 1; 76 | ea.style.fontFamily = appState.currentItemFontFamily ?? 1; 77 | ea.style.fontSize = appState.currentItemFontSize ?? 20; 78 | ea.style.textAlign = appState.currentItemTextAlign ?? "left"; 79 | //@ts-ignore 80 | const pos = ExcalidrawLib.viewportCoordsToSceneCoords({ 81 | clientX: event.clientX, 82 | clientY: event.clientY 83 | }, eaView.excalidrawAPI.getAppState()) 84 | const id = ea.addText( 85 | pos.x, 86 | pos.y, 87 | text, 88 | ) 89 | await view.addElements(ea.getElements(), false, true, undefined, true); 90 | return id 91 | 92 | } catch (error) { 93 | console.log(error); 94 | } 95 | 96 | } 97 | export async function addLink(fromNodeId: string, toNodeId: string, view: ExcalidrawView, plugin: CardNote) { 98 | try { 99 | const ea = getEA(); 100 | const eaView = ea.setView(view); 101 | ea.copyViewElementsToEAforEditing(ea.getViewElements()); 102 | const edgeId = ea.connectObjects(fromNodeId, null, toNodeId, null, { 103 | startArrowHead: plugin.arrowToFrom() ? 'arrow' : null, 104 | endArrowHead: plugin.arrowToEnd() ? 'arrow' : null, 105 | }); 106 | const label = plugin.settings.defaultLinkLabel; 107 | if (label) { 108 | const labelId = ea.addLabelToLine(edgeId, label); 109 | const edge = ea.elementsDict[edgeId]; 110 | edge.boundElements.push( 111 | { 112 | type: "text", 113 | id: labelId, 114 | } 115 | ); 116 | const labelElement = ea.elementsDict[labelId]; 117 | labelElement.containerId = edgeId; 118 | labelElement.angle = 0; 119 | } 120 | await ea.addElementsToView(false, true, true); 121 | } catch (error) { 122 | console.log(error); 123 | } 124 | } -------------------------------------------------------------------------------- /src/adapters/obsidian-excalidraw-plugin/types/ExcalidrawAutomate.d.ts: -------------------------------------------------------------------------------- 1 | import { Editor, WorkspaceLeaf } from 'obsidian'; 2 | import { ExcalidrawElement, ExcalidrawImperativeAPI } from '..'; 3 | import { ObsidianCanvasNode } from 'obsidian-excalidraw-plugin/lib/utils/CanvasNodeFactory'; 4 | import { default as BaseView } from 'obsidian-excalidraw-plugin/lib/ExcalidrawView'; 5 | import { ExcalidrawData } from 'obsidian-excalidraw-plugin/lib/ExcalidrawData'; 6 | 7 | //import ExcalidrawView from 'obsidian-excalidraw-plugin/lib/ExcalidrawView'; 8 | 9 | export type ObsidianMarkdownEmbeded = { 10 | leaf: WorkspaceLeaf; 11 | node?: ObsidianCanvasNode; 12 | } 13 | declare module "obsidian-excalidraw-plugin/lib/ExcalidrawAutomate" { 14 | interface ExcalidrawAutomate { 15 | /** 16 | * 17 | * @returns https://github.com/excalidraw/excalidraw/tree/master/src/packages/excalidraw#ref 18 | */ 19 | getExcalidrawAPI(): ExcalidrawImperativeAPI; 20 | 21 | // setView(view?: ExcalidrawView | "first" | "active"):ExcalidrawView 22 | 23 | } 24 | } 25 | declare module 'obsidian-excalidraw-plugin/lib/ExcalidrawView' { 26 | interface ExcalidrawView extends Omit { 27 | currentPosition: { x: number, y: number }; 28 | excalidrawAPI: ExcalidrawImperativeAPI; 29 | editor: Editor; 30 | excalidrawData: ExcalidrawData; 31 | embeddableLeafRefs: Map; 32 | getActiveEmbeddable(): ObsidianMarkdownEmbeded | null; 33 | //setDirty: (debug?: number) => void; 34 | updateScene: (scene: { 35 | elements?: ExcalidrawElement[]; 36 | appState?: any; 37 | files?: any; 38 | commitToHistory?: boolean; 39 | }, shouldRestore?: boolean) => void; 40 | addElements: ( 41 | newElements: ExcalidrawElement[], 42 | repositionToCursor?: boolean, 43 | save?: boolean, 44 | images?: any, 45 | newElementsOnTop?: boolean, 46 | shouldRestoreElements?: boolean, 47 | ) => Promise 48 | } 49 | } 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/adapters/obsidian/index.ts: -------------------------------------------------------------------------------- 1 | import { TextFileView, type MarkdownFileInfo } from "obsidian"; 2 | import type { CanvasFileNode, CanvasNode, CanvasTextNode, CanvasView } from "./types/canvas"; 3 | import type { ObsidianCanvasNode } from "obsidian-excalidraw-plugin/lib/utils/CanvasNodeFactory"; 4 | 5 | 6 | export const OBSIDIAN_CANVAS = "canvas"; 7 | 8 | export function isObsidianCanvasView(view?: TextFileView): view is CanvasView { 9 | return view?.getViewType() === OBSIDIAN_CANVAS; 10 | } 11 | export function isCanvasFileNode(node: CanvasNode | ObsidianCanvasNode): node is CanvasFileNode { 12 | return 'file' in node && 'canvas' in node 13 | } 14 | export function isCanvasEditorNode(node: CanvasNode | ObsidianCanvasNode | MarkdownFileInfo | undefined | null): node is CanvasFileNode | CanvasTextNode { 15 | return node 16 | ? ('file' in node || 'text' in node) && 'id' in node 17 | : false 18 | } 19 | export function getOffset(node: CanvasFileNode | CanvasTextNode) { 20 | const child = isCanvasFileNode(node) ? node.child : undefined; 21 | return child === undefined ? 0 : child?.before.length + child?.heading.length 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/adapters/obsidian/types/PortalType.ts: -------------------------------------------------------------------------------- 1 | export type PortalType = "none" | 2 | "doc" | 3 | "block" | 4 | "foldernote" | 5 | "flow" | 6 | "context"; 7 | -------------------------------------------------------------------------------- /src/adapters/obsidian/types/TransactionRange.ts: -------------------------------------------------------------------------------- 1 | 2 | export type TransactionRange = { 3 | from: number; 4 | to: number; 5 | }; 6 | -------------------------------------------------------------------------------- /src/adapters/obsidian/types/canvas.d.ts: -------------------------------------------------------------------------------- 1 | import { TFile, TextFileView } from "obsidian"; 2 | import { AllCanvasNodeData, CanvasNodeData, type CanvasData, CanvasEdgeData } from "obsidian/canvas"; 3 | 4 | 5 | export function isInstanceofCanvasEdge() 6 | export interface CanvasView extends TextFileView { 7 | canvas: ObsidianCanvas; 8 | } 9 | export interface ObsidianCanvas { 10 | nodes: Map, 11 | selection: Set, 12 | posFromEvt: (event: MouseEvent) => { x: number, y: number }, 13 | createFileNode: (config: { 14 | file: TFile, 15 | pos: { x: number, y: number }, 16 | subpath?: string, 17 | position?: "top" | "bottom" | "left" | "right" | "center", 18 | size?: { heigth: number, width: number }, 19 | save?: boolean, 20 | focus?: boolean, 21 | }) => CanvasFileNode, 22 | createTextNode: (config: { 23 | text: string, 24 | pos: { x: number, y: number }, 25 | position?: "top" | "bottom" | "left" | "right" | "center", 26 | size?: { heigth: number, width: number }, 27 | save?: boolean, 28 | focus?: boolean, 29 | }) => CanvasTextNode, 30 | edges: Map, 31 | addEdge: (edge: CanvasEdgeNode) => void, 32 | getData: () => CanvasData, 33 | importData: (data: CanvasData) => void, 34 | requestFrame: () => Promise, 35 | requestSave: () => Promis, 36 | } 37 | export type CanvasNode = CanvasFileNode | CanvasTextNode 38 | export interface CanvasFileNode extends CanvasNodeData { 39 | file: TFile, 40 | canvas: ObsidianCanvas, 41 | setFilePath: (filePath: string, subpath: string) => void, 42 | filePath: string, 43 | subpath: string, 44 | child?: { 45 | //text before current 46 | before: string, 47 | //text after current 48 | after: string, 49 | file: TFile, 50 | //heading text if not narrow to heading "" instead. 51 | heading: string, 52 | data: string, 53 | //if show all file subpath is "". 54 | subpath: string, 55 | subpathNotFound: boolean, 56 | //text show in canvas 57 | text: string, 58 | } 59 | } 60 | export interface CanvasTextNode extends CanvasNodeData { 61 | text: string, 62 | setText: (text: string) => void, 63 | } 64 | 65 | export interface CanvasEdgeNode extends CanvasEdgeData { 66 | setLabel: (label?: string) => void 67 | } 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /src/adapters/obsidian/types/obsidian.d.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view"; 2 | //import { FlowEditorParent } from "adapters/obsidian/ui/editors/FlowEditor"; 3 | 4 | 5 | //copy from makemd 6 | //https://github.com/Make-md/makemd/blob/1560cf1d522eebabd5a8b36a943f296ece1069df/src/adapters/obsidian/types/obsidian.d.ts#L4 7 | declare module "obsidian" { 8 | interface App { 9 | appId: string; 10 | dragManager: any; 11 | commands: { 12 | listCommands(): Command[]; 13 | findCommand(id: string): Command; 14 | removeCommand(id: string): void; 15 | executeCommandById(id: string): void; 16 | commands: Record; 17 | }; 18 | embedRegistry: { 19 | embedByExtension: Record; 20 | }; 21 | mobileToolbar: { 22 | containerEl: HTMLElement; 23 | }; 24 | hotkeyManager: { 25 | getHotkeys(id: string): Hotkey[]; 26 | getDefaultHotkeys(id: string): Hotkey[]; 27 | }; 28 | internalPlugins: { 29 | getPluginById(id: string): { instance: { options: { pinned: [] } } }; 30 | }; 31 | } 32 | interface Vault { 33 | checkPath(path: string): null; 34 | getConfig(option: "useMarkdownLinks"): boolean; 35 | } 36 | 37 | interface FileManager { 38 | processFrontMatter: ( 39 | file: TFile, 40 | callback: (FrontMatterCache: any) => void, 41 | option?: DataWriteOptions 42 | ) => Promise; 43 | createNewMarkdownFile: (folder: TFolder, name: string) => Promise; 44 | /** 45 | * 46 | * @param fn 47 | * @returns 48 | */ 49 | iterateAllRefs: (fn: (fileName: string, cache: LinkCache) => void) => void 50 | updateInternalLinks: (changes: Changes) => void 51 | linkUpdaters: { 52 | canvas: { 53 | canvas: { 54 | index: { 55 | getAll: () => Record[], embeds: { file?: string, subpath?: string }[] }> 56 | } 57 | } 58 | //subpath behind # 59 | renameSubpath: (file: TFile, oldSubpath: string, newSubpath: string) => any 60 | } 61 | } 62 | } 63 | 64 | interface MetadataCache { 65 | getCachedFiles(): string[]; 66 | getTags(): Record; 67 | } 68 | 69 | // class FileExplorerPlugin extends Plugin_2 { 70 | // revealInFolder(this: any, ...args: any[]): any; 71 | // } 72 | 73 | interface WorkspaceParent { 74 | insertChild(index: number, child: WorkspaceItem, resize?: boolean): void; 75 | replaceChild(index: number, child: WorkspaceItem, resize?: boolean): void; 76 | removeChild(leaf: WorkspaceLeaf, resize?: boolean): void; 77 | containerEl: HTMLElement; 78 | } 79 | 80 | interface EmptyView extends View { 81 | actionListEl: HTMLElement; 82 | emptyTitleEl: HTMLElement; 83 | } 84 | 85 | interface MousePos { 86 | x: number; 87 | y: number; 88 | } 89 | 90 | interface EphemeralState { 91 | focus?: boolean; 92 | subpath?: string; 93 | line?: number; 94 | startLoc?: Loc; 95 | endLoc?: Loc; 96 | scroll?: number; 97 | } 98 | interface WorkspaceMobileDrawer { 99 | currentTab: number; 100 | children: WorkspaceLeaf[]; 101 | } 102 | 103 | interface HoverPopover { 104 | //parent: FlowEditorParent | null; 105 | targetEl: HTMLElement; 106 | hoverEl: HTMLElement; 107 | hide(): void; 108 | show(): void; 109 | shouldShowSelf(): boolean; 110 | timer: number; 111 | waitTime: number; 112 | shouldShow(): boolean; 113 | transition(): void; 114 | } 115 | interface MarkdownFileInfo { 116 | contentEl: HTMLElement; 117 | } 118 | interface Workspace { 119 | activeEditor: MarkdownFileInfo | null; 120 | recordHistory(leaf: WorkspaceLeaf, pushHistory: boolean): void; 121 | iterateLeaves( 122 | callback: (item: WorkspaceLeaf) => boolean | void, 123 | item: WorkspaceItem | WorkspaceItem[] 124 | ): boolean; 125 | iterateLeaves( 126 | item: WorkspaceItem | WorkspaceItem[], 127 | callback: (item: WorkspaceLeaf) => boolean | void 128 | ): boolean; 129 | getDropLocation(event: MouseEvent): { 130 | children: { 131 | tabHeaderEl:HTMLElement, 132 | view:TextFileView 133 | }[], 134 | target: WorkspaceItem; 135 | sidedock: boolean; 136 | }; 137 | recursiveGetTarget( 138 | event: MouseEvent, 139 | parent: WorkspaceParent 140 | ): WorkspaceItem; 141 | recordMostRecentOpenedFile(file: TFile): void; 142 | onDragLeaf(event: MouseEvent, leaf: WorkspaceLeaf): void; 143 | onLayoutChange(): void; // tell Obsidian leaves have been added/removed/etc. 144 | floatingSplit: WorkspaceSplit; 145 | } 146 | interface WorkspaceSplit { 147 | children: SplitItem[]; 148 | } 149 | interface WorkspaceLeaf { 150 | containerEl: HTMLElement; 151 | tabHeaderInnerTitleEl: HTMLElement; 152 | tabHeaderInnerIconEl: HTMLElement; 153 | } 154 | interface Editor { 155 | cm: EditorView; 156 | } 157 | 158 | // interface View { 159 | // headerEl: HTMLDivElement; 160 | // editor?: Editor, 161 | // setMode?: (arg0: unknown) => unknown, 162 | // editMode?: unknown, 163 | // file?: TAbstractFile, 164 | // } 165 | interface MenuItem { 166 | dom: HTMLElement; 167 | iconEl: HTMLElement 168 | } 169 | interface Menu { 170 | dom: HTMLElement; 171 | scope: Scope; 172 | } 173 | interface Scope { 174 | keys: KeymapEventHandler[]; 175 | } 176 | interface EditorSuggest { 177 | suggestEl: HTMLElement; 178 | } 179 | interface SplitItem { 180 | id: string, 181 | containerEl: HTMLElement, 182 | doc?: Document, 183 | 184 | } 185 | interface SectionCache { 186 | type: 'heading' | 'list' | 'paragraph' | 'blockquote' | string; 187 | } 188 | 189 | interface ChangeInfo { 190 | /** 191 | * new link text set to editor 192 | */ 193 | change: string, 194 | /** 195 | * old link info 196 | */ 197 | reference: LinkCache, 198 | /** 199 | * file contains this link 200 | */ 201 | sourcePath: string, 202 | } 203 | interface Changes { 204 | data: Record 205 | add: (key: string, value: ChangeInfo) => void, 206 | remove: (key: string, value: ChangeInfo) => void, 207 | removeKey: (key: string) => void, 208 | get: (key: string) => ChangeInfo[], 209 | keys: () => string[], 210 | clear: (key: string) => void, 211 | clearAll: () => void, 212 | contains: (key: string, value: ChangeInfo) => boolean, 213 | count: () => number, 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/dragUpdate.ts: -------------------------------------------------------------------------------- 1 | import CardNote from "main"; 2 | import { EditorView, gutter, GutterMarker } from "@codemirror/view"; 3 | import { StateField, StateEffect, RangeSet, Line } from "@codemirror/state"; 4 | import { foldable } from "@codemirror/language"; 5 | import type { Break, LinkInfo, RequiredProperties, } from "src/utility"; 6 | import { 7 | ReCheck, isBreak, LineBreak as LINEBREAK, MarkdownFileExtension, throttle, BLOCKIDREPLACE, listItemParser, 8 | getRelativePosition, 9 | reverseRelative 10 | } from "src/utility"; 11 | import type { BlockCache, CachedMetadata, CacheItem, HeadingCache, ListItemCache, MarkdownFileInfo, SectionCache, } from "obsidian"; 12 | import { TFile } from "obsidian"; 13 | import type { BaseAction, FileNameModelConfig, UserAction } from "src/ui"; 14 | import { FileNameCheckModal } from "src/ui"; 15 | import { addLink, createTextOnDrawing, insertEmbeddableOnDrawing as insertEmbeddableNoteOnDrawing, isExcalidrawView } from "src/adapters/obsidian-excalidraw-plugin"; 16 | import { isCanvasEditorNode, isCanvasFileNode, isObsidianCanvasView } from "src/adapters/obsidian"; 17 | import type { CanvasEdgeNode, CanvasFileNode, CanvasTextNode, CanvasView } from "./adapters/obsidian/types/canvas"; 18 | import type { ExcalidrawView } from 'obsidian-excalidraw-plugin/lib/ExcalidrawView'; 19 | //import { syntaxTree } from "@codemirror/language"; 20 | 21 | type WhiteBoard = { 22 | located: CanvasView | ExcalidrawView, 23 | draw: (target: string | RequiredProperties) => void, 24 | updateLinks: (para: UpdateLinksInDrawPara) => void, 25 | } 26 | 27 | 28 | type Selection = { 29 | from: number, 30 | to: number, 31 | } 32 | type FoldableLine = { 33 | type: 'foldable', 34 | startLine: Line, 35 | selection: Selection, 36 | } 37 | type OneLine = { 38 | type: 'line', 39 | line: Line, 40 | selection: Selection, 41 | section?: Section, 42 | } 43 | type SingleSelection = { 44 | type: 'single', 45 | selection: Selection, 46 | } 47 | type MutipleSelction = { 48 | type: 'mutiple', 49 | selections: Selection[], 50 | } 51 | type BaseSelection = FoldableLine | OneLine | SingleSelection | MutipleSelction; 52 | type UserSelection = BaseSelection & { 53 | content: string, 54 | }; 55 | 56 | type ListBlock = { 57 | type: 'list', 58 | cache: ListItemCache, 59 | } 60 | type HeadingBlcok = { 61 | type: 'heading', 62 | cache: HeadingCache, 63 | name: string, 64 | } 65 | type LinkBlock = { 66 | type: 'linkBlock', 67 | name: string, 68 | cache: SectionCache 69 | } 70 | type NamedBlock = LinkBlock | HeadingBlcok | (ListBlock & { name: string }); 71 | type UnNamedBlock = { 72 | cache: SectionCache, 73 | } | ListBlock; 74 | 75 | type Block = NamedBlock | UnNamedBlock; 76 | export type BaseReferenceSection = { 77 | type: 'reference', 78 | block: Block, 79 | file: TFile, 80 | } 81 | export type LazyReferenceSection = { 82 | type: 'lazy', 83 | file: TFile, 84 | } 85 | export type UnReferenceSection = { 86 | type: 'unreference' 87 | } 88 | export type Section = (BaseReferenceSection | LazyReferenceSection | UnReferenceSection) 89 | 90 | export type LinkPath = { 91 | path: string, 92 | // #^... 93 | subpath?: string, 94 | // link to which file 95 | file?: TFile, 96 | text?: string, 97 | displayText?: string, 98 | } 99 | 100 | export type LinkFilePath = RequiredProperties 101 | 102 | export function isNamedBlock(block: Block): block is NamedBlock { 103 | return 'name' in block && block.name !== undefined; 104 | } 105 | export function isHeadingBlock(block: Block): block is HeadingBlcok { 106 | return 'type' in block && block.type === 'heading'; 107 | } 108 | export function isListBlock(block: Block): block is ListBlock { 109 | return 'type' in block && block.type === 'list'; 110 | } 111 | 112 | function getSelectOffset(select: (FoldableLine | OneLine | SingleSelection) & { textOffset: number }) { 113 | if (select.type === 'line' && select.section?.type === 'reference') { 114 | const pos = select.section.block.cache.position; 115 | return { 116 | from: pos.start.offset - select.textOffset, 117 | to: pos.end.offset - select.textOffset, 118 | }; 119 | } 120 | else { 121 | return { 122 | from: select.selection.from, 123 | to: select.selection.to, 124 | }; 125 | } 126 | } 127 | 128 | function getLinkBlocks(select: UserSelection & { textOffset: number }, file: TFile | null | undefined, plugin: CardNote): [BlockCache[], HeadingCache[]] { 129 | const textOffset = select.textOffset; 130 | if (!file) { 131 | return [[], []]; 132 | } 133 | else if (select.type === 'mutiple') { 134 | const res = select.selections.map(sel => plugin.findLinkBlocks(file, sel.from + textOffset, sel.to + textOffset)); 135 | const blocks = res.flatMap(r => r[0]); 136 | const headings = res.flatMap(r => r[1]); 137 | return [blocks, headings]; 138 | } 139 | else { 140 | const { from, to } = getSelectOffset(select); 141 | return plugin.findLinkBlocks(file, from + textOffset, to + textOffset); 142 | } 143 | } 144 | 145 | 146 | type ReNameConfig = Omit; 147 | 148 | async function userAction(plugin: CardNote, section: Section, selected: UserSelection) { 149 | const folderPath = plugin.settings.defaultFolder; 150 | const getUserRename = (config: ReNameConfig) => { 151 | return new Promise<(UserAction) | Break>(resolve => { 152 | const onSubmit = (action: UserAction) => { 153 | resolve({ ...action }); 154 | }; 155 | new FileNameCheckModal({ ...config, onSubmit }) 156 | .open(); 157 | }); 158 | }; 159 | const provide = async (arg: ReNameConfig, unvalid: UserAction | undefined, error: string | undefined) => { 160 | if (unvalid?.type !== 'cancel' && unvalid?.type !== 'cut') { 161 | const newName = unvalid?.newName; 162 | const name = newName && newName.length !== 0 ? newName : arg.name; 163 | return getUserRename({ ...arg, name, errorMessage: error }); 164 | } 165 | }; 166 | const check = async (value: UserAction): Promise | Error> => { 167 | if (value.type !== 'cancel' && value.type !== 'cut') { 168 | const newName = value.newName; 169 | if (value.type === 'createFile') { 170 | const file = await plugin.checkFileName({ folderPath, fileName: newName, extension: MarkdownFileExtension }); 171 | return file instanceof Error ? file : { ...value, file }; 172 | } 173 | if (value.type === 'linkToReference') { 174 | const findUnvalidBlockSymbol = () => BLOCKIDREPLACE().exec(value.newName); 175 | 176 | return isHeadingBlock(value.section.block) 177 | ? value 178 | : findUnvalidBlockSymbol() 179 | ? new Error('Block id only accept alphanumeric and -') 180 | : value; 181 | } 182 | } 183 | return value; 184 | }; 185 | const action = await ReCheck>({ 186 | create() { 187 | //"if not referenceable ,extract from content"; 188 | const getDefault = () => { 189 | return selected.content.split(LINEBREAK, 1)[0].substring(0, 20).trim(); 190 | }; 191 | 192 | const defulatName = 193 | section.type === 'reference' 194 | ? isNamedBlock(section.block) 195 | ? section.block.name 196 | : isListBlock(section.block) 197 | ? listItemParser(selected.content)?.item 198 | : getDefault() 199 | : getDefault(); 200 | 201 | return { 202 | plugin, 203 | section, 204 | name: defulatName ?? "", 205 | }; 206 | }, 207 | update(prev) { 208 | return prev; 209 | }, 210 | provide, 211 | check, 212 | }); 213 | return action; 214 | } 215 | 216 | function getSection(source: FileEditor | undefined, selected: UserSelection, plugin: CardNote): Section { 217 | const sourceFile = getFile(source); 218 | if (sourceFile instanceof TFile && selected.type !== 'mutiple') { 219 | const offset = source?.offset ?? 0; 220 | const fileCache = plugin.app.metadataCache.getFileCache(sourceFile), 221 | matchStart = (block: CacheItem) => { 222 | const start = selected.selection.from + offset; 223 | return block.position.start.offset === start; 224 | }, 225 | touch = (block: CacheItem) => { 226 | const start = selected.selection.from + offset, 227 | end = selected.selection.to + offset, 228 | blockStart = block.position.start.offset, 229 | blockEnd = block.position.end.offset; 230 | 231 | return (blockEnd > start && blockEnd <= end) 232 | || (blockStart >= start && blockStart < end) 233 | }, 234 | findCorrespondBlock = () => { 235 | const start = selected.selection.from + offset; 236 | const block = fileCache?.sections?.find(cache => { 237 | //only top list show in section cache,so find the top list first 238 | //next step will find the corresponding list in cache.listItems 239 | if (cache.type === 'list') { 240 | return cache.position.start.offset <= start 241 | && start <= cache.position.end.offset; 242 | } 243 | else { 244 | return matchStart(cache); 245 | } 246 | 247 | }); 248 | return block; 249 | }; 250 | 251 | const blockCache = findCorrespondBlock(), 252 | getList = (): ListBlock & { name?: string } | undefined => { 253 | const listItem = fileCache?.listItems?.find(item => { 254 | const listStartPosition = item.position.start, 255 | listEndPosition = item.position.end, 256 | listStart = listStartPosition.offset, 257 | listEnd = listEndPosition.offset, 258 | listLineStart = listStart - listStartPosition.col, 259 | selectStart = selected.selection.from + offset, 260 | selectEnd = selected.selection.to + offset; 261 | 262 | return selectStart >= listLineStart 263 | && (listStart >= selectStart 264 | && listEnd <= selectEnd) 265 | }); 266 | if (listItem) { 267 | return { 268 | type: 'list', 269 | cache: listItem, 270 | name: listItem.id 271 | }; 272 | } 273 | 274 | }, 275 | getHeading = (): HeadingBlcok | undefined => { 276 | const heading = fileCache?.headings?.find(matchStart); 277 | if (heading) { 278 | return { 279 | type: 'heading', 280 | name: heading?.heading, 281 | cache: heading, 282 | }; 283 | } 284 | 285 | }, 286 | getBlock = (): Block | undefined => { 287 | if (blockCache) { 288 | if (blockCache.type === 'list') { 289 | return getList() 290 | } 291 | else if (blockCache.type === 'heading') { 292 | return getHeading() 293 | } 294 | else { 295 | return blockCache.id 296 | ? { 297 | type: 'linkBlock', 298 | cache: blockCache, 299 | name: blockCache.id, 300 | } 301 | : { 302 | cache: blockCache, 303 | }; 304 | } 305 | } 306 | }; 307 | 308 | const block = getList() ?? getHeading() ?? getBlock(); 309 | 310 | return block 311 | ? { 312 | type: 'reference', 313 | block, 314 | file: sourceFile, 315 | } 316 | : { 317 | type: 'unreference', 318 | }; 319 | } 320 | else { 321 | return { 322 | type: 'unreference' 323 | }; 324 | } 325 | } 326 | type UpdateLinksInDrawPara = { 327 | getNewPath: (oldPath: LinkPath) => LinkFilePath, 328 | linkMatch: (link: LinkPath) => boolean, 329 | } 330 | type FileCache = { 331 | file: TFile, 332 | data: string, 333 | cache: CachedMetadata, 334 | } 335 | export function fileUpdateObserver(plugin: CardNote, file: TFile) { 336 | let res: Promise | undefined, 337 | waiting: ((value: FileCache | PromiseLike) => void)[] = []; 338 | const metadataCache = plugin.app.metadataCache; 339 | const e = metadataCache.on('changed', (changeFile, data, cache) => { 340 | if (changeFile === file) { 341 | const r = { 342 | file: changeFile, 343 | data, 344 | cache, 345 | }; 346 | res = Promise.resolve(r); 347 | waiting.forEach(resolve => resolve(r)); 348 | waiting = []; 349 | } 350 | }); 351 | return { 352 | getUpdate: () => res ?? new Promise(resolve => { 353 | waiting.push(resolve); 354 | }), 355 | close: () => metadataCache.offref(e), 356 | }; 357 | } 358 | export async function onFilesUpdated(plugin: CardNote, files: TFile[], on: (cache: FileCache[]) => void, timeLimited: number) { 359 | const time = timeLimited * files.length, 360 | observers = files.map(file => fileUpdateObserver(plugin, file)), 361 | closeObserver = () => observers.forEach(ob => ob.close()), 362 | update = observers.map(ob => ob.getUpdate()); 363 | 364 | return new Promise((resolve, reject) => { 365 | resolve(Promise.all(update).then(data => { closeObserver(); on(data); })); 366 | setTimeout(() => { 367 | closeObserver(); 368 | reject(`files: ${files.map(file => file.path)} are not detected in ${time} seconds, `); 369 | }, 1000 * time); 370 | }); 371 | 372 | } 373 | type FileEditor = { 374 | fileEditor: MarkdownFileInfo | CanvasFileNode | CanvasTextNode | null | undefined, 375 | offset: number, 376 | } 377 | function getFile(ed: FileEditor | undefined | null) { 378 | const hasFile = (fe: MarkdownFileInfo | CanvasFileNode | CanvasTextNode): fe is MarkdownFileInfo | CanvasFileNode => { 379 | return 'file' in fe; 380 | } 381 | if (ed?.fileEditor && hasFile(ed.fileEditor)) { 382 | return ed.fileEditor.file 383 | } 384 | } 385 | async function extractSelect( 386 | action: Required, 387 | extract: UserSelection & { textOffset: number }, 388 | view: EditorView, 389 | activeFile: FileEditor | null | undefined, 390 | whiteboard: WhiteBoard, 391 | plugin: CardNote, 392 | ) { 393 | // let target: string | RequiredProperties; 394 | const updateInternalLinks = async ( 395 | sourceFile: TFile, 396 | createNewPath: (old: LinkPath) => LinkFilePath, 397 | match: (old: LinkPath) => boolean, 398 | updateAfterDraw: TFile[]) => { 399 | // const [selfLinks, outer] = plugin.findLinks(sourceFile, match); 400 | const linksInFiles = plugin.findLinks(sourceFile, match); 401 | onFilesUpdated(plugin, updateAfterDraw, async (data) => { 402 | const [selfLinks, _] = await linksInFiles; 403 | if (selfLinks) { 404 | const linksSet = selfLinks.map(l => l.link.link); 405 | const res = new Map; 406 | data.map(d => { 407 | const links = d.cache.links ?? []; 408 | const embeds = d.cache.embeds ?? []; 409 | const all = links.concat(embeds); 410 | const linkRef = all.filter(cache => linksSet.contains(cache.link)) 411 | .map(plugin.createLinkInfo); 412 | return { 413 | file: d.file.path, 414 | linkRef 415 | }; 416 | }).forEach(d => { if (d.linkRef.length > 0) { res.set(d.file, d.linkRef); } }); 417 | plugin.updateInternalLinks(res, text => { 418 | const newPath = createNewPath({ path: text.path, subpath: text.subpath }); 419 | return `${newPath.path}${newPath.subpath}`; 420 | }); 421 | } 422 | }, 10); 423 | 424 | const [_, outer] = await linksInFiles; 425 | const canvasHasMatchLinks = plugin.getCanvas((canvasPath, embed) => { 426 | const subpath = embed.subpath;//#^ 427 | return match({ path: embed.file ?? '', subpath }); 428 | }); 429 | const whiteboardPath = whiteboard.located.file?.path, 430 | updateLinksInDraw = () => { 431 | whiteboard.updateLinks({ 432 | getNewPath: createNewPath, 433 | linkMatch: match, 434 | }) 435 | }; 436 | if (outer.has(whiteboardPath ?? '')) { 437 | outer.delete(whiteboardPath ?? ''); 438 | updateLinksInDraw(); 439 | } 440 | else if (canvasHasMatchLinks.contains(whiteboardPath ?? '')) { 441 | canvasHasMatchLinks.remove(whiteboardPath ?? ''); 442 | updateLinksInDraw() 443 | } 444 | 445 | plugin.updateInternalLinks(outer, text => { 446 | const newPath = createNewPath({ path: text.path, subpath: text.subpath }); 447 | return `${newPath.path}${newPath.subpath}`; 448 | }); 449 | plugin.updateCanvasLinks(canvasHasMatchLinks, node => { 450 | if (match({ path: node.file, subpath: node.subpath })) { 451 | const newPath = createNewPath({ path: node.file, subpath: node.subpath }); 452 | return { 453 | ...node, 454 | file: newPath.path + MarkdownFileExtension, 455 | subpath: newPath.subpath, 456 | }; 457 | } 458 | return node; 459 | }); 460 | 461 | }; 462 | if (action.type === 'createFile') { 463 | 464 | const updateConfig = () => { 465 | const sourceFile = getFile(activeFile); 466 | if (sourceFile) { 467 | const match = (link: LinkPath) => 468 | (link.path === sourceFile.path || link.file === sourceFile) 469 | && link.subpath !== undefined 470 | && subpathSet.contains(link.subpath); 471 | const createNewPath = (oldPath: LinkPath): LinkFilePath => { 472 | return plugin.createLinkText(newFile, oldPath.subpath, oldPath.displayText); 473 | }; 474 | //update vault internal link 475 | const [blocks, headings] = getLinkBlocks(extract, sourceFile, plugin); 476 | const subpathSet = [...blocks.map(block => `#^${block.id}`), ...headings.map(cache => `#${plugin.normalizeHeadingToLinkText(cache.heading)}`)]; 477 | return { 478 | sourceFile, 479 | match, 480 | createNewPath, 481 | subpathSet, 482 | } 483 | } 484 | } 485 | 486 | const config = updateConfig(); 487 | //replace editor's select line or text with link 488 | const filePath = action.file.fileName; 489 | const newFile = await plugin.app.vault.create(filePath, extract.content); 490 | const newFileLink = plugin.createLinkText(newFile); 491 | // target = newFileLink; 492 | 493 | //handle self link and replace text with link 494 | const replaceTextWithLink = () => { 495 | const trans = view.state.update({ 496 | changes: extract.type !== 'mutiple' 497 | ? { ...getSelectOffset(extract), insert: newFileLink.text } 498 | : extract.selections.map(line => { 499 | return { from: line.from, to: line.to, insert: newFileLink.text }; 500 | }) 501 | }); 502 | view.dispatch(trans); 503 | }; 504 | replaceTextWithLink(); 505 | if (config && config.subpathSet.length !== 0) { 506 | const { sourceFile, createNewPath, match } = config; 507 | await updateInternalLinks(sourceFile, createNewPath, match, [sourceFile, newFile]); 508 | } 509 | whiteboard.draw(newFileLink); 510 | 511 | } 512 | else if (action.type === 'linkToReference') { 513 | const block = action.section.block, 514 | sourceFile = action.section.file; 515 | const name = action.newName; 516 | 517 | const subpath = isHeadingBlock(block) 518 | ? { 519 | symbol: '#', 520 | path: plugin.normalizeHeadingToLinkText(name), 521 | } 522 | : { 523 | symbol: '#^', 524 | path: name, 525 | } 526 | 527 | const oldBlock = isNamedBlock(block) ? block : undefined; 528 | 529 | const newPath = plugin.createLinkText(sourceFile, subpath.symbol + subpath.path); 530 | // target = newPath; 531 | 532 | if (oldBlock) { 533 | const reName = () => oldBlock.name !== name; 534 | if (reName()) { 535 | const oldName = oldBlock.name, 536 | oldPath = isHeadingBlock(oldBlock) 537 | ? '#' + plugin.normalizeHeadingToLinkText(oldName) 538 | : '#^' + oldName, 539 | from = block.cache.position.end.offset - extract.textOffset - oldName.length, 540 | to = block.cache.position.end.offset - extract.textOffset; 541 | //replace old name 542 | const trans = view.state.update({ 543 | changes: { from, to, insert: subpath.path } 544 | }); 545 | view.dispatch(trans); 546 | await updateInternalLinks( 547 | sourceFile, 548 | old => newPath, 549 | link => (link.path === sourceFile.path || link.file === sourceFile) 550 | && link.subpath !== undefined 551 | && link.subpath === oldPath 552 | , 553 | [sourceFile] 554 | ) 555 | } 556 | } 557 | else { 558 | //insert new block name 559 | const insertNamePosition = block.cache.position.end.offset - extract.textOffset; 560 | const trans = view.state.update({ 561 | changes: { from: insertNamePosition, insert: ' ^' + name } 562 | }); 563 | view.dispatch(trans); 564 | } 565 | whiteboard.draw(newPath); 566 | } 567 | else { 568 | // target = extract.content; 569 | const deleteText = () => { 570 | const trans = view.state.update({ 571 | changes: extract.type !== 'mutiple' 572 | ? { ...getSelectOffset(extract), } 573 | : extract.selections.map(line => { 574 | return { from: line.from, to: line.to, }; 575 | }) 576 | }); 577 | view.dispatch(trans); 578 | }; 579 | deleteText(); 580 | whiteboard.draw(extract.content); 581 | } 582 | } 583 | export const dragExtension = (plugin: CardNote) => { 584 | const addDragStartEvent = (dragSymbol: HTMLElement, view: EditorView) => { 585 | let info: UserSelection; 586 | let source: FileEditor | undefined; 587 | let listener: { reset: () => void }; 588 | const handleDrop = async (e: DragEvent) => { 589 | const createFileAndDraw = async ( 590 | whiteboard: WhiteBoard, 591 | ) => { 592 | const section = info.type === 'line' && info.section ? info.section : getSection(source, info, plugin); 593 | const action = await userAction(plugin, section, info); 594 | if (!isBreak(action) && action.type !== 'cancel') { 595 | extractSelect( 596 | action, 597 | { ...info, textOffset: source?.offset ?? 0 }, 598 | view, 599 | source, 600 | whiteboard, 601 | plugin, 602 | ); 603 | } 604 | }; 605 | const drawView = plugin.getDropView(e); 606 | if (isExcalidrawView(drawView)) { 607 | createFileAndDraw( 608 | { 609 | located: drawView, 610 | draw: async (target) => { 611 | const createNode = typeof (target) === 'string' 612 | ? createTextOnDrawing(e, drawView, target, plugin) 613 | : insertEmbeddableNoteOnDrawing(e, drawView, target.text, target.file, plugin); 614 | if (plugin.settings.autoLink && isCanvasEditorNode(source?.fileEditor)) { 615 | const createNodeId = await createNode; 616 | if (createNodeId) { 617 | await addLink(source.fileEditor.id, createNodeId, drawView, plugin) 618 | } 619 | } 620 | }, 621 | updateLinks: (para) => { 622 | const { linkMatch, getNewPath } = para; 623 | const nodes = Array.from(drawView.canvasNodeFactory.nodes.entries()).map(value => { 624 | const [id, refNode] = value; 625 | const getLinkInfo = (node: CanvasFileNode) => { 626 | return { path: node.filePath, subpath: node.subpath }; 627 | }; 628 | if (isCanvasFileNode(refNode) 629 | && linkMatch(getLinkInfo(refNode))) { 630 | return { id, link: getNewPath(getLinkInfo(refNode)) }; 631 | } 632 | }).filter(v => v !== undefined) as { id: string, link: LinkFilePath }[]; 633 | nodes.forEach(node => { 634 | const elements = drawView.excalidrawAPI.getSceneElements().filter((e) => e.id === node.id); 635 | elements.forEach(elem => { 636 | drawView.excalidrawData.elementLinks.set(node.id, node.link.text!); 637 | //@ts-ignore 638 | ExcalidrawLib.mutateElement(elem, { link: node.link.text }); 639 | }) 640 | } 641 | ); 642 | drawView.setDirty(99); 643 | drawView.updateScene({}); 644 | } 645 | }); 646 | } else if (isObsidianCanvasView(drawView)) { 647 | const pos = drawView.canvas.posFromEvt(e); 648 | createFileAndDraw({ 649 | located: drawView, 650 | draw: async (target) => { 651 | const dropCanvas = drawView.canvas; 652 | const createNode = typeof (target) === 'string' ? dropCanvas.createTextNode({ 653 | text: target, 654 | pos, 655 | save: false, 656 | }) : dropCanvas.createFileNode({ 657 | file: target.file, 658 | pos, 659 | subpath: target.subpath, 660 | save: false, 661 | }); 662 | if (plugin.settings.autoLink && isCanvasEditorNode(source?.fileEditor)) { 663 | const fromSide = getRelativePosition(source.fileEditor, createNode); 664 | if (fromSide && reverseRelative.has(fromSide)) { 665 | const toSide = reverseRelative.get(fromSide); 666 | const edgeID = plugin.createRandomHexString(16); 667 | const fromEnd = plugin.arrowToFrom() ? 'arrow' : 'none'; 668 | const toEnd = plugin.arrowToEnd() ? 'arrow' : 'none'; 669 | const label = plugin.settings.defaultLinkLabel; 670 | const edgeSample = dropCanvas.edges.values().next().value; 671 | if (edgeSample) { 672 | // @ts-ignore: Wait for the Obsidian API then fix this lack of proper constructor 673 | const e: CanvasEdgeNode = new edgeSample.constructor(dropCanvas, edgeID, 674 | { side: fromSide, node: source.fileEditor, end: fromEnd }, 675 | { side: toSide, node: createNode, end: toEnd }) 676 | e.setLabel(label); 677 | dropCanvas.addEdge(e); 678 | e.attach(); 679 | e.render(); 680 | } 681 | else { 682 | const data = dropCanvas.getData() 683 | dropCanvas.importData({ 684 | nodes: data.nodes, 685 | edges: [...data.edges, { 686 | id: edgeID, 687 | fromNode: source.fileEditor.id, 688 | fromSide, 689 | fromEnd, 690 | toNode: createNode.id, 691 | toSide: toSide!, 692 | toEnd, 693 | label, 694 | }] 695 | }); 696 | } 697 | } 698 | } 699 | else { 700 | await dropCanvas.requestFrame(); 701 | } 702 | dropCanvas.requestSave() 703 | }, 704 | updateLinks: (para) => { 705 | const { linkMatch, getNewPath } = para; 706 | drawView.canvas.nodes.forEach((node, id) => { 707 | const path = (node: CanvasFileNode): LinkPath => ({ 708 | path: node.filePath, 709 | file: node.file, 710 | subpath: node.subpath 711 | }); 712 | if (isCanvasFileNode(node) && linkMatch(path(node))) { 713 | const newPath = getNewPath(path(node)); 714 | node.setFilePath(newPath.file.path, newPath.subpath ?? ""); 715 | //node.canvas.requestSave(); 716 | } 717 | }); 718 | drawView.canvas.requestSave(); 719 | } 720 | }); 721 | } 722 | }; 723 | dragSymbol.addEventListener("dragstart", (e) => { 724 | source = plugin.getActiveEditorFile(); 725 | 726 | const getSelection = () => { 727 | const selectLines = view.state.selection.ranges.map(range => ({ 728 | from: range.from, 729 | to: range.to, 730 | })); 731 | const content = selectLines.map(range => { 732 | return view.state.sliceDoc(range.from, range.to); 733 | }).join().trim(); 734 | return { content, selectLines }; 735 | }; 736 | 737 | const getLineString = (): UserSelection => { 738 | const statefield = view.state.field(dragSymbolSet); 739 | const start = statefield.iter().from; 740 | const doc = view.state.doc; 741 | const line = view.state.doc.lineAt(start); 742 | 743 | const foldableRange = foldable(view.state, line.from, line.to); 744 | if (foldableRange) { 745 | return { 746 | type: 'foldable', 747 | startLine: line, 748 | selection: { 749 | from: line.from, 750 | to: foldableRange.to, 751 | }, 752 | content: doc.sliceString(line.from, foldableRange.to), 753 | }; 754 | } 755 | else { 756 | const selected: OneLine = { 757 | type: 'line', 758 | line, 759 | selection: { 760 | from: line.from, 761 | to: line.to, 762 | }, 763 | }; 764 | const referenceTextOffset = source?.offset ?? 0; 765 | const section = getSection(source, { ...selected, content: '' }, plugin); 766 | const content = section && section.type === 'reference' 767 | ? doc.sliceString( 768 | section.block.cache.position.start.offset - referenceTextOffset, 769 | section.block.cache.position.end.offset - referenceTextOffset) 770 | : line.text; 771 | 772 | return { 773 | ...selected, 774 | content, 775 | section, 776 | }; 777 | } 778 | }; 779 | const defaultSelect = getSelection(); 780 | info = defaultSelect.content.length === 0 781 | ? getLineString() 782 | : defaultSelect.selectLines.length === 1 783 | ? { type: 'single', selection: defaultSelect.selectLines.first()!, content: defaultSelect.content } 784 | : { type: 'mutiple', selections: defaultSelect.selectLines, content: defaultSelect.content }; 785 | 786 | //Drag table will cause dragend event would be triggerd immediately at dragstart 787 | //https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately 788 | setTimeout(() => { 789 | listener = plugin.listenDragAndDrop(e, info.content, handleDrop); 790 | }); 791 | 792 | }); 793 | 794 | 795 | return { 796 | reset: () => listener?.reset(), 797 | }; 798 | }; 799 | const dragMarker = new (class extends GutterMarker { 800 | destroy(dom: Node): void { 801 | } 802 | toDOM(view: EditorView) { 803 | const dragSymbol = document.createElement("div"); 804 | dragSymbol.draggable = true; 805 | const symbol = dragSymbol.createSpan(); 806 | symbol.innerText = plugin.settings.dragSymbol; 807 | symbol.style.fontSize = `${plugin.settings.dragSymbolSize}px`; 808 | 809 | const { reset } = addDragStartEvent(dragSymbol, view); 810 | 811 | dragSymbol.addEventListener("dragend", () => { 812 | reset(); 813 | }); 814 | 815 | return dragSymbol; 816 | } 817 | })(); 818 | const mousemoveEffect = StateEffect.define<{ from: number, to: number }>({ 819 | map: (val, mapping) => ({ from: mapping.mapPos(val.from), to: mapping.mapPos(val.to) }), 820 | }); 821 | const dragSymbolSet = StateField.define>({ 822 | create() { 823 | return RangeSet.empty; 824 | }, 825 | update(set, transaction) { 826 | set = set.map(transaction.changes); 827 | for (const e of transaction.effects) { 828 | if (e.is(mousemoveEffect)) { 829 | set = RangeSet.of(dragMarker.range(e.value.from)); 830 | } 831 | } 832 | return set; 833 | }, 834 | //依此stateField狀態所需要更新的Extension都可以放在provide funciton中提供出來 835 | provide: (value) => { 836 | const gut = gutter({ 837 | class: 'cn-drag-symbol', 838 | markers: (v) => { 839 | const range_set = v.state.field(value); 840 | return v.state.doc.length !== 0 ? range_set : RangeSet.empty; 841 | }, 842 | initialSpacer: () => dragMarker, 843 | }); 844 | return [gut]; 845 | } 846 | }); 847 | const addSymbolWhenMouseMove = (event: MouseEvent, view: EditorView) => { 848 | const pos = view.posAtCoords({ 849 | x: event.clientX, 850 | y: event.clientY, 851 | }); 852 | if (pos) { 853 | const dragLine = view.state.field(dragSymbolSet); 854 | const line = view.lineBlockAt(pos); 855 | let hasDragPoint = false; 856 | dragLine.between(line.from, line.from, () => { 857 | hasDragPoint = true; 858 | }); 859 | if (!hasDragPoint) { 860 | view.dispatch({ 861 | effects: mousemoveEffect.of({ from: line.from, to: line.to }), 862 | }); 863 | } 864 | } 865 | return pos; 866 | }; 867 | const mouseMoveWatch = EditorView.domEventHandlers({ 868 | mousemove: (event: MouseEvent, view) => { 869 | //debounceMousemove(event, view); 870 | throttle(addSymbolWhenMouseMove, 0.2)(event, view); 871 | }, 872 | }); 873 | 874 | 875 | return [ 876 | dragSymbolSet, 877 | mouseMoveWatch, 878 | ]; 879 | }; 880 | 881 | -------------------------------------------------------------------------------- /src/file.ts: -------------------------------------------------------------------------------- 1 | import { App, Component, MarkdownRenderer, prepareSimpleSearch, renderMatches, type SearchMatches, type SearchResult, type TFile } from "obsidian" 2 | import { tryCreateRegex } from "./utility"; 3 | export enum Seq { 4 | ascending, 5 | descending 6 | } 7 | export type FileMatch = { 8 | file: TFile; 9 | content?: string; 10 | match?: SearchResult; 11 | nameMatch?: SearchResult; 12 | } 13 | export type SortMethod = (a: FileMatch, b: FileMatch) => number 14 | export type SearchedFile = FileMatch & { 15 | content: string; 16 | } 17 | export type File = TFile | SearchedFile 18 | export function isSearchedFile(file: File | FileMatch): file is SearchedFile { 19 | return "content" in file 20 | } 21 | 22 | export type RenderPara = { 23 | app: App; 24 | markdown: string; 25 | sourcePath: string; 26 | component: Component; 27 | } 28 | export function search(query: string) { 29 | const fuzzy = prepareSimpleSearch(query), 30 | searching = async ( 31 | file: TFile, 32 | content: string 33 | ): Promise => { 34 | const contentResult = fuzzy(content), 35 | filePathResult = fuzzy(file.path); 36 | if (contentResult || filePathResult) { 37 | return { 38 | file: file, 39 | content, 40 | match: contentResult ?? undefined, 41 | nameMatch: filePathResult ?? undefined, 42 | }; 43 | } 44 | }; 45 | return searching; 46 | } 47 | export function searchByRegex(query: string, flags?: string) { 48 | return (file: TFile, content: string): SearchedFile | undefined => { 49 | const contentMatches = regexSearch(content, tryCreateRegex(query, flags)); 50 | const filePathMatches = regexSearch(file.path, tryCreateRegex(query, flags)); 51 | 52 | if (contentMatches.length !== 0 || filePathMatches.length !== 0) { 53 | return { 54 | file, 55 | content, 56 | match: { score: contentMatches.length, matches: contentMatches }, 57 | nameMatch: { score: filePathMatches.length, matches: filePathMatches } 58 | } 59 | } 60 | 61 | } 62 | } 63 | function regexSearch(content: string, regex?: RegExp): SearchMatches { 64 | if (regex) { 65 | let matches: SearchMatches = []; 66 | while (regex.lastIndex < content.length) { 67 | const match = regex.exec(content); 68 | if (match) { 69 | matches.push([match.index, regex.lastIndex]); 70 | continue; 71 | } 72 | else { 73 | break; 74 | } 75 | } 76 | return matches 77 | } 78 | return [] 79 | 80 | } 81 | export function sortByName(a: FileMatch, b: FileMatch) { 82 | return a.file.path < b.file.path 83 | ? -1 84 | : a.file.path > b.file.path 85 | ? 1 86 | : 0; 87 | } 88 | export function sortByModifiedTime(a: FileMatch, b: FileMatch) { 89 | return a.file.stat.mtime - b.file.stat.mtime; 90 | } 91 | export function sortByCreateTime(a: FileMatch, b: FileMatch) { 92 | return a.file.stat.ctime - b.file.stat.ctime; 93 | } 94 | export function sortByRelated(a: FileMatch, b: FileMatch) { 95 | return computeScore(a) - computeScore(b); 96 | } 97 | function computeScore(value: FileMatch) { 98 | return ( 99 | (value.match?.score ?? -5) + 100 | (value.nameMatch?.score ?? -5) 101 | ); 102 | } 103 | export function Touch(source: [number, number]) { 104 | const [from, to] = source; 105 | 106 | return (target: [number, number]) => { 107 | const [start, end] = target; 108 | return (end >= from && end <= to) || 109 | (start >= from && start <= to) || 110 | (start <= from && end >= to) 111 | } 112 | } 113 | export function InRange(source: [number, number]) { 114 | const [from, to] = source; 115 | return (target: [number, number]) => { 116 | const [start, end] = target; 117 | return from <= start && end <= to 118 | } 119 | } 120 | export function ObsidianMarkdownRender(element: HTMLElement, para: RenderPara) { 121 | MarkdownRenderer.render( 122 | para.app, 123 | para.markdown, 124 | element, 125 | para.sourcePath, 126 | para.component 127 | ) 128 | } 129 | export type ResultRenderPara = { 130 | text: string, 131 | result: SearchMatches, 132 | offset?: number, 133 | } 134 | export function ObsidianResultRender(element: HTMLElement, para: ResultRenderPara) { 135 | 136 | renderMatches( 137 | element, 138 | para.text, 139 | para.result, 140 | para.offset 141 | ) 142 | } 143 | export const validCacheReadFilesExtension = ["md", "canvas"] 144 | 145 | export enum SectionCacheType { 146 | code = "code", 147 | } -------------------------------------------------------------------------------- /src/images/CardNoteCanvas.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycsd/obsidian-card-note/bdb6d462a53b184fe48cdbac712182070815991b/src/images/CardNoteCanvas.gif -------------------------------------------------------------------------------- /src/images/CardNoteExcalidraw.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycsd/obsidian-card-note/bdb6d462a53b184fe48cdbac712182070815991b/src/images/CardNoteExcalidraw.gif -------------------------------------------------------------------------------- /src/images/CardNoteFoldable.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycsd/obsidian-card-note/bdb6d462a53b184fe48cdbac712182070815991b/src/images/CardNoteFoldable.gif -------------------------------------------------------------------------------- /src/images/CardNoteSearchView.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycsd/obsidian-card-note/bdb6d462a53b184fe48cdbac712182070815991b/src/images/CardNoteSearchView.gif -------------------------------------------------------------------------------- /src/images/CardNoteSection.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cycsd/obsidian-card-note/bdb6d462a53b184fe48cdbac712182070815991b/src/images/CardNoteSection.gif -------------------------------------------------------------------------------- /src/ui.ts: -------------------------------------------------------------------------------- 1 | import CardNote from "main"; 2 | import { Modal, Setting, TextComponent } from "obsidian"; 3 | import type { BaseReferenceSection, Section, } from "src/dragUpdate" 4 | import { isHeadingBlock } from "src/dragUpdate" 5 | import type { FileInfo, } from "src/utility"; 6 | import { BLOCKIDREPLACE, FILENAMEREPLACE } from "src/utility"; 7 | 8 | 9 | export type CreateFile = { 10 | type: 'createFile', 11 | file?: FileInfo, 12 | } 13 | 14 | export type LinkToReference = { 15 | type: 'linkToReference', 16 | section: BaseReferenceSection, 17 | } 18 | type Cut = { 19 | type: 'cut', 20 | } 21 | export type BaseAction = (CreateFile | LinkToReference) & { newName: string } | Cut 22 | export type UserAction = BaseAction | { type: 'cancel' } 23 | 24 | export type FileNameModelConfig = { 25 | plugin: CardNote, 26 | name: string, 27 | section: Section, 28 | onSubmit: (action: UserAction) => void, 29 | errorMessage?: string, 30 | 31 | } 32 | export class FileNameCheckModal extends Modal { 33 | //newName: Promise; 34 | section: Section; 35 | onSubmit: (action: UserAction) => void; 36 | errorMessage?: string 37 | userInput: string; 38 | plugin: CardNote; 39 | 40 | constructor( 41 | config: FileNameModelConfig 42 | ) { 43 | super(config.plugin.app); 44 | this.plugin = config.plugin; 45 | this.userInput = config.name; 46 | //this.newName = Promise.resolve(config.name); 47 | this.section = config.section; 48 | this.onSubmit = config.onSubmit; 49 | this.errorMessage = config.errorMessage; 50 | } 51 | onOpen(): void { 52 | 53 | const linkReferenceDescription = this.section.type === 'reference' 54 | ? isHeadingBlock(this.section.block) 55 | ? ' or link to heading' : ' or link to block' 56 | : ''; 57 | 58 | const { contentEl } = this; 59 | let userInputText: TextComponent; 60 | const nameSetting = new Setting(contentEl) 61 | //.setName("New Name") 62 | .setDesc(`Create file${linkReferenceDescription}`) 63 | .addText(text => { 64 | userInputText = text; 65 | text.setValue(this.userInput ?? ""); 66 | text.onChange(value => { 67 | this.userInput = value; 68 | }); 69 | }) 70 | .addButton(btn => { 71 | btn.setIcon('dices') 72 | .setTooltip('Create random block id') 73 | .setCta() 74 | .onClick(() => { 75 | this.userInput = this.plugin.createRandomHexString(); 76 | userInputText?.setValue(this.userInput); 77 | }) 78 | }) 79 | 80 | const actions = new Setting(contentEl) 81 | .addButton(btn => { 82 | btn.setIcon('file-plus-2') 83 | .setTooltip('Create file') 84 | .setCta() 85 | .onClick(() => { 86 | this.onSubmit({ type: 'createFile', newName: this.userInput.trimEnd() }); 87 | this.close(); 88 | }) 89 | }) 90 | if (this.section.type === 'reference') { 91 | const section = this.section; 92 | actions.addButton(btn => { 93 | btn.setIcon('link') 94 | .setTooltip('Link to reference') 95 | .setCta() 96 | .onClick(() => { 97 | this.onSubmit({ 98 | type: 'linkToReference', 99 | section, 100 | newName: this.userInput.trimEnd() 101 | }); 102 | this.close(); 103 | }) 104 | }) 105 | } 106 | actions.addButton(btn => { 107 | btn.setIcon('scissors') 108 | .setTooltip('Cut') 109 | .setCta() 110 | .onClick(() => { 111 | this.onSubmit({ type: 'cut' }); 112 | this.close(); 113 | }) 114 | }).addButton(btn => { 115 | btn.setIcon('x') 116 | .setTooltip(`Cancel`) 117 | .setCta() 118 | .onClick(() => { 119 | this.onSubmit({ type: 'cancel' }); 120 | this.close(); 121 | }) 122 | }) 123 | if (this.errorMessage) { 124 | actions.setDesc(this.errorMessage) 125 | } 126 | } 127 | onClose(): void { 128 | const { contentEl } = this; 129 | contentEl.empty(); 130 | } 131 | getNameDesc = (fileName: string, blockName?: string): DocumentFragment => { 132 | const frag = document.createDocumentFragment() 133 | frag.createDiv().innerText = `Create file ${fileName}`; 134 | if (blockName) { 135 | frag.createDiv().innerText = `or`; 136 | frag.createDiv().innerText = `Link to block ${fileName}`; 137 | } 138 | return frag 139 | } 140 | trySetDescription(setting: Setting, desc: string | DocumentFragment): void { 141 | try { 142 | setting?.setDesc(desc); 143 | } catch (e) { 144 | console.log("expect set description before closing Modal", e); 145 | } 146 | } 147 | debounce(fn: (...arg: [...T]) => R, sec: number) { 148 | let timer: NodeJS.Timeout; 149 | return (...arg: [...T]) => { 150 | clearTimeout(timer); 151 | return new Promise(resolve => { 152 | timer = setTimeout(() => { 153 | const res = fn(...arg); 154 | resolve(res) 155 | }, sec * 1000); 156 | }) 157 | } 158 | } 159 | parseToValidFile(text: string) { 160 | return text.replace(FILENAMEREPLACE(), '') 161 | } 162 | parseToValidBlockName(text: string) { 163 | if (this.section.type === 'reference') { 164 | const block = this.section.block; 165 | return isHeadingBlock(block) 166 | ? text 167 | : text.replace(BLOCKIDREPLACE(), '') 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/ui/linkSettings.ts: -------------------------------------------------------------------------------- 1 | import type CardNote from "main"; 2 | import { Modal, Setting } from "obsidian"; 3 | 4 | export class LinkSettingModel extends Modal { 5 | plugin: CardNote; 6 | label?: string; 7 | onSubmit: (value?: string) => void; 8 | constructor(plugin: CardNote, onSubmit: (value?: string) => void) { 9 | super(plugin.app); 10 | this.plugin = plugin; 11 | this.onSubmit = onSubmit; 12 | } 13 | onOpen(): void { 14 | const { contentEl } = this; 15 | const setting = new Setting(contentEl) 16 | .setName('Set your label') 17 | .setDesc('Enter empty could disable adding a label on the link edge automatically') 18 | .addText(text => { 19 | text.setValue(this.plugin.settings.defaultLinkLabel ?? '') 20 | text.onChange(value => { 21 | this.label = value.length !== 0 ? value : undefined; 22 | }) 23 | }) 24 | .addButton(btn => { 25 | btn.setIcon('check') 26 | .onClick(() => { 27 | this.onSubmit(this.label) 28 | this.close(); 29 | }) 30 | .setCta(); 31 | }) 32 | } 33 | onClose(): void { 34 | const { contentEl } = this; 35 | contentEl.empty(); 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /src/utility.ts: -------------------------------------------------------------------------------- 1 | import CardNote from "main"; 2 | import type { Changes, ChangeInfo, LinkCache, CacheItem } from "obsidian"; 3 | import type { CanvasNodeData, NodeSide } from "obsidian/canvas"; 4 | 5 | 6 | 7 | export const BLOCKIDREPLACE = () => /[^a-zA-Z\d-]+/g; 8 | export const FILENAMEREPLACE = () => /[!"#$%&()*+,.:;<=>?@^`{|}~/[\]\\\r\n]/g; 9 | export const HEADINGREPLACE = () => /([:#|^\\\r\n]|%%|\[\[|]])/g 10 | 11 | export type RequiredProperties = Omit & Required> 12 | 13 | export const getCacheOffset = (c: CacheItem): [number, number] => { 14 | const cacheStart = c.position.start.offset, 15 | cacheEnd = c.position.end.offset; 16 | return [cacheStart, cacheEnd]; 17 | }; 18 | export function throttle( 19 | cb: (...args: [...T]) => V, 20 | secondTimeout = 0, 21 | ) { 22 | let timer = false; 23 | let result: V; 24 | return (...args: [...T]) => { 25 | if (!timer) { 26 | timer = true; 27 | setTimeout(() => { 28 | timer = false; 29 | }, 1000 * secondTimeout); 30 | result = cb(...args); 31 | } 32 | return result; 33 | }; 34 | } 35 | 36 | export const LineBreak = "\n"; 37 | export const MarkdownFileExtension = ".md"; 38 | 39 | export type Break = undefined; 40 | export type FileInfo = { 41 | fileName: string, 42 | folderPath: string, 43 | extension: string, 44 | } 45 | export type CheckConfig = { 46 | create: () => T, 47 | update: (prev: T) => T, 48 | provide: (arg: T, unapprove: R | undefined, errorMessage?: string) => Promise, 49 | check: (value: R) => Promise, 50 | } 51 | export function isBreak(name: any): name is Break { 52 | return name === undefined; 53 | } 54 | export function createFullPath(file: FileInfo) { 55 | const fileName = `${file.fileName}${file.extension}`; 56 | return file.folderPath.length === 0 57 | ? fileName 58 | : `${file.folderPath}/${fileName}`; 59 | } 60 | 61 | export async function ReCheck(config: CheckConfig): Promise { 62 | let errorMessage: string | undefined; 63 | let args = config.create(); 64 | let result: Awaited | undefined; 65 | // eslint-disable-next-line no-constant-condition 66 | while (true) { 67 | try { 68 | result = await config.provide(args, result, errorMessage); 69 | if (isBreak(result)) { 70 | return result; 71 | } 72 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 73 | const validResult = await config.check(result!); 74 | if (validResult instanceof Error) { 75 | throw validResult; 76 | } 77 | else { 78 | return validResult; 79 | } 80 | } catch (error: any) { 81 | args = config.update(args); 82 | errorMessage = error.message; 83 | continue; 84 | } 85 | 86 | } 87 | } 88 | export async function createDefaultFileName(plugin: CardNote, content: string) { 89 | const folderPath = plugin.settings.defaultFolder; 90 | const createRandomFileName = () => { 91 | return ReCheck({ 92 | create: () => { 93 | return { name: "NewNote", count: 0 }; 94 | }, 95 | update: (prev) => ({ name: prev.name, count: prev.count + 1 }), 96 | provide: (arg) => Promise.resolve({ 97 | folderPath, 98 | fileName: `${arg.name}${arg.count}`, 99 | extension: MarkdownFileExtension, 100 | }), 101 | check: plugin.checkFileName 102 | }) 103 | } 104 | return await createRandomFileName() as FileInfo; 105 | } 106 | 107 | export const HEADING = () => /^(?
#{1,6}\s)(?.*)/; 108 | export type Heading = { 109 | type: 'heading' 110 | headingSymbol: string, 111 | title: string, 112 | } 113 | 114 | export const LIST = /^([ \t]*)(?<listSymbol>[*+-]|\d+[.)])( {1,4}(?! )| |\t|$|(?=\n))(?<item>[^\n]*)/ 115 | export const TASK = /^([ \t]*)(?<task>\[.\])?( {1,4}(?! )| |\t|$|(?=\n))(?<item>[^\n]*)/ 116 | export type ListItem = { 117 | type: 'list', 118 | listSymbol: string, 119 | item: string, 120 | } 121 | export type TaskItem = Omit<ListItem, 'type'> & { 122 | type: 'task', 123 | task: string 124 | } 125 | export type Text = { 126 | type: 'text' 127 | title: string 128 | } 129 | export type TextWithSymbol = { 130 | type: 'heading' | 'list', 131 | symbol: string, 132 | title: string, 133 | } 134 | export type MarkdownSyntax = TextWithSymbol | Text; 135 | export function listItemParser(text: string): ListItem | TaskItem | undefined { 136 | const match = LIST.exec(text); 137 | if (match) { 138 | const groups = match.groups, 139 | listSymbol = groups?.listSymbol, 140 | item = groups?.item; 141 | if (item) { 142 | const taskMatch = TASK.exec(item), 143 | taskGroups = taskMatch?.groups, 144 | task = taskGroups?.task, 145 | taskItem = taskGroups?.item; 146 | return task ? { 147 | type: 'task', 148 | listSymbol: listSymbol!, 149 | task: task, 150 | item: taskItem ?? '', 151 | } : { 152 | type: 'list', 153 | listSymbol: listSymbol!, 154 | item, 155 | } 156 | } 157 | else { 158 | return { 159 | type: 'list', 160 | listSymbol: listSymbol!, 161 | item: '', 162 | } 163 | } 164 | } 165 | } 166 | export function markdownParser(content: string): MarkdownSyntax { 167 | const headingMatch = HEADING().exec(content); 168 | if (headingMatch?.groups) { 169 | return { 170 | type: 'heading', 171 | symbol: headingMatch.groups.header.trim(), 172 | title: headingMatch.groups.title, 173 | } 174 | } 175 | const listMatch = LIST.exec(content); 176 | if (listMatch?.groups) { 177 | return { 178 | type: 'list', 179 | symbol: listMatch.groups.list.trim(), 180 | title: listMatch.groups.text, 181 | } 182 | } 183 | return { type: 'text', title: content } 184 | } 185 | export type LinkInfo = { 186 | path: string, 187 | subpath: string, 188 | link: LinkCache, 189 | } 190 | 191 | 192 | export type LinkText = Partial<{ 193 | left: string, 194 | right: string, 195 | path: string, 196 | display: string, 197 | displayText: string, 198 | }> & { 199 | text: string 200 | } 201 | 202 | const WIKILINK = () => new RegExp(/^(?<left>!?\[\[)(?<link>.*?)(?<display>\|(?<displayText>.*))?(?<right>]])$/); 203 | const MARKDOWNLINK = () => /^(?<left>!?\[)(?<displayText>.*?)(?<mid>]\(\s*)(?<link>[^ ]+)(?<right>(?:\s+.*?)?\))$/; 204 | export function UpdateLinkText(sourcePath: string, linkInfo: LinkInfo, newPath: (link: LinkInfo) => string): ChangeInfo { 205 | const linkMatch: { regex: RegExp, newText: (match: RegExpExecArray, path: string) => string }[] = [ 206 | { 207 | regex: WIKILINK(), 208 | newText: (match, path) => { 209 | const display = match.groups?.display ?? ""; 210 | return `${match.groups?.left}${path}${display}${match.groups?.right}` 211 | } 212 | }, 213 | { 214 | regex: MARKDOWNLINK(), 215 | newText(match, path) { 216 | const display = match.groups?.displayText ?? ""; 217 | return `${match.groups?.left}${display}${match.groups?.mid}${path}${match.groups?.right}` 218 | }, 219 | }]; 220 | for (const r of linkMatch) { 221 | const match = r.regex.exec(linkInfo.link.original); 222 | if (match) { 223 | const np = newPath(linkInfo); 224 | const newText = r.newText(match, np); 225 | return { 226 | change: newText, 227 | reference: linkInfo.link, 228 | sourcePath, 229 | } 230 | } 231 | } 232 | return { 233 | change: `[[${newPath(linkInfo)}]]`, 234 | reference: linkInfo.link, 235 | sourcePath, 236 | } 237 | 238 | } 239 | export function LinkToChanges(linkMap: Map<string, LinkInfo[]>, newPath: (link: LinkInfo) => string): Changes { 240 | const change: Changes = { 241 | data: {}, 242 | keys: () => Object.keys(change.data), 243 | add: (key, value) => { 244 | const values = change.data[key]; 245 | if (values && !values.contains(value)) { 246 | if (!values.contains(value)) { 247 | values.push(value); 248 | } 249 | } 250 | else { 251 | change.data[key] = [value]; 252 | } 253 | }, 254 | remove: (key, value) => { 255 | const values = change.data[key]; 256 | values?.remove(value); 257 | }, 258 | removeKey: (key) => { delete change.data[key] }, 259 | get: (key) => change.data[key], 260 | clear: (key) => change.removeKey(key), 261 | clearAll: () => { change.data = {} }, 262 | contains: (key, value) => change.data[key]?.contains(value), 263 | count: () => { 264 | let c = 0; 265 | for (const key in change.data) { 266 | const len = change.data[key].length; 267 | c += len; 268 | } 269 | return c 270 | }, 271 | } 272 | linkMap.forEach((value, key) => { 273 | const changeInfo = value.map(text => UpdateLinkText(key, text, newPath)); 274 | change.data[key] = changeInfo 275 | }) 276 | 277 | return change 278 | } 279 | 280 | export const reverseRelative = new Map<NodeSide, NodeSide>([ 281 | ['top', 'bottom'], 282 | ['right', 'left'], 283 | ['bottom', 'top'], 284 | ['left', 'right'] 285 | ]) 286 | export function getRelativePosition(center: CanvasNodeData, relative: CanvasNodeData): NodeSide | undefined { 287 | const { x: xStart, y: yStart, width, height } = center; 288 | const xEnd = xStart + width, 289 | yEnd = yStart + height; 290 | const { x: xR, y: yR, width: Rwidth } = relative; 291 | const xREnd = xR + Rwidth; 292 | return xR >= xEnd ? 'right' 293 | : xREnd <= xStart ? 'left' 294 | : yR <= yStart ? 'top' 295 | : yR >= yEnd ? 'bottom' 296 | : undefined 297 | } 298 | export type PatternMatch = { 299 | test: (string: string) => boolean 300 | } 301 | export function tryCreateRegex(pattern: string, flags?: string) { 302 | try { 303 | return new RegExp(pattern, flags) 304 | } 305 | catch { 306 | // return undefined 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/view/Index.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import type { GridChildComponentProps } from "svelte-window"; 3 | 4 | export let items: GridChildComponentProps[]; 5 | export let columnCount: number; 6 | // export let files:FileMatch[]; 7 | const computeIndex = (item: GridChildComponentProps) => { 8 | const dataBefore = item.rowIndex * columnCount, 9 | columOffest = item.columnIndex, 10 | dataIndex = dataBefore + columOffest; 11 | return dataIndex 12 | }; 13 | </script> 14 | <!-- {#each items as prop (files[computeIndex(prop)].file.path)} --> 15 | <slot item={items.map(it=>({...it,index:computeIndex(it)}))}></slot> 16 | <!-- {/each} --> 17 | -------------------------------------------------------------------------------- /src/view/cardSearchView.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf } from "obsidian"; 2 | import Search from "./components/Search.svelte" 3 | import type CardNote from "main"; 4 | 5 | 6 | export const VIEW_TYPE_CARDNOTESEARCH = "card-notes-view" 7 | export class CardSearchView extends ItemView { 8 | plugin: CardNote; 9 | component?: Search; 10 | constructor(leaf: WorkspaceLeaf, plugin: CardNote) { 11 | super(leaf); 12 | this.plugin = plugin; 13 | } 14 | getViewType(): string { 15 | return VIEW_TYPE_CARDNOTESEARCH 16 | } 17 | getDisplayText(): string { 18 | return "Notes" 19 | } 20 | protected async onOpen(): Promise<void> { 21 | this.component = new Search({ 22 | target: this.containerEl.children[1], 23 | props: { 24 | view: this, 25 | }, 26 | }) 27 | } 28 | protected async onClose(): Promise<void> { 29 | this.component?.$destroy() 30 | } 31 | } -------------------------------------------------------------------------------- /src/view/components/ButtonGroups.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts" context="module"> 2 | export type Button<T> = { 3 | icon: string; 4 | toolTip: string; 5 | value: T; 6 | active?: boolean; 7 | }; 8 | </script> 9 | 10 | <script lang="ts" generics="T"> 11 | import { ButtonComponent } from "obsidian"; 12 | 13 | export let buttons: Button<T>[] = []; 14 | export let onclick: (e: MouseEvent, value: T) => void; 15 | let active = ""; 16 | let unActive = () => {}; 17 | const setUnActiveButton = (butt: ButtonComponent) => { 18 | unActive = () => { 19 | butt.removeCta(); 20 | }; 21 | }; 22 | const renderIcon = (el: HTMLElement, but: Button<T>) => { 23 | const b = new ButtonComponent(el) 24 | .setIcon(but.icon) 25 | .setTooltip(but.toolTip) 26 | .onClick((e) => { 27 | if (active !== but.icon) { 28 | b.setCta(); 29 | unActive(); 30 | onclick(e, but.value); 31 | active = but.icon; 32 | setUnActiveButton(b); 33 | } 34 | }); 35 | if (but.active) { 36 | b.setCta(); 37 | active = but.icon; 38 | setUnActiveButton(b); 39 | } 40 | }; 41 | </script> 42 | 43 | {#each buttons as but} 44 | <div use:renderIcon={but}></div> 45 | {/each} 46 | -------------------------------------------------------------------------------- /src/view/components/Card.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts" context="module"> 2 | export type SectionContent = { 3 | content: string; 4 | matches?: SearchMatches; 5 | cache?: SectionCache; 6 | offset?: number; 7 | }; 8 | const extendMatchRange = ( 9 | result?: SearchResult, 10 | cache?: CachedMetadata | null, 11 | ) => { 12 | return ( 13 | result?.matches.map((match) => { 14 | const matchInternalLink = 15 | cache?.embeds?.map(getCacheOffset).find(Touch(match)) ?? 16 | cache?.links?.map(getCacheOffset).find(Touch(match)); 17 | return matchInternalLink 18 | ? ([ 19 | Math.min(matchInternalLink[0], match[0]), 20 | Math.max(matchInternalLink[1], match[1]), 21 | ] as SearchMatchPart) 22 | : match; 23 | }) ?? [] 24 | ); 25 | }; 26 | </script> 27 | 28 | <script lang="ts"> 29 | import { 30 | Menu, 31 | setIcon, 32 | type CachedMetadata, 33 | type Component, 34 | type SearchMatches, 35 | type SearchMatchPart, 36 | type SearchResult, 37 | type SectionCache, 38 | } from "obsidian"; 39 | import { onMount } from "svelte"; 40 | import ObsidianMarkdown from "./obsidian/obsidianMarkdown.svelte"; 41 | import { styleString, type StyleObject } from "svelte-window"; 42 | import { getCacheOffset } from "src/utility"; 43 | import { 44 | validCacheReadFilesExtension, 45 | Touch, 46 | type FileMatch, 47 | InRange, 48 | SectionCacheType, 49 | } from "src/file"; 50 | import { curryRight, take, uniqWith } from "lodash"; 51 | import type { CardSearchView } from "../cardSearchView"; 52 | import { isObsidianCanvasView } from "src/adapters/obsidian"; 53 | import { 54 | insertEmbeddableOnDrawing, 55 | isExcalidrawView, 56 | } from "src/adapters/obsidian-excalidraw-plugin"; 57 | 58 | export let view: CardSearchView; 59 | export let component: Component; 60 | export let files: FileMatch[]; 61 | export let index: number; 62 | export let cellStyle: StyleObject; 63 | let fileMatch: FileMatch = files[index]; 64 | let app = view.app; 65 | let data: string | undefined; 66 | let contents: SectionContent[] = []; 67 | let onHover = false; 68 | let showContentCounts = 3; 69 | let listener: { 70 | reset: () => void; 71 | }; 72 | let dragSymbol: HTMLElement; 73 | const moveFileToTrashFolder = (e: MouseEvent) => { 74 | const mn = new Menu().addItem((item) => { 75 | item.setIcon("trash-2") 76 | .setTitle("delete file") 77 | .onClick((c) => { 78 | view.app.vault.trash(fileMatch.file, false); 79 | }); 80 | }); 81 | mn.showAtMouseEvent(e); 82 | }; 83 | 84 | const parsing = (data?: string) => { 85 | if (data) { 86 | const cache = app.metadataCache.getFileCache(fileMatch.file); 87 | const matches = uniqWith( 88 | extendMatchRange(fileMatch.match, cache), 89 | (a, b) => Touch(a)(b), 90 | ); 91 | return cache?.sections?.map((section) => { 92 | const sectionPostion = getCacheOffset(section); 93 | const [start, end] = sectionPostion; 94 | const matchInSection = matches.filter(InRange(sectionPostion)); 95 | const sectionContent = data.substring(start, end); 96 | const offset = start; 97 | if (section.type === SectionCacheType.code) { 98 | return { 99 | content: sectionContent, 100 | matches: matchInSection, 101 | cache: section, 102 | offset, 103 | }; 104 | } 105 | const highlightParsing: [string, number] = 106 | matchInSection.reduce( 107 | (prev, match) => { 108 | const [prevContent, prevEnd] = prev; 109 | const [matchStart, matchEnd] = [ 110 | match[0] - offset, 111 | match[1] - offset, 112 | ]; 113 | const prevSection = sectionContent.substring( 114 | prevEnd, 115 | matchStart, 116 | ); 117 | const highlightMatch = `==${sectionContent.substring(matchStart, matchEnd)}==`; 118 | 119 | return [ 120 | prevContent + prevSection + highlightMatch, 121 | matchEnd, 122 | ]; 123 | }, 124 | ["", 0] as [string, number], 125 | ); 126 | const [highlightContensts, parseEnd] = highlightParsing; 127 | return { 128 | content: 129 | parseEnd <= end 130 | ? highlightContensts + 131 | sectionContent.substring(parseEnd, end) 132 | : highlightContensts, 133 | matches: matchInSection, 134 | cache: section, 135 | }; 136 | }); 137 | } 138 | }; 139 | const openFileOnMatch = (e: MouseEvent, matches: SearchMatches) => { 140 | if (e.target instanceof HTMLAnchorElement) { 141 | //do nothing 142 | //handle by container 143 | return; 144 | } 145 | if (matches) { 146 | view.plugin.onClickOpenFile(e, fileMatch.file, { 147 | eState: { 148 | match: { 149 | content: data ?? "", 150 | matches, 151 | }, 152 | }, 153 | }); 154 | e.stopPropagation(); 155 | } 156 | }; 157 | const onOpenFile = (e: MouseEvent) => { 158 | const target = e.target; 159 | if (target instanceof HTMLAnchorElement) { 160 | if (target.classList.contains("internal-link")) { 161 | const linktext = target.getAttribute("data-href"); 162 | if (linktext) { 163 | view.app.workspace.openLinkText( 164 | linktext, 165 | fileMatch.file.path, 166 | ); 167 | } 168 | } 169 | //have nothing to do 170 | //do the HtmlAnchor default action 171 | return; 172 | } else { 173 | view.plugin.onClickOpenFile(e, fileMatch.file); 174 | } 175 | }; 176 | const setContent = async () => { 177 | const content = validCacheReadFilesExtension.contains( 178 | fileMatch.file.extension, 179 | ) 180 | ? fileMatch.content 181 | ? Promise.resolve(fileMatch.content) 182 | : app.vault.cachedRead(fileMatch.file) 183 | : Promise.resolve(fileMatch.content); 184 | data = await content; 185 | contents = parsing(data) ?? [ 186 | { 187 | content: app.fileManager.generateMarkdownLink( 188 | fileMatch.file, 189 | "", 190 | ), 191 | }, 192 | ]; 193 | }; 194 | const dragCard = (dragStart: DragEvent) => { 195 | const createFileInView = (drop: DragEvent) => { 196 | const drawView = view.plugin.getDropView(drop); 197 | 198 | if (isObsidianCanvasView(drawView)) { 199 | const pos = drawView.canvas.posFromEvt(drop); 200 | drawView.canvas.createFileNode({ 201 | file: fileMatch.file, 202 | pos, 203 | save: true, 204 | }); 205 | } 206 | if (isExcalidrawView(drawView)) { 207 | const link = view.app.fileManager.generateMarkdownLink( 208 | fileMatch.file, 209 | drawView.file?.path ?? "", 210 | ); 211 | insertEmbeddableOnDrawing( 212 | drop, 213 | drawView, 214 | link, 215 | fileMatch.file, 216 | view.plugin, 217 | ); 218 | } 219 | }; 220 | // the default drag img will drag sibling elements... 221 | const img = new Image(); 222 | setIcon(img, "file-text"); 223 | dragSymbol = view.containerEl.createDiv(); 224 | const icon = dragSymbol.createDiv(), 225 | filInfoEl = dragSymbol.createSpan(); 226 | icon.style.display = "inline-block"; 227 | setIcon(icon, "file-text"); 228 | filInfoEl.textContent = " " + fileMatch.file.path; 229 | dragSymbol.setCssStyles({ 230 | position: "absolute", 231 | transform: "translate(-1000px,-1000px)", 232 | }); 233 | dragStart.dataTransfer?.setDragImage(dragSymbol, 0, 30); 234 | setTimeout(async () => { 235 | listener = view.plugin.listenDragAndDrop( 236 | dragStart, 237 | data ?? "", 238 | createFileInView, 239 | ); 240 | }); 241 | }; 242 | const reset = (dragEnd: DragEvent) => { 243 | listener.reset(); 244 | view.containerEl.removeChild(dragSymbol); 245 | }; 246 | onMount(() => { 247 | setContent(); 248 | }); 249 | </script> 250 | 251 | <div 252 | on:mouseenter={(e) => (onHover = true)} 253 | on:mouseleave={(e) => (onHover = false)} 254 | on:contextmenu={moveFileToTrashFolder} 255 | on:dragstart={dragCard} 256 | on:dragend={reset} 257 | class={onHover ? "fullContent" : "fewContent"} 258 | style={styleString(cellStyle)} 259 | draggable={true} 260 | on:click={onOpenFile} 261 | > 262 | {#if fileMatch?.file} 263 | <h2>{fileMatch.file.basename}</h2> 264 | <h6 class="nav-file-tag" style:font-size={"12px"}> 265 | {fileMatch.file.extension !== "md" ? fileMatch.file.extension : ""} 266 | </h6> 267 | {#if fileMatch.file.parent && fileMatch.file.parent.path !== "/"} 268 | <strong>{fileMatch.file.parent?.path}</strong> 269 | {/if} 270 | {/if} 271 | {#each onHover ? contents : take(contents, showContentCounts) as cont, index (index)} 272 | {#if cont.matches && cont.matches.length !== 0} 273 | <!-- {#if cont.cache?.type === SectionCacheType.code} 274 | <div 275 | use:ObsidianResultRender={{ 276 | text: cont.content, 277 | result: cont.matches, 278 | offset: -(cont.offset ?? 0), 279 | }} 280 | ></div> 281 | {/if} --> 282 | <div on:click={curryRight(openFileOnMatch)(cont.matches)}> 283 | <ObsidianMarkdown 284 | {app} 285 | {component} 286 | sourcePath={fileMatch.file.path} 287 | markdown={cont.content} 288 | ></ObsidianMarkdown> 289 | </div> 290 | {:else} 291 | <ObsidianMarkdown 292 | {app} 293 | {component} 294 | sourcePath={fileMatch.file.path} 295 | markdown={cont.content} 296 | ></ObsidianMarkdown> 297 | {/if} 298 | {/each} 299 | {#if contents.length > showContentCounts && !onHover} 300 | ... 301 | {/if} 302 | </div> 303 | 304 | <style> 305 | .fullContent, 306 | .fewContent { 307 | border: 2px solid; 308 | border-radius: 15px; 309 | padding: 10px; 310 | } 311 | .fewContent { 312 | overflow: hidden; 313 | } 314 | .fullContent { 315 | overflow: scroll; 316 | } 317 | </style> 318 | -------------------------------------------------------------------------------- /src/view/components/ComputeLayout.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | export let viewHeight: number; 3 | export let viewWidth: number; 4 | export let columnWidth: number; 5 | export let gap: number; 6 | export let totalCount: number; 7 | 8 | let columns: number, 9 | rows: number, 10 | residueSpace: number = 0; 11 | 12 | $: { 13 | const acutualColumnWidth = columnWidth + gap; 14 | const col = Math.floor(viewWidth / acutualColumnWidth); 15 | columns = col === 0 ? 1 : col; 16 | rows = Math.ceil(totalCount / columns); 17 | residueSpace = (viewWidth - columns * acutualColumnWidth) / 2; 18 | } 19 | 20 | </script> 21 | 22 | 23 | <slot 24 | gridProps={{ 25 | columns, 26 | rows, 27 | viewHeight, 28 | padding: residueSpace >= 0 ? residueSpace : 0, 29 | }} 30 | /> 31 | -------------------------------------------------------------------------------- /src/view/components/Search.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { 3 | SliderComponent, 4 | TFile, 5 | debounce, 6 | ButtonComponent, 7 | TAbstractFile, 8 | } from "obsidian"; 9 | import { onMount } from "svelte"; 10 | import { 11 | FixedSizeGrid as Grid, 12 | type StyleObject, 13 | FixedSizeGrid, 14 | type GridOnScrollProps, 15 | } from "svelte-window"; 16 | import AutoSizer from "svelte-virtualized-auto-sizer"; 17 | import type { CardSearchView } from "../cardSearchView"; 18 | import ComputeLayout from "./ComputeLayout.svelte"; 19 | import ButtonGroups, { type Button } from "./ButtonGroups.svelte"; 20 | import { derived, writable, type Readable } from "svelte/store"; 21 | import { 22 | search as searchByObsidian, 23 | sortByCreateTime as byCreateTime, 24 | sortByModifiedTime as byModifiedTime, 25 | sortByRelated as byRelated, 26 | sortByName as byName, 27 | Seq, 28 | type SortMethod as Sort, 29 | type File, 30 | type FileMatch as FM, 31 | type SearchedFile, 32 | isSearchedFile, 33 | validCacheReadFilesExtension, 34 | searchByRegex, 35 | } from "src/file"; 36 | import Card from "src/view/components/Card.svelte"; 37 | import Index from "../Index.svelte"; 38 | import SearchInput, { type SearchSettings } from "./searchInput.svelte"; 39 | import { tryCreateRegex } from "src/utility"; 40 | 41 | export let view: CardSearchView; 42 | 43 | let columnWidth = view.plugin.settings.columnWidth; 44 | let rowHeight = view.plugin.settings.rowHeight; 45 | let showLayoutMenu = false; 46 | const gutter = 30; 47 | const filesNew = writable<TFile[]>([]); 48 | const searchSettings = writable<SearchSettings>({ 49 | useRegex: view.plugin.settings.useRegex, 50 | matchCase: view.plugin.settings.matchCase, 51 | showSearchDetail: view.plugin.settings.showSearchDetail, 52 | }); 53 | const queryNew = writable(view.plugin.settings.query); 54 | const include = writable(view.plugin.settings.include); 55 | const exclude = writable(view.plugin.settings.exclude); 56 | const sortMethodNew = writable(byModifiedTime); 57 | const seqNew = writable(Seq.descending); 58 | const filesReadyForSearch: Readable<TFile[]> = derived( 59 | [filesNew, include, exclude], 60 | ([$files, $include, $exclude], set) => { 61 | const getRemovePattern = () => 62 | tryCreateRegex($exclude) ?? { 63 | test: (str: string) => false, 64 | }; 65 | const getIncludePattern = () => 66 | tryCreateRegex($include) ?? { 67 | test: (str: string) => true, 68 | }; 69 | const includeFiles = $files.filter((file) => { 70 | const remove = 71 | $exclude.length != 0 && getRemovePattern().test(file.path); 72 | const need = 73 | $include.length == 0 || getIncludePattern().test(file.path); 74 | return !remove && need; 75 | }); 76 | set(includeFiles); 77 | }, 78 | ); 79 | const searchedFiles: Readable<File[]> = derived( 80 | [filesReadyForSearch, queryNew, searchSettings], 81 | ([$files, $query, $setting], set) => { 82 | const searchMethod = $setting.useRegex 83 | ? searchByRegex($query, $setting.matchCase ? "g" : "gi") 84 | : searchByObsidian($query); 85 | const finds = 86 | $query.length !== 0 87 | ? Promise.all( 88 | $files.map(async (file) => { 89 | var fileCache = $searchedFiles.find( 90 | (f) => 91 | isSearchedFile(f) && 92 | f.file.path === file.path, 93 | ) as SearchedFile | undefined; 94 | var content = 95 | fileCache && 96 | fileCache.file.stat.mtime === 97 | file.stat.mtime 98 | ? Promise.resolve(fileCache.content) 99 | : validCacheReadFilesExtension.contains( 100 | file.extension, 101 | ) 102 | ? view.app.vault.cachedRead(file) 103 | : Promise.resolve(""); 104 | const cont = await content; 105 | return searchMethod(file, cont ?? ""); 106 | }), 107 | ).then( 108 | (files) => 109 | files.filter( 110 | (f) => f !== undefined, 111 | ) as SearchedFile[], 112 | ) 113 | : Promise.resolve($files); 114 | finds.then((data) => set(data)); 115 | }, 116 | [] as File[], 117 | ); 118 | const filesDisplay: Readable<FM[]> = derived( 119 | [searchedFiles, sortMethodNew, seqNew], 120 | ([$files, $sortMethod, $seq], set) => { 121 | const sortM: Sort = 122 | $seq === Seq.descending 123 | ? (a, b) => -$sortMethod(a, b) 124 | : $sortMethod; 125 | const sortFiles = $files.map((f) => 126 | isSearchedFile(f) ? f : { file: f }, 127 | ); 128 | sortFiles.sort(sortM); 129 | set([...sortFiles]); 130 | }, 131 | [] as FM[], 132 | ); 133 | 134 | let offset: GridOnScrollProps = { 135 | scrollLeft: 0, 136 | scrollTop: 0, 137 | verticalScrollDirection: "forward", 138 | scrollUpdateWasRequested: false, 139 | horizontalScrollDirection: "forward", 140 | }; 141 | 142 | const computeKey = (index: number) => { 143 | return index < $filesDisplay.length 144 | ? index + 145 | $filesDisplay[index].file.path + 146 | $filesDisplay[index].file.stat.mtime + 147 | $queryNew + 148 | ($queryNew.length !== 0 149 | ? `${$searchSettings.useRegex}${$searchSettings.matchCase}` 150 | : "") 151 | : index; 152 | }; 153 | 154 | onMount(() => { 155 | $filesNew = view.app.vault.getFiles(); 156 | const vault = view.app.vault; 157 | const registerVaultEvent = (callback: (f: TFile) => void) => { 158 | return (tf: TAbstractFile) => { 159 | if (tf instanceof TFile) { 160 | callback(tf); 161 | } 162 | }; 163 | }; 164 | const create = view.app.vault.on( 165 | "create", 166 | registerVaultEvent((newF) => { 167 | $filesNew = [...$filesNew, newF]; 168 | }), 169 | ); 170 | const del = view.app.vault.on( 171 | "delete", 172 | registerVaultEvent((delF) => { 173 | $filesNew = $filesNew.filter((of) => of !== delF); 174 | }), 175 | ); 176 | const modify = view.app.vault.on( 177 | "modify", 178 | registerVaultEvent(async (mf) => { 179 | $filesNew = $filesNew.map((of) => (of === mf ? mf : of)); 180 | }), 181 | ); 182 | const rename = view.app.vault.on("rename", (tf, oldPath) => 183 | registerVaultEvent((renameFile) => { 184 | $filesNew = $filesNew.map((old) => 185 | old.path === oldPath ? renameFile : old, 186 | ); 187 | })(tf), 188 | ); 189 | const leafChange = view.app.workspace.on( 190 | "active-leaf-change", 191 | async (leaf) => { 192 | if (leaf?.view.getViewType() === view.getViewType() && grid) { 193 | //if this view does not display on screen and the file is modified. 194 | //the scroll items display in the correct position but div element scrolltop will not scroll to the position we want 195 | //when the leaf change to this view 196 | //so sroll to 0,0 first 197 | //then scroll to memorized offset 198 | grid.scrollTo({ scrollLeft: 0, scrollTop: 0 }); 199 | grid.scrollTo({ 200 | scrollLeft: offset.scrollLeft, 201 | scrollTop: offset.scrollTop, 202 | }); 203 | } 204 | }, 205 | ); 206 | 207 | return () => { 208 | vault.offref(create); 209 | vault.offref(modify); 210 | vault.offref(del); 211 | vault.offref(rename); 212 | view.app.workspace.offref(leafChange); 213 | view.plugin.settings.query = $queryNew; 214 | view.plugin.settings.useRegex = $searchSettings.useRegex; 215 | view.plugin.settings.matchCase = $searchSettings.matchCase; 216 | view.plugin.settings.showSearchDetail = 217 | $searchSettings.showSearchDetail; 218 | view.plugin.settings.include = $include; 219 | view.plugin.settings.exclude = $exclude; 220 | view.plugin.saveSettings(); 221 | }; 222 | }); 223 | 224 | const layoutSetting = (ele: HTMLElement) => { 225 | const b = new ButtonComponent(ele) 226 | .setIcon("layout-grid") 227 | .setTooltip("Toggle Layout Detail") 228 | .onClick((e) => { 229 | if (showLayoutMenu) { 230 | b.removeCta(); 231 | } else { 232 | b.setCta(); 233 | } 234 | showLayoutMenu = !showLayoutMenu; 235 | }); 236 | }; 237 | 238 | const columnWidthSetting = (ele: HTMLElement) => { 239 | new SliderComponent(ele) 240 | .setLimits(200, 1000, 10) 241 | .setValue(columnWidth) 242 | .setDynamicTooltip() 243 | .onChange((value) => { 244 | view.plugin.settings.columnWidth = value; 245 | view.plugin.saveSettings(); 246 | columnWidth = value; 247 | }); 248 | }; 249 | const rowHeightSetting = (ele: HTMLElement) => { 250 | new SliderComponent(ele) 251 | .setLimits(200, 1000, 10) 252 | .setValue(rowHeight) 253 | .setDynamicTooltip() 254 | .onChange((value) => { 255 | view.plugin.settings.rowHeight = value; 256 | view.plugin.saveSettings(); 257 | rowHeight = value; 258 | }); 259 | }; 260 | const sortSeq: Button<Seq>[] = [ 261 | { 262 | icon: "arrow-down-narrow-wide", 263 | toolTip: "asc", 264 | value: Seq.ascending, 265 | }, 266 | { 267 | icon: "arrow-up-narrow-wide", 268 | toolTip: "desc", 269 | value: Seq.descending, 270 | active: true, 271 | }, 272 | ]; 273 | const sortMethods: Button<Sort>[] = [ 274 | { 275 | icon: "file-type-2", 276 | toolTip: "name", 277 | value: byName, 278 | }, 279 | { 280 | icon: "file-plus-2", 281 | toolTip: "last created", 282 | value: byCreateTime, 283 | }, 284 | { 285 | icon: "file-clock", 286 | toolTip: "last modified", 287 | value: byModifiedTime, 288 | active: true, 289 | }, 290 | { 291 | icon: "file-search", 292 | toolTip: "related", 293 | value: byRelated, 294 | }, 295 | ]; 296 | 297 | const computeGapStyle = ( 298 | style: StyleObject, 299 | padding: number, 300 | ): StyleObject => { 301 | const top = (style.top ?? 0) + gutter, 302 | left = (style.left ?? 0) + gutter + padding, 303 | width = 304 | typeof style.width === "number" 305 | ? style.width - gutter 306 | : style.width, 307 | height = 308 | typeof style.height === "number" 309 | ? style.height - gutter 310 | : style.height; 311 | 312 | return { 313 | ...style, 314 | top, 315 | left, 316 | width, 317 | height, 318 | }; 319 | }; 320 | const rememberScrollOffsetForFileUpdate = debounce( 321 | (props: GridOnScrollProps) => { 322 | offset = props; 323 | }, 324 | 2000, 325 | ); 326 | let grid: FixedSizeGrid; 327 | </script> 328 | 329 | <SearchInput 330 | query={queryNew} 331 | {include} 332 | {exclude} 333 | settings={searchSettings} 334 | debounceTime={700} 335 | ></SearchInput> 336 | <div class="searchMenuBar"> 337 | <div> 338 | {$filesDisplay.length} results 339 | </div> 340 | <div class="buttonBar"> 341 | {#if showLayoutMenu} 342 | <div> 343 | <div use:columnWidthSetting>column width</div> 344 | <div use:rowHeightSetting>row height</div> 345 | </div> 346 | {/if} 347 | <div use:layoutSetting></div> 348 | <ButtonGroups 349 | buttons={sortMethods} 350 | onclick={(e, value) => { 351 | $sortMethodNew = value; 352 | }} 353 | ></ButtonGroups> 354 | <ButtonGroups 355 | buttons={sortSeq} 356 | onclick={(e, value) => { 357 | $seqNew = value; 358 | }} 359 | ></ButtonGroups> 360 | </div> 361 | </div> 362 | <AutoSizer let:width={childWidth} let:height={childHeight}> 363 | <svelte:fragment> 364 | <ComputeLayout 365 | viewHeight={childHeight ?? 1000} 366 | viewWidth={childWidth ?? 1000} 367 | {columnWidth} 368 | gap={gutter} 369 | totalCount={$filesDisplay.length} 370 | let:gridProps 371 | > 372 | <Grid 373 | bind:this={grid} 374 | initialScrollTop={offset.scrollTop} 375 | columnCount={gridProps.columns} 376 | columnWidth={columnWidth + gutter} 377 | height={childHeight ?? 500} 378 | rowCount={gridProps.rows} 379 | rowHeight={rowHeight + gutter} 380 | width={childWidth ?? 500} 381 | onScroll={rememberScrollOffsetForFileUpdate} 382 | let:items 383 | > 384 | <Index {items} columnCount={gridProps.columns} let:item={cells}> 385 | {#each cells as cell (computeKey(cell.index))} 386 | {#if cell.index < $filesDisplay.length} 387 | <Card 388 | {view} 389 | cellStyle={computeGapStyle( 390 | cell.style, 391 | gridProps.padding, 392 | )} 393 | component={view} 394 | files={$filesDisplay} 395 | index={cell.index} 396 | ></Card> 397 | {/if} 398 | {/each} 399 | </Index> 400 | </Grid> 401 | </ComputeLayout> 402 | </svelte:fragment> 403 | </AutoSizer> 404 | 405 | <style> 406 | .searchMenuBar { 407 | display: flex; 408 | align-items: end; 409 | } 410 | .searchMenuBar { 411 | justify-content: space-between; 412 | } 413 | .buttonBar { 414 | display: flex; 415 | align-items: center; 416 | justify-content: space-evenly; 417 | /* grid-template-columns: repeat(6, minmax(0, 1fr)); */ 418 | /* grid-auto-flow: row; */ 419 | /* grid-auto-columns: min-content; */ 420 | gap: 3px; 421 | /* min-width: '150px'; */ 422 | /* max-width: 50%; */ 423 | } 424 | </style> 425 | -------------------------------------------------------------------------------- /src/view/components/obsidian/obsidianMarkdown.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { type App, type Component } from "obsidian"; 3 | import { ObsidianMarkdownRender } from "src/file"; 4 | 5 | 6 | export let app:App; 7 | export let component:Component; 8 | export let markdown:string; 9 | export let sourcePath:string; 10 | </script> 11 | 12 | <div use:ObsidianMarkdownRender={{ 13 | app, 14 | markdown, 15 | sourcePath, 16 | component, 17 | }} 18 | > 19 | </div> 20 | 21 | -------------------------------------------------------------------------------- /src/view/components/obsidian/useComponent.ts: -------------------------------------------------------------------------------- 1 | import { ButtonComponent, SearchComponent, TextComponent, ToggleComponent } from "obsidian"; 2 | import type { Action } from "svelte/action"; 3 | 4 | export type useSearch = (comp: SearchComponent) => void; 5 | 6 | export const obsidianSearch: Action<HTMLDivElement, useSearch> = (ele, use) => { 7 | use(new SearchComponent(ele)) 8 | } 9 | 10 | export type useButton = (comp: ButtonComponent) => void 11 | export const obsidianButton: Action<HTMLDivElement, useButton> = (ele, use) => { 12 | use(new ButtonComponent(ele)) 13 | } 14 | export type useToggleButton = { 15 | buttonSetting: useButton, 16 | toggle: () => boolean, 17 | } 18 | export const obsdianToggleButton: Action<HTMLDivElement, useToggleButton> = (ele, use) => { 19 | const button = new ButtonComponent(ele); 20 | use.toggle() && button.setCta(); 21 | ele.onClickEvent(() => { 22 | if (use.toggle()) { 23 | button.setCta(); 24 | } 25 | else { 26 | button.removeCta(); 27 | } 28 | }) 29 | use.buttonSetting(button) 30 | } 31 | export type useToggle = (comp: ToggleComponent) => void 32 | export const obsidianToggle: Action<HTMLDivElement, useToggle> = (ele, use) => { 33 | use(new ToggleComponent(ele)) 34 | } 35 | 36 | export type useText = (comp: TextComponent) => void 37 | export const obsidianText: Action<HTMLDivElement, useText> = (ele, use) => { 38 | use(new TextComponent(ele)) 39 | } 40 | -------------------------------------------------------------------------------- /src/view/components/searchInput.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts" context="module"> 2 | export type SearchSettings = { 3 | showSearchDetail: boolean; 4 | useRegex: boolean; 5 | matchCase: boolean; 6 | }; 7 | </script> 8 | 9 | <script lang="ts"> 10 | import { debounce } from "obsidian"; 11 | import { 12 | obsdianToggleButton as obsidianToggleButton, 13 | obsidianSearch, 14 | obsidianText, 15 | type useButton, 16 | type useSearch, 17 | type useText, 18 | } from "./obsidian/useComponent"; 19 | import { writable, type Writable } from "svelte/store"; 20 | 21 | export let query: Writable<string>; 22 | export let debounceTime = 0; 23 | export let include = writable(""); 24 | export let exclude = writable(""); 25 | export let settings: Writable<SearchSettings>; 26 | 27 | const bindInput: useSearch = (comp) => { 28 | comp.setValue($query).onChange( 29 | debounce((text) => { 30 | $query = text; 31 | }, debounceTime), 32 | ); 33 | // .inputEl.addClass("no-border-search-input") 34 | }; 35 | const regexButton: useButton = (comp) => { 36 | comp.setIcon("regex") 37 | .setTooltip("Use Regular Expression") 38 | .onClick((e) => { 39 | $settings.useRegex = !$settings.useRegex; 40 | }); 41 | }; 42 | const matchCaseButton: useButton = (comp) => { 43 | comp.setIcon("case-sensitive") 44 | .setTooltip("Match Case") 45 | .onClick((e) => { 46 | $settings.matchCase = !$settings.matchCase; 47 | }); 48 | }; 49 | const showSearchDetail: useButton = (button) => { 50 | button 51 | .setButtonText("...") 52 | .setTooltip("Toggle Search Detail") 53 | .onClick(() => { 54 | $settings.showSearchDetail = !$settings.showSearchDetail; 55 | }); 56 | }; 57 | const setTexInputStyle = (inputEl: HTMLInputElement) => { 58 | inputEl.setCssStyles({ width: "100%" }); 59 | }; 60 | const includeText: useText = (comp) => { 61 | setTexInputStyle( 62 | comp 63 | .setValue($include) 64 | .onChange( 65 | debounce((text) => { 66 | $include = text; 67 | }, debounceTime), 68 | ) 69 | .setPlaceholder("e.g. .*\\.md").inputEl, 70 | ); 71 | }; 72 | const excludeText: useText = (comp) => { 73 | setTexInputStyle( 74 | comp 75 | .setValue($exclude) 76 | .onChange( 77 | debounce((text) => { 78 | $exclude = text; 79 | }, debounceTime), 80 | ) 81 | .setPlaceholder("e.g. .*\\.png").inputEl, 82 | ); 83 | }; 84 | </script> 85 | 86 | <div class="searchBar"> 87 | <div use:obsidianSearch={bindInput} style:width="100%"></div> 88 | <div 89 | style:position="absolute" 90 | style:inset-inline-end="50px" 91 | style:display="flex" 92 | > 93 | <div 94 | use:obsidianToggleButton={{ 95 | buttonSetting: matchCaseButton, 96 | toggle: () => $settings.matchCase, 97 | }} 98 | ></div> 99 | <div 100 | use:obsidianToggleButton={{ 101 | buttonSetting: regexButton, 102 | toggle: () => $settings.useRegex, 103 | }} 104 | ></div> 105 | </div> 106 | </div> 107 | <div 108 | use:obsidianToggleButton={{ 109 | buttonSetting: showSearchDetail, 110 | toggle: () => $settings.showSearchDetail, 111 | }} 112 | ></div> 113 | {#if $settings.showSearchDetail} 114 | <div>files to include</div> 115 | <div use:obsidianText={includeText}></div> 116 | <div>files to exclude</div> 117 | <div use:obsidianText={excludeText}></div> 118 | {/if} 119 | 120 | <style> 121 | .searchBar { 122 | border-width: 0px; 123 | display: flex; 124 | justify-content: space-around; 125 | outline-width: 1px; 126 | } 127 | </style> 128 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | 10 | .ghost { 11 | position: absolute; 12 | padding: 5px 25px; 13 | border: solid; 14 | border-width: 3px; 15 | border-radius: 10px; 16 | width: 300px; 17 | min-height: 200px; 18 | } 19 | 20 | .hidden { 21 | display: none; 22 | } 23 | 24 | .dragbackground { 25 | opacity: 0; 26 | width: 100%; 27 | height: 100%; 28 | position: fixed; 29 | } 30 | 31 | /* .no-border-search-input{ 32 | border-width: 0px !important; 33 | outline-color: blue; 34 | outline-width: 0 !important; 35 | box-shadow:none !important; 36 | width: 100; 37 | } 38 | .no-border-search-input:focus{ 39 | outline-color: blue; 40 | } */ 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "svelte", 6 | "node" 7 | ], 8 | "baseUrl": ".", 9 | "inlineSources": true, 10 | "module": "ESNext", 11 | "target": "ES6", 12 | "allowJs": true, 13 | "noImplicitAny": true, 14 | "moduleResolution": "node", 15 | "importHelpers": true, 16 | "isolatedModules": true, 17 | "strictNullChecks": true, 18 | "lib": [ 19 | "DOM", 20 | "ES5", 21 | "ES6", 22 | "ES7", 23 | ] 24 | }, 25 | "include": [ 26 | "**/*.ts", 27 | ], 28 | } 29 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.1": "0.15.0", 3 | "0.2.0": "0.15.0", 4 | "0.2.1": "0.15.0", 5 | "0.2.2": "0.15.0", 6 | "0.2.3": "0.15.0", 7 | "0.3.0": "0.15.0", 8 | "0.3.1": "0.15.0", 9 | "0.4.0": "0.15.0", 10 | "0.5.0": "0.15.0", 11 | "1.0.0": "1.5.11", 12 | "1.0.1": "1.5.11", 13 | "1.0.2": "1.5.11", 14 | "1.1.0": "1.5.11", 15 | "1.2.0": "1.5.11" 16 | } --------------------------------------------------------------------------------