diff --git a/README.md b/README.md index 45636cf..dfe249f 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Tldraw in Obsidian is now available on the official community plugins list! ## Guides - [Custom icons and fonts](/~https://github.com/holxsam/tldraw-in-obsidian/issues/58#issue-2571070259) +- [Customizing embeds](/~https://github.com/holxsam/tldraw-in-obsidian/issues/59) ## Development diff --git a/manifest.json b/manifest.json index e199415..c2504b6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "tldraw", "name": "Tldraw", - "version": "1.12.0", + "version": "1.14.1", "minAppVersion": "0.15.0", "description": "Integrates Tldraw into Obsidian, allowing users to draw and edit content on a virtual whiteboard.", "author": "Sam Alhaqab", diff --git a/src/components/BoundsTool.tsx b/src/components/BoundsTool.tsx new file mode 100644 index 0000000..7548803 --- /dev/null +++ b/src/components/BoundsTool.tsx @@ -0,0 +1,144 @@ +/** + * Used some code from https://tldraw.dev/examples/shapes/tools/screenshot-tool + */ +import * as React from "react"; +import BoundsSelectorTool, { + BoundsDraggingState, + BOUNDS_BOX, + BOUNDS_SHAPES_BOX, + BOUNDS_USING_ASPECT_RATIO, + BOUNDS_ASPECT_RATIO, + BOUNDS_CURRENT_BOX, + BOUNDS_SELECTOR_INITIALIZED +} from "src/tldraw/tools/bounds-selector-tool"; +import { Box, Editor, useEditor, useValue } from "tldraw"; + +function calculateBoundingBoxInEditor(box: Box, editor: Editor) { + const zoomLevel = editor.getZoomLevel() + const { x, y } = editor.pageToViewport({ x: box.x, y: box.y }) + return new Box(x, y, box.w * zoomLevel, box.h * zoomLevel) +} + +export default function BoundsTool() { + const editor = useEditor(); + + const toolInitialized = useValue(BOUNDS_SELECTOR_INITIALIZED, () => ( + editor.getStateDescendant(BoundsSelectorTool.id)?.boundsSelectorInitialized.get() + ), [editor]); + + React.useEffect( + () => { + if (toolInitialized === undefined) { + return; + } + if (!toolInitialized) { + editor.getStateDescendant(BoundsSelectorTool.id)?.init(); + } + }, + [toolInitialized] + ); + + const currentBox = useValue(BOUNDS_CURRENT_BOX, + () => { + const selectorTool = editor.getStateDescendant(BoundsSelectorTool.id); + const box = selectorTool?.currentBounds.get() + + if (!box) return; + + return calculateBoundingBoxInEditor(box, editor); + }, [editor], + ); + + const boundsBox = useValue(BOUNDS_BOX, + () => { + if (editor.getPath() !== BoundsSelectorTool.draggingStatePath) return null; + + const draggingState = editor.getStateDescendant(BoundsSelectorTool.draggingStatePath)!; + const box = draggingState.boundsBox.get() + + return calculateBoundingBoxInEditor(box, editor); + }, [editor], + ); + + const shapesBox = useValue(BOUNDS_SHAPES_BOX, + () => { + if (editor.getPath() !== BoundsSelectorTool.draggingStatePath) return null; + + const draggingState = editor.getStateDescendant(BoundsSelectorTool.draggingStatePath)!; + const box = draggingState.shapesBox.get() + + if (!box) return null; + + return calculateBoundingBoxInEditor(box, editor); + }, [editor], + ); + + const boundsUsingAspectRatio = useValue(BOUNDS_USING_ASPECT_RATIO, () => ( + editor.getStateDescendant(BoundsSelectorTool.draggingStatePath)?.boundsUsingAspectRatio.get() ?? false + ), [editor]); + + const boundsAspectRatio = useValue(BOUNDS_ASPECT_RATIO, () => ( + editor.getStateDescendant(BoundsSelectorTool.id)?.aspectRatio.get() + ), [editor]); + + return ( + <> + { + !currentBox ? <> : ( +
+ ) + } + { + !boundsBox ? <> : ( +
+ Hold Ctrl to use the bounds within the shapes. +
+ Hold Shift to use an aspect ratio. +
+ Press Alt to cycle through aspect ratios. + { + !boundsAspectRatio || !boundsUsingAspectRatio ? <> : ( + <> +
+ Aspect ratio: {boundsAspectRatio.w}:{boundsAspectRatio.h} + + ) + } +
+ ) + } + { + !shapesBox ? <> : ( +
+ ) + } + + ); +} \ No newline at end of file diff --git a/src/components/BoundsToolSelectedShapesIndicator.tsx b/src/components/BoundsToolSelectedShapesIndicator.tsx new file mode 100644 index 0000000..a4268ed --- /dev/null +++ b/src/components/BoundsToolSelectedShapesIndicator.tsx @@ -0,0 +1,30 @@ +/** + * https://tldraw.dev/examples/editor-api/indicators-logic + */ +import * as React from "react" +import BoundsSelectorTool, { BOUNDS_SELECTED_SHAPES, BoundsDraggingState } from "src/tldraw/tools/bounds-selector-tool" +import { useEditor, useEditorComponents, useValue } from "tldraw" + +export default function BoundsToolSelectedShapeIndicator() { + const editor = useEditor() + + const boundsSelectedShapes = useValue(BOUNDS_SELECTED_SHAPES, + () => { + if (editor.getPath() !== BoundsSelectorTool.draggingStatePath) return null; + + const draggingState = editor.getStateDescendant(BoundsSelectorTool.draggingStatePath)!; + return draggingState.selectedShapes.get(); + }, [editor], + ); + + const { ShapeIndicator } = useEditorComponents() + if (!ShapeIndicator || !boundsSelectedShapes || !boundsSelectedShapes.length) return null + + return ( +
+ {boundsSelectedShapes.map(({ id }) => ( + + ))} +
+ ) +} \ No newline at end of file diff --git a/src/components/EmbedTldrawToolBar.tsx b/src/components/EmbedTldrawToolBar.tsx new file mode 100644 index 0000000..1479929 --- /dev/null +++ b/src/components/EmbedTldrawToolBar.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import BoundsSelectorTool from "src/tldraw/tools/bounds-selector-tool"; +import { DefaultToolbar, DefaultToolbarContent, TldrawUiMenuItem, useIsToolSelected, useTools } from "tldraw"; + +export default function EmbedTldrawToolBar() { + const tools = useTools(); + const boundsSelectorTool = tools[BoundsSelectorTool.id]; + const isBoundsSelectorSelected = useIsToolSelected(boundsSelectorTool); + return ( + + + + + ) +} diff --git a/src/components/TldrawApp.tsx b/src/components/TldrawApp.tsx index 2b18e12..1dd766e 100644 --- a/src/components/TldrawApp.tsx +++ b/src/components/TldrawApp.tsx @@ -12,7 +12,10 @@ import { TldrawImage, TldrawUiMenuItem, TldrawUiMenuSubmenu, + TLStateNodeConstructor, TLStoreSnapshot, + TLUiAssetUrlOverrides, + TLUiOverrides, useActions, } from "tldraw"; import { OPEN_FILE_ACTION, SAVE_FILE_COPY_ACTION, SAVE_FILE_COPY_IN_VAULT_ACTION } from "src/utils/file"; @@ -23,12 +26,12 @@ import { TldrawAppViewModeController } from "src/obsidian/helpers/TldrawAppEmbed import { useTldrawAppEffects } from "src/hooks/useTldrawAppHook"; import { useViewModeState } from "src/hooks/useViewModeController"; import { useClickAwayListener } from "src/hooks/useClickAwayListener"; -import { nextTick } from "process"; import { TLDataDocumentStore } from "src/utils/document"; import useSnapshotFromStoreProps from "src/hooks/useSnapshotFromStoreProps"; type TldrawAppOptions = { controller?: TldrawAppViewModeController; + iconAssetUrls?: TLUiAssetUrlOverrides['icons'], isReadonly?: boolean, autoFocus?: boolean, assetStore?: TLAssetStore, @@ -43,6 +46,9 @@ type TldrawAppOptions = { * Whether to call `.selectNone` on the Tldraw editor instance when it is mounted. */ selectNone?: boolean, + tools?: readonly TLStateNodeConstructor[], + uiOverrides?: TLUiOverrides, + components?: TLComponents, /** * Whether or not to initially zoom to the bounds when the component is mounted. * @@ -117,22 +123,35 @@ function getEditorStoreProps(storeProps: TldrawAppStoreProps) { const TldrawApp = ({ plugin, store, options: { assetStore, + components: otherComponents, controller, focusOnMount = true, hideUi = false, + iconAssetUrls, initialImageSize, initialTool, isReadonly = false, onInitialSnapshot, selectNone = false, + tools, + uiOverrides: otherUiOverrides, zoomToBounds = false, } }: TldrawAppProps) => { const assetUrls = React.useRef({ fonts: plugin.getFontOverrides(), - icons: plugin.getIconOverrides(), + icons: { + ...plugin.getIconOverrides(), + ...iconAssetUrls, + }, + }) + const overridesUi = React.useRef({ + ...uiOverrides(plugin), + ...otherUiOverrides + }) + const overridesUiComponents = React.useRef({ + ...components(plugin), + ...otherComponents }) - const overridesUi = React.useRef(uiOverrides(plugin)) - const overridesUiComponents = React.useRef(components(plugin)) const [storeProps, setStoreProps] = React.useState( !store ? undefined : getEditorStoreProps(store) ); @@ -171,7 +190,7 @@ const TldrawApp = ({ plugin, store, options: { if (currTldrawEditor) { currTldrawEditor.blur(); } - if(isMounting && !focusOnMount) { + if (isMounting && !focusOnMount) { plugin.currTldrawEditor = undefined; return; } @@ -194,9 +213,7 @@ const TldrawApp = ({ plugin, store, options: { enableClickAwayListener: isFocused, handler() { editor?.blur(); - nextTick(() => { - controller?.onClickAway(); - }) + nextFrame().then(() => controller?.onClickAway()); setIsFocused(false); const { currTldrawEditor } = plugin; if (currTldrawEditor) { @@ -207,64 +224,59 @@ const TldrawApp = ({ plugin, store, options: { } }); - return ( + return displayImage ? ( +
+ { + !storeSnapshot ? ( + <>No tldraw data to display + ) : ( +
+ +
+ ) + } +
+ ) : (
e.stopPropagation()} + ref={editorContainerRef} + onFocus={(e) => { + setFocusedEditor(false, editor); + }} + style={{ + width: '100%', + height: '100%', + }} > - {displayImage ? ( -
- { - !storeSnapshot ? ( - <>No tldraw data to display - ) : ( -
- -
- ) - } -
- ) : ( -
e.stopPropagation()} - ref={editorContainerRef} - onFocus={(e) => { - setFocusedEditor(false, editor); - }} - style={{ - width: '100%', - height: '100%', - }} - > - -
- )} +
); }; diff --git a/src/hooks/useTldrawAppHook.ts b/src/hooks/useTldrawAppHook.ts index 31fd0bb..4f30b25 100644 --- a/src/hooks/useTldrawAppHook.ts +++ b/src/hooks/useTldrawAppHook.ts @@ -44,7 +44,6 @@ export function useTldrawAppEffects({ React.useEffect(() => { if (!editor) return; - setFocusedEditor(editor); if (selectNone) { editor.selectNone(); @@ -78,15 +77,18 @@ export function useTldrawAppEffects({ isFocusMode: focusMode, }); - const zoomBounds = bounds ?? editor.getCurrentPageBounds(); - if (zoomToBounds && zoomBounds) { + if (zoomToBounds) { + const zoomBounds = bounds ?? editor.getViewportPageBounds(); editor.zoomToBounds(zoomBounds, { // Define an inset to 0 so that it is consistent with TldrawImage component inset: 0, animation: { duration: 0 } }); + } else { + editor.zoomToFit({ animation: { duration: 0 } }); } + setFocusedEditor(editor); // NOTE: These could probably be utilized for storing assets as files in the vault instead of tldraw's default indexedDB. // editor.registerExternalAssetHandler // editor.registerExternalContentHandler diff --git a/src/hooks/useViewModeController.ts b/src/hooks/useViewModeController.ts index ec74cc2..1cca0b1 100644 --- a/src/hooks/useViewModeController.ts +++ b/src/hooks/useViewModeController.ts @@ -24,11 +24,7 @@ export function useViewModeState(editor: Editor | undefined, const removeViewModeImageListener = controller?.setOnChangeHandlers({ onViewMode: (mode) => { onViewModeChanged(mode); - setDisplayImage(mode === 'image') - const bounds = editor?.getViewportPageBounds(); - if (bounds) { - controller?.setImageBounds(bounds) - } + setDisplayImage(mode === 'image'); }, onImageBounds: (bounds) => setImageViewOptions({ ...viewOptions, diff --git a/src/main.ts b/src/main.ts index 1762297..a6e7db5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { normalizePath, moment, Notice, + getIcon, } from "obsidian"; import { TldrawFileView, TldrawView } from "./obsidian/TldrawView"; import { @@ -76,6 +77,7 @@ export default class TldrawPlugin extends Plugin { currTldrawEditor?: Editor; // misc: + embedBoundsSelectorIcon: string; settings: TldrawPluginSettings; async onload() { @@ -101,6 +103,9 @@ export default class TldrawPlugin extends Plugin { // icons: addIcon(TLDRAW_ICON_NAME, TLDRAW_ICON); addIcon(MARKDOWN_ICON_NAME, MARKDOWN_ICON); + this.embedBoundsSelectorIcon = URL.createObjectURL(new Blob([getIcon('frame')?.outerHTML ?? ''], { + type: 'image/svg+xml' + })); // this creates an icon in the left ribbon: this.addRibbonIcon(TLDRAW_ICON_NAME, RIBBON_NEW_FILE, async () => { @@ -145,6 +150,7 @@ export default class TldrawPlugin extends Plugin { onunload() { this.unsubscribeToViewModeState(); this.statusBarViewModeReactRoot.unmount(); + URL.revokeObjectURL(this.embedBoundsSelectorIcon); } private registerEvents() { @@ -540,11 +546,24 @@ export default class TldrawPlugin extends Plugin { const embedsMerged = Object.assign({}, embedsDefault, embeds) const fileDestinationsMerged = Object.assign({}, fileDestinationsDefault, - { // Migrate old settings - defaultFolder: rest.folder, - assetsFolder: rest.assetsFolder, - destinationMethod: !rest.useAttachmentsFolder ? undefined : 'attachments-folder', - } as Partial, + (() => { + // Do not migrate if the the old file destination settings were already migrated. + if (fileDestinations === undefined) return {}; + // Migrate old settings + const migrated: Partial = {}; + + if (rest.folder !== undefined) { + migrated.defaultFolder = rest.folder; + } + + if (rest.assetsFolder !== undefined) { + migrated.assetsFolder = rest.assetsFolder; + } + + if (rest.useAttachmentsFolder !== undefined && rest.useAttachmentsFolder) { + migrated.destinationMethod = 'attachments-folder'; + } + })(), fileDestinations, ); delete rest.folder; @@ -563,6 +582,10 @@ export default class TldrawPlugin extends Plugin { await this.saveData(this.settings); } + getEmbedBoundsSelectorIcon() { + return this.embedBoundsSelectorIcon; + } + getFontOverrides() { return processFontOverrides(this.settings.fonts?.overrides, (font) => { return this.app.vault.adapter.getResourcePath(font).split('?')[0] diff --git a/src/obsidian/TldrawMixins.ts b/src/obsidian/TldrawMixins.ts index 70e97f7..151ef4a 100644 --- a/src/obsidian/TldrawMixins.ts +++ b/src/obsidian/TldrawMixins.ts @@ -30,6 +30,7 @@ export function TldrawLoadableMixin F * and the "View as markdown" action button. */ override onload(): void { + super.onload(); this.contentEl.addClass("tldraw-view-content"); this.addAction(MARKDOWN_ICON_NAME, "View as markdown", () => this.viewAsMarkdownClicked()); @@ -41,6 +42,7 @@ export function TldrawLoadableMixin F override onunload(): void { this.contentEl.removeClass("tldraw-view-content"); this.reactRoot?.unmount(); + super.onunload(); } override onUnloadFile(file: TFile): Promise { diff --git a/src/obsidian/TldrawSettingsTab.ts b/src/obsidian/TldrawSettingsTab.ts index 8a42a60..879c2cc 100644 --- a/src/obsidian/TldrawSettingsTab.ts +++ b/src/obsidian/TldrawSettingsTab.ts @@ -116,6 +116,7 @@ export interface TldrawPluginSettings extends DeprecatedFileDestinationSettings overrides?: IconOverrides } embeds: { + padding: number; /** * Default value to control whether to show the background for markdown embeds */ @@ -145,6 +146,7 @@ export const DEFAULT_SETTINGS = { colocationSubfolder: "", }, embeds: { + padding: 0, showBg: true, showBgDots: true, }, @@ -465,6 +467,22 @@ export class TldrawSettingsTab extends PluginSettingTab { text: 'Reload Obsidian to apply changes' }) + new Setting(containerEl) + .setName("Padding") + .setDesc( + "The amount of padding to use by default for each embed image preview. This must be a non-negative number." + ) + .addText((text) => text.setValue(`${this.plugin.settings.embeds.padding}`) + .onChange(async (value) => { + const padding = parseInt(value); + if(isNaN(padding) || padding < 0) { + return; + } + this.plugin.settings.embeds.padding = padding; + await this.plugin.saveSettings(); + }) + ) + new Setting(containerEl) .setName("Show background") .setDesc( diff --git a/src/obsidian/factories/createTldrawAppViewModeController.ts b/src/obsidian/factories/createTldrawAppViewModeController.ts index b020a1b..45056aa 100644 --- a/src/obsidian/factories/createTldrawAppViewModeController.ts +++ b/src/obsidian/factories/createTldrawAppViewModeController.ts @@ -2,9 +2,11 @@ import { BoxLike } from "tldraw"; import { TldrawAppViewModeController, ViewMode, ImageViewModeOptions, OnChangeHandlers } from "../helpers/TldrawAppEmbedViewController"; export function createTldrawAppViewModeController({ - initialBounds, showBg + initialBounds, padding, showBg, darkMode }: { initialBounds?: BoxLike, + padding?: number, + darkMode: boolean, showBg: boolean, }): TldrawAppViewModeController { return { @@ -14,8 +16,10 @@ export function createTldrawAppViewModeController({ format: 'svg', background: showBg, bounds: initialBounds, + darkMode, // FIXME: Image aspect ratio is ruined in reading mode when viewing with png format due to 300px height restriction on `.ptl-markdown-embed .ptl-view-content` // format: 'png', + padding, // preserveAspectRatio: '', }, onChangeHandlers: undefined, diff --git a/src/obsidian/helpers/TldrawAppEmbedViewController.ts b/src/obsidian/helpers/TldrawAppEmbedViewController.ts index b36667d..c443eed 100644 --- a/src/obsidian/helpers/TldrawAppEmbedViewController.ts +++ b/src/obsidian/helpers/TldrawAppEmbedViewController.ts @@ -26,6 +26,7 @@ export type ImageViewModeOptions = { format?: TldrawImageProps['format']; pageId?: TldrawImageProps['pageId']; darkMode?: TldrawImageProps['darkMode']; + padding?: TldrawImageProps['padding']; preserveAspectRatio?: TldrawImageProps['preserveAspectRatio']; }; diff --git a/src/obsidian/helpers/show-embed-context-menu.ts b/src/obsidian/helpers/show-embed-context-menu.ts index 2457869..2c02a09 100644 --- a/src/obsidian/helpers/show-embed-context-menu.ts +++ b/src/obsidian/helpers/show-embed-context-menu.ts @@ -3,7 +3,7 @@ import { createEmbedMenu } from "../menu/create-embed-menu"; import { TldrawAppViewModeController } from "./TldrawAppEmbedViewController"; import { TFile } from "obsidian"; -export function showEmbedContextMenu(ev: MouseEvent | undefined, { +export function showEmbedContextMenu(ev: MouseEvent | TouchEvent, { tFile, plugin, controller, focusContainer, }: { tFile: TFile, @@ -11,6 +11,12 @@ export function showEmbedContextMenu(ev: MouseEvent | undefined, { controller: TldrawAppViewModeController, focusContainer: HTMLElement, }) { + // This is done so that when editing the embed bounds, the editor knows which range of text belongs to the embed. + focusContainer.dispatchEvent(new MouseEvent('click', { + bubbles: ev.bubbles, + cancelable: ev.cancelable, + })); + createEmbedMenu({ tFile, plugin, controller, @@ -19,10 +25,13 @@ export function showEmbedContextMenu(ev: MouseEvent | undefined, { bubbles: ev.bubbles, cancelable: ev.cancelable, clientX: ev.clientX, - clientY: ev.clientY + clientY: ev.clientY, })) } - }).showAtMouseEvent(ev ?? + }).showAtMouseEvent(ev instanceof MouseEvent ? ev : // simulate click when it ev is undefined, e.g. MouseEvent not given because it was a touch event. - new MouseEvent('click')); + new MouseEvent('click', { + clientX: ev.touches.item(0)?.clientX, + clientY: ev.touches.item(0)?.clientY, + })); } \ No newline at end of file diff --git a/src/obsidian/index.d.ts b/src/obsidian/index.d.ts index 3b2455f..2c39ed7 100644 --- a/src/obsidian/index.d.ts +++ b/src/obsidian/index.d.ts @@ -7,7 +7,7 @@ declare module "obsidian" { * @param path A vault file path * @returns */ - openWithDefaultApp: (path: string) => void + openWithDefaultApp(path: string): void } interface Workspace { @@ -32,7 +32,7 @@ declare module "obsidian" { * @param data * @returns */ - onQuickPreview: (file: TFile, data: string) => void + onQuickPreview(file: TFile, data: string): void } interface Vault { @@ -46,6 +46,24 @@ declare module "obsidian" { /** * This function is present at runtime in the web developer console in Obsidian, but not in the type definition for some reason. */ - updateSuggestions: () => void; + updateSuggestions(): void; + } + + interface Editor { + getClickableTokenAt(position: EditorPosition): undefined | ( + { + end: EditorPosition, + start: EditorPosition, + text: string, + } & ( + { + displayText?: undefined, + type: 'blockid' | 'tag', + } | { + displayText: string, + type: 'internal-link' + } + ) + ) } } diff --git a/src/obsidian/plugin/TLDataDocumentStoreManager.ts b/src/obsidian/plugin/TLDataDocumentStoreManager.ts index 9c2107a..5a45c0d 100644 --- a/src/obsidian/plugin/TLDataDocumentStoreManager.ts +++ b/src/obsidian/plugin/TLDataDocumentStoreManager.ts @@ -44,7 +44,6 @@ export default class TLDataDocumentStoreManager { } { const instanceInfo: InstanceInfo = { instanceId: window.crypto.randomUUID(), - sharedId: tFile.path, syncToMain, data: { tFile, @@ -53,7 +52,10 @@ export default class TLDataDocumentStoreManager { }; const storeContext = this.storesManager.registerInstance(instanceInfo, - () => this.createMain(instanceInfo, getData) + { + createMain: () => this.createMain(instanceInfo, getData), + getSharedId: () => tFile.path, + } ); instanceInfo.data.onUpdatedData(storeContext.storeGroup.main.data.fileData); @@ -70,14 +72,15 @@ export default class TLDataDocumentStoreManager { } private createMain(info: InstanceInfo, getData: () => string): MainStore { - const { app } = this.plugin; const { tFile } = info.data; - const { workspace } = app; + const { workspace, vault } = this.plugin.app; const fileData = getData(); const documentStore = processInitialData(parseTLDataDocument(this.plugin.manifest.version, fileData)); const debouncedSave = this.createDebouncedSaveStoreListener(documentStore); let onExternalModificationsRef: undefined | EventRef; + let onFileRenamedRef: undefined | EventRef; + let onFileDeletedRef: undefined | EventRef; let onQuickPreviewRef: undefined | EventRef; let assetStore: undefined | ObsidianTLAssetStore; return { @@ -88,12 +91,22 @@ export default class TLDataDocumentStoreManager { documentStore: documentStore, }, init: (storeGroup) => { - onExternalModificationsRef = app.vault.on('modify', async (file) => { + onExternalModificationsRef = vault.on('modify', async (file) => { if (!(file instanceof TFile) || file.path !== storeGroup.main.data.tFile.path) return; - const data = await app.vault.cachedRead(file); + const data = await vault.cachedRead(file); this.onExternalModification(workspace, storeGroup, data); }); + onFileRenamedRef = vault.on('rename', async (file, oldPath) => { + if (!(file instanceof TFile) || file.path !== storeGroup.main.data.tFile.path) return; + this.storesManager.refreshSharedId(oldPath); + }); + + onFileDeletedRef = vault.on('delete', async (file) => { + if (!(file instanceof TFile) || file.path !== storeGroup.main.data.tFile.path) return; + storeGroup.unregister(); + }); + onQuickPreviewRef = workspace.on('quick-preview', (file, data) => { if (file.path !== storeGroup.main.data.tFile.path) return; this.onExternalModification(workspace, storeGroup, data) @@ -111,7 +124,13 @@ export default class TLDataDocumentStoreManager { dispose: () => { assetStore?.dispose(); if (onExternalModificationsRef) { - workspace.offref(onExternalModificationsRef); + vault.offref(onExternalModificationsRef); + } + if (onFileRenamedRef) { + vault.offref(onFileRenamedRef); + } + if (onFileDeletedRef) { + vault.offref(onFileDeletedRef); } if (onQuickPreviewRef) { workspace.offref(onQuickPreviewRef); diff --git a/src/obsidian/plugin/markdown-post-processor.ts b/src/obsidian/plugin/markdown-post-processor.ts index f7f3c0d..2fb20b6 100644 --- a/src/obsidian/plugin/markdown-post-processor.ts +++ b/src/obsidian/plugin/markdown-post-processor.ts @@ -1,4 +1,4 @@ -import { MarkdownPostProcessorContext, TFile } from "obsidian"; +import { Editor, MarkdownPostProcessorContext, TFile } from "obsidian"; import { createRootAndRenderTldrawApp } from "src/components/TldrawApp"; import TldrawPlugin from "src/main"; import { TldrawAppViewModeController } from "../helpers/TldrawAppEmbedViewController"; @@ -8,6 +8,12 @@ import { createTldrawAppViewModeController } from "../factories/createTldrawAppV import { Root } from "react-dom/client"; import { showEmbedContextMenu } from "../helpers/show-embed-context-menu"; import { TLDataDocumentStore } from "src/utils/document"; +import BoundsSelectorTool from "src/tldraw/tools/bounds-selector-tool"; +import BoundsTool from "src/components/BoundsTool"; +import { BoxLike } from "tldraw"; +import EmbedTldrawToolBar from "src/components/EmbedTldrawToolBar"; +import BoundsToolSelectedShapeIndicator from "src/components/BoundsToolSelectedShapesIndicator"; +import { isObsidianThemeDark } from "src/utils/utils"; /** * Processes the embed view for a tldraw white when including it in another obsidian note. @@ -108,6 +114,13 @@ export async function markdownPostProcessor(plugin: TldrawPlugin, element: HTMLE const controller = createTldrawAppViewModeController({ showBg: embedValues.showBg, initialBounds: embedValues.bounds, + padding: plugin.settings.embeds.padding, + darkMode: (() => { + const { themeMode } = plugin.settings; + if (themeMode === "dark") return true; + else if (themeMode === "light") return false; + else return isObsidianThemeDark() + })() }); const { tldrawEmbedViewContent } = createTldrawEmbedView(internalEmbedDiv, { @@ -148,32 +161,35 @@ async function loadEmbedTldraw(tldrawEmbedViewContent: HTMLElement, { let storeInstance: undefined | ReturnType; - const fileListener = plugin.tldrawFileListeners.addListener(file, async () => { - if (!parent.isConnected) { - fileListener.remove(); - return; - } - if (storeInstance) { - controller.setStoreProps({ plugin: storeInstance.documentStore }); - } - }, { immediatelyPause: true }); + const dataUpdated = (_storeInstance: NonNullable) => { + controller.setStoreProps({ plugin: _storeInstance.documentStore }); + tldrawEmbedViewContent.setAttr('data-has-shape', + _storeInstance.documentStore.store.query.record('shape').get() !== undefined + ); + }; + + let pauseListener = true; const activateReactRoot = async () => { if (timer) { clearTimeout(timer); } try { - fileListener.isPaused = true; + pauseListener = true; storeInstance ??= await (async () => { const fileData = await plugin.app.vault.read(file); - return plugin.tlDataDocumentStoreManager.register(file, () => fileData, () => { }, false); + return plugin.tlDataDocumentStoreManager.register(file, () => fileData, () => { + if (pauseListener) return; + if (storeInstance) dataUpdated(storeInstance); + }, false); })(); + dataUpdated(storeInstance); + reactRoot = await createReactTldrawAppRoot({ controller, documentStore: storeInstance.documentStore, plugin, tldrawEmbedViewContent, embedValues, }) - fileListener.isPaused = false; - // log(`React root loaded.`); + pauseListener = false; } catch (e) { console.error('There was an error while mounting the tldraw app: ', e); } @@ -199,11 +215,12 @@ async function loadEmbedTldraw(tldrawEmbedViewContent: HTMLElement, { clearTimeout(timer); } - const { bounds, imageSize, showBg } = parseEmbedValues(target, { - showBgDefault: plugin.settings.embeds.showBg - }) timer = setTimeout(async () => { + const { bounds, imageSize, showBg } = parseEmbedValues(target, { + showBgDefault: plugin.settings.embeds.showBg + }); + controller.setShowBackground(showBg); controller.setImageSize(imageSize) controller.setImageBounds(bounds); @@ -216,7 +233,7 @@ async function loadEmbedTldraw(tldrawEmbedViewContent: HTMLElement, { const observerParent = new CustomMutationObserver(function markdownParentObserverFn(m) { // log(`${markdownParentObserverFn.name} watching`, m, parent); if (!parent.contains(internalEmbedDiv)) { - fileListener.isPaused = true; + pauseListener = true; // log(`${markdownParentObserverFn.name}: Unmounting react root`); reactRoot?.unmount(); reactRoot = undefined; @@ -231,7 +248,6 @@ async function loadEmbedTldraw(tldrawEmbedViewContent: HTMLElement, { new CustomMutationObserver(function (m) { if (parent.isConnected) return; - fileListener.remove(); storeInstance?.unregister(); storeInstance = undefined; }, 'markdownTldrawFileListener').observe(parent, { childList: true }) @@ -262,6 +278,14 @@ function createTldrawEmbedView(internalEmbedDiv: HTMLElement, { } }) + tldrawEmbedView.addEventListener('dblclick', (ev) => { + if (controller.getViewMode() === 'image') { + console.log('double click') + plugin.openTldrFile(file, 'new-tab', 'tldraw-view'); + ev.stopPropagation(); + } + }) + tldrawEmbedViewContent.addEventListener('contextmenu', (ev) => { if (ev.button === 2) { showEmbedContextMenu(ev, { @@ -278,7 +302,7 @@ function createTldrawEmbedView(internalEmbedDiv: HTMLElement, { let longPressTimer: NodeJS.Timer | undefined; tldrawEmbedViewContent.addEventListener('touchstart', (ev) => { clearTimeout(longPressTimer) - longPressTimer = setTimeout(() => showEmbedContextMenu(undefined, { + longPressTimer = setTimeout(() => showEmbedContextMenu(ev, { plugin, controller, focusContainer: tldrawEmbedView, tFile: file }), 500) @@ -286,11 +310,11 @@ function createTldrawEmbedView(internalEmbedDiv: HTMLElement, { tldrawEmbedViewContent.addEventListener('touchmove', (ev) => { clearTimeout(longPressTimer) - }); + }, { passive: true }); tldrawEmbedViewContent.addEventListener('touchend', (ev) => { - clearTimeout(longPressTimer) - }); + clearTimeout(longPressTimer); + }, { passive: true }); } return { @@ -299,6 +323,12 @@ function createTldrawEmbedView(internalEmbedDiv: HTMLElement, { } } +function parseAltText(altText: string): Partial> { + const altSplit = altText.split(';').map((e) => e.trim()) + const altEntries = altSplit.map((e) => e.split('=')) + return Object.fromEntries(altEntries); +} + function parseEmbedValues(el: HTMLElement, { showBgDefault, imageBounds = { @@ -316,9 +346,7 @@ function parseEmbedValues(el: HTMLElement, { } }) { const alt = el.attributes.getNamedItem('alt')?.value ?? ''; - const altSplit = alt.split(';').map((e) => e.trim()) - const altEntries = altSplit.map((e) => e.split('=')) - const altNamedProps: Partial> = Object.fromEntries(altEntries); + const altNamedProps = parseAltText(alt); const posValue = altNamedProps['pos']?.split(',').map((e) => Number.parseFloat(e)) ?? []; const pos = { x: posValue.at(0) ?? imageBounds.pos.x, y: posValue.at(1) ?? imageBounds.pos.y } @@ -355,8 +383,45 @@ function parseEmbedValues(el: HTMLElement, { }; } +function replaceBoundsProps(bounds: BoxLike | undefined, props: Partial>) { + if (bounds) { + props['pos'] = `${bounds.x.toFixed(0)},${bounds.y.toFixed(0)}`; + props['size'] = `${bounds.w.toFixed(0)},${bounds.h.toFixed(0)}`; + } else { + delete props['pos']; + delete props['size']; + } + return props; +} + +function updateEmbedBoundsAtCursorPostion(bounds: BoxLike | undefined, editor: Editor) { + const anchorPos = editor.getCursor('anchor'); + const token = editor.getClickableTokenAt(anchorPos); + if (!token) return; + + if (token.type === 'internal-link') { + token.displayText + const [altText, ...rest] = token.displayText.split('|'); + editor.replaceRange( + [ + token.text, + Object.entries(replaceBoundsProps(bounds, parseAltText(altText))) + .filter(([key, value]) => key.length > 0 && value !== undefined) + .map( + ([key, value]) => `${key}=${value}` + ).join(';'), + ...rest + ].join('|'), + token.start, + token.end + ); + } +} + type EmbedValues = ReturnType; +const boundsSelectorToolIconName = `tool-${BoundsSelectorTool.id}`; + async function createReactTldrawAppRoot({ controller, documentStore, plugin, tldrawEmbedViewContent, embedValues, }: { @@ -367,6 +432,8 @@ async function createReactTldrawAppRoot({ embedValues: EmbedValues }) { const { imageSize } = embedValues; + const boundsSelectorIcon = plugin.getEmbedBoundsSelectorIcon(); + return createRootAndRenderTldrawApp(tldrawEmbedViewContent, plugin, { @@ -374,11 +441,51 @@ async function createReactTldrawAppRoot({ app: { assetStore: documentStore.store.props.assets, isReadonly: true, + components: { + InFrontOfTheCanvas: BoundsTool, + OnTheCanvas: BoundsToolSelectedShapeIndicator, + Toolbar: EmbedTldrawToolBar, + }, controller, selectNone: true, + iconAssetUrls: { + [boundsSelectorToolIconName]: boundsSelectorIcon, + }, initialTool: 'hand', initialImageSize: imageSize, zoomToBounds: true, + tools: [ + BoundsSelectorTool.create({ + getInitialBounds: () => { + return controller.getViewOptions().bounds; + }, + callback: (bounds) => { + const { activeEditor } = plugin.app.workspace; + if (activeEditor && activeEditor.editor) { + updateEmbedBoundsAtCursorPostion(bounds, activeEditor.editor); + } else { + console.warn("No active editor; setting the controller's bounds instead."); + controller.setImageBounds(bounds); + } + } + }), + ], + uiOverrides: { + tools: (editor, tools, _) => { + return { + ...tools, + [BoundsSelectorTool.id]: { + id: BoundsSelectorTool.id, + label: 'Select embed bounds', + icon: boundsSelectorToolIconName, + readonlyOk: true, + onSelect(_) { + editor.setCurrentTool(BoundsSelectorTool.id) + }, + } + } + }, + } }, } ); diff --git a/src/styles.css b/src/styles.css index 8e46260..687dd34 100644 --- a/src/styles.css +++ b/src/styles.css @@ -173,6 +173,14 @@ div[data-type="tldraw-read-only"] .view-content.tldraw-view-content { /* Offset for the dotted pattern */ } +.ptl-markdown-embed .ptl-view-content[data-has-shape="false"] .ptl-tldraw-image::before { + display: block; + content: "This is an empty tldraw file. Double click to edit in a new tab, or right click for options."; + height: 100%; + text-align: center; + align-content: center; +} + .ptl-markdown-embed .ptl-tldraw-image-container { display: flex; } @@ -241,14 +249,39 @@ div[data-type="tldraw-read-only"] .view-content.tldraw-view-content { } .ptl-suggestion-item { - display: flex; - gap: 0.25em; + display: flex; + gap: 0.25em; } .ptl-suggestion-item-icon { - display: contents; + display: contents; } .ptl-suggestion-label { - font-size: x-small; + font-size: x-small; +} + +.ptl-embed-bounds-selection { + position: absolute; + top: 0; + left: 0; + z-index: 999; +} + +.ptl-embed-bounds-selection[data-target-bounds="true"] { + border: 1px solid var(--color-text-0); +} + +.ptl-embed-bounds-selection[data-target-bounds="false"] { + border: 1px dashed var(--color-text-0); } + +.ptl-embed-bounds-selection[data-shade-bg="true"]::before { + position: absolute; + content: ""; + display: block; + width: 100%; + height: 100%; + background-color: var(--color-text-0); + opacity: 0.15; +} \ No newline at end of file diff --git a/src/tldraw/TldrawStoresManager.ts b/src/tldraw/TldrawStoresManager.ts index 733d4be..751354d 100644 --- a/src/tldraw/TldrawStoresManager.ts +++ b/src/tldraw/TldrawStoresManager.ts @@ -2,7 +2,6 @@ import { createTLStore, HistoryEntry, TLRecord, TLStore } from "tldraw"; export type StoreInstanceInfo = { instanceId: string, - sharedId: string, syncToMain: boolean, data: T, }; @@ -29,6 +28,8 @@ export type StoreGroup = { * @returns */ apply: (instanceId: string, entry: HistoryEntry) => void, + getSharedId: () => string, + unregister: () => void, }; export type StoreListenerContext = { @@ -67,24 +68,38 @@ export default class TldrawStoresManager { * @param info * @returns An object containing a new {@linkcode TLStore} instance. */ - registerInstance(info: StoreInstanceInfo, createMain: () => MainStore): StoreContext { - let storeGroup = this.storeGroupMap.get(info.sharedId); + registerInstance(info: StoreInstanceInfo, { createMain, getSharedId }: { + createMain: () => MainStore, + getSharedId: () => string, + }): StoreContext { + const initialSharedId = getSharedId(); + let storeGroup = this.storeGroupMap.get(initialSharedId); if (!storeGroup) { const main = createMain(); const _storeGroup: StoreGroup = { main, instances: [], + getSharedId, apply: (instanceId, entry) => { // TODO: Find a way to debounce the synchronziation of entry to the other stores. syncToStore(main.store, entry); for (const _instance of _storeGroup.instances) { if (_instance.source.instanceId == instanceId) continue; - syncToStore(_instance.store, entry); + // We want to sync this entry as a remote change so that it doesn't trigger the store listener of this instance. + _instance.store.mergeRemoteChanges(() => { + syncToStore(_instance.store, entry); + }); } }, + unregister: () => { + const instances = [..._storeGroup.instances]; + for (const instance of instances) { + instance.unregister(); + } + } } + this.storeGroupMap.set(initialSharedId, storeGroup = _storeGroup); main.init(_storeGroup); - this.storeGroupMap.set(info.sharedId, storeGroup = _storeGroup); this.listenDocumentStore(_storeGroup); } @@ -96,7 +111,8 @@ export default class TldrawStoresManager { storeGroup.instances.remove(instance); instance.store.dispose(); if (storeGroup.instances.length === 0) { - this.storeGroupMap.delete(info.sharedId); + // NOTE: We call .getSharedId() here in case the sharedId was changed. + this.storeGroupMap.delete(storeGroup.getSharedId()); storeGroup.main.store.dispose(); storeGroup.main.dispose(); } @@ -106,6 +122,15 @@ export default class TldrawStoresManager { return { instance, storeGroup }; } + refreshSharedId(oldSharedId: string) { + const storeGroup = this.storeGroupMap.get(oldSharedId); + if (!storeGroup) return; + const newSharedId = storeGroup.getSharedId(); + if(oldSharedId === newSharedId) return; + this.storeGroupMap.delete(oldSharedId); + this.storeGroupMap.set(newSharedId, storeGroup); + } + private listenDocumentStore(storeGroup: StoreGroup) { const removeListener = storeGroup.main.store.listen( (entry) => storeGroup.main.storeListener(entry, { @@ -131,8 +156,12 @@ function createSourceStore(storeGroup: Group, instance // NOTE: We want to preserve the assets object that is attached to props, otherwise the context will be lost if provided as a param in createTLStore store.props.assets = storeGroup.main.store.props.assets; - if(syncToMain) { - store.listen((entry) => storeGroup.apply(instanceId, entry), { scope: 'document' }); + if (syncToMain) { + store.listen((entry) => storeGroup.apply(instanceId, entry), { + scope: 'document', + // Only listen to changes made by the user + source: 'user', + }); } return store; diff --git a/src/tldraw/tools/bounds-selector-tool.ts b/src/tldraw/tools/bounds-selector-tool.ts new file mode 100644 index 0000000..ca69708 --- /dev/null +++ b/src/tldraw/tools/bounds-selector-tool.ts @@ -0,0 +1,236 @@ +/** + * Followed example from https://tldraw.dev/examples/shapes/tools/screenshot-tool + */ +import { atom, Box, BoxLike, StateNode, TLCancelEventInfo, TLPointerEventInfo, TLShape, TLStateNodeConstructor } from "tldraw"; + +class IdleBoundsState extends StateNode { + static override id = 'bounds-idle'; + + override onPointerDown(info: TLPointerEventInfo): void { + this.parent.transition(PointingBoundsState.id); + } +} + +class PointingBoundsState extends StateNode { + static override id = 'bounds-pointing'; + + override onPointerMove(info: TLPointerEventInfo): void { + if (this.editor.inputs.isDragging) { + this.parent.transition(BoundsDraggingState.id); + } + } + + override onPointerUp(info: TLPointerEventInfo): void { + this.complete(); + } + + override onCancel(info: TLCancelEventInfo): void { + this.complete(); + } + + private complete() { + this.parent.transition(IdleBoundsState.id); + } +} + +/** + * The {@linkcode atom} name for {@linkcode BoundsDraggingState.boundsBox} + */ +export const BOUNDS_BOX = 'bounds box'; +export const BOUNDS_USING_ASPECT_RATIO = 'bounds using aspect ratio'; +export const BOUNDS_SHAPES_BOX = 'bounds shapes box'; +export const BOUNDS_SELECTED_SHAPES = 'bounds selected shapes'; + +export class BoundsDraggingState extends StateNode { + static override readonly id = 'bounds-dragging' + + boundsBox = atom(BOUNDS_BOX, new Box()) + shapesBox = atom(BOUNDS_BOX, new Box()) + boundsUsingAspectRatio = atom(BOUNDS_USING_ASPECT_RATIO, false); + selectedShapes = atom(BOUNDS_SELECTED_SHAPES, []); + + private get _parent(): BoundsSelectorTool { + return this.parent as BoundsSelectorTool; + } + + override onEnter() { + const parent = this.parent; + if (!(parent instanceof BoundsSelectorTool)) { + console.error(`${parent.id} is not a ${BoundsSelectorTool.name}`); + this.complete(); + return; + } + this.update() + } + + override onPointerMove() { + this.update() + } + + override onKeyDown() { + if (this.editor.inputs.altKey) { + this._parent.cycleAspectRatio(); + } + this.update() + } + + override onKeyUp() { + this.update() + } + + override onPointerUp() { + const { editor } = this + const box = this.boundsBox.get() + const shapesBox = this.shapesBox.get(); + + if (editor.inputs.ctrlKey && shapesBox) { + this._parent.onBounds(shapesBox); + } else { + this._parent.onBounds(box); + } + + this.complete(); + } + + override onCancel() { + this.complete(); + } + + private update() { + const { + inputs: { ctrlKey, shiftKey, originPagePoint, currentPagePoint, }, + } = this.editor + + const box = Box.FromPoints([originPagePoint, currentPagePoint]) + + if (shiftKey) { + this.boundsUsingAspectRatio.set(true); + const { w, h } = this._parent.aspectRatio.get(); + if (!isNaN(w) && !isNaN(h)) { + if (box.w > box.h * (w / h)) { + box.h = box.w * (h / w) + } else { + box.w = box.h * (w / h) + } + } + + if (currentPagePoint.x < originPagePoint.x) { + box.x = originPagePoint.x - box.w + } + + if (currentPagePoint.y < originPagePoint.y) { + box.y = originPagePoint.y - box.h + } + } else { + this.boundsUsingAspectRatio.set(false); + } + + this.boundsBox.set(box); + + if (ctrlKey) { + const selectedShapes = this.editor.getCurrentPageShapes().filter((s) => { + const pageBounds = this.editor.getShapeMaskedPageBounds(s) + if (!pageBounds) return false + return box.includes(pageBounds) + }); + this.shapesBox.set(Box.Common(selectedShapes.map((s) => this.editor.getShapePageBounds(s)!))); + this.selectedShapes.set(selectedShapes); + } else { + this.shapesBox.set(null); + this.selectedShapes.set([]); + } + } + + private complete() { + this.editor.selectNone(); + this.parent.transition(IdleBoundsState.id); + } +} + +type AspectRatio = { + /** + * Aspect ratio width. + */ + w: number, + /** + * Aspect ratio height + */ + h: number, +}; + +export const BOUNDS_ASPECT_RATIO = 'bounds aspect ratio'; +export const BOUNDS_CURRENT_BOX = 'bounds current box'; +export const BOUNDS_SELECTOR_INITIALIZED = 'bounds selector initialized'; + +export default class BoundsSelectorTool extends StateNode { + static override readonly id = 'bounds-selector-tool'; + static override initial = IdleBoundsState.id; + static readonly draggingStatePath = `${BoundsSelectorTool.id}.${BoundsDraggingState.id}` as const; + + private static readonly aspectRatios = [ + { w: 1, h: 1 }, + { w: 3, h: 2 }, + { w: 16, h: 10 }, + { w: 16, h: 9 }, + { w: 21, h: 9 } + ] as const satisfies AspectRatio[]; + + static override children() { + return [IdleBoundsState, PointingBoundsState, BoundsDraggingState]; + } + + static create({ + getInitialBounds, + callback, + }: { + callback: BoundsSelectorTool['onBounds'], + getInitialBounds?: () => BoxLike | undefined, + }): TLStateNodeConstructor { + class _BoundsSelectorTool extends BoundsSelectorTool { + override init() { + const bounds = getInitialBounds?.(); + // We don't want to trigger the callback when the tool is initialized, so we call the super method instead. + super.onBounds(!bounds ? undefined : Box.From(bounds)); + super.init(); + } + + override onBounds(bounds?: Box) { + super.onBounds(bounds); + callback(bounds); + } + } + return _BoundsSelectorTool; + } + + private aspectRatioIndex = 0; + + aspectRatio = atom(BOUNDS_ASPECT_RATIO, BoundsSelectorTool.aspectRatios[0]); + currentBounds = atom(BOUNDS_CURRENT_BOX, undefined); + boundsSelectorInitialized = atom(BOUNDS_SELECTOR_INITIALIZED, false); + + override onEnter() { + this.editor.setCursor({ type: 'cross', rotation: 0 }); + } + + override onExit() { + this.editor.setCursor({ type: 'default', rotation: 0 }); + } + + onBounds(bounds?: Box) { + this.currentBounds.set(bounds); + } + + /** + * Cycles through {@linkcode aspectRatios} ands sets {@linkcode aspectRatio} + */ + cycleAspectRatio() { + this.aspectRatioIndex++; + this.aspectRatio.set(BoundsSelectorTool.aspectRatios[ + this.aspectRatioIndex % BoundsSelectorTool.aspectRatios.length + ]); + } + + init() { + this.boundsSelectorInitialized.set(true); + } +} \ No newline at end of file