@dickpy/dsh-imagegen 1.0.3 → 1.0.4

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/lib/index.js CHANGED
@@ -14,7 +14,7 @@ import { spawn } from "node:child_process";
14
14
  /** Settings namespace this plugin owns (host settings seam + bridge). */
15
15
  const IMAGEGEN_SETTINGS_NAMESPACE = "dsh-imagegen";
16
16
  /** Published package version shared by the host updater and the client UI. */
17
- const PLUGIN_VERSION = "1.0.3";
17
+ const PLUGIN_VERSION = "1.0.4";
18
18
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
19
19
  const SETTINGS_API = {
20
20
  describe: "/api/dsh-imagegen/settings/describe",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dickpy/dsh-imagegen",
3
3
  "description": "AI 生图 (image generation) plugin for the dsh web GUI: text-to-image and image-to-image through a configurable OpenAI-compatible endpoint (gpt-image-2 / gpt-image-1 / dall-e-3), with a settings card for api_url / api_key and a sidebar entry opening a split-pane generation studio.",
4
- "version": "1.0.3",
4
+ "version": "1.0.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -42,6 +42,13 @@ const DETAILS = ['', 'standard', 'high'] as const
42
42
 
43
43
  const PROMPT_MAX = 2000
44
44
  const REF_IMAGE_MAX_BYTES = 10 * 1024 * 1024
45
+ const PREVIEW_SCALE_MIN = 0.5
46
+ const PREVIEW_SCALE_MAX = 3
47
+ const PREVIEW_SCALE_STEP = 0.25
48
+
49
+ function clampPreviewScale(scale: number): number {
50
+ return Math.min(PREVIEW_SCALE_MAX, Math.max(PREVIEW_SCALE_MIN, scale))
51
+ }
45
52
 
46
53
  /** Read the current config from the settings scope snapshot. */
47
54
  function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
@@ -133,6 +140,8 @@ export function ImageGenPanel(props: {
133
140
  const [history, setHistory] = useState<HistoryEntry[]>([])
134
141
  const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
135
142
  const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
143
+ const [previewScale, setPreviewScale] = useState(1)
144
+ const [promptCopied, setPromptCopied] = useState(false)
136
145
  const [update, setUpdate] = useState<UpdateInfo | null>(null)
137
146
  const [updating, setUpdating] = useState(false)
138
147
  const [updateMessage, setUpdateMessage] = useState<string | null>(null)
@@ -242,10 +251,20 @@ export function ImageGenPanel(props: {
242
251
  /** Open the full-screen image preview at a given index. */
243
252
  const openPreview = (previewImages: GeneratedImage[], index: number): void => {
244
253
  setPreview({ images: previewImages, index })
254
+ setPreviewScale(1)
255
+ setPromptCopied(false)
256
+ }
257
+
258
+ const closePreview = (): void => {
259
+ setPreview(null)
260
+ setPreviewScale(1)
261
+ setPromptCopied(false)
245
262
  }
246
263
 
247
264
  /** Step the preview by ±1, wrapping around. */
248
265
  const stepPreview = (delta: number): void => {
266
+ setPreviewScale(1)
267
+ setPromptCopied(false)
249
268
  setPreview(current => {
250
269
  if (current === null) return null
251
270
  const total = current.images.length
@@ -257,9 +276,12 @@ export function ImageGenPanel(props: {
257
276
  useEffect(() => {
258
277
  if (preview === null) return
259
278
  const onKey = (event: KeyboardEvent): void => {
260
- if (event.key === 'Escape') setPreview(null)
279
+ if (event.key === 'Escape') closePreview()
261
280
  else if (event.key === 'ArrowLeft') stepPreview(-1)
262
281
  else if (event.key === 'ArrowRight') stepPreview(1)
282
+ else if (event.key === '+' || event.key === '=') setPreviewScale(current => clampPreviewScale(current + PREVIEW_SCALE_STEP))
283
+ else if (event.key === '-') setPreviewScale(current => clampPreviewScale(current - PREVIEW_SCALE_STEP))
284
+ else if (event.key === '0') setPreviewScale(1)
263
285
  }
264
286
  window.addEventListener('keydown', onKey)
265
287
  return () => window.removeEventListener('keydown', onKey)
@@ -322,6 +344,40 @@ export function ImageGenPanel(props: {
322
344
  const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
323
345
  const previewImage = preview === null ? null : preview.images[preview.index] ?? null
324
346
 
347
+ const copyPreviewPrompt = async (text: string): Promise<void> => {
348
+ try {
349
+ if (navigator.clipboard?.writeText !== undefined) {
350
+ await navigator.clipboard.writeText(text)
351
+ } else {
352
+ const textarea = document.createElement('textarea')
353
+ textarea.value = text
354
+ textarea.style.position = 'fixed'
355
+ textarea.style.opacity = '0'
356
+ document.body.appendChild(textarea)
357
+ textarea.select()
358
+ const copied = document.execCommand('copy')
359
+ textarea.remove()
360
+ if (!copied) throw new Error('copy failed')
361
+ }
362
+ setPromptCopied(true)
363
+ window.setTimeout(() => { setPromptCopied(false) }, 1800)
364
+ } catch {
365
+ setPromptCopied(false)
366
+ }
367
+ }
368
+
369
+ const addPreviewToEdit = (): void => {
370
+ if (previewImage === null || preview === null) return
371
+ setMode('edit')
372
+ setRefImage({
373
+ dataUrl: srcOf(previewImage),
374
+ name: `dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`,
375
+ })
376
+ if (prompt.trim() === '' && previewImage.revisedPrompt !== undefined) setPrompt(previewImage.revisedPrompt)
377
+ setError(null)
378
+ closePreview()
379
+ }
380
+
325
381
  return (
326
382
  <div className={css.panel}>
327
383
  <header className={css.panelHeader}>
@@ -569,7 +625,7 @@ export function ImageGenPanel(props: {
569
625
  <span className={css.canvasHistoryTag}>{tt('history.viewing', { time: formatTime(viewingEntry.createdAt) })}</span>
570
626
  ) : null}
571
627
  </div>
572
- <div className={css.grid}>
628
+ <div className={css.grid} data-count={images.length}>
573
629
  {images.map((image, index) => (
574
630
  <figure
575
631
  key={index}
@@ -677,9 +733,9 @@ export function ImageGenPanel(props: {
677
733
  role="dialog"
678
734
  aria-modal="true"
679
735
  aria-label={tt('preview.title')}
680
- onClick={() => { setPreview(null) }}
736
+ onClick={closePreview}
681
737
  >
682
- <button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} onClick={() => { setPreview(null) }}>
738
+ <button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} title={tt('preview.close')} onClick={closePreview}>
683
739
  <svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
684
740
  </button>
685
741
  {preview.images.length > 1 ? (
@@ -693,25 +749,60 @@ export function ImageGenPanel(props: {
693
749
  </>
694
750
  ) : null}
695
751
  <figure className={css.lightboxFigure} onClick={(event) => { event.stopPropagation() }}>
696
- <img
697
- className={css.lightboxImage}
698
- src={srcOf(previewImage)}
699
- alt={previewImage.revisedPrompt ?? tt('preview.title')}
700
- />
752
+ <div
753
+ className={css.lightboxStage}
754
+ onWheel={(event) => {
755
+ event.preventDefault()
756
+ setPreviewScale(current => clampPreviewScale(current + (event.deltaY < 0 ? PREVIEW_SCALE_STEP : -PREVIEW_SCALE_STEP)))
757
+ }}
758
+ >
759
+ <img
760
+ className={css.lightboxImage}
761
+ style={{ transform: `scale(${previewScale})` }}
762
+ src={srcOf(previewImage)}
763
+ alt={previewImage.revisedPrompt ?? tt('preview.title')}
764
+ />
765
+ </div>
766
+ <div className={css.lightboxTools} role="group" aria-label={tt('preview.zoomControls')}>
767
+ <button type="button" className={css.lightboxTool} aria-label={tt('preview.zoomOut')} title={tt('preview.zoomOut')} onClick={() => { setPreviewScale(current => clampPreviewScale(current - PREVIEW_SCALE_STEP)) }}>
768
+ <svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.2"/><path d="M4.8 7h4.4M13 13l-2.8-2.8"/></svg>
769
+ </button>
770
+ <button type="button" className={css.lightboxZoomLevel} aria-label={tt('preview.zoomReset')} title={tt('preview.zoomReset')} onClick={() => { setPreviewScale(1) }}>
771
+ {tt('preview.zoomLevel', { percent: Math.round(previewScale * 100) })}
772
+ </button>
773
+ <button type="button" className={css.lightboxTool} aria-label={tt('preview.zoomIn')} title={tt('preview.zoomIn')} onClick={() => { setPreviewScale(current => clampPreviewScale(current + PREVIEW_SCALE_STEP)) }}>
774
+ <svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.2"/><path d="M7 4.8v4.4M4.8 7h4.4M13 13l-2.8-2.8"/></svg>
775
+ </button>
776
+ </div>
701
777
  {previewImage.revisedPrompt !== undefined ? (
702
- <figcaption className={css.lightboxCaption} title={previewImage.revisedPrompt}>
703
- {tt('revisedPrompt', { prompt: previewImage.revisedPrompt })}
704
- </figcaption>
778
+ <div className={css.lightboxCaptionRow}>
779
+ <figcaption className={css.lightboxCaption} title={previewImage.revisedPrompt}>
780
+ {tt('revisedPrompt', { prompt: previewImage.revisedPrompt })}
781
+ </figcaption>
782
+ <button type="button" className={css.lightboxCopy} aria-label={tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')} title={tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')} onClick={() => { void copyPreviewPrompt(previewImage.revisedPrompt!) }}>
783
+ {promptCopied ? (
784
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 8l3 3 7-7"/></svg>
785
+ ) : (
786
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="5" y="5" width="7" height="8" rx="1"/><path d="M3 10V3.8c0-.44.36-.8.8-.8H9"/></svg>
787
+ )}
788
+ <span>{tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')}</span>
789
+ </button>
790
+ </div>
705
791
  ) : null}
706
792
  <div className={css.lightboxMeta}>
707
793
  <span className={css.lightboxIndex}>{tt('preview.index', { index: preview.index + 1, total: preview.images.length })}</span>
708
- <a
709
- className={css.lightboxDownload}
710
- href={srcOf(previewImage)}
711
- download={`dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`}
712
- >
713
- {tt('download')}
714
- </a>
794
+ <span className={css.lightboxActions}>
795
+ <button type="button" className={css.lightboxEdit} onClick={addPreviewToEdit}>
796
+ {tt('preview.addToEdit')}
797
+ </button>
798
+ <a
799
+ className={css.lightboxDownload}
800
+ href={srcOf(previewImage)}
801
+ download={`dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`}
802
+ >
803
+ {tt('download')}
804
+ </a>
805
+ </span>
715
806
  </div>
716
807
  </figure>
717
808
  </div>,
@@ -72,6 +72,14 @@ export const zh = {
72
72
  'preview.prev': '上一张',
73
73
  'preview.next': '下一张',
74
74
  'preview.index': '{index} / {total}',
75
+ 'preview.zoomControls': '图片缩放控制',
76
+ 'preview.zoomIn': '放大',
77
+ 'preview.zoomOut': '缩小',
78
+ 'preview.zoomReset': '重置缩放',
79
+ 'preview.zoomLevel': '{percent}%',
80
+ 'preview.copyPrompt': '复制提示词',
81
+ 'preview.copied': '已复制',
82
+ 'preview.addToEdit': '添加到图生图',
75
83
  // config banner
76
84
  'config.missing': '尚未配置 API:请前往「设置 → 插件 → 可配置」为 AI 生图填写 api_url 与 api_key。',
77
85
  'config.configured': '已连接 {url}',
@@ -178,6 +186,14 @@ export const en: Record<keyof typeof zh, string> = {
178
186
  'preview.prev': 'Previous',
179
187
  'preview.next': 'Next',
180
188
  'preview.index': '{index} / {total}',
189
+ 'preview.zoomControls': 'Image zoom controls',
190
+ 'preview.zoomIn': 'Zoom in',
191
+ 'preview.zoomOut': 'Zoom out',
192
+ 'preview.zoomReset': 'Reset zoom',
193
+ 'preview.zoomLevel': '{percent}%',
194
+ 'preview.copyPrompt': 'Copy prompt',
195
+ 'preview.copied': 'Copied',
196
+ 'preview.addToEdit': 'Add to image to image',
181
197
  'config.missing': 'API not configured: open "Settings → Plugins → Configurable" and fill in api_url and api_key for AI Image.',
182
198
  'config.configured': 'Connected to {url}',
183
199
  'config.disabled': 'The plugin is disabled — re-enable it in Settings.',
@@ -798,9 +798,29 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
798
798
 
799
799
  .grid {
800
800
  display: grid;
801
- grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
801
+ flex: 1;
802
+ min-height: 0;
802
803
  gap: 14px;
803
- align-content: start;
804
+ }
805
+
806
+ .grid[data-count='1'] {
807
+ grid-template-columns: minmax(0, 1fr);
808
+ grid-template-rows: minmax(0, 1fr);
809
+ }
810
+
811
+ .grid[data-count='2'] {
812
+ grid-template-columns: repeat(2, minmax(0, 1fr));
813
+ grid-template-rows: minmax(0, 1fr);
814
+ }
815
+
816
+ .grid[data-count='3'] {
817
+ grid-template-columns: repeat(3, minmax(0, 1fr));
818
+ grid-template-rows: minmax(0, 1fr);
819
+ }
820
+
821
+ .grid[data-count='4'] {
822
+ grid-template-columns: repeat(2, minmax(0, 1fr));
823
+ grid-template-rows: repeat(2, minmax(0, 1fr));
804
824
  }
805
825
 
806
826
  .imageCard {
@@ -813,6 +833,7 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
813
833
  overflow: hidden;
814
834
  background: var(--dsw-alias-bg-layer-2);
815
835
  cursor: zoom-in;
836
+ min-height: 0;
816
837
  }
817
838
 
818
839
  .imageCard:focus-visible {
@@ -822,9 +843,10 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
822
843
 
823
844
  .image {
824
845
  display: block;
846
+ flex: 1;
847
+ min-height: 0;
825
848
  width: 100%;
826
- aspect-ratio: 1 / 1;
827
- object-fit: cover;
849
+ object-fit: contain;
828
850
  background: var(--dsw-alias-bg-base);
829
851
  }
830
852
 
@@ -971,11 +993,11 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
971
993
  }
972
994
 
973
995
  .lightboxNav[data-dir='prev'] {
974
- left: 16px;
996
+ left: max(20px, calc(50% - 640px));
975
997
  }
976
998
 
977
999
  .lightboxNav[data-dir='next'] {
978
- right: 16px;
1000
+ right: max(20px, calc(50% - 640px));
979
1001
  }
980
1002
 
981
1003
  .lightboxFigure {
@@ -984,19 +1006,84 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
984
1006
  gap: 10px;
985
1007
  margin: 0;
986
1008
  max-width: min(1100px, calc(100vw - 160px));
987
- max-height: calc(100vh - 48px);
1009
+ width: min(1100px, calc(100vw - 160px));
1010
+ height: min(820px, calc(100vh - 48px));
1011
+ min-height: 0;
1012
+ }
1013
+
1014
+ .lightboxStage {
1015
+ flex: 1;
1016
+ min-height: 0;
1017
+ display: grid;
1018
+ place-items: center;
1019
+ overflow: auto;
1020
+ border-radius: 10px;
1021
+ background: rgba(255, 255, 255, 0.04);
988
1022
  }
989
1023
 
990
1024
  .lightboxImage {
991
- max-width: 100%;
992
- max-height: calc(100vh - 160px);
1025
+ max-width: calc(100% - 4px);
1026
+ max-height: calc(100% - 4px);
993
1027
  object-fit: contain;
994
1028
  border-radius: 10px;
995
1029
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
996
- background: rgba(255, 255, 255, 0.04);
1030
+ transition: transform 120ms ease;
1031
+ }
1032
+
1033
+ .lightboxTools {
1034
+ display: flex;
1035
+ align-items: center;
1036
+ justify-content: center;
1037
+ gap: 6px;
1038
+ }
1039
+
1040
+ .lightboxTool,
1041
+ .lightboxZoomLevel,
1042
+ .lightboxCopy {
1043
+ display: inline-flex;
1044
+ align-items: center;
1045
+ justify-content: center;
1046
+ color: #fff;
1047
+ background: rgba(255, 255, 255, 0.14);
1048
+ border: 1px solid rgba(255, 255, 255, 0.28);
1049
+ cursor: pointer;
1050
+ }
1051
+
1052
+ .lightboxTool,
1053
+ .lightboxZoomLevel {
1054
+ height: 32px;
1055
+ }
1056
+
1057
+ .lightboxTool {
1058
+ width: 32px;
1059
+ border-radius: 50%;
1060
+ }
1061
+
1062
+ .lightboxZoomLevel {
1063
+ min-width: 58px;
1064
+ padding: 0 9px;
1065
+ border-radius: 999px;
1066
+ font: inherit;
1067
+ font-size: 12px;
1068
+ font-variant-numeric: tabular-nums;
1069
+ }
1070
+
1071
+ .lightboxTool:hover,
1072
+ .lightboxZoomLevel:hover,
1073
+ .lightboxCopy:hover {
1074
+ background: rgba(255, 255, 255, 0.26);
1075
+ }
1076
+
1077
+ .lightboxCaptionRow {
1078
+ display: flex;
1079
+ align-items: flex-start;
1080
+ gap: 8px;
1081
+ min-width: 0;
997
1082
  }
998
1083
 
999
1084
  .lightboxCaption {
1085
+ flex: 1;
1086
+ min-width: 0;
1000
1087
  font-size: 12px;
1001
1088
  line-height: 1.6;
1002
1089
  color: rgba(255, 255, 255, 0.9);
@@ -1006,6 +1093,17 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
1006
1093
  overflow: hidden;
1007
1094
  }
1008
1095
 
1096
+ .lightboxCopy {
1097
+ flex: none;
1098
+ gap: 5px;
1099
+ min-height: 28px;
1100
+ padding: 4px 9px;
1101
+ border-radius: 999px;
1102
+ font: inherit;
1103
+ font-size: 12px;
1104
+ white-space: nowrap;
1105
+ }
1106
+
1009
1107
  .lightboxMeta {
1010
1108
  display: flex;
1011
1109
  align-items: center;
@@ -1019,8 +1117,16 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
1019
1117
  font-variant-numeric: tabular-nums;
1020
1118
  }
1021
1119
 
1022
- .lightboxDownload {
1120
+ .lightboxActions {
1121
+ display: inline-flex;
1122
+ align-items: center;
1123
+ gap: 8px;
1124
+ }
1125
+
1126
+ .lightboxDownload,
1127
+ .lightboxEdit {
1023
1128
  padding: 4px 14px;
1129
+ font: inherit;
1024
1130
  font-size: 12.5px;
1025
1131
  font-weight: 500;
1026
1132
  color: #fff;
@@ -1028,12 +1134,51 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
1028
1134
  border: 1px solid rgba(255, 255, 255, 0.28);
1029
1135
  border-radius: 999px;
1030
1136
  text-decoration: none;
1137
+ cursor: pointer;
1031
1138
  }
1032
1139
 
1033
- .lightboxDownload:hover {
1140
+ .lightboxDownload:hover,
1141
+ .lightboxEdit:hover {
1034
1142
  background: rgba(255, 255, 255, 0.26);
1035
1143
  }
1036
1144
 
1145
+ .lightboxEdit {
1146
+ color: #fff;
1147
+ background: rgba(255, 255, 255, 0.14);
1148
+ border: 1px solid rgba(255, 255, 255, 0.28);
1149
+ border-radius: 999px;
1150
+ }
1151
+
1152
+ @media (max-width: 720px) {
1153
+ .lightbox {
1154
+ padding: 16px;
1155
+ }
1156
+
1157
+ .lightboxFigure {
1158
+ width: calc(100vw - 32px);
1159
+ max-width: none;
1160
+ }
1161
+
1162
+ .lightboxNav[data-dir='prev'] {
1163
+ left: 20px;
1164
+ }
1165
+
1166
+ .lightboxNav[data-dir='next'] {
1167
+ right: 20px;
1168
+ }
1169
+
1170
+ .lightboxCaptionRow,
1171
+ .lightboxMeta {
1172
+ align-items: stretch;
1173
+ flex-direction: column;
1174
+ }
1175
+
1176
+ .lightboxCopy,
1177
+ .lightboxActions {
1178
+ align-self: flex-end;
1179
+ }
1180
+ }
1181
+
1037
1182
  @keyframes dshImageGenSpin {
1038
1183
  to { transform: rotate(360deg); }
1039
1184
  }
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const IMAGEGEN_SETTINGS_NAMESPACE = 'dsh-imagegen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '1.0.3'
11
+ export const PLUGIN_VERSION = '1.0.4'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
14
14
  export const SETTINGS_API = {