├── .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 |
7 |
8 |
9 |
10 |
11 |
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 | 
33 | ## Excalidraw
34 | 
35 | ## Extract selections
36 | 
37 | ## Extract foldable range
38 | 
39 |
40 | ## Cards View
41 | 
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 |
7 |
8 |
9 |
10 |
11 |
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 | 
30 | ## Excalidraw
31 | 
32 | ## Extract selections
33 | 
34 | ## Extract foldable range
35 | 
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]*)(?[*+-]|\d+[.)])( {1,4}(?! )| |\t|$|(?=\n))(?- [^\n]*)/
115 | export const TASK = /^([ \t]*)(?
\[.\])?( {1,4}(?! )| |\t|$|(?=\n))(?- [^\n]*)/
116 | export type ListItem = {
117 | type: 'list',
118 | listSymbol: string,
119 | item: string,
120 | }
121 | export type TaskItem = Omit
& {
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(/^(?!?\[\[)(? .*?)(?\|(?.*))?(?]])$/);
203 | const MARKDOWNLINK = () => /^(?!?\[)(?.*?)(?]\(\s*)(? [^ ]+)(?(?:\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, 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([
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 |
14 |
15 | ({...it,index:computeIndex(it)}))}>
16 |
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 {
21 | this.component = new Search({
22 | target: this.containerEl.children[1],
23 | props: {
24 | view: this,
25 | },
26 | })
27 | }
28 | protected async onClose(): Promise {
29 | this.component?.$destroy()
30 | }
31 | }
--------------------------------------------------------------------------------
/src/view/components/ButtonGroups.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
42 |
43 | {#each buttons as but}
44 |
45 | {/each}
46 |
--------------------------------------------------------------------------------
/src/view/components/Card.svelte:
--------------------------------------------------------------------------------
1 |
27 |
28 |
250 |
251 | (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 |
{fileMatch.file.basename}
264 |
265 | {fileMatch.file.extension !== "md" ? fileMatch.file.extension : ""}
266 |
267 | {#if fileMatch.file.parent && fileMatch.file.parent.path !== "/"}
268 |
{fileMatch.file.parent?.path}
269 | {/if}
270 | {/if}
271 | {#each onHover ? contents : take(contents, showContentCounts) as cont, index (index)}
272 | {#if cont.matches && cont.matches.length !== 0}
273 |
282 |
283 |
289 |
290 | {:else}
291 |
297 | {/if}
298 | {/each}
299 | {#if contents.length > showContentCounts && !onHover}
300 | ...
301 | {/if}
302 |
303 |
304 |
318 |
--------------------------------------------------------------------------------
/src/view/components/ComputeLayout.svelte:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 | = 0 ? residueSpace : 0,
29 | }}
30 | />
31 |
--------------------------------------------------------------------------------
/src/view/components/Search.svelte:
--------------------------------------------------------------------------------
1 |
328 |
329 |
336 |
362 |
363 |
364 |
372 |
384 |
385 | {#each cells as cell (computeKey(cell.index))}
386 | {#if cell.index < $filesDisplay.length}
387 |
397 | {/if}
398 | {/each}
399 |
400 |
401 |
402 |
403 |
404 |
405 |
425 |
--------------------------------------------------------------------------------
/src/view/components/obsidian/obsidianMarkdown.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
19 |
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 = (ele, use) => {
7 | use(new SearchComponent(ele))
8 | }
9 |
10 | export type useButton = (comp: ButtonComponent) => void
11 | export const obsidianButton: Action = (ele, use) => {
12 | use(new ButtonComponent(ele))
13 | }
14 | export type useToggleButton = {
15 | buttonSetting: useButton,
16 | toggle: () => boolean,
17 | }
18 | export const obsdianToggleButton: Action = (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 = (ele, use) => {
33 | use(new ToggleComponent(ele))
34 | }
35 |
36 | export type useText = (comp: TextComponent) => void
37 | export const obsidianText: Action = (ele, use) => {
38 | use(new TextComponent(ele))
39 | }
40 |
--------------------------------------------------------------------------------
/src/view/components/searchInput.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
85 |
86 |
87 |
88 |
93 |
$settings.matchCase,
97 | }}
98 | >
99 |
$settings.useRegex,
103 | }}
104 | >
105 |
106 |
107 | $settings.showSearchDetail,
111 | }}
112 | >
113 | {#if $settings.showSearchDetail}
114 | files to include
115 |
116 | files to exclude
117 |
118 | {/if}
119 |
120 |
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 | }
--------------------------------------------------------------------------------