@bendyline/squisq-editor-react 1.6.1 → 1.6.2
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.d.ts +87 -41
- package/dist/index.js +8263 -6705
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +841 -91
- package/package.json +4 -4
- package/src/BlockPropertiesPopover.tsx +23 -7
- package/src/EditorShell.tsx +65 -20
- package/src/MediaBin.tsx +171 -28
- package/src/PreviewControls.tsx +177 -44
- package/src/PreviewPanel.tsx +2 -0
- package/src/TemplateAnnotation.ts +22 -7
- package/src/TemplateContentPreview.tsx +56 -0
- package/src/TemplatePicker.tsx +295 -128
- package/src/ThemeCustomizerPanel.tsx +22 -15
- package/src/Toolbar.tsx +527 -207
- package/src/TransitionPicker.tsx +8 -1
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +45 -3
- package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
- package/src/__tests__/editorShellProps.test.tsx +268 -1
- package/src/__tests__/headingTransition.test.ts +59 -9
- package/src/__tests__/imageEditorShell.test.tsx +23 -0
- package/src/__tests__/mediaReferences.test.ts +82 -0
- package/src/__tests__/previewControls.test.tsx +94 -1
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
- package/src/__tests__/templateContentPreview.test.ts +101 -0
- package/src/__tests__/tiptapBridge.test.ts +47 -0
- package/src/diagram/DiagramWidget.tsx +6 -4
- package/src/headingTransition.ts +96 -21
- package/src/index.ts +2 -1
- package/src/mediaReferences.ts +299 -0
- package/src/scene/Scene.tsx +53 -7
- package/src/scene/SceneBlockWidget.tsx +6 -3
- package/src/scene/SceneSelection.tsx +19 -15
- package/src/scene/SceneSideToolbar.tsx +89 -0
- package/src/scene/layers/DiagramEdges.tsx +4 -3
- package/src/scene/layers/edgeGeometry.ts +23 -4
- package/src/scene/scene.css +142 -2
- package/src/scene/tools/ConnectTool.ts +113 -24
- package/src/scene/tools/DrawingConnectTool.ts +86 -16
- package/src/scene/tools/SceneTool.ts +2 -0
- package/src/styles/editor.css +862 -101
- package/src/templateContentPreviewResolver.ts +353 -0
- package/src/tiptapBridge.ts +60 -33
package/src/TemplatePicker.tsx
CHANGED
|
@@ -10,8 +10,14 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
10
10
|
import { createPortal } from 'react-dom';
|
|
11
11
|
import type { CustomTemplateDefinition } from '@bendyline/squisq/schemas';
|
|
12
12
|
import { TEMPLATE_METADATA, resolveTemplateName } from '@bendyline/squisq/doc';
|
|
13
|
+
import { extractPlainText } from '@bendyline/squisq/markdown';
|
|
13
14
|
import { useCustomTemplates } from './customTemplates/CustomTemplateContext';
|
|
14
15
|
import { TemplateThumbnail } from './customTemplates/thumbnail';
|
|
16
|
+
import { TemplateContentPreview } from './TemplateContentPreview';
|
|
17
|
+
import {
|
|
18
|
+
resolveTemplateContentPreviewResult,
|
|
19
|
+
type TemplatePreviewSource,
|
|
20
|
+
} from './templateContentPreviewResolver';
|
|
15
21
|
|
|
16
22
|
// ── Template metadata ─────────────────────────────────────────────
|
|
17
23
|
//
|
|
@@ -30,6 +36,15 @@ interface TemplateEntry {
|
|
|
30
36
|
icon: JSX.Element;
|
|
31
37
|
}
|
|
32
38
|
|
|
39
|
+
/**
|
|
40
|
+
* DOM id of the portaled template gallery. Exported so hosts that embed the
|
|
41
|
+
* picker inside their own popovers (e.g. the toolbar overflow menu) can treat
|
|
42
|
+
* clicks inside the gallery as "inside" in their outside-click handling.
|
|
43
|
+
*/
|
|
44
|
+
export const TEMPLATE_GALLERY_PORTAL_ID = 'squisq-template-gallery-portal';
|
|
45
|
+
|
|
46
|
+
const TEMPLATE_GALLERY_DIALOG_ID = 'squisq-template-gallery-dialog';
|
|
47
|
+
|
|
33
48
|
const W = 56;
|
|
34
49
|
const H = 40;
|
|
35
50
|
|
|
@@ -57,7 +72,7 @@ function TemplateIcon({ children }: { children: React.ReactNode }) {
|
|
|
57
72
|
const NONE_ENTRY: TemplateEntry = {
|
|
58
73
|
name: '',
|
|
59
74
|
label: '— none —',
|
|
60
|
-
description: 'Plain heading block with no visual
|
|
75
|
+
description: 'Plain heading block with no visual treatment.',
|
|
61
76
|
icon: (
|
|
62
77
|
<TemplateIcon>
|
|
63
78
|
<rect
|
|
@@ -513,6 +528,8 @@ export function templateLabel(
|
|
|
513
528
|
export interface TemplatePickerProps {
|
|
514
529
|
value: string;
|
|
515
530
|
onChange: (name: string) => void;
|
|
531
|
+
/** Active editor chrome theme, used by the portaled dialog. */
|
|
532
|
+
colorScheme?: 'light' | 'dark';
|
|
516
533
|
/** When true, shows only the trigger button (no popover) — used in the overflow menu. */
|
|
517
534
|
compact?: boolean;
|
|
518
535
|
/**
|
|
@@ -528,48 +545,35 @@ export interface TemplatePickerProps {
|
|
|
528
545
|
* hidden.
|
|
529
546
|
*/
|
|
530
547
|
onOpenDesigner?: () => void;
|
|
548
|
+
/**
|
|
549
|
+
* Active block content used to render live template thumbnails. When omitted,
|
|
550
|
+
* or when a template cannot be meaningfully derived from the block, cards use
|
|
551
|
+
* their static wireframe icons.
|
|
552
|
+
*/
|
|
553
|
+
previewSource?: TemplatePreviewSource;
|
|
531
554
|
}
|
|
532
555
|
|
|
533
556
|
export function TemplatePicker({
|
|
534
557
|
value,
|
|
535
558
|
onChange,
|
|
559
|
+
colorScheme = 'light',
|
|
536
560
|
compact,
|
|
537
561
|
recommended,
|
|
538
562
|
onOpenDesigner,
|
|
563
|
+
previewSource,
|
|
539
564
|
}: TemplatePickerProps) {
|
|
540
565
|
const [open, setOpen] = useState(false);
|
|
541
|
-
const [
|
|
566
|
+
const [dialogStyle, setDialogStyle] = useState<React.CSSProperties>({});
|
|
542
567
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
543
568
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
544
569
|
|
|
545
|
-
|
|
546
|
-
// left edge so the gallery (up to 780px wide) doesn't overflow the
|
|
547
|
-
// viewport when the toolbar trigger sits near the right edge of the
|
|
548
|
-
// window — previously the popover was left-aligned to the trigger,
|
|
549
|
-
// which pushed half the cards off-screen at narrow widths.
|
|
550
|
-
const updatePosition = () => {
|
|
570
|
+
const updateDialogBounds = () => {
|
|
551
571
|
if (!triggerRef.current) return;
|
|
552
|
-
|
|
553
|
-
const popoverEl = document.getElementById('squisq-template-gallery-portal');
|
|
554
|
-
// Use the actual rendered width if the popover is already mounted;
|
|
555
|
-
// otherwise fall back to the CSS-defined max so the first paint
|
|
556
|
-
// doesn't overflow either. Measure the portal element directly —
|
|
557
|
-
// its `firstElementChild` is the (small) "(none)" option, not the
|
|
558
|
-
// gallery itself.
|
|
559
|
-
const popoverWidth = popoverEl?.getBoundingClientRect().width ?? 780;
|
|
560
|
-
const margin = 8;
|
|
561
|
-
const maxLeft = Math.max(margin, window.innerWidth - popoverWidth - margin);
|
|
562
|
-
const left = Math.min(Math.max(margin, rect.left), maxLeft);
|
|
563
|
-
setPopoverStyle({
|
|
564
|
-
position: 'fixed',
|
|
565
|
-
top: rect.bottom + 6,
|
|
566
|
-
left,
|
|
567
|
-
zIndex: 9999,
|
|
568
|
-
});
|
|
572
|
+
setDialogStyle(computeDialogStyle(triggerRef.current));
|
|
569
573
|
};
|
|
570
574
|
|
|
571
575
|
const handleOpen = () => {
|
|
572
|
-
|
|
576
|
+
updateDialogBounds();
|
|
573
577
|
setOpen((v) => !v);
|
|
574
578
|
};
|
|
575
579
|
|
|
@@ -579,8 +583,8 @@ export function TemplatePicker({
|
|
|
579
583
|
const handler = (e: MouseEvent) => {
|
|
580
584
|
const target = e.target as Node;
|
|
581
585
|
const inTrigger = triggerRef.current?.contains(target);
|
|
582
|
-
const
|
|
583
|
-
if (!inTrigger && !
|
|
586
|
+
const inDialog = document.getElementById(TEMPLATE_GALLERY_DIALOG_ID)?.contains(target);
|
|
587
|
+
if (!inTrigger && !inDialog) {
|
|
584
588
|
setOpen(false);
|
|
585
589
|
}
|
|
586
590
|
};
|
|
@@ -601,10 +605,8 @@ export function TemplatePicker({
|
|
|
601
605
|
// Reposition on scroll/resize while open
|
|
602
606
|
useEffect(() => {
|
|
603
607
|
if (!open) return;
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
requestAnimationFrame(updatePosition);
|
|
607
|
-
const handler = () => updatePosition();
|
|
608
|
+
requestAnimationFrame(updateDialogBounds);
|
|
609
|
+
const handler = () => updateDialogBounds();
|
|
608
610
|
window.addEventListener('scroll', handler, true);
|
|
609
611
|
window.addEventListener('resize', handler);
|
|
610
612
|
return () => {
|
|
@@ -621,6 +623,7 @@ export function TemplatePicker({
|
|
|
621
623
|
const currentLabel = templateLabel(value);
|
|
622
624
|
const currentEntry: TemplateEntry =
|
|
623
625
|
(value && TEMPLATE_ENTRIES.find((e) => e.name === value)) || NONE_ENTRY;
|
|
626
|
+
const dialogTitle = templateDialogTitle(previewSource);
|
|
624
627
|
|
|
625
628
|
if (compact) {
|
|
626
629
|
// In overflow menu, use a simple select for space efficiency
|
|
@@ -642,13 +645,21 @@ export function TemplatePicker({
|
|
|
642
645
|
|
|
643
646
|
const gallery = open
|
|
644
647
|
? createPortal(
|
|
645
|
-
<
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
style={
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
648
|
+
<TemplateGalleryDialog
|
|
649
|
+
title={dialogTitle}
|
|
650
|
+
colorScheme={colorScheme}
|
|
651
|
+
style={dialogStyle}
|
|
652
|
+
onClose={() => setOpen(false)}
|
|
653
|
+
>
|
|
654
|
+
<TemplateGalleryBody
|
|
655
|
+
value={value}
|
|
656
|
+
onSelect={handleSelect}
|
|
657
|
+
style={{}}
|
|
658
|
+
recommended={recommended}
|
|
659
|
+
onOpenDesigner={onOpenDesigner}
|
|
660
|
+
previewSource={previewSource}
|
|
661
|
+
/>
|
|
662
|
+
</TemplateGalleryDialog>,
|
|
652
663
|
document.body,
|
|
653
664
|
)
|
|
654
665
|
: null;
|
|
@@ -660,9 +671,9 @@ export function TemplatePicker({
|
|
|
660
671
|
type="button"
|
|
661
672
|
className={`squisq-template-picker-trigger${open ? ' squisq-template-picker-trigger--open' : ''}`}
|
|
662
673
|
onClick={handleOpen}
|
|
663
|
-
aria-haspopup="
|
|
674
|
+
aria-haspopup="dialog"
|
|
664
675
|
aria-expanded={open}
|
|
665
|
-
title="Choose block
|
|
676
|
+
title="Choose block type"
|
|
666
677
|
>
|
|
667
678
|
<span className="squisq-template-picker-trigger-label">Block:</span>
|
|
668
679
|
<span className="squisq-template-picker-trigger-thumb" aria-hidden="true">
|
|
@@ -695,8 +706,61 @@ export function TemplatePicker({
|
|
|
695
706
|
|
|
696
707
|
// ── Reusable gallery body ──────────────────────────────────────────
|
|
697
708
|
|
|
709
|
+
function TemplateGalleryDialog({
|
|
710
|
+
children,
|
|
711
|
+
title,
|
|
712
|
+
colorScheme,
|
|
713
|
+
style,
|
|
714
|
+
onClose,
|
|
715
|
+
}: {
|
|
716
|
+
children: React.ReactNode;
|
|
717
|
+
title: string;
|
|
718
|
+
colorScheme: 'light' | 'dark';
|
|
719
|
+
style: React.CSSProperties;
|
|
720
|
+
onClose: () => void;
|
|
721
|
+
}) {
|
|
722
|
+
return (
|
|
723
|
+
<div
|
|
724
|
+
id={TEMPLATE_GALLERY_DIALOG_ID}
|
|
725
|
+
className="squisq-template-gallery-dialog"
|
|
726
|
+
data-theme={colorScheme}
|
|
727
|
+
style={style}
|
|
728
|
+
onMouseDown={(event) => {
|
|
729
|
+
if (event.target === event.currentTarget) onClose();
|
|
730
|
+
}}
|
|
731
|
+
>
|
|
732
|
+
<div
|
|
733
|
+
className="squisq-template-gallery-dialog-panel"
|
|
734
|
+
role="dialog"
|
|
735
|
+
aria-modal="true"
|
|
736
|
+
aria-label={title}
|
|
737
|
+
>
|
|
738
|
+
<div className="squisq-template-gallery-dialog-header">
|
|
739
|
+
<h2 className="squisq-template-gallery-dialog-title">{title}</h2>
|
|
740
|
+
<button
|
|
741
|
+
type="button"
|
|
742
|
+
className="squisq-template-gallery-dialog-close"
|
|
743
|
+
onClick={onClose}
|
|
744
|
+
aria-label="Close block type picker"
|
|
745
|
+
>
|
|
746
|
+
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
|
|
747
|
+
<path
|
|
748
|
+
d="M3.2 3.2l7.6 7.6M10.8 3.2l-7.6 7.6"
|
|
749
|
+
stroke="currentColor"
|
|
750
|
+
strokeWidth="1.7"
|
|
751
|
+
strokeLinecap="round"
|
|
752
|
+
/>
|
|
753
|
+
</svg>
|
|
754
|
+
</button>
|
|
755
|
+
</div>
|
|
756
|
+
<div className="squisq-template-gallery-dialog-body">{children}</div>
|
|
757
|
+
</div>
|
|
758
|
+
</div>
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
698
762
|
/**
|
|
699
|
-
* The
|
|
763
|
+
* The dialog/grid markup shared by the toolbar `TemplatePicker` and the
|
|
700
764
|
* inline `TemplateBadgeMenu`. Renders all template cards plus the
|
|
701
765
|
* "(none)" option; no positioning logic — callers supply `style` (typically
|
|
702
766
|
* a `position: fixed` rect from `getBoundingClientRect()`).
|
|
@@ -705,10 +769,12 @@ function TemplateCard({
|
|
|
705
769
|
entry,
|
|
706
770
|
value,
|
|
707
771
|
onSelect,
|
|
772
|
+
previewSource,
|
|
708
773
|
}: {
|
|
709
774
|
entry: TemplateEntry;
|
|
710
775
|
value: string;
|
|
711
776
|
onSelect: (name: string) => void;
|
|
777
|
+
previewSource?: TemplatePreviewSource;
|
|
712
778
|
}) {
|
|
713
779
|
return (
|
|
714
780
|
<button
|
|
@@ -719,7 +785,13 @@ function TemplateCard({
|
|
|
719
785
|
onClick={() => onSelect(entry.name)}
|
|
720
786
|
title={entry.description}
|
|
721
787
|
>
|
|
722
|
-
<div className="squisq-template-gallery-card-icon">
|
|
788
|
+
<div className="squisq-template-gallery-card-icon">
|
|
789
|
+
<TemplateContentPreview
|
|
790
|
+
templateName={entry.name}
|
|
791
|
+
source={previewSource}
|
|
792
|
+
fallback={entry.icon}
|
|
793
|
+
/>
|
|
794
|
+
</div>
|
|
723
795
|
<div className="squisq-template-gallery-card-body">
|
|
724
796
|
<span className="squisq-template-gallery-card-name">{entry.label}</span>
|
|
725
797
|
<span className="squisq-template-gallery-card-desc">{entry.description}</span>
|
|
@@ -728,18 +800,38 @@ function TemplateCard({
|
|
|
728
800
|
);
|
|
729
801
|
}
|
|
730
802
|
|
|
803
|
+
function splitBlockTypeEntriesByContent(
|
|
804
|
+
entries: readonly TemplateEntry[],
|
|
805
|
+
previewSource?: TemplatePreviewSource,
|
|
806
|
+
): { blockTypeEntries: TemplateEntry[]; contentNeededEntries: TemplateEntry[] } {
|
|
807
|
+
if (!previewSource) return { blockTypeEntries: [...entries], contentNeededEntries: [] };
|
|
808
|
+
|
|
809
|
+
const blockTypeEntries: TemplateEntry[] = [];
|
|
810
|
+
const contentNeededEntries: TemplateEntry[] = [];
|
|
811
|
+
|
|
812
|
+
for (const entry of entries) {
|
|
813
|
+
const result = resolveTemplateContentPreviewResult(entry.name, previewSource);
|
|
814
|
+
if (result.warning) contentNeededEntries.push(entry);
|
|
815
|
+
else blockTypeEntries.push(entry);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return { blockTypeEntries, contentNeededEntries };
|
|
819
|
+
}
|
|
820
|
+
|
|
731
821
|
function TemplateGalleryBody({
|
|
732
822
|
value,
|
|
733
823
|
onSelect,
|
|
734
824
|
style,
|
|
735
825
|
recommended,
|
|
736
826
|
onOpenDesigner,
|
|
827
|
+
previewSource,
|
|
737
828
|
}: {
|
|
738
829
|
value: string;
|
|
739
830
|
onSelect: (name: string) => void;
|
|
740
831
|
style: React.CSSProperties;
|
|
741
832
|
recommended?: readonly string[];
|
|
742
833
|
onOpenDesigner?: () => void;
|
|
834
|
+
previewSource?: TemplatePreviewSource;
|
|
743
835
|
}) {
|
|
744
836
|
const [query, setQuery] = useState('');
|
|
745
837
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
@@ -779,21 +871,33 @@ function TemplateGalleryBody({
|
|
|
779
871
|
return { built, custom };
|
|
780
872
|
}, [hasQuery, trimmedQuery, customTemplates]);
|
|
781
873
|
|
|
782
|
-
const recommendedSet =
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
const
|
|
787
|
-
? TEMPLATE_ENTRIES.filter((e) =>
|
|
788
|
-
|
|
789
|
-
|
|
874
|
+
const recommendedSet = useMemo(
|
|
875
|
+
() => (recommended && recommended.length > 0 ? new Set(recommended) : null),
|
|
876
|
+
[recommended],
|
|
877
|
+
);
|
|
878
|
+
const recommendedEntries = useMemo(
|
|
879
|
+
() => (recommendedSet ? TEMPLATE_ENTRIES.filter((e) => recommendedSet.has(e.name)) : []),
|
|
880
|
+
[recommendedSet],
|
|
881
|
+
);
|
|
882
|
+
const restEntries = useMemo(
|
|
883
|
+
() =>
|
|
884
|
+
recommendedSet
|
|
885
|
+
? TEMPLATE_ENTRIES.filter((e) => !recommendedSet.has(e.name))
|
|
886
|
+
: TEMPLATE_ENTRIES,
|
|
887
|
+
[recommendedSet],
|
|
888
|
+
);
|
|
889
|
+
const { blockTypeEntries, contentNeededEntries } = useMemo(
|
|
890
|
+
() => splitBlockTypeEntriesByContent(restEntries, previewSource),
|
|
891
|
+
[restEntries, previewSource],
|
|
892
|
+
);
|
|
893
|
+
const grouped = !hasQuery && (recommendedEntries.length > 0 || contentNeededEntries.length > 0);
|
|
790
894
|
|
|
791
895
|
return (
|
|
792
896
|
<div
|
|
793
|
-
id=
|
|
794
|
-
className={`squisq-template-gallery${
|
|
897
|
+
id={TEMPLATE_GALLERY_PORTAL_ID}
|
|
898
|
+
className={`squisq-template-gallery${grouped ? ' squisq-template-gallery--segmented' : ''}`}
|
|
795
899
|
role="listbox"
|
|
796
|
-
aria-label="Block
|
|
900
|
+
aria-label="Block types"
|
|
797
901
|
style={style}
|
|
798
902
|
>
|
|
799
903
|
<div className="squisq-template-gallery-search">
|
|
@@ -812,10 +916,10 @@ function TemplateGalleryBody({
|
|
|
812
916
|
ref={searchRef}
|
|
813
917
|
type="search"
|
|
814
918
|
className="squisq-template-gallery-search-input"
|
|
815
|
-
placeholder="Search
|
|
919
|
+
placeholder="Search block types…"
|
|
816
920
|
value={query}
|
|
817
921
|
onChange={(e) => setQuery(e.target.value)}
|
|
818
|
-
aria-label="Search block
|
|
922
|
+
aria-label="Search block types"
|
|
819
923
|
/>
|
|
820
924
|
</div>
|
|
821
925
|
|
|
@@ -854,13 +958,19 @@ function TemplateGalleryBody({
|
|
|
854
958
|
{matches.built.length > 0 && (
|
|
855
959
|
<div className="squisq-template-gallery-grid">
|
|
856
960
|
{matches.built.map((entry) => (
|
|
857
|
-
<TemplateCard
|
|
961
|
+
<TemplateCard
|
|
962
|
+
key={entry.name}
|
|
963
|
+
entry={entry}
|
|
964
|
+
value={value}
|
|
965
|
+
onSelect={onSelect}
|
|
966
|
+
previewSource={previewSource}
|
|
967
|
+
/>
|
|
858
968
|
))}
|
|
859
969
|
</div>
|
|
860
970
|
)}
|
|
861
971
|
</>
|
|
862
972
|
) : (
|
|
863
|
-
<div className="squisq-template-gallery-empty">No
|
|
973
|
+
<div className="squisq-template-gallery-empty">No block types match "{query}".</div>
|
|
864
974
|
)
|
|
865
975
|
) : (
|
|
866
976
|
<>
|
|
@@ -872,9 +982,9 @@ function TemplateGalleryBody({
|
|
|
872
982
|
+
|
|
873
983
|
</span>
|
|
874
984
|
<span className="squisq-template-gallery-new-body">
|
|
875
|
-
<span className="squisq-template-gallery-new-label">New
|
|
985
|
+
<span className="squisq-template-gallery-new-label">New block type</span>
|
|
876
986
|
<span className="squisq-template-gallery-new-desc">
|
|
877
|
-
Design a reusable
|
|
987
|
+
Design a reusable block type with placeholders for {'{title}'} and {'{content}'}.
|
|
878
988
|
</span>
|
|
879
989
|
</span>
|
|
880
990
|
</button>
|
|
@@ -891,30 +1001,69 @@ function TemplateGalleryBody({
|
|
|
891
1001
|
</div>
|
|
892
1002
|
)}
|
|
893
1003
|
|
|
894
|
-
{
|
|
1004
|
+
{recommendedEntries.length > 0 && (
|
|
895
1005
|
<div className="squisq-template-gallery-section">
|
|
896
|
-
<h3 className="squisq-template-gallery-section-title">
|
|
1006
|
+
<h3 className="squisq-template-gallery-section-title">Suggested Block Types</h3>
|
|
897
1007
|
<div className="squisq-template-gallery-grid">
|
|
898
1008
|
{recommendedEntries.map((entry) => (
|
|
899
|
-
<TemplateCard
|
|
1009
|
+
<TemplateCard
|
|
1010
|
+
key={entry.name}
|
|
1011
|
+
entry={entry}
|
|
1012
|
+
value={value}
|
|
1013
|
+
onSelect={onSelect}
|
|
1014
|
+
previewSource={previewSource}
|
|
1015
|
+
/>
|
|
900
1016
|
))}
|
|
901
1017
|
</div>
|
|
902
1018
|
</div>
|
|
903
1019
|
)}
|
|
904
1020
|
|
|
905
|
-
{
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
<
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1021
|
+
{grouped ? (
|
|
1022
|
+
<>
|
|
1023
|
+
{blockTypeEntries.length > 0 && (
|
|
1024
|
+
<div className="squisq-template-gallery-section">
|
|
1025
|
+
<h3 className="squisq-template-gallery-section-title">Block Types</h3>
|
|
1026
|
+
<div className="squisq-template-gallery-grid">
|
|
1027
|
+
{blockTypeEntries.map((entry) => (
|
|
1028
|
+
<TemplateCard
|
|
1029
|
+
key={entry.name}
|
|
1030
|
+
entry={entry}
|
|
1031
|
+
value={value}
|
|
1032
|
+
onSelect={onSelect}
|
|
1033
|
+
previewSource={previewSource}
|
|
1034
|
+
/>
|
|
1035
|
+
))}
|
|
1036
|
+
</div>
|
|
1037
|
+
</div>
|
|
1038
|
+
)}
|
|
1039
|
+
|
|
1040
|
+
{contentNeededEntries.length > 0 && (
|
|
1041
|
+
<div className="squisq-template-gallery-section">
|
|
1042
|
+
<h3 className="squisq-template-gallery-section-title">Block Types for Content</h3>
|
|
1043
|
+
<div className="squisq-template-gallery-grid">
|
|
1044
|
+
{contentNeededEntries.map((entry) => (
|
|
1045
|
+
<TemplateCard
|
|
1046
|
+
key={entry.name}
|
|
1047
|
+
entry={entry}
|
|
1048
|
+
value={value}
|
|
1049
|
+
onSelect={onSelect}
|
|
1050
|
+
previewSource={previewSource}
|
|
1051
|
+
/>
|
|
1052
|
+
))}
|
|
1053
|
+
</div>
|
|
1054
|
+
</div>
|
|
1055
|
+
)}
|
|
1056
|
+
</>
|
|
914
1057
|
) : (
|
|
915
1058
|
<div className="squisq-template-gallery-grid">
|
|
916
|
-
{
|
|
917
|
-
<TemplateCard
|
|
1059
|
+
{blockTypeEntries.map((entry) => (
|
|
1060
|
+
<TemplateCard
|
|
1061
|
+
key={entry.name}
|
|
1062
|
+
entry={entry}
|
|
1063
|
+
value={value}
|
|
1064
|
+
onSelect={onSelect}
|
|
1065
|
+
previewSource={previewSource}
|
|
1066
|
+
/>
|
|
918
1067
|
))}
|
|
919
1068
|
</div>
|
|
920
1069
|
)}
|
|
@@ -957,7 +1106,7 @@ function CustomTemplateCard({
|
|
|
957
1106
|
<div className="squisq-template-gallery-card-body">
|
|
958
1107
|
<span className="squisq-template-gallery-card-name">{def.label}</span>
|
|
959
1108
|
<span className="squisq-template-gallery-card-desc">
|
|
960
|
-
{def.description ?? 'Custom
|
|
1109
|
+
{def.description ?? 'Custom block type'}
|
|
961
1110
|
</span>
|
|
962
1111
|
</div>
|
|
963
1112
|
</button>
|
|
@@ -973,6 +1122,8 @@ export interface TemplateBadgePopoverProps {
|
|
|
973
1122
|
value: string;
|
|
974
1123
|
onChange: (name: string) => void;
|
|
975
1124
|
onClose: () => void;
|
|
1125
|
+
/** Active editor chrome theme, used by the portaled dialog. */
|
|
1126
|
+
colorScheme?: 'light' | 'dark';
|
|
976
1127
|
/** Optional list of template names to surface as "Recommended for this block". */
|
|
977
1128
|
recommended?: readonly string[];
|
|
978
1129
|
/**
|
|
@@ -981,27 +1132,32 @@ export interface TemplateBadgePopoverProps {
|
|
|
981
1132
|
* it closes the popover and opens the designer.
|
|
982
1133
|
*/
|
|
983
1134
|
onOpenDesigner?: () => void;
|
|
1135
|
+
/**
|
|
1136
|
+
* Active block content used to render live template thumbnails. Falls back to
|
|
1137
|
+
* static icons when a candidate cannot be derived.
|
|
1138
|
+
*/
|
|
1139
|
+
previewSource?: TemplatePreviewSource;
|
|
984
1140
|
}
|
|
985
1141
|
|
|
986
1142
|
/**
|
|
987
|
-
* Standalone
|
|
988
|
-
*
|
|
989
|
-
* `.squisq-template-badge` span). Handles its own positioning, outside
|
|
990
|
-
* clicks, Escape, and viewport-edge clamping.
|
|
1143
|
+
* Standalone dialog that mirrors the toolbar `TemplatePicker`'s gallery
|
|
1144
|
+
* when opened from an inline heading badge.
|
|
991
1145
|
*/
|
|
992
1146
|
export function TemplateBadgePopover({
|
|
993
1147
|
anchorRect,
|
|
994
1148
|
value,
|
|
995
1149
|
onChange,
|
|
996
1150
|
onClose,
|
|
1151
|
+
colorScheme = 'light',
|
|
997
1152
|
recommended,
|
|
998
1153
|
onOpenDesigner,
|
|
1154
|
+
previewSource,
|
|
999
1155
|
}: TemplateBadgePopoverProps) {
|
|
1000
|
-
const [style, setStyle] = useState<React.CSSProperties>(() =>
|
|
1156
|
+
const [style, setStyle] = useState<React.CSSProperties>(() => computeDialogStyle(anchorRect));
|
|
1001
1157
|
|
|
1002
|
-
// Reposition once after mount
|
|
1158
|
+
// Reposition once after mount so the dialog covers the current editor shell.
|
|
1003
1159
|
useEffect(() => {
|
|
1004
|
-
requestAnimationFrame(() => setStyle(
|
|
1160
|
+
requestAnimationFrame(() => setStyle(computeDialogStyle(anchorRect)));
|
|
1005
1161
|
}, [anchorRect]);
|
|
1006
1162
|
|
|
1007
1163
|
// Outside click + Escape close
|
|
@@ -1011,8 +1167,8 @@ export function TemplateBadgePopover({
|
|
|
1011
1167
|
};
|
|
1012
1168
|
const onMouse = (e: MouseEvent) => {
|
|
1013
1169
|
const target = e.target as Node;
|
|
1014
|
-
const
|
|
1015
|
-
if (!
|
|
1170
|
+
const inDialog = document.getElementById(TEMPLATE_GALLERY_DIALOG_ID)?.contains(target);
|
|
1171
|
+
if (!inDialog) onClose();
|
|
1016
1172
|
};
|
|
1017
1173
|
// Defer the mousedown listener by one frame so the click that opened
|
|
1018
1174
|
// us doesn't immediately close us.
|
|
@@ -1032,62 +1188,73 @@ export function TemplateBadgePopover({
|
|
|
1032
1188
|
onClose();
|
|
1033
1189
|
};
|
|
1034
1190
|
|
|
1191
|
+
const dialogTitle = templateDialogTitle(previewSource);
|
|
1192
|
+
|
|
1035
1193
|
return createPortal(
|
|
1036
|
-
<
|
|
1037
|
-
|
|
1038
|
-
|
|
1194
|
+
<TemplateGalleryDialog
|
|
1195
|
+
title={dialogTitle}
|
|
1196
|
+
colorScheme={colorScheme}
|
|
1039
1197
|
style={style}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1198
|
+
onClose={onClose}
|
|
1199
|
+
>
|
|
1200
|
+
<TemplateGalleryBody
|
|
1201
|
+
value={value}
|
|
1202
|
+
onSelect={handleSelect}
|
|
1203
|
+
style={{}}
|
|
1204
|
+
recommended={recommended}
|
|
1205
|
+
onOpenDesigner={onOpenDesigner}
|
|
1206
|
+
previewSource={previewSource}
|
|
1207
|
+
/>
|
|
1208
|
+
</TemplateGalleryDialog>,
|
|
1043
1209
|
document.body,
|
|
1044
1210
|
);
|
|
1045
1211
|
}
|
|
1046
1212
|
|
|
1047
|
-
function
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
// `firstElementChild` returns the "(none)" option, which is ~50px tall
|
|
1051
|
-
// and made the "fits below" check incorrectly pass on tall galleries.
|
|
1052
|
-
const popoverEl = document.getElementById('squisq-template-gallery-portal');
|
|
1053
|
-
const popoverRect = popoverEl?.getBoundingClientRect();
|
|
1054
|
-
const popoverWidth = popoverRect?.width ?? 780;
|
|
1055
|
-
const popoverHeight = popoverRect?.height ?? 520;
|
|
1056
|
-
const margin = 8;
|
|
1057
|
-
const gap = 6;
|
|
1058
|
-
const vw = window.innerWidth;
|
|
1059
|
-
const vh = window.innerHeight;
|
|
1213
|
+
function templateDialogTitle(previewSource?: TemplatePreviewSource): string {
|
|
1214
|
+
return `Block type for ${templateDialogBlockTitle(previewSource)}`;
|
|
1215
|
+
}
|
|
1060
1216
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
const
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
left = Math.min(Math.max(margin, rect.left), maxLeft);
|
|
1077
|
-
} else {
|
|
1078
|
-
// Center it.
|
|
1079
|
-
top = Math.max(margin, Math.floor((vh - popoverHeight) / 2));
|
|
1080
|
-
left = Math.max(margin, Math.floor((vw - popoverWidth) / 2));
|
|
1081
|
-
}
|
|
1217
|
+
function templateDialogBlockTitle(previewSource?: TemplatePreviewSource): string {
|
|
1218
|
+
const block = previewSource?.block;
|
|
1219
|
+
if (!block) return 'this block';
|
|
1220
|
+
|
|
1221
|
+
const headingText = block.sourceHeading ? extractPlainText(block.sourceHeading).trim() : '';
|
|
1222
|
+
const title = (headingText || block.title || '').replace(/\s+/g, ' ').trim();
|
|
1223
|
+
return title || 'this block';
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function computeDialogStyle(anchor: Element | DOMRect): React.CSSProperties {
|
|
1227
|
+
const shellRect = findEditorShellRect(anchor);
|
|
1228
|
+
const left = Math.max(0, shellRect.left);
|
|
1229
|
+
const top = Math.max(0, shellRect.top);
|
|
1230
|
+
const right = Math.min(window.innerWidth, shellRect.right);
|
|
1231
|
+
const bottom = Math.min(window.innerHeight, shellRect.bottom);
|
|
1082
1232
|
|
|
1083
1233
|
return {
|
|
1084
1234
|
position: 'fixed',
|
|
1085
1235
|
top,
|
|
1086
1236
|
left,
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
maxHeight: `${vh - 2 * margin}px`,
|
|
1090
|
-
overflowY: 'auto',
|
|
1237
|
+
width: Math.max(0, right - left),
|
|
1238
|
+
height: Math.max(0, bottom - top),
|
|
1091
1239
|
zIndex: 9999,
|
|
1092
1240
|
};
|
|
1093
1241
|
}
|
|
1242
|
+
|
|
1243
|
+
function findEditorShellRect(anchor: Element | DOMRect): DOMRect {
|
|
1244
|
+
if (anchor instanceof Element) {
|
|
1245
|
+
const shell = anchor.closest('.squisq-editor-shell');
|
|
1246
|
+
if (shell) return shell.getBoundingClientRect();
|
|
1247
|
+
} else {
|
|
1248
|
+
const x = anchor.left + anchor.width / 2;
|
|
1249
|
+
const y = anchor.top + anchor.height / 2;
|
|
1250
|
+
for (const element of document.elementsFromPoint(x, y)) {
|
|
1251
|
+
const shell = element.closest('.squisq-editor-shell');
|
|
1252
|
+
if (shell) return shell.getBoundingClientRect();
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const fallback = document.querySelector('.squisq-editor-shell');
|
|
1257
|
+
if (fallback) return fallback.getBoundingClientRect();
|
|
1258
|
+
|
|
1259
|
+
return new DOMRect(0, 0, window.innerWidth, window.innerHeight);
|
|
1260
|
+
}
|