@restregular/dsh-notepad 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -100,8 +108,8 @@ window.__ModuleLoader__.load({
100
108
  archived: '归档',
101
109
  archivedToast: '已归档,可在归档中查看',
102
110
  archivedCount: '已归档 {n} 段',
103
- showArchive: '显示归档({n})',
104
- hideArchive: '隐藏归档({n})',
111
+ showArchive: '显示归档',
112
+ hideArchive: '隐藏归档',
105
113
  archiveCount: '归档 · {n}',
106
114
  archiveEmpty: '还没有归档的分段',
107
115
  noActive: '没有进行中的分段',
@@ -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',
@@ -173,8 +193,8 @@ window.__ModuleLoader__.load({
173
193
  archived: 'Archived',
174
194
  archivedToast: 'Archived; available under Archive',
175
195
  archivedCount: 'Archived {n}',
176
- showArchive: 'Show archive ({n})',
177
- hideArchive: 'Hide archive ({n})',
196
+ showArchive: 'Show archive',
197
+ hideArchive: 'Hide archive',
178
198
  archiveCount: 'Archive · {n}',
179
199
  archiveEmpty: 'Nothing archived yet',
180
200
  noActive: 'No active segments',
@@ -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,8 +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}
484
- .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}
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}
485
534
  .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
535
  .dsh-np-card[data-minimized=true] .dsh-np-body{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .16s ease,visibility 0s linear .16s}
487
536
  .dsh-np-tools{display:flex;align-items:center;gap:4px;flex:none;padding:0 2px 7px;border-bottom:1px solid var(--dsw-alias-border-l1,rgb(15 23 42/.08))}
@@ -505,7 +554,20 @@ window.__ModuleLoader__.load({
505
554
  .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
555
  .dsh-np-seg-text::placeholder{color:var(--dsw-alias-label-caption,#9aa3b2)}
507
556
  .dsh-np-seg-text:disabled{opacity:.6}
557
+ .dsh-np-file{display:none}
558
+ .dsh-np-editor{display:flex;flex-direction:column;min-width:0;border-radius:0 0 9px 9px;transition:background .12s}
559
+ .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)}
560
+ .dsh-np-medias{display:flex;flex-wrap:wrap;gap:5px;padding:5px 9px 0}
561
+ .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)}
562
+ .dsh-np-img[data-pending=true]{padding-right:8px;color:var(--dsw-alias-label-tertiary,#6b7280)}
563
+ .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}
564
+ .dsh-np-img-open img{display:block;width:100%;height:100%;object-fit:cover}
565
+ .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)}
566
+ .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}
567
+ .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)}
568
+ .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
569
  .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}
570
+ .dsh-np-seg[data-archived=true] .dsh-np-medias{padding:0 10px 8px}
509
571
  .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
572
  .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
573
  .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 +600,12 @@ window.__ModuleLoader__.load({
538
600
  @keyframes dsh-np-pulse{0%,100%{opacity:1}50%{opacity:.35}}
539
601
  @keyframes dsh-np-rise{from{opacity:0;transform:translate(-50%,4px)}to{opacity:1;transform:translate(-50%,0)}}
540
602
  @keyframes dsh-np-item-in{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
603
+ @keyframes dsh-np-spin{to{transform:rotate(360deg)}}
541
604
  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
605
  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
606
  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
607
  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}}`;
608
+ @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
609
 
547
610
  // ------------------------------------------------------------------
548
611
  // Helpers
@@ -563,13 +626,16 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
563
626
  }
564
627
 
565
628
  function newSegment(text) {
566
- return { id: newSegmentId(), text, archived: false, archivedAt: null };
629
+ return { id: newSegmentId(), text, images: [], archived: false, archivedAt: null };
567
630
  }
568
631
 
569
632
  function serializeSegments(segments) {
570
633
  return segments.map((segment) => ({
571
634
  id: segment.id,
572
635
  text: segment.text,
636
+ ...(Array.isArray(segment.images) && segment.images.length > 0
637
+ ? { images: segment.images.map((image) => ({ path: image.path, name: image.name })) }
638
+ : {}),
573
639
  ...(segment.archived === true ? { archived: true } : {}),
574
640
  ...(segment.archived === true && typeof segment.archivedAt === 'string' ? { archivedAt: segment.archivedAt } : {}),
575
641
  }));
@@ -588,6 +654,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
588
654
  out.push({
589
655
  id,
590
656
  text: item.text,
657
+ images: normalizeImages(item.images),
591
658
  archived,
592
659
  archivedAt: archived && typeof item.archivedAt === 'string' ? item.archivedAt : null,
593
660
  });
@@ -598,13 +665,196 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
598
665
  return text === '' ? [] : [newSegment(text)];
599
666
  }
600
667
 
668
+ function baseNameOf(value) {
669
+ const trimmed = String(value ?? '').replace(/[/\\]+$/u, '');
670
+ const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
671
+ return trimmed.slice(separator + 1);
672
+ }
673
+
674
+ function normalizeImage(value) {
675
+ if (typeof value === 'string') value = { path: value };
676
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
677
+ if (typeof value.path !== 'string') return null;
678
+ const filePath = value.path.trim().replace(/\\/gu, '/');
679
+ if (filePath === '') return null;
680
+ const rawName = typeof value.name === 'string' && value.name.trim() !== ''
681
+ ? value.name.trim().replace(/\s+/gu, ' ')
682
+ : baseNameOf(filePath);
683
+ return { path: filePath, name: rawName === '' ? 'image' : rawName.slice(0, 160) };
684
+ }
685
+
686
+ function normalizeImages(value) {
687
+ if (!Array.isArray(value)) return [];
688
+ const out = [];
689
+ for (const item of value) {
690
+ if (out.length >= MAX_IMAGES) break;
691
+ const image = normalizeImage(item);
692
+ if (image !== null) out.push(image);
693
+ }
694
+ return out;
695
+ }
696
+
697
+ /** Markdown reference used in the note body and composer transfers. */
698
+ function imageMarkdown(image) {
699
+ const file = typeof image?.path === 'string' ? image.path.replace(/\\/gu, '/') : '';
700
+ if (file === '') return '';
701
+ const label = (typeof image?.name === 'string' ? image.name : '')
702
+ .replace(/[\r\n]+/gu, ' ')
703
+ .replace(/[[\]]/gu, ' ')
704
+ .trim() || baseNameOf(file) || 'image';
705
+ let target;
706
+ try {
707
+ target = encodeURI(file);
708
+ } catch {
709
+ target = file;
710
+ }
711
+ target = target
712
+ .replace(/#/gu, '%23')
713
+ .replace(/\?/gu, '%3F')
714
+ .replace(/\(/gu, '%28')
715
+ .replace(/\)/gu, '%29');
716
+ return `![${label}](${target})`;
717
+ }
718
+
719
+ /** One segment as text plus one Markdown line per attached image. */
720
+ function segmentBody(segment) {
721
+ if (segment === null || segment === undefined) return '';
722
+ const text = typeof segment.text === 'string' ? segment.text : '';
723
+ const images = Array.isArray(segment.images) ? segment.images : [];
724
+ const parts = [];
725
+ if (text.trim() !== '') parts.push(text);
726
+ for (const image of images) {
727
+ const markdown = imageMarkdown(image);
728
+ if (markdown !== '') parts.push(markdown);
729
+ }
730
+ return parts.join('\n');
731
+ }
732
+
601
733
  function mergedText(segments) {
602
734
  return segments
603
- .map((segment) => (segment !== null && segment !== undefined && typeof segment.text === 'string' ? segment.text : ''))
604
- .filter((text) => text.trim() !== '')
735
+ .map((segment) => segmentBody(segment))
736
+ .filter((text) => text !== '')
605
737
  .join('\n\n');
606
738
  }
607
739
 
740
+ function isAbsolutePath(value) {
741
+ const text = String(value ?? '');
742
+ return text.startsWith('/') || /^[A-Za-z]:\//u.test(text) || text.startsWith('//');
743
+ }
744
+
745
+ function normalizedCwd(value) {
746
+ return String(value ?? '').replace(/\\/gu, '/').replace(/\/+$/u, '');
747
+ }
748
+
749
+ /** Store a path relative to the workspace when it lives inside it. */
750
+ function imagePathForNote(pathValue, cwd) {
751
+ const file = String(pathValue ?? '').replace(/\\/gu, '/');
752
+ const root = normalizedCwd(cwd);
753
+ if (root === '') return file;
754
+ // Windows drive / UNC spellings are case-insensitive; POSIX ones are not.
755
+ const windows = /^[A-Za-z]:\//u.test(root) || root.startsWith('//');
756
+ const compareFile = windows ? file.toLowerCase() : file;
757
+ const compareRoot = windows ? root.toLowerCase() : root;
758
+ if (compareFile === compareRoot) return '.';
759
+ if (compareFile.startsWith(`${compareRoot}/`)) return file.slice(root.length + 1);
760
+ return file;
761
+ }
762
+
763
+ function absoluteImagePath(pathValue, cwd) {
764
+ const file = String(pathValue ?? '').replace(/\\/gu, '/');
765
+ if (isAbsolutePath(file)) return file;
766
+ const root = normalizedCwd(cwd);
767
+ return root === '' ? file : `${root}/${file.replace(/^\/+/u, '')}`;
768
+ }
769
+
770
+ /** Authenticated host file route used by the browser image renderer. */
771
+ function imageUrl(pathValue, cwd) {
772
+ const target = absoluteImagePath(pathValue, cwd);
773
+ try {
774
+ const base = typeof document !== 'undefined' && typeof document.baseURI === 'string'
775
+ ? document.baseURI
776
+ : 'http://localhost/';
777
+ const url = new URL('api/file', base);
778
+ url.searchParams.set('path', target);
779
+ return url.href;
780
+ } catch {
781
+ return `/api/file?path=${encodeURIComponent(target)}`;
782
+ }
783
+ }
784
+
785
+ function imageMimeOf(file) {
786
+ const type = typeof file?.type === 'string' ? file.type.toLowerCase() : '';
787
+ if (type !== '') return Object.prototype.hasOwnProperty.call(IMAGE_MIME, type) ? type : '';
788
+ const name = String(file?.name ?? '');
789
+ if (/\.png$/iu.test(name)) return 'image/png';
790
+ if (/\.jpe?g$/iu.test(name)) return 'image/jpeg';
791
+ if (/\.webp$/iu.test(name)) return 'image/webp';
792
+ if (/\.gif$/iu.test(name)) return 'image/gif';
793
+ return '';
794
+ }
795
+
796
+ function isSupportedImage(file) {
797
+ return imageMimeOf(file) !== '';
798
+ }
799
+
800
+ function hostPathOf(file) {
801
+ try {
802
+ const bridge = globalThis.__DSH_HOST_PATHS__;
803
+ if (bridge === null || bridge === undefined || typeof bridge.pathFor !== 'function') return '';
804
+ const value = bridge.pathFor(file);
805
+ return typeof value === 'string' ? value : '';
806
+ } catch {
807
+ return '';
808
+ }
809
+ }
810
+
811
+ class ImageError extends Error {
812
+ constructor(code, message) {
813
+ super(message ?? code);
814
+ this.name = 'ImageError';
815
+ this.code = code;
816
+ }
817
+ }
818
+
819
+ function readFileBase64(file) {
820
+ return new Promise((resolve, reject) => {
821
+ try {
822
+ const reader = new FileReader();
823
+ reader.onerror = () => reject(new ImageError('read-failed', 'Could not read the image file.'));
824
+ reader.onload = () => {
825
+ const result = typeof reader.result === 'string' ? reader.result : '';
826
+ const comma = result.indexOf(',');
827
+ resolve(comma >= 0 ? result.slice(comma + 1) : result);
828
+ };
829
+ reader.readAsDataURL(file);
830
+ } catch (error) {
831
+ reject(error);
832
+ }
833
+ });
834
+ }
835
+
836
+ async function uploadImage(root, sessionId, file) {
837
+ if (Number(file?.size) > MAX_IMAGE_BYTES) throw new ImageError('too-large');
838
+ const mime = imageMimeOf(file);
839
+ if (mime === '') throw new ImageError('unsupported');
840
+ const data = await readFileBase64(file);
841
+ const response = await fetch(`${API}/image`, {
842
+ method: 'POST',
843
+ headers: { 'Content-Type': 'application/json' },
844
+ body: JSON.stringify({
845
+ root,
846
+ sessionId,
847
+ name: typeof file?.name === 'string' && file.name !== '' ? file.name : 'image.png',
848
+ mime,
849
+ data,
850
+ }),
851
+ });
852
+ const payload = await readResponse(response);
853
+ const image = normalizeImage(payload);
854
+ if (image === null) throw new ImageError('upload-failed', 'The host did not return an image path.');
855
+ return image;
856
+ }
857
+
608
858
  function autoGrow(element) {
609
859
  if (element === null || element === undefined || element.style === undefined) return;
610
860
  try {
@@ -769,6 +1019,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
769
1019
  const [ui, setUi] = useState(readStoredUi);
770
1020
  const [sending, setSending] = useState(false);
771
1021
  const [resizing, setResizing] = useState(false);
1022
+ const [uploading, setUploading] = useState({});
1023
+ const [dragSegment, setDragSegment] = useState('');
772
1024
 
773
1025
  const bindingRef = useRef(null);
774
1026
  const rootSessionRef = useRef('');
@@ -789,6 +1041,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
789
1041
  const resizeRef = useRef(null);
790
1042
  const sendingRef = useRef(false);
791
1043
  const textareaRefs = useRef(new Map());
1044
+ const fileInputRefs = useRef(new Map());
792
1045
  const listRef = useRef(null);
793
1046
  const pendingFocusRef = useRef(null);
794
1047
  const undoRef = useRef(null);
@@ -1118,6 +1371,153 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1118
1371
  scheduleSave();
1119
1372
  };
1120
1373
 
1374
+ // Image attachments ---------------------------------------------------
1375
+ const imageErrorText = (reason) => {
1376
+ const code = reason?.code;
1377
+ if (code === 'too-large') return t('imageTooLarge');
1378
+ if (code === 'unsupported') return t('imageUnsupported');
1379
+ if (code === 'too-many') return t('imageTooMany', { n: MAX_IMAGES });
1380
+ return `${t('imageFailed')}: ${String(reason?.message ?? reason)}`;
1381
+ };
1382
+
1383
+ const appendImages = (id, images) => {
1384
+ if (!Array.isArray(images) || images.length === 0) return 0;
1385
+ const target = segmentsRef.current.find((segment) => segment.id === id);
1386
+ if (target === undefined || target.archived === true) return 0;
1387
+ const existing = Array.isArray(target.images) ? target.images : [];
1388
+ const room = Math.max(0, MAX_IMAGES - existing.length);
1389
+ if (room === 0) {
1390
+ showToast(t('imageTooMany', { n: MAX_IMAGES }));
1391
+ return 0;
1392
+ }
1393
+ const accepted = images.slice(0, room);
1394
+ const next = segmentsRef.current.map((segment) =>
1395
+ (segment.id === id ? { ...segment, images: [...existing, ...accepted] } : segment));
1396
+ commitSegments(next);
1397
+ if (accepted.length < images.length) showToast(t('imageTooMany', { n: MAX_IMAGES }));
1398
+ else showToast(t('imageAdded', { n: accepted.length }));
1399
+ return accepted.length;
1400
+ };
1401
+
1402
+ /**
1403
+ * Add browser files to one segment. Pasted images are always written into
1404
+ * the plugin directory; selected images use their host path when the
1405
+ * desktop bridge exposes it and fall back to the same upload otherwise.
1406
+ * @param {string} id segment id
1407
+ * @param {ArrayLike<File>} files
1408
+ * @param {'paste'|'pick'} origin
1409
+ */
1410
+ const attachImages = async (id, files, origin) => {
1411
+ const list = Array.from(files ?? []).filter(isSupportedImage);
1412
+ if (list.length === 0) {
1413
+ showToast(t('imageUnsupported'));
1414
+ return;
1415
+ }
1416
+ const root = rootRef.current;
1417
+ const sessionId = rootSessionRef.current;
1418
+ if (root === '' || sessionId === '') return;
1419
+ setUploading((previous) => ({ ...previous, [id]: (previous[id] ?? 0) + list.length }));
1420
+ const collected = [];
1421
+ try {
1422
+ for (const file of list) {
1423
+ const directPath = origin === 'pick' ? hostPathOf(file) : '';
1424
+ if (directPath !== '') {
1425
+ collected.push({
1426
+ path: imagePathForNote(directPath, bindingRef.current?.cwd ?? root),
1427
+ name: baseNameOf(directPath) || file?.name || 'image',
1428
+ });
1429
+ continue;
1430
+ }
1431
+ collected.push(await uploadImage(root, sessionId, file));
1432
+ }
1433
+ if (collected.length > 0) appendImages(id, collected);
1434
+ } catch (reason) {
1435
+ if (collected.length > 0) appendImages(id, collected);
1436
+ showToast(imageErrorText(reason));
1437
+ } finally {
1438
+ setUploading((previous) => {
1439
+ const next = { ...previous };
1440
+ const remaining = (next[id] ?? 0) - list.length;
1441
+ if (remaining > 0) next[id] = remaining;
1442
+ else delete next[id];
1443
+ return next;
1444
+ });
1445
+ }
1446
+ };
1447
+
1448
+ const removeImage = (id, pathValue) => {
1449
+ const next = segmentsRef.current.map((segment) => {
1450
+ if (segment.id !== id) return segment;
1451
+ const images = Array.isArray(segment.images) ? segment.images.filter((image) => image.path !== pathValue) : [];
1452
+ return { ...segment, images };
1453
+ });
1454
+ commitSegments(next);
1455
+ };
1456
+
1457
+ const triggerFilePicker = (id) => {
1458
+ const input = fileInputRefs.current.get(id);
1459
+ if (input !== undefined && input !== null && typeof input.click === 'function') input.click();
1460
+ };
1461
+
1462
+ const onFileInputChange = (event, id) => {
1463
+ const input = event?.currentTarget ?? event?.target;
1464
+ const files = input?.files;
1465
+ if (files !== undefined && files !== null && files.length > 0) attachImages(id, files, 'pick');
1466
+ try {
1467
+ if (input !== null && input !== undefined) input.value = '';
1468
+ } catch {
1469
+ /* A read-only file input value must not break the flow. */
1470
+ }
1471
+ };
1472
+
1473
+ const clipboardImageFiles = (data) => {
1474
+ const files = data?.files;
1475
+ if (files === undefined || files === null) return [];
1476
+ return Array.from(files).filter(isSupportedImage);
1477
+ };
1478
+
1479
+ const onSegmentPaste = (event, id) => {
1480
+ const data = event?.clipboardData ?? event?.nativeEvent?.clipboardData;
1481
+ const files = clipboardImageFiles(data);
1482
+ if (files.length === 0) return;
1483
+ event.preventDefault?.();
1484
+ event.stopPropagation?.();
1485
+ attachImages(id, files, 'paste');
1486
+ };
1487
+
1488
+ const onSegmentDrop = (event, id) => {
1489
+ const files = clipboardImageFiles(event?.dataTransfer);
1490
+ setDragSegment('');
1491
+ if (files.length === 0) return;
1492
+ event.preventDefault?.();
1493
+ event.stopPropagation?.();
1494
+ attachImages(id, files, 'pick');
1495
+ };
1496
+
1497
+ const onSegmentDragOver = (event, id) => {
1498
+ const types = event?.dataTransfer?.types;
1499
+ if (types !== undefined && types !== null && !Array.from(types).includes('Files')) return;
1500
+ event.preventDefault?.();
1501
+ if (dragSegment !== id) setDragSegment(id);
1502
+ };
1503
+
1504
+ const onSegmentDragLeave = (event, id) => {
1505
+ const related = event?.relatedTarget;
1506
+ const current = event?.currentTarget;
1507
+ if (related !== null && related !== undefined && current !== null && current !== undefined
1508
+ && typeof current.contains === 'function' && current.contains(related)) return;
1509
+ if (dragSegment === id) setDragSegment('');
1510
+ };
1511
+
1512
+ const openImage = (image) => {
1513
+ const url = imageUrl(image.path, rootRef.current);
1514
+ try {
1515
+ window.open(url, '_blank', 'noopener,noreferrer');
1516
+ } catch {
1517
+ /* Popups may be blocked; the path remains available in the title. */
1518
+ }
1519
+ };
1520
+
1121
1521
  const addSegment = () => {
1122
1522
  if (segmentsRef.current.length >= MAX_SEGMENTS) {
1123
1523
  showToast(t('tooManySegments', { n: MAX_SEGMENTS }));
@@ -1520,9 +1920,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1520
1920
  statusText = clock === '' ? t('saved') : `${t('saved')} · ${clock}`;
1521
1921
  } else statusText = t('empty');
1522
1922
 
1523
- const archiveButtonTitle = ui.showArchive
1524
- ? t('hideArchive', { n: archivedSegments.length })
1525
- : t('showArchive', { n: archivedSegments.length });
1923
+ const archiveButtonTitle = ui.showArchive ? t('hideArchive') : t('showArchive');
1526
1924
 
1527
1925
  const header = h(
1528
1926
  'div',
@@ -1555,9 +1953,6 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1555
1953
  onClick: toggleArchive,
1556
1954
  },
1557
1955
  h(ArchiveIcon, { size: 15 }),
1558
- archivedSegments.length > 0
1559
- ? h('span', { className: 'dsh-np-badge' }, String(archivedSegments.length))
1560
- : null,
1561
1956
  ),
1562
1957
  h(
1563
1958
  'button',
@@ -1677,101 +2072,201 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1677
2072
  : null,
1678
2073
  );
1679
2074
 
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(
2075
+ const totalImages = segments.reduce(
2076
+ (sum, segment) => sum + (Array.isArray(segment.images) ? segment.images.length : 0), 0);
2077
+
2078
+ const renderMedia = (segment, options = {}) => {
2079
+ const images = Array.isArray(segment.images) ? segment.images : [];
2080
+ const pending = options.pending === true;
2081
+ if (images.length === 0 && !pending) return null;
2082
+ return h(
1690
2083
  '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]),
2084
+ { className: 'dsh-np-medias' },
2085
+ pending
2086
+ ? h(
2087
+ 'div',
2088
+ { key: 'pending', className: 'dsh-np-img', 'data-pending': 'true' },
2089
+ h('span', { className: 'dsh-np-img-spinner' }),
2090
+ h('span', { className: 'dsh-np-img-name' }, t('imageUploading')),
2091
+ )
2092
+ : null,
2093
+ images.map((image, imageIndex) => h(
2094
+ 'div',
2095
+ { key: `${imageIndex}-${image.path}`, className: 'dsh-np-img', title: image.path },
2096
+ h(
2097
+ 'button',
2098
+ {
2099
+ type: 'button',
2100
+ className: 'dsh-np-img-open',
2101
+ title: `${t('openImage')}: ${image.path}`,
2102
+ 'aria-label': t('openImage'),
2103
+ onClick: () => openImage(image),
2104
+ },
2105
+ h('img', {
2106
+ src: imageUrl(image.path, binding.cwd),
2107
+ alt: image.name,
2108
+ loading: 'lazy',
2109
+ decoding: 'async',
2110
+ draggable: false,
2111
+ }),
2112
+ ),
2113
+ h('span', { className: 'dsh-np-img-name', title: image.path }, image.name),
2114
+ options.readOnly === true
2115
+ ? null
2116
+ : h(
2117
+ 'button',
2118
+ {
2119
+ type: 'button',
2120
+ className: 'dsh-np-img-remove',
2121
+ title: t('removeImage'),
2122
+ 'aria-label': t('removeImage'),
2123
+ onClick: () => removeImage(segment.id, image.path),
2124
+ },
2125
+ h(CloseIcon, { size: 11 }),
2126
+ ),
2127
+ )),
2128
+ );
2129
+ };
2130
+
2131
+ const activeCards = activeSegments.map((segment, index) => {
2132
+ const images = Array.isArray(segment.images) ? segment.images : [];
2133
+ const empty = segment.text.trim() === '' && images.length === 0;
2134
+ return h(
2135
+ 'article',
2136
+ {
2137
+ key: segment.id,
2138
+ className: 'dsh-np-seg',
2139
+ 'data-seg-id': segment.id,
2140
+ 'data-archived': 'false',
2141
+ 'data-selected': selected.has(segment.id) ? 'true' : 'false',
2142
+ },
2143
+ h('input', {
2144
+ key: 'file-input',
2145
+ ref: (element) => {
2146
+ if (element === null || element === undefined) {
2147
+ fileInputRefs.current.delete(segment.id);
2148
+ return;
2149
+ }
2150
+ fileInputRefs.current.set(segment.id, element);
1725
2151
  },
1726
- h(ReplaceIcon, { size: 14 }),
1727
- ),
2152
+ className: 'dsh-np-file',
2153
+ type: 'file',
2154
+ accept: 'image/png,image/jpeg,image/webp,image/gif',
2155
+ multiple: true,
2156
+ tabIndex: -1,
2157
+ 'aria-hidden': 'true',
2158
+ onChange: (event) => onFileInputChange(event, segment.id),
2159
+ }),
1728
2160
  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 }),
2161
+ 'div',
2162
+ { className: 'dsh-np-seg-head' },
2163
+ h(
2164
+ 'label',
2165
+ { className: 'dsh-np-check', title: t('selectSegment') },
2166
+ h('input', {
2167
+ type: 'checkbox',
2168
+ checked: selected.has(segment.id),
2169
+ 'aria-label': t('selectSegment'),
2170
+ onChange: () => toggleSelected(segment.id),
2171
+ }),
2172
+ ),
2173
+ h('span', { className: 'dsh-np-index' }, String(index + 1).padStart(2, '0')),
2174
+ h('span', { className: 'dsh-np-spacer' }),
2175
+ h(
2176
+ 'button',
2177
+ {
2178
+ type: 'button',
2179
+ className: 'dsh-np-segbtn',
2180
+ title: t('addImage'),
2181
+ 'aria-label': t('addImage'),
2182
+ onClick: () => triggerFilePicker(segment.id),
2183
+ },
2184
+ h(ImageIcon, { size: 14 }),
2185
+ ),
2186
+ h(
2187
+ 'button',
2188
+ {
2189
+ type: 'button',
2190
+ className: 'dsh-np-segbtn',
2191
+ title: t('insertSegment'),
2192
+ 'aria-label': t('insertSegment'),
2193
+ disabled: empty,
2194
+ onClick: () => insertSegments([segment]),
2195
+ },
2196
+ h(InsertIcon, { size: 14 }),
2197
+ ),
2198
+ h(
2199
+ 'button',
2200
+ {
2201
+ type: 'button',
2202
+ className: 'dsh-np-segbtn',
2203
+ title: t('replaceSegment'),
2204
+ 'aria-label': t('replaceSegment'),
2205
+ disabled: empty,
2206
+ onClick: () => replaceSegments([segment]),
2207
+ },
2208
+ h(ReplaceIcon, { size: 14 }),
2209
+ ),
2210
+ h(
2211
+ 'button',
2212
+ {
2213
+ type: 'button',
2214
+ className: 'dsh-np-segbtn',
2215
+ title: t('sendSegment'),
2216
+ 'aria-label': t('sendSegment'),
2217
+ disabled: empty || sending,
2218
+ onClick: () => sendSegments([segment]),
2219
+ },
2220
+ h(SendIcon, { size: 14 }),
2221
+ ),
2222
+ h(
2223
+ 'button',
2224
+ {
2225
+ type: 'button',
2226
+ className: 'dsh-np-segbtn',
2227
+ title: t('archiveSegment'),
2228
+ 'aria-label': t('archiveSegment'),
2229
+ onClick: () => archiveSegment(segment.id),
2230
+ },
2231
+ h(ArchiveIcon, { size: 14 }),
2232
+ ),
1739
2233
  ),
1740
2234
  h(
1741
- 'button',
2235
+ 'div',
1742
2236
  {
1743
- type: 'button',
1744
- className: 'dsh-np-segbtn',
1745
- title: t('archiveSegment'),
1746
- 'aria-label': t('archiveSegment'),
1747
- onClick: () => archiveSegment(segment.id),
2237
+ className: 'dsh-np-editor',
2238
+ 'data-drag': dragSegment === segment.id ? 'true' : 'false',
2239
+ onPaste: (event) => onSegmentPaste(event, segment.id),
2240
+ onDrop: (event) => onSegmentDrop(event, segment.id),
2241
+ onDragOver: (event) => onSegmentDragOver(event, segment.id),
2242
+ onDragLeave: (event) => onSegmentDragLeave(event, segment.id),
1748
2243
  },
1749
- h(ArchiveIcon, { size: 14 }),
2244
+ renderMedia(segment, { pending: (uploading[segment.id] ?? 0) > 0 }),
2245
+ h('textarea', {
2246
+ ref: (element) => {
2247
+ if (element === null || element === undefined) {
2248
+ textareaRefs.current.delete(segment.id);
2249
+ return;
2250
+ }
2251
+ textareaRefs.current.set(segment.id, element);
2252
+ autoGrow(element);
2253
+ },
2254
+ className: 'dsh-np-seg-text',
2255
+ rows: 1,
2256
+ value: segment.text,
2257
+ placeholder: index === 0 ? t('placeholder') : t('segmentPlaceholder'),
2258
+ spellCheck: false,
2259
+ maxLength: MAX_SEGMENT_CHARS,
2260
+ 'aria-label': t('editor'),
2261
+ onChange: (event) => onSegmentChange(event, segment.id),
2262
+ onKeyDown: (event) => onSegmentKeyDown(event, segment.id),
2263
+ onBlur: () => {
2264
+ if (dirtyRef.current) saveNowRef.current?.();
2265
+ },
2266
+ }),
1750
2267
  ),
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
- ));
2268
+ );
2269
+ });
1775
2270
 
1776
2271
  const activeEmpty = activeSegments.length === 0
1777
2272
  ? h(
@@ -1849,7 +2344,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1849
2344
  className: 'dsh-np-segbtn',
1850
2345
  title: t('insertSegment'),
1851
2346
  'aria-label': t('insertSegment'),
1852
- disabled: segment.text.trim() === '',
2347
+ disabled: segment.text.trim() === ''
2348
+ && !(Array.isArray(segment.images) && segment.images.length > 0),
1853
2349
  onClick: () => insertSegments([segment]),
1854
2350
  },
1855
2351
  h(InsertIcon, { size: 14 }),
@@ -1861,7 +2357,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1861
2357
  className: 'dsh-np-segbtn',
1862
2358
  title: t('replaceSegment'),
1863
2359
  'aria-label': t('replaceSegment'),
1864
- disabled: segment.text.trim() === '',
2360
+ disabled: segment.text.trim() === ''
2361
+ && !(Array.isArray(segment.images) && segment.images.length > 0),
1865
2362
  onClick: () => replaceSegments([segment]),
1866
2363
  },
1867
2364
  h(ReplaceIcon, { size: 14 }),
@@ -1891,7 +2388,10 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1891
2388
  ),
1892
2389
  ),
1893
2390
  h('div', { className: 'dsh-np-seg-archived-text' },
1894
- segment.text === '' ? t('emptySegment') : segment.text),
2391
+ segment.text === ''
2392
+ ? (Array.isArray(segment.images) && segment.images.length > 0 ? null : t('emptySegment'))
2393
+ : segment.text),
2394
+ renderMedia(segment, { readOnly: true }),
1895
2395
  )),
1896
2396
  ),
1897
2397
  ),
@@ -1921,7 +2421,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1921
2421
  }, t('retry'))
1922
2422
  : null,
1923
2423
  h('span', { className: 'dsh-np-count' },
1924
- `${t('chars', { n: totalChars })} · ${t('segments', { n: activeSegments.length })}`),
2424
+ `${t('chars', { n: totalChars })} · ${t('segments', { n: activeSegments.length })}`
2425
+ + (totalImages > 0 ? ` · ${t('images', { n: totalImages })}` : '')),
1925
2426
  h('span', { className: 'dsh-np-hint', title: t('hint') }, t('hint')),
1926
2427
  );
1927
2428