Closed anonhostpi closed 5 months ago
Amazing idea! I'm going to implement this.
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
I'm not sure yet. I'll look into it soon.
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.
I'm thinking what you could potentially do is update this method to accept a file path parameter:
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.
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:
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.
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
}
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
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" )
%>
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:
WorkspaceLeaf
class, and replace new this.app.workspace.activeLeaf.constructor( this.app, null )
with new WorkspaceLeaf( this.app, null )
path
instead of a javascript-based one
path_of_rendered_file
to something that isn't ridiculousupdateContent()
to work with async
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".
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.
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
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.
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.
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
}
const
declaration to handle default valuesupdateContent()
I'm preparing a release and will soon address your points. Didn't mean to close the issue yet.
I've just published 1.4.0 release, see https://github.com/lumetrium/obsidian-teleprompter/releases/tag/1.4.0
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.
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 the teleprompter window.
Example usage: app.plugins.plugins.teleprompter.api.close()
I've described the API in more detail in the README.
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.
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:
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.
Wow. That was seriously way more features than I had anticipated. Good work!
Thanks for sharing the idea and helping with the implementation! Closing now.
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):
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.