Skip to content

Commit 6bc2a36

Browse files
committed
feat: add embed shape
1 parent d2707e6 commit 6bc2a36

File tree

16 files changed

+322
-6
lines changed

16 files changed

+322
-6
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { field, Type } from '@lastolivegames/becsy';
2+
import { AABB } from '../math';
3+
4+
export class Embed {
5+
static getGeometryBounds(embed: Partial<Embed>) {
6+
const { x = 0, y = 0, width = 0, height = 0 } = embed;
7+
return new AABB(
8+
Math.min(x, x + width),
9+
Math.min(y, y + height),
10+
Math.max(x, x + width),
11+
Math.max(y, y + height),
12+
);
13+
}
14+
15+
@field({ type: Type.object, default: null }) declare url: string;
16+
17+
/**
18+
* The x attribute defines an x-axis coordinate in the user coordinate system.
19+
* @see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/x
20+
*/
21+
@field({ type: Type.float32, default: 0 }) declare x: number;
22+
23+
/**
24+
* The y attribute defines an x-axis coordinate in the user coordinate system.
25+
* @see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/y
26+
*/
27+
@field({ type: Type.float32, default: 0 }) declare y: number;
28+
29+
/**
30+
* The width attribute defines the horizontal length of an element in the user coordinate system.
31+
* Negative values are allowed.
32+
* @see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/width
33+
*
34+
*/
35+
@field({ type: Type.float32, default: 0 }) declare width: number;
36+
37+
/**
38+
* The height attribute defines the vertical length of an element in the user coordinate system.
39+
* Negative values are allowed.
40+
* @see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/height
41+
*
42+
*/
43+
@field({ type: Type.float32, default: 0 }) declare height: number;
44+
45+
constructor(props?: Partial<Embed>) {
46+
if (props) {
47+
Object.assign(this, props);
48+
}
49+
}
50+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './HTML';
22
export * from './HTMLContainer';
3+
export * from './Embed';

packages/ecs/src/history/ElementsChange.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
Line,
4242
LockAspectRatio,
4343
HTML,
44+
Embed,
4445
} from '../components';
4546

4647
export type SceneElementsMap = Map<SerializedNode['id'], SerializedNode>;
@@ -859,6 +860,8 @@ export const mutateElement = <TElement extends Mutable<SerializedNode>>(
859860
});
860861
} else if (entity.has(HTML)) {
861862
entity.write(HTML).width = width;
863+
} else if (entity.has(Embed)) {
864+
entity.write(Embed).width = width;
862865
}
863866
}
864867
if (!isNil(height)) {
@@ -871,6 +874,8 @@ export const mutateElement = <TElement extends Mutable<SerializedNode>>(
871874
});
872875
} else if (entity.has(HTML)) {
873876
entity.write(HTML).height = height;
877+
} else if (entity.has(Embed)) {
878+
entity.write(Embed).height = height;
874879
}
875880
}
876881
if (!isNil(points)) {

packages/ecs/src/plugins/HTML.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { component, system } from '@lastolivegames/becsy';
22
import { Plugin } from './types';
3-
import { HTML, HTMLContainer } from '../components';
3+
import { HTML, HTMLContainer, Embed } from '../components';
44
import { Deleter, Last, RenderHTML } from '../systems';
55

66
export const HTMLPlugin: Plugin = () => {
77
component(HTML);
8+
component(Embed);
89
component(HTMLContainer);
910

1011
system(Last)(RenderHTML);

packages/ecs/src/systems/ComputeBounds.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ComputedTextMetrics,
99
DropShadow,
1010
Ellipse,
11+
Embed,
1112
GlobalTransform,
1213
HTML,
1314
Line,
@@ -63,6 +64,7 @@ export class ComputeBounds extends System {
6364
Stroke,
6465
DropShadow,
6566
HTML,
67+
Embed,
6668
).read,
6769
);
6870
}
@@ -138,6 +140,9 @@ export class ComputeBounds extends System {
138140
} else if (entity.has(HTML)) {
139141
geometryBounds = HTML.getGeometryBounds(entity.read(HTML));
140142
renderBounds = geometryBounds;
143+
} else if (entity.has(Embed)) {
144+
geometryBounds = Embed.getGeometryBounds(entity.read(Embed));
145+
renderBounds = geometryBounds;
141146
}
142147

143148
const hitArea = entity.has(Renderable)

packages/ecs/src/systems/RenderHTML.ts

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,27 @@ import {
77
HTMLContainer,
88
GlobalTransform,
99
Mat3,
10+
Embed,
1011
} from '../components';
1112
import { getSceneRoot } from '../systems';
12-
import { isBrowser, toDomPrecision } from '../utils';
13+
import { isBrowser, safeParseUrl, toDomPrecision } from '../utils';
1314

1415
export class RenderHTML extends System {
1516
private readonly htmls = this.query(
1617
(q) => q.added.and.changed.with(HTML, GlobalTransform).trackWrites,
1718
);
1819

20+
private readonly embeds = this.query(
21+
(q) => q.added.and.changed.with(Embed, GlobalTransform).trackWrites,
22+
);
23+
1924
constructor() {
2025
super();
2126
this.query(
2227
(q) =>
2328
q
2429
.using(Camera, Canvas, Children, GlobalTransform)
25-
.read.and.using(HTMLContainer).write,
30+
.read.and.using(HTMLContainer, Embed).write,
2631
);
2732
}
2833

@@ -51,7 +56,7 @@ export class RenderHTML extends System {
5156
$child.innerHTML = html;
5257
$child.style.position = 'absolute';
5358
$child.style.pointerEvents = 'none';
54-
$child.style.overflow = 'visible';
59+
$child.style.overflow = 'hidden';
5560
$child.style.transformOrigin = 'top left';
5661
$child.style.contain = 'size layout';
5762

@@ -66,13 +71,102 @@ export class RenderHTML extends System {
6671
const { width, height } = entity.read(HTML);
6772
this.updateCSSTransform($child, matrix, width, height);
6873
});
74+
75+
this.embeds.added.forEach((entity) => {
76+
const { url, width, height } = entity.read(Embed);
77+
const { element } = entity.read(HTMLContainer);
78+
79+
// Create HTML container if not exists.
80+
if (!element) {
81+
entity.write(HTMLContainer).element = document.createElement('div');
82+
}
83+
84+
const { matrix } = entity.read(GlobalTransform);
85+
86+
const camera = getSceneRoot(entity);
87+
const { canvas } = camera.read(Camera);
88+
const { api } = canvas.read(Canvas);
89+
const htmlLayer = api.getHtmlLayer();
90+
91+
const $child = entity.read(HTMLContainer).element;
92+
$child.style.position = 'absolute';
93+
$child.style.pointerEvents = 'none';
94+
$child.style.overflow = 'hidden';
95+
$child.style.transformOrigin = 'top left';
96+
$child.style.contain = 'size layout';
97+
98+
let embedUrl = url;
99+
const urlObj = safeParseUrl(url);
100+
if (urlObj) {
101+
const hostname = urlObj.hostname.replace(/^www./, '');
102+
if (hostname === 'youtu.be') {
103+
const videoId = urlObj.pathname.split('/').filter(Boolean)[0];
104+
const searchParams = new URLSearchParams(urlObj.search);
105+
const timeStart = searchParams.get('t');
106+
if (timeStart) {
107+
searchParams.set('start', timeStart);
108+
searchParams.delete('t');
109+
}
110+
const search = searchParams.toString()
111+
? '?' + searchParams.toString()
112+
: '';
113+
embedUrl = `https://www.youtube.com/embed/${videoId}${search}`;
114+
} else if (
115+
(hostname === 'youtube.com' || hostname === 'm.youtube.com') &&
116+
urlObj.pathname.match(/^\/watch/)
117+
) {
118+
const videoId = urlObj.searchParams.get('v');
119+
const searchParams = new URLSearchParams(urlObj.search);
120+
searchParams.delete('v');
121+
const timeStart = searchParams.get('t');
122+
if (timeStart) {
123+
searchParams.set('start', timeStart);
124+
searchParams.delete('t');
125+
}
126+
const search = searchParams.toString()
127+
? '?' + searchParams.toString()
128+
: '';
129+
embedUrl = `https://www.youtube.com/embed/${videoId}${search}`;
130+
}
131+
}
132+
133+
const $iframe = document.createElement('iframe');
134+
$iframe.src = embedUrl;
135+
$iframe.draggable = false;
136+
$iframe.frameBorder = '0';
137+
$iframe.referrerPolicy = 'no-referrer-when-downgrade';
138+
$iframe.tabIndex = -1;
139+
$iframe.style.border = '0';
140+
$iframe.style.pointerEvents = 'none';
141+
$iframe.sandbox = getSandboxPermissions({
142+
...embedShapePermissionDefaults,
143+
...{
144+
'allow-presentation': true,
145+
'allow-popups-to-escape-sandbox': true,
146+
},
147+
});
148+
149+
$child.appendChild($iframe);
150+
htmlLayer.appendChild($child);
151+
152+
this.updateCSSTransform($child, matrix, width, height, $iframe);
153+
});
154+
155+
this.embeds.changed.forEach((entity) => {
156+
const $child = entity.read(HTMLContainer).element;
157+
const $iframe = $child.querySelector('iframe');
158+
const { matrix } = entity.read(GlobalTransform);
159+
const { width, height } = entity.read(Embed);
160+
this.updateCSSTransform($child, matrix, width, height, $iframe);
161+
});
69162
}
70163

71164
private updateCSSTransform(
72165
$child: HTMLElement,
73166
matrix: Mat3,
74167
width: number,
75168
height: number,
169+
$iframe?: HTMLIFrameElement,
76170
) {
77171
$child.style.transform = `matrix(${toDomPrecision(
78172
matrix.m00,
@@ -83,5 +177,74 @@ export class RenderHTML extends System {
83177
)}, ${toDomPrecision(matrix.m21)})`;
84178
$child.style.width = `${toDomPrecision(width)}px`;
85179
$child.style.height = `${toDomPrecision(height)}px`;
180+
181+
if ($iframe) {
182+
$iframe.width = `${toDomPrecision(width)}px`;
183+
$iframe.height = `${toDomPrecision(height)}px`;
184+
}
86185
}
87186
}
187+
188+
const getSandboxPermissions = (permissions: TLEmbedShapePermissions) => {
189+
return Object.entries(permissions)
190+
.filter(([_perm, isEnabled]) => isEnabled)
191+
.map(([perm]) => perm)
192+
.join(' ');
193+
};
194+
195+
export type TLEmbedShapePermissions = {
196+
[K in keyof typeof embedShapePermissionDefaults]?: boolean;
197+
};
198+
/**
199+
* Permissions with note inline from
200+
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
201+
*
202+
* @see https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/defaultEmbedDefinitions.ts#L606
203+
*/
204+
export const embedShapePermissionDefaults = {
205+
// ========================================================================================
206+
// Disabled permissions
207+
// ========================================================================================
208+
// [MDN] Experimental: Allows for downloads to occur without a gesture from the user.
209+
// [REASON] Disabled because otherwise the <iframe/> can trick the user on behalf of us to perform an action.
210+
'allow-downloads-without-user-activation': false,
211+
// [MDN] Allows for downloads to occur with a gesture from the user.
212+
// [REASON] Disabled because otherwise the <iframe/> can trick the user on behalf of us to perform an action.
213+
'allow-downloads': false,
214+
// [MDN] Lets the resource open modal windows.
215+
// [REASON] The <iframe/> could 'window.prompt("Enter your tldraw password")'.
216+
'allow-modals': false,
217+
// [MDN] Lets the resource lock the screen orientation.
218+
// [REASON] Would interfere with the tldraw interface.
219+
'allow-orientation-lock': false,
220+
// [MDN] Lets the resource use the Pointer Lock API.
221+
// [REASON] Maybe we should allow this for games embeds (scratch/codepen/codesandbox).
222+
'allow-pointer-lock': false,
223+
// [MDN] Allows popups (such as window.open(), target="_blank", or showModalDialog()). If this keyword is not used, the popup will silently fail to open.
224+
// [REASON] We want to allow embeds to link back to their original sites (e.g. YouTube).
225+
'allow-popups': true,
226+
// [MDN] Lets the sandboxed document open new windows without those windows inheriting the sandboxing. For example, this can safely sandbox an advertisement without forcing the same restrictions upon the page the ad links to.
227+
// [REASON] We shouldn't allow popups as a embed could pretend to be us by opening a mocked version of tldraw. This is very unobvious when it is performed as an action within our app.
228+
'allow-popups-to-escape-sandbox': false,
229+
// [MDN] Lets the resource start a presentation session.
230+
// [REASON] Prevents embed from navigating away from tldraw and pretending to be us.
231+
'allow-presentation': false,
232+
// [MDN] Experimental: Lets the resource request access to the parent's storage capabilities with the Storage Access API.
233+
// [REASON] We don't want anyone else to access our storage.
234+
'allow-storage-access-by-user-activation': false,
235+
// [MDN] Lets the resource navigate the top-level browsing context (the one named _top).
236+
// [REASON] Prevents embed from navigating away from tldraw and pretending to be us.
237+
'allow-top-navigation': false,
238+
// [MDN] Lets the resource navigate the top-level browsing context, but only if initiated by a user gesture.
239+
// [REASON] Prevents embed from navigating away from tldraw and pretending to be us.
240+
'allow-top-navigation-by-user-activation': false,
241+
// ========================================================================================
242+
// Enabled permissions
243+
// ========================================================================================
244+
// [MDN] Lets the resource run scripts (but not create popup windows).
245+
'allow-scripts': true,
246+
// [MDN] If this token is not used, the resource is treated as being from a special origin that always fails the same-origin policy (potentially preventing access to data storage/cookies and some JavaScript APIs).
247+
'allow-same-origin': true,
248+
// [MDN] Allows the resource to submit forms. If this keyword is not used, form submission is blocked.
249+
'allow-forms': true,
250+
} as const;

packages/ecs/src/systems/Select.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
ToBeDeleted,
4747
Brush,
4848
HTML,
49+
Embed,
4950
} from '../components';
5051
import { Commands } from '../commands/Commands';
5152
import {
@@ -145,6 +146,7 @@ export class Select extends System {
145146
Opacity,
146147
Stroke,
147148
HTML,
149+
Embed,
148150
Rect,
149151
Circle,
150152
Ellipse,

0 commit comments

Comments
 (0)