@huaqiu/component-gen-app 0.4.7 → 0.4.9

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.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>华秋 Component Gen</title>
7
- <script type="module" crossorigin src="./assets/index-t51t_a62.js"></script>
7
+ <script type="module" crossorigin src="./assets/index-BnNwBC2r.js"></script>
8
8
  </head>
9
9
  <body>
10
10
  <div id="root"></div>
package/lib/index.js CHANGED
@@ -337,6 +337,81 @@ function translateFor(lang) {
337
337
  /** zh-only fallback (used when no locale is supplied). */
338
338
  const defaultT = (key, params) => translate("zh", key, params);
339
339
  //#endregion
340
+ //#region src/utils/drag.ts
341
+ /**
342
+ * `@huaqiu/component-gen-app` — page-level drag-state interop.
343
+ *
344
+ * The app is rendered inside hosts that own a *page-wide* file drop target
345
+ * (dsh's attachment plugin listens on `document` for dragenter/dragover/
346
+ * dragleave/drop and paints a full-screen "drop to attach" invitation). Two
347
+ * properties of that pattern matter to any nested drop zone:
348
+ *
349
+ * - `dragover` writes `dataTransfer.dropEffect = 'none'` whenever the host
350
+ * refuses the drop (no session, composer busy, …). `dropEffect` lives on the
351
+ * drag data store shared by the whole drag, so the host's verdict outranks
352
+ * the nested zone's: Chromium then cancels the drag operation and the `drop`
353
+ * event is never dispatched anywhere on the page. The nested zone is dead
354
+ * even though its own `dragover` called `preventDefault()`.
355
+ * - The host counts dragenter/dragleave to drive that invitation, and only
356
+ * clears the counter from its own `drop`, `dragleave`, or window `dragend`.
357
+ * A drag of OS files fires **no** `dragend` ("dragstart and dragend are not
358
+ * fired when dragging a file into the browser from the OS" — MDN, File drag
359
+ * and drop), so a nested zone that swallows the drop leaves the host's
360
+ * invitation dimming the screen forever.
361
+ *
362
+ * A nested zone therefore has to do two things: keep its own drag events to
363
+ * itself (`stopPropagation`, done by the drop zone) and tell the page when the
364
+ * drag has moved onto it / been consumed, which is what this module is for.
365
+ */
366
+ /**
367
+ * Broadcast "this file drag is no longer yours" to page-wide drop targets.
368
+ *
369
+ * Dispatched on `document.body` with a Files-carrying `dataTransfer`, which is
370
+ * the shape page-wide drop targets test for, and at the viewport origin, which
371
+ * is how they recognise "the pointer left the page" and drop their drag state.
372
+ * Holding a page-wide target's enter/leave counter at zero while the pointer is
373
+ * over our own drop zone is also what lets the zone be seen: dsh's invitation
374
+ * paints a blurred full-viewport mask, and it is the host's own handler that
375
+ * has to take it back down.
376
+ *
377
+ * Idempotent and a no-op in hosts without a page-wide drop target (the
378
+ * standalone build, SSR, environments without `DataTransfer`).
379
+ */
380
+ function releasePageDragState() {
381
+ if (typeof document === "undefined" || !document.body) return;
382
+ const event = new Event("dragleave", {
383
+ bubbles: true,
384
+ cancelable: true
385
+ });
386
+ Object.assign(event, {
387
+ clientX: 0,
388
+ clientY: 0,
389
+ dataTransfer: dragStateTransfer()
390
+ });
391
+ document.body.dispatchEvent(event);
392
+ }
393
+ /** Minimal Files payload page-wide drop targets recognise. */
394
+ function dragStateTransfer() {
395
+ try {
396
+ const transfer = new DataTransfer();
397
+ transfer.items.add(new File([], "drag-state"));
398
+ return transfer;
399
+ } catch {
400
+ return { types: ["Files"] };
401
+ }
402
+ }
403
+ /**
404
+ * True when a drag carries files (as opposed to text or an in-page element
405
+ * being dragged). Guards the drag-state broadcast so text drags over the
406
+ * upload zone do not disturb hosts tracking their own drags.
407
+ */
408
+ function dragHasFiles(dataTransfer) {
409
+ if (!dataTransfer) return false;
410
+ const types = dataTransfer.types;
411
+ if (!types) return false;
412
+ return Array.from(types).some((type) => type === "Files" || type === "application/x-moz-file");
413
+ }
414
+ //#endregion
340
415
  //#region src/components/UploadInput.tsx
341
416
  /**
342
417
  * `@huaqiu/component-gen-app` — image upload control (click + paste + drag).
@@ -344,6 +419,13 @@ const defaultT = (key, params) => translate("zh", key, params);
344
419
  * Reads a file, downscales it to a thumbnail data URL if needed (bounded by
345
420
  * `maxBytes`), and reports both the original file and the data URL to the
346
421
  * parent. The parent owns sending it to the server.
422
+ *
423
+ * Drag-and-drop note: the zone has to *claim* the drag. Hosts that install a
424
+ * page-wide file drop target (dsh's attachment plugin does) otherwise either
425
+ * cancel the drag outright — their `dragover` writes `dropEffect = 'none'` into
426
+ * the shared drag data store, which stops `drop` from ever being dispatched —
427
+ * or take the dropped file for themselves at document level. See
428
+ * `utils/drag.ts` for the full mechanism.
347
429
  */
348
430
  const MAX_EDGE = 1600;
349
431
  /** Downscale an image file to a data URL bounded in edge + bytes. */
@@ -391,6 +473,7 @@ function UploadInput(props) {
391
473
  const inputRef = useRef(null);
392
474
  const [dragging, setDragging] = useState(false);
393
475
  const [error, setError] = useState(null);
476
+ const dragDepth = useRef(0);
394
477
  const accept = useCallback(async (f) => {
395
478
  if (!f) return;
396
479
  if (f.size > maxBytes) {
@@ -409,19 +492,44 @@ function UploadInput(props) {
409
492
  onFile,
410
493
  t
411
494
  ]);
495
+ /** Keep a drag event away from page-wide drop targets (see the module docs). */
496
+ const claim = (ev) => {
497
+ ev.stopPropagation();
498
+ };
499
+ /** The drag now belongs to the page again — let its drop targets resume. */
500
+ const release = (ev) => {
501
+ if (dragHasFiles(ev.dataTransfer)) releasePageDragState();
502
+ };
412
503
  return /* @__PURE__ */ jsxs("div", {
413
504
  className: `cga-upload${dragging ? " cga-upload--dragging" : ""}`,
414
505
  onClick: () => {
415
506
  if (!disabled) inputRef.current?.click();
416
507
  },
508
+ onDragEnter: (ev) => {
509
+ ev.preventDefault();
510
+ claim(ev);
511
+ dragDepth.current += 1;
512
+ if (dragDepth.current === 1) {
513
+ setDragging(true);
514
+ release(ev);
515
+ }
516
+ },
417
517
  onDragOver: (ev) => {
418
518
  ev.preventDefault();
519
+ claim(ev);
520
+ if (ev.dataTransfer) ev.dataTransfer.dropEffect = disabled ? "none" : "copy";
419
521
  if (!disabled) setDragging(true);
420
522
  },
421
- onDragLeave: () => setDragging(false),
523
+ onDragLeave: (ev) => {
524
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
525
+ if (dragDepth.current === 0) setDragging(false);
526
+ },
422
527
  onDrop: (ev) => {
423
528
  ev.preventDefault();
529
+ claim(ev);
530
+ dragDepth.current = 0;
424
531
  setDragging(false);
532
+ release(ev);
425
533
  if (disabled) return;
426
534
  accept(ev.dataTransfer.files?.[0] ?? null);
427
535
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/component-gen-app",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "type": "module",
5
5
  "main": "./lib/index.js",
6
6
  "types": "./lib/index.d.ts",
@@ -4,9 +4,17 @@
4
4
  * Reads a file, downscales it to a thumbnail data URL if needed (bounded by
5
5
  * `maxBytes`), and reports both the original file and the data URL to the
6
6
  * parent. The parent owns sending it to the server.
7
+ *
8
+ * Drag-and-drop note: the zone has to *claim* the drag. Hosts that install a
9
+ * page-wide file drop target (dsh's attachment plugin does) otherwise either
10
+ * cancel the drag outright — their `dragover` writes `dropEffect = 'none'` into
11
+ * the shared drag data store, which stops `drop` from ever being dispatched —
12
+ * or take the dropped file for themselves at document level. See
13
+ * `utils/drag.ts` for the full mechanism.
7
14
  */
8
- import { useCallback, useRef, useState, type ReactElement } from 'react'
15
+ import { useCallback, useRef, useState, type DragEvent as ReactDragEvent, type ReactElement } from 'react'
9
16
  import type { Translate } from '../copy/index.js'
17
+ import { dragHasFiles, releasePageDragState } from '../utils/drag.js'
10
18
 
11
19
  export interface UploadInputProps {
12
20
  maxBytes: number
@@ -58,6 +66,10 @@ export function UploadInput(props: UploadInputProps): ReactElement {
58
66
  const inputRef = useRef<HTMLInputElement | null>(null)
59
67
  const [dragging, setDragging] = useState(false)
60
68
  const [error, setError] = useState<string | null>(null)
69
+ // Enter/leave counter instead of a boolean: the browser fires dragenter and
70
+ // dragleave for *every* element transition inside the zone, so a boolean
71
+ // flickers off whenever the pointer crosses the label, thumbnail or button.
72
+ const dragDepth = useRef(0)
61
73
 
62
74
  const accept = useCallback(async (f: File | null): Promise<void> => {
63
75
  if (!f) return
@@ -74,15 +86,54 @@ export function UploadInput(props: UploadInputProps): ReactElement {
74
86
  }
75
87
  }, [maxBytes, onFile, t])
76
88
 
89
+ /** Keep a drag event away from page-wide drop targets (see the module docs). */
90
+ const claim = (ev: ReactDragEvent<HTMLDivElement>): void => {
91
+ ev.stopPropagation()
92
+ }
93
+
94
+ /** The drag now belongs to the page again — let its drop targets resume. */
95
+ const release = (ev: ReactDragEvent<HTMLDivElement>): void => {
96
+ if (dragHasFiles(ev.dataTransfer)) releasePageDragState()
97
+ }
98
+
77
99
  return (
78
100
  <div
79
101
  className={`cga-upload${dragging ? ' cga-upload--dragging' : ''}`}
80
102
  onClick={() => { if (!disabled) inputRef.current?.click() }}
81
- onDragOver={(ev) => { ev.preventDefault(); if (!disabled) setDragging(true) }}
82
- onDragLeave={() => setDragging(false)}
103
+ onDragEnter={(ev) => {
104
+ ev.preventDefault()
105
+ claim(ev)
106
+ dragDepth.current += 1
107
+ if (dragDepth.current === 1) {
108
+ setDragging(true)
109
+ // Take the page's drag state down with us: while the file is over the
110
+ // zone the host's full-viewport drop invitation must not cover it.
111
+ release(ev)
112
+ }
113
+ }}
114
+ onDragOver={(ev) => {
115
+ ev.preventDefault()
116
+ claim(ev)
117
+ // Our verdict, not the host's: a 'none' written by a document-level
118
+ // handler reaches the drag data store too, and the browser cancels the
119
+ // whole drag when the last word on it was 'none'.
120
+ if (ev.dataTransfer) ev.dataTransfer.dropEffect = disabled ? 'none' : 'copy'
121
+ if (!disabled) setDragging(true)
122
+ }}
123
+ onDragLeave={(ev) => {
124
+ // Not claimed: the host's own enter/leave accounting stays balanced
125
+ // when the file is dragged back out of the zone.
126
+ dragDepth.current = Math.max(0, dragDepth.current - 1)
127
+ if (dragDepth.current === 0) setDragging(false)
128
+ }}
83
129
  onDrop={(ev) => {
84
130
  ev.preventDefault()
131
+ // Claimed for good: the host must not also attach this file to its own
132
+ // draft (the point of the drop is the generator, not the composer).
133
+ claim(ev)
134
+ dragDepth.current = 0
85
135
  setDragging(false)
136
+ release(ev)
86
137
  if (disabled) return
87
138
  void accept(ev.dataTransfer.files?.[0] ?? null)
88
139
  }}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — page-level drag-state interop.
3
+ *
4
+ * The app is rendered inside hosts that own a *page-wide* file drop target
5
+ * (dsh's attachment plugin listens on `document` for dragenter/dragover/
6
+ * dragleave/drop and paints a full-screen "drop to attach" invitation). Two
7
+ * properties of that pattern matter to any nested drop zone:
8
+ *
9
+ * - `dragover` writes `dataTransfer.dropEffect = 'none'` whenever the host
10
+ * refuses the drop (no session, composer busy, …). `dropEffect` lives on the
11
+ * drag data store shared by the whole drag, so the host's verdict outranks
12
+ * the nested zone's: Chromium then cancels the drag operation and the `drop`
13
+ * event is never dispatched anywhere on the page. The nested zone is dead
14
+ * even though its own `dragover` called `preventDefault()`.
15
+ * - The host counts dragenter/dragleave to drive that invitation, and only
16
+ * clears the counter from its own `drop`, `dragleave`, or window `dragend`.
17
+ * A drag of OS files fires **no** `dragend` ("dragstart and dragend are not
18
+ * fired when dragging a file into the browser from the OS" — MDN, File drag
19
+ * and drop), so a nested zone that swallows the drop leaves the host's
20
+ * invitation dimming the screen forever.
21
+ *
22
+ * A nested zone therefore has to do two things: keep its own drag events to
23
+ * itself (`stopPropagation`, done by the drop zone) and tell the page when the
24
+ * drag has moved onto it / been consumed, which is what this module is for.
25
+ */
26
+
27
+ /**
28
+ * Broadcast "this file drag is no longer yours" to page-wide drop targets.
29
+ *
30
+ * Dispatched on `document.body` with a Files-carrying `dataTransfer`, which is
31
+ * the shape page-wide drop targets test for, and at the viewport origin, which
32
+ * is how they recognise "the pointer left the page" and drop their drag state.
33
+ * Holding a page-wide target's enter/leave counter at zero while the pointer is
34
+ * over our own drop zone is also what lets the zone be seen: dsh's invitation
35
+ * paints a blurred full-viewport mask, and it is the host's own handler that
36
+ * has to take it back down.
37
+ *
38
+ * Idempotent and a no-op in hosts without a page-wide drop target (the
39
+ * standalone build, SSR, environments without `DataTransfer`).
40
+ */
41
+ export function releasePageDragState(): void {
42
+ if (typeof document === 'undefined' || !document.body) return
43
+ // A plain `Event` carrying the same shape, so this works in every engine
44
+ // (notably jsdom, which implements neither `DragEvent` nor `DataTransfer`).
45
+ const event = new Event('dragleave', { bubbles: true, cancelable: true })
46
+ Object.assign(event, { clientX: 0, clientY: 0, dataTransfer: dragStateTransfer() })
47
+ document.body.dispatchEvent(event)
48
+ }
49
+
50
+ /** Minimal Files payload page-wide drop targets recognise. */
51
+ function dragStateTransfer(): unknown {
52
+ try {
53
+ const transfer = new DataTransfer()
54
+ // The `types` list only reports `Files` once a file is actually held.
55
+ transfer.items.add(new File([], 'drag-state'))
56
+ return transfer
57
+ } catch {
58
+ return { types: ['Files'] }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * True when a drag carries files (as opposed to text or an in-page element
64
+ * being dragged). Guards the drag-state broadcast so text drags over the
65
+ * upload zone do not disturb hosts tracking their own drags.
66
+ */
67
+ export function dragHasFiles(dataTransfer: DataTransfer | null | undefined): boolean {
68
+ if (!dataTransfer) return false
69
+ const types = dataTransfer.types
70
+ if (!types) return false
71
+ return Array.from(types).some((type) => type === 'Files' || type === 'application/x-moz-file')
72
+ }