diff --git a/package.json b/package.json index 646febc..8456520 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@types/js-beautify": "^1.13.3", "@types/node": "^15.12.4", "@types/react-dom": "^17.0.11", + "@popperjs/core": "^2.11.2", "cross-env": "^7.0.3", "html2canvas": "^1.4.0", "nanoid": "^3.1.31", diff --git a/src/ExcalidrawView.ts b/src/ExcalidrawView.ts index ea1ba7b..4a563aa 100644 --- a/src/ExcalidrawView.ts +++ b/src/ExcalidrawView.ts @@ -62,7 +62,7 @@ import { svgToBase64, viewportCoordsToSceneCoords, } from "./Utils"; -import { Prompt } from "./Prompt"; +import { NewFileActions, Prompt } from "./Prompt"; import { ClipboardData } from "@zsviczian/excalidraw/types/clipboard"; import { updateEquation } from "./LaTeX"; import { @@ -464,10 +464,6 @@ export default class ExcalidrawView extends TextFileView { linkText, view.file.path, ); - if (!ev.altKey && !file) { - new Notice(t("FILE_DOES_NOT_EXIST"), 4000); - return; - } } else { const selectedImage = this.getSelectedImageElement(); if (selectedImage?.id) { @@ -526,7 +522,7 @@ export default class ExcalidrawView extends TextFileView { } } } - + if (!linkText) { new Notice(t("LINK_BUTTON_CLICK_NO_TEXT"), 20000); return; @@ -536,15 +532,15 @@ export default class ExcalidrawView extends TextFileView { if (ev.shiftKey && this.isFullscreen()) { this.exitFullscreen(); } + if (!file) { + (new NewFileActions(this.plugin,linkText,ev.shiftKey,view)).open(); + return; + } const leaf = ev.shiftKey ? getNewOrAdjacentLeaf(this.plugin, view.leaf) : view.leaf; - view.app.workspace.setActiveLeaf(leaf); - if (file) { - leaf.openFile(file, { eState: { line: lineNum - 1 } }); //if file exists open file and jump to reference - } else { - leaf.view.app.workspace.openLinkText(linkText, view.file.path); - } + leaf.openFile(file, { eState: { line: lineNum - 1 } }); //if file exists open file and jump to reference + view.app.workspace.setActiveLeaf(leaf,true,true); } catch (e) { new Notice(e, 4000); } diff --git a/src/FolderSuggester.ts b/src/FolderSuggester.ts new file mode 100644 index 0000000..828764b --- /dev/null +++ b/src/FolderSuggester.ts @@ -0,0 +1,460 @@ +import { + FuzzyMatch, + TFile, + BlockCache, + HeadingCache, + CachedMetadata, + TextComponent, + App, + TFolder, + FuzzySuggestModal, + SuggestModal, + Scope +} from "obsidian"; +import { t } from "./lang/helpers"; +import { createPopper, Instance as PopperInstance } from "@popperjs/core"; + +class Suggester { + owner: SuggestModal; + items: T[]; + suggestions: HTMLDivElement[]; + selectedItem: number; + containerEl: HTMLElement; + constructor( + owner: SuggestModal, + containerEl: HTMLElement, + scope: Scope + ) { + this.containerEl = containerEl; + this.owner = owner; + containerEl.on( + "click", + ".suggestion-item", + this.onSuggestionClick.bind(this) + ); + containerEl.on( + "mousemove", + ".suggestion-item", + this.onSuggestionMouseover.bind(this) + ); + + scope.register([], "ArrowUp", () => { + this.setSelectedItem(this.selectedItem - 1, true); + return false; + }); + + scope.register([], "ArrowDown", () => { + this.setSelectedItem(this.selectedItem + 1, true); + return false; + }); + + scope.register([], "Enter", (evt) => { + this.useSelectedItem(evt); + return false; + }); + + scope.register([], "Tab", (evt) => { + this.chooseSuggestion(evt); + return false; + }); + } + chooseSuggestion(evt: KeyboardEvent) { + if (!this.items || !this.items.length) return; + const currentValue = this.items[this.selectedItem]; + if (currentValue) { + this.owner.onChooseSuggestion(currentValue, evt); + } + } + onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { + event.preventDefault(); + if (!this.suggestions || !this.suggestions.length) return; + + const item = this.suggestions.indexOf(el); + this.setSelectedItem(item, false); + this.useSelectedItem(event); + } + + onSuggestionMouseover(event: MouseEvent, el: HTMLDivElement): void { + if (!this.suggestions || !this.suggestions.length) return; + const item = this.suggestions.indexOf(el); + this.setSelectedItem(item, false); + } + empty() { + this.containerEl.empty(); + } + setSuggestions(items: T[]) { + this.containerEl.empty(); + const els: HTMLDivElement[] = []; + + items.forEach((item) => { + const suggestionEl = this.containerEl.createDiv("suggestion-item"); + this.owner.renderSuggestion(item, suggestionEl); + els.push(suggestionEl); + }); + this.items = items; + this.suggestions = els; + this.setSelectedItem(0, false); + } + useSelectedItem(event: MouseEvent | KeyboardEvent) { + if (!this.items || !this.items.length) return; + const currentValue = this.items[this.selectedItem]; + if (currentValue) { + this.owner.selectSuggestion(currentValue, event); + } + } + wrap(value: number, size: number): number { + return ((value % size) + size) % size; + } + setSelectedItem(index: number, scroll: boolean) { + const nIndex = this.wrap(index, this.suggestions.length); + const prev = this.suggestions[this.selectedItem]; + const next = this.suggestions[nIndex]; + + if (prev) prev.removeClass("is-selected"); + if (next) next.addClass("is-selected"); + + this.selectedItem = nIndex; + + if (scroll) { + next.scrollIntoView(false); + } + } +} + +export abstract class SuggestionModal extends FuzzySuggestModal { + items: T[] = []; + suggestions: HTMLDivElement[]; + popper: PopperInstance; + //@ts-ignore + scope: Scope = new Scope(this.app.scope); + suggester: Suggester>; + suggestEl: HTMLDivElement; + promptEl: HTMLDivElement; + emptyStateText: string = "No match found"; + limit: number = 100; + shouldNotOpen: boolean; + constructor(app: App, inputEl: HTMLInputElement, items: T[]) { + super(app); + this.inputEl = inputEl; + this.items = items; + + this.suggestEl = createDiv("suggestion-container"); + + this.contentEl = this.suggestEl.createDiv("suggestion"); + + this.suggester = new Suggester(this, this.contentEl, this.scope); + + this.scope.register([], "Escape", this.onEscape.bind(this)); + + this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); + this.inputEl.addEventListener("focus", this.onFocus.bind(this)); + this.inputEl.addEventListener("blur", this.close.bind(this)); + this.suggestEl.on( + "mousedown", + ".suggestion-container", + (event: MouseEvent) => { + event.preventDefault(); + } + ); + } + empty() { + this.suggester.empty(); + } + onInputChanged(): void { + if (this.shouldNotOpen) return; + const inputStr = this.modifyInput(this.inputEl.value); + const suggestions = this.getSuggestions(inputStr); + if (suggestions.length > 0) { + this.suggester.setSuggestions(suggestions.slice(0, this.limit)); + } else { + this.onNoSuggestion(); + } + this.open(); + } + onFocus(): void { + this.shouldNotOpen = false; + this.onInputChanged(); + } + modifyInput(input: string): string { + return input; + } + onNoSuggestion() { + this.empty(); + this.renderSuggestion( + null, + this.contentEl.createDiv("suggestion-item") + ); + } + open(): void { + // TODO: Figure out a better way to do this. Idea from Periodic Notes plugin + this.app.keymap.pushScope(this.scope); + + document.body.appendChild(this.suggestEl); + this.popper = createPopper(this.inputEl, this.suggestEl, { + placement: "bottom-start", + modifiers: [ + { + name: "offset", + options: { + offset: [0, 10] + } + }, + { + name: "flip", + options: { + fallbackPlacements: ["top"] + } + } + ] + }); + } + + onEscape(): void { + this.close(); + this.shouldNotOpen = true; + } + close(): void { + // TODO: Figure out a better way to do this. Idea from Periodic Notes plugin + this.app.keymap.popScope(this.scope); + + this.suggester.setSuggestions([]); + if (this.popper) { + this.popper.destroy(); + } + + this.suggestEl.detach(); + } + createPrompt(prompts: HTMLSpanElement[]) { + if (!this.promptEl) + this.promptEl = this.suggestEl.createDiv("prompt-instructions"); + let prompt = this.promptEl.createDiv("prompt-instruction"); + for (let p of prompts) { + prompt.appendChild(p); + } + } + abstract onChooseItem(item: T, evt: MouseEvent | KeyboardEvent): void; + abstract getItemText(arg: T): string; + abstract getItems(): T[]; +} + +export class PathSuggestionModal extends SuggestionModal< + TFile | BlockCache | HeadingCache +> { + file: TFile; + files: TFile[]; + text: TextComponent; + cache: CachedMetadata; + constructor(app: App, input: TextComponent, items: TFile[]) { + super(app, input.inputEl, items); + this.files = [...items]; + this.text = input; + //this.getFile(); + + + this.inputEl.addEventListener("input", this.getFile.bind(this)); + } + + getFile() { + const v = this.inputEl.value, + file = this.app.metadataCache.getFirstLinkpathDest( + v.split(/[\^#]/).shift() || "", + "" + ); + if (file == this.file) return; + this.file = file; + if (this.file) + this.cache = this.app.metadataCache.getFileCache(this.file); + this.onInputChanged(); + } + getItemText(item: TFile | HeadingCache | BlockCache) { + if (item instanceof TFile) return item.path; + if (Object.prototype.hasOwnProperty.call(item, "heading")) { + return (item).heading; + } + if (Object.prototype.hasOwnProperty.call(item, "id")) { + return (item).id; + } + } + onChooseItem(item: TFile | HeadingCache | BlockCache) { + if (item instanceof TFile) { + this.text.setValue(item.basename); + this.file = item; + this.cache = this.app.metadataCache.getFileCache(this.file); + } else if (Object.prototype.hasOwnProperty.call(item, "heading")) { + this.text.setValue( + this.file.basename + "#" + (item).heading + ); + } else if (Object.prototype.hasOwnProperty.call(item, "id")) { + this.text.setValue( + this.file.basename + "^" + (item).id + ); + } + } + selectSuggestion({ item }: FuzzyMatch) { + let link: string; + if (item instanceof TFile) { + link = item.basename; + } else if (Object.prototype.hasOwnProperty.call(item, "heading")) { + link = this.file.basename + "#" + (item).heading; + } else if (Object.prototype.hasOwnProperty.call(item, "id")) { + link = this.file.basename + "^" + (item).id; + } + + this.text.setValue(link); + this.onClose(); + + this.close(); + } + renderSuggestion( + result: FuzzyMatch, + el: HTMLElement + ) { + let { item, match: matches } = result || {}; + let content = el.createDiv({ + cls: "suggestion-content" + }); + if (!item) { + content.setText(this.emptyStateText); + content.parentElement.addClass("is-selected"); + return; + } + + if (item instanceof TFile) { + let pathLength = item.path.length - item.name.length; + const matchElements = matches.matches.map((m) => { + return createSpan("suggestion-highlight"); + }); + for ( + let i = pathLength; + i < item.path.length - item.extension.length - 1; + i++ + ) { + let match = matches.matches.find((m) => m[0] === i); + if (match) { + let element = matchElements[matches.matches.indexOf(match)]; + content.appendChild(element); + element.appendText(item.path.substring(match[0], match[1])); + + i += match[1] - match[0] - 1; + continue; + } + + content.appendText(item.path[i]); + } + el.createDiv({ + cls: "suggestion-note", + text: item.path + }); + } else if (Object.prototype.hasOwnProperty.call(item, "heading")) { + content.setText((item).heading); + content.prepend( + createSpan({ + cls: "suggestion-flair", + text: `H${(item).level}` + }) + ); + } else if (Object.prototype.hasOwnProperty.call(item, "id")) { + content.setText((item).id); + } + } + get headings() { + if (!this.file) return []; + if (!this.cache) { + this.cache = this.app.metadataCache.getFileCache(this.file); + } + return this.cache.headings || []; + } + get blocks() { + if (!this.file) return []; + if (!this.cache) { + this.cache = this.app.metadataCache.getFileCache(this.file); + } + return Object.values(this.cache.blocks || {}) || []; + } + getItems() { + const v = this.inputEl.value; + if (/#/.test(v)) { + this.modifyInput = (i) => i.split(/#/).pop(); + return this.headings; + } else if (/\^/.test(v)) { + this.modifyInput = (i) => i.split(/\^/).pop(); + return this.blocks; + } + return this.files; + } +} + +export class FolderSuggestionModal extends SuggestionModal { + text: TextComponent; + cache: CachedMetadata; + folders: TFolder[]; + folder: TFolder; + constructor(app: App, input: TextComponent, items: TFolder[]) { + super(app, input.inputEl, items); + this.folders = [...items]; + this.text = input; + + this.inputEl.addEventListener("input", () => this.getFolder()); + } + getFolder() { + const v = this.inputEl.value, + folder = this.app.vault.getAbstractFileByPath(v); + if (folder == this.folder) return; + if (!(folder instanceof TFolder)) return; + this.folder = folder; + + this.onInputChanged(); + } + getItemText(item: TFolder) { + return item.path; + } + onChooseItem(item: TFolder) { + this.text.setValue(item.path); + this.folder = item; + } + selectSuggestion({ item }: FuzzyMatch) { + let link = item.path; + + this.text.setValue(link); + this.onClose(); + + this.close(); + } + renderSuggestion(result: FuzzyMatch, el: HTMLElement) { + let { item, match: matches } = result || {}; + let content = el.createDiv({ + cls: "suggestion-content" + }); + if (!item) { + content.setText(this.emptyStateText); + content.parentElement.addClass("is-selected"); + return; + } + + let pathLength = item.path.length - item.name.length; + const matchElements = matches.matches.map((m) => { + return createSpan("suggestion-highlight"); + }); + for (let i = pathLength; i < item.path.length; i++) { + let match = matches.matches.find((m) => m[0] === i); + if (match) { + let element = matchElements[matches.matches.indexOf(match)]; + content.appendChild(element); + element.appendText(item.path.substring(match[0], match[1])); + + i += match[1] - match[0] - 1; + continue; + } + + content.appendText(item.path[i]); + } + el.createDiv({ + cls: "suggestion-note", + text: item.path + }); + } + + getItems() { + return this.folders; + } +} \ No newline at end of file diff --git a/src/Prompt.ts b/src/Prompt.ts index 88df522..0d01b09 100644 --- a/src/Prompt.ts +++ b/src/Prompt.ts @@ -6,7 +6,11 @@ import { FuzzyMatch, FuzzySuggestModal, Instruction, + TFile, } from "obsidian"; +import ExcalidrawView from "./ExcalidrawView"; +import ExcalidrawPlugin from "./main"; +import { getNewOrAdjacentLeaf } from "./Utils"; export class Prompt extends Modal { private promptEl: HTMLInputElement; @@ -281,3 +285,131 @@ export class GenericSuggester extends FuzzySuggestModal { } } } + +class MigrationPrompt extends Modal { + private plugin: ExcalidrawPlugin; + + constructor(app: App, plugin: ExcalidrawPlugin) { + super(app); + this.plugin = plugin; + } + + onOpen(): void { + this.titleEl.setText("Welcome to Excalidraw 1.2"); + this.createForm(); + } + + onClose(): void { + this.contentEl.empty(); + } + + createForm(): void { + const div = this.contentEl.createDiv(); + // div.addClass("excalidraw-prompt-div"); + // div.style.maxWidth = "600px"; + div.createEl("p", { + text: "This version comes with tons of new features and possibilities. Please read the description in Community Plugins to find out more.", + }); + div.createEl("p", { text: "" }, (el) => { + el.innerHTML = + "Drawings you've created with version 1.1.x need to be converted to take advantage of the new features. You can also continue to use them in compatibility mode. " + + "During conversion your old *.excalidraw files will be replaced with new *.excalidraw.md files."; + }); + div.createEl("p", { text: "" }, (el) => { + //files manually follow one of two options: + el.innerHTML = + "To convert your drawings you have the following options:
    " + + "
  • Click CONVERT FILES now to convert all of your *.excalidraw files, or if you prefer to make a backup first, then click CANCEL.
  • " + + "
  • In the Command Palette select Excalidraw: Convert *.excalidraw files to *.excalidraw.md files
  • " + + "
  • Right click an *.excalidraw file in File Explorer and select one of the following options to convert files one by one:
      " + + "
    • *.excalidraw => *.excalidraw.md
    • " + + "
    • *.excalidraw => *.md (Logseq compatibility). This option will retain the original *.excalidraw file next to the new Obsidian format. " + + "Make sure you also enable Compatibility features in Settings for a full solution.
  • " + + "
  • Open a drawing in compatibility mode and select Convert to new format from the Options Menu
"; + }); + div.createEl("p", { + text: "This message will only appear maximum 3 times in case you have *.excalidraw files in your Vault.", + }); + const bConvert = div.createEl("button", { text: "CONVERT FILES" }); + bConvert.onclick = () => { + this.plugin.convertExcalidrawToMD(); + this.close(); + }; + const bCancel = div.createEl("button", { text: "CANCEL" }); + bCancel.onclick = () => { + this.close(); + }; + } +} + +export class NewFileActions extends Modal { + constructor ( + private plugin: ExcalidrawPlugin, + private path: string, + private newPane: boolean, + private view: ExcalidrawView, + ) { + super(plugin.app); + } + + onOpen(): void { + this.createForm(); + } + + async onClose() { + } + + openFile(file: TFile): void { + if(!file) return; + const leaf = this.newPane + ? getNewOrAdjacentLeaf(this.plugin, this.view.leaf) + : this.view.leaf; + leaf.openFile(file); + this.app.workspace.setActiveLeaf(leaf, true, true); + } + + createForm(): void { + this.titleEl.setText("New File"); + + this.contentEl.createDiv({ + cls: "excalidraw-prompt-center", + text: "File does not exist. Do you want to create it?" + }); + this.contentEl.createDiv({ + cls: "excalidraw-prompt-center filepath", + text: this.path + }); + + this.contentEl.createDiv({cls: "excalidraw-prompt-center"}, (el) => { + //files manually follow one of two options: + el.style.textAlign = "right"; + + const bMd = el.createEl("button", { text: "Create Markdown" }); + bMd.onclick = async () => { + //@ts-ignore + const f = await this.app.fileManager.createNewMarkdownFileFromLinktext(this.path,this.viewFile); + this.openFile(f); + this.close(); + }; + + + const bEx = el.createEl("button", { text: "Create Excalidraw" }); + bEx.onclick = async () => { + //@ts-ignore + const f = await this.app.fileManager.createNewMarkdownFileFromLinktext(this.path,this.viewFile) + if(!f) return; + await this.app.vault.modify(f,await this.plugin.getBlankDrawing()); + await new Promise(r => setTimeout(r, 200)); //wait for metadata cache to update, so file opens as excalidraw + this.openFile(f); + this.close(); + }; + + const bCancel = el.createEl("button", { + text: "Never Mind", + }); + bCancel.onclick = () => { + this.close(); + }; + }); + } +} diff --git a/src/main.ts b/src/main.ts index 34bb7f3..35bae14 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1459,7 +1459,7 @@ export default class ExcalidrawPlugin extends Plugin { ); } - private async getBlankDrawing(): Promise { + public async getBlankDrawing(): Promise { const template = this.app.metadataCache.getFirstLinkpathDest( normalizePath(this.settings.templateFilePath), "", diff --git a/styles.css b/styles.css index 1cd08b2..49e60f2 100644 --- a/styles.css +++ b/styles.css @@ -134,4 +134,14 @@ li[data-testid] { .excalidraw-scriptengine-install .modal { max-height:90%; +} + +.excalidraw-prompt-center { + text-align: center; +} + +.excalidraw-prompt-center.filepath { + text-align: center; + font-weight: bold; + margin-bottom: 2em; } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index de4071b..6af5346 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1355,6 +1355,11 @@ "schema-utils" "^3.0.0" "source-map" "^0.7.3" +"@popperjs/core@^2.11.2": + "integrity" "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==" + "resolved" "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz" + "version" "2.11.2" + "@rollup/plugin-babel@^5.2.0", "@rollup/plugin-babel@^5.3.0": "integrity" "sha512-9uIC8HZOnVLrLHxayq/PTzw+uS25E14KPUBh5ktF+18Mjo5yK0ToMMx6epY0uEgkjwJw0aBW4x2horYXh8juWw==" "resolved" "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz"