@editframe/elements 0.15.0-beta.1 → 0.15.0-beta.10
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/dist/EF_FRAMEGEN.js +0 -2
- package/dist/elements/EFAudio.d.ts +0 -1
- package/dist/elements/EFAudio.js +1 -5
- package/dist/elements/EFCaptions.js +1 -1
- package/dist/elements/EFMedia.d.ts +2 -1
- package/dist/elements/EFMedia.js +125 -14
- package/dist/elements/EFTemporal.d.ts +3 -3
- package/dist/elements/EFTemporal.js +6 -2
- package/dist/elements/EFTimegroup.d.ts +1 -5
- package/dist/elements/EFTimegroup.js +4 -5
- package/dist/elements/EFWaveform.d.ts +14 -6
- package/dist/elements/EFWaveform.js +155 -53
- package/dist/elements/TargetController.d.ts +25 -0
- package/dist/elements/TargetController.js +164 -0
- package/dist/elements/TargetController.test.d.ts +19 -0
- package/dist/gui/EFPreview.d.ts +1 -1
- package/dist/gui/EFPreview.js +1 -0
- package/dist/gui/TWMixin.css.js +1 -1
- package/dist/style.css +3 -0
- package/package.json +9 -4
- package/src/elements/EFAudio.ts +1 -4
- package/src/elements/EFCaptions.ts +1 -1
- package/src/elements/EFMedia.ts +158 -22
- package/src/elements/EFTemporal.ts +10 -10
- package/src/elements/EFTimegroup.ts +4 -9
- package/src/elements/EFWaveform.ts +214 -70
- package/src/elements/TargetController.test.ts +229 -0
- package/src/elements/TargetController.ts +219 -0
- package/src/gui/EFPreview.ts +10 -9
- package/types.json +1 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { LitElement, html } from "lit";
|
|
2
|
+
import { customElement, property, state } from "lit/decorators.js";
|
|
3
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
4
|
+
import { EFTargetable, TargetController } from "./TargetController.ts";
|
|
5
|
+
|
|
6
|
+
let id = 0;
|
|
7
|
+
|
|
8
|
+
const nextId = () => {
|
|
9
|
+
return `targetable-test-${id++}`;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
@customElement("targetable-test")
|
|
13
|
+
class TargetableTest extends EFTargetable(LitElement) {
|
|
14
|
+
@property()
|
|
15
|
+
value = "initial";
|
|
16
|
+
|
|
17
|
+
render() {
|
|
18
|
+
return html`<div>${this.value}</div>`;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@customElement("targeter-test")
|
|
23
|
+
class TargeterTest extends LitElement {
|
|
24
|
+
// @ts-expect-error this controller is needed, but never referenced
|
|
25
|
+
private targetController: TargetController = new TargetController(this);
|
|
26
|
+
|
|
27
|
+
@state()
|
|
28
|
+
targetElement: Element | null = null;
|
|
29
|
+
|
|
30
|
+
@property()
|
|
31
|
+
target = "";
|
|
32
|
+
|
|
33
|
+
render() {
|
|
34
|
+
const target = this.targetElement;
|
|
35
|
+
return html`
|
|
36
|
+
<div>
|
|
37
|
+
${target ? html`Found: ${target.tagName}` : html`Finding target...`}
|
|
38
|
+
</div>
|
|
39
|
+
`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("target", () => {
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
// Clean up all test elements from the document body
|
|
46
|
+
document.body.innerHTML = "";
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("should be able to get the target element", async () => {
|
|
50
|
+
const target = document.createElement("targetable-test");
|
|
51
|
+
const element = document.createElement("targeter-test");
|
|
52
|
+
document.body.appendChild(target);
|
|
53
|
+
document.body.appendChild(element);
|
|
54
|
+
|
|
55
|
+
const id = nextId();
|
|
56
|
+
target.id = id;
|
|
57
|
+
element.target = id;
|
|
58
|
+
|
|
59
|
+
await element.updateComplete;
|
|
60
|
+
expect(element.targetElement).toBe(target);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("should update when document changes", async () => {
|
|
64
|
+
const target = document.createElement("targetable-test");
|
|
65
|
+
const element = document.createElement("targeter-test");
|
|
66
|
+
document.body.appendChild(element);
|
|
67
|
+
|
|
68
|
+
const id = nextId();
|
|
69
|
+
element.target = id;
|
|
70
|
+
|
|
71
|
+
expect(element.targetElement).toBe(null);
|
|
72
|
+
|
|
73
|
+
target.id = id;
|
|
74
|
+
document.body.appendChild(target);
|
|
75
|
+
await element.updateComplete;
|
|
76
|
+
expect(element.targetElement).toBe(target);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("should update when attribute changes", async () => {
|
|
80
|
+
const target = document.createElement("targetable-test");
|
|
81
|
+
const element = document.createElement("targeter-test");
|
|
82
|
+
document.body.appendChild(element);
|
|
83
|
+
document.body.appendChild(target);
|
|
84
|
+
|
|
85
|
+
const id = nextId();
|
|
86
|
+
target.id = id;
|
|
87
|
+
element.target = id;
|
|
88
|
+
|
|
89
|
+
await element.updateComplete;
|
|
90
|
+
expect(element.targetElement).toBe(target);
|
|
91
|
+
|
|
92
|
+
target.id = nextId();
|
|
93
|
+
await element.updateComplete;
|
|
94
|
+
expect(element.targetElement).toBe(null);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("should update when target is set before id exists", async () => {
|
|
98
|
+
const target = document.createElement("targetable-test");
|
|
99
|
+
const element = document.createElement("targeter-test");
|
|
100
|
+
document.body.appendChild(target);
|
|
101
|
+
document.body.appendChild(element);
|
|
102
|
+
|
|
103
|
+
const id = nextId();
|
|
104
|
+
element.target = id;
|
|
105
|
+
expect(element.targetElement).toBe(null);
|
|
106
|
+
|
|
107
|
+
target.id = id;
|
|
108
|
+
await element.updateComplete;
|
|
109
|
+
expect(element.targetElement).toBe(target);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("should update when target changes to match existing id", async () => {
|
|
113
|
+
const target = document.createElement("targetable-test");
|
|
114
|
+
const element = document.createElement("targeter-test");
|
|
115
|
+
document.body.appendChild(target);
|
|
116
|
+
document.body.appendChild(element);
|
|
117
|
+
|
|
118
|
+
const id = nextId();
|
|
119
|
+
target.id = id;
|
|
120
|
+
expect(element.targetElement).toBe(null);
|
|
121
|
+
|
|
122
|
+
element.target = id;
|
|
123
|
+
await element.updateComplete;
|
|
124
|
+
expect(element.targetElement).toBe(target);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("should handle target being cleared", async () => {
|
|
128
|
+
const target = document.createElement("targetable-test");
|
|
129
|
+
const element = document.createElement("targeter-test");
|
|
130
|
+
document.body.appendChild(target);
|
|
131
|
+
document.body.appendChild(element);
|
|
132
|
+
|
|
133
|
+
const id = nextId();
|
|
134
|
+
target.id = id;
|
|
135
|
+
element.target = id;
|
|
136
|
+
|
|
137
|
+
await element.updateComplete;
|
|
138
|
+
expect(element.targetElement).toBe(target);
|
|
139
|
+
|
|
140
|
+
element.target = "";
|
|
141
|
+
await element.updateComplete;
|
|
142
|
+
expect(element.targetElement).toBe(null);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("should handle multiple elements targeting the same id", async () => {
|
|
146
|
+
const target = document.createElement("targetable-test");
|
|
147
|
+
const element1 = document.createElement("targeter-test");
|
|
148
|
+
const element2 = document.createElement("targeter-test");
|
|
149
|
+
document.body.appendChild(target);
|
|
150
|
+
document.body.appendChild(element1);
|
|
151
|
+
document.body.appendChild(element2);
|
|
152
|
+
|
|
153
|
+
const id = nextId();
|
|
154
|
+
target.id = id;
|
|
155
|
+
element1.target = id;
|
|
156
|
+
element2.target = id;
|
|
157
|
+
|
|
158
|
+
await Promise.all([element1.updateComplete, element2.updateComplete]);
|
|
159
|
+
expect(element1.targetElement).toBe(target);
|
|
160
|
+
expect(element2.targetElement).toBe(target);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("should handle element removal from DOM", async () => {
|
|
164
|
+
const target = document.createElement("targetable-test");
|
|
165
|
+
const element = document.createElement("targeter-test");
|
|
166
|
+
document.body.appendChild(target);
|
|
167
|
+
document.body.appendChild(element);
|
|
168
|
+
|
|
169
|
+
const id = nextId();
|
|
170
|
+
target.id = id;
|
|
171
|
+
element.target = id;
|
|
172
|
+
|
|
173
|
+
await element.updateComplete;
|
|
174
|
+
expect(element.targetElement).toBe(target);
|
|
175
|
+
|
|
176
|
+
document.body.removeChild(target);
|
|
177
|
+
await element.updateComplete;
|
|
178
|
+
expect(element.targetElement).toBe(null);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("should handle rapid target id changes", async () => {
|
|
182
|
+
const target = document.createElement("targetable-test");
|
|
183
|
+
const element = document.createElement("targeter-test");
|
|
184
|
+
document.body.appendChild(target);
|
|
185
|
+
document.body.appendChild(element);
|
|
186
|
+
|
|
187
|
+
const id1 = nextId();
|
|
188
|
+
const id2 = nextId();
|
|
189
|
+
const id3 = nextId();
|
|
190
|
+
|
|
191
|
+
target.id = id1;
|
|
192
|
+
element.target = id1;
|
|
193
|
+
await element.updateComplete;
|
|
194
|
+
expect(element.targetElement).toBe(target);
|
|
195
|
+
|
|
196
|
+
target.id = id2;
|
|
197
|
+
target.id = id3; // Immediately change again
|
|
198
|
+
await element.updateComplete;
|
|
199
|
+
expect(element.targetElement).toBe(null);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("should not trigger unnecessary updates when setting same id multiple times", async () => {
|
|
203
|
+
const target = document.createElement("targetable-test");
|
|
204
|
+
const element = document.createElement("targeter-test");
|
|
205
|
+
document.body.appendChild(target);
|
|
206
|
+
document.body.appendChild(element);
|
|
207
|
+
|
|
208
|
+
const id = nextId();
|
|
209
|
+
target.id = id;
|
|
210
|
+
element.target = id;
|
|
211
|
+
|
|
212
|
+
await element.updateComplete;
|
|
213
|
+
expect(element.targetElement).toBe(target);
|
|
214
|
+
|
|
215
|
+
// Set the same ID again
|
|
216
|
+
target.id = id;
|
|
217
|
+
await element.updateComplete;
|
|
218
|
+
|
|
219
|
+
// The target element should remain stable
|
|
220
|
+
expect(element.targetElement).toBe(target);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
declare global {
|
|
225
|
+
interface HTMLElementTagNameMap {
|
|
226
|
+
"targetable-test": TargetableTest & Element;
|
|
227
|
+
"targeter-test": TargeterTest & Element;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { LitElement, type ReactiveController } from "lit";
|
|
2
|
+
|
|
3
|
+
type Constructor<T = {}> = new (...args: any[]) => T;
|
|
4
|
+
|
|
5
|
+
// Symbol to identify elements that can be targeted
|
|
6
|
+
const EF_TARGETABLE = Symbol("EF_TARGETABLE");
|
|
7
|
+
|
|
8
|
+
class TargetRegistry {
|
|
9
|
+
private idMap = new Map<string, LitElement>();
|
|
10
|
+
private callbacks = new Map<
|
|
11
|
+
string,
|
|
12
|
+
Set<(target: LitElement | undefined) => void>
|
|
13
|
+
>();
|
|
14
|
+
|
|
15
|
+
subscribe(id: string, callback: (target: LitElement | undefined) => void) {
|
|
16
|
+
this.callbacks.set(id, this.callbacks.get(id) ?? new Set());
|
|
17
|
+
this.callbacks.get(id)?.add(callback);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
unsubscribe(
|
|
21
|
+
id: string | null,
|
|
22
|
+
callback: (target: LitElement | undefined) => void,
|
|
23
|
+
) {
|
|
24
|
+
if (id === null) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.callbacks.get(id)?.delete(callback);
|
|
28
|
+
if (this.callbacks.get(id)?.size === 0) {
|
|
29
|
+
this.callbacks.delete(id);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get(id: string) {
|
|
34
|
+
return this.idMap.get(id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
register(id: string, target: LitElement) {
|
|
38
|
+
this.idMap.set(id, target);
|
|
39
|
+
for (const callback of this.callbacks.get(id) ?? []) {
|
|
40
|
+
callback(target);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
unregister(id: string) {
|
|
45
|
+
for (const callback of this.callbacks.get(id) ?? []) {
|
|
46
|
+
callback(undefined);
|
|
47
|
+
}
|
|
48
|
+
this.idMap.delete(id);
|
|
49
|
+
this.callbacks.delete(id);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Map of root nodes to their target registries
|
|
54
|
+
const documentRegistries = new WeakMap<Node, TargetRegistry>();
|
|
55
|
+
|
|
56
|
+
const getRegistry = (root: Node) => {
|
|
57
|
+
let registry = documentRegistries.get(root);
|
|
58
|
+
if (!registry) {
|
|
59
|
+
registry = new TargetRegistry();
|
|
60
|
+
documentRegistries.set(root, registry);
|
|
61
|
+
}
|
|
62
|
+
return registry;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export declare class TargetableMixinInterface {
|
|
66
|
+
id: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const isEFTargetable = (obj: any): obj is TargetableMixinInterface =>
|
|
70
|
+
obj[EF_TARGETABLE];
|
|
71
|
+
|
|
72
|
+
export const EFTargetable = <T extends Constructor<LitElement>>(
|
|
73
|
+
superClass: T,
|
|
74
|
+
) => {
|
|
75
|
+
class TargetableElement extends superClass {
|
|
76
|
+
#registry: TargetRegistry | null = null;
|
|
77
|
+
|
|
78
|
+
static get observedAttributes(): string[] {
|
|
79
|
+
// Get parent's observed attributes
|
|
80
|
+
const parentAttributes = (superClass as any).observedAttributes || [];
|
|
81
|
+
// Add 'id' if not already present
|
|
82
|
+
return [...new Set([...parentAttributes, "id"])];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private updateRegistry(oldValue: string, newValue: string) {
|
|
86
|
+
if (!this.#registry) return;
|
|
87
|
+
if (oldValue === newValue) return;
|
|
88
|
+
|
|
89
|
+
if (oldValue) {
|
|
90
|
+
this.#registry.unregister(oldValue);
|
|
91
|
+
}
|
|
92
|
+
if (newValue) {
|
|
93
|
+
this.#registry.register(newValue, this);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
connectedCallback() {
|
|
98
|
+
super.connectedCallback();
|
|
99
|
+
this.#registry = getRegistry(this.getRootNode());
|
|
100
|
+
const initialId = this.getAttribute("id");
|
|
101
|
+
if (initialId) {
|
|
102
|
+
this.updateRegistry("", initialId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
attributeChangedCallback(
|
|
107
|
+
name: string,
|
|
108
|
+
old: string | null,
|
|
109
|
+
value: string | null,
|
|
110
|
+
) {
|
|
111
|
+
super.attributeChangedCallback(name, old, value);
|
|
112
|
+
if (name === "id") {
|
|
113
|
+
this.updateRegistry(old ?? "", value ?? "");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
disconnectedCallback() {
|
|
118
|
+
if (this.#registry) {
|
|
119
|
+
this.updateRegistry(this.id, "");
|
|
120
|
+
this.#registry = null;
|
|
121
|
+
}
|
|
122
|
+
super.disconnectedCallback();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
Object.defineProperty(TargetableElement.prototype, EF_TARGETABLE, {
|
|
127
|
+
value: true,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return TargetableElement as T;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
class TargetUpdateController implements ReactiveController {
|
|
134
|
+
constructor(private host: LitElement) {}
|
|
135
|
+
|
|
136
|
+
hostConnected() {
|
|
137
|
+
this.host.requestUpdate();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
hostDisconnected() {
|
|
141
|
+
this.host.requestUpdate();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
hostUpdate() {
|
|
145
|
+
this.host.requestUpdate();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export class TargetController implements ReactiveController {
|
|
150
|
+
private host: LitElement & { targetElement: Element | null; target: string };
|
|
151
|
+
private targetController: ReactiveController | null = null;
|
|
152
|
+
private currentTargetString: string | null = null;
|
|
153
|
+
|
|
154
|
+
constructor(
|
|
155
|
+
host: LitElement & { targetElement: Element | null; target: string },
|
|
156
|
+
) {
|
|
157
|
+
this.host = host;
|
|
158
|
+
this.host.addController(this);
|
|
159
|
+
this.currentTargetString = this.host.target;
|
|
160
|
+
if (this.currentTargetString) {
|
|
161
|
+
this.registry.subscribe(this.currentTargetString, this.registryCallback);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private registryCallback = (target: LitElement | undefined) => {
|
|
166
|
+
this.host.targetElement = target ?? null;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
private updateTarget() {
|
|
170
|
+
const newTarget = this.registry.get(this.host.target);
|
|
171
|
+
if (this.host.targetElement !== newTarget) {
|
|
172
|
+
this.disconnectFromTarget();
|
|
173
|
+
this.host.targetElement = newTarget ?? (null as Element | null);
|
|
174
|
+
this.connectToTarget();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private connectToTarget() {
|
|
179
|
+
if (this.host.targetElement instanceof LitElement) {
|
|
180
|
+
this.targetController = new TargetUpdateController(this.host);
|
|
181
|
+
this.host.targetElement.addController(this.targetController);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private disconnectFromTarget() {
|
|
186
|
+
if (
|
|
187
|
+
this.host.targetElement instanceof LitElement &&
|
|
188
|
+
this.targetController
|
|
189
|
+
) {
|
|
190
|
+
this.host.targetElement.removeController(this.targetController);
|
|
191
|
+
this.targetController = null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private get registry() {
|
|
196
|
+
const root = this.host.getRootNode();
|
|
197
|
+
return getRegistry(root);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
hostDisconnected() {
|
|
201
|
+
this.disconnectFromTarget();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
hostConnected() {
|
|
205
|
+
this.updateTarget();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
hostUpdate() {
|
|
209
|
+
if (this.currentTargetString !== this.host.target) {
|
|
210
|
+
this.registry.unsubscribe(
|
|
211
|
+
this.currentTargetString,
|
|
212
|
+
this.registryCallback,
|
|
213
|
+
);
|
|
214
|
+
this.registry.subscribe(this.host.target, this.registryCallback);
|
|
215
|
+
this.updateTarget();
|
|
216
|
+
this.currentTargetString = this.host.target;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/gui/EFPreview.ts
CHANGED
|
@@ -8,6 +8,16 @@ import { focusedElementContext } from "./focusedElementContext.js";
|
|
|
8
8
|
|
|
9
9
|
@customElement("ef-preview")
|
|
10
10
|
export class EFPreview extends ContextMixin(TWMixin(LitElement)) {
|
|
11
|
+
static styles = [
|
|
12
|
+
css`
|
|
13
|
+
:host {
|
|
14
|
+
position: relative;
|
|
15
|
+
display: block;
|
|
16
|
+
cursor: crosshair;
|
|
17
|
+
}
|
|
18
|
+
`,
|
|
19
|
+
];
|
|
20
|
+
|
|
11
21
|
@provide({ context: focusedElementContext })
|
|
12
22
|
focusedElement?: HTMLElement;
|
|
13
23
|
|
|
@@ -37,15 +47,6 @@ export class EFPreview extends ContextMixin(TWMixin(LitElement)) {
|
|
|
37
47
|
});
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
static styles = [
|
|
41
|
-
css`
|
|
42
|
-
:host {
|
|
43
|
-
display: block;
|
|
44
|
-
cursor: crosshair;
|
|
45
|
-
}
|
|
46
|
-
`,
|
|
47
|
-
];
|
|
48
|
-
|
|
49
50
|
render() {
|
|
50
51
|
return html`<slot></slot>`;
|
|
51
52
|
}
|