TASK: #104293 - add clock widgets for homescreen

This commit is contained in:
luojiahao 2022-10-04 09:11:37 +08:00
parent 896cf24e2c
commit b0fa69bbce
7 changed files with 530 additions and 2 deletions

12
bin/build-widget Executable file
View File

@ -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 $@

View File

@ -13,7 +13,8 @@
"dev": "vite", "dev": "vite",
"build": "yarn run esbuild:ts && yarn run build:ts", "build": "yarn run esbuild:ts && yarn run build:ts",
"build:ts": "yarn tsc --build tsconfig-all.json", "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", "esbuild:ts": "node ./tasks/esbuild-packages.js",
"format": "npm run format:prettier", "format": "npm run format:prettier",
"format:prettier": "prettier \"**/*.{cjs,html,js,json,md,ts}\" --write" "format:prettier": "prettier \"**/*.{cjs,html,js,json,md,ts}\" --write"

View File

@ -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

433
src/widgets/gaia-widget.ts Normal file
View File

@ -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
}
)
}
/**
* ascript 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) {}

View File

@ -21,6 +21,8 @@ export const watchFiles = async () => {
const files = await fg([ const files = await fg([
'./src/components/**/!(*.d).ts', './src/components/**/!(*.d).ts',
'./src/lib/**/!(*.d).ts', './src/lib/**/!(*.d).ts',
"src/widgets/*.ts",
"src/widgets/**/*.ts"
]) ])
return files return files
} }

View File

@ -5,6 +5,6 @@
"noEmit": true, "noEmit": true,
"emitDeclarationOnly": false "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"] "exclude": ["src/*/node_modules/**/*.ts"]
} }

13
widgets.config.ts Normal file
View File

@ -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}/`,
},
})