@w-lfpup/superaction 0.1.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.
- package/.github/workflows/build.yml +16 -0
- package/.prettierignore +5 -0
- package/.prettierrc +5 -0
- package/LICENSE +28 -0
- package/README.md +96 -0
- package/action_events.md +76 -0
- package/dist/mod.d.ts +28 -0
- package/dist/mod.js +59 -0
- package/examples/counter/index.html +20 -0
- package/examples/counter/mod.js +18 -0
- package/examples/counter/mod.ts +32 -0
- package/examples/index.html +15 -0
- package/examples/sketch/actions.js +1 -0
- package/examples/sketch/actions.ts +51 -0
- package/examples/sketch/index.html +49 -0
- package/examples/sketch/mod.js +50 -0
- package/examples/sketch/mod.ts +74 -0
- package/examples/sketch/worker.js +59 -0
- package/examples/sketch/worker.ts +72 -0
- package/examples/tsconfig.json +9 -0
- package/package.json +22 -0
- package/src/mod.ts +94 -0
- package/src/tsconfig.json +7 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: Build and Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["main"]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: ["main"]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build_and_test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v5
|
|
14
|
+
- uses: actions/setup-node@v4
|
|
15
|
+
- name: Install
|
|
16
|
+
run: npm ci
|
package/.prettierignore
ADDED
package/.prettierrc
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Taylor Vann
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# SuperAction-js
|
|
2
|
+
|
|
3
|
+
A hypertext extension to dispatch meaningful actions from the DOM.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Install via npm.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --save-dev @w-lfpup/superaction
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install directly from github.
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install --save-dev https://github.com/w-lfpup/superaction-js
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
Create a `SuperAction` instance dispatch action events.
|
|
22
|
+
|
|
23
|
+
The `SuperAction` instance below listens for `click` events. Event listeners are immediately `connected` to the `document`.
|
|
24
|
+
|
|
25
|
+
This enables the DOM to declaratively send meaningful messages to Javascript-land.
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { SuperAction } from "superaction";
|
|
29
|
+
|
|
30
|
+
const _superAction = new SuperAction({
|
|
31
|
+
host: document,
|
|
32
|
+
connected: true,
|
|
33
|
+
eventNames: ["click"],
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Declare
|
|
38
|
+
|
|
39
|
+
Add an attribute with the pattern `event:=action`. The `#action` event will dispatch from the `host` element
|
|
40
|
+
|
|
41
|
+
```html
|
|
42
|
+
<button click:="increment">+</button>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Listen
|
|
46
|
+
|
|
47
|
+
Now the `button` will dispatch an `ActionEvent` from the `host` when clicked.
|
|
48
|
+
|
|
49
|
+
Add an event listener to connect action events from the UI to javascript-land.
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
document.addEventListener("#action", (e) => {
|
|
53
|
+
let { action, sourceEvent, formData } = e.actionParams;
|
|
54
|
+
|
|
55
|
+
if ("increment" === action) {
|
|
56
|
+
// increment something!
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Form data is available when action events originate from form elements.
|
|
62
|
+
|
|
63
|
+
Learn more about action events [here](./action_events.md).
|
|
64
|
+
|
|
65
|
+
## Typescript
|
|
66
|
+
|
|
67
|
+
I'm not trying to pollute your globals so if you want typed `#action` events, please add the following to your app somewhere thoughtful.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import type { ActionEventInterface } from "superaction";
|
|
71
|
+
|
|
72
|
+
declare global {
|
|
73
|
+
interface GlobalEventHandlersEventMap {
|
|
74
|
+
["#action"]: ActionEventInterface;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Examples
|
|
80
|
+
|
|
81
|
+
Here are some examples to demonstrate how easy it is to work with `SuperAction-js`:
|
|
82
|
+
|
|
83
|
+
- a simple [counter](https://w-lfpup.github.io/superaction-js/examples/counter/)
|
|
84
|
+
- a small [sketchpad](https://w-lfpup.github.io/superaction-js/examples/sketch/) using an offscreen canvas
|
|
85
|
+
|
|
86
|
+
## Why do this?
|
|
87
|
+
|
|
88
|
+
`Superaction` is inspired by the [elm](https://elm-lang.org) project.
|
|
89
|
+
|
|
90
|
+
It turns HTML into a declarative and _explicit_ message generator and removes several layers of indirection between UI and app state.
|
|
91
|
+
|
|
92
|
+
`Superaction` is a straightforward way to work with vanilla web technologies and escape the JSX rabbithole.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
`SuperAction-js` is released under the BSD-3 Clause License.
|
package/action_events.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Action Events
|
|
2
|
+
|
|
3
|
+
## Event stacking
|
|
4
|
+
|
|
5
|
+
`Superaction-js` listens to any DOM event that bubbles. It also dispatches all actions found along the composed path of a DOM event.
|
|
6
|
+
|
|
7
|
+
Turns out that's [all UI Events](https://www.w3.org/TR/uievents/#events-uievents). Which is a lot of events!
|
|
8
|
+
|
|
9
|
+
Consider the following example:
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<body click:="A">
|
|
13
|
+
<div click:="B">
|
|
14
|
+
<button click:="C">hai :3</button>
|
|
15
|
+
</div>
|
|
16
|
+
</body>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
When a person clicks the button above, the order of action events is:
|
|
20
|
+
|
|
21
|
+
- Action "C"
|
|
22
|
+
- Action "B"
|
|
23
|
+
- Action "A"
|
|
24
|
+
|
|
25
|
+
## Propagation
|
|
26
|
+
|
|
27
|
+
Action events propagate similar to DOM events. Their declarative API reflects their DOM Event counterpart:
|
|
28
|
+
|
|
29
|
+
- `event:prevent-default`
|
|
30
|
+
- `event:stop-propagation`
|
|
31
|
+
- `event:stop-immediate-propagation`
|
|
32
|
+
|
|
33
|
+
Consider the following example:
|
|
34
|
+
|
|
35
|
+
```html
|
|
36
|
+
<body
|
|
37
|
+
click:="A"
|
|
38
|
+
click:stop-immediate-propagation>
|
|
39
|
+
<form
|
|
40
|
+
click:="B"
|
|
41
|
+
click:prevent-default>
|
|
42
|
+
<button
|
|
43
|
+
type=submit
|
|
44
|
+
click:="C">
|
|
45
|
+
UwU
|
|
46
|
+
</button>
|
|
47
|
+
<button
|
|
48
|
+
type=submit
|
|
49
|
+
click:="D"
|
|
50
|
+
click:stop-propagation>
|
|
51
|
+
^_^
|
|
52
|
+
</button>
|
|
53
|
+
</form>
|
|
54
|
+
</body>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
So when a person clicks the buttons above, the order of actions is:
|
|
58
|
+
|
|
59
|
+
Click button C:
|
|
60
|
+
|
|
61
|
+
- Action "C" dispatched
|
|
62
|
+
- `preventDefault()` is called on the original `HTMLSubmitEvent`
|
|
63
|
+
- Action "B" dispatched
|
|
64
|
+
- Action propagation is stopped similar to `event.stopImmediatePropagation()`
|
|
65
|
+
- Action "A" does _not_ dispatch
|
|
66
|
+
|
|
67
|
+
Click button D:
|
|
68
|
+
|
|
69
|
+
- Action "D" dispatched
|
|
70
|
+
- Action event propagation stopped similar to `event.stopPropagation()`
|
|
71
|
+
|
|
72
|
+
## Why #action ?
|
|
73
|
+
|
|
74
|
+
The `#action` event name, specifically the `#`, is used to prevent cyclical event disptaches.
|
|
75
|
+
|
|
76
|
+
We can't _dynamically_ add attribtues to elements that start with `#`. And in this way, some of the infinite loop risk is mitigated.
|
package/dist/mod.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface ActionInterface {
|
|
2
|
+
action: string;
|
|
3
|
+
formData?: FormData;
|
|
4
|
+
sourceEvent: Event;
|
|
5
|
+
}
|
|
6
|
+
export interface ActionEventInterface extends Event {
|
|
7
|
+
actionParams: ActionInterface;
|
|
8
|
+
}
|
|
9
|
+
export interface SuperActionParamsInterface {
|
|
10
|
+
connected?: boolean;
|
|
11
|
+
eventNames: string[];
|
|
12
|
+
host: EventTarget;
|
|
13
|
+
target?: EventTarget;
|
|
14
|
+
}
|
|
15
|
+
export interface SuperActionInterface {
|
|
16
|
+
connect(): void;
|
|
17
|
+
disconnect(): void;
|
|
18
|
+
}
|
|
19
|
+
export declare class ActionEvent extends Event implements ActionEventInterface {
|
|
20
|
+
actionParams: ActionInterface;
|
|
21
|
+
constructor(actionParams: ActionInterface, eventInit?: EventInit);
|
|
22
|
+
}
|
|
23
|
+
export declare class SuperAction implements SuperActionInterface {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(params: SuperActionParamsInterface);
|
|
26
|
+
connect(): void;
|
|
27
|
+
disconnect(): void;
|
|
28
|
+
}
|
package/dist/mod.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export class ActionEvent extends Event {
|
|
2
|
+
actionParams;
|
|
3
|
+
constructor(actionParams, eventInit) {
|
|
4
|
+
super("#action", eventInit);
|
|
5
|
+
this.actionParams = actionParams;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export class SuperAction {
|
|
9
|
+
#connected = false;
|
|
10
|
+
#boundDispatch;
|
|
11
|
+
#params;
|
|
12
|
+
#target;
|
|
13
|
+
constructor(params) {
|
|
14
|
+
this.#params = { ...params };
|
|
15
|
+
this.#target = params.target ?? params.host;
|
|
16
|
+
this.#boundDispatch = this.#dispatch.bind(this);
|
|
17
|
+
if (this.#params.connected)
|
|
18
|
+
this.connect();
|
|
19
|
+
}
|
|
20
|
+
connect() {
|
|
21
|
+
if (this.#connected)
|
|
22
|
+
return;
|
|
23
|
+
this.#connected = true;
|
|
24
|
+
let { host, eventNames } = this.#params;
|
|
25
|
+
for (let name of eventNames) {
|
|
26
|
+
host.addEventListener(name, this.#boundDispatch);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
disconnect() {
|
|
30
|
+
let { host, eventNames } = this.#params;
|
|
31
|
+
for (let name of eventNames) {
|
|
32
|
+
host.removeEventListener(name, this.#boundDispatch);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
#dispatch(sourceEvent) {
|
|
36
|
+
let { type, currentTarget, target } = sourceEvent;
|
|
37
|
+
if (!currentTarget)
|
|
38
|
+
return;
|
|
39
|
+
let formData;
|
|
40
|
+
if (target instanceof HTMLFormElement)
|
|
41
|
+
formData = new FormData(target);
|
|
42
|
+
for (let node of sourceEvent.composedPath()) {
|
|
43
|
+
if (node instanceof Element) {
|
|
44
|
+
if (node.hasAttribute(`${type}:prevent-default`))
|
|
45
|
+
sourceEvent.preventDefault();
|
|
46
|
+
if (node.hasAttribute(`${type}:stop-immediate-propagation`))
|
|
47
|
+
return;
|
|
48
|
+
let action = node.getAttribute(`${type}:`);
|
|
49
|
+
if (action) {
|
|
50
|
+
let composed = node.hasAttribute(`${type}:composed`);
|
|
51
|
+
let event = new ActionEvent({ action, sourceEvent, formData }, { bubbles: true, composed });
|
|
52
|
+
this.#target.dispatchEvent(event);
|
|
53
|
+
}
|
|
54
|
+
if (node.hasAttribute(`${type}:stop-propagation`))
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<script type="importmap">
|
|
7
|
+
{
|
|
8
|
+
"imports": {
|
|
9
|
+
"superaction": "../../dist/mod.js"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
<script type="module" src="./mod.js"></script>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<button click:="decrement">-</button>
|
|
17
|
+
<span count>42</span>
|
|
18
|
+
<button click:="increment">+</button>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { SuperAction } from "superaction";
|
|
2
|
+
const _superAction = new SuperAction({
|
|
3
|
+
host: document,
|
|
4
|
+
connected: true,
|
|
5
|
+
eventNames: ["click"],
|
|
6
|
+
});
|
|
7
|
+
const countEl = document.querySelector("[count]");
|
|
8
|
+
let count = parseFloat(countEl.textContent ?? "");
|
|
9
|
+
addEventListener("#action", function (e) {
|
|
10
|
+
let { action } = e.actionParams;
|
|
11
|
+
if ("increment" === action) {
|
|
12
|
+
count += 1;
|
|
13
|
+
}
|
|
14
|
+
if ("decrement" === action) {
|
|
15
|
+
count -= 1;
|
|
16
|
+
}
|
|
17
|
+
countEl.textContent = count.toString();
|
|
18
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ActionEventInterface } from "superaction";
|
|
2
|
+
|
|
3
|
+
import { SuperAction } from "superaction";
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
interface GlobalEventHandlersEventMap {
|
|
7
|
+
["#action"]: ActionEventInterface;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const _superAction = new SuperAction({
|
|
12
|
+
host: document,
|
|
13
|
+
connected: true,
|
|
14
|
+
eventNames: ["click"],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const countEl = document.querySelector("[count]")!;
|
|
18
|
+
let count = parseFloat(countEl.textContent ?? "");
|
|
19
|
+
|
|
20
|
+
addEventListener("#action", function (e) {
|
|
21
|
+
let { action } = e.actionParams;
|
|
22
|
+
|
|
23
|
+
if ("increment" === action) {
|
|
24
|
+
count += 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if ("decrement" === action) {
|
|
28
|
+
count -= 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
countEl.textContent = count.toString();
|
|
32
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<header></header>
|
|
9
|
+
<main>
|
|
10
|
+
<p>A <a href="./counter/index.html">counter</a> example.</p>
|
|
11
|
+
<p>A <a href="./sketch/index.html">canvas</a> example.</p>
|
|
12
|
+
</main>
|
|
13
|
+
<footer></footer>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface CanvasParams {
|
|
2
|
+
top: number;
|
|
3
|
+
left: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface PenParams {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
movementX: number;
|
|
12
|
+
movementY: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SetupCanvas {
|
|
16
|
+
action: "setup_canvas";
|
|
17
|
+
offscreenCanvas: OffscreenCanvas;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SetCanvasParams {
|
|
21
|
+
action: "set_canvas_params";
|
|
22
|
+
params: CanvasParams;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SetColor {
|
|
26
|
+
action: "set_color";
|
|
27
|
+
color: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface MovePen {
|
|
31
|
+
action: "move_pen";
|
|
32
|
+
params: PenParams;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PressPen {
|
|
36
|
+
action: "press_pen";
|
|
37
|
+
params: PenParams;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface LiftPen {
|
|
41
|
+
action: "lift_pen";
|
|
42
|
+
params: PenParams;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type Actions =
|
|
46
|
+
| SetupCanvas
|
|
47
|
+
| SetCanvasParams
|
|
48
|
+
| SetColor
|
|
49
|
+
| MovePen
|
|
50
|
+
| PressPen
|
|
51
|
+
| LiftPen;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html pointerup:="lift_pen" pointerdown:="press_pen" pointermove:="move_pen">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<script type="importmap">
|
|
7
|
+
{
|
|
8
|
+
"imports": {
|
|
9
|
+
"superaction": "../../dist/mod.js"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
<script type="module" src="./mod.js"></script>
|
|
14
|
+
<style>
|
|
15
|
+
:root {
|
|
16
|
+
background-color: #d3d3d3;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
margin: 0;
|
|
21
|
+
display: grid;
|
|
22
|
+
grid-template-columns: 1fr min(80dvw, 80dvh) 1fr;
|
|
23
|
+
grid-template-rows: 1fr min(80dvw, 80dvh) 1fr;
|
|
24
|
+
block-size: 100dvh;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
input {
|
|
28
|
+
grid-column-start: 1;
|
|
29
|
+
grid-row-start: 2;
|
|
30
|
+
align-self: flex-start;
|
|
31
|
+
justify-self: end;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
canvas {
|
|
35
|
+
background-color: white;
|
|
36
|
+
grid-column-start: 2;
|
|
37
|
+
grid-row-start: 2;
|
|
38
|
+
block-size: 100%;
|
|
39
|
+
inline-size: 100%;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<menu>
|
|
45
|
+
<input type="color" input:="set_color" pointerdown:stop-propagation />
|
|
46
|
+
</menu>
|
|
47
|
+
<canvas></canvas>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { SuperAction } from "superaction";
|
|
2
|
+
const _superAction = new SuperAction({
|
|
3
|
+
host: document,
|
|
4
|
+
connected: true,
|
|
5
|
+
eventNames: ["input", "pointerdown", "pointerup", "pointermove"],
|
|
6
|
+
});
|
|
7
|
+
// Setup workers
|
|
8
|
+
const worker = new Worker("worker.js", { type: "module" });
|
|
9
|
+
const canvas = document.querySelector("canvas");
|
|
10
|
+
const offscreenCanvas = canvas.transferControlToOffscreen();
|
|
11
|
+
const resizeObserver = new ResizeObserver(sendCanvasParams);
|
|
12
|
+
resizeObserver.observe(canvas);
|
|
13
|
+
// Add reactions
|
|
14
|
+
addEventListener("#action", function (e) {
|
|
15
|
+
let { action, sourceEvent } = e.actionParams;
|
|
16
|
+
// send actions to the offscreen canvas worker
|
|
17
|
+
// set color action needs input value
|
|
18
|
+
if ("set_color" === action &&
|
|
19
|
+
sourceEvent.target instanceof HTMLInputElement) {
|
|
20
|
+
worker.postMessage({
|
|
21
|
+
action,
|
|
22
|
+
color: sourceEvent.target.value,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// other pointer actions
|
|
26
|
+
if (sourceEvent instanceof PointerEvent) {
|
|
27
|
+
let { x, y, movementX, movementY } = sourceEvent;
|
|
28
|
+
worker.postMessage({
|
|
29
|
+
action,
|
|
30
|
+
params: { x, y, movementX, movementY },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
// Initialize offscreen canvas
|
|
35
|
+
function setupCanvas() {
|
|
36
|
+
worker.postMessage({
|
|
37
|
+
action: "setup_canvas",
|
|
38
|
+
offscreenCanvas,
|
|
39
|
+
}, [offscreenCanvas]);
|
|
40
|
+
}
|
|
41
|
+
function sendCanvasParams() {
|
|
42
|
+
let { top, left } = canvas.getBoundingClientRect();
|
|
43
|
+
let { clientWidth, clientHeight } = canvas;
|
|
44
|
+
worker.postMessage({
|
|
45
|
+
action: "set_canvas_params",
|
|
46
|
+
params: { top, left, width: clientWidth, height: clientHeight },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
setupCanvas();
|
|
50
|
+
sendCanvasParams();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { SuperAction, ActionEventInterface } from "superaction";
|
|
2
|
+
|
|
3
|
+
// Setup SuperAction
|
|
4
|
+
declare global {
|
|
5
|
+
interface GlobalEventHandlersEventMap {
|
|
6
|
+
["#action"]: ActionEventInterface;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const _superAction = new SuperAction({
|
|
11
|
+
host: document,
|
|
12
|
+
connected: true,
|
|
13
|
+
eventNames: ["input", "pointerdown", "pointerup", "pointermove"],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Setup workers
|
|
17
|
+
const worker = new Worker("worker.js", { type: "module" });
|
|
18
|
+
const canvas = document.querySelector("canvas")!;
|
|
19
|
+
const offscreenCanvas = canvas.transferControlToOffscreen();
|
|
20
|
+
|
|
21
|
+
const resizeObserver = new ResizeObserver(sendCanvasParams);
|
|
22
|
+
resizeObserver.observe(canvas);
|
|
23
|
+
|
|
24
|
+
// Add reactions
|
|
25
|
+
addEventListener("#action", function (e: ActionEventInterface) {
|
|
26
|
+
let { action, sourceEvent } = e.actionParams;
|
|
27
|
+
|
|
28
|
+
// send actions to the offscreen canvas worker
|
|
29
|
+
|
|
30
|
+
// set color action needs input value
|
|
31
|
+
if (
|
|
32
|
+
"set_color" === action &&
|
|
33
|
+
sourceEvent.target instanceof HTMLInputElement
|
|
34
|
+
) {
|
|
35
|
+
worker.postMessage({
|
|
36
|
+
action,
|
|
37
|
+
color: sourceEvent.target.value,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// other pointer actions
|
|
42
|
+
if (sourceEvent instanceof PointerEvent) {
|
|
43
|
+
let { x, y, movementX, movementY } = sourceEvent;
|
|
44
|
+
|
|
45
|
+
worker.postMessage({
|
|
46
|
+
action,
|
|
47
|
+
params: { x, y, movementX, movementY },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Initialize offscreen canvas
|
|
53
|
+
function setupCanvas() {
|
|
54
|
+
worker.postMessage(
|
|
55
|
+
{
|
|
56
|
+
action: "setup_canvas",
|
|
57
|
+
offscreenCanvas,
|
|
58
|
+
},
|
|
59
|
+
[offscreenCanvas],
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sendCanvasParams() {
|
|
64
|
+
let { top, left } = canvas.getBoundingClientRect();
|
|
65
|
+
let { clientWidth, clientHeight } = canvas;
|
|
66
|
+
|
|
67
|
+
worker.postMessage({
|
|
68
|
+
action: "set_canvas_params",
|
|
69
|
+
params: { top, left, width: clientWidth, height: clientHeight },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setupCanvas();
|
|
74
|
+
sendCanvasParams();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
let canvas;
|
|
2
|
+
let ctx;
|
|
3
|
+
let pen_to_paper = false;
|
|
4
|
+
let canvasParams;
|
|
5
|
+
self.addEventListener("message", function (e) {
|
|
6
|
+
let { data } = e;
|
|
7
|
+
if ("setup_canvas" === data.action) {
|
|
8
|
+
canvas = data.offscreenCanvas;
|
|
9
|
+
ctx = canvas.getContext("2d");
|
|
10
|
+
}
|
|
11
|
+
if ("set_canvas_params" === data.action) {
|
|
12
|
+
canvas.width = data.params.width;
|
|
13
|
+
canvas.height = data.params.height;
|
|
14
|
+
canvasParams = data.params;
|
|
15
|
+
if (ctx) {
|
|
16
|
+
ctx.lineWidth = 10;
|
|
17
|
+
ctx.lineCap = "round";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if ("set_color" === data.action) {
|
|
21
|
+
let { color } = data;
|
|
22
|
+
if (ctx) {
|
|
23
|
+
ctx.strokeStyle = color;
|
|
24
|
+
ctx.fillStyle = color;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if ("press_pen" === data.action) {
|
|
28
|
+
pen_to_paper = true;
|
|
29
|
+
if (ctx) {
|
|
30
|
+
// create first point
|
|
31
|
+
ctx.beginPath();
|
|
32
|
+
let { top, left } = canvasParams;
|
|
33
|
+
let { x, y } = data.params;
|
|
34
|
+
let dx = x - left;
|
|
35
|
+
let dy = y - top;
|
|
36
|
+
ctx.arc(dx, dy, ctx.lineWidth * 0.5, 0, 2 * Math.PI, true);
|
|
37
|
+
ctx.fill();
|
|
38
|
+
ctx.closePath();
|
|
39
|
+
// start a "line"
|
|
40
|
+
ctx.beginPath();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if ("move_pen" === data.action) {
|
|
44
|
+
if (ctx && pen_to_paper) {
|
|
45
|
+
let { top, left } = canvasParams;
|
|
46
|
+
let { movementY, movementX, x, y } = data.params;
|
|
47
|
+
let dx = x - left;
|
|
48
|
+
let dy = y - top;
|
|
49
|
+
ctx.moveTo(dx - movementX, dy - movementY);
|
|
50
|
+
ctx.lineTo(dx, dy);
|
|
51
|
+
ctx.stroke();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if ("lift_pen" === data.action) {
|
|
55
|
+
pen_to_paper = false;
|
|
56
|
+
ctx?.closePath();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Actions, CanvasParams } from "./actions.ts";
|
|
2
|
+
|
|
3
|
+
let canvas: OffscreenCanvas;
|
|
4
|
+
let ctx: OffscreenCanvasRenderingContext2D | null;
|
|
5
|
+
let pen_to_paper = false;
|
|
6
|
+
let canvasParams: CanvasParams;
|
|
7
|
+
|
|
8
|
+
self.addEventListener("message", function (e: MessageEvent<Actions>) {
|
|
9
|
+
let { data } = e;
|
|
10
|
+
|
|
11
|
+
if ("setup_canvas" === data.action) {
|
|
12
|
+
canvas = data.offscreenCanvas;
|
|
13
|
+
ctx = canvas.getContext("2d");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if ("set_canvas_params" === data.action) {
|
|
17
|
+
canvas.width = data.params.width;
|
|
18
|
+
canvas.height = data.params.height;
|
|
19
|
+
canvasParams = data.params;
|
|
20
|
+
if (ctx) {
|
|
21
|
+
ctx.lineWidth = 10;
|
|
22
|
+
ctx.lineCap = "round";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if ("set_color" === data.action) {
|
|
27
|
+
let { color } = data;
|
|
28
|
+
if (ctx) {
|
|
29
|
+
ctx.strokeStyle = color;
|
|
30
|
+
ctx.fillStyle = color;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if ("press_pen" === data.action) {
|
|
35
|
+
pen_to_paper = true;
|
|
36
|
+
if (ctx) {
|
|
37
|
+
// create first point
|
|
38
|
+
ctx.beginPath();
|
|
39
|
+
let { top, left } = canvasParams;
|
|
40
|
+
let { x, y } = data.params;
|
|
41
|
+
|
|
42
|
+
let dx = x - left;
|
|
43
|
+
let dy = y - top;
|
|
44
|
+
|
|
45
|
+
ctx.arc(dx, dy, ctx.lineWidth * 0.5, 0, 2 * Math.PI, true);
|
|
46
|
+
ctx.fill();
|
|
47
|
+
ctx.closePath();
|
|
48
|
+
|
|
49
|
+
// start a "line"
|
|
50
|
+
ctx.beginPath();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if ("move_pen" === data.action) {
|
|
55
|
+
if (ctx && pen_to_paper) {
|
|
56
|
+
let { top, left } = canvasParams;
|
|
57
|
+
let { movementY, movementX, x, y } = data.params;
|
|
58
|
+
|
|
59
|
+
let dx = x - left;
|
|
60
|
+
let dy = y - top;
|
|
61
|
+
|
|
62
|
+
ctx.moveTo(dx - movementX, dy - movementY);
|
|
63
|
+
ctx.lineTo(dx, dy);
|
|
64
|
+
ctx.stroke();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if ("lift_pen" === data.action) {
|
|
69
|
+
pen_to_paper = false;
|
|
70
|
+
ctx?.closePath();
|
|
71
|
+
}
|
|
72
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@w-lfpup/superaction",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"main": "dist/mod.js",
|
|
5
|
+
"description": "A hypertext extension to dispatch meaningful actions from the DOM",
|
|
6
|
+
"license": "BSD-3-Clause",
|
|
7
|
+
"version": "0.1.0",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"prepare": "npm run build && npm run build:examples",
|
|
10
|
+
"build": "npx tsc --project ./src",
|
|
11
|
+
"build:examples": "npx tsc --project ./examples",
|
|
12
|
+
"format": "npx prettier ./ --write"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"prettier": "^3.2.5",
|
|
16
|
+
"typescript": "^5.4.5"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/w-lfpup/superaction-js.git"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/mod.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export interface ActionInterface {
|
|
2
|
+
action: string;
|
|
3
|
+
formData?: FormData;
|
|
4
|
+
sourceEvent: Event;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ActionEventInterface extends Event {
|
|
8
|
+
actionParams: ActionInterface;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SuperActionParamsInterface {
|
|
12
|
+
connected?: boolean;
|
|
13
|
+
eventNames: string[];
|
|
14
|
+
host: EventTarget;
|
|
15
|
+
target?: EventTarget;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SuperActionInterface {
|
|
19
|
+
connect(): void;
|
|
20
|
+
disconnect(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ActionEvent extends Event implements ActionEventInterface {
|
|
24
|
+
actionParams: ActionInterface;
|
|
25
|
+
|
|
26
|
+
constructor(actionParams: ActionInterface, eventInit?: EventInit) {
|
|
27
|
+
super("#action", eventInit);
|
|
28
|
+
this.actionParams = actionParams;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class SuperAction implements SuperActionInterface {
|
|
33
|
+
#connected = false;
|
|
34
|
+
|
|
35
|
+
#boundDispatch: EventListenerOrEventListenerObject;
|
|
36
|
+
#params: SuperActionParamsInterface;
|
|
37
|
+
#target: EventTarget;
|
|
38
|
+
|
|
39
|
+
constructor(params: SuperActionParamsInterface) {
|
|
40
|
+
this.#params = { ...params };
|
|
41
|
+
this.#target = params.target ?? params.host;
|
|
42
|
+
this.#boundDispatch = this.#dispatch.bind(this);
|
|
43
|
+
|
|
44
|
+
if (this.#params.connected) this.connect();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
connect() {
|
|
48
|
+
if (this.#connected) return;
|
|
49
|
+
this.#connected = true;
|
|
50
|
+
|
|
51
|
+
let { host, eventNames } = this.#params;
|
|
52
|
+
for (let name of eventNames) {
|
|
53
|
+
host.addEventListener(name, this.#boundDispatch);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
disconnect() {
|
|
58
|
+
let { host, eventNames } = this.#params;
|
|
59
|
+
for (let name of eventNames) {
|
|
60
|
+
host.removeEventListener(name, this.#boundDispatch);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#dispatch(sourceEvent: Event) {
|
|
65
|
+
let { type, currentTarget, target } = sourceEvent;
|
|
66
|
+
if (!currentTarget) return;
|
|
67
|
+
|
|
68
|
+
let formData: FormData | undefined;
|
|
69
|
+
if (target instanceof HTMLFormElement) formData = new FormData(target);
|
|
70
|
+
|
|
71
|
+
for (let node of sourceEvent.composedPath()) {
|
|
72
|
+
if (node instanceof Element) {
|
|
73
|
+
if (node.hasAttribute(`${type}:prevent-default`))
|
|
74
|
+
sourceEvent.preventDefault();
|
|
75
|
+
|
|
76
|
+
if (node.hasAttribute(`${type}:stop-immediate-propagation`))
|
|
77
|
+
return;
|
|
78
|
+
|
|
79
|
+
let action = node.getAttribute(`${type}:`);
|
|
80
|
+
if (action) {
|
|
81
|
+
let composed = node.hasAttribute(`${type}:composed`);
|
|
82
|
+
let event = new ActionEvent(
|
|
83
|
+
{ action, sourceEvent, formData },
|
|
84
|
+
{ bubbles: true, composed },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
this.#target.dispatchEvent(event);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (node.hasAttribute(`${type}:stop-propagation`)) return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|