amateras 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +6 -4
  2. package/ext/css/README.md +19 -0
  3. package/ext/css/src/index.ts +347 -331
  4. package/ext/css/src/lib/colorAssign.ts +1 -1
  5. package/ext/css/src/structure/$CSSContainerRule.ts +13 -0
  6. package/ext/css/src/structure/$CSSRule.ts +1 -1
  7. package/ext/css/src/structure/$CSSStyleRule.ts +0 -7
  8. package/ext/css/src/structure/$CSSVariable.ts +3 -3
  9. package/ext/html/html.ts +1 -13
  10. package/ext/i18n/README.md +53 -0
  11. package/ext/i18n/package.json +10 -0
  12. package/ext/i18n/src/index.ts +54 -0
  13. package/ext/i18n/src/node/I18nText.ts +35 -0
  14. package/ext/i18n/src/structure/I18n.ts +40 -0
  15. package/ext/i18n/src/structure/I18nDictionary.ts +31 -0
  16. package/ext/markdown/index.ts +123 -0
  17. package/ext/router/index.ts +8 -1
  18. package/ext/router/node/Page.ts +1 -0
  19. package/ext/router/node/Route.ts +2 -1
  20. package/ext/router/node/Router.ts +33 -22
  21. package/ext/ssr/index.ts +4 -2
  22. package/ext/ui/lib/VirtualScroll.ts +24 -0
  23. package/ext/ui/node/Accordian.ts +97 -0
  24. package/ext/ui/node/Tabs.ts +114 -0
  25. package/ext/ui/node/Toast.ts +16 -0
  26. package/ext/ui/node/Waterfall.ts +73 -0
  27. package/ext/ui/package.json +11 -0
  28. package/package.json +4 -7
  29. package/src/core.ts +21 -8
  30. package/src/lib/assign.ts +8 -9
  31. package/src/lib/assignHelper.ts +1 -1
  32. package/src/lib/chain.ts +3 -0
  33. package/src/lib/debounce.ts +7 -0
  34. package/src/lib/env.ts +2 -0
  35. package/src/lib/native.ts +22 -35
  36. package/src/lib/randomId.ts +1 -1
  37. package/src/lib/sleep.ts +1 -1
  38. package/src/node/$Element.ts +182 -20
  39. package/src/node/$HTMLElement.ts +24 -0
  40. package/src/node/$Node.ts +75 -52
  41. package/src/node/$Virtual.ts +58 -0
  42. package/src/{node/node.ts → node.ts} +2 -5
@@ -1,6 +1,6 @@
1
1
  import { _Object_assign } from "../../../../src/lib/native";
2
2
 
3
- export function colorAssign(key: string, colors: {[key: number]: string}) {
3
+ export const colorAssign = (key: string, colors: {[key: number]: string}) => {
4
4
  if (!$.color) _Object_assign($, {color: {}});
5
5
  _Object_assign($.color, {[key]: colors})
6
6
  }
@@ -0,0 +1,13 @@
1
+ import { _Array_from, _instanceof } from "amateras/lib/native";
2
+ import { $CSSRule } from "#structure/$CSSRule";
3
+
4
+ export class $CSSContainerRule extends $CSSRule {
5
+ condition: string;
6
+ name: string;
7
+ constructor(selector: string) {
8
+ super(selector);
9
+ const [_, name, condition] = selector.match(/@container (.+?) (.+)/) as [string, string, string]
10
+ this.name = name;
11
+ this.condition = condition;
12
+ }
13
+ }
@@ -8,6 +8,6 @@ export abstract class $CSSRule {
8
8
  }
9
9
 
10
10
  get options(): {[key: string]: any} {
11
- return _Object_fromEntries(_Array_from(this.rules).map(rule => [rule.selector, rule]))
11
+ return _Object_fromEntries(_Array_from(this.rules).map(rule => [rule.selector, rule.options]))
12
12
  }
13
13
  }
@@ -8,13 +8,6 @@ export class $CSSStyleRule extends $CSSRule {
8
8
  super(selector);
9
9
  }
10
10
 
11
- clone(selector: string) {
12
- const rule = new $CSSStyleRule(selector)
13
- rule.declarations = this.declarations;
14
- rule.rules = this.rules;
15
- return rule
16
- }
17
-
18
11
  get options(): {[key: string]: any} {
19
12
  return {..._Object_fromEntries(_Array_from(this.declarations).map(([_, dec]) => [dec.key, dec])), ...super.options}
20
13
  }
@@ -1,7 +1,7 @@
1
- export class $CSSVariable {
1
+ export class $CSSVariable<V = string> {
2
2
  key: string;
3
- value: string;
4
- constructor(key: string, value: string) {
3
+ value: V;
4
+ constructor(key: string, value: V) {
5
5
  this.key = key;
6
6
  this.value = value;
7
7
  }
package/ext/html/html.ts CHANGED
@@ -10,16 +10,4 @@ import './node/$Media';
10
10
  import './node/$OptGroup';
11
11
  import './node/$Option';
12
12
  import './node/$Select';
13
- import './node/$TextArea';
14
- export * from './node/$Anchor';
15
- export * from './node/$Canvas';
16
- export * from './node/$Dialog';
17
- export * from './node/$Form';
18
- export * from './node/$Image';
19
- export * from './node/$Input';
20
- export * from './node/$Label';
21
- export * from './node/$Media';
22
- export * from './node/$OptGroup';
23
- export * from './node/$Option';
24
- export * from './node/$Select';
25
- export * from './node/$TextArea';
13
+ import './node/$TextArea';
@@ -0,0 +1,53 @@
1
+ # amateras/i18n
2
+
3
+ ## Usage
4
+ ```ts
5
+ import 'amateras';
6
+ import 'amateras/i18n';
7
+
8
+ const $t = $.i18n()
9
+ // add 'en' locale dictionary context
10
+ .add('en', {
11
+ homepage: {
12
+ _: 'Home',
13
+ hello: 'Hello, $name$!',
14
+ }
15
+ })
16
+ // set 'en' as locale language
17
+ .locale('en')
18
+
19
+ $(document.body).content([
20
+ $('h1').content( $t('homepage') )
21
+ // <h1><text>Home</text></h1>
22
+ $t('homepage.hello', {name: 'Amateras'})
23
+ // <text>Hello, Amateras!</text>
24
+ ])
25
+ ```
26
+
27
+ ## Change Language
28
+ ```ts
29
+ $t.locale('zh')
30
+ // all translation text will be updated
31
+ ```
32
+
33
+ ## Import Dictionary Context
34
+
35
+ ```ts
36
+ // ./i18n/en.ts
37
+ export default {
38
+ hello: 'Hello, $name$!'
39
+ }
40
+
41
+ // ./i18n/zh.ts
42
+ export default {
43
+ hello: '您好,$name$!'
44
+ }
45
+
46
+ // ./entry_file.ts
47
+ const $t = $.i18n()
48
+ .add('en', () => import('./i18n/en.ts'))
49
+ .add('zh', () => import('./i18n/zh.ts'))
50
+ // set 'zh' as locale language
51
+ // and fetch file automatically from path
52
+ .locale('zh');
53
+ ```
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@amateras/i18n",
3
+ "peerDependencies": {
4
+ "amateras": "../../"
5
+ },
6
+ "imports": {
7
+ "#structure/*": "./src/structure/*.ts",
8
+ "#node/*": "./src/node/*.ts"
9
+ }
10
+ }
@@ -0,0 +1,54 @@
1
+ import { _Array_from, _instanceof, _Object_assign } from "amateras/lib/native"
2
+ import { $ } from "amateras/core"
3
+ import { I18n } from "#structure/I18n"
4
+ import type { I18nText as _I18nText, I18nTextOptions } from "#node/I18nText";
5
+ import { I18nDictionary, type I18nDictionaryContext, type I18nDictionaryContextImporter } from "#structure/I18nDictionary";
6
+
7
+ _Object_assign($, {
8
+ i18n(defaultLocale: string) {
9
+ const i18n = new I18n(defaultLocale);
10
+ const i18nFn = (key: string, options?: I18nTextOptions) => i18n.translate(key, options);
11
+ _Object_assign(i18nFn, {
12
+ i18n,
13
+ locale(locale: string) {
14
+ if (!arguments.length) return i18n.locale();
15
+ i18n.locale(locale);
16
+ return this;
17
+ },
18
+ add(lang: string, context: I18nDictionaryContext | I18nDictionaryContextImporter) {
19
+ i18n.map.set(lang, new I18nDictionary(context));
20
+ return this;
21
+ },
22
+ delete(lang: string) {
23
+ i18n.map.delete(lang);
24
+ return this;
25
+ }
26
+ })
27
+ return i18nFn
28
+ }
29
+ })
30
+
31
+ type ResolvedAsyncDictionary<F extends I18nDictionaryContextImporter> = Awaited<ReturnType<F>>['default'];
32
+
33
+ type DeepKeys<T> = T extends I18nDictionaryContext
34
+ ? {
35
+ [K in keyof T]: K extends string
36
+ ? K extends '_' ? never : `${K}` | `${K}.${DeepKeys<T[K]>}`
37
+ : never;
38
+ }[keyof T]
39
+ : never;
40
+
41
+ declare module "amateras/core" {
42
+ export namespace $ {
43
+ export interface I18nFunction<D extends I18nDictionaryContext = {}> {
44
+ (path: DeepKeys<D>, ...args: any[]): I18nText;
45
+ i18n: I18n;
46
+ locale(): string;
47
+ locale(lang?: $Parameter<string>): this;
48
+ add<F extends I18nDictionaryContext | I18nDictionaryContextImporter>(lang: string, dictionary: F): I18nFunction<D | (F extends I18nDictionaryContextImporter ? ResolvedAsyncDictionary<F> : F)>;
49
+ delete(lang: string): this;
50
+ }
51
+ export function i18n(defaultLocale: string): I18nFunction;
52
+ export type I18nText = _I18nText;
53
+ }
54
+ }
@@ -0,0 +1,35 @@
1
+ import { _Array_from, isUndefined } from "amateras/lib/native";
2
+ import { $HTMLElement } from "amateras/node/$HTMLElement";
3
+ import type { I18n } from "#structure/I18n";
4
+
5
+ export class I18nText extends $HTMLElement<HTMLElement, { i18nupdate: Event }> {
6
+ i18n: I18n;
7
+ key: string;
8
+ options: I18nTextOptions | undefined;
9
+ constructor(i18n: I18n, key: string, options?: I18nTextOptions) {
10
+ super('text');
11
+ this.i18n = i18n;
12
+ this.key = key;
13
+ this.options = options;
14
+ i18n.locale$.signal.subscribe(() => this.update())
15
+ this.update();
16
+ }
17
+
18
+ async update() {
19
+ update: {
20
+ const {key, i18n} = this;
21
+ const dictionary = i18n.dictionary();
22
+ if (!dictionary) {this.content(key); break update}
23
+ const target = await dictionary.find(key);
24
+ if (isUndefined(target)) break update;
25
+ const snippets = target.split(/\$[a-zA-Z0-9_]+\$/);
26
+ if (snippets.length === 1 || !this.options) {this.content(target); break update}
27
+ const matches = target.matchAll(/(\$([a-zA-Z0-9_]+)\$)/g);
28
+ this.content(snippets.map(text => [text, this.options?.[matches.next().value?.at(2)!] ?? null]));
29
+ }
30
+ this.dispatchEvent(new Event('i18nupdate'));
31
+ return this;
32
+ }
33
+ }
34
+
35
+ export type I18nTextOptions = {[key: string]: any}
@@ -0,0 +1,40 @@
1
+ import { _instanceof } from "amateras/lib/native";
2
+ import { I18nText, type I18nTextOptions } from "#node/I18nText";
3
+ import { I18nDictionary } from "#structure/I18nDictionary";
4
+
5
+ export class I18n {
6
+ locale$ = $.signal<string>('');
7
+ map = new Map<string, I18nDictionary>();
8
+ #defaultLocale: string;
9
+ constructor(defaultLocale: string) {
10
+ this.#defaultLocale = defaultLocale;
11
+ this.locale$.set(defaultLocale);
12
+ }
13
+
14
+ defaultLocale(): string;
15
+ defaultLocale(locale: string): this;
16
+ defaultLocale(locale?: string) {
17
+ if (!arguments.length) return this.#defaultLocale;
18
+ if (locale) this.locale$.set(locale);
19
+ return this;
20
+ }
21
+
22
+ locale(): string;
23
+ locale(locale: string): this;
24
+ locale(locale?: string) {
25
+ if (!arguments.length) return this.locale$();
26
+ if (locale) this.locale$.set(locale)
27
+ return this;
28
+ }
29
+
30
+ dictionary(locale = this.locale$()) {
31
+ if (!locale) return null;
32
+ const dictionary = this.map.get(locale);
33
+ return dictionary;
34
+ }
35
+
36
+ translate(key: string, options?: I18nTextOptions) {
37
+ return new I18nText(this, key, options);
38
+ }
39
+ }
40
+
@@ -0,0 +1,31 @@
1
+ import { _instanceof, isObject } from "amateras/lib/native";
2
+
3
+ export class I18nDictionary {
4
+ #context: I18nDictionaryContext | Promise<I18nDictionaryContext> | null = null;
5
+ #fetch: I18nDictionaryContextImporter | null = null;
6
+ constructor(resolver: I18nDictionaryContext | I18nDictionaryContextImporter) {
7
+ if (_instanceof(resolver, Function)) this.#fetch = resolver;
8
+ else this.#context = resolver;
9
+ }
10
+
11
+ async context(): Promise<I18nDictionaryContext> {
12
+ if (this.#context) return await this.#context;
13
+ if (!this.#fetch) throw 'I18n Context Fetch Error';
14
+ return this.#context = this.#fetch().then((module) => module.default);
15
+ }
16
+
17
+ async find(path: string, context?: I18nDictionaryContext): Promise<string | undefined> {
18
+ if (!context) context = await this.context();
19
+ const [snippet, ...rest] = path.split('.') as [string, ...string[]];
20
+ const target = context[snippet];
21
+ if (isObject(target)) {
22
+ if (rest.length) return this.find(rest.join('.'), target);
23
+ else return target['_'] as string;
24
+ }
25
+ if (rest.length) return path;
26
+ else return target;
27
+ }
28
+ }
29
+
30
+ export type I18nDictionaryContext = {[key: string]: string | I18nDictionaryContext}
31
+ export type I18nDictionaryContextImporter = () => Promise<{default: I18nDictionaryContext}>
@@ -0,0 +1,123 @@
1
+ import { _Array_from, forEach } from "#lib/native";
2
+
3
+ const blockProcesses = new Set<MarkdownBlockProcessOptions>();
4
+ const inlineProcesses = new Set<MarkdownProcessFunction>();
5
+
6
+ export class Markdown {
7
+ blockProcessSet = new Set(blockProcesses);
8
+ inlineProcessSet = new Set(inlineProcesses);
9
+ constructor() {}
10
+
11
+ blockProcess(options: MarkdownBlockProcessOptions) {
12
+ this.blockProcessSet.add(options);
13
+ return this;
14
+ }
15
+
16
+ inlineProcess(handle: MarkdownProcessFunction) {
17
+ this.inlineProcessSet.add(handle);
18
+ return this;
19
+ }
20
+
21
+ toHTML(text: string) {
22
+ const blocks = _Array_from(text.matchAll(/(?:.+?\n?)+/gm));
23
+ return blocks.map(block => {
24
+ let matched, blockText = block[0]
25
+ for (const blockProcess of blockProcesses) {
26
+ matched = blockText.match(blockProcess.regexp);
27
+ if (!matched) continue;
28
+ blockText = blockProcess.handle(blockText);
29
+ const removeHTML = blockText.replaceAll(/<.+>[^<]+?<\/.+>/gm, '');
30
+ if (!removeHTML) break;
31
+ }
32
+ if (!matched) blockText = paragraph(blockText);
33
+ inlineProcesses.forEach(fn => blockText = fn(blockText))
34
+ return blockText;
35
+ }).join('')
36
+ }
37
+
38
+ toDOM(text: string) {
39
+ return $('article').innerHTML(this.toHTML(text))
40
+ }
41
+ }
42
+
43
+ export type MarkdownProcessFunction = (text: string) => string;
44
+ export interface MarkdownBlockProcessOptions {
45
+ regexp: RegExp,
46
+ handle: (text: string) => string;
47
+ }
48
+
49
+ const blockProcess = (options: MarkdownBlockProcessOptions) => blockProcesses.add(options);
50
+ const inlineProcess = (handle: MarkdownProcessFunction) => inlineProcesses.add(handle);
51
+ const replaceAll = (str: string, searchValue: string | RegExp, replacer: ((substring: string, ...args: any[]) => string) | string): string => str.replaceAll(searchValue, replacer as any);
52
+ const trim = (str: string) => str.trim();
53
+ const paragraph = (str: string) => {
54
+ return replaceAll(str, /(?:.+?\n?)+/gm, $0 => `<p>${trim($0)}</p>`)
55
+ }
56
+ // Headings
57
+ blockProcess({
58
+ regexp: /^(#+) (.+)/gm,
59
+ handle: text => replaceAll(text, /^(#+) (.+)/gm, (_, $1: string, $2) => `<h${$1.length}>${$2}</h${$1.length}>`)
60
+ });
61
+ blockProcess({
62
+ regexp: /^(.+)\n==+$/gm,
63
+ handle: text => replaceAll(text, /^(.+)\n==+$/gm, (_, $1) => `<h1>${$1}</h1>`)
64
+ });
65
+ blockProcess({
66
+ regexp: /^(.+)\n--+$/gm,
67
+ handle: text => replaceAll(text, /^(.+)\n--+$/gm, (_, $1) => `<h2>${$1}</h2>`)
68
+ });
69
+ // Blockquote
70
+ blockProcess({
71
+ regexp: /(?:^> ?.*(?:\n|$))+/gm,
72
+ handle: text => {
73
+ const fn = (str: string) => {
74
+ const blocks = _Array_from(str.matchAll(/(?:^> ?.*(?:\n|$))+/gm));
75
+ forEach(blocks, block => {
76
+ const blocked = fn(replaceAll(block[0], /^> ?/gm, ''));
77
+ str = str.replace(block[0], `<blockquote>\n${paragraph(blocked)}\n</blockquote>`);
78
+ })
79
+ return str;
80
+ }
81
+ return fn(text);
82
+ }
83
+ });
84
+ // List
85
+ blockProcess({
86
+ regexp: /(?:^(?:\t|(?: )+)?(?:-|[0-9]+\.) (?:.+\n?))+/gm,
87
+ handle: text => {
88
+ const fn = (str: string) => {
89
+ const blocks = _Array_from(str.matchAll(/(?:^(?:\t|(?: )+)?(?:-|[0-9]+\.) (?:.+\n?))+/gm));
90
+ forEach(blocks, block => {
91
+ let haveList = false // check this loop have list
92
+ const type = block[0].match(/^(-|[0-9]+\.) /)?.[1] === '-' ? 'ul' : 'ol';
93
+ const listed = replaceAll(block[0], /^(?:-|[0-9]+\.) (.+)/gm, (_, $1: string) => (haveList = true, `<li>\n${trim($1)}\n</li>`));
94
+ const clearTabbed = replaceAll(listed, /^(?:\t|(?: ))/gm, '');
95
+ const convertedList = fn(clearTabbed);
96
+ str = str.replace(block[0], haveList ? `<${type}>\n${trim(convertedList)}\n</${type}>` : convertedList);
97
+ })
98
+ return str;
99
+ }
100
+ return fn(text);
101
+ }
102
+ })
103
+ // Codeblock
104
+ blockProcess({
105
+ regexp: /^```([^`\n]+)\n([^`]+)?```/gm,
106
+ handle: text => replaceAll(text, /^```([^`\n]+)\n([^`]+)?```/gm, (_, $1, $2: string) => `<pre><code>\n${trim($2)}\n</code></pre>`)
107
+ })
108
+ // Horizontal Rule
109
+ blockProcess({
110
+ regexp: /^(?:---|\*\*\*|___)(\s+)?$/gm,
111
+ handle: text => replaceAll(text, /^(?:---|\*\*\*|___)(\s+)?$/gm, _ => `<hr>`)
112
+ })
113
+ // Bold
114
+ inlineProcess(text => replaceAll(text, /\*\*([^*]+?)\*\*/g, (_, $1) => `<b>${$1}</b>`));
115
+ // Italic
116
+ inlineProcess(text => replaceAll(text, /\*([^*]+?)\*/g, (_, $1) => `<i>${$1}</i>`));
117
+ // Image
118
+ inlineProcess(text => replaceAll(text, /!\[(.+?)\]\((.+?)(?: "(.+?)?")?\)/g, (_, alt, src, title) => `<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ''}>`));
119
+ // Link
120
+ inlineProcess(text => replaceAll(text, /\[(.+?)\]\((?:(\w\w+?:[^\s]+?)(?: "(.+?)?")?)\)/g, (_, content, href, title) => `<a href="${href}"${title ? ` title="${title}"` : ''}>${content}</a>`));
121
+ inlineProcess(text => replaceAll(text, /\[(.+?)\]\((?:(\w+?@(?:\w|\.\w)+?)(?: "(.+)?")?)\)/g, (_, content, mail, title) => `<a href="mailto:${mail}"${title ? ` title="${title}"` : ''}>${content}</a>`));
122
+ inlineProcess(text => replaceAll(text, /<(\w\w+?:[^\s]+?)>/g, (_, href) => `<a href="${href}">${href}</a>`));
123
+ inlineProcess(text => replaceAll(text, /<(\w+?@(?:\w|\.\w)+?)>/g, (_, mail) => `<a href="mailto:${mail}">${mail}</a>`));
@@ -21,6 +21,13 @@ declare module 'amateras/core' {
21
21
  export function forward(): typeof Router;
22
22
  }
23
23
  }
24
+
25
+ declare global {
26
+ interface GlobalEventHandlersEventMap {
27
+ 'routeopen': Event;
28
+ }
29
+ }
30
+
24
31
  // assign methods
25
32
  _Object_assign($, {
26
33
  open: Router.open.bind(Router),
@@ -32,7 +39,7 @@ _Object_assign($, {
32
39
  forEach([
33
40
  `router{display:block}`,
34
41
  `page{display:block}`
35
- ], rule => $.stylesheet.insertRule(rule));
42
+ ], $.style);
36
43
  // assign nodes
37
44
  $.assign([
38
45
  ['router', Router],
@@ -9,6 +9,7 @@ export class Page<R extends Route<any> = any, Data extends RouteData = any> exte
9
9
  params: Data['params'];
10
10
  query: Data['query'];
11
11
  #pageTitle: null | string = null;
12
+ initial = false;
12
13
  constructor(route: R, data?: {params: any, query: any}) {
13
14
  super('page');
14
15
  this.route = route;
@@ -1,5 +1,5 @@
1
1
  import { _instanceof, _Object_fromEntries, _Array_from } from "#lib/native";
2
- import { $Element } from "#node/node";
2
+ import { $Element } from "#node/$Element";
3
3
  import type { AsyncRoute, RouteBuilder, RouteDataResolver } from "..";
4
4
  import { Page } from "./Page";
5
5
 
@@ -37,6 +37,7 @@ export class Route<Path extends string = string> extends BaseRouteNode<Path> {
37
37
  async build(data: {params: any, query: any} = {params: {}, query: {}}, page?: Page) {
38
38
  page = page ?? new Page(this, data);
39
39
  page.params = data.params;
40
+ page.initial = true;
40
41
  let resolver: any = this.builder(page);
41
42
  if (_instanceof(resolver, Promise)) {
42
43
  const result = await resolver as any;
@@ -1,24 +1,32 @@
1
1
  import type { AnchorTarget } from "#html/$Anchor";
2
- import { _Array_from, _document, _instanceof, _Object_fromEntries, forEach } from "#lib/native";
2
+ import { _document } from "#lib/env";
3
+ import { _Array_from, _instanceof, _JSON_parse, _JSON_stringify, _Object_entries, _Object_fromEntries, forEach, startsWith } from "#lib/native";
3
4
  import { Page } from "./Page";
4
5
  import { BaseRouteNode, Route } from "./Route";
5
6
 
6
7
  // history index
7
8
  let index = 0;
9
+ const _addEventListener = addEventListener;
8
10
  const _location = location;
9
11
  const {origin} = _location;
10
12
  const _history = history;
13
+ const _sessionStorage = sessionStorage;
11
14
  const documentElement = _document.documentElement;
12
15
  const [PUSH, REPLACE] = [1, 2] as const;
13
16
  const [FORWARD, BACK] = ['forward', 'back'] as const;
14
-
15
- // disable browser scroll restoration
16
- _history.scrollRestoration = 'manual';
17
-
17
+ const scrollStorageKey = '__scroll__';
18
18
  /** convert path string to URL object */
19
19
  const toURL = (path: string | URL) =>
20
- _instanceof(path, URL) ? path : path.startsWith('http') ? new URL(path) : new URL(path.startsWith(origin) ? path : origin + path);
20
+ _instanceof(path, URL) ? path : startsWith(path, 'http') ? new URL(path) : new URL(startsWith(path, origin) ? path : origin + path);
21
21
 
22
+ type ScrollData = {[key: number]: {x: number, y: number}};
23
+ const scrollRecord = (e?: Event) => {
24
+ const data = _JSON_parse(_sessionStorage.getItem(scrollStorageKey) ?? '{}') as ScrollData;
25
+ data[index] = { x: documentElement.scrollLeft, y: documentElement.scrollTop };
26
+ // e is Event when called from scroll or beforeload
27
+ if (!e) forEach(_Object_entries(data), ([i]) => +i > index && delete data[+i])
28
+ _sessionStorage.setItem(scrollStorageKey, _JSON_stringify(data));
29
+ }
22
30
  /** handle history state with push and replace state. */
23
31
  const historyHandler = async (path: string | URL | Nullish, mode: 1 | 2, target?: AnchorTarget) => {
24
32
  if (!path) return;
@@ -26,16 +34,14 @@ const historyHandler = async (path: string | URL | Nullish, mode: 1 | 2, target?
26
34
  if (url.href === _location.href) return;
27
35
  if (target && target !== '_self') return open(url, target);
28
36
  if (url.origin !== origin) return open(url, target);
29
- _history.replaceState({
30
- index: index,
31
- x: documentElement.scrollLeft,
32
- y: documentElement.scrollTop
33
- }, '', _location.href);
37
+ scrollRecord();
34
38
  if (mode === PUSH) index += 1;
35
39
  Router.direction = FORWARD;
36
- history[mode === PUSH ? 'pushState' : 'replaceState']({index}, '' , url)
37
- for (let router of Router.routers) router.routes.size && await router.resolve(path)
40
+ _history[mode === PUSH ? 'pushState' : 'replaceState']({index}, '' , url);
41
+ for (let router of Router.routers) router.routes.size && await router.resolve(path);
38
42
  }
43
+ // disable browser scroll restoration
44
+ _history.scrollRestoration = 'manual';
39
45
 
40
46
  export class Router extends BaseRouteNode<''> {
41
47
  static pageRouters = new Map<Page, Router>();
@@ -88,7 +94,7 @@ export class Router extends BaseRouteNode<''> {
88
94
  if (routeSnippet.includes(':')) {
89
95
  if (targetSnippet === '/') continue routeLoop;
90
96
  const [prefix, paramName] = routeSnippet.split(':') as [string, string];
91
- if (!targetSnippet.startsWith(prefix)) continue routeLoop;
97
+ if (!startsWith(targetSnippet, prefix)) continue routeLoop;
92
98
  routeData.params[paramName] = targetSnippet.replace(`${prefix}`, '');
93
99
  pass();
94
100
  continue splitLoop;
@@ -105,21 +111,20 @@ export class Router extends BaseRouteNode<''> {
105
111
  const targetRoutes = determineRoute(this, pathname + '/', hash);
106
112
  // build pages
107
113
  let prevPage: null | Page = null, prevRoute: BaseRouteNode<any> = this;
114
+ const appendPage = (prevRouter: Router | undefined, page: Page) => page.parentNode !== prevRouter?.node && prevRouter?.content(page);
115
+
108
116
  for (const [route, pathId] of targetRoutes) {
109
117
  const page = this.pageMap.get(pathId) ?? new Page(route ?? prevRoute.routes.get('404') ?? new Route('404', () => null), routeData);
110
- await route?.build(routeData, page);
118
+ if (!page.initial) await route?.build(routeData, page);
111
119
  _document && (_document.title = page.pageTitle() ?? _document.title);
112
120
  this.pageMap.set(pathId, page);
113
-
114
- if (href === _location.href) {
115
- if (prevPage) Router.pageRouters.get(prevPage)?.content(page);
116
- else this.content(page);
117
- }
121
+ if (href === _location.href) appendPage(prevPage ? Router.pageRouters.get(prevPage) : this, page);
118
122
  prevPage = page;
119
123
  if (route) prevRoute = route;
120
124
  }
121
- let { x, y } = _history.state ?? {x: 0, y: 0};
125
+ let { x, y } = Router.scroll ?? {x: 0, y: 0};
122
126
  scrollTo(x, y);
127
+ this.dispatchEvent(new Event('routeopen', {bubbles: true}));
123
128
  return this;
124
129
  }
125
130
 
@@ -131,8 +136,14 @@ export class Router extends BaseRouteNode<''> {
131
136
  index = stateIndex;
132
137
  this.resolve(_location.href);
133
138
  }
134
- addEventListener('popstate', resolve);
139
+ _addEventListener('popstate', resolve);
140
+ _addEventListener('beforeunload', scrollRecord);
141
+ _addEventListener('scroll', scrollRecord, false);
135
142
  resolve();
136
143
  return this;
137
144
  }
145
+
146
+ static get scroll(): ScrollData[number] {
147
+ return _JSON_parse(_sessionStorage.getItem(scrollStorageKey) ?? '{}')[index] ?? {x: 0, y: 0}
148
+ }
138
149
  }
package/ext/ssr/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import './env';
2
2
  import 'amateras';
3
- import { _Array_from, _document, _instanceof, _Object_assign, _Object_defineProperty, forEach } from "amateras/lib/native";
4
- import { $Element, $Node, $Text } from "amateras/node";
3
+ import { _Array_from, _instanceof, _Object_assign, _Object_defineProperty, forEach } from "amateras/lib/native";
4
+ import { $Element } from 'amateras/node/$Element';
5
5
  import { BROWSER, NODE } from 'esm-env';
6
+ import { $Node, $Text } from 'amateras/node/$Node';
7
+ import { _document } from '../../src/lib/env';
6
8
 
7
9
  declare module 'amateras/core' {
8
10
  export namespace $ {
@@ -0,0 +1,24 @@
1
+ import { _Array_from, _instanceof, forEach } from "amateras/lib/native";
2
+ import type { $Virtual } from "amateras/node/$Virtual";
3
+ import { $HTMLElement } from "amateras/node/$HTMLElement";
4
+ import { _document } from "../../../src/lib/env";
5
+
6
+ export function VirtualScroll($parent: $Virtual, scroller: Node = _document) {
7
+ scroller.addEventListener('scroll', render, true);
8
+ $parent.on('layout', render);
9
+ function render() {
10
+ const getRect = ($node: $HTMLElement) => $node.getBoundingClientRect();
11
+ const number = (str: string) => parseInt(str);
12
+ const parentRect = getRect($parent);
13
+ const children = _Array_from($parent.nodes);
14
+ forEach(children, $child => {
15
+ if (!_instanceof($child, $HTMLElement)) return;
16
+ const { top, height } = $child.style();
17
+ const topPos = parentRect.top + number(top);
18
+ const bottomPos = topPos + number(height);
19
+ if (bottomPos < 0 || topPos > innerHeight) $parent.hide($child);
20
+ else $parent.show($child);
21
+ })
22
+ $parent.render();
23
+ }
24
+ }