diff --git a/src/main.ts b/src/main.ts index e6c5a05..9e410d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import { Root } from 'react-dom/client' import { Editor, TLDRAW_FILE_EXTENSION, TLStore } from 'tldraw' import { createReactStatusBarViewMode } from './components/StatusBarViewMode' import createMain from './components/plugin/TldrawInObsidian' +import { TldrawOfflineFileView } from './obsidian/TldrawOfflineFileView' import { ReadonlyTldrawView } from './obsidian/TldrawReadonlyView' import { DEFAULT_SETTINGS, @@ -45,6 +46,7 @@ import { TLDRAW_ICON_NAME, VIEW_TYPE_MARKDOWN, VIEW_TYPE_TLDRAW, + VIEW_TYPE_TLDRAW_OFFLINE, VIEW_TYPE_TLDRAW_READ_ONLY, ViewType, } from './utils/constants' @@ -104,6 +106,8 @@ export default class TldrawPlugin extends Plugin { this.registerView(VIEW_TYPE_TLDRAW_READ_ONLY, (leaf) => new ReadonlyTldrawView(leaf, this)) + this.registerView(VIEW_TYPE_TLDRAW_OFFLINE, (leaf) => new TldrawOfflineFileView(leaf)) + // settings: await this.settingsManager.loadSettings() this.addSettingTab(new TldrawSettingsTab(this.app, this)) @@ -165,6 +169,14 @@ export default class TldrawPlugin extends Plugin { this.registerExtensions(['tldr'], VIEW_TYPE_TLDRAW) + try { + this.registerExtensions(['tldraw'], VIEW_TYPE_TLDRAW_OFFLINE) + } catch (e) { + // Obsidian throws if another plugin already owns the extension. Explaining that we can't + // open these files isn't worth taking the file type away from a plugin that can. + console.error(e) + } + const unmount = createMain(this, this.app.dom.statusBarEl) this.register(() => { unmount() diff --git a/src/obsidian/TldrawOfflineFileView.ts b/src/obsidian/TldrawOfflineFileView.ts new file mode 100644 index 0000000..289f469 --- /dev/null +++ b/src/obsidian/TldrawOfflineFileView.ts @@ -0,0 +1,73 @@ +import { FileView, Menu, WorkspaceLeaf } from 'obsidian' +import { + TLDRAW_ICON_NAME, + TLDRAW_OFFLINE_UNSUPPORTED_TITLE, + VIEW_TYPE_TLDRAW_OFFLINE, +} from 'src/utils/constants' +import { appendTldrawOfflineMessage } from 'src/utils/tldraw-offline-message' +import { pluginMenuLabel } from './menu' + +const OPEN_IN_DEFAULT_APP = 'Open in default app' + +/** + * Shown when a `.tldraw` file is opened. tldraw offline saves documents as an archive holding a + * SQLite database rather than the JSON a `.tldr` file holds, so we can't render one. We claim the + * extension anyway so that opening the file explains that, rather than leaving the user on + * Obsidian's generic "no view for this file type" screen. + * + * Claiming the extension takes away Obsidian's own handling, so this offers "open in default app" + * to keep a route to whatever program can read the file. + * + * This extends `FileView` directly rather than `BaseTldrawFileView`, which reads the file as text + * and parses it as JSON — on an archive that only produces an opaque parse error. + */ +export class TldrawOfflineFileView extends FileView { + constructor(leaf: WorkspaceLeaf) { + super(leaf) + this.navigation = true + } + + override getViewType() { + return VIEW_TYPE_TLDRAW_OFFLINE + } + + override getIcon() { + return TLDRAW_ICON_NAME + } + + override getDisplayText() { + return this.file ? this.file.basename : 'NO_FILE' + } + + override onload() { + super.onload() + this.addAction('external-link', OPEN_IN_DEFAULT_APP, () => this.openInDefaultApp()) + } + + override onPaneMenu(menu: Menu, source: 'more-options' | 'tab-header' | string): void { + super.onPaneMenu(menu, source) + if (!this.file) return + + menu + .addItem((item) => pluginMenuLabel(item.setSection('tldraw'))) + .addItem((item) => + item + .setIcon('external-link') + .setSection('tldraw') + .setTitle(OPEN_IN_DEFAULT_APP) + .onClick(() => this.openInDefaultApp()) + ) + } + + override async onOpen() { + this.contentEl.empty() + const container = this.contentEl.createDiv({ cls: 'ptl-offline-unsupported' }) + container.createEl('h3', { text: TLDRAW_OFFLINE_UNSUPPORTED_TITLE }) + appendTldrawOfflineMessage(container) + } + + private openInDefaultApp() { + if (!this.file) return + this.app.openWithDefaultApp(this.file.path) + } +} diff --git a/src/obsidian/plugin/commands.ts b/src/obsidian/plugin/commands.ts index 3d552f1..780eb32 100644 --- a/src/obsidian/plugin/commands.ts +++ b/src/obsidian/plugin/commands.ts @@ -153,6 +153,7 @@ export function registerCommands(plugin: TldrawPlugin) { name: 'Import file as new document and open in a new tab', callback: async () => { const tFile = await importTldrawFile(plugin) + if (!tFile) return await plugin.openTldrFile(tFile, 'new-tab') }, }) @@ -169,6 +170,7 @@ export function registerCommands(plugin: TldrawPlugin) { const from = editor.getCursor('from') const to = editor.getCursor('to') const tFile = await importTldrawFile(plugin, file) + if (!tFile) return editorInsert(new TldrawDocument(plugin, tFile), editor, from, to) }, }) diff --git a/src/styles.css b/src/styles.css index 2a46686..04529ad 100644 --- a/src/styles.css +++ b/src/styles.css @@ -548,3 +548,23 @@ div[data-type='tldraw-read-only'] .view-content.tldraw-view-content { .ptl-document-messages-actions button.mod-cta { margin: 0; } + +.ptl-offline-unsupported { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--size-4-1); + height: 100%; + padding: var(--size-4-4); + text-align: center; +} + +.ptl-offline-unsupported h3 { + margin: 0; +} + +.ptl-offline-unsupported p { + margin: 0; + color: var(--text-muted); +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 5ea2f85..817e887 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -6,6 +6,11 @@ export type ViewType = (typeof VIEW_TYPES)[number] export const VIEW_TYPE_TLDRAW = 'tldraw-view' // custom view type export const VIEW_TYPE_TLDRAW_READ_ONLY = 'tldraw-read-only' // custom view type +/** + * For `.tldraw` ending files. Deliberately left out of {@link VIEW_TYPES}, since it isn't a mode + * the user can switch a document into — it only exists to explain why we can't open the file. + */ +export const VIEW_TYPE_TLDRAW_OFFLINE = 'tldraw-offline' // custom view type export const VIEW_TYPE_MARKDOWN = 'markdown' // NOT ACTUALLY A CUSTOM VIEW TYPE, its built in from obsidian export const VIEW_TYPES = [ VIEW_TYPE_MARKDOWN, @@ -15,6 +20,12 @@ export const VIEW_TYPES = [ export const PANE_TARGETS = ['new-window', 'new-tab', 'current-tab', 'split-tab'] as const export const FILE_EXTENSION = '.md' +/** + * The extension used by tldraw offline. A `.tldraw` file is an archive holding a SQLite database + * and its assets, not the JSON a `.tldr` file holds, so none of our parsing can read one. + */ +export const TLDRAW_OFFLINE_FILE_EXTENSION = '.tldraw' +export const TLDRAW_OFFLINE_UNSUPPORTED_TITLE = 'Can’t open .tldraw files yet' export const FRONTMATTER_KEY = 'tldraw-file' export const TLDATA_DELIMITER_START = '!!!_START_OF_TLDRAW_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!' export const TLDATA_DELIMITER_END = '!!!_END_OF_TLDRAW_DATA__DO_NOT_CHANGE_THIS_PHRASE_!!!' diff --git a/src/utils/file.ts b/src/utils/file.ts index af8e8fe..d59ef89 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,4 +1,4 @@ -import { Platform, TFile } from 'obsidian' +import { Notice, Platform, TFile } from 'obsidian' import TldrawPlugin from 'src/main' import { showSaveFileModal } from 'src/obsidian/modal/save-file-modal' import { @@ -8,7 +8,9 @@ import { serializeTldrawJsonBlob, useDefaultHelpers, } from 'tldraw' +import { TLDRAW_OFFLINE_FILE_EXTENSION, TLDRAW_OFFLINE_UNSUPPORTED_TITLE } from './constants' import { migrateTldrawFileDataIfNecessary } from './migrate/tl-data-to-tlstore' +import { appendTldrawOfflineMessage } from './tldraw-offline-message' // import { shouldOverrideDocument } from "src/components/file-menu/shouldOverrideDocument"; export const SAVE_FILE_COPY_ACTION = 'save-file-copy' @@ -103,12 +105,16 @@ export function importFileAction( readonlyOk: true, async onSelect(source) { const tFile = await importTldrawFile(plugin) + if (!tFile) return await plugin.openTldrFile(tFile, 'new-tab') }, } } -export async function importTldrawFile(plugin: TldrawPlugin, attachTo?: TFile) { +export async function importTldrawFile( + plugin: TldrawPlugin, + attachTo?: TFile +): Promise { if ('showOpenFilePicker' in window) { const [file] = await window.showOpenFilePicker({ id: 'tldraw-open-file', @@ -117,13 +123,26 @@ export async function importTldrawFile(plugin: TldrawPlugin, attachTo?: TFile) { { description: 'Tldraw Document', accept: { - 'text/tldr': ['.tldr'], + // tldraw offline files are selectable so that we can explain why we can't import + // them yet. Leaving them out would just grey them out with no explanation. + 'text/tldr': ['.tldr', TLDRAW_OFFLINE_FILE_EXTENSION], }, }, ], excludeAcceptAllOption: true, }) + // Case-insensitive: file dialogs on macOS and Windows match their filters that way, so a + // `.TLDRAW` file is selectable and would otherwise fall through to the JSON parser. + if (file.name.toLowerCase().endsWith(TLDRAW_OFFLINE_FILE_EXTENSION)) { + // A fragment rather than a string, so the notice can carry the links. + const notice = document.createDocumentFragment() + notice.createEl('strong', { text: TLDRAW_OFFLINE_UNSUPPORTED_TITLE }) + appendTldrawOfflineMessage(notice) + new Notice(notice) + return undefined + } + return plugin.createUntitledTldrFile({ attachTo, tlStore: migrateTldrawFileDataIfNecessary(await (await file.getFile()).text()), diff --git a/src/utils/tldraw-offline-message.ts b/src/utils/tldraw-offline-message.ts new file mode 100644 index 0000000..c6d5bff --- /dev/null +++ b/src/utils/tldraw-offline-message.ts @@ -0,0 +1,24 @@ +const TLDRAW_OFFLINE_URL = 'https://offline.tldraw.com/' + +/** Anchors the "Export as .tldr" section of the tldraw offline user manual. */ +const TLDRAW_OFFLINE_EXPORT_URL = + 'https://tldraw.notion.site/User-manual-tldraw-offline-39a3e4c324c080e7b2eacc5afd078e85#3aa3e4c324c080669967e2cc3ae2c789' + +/** + * Appends the explanation for why a `.tldraw` file can't be opened, and the way forward, into + * {@linkcode parent}. + * + * Built as nodes rather than returned as a string so that both the file view and the import notice + * can carry the links: Obsidian's `Notice` only renders them when given a fragment. + */ +export function appendTldrawOfflineMessage(parent: HTMLElement | DocumentFragment) { + const explanation = parent.createEl('p') + explanation.appendText('We’re working on support for files from ') + explanation.createEl('a', { text: 'tldraw offline', href: TLDRAW_OFFLINE_URL }) + explanation.appendText('.') + + const prompt = parent.createEl('p') + prompt.appendText('For now, you can ') + prompt.createEl('a', { text: 'export as a .tldr file', href: TLDRAW_OFFLINE_EXPORT_URL }) + prompt.appendText(' to use it here.') +}