@package-uploader/ui 1.1.0 → 1.1.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.
@@ -0,0 +1,184 @@
1
+ import { useState, useCallback, useMemo } from 'react';
2
+
3
+ /** A group of adjacent blocks wrapped in a container div at runtime */
4
+ export interface BlockGroup {
5
+ id: string;
6
+ lessonId: string;
7
+ blockIds: string[];
8
+ className: string;
9
+ label?: string;
10
+ }
11
+
12
+ interface UseBlockGroupingProps {
13
+ /** All block IDs in the current lesson, in order */
14
+ lessonBlockIds: string[];
15
+ /** Current lesson ID */
16
+ lessonId: string;
17
+ /** Existing groups for this lesson */
18
+ groups: BlockGroup[];
19
+ /** Callback when groups change */
20
+ onGroupsChange: (groups: BlockGroup[]) => void;
21
+ }
22
+
23
+ interface UseBlockGroupingReturn {
24
+ /** Currently selected block IDs (for creating a new group) */
25
+ selectedBlockIds: Set<string>;
26
+ /** Toggle selection of a block */
27
+ toggleBlock: (blockId: string) => void;
28
+ /** Clear selection */
29
+ clearSelection: () => void;
30
+ /** Whether current selection forms a valid group (adjacent, min 2, not already grouped) */
31
+ canCreateGroup: boolean;
32
+ /** Reason why group can't be created (empty if valid) */
33
+ validationError: string;
34
+ /** Create a group from current selection */
35
+ createGroup: (className: string, label?: string) => void;
36
+ /** Remove a group (ungroup) */
37
+ removeGroup: (groupId: string) => void;
38
+ /** Update a group's className or label */
39
+ updateGroup: (groupId: string, updates: { className?: string; label?: string }) => void;
40
+ /** Check if a block is already in a group */
41
+ isBlockGrouped: (blockId: string) => boolean;
42
+ /** Get the group a block belongs to (if any) */
43
+ getBlockGroup: (blockId: string) => BlockGroup | undefined;
44
+ }
45
+
46
+ let nextGroupId = 1;
47
+ function generateGroupId(): string {
48
+ return `bg_${Date.now()}_${nextGroupId++}`;
49
+ }
50
+
51
+ export function useBlockGrouping({
52
+ lessonBlockIds,
53
+ lessonId,
54
+ groups,
55
+ onGroupsChange,
56
+ }: UseBlockGroupingProps): UseBlockGroupingReturn {
57
+ const [selectedBlockIds, setSelectedBlockIds] = useState<Set<string>>(new Set());
58
+
59
+ // Map: blockId -> group it belongs to
60
+ const blockToGroup = useMemo(() => {
61
+ const map = new Map<string, BlockGroup>();
62
+ for (const group of groups) {
63
+ for (const blockId of group.blockIds) {
64
+ map.set(blockId, group);
65
+ }
66
+ }
67
+ return map;
68
+ }, [groups]);
69
+
70
+ const isBlockGrouped = useCallback(
71
+ (blockId: string) => blockToGroup.has(blockId),
72
+ [blockToGroup]
73
+ );
74
+
75
+ const getBlockGroup = useCallback(
76
+ (blockId: string) => blockToGroup.get(blockId),
77
+ [blockToGroup]
78
+ );
79
+
80
+ const toggleBlock = useCallback((blockId: string) => {
81
+ setSelectedBlockIds((prev) => {
82
+ const next = new Set(prev);
83
+ if (next.has(blockId)) {
84
+ next.delete(blockId);
85
+ } else {
86
+ next.add(blockId);
87
+ }
88
+ return next;
89
+ });
90
+ }, []);
91
+
92
+ const clearSelection = useCallback(() => {
93
+ setSelectedBlockIds(new Set());
94
+ }, []);
95
+
96
+ // Validate current selection
97
+ const { canCreateGroup, validationError } = useMemo(() => {
98
+ if (selectedBlockIds.size < 2) {
99
+ return { canCreateGroup: false, validationError: 'Select at least 2 blocks' };
100
+ }
101
+
102
+ // Check none are already grouped
103
+ for (const blockId of selectedBlockIds) {
104
+ if (blockToGroup.has(blockId)) {
105
+ return { canCreateGroup: false, validationError: 'A selected block is already in a group' };
106
+ }
107
+ }
108
+
109
+ // Check adjacency: selected blocks must be contiguous in lessonBlockIds
110
+ const indices = Array.from(selectedBlockIds)
111
+ .map((id) => lessonBlockIds.indexOf(id))
112
+ .filter((i) => i !== -1)
113
+ .sort((a, b) => a - b);
114
+
115
+ if (indices.length !== selectedBlockIds.size) {
116
+ return { canCreateGroup: false, validationError: 'Some selected blocks not found in lesson' };
117
+ }
118
+
119
+ for (let i = 1; i < indices.length; i++) {
120
+ if (indices[i] !== indices[i - 1] + 1) {
121
+ return { canCreateGroup: false, validationError: 'Blocks must be adjacent' };
122
+ }
123
+ }
124
+
125
+ return { canCreateGroup: true, validationError: '' };
126
+ }, [selectedBlockIds, lessonBlockIds, blockToGroup]);
127
+
128
+ const createGroup = useCallback(
129
+ (className: string, label?: string) => {
130
+ if (!canCreateGroup) return;
131
+
132
+ // Order selected blocks by their position in the lesson
133
+ const orderedBlockIds = lessonBlockIds.filter((id) => selectedBlockIds.has(id));
134
+
135
+ const newGroup: BlockGroup = {
136
+ id: generateGroupId(),
137
+ lessonId,
138
+ blockIds: orderedBlockIds,
139
+ className: className.trim(),
140
+ label: label?.trim() || undefined,
141
+ };
142
+
143
+ onGroupsChange([...groups, newGroup]);
144
+ setSelectedBlockIds(new Set());
145
+ },
146
+ [canCreateGroup, lessonBlockIds, selectedBlockIds, lessonId, groups, onGroupsChange]
147
+ );
148
+
149
+ const removeGroup = useCallback(
150
+ (groupId: string) => {
151
+ onGroupsChange(groups.filter((g) => g.id !== groupId));
152
+ },
153
+ [groups, onGroupsChange]
154
+ );
155
+
156
+ const updateGroup = useCallback(
157
+ (groupId: string, updates: { className?: string; label?: string }) => {
158
+ onGroupsChange(
159
+ groups.map((g) => {
160
+ if (g.id !== groupId) return g;
161
+ return {
162
+ ...g,
163
+ ...(updates.className !== undefined && { className: updates.className.trim() }),
164
+ ...(updates.label !== undefined && { label: updates.label.trim() || undefined }),
165
+ };
166
+ })
167
+ );
168
+ },
169
+ [groups, onGroupsChange]
170
+ );
171
+
172
+ return {
173
+ selectedBlockIds,
174
+ toggleBlock,
175
+ clearSelection,
176
+ canCreateGroup,
177
+ validationError,
178
+ createGroup,
179
+ removeGroup,
180
+ updateGroup,
181
+ isBlockGrouped,
182
+ getBlockGroup,
183
+ };
184
+ }
package/src/index.css CHANGED
@@ -1608,3 +1608,385 @@ body {
1608
1608
  font-size: 0.8rem;
1609
1609
  margin-top: 0.25rem;
1610
1610
  }
1611
+
1612
+ /* ========================================
1613
+ COLLAPSIBLE STRUCTURE SECTION
1614
+ ======================================== */
1615
+
1616
+ .structure-section {
1617
+ display: flex;
1618
+ flex-direction: column;
1619
+ }
1620
+
1621
+ .structure-toggle-btn {
1622
+ display: flex;
1623
+ align-items: center;
1624
+ gap: 0.5rem;
1625
+ width: 100%;
1626
+ padding: 0.625rem 0.75rem;
1627
+ background: var(--color-bg);
1628
+ border: 1px solid var(--color-border);
1629
+ border-radius: var(--radius);
1630
+ cursor: pointer;
1631
+ font-size: 0.875rem;
1632
+ font-weight: 500;
1633
+ color: var(--color-text);
1634
+ transition: all 0.2s;
1635
+ }
1636
+
1637
+ .structure-toggle-btn:hover:not(:disabled) {
1638
+ border-color: var(--color-primary);
1639
+ background: rgba(37, 99, 235, 0.03);
1640
+ }
1641
+
1642
+ .structure-toggle-btn.open {
1643
+ border-color: var(--color-primary);
1644
+ border-bottom-left-radius: 0;
1645
+ border-bottom-right-radius: 0;
1646
+ background: rgba(37, 99, 235, 0.05);
1647
+ }
1648
+
1649
+ .structure-toggle-btn.disabled {
1650
+ opacity: 0.5;
1651
+ cursor: not-allowed;
1652
+ color: var(--color-text-muted);
1653
+ }
1654
+
1655
+ .structure-toggle-btn .spinner {
1656
+ width: 16px;
1657
+ height: 16px;
1658
+ flex-shrink: 0;
1659
+ }
1660
+
1661
+ .structure-toggle-arrow {
1662
+ margin-left: auto;
1663
+ font-size: 0.625rem;
1664
+ color: var(--color-text-muted);
1665
+ transition: transform 0.2s;
1666
+ }
1667
+
1668
+ .structure-panel {
1669
+ border: 1px solid var(--color-primary);
1670
+ border-top: none;
1671
+ border-bottom-left-radius: var(--radius);
1672
+ border-bottom-right-radius: var(--radius);
1673
+ padding: 0.75rem;
1674
+ background: var(--color-bg);
1675
+ animation: panelSlideDown 0.15s ease-out;
1676
+ }
1677
+
1678
+ @keyframes panelSlideDown {
1679
+ from {
1680
+ opacity: 0;
1681
+ transform: translateY(-4px);
1682
+ }
1683
+ to {
1684
+ opacity: 1;
1685
+ transform: translateY(0);
1686
+ }
1687
+ }
1688
+
1689
+ /* ========================================
1690
+ BLOCK GROUPING PANEL
1691
+ ======================================== */
1692
+
1693
+ .bgp-toggle-btn {
1694
+ margin-top: 0.5rem;
1695
+ display: inline-flex;
1696
+ align-items: center;
1697
+ gap: 0.375rem;
1698
+ background: var(--color-bg);
1699
+ border: 1px solid var(--color-border);
1700
+ color: var(--color-text-muted);
1701
+ transition: all 0.15s;
1702
+ }
1703
+
1704
+ .bgp-toggle-btn:hover {
1705
+ border-color: var(--color-primary);
1706
+ color: var(--color-primary);
1707
+ }
1708
+
1709
+ .bgp-toggle-btn.active {
1710
+ border-color: var(--color-primary);
1711
+ color: var(--color-primary);
1712
+ background: rgba(37, 99, 235, 0.05);
1713
+ }
1714
+
1715
+ .bgp-toggle-count {
1716
+ background: var(--color-primary);
1717
+ color: white;
1718
+ font-size: 0.625rem;
1719
+ padding: 0.0625rem 0.375rem;
1720
+ border-radius: 999px;
1721
+ font-weight: 600;
1722
+ }
1723
+
1724
+ .block-grouping-panel {
1725
+ margin-top: 0.5rem;
1726
+ border: 1px solid var(--color-border);
1727
+ border-radius: var(--radius);
1728
+ background: var(--color-bg);
1729
+ overflow: hidden;
1730
+ }
1731
+
1732
+ .bgp-header {
1733
+ display: flex;
1734
+ align-items: center;
1735
+ gap: 0.5rem;
1736
+ padding: 0.5rem 0.75rem;
1737
+ background: var(--color-bg-secondary);
1738
+ border-bottom: 1px solid var(--color-border);
1739
+ }
1740
+
1741
+ .bgp-title {
1742
+ font-size: 0.8rem;
1743
+ font-weight: 600;
1744
+ color: var(--color-text);
1745
+ }
1746
+
1747
+ .bgp-count {
1748
+ font-size: 0.7rem;
1749
+ color: var(--color-primary);
1750
+ background: rgba(37, 99, 235, 0.1);
1751
+ padding: 0.0625rem 0.375rem;
1752
+ border-radius: 999px;
1753
+ }
1754
+
1755
+ /* Schematic preview */
1756
+ .bgp-preview {
1757
+ padding: 0.5rem;
1758
+ display: flex;
1759
+ flex-direction: column;
1760
+ gap: 0.25rem;
1761
+ max-height: 300px;
1762
+ overflow-y: auto;
1763
+ }
1764
+
1765
+ .bgp-block {
1766
+ display: flex;
1767
+ align-items: center;
1768
+ gap: 0.375rem;
1769
+ padding: 0.375rem 0.5rem;
1770
+ border-radius: var(--radius-sm);
1771
+ cursor: pointer;
1772
+ transition: all 0.15s;
1773
+ border: 1px solid transparent;
1774
+ font-size: 0.75rem;
1775
+ }
1776
+
1777
+ .bgp-block:hover:not(.grouped) {
1778
+ background: var(--color-bg-secondary);
1779
+ border-color: var(--color-border);
1780
+ }
1781
+
1782
+ .bgp-block.selected {
1783
+ background: rgba(37, 99, 235, 0.08);
1784
+ border-color: var(--color-primary);
1785
+ }
1786
+
1787
+ .bgp-block.grouped {
1788
+ cursor: default;
1789
+ opacity: 0.7;
1790
+ }
1791
+
1792
+ .bgp-block-color {
1793
+ width: 4px;
1794
+ height: 1.25rem;
1795
+ border-radius: 2px;
1796
+ background: var(--block-color, #6b7280);
1797
+ flex-shrink: 0;
1798
+ }
1799
+
1800
+ .bgp-block-label {
1801
+ font-weight: 500;
1802
+ color: var(--color-text);
1803
+ white-space: nowrap;
1804
+ }
1805
+
1806
+ .bgp-block-title {
1807
+ color: var(--color-text-muted);
1808
+ overflow: hidden;
1809
+ text-overflow: ellipsis;
1810
+ white-space: nowrap;
1811
+ flex: 1;
1812
+ min-width: 0;
1813
+ }
1814
+
1815
+ .bgp-block-check {
1816
+ color: var(--color-primary);
1817
+ font-weight: 600;
1818
+ flex-shrink: 0;
1819
+ margin-left: auto;
1820
+ }
1821
+
1822
+ /* Group wrapper in preview */
1823
+ .bgp-group-wrapper {
1824
+ border: 2px dashed var(--color-primary);
1825
+ border-radius: var(--radius);
1826
+ padding: 0.25rem;
1827
+ margin: 0.25rem 0;
1828
+ background: rgba(37, 99, 235, 0.02);
1829
+ }
1830
+
1831
+ .bgp-group-header {
1832
+ display: flex;
1833
+ align-items: center;
1834
+ gap: 0.5rem;
1835
+ padding: 0.25rem 0.5rem;
1836
+ font-size: 0.7rem;
1837
+ border-bottom: 1px solid rgba(37, 99, 235, 0.15);
1838
+ margin-bottom: 0.25rem;
1839
+ }
1840
+
1841
+ .bgp-group-label {
1842
+ font-weight: 600;
1843
+ color: var(--color-primary);
1844
+ }
1845
+
1846
+ .bgp-group-class {
1847
+ color: var(--color-text-muted);
1848
+ font-family: monospace;
1849
+ font-size: 0.65rem;
1850
+ }
1851
+
1852
+ .bgp-btn {
1853
+ background: none;
1854
+ border: none;
1855
+ font-size: 0.65rem;
1856
+ cursor: pointer;
1857
+ padding: 0.125rem 0.375rem;
1858
+ border-radius: var(--radius-sm);
1859
+ transition: all 0.15s;
1860
+ }
1861
+
1862
+ .bgp-btn-edit {
1863
+ color: var(--color-text-muted);
1864
+ margin-left: auto;
1865
+ }
1866
+
1867
+ .bgp-btn-edit:hover {
1868
+ color: var(--color-primary);
1869
+ background: rgba(37, 99, 235, 0.1);
1870
+ }
1871
+
1872
+ .bgp-btn-ungroup {
1873
+ color: var(--color-text-muted);
1874
+ }
1875
+
1876
+ .bgp-btn-ungroup:hover {
1877
+ color: var(--color-error);
1878
+ background: rgba(239, 68, 68, 0.1);
1879
+ }
1880
+
1881
+ .bgp-btn-save {
1882
+ color: var(--color-success);
1883
+ }
1884
+
1885
+ .bgp-btn-save:hover {
1886
+ background: rgba(16, 185, 129, 0.1);
1887
+ }
1888
+
1889
+ .bgp-btn-cancel {
1890
+ color: var(--color-text-muted);
1891
+ }
1892
+
1893
+ .bgp-btn-cancel:hover {
1894
+ background: var(--color-bg-secondary);
1895
+ }
1896
+
1897
+ /* Group edit inline form */
1898
+ .bgp-group-edit {
1899
+ display: flex;
1900
+ align-items: center;
1901
+ gap: 0.375rem;
1902
+ flex: 1;
1903
+ }
1904
+
1905
+ .bgp-edit-input {
1906
+ padding: 0.1875rem 0.375rem;
1907
+ font-size: 0.7rem;
1908
+ border: 1px solid var(--color-border);
1909
+ border-radius: var(--radius-sm);
1910
+ background: var(--color-bg);
1911
+ color: var(--color-text);
1912
+ flex: 1;
1913
+ }
1914
+
1915
+ .bgp-edit-input:focus {
1916
+ outline: none;
1917
+ border-color: var(--color-primary);
1918
+ }
1919
+
1920
+ .bgp-edit-input-sm {
1921
+ max-width: 120px;
1922
+ }
1923
+
1924
+ /* Create group controls */
1925
+ .bgp-create {
1926
+ padding: 0.5rem 0.75rem;
1927
+ border-top: 1px solid var(--color-border);
1928
+ background: var(--color-bg-secondary);
1929
+ display: flex;
1930
+ flex-direction: column;
1931
+ gap: 0.375rem;
1932
+ }
1933
+
1934
+ .bgp-create-info {
1935
+ font-size: 0.75rem;
1936
+ font-weight: 500;
1937
+ color: var(--color-text);
1938
+ }
1939
+
1940
+ .bgp-validation-error {
1941
+ color: var(--color-error);
1942
+ font-weight: 400;
1943
+ }
1944
+
1945
+ .bgp-create-inputs {
1946
+ display: flex;
1947
+ gap: 0.375rem;
1948
+ }
1949
+
1950
+ .bgp-create-class {
1951
+ flex: 2;
1952
+ padding: 0.25rem 0.5rem;
1953
+ font-size: 0.8rem;
1954
+ border: 1px solid var(--color-border);
1955
+ border-radius: var(--radius-sm);
1956
+ background: var(--color-bg);
1957
+ color: var(--color-text);
1958
+ }
1959
+
1960
+ .bgp-create-class:focus {
1961
+ outline: none;
1962
+ border-color: var(--color-primary);
1963
+ }
1964
+
1965
+ .bgp-create-label {
1966
+ flex: 1;
1967
+ padding: 0.25rem 0.5rem;
1968
+ font-size: 0.8rem;
1969
+ border: 1px solid var(--color-border);
1970
+ border-radius: var(--radius-sm);
1971
+ background: var(--color-bg);
1972
+ color: var(--color-text);
1973
+ }
1974
+
1975
+ .bgp-create-label:focus {
1976
+ outline: none;
1977
+ border-color: var(--color-primary);
1978
+ }
1979
+
1980
+ .bgp-create-actions {
1981
+ display: flex;
1982
+ gap: 0.375rem;
1983
+ }
1984
+
1985
+ /* Hint when empty */
1986
+ .bgp-hint {
1987
+ padding: 0.75rem;
1988
+ font-size: 0.75rem;
1989
+ color: var(--color-text-muted);
1990
+ text-align: center;
1991
+ font-style: italic;
1992
+ }