TASK: #104293 - add clock widgets for homescreen
This commit is contained in:
parent
896cf24e2c
commit
b0fa69bbce
|
@ -0,0 +1,12 @@
|
|||
#! /bin/bash -e
|
||||
|
||||
cmd_run() {
|
||||
./node_modules/.bin/vite build --config ./widgets.config.ts
|
||||
}
|
||||
|
||||
cmd_particular() {
|
||||
export WIDGET_FILE_NAME=${@:1}
|
||||
cmd_run
|
||||
}
|
||||
|
||||
cmd_particular $@
|
|
@ -13,7 +13,8 @@
|
|||
"dev": "vite",
|
||||
"build": "yarn run esbuild:ts && yarn run build:ts",
|
||||
"build:ts": "yarn tsc --build tsconfig-all.json",
|
||||
"build:vite": "tsc && vite build",
|
||||
"build:vite": "vite build",
|
||||
"build:widgets": "bin/build-widget",
|
||||
"esbuild:ts": "node ./tasks/esbuild-packages.js",
|
||||
"format": "npm run format:prettier",
|
||||
"format:prettier": "prettier \"**/*.{cjs,html,js,json,md,ts}\" --write"
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
import GaiaWidget from '../gaia-widget'
|
||||
import '../../components/clock/clock'
|
||||
import {StarClock} from '../../components/clock/clock'
|
||||
import {customElement} from 'lit/decorators.js'
|
||||
|
||||
@customElement('gaia-clock')
|
||||
class ClockWidget extends GaiaWidget {
|
||||
_type: 'transparent' | 'daile' = 'daile'
|
||||
|
||||
get type() {
|
||||
return this._type
|
||||
}
|
||||
|
||||
set type(value: 'transparent' | 'daile') {
|
||||
if (value !== this._type) {
|
||||
this._type = value
|
||||
this.clock.type = value
|
||||
}
|
||||
}
|
||||
|
||||
constructor({
|
||||
url,
|
||||
appName,
|
||||
origin,
|
||||
size,
|
||||
manifestWidgetName,
|
||||
}: {
|
||||
url: string
|
||||
size: [number, number]
|
||||
origin: string
|
||||
appName: string
|
||||
manifestWidgetName: string
|
||||
}) {
|
||||
super({
|
||||
url: url || 'js/widgets/clock.js',
|
||||
appName: appName || 'homescreen',
|
||||
origin: origin || 'http://homescreen.localhost/manifest.webmanifest',
|
||||
size: size || [2, 2],
|
||||
manifestWidgetName: manifestWidgetName || 'clock',
|
||||
})
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
clock!: StarClock
|
||||
connectedCallback() {
|
||||
this.clock = this.shadowRoot!.querySelector('star-clock')!
|
||||
this.clock.date = new Date()
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.clock.date = new Date()
|
||||
}
|
||||
get template() {
|
||||
return `
|
||||
<style>
|
||||
:host {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<star-clock type="diale"></star-clock>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export default ClockWidget
|
|
@ -0,0 +1,433 @@
|
|||
// import {html, LitElement, css} from 'lit'
|
||||
// import {customElement} from 'lit/decorators.js'
|
||||
|
||||
/**
|
||||
* 主屏小组件:
|
||||
*
|
||||
* const widget = new GaiaWidget({
|
||||
* url: 'http://rigesterapp.localhost/widget/a_widget.html',
|
||||
* size: manifest.b2g_features.widget.a_widget.size || [1, 1],
|
||||
* origin: 'http://rigesterapp.localhost',
|
||||
* appName: 'rigesterapp'
|
||||
* manifestWidgetName: 'test-widget'
|
||||
* })
|
||||
*
|
||||
* 向注册小组件的应用发送Activity请求:
|
||||
* new WebActivity(activityName, {
|
||||
* data: {
|
||||
* widgetTitle, // 组件名,写在小组件页面的 title 标签内
|
||||
* manifestName, // 清单内的组件名,写在清单文件的 widget 对象中
|
||||
* viewName, // 视图名,写在 template 标签内
|
||||
* },
|
||||
* type: ['widget']
|
||||
* })
|
||||
*
|
||||
* TBD:
|
||||
* 1. 多视图功能,每个视图由 <template name="view-name"></template> 包裹,与单视图
|
||||
* 写法(没有 <template> 标签)互斥
|
||||
*/
|
||||
// @customElement('gaia-widget')
|
||||
export default class GaiaWidget extends HTMLElement {
|
||||
url!: string
|
||||
size!: [number, number]
|
||||
origin!: string
|
||||
appName!: string
|
||||
manifestWidgetName!: string // 清单文件中的组件名
|
||||
widgetTitle: string = '' // 组件名
|
||||
viewName: string = '' // 视图名,当该组件有多个视图时,视图名用于区分
|
||||
createTime: number = new Date().getTime()
|
||||
container!: HTMLElement
|
||||
activityRequestTimer: number | undefined
|
||||
constructor({
|
||||
url,
|
||||
size,
|
||||
origin,
|
||||
appName,
|
||||
manifestWidgetName,
|
||||
}: {
|
||||
url: string
|
||||
size: [number, number]
|
||||
origin: string
|
||||
appName: string
|
||||
manifestWidgetName: string
|
||||
}) {
|
||||
super()
|
||||
this.widgetTitle = ''
|
||||
this.manifestWidgetName = manifestWidgetName
|
||||
this.viewName = ''
|
||||
this.createTime = new Date().getTime()
|
||||
this.url = url // 组件文件地址
|
||||
this.origin = origin // 注册应用 origin
|
||||
this.size = size // 组件行列大小
|
||||
this.appName = appName
|
||||
this.attachShadow({mode: 'open'})
|
||||
this.shadowRoot!.innerHTML = this.template
|
||||
|
||||
this.init()
|
||||
}
|
||||
|
||||
connectedCallback() {}
|
||||
|
||||
init() {
|
||||
// 补全小组件入口地址
|
||||
if (!/^http(s)?:\/\//.test(this.url)) {
|
||||
if (/^\//.test(this.url)) {
|
||||
this.url = this.origin + this.url
|
||||
} else {
|
||||
this.url = this.origin + '/' + this.url
|
||||
}
|
||||
}
|
||||
this.container = this.shadowRoot!.querySelector(
|
||||
'#gaia-widget-container-' + this.createTime
|
||||
)!
|
||||
this.container.addEventListener('touchstart', this)
|
||||
this.container.addEventListener('touchmove', this)
|
||||
this.container.addEventListener('touchend', this)
|
||||
this.container.addEventListener('click', this)
|
||||
|
||||
// 需要在构造函数中被调用,在connectedCallback中调用会导致移动元素时刷新的问题
|
||||
this.querySources(this.url)
|
||||
// @ts-ignore
|
||||
.then(this.parseHTML)
|
||||
.then(() => {
|
||||
// 防止安装、更新应用时,首次 Activity 请求因注册方 Service Worker 未完成安装而丢失
|
||||
let n = 0
|
||||
this.activityRequestTimer = window.setInterval(() => {
|
||||
if (n++ > 4) clearInterval(this.activityRequestTimer)
|
||||
this.openActivity({data: {type: 'ready'}})
|
||||
}, 100)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
}
|
||||
|
||||
refresh(widgetInfo: {size: [number, number]; url: string}) {
|
||||
if (
|
||||
this.size[0] != widgetInfo.size[0] ||
|
||||
this.size[1] != widgetInfo.size[1]
|
||||
) {
|
||||
// 更新后的组件大小发生了变化
|
||||
this.dispatchEvent(new CustomEvent('widget-resize'))
|
||||
}
|
||||
|
||||
this.url = widgetInfo.url
|
||||
this.shadowRoot!.innerHTML = this.template
|
||||
this.container.removeEventListener('touchstart', this)
|
||||
this.container.removeEventListener('touchmove', this)
|
||||
this.container.removeEventListener('touchend', this)
|
||||
this.container.removeEventListener('click', this)
|
||||
|
||||
this.init()
|
||||
}
|
||||
|
||||
touchTimer: number | undefined
|
||||
handleEvent(event: TouchEvent) {
|
||||
switch (event.type) {
|
||||
case 'touchstart':
|
||||
console.log(event)
|
||||
clearTimeout(this.touchTimer)
|
||||
this.touchTimer = window.setTimeout(() => {
|
||||
console.log('touchTimer end')
|
||||
this.touchTimer = undefined
|
||||
}, 100)
|
||||
break
|
||||
case 'touchmove':
|
||||
break
|
||||
case 'touchend':
|
||||
case 'click':
|
||||
console.log(this.touchTimer)
|
||||
// @ts-ignore
|
||||
this.touchTimer && this.dispatch(event)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
toCamelCase = (string: string) => {
|
||||
// @ts-ignore
|
||||
return string.replace(/\-(.)/g, function replacer(str, p1) {
|
||||
return p1.toUpperCase()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 向注册应用发送事件
|
||||
*/
|
||||
dispatch = (event: CustomEvent) => {
|
||||
// @ts-ignore
|
||||
if (!WebActivity) {
|
||||
return console.error('WebActivity is not defined!')
|
||||
}
|
||||
|
||||
const data = this.wrapEvent(event)
|
||||
this.openActivity({data})
|
||||
}
|
||||
|
||||
openActivity = ({data}: any) => {
|
||||
data.widgetTitle = this.widgetTitle
|
||||
data.manifestName = this.manifestWidgetName
|
||||
data.viewName = this.viewName
|
||||
|
||||
// @ts-ignore
|
||||
const activity = new WebActivity(
|
||||
`${this.appName}_${this.manifestWidgetName}`,
|
||||
{data, type: ['widget']}
|
||||
)
|
||||
|
||||
activity
|
||||
.start()
|
||||
.then((result: any) => {
|
||||
if (this.activityRequestTimer) clearInterval(this.activityRequestTimer)
|
||||
this.handleActivity(result)
|
||||
})
|
||||
.catch((err: any) => console.log(err))
|
||||
}
|
||||
|
||||
wrapEvent = (event: any) => {
|
||||
const target = event?.path?.[0] || event.originalTarget
|
||||
const style: {[prop: string]: string} = {}
|
||||
for (let index = 0; index < target.style.length; index++) {
|
||||
const styleName = target.style.item(index)
|
||||
const camelName = this.toCamelCase(styleName)
|
||||
style[styleName] = target.style[camelName]
|
||||
}
|
||||
|
||||
return {
|
||||
timeStamp: event.timeStamp,
|
||||
type: event.type,
|
||||
id: target.id,
|
||||
className: target.className,
|
||||
classList: target.classList,
|
||||
dataset: target.dataset,
|
||||
style,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求组件资源
|
||||
*/
|
||||
querySources(url: string) {
|
||||
return new Promise((res, rej) => {
|
||||
// @ts-ignore
|
||||
const xhr = new XMLHttpRequest({mozSystem: true})
|
||||
xhr.open('GET', url, true)
|
||||
// xhr.responseType = "text";
|
||||
xhr.send()
|
||||
xhr.onload = () => {
|
||||
if (xhr.status == 200) {
|
||||
if (!xhr.response) {
|
||||
rej(
|
||||
new Error(
|
||||
'Empty response to ' + url + ', ' + 'possibly syntax error'
|
||||
)
|
||||
)
|
||||
}
|
||||
res(xhr.response)
|
||||
} else {
|
||||
rej(
|
||||
new Error(
|
||||
`query soures err! xhr.status: ${xhr.status}; xhr.url: ${url}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文档字符串转化为 DOM 节点
|
||||
* TBD:当前一个组件仅能有一个视图,需要添加多视图情况
|
||||
* @param {DOMString} html 文档字符串
|
||||
* @returns
|
||||
*/
|
||||
parseHTML = (html: string) => {
|
||||
html = this.handleSource(this.filterDom(html))
|
||||
|
||||
const parse = new DOMParser()
|
||||
const htmlDocument = parse.parseFromString(html, 'text/html')
|
||||
const {head, body} = htmlDocument
|
||||
// @ts-ignore
|
||||
this.widgetTitle = head?.querySelector('title').innerHTML
|
||||
if (this.widgetTitle === void 0) {
|
||||
throw new Error('There is not a widget name yet!')
|
||||
}
|
||||
|
||||
this.appendStyle(htmlDocument)
|
||||
this.container.innerHTML = body.innerHTML
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理来自注册应用的 Activity 传入的操作命令
|
||||
*/
|
||||
handleActivity = (data: any) => {
|
||||
const {changeContext, changeAttributes} = data
|
||||
|
||||
this.changeAttributes(changeAttributes)
|
||||
this.changeContext(changeContext)
|
||||
}
|
||||
|
||||
changeContext = (data: any) => {
|
||||
if (!data) return
|
||||
|
||||
data.forEach((operator: any) => {
|
||||
const dom = this.shadowRoot!.querySelector(operator.target)
|
||||
dom.innerText = operator.data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 setAttribute 改变指定元素的属性
|
||||
*/
|
||||
changeAttributes = (data: any) => {
|
||||
if (!data) return
|
||||
|
||||
data.forEach((operator: any) => {
|
||||
if (operator?.data?.length !== 2) throw new Error('Illegal parameter')
|
||||
|
||||
const dom = this.shadowRoot!.querySelector(operator.target)
|
||||
|
||||
if (operator.data[0] === 'src' && !/^http:\/\//.test(operator.data[1])) {
|
||||
let str = operator.data[1].replace(/^\//, '')
|
||||
operator.data[1] = this.origin + '/' + str
|
||||
}
|
||||
dom.setAttribute(...operator.data)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* TBD: 需要考虑更多元素
|
||||
* 处理img资源请求路径
|
||||
*/
|
||||
handleSource = (html: string) => {
|
||||
html = html.replace(
|
||||
/<img.*?src="(.*?)"/g,
|
||||
(string: string, url: string) => {
|
||||
if (!/^http:\/\//.test(url)) {
|
||||
string = string.replace(url, `${this.origin}/${url}`)
|
||||
}
|
||||
return string
|
||||
}
|
||||
)
|
||||
return html
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定元素内的style标签和[rel="stylesheet"]标签的样式
|
||||
* 加载并添加进影子节点内
|
||||
*/
|
||||
appendStyle = (dom: Document) => {
|
||||
this.collectStyle(dom).then((styles) => {
|
||||
for (const style of styles) {
|
||||
this.shadowRoot!.appendChild(style)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集指定元素内的style标签和[rel="stylesheet"]标签
|
||||
*/
|
||||
collectStyle(dom: any): Promise<HTMLStyleElement[]> {
|
||||
return new Promise((res) => {
|
||||
let promises: Promise<any>[] = []
|
||||
let styles = [...dom.querySelectorAll('style')] || []
|
||||
const links = dom.querySelectorAll('[rel="stylesheet"]')
|
||||
|
||||
if (links?.length) {
|
||||
links.forEach((element: HTMLLinkElement) => {
|
||||
let href = element.href
|
||||
// 将href的源改为注册组件的源
|
||||
href = href.replace(location.origin, this.origin)
|
||||
if (!/^http:\/\//.test(href)) {
|
||||
href = `${this.origin}/${href}`
|
||||
}
|
||||
promises.push(this.querySources(href))
|
||||
})
|
||||
|
||||
Promise.all(promises).then((sheets) => {
|
||||
for (const css of sheets) {
|
||||
const style = document.createElement('style')
|
||||
style.innerHTML = this.modifyCss(css)
|
||||
styles.push(style)
|
||||
}
|
||||
|
||||
res(styles)
|
||||
})
|
||||
} else {
|
||||
for (const style of styles) {
|
||||
style.innerHTML = this.modifyCss(style.innerHTML)
|
||||
}
|
||||
|
||||
res(styles)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 html body 样式,并将其样式转为对该组件的样式
|
||||
*/
|
||||
modifyCss = (css: string) => {
|
||||
return css.replace(
|
||||
/([^{]+)(\{[^}]+\})/g,
|
||||
// @ts-ignore
|
||||
(group, selector, declarations) => {
|
||||
selector = selector.replace(
|
||||
/(?<!=[a-zA-Z]|-)(html|body)(?!=[a-zA-Z]|-)/g,
|
||||
// @ts-ignore
|
||||
(selecor, str) => {
|
||||
return `#gaia-widget-container-${this.createTime}`
|
||||
}
|
||||
)
|
||||
return selector + declarations
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛除 a、script 标签,更改 form 标签的 action 属性
|
||||
*/
|
||||
filterDom = (string: string) => {
|
||||
string = string.replace(
|
||||
/(<a)(.*?>.*)(<\/a>)/g,
|
||||
// @ts-ignore
|
||||
function (string, head, body, tail) {
|
||||
body = body.replace(/href=".*?"(.*>)/, '$1')
|
||||
return `<span${body}</span>`
|
||||
}
|
||||
)
|
||||
|
||||
string = string.replace(/<script.*?<\/script>/, '')
|
||||
|
||||
// @ts-ignore
|
||||
string = string.replace(/(<form .*action=")(.*)/g, function (string, form) {
|
||||
return `${form}#`
|
||||
})
|
||||
|
||||
return string
|
||||
}
|
||||
|
||||
get template() {
|
||||
return `
|
||||
<div id="gaia-widget-container-${this.createTime}"></div>
|
||||
<style>${this.shadowStyle}</style>
|
||||
`
|
||||
}
|
||||
|
||||
get shadowStyle() {
|
||||
return `
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
:host > div[id^=gaia-widget-container-] {
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
try {
|
||||
customElements.define('gaia-widget', GaiaWidget)
|
||||
} catch (error) {}
|
|
@ -21,6 +21,8 @@ export const watchFiles = async () => {
|
|||
const files = await fg([
|
||||
'./src/components/**/!(*.d).ts',
|
||||
'./src/lib/**/!(*.d).ts',
|
||||
"src/widgets/*.ts",
|
||||
"src/widgets/**/*.ts"
|
||||
])
|
||||
return files
|
||||
}
|
||||
|
|
|
@ -5,6 +5,6 @@
|
|||
"noEmit": true,
|
||||
"emitDeclarationOnly": false
|
||||
},
|
||||
"include": ["src/components/**/*.ts", "src/lib/**/*.ts"],
|
||||
"include": ["src/components/**/*.ts", "src/lib/**/*.ts", "src/widgets/*.ts", "src/widgets/**/.ts"],
|
||||
"exclude": ["src/*/node_modules/**/*.ts"]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import {defineConfig} from 'vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: `src/widgets/${process.env.WIDGET_FILE_NAME}/${process.env.WIDGET_FILE_NAME}.ts`,
|
||||
formats: ['es'],
|
||||
fileName: `${process.env.WIDGET_FILE_NAME}`
|
||||
},
|
||||
outDir: `dist/widgets/${process.env.WIDGET_FILE_NAME}/`,
|
||||
},
|
||||
})
|
Loading…
Reference in New Issue