frame menu and section zoom ready

This commit is contained in:
zsviczian
2023-07-09 07:49:49 +02:00
parent b869bd6861
commit 9067f2b79a
17 changed files with 673 additions and 271 deletions

View File

@@ -18,7 +18,7 @@
"license": "MIT",
"dependencies": {
"@types/lz-string": "^1.3.34",
"@zsviczian/excalidraw": "0.15.2-obsidian-5",
"@zsviczian/excalidraw": "0.15.2-obsidian-6",
"chroma-js": "^2.4.2",
"clsx": "^1.2.1",
"colormaster": "^1.2.1",
@@ -31,7 +31,8 @@
"roughjs": "^4.5.2",
"html2canvas": "^1.4.1",
"@popperjs/core": "^2.11.6",
"nanoid": "^4.0.0"
"nanoid": "^4.0.0",
"lucide-react": "0.259.0"
},
"devDependencies": {
"@babel/core": "^7.20.12",

View File

@@ -23,6 +23,12 @@ import {
COLOR_NAMES,
fileid,
GITHUB_RELEASES,
determineFocusDistance,
getCommonBoundingBox,
getDefaultLineHeight,
getMaximumGroups,
intersectElementWithLine,
measureText,
} from "./Constants";
import { getDrawingFilename, getNewUniqueFilepath, } from "./utils/FileUtils";
import {
@@ -42,7 +48,6 @@ import { getAttachmentsFolderAndFilePath, getNewOrAdjacentLeaf, isObsidianThemeD
import { AppState, BinaryFileData, DataURL, ExcalidrawImperativeAPI, Point } from "@zsviczian/excalidraw/types/types";
import { EmbeddedFile, EmbeddedFilesLoader, FileData } from "./EmbeddedFileLoader";
import { tex2dataURL } from "./LaTeX";
//import Excalidraw from "@zsviczian/excalidraw";
import { Prompt } from "./dialogs/Prompt";
import { t } from "./lang/helpers";
import { ScriptEngine } from "./Scripts";
@@ -83,16 +88,6 @@ extendPlugins([
declare const PLUGIN_VERSION:string;
const {
determineFocusDistance,
intersectElementWithLine,
getCommonBoundingBox,
getMaximumGroups,
measureText,
getDefaultLineHeight,
//@ts-ignore
} = excalidrawLib;
const GAP = 4;
declare global {

View File

@@ -20,6 +20,10 @@ import {
FRONTMATTER_KEY_IFRAME_THEME,
DEVICE,
IFRAME_THEME_FRONTMATTER_VALUES,
getBoundTextMaxWidth,
getDefaultLineHeight,
getFontString,
wrapText,
} from "./Constants";
import { _measureText } from "./ExcalidrawAutomate";
import ExcalidrawPlugin from "./main";
@@ -58,14 +62,6 @@ declare module "obsidian" {
}
}
const {
wrapText,
getFontString,
getBoundTextMaxWidth,
getDefaultLineHeight,
//@ts-ignore
} = excalidrawLib;
export enum AutoexportPreference {
none,
both,

View File

@@ -46,6 +46,7 @@ import {
DEVICE,
GITHUB_RELEASES,
EXPORT_IMG_ICON_NAME,
viewportCoordsToSceneCoords,
} from "./Constants";
import ExcalidrawPlugin from "./main";
import {
@@ -87,7 +88,6 @@ import {
hasExportTheme,
scaleLoadedImage,
svgToBase64,
viewportCoordsToSceneCoords,
updateFrontmatterInString,
hyperlinkIsImage,
hyperlinkIsYouTubeLink,
@@ -117,9 +117,12 @@ import { getEA } from "src";
import { emulateCTRLClickForLinks, externalDragModifierType, internalDragModifierType, isALT, isCTRL, isMETA, isSHIFT, linkClickModifierType, mdPropModifier, ModifierKeys } from "./utils/ModifierkeyHelper";
import { setDynamicStyle } from "./utils/DynamicStyling";
import { InsertPDFModal } from "./dialogs/InsertPDFModal";
import { CustomIFrame, renderWebView, useDefaultExcalidrawFrame } from "./customIFrame";
import { CustomIFrame, renderWebView } from "./customIFrame";
import { insertIFrameToView, insertImageToView } from "./utils/ExcalidrawViewUtils";
import { imageCache } from "./utils/ImageCache";
import { CanvasNodeFactory } from "./utils/CanvasNodeFactory";
import { IFrameMenu } from "./menu/IFrameActionsMenu";
import { useDefaultExcalidrawFrame } from "./utils/CustomIFrameUtils";
declare const PLUGIN_VERSION:string;
@@ -232,6 +235,7 @@ export default class ExcalidrawView extends TextFileView {
public excalidrawAPI: any = null;
public excalidrawWrapperRef: React.MutableRefObject<any> = null;
public toolsPanelRef: React.MutableRefObject<any> = null;
public iframeMenuRef: React.MutableRefObject<any> = null;
private parentMoveObserver: MutationObserver;
public linksAlwaysOpenInANewPane: boolean = false; //override the need for SHIFT+CTRL+click (used by ExcaliBrain)
private hookServer: ExcalidrawAutomate;
@@ -246,6 +250,8 @@ export default class ExcalidrawView extends TextFileView {
public ownerWindow: Window;
public ownerDocument: Document;
private draginfoDiv: HTMLDivElement;
public canvasNodeFactory: CanvasNodeFactory;
private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement | HTMLWebViewElement>();
public semaphores: {
popoutUnload: boolean; //the unloaded Excalidraw view was the last leaf in the popout window
@@ -305,6 +311,7 @@ export default class ExcalidrawView extends TextFileView {
private linkAction_Element: HTMLElement;
public compatibilityMode: boolean = false;
private obsidianMenu: ObsidianMenu;
private iframeMenu: IFrameMenu;
//https://stackoverflow.com/questions/27132796/is-there-any-javascript-event-fired-when-the-on-screen-keyboard-on-mobile-safari
private isEditingTextResetTimer: NodeJS.Timeout = null;
@@ -316,6 +323,7 @@ export default class ExcalidrawView extends TextFileView {
this.plugin = plugin;
this.excalidrawData = new ExcalidrawData(plugin);
this.hookServer = plugin.ea;
this.canvasNodeFactory = new CanvasNodeFactory(this);
}
setHookServer(ea:ExcalidrawAutomate) {
@@ -1211,6 +1219,7 @@ export default class ExcalidrawView extends TextFileView {
const self = this;
app.workspace.onLayoutReady(async () => {
this.canvasNodeFactory.initialize();
self.contentEl.addClass("excalidraw-view");
//https://github.com/zsviczian/excalibrain/issues/28
await self.addSlidingPanesListner(); //awaiting this because when using workspaces, onLayoutReady comes too early
@@ -1622,6 +1631,9 @@ export default class ExcalidrawView extends TextFileView {
// clear the view content
clear() {
this.canvasNodeFactory.purgeNodes();
this.iFrameRefs.clear();
delete this.exportDialog;
const api = this.excalidrawAPI;
if (!this.excalidrawRef || !api) {
@@ -2296,6 +2308,7 @@ export default class ExcalidrawView extends TextFileView {
const reactElement = React.createElement(() => {
const excalidrawWrapperRef = React.useRef(null);
const toolsPanelRef = React.useRef(null);
const iframeMenuRef = React.useRef(null);
const [dimensions, setDimensions] = React.useState({
width: undefined,
@@ -2310,8 +2323,10 @@ export default class ExcalidrawView extends TextFileView {
let blockOnMouseButtonDown = false;
this.toolsPanelRef = toolsPanelRef;
this.iframeMenuRef = iframeMenuRef;
this.obsidianMenu = new ObsidianMenu(this.plugin, toolsPanelRef, this);
this.iframeMenu = new IFrameMenu(this, iframeMenuRef);
//excalidrawRef readypromise based on
//https://codesandbox.io/s/eexcalidraw-resolvable-promise-d0qg3?file=/src/App.js:167-760
const resolvablePromise = () => {
@@ -3222,7 +3237,8 @@ export default class ExcalidrawView extends TextFileView {
await this.plugin.saveSettings();
})();
},
renderTopRightUI: this.obsidianMenu.renderButton,
renderTopRightUI: (isMobile: boolean, appState: AppState) => this.obsidianMenu.renderButton (isMobile, appState),
renderIFrameMenu: (appState: AppState) => this.iframeMenu.renderButtons(appState),
onPaste: (data: ClipboardData) => {
//, event: ClipboardEvent | null
/*if(data && data.text && hyperlinkIsYouTubeLink(data.text)) {
@@ -3783,7 +3799,8 @@ export default class ExcalidrawView extends TextFileView {
}
},
iframeURLWhitelist: [true],
validateIFrame: true,
renderWebview: DEVICE.isDesktop,
renderCustomIFrame: (
element: NonDeletedExcalidrawElement,
radius: number,
@@ -3793,10 +3810,6 @@ export default class ExcalidrawView extends TextFileView {
if(!this.file || !element || !element.link || element.link.length === 0 || useDefaultExcalidrawFrame(element)) {
return null;
}
if(element.link.match(REG_LINKINDEX_HYPERLINK)) {
return renderWebView(element.link, radius);
}
const res = REGEX_LINK.getRes(element.link).next();
if(!res || (!res.value && res.done)) {
@@ -3807,7 +3820,7 @@ export default class ExcalidrawView extends TextFileView {
if(linkText.match(REG_LINKINDEX_HYPERLINK)) {
if(DEVICE.isDesktop) {
return renderWebView(linkText, radius);
return renderWebView(linkText, radius, this, element.id);
} else {
return null;
}
@@ -4331,6 +4344,16 @@ export default class ExcalidrawView extends TextFileView {
}
}
}
public updateIFrameRef(id: string, ref: HTMLIFrameElement | HTMLWebViewElement | null) {
if (ref) {
this.iFrameRefs.set(id, ref);
}
}
public getIFrameElementById(id: string): HTMLIFrameElement | HTMLWebViewElement | undefined {
return this.iFrameRefs.get(id);
}
}
export function getTextMode(data: string): TextMode {

View File

@@ -1,6 +1,24 @@
import { customAlphabet } from "nanoid";
//This is only for backward compatibility because an early version of obsidian included an encoding to avoid fantom links from littering Obsidian graph view
declare const PLUGIN_VERSION:string;
export const {
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
determineFocusDistance,
intersectElementWithLine,
getCommonBoundingBox,
getMaximumGroups,
measureText,
getDefaultLineHeight,
wrapText,
getFontString,
getBoundTextMaxWidth,
exportToSvg,
exportToBlob,
//@ts-ignore
} = excalidrawLib;
export function JSON_parse(x: string): any {
return JSON.parse(x.replaceAll("&#91;", "["));
}
@@ -27,6 +45,17 @@ export const DEVICE: {
isAndroid: document.body.hasClass("is-android")
};
export const ROOTELEMENTSIZE = (() => {
const tempElement = document.createElement('div');
tempElement.style.fontSize = '1rem';
tempElement.style.display = 'none'; // Hide the element
document.body.appendChild(tempElement);
const computedStyle = getComputedStyle(tempElement);
const pixelSize = parseFloat(computedStyle.fontSize);
document.body.removeChild(tempElement);
return pixelSize;
})();
export const nanoid = customAlphabet(
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
8,
@@ -98,6 +127,45 @@ export const TEXT_DISPLAY_RAW_ICON_NAME = "presentation";
export const FULLSCREEN_ICON_NAME = "fullscreen";
export const EXIT_FULLSCREEN_ICON_NAME = "exit-fullscreen";
export const SCRIPTENGINE_ICON_NAME = "ScriptEngine";
export const KEYBOARD_EVENT_TYPES = [
"keydown",
"keyup",
"keypress"
];
export const EXTENDED_EVENT_TYPES = [
/* "pointerdown",
"pointerup",
"pointermove",
"mousedown",
"mouseup",
"mousemove",
"mouseover",
"mouseout",
"mouseenter",
"mouseleave",
"dblclick",
"drag",
"dragend",
"dragenter",
"dragexit",
"dragleave",
"dragover",
"dragstart",
"drop",*/
"copy",
"cut",
"paste",
/*"wheel",
"touchstart",
"touchend",
"touchmove",*/
];
export const TWITTER_REG = /^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
export const COLOR_NAMES = new Map<string, string>();
COLOR_NAMES.set("aliceblue", "#f0f8ff");
COLOR_NAMES.set("antiquewhite", "#faebd7");

View File

@@ -1,11 +1,12 @@
import { NonDeletedExcalidrawElement } from "@zsviczian/excalidraw/types/element/types";
import ExcalidrawView from "./ExcalidrawView";
import { Notice, Workspace, WorkspaceLeaf, WorkspaceSplit } from "obsidian";
import { Notice, WorkspaceLeaf, WorkspaceSplit } from "obsidian";
import * as React from "react";
import { ConstructableWorkspaceSplit, getContainerForDocument, getParentOfClass, isObsidianThemeDark } from "./utils/ObsidianUtils";
import { getLinkParts } from "./utils/Utils";
import { DEVICE, REG_LINKINDEX_INVALIDCHARS } from "./Constants";
import { ConstructableWorkspaceSplit, getContainerForDocument, isObsidianThemeDark } from "./utils/ObsidianUtils";
import { DEVICE, EXTENDED_EVENT_TYPES, KEYBOARD_EVENT_TYPES, TWITTER_REG } from "./Constants";
import { ExcalidrawImperativeAPI, UIAppState } from "@zsviczian/excalidraw/types/types";
import { ObsidianCanvasNode } from "./utils/CanvasNodeFactory";
import { processLinkText, patchMobileView } from "./utils/CustomIFrameUtils";
declare module "obsidian" {
interface Workspace {
@@ -17,68 +18,39 @@ declare module "obsidian" {
}
}
const KEYBOARD_EVENT_TYPES = [
"keydown",
"keyup",
"keypress"
];
const EXTENDED_EVENT_TYPES = [
/* "pointerdown",
"pointerup",
"pointermove",
"mousedown",
"mouseup",
"mousemove",
"mouseover",
"mouseout",
"mouseenter",
"mouseleave",
"dblclick",
"drag",
"dragend",
"dragenter",
"dragexit",
"dragleave",
"dragover",
"dragstart",
"drop",*/
"copy",
"cut",
"paste",
/*"wheel",
"touchstart",
"touchend",
"touchmove",*/
];
const YOUTUBE_REG =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?youtu(?:be|.be)?(?:\.com)?\/(?:embed\/|watch\?v=|shorts\/)?([a-zA-Z0-9_-]+)(?:\?t=|&t=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
const VIMEO_REG =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
const TWITTER_REG = /^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
export const useDefaultExcalidrawFrame = (element: NonDeletedExcalidrawElement) => {
return element.link.match(YOUTUBE_REG) || element.link.match(VIMEO_REG);
}
const leafMap = new Map<string, WorkspaceLeaf>();
export const renderWebView = (src: string, radius: number):JSX.Element =>{
if(DEVICE.isIOS || DEVICE.isAndroid) {
return null;
}
//--------------------------------------------------------------------------------
//Render webview for anything other than Vimeo and Youtube
//Vimeo and Youtube are rendered by Excalidraw because of the window messaging
//required to control the video
//--------------------------------------------------------------------------------
export const renderWebView = (src: string, radius: number, view: ExcalidrawView, id: string):JSX.Element =>{
const twitterLink = src.match(TWITTER_REG);
if (twitterLink) {
src = `https://twitframe.com/show?url=${encodeURIComponent(src)}`;
}
if(DEVICE.isDesktop) {
return (
<webview
ref={(ref) => view.updateIFrameRef(id, ref)}
className="excalidraw__iframe"
title="Excalidraw Embedded Content"
allowFullScreen={true}
src={src}
style={{
overflow: "hidden",
borderRadius: `${radius}px`,
}}
/>
);
}
return (
<webview
<iframe
ref={(ref) => view.updateIFrameRef(id, ref)}
className="excalidraw__iframe"
title="Excalidraw Embedded Content"
allowFullScreen={true}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
src={src}
style={{
overflow: "hidden",
@@ -88,45 +60,21 @@ export const renderWebView = (src: string, radius: number):JSX.Element =>{
);
}
//--------------------------------------------------------------------------------
//Render WorkspaceLeaf or CanvasNode
//--------------------------------------------------------------------------------
function RenderObsidianView(
{ element, linkText, radius, view, containerRef, appState }:{
{ element, linkText, radius, view, containerRef, appState, theme }:{
element: NonDeletedExcalidrawElement;
linkText: string;
radius: number;
view: ExcalidrawView;
containerRef: React.RefObject<HTMLDivElement>;
appState: UIAppState;
theme: string;
}): JSX.Element {
//This is definitely not the right solution, feels like sticking plaster
//patch disappearing content on mobile
const patchMobileView = () => {
if(DEVICE.isDesktop) return;
console.log("patching mobile view");
const parent = getParentOfClass(view.containerEl,"mod-top");
if(parent) {
if(!parent.hasClass("mod-visible")) {
parent.addClass("mod-visible");
}
}
}
let subpath:string = null;
if (linkText.search("#") > -1) {
const linkParts = getLinkParts(linkText, view.file);
subpath = `#${linkParts.isBlockRef ? "^" : ""}${linkParts.ref}`;
linkText = linkParts.path;
}
if (linkText.match(REG_LINKINDEX_INVALIDCHARS)) {
return null;
}
const file = app.metadataCache.getFirstLinkpathDest(
linkText,
view.file.path,
);
const { subpath, file } = processLinkText(linkText, view);
if (!file) {
return null;
@@ -134,95 +82,21 @@ function RenderObsidianView(
const react = view.plugin.getPackage(view.ownerWindow).react;
//@ts-ignore
const leafRef = react.useRef<WorkspaceLeaf | null>(null);
const leafRef = react.useRef<{leaf: WorkspaceLeaf; node?: ObsidianCanvasNode} | null>(null);
const isEditingRef = react.useRef(false);
const isActiveRef = react.useRef(false);
//--------------------------------------------------------------------------------
//block propagation of events to the parent if the iframe element is active
//--------------------------------------------------------------------------------
const stopPropagation = react.useCallback((event:React.PointerEvent<HTMLElement>) => {
if(isActiveRef.current) {
event.stopPropagation(); // Stop the event from propagating up the DOM tree
}
}, [isActiveRef.current]);
react.useEffect(() => {
EXTENDED_EVENT_TYPES.forEach((type) => containerRef.current.removeEventListener(type, stopPropagation));
if(!containerRef?.current) {
return;
}
if(isActiveRef.current) {
EXTENDED_EVENT_TYPES.forEach((type) => containerRef.current.addEventListener(type, stopPropagation));
}
return () => {
if(!containerRef?.current) {
return;
}
EXTENDED_EVENT_TYPES.forEach((type) => containerRef.current.removeEventListener(type, stopPropagation));
}; //cleanup on unmount
}, [isActiveRef.current, containerRef.current]);
react.useEffect(() => {
if(!containerRef?.current) {
return;
}
while(containerRef.current.hasChildNodes()) {
containerRef.current.removeChild(containerRef.current.lastChild);
}
const doc = view.ownerDocument;
const rootSplit:WorkspaceSplit = new (WorkspaceSplit as ConstructableWorkspaceSplit)(app.workspace, "vertical");
rootSplit.getRoot = () => app.workspace[doc === document ? 'rootSplit' : 'floatingSplit'];
rootSplit.getContainer = () => getContainerForDocument(doc);
containerRef.current.appendChild(rootSplit.containerEl);
rootSplit.containerEl.style.width = '100%';
rootSplit.containerEl.style.height = '100%';
rootSplit.containerEl.style.borderRadius = `${radius}px`;
leafRef.current = app.workspace.createLeafInParent(rootSplit, 0);
const workspaceLeaf:HTMLDivElement = rootSplit.containerEl.querySelector("div.workspace-leaf");
if(workspaceLeaf) workspaceLeaf.style.borderRadius = `${radius}px`;
(async () => {
await leafRef.current.openFile(file, subpath ? { eState: { subpath }, state: {mode:"preview"} } : undefined);
if (leafRef.current.view?.getViewType() === "canvas") {
leafRef.current.view.canvas?.setReadonly(true);
}
patchMobileView();
})();
return () => {}; //cleanup on unmount
}, [linkText, subpath]);
const handleClick = react.useCallback((event: React.PointerEvent<HTMLElement>) => {
if(isActiveRef.current) {
event.stopPropagation();
}
if (isActiveRef.current && !isEditingRef.current) {
if (!leafRef.current?.view || leafRef.current.view.getViewType() !== 'markdown') {
return;
}
const api:ExcalidrawImperativeAPI = view.excalidrawAPI;
const el = api.getSceneElements().filter(el=>el.id === element.id)[0];
if(!el || el.angle !== 0) {
new Notice("Sorry, cannot edit rotated markdown documents");
return;
}
//@ts-ignore
const modes = leafRef.current.view.modes;
if (!modes) {
return;
}
leafRef.current.view.setMode(modes['source']);
//@ts-ignore
window.al = leafRef.current;
app.workspace.setActiveLeaf(leafRef.current);
isEditingRef.current = true;
patchMobileView();
}
}, [leafRef.current, isActiveRef.current, element]);
//runs once after mounting of the component and when the component is unmounted
react.useEffect(() => {
if(!containerRef?.current) {
return;
@@ -241,32 +115,146 @@ function RenderObsidianView(
}; //cleanup on unmount
}, []);
//blocking or not the propagation of events to the parent if the iframe is active
react.useEffect(() => {
EXTENDED_EVENT_TYPES.forEach((type) => containerRef.current.removeEventListener(type, stopPropagation));
if(!containerRef?.current) {
return;
}
if(isActiveRef.current) {
EXTENDED_EVENT_TYPES.forEach((type) => containerRef.current.addEventListener(type, stopPropagation));
}
return () => {
if(!containerRef?.current) {
return;
}
EXTENDED_EVENT_TYPES.forEach((type) => containerRef.current.removeEventListener(type, stopPropagation));
}; //cleanup on unmount
}, [isActiveRef.current, containerRef.current]);
//--------------------------------------------------------------------------------
//mount the workspace leaf or the canvas node depending on subpath
//--------------------------------------------------------------------------------
react.useEffect(() => {
if(!containerRef?.current) {
return;
}
if(!leafRef.current?.view || leafRef.current.view.getViewType() !== "markdown") {
return;
while(containerRef.current.hasChildNodes()) {
containerRef.current.removeChild(containerRef.current.lastChild);
}
//@ts-ignore
const modes = leafRef.current.view.modes;
if(!modes) {
return;
}
isActiveRef.current = (appState.activeIFrame?.element.id === element.id) && (appState.activeIFrame?.state === "active");
if(!isActiveRef.current) {
//@ts-ignore
leafRef.current.view.setMode(modes["preview"]);
if(isEditingRef.current) {
if(leafRef.current?.node) {
view.canvasNodeFactory.stopEditing(leafRef.current.node);
}
isEditingRef.current = false;
app.workspace.setActiveLeaf(view.leaf);
}
const doc = view.ownerDocument;
const rootSplit:WorkspaceSplit = new (WorkspaceSplit as ConstructableWorkspaceSplit)(app.workspace, "vertical");
rootSplit.getRoot = () => app.workspace[doc === document ? 'rootSplit' : 'floatingSplit'];
rootSplit.getContainer = () => getContainerForDocument(doc);
rootSplit.containerEl.style.width = '100%';
rootSplit.containerEl.style.height = '100%';
rootSplit.containerEl.style.borderRadius = `${radius}px`;
leafRef.current = {
leaf: app.workspace.createLeafInParent(rootSplit, 0),
node: null
};
//if subpath is defined, create a canvas node else create a workspace leaf
if(subpath && view.canvasNodeFactory.isInitialized()) {
leafRef.current.node = view.canvasNodeFactory.createFileNote(file, subpath, containerRef.current, element.id);
} else {
containerRef.current.appendChild(rootSplit.containerEl);
const workspaceLeaf:HTMLDivElement = rootSplit.containerEl.querySelector("div.workspace-leaf");
if(workspaceLeaf) workspaceLeaf.style.borderRadius = `${radius}px`;
(async () => {
await leafRef.current.leaf.openFile(file, subpath ? { eState: { subpath }, state: {mode:"preview"} } : undefined);
if (leafRef.current.leaf.view?.getViewType() === "canvas") {
leafRef.current.leaf.view.canvas?.setReadonly(true);
}
patchMobileView(view);
})();
}
return () => {}; //cleanup on unmount
}, [linkText, subpath, view, containerRef, app, radius, isEditingRef, leafRef]);
//--------------------------------------------------------------------------------
//Switch to edit mode when markdown view is clicked
//--------------------------------------------------------------------------------
const handleClick = react.useCallback((event: React.PointerEvent<HTMLElement>) => {
if(isActiveRef.current) {
event.stopPropagation();
}
if (isActiveRef.current && !isEditingRef.current && leafRef.current?.leaf) {
if(leafRef.current.leaf.view?.getViewType() === "markdown") {
const api:ExcalidrawImperativeAPI = view.excalidrawAPI;
const el = api.getSceneElements().filter(el=>el.id === element.id)[0];
if(!el || el.angle !== 0) {
new Notice("Sorry, cannot edit rotated markdown documents");
return;
}
//@ts-ignore
const modes = leafRef.current.leaf.view.modes;
if (!modes) {
return;
}
leafRef.current.leaf.view.setMode(modes['source']);
//@ts-ignore
window.al = leafRef.current.leaf;
app.workspace.setActiveLeaf(leafRef.current.leaf);
isEditingRef.current = true;
patchMobileView(view);
} else if (leafRef.current?.node) {
//Handle canvas node
view.canvasNodeFactory.startEditing(leafRef.current.node, theme);
}
}
}, [leafRef.current?.leaf, element]);
//--------------------------------------------------------------------------------
// Set isActiveRef and switch to preview mode when the iframe is not active
//--------------------------------------------------------------------------------
react.useEffect(() => {
if(!containerRef?.current || !leafRef?.current) {
return;
}
}, [appState.activeIFrame?.element, appState.activeIFrame?.state, element.id]);
}
const previousIsActive = isActiveRef.current;
isActiveRef.current = (appState.activeIFrame?.element.id === element.id) && (appState.activeIFrame?.state === "active");
if (previousIsActive === isActiveRef.current) {
return;
}
if(leafRef.current.leaf?.view?.getViewType() === "markdown") {
//Handle markdown leaf
//@ts-ignore
const modes = leafRef.current.leaf.view.modes;
if(!modes) {
return;
}
if(!isActiveRef.current) {
//@ts-ignore
leafRef.current.leaf.view.setMode(modes["preview"]);
isEditingRef.current = false;
app.workspace.setActiveLeaf(view.leaf);
return;
}
} else if (leafRef.current?.node) {
//Handle canvas node
view.canvasNodeFactory.stopEditing(leafRef.current.node);
}
}, [containerRef, leafRef, isActiveRef, appState, element, view, linkText, subpath, file, theme, isEditingRef, view.canvasNodeFactory]);
return null;
};
@@ -299,7 +287,8 @@ export const CustomIFrame: React.FC<{element: NonDeletedExcalidrawElement; radiu
radius={radius}
view={view}
containerRef={containerRef}
appState={appState}/>
appState={appState}
theme={theme}/>
</div>
)
}

View File

@@ -3,19 +3,14 @@ import ExcalidrawView from "../ExcalidrawView";
import ExcalidrawPlugin from "../main";
import { Modal, Setting, TextComponent } from "obsidian";
import { FileSuggestionModal } from "./FolderSuggester";
import { IMAGE_TYPES, REG_BLOCK_REF_CLEAN } from "src/Constants";
import { IMAGE_TYPES, REG_BLOCK_REF_CLEAN, sceneCoordsToViewportCoords, viewportCoordsToSceneCoords } from "src/Constants";
import { insertIFrameToView, insertImageToView } from "src/utils/ExcalidrawViewUtils";
import { getEA } from "src";
import { InsertPDFModal } from "./InsertPDFModal";
import { ExcalidrawImperativeAPI } from "@zsviczian/excalidraw/types/types";
import { MAX_IMAGE_SIZE } from "src/Constants";
import { ExcalidrawAutomate } from "src/ExcalidrawAutomate";
const {
viewportCoordsToSceneCoords,
sceneCoordsToViewportCoords
//@ts-ignore
} = excalidrawLib;
import { ExcalidrawIFrameElement } from "@zsviczian/excalidraw/types/element/types";
export class UniversalInsertFileModal extends Modal {
private center: { x: number, y: number } = { x: 0, y: 0 };

View File

@@ -512,5 +512,13 @@ FILENAME_HEAD: "Filename",
TOGGLE_FULLSCREEN: "Toggle fullscreen mode",
TOGGLE_DISABLEBINDING: "Toggle to invert default binding behavior",
OPEN_LINK_CLICK: "Navigate to selected element link",
OPEN_LINK_PROPS: "Open markdown-embed properties or open link in new window"
OPEN_LINK_PROPS: "Open markdown-embed properties or open link in new window",
//IFrameActionsMenu.tsx
NARROW_TO_HEADING: "Narrow to heading...",
NARROW_TO_BLOCK: "Narrow to block...",
SHOW_ENTIRE_FILE: "Show entire file",
ZOOM_TO_FIT: "Zoom to fit",
RELOAD: "Reload original link",
OPEN_IN_BROWSER: "Open current link in browser",
};

View File

@@ -2131,7 +2131,7 @@ export default class ExcalidrawPlugin extends Plugin {
const imgFile = this.app.vault.getAbstractFileByPath(imageFullpath);
if (!imgFile) {
await this.app.vault.create(imageFullpath, "");
await sleep(200);
await sleep(200); //wait for metadata cache to update
}
editor.replaceSelection(

View File

@@ -1,3 +1,4 @@
import { ArrowBigLeft, Globe, Minimize2, RotateCcw, Scan } from "lucide-react";
import * as React from "react";
import { PenStyle } from "src/PenTypes";
@@ -25,6 +26,35 @@ export const ICONS = {
</g>
</svg>
),
Reload: (<RotateCcw />),
Globe: (<Globe />),
ZoomToSelectedElement: (<Scan />),
ZoomToSection: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="var(--icon-fill-color)"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
>
<text x="6" y="18" fontSize="22px">#</text>
</svg>
),
ZoomToBlock: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="var(--icon-fill-color)"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
>
<text x="1" y="18" fontSize="22px">#^</text>
</svg>
),
Discord: (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -0,0 +1,229 @@
import { TFile } from "obsidian";
import * as React from "react";
import ExcalidrawView from "../ExcalidrawView";
import { ExcalidrawIFrameElement } from "@zsviczian/excalidraw/types/element/types";
import { AppState, ExcalidrawImperativeAPI } from "@zsviczian/excalidraw/types/types";
import clsx from "clsx";
import { ActionButton } from "./ActionButton";
import { ICONS } from "./ActionIcons";
import { t } from "src/lang/helpers";
import { ScriptEngine } from "src/Scripts";
import { REG_BLOCK_REF_CLEAN, ROOTELEMENTSIZE, nanoid, sceneCoordsToViewportCoords } from "src/Constants";
import { ExcalidrawAutomate } from "src/ExcalidrawAutomate";
import { getEA } from "src";
import { REGEX_LINK, REG_LINKINDEX_HYPERLINK } from "src/ExcalidrawData";
import { errorlog } from "src/utils/Utils";
import { processLinkText, useDefaultExcalidrawFrame } from "src/utils/CustomIFrameUtils";
export class IFrameMenu {
constructor(
private view:ExcalidrawView,
private containerRef: React.RefObject<HTMLDivElement>,
) {
}
private updateElement = (subpath: string, element: ExcalidrawIFrameElement, file: TFile) => {
if(!element) return;
const view = this.view;
const path = app.metadataCache.fileToLinktext(
file,
view.file.path,
file.extension === "md",
)
const ea:ExcalidrawAutomate = getEA(this.view);
ea.copyViewElementsToEAforEditing([element]);
(ea.getElement(element.id) as any).link = `[[${path}${subpath}]]`;
view.setDirty(99);
view.updateScene({appState: {activeIFrame: null}});
ea.addElementsToView(false,true);
}
renderButtons(appState: AppState) {
const view = this.view;
const api = view?.excalidrawAPI as ExcalidrawImperativeAPI;
if(!api) return null;
if(!appState.activeIFrame || appState.activeIFrame.state !== "active") return null;
const element = appState.activeIFrame?.element as ExcalidrawIFrameElement;
let link = element.link;
const isExcalidrawiFrame = useDefaultExcalidrawFrame(element);
let isObsidianiFrame = element.link?.match(REG_LINKINDEX_HYPERLINK);
if(!isExcalidrawiFrame && !isObsidianiFrame) {
const res = REGEX_LINK.getRes(element.link).next();
if(!res || (!res.value && res.done)) {
return null;
}
link = REGEX_LINK.getLink(res);
isObsidianiFrame = link.match(REG_LINKINDEX_HYPERLINK);
if(!isObsidianiFrame) {
const { subpath, file } = processLinkText(link, view);
if(!file || file.extension!=="md") return null;
const { x, y } = sceneCoordsToViewportCoords( { sceneX: element.x, sceneY: element.y }, appState);
const top = `${y-2.5*ROOTELEMENTSIZE-appState.offsetTop}px`;
const left = `${x-appState.offsetLeft}px`;
const isDark = appState?.theme === "dark";
return (
<div
ref={this.containerRef}
className="iframe-menu"
style={{
top,
left,
}}
>
<div
className="Island"
style={{
position: "relative",
}}
>
<ActionButton
key={"MarkdownSection"}
title={t("NARROW_TO_HEADING")}
action={async () => {
const sections = (await app.metadataCache.blockCache
.getForFile({ isCancelled: () => false },file))
.blocks.filter((b: any) => b.display && b.node?.type === "heading");
const values = [""].concat(
sections.map((b: any) => `#${b.display.replaceAll(REG_BLOCK_REF_CLEAN, "").trim()}`)
);
const display = [t("SHOW_ENTIRE_FILE")].concat(
sections.map((b: any) => b.display)
);
const newSubpath = await ScriptEngine.suggester(
app, display, values, "Select section from document"
);
if(!newSubpath && newSubpath!=="") return;
if (newSubpath !== subpath) {
this.updateElement(newSubpath, element, file);
}
}}
icon={ICONS.ZoomToSection}
view={view}
/>
<ActionButton
key={"MarkdownBlock"}
title={t("NARROW_TO_BLOCK")}
action={async () => {
if(!file) return;
const paragrphs = (await app.metadataCache.blockCache
.getForFile({ isCancelled: () => false },file))
.blocks.filter((b: any) => b.display && b.node?.type === "paragraph");
const values = ["entire-file"].concat(paragrphs);
const display = [t("SHOW_ENTIRE_FILE")].concat(
paragrphs.map((b: any) => `${b.node?.id ? `#^${b.node.id}: ` : ``}${b.display.trim()}`));
const selectedBlock = await ScriptEngine.suggester(
app, display, values, "Select section from document"
);
if(!selectedBlock) return;
if(selectedBlock==="entire-file") {
if(subpath==="") return;
this.updateElement("", element, file);
return;
}
let blockID = selectedBlock.node.id;
if(blockID && (`#^${blockID}` === subpath)) return;
if (!blockID) {
const offset = selectedBlock.node?.position?.end?.offset;
if(!offset) return;
blockID = nanoid();
const fileContents = await app.vault.cachedRead(file);
if(!fileContents) return;
await app.vault.modify(file, fileContents.slice(0, offset) + ` ^${blockID}` + fileContents.slice(offset));
await sleep(200); //wait for cache to update
}
this.updateElement(`#^${blockID}`, element, file);
}}
icon={ICONS.ZoomToBlock}
view={view}
/>
<ActionButton
key={"ZoomToElement"}
title={t("ZOOM_TO_FIT")}
action={() => {
if(!element) return;
api.zoomToFit([element], view.plugin.settings.zoomToFitMaxLevel, 0.1);
}}
icon={ICONS.ZoomToSelectedElement}
view={view}
/>
</div>
</div>
);
}
}
if(isObsidianiFrame || isExcalidrawiFrame) {
const iframe = isExcalidrawiFrame
? api.getIFrameElementById(element.id)
: view.getIFrameElementById(element.id);
if(!iframe || !iframe.contentWindow) return null;
const { x, y } = sceneCoordsToViewportCoords( { sceneX: element.x, sceneY: element.y }, appState);
const top = `${y-2.5*ROOTELEMENTSIZE-appState.offsetTop}px`;
const left = `${x-appState.offsetLeft}px`;
const isDark = appState?.theme === "dark";
return (
<div
ref={this.containerRef}
className={clsx("excalidraw", {
"theme--dark": isDark,
})}
style={{
top,
left,
width: "fit-content",
height: "100%",
position: "fixed",
display: "block",
zIndex: 10000,
}}
>
<div
className="Island"
style={{
position: "relative",
}}
>
{(iframe.src !== link) && !iframe.src.startsWith("https://www.youtube.com") && !iframe.src.startsWith("https://player.vimeo.com") && (
<ActionButton
key={"Reload"}
title={t("RELOAD")}
action={()=>{
iframe.src = link;
}}
icon={ICONS.Reload}
view={view}
/>
)}
<ActionButton
key={"Open"}
title={t("OPEN_IN_BROWSER")}
action={() => {
view.openExternalLink(iframe.src);
}}
icon={ICONS.Globe}
view={view}
/>
<ActionButton
key={"ZoomToElement"}
title={t("ZOOM_TO_FIT")}
action={() => {
if(!element) return;
api.zoomToFit([element], view.plugin.settings.zoomToFitMaxLevel, 0.1);
}}
icon={ICONS.ZoomToSelectedElement}
view={view}
/>
</div>
</div>
);
}
}
}

View File

@@ -60,7 +60,7 @@ export class ObsidianMenu {
constructor(
private plugin: ExcalidrawPlugin,
private toolsRef: React.MutableRefObject<any>,
private view: ExcalidrawView
private view: ExcalidrawView,
) {
this.clickTimestamp = Array.from({length: Object.keys(PENS).length}, () => 0);
}

View File

@@ -10,7 +10,7 @@ container.appendChild(node.contentEl)
import { TFile, WorkspaceLeaf, WorkspaceSplit } from "obsidian";
import ExcalidrawView from "src/ExcalidrawView";
import { getContainerForDocument, ConstructableWorkspaceSplit } from "./ObsidianUtils";
import { getContainerForDocument, ConstructableWorkspaceSplit, isObsidianThemeDark } from "./ObsidianUtils";
declare module "obsidian" {
interface Workspace {
@@ -27,7 +27,7 @@ interface ObsidianCanvas {
removeNode: Function;
}
interface ObsidianCanvasNode {
export interface ObsidianCanvasNode {
startEditing: Function;
child: any;
}
@@ -36,6 +36,8 @@ export class CanvasNodeFactory {
leaf: WorkspaceLeaf;
canvas: ObsidianCanvas;
nodes = new Map<string, ObsidianCanvasNode>();
initialized: boolean = false;
public isInitialized = () => this.initialized;
constructor(
private view: ExcalidrawView,
@@ -54,31 +56,65 @@ export class CanvasNodeFactory {
rootSplit.getRoot = () => app.workspace[doc === document ? 'rootSplit' : 'floatingSplit'];
rootSplit.getContainer = () => getContainerForDocument(doc);
this.leaf = app.workspace.createLeafInParent(rootSplit, 0);
this.canvas = canvasPlugin.views.canvas(this.leaf);
this.canvas = canvasPlugin.views.canvas(this.leaf).canvas;
this.initialized = true;
}
public createFileNote(file: TFile, subpath: string, containerEl: HTMLDivElement, elementId: string) {
public createFileNote(file: TFile, subpath: string, containerEl: HTMLDivElement, elementId: string): ObsidianCanvasNode {
if(!this.initialized) return;
if(this.nodes.has(elementId)) {
this.canvas.removeNode(this.nodes.get(elementId));
this.nodes.delete(elementId);
}
const node = this.canvas.createFileNode({pos: {x:0,y:0}, file, subpath, save: false});
node.setFilePath(file.path,subpath);
node.render();
containerEl.style.background = "var(--background-primary)";
containerEl.appendChild(node.contentEl)
this.nodes.set(elementId, node);
return node;
}
public startEditing(elementId: string, theme: string) {
const node = this.nodes.get(elementId);
if(!node) return;
public startEditing(node: ObsidianCanvasNode, theme: string) {
if (!this.initialized || !node) return;
node.startEditing();
node.child.editor.containerEl.parentElement.parentElement.removeClass("theme-light");
node.child.editor.containerEl.parentElement.parentElement.removeClass("theme-dark");
node.child.editor.containerEl.parentElement.parentElement.addClass(theme);
}
const obsidianTheme = isObsidianThemeDark() ? "theme-dark" : "theme-light";
if (obsidianTheme === theme) return;
(async () => {
let counter = 0;
while (!node.child.editor?.containerEl?.parentElement?.parentElement && counter++ < 100) {
await sleep(25);
}
if (!node.child.editor?.containerEl?.parentElement?.parentElement) return;
node.child.editor.containerEl.parentElement.parentElement.classList.remove(obsidianTheme);
node.child.editor.containerEl.parentElement.parentElement.classList.add(theme);
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const targetElement = mutation.target as HTMLElement;
if (targetElement.classList.contains(obsidianTheme)) {
targetElement.classList.remove(obsidianTheme);
targetElement.classList.add(theme);
}
}
}
});
observer.observe(node.child.editor.containerEl.parentElement.parentElement, { attributes: true });
})();
}
public stopEditing(elementId: string) {
public stopEditing(node: ObsidianCanvasNode) {
if(!this.initialized || !node) return;
if(!node.child.editMode) return;
node.child.showPreview();
}
public purgeNodes() {
if(!this.initialized) return;
this.nodes.forEach(node => {
this.canvas.removeNode(node);
});

View File

@@ -0,0 +1,46 @@
import { NonDeletedExcalidrawElement } from "@zsviczian/excalidraw/types/element/types";
import { DEVICE, REG_LINKINDEX_INVALIDCHARS, TWITTER_REG } from "src/Constants";
import { getParentOfClass } from "./ObsidianUtils";
import { TFile, WorkspaceLeaf } from "obsidian";
import { getLinkParts } from "./Utils";
import ExcalidrawView from "src/ExcalidrawView";
export const useDefaultExcalidrawFrame = (element: NonDeletedExcalidrawElement) => {
return !element.link.startsWith("[") && !element.link.match(TWITTER_REG);
}
export const leafMap = new Map<string, WorkspaceLeaf>();
//This is definitely not the right solution, feels like sticking plaster
//patch disappearing content on mobile
export const patchMobileView = (view: ExcalidrawView) => {
if(DEVICE.isDesktop) return;
console.log("patching mobile view");
const parent = getParentOfClass(view.containerEl,"mod-top");
if(parent) {
if(!parent.hasClass("mod-visible")) {
parent.addClass("mod-visible");
}
}
}
export const processLinkText = (linkText: string, view:ExcalidrawView): { subpath:string, file:TFile } => {
let subpath:string = null;
if (linkText.search("#") > -1) {
const linkParts = getLinkParts(linkText, view.file);
subpath = `#${linkParts.isBlockRef ? "^" : ""}${linkParts.ref}`;
linkText = linkParts.path;
}
if (linkText.match(REG_LINKINDEX_INVALIDCHARS)) {
return {subpath, file: null};
}
const file = app.metadataCache.getFirstLinkpathDest(
linkText,
view.file.path,
);
return { subpath, file };
}

View File

@@ -17,6 +17,8 @@ import {
FRONTMATTER_KEY_EXPORT_SVGPADDING,
FRONTMATTER_KEY_EXPORT_PNGSCALE,
FRONTMATTER_KEY_EXPORT_PADDING,
exportToSvg,
exportToBlob,
} from "../Constants";
import ExcalidrawPlugin from "../main";
import { ExcalidrawElement } from "@zsviczian/excalidraw/types/element/types";
@@ -28,12 +30,6 @@ import { IMAGE_TYPES } from "../Constants";
declare const PLUGIN_VERSION:string;
const {
exportToSvg,
exportToBlob,
//@ts-ignore
} = excalidrawLib;
declare module "obsidian" {
interface Workspace {
getAdjacentLeafInDirection(
@@ -180,29 +176,6 @@ export const rotatedDimensions = (
];
};
export const viewportCoordsToSceneCoords = (
{ clientX, clientY }: { clientX: number; clientY: number },
{
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
}: {
zoom: Zoom;
offsetLeft: number;
offsetTop: number;
scrollX: number;
scrollY: number;
},
) => {
const invScale = 1 / zoom.value;
const x = (clientX - offsetLeft) * invScale - scrollX;
const y = (clientY - offsetTop) * invScale - scrollY;
return { x, y };
};
export const getDataURL = async (
file: ArrayBuffer,
mimeType: string,

View File

@@ -356,4 +356,12 @@ div.excalidraw-draginfo {
.excalidraw .HelpDialog__key {
background-color: var(--color-gray-80) !important;
}
.excalidraw .iframe-menu {
width: fit-content;
height: fit-content;
position: absolute;
display: block;
z-index: var(--zIndex-layerUI);
}

View File

@@ -2272,10 +2272,10 @@
dependencies:
"@zerollup/ts-helpers" "^1.7.18"
"@zsviczian/excalidraw@0.15.2-obsidian-4":
"integrity" "sha512-piFX8c6PXPZ1N5DdWZFaxQNfkVvaofizy2cPKFChHx5PDjhtlD8FXzQ7zztluYjFvCF5RpJ2OfJWaNKQ1vn+7A=="
"resolved" "https://registry.npmjs.org/@zsviczian/excalidraw/-/excalidraw-0.15.2-obsidian-4.tgz"
"version" "0.15.2-obsidian-4"
"@zsviczian/excalidraw@0.15.2-obsidian-6":
"integrity" "sha512-+Wz1yHkKEh5+OUmAfXtT9NNVpMoLxODt2H3lhIKz+kdtdITg3D65Z/sCBIKArjJKwIIgd0KeO327N4DkEAovWw=="
"resolved" "https://registry.npmjs.org/@zsviczian/excalidraw/-/excalidraw-0.15.2-obsidian-6.tgz"
"version" "0.15.2-obsidian-6"
"abab@^2.0.3", "abab@^2.0.5":
"integrity" "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q=="
@@ -6172,6 +6172,11 @@
dependencies:
"yallist" "^4.0.0"
"lucide-react@0.259.0":
"integrity" "sha512-dFBLc6jRDfcpD9NQ7NyFVa+YR3RHX6+bs+f/UiotvNPho+kd4WyeXWMCCchUf7i/pq3BAaHkbmtkbx/GxxHVUw=="
"resolved" "https://registry.npmjs.org/lucide-react/-/lucide-react-0.259.0.tgz"
"version" "0.259.0"
"lz-string@^1.4.4":
"integrity" "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY="
"resolved" "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz"
@@ -7613,7 +7618,7 @@
optionalDependencies:
"fsevents" "^2.3.2"
"react@^17.0.2 || ^18.2.0", "react@^18.2.0", "react@>= 16":
"react@^16.5.1 || ^17.0.0 || ^18.0.0", "react@^17.0.2 || ^18.2.0", "react@^18.2.0", "react@>= 16":
"integrity" "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ=="
"resolved" "https://registry.npmjs.org/react/-/react-18.2.0.tgz"
"version" "18.2.0"