From 45804d45f60824e211796faa1fb142c47cea6377 Mon Sep 17 00:00:00 2001 From: defaultkavy Date: Thu, 25 Apr 2024 19:56:42 +0800 Subject: [PATCH] initial --- README.md | 66 +++++++++++++++++ index.ts | 12 ++++ lib/$Layout.ts | 190 +++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 25 +++++++ tsconfig.json | 20 ++++++ 5 files changed, 313 insertions(+) create mode 100644 README.md create mode 100644 index.ts create mode 100644 lib/$Layout.ts create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f70ea0 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# @elexis/layout +Auto layout component for [ElexisJS](https://github.com/defaultkavy/elexis) + +## Installation +``` +npm i @elexis/layout +``` + +## Usage +```ts +import "elexis"; +import "@elexis/layout"; + +const $layout = $('layout') + .type("justified") // justified view + .gap(4) // 4px gap + .maxHeight(300) // each row maximum height is 300px + .content([ + $('div').content([ + // any content here + ]) + ]) + .render() // start render +``` + +## How we handle async element? +Sometimes, we need to load data first. Every content of `$Layout` must be sized in DOM before the layout start render. However, there are some situations cause we can't get the accurate size when element inserted to the DOM. Example: image source load. + +First, we need an async function to fetch data and return `$Element`: +```ts +async function promiseElement(url: string) { + const data = await fetch(url); + return $('div').content(data); +} +``` + +After that, we should load all the data first, and put element into layout at every promise completed... Wait. This sound good, but the result of layout order will be disrupted. This is because fetch all the data in order doen't mean these data will be promised in ordered. Even if await all data promised, that will make user read a blank page period of time. + +### We need some help with `$Async` +ElexisJS can replace unloaded element with `$Async` to put in the DOM. After the element loaded, async element will be replaced by loaded element. + +Here is the way to create `$Async`: +```ts +$('async') // create async element + .await( promiseElement('/api/hello-world') ) // set the promise function + .on('load', () => { $layout.render() }) + // this event will be fire when element promised, + // we should render layout with the new sized element +``` + +Thourgh help with async element, we can put unloaded element in the layout first, this will make sure the order of elements will not be disrupted. Now, layout will render everytime the sized element is promised. + +## Image element width and height attribute +If image dimension is exists before inserted to DOM, using width and height attribute of image element can make layout compute reserve space for it. + +For example: +```ts +const imageDataList: ImageData[] = await fetch('/api/images'); +$layout.content([ + imageDataList.map(data => { + return $('img').width(data.width).height(data.height).src(data.url) + }) +]) +``` + +Store image dimension data should be the best practice for building layout. \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..a6beb7d --- /dev/null +++ b/index.ts @@ -0,0 +1,12 @@ +import 'elexis'; +import { $Layout } from './lib/$Layout'; +declare module 'elexis' { + export namespace $ { + export interface TagNameElementMap { + 'layout': typeof $Layout; + } + } +} +$.registerTagName('layout', $Layout) + +export * from './lib/$Layout' \ No newline at end of file diff --git a/lib/$Layout.ts b/lib/$Layout.ts new file mode 100644 index 0000000..15dfe50 --- /dev/null +++ b/lib/$Layout.ts @@ -0,0 +1,190 @@ +import { $ContainerOptions, $Container, $StateArgument, $Element } from "elexis"; + +export interface $LayoutOptions extends $ContainerOptions {} +export class $Layout extends $Container { + protected _property = { + ROW_MAX_HEIGHT: 200, + GAP: 0, + IS_RENDERING: false, + RENDER_REQUEST: false, + COLUNM: 1, + TYPE: 'justified' as $LayoutType, + ROOT: null as null | $Container, + THRESHOLD: null as null | number + } + constructor(options?: $ContainerOptions) { + super('layout', options); + this.css({display: 'block', position: 'relative'}) + new ResizeObserver(() => { + if (!this.inDOM()) return; + // if (this._property.IS_RENDERING) return this._property.RENDER_REQUEST = true; + // if (this._property.RENDER_REQUEST) return this._property.RENDER_REQUEST = false; + // this._property.IS_RENDERING = true; + this.render(); + this.scrollCompute() + this.dom.dispatchEvent(new Event('resize')); + // setTimeout(() => { + // this._property.IS_RENDERING = false; + // if (this._property.RENDER_REQUEST) this.render(); + // }, 10); + }).observe(this.dom); + document.addEventListener('scroll', (e) => { + if (e.target === this.root().dom) this.scrollCompute(); + }) + } + + type(): $LayoutType + type(type: $LayoutType): this + type(type?: $LayoutType) { return $.fluent(this, arguments, () => this._property.TYPE, () => $.set(this._property, 'TYPE', type)) } + + /** + * The maximum height of justified layout row. + */ + maxHeight(): number; + maxHeight(height: $StateArgument | undefined): this + maxHeight(height?: $StateArgument | undefined) { return $.fluent(this, arguments, () => this._property.ROW_MAX_HEIGHT, () => $.set(this._property, 'ROW_MAX_HEIGHT', height, 'maxHeight'))} + + /** + * The column amount of waterfall layout row. + */ + column(): number; + column(column: $StateArgument | undefined): this + column(column?: $StateArgument | undefined) { return $.fluent(this, arguments, () => this._property.COLUNM, () => $.set(this._property, 'COLUNM', column, 'column'))} + + gap(): number; + gap(gap: $StateArgument | undefined): this; + gap(gap?: $StateArgument | undefined) { return $.fluent(this, arguments, () => this._property.GAP, ()=> $.set(this._property, 'GAP', gap, 'gap'))} + + /** + * The srcollable parent element. Default to the document `body` element. + */ + root(): $Container + root(root: $Container): this + root(root?: $Container) { return $.fluent(this, arguments, () => this._property.ROOT ?? $(document), () => $.set(this._property, 'ROOT', root, 'root')) } + + /** + * The top and bottom of display element area, depend by window.innerHeight. Default to the `innerHeight / 2` at everytime scroll. + * Using `resize` event and $State value to change threshold dynamically. + */ + threshold(): number; + threshold(threshold: $StateArgument | undefined): this; + threshold(threshold?: $StateArgument | undefined) { return $.fluent(this, arguments, () => this._property.THRESHOLD ?? innerHeight / 2, () => $.set(this._property, 'THRESHOLD', threshold, 'threshold')) } + + protected get COL_WIDTH() { return (this.offsetWidth - this._property.GAP * (this._property.COLUNM - 1)) / (this._property.COLUNM); } + + protected computeLayout() { + if (this._property.TYPE === 'justified') return this.justifiedCompute(); + else return this.justifiedCompute(); + } + + protected justifiedCompute() { + const ROW_LIST: Row[] = []; + const LAYOUT_WIDTH = this.offsetWidth; + type Row = {items: Item[], ratio: number, height: number}; + type Item = {$node: $Element, ratio: number}; + for (const child of this.children.array) { + const $child = $(child) as $Element; + if ($child instanceof $Element === false) continue; + const ratio_attr = $child.attribute('layout-item-ratio'); + const CHILD_RATIO: number = ratio_attr ? parseFloat(ratio_attr) : $child.dom.offsetWidth / $child.dom.offsetHeight; + const CHILD_ITEM: Item = {$node: $child, ratio: CHILD_RATIO}; + let LAST_ROW = ROW_LIST.at(-1); + if (!LAST_ROW || LAST_ROW.height < this._property.ROW_MAX_HEIGHT) { LAST_ROW = {height: 0, items: [], ratio: 0}; ROW_LIST.push(LAST_ROW)} + let ITEMS_RATIO = 0; + LAST_ROW.items.forEach(item => ITEMS_RATIO += item.ratio); + const ROW_RATIO_WITH_CHILD = ITEMS_RATIO + CHILD_RATIO; + const ROW_HEIGHT_WITH_CHILD = (LAYOUT_WIDTH - this._property.GAP * LAST_ROW.items.length) / ROW_RATIO_WITH_CHILD; + LAST_ROW.items.push(CHILD_ITEM); LAST_ROW.ratio = ROW_RATIO_WITH_CHILD; LAST_ROW.height = ROW_HEIGHT_WITH_CHILD; + } + return ROW_LIST; + } + + protected waterfallCompute() { + const COLUMN_LIST: Column[] = []; + type Column = {items: Item[], ratio: number, height: number}; + type Item = {$node: $Element, ratio: number}; + const COL_WIDTH = this.COL_WIDTH; + const SHORTEST_COL = () => { + if (COLUMN_LIST.length < this._property.COLUNM) { const col: Column = {items: [], ratio: 0, height: 0}; COLUMN_LIST.push(col); return col; } + return [...COLUMN_LIST].sort((a, b) => a.height - b.height)[0]; + } + for (const child of this.children.array) { + const $child = $(child) as $Element; + if ($child instanceof $Element === false) continue; + const ratio_attr = $child.attribute('layout-item-ratio'); + const CHILD_RATIO: number = ratio_attr ? parseFloat(ratio_attr) : $child.dom.offsetWidth / $child.dom.offsetHeight; + const CHILD_ITEM: Item = {$node: $child, ratio: CHILD_RATIO}; + const COL = SHORTEST_COL(); + let ITEMS_RATIO = 0; + COL.items.forEach(item => ITEMS_RATIO += item.ratio); + const COL_RATIO_WITH_CHILD = COL_WIDTH / (COL.height + COL_WIDTH / CHILD_RATIO); + const COL_HEIGHT_WITH_CHILD = COL_WIDTH / COL_RATIO_WITH_CHILD; + COL.items.push(CHILD_ITEM); COL.ratio = COL_RATIO_WITH_CHILD; COL.height = COL_HEIGHT_WITH_CHILD; + } + return COLUMN_LIST; + } + + render() { + if (this._property.TYPE === 'justified') { + const ROW_LIST = this.justifiedCompute(); + let ROW_POSITION_Y = 0; + for (const ROW of ROW_LIST) { + let ITEM_POSITION_X = 0; + if (ROW.height > this._property.ROW_MAX_HEIGHT) ROW.height = this._property.ROW_MAX_HEIGHT; + for (const item of ROW.items) { + const ITEM_WIDTH = item.ratio * ROW.height; + item.$node.css({ + position: 'absolute', + height: `${ROW.height}px`, + width: `${ITEM_WIDTH}px`, + top: `${ROW_POSITION_Y}px`, + left: `${ITEM_POSITION_X}px`, + }) + item.$node.attribute('layout-item-ratio', item.ratio); + ITEM_POSITION_X += (ROW.height * item.ratio) + this._property.GAP; + } + ROW_POSITION_Y += ROW.height + this._property.GAP; + } + this.css({height: `${ROW_POSITION_Y}px`}) + } + + else if (this._property.TYPE = 'waterfall') { + const COL_LIST = this.waterfallCompute(); + const COL_WIDTH = this.COL_WIDTH; + let COL_POSITION_X = 0; + for (const COL of COL_LIST) { + let ITEM_POSITION_Y = 0; + for (const item of COL.items) { + const ITEM_HEIGHT = COL_WIDTH / item.ratio; + item.$node.css({ + position: 'absolute', + height: `${ITEM_HEIGHT}px`, + width: `${COL_WIDTH}px`, + top: `${ITEM_POSITION_Y}px`, + left: `${COL_POSITION_X}px` + }) + item.$node.attribute('layout-item-ratio', item.ratio); + ITEM_POSITION_Y += ITEM_HEIGHT + this._property.GAP; + } + COL_POSITION_X += COL_WIDTH + this._property.GAP; + } + if (COL_LIST.length) this.css({height: `${COL_LIST.sort((a, b) => b.height - a.height)[0].height}px`}) + } + return this; + } + + protected scrollCompute() { + if (this.inDOM() === false) return; + const threshold = this.threshold(); + this.children.array.forEach(child => { + if (!child.isElement()) return; + const rect = child.domRect(); + if (rect.bottom < -threshold) child.hide(true, false); + else if (rect.top > innerHeight + threshold) child.hide(true, false); + else child.hide(false, false); + }) + this.children.render(); + } +} + +export type $LayoutType = 'justified' | 'waterfall' \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..350beb1 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "@elexis/layout", + "description": "A simple justified/waterfall layout for ElexisJS", + "version": "0.1.0", + "author": { + "name": "defaultkavy", + "email": "defaultkavy@gmail.com", + "url": "https://github.com/defaultkavy" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/elexisjs/layout.git" + }, + "module": "index.ts", + "bugs": { + "url": "https://github.com/elexisjs/layout/issues" + }, + "homepage": "https://github.com/elexisjs/layout", + "keywords": ["web", "front-end", "lib", "fluent", "framework"], + "license": "ISC", + "type": "module", + "dependencies": { + "elexis": "^0.1.0" + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e73754d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["dom", "ES2022"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true + }, + } + \ No newline at end of file