@meowdown/embed 0.0.1 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ocavue
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -53,23 +53,6 @@ function isPromiseLike(value) {
53
53
  return typeof value === "object" && value !== null && typeof value.then === "function";
54
54
  }
55
55
 
56
- //#endregion
57
- //#region src/root.ts
58
- /**
59
- * The direct `<div data-root>` child that holds the rendered snapshot. Reused
60
- * across updates so other children the host appended survive; created on
61
- * first use.
62
- */
63
- function getRootContainer(host) {
64
- let container = host.querySelector(":scope > div[data-root]");
65
- if (!container) {
66
- container = host.ownerDocument.createElement("div");
67
- container.dataset.root = "";
68
- host.append(container);
69
- }
70
- return container;
71
- }
72
-
73
56
  //#endregion
74
57
  //#region src/safe-url.ts
75
58
  function getSafeUrl(value, protocols) {
@@ -97,4 +80,27 @@ function renderLink(content, destination) {
97
80
  }
98
81
 
99
82
  //#endregion
100
- export { useFetch as i, getSafeUrl as n, getRootContainer as r, renderLink as t };
83
+ //#region src/root.ts
84
+ /**
85
+ * The direct `<div data-root>` child that holds the rendered snapshot. Reused
86
+ * across updates so other children the host appended survive; created on
87
+ * first use.
88
+ */
89
+ function getRootContainer(host) {
90
+ let container = host.querySelector(":scope > div[data-root]");
91
+ if (!container) {
92
+ container = host.ownerDocument.createElement("div");
93
+ container.dataset.root = "";
94
+ host.append(container);
95
+ }
96
+ return container;
97
+ }
98
+
99
+ //#endregion
100
+ //#region src/is-plain-click.ts
101
+ function isPlainClick(event) {
102
+ return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
103
+ }
104
+
105
+ //#endregion
106
+ export { useFetch as a, getSafeUrl as i, getRootContainer as n, renderLink as r, isPlainClick as t };
package/dist/x/index.d.ts CHANGED
@@ -1,6 +1,29 @@
1
1
  import { n as Resolver, t as FetchProps } from "../fetch-Djnul03u.js";
2
2
  import { HostElement, State } from "@aria-ui/core";
3
- import { XPost } from "@post-embed/types";
3
+ import { XPost, XPostMedia } from "@post-embed/types";
4
+ //#region src/x/media-click.d.ts
5
+ export declare const X_POST_MEDIA_CLICK = "meowdown-embed-media-click";
6
+ interface XPostMediaClickDetail {
7
+ /**
8
+ * The activated item. Its URLs already passed the `mediaUrlProtocols` check,
9
+ * and video sources are sorted best first.
10
+ */
11
+ media: XPostMedia;
12
+ /**
13
+ * Every displayable item of the same post, in order, for paging.
14
+ */
15
+ items: XPostMedia[];
16
+ /**
17
+ * Position of `media` in `items`.
18
+ */
19
+ index: number;
20
+ /**
21
+ * The rendered thumbnail: the photo `<img>`, or the video poster.
22
+ */
23
+ element: HTMLElement;
24
+ }
25
+ type XPostMediaClickEvent = CustomEvent<XPostMediaClickDetail>;
26
+ //#endregion
4
27
  //#region src/x/x-post.d.ts
5
28
  interface XPostProps extends FetchProps<XPost> {
6
29
  mediaUrlProtocols: readonly string[] | null;
@@ -17,6 +40,9 @@ declare global {
17
40
  interface HTMLElementTagNameMap {
18
41
  'meowdown-embed-x': XPostElement;
19
42
  }
43
+ interface HTMLElementEventMap {
44
+ 'meowdown-embed-media-click': XPostMediaClickEvent;
45
+ }
20
46
  }
21
47
  //#endregion
22
- export type { Resolver, XPostElement, XPostProps };
48
+ export type { Resolver, XPostElement, XPostMediaClickDetail, XPostMediaClickEvent, XPostProps };
package/dist/x/index.js CHANGED
@@ -1,8 +1,24 @@
1
- import { i as useFetch, n as getSafeUrl, r as getRootContainer, t as renderLink } from "../render-link-BqiZxTy9.js";
1
+ import { a as useFetch, i as getSafeUrl, n as getRootContainer, r as renderLink, t as isPlainClick } from "../is-plain-click-QDpLdFP7.js";
2
2
  import { defineCustomElement, defineProps, registerCustomElement, useEffect } from "@aria-ui/core";
3
3
  import { parseXPost, parseXPostId } from "@post-embed/schema";
4
4
  import el from "crelt";
5
5
 
6
+ //#region src/x/media-click.ts
7
+ const X_POST_MEDIA_CLICK = "meowdown-embed-media-click";
8
+ /**
9
+ * Returns false when a listener called `preventDefault()`: the host shows the
10
+ * media itself, so the card must not run its own default.
11
+ */
12
+ function dispatchMediaClick(target, detail) {
13
+ return target.dispatchEvent(new CustomEvent(X_POST_MEDIA_CLICK, {
14
+ detail,
15
+ bubbles: true,
16
+ cancelable: true,
17
+ composed: true
18
+ }));
19
+ }
20
+
21
+ //#endregion
6
22
  //#region src/x/render-author.ts
7
23
  function renderAuthor(author, protocols, date) {
8
24
  if (!author.name && !author.handle) return;
@@ -22,103 +38,197 @@ function renderAuthor(author, protocols, date) {
22
38
  }
23
39
 
24
40
  //#endregion
25
- //#region src/x/render-media.ts
26
- function dimension(value) {
27
- return Number.isFinite(value) && value > 0 ? Math.round(value) : void 0;
41
+ //#region src/to-positive-number.ts
42
+ /**
43
+ * The value rounded to an integer, or nothing unless it is a finite number
44
+ * above zero.
45
+ */
46
+ function toPositiveNumber(value) {
47
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.round(value);
48
+ }
49
+
50
+ //#endregion
51
+ //#region src/x/sort-video-sources.ts
52
+ /**
53
+ * Orders the sources the way a `<video>` should try them: it plays the first
54
+ * `<source>` it supports, so MP4 files come before HLS playlists (which only
55
+ * Safari plays natively), and within a type the highest bitrate comes first.
56
+ * A source without a bitrate counts as 0. Returns a new array.
57
+ */
58
+ function sortVideoSources(sources) {
59
+ return [...sources].sort((a, b) => {
60
+ return Number(b.type === "video/mp4") - Number(a.type === "video/mp4") || (b.bitrate || 0) - (a.bitrate || 0);
61
+ });
28
62
  }
29
- function sizeAttrs(media) {
30
- const width = dimension(media.width);
31
- const height = dimension(media.height);
63
+
64
+ //#endregion
65
+ //#region src/x/render-media.ts
66
+ function getSizeAttrs(media) {
32
67
  return {
33
- width,
34
- height,
35
- style: width && height ? `--_ratio: ${width} / ${height}` : void 0
68
+ width: toPositiveNumber(media.width),
69
+ height: toPositiveNumber(media.height)
36
70
  };
37
71
  }
72
+ function getRatioStyle(media) {
73
+ const { width, height } = getSizeAttrs(media);
74
+ return width && height ? `--_ratio: ${width} / ${height}` : void 0;
75
+ }
38
76
  function getOrientation(media) {
39
- const width = dimension(media.width);
40
- const height = dimension(media.height);
77
+ const width = toPositiveNumber(media.width);
78
+ const height = toPositiveNumber(media.height);
41
79
  if (!width || !height) return;
42
80
  return height > width * 1.1 ? "portrait" : width > height * 1.1 ? "landscape" : "square";
43
81
  }
82
+ function getSafeMediaUrl(value, protocols) {
83
+ const url = getSafeUrl(value, protocols);
84
+ if (!url) console.warn(`[meowdown] Ignored unsafe media URL: ${value}`);
85
+ return url;
86
+ }
87
+ /**
88
+ * The item with only the URLs the card may load, or nothing when it has none
89
+ * left to show.
90
+ */
91
+ function getDisplayable(media, protocols) {
92
+ if (media.unavailable) return;
93
+ if (media.type === "photo") {
94
+ const url = getSafeMediaUrl(media.url, protocols);
95
+ return url ? {
96
+ ...media,
97
+ url
98
+ } : void 0;
99
+ }
100
+ const sources = sortVideoSources(media.sources.flatMap((source) => {
101
+ const url = getSafeMediaUrl(source.url, protocols);
102
+ return url ? [{
103
+ ...source,
104
+ url
105
+ }] : [];
106
+ }));
107
+ if (sources.length === 0) return;
108
+ const poster = media.poster && getSafeMediaUrl(media.poster, protocols);
109
+ return {
110
+ ...media,
111
+ sources,
112
+ poster
113
+ };
114
+ }
44
115
  function renderUnavailable(permalink) {
45
116
  return el("div", { "data-media-unavailable": "" }, "Media unavailable. ", renderLink("View on X", permalink));
46
117
  }
47
- function renderItem(media, protocols, permalink) {
48
- const error = el("div", {
118
+ function renderError(permalink) {
119
+ return el("div", {
49
120
  "data-media-error": "",
50
121
  hidden: true
51
122
  }, "Media could not be loaded. ", renderLink("View on X", permalink));
52
- let content;
53
- if (media.type === "photo") {
54
- const url = getSafeUrl(media.url, protocols);
55
- if (media.unavailable || !url) return renderUnavailable(permalink);
56
- const image = el("img", {
57
- src: url,
58
- alt: media.alt || "Post image",
59
- ...sizeAttrs(media),
60
- loading: "lazy",
61
- decoding: "async",
62
- referrerpolicy: "no-referrer"
63
- });
64
- const link = el("a", {
65
- href: url,
66
- target: "_blank",
67
- rel: "noopener noreferrer"
68
- }, image);
69
- image.addEventListener("error", () => {
70
- link.hidden = true;
71
- error.hidden = false;
72
- });
73
- content = link;
74
- } else {
75
- const sources = media.sources.flatMap((source) => {
76
- const url = getSafeUrl(source.url, protocols);
77
- return url ? [{
78
- ...source,
79
- url
80
- }] : [];
81
- }).sort((a, b) => {
82
- return Number(b.type === "video/mp4") - Number(a.type === "video/mp4") || (b.bitrate || 0) - (a.bitrate || 0);
83
- }).map((source) => el("source", {
123
+ }
124
+ function renderPhoto(media, error, onClick) {
125
+ const image = el("img", {
126
+ src: media.url,
127
+ alt: media.alt || "Post image",
128
+ ...getSizeAttrs(media),
129
+ loading: "lazy",
130
+ decoding: "async",
131
+ referrerpolicy: "no-referrer"
132
+ });
133
+ const link = el("a", {
134
+ href: media.url,
135
+ target: "_blank",
136
+ rel: "noopener noreferrer"
137
+ }, image);
138
+ image.addEventListener("error", () => {
139
+ link.hidden = true;
140
+ error.hidden = false;
141
+ });
142
+ link.addEventListener("click", (event) => {
143
+ if (isPlainClick(event) && !onClick(image)) event.preventDefault();
144
+ });
145
+ return link;
146
+ }
147
+ function renderPlayer(media, error, permalink) {
148
+ const gif = media.type === "gif";
149
+ const sources = media.sources.map((source) => {
150
+ return el("source", {
84
151
  src: source.url,
85
152
  type: source.type
86
- }));
87
- if (media.unavailable || sources.length === 0) return renderUnavailable(permalink);
88
- const gif = media.type === "gif";
89
- const video = el("video", {
90
- controls: true,
91
- playsInline: true,
92
- preload: "none",
93
- "aria-label": gif ? "Animated GIF" : "Post video",
94
- poster: media.poster && getSafeUrl(media.poster, protocols),
95
- ...sizeAttrs(media),
96
- loop: gif
97
- }, sources, renderLink("Watch on X", permalink));
98
- video.muted = gif;
99
- const showError = () => {
100
- video.hidden = true;
101
- error.hidden = false;
102
- };
103
- video.addEventListener("error", showError);
104
- let failedSources = 0;
105
- for (const source of sources) source.addEventListener("error", () => {
106
- failedSources++;
107
- if (failedSources === sources.length) showError();
108
- }, { once: true });
109
- content = video;
110
- }
111
- return el("div", {
112
- "data-media-item": "",
113
- "data-orientation": getOrientation(media)
114
- }, content, error);
153
+ });
154
+ });
155
+ const video = el("video", {
156
+ controls: true,
157
+ playsInline: true,
158
+ "aria-label": gif ? "Animated GIF" : "Post video",
159
+ poster: media.poster,
160
+ ...getSizeAttrs(media),
161
+ loop: gif
162
+ }, sources, renderLink("Watch on X", permalink));
163
+ video.muted = gif;
164
+ const showError = () => {
165
+ video.hidden = true;
166
+ error.hidden = false;
167
+ };
168
+ video.addEventListener("error", showError);
169
+ let failedSources = 0;
170
+ for (const source of sources) source.addEventListener("error", () => {
171
+ failedSources++;
172
+ if (failedSources === sources.length) showError();
173
+ }, { once: true });
174
+ return video;
175
+ }
176
+ /**
177
+ * A poster button. Its click plays the video in place, unless a
178
+ * `meowdown-embed-media-click` listener takes over.
179
+ */
180
+ function renderVideo(media, error, onClick, permalink) {
181
+ const gif = media.type === "gif";
182
+ const poster = media.poster ? el("img", {
183
+ src: media.poster,
184
+ alt: "",
185
+ ...getSizeAttrs(media),
186
+ loading: "lazy",
187
+ decoding: "async",
188
+ referrerpolicy: "no-referrer"
189
+ }) : void 0;
190
+ const button = el("button", {
191
+ "data-poster": "",
192
+ type: "button",
193
+ "aria-label": gif ? "Play GIF" : "Play video"
194
+ }, poster, el("span", {
195
+ "data-play": "",
196
+ "aria-hidden": "true"
197
+ }));
198
+ button.addEventListener("click", () => {
199
+ if (!onClick(poster ?? button)) return;
200
+ const video = renderPlayer(media, error, permalink);
201
+ button.replaceWith(video);
202
+ video.focus();
203
+ video.play().catch(() => {});
204
+ });
205
+ return button;
115
206
  }
116
207
  function renderMedia(media, protocols, permalink) {
117
208
  if (!media?.length) return;
209
+ const displayable = media.map((item) => getDisplayable(item, protocols));
210
+ const items = displayable.filter((item) => item != null);
118
211
  return el("div", {
119
212
  "data-media": "",
120
213
  "data-count": String(media.length)
121
- }, media.map((item) => renderItem(item, protocols, permalink)));
214
+ }, displayable.map((item) => {
215
+ if (!item) return renderUnavailable(permalink);
216
+ const error = renderError(permalink);
217
+ const onClick = (element) => {
218
+ return dispatchMediaClick(element, {
219
+ media: item,
220
+ items,
221
+ index: items.indexOf(item),
222
+ element
223
+ });
224
+ };
225
+ return el("div", {
226
+ "data-media-item": "",
227
+ "data-type": item.type,
228
+ "data-orientation": getOrientation(item),
229
+ style: getRatioStyle(item)
230
+ }, item.type === "photo" ? renderPhoto(item, error, onClick) : renderVideo(item, error, onClick, permalink), error);
231
+ }));
122
232
  }
123
233
 
124
234
  //#endregion
@@ -149,14 +259,14 @@ function renderDate(post) {
149
259
 
150
260
  //#endregion
151
261
  //#region src/x/render-post.ts
152
- function renderBody(post) {
262
+ function renderBody(post, collapseBlankLines = false) {
153
263
  const permalink = getPermalink(post);
154
264
  return el("p", {
155
265
  "data-body": "",
156
266
  dir: "auto",
157
267
  lang: post.lang || void 0
158
268
  }, el("span", { "data-text": "" }, post.body.map((segment) => {
159
- return segment.type === "link" ? renderLink(segment.text, segment.url) : segment.text.split("\n").map((line, index) => {
269
+ return segment.type === "link" ? renderLink(segment.text, segment.url) : segment.text.split(collapseBlankLines ? /\n+/ : "\n").map((line, index) => {
160
270
  return [index ? el("br", {}) : void 0, line];
161
271
  });
162
272
  })), post.truncated && permalink ? [" ", renderLink("Show more", permalink)] : void 0);
@@ -169,7 +279,7 @@ function renderQuoted(post, protocols) {
169
279
  return el("article", {
170
280
  "data-quoted": "",
171
281
  "aria-label": "Quoted post"
172
- }, renderAuthor(post.author, protocols, renderDate(post)), renderBody(post), renderMedia(post.media, protocols, getPermalink(post)), renderEdit(post));
282
+ }, renderAuthor(post.author, protocols, renderDate(post)), renderBody(post, true), renderMedia(post.media, protocols, getPermalink(post)), renderEdit(post));
173
283
  }
174
284
  function renderPost(post, protocols = null) {
175
285
  const reply = post.replyTo;
@@ -192,17 +302,18 @@ function useXPost(host, props) {
192
302
  const url = props.url.get();
193
303
  const value = result && !result.issues ? result.value : void 0;
194
304
  const valid = value && (props.data.get() != null || !url || parseXPostId(url) === value.id);
195
- container.replaceChildren(valid ? renderPost(value, protocols) : renderFallback(pending.get()));
305
+ container.replaceChildren(valid ? renderPost(value, protocols) : renderFallback(pending.get(), url));
196
306
  return () => {
197
307
  for (const video of container.querySelectorAll("video")) video.pause();
198
308
  };
199
309
  });
200
310
  }
201
- function renderFallback(pending) {
311
+ function renderFallback(pending, url) {
312
+ const permalink = url != null && parseXPostId(url) ? url : void 0;
202
313
  return el("article", pending ? {
203
314
  "data-fallback": "",
204
315
  "data-pending": ""
205
- } : { "data-fallback": "" }, el("header", { "data-author": "" }, el("bdi", {}, "X post")), el("p", { "data-body": "" }, pending ? "Loading this post…" : "This post is unavailable."), pending ? null : el("footer", { "data-footer": "" }, "No saved post could be displayed."));
316
+ } : { "data-fallback": "" }, el("header", { "data-author": "" }, el("bdi", {}, "X post")), el("p", { "data-body": "" }, pending ? "Loading this post…" : "This post is unavailable."), pending ? null : el("footer", { "data-footer": "" }, permalink ? renderLink("View on X", permalink) : "No saved post could be displayed."));
206
317
  }
207
318
  const XPost = defineCustomElement(useXPost, defineProps({
208
319
  data: {
@@ -230,4 +341,4 @@ function registerXPost(name = "meowdown-embed-x") {
230
341
  }
231
342
 
232
343
  //#endregion
233
- export { registerXPost, useXPost };
344
+ export { X_POST_MEDIA_CLICK, registerXPost, useXPost };
package/dist/x/theme.css CHANGED
@@ -57,6 +57,7 @@
57
57
  --_muted: var(--meowdown-embed-muted-color, color-mix(in srgb, currentColor 55%, transparent));
58
58
  --_link: var(--meowdown-embed-link-color, #1d9bf0);
59
59
  --_tint: color-mix(in srgb, currentColor 5%, transparent);
60
+ --_media-height: 7.5rem;
60
61
 
61
62
  display: block;
62
63
  /* As wide as the content, between 18rem and 28rem: a short text post stays
@@ -77,7 +78,7 @@
77
78
  [data-meowdown-embed='x'] [data-root]::before {
78
79
  content: '';
79
80
  display: block;
80
- width: calc(18rem - 2 * var(--meowdown-embed-padding, 0.75rem));
81
+ width: calc(18rem - 2 * var(--meowdown-embed-padding, 1rem) - 2px);
81
82
  max-width: 100%;
82
83
  height: 0;
83
84
  }
@@ -86,9 +87,10 @@
86
87
  display: block;
87
88
  position: relative;
88
89
  color: var(--meowdown-embed-color, inherit);
89
- background: var(--meowdown-embed-background, var(--_tint));
90
+ background: var(--meowdown-embed-background, transparent);
91
+ border: 1px solid var(--_hairline);
90
92
  border-radius: var(--meowdown-embed-radius, 0.75rem);
91
- padding: var(--meowdown-embed-padding, 0.75rem);
93
+ padding: var(--meowdown-embed-padding, 1rem);
92
94
  font-size: 1em;
93
95
  font-weight: 400;
94
96
  font-style: normal;
@@ -130,14 +132,14 @@
130
132
  & [data-author] {
131
133
  display: flex;
132
134
  align-items: center;
133
- gap: 0.5rem;
135
+ gap: 0.625rem;
134
136
  min-width: 0;
135
137
  }
136
138
  & [data-avatar] {
137
139
  display: block;
138
140
  flex: none;
139
- width: 2rem;
140
- height: 2rem;
141
+ width: 2.5rem;
142
+ height: 2.5rem;
141
143
  object-fit: cover;
142
144
  border-radius: 50%;
143
145
  background: var(--_tint);
@@ -152,7 +154,7 @@
152
154
  display: block;
153
155
  min-width: 0;
154
156
  flex: 1;
155
- line-height: 1.25;
157
+ line-height: 1.3;
156
158
  }
157
159
  & [data-author-name] {
158
160
  display: flex;
@@ -162,7 +164,7 @@
162
164
  font-weight: 600;
163
165
  }
164
166
  & [data-author-details] > bdi {
165
- font-size: 0.875em;
167
+ font-size: 0.9375em;
166
168
  color: var(--_muted);
167
169
  }
168
170
  & [data-author-details] > bdi > a {
@@ -174,7 +176,7 @@
174
176
  align-self: flex-start;
175
177
  padding-inline-start: 0.25rem;
176
178
  font-size: 0.8125em;
177
- line-height: 1.25rem;
179
+ line-height: 1.3rem;
178
180
  white-space: nowrap;
179
181
  color: var(--_muted);
180
182
  }
@@ -185,18 +187,18 @@
185
187
  /* Body */
186
188
  & [data-reply-to] {
187
189
  display: block;
188
- margin-block-start: 0.375rem;
189
- font-size: 0.8125em;
190
+ margin-block-start: 0.625rem;
191
+ font-size: 0.875em;
190
192
  color: var(--_muted);
191
193
  }
192
194
  & [data-body] {
193
195
  display: block;
194
- margin-block: 0.625rem 0;
196
+ margin-block: 0.75rem 0;
195
197
  font-size: 1em;
196
198
  line-height: 1.5;
197
199
  }
198
200
  & [data-reply-to] + [data-body] {
199
- margin-block-start: 0.25rem;
201
+ margin-block-start: 0.375rem;
200
202
  }
201
203
  & [data-text] {
202
204
  white-space: pre-wrap;
@@ -207,14 +209,13 @@
207
209
  display: grid;
208
210
  grid-template-columns: repeat(2, minmax(0, 1fr));
209
211
  gap: 2px;
212
+ height: var(--_media-height);
213
+ max-width: calc(2 * var(--_media-height));
210
214
  margin-block-start: 0.75rem;
211
215
  border-radius: 0.5rem;
212
216
  overflow: hidden;
213
217
  background: var(--_hairline);
214
218
  }
215
- & [data-media][data-count='1'] {
216
- grid-template-columns: minmax(0, 1fr);
217
- }
218
219
  & [data-media-item] {
219
220
  display: block;
220
221
  position: relative;
@@ -234,34 +235,79 @@
234
235
  display: block;
235
236
  height: 100%;
236
237
  }
237
- /* A single photo or video fills the width at its own ratio, up to 20rem
238
- * tall; taller media is cropped and the photo links to the original. */
238
+ & [data-media-item] > [data-poster] {
239
+ position: relative;
240
+ width: 100%;
241
+ height: 100%;
242
+ padding: 0;
243
+ border: 0;
244
+ background: var(--_tint);
245
+ font: inherit;
246
+ color: inherit;
247
+ cursor: pointer;
248
+ }
249
+ & [data-play] {
250
+ position: absolute;
251
+ inset: 50% auto auto 50%;
252
+ width: 2.25rem;
253
+ height: 2.25rem;
254
+ translate: -50% -50%;
255
+ border-radius: 50%;
256
+ background: rgb(0 0 0 / 60%);
257
+ }
258
+ & [data-play]::after {
259
+ content: '';
260
+ position: absolute;
261
+ inset: 50% auto auto 54%;
262
+ translate: -50% -50%;
263
+ border-block: 0.375rem solid transparent;
264
+ border-inline-start: 0.6rem solid #fff;
265
+ }
239
266
  & [data-media-item] img,
240
267
  & [data-media-item] video {
241
268
  display: block;
242
269
  width: 100%;
243
- height: auto;
244
- aspect-ratio: var(--_ratio, auto);
245
- max-height: 20rem;
270
+ height: 100%;
246
271
  object-fit: cover;
247
272
  background: var(--_tint);
248
273
  }
249
274
  & [data-media-item][data-orientation='portrait'] :is(img, video) {
250
275
  object-position: center 30%;
251
276
  }
252
- & [data-media]:not([data-count='1']) [data-media-item] :is(img, video) {
253
- height: 100%;
254
- aspect-ratio: 3 / 2;
255
- }
256
277
  & [data-media][data-count='3'] [data-media-item]:first-child {
257
278
  grid-row: span 2;
258
279
  }
259
- & [data-media][data-count='3'] [data-media-item]:first-child :is(img, video) {
280
+ /* A single photo or video is a thumbnail of fixed height and its own ratio,
281
+ * never cropped; media wider than the card shrinks to fit. A video playing
282
+ * in place grows, and turns a grid into a stack so its player has room. */
283
+ & [data-media]:is([data-count='1'], :has(video)) {
284
+ display: block;
285
+ height: auto;
286
+ max-width: none;
287
+ border-radius: 0;
288
+ overflow: visible;
289
+ background: none;
290
+ }
291
+ & [data-media]:is([data-count='1'], :has(video)) > [data-media-item] {
292
+ width: calc(var(--_media-height) * (var(--_ratio, 16 / 9)));
293
+ max-width: 100%;
294
+ aspect-ratio: var(--_ratio, 16 / 9);
295
+ border-radius: 0.5rem;
296
+ }
297
+ & [data-media]:has(video) > [data-media-item] + [data-media-item] {
298
+ margin-block-start: 0.375rem;
299
+ }
300
+ & [data-media-item]:has(> video) {
301
+ --_media-height: 20rem;
302
+ }
303
+ & [data-media-item]:has(> [data-media-error]:not([hidden])) {
304
+ width: auto;
260
305
  aspect-ratio: auto;
261
306
  }
262
307
  & [data-media-unavailable],
263
308
  & [data-media-error] {
264
309
  display: block;
310
+ border-radius: 0.5rem;
265
311
  padding: 0.75rem 1rem;
266
312
  color: var(--_muted);
267
313
  background: var(--_tint);
@@ -269,11 +315,13 @@
269
315
 
270
316
  /* Quoted post */
271
317
  & [data-quoted] {
318
+ --_media-height: 5rem;
319
+
272
320
  display: block;
273
321
  margin-block-start: 0.75rem;
274
322
  padding: 0.75rem;
323
+ border: 1px solid var(--_hairline);
275
324
  border-radius: 0.5rem;
276
- background: var(--_tint);
277
325
  }
278
326
  & [data-quoted] [data-avatar] {
279
327
  width: 1.375rem;
@@ -317,12 +365,15 @@
317
365
  margin-block-start: 0.5rem;
318
366
  border-radius: 0.375rem;
319
367
  }
320
- & [data-quoted] [data-media-item] :is(img, video) {
321
- max-height: 10rem;
368
+ & [data-quoted] [data-play] {
369
+ scale: 0.75;
322
370
  }
323
371
  /* Quoted photos shrink to one thumbnail beside the text, with a count
324
372
  * badge; a quoted video or GIF stays below the text so its player works. */
325
- & [data-quoted]:has(> [data-media]):not(:has(> [data-media] video)) {
373
+ &
374
+ [data-quoted]:has(> [data-media]):not(
375
+ :has(> [data-media] [data-poster], > [data-media] video)
376
+ ) {
326
377
  display: grid;
327
378
  grid-template-columns: minmax(0, 1fr) auto;
328
379
  grid-template-areas:
@@ -355,11 +406,6 @@
355
406
  & > [data-media] > [data-media-item]:nth-child(n + 2) {
356
407
  display: none;
357
408
  }
358
- & > [data-media] > [data-media-item] img {
359
- height: 100%;
360
- max-height: none;
361
- aspect-ratio: auto;
362
- }
363
409
  & > [data-media]:not([data-count='1'])::after {
364
410
  content: attr(data-count);
365
411
  position: absolute;
@@ -1,6 +1,34 @@
1
1
  import { n as Resolver, t as FetchProps } from "../fetch-Djnul03u.js";
2
2
  import { HostElement, State } from "@aria-ui/core";
3
3
  import { YouTubeVideo } from "@post-embed/types";
4
+ //#region src/youtube/video-click.d.ts
5
+ export declare const YOUTUBE_VIDEO_CLICK = "meowdown-embed-youtube-click";
6
+ interface YouTubeVideoClickDetail {
7
+ /**
8
+ * The snapshot the card rendered.
9
+ */
10
+ video: YouTubeVideo;
11
+ videoId: string;
12
+ /**
13
+ * Where playback starts, from the `t` or `start` parameter of the URL.
14
+ */
15
+ startSeconds: number;
16
+ /**
17
+ * A Short, which plays in portrait.
18
+ */
19
+ short: boolean;
20
+ /**
21
+ * The player page for an `<iframe>`. It starts playing as soon as it loads.
22
+ */
23
+ embedUrl: string;
24
+ /**
25
+ * The rendered thumbnail: the poster `<img>`, or the poster itself when the
26
+ * snapshot has no thumbnail.
27
+ */
28
+ element: HTMLElement;
29
+ }
30
+ type YouTubeVideoClickEvent = CustomEvent<YouTubeVideoClickDetail>;
31
+ //#endregion
4
32
  //#region src/youtube/youtube-video.d.ts
5
33
  interface YouTubeVideoProps extends FetchProps<YouTubeVideo> {
6
34
  /**
@@ -21,6 +49,9 @@ declare global {
21
49
  interface HTMLElementTagNameMap {
22
50
  'meowdown-embed-youtube': YouTubeVideoElement;
23
51
  }
52
+ interface HTMLElementEventMap {
53
+ 'meowdown-embed-youtube-click': YouTubeVideoClickEvent;
54
+ }
24
55
  }
25
56
  //#endregion
26
- export type { Resolver, YouTubeVideoElement, YouTubeVideoProps };
57
+ export type { Resolver, YouTubeVideoClickDetail, YouTubeVideoClickEvent, YouTubeVideoElement, YouTubeVideoProps };
@@ -1,4 +1,4 @@
1
- import { i as useFetch, n as getSafeUrl, r as getRootContainer, t as renderLink } from "../render-link-BqiZxTy9.js";
1
+ import { a as useFetch, i as getSafeUrl, n as getRootContainer, r as renderLink, t as isPlainClick } from "../is-plain-click-QDpLdFP7.js";
2
2
  import { defineCustomElement, defineProps, registerCustomElement, useEffect } from "@aria-ui/core";
3
3
  import { parseYouTubeVideo } from "@post-embed/schema";
4
4
  import el from "crelt";
@@ -56,6 +56,22 @@ function getEmbedUrl(ref) {
56
56
  return `https://www.youtube-nocookie.com/embed/${ref.videoId}?${params}`;
57
57
  }
58
58
 
59
+ //#endregion
60
+ //#region src/youtube/video-click.ts
61
+ const YOUTUBE_VIDEO_CLICK = "meowdown-embed-youtube-click";
62
+ /**
63
+ * Returns false when a listener called `preventDefault()`: the host plays the
64
+ * video itself, so the card must not run its own default.
65
+ */
66
+ function dispatchVideoClick(target, detail) {
67
+ return target.dispatchEvent(new CustomEvent(YOUTUBE_VIDEO_CLICK, {
68
+ detail,
69
+ bubbles: true,
70
+ cancelable: true,
71
+ composed: true
72
+ }));
73
+ }
74
+
59
75
  //#endregion
60
76
  //#region src/youtube/render-video.ts
61
77
  function renderFrame(ref, title) {
@@ -75,7 +91,7 @@ function renderVideo(video, ref, playback) {
75
91
  target: "_blank",
76
92
  rel: "noopener noreferrer"
77
93
  };
78
- const posterContent = [poster ? el("img", {
94
+ const image = poster ? el("img", {
79
95
  src: poster,
80
96
  alt: "",
81
97
  width: video.thumbnail_width || void 0,
@@ -83,10 +99,19 @@ function renderVideo(video, ref, playback) {
83
99
  loading: "lazy",
84
100
  decoding: "async",
85
101
  referrerpolicy: "no-referrer"
86
- }) : void 0, el("span", {
102
+ }) : void 0;
103
+ const posterContent = [image, el("span", {
87
104
  "data-play": "",
88
105
  "aria-hidden": "true"
89
106
  })];
107
+ const onClick = (element) => {
108
+ return dispatchVideoClick(element, {
109
+ video,
110
+ ...ref,
111
+ embedUrl: getEmbedUrl(ref),
112
+ element
113
+ });
114
+ };
90
115
  let posterElement;
91
116
  if (playback === "inline") {
92
117
  const button = el("button", {
@@ -95,16 +120,23 @@ function renderVideo(video, ref, playback) {
95
120
  "aria-label": `Play: ${title}`
96
121
  }, posterContent);
97
122
  button.addEventListener("click", () => {
123
+ if (!onClick(image ?? button)) return;
98
124
  const frame = renderFrame(ref, title);
99
125
  button.replaceWith(frame);
100
126
  frame.focus();
101
127
  });
102
128
  posterElement = button;
103
- } else posterElement = el("a", {
104
- "data-poster": "",
105
- href: watchUrl,
106
- ...external
107
- }, posterContent);
129
+ } else {
130
+ const link = el("a", {
131
+ "data-poster": "",
132
+ href: watchUrl,
133
+ ...external
134
+ }, posterContent);
135
+ link.addEventListener("click", (event) => {
136
+ if (isPlainClick(event) && !onClick(image ?? link)) event.preventDefault();
137
+ });
138
+ posterElement = link;
139
+ }
108
140
  return el("article", { "data-orientation": ref.short ? "portrait" : void 0 }, posterElement, el("div", { "data-details": "" }, el("a", {
109
141
  "data-title": "",
110
142
  href: watchUrl,
@@ -161,4 +193,4 @@ function registerYouTubeVideo(name = "meowdown-embed-youtube") {
161
193
  }
162
194
 
163
195
  //#endregion
164
- export { registerYouTubeVideo, useYouTubeVideo };
196
+ export { YOUTUBE_VIDEO_CLICK, registerYouTubeVideo, useYouTubeVideo };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meowdown/embed",
3
3
  "type": "module",
4
- "version": "0.0.1",
4
+ "version": "0.2.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -12,27 +12,24 @@
12
12
  "**/*.css"
13
13
  ],
14
14
  "exports": {
15
- "./x": "./src/x/index.ts",
16
- "./x/theme.css": "./src/x/theme.css",
17
- "./youtube": "./src/youtube/index.ts",
18
- "./youtube/theme.css": "./src/youtube/theme.css"
15
+ "./x": "./dist/x/index.js",
16
+ "./x/theme.css": "./dist/x/theme.css",
17
+ "./youtube": "./dist/youtube/index.js",
18
+ "./youtube/theme.css": "./dist/youtube/theme.css"
19
19
  },
20
20
  "files": [
21
21
  "./dist/**/*.js",
22
22
  "./dist/**/*.d.ts",
23
23
  "./dist/**/*.css"
24
24
  ],
25
- "scripts": {
26
- "build": "tsdown"
27
- },
28
25
  "dependencies": {
29
26
  "@aria-ui/core": "^0.2.1",
30
- "@post-embed/schema": "^0.5.0",
31
- "@post-embed/types": "^0.4.0",
27
+ "@post-embed/schema": "^0.5.2",
28
+ "@post-embed/types": "^0.5.0",
32
29
  "crelt": "^1.0.7"
33
30
  },
34
31
  "devDependencies": {
35
- "@meowdown/vitest": "workspace:*",
32
+ "@meowdown/vitest": "0.0.0",
36
33
  "@ocavue/tsconfig": "^0.7.1",
37
34
  "@ocavue/utils": "^1.8.0",
38
35
  "@vitest/browser-playwright": "^5.0.1",
@@ -40,12 +37,9 @@
40
37
  "vitest": "^5.0.1"
41
38
  },
42
39
  "publishConfig": {
43
- "exports": {
44
- "./x": "./dist/x/index.js",
45
- "./x/theme.css": "./dist/x/theme.css",
46
- "./youtube": "./dist/youtube/index.js",
47
- "./youtube/theme.css": "./dist/youtube/theme.css"
48
- },
49
40
  "access": "public"
41
+ },
42
+ "scripts": {
43
+ "build": "tsdown"
50
44
  }
51
- }
45
+ }