@restregular/dsh-notepad 0.2.0 → 0.3.0

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/client.js CHANGED
@@ -50,6 +50,14 @@ window.__ModuleLoader__.load({
50
50
  const SAVE_DELAY = 500;
51
51
  const MAX_SEGMENTS = 200;
52
52
  const MAX_SEGMENT_CHARS = 160000;
53
+ const MAX_IMAGES = 20;
54
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
55
+ const IMAGE_MIME = {
56
+ 'image/png': 'png',
57
+ 'image/jpeg': 'jpg',
58
+ 'image/webp': 'webp',
59
+ 'image/gif': 'gif',
60
+ };
53
61
 
54
62
  // ------------------------------------------------------------------
55
63
  // Copy
@@ -124,9 +132,21 @@ window.__ModuleLoader__.load({
124
132
  show: '显示记事本(Ctrl+Shift+N)',
125
133
  chars: '{n} 字符',
126
134
  segments: '{n} 段',
127
- hint: 'Enter 新建分段 · Shift+Enter 换行 · Ctrl+Enter 插入',
135
+ hint: 'Enter 新建分段 · Shift+Enter 换行 · Ctrl+Enter 插入 · 支持粘贴图片',
128
136
  workspace: '工作区',
129
137
  resize: '拖动调整大小',
138
+ addImage: '添加图片',
139
+ removeImage: '移除图片',
140
+ openImage: '打开图片',
141
+ imageAdded: '已添加 {n} 张图片',
142
+ imageFailed: '图片添加失败',
143
+ imageUnsupported: '只支持 PNG / JPEG / WebP / GIF 图片',
144
+ imageTooLarge: '图片太大(最大 20 MB)',
145
+ imageTooMany: '每段最多 {n} 张图片',
146
+ imageUploading: '图片处理中…',
147
+ images: '{n} 张图片',
148
+ dropImage: '松开以添加图片',
149
+ imagePasteSaved: '已保存到 .dsh/notepad/images',
130
150
  },
131
151
  en: {
132
152
  title: 'Notepad',
@@ -197,9 +217,21 @@ window.__ModuleLoader__.load({
197
217
  show: 'Show notepad (Ctrl+Shift+N)',
198
218
  chars: '{n} chars',
199
219
  segments: '{n} segments',
200
- hint: 'Enter new segment · Shift+Enter newline · Ctrl+Enter insert',
220
+ hint: 'Enter new segment · Shift+Enter newline · Ctrl+Enter insert · Paste images',
201
221
  workspace: 'Workspace',
202
222
  resize: 'Drag to resize',
223
+ addImage: 'Add image',
224
+ removeImage: 'Remove image',
225
+ openImage: 'Open image',
226
+ imageAdded: 'Added {n} image(s)',
227
+ imageFailed: 'Could not add image',
228
+ imageUnsupported: 'Only PNG / JPEG / WebP / GIF images are supported',
229
+ imageTooLarge: 'Image is too large (20 MB max)',
230
+ imageTooMany: 'At most {n} images per segment',
231
+ imageUploading: 'Processing image…',
232
+ images: '{n} image(s)',
233
+ dropImage: 'Drop to add the image',
234
+ imagePasteSaved: 'Saved under .dsh/notepad/images',
203
235
  },
204
236
  };
205
237
 
@@ -438,6 +470,24 @@ window.__ModuleLoader__.load({
438
470
  );
439
471
  }
440
472
 
473
+ /** Picture frame with a mountain: image attachment. */
474
+ function ImageIcon({ size = 16 }) {
475
+ return h(
476
+ Svg,
477
+ { size, viewBox: '0 0 16 16' },
478
+ h('rect', {
479
+ x: 2.2, y: 3.2, width: 11.6, height: 9.6, rx: 1.6,
480
+ stroke: 'currentColor', strokeWidth: 1.25, fill: 'none',
481
+ }),
482
+ h('circle', { cx: 5.9, cy: 6.5, r: 1.05, fill: 'currentColor' }),
483
+ h('path', {
484
+ d: 'M3.4 11.5 6.6 8.5l2 1.8 1.9-2.1 2.3 2.4',
485
+ stroke: 'currentColor', strokeWidth: 1.25,
486
+ strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none',
487
+ }),
488
+ );
489
+ }
490
+
441
491
  /** Arrow towards the bottom-right corner: minimize to the corner icon. */
442
492
  function MinimizeIcon({ size = 16 }) {
443
493
  return h(
@@ -480,7 +530,7 @@ window.__ModuleLoader__.load({
480
530
  .dsh-np-btn[data-accent=true]{background:var(--np-accent);color:var(--np-accent-fg)}
481
531
  .dsh-np-btn[data-accent=true]:hover:not(:disabled){background:color-mix(in srgb,var(--np-accent) 88%,#000);color:var(--np-accent-fg)}
482
532
  .dsh-np-btn:disabled{opacity:.38;cursor:default;background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280)}
483
- .dsh-np-btn:focus-visible,.dsh-np-segbtn:focus-visible,.dsh-np-link:focus-visible,.dsh-np-empty-add:focus-visible,.dsh-np-toast-action:focus-visible,.dsh-np-selectall input:focus-visible,.dsh-np-check input:focus-visible,.dsh-np-restore:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4176e6));outline-offset:-2px}
533
+ .dsh-np-btn:focus-visible,.dsh-np-segbtn:focus-visible,.dsh-np-link:focus-visible,.dsh-np-empty-add:focus-visible,.dsh-np-toast-action:focus-visible,.dsh-np-selectall input:focus-visible,.dsh-np-check input:focus-visible,.dsh-np-restore:focus-visible,.dsh-np-img-open:focus-visible,.dsh-np-img-remove:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4176e6));outline-offset:-2px}
484
534
  .dsh-np-badge{position:absolute;top:2px;right:2px;min-width:13px;height:13px;padding:0 3px;box-sizing:border-box;border-radius:7px;background:var(--np-accent);color:var(--np-accent-fg);font-size:9px;font-weight:600;line-height:13px;text-align:center;font-variant-numeric:tabular-nums}
485
535
  .dsh-np-body{display:flex;flex:1 1 auto;flex-direction:column;min-height:0;padding:8px 10px 0;opacity:1;transition:opacity .16s ease,visibility 0s linear 0s}
486
536
  .dsh-np-card[data-minimized=true] .dsh-np-body{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .16s ease,visibility 0s linear .16s}
@@ -505,7 +555,20 @@ window.__ModuleLoader__.load({
505
555
  .dsh-np-seg-text{display:block;width:100%;box-sizing:border-box;padding:2px 10px 8px;border:0;outline:0;resize:none;background:transparent;color:inherit;font-family:${FONT_STACK};font-size:13px;line-height:1.6;min-height:26px;max-height:240px;overflow-y:auto;white-space:pre-wrap;overflow-wrap:anywhere;tab-size:2}
506
556
  .dsh-np-seg-text::placeholder{color:var(--dsw-alias-label-caption,#9aa3b2)}
507
557
  .dsh-np-seg-text:disabled{opacity:.6}
558
+ .dsh-np-file{display:none}
559
+ .dsh-np-editor{display:flex;flex-direction:column;min-width:0;border-radius:0 0 9px 9px;transition:background .12s}
560
+ .dsh-np-editor[data-drag=true]{background:color-mix(in srgb,var(--np-accent) 8%,transparent);box-shadow:inset 0 0 0 1.5px var(--np-accent)}
561
+ .dsh-np-medias{display:flex;flex-wrap:wrap;gap:5px;padding:5px 9px 0}
562
+ .dsh-np-img{display:inline-flex;align-items:center;gap:4px;max-width:100%;height:38px;padding:2px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2,rgb(15 23 42/.12));border-radius:8px;background:var(--np-surface);box-shadow:0 1px 2px rgb(15 23 42/.05)}
563
+ .dsh-np-img[data-pending=true]{padding-right:8px;color:var(--dsw-alias-label-tertiary,#6b7280)}
564
+ .dsh-np-img-open{display:inline-flex;align-items:center;justify-content:center;flex:none;width:32px;height:32px;padding:0;border:0;border-radius:6px;background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06));overflow:hidden;cursor:zoom-in}
565
+ .dsh-np-img-open img{display:block;width:100%;height:100%;object-fit:cover}
566
+ .dsh-np-img-name{max-width:118px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10.5px;line-height:13px;color:var(--dsw-alias-label-secondary,#4b5563)}
567
+ .dsh-np-img-remove{display:inline-flex;align-items:center;justify-content:center;flex:none;width:17px;height:17px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--dsw-alias-label-caption,#9aa3b2);cursor:pointer}
568
+ .dsh-np-img-remove:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06));color:var(--dsw-alias-state-error-primary,#e25555)}
569
+ .dsh-np-img-spinner{flex:none;width:14px;height:14px;border:2px solid color-mix(in srgb,var(--np-accent) 30%,transparent);border-top-color:var(--np-accent);border-radius:50%;animation:dsh-np-spin .8s linear infinite}
508
570
  .dsh-np-seg-archived-text{padding:2px 10px 9px;color:var(--dsw-alias-label-secondary,#4b5563);font-size:13px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere}
571
+ .dsh-np-seg[data-archived=true] .dsh-np-medias{padding:0 10px 8px}
509
572
  .dsh-np-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;flex:none;padding:22px 12px;text-align:center;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px}
510
573
  .dsh-np-empty-add{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border:1px solid var(--dsw-alias-border-l2,rgb(15 23 42/.12));border-radius:7px;background:var(--np-surface-2);color:var(--dsw-alias-label-secondary,#4b5563);font:inherit;font-size:12px;cursor:pointer}
511
574
  .dsh-np-empty-add:hover{color:var(--dsw-alias-label-primary,#1f2430);background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06))}
@@ -538,11 +601,12 @@ window.__ModuleLoader__.load({
538
601
  @keyframes dsh-np-pulse{0%,100%{opacity:1}50%{opacity:.35}}
539
602
  @keyframes dsh-np-rise{from{opacity:0;transform:translate(-50%,4px)}to{opacity:1;transform:translate(-50%,0)}}
540
603
  @keyframes dsh-np-item-in{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
604
+ @keyframes dsh-np-spin{to{transform:rotate(360deg)}}
541
605
  body[data-ds-dark-theme] .dsh-np-card{--np-accent-fg:#0f1115;box-shadow:0 18px 48px rgb(0 0 0/.55),0 0 0 .5px rgb(255 255 255/.07)}
542
606
  body[data-ds-dark-theme] .dsh-np-seg[data-selected=true]{background:color-mix(in srgb,var(--np-accent) 14%,var(--dsw-alias-bg-layer-2,#2c2c2e))}
543
607
  body[data-ds-dark-theme] .dsh-np-btn[data-accent=true]:hover:not(:disabled){background:color-mix(in srgb,var(--np-accent) 84%,#fff)}
544
608
  body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
545
- @media (prefers-reduced-motion:reduce){.dsh-np-card,.dsh-np-body,.dsh-np-archive,.dsh-np-restore,.dsh-np-seg,.dsh-np-toast{transition:none!important;animation:none!important}.dsh-np-dot[data-state=saving]{animation:none}}`;
609
+ @media (prefers-reduced-motion:reduce){.dsh-np-card,.dsh-np-body,.dsh-np-archive,.dsh-np-restore,.dsh-np-seg,.dsh-np-toast{transition:none!important;animation:none!important}.dsh-np-dot[data-state=saving]{animation:none}.dsh-np-img-spinner{animation:none}}`;
546
610
 
547
611
  // ------------------------------------------------------------------
548
612
  // Helpers
@@ -563,13 +627,16 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
563
627
  }
564
628
 
565
629
  function newSegment(text) {
566
- return { id: newSegmentId(), text, archived: false, archivedAt: null };
630
+ return { id: newSegmentId(), text, images: [], archived: false, archivedAt: null };
567
631
  }
568
632
 
569
633
  function serializeSegments(segments) {
570
634
  return segments.map((segment) => ({
571
635
  id: segment.id,
572
636
  text: segment.text,
637
+ ...(Array.isArray(segment.images) && segment.images.length > 0
638
+ ? { images: segment.images.map((image) => ({ path: image.path, name: image.name })) }
639
+ : {}),
573
640
  ...(segment.archived === true ? { archived: true } : {}),
574
641
  ...(segment.archived === true && typeof segment.archivedAt === 'string' ? { archivedAt: segment.archivedAt } : {}),
575
642
  }));
@@ -588,6 +655,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
588
655
  out.push({
589
656
  id,
590
657
  text: item.text,
658
+ images: normalizeImages(item.images),
591
659
  archived,
592
660
  archivedAt: archived && typeof item.archivedAt === 'string' ? item.archivedAt : null,
593
661
  });
@@ -598,13 +666,196 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
598
666
  return text === '' ? [] : [newSegment(text)];
599
667
  }
600
668
 
669
+ function baseNameOf(value) {
670
+ const trimmed = String(value ?? '').replace(/[/\\]+$/u, '');
671
+ const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
672
+ return trimmed.slice(separator + 1);
673
+ }
674
+
675
+ function normalizeImage(value) {
676
+ if (typeof value === 'string') value = { path: value };
677
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
678
+ if (typeof value.path !== 'string') return null;
679
+ const filePath = value.path.trim().replace(/\\/gu, '/');
680
+ if (filePath === '') return null;
681
+ const rawName = typeof value.name === 'string' && value.name.trim() !== ''
682
+ ? value.name.trim().replace(/\s+/gu, ' ')
683
+ : baseNameOf(filePath);
684
+ return { path: filePath, name: rawName === '' ? 'image' : rawName.slice(0, 160) };
685
+ }
686
+
687
+ function normalizeImages(value) {
688
+ if (!Array.isArray(value)) return [];
689
+ const out = [];
690
+ for (const item of value) {
691
+ if (out.length >= MAX_IMAGES) break;
692
+ const image = normalizeImage(item);
693
+ if (image !== null) out.push(image);
694
+ }
695
+ return out;
696
+ }
697
+
698
+ /** Markdown reference used in the note body and composer transfers. */
699
+ function imageMarkdown(image) {
700
+ const file = typeof image?.path === 'string' ? image.path.replace(/\\/gu, '/') : '';
701
+ if (file === '') return '';
702
+ const label = (typeof image?.name === 'string' ? image.name : '')
703
+ .replace(/[\r\n]+/gu, ' ')
704
+ .replace(/[[\]]/gu, ' ')
705
+ .trim() || baseNameOf(file) || 'image';
706
+ let target;
707
+ try {
708
+ target = encodeURI(file);
709
+ } catch {
710
+ target = file;
711
+ }
712
+ target = target
713
+ .replace(/#/gu, '%23')
714
+ .replace(/\?/gu, '%3F')
715
+ .replace(/\(/gu, '%28')
716
+ .replace(/\)/gu, '%29');
717
+ return `![${label}](${target})`;
718
+ }
719
+
720
+ /** One segment as text plus one Markdown line per attached image. */
721
+ function segmentBody(segment) {
722
+ if (segment === null || segment === undefined) return '';
723
+ const text = typeof segment.text === 'string' ? segment.text : '';
724
+ const images = Array.isArray(segment.images) ? segment.images : [];
725
+ const parts = [];
726
+ if (text.trim() !== '') parts.push(text);
727
+ for (const image of images) {
728
+ const markdown = imageMarkdown(image);
729
+ if (markdown !== '') parts.push(markdown);
730
+ }
731
+ return parts.join('\n');
732
+ }
733
+
601
734
  function mergedText(segments) {
602
735
  return segments
603
- .map((segment) => (segment !== null && segment !== undefined && typeof segment.text === 'string' ? segment.text : ''))
604
- .filter((text) => text.trim() !== '')
736
+ .map((segment) => segmentBody(segment))
737
+ .filter((text) => text !== '')
605
738
  .join('\n\n');
606
739
  }
607
740
 
741
+ function isAbsolutePath(value) {
742
+ const text = String(value ?? '');
743
+ return text.startsWith('/') || /^[A-Za-z]:\//u.test(text) || text.startsWith('//');
744
+ }
745
+
746
+ function normalizedCwd(value) {
747
+ return String(value ?? '').replace(/\\/gu, '/').replace(/\/+$/u, '');
748
+ }
749
+
750
+ /** Store a path relative to the workspace when it lives inside it. */
751
+ function imagePathForNote(pathValue, cwd) {
752
+ const file = String(pathValue ?? '').replace(/\\/gu, '/');
753
+ const root = normalizedCwd(cwd);
754
+ if (root === '') return file;
755
+ // Windows drive / UNC spellings are case-insensitive; POSIX ones are not.
756
+ const windows = /^[A-Za-z]:\//u.test(root) || root.startsWith('//');
757
+ const compareFile = windows ? file.toLowerCase() : file;
758
+ const compareRoot = windows ? root.toLowerCase() : root;
759
+ if (compareFile === compareRoot) return '.';
760
+ if (compareFile.startsWith(`${compareRoot}/`)) return file.slice(root.length + 1);
761
+ return file;
762
+ }
763
+
764
+ function absoluteImagePath(pathValue, cwd) {
765
+ const file = String(pathValue ?? '').replace(/\\/gu, '/');
766
+ if (isAbsolutePath(file)) return file;
767
+ const root = normalizedCwd(cwd);
768
+ return root === '' ? file : `${root}/${file.replace(/^\/+/u, '')}`;
769
+ }
770
+
771
+ /** Authenticated host file route used by the browser image renderer. */
772
+ function imageUrl(pathValue, cwd) {
773
+ const target = absoluteImagePath(pathValue, cwd);
774
+ try {
775
+ const base = typeof document !== 'undefined' && typeof document.baseURI === 'string'
776
+ ? document.baseURI
777
+ : 'http://localhost/';
778
+ const url = new URL('api/file', base);
779
+ url.searchParams.set('path', target);
780
+ return url.href;
781
+ } catch {
782
+ return `/api/file?path=${encodeURIComponent(target)}`;
783
+ }
784
+ }
785
+
786
+ function imageMimeOf(file) {
787
+ const type = typeof file?.type === 'string' ? file.type.toLowerCase() : '';
788
+ if (type !== '') return Object.prototype.hasOwnProperty.call(IMAGE_MIME, type) ? type : '';
789
+ const name = String(file?.name ?? '');
790
+ if (/\.png$/iu.test(name)) return 'image/png';
791
+ if (/\.jpe?g$/iu.test(name)) return 'image/jpeg';
792
+ if (/\.webp$/iu.test(name)) return 'image/webp';
793
+ if (/\.gif$/iu.test(name)) return 'image/gif';
794
+ return '';
795
+ }
796
+
797
+ function isSupportedImage(file) {
798
+ return imageMimeOf(file) !== '';
799
+ }
800
+
801
+ function hostPathOf(file) {
802
+ try {
803
+ const bridge = globalThis.__DSH_HOST_PATHS__;
804
+ if (bridge === null || bridge === undefined || typeof bridge.pathFor !== 'function') return '';
805
+ const value = bridge.pathFor(file);
806
+ return typeof value === 'string' ? value : '';
807
+ } catch {
808
+ return '';
809
+ }
810
+ }
811
+
812
+ class ImageError extends Error {
813
+ constructor(code, message) {
814
+ super(message ?? code);
815
+ this.name = 'ImageError';
816
+ this.code = code;
817
+ }
818
+ }
819
+
820
+ function readFileBase64(file) {
821
+ return new Promise((resolve, reject) => {
822
+ try {
823
+ const reader = new FileReader();
824
+ reader.onerror = () => reject(new ImageError('read-failed', 'Could not read the image file.'));
825
+ reader.onload = () => {
826
+ const result = typeof reader.result === 'string' ? reader.result : '';
827
+ const comma = result.indexOf(',');
828
+ resolve(comma >= 0 ? result.slice(comma + 1) : result);
829
+ };
830
+ reader.readAsDataURL(file);
831
+ } catch (error) {
832
+ reject(error);
833
+ }
834
+ });
835
+ }
836
+
837
+ async function uploadImage(root, sessionId, file) {
838
+ if (Number(file?.size) > MAX_IMAGE_BYTES) throw new ImageError('too-large');
839
+ const mime = imageMimeOf(file);
840
+ if (mime === '') throw new ImageError('unsupported');
841
+ const data = await readFileBase64(file);
842
+ const response = await fetch(`${API}/image`, {
843
+ method: 'POST',
844
+ headers: { 'Content-Type': 'application/json' },
845
+ body: JSON.stringify({
846
+ root,
847
+ sessionId,
848
+ name: typeof file?.name === 'string' && file.name !== '' ? file.name : 'image.png',
849
+ mime,
850
+ data,
851
+ }),
852
+ });
853
+ const payload = await readResponse(response);
854
+ const image = normalizeImage(payload);
855
+ if (image === null) throw new ImageError('upload-failed', 'The host did not return an image path.');
856
+ return image;
857
+ }
858
+
608
859
  function autoGrow(element) {
609
860
  if (element === null || element === undefined || element.style === undefined) return;
610
861
  try {
@@ -769,6 +1020,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
769
1020
  const [ui, setUi] = useState(readStoredUi);
770
1021
  const [sending, setSending] = useState(false);
771
1022
  const [resizing, setResizing] = useState(false);
1023
+ const [uploading, setUploading] = useState({});
1024
+ const [dragSegment, setDragSegment] = useState('');
772
1025
 
773
1026
  const bindingRef = useRef(null);
774
1027
  const rootSessionRef = useRef('');
@@ -789,6 +1042,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
789
1042
  const resizeRef = useRef(null);
790
1043
  const sendingRef = useRef(false);
791
1044
  const textareaRefs = useRef(new Map());
1045
+ const fileInputRefs = useRef(new Map());
792
1046
  const listRef = useRef(null);
793
1047
  const pendingFocusRef = useRef(null);
794
1048
  const undoRef = useRef(null);
@@ -1118,6 +1372,153 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1118
1372
  scheduleSave();
1119
1373
  };
1120
1374
 
1375
+ // Image attachments ---------------------------------------------------
1376
+ const imageErrorText = (reason) => {
1377
+ const code = reason?.code;
1378
+ if (code === 'too-large') return t('imageTooLarge');
1379
+ if (code === 'unsupported') return t('imageUnsupported');
1380
+ if (code === 'too-many') return t('imageTooMany', { n: MAX_IMAGES });
1381
+ return `${t('imageFailed')}: ${String(reason?.message ?? reason)}`;
1382
+ };
1383
+
1384
+ const appendImages = (id, images) => {
1385
+ if (!Array.isArray(images) || images.length === 0) return 0;
1386
+ const target = segmentsRef.current.find((segment) => segment.id === id);
1387
+ if (target === undefined || target.archived === true) return 0;
1388
+ const existing = Array.isArray(target.images) ? target.images : [];
1389
+ const room = Math.max(0, MAX_IMAGES - existing.length);
1390
+ if (room === 0) {
1391
+ showToast(t('imageTooMany', { n: MAX_IMAGES }));
1392
+ return 0;
1393
+ }
1394
+ const accepted = images.slice(0, room);
1395
+ const next = segmentsRef.current.map((segment) =>
1396
+ (segment.id === id ? { ...segment, images: [...existing, ...accepted] } : segment));
1397
+ commitSegments(next);
1398
+ if (accepted.length < images.length) showToast(t('imageTooMany', { n: MAX_IMAGES }));
1399
+ else showToast(t('imageAdded', { n: accepted.length }));
1400
+ return accepted.length;
1401
+ };
1402
+
1403
+ /**
1404
+ * Add browser files to one segment. Pasted images are always written into
1405
+ * the plugin directory; selected images use their host path when the
1406
+ * desktop bridge exposes it and fall back to the same upload otherwise.
1407
+ * @param {string} id segment id
1408
+ * @param {ArrayLike<File>} files
1409
+ * @param {'paste'|'pick'} origin
1410
+ */
1411
+ const attachImages = async (id, files, origin) => {
1412
+ const list = Array.from(files ?? []).filter(isSupportedImage);
1413
+ if (list.length === 0) {
1414
+ showToast(t('imageUnsupported'));
1415
+ return;
1416
+ }
1417
+ const root = rootRef.current;
1418
+ const sessionId = rootSessionRef.current;
1419
+ if (root === '' || sessionId === '') return;
1420
+ setUploading((previous) => ({ ...previous, [id]: (previous[id] ?? 0) + list.length }));
1421
+ const collected = [];
1422
+ try {
1423
+ for (const file of list) {
1424
+ const directPath = origin === 'pick' ? hostPathOf(file) : '';
1425
+ if (directPath !== '') {
1426
+ collected.push({
1427
+ path: imagePathForNote(directPath, bindingRef.current?.cwd ?? root),
1428
+ name: baseNameOf(directPath) || file?.name || 'image',
1429
+ });
1430
+ continue;
1431
+ }
1432
+ collected.push(await uploadImage(root, sessionId, file));
1433
+ }
1434
+ if (collected.length > 0) appendImages(id, collected);
1435
+ } catch (reason) {
1436
+ if (collected.length > 0) appendImages(id, collected);
1437
+ showToast(imageErrorText(reason));
1438
+ } finally {
1439
+ setUploading((previous) => {
1440
+ const next = { ...previous };
1441
+ const remaining = (next[id] ?? 0) - list.length;
1442
+ if (remaining > 0) next[id] = remaining;
1443
+ else delete next[id];
1444
+ return next;
1445
+ });
1446
+ }
1447
+ };
1448
+
1449
+ const removeImage = (id, pathValue) => {
1450
+ const next = segmentsRef.current.map((segment) => {
1451
+ if (segment.id !== id) return segment;
1452
+ const images = Array.isArray(segment.images) ? segment.images.filter((image) => image.path !== pathValue) : [];
1453
+ return { ...segment, images };
1454
+ });
1455
+ commitSegments(next);
1456
+ };
1457
+
1458
+ const triggerFilePicker = (id) => {
1459
+ const input = fileInputRefs.current.get(id);
1460
+ if (input !== undefined && input !== null && typeof input.click === 'function') input.click();
1461
+ };
1462
+
1463
+ const onFileInputChange = (event, id) => {
1464
+ const input = event?.currentTarget ?? event?.target;
1465
+ const files = input?.files;
1466
+ if (files !== undefined && files !== null && files.length > 0) attachImages(id, files, 'pick');
1467
+ try {
1468
+ if (input !== null && input !== undefined) input.value = '';
1469
+ } catch {
1470
+ /* A read-only file input value must not break the flow. */
1471
+ }
1472
+ };
1473
+
1474
+ const clipboardImageFiles = (data) => {
1475
+ const files = data?.files;
1476
+ if (files === undefined || files === null) return [];
1477
+ return Array.from(files).filter(isSupportedImage);
1478
+ };
1479
+
1480
+ const onSegmentPaste = (event, id) => {
1481
+ const data = event?.clipboardData ?? event?.nativeEvent?.clipboardData;
1482
+ const files = clipboardImageFiles(data);
1483
+ if (files.length === 0) return;
1484
+ event.preventDefault?.();
1485
+ event.stopPropagation?.();
1486
+ attachImages(id, files, 'paste');
1487
+ };
1488
+
1489
+ const onSegmentDrop = (event, id) => {
1490
+ const files = clipboardImageFiles(event?.dataTransfer);
1491
+ setDragSegment('');
1492
+ if (files.length === 0) return;
1493
+ event.preventDefault?.();
1494
+ event.stopPropagation?.();
1495
+ attachImages(id, files, 'pick');
1496
+ };
1497
+
1498
+ const onSegmentDragOver = (event, id) => {
1499
+ const types = event?.dataTransfer?.types;
1500
+ if (types !== undefined && types !== null && !Array.from(types).includes('Files')) return;
1501
+ event.preventDefault?.();
1502
+ if (dragSegment !== id) setDragSegment(id);
1503
+ };
1504
+
1505
+ const onSegmentDragLeave = (event, id) => {
1506
+ const related = event?.relatedTarget;
1507
+ const current = event?.currentTarget;
1508
+ if (related !== null && related !== undefined && current !== null && current !== undefined
1509
+ && typeof current.contains === 'function' && current.contains(related)) return;
1510
+ if (dragSegment === id) setDragSegment('');
1511
+ };
1512
+
1513
+ const openImage = (image) => {
1514
+ const url = imageUrl(image.path, rootRef.current);
1515
+ try {
1516
+ window.open(url, '_blank', 'noopener,noreferrer');
1517
+ } catch {
1518
+ /* Popups may be blocked; the path remains available in the title. */
1519
+ }
1520
+ };
1521
+
1121
1522
  const addSegment = () => {
1122
1523
  if (segmentsRef.current.length >= MAX_SEGMENTS) {
1123
1524
  showToast(t('tooManySegments', { n: MAX_SEGMENTS }));
@@ -1677,101 +2078,201 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1677
2078
  : null,
1678
2079
  );
1679
2080
 
1680
- const activeCards = activeSegments.map((segment, index) => h(
1681
- 'article',
1682
- {
1683
- key: segment.id,
1684
- className: 'dsh-np-seg',
1685
- 'data-seg-id': segment.id,
1686
- 'data-archived': 'false',
1687
- 'data-selected': selected.has(segment.id) ? 'true' : 'false',
1688
- },
1689
- h(
2081
+ const totalImages = segments.reduce(
2082
+ (sum, segment) => sum + (Array.isArray(segment.images) ? segment.images.length : 0), 0);
2083
+
2084
+ const renderMedia = (segment, options = {}) => {
2085
+ const images = Array.isArray(segment.images) ? segment.images : [];
2086
+ const pending = options.pending === true;
2087
+ if (images.length === 0 && !pending) return null;
2088
+ return h(
1690
2089
  'div',
1691
- { className: 'dsh-np-seg-head' },
1692
- h(
1693
- 'label',
1694
- { className: 'dsh-np-check', title: t('selectSegment') },
1695
- h('input', {
1696
- type: 'checkbox',
1697
- checked: selected.has(segment.id),
1698
- 'aria-label': t('selectSegment'),
1699
- onChange: () => toggleSelected(segment.id),
1700
- }),
1701
- ),
1702
- h('span', { className: 'dsh-np-index' }, String(index + 1).padStart(2, '0')),
1703
- h('span', { className: 'dsh-np-spacer' }),
1704
- h(
1705
- 'button',
1706
- {
1707
- type: 'button',
1708
- className: 'dsh-np-segbtn',
1709
- title: t('insertSegment'),
1710
- 'aria-label': t('insertSegment'),
1711
- disabled: segment.text.trim() === '',
1712
- onClick: () => insertSegments([segment]),
1713
- },
1714
- h(InsertIcon, { size: 14 }),
1715
- ),
1716
- h(
1717
- 'button',
1718
- {
1719
- type: 'button',
1720
- className: 'dsh-np-segbtn',
1721
- title: t('replaceSegment'),
1722
- 'aria-label': t('replaceSegment'),
1723
- disabled: segment.text.trim() === '',
1724
- onClick: () => replaceSegments([segment]),
2090
+ { className: 'dsh-np-medias' },
2091
+ pending
2092
+ ? h(
2093
+ 'div',
2094
+ { key: 'pending', className: 'dsh-np-img', 'data-pending': 'true' },
2095
+ h('span', { className: 'dsh-np-img-spinner' }),
2096
+ h('span', { className: 'dsh-np-img-name' }, t('imageUploading')),
2097
+ )
2098
+ : null,
2099
+ images.map((image, imageIndex) => h(
2100
+ 'div',
2101
+ { key: `${imageIndex}-${image.path}`, className: 'dsh-np-img', title: image.path },
2102
+ h(
2103
+ 'button',
2104
+ {
2105
+ type: 'button',
2106
+ className: 'dsh-np-img-open',
2107
+ title: `${t('openImage')}: ${image.path}`,
2108
+ 'aria-label': t('openImage'),
2109
+ onClick: () => openImage(image),
2110
+ },
2111
+ h('img', {
2112
+ src: imageUrl(image.path, binding.cwd),
2113
+ alt: image.name,
2114
+ loading: 'lazy',
2115
+ decoding: 'async',
2116
+ draggable: false,
2117
+ }),
2118
+ ),
2119
+ h('span', { className: 'dsh-np-img-name', title: image.path }, image.name),
2120
+ options.readOnly === true
2121
+ ? null
2122
+ : h(
2123
+ 'button',
2124
+ {
2125
+ type: 'button',
2126
+ className: 'dsh-np-img-remove',
2127
+ title: t('removeImage'),
2128
+ 'aria-label': t('removeImage'),
2129
+ onClick: () => removeImage(segment.id, image.path),
2130
+ },
2131
+ h(CloseIcon, { size: 11 }),
2132
+ ),
2133
+ )),
2134
+ );
2135
+ };
2136
+
2137
+ const activeCards = activeSegments.map((segment, index) => {
2138
+ const images = Array.isArray(segment.images) ? segment.images : [];
2139
+ const empty = segment.text.trim() === '' && images.length === 0;
2140
+ return h(
2141
+ 'article',
2142
+ {
2143
+ key: segment.id,
2144
+ className: 'dsh-np-seg',
2145
+ 'data-seg-id': segment.id,
2146
+ 'data-archived': 'false',
2147
+ 'data-selected': selected.has(segment.id) ? 'true' : 'false',
2148
+ },
2149
+ h('input', {
2150
+ key: 'file-input',
2151
+ ref: (element) => {
2152
+ if (element === null || element === undefined) {
2153
+ fileInputRefs.current.delete(segment.id);
2154
+ return;
2155
+ }
2156
+ fileInputRefs.current.set(segment.id, element);
1725
2157
  },
1726
- h(ReplaceIcon, { size: 14 }),
1727
- ),
2158
+ className: 'dsh-np-file',
2159
+ type: 'file',
2160
+ accept: 'image/png,image/jpeg,image/webp,image/gif',
2161
+ multiple: true,
2162
+ tabIndex: -1,
2163
+ 'aria-hidden': 'true',
2164
+ onChange: (event) => onFileInputChange(event, segment.id),
2165
+ }),
1728
2166
  h(
1729
- 'button',
1730
- {
1731
- type: 'button',
1732
- className: 'dsh-np-segbtn',
1733
- title: t('sendSegment'),
1734
- 'aria-label': t('sendSegment'),
1735
- disabled: segment.text.trim() === '' || sending,
1736
- onClick: () => sendSegments([segment]),
1737
- },
1738
- h(SendIcon, { size: 14 }),
2167
+ 'div',
2168
+ { className: 'dsh-np-seg-head' },
2169
+ h(
2170
+ 'label',
2171
+ { className: 'dsh-np-check', title: t('selectSegment') },
2172
+ h('input', {
2173
+ type: 'checkbox',
2174
+ checked: selected.has(segment.id),
2175
+ 'aria-label': t('selectSegment'),
2176
+ onChange: () => toggleSelected(segment.id),
2177
+ }),
2178
+ ),
2179
+ h('span', { className: 'dsh-np-index' }, String(index + 1).padStart(2, '0')),
2180
+ h('span', { className: 'dsh-np-spacer' }),
2181
+ h(
2182
+ 'button',
2183
+ {
2184
+ type: 'button',
2185
+ className: 'dsh-np-segbtn',
2186
+ title: t('addImage'),
2187
+ 'aria-label': t('addImage'),
2188
+ onClick: () => triggerFilePicker(segment.id),
2189
+ },
2190
+ h(ImageIcon, { size: 14 }),
2191
+ ),
2192
+ h(
2193
+ 'button',
2194
+ {
2195
+ type: 'button',
2196
+ className: 'dsh-np-segbtn',
2197
+ title: t('insertSegment'),
2198
+ 'aria-label': t('insertSegment'),
2199
+ disabled: empty,
2200
+ onClick: () => insertSegments([segment]),
2201
+ },
2202
+ h(InsertIcon, { size: 14 }),
2203
+ ),
2204
+ h(
2205
+ 'button',
2206
+ {
2207
+ type: 'button',
2208
+ className: 'dsh-np-segbtn',
2209
+ title: t('replaceSegment'),
2210
+ 'aria-label': t('replaceSegment'),
2211
+ disabled: empty,
2212
+ onClick: () => replaceSegments([segment]),
2213
+ },
2214
+ h(ReplaceIcon, { size: 14 }),
2215
+ ),
2216
+ h(
2217
+ 'button',
2218
+ {
2219
+ type: 'button',
2220
+ className: 'dsh-np-segbtn',
2221
+ title: t('sendSegment'),
2222
+ 'aria-label': t('sendSegment'),
2223
+ disabled: empty || sending,
2224
+ onClick: () => sendSegments([segment]),
2225
+ },
2226
+ h(SendIcon, { size: 14 }),
2227
+ ),
2228
+ h(
2229
+ 'button',
2230
+ {
2231
+ type: 'button',
2232
+ className: 'dsh-np-segbtn',
2233
+ title: t('archiveSegment'),
2234
+ 'aria-label': t('archiveSegment'),
2235
+ onClick: () => archiveSegment(segment.id),
2236
+ },
2237
+ h(ArchiveIcon, { size: 14 }),
2238
+ ),
1739
2239
  ),
1740
2240
  h(
1741
- 'button',
2241
+ 'div',
1742
2242
  {
1743
- type: 'button',
1744
- className: 'dsh-np-segbtn',
1745
- title: t('archiveSegment'),
1746
- 'aria-label': t('archiveSegment'),
1747
- onClick: () => archiveSegment(segment.id),
2243
+ className: 'dsh-np-editor',
2244
+ 'data-drag': dragSegment === segment.id ? 'true' : 'false',
2245
+ onPaste: (event) => onSegmentPaste(event, segment.id),
2246
+ onDrop: (event) => onSegmentDrop(event, segment.id),
2247
+ onDragOver: (event) => onSegmentDragOver(event, segment.id),
2248
+ onDragLeave: (event) => onSegmentDragLeave(event, segment.id),
1748
2249
  },
1749
- h(ArchiveIcon, { size: 14 }),
2250
+ renderMedia(segment, { pending: (uploading[segment.id] ?? 0) > 0 }),
2251
+ h('textarea', {
2252
+ ref: (element) => {
2253
+ if (element === null || element === undefined) {
2254
+ textareaRefs.current.delete(segment.id);
2255
+ return;
2256
+ }
2257
+ textareaRefs.current.set(segment.id, element);
2258
+ autoGrow(element);
2259
+ },
2260
+ className: 'dsh-np-seg-text',
2261
+ rows: 1,
2262
+ value: segment.text,
2263
+ placeholder: index === 0 ? t('placeholder') : t('segmentPlaceholder'),
2264
+ spellCheck: false,
2265
+ maxLength: MAX_SEGMENT_CHARS,
2266
+ 'aria-label': t('editor'),
2267
+ onChange: (event) => onSegmentChange(event, segment.id),
2268
+ onKeyDown: (event) => onSegmentKeyDown(event, segment.id),
2269
+ onBlur: () => {
2270
+ if (dirtyRef.current) saveNowRef.current?.();
2271
+ },
2272
+ }),
1750
2273
  ),
1751
- ),
1752
- h('textarea', {
1753
- ref: (element) => {
1754
- if (element === null || element === undefined) {
1755
- textareaRefs.current.delete(segment.id);
1756
- return;
1757
- }
1758
- textareaRefs.current.set(segment.id, element);
1759
- autoGrow(element);
1760
- },
1761
- className: 'dsh-np-seg-text',
1762
- rows: 1,
1763
- value: segment.text,
1764
- placeholder: index === 0 ? t('placeholder') : t('segmentPlaceholder'),
1765
- spellCheck: false,
1766
- maxLength: MAX_SEGMENT_CHARS,
1767
- 'aria-label': t('editor'),
1768
- onChange: (event) => onSegmentChange(event, segment.id),
1769
- onKeyDown: (event) => onSegmentKeyDown(event, segment.id),
1770
- onBlur: () => {
1771
- if (dirtyRef.current) saveNowRef.current?.();
1772
- },
1773
- }),
1774
- ));
2274
+ );
2275
+ });
1775
2276
 
1776
2277
  const activeEmpty = activeSegments.length === 0
1777
2278
  ? h(
@@ -1849,7 +2350,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1849
2350
  className: 'dsh-np-segbtn',
1850
2351
  title: t('insertSegment'),
1851
2352
  'aria-label': t('insertSegment'),
1852
- disabled: segment.text.trim() === '',
2353
+ disabled: segment.text.trim() === ''
2354
+ && !(Array.isArray(segment.images) && segment.images.length > 0),
1853
2355
  onClick: () => insertSegments([segment]),
1854
2356
  },
1855
2357
  h(InsertIcon, { size: 14 }),
@@ -1861,7 +2363,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1861
2363
  className: 'dsh-np-segbtn',
1862
2364
  title: t('replaceSegment'),
1863
2365
  'aria-label': t('replaceSegment'),
1864
- disabled: segment.text.trim() === '',
2366
+ disabled: segment.text.trim() === ''
2367
+ && !(Array.isArray(segment.images) && segment.images.length > 0),
1865
2368
  onClick: () => replaceSegments([segment]),
1866
2369
  },
1867
2370
  h(ReplaceIcon, { size: 14 }),
@@ -1891,7 +2394,10 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1891
2394
  ),
1892
2395
  ),
1893
2396
  h('div', { className: 'dsh-np-seg-archived-text' },
1894
- segment.text === '' ? t('emptySegment') : segment.text),
2397
+ segment.text === ''
2398
+ ? (Array.isArray(segment.images) && segment.images.length > 0 ? null : t('emptySegment'))
2399
+ : segment.text),
2400
+ renderMedia(segment, { readOnly: true }),
1895
2401
  )),
1896
2402
  ),
1897
2403
  ),
@@ -1921,7 +2427,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1921
2427
  }, t('retry'))
1922
2428
  : null,
1923
2429
  h('span', { className: 'dsh-np-count' },
1924
- `${t('chars', { n: totalChars })} · ${t('segments', { n: activeSegments.length })}`),
2430
+ `${t('chars', { n: totalChars })} · ${t('segments', { n: activeSegments.length })}`
2431
+ + (totalImages > 0 ? ` · ${t('images', { n: totalImages })}` : '')),
1925
2432
  h('span', { className: 'dsh-np-hint', title: t('hint') }, t('hint')),
1926
2433
  );
1927
2434