improved new link handling

This commit is contained in:
Zsolt Viczian
2022-02-01 21:56:19 +01:00
parent 3e0a6e839f
commit 962c6a71f7
7 changed files with 617 additions and 13 deletions

View File

@@ -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",

View File

@@ -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);
}

460
src/FolderSuggester.ts Normal file
View File

@@ -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<T> {
owner: SuggestModal<T>;
items: T[];
suggestions: HTMLDivElement[];
selectedItem: number;
containerEl: HTMLElement;
constructor(
owner: SuggestModal<T>,
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<T> extends FuzzySuggestModal<T> {
items: T[] = [];
suggestions: HTMLDivElement[];
popper: PopperInstance;
//@ts-ignore
scope: Scope = new Scope(this.app.scope);
suggester: Suggester<FuzzyMatch<T>>;
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 (<HeadingCache>item).heading;
}
if (Object.prototype.hasOwnProperty.call(item, "id")) {
return (<BlockCache>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 + "#" + (<HeadingCache>item).heading
);
} else if (Object.prototype.hasOwnProperty.call(item, "id")) {
this.text.setValue(
this.file.basename + "^" + (<BlockCache>item).id
);
}
}
selectSuggestion({ item }: FuzzyMatch<TFile | BlockCache | HeadingCache>) {
let link: string;
if (item instanceof TFile) {
link = item.basename;
} else if (Object.prototype.hasOwnProperty.call(item, "heading")) {
link = this.file.basename + "#" + (<HeadingCache>item).heading;
} else if (Object.prototype.hasOwnProperty.call(item, "id")) {
link = this.file.basename + "^" + (<BlockCache>item).id;
}
this.text.setValue(link);
this.onClose();
this.close();
}
renderSuggestion(
result: FuzzyMatch<TFile | BlockCache | HeadingCache>,
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((<HeadingCache>item).heading);
content.prepend(
createSpan({
cls: "suggestion-flair",
text: `H${(<HeadingCache>item).level}`
})
);
} else if (Object.prototype.hasOwnProperty.call(item, "id")) {
content.setText((<BlockCache>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<TFolder> {
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<TFolder>) {
let link = item.path;
this.text.setValue(link);
this.onClose();
this.close();
}
renderSuggestion(result: FuzzyMatch<TFolder>, 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;
}
}

View File

@@ -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<any> {
}
}
}
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:<br><ul>" +
"<li>Click <code>CONVERT FILES</code> now to convert all of your *.excalidraw files, or if you prefer to make a backup first, then click <code>CANCEL</code>.</li>" +
"<li>In the Command Palette select <code>Excalidraw: Convert *.excalidraw files to *.excalidraw.md files</code></li>" +
"<li>Right click an <code>*.excalidraw</code> file in File Explorer and select one of the following options to convert files one by one: <ul>" +
"<li><code>*.excalidraw => *.excalidraw.md</code></li>" +
"<li><code>*.excalidraw => *.md (Logseq compatibility)</code>. This option will retain the original *.excalidraw file next to the new Obsidian format. " +
"Make sure you also enable <code>Compatibility features</code> in Settings for a full solution.</li></ul></li>" +
"<li>Open a drawing in compatibility mode and select <code>Convert to new format</code> from the <code>Options Menu</code></li></ul>";
});
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();
};
});
}
}

View File

@@ -1459,7 +1459,7 @@ export default class ExcalidrawPlugin extends Plugin {
);
}
private async getBlankDrawing(): Promise<string> {
public async getBlankDrawing(): Promise<string> {
const template = this.app.metadataCache.getFirstLinkpathDest(
normalizePath(this.settings.templateFilePath),
"",

View File

@@ -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;
}

View File

@@ -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"