node build fixed
This commit is contained in:
87
seanime-2.9.10/internal/plugin/ui/DOCS.md
Normal file
87
seanime-2.9.10/internal/plugin/ui/DOCS.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Code
|
||||
|
||||
## Dev notes for the plugin UI
|
||||
|
||||
Avoid
|
||||
```go
|
||||
func (d *DOMManager) getElementChildren(elementID string) []*goja.Object {
|
||||
|
||||
// Listen for changes from the client
|
||||
eventListener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent)
|
||||
defer d.ctx.UnregisterEventListener(eventListener.ID)
|
||||
payload := ClientDOMElementUpdatedEventPayload{}
|
||||
|
||||
doneCh := make(chan []*goja.Object)
|
||||
|
||||
go func(eventListener *EventListener) {
|
||||
for event := range eventListener.Channel {
|
||||
if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) {
|
||||
if payload.Action == "getChildren" && payload.ElementId == elementID {
|
||||
if v, ok := payload.Result.([]interface{}); ok {
|
||||
arr := make([]*goja.Object, 0, len(v))
|
||||
for _, elem := range v {
|
||||
if elemData, ok := elem.(map[string]interface{}); ok {
|
||||
arr = append(arr, d.createDOMElementObject(elemData))
|
||||
}
|
||||
}
|
||||
doneCh <- arr
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}(eventListener)
|
||||
|
||||
d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{
|
||||
ElementId: elementID,
|
||||
Action: "getChildren",
|
||||
Params: map[string]interface{}{},
|
||||
})
|
||||
timeout := time.After(4 * time.Second)
|
||||
|
||||
select {
|
||||
case <-timeout:
|
||||
return []*goja.Object{}
|
||||
case res := <-doneCh:
|
||||
return res
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the above code
|
||||
```go
|
||||
arr = append(arr, d.createDOMElementObject(elemData))
|
||||
```
|
||||
Uses the VM so it should be scheduled.
|
||||
```go
|
||||
d.ctx.ScheduleAsync(func() error {
|
||||
arr := make([]*goja.Object, 0, len(v))
|
||||
for _, elem := range v {
|
||||
if elemData, ok := elem.(map[string]interface{}); ok {
|
||||
arr = append(arr, d.createDOMElementObject(elemData))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
However, getElementChildren() might be launched in a scheduled task.
|
||||
```ts
|
||||
ctx.registerEventHandler("test", () => {
|
||||
const el = ctx.dom.queryOne("#test")
|
||||
el.getChildren()
|
||||
})
|
||||
```
|
||||
|
||||
And since getElementChildren() is coded "synchronously" (without promises), it will block the task
|
||||
until the timeout and won't run its own task.
|
||||
You'll end up with something like this:
|
||||
|
||||
```txt
|
||||
> event received
|
||||
> timeout
|
||||
> processing scheduled task
|
||||
> sending task
|
||||
```
|
||||
|
||||
Conclusion: Prefer promises when possible. For synchronous functions, avoid scheduling tasks inside them.
|
||||
214
seanime-2.9.10/internal/plugin/ui/_scheduler.go
Normal file
214
seanime-2.9.10/internal/plugin/ui/_scheduler.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Job represents a task to be executed in the VM
|
||||
type Job struct {
|
||||
fn func() error
|
||||
resultCh chan error
|
||||
async bool // Flag to indicate if the job is async (doesn't need to wait for result)
|
||||
}
|
||||
|
||||
// Scheduler handles all VM operations added concurrently in a single goroutine
|
||||
// Any goroutine that needs to execute a VM operation must schedule it because the UI VM isn't thread safe
|
||||
type Scheduler struct {
|
||||
jobQueue chan *Job
|
||||
ctx context.Context
|
||||
context *Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
// Track the currently executing job to detect nested scheduling
|
||||
currentJob *Job
|
||||
currentJobLock sync.Mutex
|
||||
}
|
||||
|
||||
func NewScheduler(uiCtx *Context) *Scheduler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &Scheduler{
|
||||
jobQueue: make(chan *Job, 9999),
|
||||
ctx: ctx,
|
||||
context: uiCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
s.start()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Scheduler) start() {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case job := <-s.jobQueue:
|
||||
// Set the current job before execution
|
||||
s.currentJobLock.Lock()
|
||||
s.currentJob = job
|
||||
s.currentJobLock.Unlock()
|
||||
|
||||
err := job.fn()
|
||||
|
||||
// Clear the current job after execution
|
||||
s.currentJobLock.Lock()
|
||||
s.currentJob = nil
|
||||
s.currentJobLock.Unlock()
|
||||
|
||||
// Only send result if the job is not async
|
||||
if !job.async {
|
||||
job.resultCh <- err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
s.context.HandleException(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// Schedule adds a job to the queue and waits for its completion
|
||||
func (s *Scheduler) Schedule(fn func() error) error {
|
||||
resultCh := make(chan error, 1)
|
||||
job := &Job{
|
||||
fn: func() error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultCh <- fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
},
|
||||
resultCh: resultCh,
|
||||
async: false,
|
||||
}
|
||||
|
||||
// Check if we're already in a job execution context
|
||||
s.currentJobLock.Lock()
|
||||
isNestedCall := s.currentJob != nil && !s.currentJob.async
|
||||
s.currentJobLock.Unlock()
|
||||
|
||||
// If this is a nested call from a synchronous job, we need to be careful
|
||||
// We can't execute directly because the VM isn't thread-safe
|
||||
// Instead, we'll queue it and use a separate goroutine to wait for the result
|
||||
if isNestedCall {
|
||||
// Queue the job
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return fmt.Errorf("scheduler stopped")
|
||||
case s.jobQueue <- job:
|
||||
// Create a separate goroutine to wait for the result
|
||||
// This prevents deadlock while still ensuring the job runs in the scheduler
|
||||
resultCh2 := make(chan error, 1)
|
||||
go func() {
|
||||
resultCh2 <- <-resultCh
|
||||
}()
|
||||
return <-resultCh2
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, queue the job normally
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return fmt.Errorf("scheduler stopped")
|
||||
case s.jobQueue <- job:
|
||||
return <-resultCh
|
||||
}
|
||||
}
|
||||
|
||||
// ScheduleAsync adds a job to the queue without waiting for completion
|
||||
// This is useful for fire-and-forget operations or when a job needs to schedule another job
|
||||
func (s *Scheduler) ScheduleAsync(fn func() error) {
|
||||
job := &Job{
|
||||
fn: func() error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
s.context.HandleException(fmt.Errorf("panic in async job: %v", r))
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
},
|
||||
resultCh: nil, // No result channel needed
|
||||
async: true,
|
||||
}
|
||||
|
||||
// Queue the job without blocking
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
// Scheduler is stopped, just ignore
|
||||
return
|
||||
case s.jobQueue <- job:
|
||||
// Job queued successfully
|
||||
return
|
||||
default:
|
||||
// Queue is full, log an error
|
||||
s.context.HandleException(fmt.Errorf("async job queue is full"))
|
||||
}
|
||||
}
|
||||
|
||||
// ScheduleWithTimeout schedules a job with a timeout
|
||||
func (s *Scheduler) ScheduleWithTimeout(fn func() error, timeout time.Duration) error {
|
||||
resultCh := make(chan error, 1)
|
||||
job := &Job{
|
||||
fn: func() error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultCh <- fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
},
|
||||
resultCh: resultCh,
|
||||
async: false,
|
||||
}
|
||||
|
||||
// Check if we're already in a job execution context
|
||||
s.currentJobLock.Lock()
|
||||
isNestedCall := s.currentJob != nil && !s.currentJob.async
|
||||
s.currentJobLock.Unlock()
|
||||
|
||||
// If this is a nested call from a synchronous job, handle it specially
|
||||
if isNestedCall {
|
||||
// Queue the job
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return fmt.Errorf("scheduler stopped")
|
||||
case s.jobQueue <- job:
|
||||
// Create a separate goroutine to wait for the result with timeout
|
||||
resultCh2 := make(chan error, 1)
|
||||
go func() {
|
||||
select {
|
||||
case err := <-resultCh:
|
||||
resultCh2 <- err
|
||||
case <-time.After(timeout):
|
||||
resultCh2 <- fmt.Errorf("operation timed out")
|
||||
}
|
||||
}()
|
||||
return <-resultCh2
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return fmt.Errorf("scheduler stopped")
|
||||
case s.jobQueue <- job:
|
||||
select {
|
||||
case err := <-resultCh:
|
||||
return err
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("operation timed out")
|
||||
}
|
||||
}
|
||||
}
|
||||
683
seanime-2.9.10/internal/plugin/ui/action.go
Normal file
683
seanime-2.9.10/internal/plugin/ui/action.go
Normal file
@@ -0,0 +1,683 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"seanime/internal/util/result"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxActionsPerType = 3 // A plugin can only at most X actions of a certain type
|
||||
)
|
||||
|
||||
// ActionManager
|
||||
//
|
||||
// Actions are buttons, dropdown items, and context menu items that are displayed in certain places in the UI.
|
||||
// They are defined in the plugin code and are used to trigger events.
|
||||
//
|
||||
// The ActionManager is responsible for registering, rendering, and handling events for actions.
|
||||
type ActionManager struct {
|
||||
ctx *Context
|
||||
|
||||
animePageButtons *result.Map[string, *AnimePageButton]
|
||||
animePageDropdownItems *result.Map[string, *AnimePageDropdownMenuItem]
|
||||
animeLibraryDropdownItems *result.Map[string, *AnimeLibraryDropdownMenuItem]
|
||||
mangaPageButtons *result.Map[string, *MangaPageButton]
|
||||
mediaCardContextMenuItems *result.Map[string, *MediaCardContextMenuItem]
|
||||
episodeCardContextMenuItems *result.Map[string, *EpisodeCardContextMenuItem]
|
||||
episodeGridItemMenuItems *result.Map[string, *EpisodeGridItemMenuItem]
|
||||
}
|
||||
|
||||
type BaseActionProps struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Style map[string]string `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// Base action struct that all action types embed
|
||||
type BaseAction struct {
|
||||
BaseActionProps
|
||||
}
|
||||
|
||||
// GetProps returns the base action properties
|
||||
func (a *BaseAction) GetProps() BaseActionProps {
|
||||
return a.BaseActionProps
|
||||
}
|
||||
|
||||
// SetProps sets the base action properties
|
||||
func (a *BaseAction) SetProps(props BaseActionProps) {
|
||||
a.BaseActionProps = props
|
||||
}
|
||||
|
||||
// UnmountAll unmounts all actions
|
||||
// It should be called when the plugin is unloaded
|
||||
func (a *ActionManager) UnmountAll() {
|
||||
|
||||
if a.animePageButtons.ClearN() > 0 {
|
||||
a.renderAnimePageButtons()
|
||||
}
|
||||
if a.animePageDropdownItems.ClearN() > 0 {
|
||||
a.renderAnimePageDropdownItems()
|
||||
}
|
||||
if a.animeLibraryDropdownItems.ClearN() > 0 {
|
||||
a.renderAnimeLibraryDropdownItems()
|
||||
}
|
||||
if a.mangaPageButtons.ClearN() > 0 {
|
||||
a.renderMangaPageButtons()
|
||||
}
|
||||
if a.mediaCardContextMenuItems.ClearN() > 0 {
|
||||
a.renderMediaCardContextMenuItems()
|
||||
}
|
||||
if a.episodeCardContextMenuItems.ClearN() > 0 {
|
||||
a.renderEpisodeCardContextMenuItems()
|
||||
}
|
||||
if a.episodeGridItemMenuItems.ClearN() > 0 {
|
||||
a.renderEpisodeGridItemMenuItems()
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type AnimePageButton struct {
|
||||
BaseAction
|
||||
Intent string `json:"intent,omitempty"`
|
||||
}
|
||||
|
||||
func (a *AnimePageButton) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
|
||||
_ = obj.Set("setIntent", func(intent string) {
|
||||
a.Intent = intent
|
||||
})
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type EpisodeCardContextMenuItem struct {
|
||||
BaseAction
|
||||
}
|
||||
|
||||
func (a *EpisodeCardContextMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type EpisodeGridItemMenuItem struct {
|
||||
BaseAction
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (a *EpisodeGridItemMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MangaPageButton struct {
|
||||
BaseAction
|
||||
Intent string `json:"intent,omitempty"`
|
||||
}
|
||||
|
||||
func (a *MangaPageButton) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
|
||||
_ = obj.Set("setIntent", func(intent string) {
|
||||
a.Intent = intent
|
||||
})
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type AnimePageDropdownMenuItem struct {
|
||||
BaseAction
|
||||
}
|
||||
|
||||
func (a *AnimePageDropdownMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type AnimeLibraryDropdownMenuItem struct {
|
||||
BaseAction
|
||||
}
|
||||
|
||||
func (a *AnimeLibraryDropdownMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MediaCardContextMenuItemFor string
|
||||
|
||||
const (
|
||||
MediaCardContextMenuItemForAnime MediaCardContextMenuItemFor = "anime"
|
||||
MediaCardContextMenuItemForManga MediaCardContextMenuItemFor = "manga"
|
||||
MediaCardContextMenuItemForBoth MediaCardContextMenuItemFor = "both"
|
||||
)
|
||||
|
||||
type MediaCardContextMenuItem struct {
|
||||
BaseAction
|
||||
For MediaCardContextMenuItemFor `json:"for"` // anime, manga, both
|
||||
}
|
||||
|
||||
func (a *MediaCardContextMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
|
||||
_ = obj.Set("setFor", func(_for MediaCardContextMenuItemFor) {
|
||||
a.For = _for
|
||||
})
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// ///////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func NewActionManager(ctx *Context) *ActionManager {
|
||||
return &ActionManager{
|
||||
ctx: ctx,
|
||||
|
||||
animePageButtons: result.NewResultMap[string, *AnimePageButton](),
|
||||
animeLibraryDropdownItems: result.NewResultMap[string, *AnimeLibraryDropdownMenuItem](),
|
||||
animePageDropdownItems: result.NewResultMap[string, *AnimePageDropdownMenuItem](),
|
||||
mangaPageButtons: result.NewResultMap[string, *MangaPageButton](),
|
||||
mediaCardContextMenuItems: result.NewResultMap[string, *MediaCardContextMenuItem](),
|
||||
episodeCardContextMenuItems: result.NewResultMap[string, *EpisodeCardContextMenuItem](),
|
||||
episodeGridItemMenuItems: result.NewResultMap[string, *EpisodeGridItemMenuItem](),
|
||||
}
|
||||
}
|
||||
|
||||
// renderAnimePageButtons is called when the client requests the buttons to display on the anime page.
|
||||
func (a *ActionManager) renderAnimePageButtons() {
|
||||
buttons := make([]*AnimePageButton, 0)
|
||||
a.animePageButtons.Range(func(key string, value *AnimePageButton) bool {
|
||||
buttons = append(buttons, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderAnimePageButtonsEvent, ServerActionRenderAnimePageButtonsEventPayload{
|
||||
Buttons: buttons,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderAnimePageDropdownItems() {
|
||||
items := make([]*AnimePageDropdownMenuItem, 0)
|
||||
a.animePageDropdownItems.Range(func(key string, value *AnimePageDropdownMenuItem) bool {
|
||||
items = append(items, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderAnimePageDropdownItemsEvent, ServerActionRenderAnimePageDropdownItemsEventPayload{
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderAnimeLibraryDropdownItems() {
|
||||
items := make([]*AnimeLibraryDropdownMenuItem, 0)
|
||||
a.animeLibraryDropdownItems.Range(func(key string, value *AnimeLibraryDropdownMenuItem) bool {
|
||||
items = append(items, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderAnimeLibraryDropdownItemsEvent, ServerActionRenderAnimeLibraryDropdownItemsEventPayload{
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderMangaPageButtons() {
|
||||
buttons := make([]*MangaPageButton, 0)
|
||||
a.mangaPageButtons.Range(func(key string, value *MangaPageButton) bool {
|
||||
buttons = append(buttons, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderMangaPageButtonsEvent, ServerActionRenderMangaPageButtonsEventPayload{
|
||||
Buttons: buttons,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderMediaCardContextMenuItems() {
|
||||
items := make([]*MediaCardContextMenuItem, 0)
|
||||
a.mediaCardContextMenuItems.Range(func(key string, value *MediaCardContextMenuItem) bool {
|
||||
items = append(items, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderMediaCardContextMenuItemsEvent, ServerActionRenderMediaCardContextMenuItemsEventPayload{
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderEpisodeCardContextMenuItems() {
|
||||
items := make([]*EpisodeCardContextMenuItem, 0)
|
||||
a.episodeCardContextMenuItems.Range(func(key string, value *EpisodeCardContextMenuItem) bool {
|
||||
items = append(items, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderEpisodeCardContextMenuItemsEvent, ServerActionRenderEpisodeCardContextMenuItemsEventPayload{
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderEpisodeGridItemMenuItems() {
|
||||
items := make([]*EpisodeGridItemMenuItem, 0)
|
||||
a.episodeGridItemMenuItems.Range(func(key string, value *EpisodeGridItemMenuItem) bool {
|
||||
items = append(items, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderEpisodeGridItemMenuItemsEvent, ServerActionRenderEpisodeGridItemMenuItemsEventPayload{
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
// bind binds 'action' to the ctx object
|
||||
//
|
||||
// Example:
|
||||
// ctx.action.newAnimePageButton(...)
|
||||
func (a *ActionManager) bind(ctxObj *goja.Object) {
|
||||
actionObj := a.ctx.vm.NewObject()
|
||||
_ = actionObj.Set("newAnimePageButton", a.jsNewAnimePageButton)
|
||||
_ = actionObj.Set("newAnimePageDropdownItem", a.jsNewAnimePageDropdownItem)
|
||||
_ = actionObj.Set("newAnimeLibraryDropdownItem", a.jsNewAnimeLibraryDropdownItem)
|
||||
_ = actionObj.Set("newMediaCardContextMenuItem", a.jsNewMediaCardContextMenuItem)
|
||||
_ = actionObj.Set("newMangaPageButton", a.jsNewMangaPageButton)
|
||||
_ = actionObj.Set("newEpisodeCardContextMenuItem", a.jsNewEpisodeCardContextMenuItem)
|
||||
_ = actionObj.Set("newEpisodeGridItemMenuItem", a.jsNewEpisodeGridItemMenuItem)
|
||||
_ = ctxObj.Set("action", actionObj)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Actions
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewEpisodeCardContextMenuItem
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newEpisodeCardContextMenuItem({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewEpisodeCardContextMenuItem(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &EpisodeCardContextMenuItem{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewEpisodeGridItemMenuItem
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newEpisodeGridItemContextMenuItem({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// type: "library",
|
||||
// })
|
||||
func (a *ActionManager) jsNewEpisodeGridItemMenuItem(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &EpisodeGridItemMenuItem{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewAnimePageButton
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newAnimePageButton({
|
||||
// label: "Download",
|
||||
// intent: "primary",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewAnimePageButton(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &AnimePageButton{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewAnimePageDropdownItem
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newAnimePageDropdownItem({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewAnimePageDropdownItem(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &AnimePageDropdownMenuItem{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewAnimeLibraryDropdownItem
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newAnimeLibraryDropdownItem({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewAnimeLibraryDropdownItem(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &AnimeLibraryDropdownMenuItem{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewMediaCardContextMenuItem
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newMediaCardContextMenuItem({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewMediaCardContextMenuItem(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &MediaCardContextMenuItem{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewMangaPageButton
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newMangaPageButton({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewMangaPageButton(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &MangaPageButton{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
// ///////////////////////////////////////////////////////////////////////////////////
|
||||
// Shared
|
||||
// ///////////////////////////////////////////////////////////////////////////////////
|
||||
// bindSharedToObject binds shared methods to action objects
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newAnimePageButton(...)
|
||||
// downloadButton.mount()
|
||||
// downloadButton.unmount()
|
||||
// downloadButton.setLabel("Downloading...")
|
||||
func (a *ActionManager) bindSharedToObject(obj *goja.Object, action interface{}) {
|
||||
var id string
|
||||
var props BaseActionProps
|
||||
var mapToUse interface{}
|
||||
|
||||
switch act := action.(type) {
|
||||
case *AnimePageButton:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.animePageButtons
|
||||
case *MangaPageButton:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.mangaPageButtons
|
||||
case *AnimePageDropdownMenuItem:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.animePageDropdownItems
|
||||
case *AnimeLibraryDropdownMenuItem:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.animeLibraryDropdownItems
|
||||
case *MediaCardContextMenuItem:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.mediaCardContextMenuItems
|
||||
case *EpisodeCardContextMenuItem:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.episodeCardContextMenuItems
|
||||
case *EpisodeGridItemMenuItem:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.episodeGridItemMenuItems
|
||||
}
|
||||
|
||||
_ = obj.Set("mount", func() {
|
||||
switch m := mapToUse.(type) {
|
||||
case *result.Map[string, *AnimePageButton]:
|
||||
if btn, ok := action.(*AnimePageButton); ok {
|
||||
m.Set(id, btn)
|
||||
a.renderAnimePageButtons()
|
||||
}
|
||||
case *result.Map[string, *MangaPageButton]:
|
||||
if btn, ok := action.(*MangaPageButton); ok {
|
||||
m.Set(id, btn)
|
||||
a.renderMangaPageButtons()
|
||||
}
|
||||
case *result.Map[string, *AnimePageDropdownMenuItem]:
|
||||
if item, ok := action.(*AnimePageDropdownMenuItem); ok {
|
||||
m.Set(id, item)
|
||||
a.renderAnimePageDropdownItems()
|
||||
}
|
||||
case *result.Map[string, *AnimeLibraryDropdownMenuItem]:
|
||||
if item, ok := action.(*AnimeLibraryDropdownMenuItem); ok {
|
||||
m.Set(id, item)
|
||||
a.renderAnimeLibraryDropdownItems()
|
||||
}
|
||||
case *result.Map[string, *MediaCardContextMenuItem]:
|
||||
if item, ok := action.(*MediaCardContextMenuItem); ok {
|
||||
if item.For == "" {
|
||||
item.For = MediaCardContextMenuItemForBoth
|
||||
}
|
||||
m.Set(id, item)
|
||||
a.renderMediaCardContextMenuItems()
|
||||
}
|
||||
case *result.Map[string, *EpisodeCardContextMenuItem]:
|
||||
if item, ok := action.(*EpisodeCardContextMenuItem); ok {
|
||||
m.Set(id, item)
|
||||
a.renderEpisodeCardContextMenuItems()
|
||||
}
|
||||
case *result.Map[string, *EpisodeGridItemMenuItem]:
|
||||
if item, ok := action.(*EpisodeGridItemMenuItem); ok {
|
||||
m.Set(id, item)
|
||||
a.renderEpisodeGridItemMenuItems()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
_ = obj.Set("unmount", func() {
|
||||
switch m := mapToUse.(type) {
|
||||
case *result.Map[string, *AnimePageButton]:
|
||||
m.Delete(id)
|
||||
a.renderAnimePageButtons()
|
||||
case *result.Map[string, *MangaPageButton]:
|
||||
m.Delete(id)
|
||||
a.renderMangaPageButtons()
|
||||
case *result.Map[string, *AnimePageDropdownMenuItem]:
|
||||
m.Delete(id)
|
||||
a.renderAnimePageDropdownItems()
|
||||
case *result.Map[string, *AnimeLibraryDropdownMenuItem]:
|
||||
m.Delete(id)
|
||||
a.renderAnimeLibraryDropdownItems()
|
||||
case *result.Map[string, *MediaCardContextMenuItem]:
|
||||
m.Delete(id)
|
||||
a.renderMediaCardContextMenuItems()
|
||||
case *result.Map[string, *EpisodeCardContextMenuItem]:
|
||||
m.Delete(id)
|
||||
a.renderEpisodeCardContextMenuItems()
|
||||
case *result.Map[string, *EpisodeGridItemMenuItem]:
|
||||
m.Delete(id)
|
||||
a.renderEpisodeGridItemMenuItems()
|
||||
}
|
||||
})
|
||||
|
||||
_ = obj.Set("setLabel", func(label string) {
|
||||
newProps := props
|
||||
newProps.Label = label
|
||||
|
||||
switch act := action.(type) {
|
||||
case *AnimePageButton:
|
||||
act.SetProps(newProps)
|
||||
case *MangaPageButton:
|
||||
act.SetProps(newProps)
|
||||
case *AnimePageDropdownMenuItem:
|
||||
act.SetProps(newProps)
|
||||
case *AnimeLibraryDropdownMenuItem:
|
||||
act.SetProps(newProps)
|
||||
case *MediaCardContextMenuItem:
|
||||
act.SetProps(newProps)
|
||||
case *EpisodeCardContextMenuItem:
|
||||
act.SetProps(newProps)
|
||||
case *EpisodeGridItemMenuItem:
|
||||
act.SetProps(newProps)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
_ = obj.Set("setStyle", func(style map[string]string) {
|
||||
newProps := props
|
||||
newProps.Style = style
|
||||
|
||||
switch act := action.(type) {
|
||||
case *AnimePageButton:
|
||||
act.SetProps(newProps)
|
||||
a.renderAnimePageButtons()
|
||||
case *MangaPageButton:
|
||||
act.SetProps(newProps)
|
||||
a.renderMangaPageButtons()
|
||||
case *AnimePageDropdownMenuItem:
|
||||
act.SetProps(newProps)
|
||||
a.renderAnimePageDropdownItems()
|
||||
case *AnimeLibraryDropdownMenuItem:
|
||||
act.SetProps(newProps)
|
||||
a.renderAnimeLibraryDropdownItems()
|
||||
case *MediaCardContextMenuItem:
|
||||
act.SetProps(newProps)
|
||||
a.renderMediaCardContextMenuItems()
|
||||
case *EpisodeCardContextMenuItem:
|
||||
act.SetProps(newProps)
|
||||
a.renderEpisodeCardContextMenuItems()
|
||||
case *EpisodeGridItemMenuItem:
|
||||
act.SetProps(newProps)
|
||||
}
|
||||
})
|
||||
|
||||
_ = obj.Set("onClick", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
a.ctx.handleTypeError("onClick requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
a.ctx.handleTypeError("onClick requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := a.ctx.RegisterEventListener(ClientActionClickedEvent)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
payload := ClientActionClickedEventPayload{}
|
||||
if event.ParsePayloadAs(ClientActionClickedEvent, &payload) && payload.ActionID == id {
|
||||
a.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), a.ctx.vm.ToValue(payload.Event))
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
})
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (a *ActionManager) unmarshalProps(call goja.FunctionCall, ret interface{}) {
|
||||
if len(call.Arguments) < 1 {
|
||||
a.ctx.handleException(fmt.Errorf("expected 1 argument"))
|
||||
}
|
||||
|
||||
props := call.Arguments[0].Export()
|
||||
if props == nil {
|
||||
a.ctx.handleException(fmt.Errorf("expected props object"))
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(props)
|
||||
if err != nil {
|
||||
a.ctx.handleException(err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(marshaled, ret)
|
||||
if err != nil {
|
||||
a.ctx.handleException(err)
|
||||
}
|
||||
}
|
||||
380
seanime-2.9.10/internal/plugin/ui/command.go
Normal file
380
seanime-2.9.10/internal/plugin/ui/command.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
goja_util "seanime/internal/util/goja"
|
||||
"seanime/internal/util/result"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CommandPaletteManager is a manager for the command palette.
|
||||
// Unlike the Tray, command palette items are not reactive to state changes.
|
||||
// They are only rendered when the setItems function is called or the refresh function is called.
|
||||
type CommandPaletteManager struct {
|
||||
ctx *Context
|
||||
updateMutex sync.Mutex
|
||||
lastUpdated time.Time
|
||||
componentManager *ComponentManager
|
||||
|
||||
placeholder string
|
||||
keyboardShortcut string
|
||||
|
||||
// registered is true if the command palette has been registered
|
||||
registered bool
|
||||
|
||||
items *result.Map[string, *commandItem]
|
||||
renderedItems []*CommandItemJSON // Store rendered items when setItems is called
|
||||
}
|
||||
|
||||
type (
|
||||
commandItem struct {
|
||||
index int
|
||||
id string
|
||||
label string
|
||||
value string
|
||||
filterType string // "includes" or "startsWith" or ""
|
||||
heading string
|
||||
renderFunc func(goja.FunctionCall) goja.Value
|
||||
onSelectFunc func(goja.FunctionCall) goja.Value
|
||||
}
|
||||
|
||||
// CommandItemJSON is the JSON representation of a command item.
|
||||
// It is used to send the command item to the client.
|
||||
CommandItemJSON struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
FilterType string `json:"filterType"`
|
||||
Heading string `json:"heading"`
|
||||
Components interface{} `json:"components"`
|
||||
}
|
||||
)
|
||||
|
||||
func NewCommandPaletteManager(ctx *Context) *CommandPaletteManager {
|
||||
return &CommandPaletteManager{
|
||||
ctx: ctx,
|
||||
componentManager: &ComponentManager{ctx: ctx},
|
||||
items: result.NewResultMap[string, *commandItem](),
|
||||
renderedItems: make([]*CommandItemJSON, 0),
|
||||
}
|
||||
}
|
||||
|
||||
type NewCommandPaletteOptions struct {
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
KeyboardShortcut string `json:"keyboardShortcut,omitempty"`
|
||||
}
|
||||
|
||||
// sendInfoToClient sends the command palette info to the client after it's been requested.
|
||||
func (c *CommandPaletteManager) sendInfoToClient() {
|
||||
if c.registered {
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteInfoEvent, ServerCommandPaletteInfoEventPayload{
|
||||
Placeholder: c.placeholder,
|
||||
KeyboardShortcut: c.keyboardShortcut,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CommandPaletteManager) jsNewCommandPalette(options NewCommandPaletteOptions) goja.Value {
|
||||
c.registered = true
|
||||
c.keyboardShortcut = options.KeyboardShortcut
|
||||
c.placeholder = options.Placeholder
|
||||
|
||||
cmdObj := c.ctx.vm.NewObject()
|
||||
|
||||
_ = cmdObj.Set("setItems", func(items []interface{}) {
|
||||
c.items.Clear()
|
||||
|
||||
for idx, item := range items {
|
||||
itemMap := item.(map[string]interface{})
|
||||
id := uuid.New().String()
|
||||
label, _ := itemMap["label"].(string)
|
||||
value, ok := itemMap["value"].(string)
|
||||
if !ok {
|
||||
c.ctx.handleTypeError("value must be a string")
|
||||
return
|
||||
}
|
||||
filterType, _ := itemMap["filterType"].(string)
|
||||
if filterType != "includes" && filterType != "startsWith" && filterType != "" {
|
||||
c.ctx.handleTypeError("filterType must be 'includes', 'startsWith'")
|
||||
return
|
||||
}
|
||||
heading, _ := itemMap["heading"].(string)
|
||||
renderFunc, ok := itemMap["render"].(func(goja.FunctionCall) goja.Value)
|
||||
if len(label) == 0 && !ok {
|
||||
c.ctx.handleTypeError("label or render function must be provided")
|
||||
return
|
||||
}
|
||||
onSelectFunc, ok := itemMap["onSelect"].(func(goja.FunctionCall) goja.Value)
|
||||
if !ok {
|
||||
c.ctx.handleTypeError("onSelect must be a function")
|
||||
return
|
||||
}
|
||||
|
||||
c.items.Set(id, &commandItem{
|
||||
index: idx,
|
||||
id: id,
|
||||
label: label,
|
||||
value: value,
|
||||
filterType: filterType,
|
||||
heading: heading,
|
||||
renderFunc: renderFunc,
|
||||
onSelectFunc: onSelectFunc,
|
||||
})
|
||||
}
|
||||
|
||||
// Convert the items to JSON
|
||||
itemsJSON := make([]*CommandItemJSON, 0)
|
||||
c.items.Range(func(key string, value *commandItem) bool {
|
||||
itemsJSON = append(itemsJSON, value.ToJSON(c.ctx, c.componentManager, c.ctx.scheduler))
|
||||
return true
|
||||
})
|
||||
// Store the converted items
|
||||
c.renderedItems = itemsJSON
|
||||
|
||||
c.renderCommandPaletteScheduled()
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("refresh", func() {
|
||||
// Convert the items to JSON
|
||||
itemsJSON := make([]*CommandItemJSON, 0)
|
||||
c.items.Range(func(key string, value *commandItem) bool {
|
||||
itemsJSON = append(itemsJSON, value.ToJSON(c.ctx, c.componentManager, c.ctx.scheduler))
|
||||
return true
|
||||
})
|
||||
|
||||
c.renderedItems = itemsJSON
|
||||
|
||||
c.renderCommandPaletteScheduled()
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("setPlaceholder", func(placeholder string) {
|
||||
c.placeholder = placeholder
|
||||
c.renderCommandPaletteScheduled()
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("open", func() {
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteOpenEvent, ServerCommandPaletteOpenEventPayload{})
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("close", func() {
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteCloseEvent, ServerCommandPaletteCloseEventPayload{})
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("setInput", func(input string) {
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteSetInputEvent, ServerCommandPaletteSetInputEventPayload{
|
||||
Value: input,
|
||||
})
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("getInput", func() string {
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteGetInputEvent, ServerCommandPaletteGetInputEventPayload{})
|
||||
|
||||
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteInputEvent)
|
||||
defer c.ctx.UnregisterEventListener(eventListener.ID)
|
||||
|
||||
timeout := time.After(1500 * time.Millisecond)
|
||||
input := make(chan string)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
payload := ClientCommandPaletteInputEventPayload{}
|
||||
if event.ParsePayloadAs(ClientCommandPaletteInputEvent, &payload) {
|
||||
input <- payload.Value
|
||||
}
|
||||
})
|
||||
|
||||
// go func() {
|
||||
// for event := range eventListener.Channel {
|
||||
// if event.ParsePayloadAs(ClientCommandPaletteInputEvent, &payload) {
|
||||
// input <- payload.Value
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
|
||||
select {
|
||||
case <-timeout:
|
||||
return ""
|
||||
case input := <-input:
|
||||
return input
|
||||
}
|
||||
})
|
||||
|
||||
// jsOnOpen
|
||||
//
|
||||
// Example:
|
||||
// commandPalette.onOpen(() => {
|
||||
// console.log("command palette opened by the user")
|
||||
// })
|
||||
_ = cmdObj.Set("onOpen", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
c.ctx.handleTypeError("onOpen requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
c.ctx.handleTypeError("onOpen requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteOpenedEvent)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
payload := ClientCommandPaletteOpenedEventPayload{}
|
||||
if event.ParsePayloadAs(ClientCommandPaletteOpenedEvent, &payload) {
|
||||
c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// go func() {
|
||||
// for event := range eventListener.Channel {
|
||||
// if event.ParsePayloadAs(ClientCommandPaletteOpenedEvent, &payload) {
|
||||
// c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
// _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
// if err != nil {
|
||||
// c.ctx.logger.Error().Err(err).Msg("plugin: Error running command palette open callback")
|
||||
// }
|
||||
// return err
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// jsOnClose
|
||||
//
|
||||
// Example:
|
||||
// commandPalette.onClose(() => {
|
||||
// console.log("command palette closed by the user")
|
||||
// })
|
||||
_ = cmdObj.Set("onClose", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
c.ctx.handleTypeError("onClose requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
c.ctx.handleTypeError("onClose requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteClosedEvent)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
payload := ClientCommandPaletteClosedEventPayload{}
|
||||
if event.ParsePayloadAs(ClientCommandPaletteClosedEvent, &payload) {
|
||||
c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// go func() {
|
||||
// for event := range eventListener.Channel {
|
||||
// if event.ParsePayloadAs(ClientCommandPaletteClosedEvent, &payload) {
|
||||
// c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
// _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
// if err != nil {
|
||||
// c.ctx.logger.Error().Err(err).Msg("plugin: Error running command palette close callback")
|
||||
// }
|
||||
// return err
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteItemSelectedEvent)
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
payload := ClientCommandPaletteItemSelectedEventPayload{}
|
||||
if event.ParsePayloadAs(ClientCommandPaletteItemSelectedEvent, &payload) {
|
||||
c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
item, found := c.items.Get(payload.ItemID)
|
||||
if found {
|
||||
_ = item.onSelectFunc(goja.FunctionCall{})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
})
|
||||
// go func() {
|
||||
// eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteItemSelectedEvent)
|
||||
// payload := ClientCommandPaletteItemSelectedEventPayload{}
|
||||
|
||||
// for event := range eventListener.Channel {
|
||||
// if event.ParsePayloadAs(ClientCommandPaletteItemSelectedEvent, &payload) {
|
||||
// item, found := c.items.Get(payload.ItemID)
|
||||
// if found {
|
||||
// c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
// _ = item.onSelectFunc(goja.FunctionCall{})
|
||||
// return nil
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
|
||||
// Register components
|
||||
_ = cmdObj.Set("div", c.componentManager.jsDiv)
|
||||
_ = cmdObj.Set("flex", c.componentManager.jsFlex)
|
||||
_ = cmdObj.Set("stack", c.componentManager.jsStack)
|
||||
_ = cmdObj.Set("text", c.componentManager.jsText)
|
||||
_ = cmdObj.Set("button", c.componentManager.jsButton)
|
||||
_ = cmdObj.Set("anchor", c.componentManager.jsAnchor)
|
||||
|
||||
return cmdObj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (c *commandItem) ToJSON(ctx *Context, componentManager *ComponentManager, scheduler *goja_util.Scheduler) *CommandItemJSON {
|
||||
|
||||
var components interface{}
|
||||
if c.renderFunc != nil {
|
||||
var err error
|
||||
components, err = componentManager.renderComponents(c.renderFunc)
|
||||
if err != nil {
|
||||
ctx.logger.Error().Err(err).Msg("plugin: Failed to render command palette item")
|
||||
ctx.handleException(err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the last rendered components, we don't care about diffing
|
||||
componentManager.lastRenderedComponents = nil
|
||||
|
||||
return &CommandItemJSON{
|
||||
Index: c.index,
|
||||
ID: c.id,
|
||||
Label: c.label,
|
||||
Value: c.value,
|
||||
FilterType: c.filterType,
|
||||
Heading: c.heading,
|
||||
Components: components,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CommandPaletteManager) renderCommandPaletteScheduled() {
|
||||
c.updateMutex.Lock()
|
||||
defer c.updateMutex.Unlock()
|
||||
|
||||
if !c.registered {
|
||||
return
|
||||
}
|
||||
|
||||
slices.SortFunc(c.renderedItems, func(a, b *CommandItemJSON) int {
|
||||
return a.Index - b.Index
|
||||
})
|
||||
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteUpdatedEvent, ServerCommandPaletteUpdatedEventPayload{
|
||||
Placeholder: c.placeholder,
|
||||
Items: c.renderedItems,
|
||||
})
|
||||
}
|
||||
374
seanime-2.9.10/internal/plugin/ui/component_utils.go
Normal file
374
seanime-2.9.10/internal/plugin/ui/component_utils.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (c *ComponentManager) renderComponents(renderFunc func(goja.FunctionCall) goja.Value) (interface{}, error) {
|
||||
if renderFunc == nil {
|
||||
return nil, errors.New("render function is not set")
|
||||
}
|
||||
|
||||
// Get new components
|
||||
newComponents := c.getComponentsData(renderFunc)
|
||||
|
||||
// If we have previous components, perform diffing
|
||||
if c.lastRenderedComponents != nil {
|
||||
newComponents = c.componentDiff(c.lastRenderedComponents, newComponents)
|
||||
}
|
||||
|
||||
// Store the new components for next render
|
||||
c.lastRenderedComponents = newComponents
|
||||
|
||||
return newComponents, nil
|
||||
}
|
||||
|
||||
// getComponentsData calls the render function and returns the current state of the component tree
|
||||
func (c *ComponentManager) getComponentsData(renderFunc func(goja.FunctionCall) goja.Value) interface{} {
|
||||
// Call the render function
|
||||
value := renderFunc(goja.FunctionCall{})
|
||||
|
||||
// Convert the value to a JSON string
|
||||
v, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ret interface{}
|
||||
err = json.Unmarshal(v, &ret)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
////
|
||||
|
||||
type ComponentProp struct {
|
||||
Name string // e.g. "label"
|
||||
Type string // e.g. "string"
|
||||
Default interface{} // Is set if the prop is not provided, if not set and required is false, the prop will not be included in the component
|
||||
Required bool // If true an no default value is provided, the component will throw a type error
|
||||
Validate func(value interface{}) error // Optional validation function
|
||||
OptionalFirstArg bool // If true, it can be the first argument to declaring the component as a shorthand (e.g. tray.button("Click me") instead of tray.button({label: "Click me"}))
|
||||
}
|
||||
|
||||
func defineComponent(vm *goja.Runtime, call goja.FunctionCall, t string, propDefs []ComponentProp) goja.Value {
|
||||
component := Component{
|
||||
ID: uuid.New().String(),
|
||||
Type: t,
|
||||
Props: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
propsList := make(map[string]interface{})
|
||||
propDefsMap := make(map[string]*ComponentProp)
|
||||
|
||||
var shorthandProp *ComponentProp
|
||||
for _, propDef := range propDefs {
|
||||
|
||||
propDefsMap[propDef.Name] = &propDef
|
||||
|
||||
if propDef.OptionalFirstArg {
|
||||
shorthandProp = &propDef
|
||||
}
|
||||
}
|
||||
|
||||
if len(call.Arguments) > 0 {
|
||||
// Check if the first argument is the type of the shorthand
|
||||
hasShorthand := false
|
||||
if shorthandProp != nil {
|
||||
switch shorthandProp.Type {
|
||||
case "string":
|
||||
if _, ok := call.Argument(0).Export().(string); ok {
|
||||
propsList[shorthandProp.Name] = call.Argument(0).Export().(string)
|
||||
hasShorthand = true
|
||||
}
|
||||
case "boolean":
|
||||
if _, ok := call.Argument(0).Export().(bool); ok {
|
||||
propsList[shorthandProp.Name] = call.Argument(0).Export().(bool)
|
||||
hasShorthand = true
|
||||
}
|
||||
case "array":
|
||||
if _, ok := call.Argument(0).Export().([]interface{}); ok {
|
||||
propsList[shorthandProp.Name] = call.Argument(0).Export().([]interface{})
|
||||
hasShorthand = true
|
||||
}
|
||||
}
|
||||
if hasShorthand {
|
||||
// Get the rest of the props from the second argument
|
||||
if len(call.Arguments) > 1 {
|
||||
rest, ok := call.Argument(1).Export().(map[string]interface{})
|
||||
if ok {
|
||||
// Only add props that are defined in the propDefs
|
||||
for k, v := range rest {
|
||||
if _, ok := propDefsMap[k]; ok {
|
||||
propsList[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasShorthand {
|
||||
propsArg, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if ok {
|
||||
for k, v := range propsArg {
|
||||
if _, ok := propDefsMap[k]; ok {
|
||||
propsList[k] = v
|
||||
} else {
|
||||
// util.SpewMany(k, fmt.Sprintf("%T", v))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate props
|
||||
for _, propDef := range propDefs {
|
||||
// If a prop is required and no value is provided, panic
|
||||
if propDef.Required && len(propsList) == 0 {
|
||||
panic(vm.NewTypeError(fmt.Sprintf("%s is required", propDef.Name)))
|
||||
}
|
||||
|
||||
// Validate the prop if the prop is defined
|
||||
if propDef.Validate != nil {
|
||||
if val, ok := propsList[propDef.Name]; ok {
|
||||
err := propDef.Validate(val)
|
||||
if err != nil {
|
||||
log.Error().Msgf("Invalid prop value: %s", err.Error())
|
||||
panic(vm.NewTypeError(err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set a default value if the prop is not provided
|
||||
if _, ok := propsList[propDef.Name]; !ok && propDef.Default != nil {
|
||||
propsList[propDef.Name] = propDef.Default
|
||||
}
|
||||
}
|
||||
|
||||
// Set the props
|
||||
for k, v := range propsList {
|
||||
component.Props[k] = v
|
||||
}
|
||||
|
||||
return vm.ToValue(component)
|
||||
}
|
||||
|
||||
// Helper function to create a validation function for a specific type
|
||||
func validateType(expectedType string) func(interface{}) error {
|
||||
return func(value interface{}) error {
|
||||
if value == nil {
|
||||
return fmt.Errorf("expected %s, got nil", expectedType)
|
||||
}
|
||||
switch expectedType {
|
||||
case "string":
|
||||
_, ok := value.(string)
|
||||
if !ok {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("expected string, got %T", value)
|
||||
}
|
||||
return nil
|
||||
case "number":
|
||||
_, ok := value.(float64)
|
||||
if !ok {
|
||||
_, ok := value.(int64)
|
||||
if !ok {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("expected number, got %T", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case "boolean":
|
||||
_, ok := value.(bool)
|
||||
if !ok {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("expected boolean, got %T", value)
|
||||
}
|
||||
return nil
|
||||
case "array":
|
||||
_, ok := value.([]interface{})
|
||||
if !ok {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("expected array, got %T", value)
|
||||
}
|
||||
return nil
|
||||
case "object":
|
||||
_, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("expected object, got %T", value)
|
||||
}
|
||||
return nil
|
||||
case "function":
|
||||
_, ok := value.(func(goja.FunctionCall) goja.Value)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected function, got %T", value)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid type: %s", expectedType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// componentDiff compares two component trees and returns a new component tree that preserves the ID of old components that did not change.
|
||||
// It also recursively handles props and items arrays.
|
||||
//
|
||||
// This is important to preserve state between renders in React.
|
||||
func (c *ComponentManager) componentDiff(old, new interface{}) (ret interface{}) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// If a panic occurs, return the new component tree
|
||||
ret = new
|
||||
}
|
||||
}()
|
||||
|
||||
if old == nil || new == nil {
|
||||
return new
|
||||
}
|
||||
|
||||
// Handle maps (components)
|
||||
if oldMap, ok := old.(map[string]interface{}); ok {
|
||||
if newMap, ok := new.(map[string]interface{}); ok {
|
||||
// If types match and it's a component (has "type" field), preserve ID
|
||||
if oldType, hasOldType := oldMap["type"]; hasOldType {
|
||||
if newType, hasNewType := newMap["type"]; hasNewType && oldType == newType {
|
||||
// Preserve the ID from the old component
|
||||
if oldID, hasOldID := oldMap["id"]; hasOldID {
|
||||
newMap["id"] = oldID
|
||||
}
|
||||
|
||||
// Recursively handle props
|
||||
if oldProps, hasOldProps := oldMap["props"].(map[string]interface{}); hasOldProps {
|
||||
if newProps, hasNewProps := newMap["props"].(map[string]interface{}); hasNewProps {
|
||||
// Special handling for items array in props
|
||||
if oldItems, ok := oldProps["items"].([]interface{}); ok {
|
||||
if newItems, ok := newProps["items"].([]interface{}); ok {
|
||||
newProps["items"] = c.componentDiff(oldItems, newItems)
|
||||
}
|
||||
}
|
||||
// Handle other props
|
||||
for k, v := range newProps {
|
||||
if k != "items" { // Skip items as we already handled it
|
||||
if oldV, exists := oldProps[k]; exists {
|
||||
newProps[k] = c.componentDiff(oldV, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
newMap["props"] = newProps
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if oldArr, ok := old.([]interface{}); ok {
|
||||
if newArr, ok := new.([]interface{}); ok {
|
||||
// Create a new array to store the diffed components
|
||||
result := make([]interface{}, len(newArr))
|
||||
|
||||
// First, try to match components by key if available
|
||||
oldKeyMap := make(map[string]interface{})
|
||||
for _, oldComp := range oldArr {
|
||||
if oldMap, ok := oldComp.(map[string]interface{}); ok {
|
||||
if key, ok := oldMap["key"].(string); ok && key != "" {
|
||||
oldKeyMap[key] = oldComp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each new component
|
||||
for i, newComp := range newArr {
|
||||
matched := false
|
||||
|
||||
// Try to match by key first
|
||||
if newMap, ok := newComp.(map[string]interface{}); ok {
|
||||
if key, ok := newMap["key"].(string); ok && key != "" {
|
||||
if oldComp, exists := oldKeyMap[key]; exists {
|
||||
// Found a match by key
|
||||
result[i] = c.componentDiff(oldComp, newComp)
|
||||
matched = true
|
||||
// t.ctx.logger.Debug().
|
||||
// Str("key", key).
|
||||
// Str("type", fmt.Sprintf("%v", newMap["type"])).
|
||||
// Msg("Component matched by key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no key match, try to match by position and type
|
||||
if !matched && i < len(oldArr) {
|
||||
oldComp := oldArr[i]
|
||||
oldType, newType := "", ""
|
||||
|
||||
if oldMap, ok := oldComp.(map[string]interface{}); ok {
|
||||
if t, ok := oldMap["type"].(string); ok {
|
||||
oldType = t
|
||||
}
|
||||
}
|
||||
if newMap, ok := newComp.(map[string]interface{}); ok {
|
||||
if t, ok := newMap["type"].(string); ok {
|
||||
newType = t
|
||||
}
|
||||
}
|
||||
|
||||
if oldType != "" && oldType == newType {
|
||||
result[i] = c.componentDiff(oldComp, newComp)
|
||||
matched = true
|
||||
// t.ctx.logger.Debug().
|
||||
// Str("type", oldType).
|
||||
// Msg("Component matched by type and position")
|
||||
}
|
||||
}
|
||||
|
||||
// If no match found, use the new component as is
|
||||
if !matched {
|
||||
result[i] = newComp
|
||||
// if newMap, ok := newComp.(map[string]interface{}); ok {
|
||||
// t.ctx.logger.Debug().
|
||||
// Str("type", fmt.Sprintf("%v", newMap["type"])).
|
||||
// Msg("New component added")
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// Log removed components
|
||||
// if len(oldArr) > len(newArr) {
|
||||
// for i := len(newArr); i < len(oldArr); i++ {
|
||||
// if oldMap, ok := oldArr[i].(map[string]interface{}); ok {
|
||||
// t.ctx.logger.Debug().
|
||||
// Str("type", fmt.Sprintf("%v", oldMap["type"])).
|
||||
// Msg("Component removed")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
}
|
||||
280
seanime-2.9.10/internal/plugin/ui/components.go
Normal file
280
seanime-2.9.10/internal/plugin/ui/components.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
const (
|
||||
MAX_FIELD_REFS = 100
|
||||
)
|
||||
|
||||
// ComponentManager is used to register components.
|
||||
// Any higher-order UI system must use this to register components. (Tray)
|
||||
type ComponentManager struct {
|
||||
ctx *Context
|
||||
|
||||
// Last rendered components
|
||||
lastRenderedComponents interface{}
|
||||
}
|
||||
|
||||
// jsDiv
|
||||
//
|
||||
// Example:
|
||||
// const div = tray.div({
|
||||
// items: [
|
||||
// tray.text("Some text"),
|
||||
// ]
|
||||
// })
|
||||
func (c *ComponentManager) jsDiv(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "div", []ComponentProp{
|
||||
{Name: "items", Type: "array", Required: false, OptionalFirstArg: true},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsFlex
|
||||
//
|
||||
// Example:
|
||||
// const flex = tray.flex({
|
||||
// items: [
|
||||
// tray.button({ label: "A button", onClick: "my-action" }),
|
||||
// true ? tray.text("Some text") : null,
|
||||
// ]
|
||||
// })
|
||||
// tray.render(() => flex)
|
||||
func (c *ComponentManager) jsFlex(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "flex", []ComponentProp{
|
||||
{Name: "items", Type: "array", Required: false, OptionalFirstArg: true},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "gap", Type: "number", Required: false, Default: 2, Validate: validateType("number")},
|
||||
{Name: "direction", Type: "string", Required: false, Default: "row", Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsStack
|
||||
//
|
||||
// Example:
|
||||
// const stack = tray.stack({
|
||||
// items: [
|
||||
// tray.text("Some text"),
|
||||
// ]
|
||||
// })
|
||||
func (c *ComponentManager) jsStack(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "stack", []ComponentProp{
|
||||
{Name: "items", Type: "array", Required: false, OptionalFirstArg: true},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "gap", Type: "number", Required: false, Default: 2, Validate: validateType("number")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsText
|
||||
//
|
||||
// Example:
|
||||
// const text = tray.text("Some text")
|
||||
// // or
|
||||
// const text = tray.text({ text: "Some text" })
|
||||
func (c *ComponentManager) jsText(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "text", []ComponentProp{
|
||||
{Name: "text", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsButton
|
||||
//
|
||||
// Example:
|
||||
// const button = tray.button("Click me")
|
||||
// // or
|
||||
// const button = tray.button({ label: "Click me", onClick: "my-action" })
|
||||
func (c *ComponentManager) jsButton(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "button", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "onClick", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "intent", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "loading", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsAnchor
|
||||
//
|
||||
// Example:
|
||||
// const anchor = tray.anchor("Click here", { href: "https://example.com" })
|
||||
// // or
|
||||
// const anchor = tray.anchor({ text: "Click here", href: "https://example.com" })
|
||||
func (c *ComponentManager) jsAnchor(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "anchor", []ComponentProp{
|
||||
{Name: "text", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "href", Type: "string", Required: true, Validate: validateType("string")},
|
||||
{Name: "target", Type: "string", Required: false, Default: "_blank", Validate: validateType("string")},
|
||||
{Name: "onClick", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
////////////////////////////////////////////
|
||||
// Fields
|
||||
////////////////////////////////////////////
|
||||
|
||||
// jsInput
|
||||
//
|
||||
// Example:
|
||||
// const input = tray.input("Enter your name") // placeholder as shorthand
|
||||
// // or
|
||||
// const input = tray.input({
|
||||
// placeholder: "Enter your name",
|
||||
// value: "John",
|
||||
// onChange: "input-changed"
|
||||
// })
|
||||
func (c *ComponentManager) jsInput(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "input", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: false, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "placeholder", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")},
|
||||
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "onSelect", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "textarea", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
func validateOptions(v interface{}) error {
|
||||
if v == nil {
|
||||
return errors.New("options must be an array of objects")
|
||||
}
|
||||
marshaled, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var arr []map[string]interface{}
|
||||
if err := json.Unmarshal(marshaled, &arr); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, option := range arr {
|
||||
if _, ok := option["label"]; !ok {
|
||||
return errors.New("options must be an array of objects with a label property")
|
||||
}
|
||||
if _, ok := option["value"]; !ok {
|
||||
return errors.New("options must be an array of objects with a value property")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsSelect
|
||||
//
|
||||
// Example:
|
||||
// const select = tray.select("Select an item", {
|
||||
// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }],
|
||||
// onChange: "select-changed"
|
||||
// })
|
||||
// // or
|
||||
// const select = tray.select({
|
||||
// placeholder: "Select an item",
|
||||
// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }],
|
||||
// value: "Item 1",
|
||||
// onChange: "select-changed"
|
||||
// })
|
||||
func (c *ComponentManager) jsSelect(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "select", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "placeholder", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{
|
||||
Name: "options",
|
||||
Type: "array",
|
||||
Required: true,
|
||||
Validate: validateOptions,
|
||||
},
|
||||
{Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")},
|
||||
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsCheckbox
|
||||
//
|
||||
// Example:
|
||||
// const checkbox = tray.checkbox("I agree to the terms and conditions")
|
||||
// // or
|
||||
// const checkbox = tray.checkbox({ label: "I agree to the terms and conditions", value: true })
|
||||
func (c *ComponentManager) jsCheckbox(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "checkbox", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "value", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsRadioGroup
|
||||
//
|
||||
// Example:
|
||||
// const radioGroup = tray.radioGroup({
|
||||
// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }],
|
||||
// onChange: "radio-group-changed"
|
||||
// })
|
||||
func (c *ComponentManager) jsRadioGroup(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "radio-group", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")},
|
||||
{
|
||||
Name: "options",
|
||||
Type: "array",
|
||||
Required: true,
|
||||
Validate: validateOptions,
|
||||
},
|
||||
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsSwitch
|
||||
//
|
||||
// Example:
|
||||
// const switch = tray.switch({
|
||||
// label: "Toggle me",
|
||||
// value: true
|
||||
// })
|
||||
func (c *ComponentManager) jsSwitch(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "switch", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "value", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "side", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
1243
seanime-2.9.10/internal/plugin/ui/context.go
Normal file
1243
seanime-2.9.10/internal/plugin/ui/context.go
Normal file
File diff suppressed because it is too large
Load Diff
1610
seanime-2.9.10/internal/plugin/ui/dom.go
Normal file
1610
seanime-2.9.10/internal/plugin/ui/dom.go
Normal file
File diff suppressed because it is too large
Load Diff
397
seanime-2.9.10/internal/plugin/ui/events.go
Normal file
397
seanime-2.9.10/internal/plugin/ui/events.go
Normal file
@@ -0,0 +1,397 @@
|
||||
package plugin_ui
|
||||
|
||||
import "github.com/goccy/go-json"
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// Client to server
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type ClientEventType string
|
||||
|
||||
// ClientPluginEvent is an event received from the client
|
||||
type ClientPluginEvent struct {
|
||||
// ExtensionID is the "sent to"
|
||||
// If not set, the event is being sent to all plugins
|
||||
ExtensionID string `json:"extensionId,omitempty"`
|
||||
Type ClientEventType `json:"type"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
const (
|
||||
ClientRenderTrayEvent ClientEventType = "tray:render" // Client wants to render the tray
|
||||
ClientListTrayIconsEvent ClientEventType = "tray:list-icons" // Client wants to list all icons from all plugins
|
||||
ClientTrayOpenedEvent ClientEventType = "tray:opened" // When the tray is opened
|
||||
ClientTrayClosedEvent ClientEventType = "tray:closed" // When the tray is closed
|
||||
ClientTrayClickedEvent ClientEventType = "tray:clicked" // When the tray is clicked
|
||||
ClientListCommandPalettesEvent ClientEventType = "command-palette:list" // When the client wants to list all command palettes
|
||||
ClientCommandPaletteOpenedEvent ClientEventType = "command-palette:opened" // When the client opens the command palette
|
||||
ClientCommandPaletteClosedEvent ClientEventType = "command-palette:closed" // When the client closes the command palette
|
||||
ClientRenderCommandPaletteEvent ClientEventType = "command-palette:render" // When the client requests the command palette to render
|
||||
ClientCommandPaletteInputEvent ClientEventType = "command-palette:input" // The client sends the current input of the command palette
|
||||
ClientCommandPaletteItemSelectedEvent ClientEventType = "command-palette:item-selected" // When the client selects an item from the command palette
|
||||
ClientActionRenderAnimePageButtonsEvent ClientEventType = "action:anime-page-buttons:render" // When the client requests the buttons to display on the anime page
|
||||
ClientActionRenderAnimePageDropdownItemsEvent ClientEventType = "action:anime-page-dropdown-items:render" // When the client requests the dropdown items to display on the anime page
|
||||
ClientActionRenderMangaPageButtonsEvent ClientEventType = "action:manga-page-buttons:render" // When the client requests the buttons to display on the manga page
|
||||
ClientActionRenderMediaCardContextMenuItemsEvent ClientEventType = "action:media-card-context-menu-items:render" // When the client requests the context menu items to display on the media card
|
||||
ClientActionRenderAnimeLibraryDropdownItemsEvent ClientEventType = "action:anime-library-dropdown-items:render" // When the client requests the dropdown items to display on the anime library
|
||||
ClientActionRenderEpisodeCardContextMenuItemsEvent ClientEventType = "action:episode-card-context-menu-items:render" // When the client requests the context menu items to display on the episode card
|
||||
ClientActionRenderEpisodeGridItemMenuItemsEvent ClientEventType = "action:episode-grid-item-menu-items:render" // When the client requests the context menu items to display on the episode grid item
|
||||
ClientActionClickedEvent ClientEventType = "action:clicked" // When the user clicks on an action
|
||||
ClientFormSubmittedEvent ClientEventType = "form:submitted" // When the form registered by the tray is submitted
|
||||
ClientScreenChangedEvent ClientEventType = "screen:changed" // When the current screen changes
|
||||
ClientEventHandlerTriggeredEvent ClientEventType = "handler:triggered" // When a custom event registered by the plugin is triggered
|
||||
ClientFieldRefSendValueEvent ClientEventType = "field-ref:send-value" // When the client sends the value of a field that has a ref
|
||||
|
||||
ClientDOMQueryResultEvent ClientEventType = "dom:query-result" // Result of a DOM query
|
||||
ClientDOMQueryOneResultEvent ClientEventType = "dom:query-one-result" // Result of a DOM query for one element
|
||||
ClientDOMObserveResultEvent ClientEventType = "dom:observe-result" // Result of a DOM observation
|
||||
ClientDOMStopObserveEvent ClientEventType = "dom:stop-observe" // Stop observing DOM elements
|
||||
ClientDOMCreateResultEvent ClientEventType = "dom:create-result" // Result of creating a DOM element
|
||||
ClientDOMElementUpdatedEvent ClientEventType = "dom:element-updated" // When a DOM element is updated
|
||||
ClientDOMEventTriggeredEvent ClientEventType = "dom:event-triggered" // When a DOM event is triggered
|
||||
ClientDOMReadyEvent ClientEventType = "dom:ready" // When a DOM element is ready
|
||||
)
|
||||
|
||||
type ClientRenderTrayEventPayload struct{}
|
||||
type ClientListTrayIconsEventPayload struct{}
|
||||
type ClientTrayOpenedEventPayload struct{}
|
||||
type ClientTrayClosedEventPayload struct{}
|
||||
type ClientTrayClickedEventPayload struct{}
|
||||
type ClientActionRenderAnimePageButtonsEventPayload struct{}
|
||||
type ClientActionRenderAnimePageDropdownItemsEventPayload struct{}
|
||||
type ClientActionRenderMangaPageButtonsEventPayload struct{}
|
||||
type ClientActionRenderMediaCardContextMenuItemsEventPayload struct{}
|
||||
type ClientActionRenderAnimeLibraryDropdownItemsEventPayload struct{}
|
||||
type ClientActionRenderEpisodeCardContextMenuItemsEventPayload struct{}
|
||||
type ClientActionRenderEpisodeGridItemMenuItemsEventPayload struct{}
|
||||
|
||||
type ClientListCommandPalettesEventPayload struct{}
|
||||
|
||||
type ClientCommandPaletteOpenedEventPayload struct{}
|
||||
|
||||
type ClientCommandPaletteClosedEventPayload struct{}
|
||||
|
||||
type ClientActionClickedEventPayload struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Event map[string]interface{} `json:"event"`
|
||||
}
|
||||
|
||||
type ClientEventHandlerTriggeredEventPayload struct {
|
||||
HandlerName string `json:"handlerName"`
|
||||
Event map[string]interface{} `json:"event"`
|
||||
}
|
||||
|
||||
type ClientFormSubmittedEventPayload struct {
|
||||
FormName string `json:"formName"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type ClientScreenChangedEventPayload struct {
|
||||
Pathname string `json:"pathname"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
type ClientFieldRefSendValueEventPayload struct {
|
||||
FieldRef string `json:"fieldRef"`
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
type ClientRenderCommandPaletteEventPayload struct{}
|
||||
|
||||
type ClientCommandPaletteItemSelectedEventPayload struct {
|
||||
ItemID string `json:"itemId"`
|
||||
}
|
||||
|
||||
type ClientCommandPaletteInputEventPayload struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type ClientDOMEventTriggeredEventPayload struct {
|
||||
ElementId string `json:"elementId"`
|
||||
EventType string `json:"eventType"`
|
||||
Event map[string]interface{} `json:"event"`
|
||||
}
|
||||
|
||||
type ClientDOMQueryResultEventPayload struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
}
|
||||
|
||||
type ClientDOMQueryOneResultEventPayload struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Element interface{} `json:"element"`
|
||||
}
|
||||
|
||||
type ClientDOMObserveResultEventPayload struct {
|
||||
ObserverId string `json:"observerId"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
}
|
||||
|
||||
type ClientDOMCreateResultEventPayload struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Element interface{} `json:"element"`
|
||||
}
|
||||
|
||||
type ClientDOMElementUpdatedEventPayload struct {
|
||||
ElementId string `json:"elementId"`
|
||||
Action string `json:"action"`
|
||||
Result interface{} `json:"result"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type ClientDOMStopObserveEventPayload struct {
|
||||
ObserverId string `json:"observerId"`
|
||||
}
|
||||
|
||||
type ClientDOMReadyEventPayload struct {
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// Server to client
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type ServerEventType string
|
||||
|
||||
// ServerPluginEvent is an event sent to the client
|
||||
type ServerPluginEvent struct {
|
||||
ExtensionID string `json:"extensionId"` // Extension ID must be set
|
||||
Type ServerEventType `json:"type"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
const (
|
||||
ServerTrayUpdatedEvent ServerEventType = "tray:updated" // When the trays are updated
|
||||
ServerTrayIconEvent ServerEventType = "tray:icon" // When the tray sends its icon to the client
|
||||
ServerTrayBadgeUpdatedEvent ServerEventType = "tray:badge-updated" // When the tray badge is updated
|
||||
ServerTrayOpenEvent ServerEventType = "tray:open" // When the tray is opened
|
||||
ServerTrayCloseEvent ServerEventType = "tray:close" // When the tray is closed
|
||||
ServerCommandPaletteInfoEvent ServerEventType = "command-palette:info" // When the command palette sends its state to the client
|
||||
ServerCommandPaletteUpdatedEvent ServerEventType = "command-palette:updated" // When the command palette is updated
|
||||
ServerCommandPaletteOpenEvent ServerEventType = "command-palette:open" // When the command palette is opened
|
||||
ServerCommandPaletteCloseEvent ServerEventType = "command-palette:close" // When the command palette is closed
|
||||
ServerCommandPaletteGetInputEvent ServerEventType = "command-palette:get-input" // When the command palette requests the input from the client
|
||||
ServerCommandPaletteSetInputEvent ServerEventType = "command-palette:set-input" // When the command palette sets the input
|
||||
ServerActionRenderAnimePageButtonsEvent ServerEventType = "action:anime-page-buttons:updated" // When the server renders the anime page buttons
|
||||
ServerActionRenderAnimePageDropdownItemsEvent ServerEventType = "action:anime-page-dropdown-items:updated" // When the server renders the anime page dropdown items
|
||||
ServerActionRenderMangaPageButtonsEvent ServerEventType = "action:manga-page-buttons:updated" // When the server renders the manga page buttons
|
||||
ServerActionRenderMediaCardContextMenuItemsEvent ServerEventType = "action:media-card-context-menu-items:updated" // When the server renders the media card context menu items
|
||||
ServerActionRenderEpisodeCardContextMenuItemsEvent ServerEventType = "action:episode-card-context-menu-items:updated" // When the server renders the episode card context menu items
|
||||
ServerActionRenderEpisodeGridItemMenuItemsEvent ServerEventType = "action:episode-grid-item-menu-items:updated" // When the server renders the episode grid item menu items
|
||||
ServerActionRenderAnimeLibraryDropdownItemsEvent ServerEventType = "action:anime-library-dropdown-items:updated" // When the server renders the anime library dropdown items
|
||||
ServerFormResetEvent ServerEventType = "form:reset"
|
||||
ServerFormSetValuesEvent ServerEventType = "form:set-values"
|
||||
ServerFieldRefSetValueEvent ServerEventType = "field-ref:set-value" // Set the value of a field (not in a form)
|
||||
ServerFatalErrorEvent ServerEventType = "fatal-error" // When the UI encounters a fatal error
|
||||
ServerScreenNavigateToEvent ServerEventType = "screen:navigate-to" // Navigate to a new screen
|
||||
ServerScreenReloadEvent ServerEventType = "screen:reload" // Reload the current screen
|
||||
ServerScreenGetCurrentEvent ServerEventType = "screen:get-current" // Get the current screen
|
||||
|
||||
ServerDOMQueryEvent ServerEventType = "dom:query" // When the server queries for DOM elements
|
||||
ServerDOMQueryOneEvent ServerEventType = "dom:query-one" // When the server queries for a single DOM element
|
||||
ServerDOMObserveEvent ServerEventType = "dom:observe" // When the server starts observing DOM elements
|
||||
ServerDOMStopObserveEvent ServerEventType = "dom:stop-observe" // When the server stops observing DOM elements
|
||||
ServerDOMCreateEvent ServerEventType = "dom:create" // When the server creates a DOM element
|
||||
ServerDOMManipulateEvent ServerEventType = "dom:manipulate" // When the server manipulates a DOM element
|
||||
ServerDOMObserveInViewEvent ServerEventType = "dom:observe-in-view"
|
||||
)
|
||||
|
||||
type ServerTrayUpdatedEventPayload struct {
|
||||
Components interface{} `json:"components"`
|
||||
}
|
||||
|
||||
type ServerCommandPaletteUpdatedEventPayload struct {
|
||||
Placeholder string `json:"placeholder"`
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerTrayOpenEventPayload struct {
|
||||
ExtensionID string `json:"extensionId"`
|
||||
}
|
||||
|
||||
type ServerTrayCloseEventPayload struct {
|
||||
ExtensionID string `json:"extensionId"`
|
||||
}
|
||||
|
||||
type ServerTrayIconEventPayload struct {
|
||||
ExtensionID string `json:"extensionId"`
|
||||
ExtensionName string `json:"extensionName"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
WithContent bool `json:"withContent"`
|
||||
TooltipText string `json:"tooltipText"`
|
||||
BadgeNumber int `json:"badgeNumber"`
|
||||
BadgeIntent string `json:"badgeIntent"`
|
||||
Width string `json:"width,omitempty"`
|
||||
MinHeight string `json:"minHeight,omitempty"`
|
||||
}
|
||||
|
||||
type ServerTrayBadgeUpdatedEventPayload struct {
|
||||
BadgeNumber int `json:"badgeNumber"`
|
||||
BadgeIntent string `json:"badgeIntent"`
|
||||
}
|
||||
|
||||
type ServerFormResetEventPayload struct {
|
||||
FormName string `json:"formName"`
|
||||
FieldToReset string `json:"fieldToReset"` // If not set, the form will be reset
|
||||
}
|
||||
|
||||
type ServerFormSetValuesEventPayload struct {
|
||||
FormName string `json:"formName"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type ServerFieldRefSetValueEventPayload struct {
|
||||
FieldRef string `json:"fieldRef"`
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
type ServerFieldRefGetValueEventPayload struct {
|
||||
FieldRef string `json:"fieldRef"`
|
||||
}
|
||||
|
||||
type ServerFatalErrorEventPayload struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type ServerScreenNavigateToEventPayload struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type ServerActionRenderAnimePageButtonsEventPayload struct {
|
||||
Buttons interface{} `json:"buttons"`
|
||||
}
|
||||
|
||||
type ServerActionRenderAnimePageDropdownItemsEventPayload struct {
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerActionRenderMangaPageButtonsEventPayload struct {
|
||||
Buttons interface{} `json:"buttons"`
|
||||
}
|
||||
|
||||
type ServerActionRenderMediaCardContextMenuItemsEventPayload struct {
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerActionRenderAnimeLibraryDropdownItemsEventPayload struct {
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerActionRenderEpisodeCardContextMenuItemsEventPayload struct {
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerActionRenderEpisodeGridItemMenuItemsEventPayload struct {
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerScreenReloadEventPayload struct{}
|
||||
|
||||
type ServerCommandPaletteInfoEventPayload struct {
|
||||
Placeholder string `json:"placeholder"`
|
||||
KeyboardShortcut string `json:"keyboardShortcut"`
|
||||
}
|
||||
|
||||
type ServerCommandPaletteOpenEventPayload struct{}
|
||||
|
||||
type ServerCommandPaletteCloseEventPayload struct{}
|
||||
|
||||
type ServerCommandPaletteGetInputEventPayload struct{}
|
||||
|
||||
type ServerCommandPaletteSetInputEventPayload struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type ServerScreenGetCurrentEventPayload struct{}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func NewClientPluginEvent(data map[string]interface{}) *ClientPluginEvent {
|
||||
extensionID, ok := data["extensionId"].(string)
|
||||
if !ok {
|
||||
extensionID = ""
|
||||
}
|
||||
|
||||
eventType, ok := data["type"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, ok := data["payload"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ClientPluginEvent{
|
||||
ExtensionID: extensionID,
|
||||
Type: ClientEventType(eventType),
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ClientPluginEvent) ParsePayload(ret interface{}) bool {
|
||||
data, err := json.Marshal(e.Payload)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(data, &ret); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *ClientPluginEvent) ParsePayloadAs(t ClientEventType, ret interface{}) bool {
|
||||
if e.Type != t {
|
||||
return false
|
||||
}
|
||||
return e.ParsePayload(ret)
|
||||
}
|
||||
|
||||
// Add DOM event payloads
|
||||
type ServerDOMQueryEventPayload struct {
|
||||
Selector string `json:"selector"`
|
||||
RequestID string `json:"requestId"`
|
||||
WithInnerHTML bool `json:"withInnerHTML"`
|
||||
WithOuterHTML bool `json:"withOuterHTML"`
|
||||
IdentifyChildren bool `json:"identifyChildren"`
|
||||
}
|
||||
|
||||
type ServerDOMQueryOneEventPayload struct {
|
||||
Selector string `json:"selector"`
|
||||
RequestID string `json:"requestId"`
|
||||
WithInnerHTML bool `json:"withInnerHTML"`
|
||||
WithOuterHTML bool `json:"withOuterHTML"`
|
||||
IdentifyChildren bool `json:"identifyChildren"`
|
||||
}
|
||||
|
||||
type ServerDOMObserveEventPayload struct {
|
||||
Selector string `json:"selector"`
|
||||
ObserverId string `json:"observerId"`
|
||||
WithInnerHTML bool `json:"withInnerHTML"`
|
||||
WithOuterHTML bool `json:"withOuterHTML"`
|
||||
IdentifyChildren bool `json:"identifyChildren"`
|
||||
}
|
||||
|
||||
type ServerDOMStopObserveEventPayload struct {
|
||||
ObserverId string `json:"observerId"`
|
||||
}
|
||||
|
||||
type ServerDOMCreateEventPayload struct {
|
||||
TagName string `json:"tagName"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type ServerDOMManipulateEventPayload struct {
|
||||
ElementId string `json:"elementId"`
|
||||
Action string `json:"action"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type ServerDOMObserveInViewEventPayload struct {
|
||||
Selector string `json:"selector"`
|
||||
ObserverId string `json:"observerId"`
|
||||
WithInnerHTML bool `json:"withInnerHTML"`
|
||||
WithOuterHTML bool `json:"withOuterHTML"`
|
||||
IdentifyChildren bool `json:"identifyChildren"`
|
||||
Margin string `json:"margin"`
|
||||
}
|
||||
27
seanime-2.9.10/internal/plugin/ui/fetch.go
Normal file
27
seanime-2.9.10/internal/plugin/ui/fetch.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"seanime/internal/goja/goja_bindings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func (c *Context) bindFetch(obj *goja.Object) {
|
||||
f := goja_bindings.NewFetch(c.vm)
|
||||
|
||||
_ = obj.Set("fetch", f.Fetch)
|
||||
|
||||
go func() {
|
||||
for fn := range f.ResponseChannel() {
|
||||
c.scheduler.ScheduleAsync(func() error {
|
||||
fn()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
c.registerOnCleanup(func() {
|
||||
c.logger.Debug().Msg("plugin: Terminating fetch")
|
||||
f.Close()
|
||||
})
|
||||
}
|
||||
337
seanime-2.9.10/internal/plugin/ui/form.go
Normal file
337
seanime-2.9.10/internal/plugin/ui/form.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FormManager struct {
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func NewFormManager(ctx *Context) *FormManager {
|
||||
return &FormManager{
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
type FormField struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Value interface{} `json:"value,omitempty"`
|
||||
Options []FormFieldOption `json:"options,omitempty"`
|
||||
Props map[string]interface{} `json:"props,omitempty"`
|
||||
}
|
||||
|
||||
type FormFieldOption struct {
|
||||
Label string `json:"label"`
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
type Form struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Props FormProps `json:"props"`
|
||||
manager *FormManager
|
||||
}
|
||||
|
||||
type FormProps struct {
|
||||
Name string `json:"name"`
|
||||
Fields []FormField `json:"fields"`
|
||||
}
|
||||
|
||||
// jsNewForm
|
||||
//
|
||||
// Example:
|
||||
// const form = tray.newForm("form-1")
|
||||
func (f *FormManager) jsNewForm(call goja.FunctionCall) goja.Value {
|
||||
name, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
f.ctx.handleTypeError("newForm requires a name")
|
||||
}
|
||||
|
||||
form := &Form{
|
||||
Name: name,
|
||||
ID: uuid.New().String(),
|
||||
Type: "form",
|
||||
Props: FormProps{Fields: make([]FormField, 0), Name: name},
|
||||
manager: f,
|
||||
}
|
||||
|
||||
formObj := f.ctx.vm.NewObject()
|
||||
|
||||
// Form methods
|
||||
formObj.Set("render", form.jsRender)
|
||||
formObj.Set("onSubmit", form.jsOnSubmit)
|
||||
|
||||
// Field creation methods
|
||||
formObj.Set("inputField", form.jsInputField)
|
||||
formObj.Set("numberField", form.jsNumberField)
|
||||
formObj.Set("selectField", form.jsSelectField)
|
||||
formObj.Set("checkboxField", form.jsCheckboxField)
|
||||
formObj.Set("radioField", form.jsRadioField)
|
||||
formObj.Set("dateField", form.jsDateField)
|
||||
formObj.Set("switchField", form.jsSwitchField)
|
||||
formObj.Set("submitButton", form.jsSubmitButton)
|
||||
formObj.Set("reset", form.jsReset)
|
||||
formObj.Set("setValues", form.jsSetValues)
|
||||
|
||||
return formObj
|
||||
}
|
||||
|
||||
func (f *Form) jsRender(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("render requires a config object")
|
||||
}
|
||||
|
||||
config, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("render requires a config object")
|
||||
}
|
||||
|
||||
if fields, ok := config["fields"].([]interface{}); ok {
|
||||
f.Props.Fields = make([]FormField, 0)
|
||||
for _, field := range fields {
|
||||
if fieldMap, ok := field.(FormField); ok {
|
||||
f.Props.Fields = append(f.Props.Fields, fieldMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return f.manager.ctx.vm.ToValue(f)
|
||||
}
|
||||
|
||||
func (f *Form) jsOnSubmit(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("onSubmit requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("onSubmit requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := f.manager.ctx.RegisterEventListener(ClientFormSubmittedEvent)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
var payload ClientFormSubmittedEventPayload
|
||||
if event.ParsePayloadAs(ClientFormSubmittedEvent, &payload) && payload.FormName == f.Name {
|
||||
f.manager.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), f.manager.ctx.vm.ToValue(payload.Data))
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// go func() {
|
||||
// for event := range eventListener.Channel {
|
||||
// if event.ParsePayloadAs(ClientFormSubmittedEvent, &payload) {
|
||||
// if payload.FormName == f.Name {
|
||||
// f.manager.ctx.scheduler.ScheduleAsync(func() error {
|
||||
// _, err := callback(goja.Undefined(), f.manager.ctx.vm.ToValue(payload.Data))
|
||||
// if err != nil {
|
||||
// f.manager.ctx.logger.Error().Err(err).Msg("error running form submit callback")
|
||||
// }
|
||||
// return err
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (f *Form) jsReset(call goja.FunctionCall) goja.Value {
|
||||
fieldToReset := ""
|
||||
if len(call.Arguments) > 0 {
|
||||
var ok bool
|
||||
fieldToReset, ok = call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("reset requires a field name")
|
||||
}
|
||||
}
|
||||
|
||||
f.manager.ctx.SendEventToClient(ServerFormResetEvent, ServerFormResetEventPayload{
|
||||
FormName: f.Name,
|
||||
FieldToReset: fieldToReset,
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (f *Form) jsSetValues(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("setValues requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("setValues requires a config object")
|
||||
}
|
||||
|
||||
f.manager.ctx.SendEventToClient(ServerFormSetValuesEvent, ServerFormSetValuesEventPayload{
|
||||
FormName: f.Name,
|
||||
Data: props,
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (f *Form) createField(fieldType string, props map[string]interface{}) goja.Value {
|
||||
nameRaw, ok := props["name"]
|
||||
name := ""
|
||||
if ok {
|
||||
name, ok = nameRaw.(string)
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("name must be a string")
|
||||
}
|
||||
}
|
||||
label := ""
|
||||
labelRaw, ok := props["label"]
|
||||
if ok {
|
||||
label, ok = labelRaw.(string)
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("label must be a string")
|
||||
}
|
||||
}
|
||||
placeholder, ok := props["placeholder"]
|
||||
if ok {
|
||||
placeholder, ok = placeholder.(string)
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("placeholder must be a string")
|
||||
}
|
||||
}
|
||||
field := FormField{
|
||||
ID: uuid.New().String(),
|
||||
Type: fieldType,
|
||||
Name: name,
|
||||
Label: label,
|
||||
Value: props["value"],
|
||||
Options: nil,
|
||||
}
|
||||
|
||||
// Handle options if present
|
||||
if options, ok := props["options"].([]interface{}); ok {
|
||||
fieldOptions := make([]FormFieldOption, len(options))
|
||||
for i, opt := range options {
|
||||
if optMap, ok := opt.(map[string]interface{}); ok {
|
||||
fieldOptions[i] = FormFieldOption{
|
||||
Label: optMap["label"].(string),
|
||||
Value: optMap["value"],
|
||||
}
|
||||
}
|
||||
}
|
||||
field.Options = fieldOptions
|
||||
}
|
||||
|
||||
return f.manager.ctx.vm.ToValue(field)
|
||||
}
|
||||
|
||||
func (f *Form) jsInputField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("inputField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("inputField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("input", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsNumberField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("numberField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("numberField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("number", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsSelectField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("selectField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("selectField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("select", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsCheckboxField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("checkboxField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("checkboxField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("checkbox", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsSwitchField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("switchField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("switchField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("switch", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsRadioField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("radioField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("radioField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("radio", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsDateField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("dateField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("dateField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("date", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsSubmitButton(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("submitButton requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("submitButton requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("submit", props)
|
||||
}
|
||||
36
seanime-2.9.10/internal/plugin/ui/notification.go
Normal file
36
seanime-2.9.10/internal/plugin/ui/notification.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"seanime/internal/notifier"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type NotificationManager struct {
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func NewNotificationManager(ctx *Context) *NotificationManager {
|
||||
return &NotificationManager{
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NotificationManager) bind(contextObj *goja.Object) {
|
||||
notificationObj := n.ctx.vm.NewObject()
|
||||
_ = notificationObj.Set("send", n.jsNotify)
|
||||
|
||||
_ = contextObj.Set("notification", notificationObj)
|
||||
}
|
||||
|
||||
func (n *NotificationManager) jsNotify(call goja.FunctionCall) goja.Value {
|
||||
message, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
n.ctx.handleTypeError("notification: notify requires a string message")
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
notifier.GlobalNotifier.Notify(notifier.Notification(n.ctx.ext.Name), message)
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
104
seanime-2.9.10/internal/plugin/ui/screen.go
Normal file
104
seanime-2.9.10/internal/plugin/ui/screen.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type ScreenManager struct {
|
||||
ctx *Context
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewScreenManager(ctx *Context) *ScreenManager {
|
||||
return &ScreenManager{
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// bind binds 'screen' to the ctx object
|
||||
//
|
||||
// Example:
|
||||
// ctx.screen.navigateTo("/entry?id=21");
|
||||
func (s *ScreenManager) bind(ctxObj *goja.Object) {
|
||||
screenObj := s.ctx.vm.NewObject()
|
||||
_ = screenObj.Set("onNavigate", s.jsOnNavigate)
|
||||
_ = screenObj.Set("navigateTo", s.jsNavigateTo)
|
||||
_ = screenObj.Set("reload", s.jsReload)
|
||||
_ = screenObj.Set("loadCurrent", s.jsLoadCurrent)
|
||||
|
||||
_ = ctxObj.Set("screen", screenObj)
|
||||
}
|
||||
|
||||
// jsNavigateTo navigates to a new screen
|
||||
//
|
||||
// Example:
|
||||
// ctx.screen.navigateTo("/entry?id=21");
|
||||
func (s *ScreenManager) jsNavigateTo(path string, searchParams map[string]string) {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
queryString := ""
|
||||
if len(searchParams) > 0 {
|
||||
query := url.Values{}
|
||||
for key, value := range searchParams {
|
||||
query.Add(key, value)
|
||||
}
|
||||
queryString = "?" + query.Encode()
|
||||
}
|
||||
|
||||
finalPath := path + queryString
|
||||
|
||||
s.ctx.SendEventToClient(ServerScreenNavigateToEvent, ServerScreenNavigateToEventPayload{
|
||||
Path: finalPath,
|
||||
})
|
||||
}
|
||||
|
||||
// jsReload reloads the current screen
|
||||
func (s *ScreenManager) jsReload() {
|
||||
s.ctx.SendEventToClient(ServerScreenReloadEvent, ServerScreenReloadEventPayload{})
|
||||
}
|
||||
|
||||
// jsLoadCurrent calls onNavigate with the current screen data
|
||||
func (s *ScreenManager) jsLoadCurrent() {
|
||||
s.ctx.SendEventToClient(ServerScreenGetCurrentEvent, ServerScreenGetCurrentEventPayload{})
|
||||
}
|
||||
|
||||
// jsOnNavigate registers a callback to be called when the current screen changes
|
||||
//
|
||||
// Example:
|
||||
// const onNavigate = (event) => {
|
||||
// console.log(event.screen);
|
||||
// };
|
||||
// ctx.screen.onNavigate(onNavigate);
|
||||
func (s *ScreenManager) jsOnNavigate(callback goja.Callable) goja.Value {
|
||||
eventListener := s.ctx.RegisterEventListener(ClientScreenChangedEvent)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
var payload ClientScreenChangedEventPayload
|
||||
if event.ParsePayloadAs(ClientScreenChangedEvent, &payload) {
|
||||
s.ctx.scheduler.ScheduleAsync(func() error {
|
||||
|
||||
parsedQuery, _ := url.ParseQuery(strings.TrimPrefix(payload.Query, "?"))
|
||||
queryMap := make(map[string]string)
|
||||
for key, value := range parsedQuery {
|
||||
queryMap[key] = strings.Join(value, ",")
|
||||
}
|
||||
|
||||
ret := map[string]interface{}{
|
||||
"pathname": payload.Pathname,
|
||||
"searchParams": queryMap,
|
||||
}
|
||||
|
||||
_, err := callback(goja.Undefined(), s.ctx.vm.ToValue(ret))
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
71
seanime-2.9.10/internal/plugin/ui/toast.go
Normal file
71
seanime-2.9.10/internal/plugin/ui/toast.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"seanime/internal/events"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type ToastManager struct {
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func NewToastManager(ctx *Context) *ToastManager {
|
||||
return &ToastManager{
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ToastManager) bind(contextObj *goja.Object) {
|
||||
toastObj := t.ctx.vm.NewObject()
|
||||
_ = toastObj.Set("success", t.jsToastSuccess)
|
||||
_ = toastObj.Set("error", t.jsToastError)
|
||||
_ = toastObj.Set("info", t.jsToastInfo)
|
||||
_ = toastObj.Set("warning", t.jsToastWarning)
|
||||
|
||||
_ = contextObj.Set("toast", toastObj)
|
||||
}
|
||||
|
||||
func (t *ToastManager) jsToastSuccess(call goja.FunctionCall) goja.Value {
|
||||
message, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
t.ctx.handleTypeError("toast: success requires a string message")
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
t.ctx.wsEventManager.SendEvent(events.SuccessToast, message)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (t *ToastManager) jsToastError(call goja.FunctionCall) goja.Value {
|
||||
message, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
t.ctx.handleTypeError("toast: error requires a string message")
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
t.ctx.wsEventManager.SendEvent(events.ErrorToast, message)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (t *ToastManager) jsToastInfo(call goja.FunctionCall) goja.Value {
|
||||
message, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
t.ctx.handleTypeError("toast: info requires a string message")
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
t.ctx.wsEventManager.SendEvent(events.InfoToast, message)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (t *ToastManager) jsToastWarning(call goja.FunctionCall) goja.Value {
|
||||
message, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
t.ctx.handleTypeError("toast: warning requires a string message")
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
t.ctx.wsEventManager.SendEvent(events.WarningToast, message)
|
||||
return goja.Undefined()
|
||||
}
|
||||
361
seanime-2.9.10/internal/plugin/ui/tray.go
Normal file
361
seanime-2.9.10/internal/plugin/ui/tray.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
type TrayManager struct {
|
||||
ctx *Context
|
||||
tray mo.Option[*Tray]
|
||||
lastUpdatedAt time.Time
|
||||
updateMutex sync.Mutex
|
||||
|
||||
componentManager *ComponentManager
|
||||
}
|
||||
|
||||
func NewTrayManager(ctx *Context) *TrayManager {
|
||||
return &TrayManager{
|
||||
ctx: ctx,
|
||||
tray: mo.None[*Tray](),
|
||||
componentManager: &ComponentManager{ctx: ctx},
|
||||
}
|
||||
}
|
||||
|
||||
// renderTrayScheduled renders the new component tree.
|
||||
// This function is unsafe because it is not thread-safe and should be scheduled.
|
||||
func (t *TrayManager) renderTrayScheduled() {
|
||||
t.updateMutex.Lock()
|
||||
defer t.updateMutex.Unlock()
|
||||
|
||||
tray, registered := t.tray.Get()
|
||||
if !registered {
|
||||
return
|
||||
}
|
||||
|
||||
if !tray.WithContent {
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit updates
|
||||
//if time.Since(t.lastUpdatedAt) < time.Millisecond*200 {
|
||||
// return
|
||||
//}
|
||||
|
||||
t.lastUpdatedAt = time.Now()
|
||||
|
||||
t.ctx.scheduler.ScheduleAsync(func() error {
|
||||
// t.ctx.logger.Trace().Msg("plugin: Rendering tray")
|
||||
newComponents, err := t.componentManager.renderComponents(tray.renderFunc)
|
||||
if err != nil {
|
||||
t.ctx.logger.Error().Err(err).Msg("plugin: Failed to render tray")
|
||||
t.ctx.handleException(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// t.ctx.logger.Trace().Msg("plugin: Sending tray update to client")
|
||||
// Send the JSON value to the client
|
||||
t.ctx.SendEventToClient(ServerTrayUpdatedEvent, ServerTrayUpdatedEventPayload{
|
||||
Components: newComponents,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// sendIconToClient sends the tray icon to the client after it's been requested.
|
||||
func (t *TrayManager) sendIconToClient() {
|
||||
if tray, registered := t.tray.Get(); registered {
|
||||
t.ctx.SendEventToClient(ServerTrayIconEvent, ServerTrayIconEventPayload{
|
||||
ExtensionID: t.ctx.ext.ID,
|
||||
ExtensionName: t.ctx.ext.Name,
|
||||
IconURL: tray.IconURL,
|
||||
WithContent: tray.WithContent,
|
||||
TooltipText: tray.TooltipText,
|
||||
BadgeNumber: tray.BadgeNumber,
|
||||
BadgeIntent: tray.BadgeIntent,
|
||||
Width: tray.Width,
|
||||
MinHeight: tray.MinHeight,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Tray
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type Tray struct {
|
||||
// WithContent is used to determine if the tray has any content
|
||||
// If false, only the tray icon will be rendered and tray.render() will be ignored
|
||||
WithContent bool `json:"withContent"`
|
||||
|
||||
IconURL string `json:"iconUrl"`
|
||||
TooltipText string `json:"tooltipText"`
|
||||
BadgeNumber int `json:"badgeNumber"`
|
||||
BadgeIntent string `json:"badgeIntent"`
|
||||
Width string `json:"width,omitempty"`
|
||||
MinHeight string `json:"minHeight,omitempty"`
|
||||
|
||||
renderFunc func(goja.FunctionCall) goja.Value
|
||||
trayManager *TrayManager
|
||||
}
|
||||
|
||||
type Component struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Props map[string]interface{} `json:"props"`
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
// jsNewTray
|
||||
//
|
||||
// Example:
|
||||
// const tray = ctx.newTray()
|
||||
func (t *TrayManager) jsNewTray(call goja.FunctionCall) goja.Value {
|
||||
tray := &Tray{
|
||||
renderFunc: nil,
|
||||
trayManager: t,
|
||||
WithContent: true,
|
||||
}
|
||||
|
||||
props := call.Arguments
|
||||
if len(props) > 0 {
|
||||
propsObj := props[0].Export().(map[string]interface{})
|
||||
if propsObj["withContent"] != nil {
|
||||
tray.WithContent, _ = propsObj["withContent"].(bool)
|
||||
}
|
||||
if propsObj["iconUrl"] != nil {
|
||||
tray.IconURL, _ = propsObj["iconUrl"].(string)
|
||||
}
|
||||
if propsObj["tooltipText"] != nil {
|
||||
tray.TooltipText, _ = propsObj["tooltipText"].(string)
|
||||
}
|
||||
if propsObj["width"] != nil {
|
||||
tray.Width, _ = propsObj["width"].(string)
|
||||
}
|
||||
if propsObj["minHeight"] != nil {
|
||||
tray.MinHeight, _ = propsObj["minHeight"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
t.tray = mo.Some(tray)
|
||||
|
||||
// Create a new tray object
|
||||
trayObj := t.ctx.vm.NewObject()
|
||||
_ = trayObj.Set("render", tray.jsRender)
|
||||
_ = trayObj.Set("update", tray.jsUpdate)
|
||||
_ = trayObj.Set("onOpen", tray.jsOnOpen)
|
||||
_ = trayObj.Set("onClose", tray.jsOnClose)
|
||||
_ = trayObj.Set("onClick", tray.jsOnClick)
|
||||
_ = trayObj.Set("open", tray.jsOpen)
|
||||
_ = trayObj.Set("close", tray.jsClose)
|
||||
_ = trayObj.Set("updateBadge", tray.jsUpdateBadge)
|
||||
|
||||
// Register components
|
||||
_ = trayObj.Set("div", t.componentManager.jsDiv)
|
||||
_ = trayObj.Set("flex", t.componentManager.jsFlex)
|
||||
_ = trayObj.Set("stack", t.componentManager.jsStack)
|
||||
_ = trayObj.Set("text", t.componentManager.jsText)
|
||||
_ = trayObj.Set("button", t.componentManager.jsButton)
|
||||
_ = trayObj.Set("anchor", t.componentManager.jsAnchor)
|
||||
_ = trayObj.Set("input", t.componentManager.jsInput)
|
||||
_ = trayObj.Set("radioGroup", t.componentManager.jsRadioGroup)
|
||||
_ = trayObj.Set("switch", t.componentManager.jsSwitch)
|
||||
_ = trayObj.Set("checkbox", t.componentManager.jsCheckbox)
|
||||
_ = trayObj.Set("select", t.componentManager.jsSelect)
|
||||
|
||||
return trayObj
|
||||
}
|
||||
|
||||
/////
|
||||
|
||||
// jsRender registers a function to be called when the tray is rendered/updated
|
||||
//
|
||||
// Example:
|
||||
// tray.render(() => flex)
|
||||
func (t *Tray) jsRender(call goja.FunctionCall) goja.Value {
|
||||
|
||||
funcRes, ok := call.Argument(0).Export().(func(goja.FunctionCall) goja.Value)
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("render requires a function")
|
||||
}
|
||||
|
||||
// Set the render function
|
||||
t.renderFunc = funcRes
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsUpdate schedules a re-render on the client
|
||||
//
|
||||
// Example:
|
||||
// tray.update()
|
||||
func (t *Tray) jsUpdate(call goja.FunctionCall) goja.Value {
|
||||
// Update the context's lastUIUpdateAt to prevent duplicate updates
|
||||
t.trayManager.ctx.uiUpdateMu.Lock()
|
||||
t.trayManager.ctx.lastUIUpdateAt = time.Now()
|
||||
t.trayManager.ctx.uiUpdateMu.Unlock()
|
||||
|
||||
t.trayManager.renderTrayScheduled()
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsOpen
|
||||
//
|
||||
// Example:
|
||||
// tray.open()
|
||||
func (t *Tray) jsOpen(call goja.FunctionCall) goja.Value {
|
||||
t.trayManager.ctx.SendEventToClient(ServerTrayOpenEvent, ServerTrayOpenEventPayload{
|
||||
ExtensionID: t.trayManager.ctx.ext.ID,
|
||||
})
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsClose
|
||||
//
|
||||
// Example:
|
||||
// tray.close()
|
||||
func (t *Tray) jsClose(call goja.FunctionCall) goja.Value {
|
||||
t.trayManager.ctx.SendEventToClient(ServerTrayCloseEvent, ServerTrayCloseEventPayload{
|
||||
ExtensionID: t.trayManager.ctx.ext.ID,
|
||||
})
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsUpdateBadge
|
||||
//
|
||||
// Example:
|
||||
// tray.updateBadge({ number: 1, intent: "success" })
|
||||
func (t *Tray) jsUpdateBadge(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
t.trayManager.ctx.handleTypeError("updateBadge requires a callback function")
|
||||
}
|
||||
|
||||
propsObj, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("updateBadge requires a callback function")
|
||||
}
|
||||
|
||||
number, ok := propsObj["number"].(int64)
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("updateBadge: number must be an integer")
|
||||
}
|
||||
|
||||
intent, ok := propsObj["intent"].(string)
|
||||
if !ok {
|
||||
intent = "info"
|
||||
}
|
||||
|
||||
t.BadgeNumber = int(number)
|
||||
t.BadgeIntent = intent
|
||||
|
||||
t.trayManager.ctx.SendEventToClient(ServerTrayBadgeUpdatedEvent, ServerTrayBadgeUpdatedEventPayload{
|
||||
BadgeNumber: t.BadgeNumber,
|
||||
BadgeIntent: t.BadgeIntent,
|
||||
})
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsOnOpen
|
||||
//
|
||||
// Example:
|
||||
// tray.onOpen(() => {
|
||||
// console.log("tray opened by the user")
|
||||
// })
|
||||
func (t *Tray) jsOnOpen(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
t.trayManager.ctx.handleTypeError("onOpen requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("onOpen requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayOpenedEvent)
|
||||
payload := ClientTrayOpenedEventPayload{}
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
if event.ParsePayloadAs(ClientTrayOpenedEvent, &payload) {
|
||||
t.trayManager.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
if err != nil {
|
||||
t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray open callback")
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsOnClick
|
||||
//
|
||||
// Example:
|
||||
// tray.onClick(() => {
|
||||
// console.log("tray clicked by the user")
|
||||
// })
|
||||
func (t *Tray) jsOnClick(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
t.trayManager.ctx.handleTypeError("onClick requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("onClick requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayClickedEvent)
|
||||
payload := ClientTrayClickedEventPayload{}
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
if event.ParsePayloadAs(ClientTrayClickedEvent, &payload) {
|
||||
t.trayManager.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
if err != nil {
|
||||
t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray click callback")
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsOnClose
|
||||
//
|
||||
// Example:
|
||||
// tray.onClose(() => {
|
||||
// console.log("tray closed by the user")
|
||||
// })
|
||||
func (t *Tray) jsOnClose(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
t.trayManager.ctx.handleTypeError("onClose requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("onClose requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayClosedEvent)
|
||||
payload := ClientTrayClosedEventPayload{}
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
if event.ParsePayloadAs(ClientTrayClosedEvent, &payload) {
|
||||
t.trayManager.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
if err != nil {
|
||||
t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray close callback")
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
325
seanime-2.9.10/internal/plugin/ui/ui.go
Normal file
325
seanime-2.9.10/internal/plugin/ui/ui.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/plugin"
|
||||
"seanime/internal/util"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTooManyExceptions = errors.New("plugin: Too many exceptions")
|
||||
ErrFatalError = errors.New("plugin: Fatal error")
|
||||
)
|
||||
|
||||
const (
|
||||
MaxExceptions = 5 // Maximum number of exceptions that can be thrown before the UI is interrupted
|
||||
MaxConcurrentFetchRequests = 10 // Maximum number of concurrent fetch requests
|
||||
MaxEffectCallsPerWindow = 100 // Maximum number of effect calls allowed in time window
|
||||
EffectTimeWindow = 1000 // Time window in milliseconds to track effect calls
|
||||
StateUpdateBatchInterval = 10 // Time in milliseconds to batch state updates
|
||||
UIUpdateRateLimit = 120 // Time in milliseconds to rate limit UI updates
|
||||
)
|
||||
|
||||
// UI registry, unique to a plugin and VM
|
||||
type UI struct {
|
||||
ext *extension.Extension
|
||||
context *Context
|
||||
mu sync.RWMutex
|
||||
vm *goja.Runtime // VM executing the UI
|
||||
logger *zerolog.Logger
|
||||
wsEventManager events.WSEventManagerInterface
|
||||
appContext plugin.AppContext
|
||||
scheduler *goja_util.Scheduler
|
||||
|
||||
lastException string
|
||||
|
||||
// Channel to signal the UI has been unloaded
|
||||
// This is used to interrupt the Plugin when the UI is stopped
|
||||
destroyedCh chan struct{}
|
||||
destroyed bool
|
||||
}
|
||||
|
||||
type NewUIOptions struct {
|
||||
Logger *zerolog.Logger
|
||||
VM *goja.Runtime
|
||||
WSManager events.WSEventManagerInterface
|
||||
Database *db.Database
|
||||
Scheduler *goja_util.Scheduler
|
||||
Extension *extension.Extension
|
||||
}
|
||||
|
||||
func NewUI(options NewUIOptions) *UI {
|
||||
ui := &UI{
|
||||
ext: options.Extension,
|
||||
vm: options.VM,
|
||||
logger: options.Logger,
|
||||
wsEventManager: options.WSManager,
|
||||
appContext: plugin.GlobalAppContext, // Get the app context from the global hook manager
|
||||
scheduler: options.Scheduler,
|
||||
destroyedCh: make(chan struct{}),
|
||||
}
|
||||
ui.context = NewContext(ui)
|
||||
ui.context.scheduler.SetOnException(func(err error) {
|
||||
ui.logger.Error().Err(err).Msg("plugin: Encountered exception in asynchronous task")
|
||||
ui.context.handleException(err)
|
||||
})
|
||||
|
||||
return ui
|
||||
}
|
||||
|
||||
// Called by the Plugin when it's being unloaded
|
||||
func (u *UI) Unload(signalDestroyed bool) {
|
||||
u.logger.Debug().Msg("plugin: Stopping UI")
|
||||
|
||||
u.UnloadFromInside(signalDestroyed)
|
||||
|
||||
u.logger.Debug().Msg("plugin: Stopped UI")
|
||||
}
|
||||
|
||||
// UnloadFromInside is called by the UI module itself when it's being unloaded
|
||||
func (u *UI) UnloadFromInside(signalDestroyed bool) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
if u.destroyed {
|
||||
return
|
||||
}
|
||||
// Stop the VM
|
||||
u.vm.ClearInterrupt()
|
||||
// Unsubscribe from client all events
|
||||
if u.context.wsSubscriber != nil {
|
||||
u.wsEventManager.UnsubscribeFromClientEvents("plugin-" + u.ext.ID)
|
||||
}
|
||||
// Clean up the context (all modules)
|
||||
if u.context != nil {
|
||||
u.context.Stop()
|
||||
}
|
||||
|
||||
// Send the plugin unloaded event to the client
|
||||
u.wsEventManager.SendEvent(events.PluginUnloaded, u.ext.ID)
|
||||
|
||||
if signalDestroyed {
|
||||
u.signalDestroyed()
|
||||
}
|
||||
}
|
||||
|
||||
// Destroyed returns a channel that is closed when the UI is destroyed
|
||||
func (u *UI) Destroyed() <-chan struct{} {
|
||||
return u.destroyedCh
|
||||
}
|
||||
|
||||
// signalDestroyed tells the plugin that the UI has been destroyed.
|
||||
// This is used to interrupt the Plugin when the UI is stopped
|
||||
// TODO: FIX
|
||||
func (u *UI) signalDestroyed() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
u.logger.Error().Msgf("plugin: Panic in signalDestroyed: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if u.destroyed {
|
||||
return
|
||||
}
|
||||
u.destroyed = true
|
||||
close(u.destroyedCh)
|
||||
}
|
||||
|
||||
// Register a UI
|
||||
// This is the main entry point for the UI
|
||||
// - It is called once when the plugin is loaded and registers all necessary modules
|
||||
func (u *UI) Register(callback string) error {
|
||||
defer util.HandlePanicInModuleThen("plugin_ui/Register", func() {
|
||||
u.logger.Error().Msg("plugin: Panic in Register")
|
||||
})
|
||||
|
||||
u.mu.Lock()
|
||||
|
||||
// Create a wrapper JavaScript function that calls the provided callback
|
||||
callback = `function(ctx) { return (` + callback + `).call(undefined, ctx); }`
|
||||
// Compile the callback into a Goja program
|
||||
// pr := goja.MustCompile("", "{("+callback+").apply(undefined, __ctx)}", true)
|
||||
|
||||
// Subscribe the plugin to client events
|
||||
u.context.wsSubscriber = u.wsEventManager.SubscribeToClientEvents("plugin-" + u.ext.ID)
|
||||
|
||||
u.logger.Debug().Msg("plugin: Registering UI")
|
||||
|
||||
// Listen for client events and send them to the event listeners
|
||||
go func() {
|
||||
for event := range u.context.wsSubscriber.Channel {
|
||||
//u.logger.Trace().Msgf("Received event %s", event.Type)
|
||||
u.HandleWSEvent(event)
|
||||
}
|
||||
u.logger.Debug().Msg("plugin: Event goroutine stopped")
|
||||
}()
|
||||
|
||||
u.context.createAndBindContextObject(u.vm)
|
||||
|
||||
// Execute the callback
|
||||
_, err := u.vm.RunString(`(` + callback + `).call(undefined, __ctx)`)
|
||||
if err != nil {
|
||||
u.mu.Unlock()
|
||||
u.logger.Error().Err(err).Msg("plugin: Encountered exception in UI handler, unloading plugin")
|
||||
u.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("plugin(%s): Encountered exception in UI handler: %s", u.ext.ID, err.Error()))
|
||||
u.wsEventManager.SendEvent(events.ConsoleLog, fmt.Sprintf("plugin(%s): Encountered exception in UI handler: %s", u.ext.ID, err.Error()))
|
||||
// Unload the UI and signal the Plugin that it's been terminated
|
||||
u.UnloadFromInside(true)
|
||||
return fmt.Errorf("plugin: Encountered exception in UI handler: %w", err)
|
||||
}
|
||||
|
||||
// Send events to the client
|
||||
u.context.trayManager.renderTrayScheduled()
|
||||
u.context.trayManager.sendIconToClient()
|
||||
u.context.actionManager.renderAnimePageButtons()
|
||||
u.context.actionManager.renderAnimePageDropdownItems()
|
||||
u.context.actionManager.renderAnimeLibraryDropdownItems()
|
||||
u.context.actionManager.renderMangaPageButtons()
|
||||
u.context.actionManager.renderMediaCardContextMenuItems()
|
||||
u.context.actionManager.renderEpisodeCardContextMenuItems()
|
||||
u.context.actionManager.renderEpisodeGridItemMenuItems()
|
||||
u.context.commandPaletteManager.renderCommandPaletteScheduled()
|
||||
u.context.commandPaletteManager.sendInfoToClient()
|
||||
|
||||
u.wsEventManager.SendEvent(events.PluginLoaded, u.ext.ID)
|
||||
|
||||
u.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add this new type to handle batched events from the client
|
||||
type BatchedClientEvents struct {
|
||||
Events []map[string]interface{} `json:"events"`
|
||||
}
|
||||
|
||||
// HandleWSEvent handles a websocket event from the client
|
||||
func (u *UI) HandleWSEvent(event *events.WebsocketClientEvent) {
|
||||
defer util.HandlePanicInModuleThen("plugin/HandleWSEvent", func() {})
|
||||
|
||||
u.mu.RLock()
|
||||
defer u.mu.RUnlock()
|
||||
|
||||
// Ignore if UI is destroyed
|
||||
if u.destroyed {
|
||||
return
|
||||
}
|
||||
|
||||
if event.Type == events.PluginEvent {
|
||||
// Extract the event payload
|
||||
payload, ok := event.Payload.(map[string]interface{})
|
||||
if !ok {
|
||||
u.logger.Error().Str("payload", fmt.Sprintf("%+v", event.Payload)).Msg("plugin/ui: Failed to parse plugin event payload")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a batch event
|
||||
eventType, _ := payload["type"].(string)
|
||||
if eventType == "client:batch-events" {
|
||||
u.handleBatchedClientEvents(event.ClientID, payload)
|
||||
return
|
||||
}
|
||||
|
||||
// Process normal event
|
||||
clientEvent := NewClientPluginEvent(payload)
|
||||
if clientEvent == nil {
|
||||
u.logger.Error().Interface("payload", payload).Msg("plugin/ui: Failed to create client plugin event")
|
||||
return
|
||||
}
|
||||
|
||||
// If the event is for this plugin
|
||||
if clientEvent.ExtensionID == u.ext.ID || clientEvent.ExtensionID == "" {
|
||||
// Process the event based on type
|
||||
u.dispatchClientEvent(clientEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchClientEvent handles a client event based on its type
|
||||
func (u *UI) dispatchClientEvent(clientEvent *ClientPluginEvent) {
|
||||
switch clientEvent.Type {
|
||||
case ClientRenderTrayEvent: // Client wants to render the tray
|
||||
u.context.trayManager.renderTrayScheduled()
|
||||
|
||||
case ClientListTrayIconsEvent: // Client wants to list all tray icons from all plugins
|
||||
u.context.trayManager.sendIconToClient()
|
||||
|
||||
case ClientActionRenderAnimePageButtonsEvent: // Client wants to update the anime page buttons
|
||||
u.context.actionManager.renderAnimePageButtons()
|
||||
|
||||
case ClientActionRenderAnimePageDropdownItemsEvent: // Client wants to update the anime page dropdown items
|
||||
u.context.actionManager.renderAnimePageDropdownItems()
|
||||
|
||||
case ClientActionRenderAnimeLibraryDropdownItemsEvent: // Client wants to update the anime library dropdown items
|
||||
u.context.actionManager.renderAnimeLibraryDropdownItems()
|
||||
|
||||
case ClientActionRenderMangaPageButtonsEvent: // Client wants to update the manga page buttons
|
||||
u.context.actionManager.renderMangaPageButtons()
|
||||
|
||||
case ClientActionRenderMediaCardContextMenuItemsEvent: // Client wants to update the media card context menu items
|
||||
u.context.actionManager.renderMediaCardContextMenuItems()
|
||||
|
||||
case ClientActionRenderEpisodeCardContextMenuItemsEvent: // Client wants to update the episode card context menu items
|
||||
u.context.actionManager.renderEpisodeCardContextMenuItems()
|
||||
|
||||
case ClientActionRenderEpisodeGridItemMenuItemsEvent: // Client wants to update the episode grid item menu items
|
||||
u.context.actionManager.renderEpisodeGridItemMenuItems()
|
||||
|
||||
case ClientRenderCommandPaletteEvent: // Client wants to render the command palette
|
||||
u.context.commandPaletteManager.renderCommandPaletteScheduled()
|
||||
|
||||
case ClientListCommandPalettesEvent: // Client wants to list all command palettes
|
||||
u.context.commandPaletteManager.sendInfoToClient()
|
||||
|
||||
default:
|
||||
// Send to registered event listeners
|
||||
eventListeners, ok := u.context.eventBus.Get(clientEvent.Type)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
eventListeners.Range(func(key string, listener *EventListener) bool {
|
||||
listener.Send(clientEvent)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleBatchedClientEvents processes a batch of client events
|
||||
func (u *UI) handleBatchedClientEvents(clientID string, payload map[string]interface{}) {
|
||||
if eventPayload, ok := payload["payload"].(map[string]interface{}); ok {
|
||||
if eventsRaw, ok := eventPayload["events"].([]interface{}); ok {
|
||||
// Process each event in the batch
|
||||
for _, eventRaw := range eventsRaw {
|
||||
if eventMap, ok := eventRaw.(map[string]interface{}); ok {
|
||||
// Create a synthetic event object
|
||||
syntheticPayload := map[string]interface{}{
|
||||
"type": eventMap["type"],
|
||||
"extensionId": eventMap["extensionId"],
|
||||
"payload": eventMap["payload"],
|
||||
}
|
||||
|
||||
// Create and dispatch the event
|
||||
clientEvent := NewClientPluginEvent(syntheticPayload)
|
||||
if clientEvent == nil {
|
||||
u.logger.Error().Interface("payload", syntheticPayload).Msg("plugin/ui: Failed to create client plugin event from batch")
|
||||
continue
|
||||
}
|
||||
|
||||
// If the event is for this plugin
|
||||
if clientEvent.ExtensionID == u.ext.ID || clientEvent.ExtensionID == "" {
|
||||
// Process the event
|
||||
u.dispatchClientEvent(clientEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
seanime-2.9.10/internal/plugin/ui/webview.go
Normal file
11
seanime-2.9.10/internal/plugin/ui/webview.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package plugin_ui
|
||||
|
||||
type WebviewManager struct {
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func NewWebviewManager(ctx *Context) *WebviewManager {
|
||||
return &WebviewManager{
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user