Feat/moveable plugin UI container (#3045)

* improve(plugin): add container for main ui frame

* feat(plugin): support draggable & resizable UI container for main ui

* feat: support fork sub layout container

* improve(plugin): add editor selection api

* improve(plugin): click outside configure for float container

* improve(plugin): api of journal for create-page

* improve(plugin): api of open-in-right-sidebar

* improve(plugin): add full screen api

* improve(plugin): api of register-palette-command

* improve(plugin): add apis
pull/3154/head
Charlie 2021-11-15 16:57:20 +08:00 committed by GitHub
parent 5605170cf9
commit 72c038e6fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 7909 additions and 105 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@logseq/libs",
"version": "0.0.1-alpha.29",
"version": "0.0.1-alpha.30",
"description": "Logseq SDK libraries",
"main": "dist/lsplugin.user.js",
"typings": "index.d.ts",

View File

@ -60,6 +60,7 @@ class LSPluginCaller extends EventEmitter {
}
}
// run in sandbox
async connectToParent (userModel = {}) {
if (this._connected) return
@ -72,7 +73,14 @@ class LSPluginCaller extends EventEmitter {
const readyDeferred = deferred(1000 * 5)
const model: any = this._extendUserModel({
[LSPMSG_READY]: async () => {
[LSPMSG_READY]: async (baseInfo) => {
// dynamically setup common msg handler
model[LSPMSGFn(baseInfo?.pid)] = ({ type, payload }: { type: string, payload: any }) => {
debug(`[call from host (_call)] ${this._debugTag}`, type, payload)
// host._call without async
caller.emit(type, payload)
}
await readyDeferred.resolve()
},
@ -87,7 +95,7 @@ class LSPluginCaller extends EventEmitter {
},
[LSPMSG]: async ({ ns, type, payload }: any) => {
debug(`[call from host] ${this._debugTag}`, ns, type, payload)
debug(`[call from host (async)] ${this._debugTag}`, ns, type, payload)
if (ns && ns.startsWith('hook')) {
caller.emit(`${ns}:${type}`, payload)
@ -187,8 +195,8 @@ class LSPluginCaller extends EventEmitter {
return this._callUserModel?.call(this, type, payload)
}
// run in host
async _setupIframeSandbox () {
const cnt = document.body
const pl = this._pluginLocal!
const id = pl.id
const url = new URL(pl.options.entry!)
@ -197,11 +205,30 @@ class LSPluginCaller extends EventEmitter {
.set(`__v__`, IS_DEV ? Date.now().toString() : pl.options.version)
// clear zombie sandbox
const zb = cnt.querySelector(`#${id}`)
const zb = document.querySelector(`#${id}`)
if (zb) zb.parentElement.removeChild(zb)
const cnt = document.createElement('div')
cnt.classList.add('lsp-iframe-sandbox-container')
cnt.id = id
// TODO: apply any container layout data
{
const mainLayoutInfo = this._pluginLocal.settings.get('layout')?.[0]
if (mainLayoutInfo) {
cnt.dataset.inited_layout = 'true'
const { width, height, left, top } = mainLayoutInfo
Object.assign(cnt.style, {
width: width + 'px', height: height + 'px',
left: left + 'px', top: top + 'px'
})
}
}
document.body.appendChild(cnt)
const pt = new Postmate({
id, container: cnt, url: url.href,
id: id + '_iframe', container: cnt, url: url.href,
classListArray: ['lsp-iframe-sandbox'],
model: { baseInfo: JSON.parse(JSON.stringify(pl.toJSON())) }
})
@ -310,10 +337,18 @@ class LSPluginCaller extends EventEmitter {
}
_getSandboxIframeContainer () {
return this._parent?.frame
return this._parent?.frame.parentNode as HTMLDivElement
}
_getSandboxShadowContainer () {
return this._shadow?.frame.parentNode as HTMLDivElement
}
_getSandboxIframeRoot () {
return this._parent?.frame
}
_getSandboxShadowRoot () {
return this._shadow?.frame
}
@ -322,13 +357,18 @@ class LSPluginCaller extends EventEmitter {
}
async destroy () {
let root: HTMLElement = null
if (this._parent) {
root = this._getSandboxIframeContainer()
await this._parent.destroy()
}
if (this._shadow) {
root = this._getSandboxShadowContainer()
this._shadow.destroy()
}
root?.parentNode.removeChild(root)
}
}

View File

@ -26,11 +26,10 @@ import {
LSPluginPkgConfig,
StyleOptions,
StyleString,
ThemeOptions, UIFrameAttrs,
ThemeOptions, UIContainerAttrs,
UIOptions
} from './LSPlugin'
import { snakeCase } from 'snake-case'
import DOMPurify from 'dompurify'
const debug = Debug('LSPlugin:core')
const DIR_PLUGINS = 'plugins'
@ -175,13 +174,13 @@ function initMainUIHandlers (pluginLocal: PluginLocal) {
const _ = (label: string): any => `main-ui:${label}`
pluginLocal.on(_('visible'), ({ visible, toggle, cursor }) => {
const el = pluginLocal.getMainUI()
const el = pluginLocal.getMainUIContainer()
el?.classList[toggle ? 'toggle' : (visible ? 'add' : 'remove')]('visible')
// pluginLocal.caller!.callUserModel(LSPMSG, { type: _('visible'), payload: visible })
// auto focus frame
if (visible) {
if (!pluginLocal.shadow && el) {
(el as HTMLIFrameElement).contentWindow?.focus()
(el.querySelector('iframe') as HTMLIFrameElement)?.contentWindow?.focus()
}
}
@ -190,16 +189,38 @@ function initMainUIHandlers (pluginLocal: PluginLocal) {
}
})
pluginLocal.on(_('attrs'), (attrs: Partial<UIFrameAttrs>) => {
const el = pluginLocal.getMainUI()
pluginLocal.on(_('attrs'), (attrs: Partial<UIContainerAttrs>) => {
const el = pluginLocal.getMainUIContainer()
Object.entries(attrs).forEach(([k, v]) => {
el?.setAttribute(k, v)
if (k === 'draggable' && v) {
pluginLocal._dispose(
pluginLocal._setupDraggableContainer(el, {
title: pluginLocal.options.name,
close: () => {
pluginLocal.caller.call('sys:ui:visible', { toggle: true })
}
}))
}
if (k === 'resizable' && v) {
pluginLocal._dispose(
pluginLocal._setupResizableContainer(el))
}
})
})
pluginLocal.on(_('style'), (style: Record<string, any>) => {
const el = pluginLocal.getMainUI()
const el = pluginLocal.getMainUIContainer()
const isInitedLayout = !!el.dataset.inited_layout
Object.entries(style).forEach(([k, v]) => {
if (isInitedLayout && [
'left', 'top', 'bottom', 'right', 'width', 'height'
].includes(k)) {
return
}
el!.style[k] = v
})
})
@ -247,10 +268,14 @@ function initProviderHandlers (pluginLocal: PluginLocal) {
pluginLocal._dispose(
setupInjectedUI.call(pluginLocal,
ui, {
ui, Object.assign({
'data-ref': pluginLocal.id
})
)
}, ui.attrs || {}),
({ el, float }) => {
if (!float) return
const identity = el.dataset.identity
pluginLocal.layoutCore.move_container_to_top(identity)
}))
})
})
}
@ -383,7 +408,7 @@ class PluginLocal
}
}
getMainUI (): HTMLElement | undefined {
getMainUIContainer (): HTMLElement | undefined {
if (this.shadow) {
return this.caller?._getSandboxShadowContainer()
}
@ -423,20 +448,23 @@ class PluginLocal
throw new IllegalPluginPackageError(e.message)
}
// Pick legal attrs
['name', 'author', 'repository', 'version',
'description', 'repo', 'title', 'effect'
].forEach(k => {
const localRoot = this._localRoot = safetyPathNormalize(url)
const logseq: Partial<LSPluginPkgConfig> = pkg.logseq || {}
// Pick legal attrs
;['name', 'author', 'repository', 'version',
'description', 'repo', 'title', 'effect',
].concat(!this.isInstalledInDotRoot ? ['devEntry'] : []).forEach(k => {
this._options[k] = pkg[k]
})
const localRoot = this._localRoot = safetyPathNormalize(url)
const logseq: Partial<LSPluginPkgConfig> = pkg.logseq || {}
const validateMain = (main) => main && /\.(js|html)$/.test(main)
const validateEntry = (main) => main && /\.(js|html)$/.test(main)
// Entry from main
if (validateMain(pkg.main)) { // Theme has no main
this._options.entry = this._resolveResourceFullUrl(pkg.main, localRoot)
const entry = logseq.entry || logseq.main || pkg.main
if (validateEntry(entry)) { // Theme has no main
this._options.entry = this._resolveResourceFullUrl(entry, localRoot)
this._options.devEntry = logseq.devEntry
if (logseq.mode) {
this._options.mode = logseq.mode
@ -489,8 +517,8 @@ class PluginLocal
}
async _tryToNormalizeEntry () {
let { entry, settings } = this.options
let devEntry = settings?.get('_devEntry')
let { entry, settings, devEntry } = this.options
devEntry = devEntry || settings?.get('_devEntry')
if (devEntry) {
this._options.entry = devEntry
@ -548,6 +576,108 @@ class PluginLocal
})
}
_persistMainUILayoutData (e: { width: number, height: number, left: number, top: number }) {
const layouts = this.settings.get('layouts') || []
layouts[0] = e
this.settings.set('layout', layouts)
}
_setupDraggableContainer (
el: HTMLElement,
opts: Partial<{ key: string, title: string, close: () => void }> = {}): () => void {
const ds = el.dataset
if (ds.inited_draggable) return
if (!ds.identity) {
ds.identity = 'dd-' + genID()
}
const isInjectedUI = !!opts.key
const handle = document.createElement('div')
handle.classList.add('draggable-handle')
handle.innerHTML = `
<div class="th">
<div class="l"><h3>${opts.title || ''}</h3></div>
<div class="r">
<a class="button x"><i class="ti ti-x"></i></a>
</div>
</div>
`
handle.querySelector('.x')
.addEventListener('click', (e) => {
opts?.close?.()
e.stopPropagation()
}, false)
handle.addEventListener('mousedown', (e) => {
const target = e.target as HTMLElement
if (target?.closest('.r')) {
e.stopPropagation()
e.preventDefault()
return
}
}, false)
el.prepend(handle)
// move to top
el.addEventListener('mousedown', (e) => {
this.layoutCore.move_container_to_top(ds.identity)
}, true)
const setTitle = (title) => {
handle.querySelector('h3').textContent = title
}
const dispose = this.layoutCore.setup_draggable_container_BANG_(el,
!isInjectedUI ? this._persistMainUILayoutData.bind(this) : () => {})
ds.inited_draggable = 'true'
if (opts.title) {
setTitle(opts.title)
}
// click outside
let removeOutsideListener = null
if (ds.close === 'outside') {
const handler = (e) => {
const target = e.target
if (!el.contains(target)) {
opts.close()
}
}
document.addEventListener('click', handler, false)
removeOutsideListener = () => {
document.removeEventListener('click', handler)
}
}
return () => {
dispose()
removeOutsideListener?.()
}
}
_setupResizableContainer (el: HTMLElement, key?: string): () => void {
const ds = el.dataset
if (ds.inited_resizable) return
if (!ds.identity) {
ds.identity = 'dd-' + genID()
}
const handle = document.createElement('div')
handle.classList.add('resizable-handle')
el.prepend(handle)
// @ts-ignore
const layoutCore = window.frontend.modules.layout.core
const dispose = layoutCore.setup_resizable_container_BANG_(el,
!key ? this._persistMainUILayoutData.bind(this) : () => {})
ds.inited_resizable = 'true'
return dispose
}
async load (readyIndicator?: DeferredActor) {
if (this.pending) {
return
@ -580,7 +710,7 @@ class PluginLocal
await this._caller.connectToChild()
const readyFn = () => {
this._caller?.callUserModel(LSPMSG_READY)
this._caller?.callUserModel(LSPMSG_READY, { pid: this.id })
}
if (readyIndicator) {
@ -690,6 +820,11 @@ class PluginLocal
}
}
get layoutCore (): any {
// @ts-ignore
return window.frontend.modules.layout.core
}
get isInstalledInDotRoot () {
const dotRoot = this.dotConfigRoot
const plgRoot = this.localRoot

View File

@ -20,7 +20,7 @@ export type StyleOptions = {
style: StyleString
}
export type UIFrameAttrs = {
export type UIContainerAttrs = {
draggable: boolean
resizable: boolean
@ -30,7 +30,10 @@ export type UIFrameAttrs = {
export type UIBaseOptions = {
key?: string
replace?: boolean
template: string
template: string | null
style?: CSS.Properties
attrs?: Record<string, string>
close?: 'outside' | string
}
export type UIPathIdentity = {
@ -51,14 +54,18 @@ export type UISlotOptions = UIBaseOptions & UISlotIdentity
export type UIPathOptions = UIBaseOptions & UIPathIdentity
export type UIOptions = UIPathOptions | UISlotOptions
export type UIOptions = UIBaseOptions | UIPathOptions | UISlotOptions
export interface LSPluginPkgConfig {
id: PluginLocalIdentity
main: string
entry: string // alias of main
title: string
mode: 'shadow' | 'iframe'
themes: Array<ThemeOptions>
icon: string
[key: string]: any
}
export interface LSPluginBaseInfo {
@ -167,6 +174,7 @@ export type SlashCommandActionCmd =
| 'editor/clear-current-slash'
| 'editor/restore-saved-cursor'
export type SlashCommandAction = [cmd: SlashCommandActionCmd, ...args: any]
export type SimpleCommandCallback = (e: IHookEvent) => void
export type BlockCommandCallback = (e: IHookEvent & { uuid: BlockUUID }) => Promise<void>
export type BlockCursorPosition = { left: number, top: number, height: number, pos: number, rect: DOMRect }
@ -174,10 +182,28 @@ export type BlockCursorPosition = { left: number, top: number, height: number, p
* App level APIs
*/
export interface IAppProxy {
// base
getUserInfo: () => Promise<AppUserInfo | null>
getUserConfigs: () => Promise<AppUserConfigs>
// commands
registerCommand: (
type: string,
opts: {
key: string,
label: string,
desc?: string,
palette?: boolean
},
action: SimpleCommandCallback) => void
registerCommandPalette: (
opts: {
key: string,
label: string,
},
action: SimpleCommandCallback) => void
// native
relaunch: () => Promise<void>
quit: () => Promise<void>
@ -191,8 +217,10 @@ export interface IAppProxy {
replaceState: (k: string, params?: Record<string, any>, query?: Record<string, any>) => void
// ui
showMsg: (content: string, status?: 'success' | 'warning' | string) => void
queryElementById: (id: string) => string | boolean
showMsg: (content: string, status?: 'success' | 'warning' | 'error' | string) => void
setZoomFactor: (factor: number) => void
setFullScreen: (flag: boolean | 'toggle') => void
registerUIItem: (
type: 'toolbar' | 'pagebar',
@ -208,7 +236,28 @@ export interface IAppProxy {
onCurrentGraphChanged: IUserHook
onThemeModeChanged: IUserHook<{ mode: 'dark' | 'light' }>
onBlockRendererSlotted: IUserSlotHook<{ uuid: BlockUUID }>
/**
* provide ui slot to block `renderer` macro for `{{renderer arg1, arg2}}`
*
* @example
* ```ts
* // e.g. {{renderer :h1, hello world, green}}
*
* logseq.App.onMacroRendererSlotted(({ slot, payload: { arguments } }) => {
* let [type, text, color] = arguments
* if (type !== ':h1') return
* logseq.provideUI({
* key: 'h1-playground',
* slot, template: `
* <h2 style="color: ${color || 'red'}">${text}</h2>
* `,
* })
* })
* ```
*/
onMacroRendererSlotted: IUserSlotHook<{ payload: { arguments: Array<string>, uuid: string, [key: string]: any } }>
onPageHeadActionsSlotted: IUserSlotHook
onRouteChanged: IUserHook<{ path: string, template: string }>
onSidebarVisibleChanged: IUserHook<{ visible: boolean }>
@ -328,7 +377,7 @@ export interface IEditorProxy extends Record<string, any> {
createPage: (
pageName: BlockPageName,
properties?: {},
opts?: Partial<{ redirect: boolean, createFirstBlock: boolean, format: BlockEntity['format'] }>
opts?: Partial<{ redirect: boolean, createFirstBlock: boolean, format: BlockEntity['format'], journal: boolean }>
) => Promise<PageEntity | null>
deletePage: (
@ -369,6 +418,11 @@ export interface IEditorProxy extends Record<string, any> {
pageName: BlockPageName,
blockId: BlockIdentity
) => void
openInRightSidebar: (uuid: BlockUUID) => void
// events
onInputSelectionEnd: IUserHook<{ caret: any, point: { x: number, y: number }, start: number, end: number, text: string }>
}
/**

View File

@ -12,7 +12,7 @@ import {
ThemeOptions,
UIOptions, IHookEvent, BlockIdentity,
BlockPageName,
UIFrameAttrs
UIContainerAttrs, SimpleCommandCallback
} from './LSPlugin'
import Debug from 'debug'
import * as CSS from 'csstype'
@ -30,7 +30,7 @@ const PROXY_CONTINUE = Symbol.for('proxy-continue')
const debug = Debug('LSPlugin:user')
/**
* @param type
* @param type (key of group commands)
* @param opts
* @param action
*/
@ -39,26 +39,43 @@ function registerSimpleCommand (
type: string,
opts: {
key: string,
label: string
label: string,
desc?: string,
palette?: boolean
},
action: BlockCommandCallback
action: SimpleCommandCallback
) {
if (typeof action !== 'function') {
return false
}
const { key, label } = opts
const { key, label, desc, palette } = opts
const eventKey = `SimpleCommandHook${key}${++registeredCmdUid}`
this.Editor['on' + eventKey](action)
this.caller?.call(`api:call`, {
method: 'register-plugin-simple-command',
args: [this.baseInfo.id, [{ key, label, type }, ['editor/hook', eventKey]]]
args: [this.baseInfo.id, [{ key, label, type, desc }, ['editor/hook', eventKey]], palette]
})
}
const app: Partial<IAppProxy> = {
registerCommand: registerSimpleCommand,
registerCommandPalette (
opts: { key: string; label: string },
action: SimpleCommandCallback) {
const { key, label } = opts
const group = 'global-palette-command'
return registerSimpleCommand.call(
this, group,
{ key, label, palette: true },
action)
},
registerUIItem (
type: 'toolbar' | 'pagebar',
opts: { key: string, template: string }
@ -89,6 +106,18 @@ const app: Partial<IAppProxy> = {
type, {
key, label
}, action)
},
setFullScreen (flag) {
const sf = (...args) => this._callWin('setFullScreen', ...args)
if (flag === 'toggle') {
this._callWin('isFullScreen').then(r => {
r ? sf() : sf(true)
})
} else {
flag ? sf(true) : sf()
}
}
}
@ -219,6 +248,12 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
) {
super()
_caller.on('sys:ui:visible', (payload) => {
if (payload?.toggle) {
this.toggleMainUI()
}
})
_caller.on('settings:changed', (payload) => {
const b = Object.assign({}, this.settings)
const a = Object.assign(this._baseInfo.settings, payload)
@ -307,7 +342,7 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
// TODO: update associated baseInfo settings
}
setMainUIAttrs (attrs: Partial<UIFrameAttrs>): void {
setMainUIAttrs (attrs: Partial<UIContainerAttrs>): void {
this.caller.call('main-ui:attrs', attrs)
}
@ -340,7 +375,7 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
}
get isMainUIVisible (): boolean {
const state = this._ui.get(0)
const state = this._ui.get(KEY_MAIN_UI)
return Boolean(state && state.visible)
}
@ -405,14 +440,23 @@ export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements IL
// Call host
return caller.callAsync(`api:call`, {
method: propKey,
args: args
tag, method: propKey, args: args
})
}
}
})
}
/**
* @param args
*/
_callWin (...args) {
return this._caller.callAsync(`api:call`, {
method: '_callMainWin',
args: args
})
}
/**
* The interface methods of {@link IAppProxy}
*/

View File

@ -169,7 +169,8 @@ export function invokeHostExportedApi (
method: string,
...args: Array<any>
) {
method = method?.replace(/^[_$]+/, '')
method = method?.startsWith('_call') ? method :
method?.replace(/^[_$]+/, '')
const method1 = snakeCase(method)
// @ts-ignore
@ -229,33 +230,37 @@ export function setupInjectedStyle (
}
}
const injectedUIEffects = new Map<string, () => void>()
export function setupInjectedUI (
this: PluginLocal,
ui: UIOptions,
attrs: Record<string, any>
attrs: Record<string, string>,
initialCallback?: (e: { el: HTMLElement, float: boolean }) => void
) {
let slot: string = ''
let selector: string
let float: boolean
const pl = this
let slot = ''
let selector = ''
const id = `${ui.key}-${slot}-${pl.id}`
const key = `${ui.key}-${pl.id}`
if ('slot' in ui) {
slot = ui.slot
selector = `#${slot}`
} else {
} else if ('path' in ui) {
selector = ui.path
} else {
float = true
}
const target = selector && document.querySelector(selector)
const target = float ? document.body : (selector && document.querySelector(selector))
if (!target) {
console.error(`${this.debugTag} can not resolve selector target ${selector}`)
return
}
const id = `${ui.key}-${slot}-${pl.id}`
const key = `${ui.key}-${pl.id}`
let el = document.querySelector(`#${id}`) as HTMLElement
if (ui.template) {
// safe template
ui.template = DOMPurify.sanitize(
@ -264,10 +269,32 @@ export function setupInjectedUI (
ALLOW_UNKNOWN_PROTOCOLS: true,
ADD_ATTR: ['allow', 'src', 'allowfullscreen', 'frameborder', 'scrolling']
})
} else { // remove ui
injectedUIEffects.get(key)?.call(null)
return
}
if (el) {
el.innerHTML = ui.template
let el = document.querySelector(`#${id}`) as HTMLElement
let content = float ? el?.querySelector('.ls-ui-float-content') : el
if (content) {
content.innerHTML = ui.template
// update attributes
attrs && Object.entries(attrs).forEach(([k, v]) => {
el.setAttribute(k, v)
})
let positionDirty = el.dataset.dx != null
ui.style && Object.entries(ui.style).forEach(([k, v]) => {
if (positionDirty && [
'left', 'top', 'bottom', 'right', 'width', 'height'].includes(k)
) {
return
}
el.style[k] = v
})
return
}
@ -275,13 +302,38 @@ export function setupInjectedUI (
el.id = id
el.dataset.injectedUi = key || ''
// TODO: Support more
el.innerHTML = ui.template
if (float) {
content = document.createElement('div')
content.classList.add('ls-ui-float-content')
el.appendChild(content)
} else {
content = el
}
// TODO: enhance template
content.innerHTML = ui.template
attrs && Object.entries(attrs).forEach(([k, v]) => {
el.setAttribute(k, v)
})
ui.style && Object.entries(ui.style).forEach(([k, v]) => {
el.style[k] = v
})
let teardownUI: () => void
let disposeFloat: () => void
if (float) {
el.setAttribute('draggable', 'true')
el.setAttribute('resizable', 'true')
ui.close && (el.dataset.close = ui.close)
el.classList.add('lsp-ui-float-container', 'visible')
disposeFloat = (
pl._setupResizableContainer(el, key),
pl._setupDraggableContainer(el, { key, close: () => teardownUI(), title: attrs?.title }))
}
target.appendChild(el);
// TODO: How handle events
@ -297,9 +349,17 @@ export function setupInjectedUI (
}, false)
})
return () => {
// callback
initialCallback?.({ el, float })
teardownUI = () => {
disposeFloat?.()
injectedUIEffects.delete(key)
target!.removeChild(el)
}
injectedUIEffects.set(key, teardownUI)
return teardownUI
}
export function transformableEvent (target: HTMLElement, e: Event) {

View File

@ -224,8 +224,7 @@ export class ChildAPI {
// Reply to Parent
resolveValue(this.model, property)
.then(value => {
// @ts-ignore
e.source.postMessage({
(e.source as WindowProxy).postMessage({
property,
postmate: 'reply',
type: messageType,
@ -383,7 +382,7 @@ export class Model {
*/
sendHandshakeReply () {
return new Promise((resolve, reject) => {
const shake = (e) => {
const shake = (e: MessageEvent<any>) => {
if (!e.data.postmate) {
return
}
@ -395,7 +394,7 @@ export class Model {
if (process.env.NODE_ENV !== 'production') {
log('Child: Sending handshake reply to Parent')
}
e.source.postMessage({
(e.source as WindowProxy).postMessage({
postmate: 'handshake-reply',
type: messageType,
}, e.origin)

File diff suppressed because one or more lines are too long

View File

@ -150,6 +150,16 @@ contextBridge.exposeInMainWorld('apis', {
return await ipcRenderer.invoke('call-application', type, ...args)
},
/**
* internal
* @param type
* @param args
* @private
*/
async _callMainWin (type, ...args) {
return await ipcRenderer.invoke('call-main-win', type, ...args)
},
getFilePathFromClipboard,
setZoomFactor (factor) {

View File

@ -51,6 +51,7 @@
{:plugins true ; pdf
:nodeIntegration false
:nodeIntegrationInWorker false
:webSecurity (not dev?)
:contextIsolation true
:spellcheck ((fnil identity true) (cfgs/get-item :spell-check))
;; Remove OverlayScrollbars and transition `.scrollbar-spacing`
@ -171,6 +172,7 @@
[^js win]
(let [toggle-win-channel "toggle-max-or-min-active-win"
call-app-channel "call-application"
call-win-channel "call-main-win"
export-publish-assets "export-publish-assets"
quit-dirty-state "set-quit-dirty-state"
web-contents (. win -webContents)]
@ -196,6 +198,13 @@
(fn [_ type & args]
(try
(js-invoke app type args)
(catch js/Error e
(js/console.error e)))))
(.handle call-win-channel
(fn [_ type & args]
(try
(js-invoke @*win type args)
(catch js/Error e
(js/console.error e))))))
@ -243,7 +252,8 @@
#(do (.removeHandler ipcMain toggle-win-channel)
(.removeHandler ipcMain export-publish-assets)
(.removeHandler ipcMain quit-dirty-state)
(.removeHandler ipcMain call-app-channel))))
(.removeHandler ipcMain call-app-channel)
(.removeHandler ipcMain call-win-channel))))
(defn- destroy-window!
[^js win]

View File

@ -8,7 +8,8 @@
[frontend.state :as state]
[frontend.ui :as ui]
[frontend.util :as util]
[rum.core :as rum]))
[rum.core :as rum]
[clojure.string :as string]))
(defn translate [t {:keys [id desc]}]
(when id
@ -24,14 +25,15 @@
[{:keys [id shortcut] :as cmd} chosen?]
(let [first-shortcut (first (str/split shortcut #" \| "))]
(rum/with-context [[t] i18n/*tongue-context*]
(let [desc (translate t cmd)]
[:div.inline-grid.grid-cols-4.gap-x-4.w-full
(let [desc (translate t cmd)]
[:div.inline-grid.grid-cols-4.gap-x-4.w-full
{:class (when chosen? "chosen")}
[:span.col-span-3 desc]
[:div.col-span-1.justify-end.tip.flex
(when (and (keyword? id) (namespace id))
[:code.opacity-20.bg-transparent (namespace id)])
[:code.ml-1 first-shortcut]]]))))
(when-not (string/blank? first-shortcut)
[:code.ml-1 first-shortcut])]]))))
(rum/defcs command-palette <
(shortcut/disable-all-shortcuts)

View File

@ -1,3 +1,7 @@
:root {
--ls-draggable-handle-height: 30px;
}
.cp__plugins {
&-page {
> h1 {
@ -286,22 +290,146 @@
}
}
.lsp-iframe-sandbox, .lsp-shadow-sandbox {
.lsp-iframe-sandbox, .lsp-shadow-sandbox, .lsp-ui-float {
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
z-index: -1;
visibility: hidden;
height: 0;
width: 0;
padding: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
&-container {
position: absolute;
top: 0;
left: 0;
z-index: -1;
visibility: hidden;
display: none;
height: 0;
width: 0;
padding: 0;
margin: 0;
border:2px solid var(--ls-border-color);
&.visible {
z-index: var(--ls-z-index-level-2);
width: 100%;
height: 100%;
visibility: visible;
display: block;
}
&[draggable=true] {
-webkit-user-drag: none;
> .draggable-handle {
display: block;
height: var(--ls-draggable-handle-height);
cursor: move;
user-select: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
> .th {
display: flex;
align-items: center;
height: var(--ls-draggable-handle-height);
user-select: none;
position: relative;
background-color: var(--ls-secondary-background-color);
color: var(--ls-primary-text-color);
> .l {
flex-basis: 80%;
}
> .r {
position: absolute;
right: 0;
top: 0;
}
h3 {
padding: 0 5px;
white-space: nowrap;
max-width: 60%;
overflow: hidden;
-webkit-line-clamp: 1;
text-overflow: ellipsis;
}
a.button {
&:hover {
background-color: transparent;
}
}
}
}
.lsp-iframe-sandbox,
.lsp-shadow-sandbox,
.ls-ui-float-content {
height: calc(100% - var(--ls-draggable-handle-height));
width: 100%;
margin-top: var(--ls-draggable-handle-height);
overflow: auto;
}
.ls-ui-float-content {
user-select: text;
}
&.is-dragging {
/*height: var(--ls-draggable-handle-height) !important;*/
overflow: hidden;
opacity: .7;
> .draggable-handle {
background-color: rgba(0, 0, 0, .1);
height: 100%;
z-index: 3;
}
}
}
&[resizable=true] {
> .resizable-handle {
position: absolute;
bottom: -1px;
right: -1px;
height: 15px;
width: 15px;
z-index: 2;
opacity: 0;
cursor: nwse-resize;
user-select: none;
}
&.is-resizing {
> .resizable-handle {
width: 90%;
height: 80%;
}
}
}
}
}
.lsp-ui-float-container {
top: 40%;
left: 30%;
.draggable-handle {
}
&.visible {
z-index: 1;
width: 100%;
height: 100%;
visibility: visible;
height: unset;
}
}

View File

@ -15,7 +15,11 @@
[]
(rfe/start!
(rf/router routes/routes nil)
route/set-route-match!
(fn [route]
(route/set-route-match! route)
(plugin-handler/hook-plugin-app
:route-changed (select-keys route [:template :path :parameters])))
;; set to false to enable HistoryAPI
{:use-fragment true}))

View File

@ -12,10 +12,11 @@
;; action fn expects zero number of arities
(fn [action] (zero? (.-length action)))))
(s/def :command/shortcut string?)
(s/def :command/tag vector?)
(s/def :command/command
(s/keys :req-un [:command/id :command/desc :command/action]
:opt-un [:command/shortcut]))
:opt-un [:command/shortcut :command/tag]))
(defn global-shortcut-commands []
(->> [:shortcut.handler/editor-global
@ -31,8 +32,13 @@
(->> (get @state/state :command-palette/commands)
(sort-by :id)))
(defn history []
(or (storage/get "commands-history") []))
(defn get-commands-unique []
(reduce #(assoc %1 (:id %2) %2) {}
(get @state/state :command-palette/commands)))
(defn history
([] (or (storage/get "commands-history") []))
([vals] (storage/set "commands-history" vals)))
(defn- assoc-invokes [cmds]
(let [invokes (->> (history)
@ -82,6 +88,15 @@
:id id})
(state/set-state! :command-palette/commands (conj cmds command)))))
(defn unregister
[id]
(let [id (keyword id)
cmds (get-commands-unique)]
(when (contains? cmds id)
(state/set-state! :command-palette/commands (vals (dissoc cmds id)))
;; clear history
(history (filter #(not= (:id %) id) (history))))))
(defn register-global-shortcut-commands []
(let [cmds (global-shortcut-commands)]
(doseq [cmd cmds] (register cmd))))

View File

@ -151,11 +151,12 @@
[block-id]
(when block-id
(when-let [block (db/pull [:block/uuid block-id])]
(state/sidebar-add-block!
(state/get-current-repo)
(:db/id block)
:block
block))))
(let [page? (nil? (:block/page block))]
(state/sidebar-add-block!
(state/get-current-repo)
(:db/id block)
(if page? :page :block)
block)))))
(defn reset-cursor-range!
[node]

View File

@ -20,6 +20,7 @@
[frontend.handler.notification :as notification]
[frontend.handler.page :as page-handler]
[frontend.handler.ui :as ui-handler]
[frontend.commands :as commands]
[frontend.spec :as spec]
[frontend.state :as state]
[frontend.ui :as ui]
@ -207,6 +208,9 @@
(defmethod handle :instrument [[_ {:keys [type payload]}]]
(posthog/capture type payload))
(defmethod handle :exec-plugin-cmd [[_ {:keys [type key pid cmd action]}]]
(commands/exec-plugin-simple-command! pid cmd action))
(defn run!
[]
(let [chan (state/get-events-chan)]

View File

@ -188,6 +188,15 @@
[pid]
(swap! state/state md/dissoc-in [:plugin/installed-commands (keyword pid)]))
(defn simple-cmd->palette-cmd
[pid {:keys [key label type desc] :as cmd} action]
(let [palette-cmd {:id (keyword (str "plugin." pid "/" key))
:desc (or desc label)
:action (fn []
(state/pub-event!
[:exec-plugin-cmd {:type type :key key :pid pid :cmd cmd :action action}]))}]
palette-cmd))
(defn register-plugin-simple-command
;; action => [:action-key :event-key]
[pid {:keys [key label type] :as cmd} action]
@ -340,7 +349,7 @@
clear-commands! (fn [pid]
;; commands
(unregister-plugin-slash-command pid)
(unregister-plugin-simple-command pid)
(invoke-exported-api "unregister_plugin_simple_command" pid)
(unregister-plugin-ui-items pid))
_ (doto js/LSPluginCore

View File

@ -2,7 +2,6 @@
(:require [clojure.string :as string]
[frontend.date :as date]
[frontend.db :as db]
[frontend.handler.plugin :as plugin-handler]
[frontend.handler.ui :as ui-handler]
[frontend.handler.search :as search-handler]
[frontend.state :as state]
@ -112,8 +111,7 @@
(jump-to-anchor! anchor)
(util/scroll-to (util/app-scroll-container-node)
(state/get-saved-scroll-position)
false))
(plugin-handler/hook-plugin-app :route-changed (select-keys route [:template :path :parameters]))))
false))))
(defn go-to-search!
[search-mode]

View File

@ -0,0 +1,114 @@
(ns frontend.modules.layout.core
(:require [cljs-bean.core :as bean]
[frontend.util :as frontend-utils]))
(defonce *movable-containers (atom {}))
(defn- calc-layout-data
[^js cnt ^js evt]
(.toJSON (.getBoundingClientRect cnt)))
(defn ^:export move-container-to-top
[identity]
(when-let [^js/HTMLElement container (and (> (count @*movable-containers) 1)
(get @*movable-containers identity))]
(let [zdx (->> @*movable-containers
(map (fn [[_ ^js el]]
(let [^js c (js/getComputedStyle el)
v1 (.-visibility c)
v2 (.-display c)]
(when-let [z (and (= "visible" v1)
(not= "none" v2)
(.-zIndex c))]
z))))
(remove nil?))
zdx (bean/->js zdx)
zdx (and zdx (js/Math.max.apply nil zdx))
zdx' (frontend-utils/safe-parse-int (.. container -style -zIndex))]
(when (or (nil? zdx') (not= zdx zdx'))
(set! (.. container -style -zIndex) (inc zdx))))))
(defn ^:export setup-draggable-container!
[^js/HTMLElement el callback]
(when-let [^js/HTMLElement handle (.querySelector el ".draggable-handle")]
(let [^js cls (.-classList el)
^js ds (.-dataset el)
identity (.-identity ds)
ing? "is-dragging"]
;; draggable
(-> (js/interact handle)
(.draggable
(bean/->js
{:listeners
{:move (fn [^js/MouseEvent e]
(let [^js dset (.-dataset el)
dx (.-dx e)
dy (.-dy e)
dx' (frontend-utils/safe-parse-float (.-dx dset))
dy' (frontend-utils/safe-parse-float (.-dy dset))
x (+ dx (if dx' dx' 0))
y (+ dy (if dy' dy' 0))]
;; update container position
(set! (.. el -style -transform) (str "translate(" x "px, " y "px)"))
;; cache dx dy
(set! (.. el -dataset -dx) x)
(set! (.. el -dataset -dy) y)))}}))
(.on "dragstart" (fn [] (.add cls ing?)))
(.on "dragend" (fn [e]
(.remove cls ing?)
(callback (bean/->js (calc-layout-data el e))))))
;; manager
(swap! *movable-containers assoc identity el)
#(swap! *movable-containers dissoc identity el))))
(defn ^:export setup-resizable-container!
[^js/HTMLElement el callback]
(let [^js cls (.-classList el)
^js ds (.-dataset el)
identity (.-identity ds)
ing? "is-resizing"]
;; resizable
(-> (js/interact el)
(.resizable
(bean/->js
{:edges
{:left true :top true :bottom true :right true}
:listeners
{:start (fn [] (.add cls ing?))
:end (fn [e] (.remove cls ing?) (callback (bean/->js (calc-layout-data el e))))
:move (fn [^js/MouseEvent e]
(let [^js dset (.-dataset el)
w (.. e -rect -width)
h (.. e -rect -height)
;; update position from top/left
dx (.. e -deltaRect -left)
dy (.. e -deltaRect -top)
dx' (frontend-utils/safe-parse-float (.-dx dset))
dy' (frontend-utils/safe-parse-float (.-dy dset))
x (+ dx (if dx' dx' 0))
y (+ dy (if dy' dy' 0))]
;; update container position
(set! (.. el -style -transform) (str "translate(" x "px, " y "px)"))
;; update container size
(set! (.. el -style -width) (str w "px"))
(set! (.. el -style -height) (str h "px"))
(set! (. dset -dx) x)
(set! (. dset -dy) y)))}})))
;; manager
(swap! *movable-containers assoc identity el)
#(swap! *movable-containers dissoc identity el)))

View File

@ -9,6 +9,9 @@
[frontend.state :as state]
[frontend.ui.date-picker]
[frontend.util :as util]
[frontend.util.cursor :as cursor]
[frontend.handler.plugin :as plugin-handler]
[cljs-bean.core :as bean]
[goog.dom :as gdom]
[promesa.core :as p]
[goog.object :as gobj]
@ -32,7 +35,21 @@
(def Tippy (r/adapt-class (gobj/get react-tippy "Tooltip")))
(def ReactTweetEmbed (r/adapt-class react-tweet-embed))
(rum/defc ls-textarea < rum/reactive
(rum/defc ls-textarea
< rum/reactive
{:did-mount (fn [state]
(let [^js el (rum/dom-node state)]
(. el addEventListener "mouseup"
#(let [start (.-selectionStart el)
end (.-selectionEnd el)]
(when-let [e (and (not= start end)
{:caret (cursor/get-caret-pos el)
:start start :end end
:text (. (.-value el) substring start end)
:point {:x (.-x %) :y (.-y %)}})]
(plugin-handler/hook-plugin-editor :input-selection-end (bean/->js e))))))
state)}
[{:keys [on-change] :as props}]
(let [skip-composition? (or
(state/sub :editor/show-page-search?)
@ -506,7 +523,7 @@
(state/close-settings!))
modal-panel-content (or modal-panel-content (fn [close] [:div]))]
[:div.ui__modal
{:style {:z-index (if show? 100 -1)}}
{:style {:z-index (if show? 9999 -1)}}
(css-transition
{:in show? :timeout 0}
(fn [state]

View File

@ -45,7 +45,7 @@
.ui__notifications {
position: fixed;
z-index: 99;
z-index: var(--ls-z-index-level-4);
width: 100%;
top: 3.2em;
pointer-events: none;

View File

@ -23,6 +23,7 @@
[frontend.handler.plugin :as plugin-handler]
[frontend.modules.outliner.core :as outliner]
[frontend.modules.outliner.tree :as outliner-tree]
[frontend.handler.command-palette :as palette-handler]
[electron.listener :as el]
[frontend.state :as state]
[frontend.util :as util]
@ -32,7 +33,8 @@
[medley.core :as medley]
[promesa.core :as p]
[reitit.frontend.easy :as rfe]
[sci.core :as sci]))
[sci.core :as sci]
[frontend.modules.layout.core]))
;; helpers
(defn- normalize-keyword-for-json
@ -227,10 +229,21 @@
(rest %)) actions)]))))
(def ^:export register_plugin_simple_command
(fn [pid ^js cmd-action]
(fn [pid ^js cmd-action palette?]
(when-let [[cmd action] (bean/->clj cmd-action)]
(plugin-handler/register-plugin-simple-command
pid cmd (assoc action 0 (keyword (first action)))))))
(let [action (assoc action 0 (keyword (first action)))]
(plugin-handler/register-plugin-simple-command pid cmd action)
(when-let [palette-cmd (and palette? (plugin-handler/simple-cmd->palette-cmd pid cmd action))]
(palette-handler/register palette-cmd))))))
(defn ^:export unregister_plugin_simple_command
[pid]
(plugin-handler/unregister-plugin-simple-command pid)
(let [palette-matched (->> (palette-handler/get-commands)
(filter #(string/includes? (str (:id %)) (str "plugin." pid))))]
(when (seq palette-matched)
(doseq [cmd palette-matched]
(palette-handler/unregister (:id cmd))))))
(def ^:export register_plugin_ui_item
(fn [pid type ^js opts]
@ -328,10 +341,11 @@
(some-> (if-let [page (db-model/get-page name)]
page
(let [properties (bean/->clj properties)
{:keys [redirect createFirstBlock format]} (bean/->clj opts)
{:keys [redirect createFirstBlock format journal]} (bean/->clj opts)
name (page-handler/create!
name
{:redirect? (if (boolean? redirect) redirect true)
:journal? journal
:create-first-block? (if (boolean? createFirstBlock) createFirstBlock true)
:format format
:properties properties})]
@ -348,6 +362,10 @@
(def ^:export rename_page
page-handler/rename!)
(defn ^:export open_in_right_sidebar
[block-uuid]
(editor-handler/open-block-in-sidebar! (medley/uuid block-uuid)))
(def ^:export edit_block
(fn [block-uuid {:keys [pos] :or {pos :max} :as opts}]
(when-let [block-uuid (and block-uuid (medley/uuid block-uuid))]
@ -523,6 +541,11 @@
content (if hiccup? (parse-hiccup-ui content) content)]
(notification/show! content (keyword status)))))
(defn ^:export query_element_by_id
[id]
(let [^js el (gdom/getElement id)]
(if el (str (.-tagName el) "#" id) false)))
(defn ^:export force_save_graph
[]
(p/let [_ (el/persist-dbs!)