Files
obsidian-excalidraw-plugin/src/menu/ToolsPanel.tsx
zsviczian 2fd800e0a0 2.4.3
2024-09-09 20:20:12 +02:00

743 lines
24 KiB
TypeScript

import clsx from "clsx";
import { Notice, TFile } from "obsidian";
import * as React from "react";
import { ActionButton } from "./ActionButton";
import { ICONS, saveIcon, stringToSVG } from "./ActionIcons";
import { DEVICE, SCRIPT_INSTALL_FOLDER, VIEW_TYPE_EXCALIDRAW } from "../constants/constants";
import { insertLaTeXToView, search } from "../ExcalidrawAutomate";
import ExcalidrawView, { TextMode } from "../ExcalidrawView";
import { t } from "../lang/helpers";
import { ReleaseNotes } from "../dialogs/ReleaseNotes";
import { ScriptIconMap } from "../Scripts";
import { ScriptInstallPrompt } from "src/dialogs/ScriptInstallPrompt";
import { ExcalidrawImperativeAPI } from "@zsviczian/excalidraw/types/excalidraw/types";
import { isWinALTorMacOPT, isWinCTRLorMacCMD, isSHIFT } from "src/utils/ModifierkeyHelper";
import { InsertPDFModal } from "src/dialogs/InsertPDFModal";
import { ExportDialog } from "src/dialogs/ExportDialog";
import { openExternalLink } from "src/utils/ExcalidrawViewUtils";
import { UniversalInsertFileModal } from "src/dialogs/UniversalInsertFileModal";
import { DEBUGGING, debug } from "src/utils/DebugHelper";
import { REM_VALUE } from "src/utils/StylesManager";
declare const PLUGIN_VERSION:string;
const dark = '<svg style="stroke:#ced4da;#212529;color:#ced4da;fill:#ced4da" ';
const light = '<svg style="stroke:#212529;color:#212529;fill:#212529" ';
type PanelProps = {
visible: boolean;
view: WeakRef<ExcalidrawView>;
centerPointer: Function;
observer: WeakRef<ResizeObserver>;
};
export type PanelState = {
visible: boolean;
top: number;
left: number;
theme: "dark" | "light";
excalidrawViewMode: boolean;
minimized: boolean;
isDirty: boolean;
isFullscreen: boolean;
isPreviewMode: boolean;
scriptIconMap: ScriptIconMap | null;
};
const TOOLS_PANEL_WIDTH = () => REM_VALUE * 14.2;
export class ToolsPanel extends React.Component<PanelProps, PanelState> {
pos1: number = 0;
pos2: number = 0;
pos3: number = 0;
pos4: number = 0;
penDownX: number = 0;
penDownY: number = 0;
previousWidth: number = 0;
previousHeight: number = 0;
onRightEdge: boolean = false;
onBottomEdge: boolean = false;
public containerRef: React.RefObject<HTMLDivElement>;
private view: ExcalidrawView;
componentWillUnmount(): void {
if (this.containerRef.current) {
this.props.observer.deref()?.unobserve(this.containerRef.current);
}
this.setState({ scriptIconMap: null });
this.containerRef = null;
this.view = null;
}
constructor(props: PanelProps) {
super(props);
this.view = props.view.deref();
const react = this.view.packages.react;
this.containerRef = react.createRef();
this.state = {
visible: props.visible,
top: 50,
left: 200,
theme: "dark",
excalidrawViewMode: false,
minimized: false,
isDirty: false,
isFullscreen: false,
isPreviewMode: true,
scriptIconMap: {},
};
}
updateScriptIconMap(scriptIconMap: ScriptIconMap) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.updateScriptIconMap,"ToolsPanel.updateScriptIconMap()");
this.setState(() => {
return { scriptIconMap };
});
}
setPreviewMode(isPreviewMode: boolean) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.setPreviewMode,"ToolsPanel.setPreviewMode()");
this.setState(() => {
return {
isPreviewMode,
};
});
}
setFullscreen(isFullscreen: boolean) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.setFullscreen,"ToolsPanel.setFullscreen()");
this.setState(() => {
return {
isFullscreen,
};
});
}
setDirty(isDirty: boolean) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.setDirty,"ToolsPanel.setDirty()");
this.setState(()=> {
return {
isDirty,
};
});
}
setExcalidrawViewMode(isViewModeEnabled: boolean) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.setExcalidrawViewMode,"ToolsPanel.setExcalidrawViewMode()");
this.setState(() => {
return {
excalidrawViewMode: isViewModeEnabled,
};
});
}
toggleVisibility(isMobileOrZen: boolean) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.toggleVisibility,"ToolsPanel.toggleVisibility()");
this.setTopCenter(isMobileOrZen);
this.setState((prevState: PanelState) => {
return {
visible: !prevState.visible,
};
});
}
setTheme(theme: "dark" | "light") {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.setTheme,"ToolsPanel.setTheme()");
this.setState((prevState: PanelState) => {
return {
theme,
};
});
}
setTopCenter(isMobileOrZen: boolean) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.setTopCenter,"ToolsPanel.setTopCenter()");
this.setState(() => {
return {
left:
(this.containerRef.current.clientWidth -
TOOLS_PANEL_WIDTH() -
(isMobileOrZen ? 0 : TOOLS_PANEL_WIDTH() + 4)) /
2 +
this.containerRef.current.parentElement.offsetLeft +
(isMobileOrZen ? 0 : TOOLS_PANEL_WIDTH() + 4),
top: 64 + this.containerRef.current.parentElement.offsetTop,
};
});
}
updatePosition(deltaY: number = 0, deltaX: number = 0) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.updatePosition,"ToolsPanel.updatePosition()");
this.setState(() => {
const {
offsetTop,
offsetLeft,
clientWidth: width,
clientHeight: height,
} = this.containerRef.current.firstElementChild as HTMLElement;
const top = offsetTop - deltaY;
const left = offsetLeft - deltaX;
const {
clientWidth: parentWidth,
clientHeight: parentHeight,
offsetTop: parentOffsetTop,
offsetLeft: parentOffsetLeft,
} = this.containerRef.current.parentElement;
this.previousHeight = parentHeight;
this.previousWidth = parentWidth;
this.onBottomEdge = top >= parentHeight - height + parentOffsetTop;
this.onRightEdge = left >= parentWidth - width + parentOffsetLeft;
return {
top:
top < parentOffsetTop
? parentOffsetTop
: this.onBottomEdge
? parentHeight - height + parentOffsetTop
: top,
left:
left < parentOffsetLeft
? parentOffsetLeft
: this.onRightEdge
? parentWidth - width + parentOffsetLeft
: left,
};
});
}
actionOpenScriptInstallDialog() {
new ScriptInstallPrompt(this.view.plugin).open();
}
actionOpenReleaseNotes() {
new ReleaseNotes(
this.view.app,
this.view.plugin,
PLUGIN_VERSION,
).open();
}
actionConvertExcalidrawToMD() {
this.view.convertExcalidrawToMD();
}
actionToggleViewMode() {
if (this.state.isPreviewMode) {
this.view.changeTextMode(TextMode.raw);
} else {
this.view.changeTextMode(TextMode.parsed);
}
}
actionToggleTrayMode() {
this.view.toggleTrayMode();
}
actionToggleFullscreen() {
if (this.state.isFullscreen) {
this.view.exitFullscreen();
} else {
this.view.gotoFullscreen();
}
}
actionSearch() {
search(this.view);
}
actionOCR(e:React.MouseEvent<HTMLButtonElement, MouseEvent>) {
if(!this.view.plugin.settings.taskboneEnabled) {
new Notice("Taskbone OCR is not enabled. Please go to plugins settings to enable it.",4000);
return;
}
this.view.plugin.taskbone.getTextForView(this.view, {forceReScan: isWinCTRLorMacCMD(e)});
}
actionOpenLink(e:React.MouseEvent<HTMLButtonElement, MouseEvent>) {
const event = new MouseEvent("click", {
ctrlKey: e.ctrlKey || !(DEVICE.isIOS || DEVICE.isMacOS),
metaKey: e.metaKey || (DEVICE.isIOS || DEVICE.isMacOS),
shiftKey: e.shiftKey,
altKey: e.altKey,
});
this.view.handleLinkClick(event);
}
actionOpenLinkProperties() {
const event = new MouseEvent("click", {
ctrlKey: true,
metaKey: true,
shiftKey: false,
altKey: false,
});
this.view.handleLinkClick(event);
}
actionForceSave() {
this.view.forceSave();
}
actionExportLibrary() {
this.view.plugin.exportLibrary();
}
actionExportImage() {
const view = this.view;
if(!view.exportDialog) {
view.exportDialog = new ExportDialog(view.plugin, view,view.file);
view.exportDialog.createForm();
}
view.exportDialog.open();
}
actionOpenAsMarkdown() {
this.view.openAsMarkdown();
}
actionLinkToElement(e:React.MouseEvent<HTMLButtonElement, MouseEvent>) {
if(isWinALTorMacOPT(e)) {
openExternalLink("https://youtu.be/yZQoJg2RCKI", this.view.app);
return;
}
this.view.copyLinkToSelectedElementToClipboard(
isWinCTRLorMacCMD(e) ? "group=" : (isSHIFT(e) ? "area=" : "")
);
}
actionAddAnyFile() {
this.props.centerPointer();
const insertFileModal = new UniversalInsertFileModal(this.view.plugin, this.view);
insertFileModal.open();
}
actionInsertImage() {
this.props.centerPointer();
this.view.plugin.insertImageDialog.start(
this.view,
);
}
actionInsertPDF() {
this.props.centerPointer();
const insertPDFModal = new InsertPDFModal(this.view.plugin, this.view);
insertPDFModal.open();
}
actionInsertMarkdown() {
this.props.centerPointer();
this.view.plugin.insertMDDialog.start(
this.view,
);
}
actionInsertBackOfNote() {
this.props.centerPointer();
this.view.insertBackOfTheNoteCard();
}
actionInsertLaTeX(e:React.MouseEvent<HTMLButtonElement, MouseEvent>) {
if(isWinALTorMacOPT(e)) {
openExternalLink("https://youtu.be/r08wk-58DPk", this.view.app);
return;
}
this.props.centerPointer();
insertLaTeXToView(this.view);
}
actionInsertLink() {
this.props.centerPointer();
this.view.plugin.insertLinkDialog.start(
this.view.file.path,
(text: string, fontFamily?: 1 | 2 | 3 | 4, save?: boolean) => this.view.addText (text, fontFamily, save),
);
}
actionImportSVG(e:React.MouseEvent<HTMLButtonElement, MouseEvent>) {
this.view.plugin.importSVGDialog.start(this.view);
}
actionCropImage(e:React.MouseEvent<HTMLButtonElement, MouseEvent>) {
// @ts-ignore
this.view.app.commands.executeCommandById("obsidian-excalidraw-plugin:crop-image");
}
async actionRunScript(key: string) {
const view = this.view;
const plugin = view.plugin;
const f = app.vault.getAbstractFileByPath(key);
if (f && f instanceof TFile) {
plugin.scriptEngine.executeScript(
view,
await app.vault.read(f),
plugin.scriptEngine.getScriptName(f),
f
);
}
}
async actionPinScript(key: string, scriptName: string) {
const view = this.view;
const api = view.excalidrawAPI as ExcalidrawImperativeAPI;
const plugin = view.plugin;
await plugin.loadSettings();
const index = plugin.settings.pinnedScripts.indexOf(key)
if(index > -1) {
plugin.settings.pinnedScripts.splice(index,1);
api?.setToast({message:`Pin removed: ${scriptName}`, duration: 3000, closable: true});
} else {
plugin.settings.pinnedScripts.push(key);
api?.setToast({message:`Pinned: ${scriptName}`, duration: 3000, closable: true})
}
await plugin.saveSettings();
plugin.app.workspace.getLeavesOfType(VIEW_TYPE_EXCALIDRAW).forEach(v=> {
if (v.view instanceof ExcalidrawView) v.view.updatePinnedScripts()
})
}
private islandOnClick(event: React.MouseEvent<HTMLDivElement, MouseEvent>) {
event.preventDefault();
if (
Math.abs(this.penDownX - this.pos3) > 5 ||
Math.abs(this.penDownY - this.pos4) > 5
) {
return;
}
this.setState((prevState: PanelState) => {
return {
minimized: !prevState.minimized,
};
});
}
private islandOnPointerDown(event: React.PointerEvent) {
const onDrag = (e: PointerEvent) => {
e.preventDefault();
this.pos1 = this.pos3 - e.clientX;
this.pos2 = this.pos4 - e.clientY;
this.pos3 = e.clientX;
this.pos4 = e.clientY;
this.updatePosition(this.pos2, this.pos1);
};
const onPointerUp = () => {
this.view.ownerDocument?.removeEventListener("pointerup", onPointerUp);
this.view.ownerDocument?.removeEventListener("pointermove", onDrag);
};
event.preventDefault();
this.penDownX = this.pos3 = event.clientX;
this.penDownY = this.pos4 = event.clientY;
this.view.ownerDocument.addEventListener("pointerup", onPointerUp);
this.view.ownerDocument.addEventListener("pointermove", onDrag);
};
render() {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.render,"ToolsPanel.render()");
return (
<div
ref={this.containerRef}
className={clsx("excalidraw", {
"theme--dark": this.state.theme === "dark",
})}
style={{
width: "100%",
height: "100%",
position: "absolute",
touchAction: "none",
}}
>
<div
className="Island"
style={{
top: `${this.state.top}px`,
left: `${this.state.left}px`,
width: `13.75rem`,
display:
this.state.visible && !this.state.excalidrawViewMode
? "block"
: "none",
height: "fit-content",
maxHeight: "400px",
zIndex: 5,
}}
>
<div
style={{
height: "26px",
width: "100%",
cursor: "move",
}}
onClick={this.islandOnClick.bind(this)}
onPointerDown={this.islandOnPointerDown.bind(this)}
>
<svg
aria-hidden="true"
focusable="false"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 228 26"
>
<path
stroke="var(--icon-fill-color)"
strokeWidth="2"
d="M40,7 h148 M40,13 h148 M40,19 h148"
/>
</svg>
</div>
<div
className="Island App-menu__left scrollbar"
style={{
maxHeight: "350px",
width: "initial",
//@ts-ignore
"--padding": 2,
display: this.state.minimized ? "none" : "block",
}}
>
<div className="panelColumn">
<fieldset>
<legend>Utility actions</legend>
<div className="buttonList buttonListIcon">
<ActionButton
key={"scriptEngine"}
title={t("INSTALL_SCRIPT_BUTTON")}
action={this.actionOpenScriptInstallDialog.bind(this)}
icon={ICONS.scriptEngine}
/>
<ActionButton
key={"release-notes"}
title={t("READ_RELEASE_NOTES")}
action={this.actionOpenReleaseNotes.bind(this)}
icon={ICONS.releaseNotes}
/>
{this.state.isPreviewMode === null ? (
<ActionButton
key={"convert"}
title={t("CONVERT_FILE")}
action={(this.actionConvertExcalidrawToMD.bind(this))}
icon={ICONS.convertFile}
/>
) : (
<ActionButton
key={"viewmode"}
title={this.state.isPreviewMode ? t("PARSED") : t("RAW")}
action={this.actionToggleViewMode.bind(this)}
icon={
this.state.isPreviewMode
? ICONS.rawMode
: ICONS.parsedMode
}
/>
)}
<ActionButton
key={"tray-mode"}
title={t("TRAY_MODE")}
action={this.actionToggleTrayMode.bind(this)}
icon={ICONS.trayMode}
/>
<ActionButton
key={"fullscreen"}
title={
this.state.isFullscreen
? t("EXIT_FULLSCREEN")
: t("GOTO_FULLSCREEN")
}
action={this.actionToggleFullscreen.bind(this)}
icon={
this.state.isFullscreen
? ICONS.exitFullScreen
: ICONS.gotoFullScreen
}
/>
<ActionButton
key={"search"}
title={t("SEARCH")}
action={this.actionSearch.bind(this)}
icon={ICONS.search}
/>
<ActionButton
key={"ocr"}
title={t("RUN_OCR")}
action={this.actionOCR.bind(this)}
icon={ICONS.ocr}
/>
<ActionButton
key={"openLink"}
title={t("OPEN_LINK_CLICK")}
action={this.actionOpenLink.bind(this)}
icon={ICONS.openLink}
/>
<ActionButton
key={"openLinkProperties"}
title={t("OPEN_LINK_PROPS")}
action={this.actionOpenLinkProperties.bind(this)}
icon={ICONS.openLinkProperties}
/>
<ActionButton
key={"save"}
title={t("FORCE_SAVE")}
action={this.actionForceSave.bind(this)}
icon={saveIcon(this.state.isDirty)}
/>
</div>
</fieldset>
<fieldset>
<legend>Export actions</legend>
<div className="buttonList buttonListIcon">
<ActionButton
key={"lib"}
title={t("DOWNLOAD_LIBRARY")}
action={this.actionExportLibrary.bind(this)}
icon={ICONS.exportLibrary}
/>
<ActionButton
key={"exportIMG"}
title={t("EXPORT_IMAGE")}
action={this.actionExportImage.bind(this)}
icon={ICONS.ExportImage}
/>
<ActionButton
key={"md"}
title={t("OPEN_AS_MD")}
action={this.actionOpenAsMarkdown.bind(this)}
icon={ICONS.switchToMarkdown}
/>
<ActionButton
key={"link-to-element"}
title={t("INSERT_LINK_TO_ELEMENT")}
action={this.actionLinkToElement.bind(this)}
icon={ICONS.copyElementLink}
/>
</div>
</fieldset>
<fieldset>
<legend>Insert actions</legend>
<div className="buttonList buttonListIcon">
<ActionButton
key={"anyfile"}
title={t("UNIVERSAL_ADD_FILE")}
action={this.actionAddAnyFile.bind(this)}
icon={ICONS["add-file"]}
/>
<ActionButton
key={"image"}
title={t("INSERT_IMAGE")}
action={this.actionInsertImage.bind(this)}
icon={ICONS.insertImage}
/>
<ActionButton
key={"pdf"}
title={t("INSERT_PDF")}
action={this.actionInsertPDF.bind(this)}
icon={ICONS.insertPDF}
/>
<ActionButton
key={"insertMD"}
title={t("INSERT_MD")}
action={this.actionInsertMarkdown.bind(this)}
icon={ICONS.insertMD}
/>
<ActionButton
key={"insertBackOfNote"}
title={t("INSERT_CARD")}
action={this.actionInsertBackOfNote.bind(this)}
icon={ICONS.BackOfNote}
/>
<ActionButton
key={"latex"}
title={t("INSERT_LATEX")}
action={this.actionInsertLaTeX.bind(this)}
icon={ICONS.insertLaTeX}
/>
<ActionButton
key={"link"}
title={t("INSERT_LINK")}
action={this.actionInsertLink.bind(this)}
icon={ICONS.insertLink}
/>
<ActionButton
key={"import-svg"}
title={t("IMPORT_SVG")}
action={this.actionImportSVG.bind(this)}
icon={ICONS.importSVG}
/>
<ActionButton
key={"crop-image"}
title={t("CROP_IMAGE")}
action={this.actionCropImage.bind(this)}
icon={ICONS.Crop}
/>
</div>
</fieldset>
{this.renderScriptButtons(false)}
{this.renderScriptButtons(true)}
</div>
</div>
</div>
</div>
);
}
private renderScriptButtons(isDownloaded: boolean) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.renderScriptButtons,"ToolsPanel.renderScriptButtons()");
if (Object.keys(this.state.scriptIconMap).length === 0) {
return "";
}
const downloadedScriptsRoot = `${this.view.plugin.settings.scriptFolderPath}/${SCRIPT_INSTALL_FOLDER}/`;
const filterCondition = (key: string): boolean =>
isDownloaded
? key.startsWith(downloadedScriptsRoot)
: !key.startsWith(downloadedScriptsRoot);
if (
Object.keys(this.state.scriptIconMap).filter((k) => filterCondition(k))
.length === 0
) {
return "";
}
const groups = new Set<string>();
Object.keys(this.state.scriptIconMap)
.filter((k) => filterCondition(k))
.forEach(k => groups.add(this.state.scriptIconMap[k].group))
const scriptlist = Array.from(groups).sort((a,b)=>a>b?1:-1);
scriptlist.push(scriptlist.shift());
return (
<>
{scriptlist.map((group, index) => (
<fieldset key={`${group}-${index}`}>
<legend>{isDownloaded ? group : (group === "" ? "User" : "User/"+group)}</legend>
<div className="buttonList buttonListIcon">
{Object.entries(this.state.scriptIconMap)
.filter(([k,v])=>v.group === group)
.sort()
.map(([key,value])=>(
<ActionButton
key={key}
title={value.name}
action={this.actionRunScript.bind(this,key)}
longpress={this.actionPinScript.bind(this,key, value.name)}
icon={
new WeakRef(value.svgString
? stringToSVG(value.svgString)
: (
ICONS.cog
)).deref()
}
/>
))}
</div>
</fieldset>
))}
</>
);
}
}