From 9067f2b79a4a21880d2dfe36de65ab6322d14cb0 Mon Sep 17 00:00:00 2001 From: zsviczian Date: Sun, 9 Jul 2023 07:49:49 +0200 Subject: [PATCH] frame menu and section zoom ready --- package.json | 5 +- src/ExcalidrawAutomate.ts | 17 +- src/ExcalidrawData.ts | 12 +- src/ExcalidrawView.ts | 43 ++- src/constants.ts | 68 +++++ src/customIFrame.tsx | 357 ++++++++++++------------ src/dialogs/UniversalInsertFileModal.ts | 9 +- src/lang/locale/en.ts | 10 +- src/main.ts | 2 +- src/menu/ActionIcons.tsx | 30 ++ src/menu/IFrameActionsMenu.tsx | 229 +++++++++++++++ src/menu/ObsidianMenu.tsx | 2 +- src/utils/CanvasNodeFactory.ts | 60 +++- src/utils/CustomIFrameUtils.ts | 46 +++ src/utils/Utils.ts | 31 +- styles.css | 8 + yarn.lock | 15 +- 17 files changed, 673 insertions(+), 271 deletions(-) create mode 100644 src/menu/IFrameActionsMenu.tsx create mode 100644 src/utils/CustomIFrameUtils.ts diff --git a/package.json b/package.json index 4f1d6af..8064b53 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/ExcalidrawAutomate.ts b/src/ExcalidrawAutomate.ts index 23b333b..dda00c8 100644 --- a/src/ExcalidrawAutomate.ts +++ b/src/ExcalidrawAutomate.ts @@ -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 { diff --git a/src/ExcalidrawData.ts b/src/ExcalidrawData.ts index 0d232db..e587418 100644 --- a/src/ExcalidrawData.ts +++ b/src/ExcalidrawData.ts @@ -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, diff --git a/src/ExcalidrawView.ts b/src/ExcalidrawView.ts index f2f1d97..32b56cd 100644 --- a/src/ExcalidrawView.ts +++ b/src/ExcalidrawView.ts @@ -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 = null; public toolsPanelRef: React.MutableRefObject = null; + public iframeMenuRef: React.MutableRefObject = 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(); 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 { diff --git a/src/constants.ts b/src/constants.ts index d9ee2de..7ad5cfd 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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("[", "[")); } @@ -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(); COLOR_NAMES.set("aliceblue", "#f0f8ff"); COLOR_NAMES.set("antiquewhite", "#faebd7"); diff --git a/src/customIFrame.tsx b/src/customIFrame.tsx index a158004..58333a4 100644 --- a/src/customIFrame.tsx +++ b/src/customIFrame.tsx @@ -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(); - -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 ( + view.updateIFrameRef(id, ref)} + className="excalidraw__iframe" + title="Excalidraw Embedded Content" + allowFullScreen={true} + src={src} + style={{ + overflow: "hidden", + borderRadius: `${radius}px`, + }} + /> + ); + } return ( - 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; 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(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) => { 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) => { - 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) => { + 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}/> ) } \ No newline at end of file diff --git a/src/dialogs/UniversalInsertFileModal.ts b/src/dialogs/UniversalInsertFileModal.ts index 55167ae..1483e91 100644 --- a/src/dialogs/UniversalInsertFileModal.ts +++ b/src/dialogs/UniversalInsertFileModal.ts @@ -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 }; diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index 981bfc7..8a85c4b 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -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", }; diff --git a/src/main.ts b/src/main.ts index c80f805..125f79b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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( diff --git a/src/menu/ActionIcons.tsx b/src/menu/ActionIcons.tsx index bcbe0b8..9aaa0d6 100644 --- a/src/menu/ActionIcons.tsx +++ b/src/menu/ActionIcons.tsx @@ -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 = { ), + Reload: (), + Globe: (), + ZoomToSelectedElement: (), + ZoomToSection: ( + + # + + ), + ZoomToBlock: ( + + #^ + + ), Discord: ( , + ) { + } + + 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 ( +
+
+ { + 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} + /> + { + 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} + /> + { + if(!element) return; + api.zoomToFit([element], view.plugin.settings.zoomToFitMaxLevel, 0.1); + }} + icon={ICONS.ZoomToSelectedElement} + view={view} + /> +
+
+ ); + } + } + 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 ( +
+
+ {(iframe.src !== link) && !iframe.src.startsWith("https://www.youtube.com") && !iframe.src.startsWith("https://player.vimeo.com") && ( + { + iframe.src = link; + }} + icon={ICONS.Reload} + view={view} + /> + )} + { + view.openExternalLink(iframe.src); + }} + icon={ICONS.Globe} + view={view} + /> + { + if(!element) return; + api.zoomToFit([element], view.plugin.settings.zoomToFitMaxLevel, 0.1); + }} + icon={ICONS.ZoomToSelectedElement} + view={view} + /> +
+
+ ); + } + } +} diff --git a/src/menu/ObsidianMenu.tsx b/src/menu/ObsidianMenu.tsx index 84cb63c..e2a078c 100644 --- a/src/menu/ObsidianMenu.tsx +++ b/src/menu/ObsidianMenu.tsx @@ -60,7 +60,7 @@ export class ObsidianMenu { constructor( private plugin: ExcalidrawPlugin, private toolsRef: React.MutableRefObject, - private view: ExcalidrawView + private view: ExcalidrawView, ) { this.clickTimestamp = Array.from({length: Object.keys(PENS).length}, () => 0); } diff --git a/src/utils/CanvasNodeFactory.ts b/src/utils/CanvasNodeFactory.ts index 4c05eeb..638aa98 100644 --- a/src/utils/CanvasNodeFactory.ts +++ b/src/utils/CanvasNodeFactory.ts @@ -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(); + 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); }); diff --git a/src/utils/CustomIFrameUtils.ts b/src/utils/CustomIFrameUtils.ts new file mode 100644 index 0000000..dba642a --- /dev/null +++ b/src/utils/CustomIFrameUtils.ts @@ -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(); + +//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 }; +} \ No newline at end of file diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 42560d9..37036b1 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -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, diff --git a/styles.css b/styles.css index bbed4c9..e72d8d3 100644 --- a/styles.css +++ b/styles.css @@ -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); } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index d79ce1a..b322e01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"