@jant/core 0.5.3 → 0.5.4

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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { _ as url_exports } from "./url-umUptr5z.js";
2
- import { A as NAV_ITEM_TYPES, C as toPostView, D as MAX_MEDIA_ATTACHMENTS, E as FORMATS, M as STATUSES, N as TEXT_ATTACHMENT_CONTENT_FORMATS, O as MAX_PINNED_POSTS, S as toNavItemViews, T as toSearchResultView, _ as createMediaContext, b as toMediaView, f as defaultFeedRenderer, j as SORT_ORDERS, k as MEDIA_KINDS, t as createApp, v as toArchiveGroups, w as toPostViews, x as toNavItemView, y as toArchiveGroupsWithMedia } from "./app-C481ssbr.js";
2
+ import { A as NAV_ITEM_TYPES, C as toPostView, D as MAX_MEDIA_ATTACHMENTS, E as FORMATS, M as STATUSES, N as TEXT_ATTACHMENT_CONTENT_FORMATS, O as MAX_PINNED_POSTS, S as toNavItemViews, T as toSearchResultView, _ as createMediaContext, b as toMediaView, f as defaultFeedRenderer, j as SORT_ORDERS, k as MEDIA_KINDS, t as createApp, v as toArchiveGroups, w as toPostViews, x as toNavItemView, y as toArchiveGroupsWithMedia } from "./app-BtNdUAqz.js";
3
3
  import { T as time_exports, a as markdown_exports } from "./export-CR9Megtb.js";
4
4
  import "./env-CgaH9Mut.js";
5
5
  import "./github-sync-DYZq9rQp.js";
package/dist/node.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import "./url-umUptr5z.js";
2
- import { F as BUILTIN_COLOR_THEMES, I as getPublicAssetBasePath, L as isAssetPath, P as buildThemeStyle, a as resolveDatabaseDialect, c as resolveConfig, d as getFontThemeCssVariables, g as schema_exports, h as createNodeDatabase, i as createSiteService, l as BUILTIN_FONT_THEMES, m as sqliteSchemaBundle, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as pgSchemaBundle, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as getCjkSerifCssVariables } from "./app-C481ssbr.js";
2
+ import { F as BUILTIN_COLOR_THEMES, I as getPublicAssetBasePath, L as isAssetPath, P as buildThemeStyle, a as resolveDatabaseDialect, c as resolveConfig, d as getFontThemeCssVariables, g as schema_exports, h as createNodeDatabase, i as createSiteService, l as BUILTIN_FONT_THEMES, m as sqliteSchemaBundle, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as pgSchemaBundle, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as getCjkSerifCssVariables } from "./app-BtNdUAqz.js";
3
3
  import { t as createExportService } from "./export-CR9Megtb.js";
4
4
  import { b as getSiteResolutionMode, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as shouldTrustProxy, y as getPort } from "./env-CgaH9Mut.js";
5
5
  import "./github-sync-DYZq9rQp.js";
@@ -474,7 +474,7 @@ async function createNodeRequestHandler(options) {
474
474
  async function start(env = process.env, app) {
475
475
  const handler = await createNodeRequestHandler({
476
476
  env,
477
- app: async () => app ?? (await import("./app-BgMwEN-M.js")).createApp()
477
+ app: async () => app ?? (await import("./app-DLINgGBd.js")).createApp()
478
478
  });
479
479
  const hostname = resolveHost(env);
480
480
  const port = resolvePort(env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -46,6 +46,7 @@ import {
46
46
  uploadAndInsertInlineImage,
47
47
  adoptPendingInlineImageUploads,
48
48
  } from "../tiptap/inline-image-upload.js";
49
+ import { getClipboardFiles } from "../tiptap/paste-media.js";
49
50
  import { isSafeAbsoluteUrl } from "../../lib/url.js";
50
51
  import { randomUUID } from "../random-uuid.js";
51
52
  import {
@@ -276,6 +277,13 @@ export class JantComposeEditor extends LitElement {
276
277
  "jant:slash-command-discovered",
277
278
  this._onSlashCommandDiscovered,
278
279
  );
280
+ this.addEventListener("dragenter", this._onDragEnter);
281
+ // Capture phase: a file dragover is stopped here before ProseMirror sees
282
+ // it, so its drop cursor never appears for file drags (the drop position
283
+ // is decided by _shouldPasteInlineImage, not the cursor).
284
+ this.addEventListener("dragover", this._onDragOver, true);
285
+ this.addEventListener("dragleave", this._onDragLeave);
286
+ this.addEventListener("drop", this._onDrop);
279
287
  }
280
288
 
281
289
  disconnectedCallback() {
@@ -291,6 +299,10 @@ export class JantComposeEditor extends LitElement {
291
299
  this._onSlashCommandDiscovered,
292
300
  );
293
301
  document.removeEventListener("click", this._onDocClickBound);
302
+ this.removeEventListener("dragenter", this._onDragEnter);
303
+ this.removeEventListener("dragover", this._onDragOver, true);
304
+ this.removeEventListener("dragleave", this._onDragLeave);
305
+ this.removeEventListener("drop", this._onDrop);
294
306
  hideSlashCommandHint(this);
295
307
  this._emojiContainer?.remove();
296
308
  this._emojiPickerEl = null;
@@ -298,6 +310,66 @@ export class JantComposeEditor extends LitElement {
298
310
  this._filePickerCleanup = null;
299
311
  }
300
312
 
313
+ // Tracks dragenter/dragleave nesting so the highlight only clears when the
314
+ // pointer actually leaves the editor, not when it crosses a child element.
315
+ #dragDepth = 0;
316
+
317
+ #dragHasFiles(event: DragEvent): boolean {
318
+ const types = event.dataTransfer?.types;
319
+ return types ? Array.from(types).includes("Files") : false;
320
+ }
321
+
322
+ private _onDragEnter = (event: DragEvent) => {
323
+ if (!this.#dragHasFiles(event)) return;
324
+ this.#dragDepth += 1;
325
+ this.classList.add("compose-editor-dragover");
326
+ };
327
+
328
+ private _onDragOver = (event: DragEvent) => {
329
+ if (!this.#dragHasFiles(event)) return;
330
+ // Keep the file dragover away from ProseMirror so its drop cursor stays
331
+ // hidden; internal content drags still bubble through untouched.
332
+ event.stopPropagation();
333
+ event.preventDefault();
334
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
335
+ };
336
+
337
+ private _onDragLeave = (event: DragEvent) => {
338
+ if (!this.#dragHasFiles(event)) return;
339
+ this.#dragDepth = Math.max(0, this.#dragDepth - 1);
340
+ if (this.#dragDepth === 0) {
341
+ this.classList.remove("compose-editor-dragover");
342
+ }
343
+ };
344
+
345
+ private _onDrop = (event: DragEvent) => {
346
+ this.#dragDepth = 0;
347
+ this.classList.remove("compose-editor-dragover");
348
+ // Drops onto the TipTap body are claimed by the pasteMedia plugin's
349
+ // handleDrop, which calls preventDefault — skip those to avoid handling
350
+ // the same files twice.
351
+ if (event.defaultPrevented) return;
352
+ const files = getClipboardFiles(event.dataTransfer);
353
+ if (files.length === 0) return;
354
+ event.preventDefault();
355
+
356
+ const inlineFiles: File[] = [];
357
+ const attachmentFiles: File[] = [];
358
+ for (const file of files) {
359
+ if (this._editor && this._shouldPasteInlineImage(file)) {
360
+ inlineFiles.push(file);
361
+ } else {
362
+ attachmentFiles.push(file);
363
+ }
364
+ }
365
+ for (const file of inlineFiles) {
366
+ this._uploadAndInsertImage(file);
367
+ }
368
+ if (attachmentFiles.length > 0) {
369
+ this.addFiles(attachmentFiles);
370
+ }
371
+ };
372
+
301
373
  private _onSlashCommandDiscovered = () => {
302
374
  markSlashCommandDiscovered();
303
375
  };
@@ -76,6 +76,47 @@ export const PasteMedia = Extension.create<PasteMediaOptions>({
76
76
  addProseMirrorPlugins() {
77
77
  const extension = this;
78
78
 
79
+ /**
80
+ * Routes dropped/pasted files into inline images or attachments using the
81
+ * same decision as the host (`shouldInsertInline`). Returns false when
82
+ * there is nothing this extension can handle, so the caller leaves the
83
+ * event to the editor's default behavior.
84
+ */
85
+ const routeFiles = (files: File[]): boolean => {
86
+ const inlineFiles = files.filter(
87
+ (file) => extension.options.shouldInsertInline?.(file) === true,
88
+ );
89
+ const attachmentFiles = files.filter(
90
+ (file) => !inlineFiles.includes(file),
91
+ );
92
+
93
+ if (
94
+ inlineFiles.length === 0 &&
95
+ (attachmentFiles.length === 0 ||
96
+ extension.options.onPasteFiles === undefined)
97
+ ) {
98
+ return false;
99
+ }
100
+
101
+ for (const file of inlineFiles) {
102
+ const uploadInlineImage = extension.options.uploadInlineImage;
103
+ if (uploadInlineImage) {
104
+ void uploadInlineImage(file);
105
+ continue;
106
+ }
107
+ void uploadAndInsertInlineImage(extension.editor, file);
108
+ }
109
+
110
+ if (
111
+ attachmentFiles.length > 0 &&
112
+ extension.options.onPasteFiles !== undefined
113
+ ) {
114
+ extension.options.onPasteFiles(attachmentFiles);
115
+ }
116
+
117
+ return true;
118
+ };
119
+
79
120
  return [
80
121
  new Plugin({
81
122
  key: pasteMediaPluginKey,
@@ -83,40 +124,15 @@ export const PasteMedia = Extension.create<PasteMediaOptions>({
83
124
  handlePaste(_view, event) {
84
125
  const files = getClipboardFiles(event.clipboardData);
85
126
  if (files.length === 0) return false;
86
-
87
- const inlineFiles = files.filter(
88
- (file) => extension.options.shouldInsertInline?.(file) === true,
89
- );
90
- const attachmentFiles = files.filter(
91
- (file) => !inlineFiles.includes(file),
92
- );
93
-
94
- if (
95
- inlineFiles.length === 0 &&
96
- (attachmentFiles.length === 0 ||
97
- extension.options.onPasteFiles === undefined)
98
- ) {
99
- return false;
100
- }
101
-
127
+ if (!routeFiles(files)) return false;
128
+ event.preventDefault();
129
+ return true;
130
+ },
131
+ handleDrop(_view, event) {
132
+ const files = getClipboardFiles(event.dataTransfer);
133
+ if (files.length === 0) return false;
134
+ if (!routeFiles(files)) return false;
102
135
  event.preventDefault();
103
-
104
- for (const file of inlineFiles) {
105
- const uploadInlineImage = extension.options.uploadInlineImage;
106
- if (uploadInlineImage) {
107
- void uploadInlineImage(file);
108
- continue;
109
- }
110
- void uploadAndInsertInlineImage(extension.editor, file);
111
- }
112
-
113
- if (
114
- attachmentFiles.length > 0 &&
115
- extension.options.onPasteFiles !== undefined
116
- ) {
117
- extension.options.onPasteFiles(attachmentFiles);
118
- }
119
-
120
136
  return true;
121
137
  },
122
138
  },
@@ -7,6 +7,8 @@
7
7
  * - Strips spurious rotation metadata from the output (mediabunny may
8
8
  * bake rotation into pixels AND write a display matrix, causing the
9
9
  * browser to double-rotate)
10
+ * - Clears the alternate_group track flag (mediabunny sets it non-zero,
11
+ * which stops Safari's native video controls from auto-hiding)
10
12
  * - Extracts poster frame + blurhash during processing
11
13
  *
12
14
  * Requires WebCodecs API support — check `isSupported()` before use.
@@ -25,6 +27,7 @@ import {
25
27
  } from "mediabunny";
26
28
  import { encode } from "blurhash";
27
29
  import { normalizeDurationSeconds } from "../lib/video-playback.js";
30
+ import { zeroTrackAlternateGroups } from "../lib/mp4-track-flags.js";
28
31
 
29
32
  /** Maximum pixels for the long edge of the output video. */
30
33
  const MAX_LONG_EDGE = 1920;
@@ -222,6 +225,12 @@ async function processToFile(
222
225
  const buffer = target.buffer;
223
226
  if (!buffer) throw new Error("Video processing produced no output");
224
227
 
228
+ // Mediabunny tags each track with a non-zero alternate_group, which makes
229
+ // Safari treat tracks as mutually exclusive alternates and never auto-hide
230
+ // the native <video> control bar during playback. Zero it so the controls
231
+ // behave like any other MP4.
232
+ zeroTrackAlternateGroups(buffer);
233
+
225
234
  // Detect whether this browser double-rotates. Chrome's WebCodecs
226
235
  // bakes rotation into the pixel data AND mediabunny writes a display
227
236
  // matrix → the browser applies the matrix again (double-rotation).
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { zeroTrackAlternateGroups } from "../mp4-track-flags.js";
3
+
4
+ const str4 = (s: string): number[] => [...s].map((c) => c.charCodeAt(0));
5
+ const u32 = (n: number): number[] => [
6
+ (n >>> 24) & 0xff,
7
+ (n >>> 16) & 0xff,
8
+ (n >>> 8) & 0xff,
9
+ n & 0xff,
10
+ ];
11
+ const u16 = (n: number): number[] => [(n >>> 8) & 0xff, n & 0xff];
12
+ const box = (type: string, ...payload: number[][]): number[] => {
13
+ const body = payload.flat();
14
+ return [...u32(body.length + 8), ...str4(type), ...body];
15
+ };
16
+
17
+ /** A `tkhd` box with a given version and alternate_group value. */
18
+ function tkhd(version: 0 | 1, alternateGroup: number): number[] {
19
+ // creation, modification, trackID, reserved: 4 fields, 4 or 8 bytes each.
20
+ const idAndTimes =
21
+ version === 1 ? new Array(24).fill(0) : new Array(16).fill(0);
22
+ const duration = version === 1 ? new Array(8).fill(0) : new Array(4).fill(0);
23
+ return box(
24
+ "tkhd",
25
+ [version, 0, 0, 0], // version + flags
26
+ idAndTimes,
27
+ duration,
28
+ new Array(8).fill(0), // reserved[2]
29
+ u16(0), // layer
30
+ u16(alternateGroup),
31
+ new Array(48).fill(0), // volume, reserved, matrix, width, height
32
+ );
33
+ }
34
+
35
+ describe("zeroTrackAlternateGroups", () => {
36
+ it("zeroes a non-zero alternate_group in every tkhd (v0)", () => {
37
+ const bytes = new Uint8Array(
38
+ box(
39
+ "moov",
40
+ box("trak", tkhd(0, 1), box("mdia", [])),
41
+ box("trak", tkhd(0, 2), box("mdia", [])),
42
+ ),
43
+ );
44
+ zeroTrackAlternateGroups(bytes.buffer);
45
+
46
+ const view = new DataView(bytes.buffer);
47
+ // Locate both tkhd boxes and confirm alternate_group is now 0.
48
+ const groups: number[] = [];
49
+ for (let i = 0; i + 8 <= bytes.length; i++) {
50
+ if (
51
+ String.fromCharCode(
52
+ bytes[i + 4],
53
+ bytes[i + 5],
54
+ bytes[i + 6],
55
+ bytes[i + 7],
56
+ ) === "tkhd"
57
+ ) {
58
+ groups.push(view.getUint16(i + 8 + 4 + 20 + 8 + 2));
59
+ }
60
+ }
61
+ expect(groups).toEqual([0, 0]);
62
+ });
63
+
64
+ it("handles version 1 tkhd boxes", () => {
65
+ const bytes = new Uint8Array(box("moov", box("trak", tkhd(1, 7))));
66
+ zeroTrackAlternateGroups(bytes.buffer);
67
+
68
+ const view = new DataView(bytes.buffer);
69
+ let offset = -1;
70
+ for (let i = 0; i + 8 <= bytes.length; i++) {
71
+ if (
72
+ String.fromCharCode(
73
+ bytes[i + 4],
74
+ bytes[i + 5],
75
+ bytes[i + 6],
76
+ bytes[i + 7],
77
+ ) === "tkhd"
78
+ ) {
79
+ offset = i + 8 + 4 + 32 + 8 + 2;
80
+ }
81
+ }
82
+ expect(view.getUint16(offset)).toBe(0);
83
+ });
84
+
85
+ it("touches nothing but the alternate_group field", () => {
86
+ const original = new Uint8Array(
87
+ box(
88
+ "moov",
89
+ box("trak", tkhd(0, 0x0102), box("mdia", box("mdhd", u32(0)))),
90
+ ),
91
+ );
92
+ const copy = new Uint8Array(original);
93
+ zeroTrackAlternateGroups(copy.buffer);
94
+
95
+ const diffs: number[] = [];
96
+ for (let i = 0; i < original.length; i++) {
97
+ if (original[i] !== copy[i]) diffs.push(i);
98
+ }
99
+ // Exactly the two bytes of one alternate_group field changed.
100
+ expect(diffs).toHaveLength(2);
101
+ expect(diffs[1]).toBe(diffs[0] + 1);
102
+ });
103
+
104
+ it("leaves an already-zero alternate_group untouched", () => {
105
+ const original = new Uint8Array(box("moov", box("trak", tkhd(0, 0))));
106
+ const copy = new Uint8Array(original);
107
+ zeroTrackAlternateGroups(copy.buffer);
108
+ expect([...copy]).toEqual([...original]);
109
+ });
110
+
111
+ it("ignores files with no tkhd box", () => {
112
+ const original = new Uint8Array(box("ftyp", str4("isom")));
113
+ const copy = new Uint8Array(original);
114
+ zeroTrackAlternateGroups(copy.buffer);
115
+ expect([...copy]).toEqual([...original]);
116
+ });
117
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * In-place patching of MP4 track header (`tkhd`) flags.
3
+ *
4
+ * Mediabunny's MP4 muxer writes a non-zero `alternate_group` into every
5
+ * `tkhd` box (video → 1, audio → 2). Per ISO-BMFF, a non-zero
6
+ * `alternate_group` marks a track as one of several mutually exclusive
7
+ * alternates. Safari's native `<video>` player reacts to this by never
8
+ * auto-hiding the control bar during playback — the controls stay pinned.
9
+ * ffmpeg writes 0 here; matching that restores normal control-bar behavior.
10
+ */
11
+
12
+ /** ISO-BMFF container boxes that hold a `tkhd` somewhere below them. */
13
+ const CONTAINER_TYPES = new Set(["moov", "trak"]);
14
+
15
+ /**
16
+ * Byte offset of the `alternate_group` field within a `tkhd` box, measured
17
+ * from the start of the box. The field sits after the box header, the
18
+ * version/flags word, the (version-sized) time/duration fields, the two
19
+ * reserved words, and the 2-byte `layer` field.
20
+ */
21
+ function alternateGroupOffset(version: number): number {
22
+ // header(8) + version/flags(4) + variable middle + reserved(8) + layer(2):
23
+ // v0: creation(4) modification(4) trackID(4) reserved(4) duration(4) = 20
24
+ // v1: creation(8) modification(8) trackID(4) reserved(4) duration(8) = 32
25
+ return 8 + 4 + (version === 1 ? 32 : 20) + 8 + 2;
26
+ }
27
+
28
+ /**
29
+ * Zero the `alternate_group` field of every `tkhd` box in an MP4 buffer,
30
+ * operating in place. Safe to call on any ISO-BMFF file; boxes without a
31
+ * `tkhd` are left untouched.
32
+ *
33
+ * @param buffer - The MP4 file bytes. Mutated in place.
34
+ * @example
35
+ * zeroTrackAlternateGroups(mediabunnyOutput);
36
+ */
37
+ export function zeroTrackAlternateGroups(buffer: ArrayBuffer): void {
38
+ const view = new DataView(buffer);
39
+
40
+ const walk = (start: number, end: number): void => {
41
+ let pos = start;
42
+ while (pos + 8 <= end) {
43
+ let size = view.getUint32(pos);
44
+ const type = String.fromCharCode(
45
+ view.getUint8(pos + 4),
46
+ view.getUint8(pos + 5),
47
+ view.getUint8(pos + 6),
48
+ view.getUint8(pos + 7),
49
+ );
50
+ if (size === 0) size = end - pos;
51
+ if (size < 8 || pos + size > end) break;
52
+
53
+ if (type === "tkhd") {
54
+ const version = view.getUint8(pos + 8);
55
+ const fieldOffset = pos + alternateGroupOffset(version);
56
+ if (
57
+ fieldOffset + 2 <= pos + size &&
58
+ view.getUint16(fieldOffset) !== 0
59
+ ) {
60
+ view.setUint16(fieldOffset, 0);
61
+ }
62
+ } else if (CONTAINER_TYPES.has(type)) {
63
+ walk(pos + 8, pos + size);
64
+ }
65
+
66
+ pos += size;
67
+ }
68
+ };
69
+
70
+ walk(0, buffer.byteLength);
71
+ }
package/src/styles/ui.css CHANGED
@@ -4432,6 +4432,24 @@
4432
4432
  position: relative;
4433
4433
  }
4434
4434
 
4435
+ jant-compose-editor.compose-editor-dragover {
4436
+ position: relative;
4437
+ }
4438
+
4439
+ jant-compose-editor.compose-editor-dragover::after {
4440
+ content: "";
4441
+ position: absolute;
4442
+ inset: 6px;
4443
+ z-index: 5;
4444
+ pointer-events: none;
4445
+ border-radius: 14px;
4446
+ background: color-mix(in srgb, var(--site-accent) 4%, transparent);
4447
+ box-shadow:
4448
+ inset 0 0 0 1.5px color-mix(in srgb, var(--site-accent) 45%, transparent),
4449
+ inset 0 0 28px -12px
4450
+ color-mix(in srgb, var(--site-accent) 45%, transparent);
4451
+ }
4452
+
4435
4453
  .compose-edit-loading {
4436
4454
  display: flex;
4437
4455
  flex: 1;