lumetrium / obsidian-teleprompter

Plugin for Obsidian designed to seamlessly integrate teleprompter functionality into your note-taking workflow.
https://lumetrium.com
GNU General Public License v3.0
54 stars 2 forks source link

[FR] Expose Teleprompter Window/Tab/Sidebar Controls To Other Plugins #19

Closed anonhostpi closed 3 months ago

anonhostpi commented 3 months ago

Feature Request:

My understanding is that Teleprompter renders its content simply by cloning the active markdown view (unless the pin option is turned on).

It would be fantastic if the file that Teleprompter renders could be selected instead of pinned.

It would also be awesome if this could be controlled by other plugins like Templater and DataviewJS.

Example Usage (A Call Center Workflow):

  1. Representative triggers Daily Note (Core Plugins) by hotkey
    1. Daily Note creates a new file from its template file.
    2. The template file contains Templater (Community Plugins) blocks.
  2. Templater sets up the Representative's workspace:
    1. Templater commands Teleprompter to render a file in a new window
    2. Templater triggers a Modal Form from the Modal Form Plugin (Community Plugins)
  3. Representative reads from the Teleprompter and fills out the Modal Form from the customer's responses.
    1. Templater populates the daily note and the note title from the Modal Form.
    2. Templater automatically closes Teleprompter

Current Implementation:

Currently, the ability to open windows and switch tabs is exposed via obsidian commands, but what file Teleprompter renders is fixed to what is pinned or what is currently rendered.

lumetrium commented 3 months ago

Amazing idea! I'm going to implement this.

anonhostpi commented 3 months ago

It's been a while since I've worked with the Obsidian API, but do you have an idea as to how you would open a specific file in a new window?

Your source code seems to just copy what's rendered--which is a clever method by the way. Not bashing on it, just saying it looks like it would need a change

lumetrium commented 3 months ago

I'm not sure yet. I'll look into it soon.

anonhostpi commented 3 months ago

I'm not sure yet. I'll look into it soon.

I might take a look myself as well later. I'll likely share my thoughts here rather than a PR, because I'm not as well-versed in vue and ts.

I'm more experienced with regular JS and ECMAScript, because it can be used in a wider range of engines like ClearScript.

anonhostpi commented 3 months ago

I'm thinking what you could potentially do is update this method to accept a file path parameter:

https://github.com/lumetrium/obsidian-teleprompter/blob/09e8f5ea6892ba3923b38bd70eb38022a622fe9a/src/plugin.ts#L64-L68

anonhostpi commented 3 months ago

the getActiveViewOfType() returns a View object.

The backlinks section from the API docs page would probably give us the best idea as to how to get a view object given a file path, even if the file isn't rendered.

anonhostpi commented 3 months ago

So it looks like the View constructor requires a WorkspaceLeaf:

Now playing around with this class tricky, because the class doesn't appear to be in the global scope of the Obsidian window. You can import it via plugin, but I don't want to write a plugin just to play with a class. So to mess with the class, I need to be able to pull its constructor which can be found on the prototype of any instance object. In this case we can use activeLeaf from the app.workspace api:

let constructor = app.workspace.activeLeaf.constructor

Neither the docs nor the type definitions for WorkspaceLeaf detail its constructor:

Checking the function in console reveals that it takes 2 arguments:

image

Dumping that output into a JS beautifier returns this:

function t(t, n) {
    var i = e.call(this, t.workspace, n) || this;
    i.type = "leaf", i.activeTime = 0, i.history = new LX(i), i.tabHeaderEl = null, i.tabHeaderInnerIconEl = null, i.tabHeaderInnerTitleEl = null, i.tabHeaderStatusContainerEl = null, i.tabHeaderStatusPinEl = null, i.tabHeaderStatusLinkEl = null, i.tabHeaderCloseEl = null, i.group = null, i.pinned = !1, i.width = 0, i.height = 0, i.resizeObserver = null, i.working = !1, i.app = t;
    var r = i.containerEl;
    r.addClass("workspace-leaf"), r.addEventListener("focusin", (function() {
        i.app.workspace.setActiveLeaf(i)
    })), r.addEventListener("mousedown", (function() {
        i.app.workspace.setActiveLeaf(i)
    }));
    var o = function() {
            if (i.parent instanceof AX) {
                var e = i.parent.children;
                i === e.last() ? i.parent.unlockTabWidths() : i.parent.lockTabWidths()
            }
            i.detach()
        },
        a = i.tabHeaderEl = createDiv("workspace-tab-header");
    a.draggable = !0, a.createDiv("workspace-tab-header-inner", (function(e) {
        i.tabHeaderInnerIconEl = e.createDiv("workspace-tab-header-inner-icon"), i.tabHeaderInnerTitleEl = e.createDiv("workspace-tab-header-inner-title"), i.tabHeaderStatusContainerEl = e.createDiv("workspace-tab-header-status-container"), i.tabHeaderCloseEl = e.createDiv("workspace-tab-header-inner-close-button", (function(e) {
            mx(e, "lucide-x"), Fx(e, Lf.interface.menu.close()), e.addEventListener("click", o)
        }))
    })), a.addEventListener("dragstart", (function(e) {
        i.workspace.onDragLeaf(e, i)
    })), a.addEventListener("contextmenu", (function(e) {
        return i.onOpenTabHeaderMenu(e, a)
    })), a.addEventListener("mousedown", (function(e) {
        1 === e.button && e.preventDefault()
    })), a.addEventListener("auxclick", (function(e) {
        1 === e.button && i.view instanceof sF && o()
    }));
    var s = Kc(i.onResize.bind(i), 20, !0);
    return (i.resizeObserver = new ResizeObserver((function(e) {
        for (var t = 0, n = e; t < n.length; t++) {
            if (n[t].target === r) {
                var o = r.offsetWidth,
                    a = r.offsetHeight;
                return void(o === i.width && a === i.height || (i.width = o, i.height = a, s()))
            }
        }
    }))).observe(r), i.view = i._empty = new lF(i), i.view.open(i.containerEl), i
}

If we further refactor this, we can immediately see what the first argument is (which is the app object):

function t(t, n) {
    var i = e.call(this, t.workspace, n) || this;
    i.type = "leaf"
    i.activeTime = 0
    i.history = new LX(i)
    i.tabHeaderEl = null
    i.tabHeaderInnerIconEl = null
    i.tabHeaderInnerTitleEl = null
    i.tabHeaderStatusContainerEl = null
    i.tabHeaderStatusPinEl = null
    i.tabHeaderStatusLinkEl = null
    i.tabHeaderCloseEl = null
    i.group = null
    i.pinned = !1
    i.width = 0
    i.height = 0
    i.resizeObserver = null
    i.working = !1

    i.app = t;

    ...
}

Now the second argument, n, is obfuscated, because it is passed to the mysterious function e.

I haven't found much information on where that function is defined. However, I suspect that if I use the new keyword, I don't need to worry about it. This seems to be the case, because if I instantiate the constructor in console, it doesn't error out:

let test = new app.workspace.activeLeaf.constructor(app, null)

What's also cool is that this WorkspaceLeaf seems to be invisible when invoked like this.

Next task is to see how I could possibly load a file into that leaf and get a View object from it.

anonhostpi commented 3 months ago

Sweet I got it.

updateContent( path ) {
    // is `path` a non-whitespace string?
    const valid_path = typeof path === 'string' && path.match(/^ *$/) == null

    let file = this.app.vault.getFileByPath( path )
    // in case the .md was accidentally omitted, retry
    if( !file )
        file = this.app.vault.getFileByPath( `${ path }.md` )

    const view = await (async function(){
        if( file ){
            const workspace_leaf = this.app.workspace.activeLeaf.constructor(app, null)
            await workspace_leaf.openFile( file, { active: false } )
            return workspace_leaf.view
        }
        return this.app.workspace.getActiveViewOfType(MarkdownView)
    })()

    const content = view?.getViewData() 

    if (content) useContentFeature().useStore().content = content 
}
anonhostpi commented 3 months ago

Cleaning up for performance (and caching the commanded path)...

get invisible_workspace_leaf() {
    let out;

    try {
        // could be replaced with new WorkspaceLeaf( this.app, null ), if imported
        out = new this.app.workspace.activeLeaf.constructor( this.app, null )
    } catch {}

    // cache it, so we don't blow up the memory usage
    if( out ){
        Object.defineProperty( this, "invisible_workspace_leaf", {
            value: out,
            writable: false
        })
    }

    return out
}

path_of_rendered_file: String;

// this needs to become an async method to handle openFile without Promisification
// you will likely need to change calls to this function that depended on synchronous execution
async updateContent( path ) {
    // validate and clean path string
    const valid_path = typeof path === 'string' && path.match(/^ *$/) == null
    path = valid_path ? path.trim() : this.path_of_rendered_file

    let file = this.app.vault.getFileByPath( path )
    // in case the .md was accidentally omitted, retry
    if( !file )
        file = this.app.vault.getFileByPath( `${ path }.md` )

    if( file ){
        this.path_of_rendered_file = path
        await this.invisible_workspace_leaf?.openFile( file, { active: false } )
    }

    const view = file ?
        this.invisible_workspace_leaf?.view :
        this.app.workspace.getActiveViewOfType(MarkdownView)

    const content = view?.getViewData() 

    if (content) useContentFeature().useStore().content = content 
}

EDIT: caching the file path

anonhostpi commented 3 months ago

I'm gonna leave setting up the Obsidian commands to you, since you have some extensive tooling for defining them.

Other than that, with the above proposed addition, Templater can command Teleprompter to render a specific file:

<%*
app.plugins.plugins.teleprompter.updateContent( "absolute_vault_path/to/my_notes" )
%>
anonhostpi commented 3 months ago

One other change is that the path needs to be cached as well.

Made an edit to the comment with my final proposed change:

Things I would do before implementing the proposed change in plugin.ts:

lumetrium commented 3 months ago

Thanks a lot! This is very helpful. I don't think it's necessary to create a hidden leaf though, since the contents of a file can be obtained using the cachedRead method of the Vault. This should be sufficient to render the file in the teleprompter window.

It could accept a few more parameters for convenience, such as opening the teleprompter in a specific spot (sidebar, new tab, or new window) and automatically pinning the note, so it doesn't update the content of the teleprompter when interacting with other notes. I'm thinking of something like this:

async function openWithParams(params: {  
  filepath?: string  
  placement?: 'sidebar' | 'tab' | 'window'  
  pin?: boolean  
}) {  
  const { filepath, placement, pin } = params  

  if (filepath) {  
    const file =  
      app.vault.getFileByPath(filepath) ??  
      app.vault.getFileByPath(`${filepath}.md`)  

    if (file) {  
      useContentFeature().useStore().content = await app.vault.cachedRead(file)
    } else {
      console.error(`File not found: "${filepath}"`)  
    }
  }

  // src/utils/activateViewInObsidian/index.ts
  activateViewInObsidian(VIEW_TYPE, app.workspace, placement)  

  if (pin !== undefined) usePinNoteFeature().useStore().value = pin
}

Then, we expose this method so that other plugins can call it.

Making a command out of it might be a bit more work since I'd need to build a UI to accept these parameters, but creating a few instances of FuzzySuggestModal sequentially should probably do it.

Another idea I have is to use the registerObsidianProtocolHandler as well. This could enable opening the teleprompter by following a link like "obsidian://teleprompter:open?placement=window&pin=1&file=Folder/MyFile.md".

anonhostpi commented 3 months ago

I would recommend having pin set to true by default, as an unpinned call will be overridden by updateContent() on the window where Templater or DataviewJS is being run.

anonhostpi commented 3 months ago

Another idea I have is to use the registerObsidianProtocolHandler as well. This could enable opening the teleprompter by following a link like "obsidian://teleprompter:open?placement=window&pin=1&file=Folder/MyFile.md".

I wonder if you could make that compatible with the Advanced URI plugin

anonhostpi commented 3 months ago

Making a command out of it might be a bit more work since I'd need to build a UI to accept these parameters, but creating a few instances of FuzzySuggestModal sequentially should probably do it.

I would recommend looking at how the Modal Form plugin source code generates their forms. Their forms would very likely have the capability you need.

anonhostpi commented 3 months ago

It could accept a few more parameters for convenience, such as opening the teleprompter in a specific spot (sidebar, new tab, or new window) and automatically pinning the note, so it doesn't update the content of the teleprompter when interacting with other notes. I'm thinking of something like this:

async function openWithParams(params: {  
  filepath?: string  
  placement?: 'sidebar' | 'tab' | 'window'  
  pin?: boolean  
}) {

...

}

Then, we expose this method so that other plugins can call it.

I would recommend having pin set to true by default, as an unpinned call will be overridden by updateContent() on the window where Templater or DataviewJS is being run.

Gonna fork this change and load it with Obsidian VARE Plugin. Let me know if you want me open a PR.

anonhostpi commented 3 months ago

Ok got it to work in my implementation:

  async openWithParams(params: {  
    filepath?: string  
    placement?: 'sidebar' | 'tab' | 'window'
    pin?: boolean
  }) {

    const filepath = params.filepath,
      placement = typeof params.placement === 'undefined' ? 'window' : params.placement,
      pin = typeof params.pin === 'undefined' ? true : params.pin

    if (filepath) {  
      const file =  
        app.vault.getFileByPath(filepath) ??  
        app.vault.getFileByPath(`${filepath}.md`)  

      if (file) {  
        useContentFeature().useStore().content = await app.vault.cachedRead(file)
      } else {
        console.error(`File not found: "${filepath}"`)  
      }
    }

    // src/utils/activateViewInObsidian/index.ts
    activateViewInObsidian(VIEW_TYPE, app.workspace, placement)  

    if (pin !== undefined) usePinNoteFeature().useStore().value = pin
  }
lumetrium commented 3 months ago

I'm preparing a release and will soon address your points. Didn't mean to close the issue yet.

lumetrium commented 3 months ago

I've just published 1.4.0 release, see https://github.com/lumetrium/obsidian-teleprompter/releases/tag/1.4.0

API

Instead of exposing methods directly, I'm providing the API through the api getter in the plugin's instance to allow registering API methods in different parts of the project.

As we discussed, I've added open and close API methods, both are registered in /src/features/open-app/integrations/useOpenAppInObsidian.ts.

open(params)

I've thrown a couple of additional parameters into the mix, specifically play and countdown. These should be convenient for the use case you described.

There's also a new content parameter that allows you to open any arbitrary text in the teleprompter window, even if it doesn't exist as a note. This parameter takes precedence when passed alongside the file parameter. It was easy to implement, so I figured why not, it seems like a very powerful option for different automation and integration purposes.

I would recommend having pin set to true by default, as an unpinned call will be overridden by updateContent() on the window where Templater or DataviewJS is being run.

I've decided not to make pin true by default because I don't want the open method to change any settings when called without parameters. That would be unintuitive, even though it might better suit your specific use case.

However, I've made it the default for six new commands, which I'll get to in a moment.

Example usage:

app.plugins.plugins.teleprompter.api.open({
  content: "Hello, world!",
  placement: "window",
  pin: true,
  play: true,
  countdown: 5
});

close()

Close the teleprompter window. Example usage: app.plugins.plugins.teleprompter.api.close()

I've described the API in more detail in the README.

Protocol handler

As discussed, I've added the obsidian://teleprompter:open protocol handler. It accepts the same parameters as the open(params) API method.

I wonder if you could make that compatible with the Advanced URI plugin

Advanced URI plugin registers its own handlers, and I found that implementing separate handlers for the teleprompter was more flexible. The Advanced URI plugin is already compatible with the teleprompter's commands.

I admit I'm not sure I fully understood what you meant by compatibility with it, so if you think there's potential for deeper integration with Advanced URI that would add value, please clarify the details.

Commands

I would recommend looking at how the Modal Form plugin source code generates their forms. Their forms would very likely have the capability you need.

Instead of building complex forms, I chose to register commands for opening a file or arbitrary text with various teleprompter window placements, all forcefully pinned:

Demos: open-file-in-new-window-and-pin open-text-in-new-window-and-pin

lumetrium commented 3 months ago

I've tested this with the Templater plugin, and it worked well. I'm open to feedback if you think it needs any improvements or changes in the implementation.

anonhostpi commented 3 months ago

Wow. That was seriously way more features than I had anticipated. Good work!

lumetrium commented 3 months ago

Thanks for sharing the idea and helping with the implementation! Closing now.