@openprd/cli 0.1.18 → 0.1.19

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.
@@ -22,6 +22,9 @@ import {
22
22
  CANVAS_LOCALE_STORAGE_KEY,
23
23
  CANVAS_LOCALES,
24
24
  } from './canvas-i18n.js';
25
+ import { CANVAS_APP_STYLES } from './canvas-app-styles.js';
26
+ import { CANVAS_APP_CLIENT_HELPERS } from './canvas-app-client-helpers.js';
27
+ import { CANVAS_APP_CLIENT_APP } from './canvas-app-client-app.js';
25
28
 
26
29
  function escapeHtml(value) {
27
30
  return String(value ?? '')
@@ -67,894 +70,7 @@ function renderCanvasAppHtml(session) {
67
70
  </script>
68
71
  <link rel="stylesheet" href="https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/dev/index.css" />
69
72
  <style>
70
- :root {
71
- color-scheme: light;
72
- --paper: #ffffff;
73
- --ink: #111827;
74
- --muted: #64748b;
75
- --line: #e5e7eb;
76
- --rail: #ffffff;
77
- --accent: #2563eb;
78
- --accent-hover: #1d4ed8;
79
- --accent-soft: #eff6ff;
80
- --warn: #b45309;
81
- }
82
-
83
- * {
84
- box-sizing: border-box;
85
- }
86
-
87
- html,
88
- body,
89
- #root {
90
- width: 100%;
91
- height: 100%;
92
- margin: 0;
93
- }
94
-
95
- body {
96
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
97
- background: var(--paper);
98
- color: var(--ink);
99
- letter-spacing: 0;
100
- overflow: hidden;
101
- }
102
-
103
- button,
104
- input,
105
- select,
106
- textarea {
107
- font: inherit;
108
- }
109
-
110
- .shell {
111
- display: grid;
112
- grid-template-rows: 48px minmax(0, 1fr);
113
- width: 100%;
114
- height: 100%;
115
- background: var(--paper);
116
- }
117
-
118
- .topbar {
119
- display: flex;
120
- align-items: center;
121
- gap: 12px;
122
- min-width: 0;
123
- padding: 0 16px;
124
- border-bottom: 1px solid var(--line);
125
- background: rgba(255, 255, 255, 0.96);
126
- }
127
-
128
- .brand {
129
- display: flex;
130
- align-items: baseline;
131
- gap: 8px;
132
- min-width: 220px;
133
- white-space: nowrap;
134
- }
135
-
136
- .brand strong {
137
- font-size: 14px;
138
- line-height: 1;
139
- }
140
-
141
- .brand span {
142
- color: var(--muted);
143
- font-size: 12px;
144
- }
145
-
146
- .chipbar {
147
- display: flex;
148
- flex-wrap: nowrap;
149
- gap: 8px;
150
- min-width: 0;
151
- overflow: hidden;
152
- }
153
-
154
- .chip {
155
- display: inline-flex;
156
- align-items: center;
157
- min-width: 0;
158
- height: 26px;
159
- max-width: min(30vw, 360px);
160
- padding: 0 9px;
161
- border: 1px solid var(--line);
162
- border-radius: 6px;
163
- color: var(--muted);
164
- background: #ffffff;
165
- font-size: 12px;
166
- white-space: nowrap;
167
- }
168
-
169
- .chip span {
170
- overflow: hidden;
171
- text-overflow: ellipsis;
172
- }
173
-
174
- .actions {
175
- display: flex;
176
- align-items: center;
177
- gap: 8px;
178
- margin-left: auto;
179
- white-space: nowrap;
180
- }
181
-
182
- .icon-button,
183
- .text-button {
184
- display: inline-flex;
185
- align-items: center;
186
- justify-content: center;
187
- height: 32px;
188
- border: 1px solid var(--line);
189
- border-radius: 6px;
190
- color: var(--ink);
191
- background: #ffffff;
192
- cursor: pointer;
193
- }
194
-
195
- .icon-button {
196
- width: 34px;
197
- }
198
-
199
- .text-button {
200
- gap: 7px;
201
- padding: 0 10px;
202
- }
203
-
204
- .icon-button:hover,
205
- .text-button:hover {
206
- border-color: #bfdbfe;
207
- background: var(--accent-soft);
208
- }
209
-
210
- .primary-button {
211
- border-color: var(--accent);
212
- color: #ffffff;
213
- background: var(--accent);
214
- }
215
-
216
- .primary-button:hover {
217
- border-color: var(--accent-hover);
218
- background: var(--accent-hover);
219
- }
220
-
221
- .subtle-button {
222
- border-color: #e2e8f0;
223
- color: #334155;
224
- background: #f1f5f9;
225
- }
226
-
227
- .subtle-button:hover {
228
- border-color: #cbd5e1;
229
- color: #1f2937;
230
- background: #e5e7eb;
231
- }
232
-
233
- .text-button:disabled {
234
- cursor: default;
235
- }
236
-
237
- .primary-button:disabled {
238
- opacity: 1;
239
- border-color: var(--accent);
240
- color: #ffffff;
241
- background: var(--accent);
242
- box-shadow: none;
243
- }
244
-
245
- .text-button:disabled:not(.primary-button) {
246
- opacity: 0.6;
247
- border-color: var(--line);
248
- color: #9ca3af;
249
- background: #f8fafc;
250
- }
251
-
252
- .language-select {
253
- height: 32px;
254
- min-width: 84px;
255
- padding: 0 8px;
256
- border: 1px solid var(--line);
257
- border-radius: 6px;
258
- color: var(--ink);
259
- background: #ffffff;
260
- cursor: pointer;
261
- }
262
-
263
- .language-select:focus,
264
- .language-select:focus-visible {
265
- outline: none;
266
- border-color: #cbd5e1;
267
- box-shadow: none;
268
- }
269
-
270
- .layout {
271
- display: grid;
272
- grid-template-columns: minmax(0, 1fr) 232px;
273
- min-height: 0;
274
- }
275
-
276
- .rail {
277
- min-width: 0;
278
- border-left: 1px solid var(--line);
279
- background: var(--rail);
280
- padding: 18px 16px;
281
- overflow: auto;
282
- }
283
-
284
- .rail-section {
285
- margin-bottom: 18px;
286
- }
287
-
288
- .rail-title {
289
- margin: 0 0 8px;
290
- color: var(--muted);
291
- font-size: 11px;
292
- font-weight: 700;
293
- letter-spacing: 0.08em;
294
- text-transform: uppercase;
295
- }
296
-
297
- .rail-value {
298
- margin: 0;
299
- color: var(--ink);
300
- font-size: 13px;
301
- line-height: 1.45;
302
- overflow-wrap: anywhere;
303
- }
304
-
305
- .thread-summary {
306
- display: grid;
307
- gap: 6px;
308
- padding: 0;
309
- }
310
-
311
- .thread-title {
312
- margin: 0;
313
- color: var(--ink);
314
- font-size: 14px;
315
- font-weight: 700;
316
- line-height: 1.35;
317
- overflow-wrap: anywhere;
318
- }
319
-
320
- .thread-id {
321
- margin: 0;
322
- color: var(--muted);
323
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
324
- font-size: 11px;
325
- line-height: 1.45;
326
- overflow-wrap: anywhere;
327
- }
328
-
329
- .thread-id-row {
330
- display: grid;
331
- grid-template-columns: minmax(0, 1fr) 28px;
332
- align-items: start;
333
- gap: 6px;
334
- }
335
-
336
- .thread-copy-button {
337
- display: inline-flex;
338
- align-items: center;
339
- justify-content: center;
340
- width: 28px;
341
- height: 28px;
342
- border: 1px solid #dbeafe;
343
- border-radius: 6px;
344
- color: var(--accent);
345
- background: var(--accent-soft);
346
- cursor: pointer;
347
- }
348
-
349
- .thread-copy-button svg {
350
- width: 15px;
351
- height: 15px;
352
- }
353
-
354
- .thread-copy-button:hover {
355
- border-color: #bfdbfe;
356
- background: #dbeafe;
357
- }
358
-
359
- .thread-copy-button:focus,
360
- .thread-copy-button:focus-visible {
361
- outline: none;
362
- border-color: var(--accent);
363
- box-shadow: 0 0 0 2px #dbeafe;
364
- }
365
-
366
- .rail-actions {
367
- display: grid;
368
- gap: 8px;
369
- margin-top: 20px;
370
- padding-top: 16px;
371
- border-top: 1px solid var(--line);
372
- }
373
-
374
- .rail-heading {
375
- margin: 0 0 2px;
376
- color: var(--muted);
377
- font-size: 11px;
378
- font-weight: 700;
379
- letter-spacing: 0.08em;
380
- text-transform: uppercase;
381
- }
382
-
383
- .rail-action-group {
384
- display: grid;
385
- gap: 7px;
386
- margin-bottom: 10px;
387
- }
388
-
389
- .guide-strip {
390
- display: grid;
391
- gap: 4px;
392
- padding: 9px 11px;
393
- border: 1px solid #c7d2fe;
394
- border-radius: 8px;
395
- background: #eef2ff;
396
- }
397
-
398
- .guide-strip strong {
399
- color: #3730a3;
400
- font-size: 12px;
401
- line-height: 1.4;
402
- }
403
-
404
- .guide-strip span {
405
- color: #52525b;
406
- font-size: 11px;
407
- line-height: 1.45;
408
- }
409
-
410
- .rail-action-grid {
411
- display: grid;
412
- grid-template-columns: repeat(3, minmax(0, 1fr));
413
- gap: 6px;
414
- }
415
-
416
- .rail-action-button {
417
- display: grid;
418
- grid-template-rows: auto auto;
419
- place-items: center;
420
- gap: 5px;
421
- width: 100%;
422
- min-height: 58px;
423
- padding: 7px 4px;
424
- border: 1px solid var(--line);
425
- border-radius: 6px;
426
- color: var(--ink);
427
- background: #ffffff;
428
- font-size: 12px;
429
- line-height: 1.15;
430
- cursor: pointer;
431
- text-align: center;
432
- }
433
-
434
- .rail-action-button svg {
435
- width: 18px;
436
- height: 18px;
437
- }
438
-
439
- .rail-action-button span {
440
- display: block;
441
- max-width: 100%;
442
- overflow: hidden;
443
- text-overflow: ellipsis;
444
- white-space: nowrap;
445
- }
446
-
447
- .rail-action-button:hover {
448
- border-color: #bfdbfe;
449
- background: var(--accent-soft);
450
- }
451
-
452
- .rail-action-button.primary {
453
- border-color: var(--accent);
454
- color: #ffffff;
455
- background: var(--accent);
456
- }
457
-
458
- .rail-action-button.primary:hover {
459
- border-color: var(--accent-hover);
460
- background: var(--accent-hover);
461
- }
462
-
463
- .rail-action-button.danger {
464
- border-color: #fecaca;
465
- color: #b91c1c;
466
- }
467
-
468
- .rail-action-button.danger:hover {
469
- border-color: #fca5a5;
470
- background: #fef2f2;
471
- }
472
-
473
- .rail-action-button:disabled {
474
- cursor: default;
475
- }
476
-
477
- .rail-action-button:disabled:not(.primary) {
478
- opacity: 0.6;
479
- color: #9ca3af;
480
- background: #f8fafc;
481
- }
482
-
483
- .rail-action-button.primary:disabled {
484
- opacity: 1;
485
- border-color: var(--accent);
486
- color: #ffffff;
487
- background: var(--accent);
488
- }
489
-
490
- .action-status {
491
- display: grid;
492
- gap: 8px;
493
- margin: 2px 0 8px;
494
- padding: 8px 9px;
495
- border: 1px solid #bbf7d0;
496
- border-radius: 6px;
497
- color: #333333;
498
- background: #ecfdf5;
499
- font-size: 12px;
500
- line-height: 1.4;
501
- overflow-wrap: anywhere;
502
- }
503
-
504
- .action-status.busy {
505
- border-color: #bfdbfe;
506
- color: #1e3a8a;
507
- background: #eff6ff;
508
- }
509
-
510
- .action-status.error {
511
- border-color: #fecaca;
512
- color: #991b1b;
513
- background: #fef2f2;
514
- }
515
-
516
- .status-folder-button {
517
- justify-self: start;
518
- min-height: 28px;
519
- padding: 5px 9px;
520
- border: 1px solid var(--accent);
521
- border-radius: 6px;
522
- color: #ffffff;
523
- background: var(--accent);
524
- font-size: 12px;
525
- font-weight: 600;
526
- cursor: pointer;
527
- }
528
-
529
- .status-folder-button:hover {
530
- border-color: var(--accent-hover);
531
- background: var(--accent-hover);
532
- }
533
-
534
- .status-folder-button:disabled {
535
- cursor: default;
536
- opacity: 0.75;
537
- }
538
-
539
- .library-grid {
540
- display: grid;
541
- grid-template-columns: repeat(2, minmax(0, 1fr));
542
- gap: 8px;
543
- }
544
-
545
- .library-tile {
546
- display: grid;
547
- gap: 6px;
548
- min-width: 0;
549
- padding: 8px;
550
- border: 1px solid var(--line);
551
- border-radius: 6px;
552
- color: var(--ink);
553
- background: #ffffff;
554
- cursor: pointer;
555
- text-align: left;
556
- }
557
-
558
- .library-tile:hover {
559
- border-color: #bfdbfe;
560
- background: var(--accent-soft);
561
- }
562
-
563
- .library-card-preview {
564
- position: relative;
565
- width: 100%;
566
- aspect-ratio: 4 / 3;
567
- border: 1px solid #94a3b8;
568
- border-radius: 5px;
569
- background: #ffffff;
570
- }
571
-
572
- .library-card-preview::after {
573
- content: "";
574
- position: absolute;
575
- inset: 38% 18%;
576
- border-top: 2px solid #cbd5e1;
577
- border-bottom: 2px solid #e2e8f0;
578
- }
579
-
580
- .library-item-preview {
581
- display: grid;
582
- place-items: center;
583
- width: 100%;
584
- aspect-ratio: 4 / 3;
585
- overflow: hidden;
586
- border: 1px solid #cbd5e1;
587
- border-radius: 5px;
588
- background: #ffffff;
589
- }
590
-
591
- .library-item-preview img {
592
- width: 100%;
593
- height: 100%;
594
- object-fit: contain;
595
- }
596
-
597
- .library-item-preview span {
598
- color: var(--muted);
599
- font-size: 11px;
600
- font-weight: 600;
601
- }
602
-
603
- .library-tile span {
604
- color: var(--ink);
605
- font-size: 12px;
606
- font-weight: 600;
607
- line-height: 1.25;
608
- overflow-wrap: anywhere;
609
- }
610
-
611
- .template-grid {
612
- display: grid;
613
- grid-template-columns: repeat(2, minmax(0, 1fr));
614
- gap: 8px;
615
- }
616
-
617
- .template-tile {
618
- display: grid;
619
- gap: 6px;
620
- min-width: 0;
621
- padding: 8px;
622
- border: 1px solid var(--line);
623
- border-radius: 6px;
624
- color: var(--ink);
625
- background: #ffffff;
626
- cursor: pointer;
627
- text-align: left;
628
- }
629
-
630
- .template-tile:hover {
631
- border-color: #bfdbfe;
632
- background: var(--accent-soft);
633
- }
634
-
635
- .template-preview {
636
- position: relative;
637
- display: grid;
638
- place-items: center;
639
- width: 100%;
640
- min-height: 42px;
641
- max-height: 86px;
642
- overflow: hidden;
643
- border: 1px dashed #93c5fd;
644
- border-radius: 5px;
645
- background: #eff6ff;
646
- }
647
-
648
- .template-preview::before {
649
- content: "AI";
650
- display: inline-grid;
651
- place-items: center;
652
- width: 30px;
653
- height: 20px;
654
- border-radius: 5px;
655
- color: #ffffff;
656
- background: var(--accent);
657
- font-size: 11px;
658
- font-weight: 800;
659
- letter-spacing: 0;
660
- }
661
-
662
- .template-tile strong,
663
- .template-tile span {
664
- color: var(--ink);
665
- font-size: 12px;
666
- line-height: 1.25;
667
- overflow-wrap: anywhere;
668
- }
669
-
670
- .template-tile strong {
671
- font-weight: 700;
672
- }
673
-
674
- .template-tile span {
675
- color: var(--muted);
676
- font-weight: 600;
677
- }
678
-
679
- .template-compose-preview {
680
- display: grid;
681
- place-items: center;
682
- padding: 14px;
683
- border: 1px solid var(--line);
684
- border-radius: 6px;
685
- background: #f8fafc;
686
- }
687
-
688
- .template-compose-frame {
689
- position: relative;
690
- display: grid;
691
- place-items: center;
692
- width: min(100%, 320px);
693
- max-height: 240px;
694
- border: 1px dashed #93c5fd;
695
- border-radius: 6px;
696
- background: #eff6ff;
697
- color: #1e3a8a;
698
- font-size: 13px;
699
- font-weight: 700;
700
- }
701
-
702
- .template-compose-frame::before {
703
- content: "AI";
704
- position: absolute;
705
- top: 10px;
706
- left: 10px;
707
- display: inline-grid;
708
- place-items: center;
709
- width: 34px;
710
- height: 22px;
711
- border-radius: 5px;
712
- color: #ffffff;
713
- background: var(--accent);
714
- font-size: 12px;
715
- font-weight: 800;
716
- letter-spacing: 0;
717
- }
718
-
719
- .rail-note {
720
- margin: 6px 0 0;
721
- color: var(--muted);
722
- font-size: 12px;
723
- line-height: 1.45;
724
- }
725
-
726
- .path {
727
- padding: 8px;
728
- border: 1px solid var(--line);
729
- border-radius: 6px;
730
- background: #ffffff;
731
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
732
- font-size: 11px;
733
- line-height: 1.4;
734
- overflow-wrap: anywhere;
735
- }
736
-
737
- .canvas-wrap {
738
- position: relative;
739
- min-width: 0;
740
- min-height: 0;
741
- background: #f8fafc;
742
- }
743
-
744
- .canvas-wrap .excalidraw {
745
- --color-primary: #2563eb;
746
- --color-primary-darker: #1d4ed8;
747
- --color-primary-light: #eff6ff;
748
- --border-radius-md: 6px;
749
- --border-radius-lg: 8px;
750
- height: 100%;
751
- }
752
-
753
- .canvas-wrap .excalidraw [data-testid="main-menu-trigger"],
754
- .canvas-wrap .excalidraw .main-menu-trigger,
755
- .canvas-wrap .excalidraw [data-testid="library-button"],
756
- .canvas-wrap .excalidraw .library-button,
757
- .canvas-wrap .excalidraw .default-sidebar-trigger,
758
- .canvas-wrap .excalidraw .sidebar-trigger__label-element,
759
- .canvas-wrap .excalidraw button[aria-label="Library"],
760
- .canvas-wrap .excalidraw button[aria-label="素材库"],
761
- .canvas-wrap .excalidraw button[title="Library"],
762
- .canvas-wrap .excalidraw button[title="素材库"] {
763
- display: none !important;
764
- }
765
-
766
- .fallback {
767
- display: grid;
768
- place-items: center;
769
- height: 100%;
770
- padding: 32px;
771
- color: var(--muted);
772
- text-align: center;
773
- }
774
-
775
- .fallback strong {
776
- display: block;
777
- margin-bottom: 8px;
778
- color: var(--ink);
779
- }
780
-
781
- .fallback pre {
782
- max-width: min(760px, calc(100vw - 48px));
783
- max-height: 220px;
784
- margin: 14px 0 0;
785
- padding: 10px 12px;
786
- overflow: auto;
787
- border: 1px solid var(--line);
788
- border-radius: 6px;
789
- background: #ffffff;
790
- color: var(--warn);
791
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
792
- font-size: 11px;
793
- line-height: 1.5;
794
- text-align: left;
795
- white-space: pre-wrap;
796
- }
797
-
798
- .selection-sendbar {
799
- position: absolute;
800
- z-index: 20;
801
- left: 50%;
802
- bottom: 20px;
803
- display: flex;
804
- align-items: center;
805
- gap: 12px;
806
- max-width: calc(100% - 40px);
807
- padding: 8px;
808
- border: 1px solid var(--line);
809
- border-radius: 8px;
810
- background: rgba(255, 255, 255, 0.98);
811
- box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
812
- transform: translateX(-50%);
813
- }
814
-
815
- .selection-sendbar strong {
816
- font-size: 12px;
817
- white-space: nowrap;
818
- }
819
-
820
- .selection-sendbar .text-button {
821
- min-width: 118px;
822
- flex: 0 0 auto;
823
- white-space: nowrap;
824
- }
825
-
826
- .selection-sendbar .primary-button {
827
- min-width: 136px;
828
- }
829
-
830
- .selection-sendbar .text-button span {
831
- white-space: nowrap;
832
- }
833
-
834
- .composer-backdrop {
835
- position: absolute;
836
- inset: 0;
837
- z-index: 30;
838
- display: grid;
839
- place-items: center;
840
- padding: 24px;
841
- background: rgba(15, 23, 42, 0.28);
842
- }
843
-
844
- .composer-modal {
845
- width: min(560px, calc(100vw - 48px));
846
- max-height: calc(100vh - 96px);
847
- overflow: auto;
848
- border: 1px solid var(--line);
849
- border-radius: 8px;
850
- background: #ffffff;
851
- box-shadow: 0 20px 48px rgba(15, 23, 42, 0.22);
852
- }
853
-
854
- .composer-header,
855
- .composer-body,
856
- .composer-actions {
857
- padding: 16px;
858
- }
859
-
860
- .composer-header {
861
- display: flex;
862
- align-items: center;
863
- justify-content: space-between;
864
- gap: 12px;
865
- border-bottom: 1px solid var(--line);
866
- }
867
-
868
- .composer-header h2 {
869
- margin: 0;
870
- color: var(--ink);
871
- font-size: 16px;
872
- line-height: 1.3;
873
- }
874
-
875
- .composer-body {
876
- display: grid;
877
- gap: 12px;
878
- }
879
-
880
- .composer-label {
881
- margin: 0;
882
- color: var(--muted);
883
- font-size: 12px;
884
- font-weight: 700;
885
- line-height: 1.35;
886
- }
887
-
888
- .composer-preview {
889
- width: 100%;
890
- min-height: 180px;
891
- max-height: 340px;
892
- border: 1px solid var(--line);
893
- border-radius: 6px;
894
- background: #f8fafc;
895
- object-fit: contain;
896
- }
897
-
898
- .composer-input {
899
- width: 100%;
900
- min-height: 110px;
901
- padding: 10px 12px;
902
- border: 1px solid var(--line);
903
- border-radius: 6px;
904
- color: var(--ink);
905
- background: #ffffff;
906
- font: inherit;
907
- line-height: 1.5;
908
- resize: vertical;
909
- }
910
-
911
- .composer-input:focus {
912
- outline: 2px solid #bfdbfe;
913
- border-color: var(--accent);
914
- }
915
-
916
- .composer-status {
917
- margin: 0;
918
- color: var(--muted);
919
- font-size: 12px;
920
- line-height: 1.45;
921
- }
922
-
923
- .composer-error {
924
- margin: 0;
925
- color: #b91c1c;
926
- font-size: 12px;
927
- line-height: 1.45;
928
- }
929
-
930
- .composer-actions {
931
- display: flex;
932
- justify-content: flex-end;
933
- gap: 8px;
934
- border-top: 1px solid var(--line);
935
- }
936
-
937
- @media (max-width: 860px) {
938
- .layout {
939
- grid-template-columns: 1fr;
940
- }
941
-
942
- .rail {
943
- display: none;
944
- }
945
-
946
- .brand {
947
- min-width: 150px;
948
- }
949
-
950
- .chipbar {
951
- display: none;
952
- }
953
-
954
- .text-button span {
955
- display: none;
956
- }
957
- }
73
+ ${CANVAS_APP_STYLES}
958
74
  </style>
959
75
  </head>
960
76
  <body>
@@ -1009,1899 +125,8 @@ function renderCanvasAppHtml(session) {
1009
125
  });
1010
126
  </script>
1011
127
  <script type="module">
1012
- import React from "react";
1013
- import { createRoot } from "react-dom/client";
1014
- import { Excalidraw, convertToExcalidrawElements, exportToCanvas, serializeAsJSON } from "@excalidraw/excalidraw";
1015
-
1016
- const session = JSON.parse(document.getElementById("openprd-session").textContent);
1017
- const localeConfig = JSON.parse(document.getElementById("openprd-canvas-locales").textContent);
1018
- const h = React.createElement;
1019
- const apiBase = "";
1020
- const sceneSaveDelay = 700;
1021
- const codexRelayStartWaitMs = 45000;
1022
- const codexRelayUiTimeoutMs = 60000;
1023
- const codexRelayCompletedStatuses = new Set(["completed"]);
1024
- let excalidrawAPI = null;
1025
- let latestElements = [];
1026
- let latestAppState = {};
1027
- let latestFiles = {};
1028
- let saveTimer = null;
1029
- let lastSeq = -1;
1030
- let pollTimer = null;
1031
- let syncSelectionState = () => {};
1032
- let syncAgentBridgeStatus = () => {};
1033
- let syncCodexRelayStatus = () => {};
1034
-
1035
- function normalizeLocale(locale) {
1036
- const value = String(locale || "").trim().toLowerCase();
1037
- if (value === "en" || value.startsWith("en-")) {
1038
- return "en";
1039
- }
1040
- if (value === "zh" || value.startsWith("zh-")) {
1041
- return "zh-CN";
1042
- }
1043
- return localeConfig.defaultLocale || "zh-CN";
1044
- }
1045
-
1046
- function initialLocale() {
1047
- try {
1048
- const saved = window.localStorage.getItem(localeConfig.storageKey);
1049
- if (saved) {
1050
- return normalizeLocale(saved);
1051
- }
1052
- } catch {}
1053
- const languages = Array.isArray(navigator.languages) && navigator.languages.length > 0
1054
- ? navigator.languages
1055
- : [navigator.language];
1056
- for (const language of languages) {
1057
- if (String(language || "").trim()) {
1058
- return normalizeLocale(language);
1059
- }
1060
- }
1061
- return localeConfig.defaultLocale || "zh-CN";
1062
- }
1063
-
1064
- function formatMessage(template, values = {}) {
1065
- return String(template || "").replace(/\\{(\\w+)\\}/g, (_, key) => {
1066
- return values[key] == null ? "" : String(values[key]);
1067
- });
1068
- }
1069
-
1070
- function messagesFor(locale) {
1071
- return localeConfig.messages[locale] || localeConfig.messages[localeConfig.defaultLocale] || {};
1072
- }
1073
-
1074
- const canvasSurfaceColor = "#ffffff";
1075
- const legacyCanvasBackgrounds = new Set(["#fbfaf7", "#fffdf9", "#f8f6f1"]);
1076
-
1077
- function normalizeCanvasAppState(appState = {}) {
1078
- const nextAppState = { ...(appState || {}) };
1079
- const current = String(nextAppState.viewBackgroundColor || "").trim().toLowerCase();
1080
- if (!current || legacyCanvasBackgrounds.has(current)) {
1081
- nextAppState.viewBackgroundColor = canvasSurfaceColor;
1082
- }
1083
- return nextAppState;
1084
- }
1085
-
1086
- function normalizeInitialScene(scene) {
1087
- const base = scene && Array.isArray(scene.elements)
1088
- ? scene
1089
- : { elements: [], appState: {}, files: {} };
1090
- return {
1091
- ...base,
1092
- elements: Array.isArray(base.elements) ? base.elements : [],
1093
- appState: normalizeCanvasAppState(base.appState),
1094
- files: base.files && typeof base.files === "object" ? base.files : {},
1095
- };
1096
- }
1097
-
1098
- function selectedCountFromAppState(appState) {
1099
- const selectedIds = appState?.selectedElementIds || {};
1100
- return Object.keys(selectedIds).filter((id) => selectedIds[id]).length;
1101
- }
1102
-
1103
- function selectedImageCountFromAppState(appState, elements = latestElements) {
1104
- const selectedIds = appState?.selectedElementIds || {};
1105
- return (elements || []).filter((element) => {
1106
- return element && !element.isDeleted && element.type === "image" && selectedIds[element.id];
1107
- }).length;
1108
- }
1109
-
1110
- function conversationTitle(t) {
1111
- return session.binding?.title || session.binding?.threadTitle || session.binding?.name || t.currentConversationTitle;
1112
- }
1113
-
1114
- function conversationId() {
1115
- return session.binding?.displayId || session.binding?.id || session.sessionKey;
1116
- }
1117
-
1118
- function toolIcon(kind) {
1119
- if (kind === "card") {
1120
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1121
- h("rect", { x: 4, y: 5, width: 16, height: 14, rx: 2 }),
1122
- h("path", { d: "M8 9h8" }),
1123
- h("path", { d: "M8 13h5" }),
1124
- );
1125
- }
1126
- if (kind === "export") {
1127
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1128
- h("rect", { x: 3, y: 5, width: 18, height: 14, rx: 2 }),
1129
- h("circle", { cx: 8, cy: 10, r: 1.6 }),
1130
- h("path", { d: "m21 16-5-5-4 4-2-2-5 5" }),
1131
- );
1132
- }
1133
- if (kind === "save") {
1134
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1135
- h("path", { d: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" }),
1136
- h("path", { d: "M17 21v-8H7v8" }),
1137
- h("path", { d: "M7 3v5h8" }),
1138
- );
1139
- }
1140
- if (kind === "reset") {
1141
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1142
- h("path", { d: "M3 6h18" }),
1143
- h("path", { d: "M8 6V4h8v2" }),
1144
- h("path", { d: "M19 6l-1 14H6L5 6" }),
1145
- h("path", { d: "M10 11v5" }),
1146
- h("path", { d: "M14 11v5" }),
1147
- );
1148
- }
1149
- if (kind === "library") {
1150
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1151
- h("path", { d: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20" }),
1152
- h("path", { d: "M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15Z" }),
1153
- );
1154
- }
1155
- if (kind === "send") {
1156
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1157
- h("path", { d: "M22 2 11 13" }),
1158
- h("path", { d: "m22 2-7 20-4-9-9-4 20-7z" }),
1159
- );
1160
- }
1161
- if (kind === "copy") {
1162
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1163
- h("rect", { x: 9, y: 9, width: 13, height: 13, rx: 2 }),
1164
- h("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" }),
1165
- );
1166
- }
1167
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1168
- h("circle", { cx: 12, cy: 12, r: 9 }),
1169
- h("path", { d: "M12 8v4" }),
1170
- h("path", { d: "M12 16h.01" }),
1171
- );
1172
- }
1173
-
1174
- async function fetchJson(url, options = {}) {
1175
- const response = await fetch(apiBase + url, {
1176
- ...options,
1177
- headers: {
1178
- "content-type": "application/json",
1179
- ...(options.headers || {}),
1180
- },
1181
- });
1182
- if (!response.ok) {
1183
- const text = await response.text().catch(() => "");
1184
- throw new Error(text || response.statusText);
1185
- }
1186
- return response.json();
1187
- }
1188
-
1189
- async function withTimeout(promise, timeoutMs) {
1190
- let timer = null;
1191
- try {
1192
- return await Promise.race([
1193
- promise,
1194
- new Promise((_, reject) => {
1195
- timer = window.setTimeout(() => reject(new Error("timeout")), timeoutMs);
1196
- }),
1197
- ]);
1198
- } finally {
1199
- if (timer !== null) {
1200
- window.clearTimeout(timer);
1201
- }
1202
- }
1203
- }
1204
-
1205
- function relayWasAccepted(result) {
1206
- return Boolean(result?.relay?.accepted || result?.relay?.delivered || codexRelayCompletedStatuses.has(result?.relay?.status));
1207
- }
1208
-
1209
- function relayIsPending(result, timedOut) {
1210
- const status = result?.relay?.status;
1211
- return Boolean(timedOut || result?.relay?.pending || result?.relay?.waitTimedOut || ["queued", "starting", "resuming", "connecting", "started"].includes(status));
1212
- }
1213
-
1214
- function agentBridgeIsPending(result) {
1215
- const status = result?.agentBridge?.status;
1216
- return Boolean(result?.agentBridge?.supportsForegroundMessage && ["pending", "claimed"].includes(status));
1217
- }
1218
-
1219
- function agentBridgePendingMessage(t, status) {
1220
- return status === "claimed" ? t.agentBridgeClaimed : t.agentBridgePending;
1221
- }
1222
-
1223
- function stripAppState(appState) {
1224
- const {
1225
- collaborators,
1226
- contextMenu,
1227
- editingElement,
1228
- editingFrame,
1229
- editingGroupId,
1230
- errorMessage,
1231
- openDialog,
1232
- openMenu,
1233
- pendingImageElementId,
1234
- resizingElement,
1235
- selectionElement,
1236
- suggestedBindings,
1237
- toast,
1238
- ...rest
1239
- } = appState || {};
1240
- return rest;
1241
- }
1242
-
1243
- function libraryItemsFromPayload(payload) {
1244
- if (Array.isArray(payload?.libraryItems)) {
1245
- return payload.libraryItems;
1246
- }
1247
- if (Array.isArray(payload?.items)) {
1248
- return payload.items;
1249
- }
1250
- return [];
1251
- }
1252
-
1253
- function createElementId(prefix = "openprd-lib") {
1254
- if (window.crypto?.randomUUID) {
1255
- return prefix + "-" + window.crypto.randomUUID();
1256
- }
1257
- return prefix + "-" + Date.now() + "-" + Math.random().toString(36).slice(2);
1258
- }
1259
-
1260
- function imageTemplateOptions(t) {
1261
- return [
1262
- { id: "1-1", label: "1:1", title: t.aiTemplateSquare, width: 320, height: 320, aspectRatio: 1 },
1263
- { id: "3-4", label: "3:4", title: t.aiTemplatePortrait, width: 300, height: 400, aspectRatio: 0.75 },
1264
- { id: "4-3", label: "4:3", title: t.aiTemplateLandscape, width: 400, height: 300, aspectRatio: 1.3333333333 },
1265
- { id: "16-9", label: "16:9", title: t.aiTemplateWide, width: 480, height: 270, aspectRatio: 1.7777777778 },
1266
- { id: "9-16", label: "9:16", title: t.aiTemplateVertical, width: 270, height: 480, aspectRatio: 0.5625 },
1267
- { id: "wechat-2-35", label: "2.35:1", title: t.aiTemplateWechat, width: 520, height: 221, aspectRatio: 2.35 },
1268
- ];
1269
- }
1270
-
1271
- function cloneLibraryElement(element, offsetX, offsetY) {
1272
- return {
1273
- ...element,
1274
- id: createElementId(),
1275
- x: Number(element.x || 0) + offsetX,
1276
- y: Number(element.y || 0) + offsetY,
1277
- seed: Math.floor(Math.random() * 2147483647),
1278
- version: 1,
1279
- versionNonce: Math.floor(Math.random() * 2147483647),
1280
- updated: Date.now(),
1281
- isDeleted: false,
1282
- };
1283
- }
1284
-
1285
- function boundsForElements(elements) {
1286
- const values = elements
1287
- .filter((element) => element && !element.isDeleted)
1288
- .map((element) => ({
1289
- minX: Number(element.x || 0),
1290
- minY: Number(element.y || 0),
1291
- maxX: Number(element.x || 0) + Number(element.width || 0),
1292
- maxY: Number(element.y || 0) + Number(element.height || 0),
1293
- }));
1294
- if (values.length === 0) {
1295
- return { x: 0, y: 0, width: 360, height: 240 };
1296
- }
1297
- const minX = Math.min(...values.map((value) => value.minX));
1298
- const minY = Math.min(...values.map((value) => value.minY));
1299
- const maxX = Math.max(...values.map((value) => value.maxX));
1300
- const maxY = Math.max(...values.map((value) => value.maxY));
1301
- return {
1302
- x: minX,
1303
- y: minY,
1304
- width: Math.max(80, maxX - minX),
1305
- height: Math.max(80, maxY - minY),
1306
- };
1307
- }
1308
-
1309
- function queueSave() {
1310
- window.clearTimeout(saveTimer);
1311
- saveTimer = window.setTimeout(async () => {
1312
- await fetchJson("/api/scene", {
1313
- method: "POST",
1314
- body: JSON.stringify({
1315
- type: "openprd.canvas.scene.v1",
1316
- elements: latestElements,
1317
- appState: stripAppState(latestAppState),
1318
- files: latestFiles,
1319
- savedAt: new Date().toISOString(),
1320
- }),
1321
- }).catch((error) => {
1322
- console.warn("OpenPrd canvas save failed", error);
1323
- });
1324
- }, sceneSaveDelay);
1325
- }
1326
-
1327
- function getViewportDropPoint(width, height) {
1328
- const appState = latestAppState || {};
1329
- const scrollX = Number(appState.scrollX || 0);
1330
- const scrollY = Number(appState.scrollY || 0);
1331
- const zoomValue = Number(appState.zoom?.value || 1);
1332
- const canvasWidth = window.innerWidth - 232;
1333
- const canvasHeight = window.innerHeight - 48;
1334
- return {
1335
- x: Math.round((canvasWidth / 2 - scrollX) / zoomValue - width / 2),
1336
- y: Math.round((canvasHeight / 2 - scrollY) / zoomValue - height / 2),
1337
- };
1338
- }
1339
-
1340
- function selectedElement() {
1341
- const selectedIds = latestAppState?.selectedElementIds || {};
1342
- return latestElements.find((element) => selectedIds[element.id] && !element.isDeleted) || null;
1343
- }
1344
-
1345
- function currentVisibleElements() {
1346
- return latestElements.filter((element) => !element.isDeleted);
1347
- }
1348
-
1349
- function currentSelectedElements() {
1350
- const selectedIds = latestAppState?.selectedElementIds || {};
1351
- return currentVisibleElements().filter((element) => selectedIds[element.id]);
1352
- }
1353
-
1354
- function createExportFilename(prefix = "canvas-selection") {
1355
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1356
- return prefix + "-" + stamp + ".png";
1357
- }
1358
-
1359
- function createSceneFilename(prefix = "openprd-canvas") {
1360
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1361
- return prefix + "-" + stamp + ".excalidraw";
1362
- }
1363
-
1364
- function downloadBlob(blob, filename) {
1365
- const url = URL.createObjectURL(blob);
1366
- const anchor = document.createElement("a");
1367
- anchor.href = url;
1368
- anchor.download = filename;
1369
- anchor.rel = "noopener";
1370
- document.body.appendChild(anchor);
1371
- anchor.click();
1372
- document.body.removeChild(anchor);
1373
- window.setTimeout(() => URL.revokeObjectURL(url), 1000);
1374
- }
1375
-
1376
- function downloadUrl(url, filename) {
1377
- if (!url) {
1378
- throw new Error("missing-export-url");
1379
- }
1380
- const anchor = document.createElement("a");
1381
- anchor.href = url;
1382
- anchor.download = filename || createExportFilename("canvas-export");
1383
- anchor.rel = "noopener";
1384
- document.body.appendChild(anchor);
1385
- anchor.click();
1386
- document.body.removeChild(anchor);
1387
- }
1388
-
1389
- function currentSceneElementsForFile() {
1390
- return excalidrawAPI?.getSceneElements?.() || latestElements;
1391
- }
1392
-
1393
- function currentFilesForFile() {
1394
- return excalidrawAPI?.getFiles?.() || latestFiles;
1395
- }
1396
-
1397
- function currentAppStateForFile() {
1398
- return normalizeCanvasAppState(excalidrawAPI?.getAppState?.() || latestAppState);
1399
- }
1400
-
1401
- async function saveCanvasToDisk() {
1402
- const elements = currentSceneElementsForFile();
1403
- const appState = stripAppState(currentAppStateForFile());
1404
- const files = currentFilesForFile();
1405
- const content = serializeAsJSON
1406
- ? serializeAsJSON(elements, appState, files, "local")
1407
- : JSON.stringify({ type: "excalidraw", version: 2, source: "https://openprd.local/canvas", elements, appState, files }, null, 2);
1408
- const filename = createSceneFilename();
1409
- await fetchJson("/api/download-file", {
1410
- method: "POST",
1411
- body: JSON.stringify({
1412
- filename,
1413
- content,
1414
- mimeType: "application/json; charset=utf-8",
1415
- }),
1416
- });
1417
- queueSave();
1418
- return { filename };
1419
- }
1420
-
1421
- async function exportCanvasImage(locale) {
1422
- const filename = createExportFilename("canvas-export");
1423
- const exported = await exportCanvasElements({ intent: "manual-export", locale, filename });
1424
- const exportData = exported.result.export || {};
1425
- downloadUrl(exportData.exportUrl, exportData.fileName || filename);
1426
- return exportData;
1427
- }
1428
-
1429
- function hideNativeExcalidrawChrome() {
1430
- const root = document.querySelector(".canvas-wrap .excalidraw");
1431
- if (!root) {
1432
- return;
1433
- }
1434
- const fixedSelectors = [
1435
- '[data-testid="main-menu-trigger"]',
1436
- ".main-menu-trigger",
1437
- '[data-testid="library-button"]',
1438
- ".library-button",
1439
- ".default-sidebar-trigger",
1440
- ".sidebar-trigger__label-element",
1441
- 'button[aria-label="Library"]',
1442
- 'button[aria-label="素材库"]',
1443
- 'button[title="Library"]',
1444
- 'button[title="素材库"]',
1445
- ];
1446
- root.querySelectorAll(fixedSelectors.join(",")).forEach((node) => {
1447
- node.setAttribute("hidden", "");
1448
- node.style.display = "none";
1449
- });
1450
- root.querySelectorAll("button").forEach((button) => {
1451
- const label = [
1452
- button.textContent,
1453
- button.getAttribute("aria-label"),
1454
- button.getAttribute("title"),
1455
- ].join(" ").trim().toLowerCase();
1456
- if (label === "library" || label === "素材库") {
1457
- button.setAttribute("hidden", "");
1458
- button.style.display = "none";
1459
- }
1460
- });
1461
- }
1462
-
1463
- async function copyTextToClipboard(text) {
1464
- let copied = false;
1465
- try {
1466
- await withTimeout(navigator.clipboard.writeText(text), 900);
1467
- copied = true;
1468
- } catch {}
1469
- try {
1470
- const textarea = document.createElement("textarea");
1471
- textarea.value = text;
1472
- textarea.setAttribute("readonly", "");
1473
- textarea.style.position = "fixed";
1474
- textarea.style.left = "-9999px";
1475
- document.body.appendChild(textarea);
1476
- textarea.select();
1477
- const ok = document.execCommand("copy");
1478
- document.body.removeChild(textarea);
1479
- copied = copied || ok;
1480
- } catch {
1481
- copied = copied || false;
1482
- }
1483
- try {
1484
- await withTimeout(fetchJson("/api/clipboard", {
1485
- method: "POST",
1486
- body: JSON.stringify({ text }),
1487
- }), 1200);
1488
- return true;
1489
- } catch {}
1490
- return copied;
1491
- }
1492
-
1493
- function buildCodexPrompt(t, exportPath, elementCount, instruction) {
1494
- const binding = conversationId();
1495
- const trimmedInstruction = String(instruction || "").trim();
1496
- return [
1497
- trimmedInstruction || t.codexPromptDefaultInstruction,
1498
- "",
1499
- t.codexPromptIntro,
1500
- "",
1501
- "![OpenPrd Canvas selection](" + exportPath + ")",
1502
- "",
1503
- exportPath,
1504
- "",
1505
- formatMessage(t.codexPromptMeta, { count: elementCount, binding }),
1506
- ].join("\\n");
1507
- }
1508
-
1509
- function buildThreadPrompt(t) {
1510
- return formatMessage(t.threadPromptTemplate, {
1511
- title: conversationTitle(t),
1512
- id: conversationId(),
1513
- });
1514
- }
1515
-
1516
- async function recordCodexHandoff(exported, promptText, locale, instruction) {
1517
- const operation = exported.result.operation || {};
1518
- const exportData = exported.result.export || {};
1519
- return fetchJson("/api/handoffs", {
1520
- method: "POST",
1521
- body: JSON.stringify({
1522
- target: "codex",
1523
- transport: "codex-app-server-codex-delegation-turn-start",
1524
- agentBridge: true,
1525
- relay: true,
1526
- exportOperationId: operation.id || null,
1527
- exportPath: exportData.exportPath || "",
1528
- exportUrl: exportData.exportUrl || "",
1529
- promptText,
1530
- selection: operation.selection || (exported.selected.length > 0 ? "selected" : "scene"),
1531
- selectedElementIds: exported.selected.map((element) => element.id),
1532
- elementCount: exported.elements.length,
1533
- locale,
1534
- instruction,
1535
- relayWaitMs: codexRelayStartWaitMs,
1536
- note: "canvas-send-to-codex",
1537
- }),
1538
- });
1539
- }
1540
-
1541
- async function recordCodexImageRequestHandoff(promptText, locale, instruction, template, created) {
1542
- return fetchJson("/api/handoffs", {
1543
- method: "POST",
1544
- body: JSON.stringify({
1545
- target: "codex",
1546
- transport: "codex-app-server-codex-delegation-turn-start",
1547
- agentBridge: true,
1548
- relay: true,
1549
- promptText,
1550
- instruction,
1551
- selection: "ai-image-template",
1552
- placeholderId: created.placeholderId,
1553
- aspectRatio: template.aspectRatio,
1554
- mode: "replace",
1555
- elementCount: created.elementIds.length,
1556
- selectedElementIds: created.elementIds,
1557
- locale,
1558
- relayWaitMs: codexRelayStartWaitMs,
1559
- note: "canvas-ai-image-template",
1560
- }),
1561
- });
1562
- }
1563
-
1564
- function buildImageRequestPrompt(t, template, instruction, placeholderId) {
1565
- const binding = conversationId();
1566
- const trimmedInstruction = String(instruction || "").trim();
1567
- return [
1568
- trimmedInstruction,
1569
- "",
1570
- t.aiImagePromptIntro,
1571
- "",
1572
- formatMessage(t.aiImagePromptMeta, {
1573
- ratio: template.label,
1574
- placeholderId,
1575
- binding,
1576
- }),
1577
- "",
1578
- t.aiImagePromptReturn,
1579
- "",
1580
- JSON.stringify({
1581
- placeholderId,
1582
- aspectRatio: template.aspectRatio,
1583
- mode: "replace",
1584
- note: "ai-image-template",
1585
- }, null, 2),
1586
- ].join("\\n");
1587
- }
1588
-
1589
- function latestPlaceholder() {
1590
- return [...latestElements].reverse().find((element) => {
1591
- const role = element.customData?.openprdRole;
1592
- return !element.isDeleted && (role === "image-placeholder" || role === "ai-image-placeholder");
1593
- }) || null;
1594
- }
1595
-
1596
- function fitIntoBox(box, aspectRatio) {
1597
- const ratio = Number(aspectRatio || 1.5);
1598
- let width = Math.max(80, Number(box.width || 360));
1599
- let height = Math.max(80, Math.round(width / ratio));
1600
- if (height > Number(box.height || height)) {
1601
- height = Math.max(80, Number(box.height || 240));
1602
- width = Math.round(height * ratio);
1603
- }
1604
- return {
1605
- x: Math.round(Number(box.x || 0) + (Number(box.width || width) - width) / 2),
1606
- y: Math.round(Number(box.y || 0) + (Number(box.height || height) - height) / 2),
1607
- width,
1608
- height,
1609
- };
1610
- }
1611
-
1612
- function updateScene(elements, appStatePatch = null) {
1613
- if (!excalidrawAPI) {
1614
- return;
1615
- }
1616
- const nextAppState = appStatePatch
1617
- ? normalizeCanvasAppState({ ...latestAppState, ...appStatePatch })
1618
- : latestAppState;
1619
- excalidrawAPI.updateScene(appStatePatch ? { elements, appState: nextAppState } : { elements });
1620
- latestElements = elements;
1621
- if (appStatePatch) {
1622
- latestAppState = nextAppState;
1623
- }
1624
- syncSelectionState(latestAppState, latestElements);
1625
- queueSave();
1626
- }
1627
-
1628
- function createAiImageCard(template, instruction, t) {
1629
- const point = getViewportDropPoint(template.width, template.height);
1630
- const requestId = createElementId("openprd-ai-image");
1631
- const groupId = createElementId("openprd-ai-group");
1632
- const centerY = point.y + Math.round(template.height / 2);
1633
- const pendingParent = {
1634
- openprdParentId: "__pending__",
1635
- openprdRequestId: requestId,
1636
- openprdGroupId: groupId,
1637
- };
1638
- let elements = convertToExcalidrawElements([
1639
- {
1640
- type: "rectangle",
1641
- x: point.x,
1642
- y: point.y,
1643
- width: template.width,
1644
- height: template.height,
1645
- backgroundColor: "#eff6ff",
1646
- strokeColor: "#2563eb",
1647
- strokeStyle: "dashed",
1648
- fillStyle: "hachure",
1649
- roughness: 1,
1650
- customData: {
1651
- openprdRole: "ai-image-placeholder",
1652
- openprdStatus: "generating",
1653
- openprdRequestId: requestId,
1654
- openprdPrompt: instruction,
1655
- openprdAspectRatio: template.aspectRatio,
1656
- openprdTemplateId: template.id,
1657
- openprdTemplateLabel: template.label,
1658
- },
1659
- },
1660
- {
1661
- type: "rectangle",
1662
- x: point.x + 18,
1663
- y: point.y + 18,
1664
- width: 58,
1665
- height: 28,
1666
- backgroundColor: "#2563eb",
1667
- strokeColor: "#2563eb",
1668
- roughness: 0,
1669
- customData: { ...pendingParent, openprdRole: "ai-image-badge" },
1670
- },
1671
- {
1672
- type: "text",
1673
- x: point.x + 36,
1674
- y: point.y + 24,
1675
- text: "AI",
1676
- fontSize: 14,
1677
- strokeColor: "#ffffff",
1678
- customData: { ...pendingParent, openprdRole: "ai-image-badge-label" },
1679
- },
1680
- {
1681
- type: "rectangle",
1682
- x: point.x + Math.round(template.width * 0.18),
1683
- y: point.y + Math.round(template.height * 0.28),
1684
- width: Math.round(template.width * 0.64),
1685
- height: 12,
1686
- backgroundColor: "#bfdbfe",
1687
- strokeColor: "#bfdbfe",
1688
- opacity: 38,
1689
- roughness: 0,
1690
- customData: { ...pendingParent, openprdRole: "ai-image-ripple" },
1691
- },
1692
- {
1693
- type: "rectangle",
1694
- x: point.x + Math.round(template.width * 0.24),
1695
- y: point.y + Math.round(template.height * 0.36),
1696
- width: Math.round(template.width * 0.52),
1697
- height: 10,
1698
- backgroundColor: "#bfdbfe",
1699
- strokeColor: "#bfdbfe",
1700
- opacity: 26,
1701
- roughness: 0,
1702
- customData: { ...pendingParent, openprdRole: "ai-image-ripple" },
1703
- },
1704
- {
1705
- type: "text",
1706
- x: point.x + 24,
1707
- y: centerY - 32,
1708
- text: t.aiGenerating,
1709
- fontSize: 22,
1710
- strokeColor: "#1e3a8a",
1711
- customData: { ...pendingParent, openprdRole: "ai-image-status-label" },
1712
- },
1713
- {
1714
- type: "text",
1715
- x: point.x + 24,
1716
- y: centerY + 4,
1717
- text: formatMessage(t.aiTemplateOnCanvas, { ratio: template.label }),
1718
- fontSize: 15,
1719
- strokeColor: "#64748b",
1720
- customData: { ...pendingParent, openprdRole: "ai-image-ratio-label" },
1721
- },
1722
- ]);
1723
- const placeholder = elements.find((element) => element.customData?.openprdRole === "ai-image-placeholder");
1724
- const placeholderId = placeholder?.id || requestId;
1725
- elements = elements.map((element) => {
1726
- const groupedElement = {
1727
- ...element,
1728
- groupIds: [groupId],
1729
- };
1730
- if (element.customData?.openprdParentId !== "__pending__") {
1731
- return {
1732
- ...groupedElement,
1733
- customData: {
1734
- ...groupedElement.customData,
1735
- openprdGroupId: groupId,
1736
- },
1737
- };
1738
- }
1739
- return {
1740
- ...groupedElement,
1741
- customData: {
1742
- ...groupedElement.customData,
1743
- openprdParentId: placeholderId,
1744
- },
1745
- };
1746
- });
1747
- const selectedElementIds = Object.fromEntries(elements.map((element) => [element.id, true]));
1748
- updateScene([...latestElements, ...elements], {
1749
- selectedElementIds,
1750
- });
1751
- return {
1752
- placeholderId,
1753
- groupId,
1754
- elementIds: elements.map((element) => element.id),
1755
- };
1756
- }
1757
-
1758
- function attachAiImagePrompt(placeholderId, promptText) {
1759
- updateScene(latestElements.map((element) => {
1760
- if (element.id !== placeholderId) {
1761
- return element;
1762
- }
1763
- return {
1764
- ...element,
1765
- customData: {
1766
- ...element.customData,
1767
- openprdPromptText: promptText,
1768
- },
1769
- };
1770
- }));
1771
- }
1772
-
1773
- function groupIdsForElement(element) {
1774
- const ids = new Set(Array.isArray(element?.groupIds) ? element.groupIds.filter(Boolean) : []);
1775
- if (element?.customData?.openprdGroupId) {
1776
- ids.add(element.customData.openprdGroupId);
1777
- }
1778
- return ids;
1779
- }
1780
-
1781
- async function addBlankCard(labelText = messagesFor(initialLocale()).placeholderText) {
1782
- const point = getViewportDropPoint(360, 240);
1783
- const elements = convertToExcalidrawElements([
1784
- {
1785
- type: "rectangle",
1786
- x: point.x,
1787
- y: point.y,
1788
- width: 360,
1789
- height: 240,
1790
- backgroundColor: "#ffffff",
1791
- strokeColor: "#2563eb",
1792
- roughness: 1,
1793
- customData: { openprdRole: "image-placeholder" },
1794
- },
1795
- {
1796
- type: "text",
1797
- x: point.x + 22,
1798
- y: point.y + 104,
1799
- text: labelText,
1800
- fontSize: 18,
1801
- strokeColor: "#65717f",
1802
- customData: { openprdRole: "image-placeholder-label" },
1803
- },
1804
- ]);
1805
- updateScene([...latestElements, ...elements]);
1806
- await fetchJson("/api/cards", {
1807
- method: "POST",
1808
- body: JSON.stringify({ source: "browser", note: "blank-card" }),
1809
- }).catch(() => {});
1810
- }
1811
-
1812
- function measureDataUrl(dataUrl) {
1813
- return new Promise((resolve) => {
1814
- const image = new Image();
1815
- image.onload = () => resolve({ width: image.naturalWidth || 1024, height: image.naturalHeight || 768 });
1816
- image.onerror = () => resolve({ width: 1024, height: 768 });
1817
- image.src = dataUrl;
1818
- });
1819
- }
1820
-
1821
- async function importReviewDirectionsToCanvas(t) {
1822
- const payload = await fetchJson("/api/review-directions");
1823
- const directions = Array.isArray(payload.directions) ? payload.directions : [];
1824
- if (!payload.ok || directions.length === 0) {
1825
- const error = new Error(payload.reason || "no-directions");
1826
- error.code = payload.reason === "no-directions" || payload.reason === "no-version" ? "empty" : "failed";
1827
- throw error;
1828
- }
1829
- const cardWidth = 460;
1830
- const gap = 60;
1831
- const headerHeight = 96;
1832
- const measured = [];
1833
- for (const direction of directions) {
1834
- const size = direction.image?.dataUrl ? await measureDataUrl(direction.image.dataUrl) : { width: 4, height: 3 };
1835
- measured.push({ direction, imageHeight: Math.round(cardWidth * (size.height / size.width)) });
1836
- }
1837
- const maxImageHeight = Math.max(...measured.map((item) => item.imageHeight));
1838
- const cardHeight = headerHeight + maxImageHeight + 24;
1839
- const boardWidth = directions.length * cardWidth + (directions.length - 1) * gap;
1840
- const origin = getViewportDropPoint(boardWidth, cardHeight);
1841
- const files = [];
1842
- const skeletons = [];
1843
- measured.forEach((item, index) => {
1844
- const direction = item.direction;
1845
- const x = origin.x + index * (cardWidth + gap);
1846
- const y = origin.y;
1847
- const isSelected = payload.selectedDirectionId && direction.id === payload.selectedDirectionId;
1848
- skeletons.push({
1849
- type: "rectangle",
1850
- x: x - 16,
1851
- y: y - 16,
1852
- width: cardWidth + 32,
1853
- height: cardHeight + 32,
1854
- backgroundColor: isSelected ? "#eef2ff" : "#ffffff",
1855
- strokeColor: isSelected ? "#4f46e5" : "#cbd5e1",
1856
- roughness: 0,
1857
- customData: { openprdRole: "review-direction-frame", openprdDirectionId: direction.id },
1858
- });
1859
- skeletons.push({
1860
- type: "text",
1861
- x: x,
1862
- y: y,
1863
- text: (index + 1) + ". " + (direction.title || direction.id) + (isSelected ? " [" + t.reviewDirectionSelectedTag + "]" : ""),
1864
- fontSize: 20,
1865
- strokeColor: "#0f172a",
1866
- customData: { openprdRole: "review-direction-title", openprdDirectionId: direction.id },
1867
- });
1868
- if (direction.summary) {
1869
- skeletons.push({
1870
- type: "text",
1871
- x: x,
1872
- y: y + 34,
1873
- text: direction.summary + (direction.tradeoff ? "\\n" + direction.tradeoff : ""),
1874
- fontSize: 14,
1875
- strokeColor: "#475569",
1876
- customData: { openprdRole: "review-direction-summary", openprdDirectionId: direction.id },
1877
- });
1878
- }
1879
- if (direction.image?.dataUrl) {
1880
- const fileId = "openprd-direction-" + direction.id + "-" + Date.now();
1881
- files.push({
1882
- id: fileId,
1883
- dataURL: direction.image.dataUrl,
1884
- mimeType: direction.image.mimeType || "image/png",
1885
- created: Date.now(),
1886
- });
1887
- skeletons.push({
1888
- type: "image",
1889
- x: x,
1890
- y: y + headerHeight,
1891
- width: cardWidth,
1892
- height: item.imageHeight,
1893
- fileId,
1894
- customData: { openprdRole: "review-direction-image", openprdDirectionId: direction.id },
1895
- });
1896
- }
1897
- });
1898
- if (excalidrawAPI && files.length > 0) {
1899
- excalidrawAPI.addFiles(files);
1900
- }
1901
- const elements = convertToExcalidrawElements(skeletons);
1902
- updateScene([...latestElements, ...elements]);
1903
- if (excalidrawAPI) {
1904
- excalidrawAPI.scrollToContent(elements, { fitToViewport: true, viewportZoomFactor: 0.85 });
1905
- }
1906
- return { count: directions.length };
1907
- }
1908
-
1909
- async function applyImageOperation(op) {
1910
- if (!excalidrawAPI) {
1911
- return;
1912
- }
1913
- const response = await fetch(op.assetUrl);
1914
- if (!response.ok) {
1915
- throw new Error("image fetch failed");
1916
- }
1917
- const blob = await response.blob();
1918
- const dataURL = await new Promise((resolve, reject) => {
1919
- const reader = new FileReader();
1920
- reader.onload = () => resolve(reader.result);
1921
- reader.onerror = reject;
1922
- reader.readAsDataURL(blob);
1923
- });
1924
- const fileId = op.fileId || ("openprd-image-" + Date.now());
1925
- excalidrawAPI.addFiles([{ id: fileId, dataURL, mimeType: blob.type || "image/png", created: Date.now() }]);
1926
- const explicitTarget = op.placeholderId
1927
- ? latestElements.find((element) => element.id === op.placeholderId && !element.isDeleted)
1928
- : null;
1929
- const target = explicitTarget || selectedElement() || latestPlaceholder();
1930
- const imageBox = target
1931
- ? fitIntoBox(target, op.aspectRatio)
1932
- : { ...getViewportDropPoint(480, 320), width: 480, height: 320 };
1933
- const [imageElement] = convertToExcalidrawElements([
1934
- {
1935
- type: "image",
1936
- x: imageBox.x,
1937
- y: imageBox.y,
1938
- width: imageBox.width,
1939
- height: imageBox.height,
1940
- fileId,
1941
- customData: {
1942
- openprdRole: "agent-image",
1943
- sourceOperationId: op.id,
1944
- sourcePath: op.sourcePath || null,
1945
- sourcePlaceholderId: target?.id || null,
1946
- },
1947
- },
1948
- ]);
1949
- const hiddenTargetIds = new Set();
1950
- if (target && op.mode !== "append") {
1951
- const hiddenGroupIds = groupIdsForElement(target);
1952
- const targetParentId = target.customData?.openprdParentId || null;
1953
- hiddenTargetIds.add(target.id);
1954
- if (targetParentId) {
1955
- hiddenTargetIds.add(targetParentId);
1956
- }
1957
- latestElements.forEach((element) => {
1958
- const elementGroupIds = groupIdsForElement(element);
1959
- const isSameGroup = [...hiddenGroupIds].some((groupId) => elementGroupIds.has(groupId));
1960
- const hasTargetAsParent = element.customData?.openprdParentId === target.id;
1961
- const sharesTargetParent = Boolean(targetParentId && element.customData?.openprdParentId === targetParentId);
1962
- if (
1963
- isSameGroup
1964
- || hasTargetAsParent
1965
- || sharesTargetParent
1966
- ) {
1967
- hiddenTargetIds.add(element.id);
1968
- }
1969
- });
1970
- }
1971
- const nextElements = latestElements
1972
- .map((element) => hiddenTargetIds.has(element.id) ? { ...element, isDeleted: true } : element)
1973
- .concat(imageElement);
1974
- updateScene(nextElements);
1975
- }
1976
-
1977
- async function applyCardOperation(op) {
1978
- if (op.source === "browser") {
1979
- return;
1980
- }
1981
- await addBlankCard();
1982
- }
1983
-
1984
- async function pollOperations() {
1985
- try {
1986
- const result = await fetchJson("/api/ops?after=" + encodeURIComponent(String(lastSeq)));
1987
- for (const op of result.ops || []) {
1988
- lastSeq = Math.max(lastSeq, Number(op.seq || 0));
1989
- if (op.type === "image") {
1990
- await applyImageOperation(op);
1991
- }
1992
- if (op.type === "card") {
1993
- await applyCardOperation(op);
1994
- }
1995
- if (op.type === "handoff-relay") {
1996
- syncCodexRelayStatus(op);
1997
- }
1998
- if (op.type === "agent-foreground-relay" || op.type === "agent-foreground-relay-status") {
1999
- syncAgentBridgeStatus(op);
2000
- }
2001
- }
2002
- } catch (error) {
2003
- console.warn("OpenPrd canvas op polling failed", error);
2004
- }
2005
- }
2006
-
2007
- async function readAgentBridgeOutbox() {
2008
- try {
2009
- const result = await fetchJson("/api/agent-bridge");
2010
- const pending = Array.isArray(result.pending) ? result.pending : [];
2011
- if (pending.length === 0) {
2012
- return;
2013
- }
2014
- const claimed = pending.find((item) => item.status === "claimed");
2015
- const item = claimed || pending[0];
2016
- syncAgentBridgeStatus({ status: item.status || "pending" });
2017
- } catch (error) {
2018
- console.warn("OpenPrd canvas agent bridge polling failed", error);
2019
- }
2020
- }
2021
-
2022
- async function exportCanvasElements(options = {}) {
2023
- if (!excalidrawAPI || !exportToCanvas) {
2024
- throw new Error("Canvas API is not ready.");
2025
- }
2026
- const visibleElements = currentVisibleElements();
2027
- const selected = currentSelectedElements();
2028
- if (options.requireSelection && selected.length === 0) {
2029
- const error = new Error("empty-selection");
2030
- error.code = "empty-selection";
2031
- throw error;
2032
- }
2033
- const elements = selected.length > 0 ? selected : visibleElements;
2034
- if (elements.length === 0) {
2035
- const error = new Error("empty-scene");
2036
- error.code = "empty-scene";
2037
- throw error;
2038
- }
2039
- const canvas = await exportToCanvas({
2040
- elements,
2041
- appState: {
2042
- ...stripAppState(latestAppState),
2043
- exportBackground: true,
2044
- viewBackgroundColor: latestAppState?.viewBackgroundColor || "#ffffff",
2045
- },
2046
- files: excalidrawAPI.getFiles(),
2047
- exportPadding: 24,
2048
- maxWidthOrHeight: 2400,
2049
- });
2050
- const dataUrl = canvas.toDataURL("image/png");
2051
- const intent = options.intent || "manual-export";
2052
- const result = await fetchJson("/api/exports", {
2053
- method: "POST",
2054
- body: JSON.stringify({
2055
- dataUrl,
2056
- selection: selected.length > 0 ? "selected" : "scene",
2057
- selectedElementIds: selected.map((element) => element.id),
2058
- elementCount: elements.length,
2059
- intent,
2060
- locale: options.locale || null,
2061
- filename: options.filename || null,
2062
- }),
2063
- });
2064
- window.dispatchEvent(new CustomEvent("openprd-canvas-exported", { detail: { ...result, intent } }));
2065
- return {
2066
- result,
2067
- elements,
2068
- selected,
2069
- };
2070
- }
2071
-
2072
- async function exportSelection() {
2073
- return exportCanvasElements({ intent: "manual-export" }).catch((error) => {
2074
- console.warn("OpenPrd canvas export failed", error);
2075
- return null;
2076
- });
2077
- }
2078
-
2079
- function CanvasApp() {
2080
- const [initialData, setInitialData] = React.useState(null);
2081
- const [loadError, setLoadError] = React.useState(null);
2082
- const [locale, setLocale] = React.useState(initialLocale);
2083
- const [selectedCount, setSelectedCount] = React.useState(0);
2084
- const [selectedImageCount, setSelectedImageCount] = React.useState(0);
2085
- const [isSending, setIsSending] = React.useState(false);
2086
- const [isExporting, setIsExporting] = React.useState(false);
2087
- const [isDownloadingSelection, setIsDownloadingSelection] = React.useState(false);
2088
- const [isOpeningFolder, setIsOpeningFolder] = React.useState(false);
2089
- const [actionStatus, setActionStatus] = React.useState(null);
2090
- const [composer, setComposer] = React.useState(null);
2091
- const [imageComposer, setImageComposer] = React.useState(null);
2092
- const [isRequestingImage, setIsRequestingImage] = React.useState(false);
2093
- const [libraryItems, setLibraryItems] = React.useState([]);
2094
- const [libraryPreviews, setLibraryPreviews] = React.useState({});
2095
- const [guide, setGuide] = React.useState(null);
2096
- const [isImportingDirections, setIsImportingDirections] = React.useState(false);
2097
- const latestHandoffSignalSeqRef = React.useRef(0);
2098
- const t = messagesFor(locale);
2099
- const aiTemplates = React.useMemo(() => imageTemplateOptions(t), [locale]);
2100
-
2101
- React.useEffect(() => {
2102
- syncSelectionState = (appState, elements) => {
2103
- setSelectedCount(selectedCountFromAppState(appState));
2104
- setSelectedImageCount(selectedImageCountFromAppState(appState, elements));
2105
- };
2106
- return () => {
2107
- syncSelectionState = () => {};
2108
- };
2109
- }, []);
2110
-
2111
- React.useEffect(() => {
2112
- const shouldApplyHandoffSignal = (op) => {
2113
- const seq = Number(op.seq || 0);
2114
- if (seq > 0 && seq < latestHandoffSignalSeqRef.current) {
2115
- return false;
2116
- }
2117
- if (seq > 0) {
2118
- latestHandoffSignalSeqRef.current = seq;
2119
- }
2120
- return true;
2121
- };
2122
- syncCodexRelayStatus = (op) => {
2123
- if (!shouldApplyHandoffSignal(op)) {
2124
- return;
2125
- }
2126
- if (op.dryRun) {
2127
- setActionStatus(null);
2128
- return;
2129
- }
2130
- if (op.status === "completed" || op.status === "started") {
2131
- setActionStatus({ type: "success", message: t.sentBodyWithRelay });
2132
- } else if (["queued", "starting", "resuming", "connecting"].includes(op.status)) {
2133
- setActionStatus({ type: "busy", message: t.sentBodyPending });
2134
- } else if (op.status === "failed" || op.status === "timeout" || op.status === "closed") {
2135
- setActionStatus({ type: "error", message: op.error || t.agentBridgeFailed });
2136
- }
2137
- };
2138
- syncAgentBridgeStatus = (op) => {
2139
- if (!shouldApplyHandoffSignal(op)) {
2140
- return;
2141
- }
2142
- if (op.status === "sent") {
2143
- setActionStatus({ type: "success", message: t.agentBridgeSent });
2144
- } else if (op.status === "failed") {
2145
- setActionStatus({ type: "error", message: op.reason || t.agentBridgeFailed });
2146
- } else if (op.status === "pending" || op.status === "claimed") {
2147
- setActionStatus({ type: "busy", message: agentBridgePendingMessage(t, op.status) });
2148
- }
2149
- };
2150
- return () => {
2151
- syncCodexRelayStatus = () => {};
2152
- syncAgentBridgeStatus = () => {};
2153
- };
2154
- }, [t]);
2155
-
2156
- React.useEffect(() => {
2157
- document.documentElement.lang = locale;
2158
- try {
2159
- window.localStorage.setItem(localeConfig.storageKey, locale);
2160
- } catch {}
2161
- }, [locale]);
2162
-
2163
- React.useEffect(() => {
2164
- let mounted = true;
2165
- Promise.all([
2166
- fetchJson("/api/scene"),
2167
- fetchJson("/api/library").catch(() => ({ libraryItems: [] })),
2168
- ])
2169
- .then(([scene, library]) => {
2170
- if (mounted) {
2171
- const libraryItems = libraryItemsFromPayload(library);
2172
- const normalizedScene = normalizeInitialScene(scene);
2173
- setInitialData({
2174
- ...normalizedScene,
2175
- libraryItems,
2176
- });
2177
- setLibraryItems(libraryItems);
2178
- latestElements = normalizedScene.elements;
2179
- latestAppState = normalizedScene.appState;
2180
- latestFiles = normalizedScene.files;
2181
- setSelectedCount(selectedCountFromAppState(normalizedScene.appState));
2182
- setSelectedImageCount(selectedImageCountFromAppState(normalizedScene.appState, normalizedScene.elements));
2183
- if (scene?.appState?.viewBackgroundColor !== normalizedScene.appState.viewBackgroundColor) {
2184
- fetchJson("/api/scene", {
2185
- method: "POST",
2186
- body: JSON.stringify({
2187
- type: "openprd.canvas.scene.v1",
2188
- elements: normalizedScene.elements,
2189
- appState: stripAppState(normalizedScene.appState),
2190
- files: normalizedScene.files,
2191
- savedAt: new Date().toISOString(),
2192
- }),
2193
- }).catch((error) => {
2194
- console.warn("OpenPrd canvas background migration failed", error);
2195
- });
2196
- }
2197
- }
2198
- })
2199
- .catch((error) => {
2200
- if (mounted) {
2201
- setLoadError(error);
2202
- }
2203
- });
2204
- return () => {
2205
- mounted = false;
2206
- window.clearInterval(pollTimer);
2207
- };
2208
- }, []);
2209
-
2210
- React.useEffect(() => {
2211
- pollTimer = window.setInterval(pollOperations, 1400);
2212
- pollOperations();
2213
- return () => window.clearInterval(pollTimer);
2214
- }, []);
2215
-
2216
- React.useEffect(() => {
2217
- let mounted = true;
2218
- const loadGuide = () => {
2219
- fetchJson("/api/guide")
2220
- .then((result) => {
2221
- if (mounted && result && result.ok && result.currentStage) {
2222
- setGuide(result);
2223
- }
2224
- })
2225
- .catch(() => {});
2226
- };
2227
- loadGuide();
2228
- const timer = window.setInterval(loadGuide, 30000);
2229
- return () => {
2230
- mounted = false;
2231
- window.clearInterval(timer);
2232
- };
2233
- }, []);
2234
-
2235
- async function handleImportReviewDirections() {
2236
- if (isImportingDirections) {
2237
- return;
2238
- }
2239
- setIsImportingDirections(true);
2240
- try {
2241
- const result = await importReviewDirectionsToCanvas(t);
2242
- setActionStatus({ type: "success", message: formatMessage(t.reviewDirectionsImported, { count: result.count }) });
2243
- } catch (error) {
2244
- setActionStatus({
2245
- type: "error",
2246
- message: error?.code === "empty" ? t.reviewDirectionsEmpty : t.reviewDirectionsFailed,
2247
- });
2248
- } finally {
2249
- setIsImportingDirections(false);
2250
- }
2251
- }
2252
-
2253
- React.useEffect(() => {
2254
- hideNativeExcalidrawChrome();
2255
- const observer = new MutationObserver(hideNativeExcalidrawChrome);
2256
- const root = document.querySelector(".canvas-wrap");
2257
- if (root) {
2258
- observer.observe(root, { childList: true, subtree: true });
2259
- }
2260
- return () => observer.disconnect();
2261
- }, [initialData, locale]);
2262
-
2263
- React.useEffect(() => {
2264
- let cancelled = false;
2265
- async function buildPreviews() {
2266
- if (!initialData || !exportToCanvas || libraryItems.length === 0) {
2267
- setLibraryPreviews({});
2268
- return;
2269
- }
2270
- const previews = {};
2271
- for (const item of libraryItems.slice(0, 40)) {
2272
- if (cancelled || !Array.isArray(item.elements) || item.elements.length === 0) {
2273
- continue;
2274
- }
2275
- try {
2276
- const canvas = await exportToCanvas({
2277
- elements: item.elements,
2278
- appState: {
2279
- exportBackground: true,
2280
- viewBackgroundColor: "#ffffff",
2281
- },
2282
- files: currentFilesForFile(),
2283
- exportPadding: 12,
2284
- maxWidthOrHeight: 320,
2285
- });
2286
- previews[item.id] = canvas.toDataURL("image/png");
2287
- } catch (error) {
2288
- console.warn("OpenPrd canvas library preview failed", error);
2289
- }
2290
- }
2291
- if (!cancelled) {
2292
- setLibraryPreviews(previews);
2293
- }
2294
- }
2295
- buildPreviews();
2296
- return () => {
2297
- cancelled = true;
2298
- };
2299
- }, [initialData, libraryItems]);
2300
-
2301
- function changeLocale(event) {
2302
- setLocale(normalizeLocale(event.target.value));
2303
- }
2304
-
2305
- async function copyThreadPrompt() {
2306
- const copied = await copyTextToClipboard(buildThreadPrompt(t));
2307
- setActionStatus({
2308
- type: copied ? "success" : "error",
2309
- message: copied ? t.threadPromptCopied : t.threadPromptCopyFailed,
2310
- });
2311
- }
2312
-
2313
- function resetCanvas() {
2314
- if (!window.confirm(t.resetConfirm)) {
2315
- return;
2316
- }
2317
- latestElements = [];
2318
- latestFiles = {};
2319
- latestAppState = normalizeCanvasAppState({
2320
- ...latestAppState,
2321
- selectedElementIds: {},
2322
- });
2323
- excalidrawAPI?.updateScene?.({ elements: [], appState: latestAppState });
2324
- setSelectedCount(0);
2325
- setSelectedImageCount(0);
2326
- setActionStatus({ type: "success", message: t.resetCanvas });
2327
- queueSave();
2328
- }
2329
-
2330
- async function persistLibraryItems(items, intent = "sync-excalidraw-library") {
2331
- return fetchJson("/api/library", {
2332
- method: "POST",
2333
- body: JSON.stringify({
2334
- type: "excalidrawlib",
2335
- version: 2,
2336
- source: "https://openprd.local/canvas",
2337
- libraryItems: items,
2338
- intent,
2339
- locale,
2340
- }),
2341
- });
2342
- }
2343
-
2344
- function handleLibraryChange(items) {
2345
- const nextItems = Array.isArray(items) ? items : [];
2346
- setLibraryItems(nextItems);
2347
- persistLibraryItems(nextItems).catch((error) => {
2348
- console.warn("OpenPrd canvas library save failed", error);
2349
- });
2350
- }
2351
-
2352
- async function openContainingFolder(target) {
2353
- const downloadFileName = target?.downloadFileName || "";
2354
- const targetPath = target?.openPath || "";
2355
- if ((!downloadFileName && !targetPath) || isOpeningFolder) {
2356
- return;
2357
- }
2358
- setIsOpeningFolder(true);
2359
- try {
2360
- await fetchJson(downloadFileName ? "/api/open-download" : "/api/open-folder", {
2361
- method: "POST",
2362
- body: JSON.stringify(downloadFileName ? { filename: downloadFileName } : { path: targetPath }),
2363
- });
2364
- } catch (error) {
2365
- console.warn("OpenPrd canvas open folder failed", error);
2366
- setActionStatus((current) => ({
2367
- type: "error",
2368
- message: t.openFolderFailed,
2369
- openPath: current?.openPath || targetPath,
2370
- downloadFileName: current?.downloadFileName || downloadFileName,
2371
- }));
2372
- } finally {
2373
- setIsOpeningFolder(false);
2374
- }
2375
- }
2376
-
2377
- async function handleSaveCanvas() {
2378
- setActionStatus({ type: "busy", message: t.savingCanvas });
2379
- try {
2380
- const result = await saveCanvasToDisk();
2381
- setActionStatus({
2382
- type: "success",
2383
- message: formatMessage(t.savedCanvas, { filename: result.filename }),
2384
- downloadFileName: result.filename,
2385
- });
2386
- } catch (error) {
2387
- console.warn("OpenPrd canvas save-as failed", error);
2388
- setActionStatus({ type: "error", message: t.saveFailed });
2389
- }
2390
- }
2391
-
2392
- async function handleExportImage() {
2393
- if (isExporting) {
2394
- return;
2395
- }
2396
- setIsExporting(true);
2397
- setActionStatus({ type: "busy", message: t.exportingImage });
2398
- try {
2399
- const exportData = await exportCanvasImage(locale);
2400
- const path = exportData.exportPath || exportData.fileName || exportData.exportUrl || "";
2401
- setActionStatus({
2402
- type: "success",
2403
- message: formatMessage(t.exportedImage, { path }),
2404
- openPath: exportData.exportPath || "",
2405
- });
2406
- } catch (error) {
2407
- console.warn("OpenPrd canvas image export failed", error);
2408
- setActionStatus({ type: "error", message: error?.code === "empty-scene" ? t.emptySceneExportBody : t.exportFailed });
2409
- } finally {
2410
- setIsExporting(false);
2411
- }
2412
- }
2413
-
2414
- async function downloadSelectedImage() {
2415
- if (isDownloadingSelection || selectedImageCount <= 0) {
2416
- return;
2417
- }
2418
- setIsDownloadingSelection(true);
2419
- setActionStatus({ type: "busy", message: t.downloadingSelectedImage });
2420
- try {
2421
- const filename = createExportFilename("openprd-selected-image");
2422
- const exported = await exportCanvasElements({
2423
- requireSelection: true,
2424
- intent: "download-selected-image",
2425
- locale,
2426
- filename,
2427
- });
2428
- const exportData = exported.result.export || {};
2429
- const result = await fetchJson("/api/download-image", {
2430
- method: "POST",
2431
- body: JSON.stringify({
2432
- path: exportData.exportPath,
2433
- filename: exportData.fileName || filename,
2434
- }),
2435
- });
2436
- const fileName = result.file?.fileName || exportData.fileName || filename;
2437
- setActionStatus({
2438
- type: "success",
2439
- message: formatMessage(t.downloadedSelectedImage, { filename: fileName }),
2440
- downloadFileName: fileName,
2441
- });
2442
- } catch (error) {
2443
- console.warn("OpenPrd canvas selected image download failed", error);
2444
- setActionStatus({ type: "error", message: t.downloadSelectedImageFailed });
2445
- } finally {
2446
- setIsDownloadingSelection(false);
2447
- }
2448
- }
2449
-
2450
- function libraryItemName(item, index) {
2451
- return item?.name || formatMessage(t.libraryItemName, {
2452
- index: index + 1,
2453
- count: Array.isArray(item?.elements) ? item.elements.length : 0,
2454
- });
2455
- }
2456
-
2457
- function insertLibraryItem(item) {
2458
- if (!item || !Array.isArray(item.elements) || item.elements.length === 0) {
2459
- return;
2460
- }
2461
- const bounds = boundsForElements(item.elements);
2462
- const point = getViewportDropPoint(bounds.width, bounds.height);
2463
- const offsetX = point.x - bounds.x;
2464
- const offsetY = point.y - bounds.y;
2465
- const cloned = item.elements.map((element) => cloneLibraryElement(element, offsetX, offsetY));
2466
- updateScene([...latestElements, ...cloned]);
2467
- setActionStatus({ type: "success", message: t.insertedLibraryItem });
2468
- }
2469
-
2470
- function updateComposerInstruction(event) {
2471
- const value = event.target.value;
2472
- setComposer((current) => current ? { ...current, instruction: value, status: current.status === "sent" ? "ready" : current.status, promptText: "" } : current);
2473
- }
2474
-
2475
- async function prepareSelectionForCodex() {
2476
- if (isSending) {
2477
- return;
2478
- }
2479
- setIsSending(true);
2480
- setComposer(null);
2481
- try {
2482
- const selected = currentSelectedElements();
2483
- if (selected.length === 0) {
2484
- setComposer({
2485
- status: "error",
2486
- title: t.emptySelectionTitle,
2487
- body: t.emptySelectionBody,
2488
- });
2489
- return;
2490
- }
2491
- const filename = createExportFilename("codex-selection");
2492
- const exported = await exportCanvasElements({
2493
- requireSelection: true,
2494
- intent: "codex-handoff",
2495
- locale,
2496
- filename,
2497
- });
2498
- const exportPath = exported.result.export.exportPath;
2499
- setComposer({
2500
- status: "ready",
2501
- title: t.composerTitle,
2502
- body: "",
2503
- exported,
2504
- exportPath,
2505
- exportUrl: exported.result.export.exportUrl,
2506
- elementCount: exported.elements.length,
2507
- instruction: "",
2508
- promptText: "",
2509
- });
2510
- } catch (error) {
2511
- setComposer({
2512
- status: "error",
2513
- title: t.sendErrorTitle,
2514
- body: error.message || String(error),
2515
- });
2516
- } finally {
2517
- setIsSending(false);
2518
- }
2519
- }
2520
-
2521
- async function sendComposerToCodex() {
2522
- if (!composer?.exported || isSending) {
2523
- return;
2524
- }
2525
- setIsSending(true);
2526
- setComposer((current) => current ? { ...current, status: "sending", error: "" } : current);
2527
- try {
2528
- const instruction = String(composer.instruction || "").trim();
2529
- const promptText = buildCodexPrompt(t, composer.exportPath, composer.elementCount, instruction);
2530
- let relayTimedOut = false;
2531
- const relay = await withTimeout(recordCodexHandoff(composer.exported, promptText, locale, instruction), codexRelayUiTimeoutMs).catch((error) => {
2532
- relayTimedOut = error?.message === "timeout";
2533
- console.warn("OpenPrd canvas handoff record failed", error);
2534
- return null;
2535
- });
2536
- const copied = await withTimeout(copyTextToClipboard(promptText), 1800).catch(() => false);
2537
- const relayed = relayWasAccepted(relay);
2538
- const agentBridgePending = agentBridgeIsPending(relay);
2539
- const relayPending = relayIsPending(relay, relayTimedOut);
2540
- setComposer((current) => current ? {
2541
- ...current,
2542
- status: "sent",
2543
- copied,
2544
- body: agentBridgePending ? t.sentBodyAgentBridge : (relayed ? t.sentBodyWithRelay : (relayPending ? t.sentBodyPending : (copied ? t.sentBody : t.sendManualCopyBody))),
2545
- promptText,
2546
- } : current);
2547
- } catch (error) {
2548
- setComposer((current) => current ? {
2549
- ...current,
2550
- status: "ready",
2551
- error: error.message || String(error),
2552
- } : current);
2553
- } finally {
2554
- setIsSending(false);
2555
- }
2556
- }
2557
-
2558
- async function copyCurrentPrompt() {
2559
- if (composer?.promptText) {
2560
- await copyTextToClipboard(composer.promptText);
2561
- }
2562
- }
2563
-
2564
- function openImageTemplate(template) {
2565
- setImageComposer({
2566
- status: "ready",
2567
- template,
2568
- instruction: "",
2569
- promptText: "",
2570
- error: "",
2571
- });
2572
- }
2573
-
2574
- function updateImageComposerInstruction(event) {
2575
- const value = event.target.value;
2576
- setImageComposer((current) => current ? {
2577
- ...current,
2578
- instruction: value,
2579
- status: current.status === "sent" ? "ready" : current.status,
2580
- error: "",
2581
- } : current);
2582
- }
2583
-
2584
- async function submitImageTemplateRequest() {
2585
- if (!imageComposer?.template || isRequestingImage) {
2586
- return;
2587
- }
2588
- const instruction = String(imageComposer.instruction || "").trim();
2589
- if (!instruction) {
2590
- setImageComposer((current) => current ? { ...current, error: t.imagePromptRequired } : current);
2591
- return;
2592
- }
2593
- setIsRequestingImage(true);
2594
- setImageComposer((current) => current ? { ...current, status: "sending", error: "" } : current);
2595
- try {
2596
- const created = createAiImageCard(imageComposer.template, instruction, t);
2597
- const promptText = buildImageRequestPrompt(t, imageComposer.template, instruction, created.placeholderId);
2598
- attachAiImagePrompt(created.placeholderId, promptText);
2599
- let relayTimedOut = false;
2600
- const relay = await withTimeout(recordCodexImageRequestHandoff(promptText, locale, instruction, imageComposer.template, created), codexRelayUiTimeoutMs).catch((error) => {
2601
- relayTimedOut = error?.message === "timeout";
2602
- console.warn("OpenPrd canvas image handoff record failed", error);
2603
- return null;
2604
- });
2605
- const copied = await withTimeout(copyTextToClipboard(promptText), 1800).catch(() => false);
2606
- const relayed = relayWasAccepted(relay);
2607
- const agentBridgePending = agentBridgeIsPending(relay);
2608
- const relayPending = relayIsPending(relay, relayTimedOut);
2609
- setActionStatus({
2610
- type: relayed ? "success" : (agentBridgePending || relayPending ? "busy" : "success"),
2611
- message: agentBridgePending ? t.imageRequestAgentBridge : (relayed ? t.imageRequestSent : (relayPending ? t.imageRequestPending : (copied ? t.imageRequestPrepared : t.imageRequestManualCopy))),
2612
- });
2613
- setImageComposer(null);
2614
- } catch (error) {
2615
- console.warn("OpenPrd canvas image request failed", error);
2616
- setImageComposer((current) => current ? {
2617
- ...current,
2618
- status: "ready",
2619
- error: error.message || String(error),
2620
- } : current);
2621
- } finally {
2622
- setIsRequestingImage(false);
2623
- }
2624
- }
2625
-
2626
- if (loadError) {
2627
- return h("div", { className: "fallback" },
2628
- h("div", null,
2629
- h("strong", null, t.loadErrorTitle),
2630
- h("span", null, loadError.message || String(loadError)),
2631
- ),
2632
- );
2633
- }
2634
-
2635
- if (!initialData) {
2636
- return h("div", { className: "fallback" },
2637
- h("div", null,
2638
- h("strong", null, t.loadingTitle),
2639
- h("span", null, t.loadingBody),
2640
- ),
2641
- );
2642
- }
2643
-
2644
- return h("div", { className: "shell" },
2645
- h("header", { className: "topbar" },
2646
- h("div", { className: "brand" },
2647
- h("strong", null, "OpenPrd Canvas"),
2648
- h("span", null, t.appSubtitle),
2649
- ),
2650
- h("div", { className: "actions" },
2651
- h("select", {
2652
- className: "language-select",
2653
- "aria-label": t.languageLabel,
2654
- "data-testid": "openprd-canvas-locale",
2655
- value: locale,
2656
- onChange: changeLocale,
2657
- },
2658
- h("option", { value: "zh-CN" }, "中文"),
2659
- h("option", { value: "en" }, "English"),
2660
- ),
2661
- ),
2662
- ),
2663
- h("main", { className: "layout" },
2664
- h("section", { className: "canvas-wrap" },
2665
- h(Excalidraw, {
2666
- excalidrawAPI: (api) => {
2667
- excalidrawAPI = api;
2668
- window.__openprdExcalidrawAPI = api;
2669
- },
2670
- initialData,
2671
- name: "OpenPrd Canvas",
2672
- langCode: locale,
2673
- UIOptions: {
2674
- canvasActions: {
2675
- loadScene: false,
2676
- saveToActiveFile: false,
2677
- export: {
2678
- saveFileToDisk: true,
2679
- },
2680
- },
2681
- },
2682
- onChange: (elements, appState, files) => {
2683
- latestElements = elements || [];
2684
- latestAppState = normalizeCanvasAppState(appState || {});
2685
- latestFiles = files || {};
2686
- setSelectedCount(selectedCountFromAppState(latestAppState));
2687
- setSelectedImageCount(selectedImageCountFromAppState(latestAppState, latestElements));
2688
- queueSave();
2689
- },
2690
- onLibraryChange: handleLibraryChange,
2691
- }),
2692
- selectedCount > 0 && h("div", { className: "selection-sendbar", "data-testid": "openprd-canvas-selection-sendbar" },
2693
- h("strong", null, formatMessage(t.selectedCount, { count: selectedCount })),
2694
- selectedImageCount > 0 && h("button", {
2695
- className: "text-button subtle-button",
2696
- disabled: isDownloadingSelection || isSending,
2697
- onClick: downloadSelectedImage,
2698
- "data-testid": "openprd-canvas-download-image",
2699
- }, toolIcon("export"), h("span", null, isDownloadingSelection ? t.downloadingSelectedImage : t.downloadSelectedImage)),
2700
- h("button", {
2701
- className: "text-button primary-button",
2702
- disabled: isSending,
2703
- onClick: prepareSelectionForCodex,
2704
- "data-testid": "openprd-canvas-send-codex",
2705
- }, toolIcon("send"), h("span", null, isSending ? t.sendingToCodex : t.sendToCodex)),
2706
- ),
2707
- composer && h("div", { className: "composer-backdrop", "data-testid": "openprd-canvas-composer" },
2708
- h("section", { className: "composer-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "openprd-canvas-composer-title" },
2709
- h("div", { className: "composer-header" },
2710
- h("h2", { id: "openprd-canvas-composer-title" }, composer.title || t.composerTitle),
2711
- h("button", { className: "text-button", onClick: () => setComposer(null) }, h("span", null, t.dismiss)),
2712
- ),
2713
- h("div", { className: "composer-body" },
2714
- composer.exportUrl && h(React.Fragment, null,
2715
- h("p", { className: "composer-label" }, t.previewLabel),
2716
- h("img", { className: "composer-preview", src: composer.exportUrl, alt: t.composerPreviewAlt }),
2717
- ),
2718
- composer.exported && h(React.Fragment, null,
2719
- h("p", { className: "composer-label" }, t.instructionLabel),
2720
- h("textarea", {
2721
- className: "composer-input",
2722
- value: composer.instruction || "",
2723
- placeholder: t.instructionPlaceholder,
2724
- disabled: isSending || composer.status === "sent",
2725
- onChange: updateComposerInstruction,
2726
- "data-testid": "openprd-canvas-composer-prompt",
2727
- }),
2728
- ),
2729
- composer.body && h("p", { className: "composer-status" }, composer.body),
2730
- composer.error && h("p", { className: "composer-error" }, composer.error),
2731
- ),
2732
- h("div", { className: "composer-actions" },
2733
- composer.promptText && h("button", { className: "text-button", onClick: copyCurrentPrompt }, toolIcon("copy"), h("span", null, t.copyPrompt)),
2734
- composer.exported && h("button", {
2735
- className: "text-button primary-button",
2736
- disabled: isSending || composer.status === "sent",
2737
- onClick: sendComposerToCodex,
2738
- "data-testid": "openprd-canvas-composer-submit",
2739
- }, toolIcon("send"), h("span", null, isSending || composer.status === "sending" ? t.sendingToCodex : t.sendToCodex)),
2740
- ),
2741
- ),
2742
- ),
2743
- imageComposer && h("div", { className: "composer-backdrop", "data-testid": "openprd-canvas-image-composer" },
2744
- h("section", { className: "composer-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "openprd-canvas-image-composer-title" },
2745
- h("div", { className: "composer-header" },
2746
- h("h2", { id: "openprd-canvas-image-composer-title" }, t.aiImageComposerTitle),
2747
- h("button", { className: "text-button", onClick: () => setImageComposer(null), disabled: isRequestingImage }, h("span", null, t.dismiss)),
2748
- ),
2749
- h("div", { className: "composer-body" },
2750
- h("p", { className: "composer-label" }, t.aiImageTemplateLabel),
2751
- h("div", { className: "template-compose-preview" },
2752
- h("div", {
2753
- className: "template-compose-frame",
2754
- style: { aspectRatio: imageComposer.template.width + " / " + imageComposer.template.height },
2755
- }, h("span", null, imageComposer.template.label)),
2756
- ),
2757
- h("p", { className: "composer-label" }, t.aiImageInstructionLabel),
2758
- h("textarea", {
2759
- className: "composer-input",
2760
- value: imageComposer.instruction || "",
2761
- placeholder: t.aiImageInstructionPlaceholder,
2762
- disabled: isRequestingImage,
2763
- onChange: updateImageComposerInstruction,
2764
- "data-testid": "openprd-canvas-image-prompt",
2765
- }),
2766
- imageComposer.error && h("p", { className: "composer-error" }, imageComposer.error),
2767
- ),
2768
- h("div", { className: "composer-actions" },
2769
- h("button", { className: "text-button", onClick: () => setImageComposer(null), disabled: isRequestingImage }, h("span", null, t.dismiss)),
2770
- h("button", {
2771
- className: "text-button primary-button",
2772
- disabled: isRequestingImage,
2773
- onClick: submitImageTemplateRequest,
2774
- "data-testid": "openprd-canvas-create-ai-image",
2775
- }, toolIcon("send"), h("span", null, isRequestingImage ? t.sendingToCodex : t.createAiImageCard)),
2776
- ),
2777
- ),
2778
- ),
2779
- ),
2780
- h("aside", { className: "rail" },
2781
- h("section", { className: "thread-summary", "data-testid": "openprd-canvas-thread-summary" },
2782
- h("p", { className: "thread-title" }, conversationTitle(t)),
2783
- h("div", { className: "thread-id-row" },
2784
- h("p", { className: "thread-id" }, conversationId()),
2785
- h("button", {
2786
- className: "thread-copy-button",
2787
- type: "button",
2788
- title: t.copyThreadPrompt,
2789
- "aria-label": t.copyThreadPrompt,
2790
- onClick: copyThreadPrompt,
2791
- "data-testid": "openprd-canvas-copy-thread",
2792
- }, toolIcon("copy")),
2793
- ),
2794
- ),
2795
- h("section", { className: "rail-actions", "data-testid": "openprd-canvas-rail-actions" },
2796
- h("div", { className: "rail-action-group", "data-testid": "openprd-canvas-review-board" },
2797
- h("p", { className: "rail-heading" }, t.reviewBoardTitle),
2798
- guide && h("div", { className: "guide-strip", "data-testid": "openprd-canvas-guide" },
2799
- h("strong", null, formatMessage(t.reviewGuideStage, {
2800
- index: guide.currentStage.index,
2801
- total: guide.totalStages,
2802
- title: guide.currentStage.title,
2803
- })),
2804
- guide.nextAction && h("span", null, formatMessage(t.reviewGuideNext, { action: guide.nextAction })),
2805
- ),
2806
- h("button", {
2807
- className: "rail-action-button",
2808
- title: t.importReviewDirections,
2809
- disabled: isImportingDirections,
2810
- onClick: handleImportReviewDirections,
2811
- "data-testid": "openprd-canvas-import-directions",
2812
- }, toolIcon("export"), h("span", null, isImportingDirections ? t.importingReviewDirections : t.importReviewDirections)),
2813
- ),
2814
- h("div", { className: "rail-action-group" },
2815
- h("p", { className: "rail-heading" }, t.canvasActionsTitle),
2816
- h("div", { className: "rail-action-grid" },
2817
- h("button", {
2818
- className: "rail-action-button",
2819
- title: t.saveCanvas,
2820
- onClick: handleSaveCanvas,
2821
- "data-testid": "openprd-canvas-save",
2822
- }, toolIcon("save"), h("span", null, t.saveCanvas)),
2823
- h("button", {
2824
- className: "rail-action-button",
2825
- title: t.exportImage,
2826
- disabled: isExporting,
2827
- onClick: handleExportImage,
2828
- "data-testid": "openprd-canvas-export-image",
2829
- }, toolIcon("export"), h("span", null, isExporting ? t.exportingImage : t.exportImage)),
2830
- h("button", {
2831
- className: "rail-action-button danger",
2832
- title: t.resetCanvas,
2833
- onClick: resetCanvas,
2834
- "data-testid": "openprd-canvas-reset",
2835
- }, toolIcon("reset"), h("span", null, t.resetCanvas)),
2836
- ),
2837
- actionStatus && h("div", {
2838
- className: "action-status" + (actionStatus.type === "error" ? " error" : "") + (actionStatus.type === "busy" ? " busy" : ""),
2839
- "data-testid": "openprd-canvas-action-status",
2840
- },
2841
- h("span", null, actionStatus.message),
2842
- (actionStatus.openPath || actionStatus.downloadFileName) && h("button", {
2843
- className: "status-folder-button",
2844
- disabled: isOpeningFolder,
2845
- onClick: () => openContainingFolder(actionStatus),
2846
- "data-testid": "openprd-canvas-open-folder",
2847
- }, isOpeningFolder ? t.openingFolder : t.openContainingFolder),
2848
- ),
2849
- ),
2850
- h("div", { className: "rail-action-group" },
2851
- h("p", { className: "rail-heading" }, t.aiImageTemplatesTitle),
2852
- h("div", { className: "template-grid", "data-testid": "openprd-canvas-ai-templates" },
2853
- aiTemplates.map((template) => h("button", {
2854
- key: template.id,
2855
- className: "template-tile",
2856
- title: template.title + " " + template.label,
2857
- onClick: () => openImageTemplate(template),
2858
- "data-testid": "openprd-canvas-ai-template-" + template.id,
2859
- },
2860
- h("span", {
2861
- className: "template-preview",
2862
- style: { aspectRatio: template.width + " / " + template.height },
2863
- "aria-hidden": "true",
2864
- }),
2865
- h("strong", null, template.label),
2866
- h("span", null, template.title),
2867
- )),
2868
- ),
2869
- ),
2870
- h("div", { className: "rail-action-group" },
2871
- h("p", { className: "rail-heading" }, t.personalLibraryTitle),
2872
- h("div", { className: "library-grid", "data-testid": "openprd-canvas-personal-library" },
2873
- libraryItems.map((item, index) => h("button", {
2874
- key: item.id || index,
2875
- className: "library-tile",
2876
- title: libraryItemName(item, index),
2877
- onClick: () => insertLibraryItem(item),
2878
- "data-testid": "openprd-canvas-library-item",
2879
- },
2880
- h("span", { className: "library-item-preview", "aria-hidden": "true" },
2881
- libraryPreviews[item.id]
2882
- ? h("img", { src: libraryPreviews[item.id], alt: "" })
2883
- : h("span", null, formatMessage(t.libraryItemCount, { count: Array.isArray(item.elements) ? item.elements.length : 0 })),
2884
- ),
2885
- h("span", null, libraryItemName(item, index)),
2886
- )),
2887
- h("button", {
2888
- className: "library-tile",
2889
- title: t.blankCard,
2890
- onClick: () => addBlankCard(t.placeholderText),
2891
- "data-testid": "openprd-canvas-blank-card",
2892
- },
2893
- h("span", { className: "library-card-preview", "aria-hidden": "true" }),
2894
- h("span", null, t.blankCard),
2895
- ),
2896
- ),
2897
- ),
2898
- ),
2899
- ),
2900
- ),
2901
- );
2902
- }
2903
-
2904
- createRoot(document.getElementById("root")).render(h(CanvasApp));
128
+ ${CANVAS_APP_CLIENT_HELPERS}
129
+ ${CANVAS_APP_CLIENT_APP}
2905
130
  </script>
2906
131
  </body>
2907
132
  </html>`;