diff --git a/packages/duoyun-ui/src/elements/base/resize.ts b/packages/duoyun-ui/src/elements/base/resize.ts index 2e4f73e7..6c1bca83 100644 --- a/packages/duoyun-ui/src/elements/base/resize.ts +++ b/packages/duoyun-ui/src/elements/base/resize.ts @@ -3,16 +3,24 @@ import { GemElement, GemElementOptions } from '@mantou/gem/lib/element'; import { throttle } from '../../lib/utils'; +export type ResizeDetail = { + contentRect: DuoyunResizeBaseElement['contentRect']; + borderBoxSize: DuoyunResizeBaseElement['borderBoxSize']; +}; + export function resizeObserver(ele: DuoyunResizeBaseElement, options: { throttle?: boolean } = {}) { const { throttle: needThrottle = true } = options; const callback = (entryList: ResizeObserverEntry[]) => { entryList.forEach((entry) => { - ele.contentRect = entry.contentRect; - ele.borderBoxSize = entry.borderBoxSize?.[0] - ? entry.borderBoxSize[0] - : { blockSize: ele.contentRect.height, inlineSize: ele.contentRect.width }; + const oldDetail = { contentRect: ele.contentRect, borderBoxSize: ele.borderBoxSize }; + const { x, y, width, height } = entry.contentRect; + // 只支持一个 + // https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry/borderBoxSize + const { blockSize, inlineSize } = entry.borderBoxSize[0]; + ele.contentRect = { x, y, width, height }; + ele.borderBoxSize = { blockSize, inlineSize }; ele.update(); - ele.resize(ele); + ele.resize(oldDetail); }); }; const throttleCallback = needThrottle ? throttle(callback, 300, { leading: true }) : callback; @@ -22,7 +30,7 @@ export function resizeObserver(ele: DuoyunResizeBaseElement, options: { throttle } export class DuoyunResizeBaseElement<_T = Record<string, unknown>> extends GemElement { - @emitter resize: Emitter<DuoyunResizeBaseElement>; + @emitter resize: Emitter<ResizeDetail>; constructor(options: GemElementOptions & { throttle?: boolean } = {}) { super(options); @@ -38,6 +46,8 @@ export class DuoyunResizeBaseElement<_T = Record<string, unknown>> extends GemEl }; contentRect = { + x: 0, + y: 0, height: 0, width: 0, }; diff --git a/packages/duoyun-ui/src/elements/list.ts b/packages/duoyun-ui/src/elements/list.ts index ac29cf62..9d0a5337 100644 --- a/packages/duoyun-ui/src/elements/list.ts +++ b/packages/duoyun-ui/src/elements/list.ts @@ -81,6 +81,7 @@ export class DuoyunListElement extends GemElement<State> { /**@deprecated */ @property data?: any[]; @property items?: any[]; + @property key?: any; // 除了 items 提供另外一种方式来更新 @property renderItem?: (item: any) => TemplateResult; @boolattribute debug: boolean; @@ -134,7 +135,8 @@ export class DuoyunListElement extends GemElement<State> { return (item === undefined && key === undefined) || key === this.getKey!(item); }; - #getElementRowHeight = (ele: DuoyunListItemElement) => ele.borderBoxSize.blockSize + this.#rowGap; + #getRowHeight = (ele?: DuoyunListItemElement) => + (ele ? ele.borderBoxSize.blockSize : this.#itemHeight) + this.#rowGap; #setState = (state: Partial<State>) => { this.#log(state); @@ -144,8 +146,8 @@ export class DuoyunListElement extends GemElement<State> { #isLeftItem = (count: number) => !(count % this.#itemColumnCount); // 没有渲染内容时 - #reLayout = (options: { silent?: boolean } = {}) => { - this.#log('reLayout'); + #reLayout = (options: { silent?: boolean; resize?: boolean } = {}) => { + this.#log('reLayout', options); const { beforeHeight, afterHeight, renderList } = this.state; // 初始状态 if (!renderList.length && !beforeHeight && !afterHeight) { @@ -160,7 +162,7 @@ export class DuoyunListElement extends GemElement<State> { const firstElementY = this === this.scrollContainer ? thisRect.top - this.scrollTop : thisRect.top; // 上下安全余量 const safeHeight = containerRect.height; - // TODO: Improve performance + let beforeHeightSum = 0; let renderHeightSum = 0; let afterHeightSum = 0; @@ -169,8 +171,13 @@ export class DuoyunListElement extends GemElement<State> { let pushed = false; while (node) { const ele = this.#getElement(node.value); + + // 修正那些尚未显示的元素高度,只适用于高度相同的情况 + // 因为之后触发 before visible 需要用到 + if (options.resize) ele.borderBoxSize.blockSize = this.#itemHeight; + const isLeft = this.#isLeftItem(count); - const currentItemHeight = this.#getElementRowHeight(ele); + const currentItemHeight = this.#getRowHeight(ele); const y = firstElementY + beforeHeightSum + renderHeightSum; @@ -223,7 +230,7 @@ export class DuoyunListElement extends GemElement<State> { for (let i = len - 1; i >= 0; i--) { const ele = this.#getElement(this.state.renderList[i]); if (!ele.visible) { - if (this.#isLeftItem(count)) afterHeight += this.#getElementRowHeight(ele); + if (this.#isLeftItem(count)) afterHeight += this.#getRowHeight(ele); len--; } count++; @@ -250,7 +257,7 @@ export class DuoyunListElement extends GemElement<State> { let beforeHeight = 0; for (let i = 0; i < this.#appendCount; i++) { if (!node) break; - if (this.#isLeftItem(i)) beforeHeight += this.#getElementRowHeight(this.#getElement(node.value)); + if (this.#isLeftItem(i)) beforeHeight += this.#getRowHeight(this.#getElement(node.value)); appendList.unshift(node.value); node = node.prev; } @@ -261,6 +268,27 @@ export class DuoyunListElement extends GemElement<State> { }); }; + #appendItems = (items: any[], oldItems?: any[]) => { + if (!oldItems) return; + let beforeHeight = 0; + for (let i = 0; i < items.length; i++) { + if (this.getKey!(items[i]) === this.getKey!(oldItems[0])) break; + if (this.#isLeftItem(i)) beforeHeight += this.#getRowHeight(); + } + if (beforeHeight) { + // 有向前(上)加载数据,必须是列数的倍数 + this.#setState({ beforeHeight: this.state.beforeHeight + beforeHeight }); + // 等待渲染后再滚动 + queueMicrotask(() => { + this.scrollContainer.scrollBy({ + left: 0, + top: beforeHeight, + behavior: 'instant', + }); + }); + } + }; + #onAfterItemVisible = () => { this.#log('onAfterItemVisible'); @@ -278,7 +306,7 @@ export class DuoyunListElement extends GemElement<State> { const ele = this.#getElement(key); if (!ele.visible) { len++; - if (this.#isLeftItem(count)) beforeHeight += this.#getElementRowHeight(ele); + if (this.#isLeftItem(count)) beforeHeight += this.#getRowHeight(ele); } count++; } @@ -306,7 +334,7 @@ export class DuoyunListElement extends GemElement<State> { for (let i = 0; i < this.#appendCount; i++) { if (!node) break; appendList.push(node.value); - if (this.#isLeftItem(i)) afterHeight += this.#getElementRowHeight(this.#getElement(node.value)); + if (this.#isLeftItem(i)) afterHeight += this.#getRowHeight(this.#getElement(node.value)); node = node.next; } this.#setState({ @@ -320,34 +348,47 @@ export class DuoyunListElement extends GemElement<State> { // 延迟执行确保读取 afterVisible 正确 #initCheckOnce = once((silent: boolean) => setTimeout(() => this.#afterVisible && this.#reLayout({ silent }), 60)); + // 用于计算那些没有显示过的元素,item 高度不一致时需要用户提供函数? #itemHeight = 0; #itemColumnCount = 1; #rowGap = 0; #columnGap = 0; // 跟用户初始 Items 长度相同会触发两次 backward 事件,用户配置? #itemCountPerScreen = 19; - #onItemResize = throttle( - ({ target }) => { - const ele = target as DuoyunListItemElement | null; - if (ele?.borderBoxSize.blockSize) { - this.#initCheckOnce(this.items!.length > this.#itemCountPerScreen); - - const style = getComputedStyle(this.listRef.element!); - const thisGrid = getComputedStyle(this); - this.#rowGap = parseFloat(style.rowGap) || parseFloat(thisGrid.rowGap) || 0; - this.#columnGap = parseFloat(style.columnGap) || parseFloat(thisGrid.columnGap) || 0; - - this.#itemColumnCount = Math.round( - this.scrollContainer.clientWidth / (ele.borderBoxSize.inlineSize + this.#columnGap), - ); - this.#itemHeight = ele.borderBoxSize.blockSize; - this.#itemCountPerScreen = - Math.ceil(this.scrollContainer.clientHeight / this.#getElementRowHeight(ele)) * this.#itemColumnCount; - } - }, - 1000, - { leading: true }, - ); + // 初次渲染 + #initLayout = (ele: DuoyunListItemElement) => { + this.#initCheckOnce(this.items!.length > this.#itemCountPerScreen); + + const style = getComputedStyle(this.listRef.element!); + const thisGrid = getComputedStyle(this); + this.#rowGap = parseFloat(style.rowGap) || parseFloat(thisGrid.rowGap) || 0; + this.#columnGap = parseFloat(style.columnGap) || parseFloat(thisGrid.columnGap) || 0; + + this.#itemColumnCount = Math.round( + this.scrollContainer.clientWidth / (ele.borderBoxSize.inlineSize + this.#columnGap), + ); + this.#itemHeight = ele.borderBoxSize.blockSize; + this.#itemCountPerScreen = + Math.ceil(this.scrollContainer.clientHeight / this.#getRowHeight(ele)) * this.#itemColumnCount; + }; + + #onItemResizeInit = throttle(this.#initLayout, 1000, { leading: true }); + + #onItemResize = ({ target }: CustomEvent) => { + const ele = target as DuoyunListItemElement; + if (!ele.borderBoxSize.blockSize) return; + this.#onItemResizeInit(ele); + + // 视口宽度改变导致的 Resize,使用 `itemHeight` 是避免滚动时再次触发 + if (this.#itemHeight !== ele.borderBoxSize.blockSize) { + this.#reLayoutByResize(ele); + } + }; + + #reLayoutByResize = throttle((ele: DuoyunListItemElement) => { + this.#initLayout(ele); + this.#reLayout({ resize: true }); + }, 200); #keyElementMap = new Map<any, DuoyunListItemElement>(); #getElement = (key: Key) => { @@ -357,11 +398,13 @@ export class DuoyunListElement extends GemElement<State> { ele.addEventListener('resize', this.#onItemResize); ele.addEventListener('show', () => this.itemshow(this.#keyItemMap.get(key))); ele.intersectionRoot = this.scrollContainer; + // 赋值初始值,用于没渲染的计算高度 ele.borderBoxSize.blockSize = this.#itemHeight; this.#keyElementMap.set(key, ele); } const ele = this.#keyElementMap.get(key)!; ele.item = this.#keyItemMap.get(key); + ele.key = this.key; ele.renderItem = this.renderItem; return ele; }; @@ -396,35 +439,25 @@ export class DuoyunListElement extends GemElement<State> { this.memo( ([items], oldDeps) => { - if (this.infinite && items) { - // 向前(上)加载数据,必须是列数的倍数 - if (items.length && oldDeps?.[0]?.length) { - let beforeHeight = 0; - for (let i = 0; i < items.length; i++) { - if (this.getKey!(items[i]) === this.getKey!(oldDeps[0][0])) break; - if (this.#isLeftItem(i)) beforeHeight += this.#itemHeight + this.#rowGap; - } - if (beforeHeight) { - this.#setState({ beforeHeight: this.state.beforeHeight + beforeHeight }); - // 等待渲染后再滚动 - queueMicrotask(() => { - this.scrollContainer.scrollBy({ - left: 0, - top: beforeHeight, - behavior: 'instant', - }); - }); - } - } - - // TODO: Improve performance - this.#itemLinked = new LinkedList(); - this.#keyItemMap = new Map(); - items.forEach((item) => { - const key = this.getKey!(item); - this.#keyItemMap.set(key, item); - this.#itemLinked.add(key); - }); + // infinite 改变会发生什么? + if (!this.infinite) return; + + const oldLinkedList = this.#itemLinked; + this.#itemLinked = new LinkedList(); + this.#keyItemMap = new Map(); + items?.forEach((item) => { + const key = this.getKey!(item); + this.#keyItemMap.set(key, item); + this.#itemLinked.add(key); + }); + + if (this.#itemLinked.isSuperLinkOf(oldLinkedList)) { + // 是父集 items 就肯定有内容 + this.#appendItems(items!, oldDeps?.at(0)); + } else { + // 列表改了,需要重排 + this.#setState({ beforeHeight: 0, renderList: [], afterHeight: 0 }); + this.#reLayout(); } }, () => [this.#items], @@ -433,7 +466,7 @@ export class DuoyunListElement extends GemElement<State> { mounted = () => { this.scrollContainer = findScrollContainer(this) || document.documentElement; - this.scrollContainer.scrollTo(0, this.#initState?.scrollTop || 0); + if (this.#initState) this.scrollContainer.scrollTo(0, this.#initState.scrollTop); this.effect(() => { this.scrollContainer.addEventListener('scroll', this.#onScroll); @@ -451,31 +484,38 @@ export class DuoyunListElement extends GemElement<State> { return html` <slot name=${DuoyunListElement.before}></slot> ${this.infinite - ? html` - <dy-list-outside - ref=${this.beforeItemRef.ref} - part=${DuoyunListElement.beforeOutside} - .intersectionRoot=${this.scrollContainer} - @show=${this.#onBeforeItemVisible} - style=${styleMap({ height: `${beforeHeight}px` })} - ></dy-list-outside> - <div ref=${this.listRef.ref} class="list" part=${DuoyunListElement.list}> - ${renderList.map((key) => this.#getElement(key))} - </div> - <dy-list-outside - ref=${this.afterItemRef.ref} - part=${DuoyunListElement.afterOutside} - .intersectionRoot=${this.scrollContainer} - @show=${this.#onAfterItemVisible} - style=${styleMap({ height: `${afterHeight}px` })} - > - </dy-list-outside> - ` - : this.#items?.map( - (item) => html` - <dy-list-item part=${DuoyunListElement.item} .item=${item} .renderItem=${this.renderItem}></dy-list-item> - `, - )} + ? html`<dy-list-outside + ref=${this.beforeItemRef.ref} + part=${DuoyunListElement.beforeOutside} + .intersectionRoot=${this.scrollContainer} + @show=${this.#onBeforeItemVisible} + style=${styleMap({ height: `${beforeHeight}px` })} + ></dy-list-outside>` + : html``} + <div ref=${this.listRef.ref} class="list" part=${DuoyunListElement.list}> + ${this.infinite + ? renderList.map((key) => this.#getElement(key)) + : this.#items?.map( + (item) => html` + <dy-list-item + part=${DuoyunListElement.item} + .item=${item} + .key=${this.key} + .renderItem=${this.renderItem} + ></dy-list-item> + `, + )} + </div> + ${this.infinite + ? html`<dy-list-outside + ref=${this.afterItemRef.ref} + part=${DuoyunListElement.afterOutside} + .intersectionRoot=${this.scrollContainer} + @show=${this.#onAfterItemVisible} + style=${styleMap({ height: `${afterHeight}px` })} + > + </dy-list-outside>` + : html``} <slot name=${DuoyunListElement.after}> <!-- 无限滚动时避免找不到 "dy-list-outside", e.g: dy-list docs --> <div class="placeholder" style="height: 1px"></div> @@ -545,6 +585,7 @@ export class DuoyunListItemElement extends DuoyunResizeBaseElement implements Du @property item?: any; @property renderItem?: (item: any) => TemplateResult; + @property key?: any; // 提供另外一种方式来更新 constructor() { super({ delegatesFocus: true }); diff --git a/packages/gem-examples/src/multi-page/index.ts b/packages/gem-examples/src/multi-page/index.ts index 2248203b..bf0d2ef2 100644 --- a/packages/gem-examples/src/multi-page/index.ts +++ b/packages/gem-examples/src/multi-page/index.ts @@ -1,5 +1,5 @@ import { GemElement, html, render } from '@mantou/gem'; -import { GemTitleElement } from '@mantou/gem/elements/title'; +import '@mantou/gem/elements/title'; import '@mantou/gem/elements/route'; import '@mantou/gem/elements/link'; @@ -54,7 +54,7 @@ class App extends GemElement { text-decoration: underline; } </style> - <header><gem-title prefix=${GemTitleElement.defaultPrefix}>AppName</gem-title></header> + <header><gem-title prefix=${'😊'}>AppName</gem-title></header> <nav> <gem-link path="/">Home</gem-link> <gem-link path="/a">PageA</gem-link> diff --git a/packages/gem/src/elements/base/route.ts b/packages/gem/src/elements/base/route.ts index 862efa68..e1a4ee9b 100644 --- a/packages/gem/src/elements/base/route.ts +++ b/packages/gem/src/elements/base/route.ts @@ -122,6 +122,8 @@ export class GemRouteElement extends GemElement<State> { @boolattribute transition: boolean; @property routes?: RouteItem[] | RoutesObject; /** + * 不要多个 `<gem-route>` 共享,因为那样会导致后面的元素卸载前触发更新 + * * @example * const locationStore = GemRouteElement.createLocationStore() * html`<gem-route .locationStore=${locationStore}>` @@ -194,10 +196,13 @@ export class GemRouteElement extends GemElement<State> { this.setState({ content }); this.routechange(this.currentRoute); this.#updateLocationStore(); - return Promise.resolve(); }; if (this.transition && 'startViewTransition' in document) { - (document as any).startViewTransition(changeContent); + (document as any).startViewTransition(() => { + changeContent(); + // 等待路由渲染 + return Promise.resolve(); + }); } else { changeContent(); } diff --git a/packages/gem/src/elements/base/title.ts b/packages/gem/src/elements/base/title.ts index 76123b24..527ac3df 100644 --- a/packages/gem/src/elements/base/title.ts +++ b/packages/gem/src/elements/base/title.ts @@ -4,7 +4,7 @@ * - 桌面端 Tab 的 title * - 移动端 AppBar 的 title * - * 修改标题: + * 修改标题:titleStore 的 title 优先级高,比如 history 添加的 dialog Title * * - `<gem-route>` 匹配路由时自动设置 `route.title` * - `<gem-link>` 的 `doc-title` 属性和 `route.title` @@ -18,42 +18,55 @@ import { attribute, connectStore } from '../../lib/decorators'; import { updateStore, connect } from '../../lib/store'; import { titleStore } from '../../lib/history'; +const defaultTitle = document.title; + +function updateTitle(str?: string | null, prefix = '', suffix = '') { + const title = titleStore.title || str; + if (title && title !== defaultTitle) { + GemTitleElement.title = title; + document.title = prefix + GemTitleElement.title + suffix; + } else { + GemTitleElement.title = defaultTitle; + document.title = GemTitleElement.title; + } +} + +export const PREFIX = `${defaultTitle} | `; +export const SUFFIX = ` - ${defaultTitle}`; + /** * 允许声明式设置 `document.title` + * @attr prefix * @attr suffix */ @connectStore(titleStore) export class GemTitleElement extends GemElement { - // 没有后缀的标题 + @attribute prefix: string; + @attribute suffix: string; + + // 没有后缀的当前标题 static title = document.title; - static defaultTitle = document.title; - static defaultPrefix = `${document.title} | `; - static defaultSuffix = ` - ${document.title}`; + + /**@deprecated */ + static defaultPrefix = PREFIX; + /**@deprecated */ + static defaultSuffix = SUFFIX; static setTitle(title: string) { updateStore(titleStore, { title }); } - static updateTitle(title = titleStore.title, prefix = '', suffix = '') { - if (title === GemTitleElement.defaultTitle) { - document.title = GemTitleElement.title = title; - } else { - GemTitleElement.title = title; - document.title = prefix + GemTitleElement.title + suffix; - } - } - - @attribute prefix: string; - @attribute suffix: string; - constructor() { super(); - new MutationObserver(() => this.update()).observe(this, { childList: true, characterData: true, subtree: true }); + new MutationObserver(() => this.update()).observe(this, { + characterData: true, + subtree: true, + }); } render() { // 多个 <gem-title> 时,最终 document.title 按执行顺序决定 - GemTitleElement.updateTitle(this.textContent || undefined, this.prefix, this.suffix); + updateTitle(this.textContent, this.prefix, this.suffix); if (this.hidden) { return html``; @@ -62,4 +75,4 @@ export class GemTitleElement extends GemElement { } } -connect(titleStore, GemTitleElement.updateTitle); +connect(titleStore, updateTitle); diff --git a/packages/gem/src/lib/utils.ts b/packages/gem/src/lib/utils.ts index 8aeaceef..eaed5a85 100644 --- a/packages/gem/src/lib/utils.ts +++ b/packages/gem/src/lib/utils.ts @@ -91,6 +91,18 @@ export class LinkedList<T = any> extends EventTarget { return this.#lastItem; } + isSuperLinkOf(subLink: LinkedList<T>) { + let subItem = subLink.first; + if (!subItem) return true; + let item = this.find(subItem.value); + while (item && item.value === subItem.value) { + subItem = subItem.next; + if (!subItem) return true; + item = item.next; + } + return false; + } + find(value: T) { return this.#map.get(value); }