@1agh/maude 0.15.0 → 0.17.1
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/README.md +4 -2
- package/cli/commands/design.mjs +108 -2
- package/package.json +12 -18
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +8 -8
- package/plugins/design/dev-server/annotations-layer.tsx +8 -10
- package/plugins/design/dev-server/api.ts +227 -3
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +40 -0
- package/plugins/design/dev-server/bin/_html-playwright.mjs +129 -0
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +105 -0
- package/plugins/design/dev-server/bin/_png-playwright.mjs +143 -0
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +98 -0
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +141 -0
- package/plugins/design/dev-server/canvas-lib.tsx +12 -13
- package/plugins/design/dev-server/canvas-shell.tsx +111 -9
- package/plugins/design/dev-server/client/app.jsx +71 -143
- package/plugins/design/dev-server/client/comments-overlay.css +381 -0
- package/plugins/design/dev-server/client/styles/3-shell.css +1 -10
- package/plugins/design/dev-server/client/styles/4-components.css +5 -161
- package/plugins/design/dev-server/client/styles.css +5 -160
- package/plugins/design/dev-server/comments-overlay.tsx +1156 -0
- package/plugins/design/dev-server/context-menu.tsx +36 -9
- package/plugins/design/dev-server/dist/client.bundle.js +52 -211
- package/plugins/design/dev-server/dist/styles.css +1 -218
- package/plugins/design/dev-server/export-dialog.tsx +401 -0
- package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
- package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
- package/plugins/design/dev-server/exporters/canva.ts +126 -0
- package/plugins/design/dev-server/exporters/html.ts +103 -0
- package/plugins/design/dev-server/exporters/index.ts +135 -0
- package/plugins/design/dev-server/exporters/pdf.ts +109 -0
- package/plugins/design/dev-server/exporters/png.ts +136 -0
- package/plugins/design/dev-server/exporters/pptx.ts +263 -0
- package/plugins/design/dev-server/exporters/scope.ts +196 -0
- package/plugins/design/dev-server/exporters/svg.ts +122 -0
- package/plugins/design/dev-server/exporters/zip.ts +109 -0
- package/plugins/design/dev-server/http.ts +109 -0
- package/plugins/design/dev-server/input-router.tsx +21 -0
- package/plugins/design/dev-server/inspect.ts +1 -1
- package/plugins/design/dev-server/server.mjs +1 -1
- package/plugins/design/dev-server/test/canvas-meta-api.test.ts +0 -10
- package/plugins/design/dev-server/test/comments-api.test.ts +229 -0
- package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
- package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
- package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
- package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
- package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
- package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
- package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
- package/plugins/design/dev-server/tool-palette.tsx +34 -16
- package/plugins/design/templates/_shell.html +33 -0
|
@@ -1163,7 +1163,7 @@
|
|
|
1163
1163
|
overflow: hidden;
|
|
1164
1164
|
}
|
|
1165
1165
|
|
|
1166
|
-
.sb-clear-sel
|
|
1166
|
+
.sb-clear-sel {
|
|
1167
1167
|
color: var(--u-fg-2);
|
|
1168
1168
|
border: 1px solid var(--u-border);
|
|
1169
1169
|
border-radius: var(--u-r-xs);
|
|
@@ -1177,22 +1177,12 @@
|
|
|
1177
1177
|
line-height: 1.4;
|
|
1178
1178
|
}
|
|
1179
1179
|
|
|
1180
|
-
.sb-add-comment {
|
|
1181
|
-
color: var(--u-accent);
|
|
1182
|
-
border-color: var(--u-accent);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
1180
|
.sb-clear-sel:hover {
|
|
1186
1181
|
color: var(--u-fg-0);
|
|
1187
1182
|
background: var(--u-bg-2);
|
|
1188
1183
|
border-color: var(--u-border-strong);
|
|
1189
1184
|
}
|
|
1190
1185
|
|
|
1191
|
-
.sb-add-comment:hover {
|
|
1192
|
-
background: var(--u-accent);
|
|
1193
|
-
color: var(--u-accent-fg);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
1186
|
.sb-unread .sb-count {
|
|
1197
1187
|
font-family: var(--u-font-mono);
|
|
1198
1188
|
color: var(--u-fg-0);
|
|
@@ -1866,74 +1856,6 @@
|
|
|
1866
1856
|
display: flex;
|
|
1867
1857
|
}
|
|
1868
1858
|
|
|
1869
|
-
.composer {
|
|
1870
|
-
gap: var(--u-s-2);
|
|
1871
|
-
padding: var(--u-s-3);
|
|
1872
|
-
background: var(--u-bg-2);
|
|
1873
|
-
border: 1px solid var(--u-accent-line);
|
|
1874
|
-
border-radius: var(--u-r-md);
|
|
1875
|
-
flex-direction: column;
|
|
1876
|
-
margin: 4px 0;
|
|
1877
|
-
display: flex;
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
.composer-head {
|
|
1881
|
-
align-items: baseline;
|
|
1882
|
-
gap: var(--u-s-2);
|
|
1883
|
-
color: var(--u-fg-3);
|
|
1884
|
-
font-size: 11px;
|
|
1885
|
-
display: flex;
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
.composer-selector {
|
|
1889
|
-
font-family: var(--u-font-mono);
|
|
1890
|
-
color: var(--u-fg-1);
|
|
1891
|
-
background: var(--u-bg-1);
|
|
1892
|
-
border-radius: var(--u-r-sm);
|
|
1893
|
-
border: 1px solid var(--u-border);
|
|
1894
|
-
text-overflow: ellipsis;
|
|
1895
|
-
white-space: nowrap;
|
|
1896
|
-
flex: 1;
|
|
1897
|
-
min-width: 0;
|
|
1898
|
-
padding: 2px 8px;
|
|
1899
|
-
font-size: 11px;
|
|
1900
|
-
overflow: hidden;
|
|
1901
|
-
}
|
|
1902
|
-
|
|
1903
|
-
.comment-bar textarea.composer-textarea {
|
|
1904
|
-
background: var(--u-bg-1);
|
|
1905
|
-
border: 1px solid var(--u-border);
|
|
1906
|
-
border-radius: var(--u-r-sm);
|
|
1907
|
-
width: 100%;
|
|
1908
|
-
color: var(--u-fg-0);
|
|
1909
|
-
padding: var(--u-s-3);
|
|
1910
|
-
resize: vertical;
|
|
1911
|
-
outline: none;
|
|
1912
|
-
min-height: 100px;
|
|
1913
|
-
font-family: inherit;
|
|
1914
|
-
font-size: 13px;
|
|
1915
|
-
line-height: 1.5;
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
.comment-bar textarea.composer-textarea:focus {
|
|
1919
|
-
border-color: var(--u-accent-line);
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
.comment-bar textarea.composer-textarea::placeholder {
|
|
1923
|
-
color: var(--u-fg-3);
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
.composer-actions {
|
|
1927
|
-
justify-content: flex-end;
|
|
1928
|
-
gap: 6px;
|
|
1929
|
-
display: flex;
|
|
1930
|
-
}
|
|
1931
|
-
|
|
1932
|
-
.cb-row.composer {
|
|
1933
|
-
flex-wrap: nowrap;
|
|
1934
|
-
align-items: stretch;
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
1859
|
.cb-label {
|
|
1938
1860
|
color: var(--u-fg-3);
|
|
1939
1861
|
flex: none;
|
|
@@ -1941,150 +1863,11 @@
|
|
|
1941
1863
|
font-size: 11px;
|
|
1942
1864
|
}
|
|
1943
1865
|
|
|
1944
|
-
.cb-label code {
|
|
1945
|
-
font-family: var(--u-font-mono);
|
|
1946
|
-
background: var(--u-bg-2);
|
|
1947
|
-
border-radius: var(--u-r-xs);
|
|
1948
|
-
color: var(--u-fg-1);
|
|
1949
|
-
padding: 1px 4px;
|
|
1950
|
-
font-size: 10px;
|
|
1951
|
-
}
|
|
1952
|
-
|
|
1953
|
-
.comment-bar textarea {
|
|
1954
|
-
background: var(--u-bg-2);
|
|
1955
|
-
border: 1px solid var(--u-border);
|
|
1956
|
-
border-radius: var(--u-r-sm);
|
|
1957
|
-
color: var(--u-fg-0);
|
|
1958
|
-
padding: var(--u-s-2);
|
|
1959
|
-
resize: vertical;
|
|
1960
|
-
outline: none;
|
|
1961
|
-
flex: 1;
|
|
1962
|
-
min-height: 40px;
|
|
1963
|
-
font-family: inherit;
|
|
1964
|
-
font-size: 12px;
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
.comment-bar textarea:focus {
|
|
1968
|
-
border-color: var(--u-accent-line);
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
.cb-actions {
|
|
1972
|
-
align-self: flex-end;
|
|
1973
|
-
gap: 4px;
|
|
1974
|
-
display: flex;
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1977
|
-
.cb-primary, .cb-secondary {
|
|
1978
|
-
border: 1px solid var(--u-border);
|
|
1979
|
-
background: var(--u-bg-2);
|
|
1980
|
-
color: var(--u-fg-1);
|
|
1981
|
-
border-radius: var(--u-r-sm);
|
|
1982
|
-
padding: 4px var(--u-s-3);
|
|
1983
|
-
font-family: var(--u-font-mono);
|
|
1984
|
-
cursor: pointer;
|
|
1985
|
-
font-size: 11px;
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
.cb-primary {
|
|
1989
|
-
background: var(--u-accent);
|
|
1990
|
-
color: var(--u-bg-0);
|
|
1991
|
-
border-color: var(--u-accent);
|
|
1992
|
-
font-weight: 600;
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
.cb-primary:hover {
|
|
1996
|
-
background: var(--u-accent-strong);
|
|
1997
|
-
border-color: var(--u-accent-strong);
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
.cb-primary:disabled {
|
|
2001
|
-
opacity: .5;
|
|
2002
|
-
cursor: not-allowed;
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
.cb-secondary:hover {
|
|
2006
|
-
background: var(--u-bg-3);
|
|
2007
|
-
color: var(--u-fg-0);
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
.cb-row.focused {
|
|
2011
|
-
background: var(--u-bg-2);
|
|
2012
|
-
border-radius: var(--u-r-sm);
|
|
2013
|
-
padding: 4px var(--u-s-2);
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
.cb-pinno {
|
|
2017
|
-
font-family: var(--u-font-mono);
|
|
2018
|
-
background: var(--u-accent);
|
|
2019
|
-
color: var(--u-accent-fg);
|
|
2020
|
-
border-radius: var(--u-r-sm);
|
|
2021
|
-
letter-spacing: var(--tracking-sku);
|
|
2022
|
-
flex: none;
|
|
2023
|
-
padding: 1px 6px;
|
|
2024
|
-
font-size: 11px;
|
|
2025
|
-
font-weight: 700;
|
|
2026
|
-
}
|
|
2027
|
-
|
|
2028
|
-
.cb-text {
|
|
2029
|
-
color: var(--u-fg-0);
|
|
2030
|
-
text-overflow: ellipsis;
|
|
2031
|
-
white-space: nowrap;
|
|
2032
|
-
flex: 1;
|
|
2033
|
-
font-size: 12px;
|
|
2034
|
-
overflow: hidden;
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
.cb-target code {
|
|
2038
|
-
font-family: var(--u-font-mono);
|
|
2039
|
-
background: var(--u-bg-3);
|
|
2040
|
-
border-radius: var(--u-r-xs);
|
|
2041
|
-
color: var(--u-fg-2);
|
|
2042
|
-
text-overflow: ellipsis;
|
|
2043
|
-
white-space: nowrap;
|
|
2044
|
-
max-width: 240px;
|
|
2045
|
-
padding: 1px 6px;
|
|
2046
|
-
font-size: 10px;
|
|
2047
|
-
display: inline-block;
|
|
2048
|
-
overflow: hidden;
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
1866
|
.cb-row.strip {
|
|
2052
1867
|
color: var(--u-fg-3);
|
|
2053
1868
|
font-size: 11px;
|
|
2054
1869
|
}
|
|
2055
1870
|
|
|
2056
|
-
.cb-pin-strip {
|
|
2057
|
-
flex-wrap: wrap;
|
|
2058
|
-
flex: 1;
|
|
2059
|
-
gap: 4px;
|
|
2060
|
-
display: flex;
|
|
2061
|
-
}
|
|
2062
|
-
|
|
2063
|
-
.cb-pin-chip {
|
|
2064
|
-
font-family: var(--u-font-mono);
|
|
2065
|
-
background: var(--u-accent);
|
|
2066
|
-
color: var(--u-accent-fg);
|
|
2067
|
-
border-radius: var(--u-r-sm);
|
|
2068
|
-
cursor: pointer;
|
|
2069
|
-
letter-spacing: var(--tracking-sku);
|
|
2070
|
-
border: 0;
|
|
2071
|
-
padding: 2px 7px;
|
|
2072
|
-
font-size: 10px;
|
|
2073
|
-
font-weight: 600;
|
|
2074
|
-
transition: transform .1s;
|
|
2075
|
-
}
|
|
2076
|
-
|
|
2077
|
-
.cb-pin-chip:hover {
|
|
2078
|
-
transform: scale(1.1);
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
.cb-more {
|
|
2082
|
-
font-family: var(--u-font-mono);
|
|
2083
|
-
color: var(--u-fg-3);
|
|
2084
|
-
align-self: center;
|
|
2085
|
-
font-size: 10px;
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
1871
|
.rsidebar {
|
|
2089
1872
|
background: var(--u-bg-1);
|
|
2090
1873
|
border-left: 1px solid var(--u-border);
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file export-dialog.tsx — Phase 6.5 T8 export dialog.
|
|
3
|
+
* @scope plugins/design/dev-server/export-dialog.tsx
|
|
4
|
+
* @purpose Native `<dialog>`-based export modal. Three controls — format,
|
|
5
|
+
* scope, per-format options — plus a Recent tab populated by
|
|
6
|
+
* `/_api/export-history`. Submit fires `POST /_api/export`, the
|
|
7
|
+
* response is piped to a Blob URL anchor download. `⌘E` opens
|
|
8
|
+
* the dialog from anywhere inside the canvas; `⌘⇧E` re-runs the
|
|
9
|
+
* most recent export without opening (T10 fast path).
|
|
10
|
+
*
|
|
11
|
+
* Mounts inside the canvas runtime alongside tool-palette and
|
|
12
|
+
* context-menu; consumer wraps the canvas with
|
|
13
|
+
* `<ExportDialogProvider>` to make `useExportDialog()` available.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type ReactNode,
|
|
18
|
+
createContext,
|
|
19
|
+
useCallback,
|
|
20
|
+
useContext,
|
|
21
|
+
useEffect,
|
|
22
|
+
useMemo,
|
|
23
|
+
useRef,
|
|
24
|
+
useState,
|
|
25
|
+
} from 'react';
|
|
26
|
+
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// Types
|
|
29
|
+
|
|
30
|
+
export type Format = 'png' | 'pdf' | 'svg' | 'html' | 'pptx' | 'canva' | 'zip';
|
|
31
|
+
export type Scope = 'selection' | 'artboard' | 'canvas-as-separate' | 'project-raw';
|
|
32
|
+
|
|
33
|
+
const FORMAT_META: Record<Format, { label: string; description: string; defaultExt: string }> = {
|
|
34
|
+
png: { label: 'PNG', description: 'Raster image, one per artboard.', defaultExt: '.png' },
|
|
35
|
+
pdf: { label: 'PDF', description: 'Multi-page PDF, one page per artboard.', defaultExt: '.pdf' },
|
|
36
|
+
svg: {
|
|
37
|
+
label: 'SVG',
|
|
38
|
+
description: 'Vector wrapper over rendered HTML. Editable in Illustrator.',
|
|
39
|
+
defaultExt: '.svg',
|
|
40
|
+
},
|
|
41
|
+
html: {
|
|
42
|
+
label: 'HTML',
|
|
43
|
+
description: 'Standalone runnable bundle. Drop into a static host.',
|
|
44
|
+
defaultExt: '.zip',
|
|
45
|
+
},
|
|
46
|
+
pptx: {
|
|
47
|
+
label: 'PPTX',
|
|
48
|
+
description: 'Editable PowerPoint. Opens in Keynote, Google Slides.',
|
|
49
|
+
defaultExt: '.pptx',
|
|
50
|
+
},
|
|
51
|
+
canva: {
|
|
52
|
+
label: 'Canva',
|
|
53
|
+
description: 'PPTX + handoff prompt. Drag into Canva or feed to your Canva MCP.',
|
|
54
|
+
defaultExt: '.zip',
|
|
55
|
+
},
|
|
56
|
+
zip: {
|
|
57
|
+
label: 'ZIP (source)',
|
|
58
|
+
description: 'Entire .design/ as raw source files. No renders.',
|
|
59
|
+
defaultExt: '.zip',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const SCOPE_META: Record<Scope, { label: string; description: string }> = {
|
|
64
|
+
selection: { label: 'Selection', description: 'Just the currently-selected element.' },
|
|
65
|
+
artboard: { label: 'Artboard', description: 'The single artboard containing the selection.' },
|
|
66
|
+
'canvas-as-separate': {
|
|
67
|
+
label: 'Canvas → separate',
|
|
68
|
+
description: 'Every artboard on the active canvas as N files.',
|
|
69
|
+
},
|
|
70
|
+
'project-raw': {
|
|
71
|
+
label: 'Project (raw)',
|
|
72
|
+
description: 'The entire `.design/` tree, minus runtime files.',
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const VALID_SCOPES_PER_FORMAT: Record<Format, Scope[]> = {
|
|
77
|
+
png: ['selection', 'artboard', 'canvas-as-separate'],
|
|
78
|
+
pdf: ['selection', 'artboard', 'canvas-as-separate'],
|
|
79
|
+
svg: ['selection', 'artboard', 'canvas-as-separate'],
|
|
80
|
+
html: ['artboard', 'canvas-as-separate'],
|
|
81
|
+
pptx: ['canvas-as-separate'],
|
|
82
|
+
canva: ['canvas-as-separate'],
|
|
83
|
+
zip: ['project-raw'],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export interface ExportHistoryEntry {
|
|
87
|
+
format: Format;
|
|
88
|
+
scope: Scope;
|
|
89
|
+
options?: Record<string, unknown>;
|
|
90
|
+
filename: string;
|
|
91
|
+
at: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface OpenOptions {
|
|
95
|
+
/** Pre-fills the scope dropdown (e.g. from context-menu "Export this artboard"). */
|
|
96
|
+
scope?: Scope;
|
|
97
|
+
/** Pre-fills format. */
|
|
98
|
+
format?: Format;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface ExportDialogValue {
|
|
102
|
+
open(opts?: OpenOptions): void;
|
|
103
|
+
close(): void;
|
|
104
|
+
/** Re-run the most recent export without opening the dialog. */
|
|
105
|
+
rerunLast(): Promise<void>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ExportDialogContext = createContext<ExportDialogValue | null>(null);
|
|
109
|
+
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// CSS — visual language mirrors tool-palette + context-menu (8 px radius,
|
|
112
|
+
// hairline border, soft shadow). Scoped to .dc-export-dialog.
|
|
113
|
+
|
|
114
|
+
const DIALOG_CSS = `
|
|
115
|
+
.dc-export-dialog {
|
|
116
|
+
border: 1px solid var(--u-fg-0, #1c1917);
|
|
117
|
+
padding: 0;
|
|
118
|
+
border-radius: 0;
|
|
119
|
+
background: var(--u-bg-2, var(--bg-1, #fff));
|
|
120
|
+
box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
|
|
121
|
+
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
122
|
+
color: var(--u-fg-0, var(--fg-0, #1a1a1a));
|
|
123
|
+
width: min(640px, 100vw - 48px);
|
|
124
|
+
max-height: min(560px, 100vh - 48px);
|
|
125
|
+
overflow: hidden;
|
|
126
|
+
}
|
|
127
|
+
.dc-export-dialog::backdrop { background: rgba(20, 20, 30, 0.32); }
|
|
128
|
+
.dc-export-dialog header { padding: 16px 20px; border-bottom: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); display: flex; justify-content: space-between; align-items: center; }
|
|
129
|
+
.dc-export-dialog header h2 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.005em; }
|
|
130
|
+
.dc-export-dialog header .dc-ed-close { background: transparent; border: 0; cursor: pointer; padding: 4px 8px; color: var(--fg-1, rgba(40,30,20,0.6)); font: inherit; font-size: 12px; }
|
|
131
|
+
.dc-export-dialog .dc-ed-body { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 16px 20px; }
|
|
132
|
+
.dc-export-dialog label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-2, rgba(40,30,20,0.5)); margin-bottom: 6px; }
|
|
133
|
+
.dc-export-dialog select { width: 100%; padding: 8px 10px; border-radius: 0; border: 1px solid var(--u-fg-0, rgba(0,0,0,0.12)); background: var(--u-bg-1, var(--bg-0, #fafafa)); font: inherit; font-size: 13px; color: inherit; }
|
|
134
|
+
.dc-export-dialog .dc-ed-desc { font-size: 12px; color: var(--fg-1, rgba(40,30,20,0.65)); margin-top: 6px; line-height: 1.4; }
|
|
135
|
+
.dc-export-dialog .dc-ed-recent { padding: 12px 20px; border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); background: var(--bg-2, rgba(0,0,0,0.02)); }
|
|
136
|
+
.dc-export-dialog .dc-ed-recent h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-2, rgba(40,30,20,0.5)); margin: 0 0 8px; }
|
|
137
|
+
.dc-export-dialog .dc-ed-recent ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; max-height: 120px; overflow-y: auto; }
|
|
138
|
+
.dc-export-dialog .dc-ed-recent button { display: flex; justify-content: space-between; gap: 12px; padding: 5px 12px; background: transparent; border: 1px solid transparent; border-radius: 0; cursor: pointer; font: inherit; font-size: 12px; color: inherit; width: 100%; text-align: left; }
|
|
139
|
+
.dc-export-dialog .dc-ed-recent button:hover { background: rgba(0,0,0,0.04); border-color: rgba(0,0,0,0.06); }
|
|
140
|
+
.dc-export-dialog footer { padding: 12px 20px; border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); display: flex; justify-content: flex-end; gap: 8px; }
|
|
141
|
+
.dc-export-dialog footer button { padding: 8px 14px; border-radius: 0; border: 1px solid var(--u-fg-0, rgba(0,0,0,0.12)); background: var(--u-bg-1, var(--bg-0, #fafafa)); font: inherit; font-size: 12px; cursor: pointer; color: inherit; }
|
|
142
|
+
.dc-export-dialog footer button.dc-ed-primary { background: var(--accent, #1a1a1a); color: var(--accent-fg, #fff); border-color: transparent; }
|
|
143
|
+
.dc-export-dialog footer button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
144
|
+
.dc-export-dialog .dc-ed-status { padding: 8px 20px; font-size: 12px; color: var(--fg-1, rgba(40,30,20,0.65)); border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); }
|
|
145
|
+
.dc-export-dialog .dc-ed-status.is-error { color: var(--status-error, #c0392b); }
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
// Provider
|
|
150
|
+
|
|
151
|
+
export function ExportDialogProvider({ children }: { children: ReactNode }): ReactNode {
|
|
152
|
+
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
|
153
|
+
const [openState, setOpenState] = useState<OpenOptions | null>(null);
|
|
154
|
+
const [history, setHistory] = useState<ExportHistoryEntry[]>([]);
|
|
155
|
+
const [submitting, setSubmitting] = useState(false);
|
|
156
|
+
const [status, setStatus] = useState<{ text: string; isError: boolean } | null>(null);
|
|
157
|
+
|
|
158
|
+
const open = useCallback((opts?: OpenOptions) => {
|
|
159
|
+
setStatus(null);
|
|
160
|
+
setOpenState(opts ?? {});
|
|
161
|
+
}, []);
|
|
162
|
+
const close = useCallback(() => {
|
|
163
|
+
setOpenState(null);
|
|
164
|
+
dialogRef.current?.close();
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
// Pre-load history when the dialog opens; refresh after each export.
|
|
168
|
+
const loadHistory = useCallback(async () => {
|
|
169
|
+
try {
|
|
170
|
+
const r = await fetch('/_api/export-history');
|
|
171
|
+
if (!r.ok) return;
|
|
172
|
+
const data = (await r.json()) as { history: ExportHistoryEntry[] };
|
|
173
|
+
setHistory(Array.isArray(data.history) ? data.history : []);
|
|
174
|
+
} catch {
|
|
175
|
+
/* ignore — history is best-effort */
|
|
176
|
+
}
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!openState) return;
|
|
181
|
+
void loadHistory();
|
|
182
|
+
dialogRef.current?.showModal();
|
|
183
|
+
}, [openState, loadHistory]);
|
|
184
|
+
|
|
185
|
+
// ⌘E / Ctrl+E to open; ⌘⇧E / Ctrl+Shift+E to re-run last.
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
function onKey(e: KeyboardEvent) {
|
|
188
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
189
|
+
if (!mod || e.key.toLowerCase() !== 'e') return;
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
if (e.shiftKey) void rerunLast();
|
|
192
|
+
else open();
|
|
193
|
+
}
|
|
194
|
+
window.addEventListener('keydown', onKey);
|
|
195
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
196
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
197
|
+
}, [open]);
|
|
198
|
+
|
|
199
|
+
// Phase 6.5 T9 — context-menu entries dispatch `maude:open-export` so they
|
|
200
|
+
// don't have to prop-drill the dialog handle through every consumer.
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
function onCustom(e: Event) {
|
|
203
|
+
const detail = (e as CustomEvent<{ scope?: Scope; format?: Format }>).detail ?? {};
|
|
204
|
+
open(detail);
|
|
205
|
+
}
|
|
206
|
+
window.addEventListener('maude:open-export', onCustom as EventListener);
|
|
207
|
+
return () => window.removeEventListener('maude:open-export', onCustom as EventListener);
|
|
208
|
+
}, [open]);
|
|
209
|
+
|
|
210
|
+
// ─── submit handlers ─────────────────────────────────────────────────────
|
|
211
|
+
const submit = useCallback(
|
|
212
|
+
async (format: Format, scope: Scope, options: Record<string, unknown>) => {
|
|
213
|
+
setSubmitting(true);
|
|
214
|
+
setStatus(null);
|
|
215
|
+
try {
|
|
216
|
+
const r = await fetch('/_api/export', {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: { 'content-type': 'application/json' },
|
|
219
|
+
body: JSON.stringify({ format, scope, options }),
|
|
220
|
+
});
|
|
221
|
+
if (!r.ok) {
|
|
222
|
+
const text = await r.text();
|
|
223
|
+
setStatus({ text: `Export failed: ${text || r.status}`, isError: true });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const disp = r.headers.get('Content-Disposition') ?? '';
|
|
227
|
+
const filename =
|
|
228
|
+
/filename="([^"]+)"/.exec(disp)?.[1] ?? `export${FORMAT_META[format].defaultExt}`;
|
|
229
|
+
const blob = await r.blob();
|
|
230
|
+
const url = URL.createObjectURL(blob);
|
|
231
|
+
const a = document.createElement('a');
|
|
232
|
+
a.href = url;
|
|
233
|
+
a.download = filename;
|
|
234
|
+
document.body.appendChild(a);
|
|
235
|
+
a.click();
|
|
236
|
+
a.remove();
|
|
237
|
+
URL.revokeObjectURL(url);
|
|
238
|
+
setStatus({ text: `Saved ${filename}`, isError: false });
|
|
239
|
+
void loadHistory();
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
242
|
+
setStatus({ text: `Export failed: ${msg}`, isError: true });
|
|
243
|
+
} finally {
|
|
244
|
+
setSubmitting(false);
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
[loadHistory]
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const rerunLast = useCallback(async () => {
|
|
251
|
+
await loadHistory();
|
|
252
|
+
const last = history[0];
|
|
253
|
+
if (!last) return;
|
|
254
|
+
await submit(last.format, last.scope, last.options ?? {});
|
|
255
|
+
}, [history, loadHistory, submit]);
|
|
256
|
+
|
|
257
|
+
const ctxValue = useMemo<ExportDialogValue>(
|
|
258
|
+
() => ({ open, close, rerunLast }),
|
|
259
|
+
[open, close, rerunLast]
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<ExportDialogContext.Provider value={ctxValue}>
|
|
264
|
+
<style>{DIALOG_CSS}</style>
|
|
265
|
+
{children}
|
|
266
|
+
<DialogShell
|
|
267
|
+
ref={dialogRef}
|
|
268
|
+
openState={openState}
|
|
269
|
+
onClose={close}
|
|
270
|
+
onSubmit={submit}
|
|
271
|
+
history={history}
|
|
272
|
+
submitting={submitting}
|
|
273
|
+
status={status}
|
|
274
|
+
/>
|
|
275
|
+
</ExportDialogContext.Provider>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function useExportDialog(): ExportDialogValue | null {
|
|
280
|
+
return useContext(ExportDialogContext);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
284
|
+
// Inner shell — pure form. Pulled out so the provider stays focused on state.
|
|
285
|
+
|
|
286
|
+
const DialogShell = (() => {
|
|
287
|
+
function Shell(props: {
|
|
288
|
+
ref: React.Ref<HTMLDialogElement>;
|
|
289
|
+
openState: OpenOptions | null;
|
|
290
|
+
onClose: () => void;
|
|
291
|
+
onSubmit: (format: Format, scope: Scope, options: Record<string, unknown>) => void;
|
|
292
|
+
history: ExportHistoryEntry[];
|
|
293
|
+
submitting: boolean;
|
|
294
|
+
status: { text: string; isError: boolean } | null;
|
|
295
|
+
}) {
|
|
296
|
+
const { ref, openState, onClose, onSubmit, history, submitting, status } = props;
|
|
297
|
+
const [format, setFormat] = useState<Format>('png');
|
|
298
|
+
const [scope, setScope] = useState<Scope>('artboard');
|
|
299
|
+
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (!openState) return;
|
|
302
|
+
if (openState.format) setFormat(openState.format);
|
|
303
|
+
if (openState.scope) setScope(openState.scope);
|
|
304
|
+
}, [openState]);
|
|
305
|
+
|
|
306
|
+
// Keep the scope valid against the chosen format.
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const valid = VALID_SCOPES_PER_FORMAT[format];
|
|
309
|
+
if (!valid.includes(scope)) {
|
|
310
|
+
setScope(valid[0] ?? 'artboard');
|
|
311
|
+
}
|
|
312
|
+
}, [format, scope]);
|
|
313
|
+
|
|
314
|
+
if (!openState) {
|
|
315
|
+
return <dialog ref={ref} className="dc-export-dialog" onClose={onClose} />;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<dialog ref={ref} className="dc-export-dialog" onClose={onClose}>
|
|
320
|
+
<header>
|
|
321
|
+
<h2>Export</h2>
|
|
322
|
+
<button type="button" className="dc-ed-close" onClick={onClose}>
|
|
323
|
+
Esc
|
|
324
|
+
</button>
|
|
325
|
+
</header>
|
|
326
|
+
<div className="dc-ed-body">
|
|
327
|
+
<div>
|
|
328
|
+
<label htmlFor="dc-ed-format">Format</label>
|
|
329
|
+
<select
|
|
330
|
+
id="dc-ed-format"
|
|
331
|
+
value={format}
|
|
332
|
+
onChange={(e) => setFormat(e.target.value as Format)}
|
|
333
|
+
>
|
|
334
|
+
{(Object.keys(FORMAT_META) as Format[]).map((f) => (
|
|
335
|
+
<option key={f} value={f}>
|
|
336
|
+
{FORMAT_META[f].label}
|
|
337
|
+
</option>
|
|
338
|
+
))}
|
|
339
|
+
</select>
|
|
340
|
+
<p className="dc-ed-desc">{FORMAT_META[format].description}</p>
|
|
341
|
+
</div>
|
|
342
|
+
<div>
|
|
343
|
+
<label htmlFor="dc-ed-scope">Scope</label>
|
|
344
|
+
<select
|
|
345
|
+
id="dc-ed-scope"
|
|
346
|
+
value={scope}
|
|
347
|
+
onChange={(e) => setScope(e.target.value as Scope)}
|
|
348
|
+
>
|
|
349
|
+
{VALID_SCOPES_PER_FORMAT[format].map((s) => (
|
|
350
|
+
<option key={s} value={s}>
|
|
351
|
+
{SCOPE_META[s].label}
|
|
352
|
+
</option>
|
|
353
|
+
))}
|
|
354
|
+
</select>
|
|
355
|
+
<p className="dc-ed-desc">{SCOPE_META[scope].description}</p>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
{history.length > 0 && (
|
|
359
|
+
<div className="dc-ed-recent">
|
|
360
|
+
<h3>Recent</h3>
|
|
361
|
+
<ul>
|
|
362
|
+
{history.slice(0, 5).map((h, i) => (
|
|
363
|
+
<li key={`${h.at}-${i}`}>
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
onClick={() => {
|
|
367
|
+
setFormat(h.format);
|
|
368
|
+
setScope(h.scope);
|
|
369
|
+
}}
|
|
370
|
+
>
|
|
371
|
+
<span>
|
|
372
|
+
{FORMAT_META[h.format].label} · {SCOPE_META[h.scope].label}
|
|
373
|
+
</span>
|
|
374
|
+
<span style={{ color: 'var(--fg-2, rgba(40,30,20,0.5))' }}>{h.filename}</span>
|
|
375
|
+
</button>
|
|
376
|
+
</li>
|
|
377
|
+
))}
|
|
378
|
+
</ul>
|
|
379
|
+
</div>
|
|
380
|
+
)}
|
|
381
|
+
{status && (
|
|
382
|
+
<div className={`dc-ed-status${status.isError ? ' is-error' : ''}`}>{status.text}</div>
|
|
383
|
+
)}
|
|
384
|
+
<footer>
|
|
385
|
+
<button type="button" onClick={onClose} disabled={submitting}>
|
|
386
|
+
Cancel
|
|
387
|
+
</button>
|
|
388
|
+
<button
|
|
389
|
+
type="button"
|
|
390
|
+
className="dc-ed-primary"
|
|
391
|
+
disabled={submitting}
|
|
392
|
+
onClick={() => onSubmit(format, scope, {})}
|
|
393
|
+
>
|
|
394
|
+
{submitting ? 'Exporting…' : 'Export'}
|
|
395
|
+
</button>
|
|
396
|
+
</footer>
|
|
397
|
+
</dialog>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return Shell;
|
|
401
|
+
})();
|