@restregular/dsh-notepad 0.3.1 → 0.5.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
@@ -37,7 +37,7 @@ window.__ModuleLoader__.load({
37
37
  factory: (require) => {
38
38
  const React = require('react');
39
39
  const h = React.createElement;
40
- const { useState, useEffect, useRef } = React;
40
+ const { useState, useEffect, useLayoutEffect, useRef } = React;
41
41
 
42
42
  const NS = 'dsh-notepad';
43
43
  const API = '/dsh-notepad/api';
@@ -50,8 +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_TITLE_CHARS = 200;
53
54
  const MAX_IMAGES = 20;
54
55
  const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
56
+ const MAX_OPTIMIZE_CHARS = 60000;
57
+ const SIDEBAR_REFRESH_MS = 20000;
58
+ const IMAGE_DELETE_DELAY = 5000;
59
+ const OPTIMIZE_CACHE_LIMIT = 100;
60
+ const EDGE_GAP = 8;
55
61
  const IMAGE_MIME = {
56
62
  'image/png': 'png',
57
63
  'image/jpeg': 'jpg',
@@ -68,6 +74,9 @@ window.__ModuleLoader__.load({
68
74
  editor: '分段内容',
69
75
  placeholder: '记录开发中遇到的问题,或下一阶段要做的事……',
70
76
  segmentPlaceholder: '新分段…',
77
+ titlePlaceholder: '分段标题(可选)',
78
+ segmentTitle: '分段标题',
79
+ indexHint: '点击选择此段 · 拖动可排序',
71
80
  loading: '读取中…',
72
81
  empty: '还没有内容',
73
82
  unsaved: '未保存',
@@ -102,6 +111,25 @@ window.__ModuleLoader__.load({
102
111
  selectHint: '勾选分段可批量操作',
103
112
  selected: '已选 {n} 段',
104
113
  selectSegment: '选择此段',
114
+ merge: '合并分段',
115
+ mergeSelected: '合并选中的 {n} 段',
116
+ mergeHint: '至少选择 2 段才能合并',
117
+ mergedToast: '已合并 {n} 段',
118
+ optimizeSegment: 'AI 优化此段',
119
+ optimizeSelected: 'AI 优化选中的 {n} 段',
120
+ optimizeSelectHint: '勾选分段后可批量优化',
121
+ optimizeAllProgress: 'AI 优化中 {done}/{total}',
122
+ optimizing: '优化中…',
123
+ undoOptimize: '撤销优化',
124
+ optimizedToast: '已优化此段',
125
+ optimizedCount: '已优化 {n} 段',
126
+ optimizeUndone: '已撤销优化',
127
+ optimizeEmpty: '没有可优化的文字',
128
+ optimizeTooLong: '内容太长,无法优化',
129
+ optimizeUnavailable: 'AI 服务不可用',
130
+ optimizeFailed: '优化失败',
131
+ dragSegment: '拖动排序',
132
+ unsent: '{n} 条未发送',
105
133
  archive: '归档',
106
134
  archiveSegment: '归档此段',
107
135
  archiveSelected: '归档选中的 {n} 段',
@@ -132,9 +160,10 @@ window.__ModuleLoader__.load({
132
160
  show: '显示记事本(Ctrl+Shift+N)',
133
161
  chars: '{n} 字符',
134
162
  segments: '{n} 段',
135
- hint: 'Enter 新建分段 · Shift+Enter 换行 · Ctrl+Enter 插入 · 支持粘贴图片',
163
+ hint: 'Enter 新建分段 · Shift+Enter 换行 · Ctrl+Enter 插入 · Ctrl+Shift+0 复位浮窗 · 支持粘贴图片',
136
164
  workspace: '工作区',
137
165
  resize: '拖动调整大小',
166
+ uiReset: '已复位浮窗位置和尺寸',
138
167
  addImage: '添加图片',
139
168
  removeImage: '移除图片',
140
169
  openImage: '打开图片',
@@ -153,6 +182,9 @@ window.__ModuleLoader__.load({
153
182
  editor: 'Segment content',
154
183
  placeholder: 'Jot down problems, or what to build next…',
155
184
  segmentPlaceholder: 'New segment…',
185
+ titlePlaceholder: 'Segment title (optional)',
186
+ segmentTitle: 'Segment title',
187
+ indexHint: 'Click to select · drag to reorder',
156
188
  loading: 'Loading…',
157
189
  empty: 'Nothing here yet',
158
190
  unsaved: 'Unsaved',
@@ -187,6 +219,25 @@ window.__ModuleLoader__.load({
187
219
  selectHint: 'Check segments to batch',
188
220
  selected: '{n} selected',
189
221
  selectSegment: 'Select this segment',
222
+ merge: 'Merge segments',
223
+ mergeSelected: 'Merge {n} selected',
224
+ mergeHint: 'Select at least 2 segments to merge',
225
+ mergedToast: 'Merged {n} segments',
226
+ optimizeSegment: 'Improve this segment with AI',
227
+ optimizeSelected: 'Improve {n} selected with AI',
228
+ optimizeSelectHint: 'Select segments to improve',
229
+ optimizeAllProgress: 'Improving {done}/{total}',
230
+ optimizing: 'Improving…',
231
+ undoOptimize: 'Undo improvement',
232
+ optimizedToast: 'Segment improved',
233
+ optimizedCount: 'Improved {n} segments',
234
+ optimizeUndone: 'Improvement undone',
235
+ optimizeEmpty: 'Nothing to improve',
236
+ optimizeTooLong: 'Too long to improve',
237
+ optimizeUnavailable: 'AI service unavailable',
238
+ optimizeFailed: 'Improve failed',
239
+ dragSegment: 'Drag to reorder',
240
+ unsent: '{n} unsent',
190
241
  archive: 'Archive',
191
242
  archiveSegment: 'Archive this segment',
192
243
  archiveSelected: 'Archive {n} selected',
@@ -217,9 +268,10 @@ window.__ModuleLoader__.load({
217
268
  show: 'Show notepad (Ctrl+Shift+N)',
218
269
  chars: '{n} chars',
219
270
  segments: '{n} segments',
220
- hint: 'Enter new segment · Shift+Enter newline · Ctrl+Enter insert · Paste images',
271
+ hint: 'Enter new segment · Shift+Enter newline · Ctrl+Enter insert · Ctrl+Shift+0 reset window · Paste images',
221
272
  workspace: 'Workspace',
222
273
  resize: 'Drag to resize',
274
+ uiReset: 'Window position and size reset',
223
275
  addImage: 'Add image',
224
276
  removeImage: 'Remove image',
225
277
  openImage: 'Open image',
@@ -488,6 +540,60 @@ window.__ModuleLoader__.load({
488
540
  );
489
541
  }
490
542
 
543
+ /** Four-point sparkle: an AI improvement action. */
544
+ function SparkleIcon({ size = 16 }) {
545
+ return h(
546
+ Svg,
547
+ { size, viewBox: '0 0 16 16' },
548
+ h('path', {
549
+ d: 'M8 1.8c.5 2.7 1.5 3.7 4.2 4.2-2.7.5-3.7 1.5-4.2 4.2-.5-2.7-1.5-3.7-4.2-4.2C6.5 5.5 7.5 4.5 8 1.8Z',
550
+ stroke: 'currentColor', strokeWidth: 1.2,
551
+ strokeLinecap: 'round', strokeLinejoin: 'round',
552
+ }),
553
+ h('path', {
554
+ d: 'M12.4 9.6c.3 1.5.8 2 2.3 2.3-1.5.3-2 .8-2.3 2.3-.3-1.5-.8-2-2.3-2.3 1.5-.3 2-.8 2.3-2.3Z',
555
+ stroke: 'currentColor', strokeWidth: 1.1,
556
+ strokeLinecap: 'round', strokeLinejoin: 'round',
557
+ }),
558
+ );
559
+ }
560
+
561
+ /** Curved arrow: undo the last AI improvement. */
562
+ function UndoIcon({ size = 16 }) {
563
+ return h(
564
+ Svg,
565
+ { size, viewBox: '0 0 16 16' },
566
+ h('path', {
567
+ d: 'M5.4 4.2 2.8 6.8l2.6 2.6',
568
+ stroke: 'currentColor', strokeWidth: 1.35,
569
+ strokeLinecap: 'round', strokeLinejoin: 'round',
570
+ }),
571
+ h('path', {
572
+ d: 'M3 6.8h5.3c2.7 0 4.9 2.2 4.9 4.9',
573
+ stroke: 'currentColor', strokeWidth: 1.35,
574
+ strokeLinecap: 'round', strokeLinejoin: 'round',
575
+ }),
576
+ );
577
+ }
578
+
579
+ /** Two arrows converging: merge selected segments into one. */
580
+ function MergeIcon({ size = 16 }) {
581
+ return h(
582
+ Svg,
583
+ { size, viewBox: '0 0 16 16' },
584
+ h('path', {
585
+ d: 'M3.2 3.4v2.2c0 1.4 1.1 2.5 2.5 2.5h4.6c1.4 0 2.5 1.1 2.5 2.5v2.2',
586
+ stroke: 'currentColor', strokeWidth: 1.3,
587
+ strokeLinecap: 'round', strokeLinejoin: 'round',
588
+ }),
589
+ h('path', {
590
+ d: 'M3.2 12.6v-2.2c0-1.4 1.1-2.5 2.5-2.5h4.6c1.4 0 2.5-1.1 2.5-2.5V3.4',
591
+ stroke: 'currentColor', strokeWidth: 1.3,
592
+ strokeLinecap: 'round', strokeLinejoin: 'round',
593
+ }),
594
+ );
595
+ }
596
+
491
597
  /** Arrow towards the bottom-right corner: minimize to the corner icon. */
492
598
  function MinimizeIcon({ size = 16 }) {
493
599
  return h(
@@ -530,10 +636,10 @@ window.__ModuleLoader__.load({
530
636
  .dsh-np-btn[data-accent=true]{background:var(--np-accent);color:var(--np-accent-fg)}
531
637
  .dsh-np-btn[data-accent=true]:hover:not(:disabled){background:color-mix(in srgb,var(--np-accent) 88%,#000);color:var(--np-accent-fg)}
532
638
  .dsh-np-btn:disabled{opacity:.38;cursor:default;background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280)}
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}
639
+ .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-title-add: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}
534
640
  .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}
535
641
  .dsh-np-card[data-minimized=true] .dsh-np-body{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .16s ease,visibility 0s linear .16s}
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))}
642
+ .dsh-np-tools{display:flex;align-items:center;flex-wrap:wrap;gap:4px;flex:none;padding:0 2px 7px;border-bottom:1px solid var(--dsw-alias-border-l1,rgb(15 23 42/.08))}
537
643
  .dsh-np-selectall{display:inline-flex;align-items:center;gap:6px;min-width:0;cursor:pointer;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px;white-space:nowrap}
538
644
  .dsh-np-selectall input{width:14px;height:14px;margin:0;accent-color:var(--np-accent);cursor:pointer;flex:none}
539
645
  .dsh-np-selectall input:disabled{cursor:default;opacity:.45}
@@ -543,12 +649,24 @@ window.__ModuleLoader__.load({
543
649
  .dsh-np-seg[data-selected=true]{border-color:var(--np-accent);background:color-mix(in srgb,var(--np-accent) 6%,var(--np-surface-2));box-shadow:0 0 0 1px var(--np-accent) inset}
544
650
  .dsh-np-seg[data-archived=true]{border-style:dashed;background:transparent;opacity:.82;animation:none}
545
651
  .dsh-np-seg-head{display:flex;align-items:center;gap:3px;flex:none;min-height:30px;padding:3px 5px 0 7px;box-sizing:border-box}
652
+ .dsh-np-seg[data-reorder-drag=true]{opacity:.45}
653
+ .dsh-np-seg[data-drop-before=true]{box-shadow:0 -2px 0 0 var(--np-accent)}
654
+ .dsh-np-seg[data-drop-after=true]{box-shadow:0 2px 0 0 var(--np-accent)}
546
655
  .dsh-np-check{display:inline-flex;align-items:center;justify-content:center;flex:none;width:20px;height:20px;cursor:pointer}
547
656
  .dsh-np-check input{width:14px;height:14px;margin:0;accent-color:var(--np-accent);cursor:pointer}
548
- .dsh-np-index{flex:none;color:var(--dsw-alias-label-caption,#9aa3b2);font-size:10px;font-weight:600;letter-spacing:.04em;font-variant-numeric:tabular-nums}
657
+ .dsh-np-index{flex:none;display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:20px;padding:0 2px;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-caption,#9aa3b2);font:inherit;font-size:10px;font-weight:700;letter-spacing:.04em;font-variant-numeric:tabular-nums;cursor:pointer;user-select:none}
658
+ .dsh-np-index:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06));color:var(--dsw-alias-label-primary,#1f2430)}
659
+ .dsh-np-index[aria-pressed=true]{background:color-mix(in srgb,var(--np-accent) 14%,transparent);color:var(--np-accent)}
660
+ .dsh-np-title-input{flex:1 1 auto;min-width:0;width:0;height:20px;padding:0 4px;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#4b5563);font:inherit;font-size:11px;font-weight:600;line-height:20px;outline:0}
661
+ .dsh-np-title-input::placeholder{color:var(--dsw-alias-label-caption,#9aa3b2);font-weight:400}
662
+ .dsh-np-title-input:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06))}
663
+ .dsh-np-title-input:focus{background:var(--np-surface);box-shadow:0 0 0 1px var(--np-accent) inset;color:var(--dsw-alias-label-primary,#1f2430)}
664
+ .dsh-np-title-add{flex:1 1 auto;min-width:0;width:0;height:20px;padding:0 4px;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-caption,#9aa3b2);font:inherit;font-size:11px;font-weight:400;line-height:20px;text-align:left;cursor:text;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.75}
665
+ .dsh-np-seg-title{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary,#4b5563);font-size:11px;font-weight:600}
549
666
  .dsh-np-archived-chip{flex:none;padding:0 6px;border-radius:5px;background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06));color:var(--dsw-alias-label-tertiary,#6b7280);font-size:10px;line-height:16px}
550
667
  .dsh-np-segbtn{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer;transition:background .12s,color .12s,opacity .12s;opacity:.8}
551
668
  .dsh-np-segbtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06));color:var(--dsw-alias-label-primary,#1f2430);opacity:1}
669
+ .dsh-np-segbtn[data-ai=true]{color:var(--np-accent);opacity:1}
552
670
  .dsh-np-segbtn[data-danger=true]:hover:not(:disabled){color:var(--dsw-alias-state-error-primary,#e25555);background:var(--dsw-alias-interactive-bg-hover-danger,rgb(236 19 19/.05))}
553
671
  .dsh-np-segbtn:disabled{opacity:.3;cursor:default}
554
672
  .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}
@@ -566,6 +684,7 @@ window.__ModuleLoader__.load({
566
684
  .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
685
  .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
686
  .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}
687
+ .dsh-np-mini-spinner{flex:none;width:12px;height:12px;border:1.8px 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}
569
688
  .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
689
  .dsh-np-seg[data-archived=true] .dsh-np-medias{padding:0 10px 8px}
571
690
  .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}
@@ -597,6 +716,9 @@ window.__ModuleLoader__.load({
597
716
  .dsh-np-restore{position:fixed;right:16px;bottom:16px;z-index:30;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;padding:0;border:1px solid var(--dsw-alias-border-l2,rgb(15 23 42/.12));border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-secondary,#4b5563);box-shadow:var(--dsw-elevation-lv2,0 8px 24px rgb(15 23 42/.16));cursor:pointer;opacity:0;visibility:hidden;pointer-events:none;transform:translateY(6px) scale(.9);transition:opacity .18s ease,transform .18s ease,visibility 0s linear .18s}
598
717
  .dsh-np-restore[data-visible=true]{opacity:.9;visibility:visible;pointer-events:auto;transform:none;transition:opacity .18s ease,transform .18s ease,visibility 0s linear 0s}
599
718
  .dsh-np-restore[data-visible=true]:hover{opacity:1;transform:translateY(-1px)}
719
+ .dsh-np-restore-badge{position:absolute;top:-5px;right:-5px;min-width:16px;height:16px;padding:0 4px;box-sizing:border-box;border-radius:9px;background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff;font-size:9.5px;font-weight:700;line-height:16px;text-align:center;font-variant-numeric:tabular-nums;box-shadow:0 0 0 1.5px var(--dsw-specific-sidebar-fill,var(--dsw-alias-bg-layer-1,#fff))}
720
+ [data-row-key^="workspace:"][data-np-unsent]{position:relative}
721
+ [data-row-key^="workspace:"][data-np-unsent]::after{content:attr(data-np-unsent);position:absolute;top:-4px;left:1px;z-index:2;min-width:15px;height:15px;padding:0 4px;box-sizing:border-box;border-radius:8px;background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff;font-size:9.5px;font-weight:700;line-height:15px;text-align:center;font-variant-numeric:tabular-nums;pointer-events:none;box-shadow:0 0 0 1.5px var(--dsw-specific-sidebar-fill,var(--dsw-alias-bg-layer-1,#fff))}
600
722
  @keyframes dsh-np-pulse{0%,100%{opacity:1}50%{opacity:.35}}
601
723
  @keyframes dsh-np-rise{from{opacity:0;transform:translate(-50%,4px)}to{opacity:1;transform:translate(-50%,0)}}
602
724
  @keyframes dsh-np-item-in{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
@@ -605,7 +727,7 @@ body[data-ds-dark-theme] .dsh-np-card{--np-accent-fg:#0f1115;box-shadow:0 18px 4
605
727
  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))}
606
728
  body[data-ds-dark-theme] .dsh-np-btn[data-accent=true]:hover:not(:disabled){background:color-mix(in srgb,var(--np-accent) 84%,#fff)}
607
729
  body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
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}}`;
730
+ @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,.dsh-np-mini-spinner{animation:none}}`;
609
731
 
610
732
  // ------------------------------------------------------------------
611
733
  // Helpers
@@ -625,14 +747,38 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
625
747
  return `seg-${Date.now().toString(36)}-${segmentSequence.toString(36)}`;
626
748
  }
627
749
 
628
- function newSegment(text) {
629
- return { id: newSegmentId(), text, images: [], archived: false, archivedAt: null };
750
+ function nowIso() {
751
+ try {
752
+ return new Date().toISOString();
753
+ } catch {
754
+ return '';
755
+ }
756
+ }
757
+
758
+ function newSegment(text, options = {}) {
759
+ return {
760
+ id: newSegmentId(),
761
+ text,
762
+ title: typeof options.title === 'string' ? options.title.slice(0, MAX_TITLE_CHARS) : '',
763
+ createdAt: typeof options.createdAt === 'string' && options.createdAt !== ''
764
+ ? options.createdAt
765
+ : nowIso(),
766
+ images: [],
767
+ archived: false,
768
+ archivedAt: null,
769
+ };
630
770
  }
631
771
 
632
772
  function serializeSegments(segments) {
633
773
  return segments.map((segment) => ({
634
774
  id: segment.id,
635
775
  text: segment.text,
776
+ ...(typeof segment.title === 'string' && segment.title !== ''
777
+ ? { title: segment.title }
778
+ : {}),
779
+ ...(typeof segment.createdAt === 'string' && segment.createdAt !== ''
780
+ ? { createdAt: segment.createdAt }
781
+ : {}),
636
782
  ...(Array.isArray(segment.images) && segment.images.length > 0
637
783
  ? { images: segment.images.map((image) => ({ path: image.path, name: image.name })) }
638
784
  : {}),
@@ -654,6 +800,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
654
800
  out.push({
655
801
  id,
656
802
  text: item.text,
803
+ title: typeof item.title === 'string' ? item.title.slice(0, MAX_TITLE_CHARS) : '',
804
+ createdAt: typeof item.createdAt === 'string' && item.createdAt !== '' ? item.createdAt : null,
657
805
  images: normalizeImages(item.images),
658
806
  archived,
659
807
  archivedAt: archived && typeof item.archivedAt === 'string' ? item.archivedAt : null,
@@ -737,6 +885,46 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
737
885
  .join('\n\n');
738
886
  }
739
887
 
888
+ /** Whether a segment still counts as unsent work (active + has content). */
889
+ function isUnsent(segment) {
890
+ if (segment === null || segment === undefined || segment.archived === true) return false;
891
+ if (typeof segment.text === 'string' && segment.text.trim() !== '') return true;
892
+ return Array.isArray(segment.images) && segment.images.length > 0;
893
+ }
894
+
895
+ function countUnsent(segments) {
896
+ let count = 0;
897
+ for (const segment of segments) {
898
+ if (isUnsent(segment)) count += 1;
899
+ }
900
+ return count;
901
+ }
902
+
903
+ /** Whether a whole server payload predates per-segment creation times. */
904
+ function isLegacyPayload(data) {
905
+ const segments = Array.isArray(data?.segments) ? data.segments : [];
906
+ return segments.length > 0 && segments.every((segment) =>
907
+ segment !== null && typeof segment === 'object'
908
+ && (typeof segment.createdAt !== 'string' || segment.createdAt === ''));
909
+ }
910
+
911
+ /**
912
+ * Adopt server content without moving cards the user is looking at.
913
+ * Known ids keep the local order; cards created elsewhere land on top.
914
+ */
915
+ function mergeServerOrder(current, incoming) {
916
+ const byId = new Map();
917
+ for (const segment of incoming) byId.set(segment.id, segment);
918
+ const kept = [];
919
+ for (const segment of current) {
920
+ const next = byId.get(segment.id);
921
+ if (next !== undefined) kept.push(next);
922
+ }
923
+ const known = new Set(kept.map((segment) => segment.id));
924
+ const added = incoming.filter((segment) => !known.has(segment.id));
925
+ return [...added, ...kept];
926
+ }
927
+
740
928
  function isAbsolutePath(value) {
741
929
  const text = String(value ?? '');
742
930
  return text.startsWith('/') || /^[A-Za-z]:\//u.test(text) || text.startsWith('//');
@@ -873,6 +1061,43 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
873
1061
  return parts.length === 0 ? String(root ?? '') : parts[parts.length - 1];
874
1062
  }
875
1063
 
1064
+ /**
1065
+ * Height of the chrome the host draws over the page (the Windows title bar
1066
+ * with maximize / minimize / close, or the macOS traffic lights). The
1067
+ * floating card must never move above that strip. The host exposes it as a
1068
+ * CSS variable on <html>; standalone browsers have none, so this is 0.
1069
+ * @returns {number}
1070
+ */
1071
+ function hostChromeTop() {
1072
+ try {
1073
+ const root = typeof document !== 'undefined' ? document.documentElement : null;
1074
+ if (root === null || root === undefined) return 0;
1075
+ const hasAttribute = (name) => typeof root.hasAttribute === 'function' && root.hasAttribute(name);
1076
+ const readVar = (name) => {
1077
+ try {
1078
+ if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') return NaN;
1079
+ const style = window.getComputedStyle(root);
1080
+ return Number.parseFloat(style?.getPropertyValue(name) ?? '');
1081
+ } catch {
1082
+ return NaN;
1083
+ }
1084
+ };
1085
+ // `--dsh-frame-chrome-top` is 0 while the host is in fullscreen.
1086
+ const chrome = readVar('--dsh-frame-chrome-top');
1087
+ if (Number.isFinite(chrome)) return Math.max(0, chrome);
1088
+ const clearance = readVar('--dsh-frame-top-clearance');
1089
+ if (Number.isFinite(clearance) && clearance > 0) return clearance;
1090
+ if (hasAttribute('data-windows-titlebar')) {
1091
+ const titlebar = readVar('--dsh-windows-titlebar-height');
1092
+ return Number.isFinite(titlebar) && titlebar > 0 ? titlebar : 32;
1093
+ }
1094
+ const platform = typeof root.getAttribute === 'function' ? root.getAttribute('data-platform') : '';
1095
+ return platform === 'darwin' ? 48 : 0;
1096
+ } catch {
1097
+ return 0;
1098
+ }
1099
+ }
1100
+
876
1101
  function readStoredUi() {
877
1102
  const base = {
878
1103
  x: null,
@@ -905,17 +1130,18 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
905
1130
  function clampUi(ui) {
906
1131
  const vw = (typeof window !== 'undefined' && window.innerWidth) || 1280;
907
1132
  const vh = (typeof window !== 'undefined' && window.innerHeight) || 800;
908
- const widthLimit = Math.max(240, vw - 16);
909
- const heightLimit = Math.max(180, vh - 16);
1133
+ const minTop = hostChromeTop() + EDGE_GAP;
1134
+ const widthLimit = Math.max(240, vw - EDGE_GAP * 2);
1135
+ const heightLimit = Math.max(180, vh - minTop - EDGE_GAP);
910
1136
  const width = Math.min(Math.max(MIN_W, Number(ui.width) || DEFAULT_W), widthLimit);
911
1137
  const height = Math.min(Math.max(MIN_H, Number(ui.height) || DEFAULT_H), heightLimit);
912
1138
  const displayHeight = ui.minimized ? HEAD_H : height;
913
1139
  const x = ui.x === null || ui.x === undefined
914
- ? vw - width - 24
915
- : Math.min(Math.max(8, ui.x), Math.max(8, vw - width - 8));
1140
+ ? Math.max(EDGE_GAP, vw - width - 24)
1141
+ : Math.min(Math.max(EDGE_GAP, ui.x), Math.max(EDGE_GAP, vw - width - EDGE_GAP));
916
1142
  const y = ui.y === null || ui.y === undefined
917
- ? vh - displayHeight - 24
918
- : Math.min(Math.max(8, ui.y), Math.max(8, vh - displayHeight - 8));
1143
+ ? Math.max(minTop, vh - displayHeight - 24)
1144
+ : Math.min(Math.max(minTop, ui.y), Math.max(minTop, vh - displayHeight - EDGE_GAP));
919
1145
  return { ...ui, x, y, width, height };
920
1146
  }
921
1147
 
@@ -950,6 +1176,32 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
950
1176
  return readResponse(response);
951
1177
  }
952
1178
 
1179
+ async function optimizeNoteText(root, sessionId, text, title) {
1180
+ const response = await fetch(`${API}/optimize`, {
1181
+ method: 'POST',
1182
+ headers: { 'Content-Type': 'application/json' },
1183
+ body: JSON.stringify({ root, sessionId, text, title }),
1184
+ });
1185
+ return readResponse(response);
1186
+ }
1187
+
1188
+ async function removeStoredImage(root, sessionId, pathValue) {
1189
+ const response = await fetch(`${API}/image`, {
1190
+ method: 'DELETE',
1191
+ headers: { 'Content-Type': 'application/json' },
1192
+ body: JSON.stringify({ root, sessionId, path: pathValue }),
1193
+ });
1194
+ return readResponse(response);
1195
+ }
1196
+
1197
+ async function fetchUnsentCounts(sessionId) {
1198
+ const response = await fetch(`${API}/unsent?sessionId=${encodeURIComponent(sessionId)}`, {
1199
+ method: 'GET',
1200
+ cache: 'no-store',
1201
+ });
1202
+ return readResponse(response);
1203
+ }
1204
+
953
1205
  async function copyText(value) {
954
1206
  try {
955
1207
  if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
@@ -1016,11 +1268,20 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1016
1268
  const [error, setError] = useState('');
1017
1269
  const [savedAt, setSavedAt] = useState(null);
1018
1270
  const [toast, setToast] = useState(null);
1019
- const [ui, setUi] = useState(readStoredUi);
1271
+ const [ui, setUi] = useState(() => clampUi(readStoredUi()));
1020
1272
  const [sending, setSending] = useState(false);
1021
1273
  const [resizing, setResizing] = useState(false);
1022
1274
  const [uploading, setUploading] = useState({});
1023
1275
  const [dragSegment, setDragSegment] = useState('');
1276
+ const [reorderDrag, setReorderDrag] = useState('');
1277
+ const [reorderOver, setReorderOver] = useState(null);
1278
+ const [optimizing, setOptimizing] = useState(() => new Set());
1279
+ const [optimizeBatch, setOptimizeBatch] = useState(null);
1280
+ const [segmentUndo, setSegmentUndo] = useState(() => new Map());
1281
+ const [batchUndo, setBatchUndo] = useState(null);
1282
+ const [titleEditing, setTitleEditing] = useState('');
1283
+ const [wsCounts, setWsCounts] = useState({});
1284
+ const [currentWsId, setCurrentWsId] = useState('');
1024
1285
 
1025
1286
  const bindingRef = useRef(null);
1026
1287
  const rootSessionRef = useRef('');
@@ -1044,7 +1305,16 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1044
1305
  const fileInputRefs = useRef(new Map());
1045
1306
  const listRef = useRef(null);
1046
1307
  const pendingFocusRef = useRef(null);
1308
+ const pendingScrollRef = useRef(null);
1309
+ const refreshSeqRef = useRef(0);
1047
1310
  const undoRef = useRef(null);
1311
+ const optimizingRef = useRef(new Set());
1312
+ const optimizeBatchRef = useRef(false);
1313
+ const optimizeCacheRef = useRef(new Map());
1314
+ const batchUndoRef = useRef(null);
1315
+ const reorderDragRef = useRef('');
1316
+ const reorderOverRef = useRef(null);
1317
+ const imageDeleteTimersRef = useRef(new Map());
1048
1318
 
1049
1319
  const setSelection = (next) => {
1050
1320
  selectedRef.current = next;
@@ -1063,6 +1333,17 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1063
1333
  if (changed) setSelection(pruned);
1064
1334
  };
1065
1335
 
1336
+ /** Keep the ref and the state in sync for the batch-optimize undo slot. */
1337
+ const setBatchUndoState = (value) => {
1338
+ batchUndoRef.current = value;
1339
+ setBatchUndo(value);
1340
+ };
1341
+
1342
+ /** Any local edit invalidates the "undo the last batch" offer. */
1343
+ const invalidateBatchUndo = () => {
1344
+ if (batchUndoRef.current !== null) setBatchUndoState(null);
1345
+ };
1346
+
1066
1347
  // Session + workspace -------------------------------------------------
1067
1348
  useEffect(() => {
1068
1349
  let disposed = false;
@@ -1182,6 +1463,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1182
1463
  saveNowRef.current = saveNow;
1183
1464
 
1184
1465
  const commitSegments = (next, options = {}) => {
1466
+ invalidateBatchUndo();
1185
1467
  segmentsRef.current = next;
1186
1468
  setSegments(next);
1187
1469
  pruneSelectionFor(next);
@@ -1200,6 +1482,10 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1200
1482
  segmentsRef.current = [];
1201
1483
  setSegments([]);
1202
1484
  setSelection(new Set());
1485
+ setSegmentUndo(new Map());
1486
+ setBatchUndoState(null);
1487
+ setTitleEditing('');
1488
+ pendingScrollRef.current = null;
1203
1489
  setStatus('idle');
1204
1490
  setError('');
1205
1491
  return undefined;
@@ -1216,6 +1502,10 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1216
1502
  segmentsRef.current = [];
1217
1503
  setSegments([]);
1218
1504
  setSelection(new Set());
1505
+ setSegmentUndo(new Map());
1506
+ setBatchUndoState(null);
1507
+ setTitleEditing('');
1508
+ pendingScrollRef.current = null;
1219
1509
  setStatus('loading');
1220
1510
  setError('');
1221
1511
  const load = () => {
@@ -1227,10 +1517,15 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1227
1517
  .then((data) => {
1228
1518
  if (cancelled || rootRef.current !== root || mySeq !== loadSeqRef.current) return;
1229
1519
  let value = normalizeServerSegments(data);
1520
+ // Notes written before 0.4 appended new cards at the bottom;
1521
+ // reverse that legacy order once so the newest card leads.
1522
+ if (isLegacyPayload(data)) value = value.slice().reverse();
1230
1523
  if (value.length === 0) value = [newSegment('')];
1231
1524
  segmentsRef.current = value;
1232
1525
  setSegments(value);
1233
1526
  setSelection(new Set());
1527
+ setSegmentUndo(new Map());
1528
+ setBatchUndoState(null);
1234
1529
  versionRef.current = typeof data.mtimeMs === 'number' ? data.mtimeMs : null;
1235
1530
  setSavedAt(typeof data.mtimeMs === 'number' ? data.mtimeMs : null);
1236
1531
  dirtyRef.current = false;
@@ -1251,6 +1546,91 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1251
1546
  };
1252
1547
  }, [binding === null ? '' : binding.id, binding === null ? '' : binding.cwd]);
1253
1548
 
1549
+ // Sidebar workspace badges --------------------------------------------
1550
+ useEffect(() => {
1551
+ if (binding === null) {
1552
+ setCurrentWsId('');
1553
+ setWsCounts({});
1554
+ return undefined;
1555
+ }
1556
+ setCurrentWsId('');
1557
+ let disposed = false;
1558
+ const refresh = () => {
1559
+ const sessionId = binding.id;
1560
+ if (typeof sessionId !== 'string' || sessionId === '') return;
1561
+ fetchUnsentCounts(sessionId)
1562
+ .then((data) => {
1563
+ if (disposed || bindingRef.current?.id !== sessionId) return;
1564
+ const next = {};
1565
+ let matched = '';
1566
+ const target = normalizedCwd(binding.cwd);
1567
+ for (const workspace of Array.isArray(data?.workspaces) ? data.workspaces : []) {
1568
+ const id = typeof workspace?.id === 'string' ? workspace.id : '';
1569
+ if (id === '') continue;
1570
+ next[id] = Number.isFinite(workspace.count) ? Math.max(0, Math.floor(workspace.count)) : 0;
1571
+ if (matched === '' && normalizedCwd(workspace.path) === target) matched = id;
1572
+ }
1573
+ setWsCounts(next);
1574
+ if (matched !== '') setCurrentWsId(matched);
1575
+ })
1576
+ .catch(() => {});
1577
+ };
1578
+ refresh();
1579
+ const timer = setInterval(refresh, SIDEBAR_REFRESH_MS);
1580
+ const onFocus = () => refresh();
1581
+ const onVisibility = () => {
1582
+ if (typeof document !== 'undefined' && document.visibilityState === 'visible') refresh();
1583
+ };
1584
+ window.addEventListener('focus', onFocus);
1585
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
1586
+ return () => {
1587
+ disposed = true;
1588
+ clearInterval(timer);
1589
+ window.removeEventListener('focus', onFocus);
1590
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
1591
+ };
1592
+ }, [binding === null ? '' : binding.id, binding === null ? '' : binding.cwd]);
1593
+
1594
+ const localUnsentCount = countUnsent(segments);
1595
+
1596
+ useEffect(() => {
1597
+ if (currentWsId === '') return;
1598
+ setWsCounts((previous) =>
1599
+ (previous[currentWsId] === localUnsentCount
1600
+ ? previous
1601
+ : { ...previous, [currentWsId]: localUnsentCount }));
1602
+ }, [currentWsId, localUnsentCount]);
1603
+
1604
+ useEffect(() => {
1605
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return undefined;
1606
+ const apply = () => {
1607
+ let rows;
1608
+ try {
1609
+ rows = document.querySelectorAll('[data-row-key^="workspace:"]');
1610
+ } catch {
1611
+ return;
1612
+ }
1613
+ for (const row of rows) {
1614
+ const key = typeof row.getAttribute === 'function' ? String(row.getAttribute('data-row-key') ?? '') : '';
1615
+ const id = key.startsWith('workspace:') ? key.slice('workspace:'.length) : '';
1616
+ const count = id === '' ? 0 : (wsCounts[id] ?? 0);
1617
+ if (count > 0) row.setAttribute('data-np-unsent', count > 99 ? '99+' : String(count));
1618
+ else row.removeAttribute('data-np-unsent');
1619
+ }
1620
+ };
1621
+ apply();
1622
+ const Observer = typeof globalThis !== 'undefined' ? globalThis.MutationObserver : undefined;
1623
+ if (typeof Observer !== 'function' || typeof document.body === 'undefined' || document.body === null) {
1624
+ return () => apply();
1625
+ }
1626
+ const observer = new Observer(apply);
1627
+ observer.observe(document.body, { childList: true, subtree: true });
1628
+ return () => {
1629
+ observer.disconnect();
1630
+ apply();
1631
+ };
1632
+ }, [wsCounts]);
1633
+
1254
1634
  // Focus a just-created or restored card ------------------------------
1255
1635
  useEffect(() => {
1256
1636
  const pending = pendingFocusRef.current;
@@ -1282,20 +1662,49 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1282
1662
  autoGrow(element);
1283
1663
  }, [segments]);
1284
1664
 
1665
+ // Restore a captured scroll offset right after a refresh commits ------
1666
+ useLayoutEffect(() => {
1667
+ const pending = pendingScrollRef.current;
1668
+ if (pending === null || pending === undefined) return;
1669
+ pendingScrollRef.current = null;
1670
+ const list = listRef.current;
1671
+ if (list === null || list === undefined) return;
1672
+ try {
1673
+ list.scrollTop = pending;
1674
+ } catch {
1675
+ /* A detached list cannot be scrolled. */
1676
+ }
1677
+ }, [segments]);
1678
+
1285
1679
  // Keyboard ------------------------------------------------------------
1286
1680
  useEffect(() => {
1287
1681
  const onKeyDown = (event) => {
1288
1682
  const chord = event.ctrlKey || event.metaKey;
1289
- if (!chord || !event.shiftKey) return;
1290
- if (event.key !== 'N' && event.key !== 'n') return;
1291
- event.preventDefault();
1292
- setUi((previous) => clampUi({ ...previous, hidden: !previous.hidden }));
1683
+ if (!chord || !event.shiftKey || event.altKey) return;
1684
+ if (event.key === 'N' || event.key === 'n') {
1685
+ event.preventDefault();
1686
+ setUi((previous) => clampUi({ ...previous, hidden: !previous.hidden }));
1687
+ return;
1688
+ }
1689
+ if (event.key === '0' || event.code === 'Digit0' || event.code === 'Numpad0') {
1690
+ event.preventDefault();
1691
+ setUi((previous) => clampUi({
1692
+ ...previous,
1693
+ x: null,
1694
+ y: null,
1695
+ width: DEFAULT_W,
1696
+ height: DEFAULT_H,
1697
+ }));
1698
+ showToast(t('uiReset'));
1699
+ }
1293
1700
  };
1294
1701
  window.addEventListener('keydown', onKeyDown, true);
1295
1702
  return () => {
1296
1703
  window.removeEventListener('keydown', onKeyDown, true);
1297
1704
  clearTimer();
1298
1705
  if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
1706
+ for (const timer of imageDeleteTimersRef.current.values()) clearTimeout(timer);
1707
+ imageDeleteTimersRef.current.clear();
1299
1708
  };
1300
1709
  }, []);
1301
1710
 
@@ -1330,20 +1739,41 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1330
1739
  const active = typeof document !== 'undefined' ? document.activeElement : null;
1331
1740
  if (active !== null && active !== undefined && typeof active.closest === 'function'
1332
1741
  && active.closest('[data-dsh-notepad="true"]') !== null) return;
1742
+ // Snapshot the scroll offset right before the refresh; the list may
1743
+ // re-render (and jump) while the updated note is applied.
1744
+ const list = listRef.current;
1745
+ const scrollTop = list !== null && list !== undefined && Number.isFinite(list.scrollTop)
1746
+ ? list.scrollTop
1747
+ : null;
1748
+ const mySeq = ++refreshSeqRef.current;
1749
+ pendingScrollRef.current = scrollTop;
1333
1750
  fetchNote(root, sessionId)
1334
1751
  .then((data) => {
1335
- if (rootRef.current !== root || dirtyRef.current) return;
1336
- if (typeof data.mtimeMs === 'number' && data.mtimeMs === versionRef.current) return;
1337
- let value = normalizeServerSegments(data);
1338
- if (value.length === 0) value = [newSegment('')];
1752
+ if (mySeq !== refreshSeqRef.current) return;
1753
+ if (rootRef.current !== root || dirtyRef.current) {
1754
+ pendingScrollRef.current = null;
1755
+ return;
1756
+ }
1757
+ if (typeof data.mtimeMs === 'number' && data.mtimeMs === versionRef.current) {
1758
+ pendingScrollRef.current = null;
1759
+ return;
1760
+ }
1761
+ let incoming = normalizeServerSegments(data);
1762
+ if (isLegacyPayload(data)) incoming = incoming.slice().reverse();
1763
+ if (incoming.length === 0) incoming = [newSegment('')];
1764
+ const value = mergeServerOrder(segmentsRef.current, incoming);
1339
1765
  segmentsRef.current = value;
1340
1766
  setSegments(value);
1341
- setSelection(new Set());
1767
+ pruneSelectionFor(value);
1768
+ setSegmentUndo(new Map());
1769
+ setBatchUndoState(null);
1342
1770
  versionRef.current = typeof data.mtimeMs === 'number' ? data.mtimeMs : null;
1343
1771
  setSavedAt(typeof data.mtimeMs === 'number' ? data.mtimeMs : null);
1344
1772
  setStatus('saved');
1345
1773
  })
1346
- .catch(() => {});
1774
+ .catch(() => {
1775
+ if (mySeq === refreshSeqRef.current) pendingScrollRef.current = null;
1776
+ });
1347
1777
  };
1348
1778
  const onVisibility = () => {
1349
1779
  if (typeof document !== 'undefined' && document.visibilityState === 'visible') refresh();
@@ -1362,9 +1792,31 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1362
1792
 
1363
1793
  // Segment mutations ---------------------------------------------------
1364
1794
  const updateSegmentText = (id, value) => {
1795
+ invalidateBatchUndo();
1365
1796
  const next = segmentsRef.current.map((segment) => (segment.id === id ? { ...segment, text: value } : segment));
1366
1797
  segmentsRef.current = next;
1367
1798
  setSegments(next);
1799
+ setSegmentUndo((previous) => {
1800
+ const record = previous.get(id);
1801
+ if (record === undefined || record.after === value) return previous;
1802
+ const pruned = new Map(previous);
1803
+ pruned.delete(id);
1804
+ return pruned;
1805
+ });
1806
+ dirtyRef.current = true;
1807
+ setStatus('dirty');
1808
+ setError('');
1809
+ scheduleSave();
1810
+ };
1811
+
1812
+ const updateSegmentTitle = (id, value) => {
1813
+ const title = String(value ?? '').slice(0, MAX_TITLE_CHARS);
1814
+ const target = segmentsRef.current.find((segment) => segment.id === id);
1815
+ if (target === undefined || target.title === title) return;
1816
+ invalidateBatchUndo();
1817
+ const next = segmentsRef.current.map((segment) => (segment.id === id ? { ...segment, title } : segment));
1818
+ segmentsRef.current = next;
1819
+ setSegments(next);
1368
1820
  dirtyRef.current = true;
1369
1821
  setStatus('dirty');
1370
1822
  setError('');
@@ -1445,13 +1897,46 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1445
1897
  }
1446
1898
  };
1447
1899
 
1900
+ const clearPendingImageDeletes = () => {
1901
+ for (const timer of imageDeleteTimersRef.current.values()) {
1902
+ clearTimeout(timer);
1903
+ }
1904
+ imageDeleteTimersRef.current.clear();
1905
+ };
1906
+
1907
+ const deleteImageFile = (pathValue) => {
1908
+ const root = rootRef.current;
1909
+ const sessionId = rootSessionRef.current;
1910
+ if (root === '' || sessionId === '' || typeof pathValue !== 'string' || pathValue === '') return;
1911
+ removeStoredImage(root, sessionId, pathValue).catch(() => {});
1912
+ };
1913
+
1914
+ const scheduleImageCleanup = (images) => {
1915
+ const list = Array.isArray(images) ? images : [];
1916
+ for (const image of list) {
1917
+ const pathValue = typeof image?.path === 'string' ? image.path : '';
1918
+ if (pathValue === '' || imageDeleteTimersRef.current.has(pathValue)) continue;
1919
+ const timer = setTimeout(() => {
1920
+ imageDeleteTimersRef.current.delete(pathValue);
1921
+ if (!segmentsRef.current.some((segment) =>
1922
+ (Array.isArray(segment.images) ? segment.images : []).some((item) => item.path === pathValue))) {
1923
+ deleteImageFile(pathValue);
1924
+ }
1925
+ }, IMAGE_DELETE_DELAY);
1926
+ imageDeleteTimersRef.current.set(pathValue, timer);
1927
+ }
1928
+ };
1929
+
1448
1930
  const removeImage = (id, pathValue) => {
1449
1931
  const next = segmentsRef.current.map((segment) => {
1450
1932
  if (segment.id !== id) return segment;
1451
1933
  const images = Array.isArray(segment.images) ? segment.images.filter((image) => image.path !== pathValue) : [];
1452
1934
  return { ...segment, images };
1453
1935
  });
1936
+ const stillReferenced = next.some((segment) =>
1937
+ (Array.isArray(segment.images) ? segment.images : []).some((image) => image.path === pathValue));
1454
1938
  commitSegments(next);
1939
+ if (!stillReferenced) deleteImageFile(pathValue);
1455
1940
  };
1456
1941
 
1457
1942
  const triggerFilePicker = (id) => {
@@ -1524,7 +2009,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1524
2009
  return;
1525
2010
  }
1526
2011
  const segment = newSegment('');
1527
- commitSegments([...segmentsRef.current, segment], { focus: { id: segment.id, caret: 0 } });
2012
+ commitSegments([segment, ...segmentsRef.current], { focus: { id: segment.id, caret: 0 } });
1528
2013
  };
1529
2014
 
1530
2015
  const splitSegment = (id, element) => {
@@ -1534,15 +2019,21 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1534
2019
  const head = current.text.slice(0, start);
1535
2020
  const tail = current.text.slice(start);
1536
2021
  const nextSegment = newSegment(tail);
1537
- const next = [];
1538
- for (const segment of segmentsRef.current) {
1539
- if (segment.id === id) {
1540
- next.push({ ...segment, text: head });
1541
- next.push(nextSegment);
1542
- } else {
1543
- next.push(segment);
1544
- }
1545
- }
2022
+ const updated = segmentsRef.current.map((segment) =>
2023
+ (segment.id === id ? { ...segment, text: head } : segment));
2024
+ // Enter at the end creates a fresh card, which belongs at the top.
2025
+ // Splitting in the middle keeps the tail next to its source card so
2026
+ // the caret does not jump away from what the user is editing.
2027
+ const next = tail.trim() === ''
2028
+ ? [nextSegment, ...updated]
2029
+ : (() => {
2030
+ const out = [];
2031
+ for (const segment of updated) {
2032
+ out.push(segment);
2033
+ if (segment.id === id) out.push(nextSegment);
2034
+ }
2035
+ return out;
2036
+ })();
1546
2037
  commitSegments(next, { focus: { id: nextSegment.id, caret: 0 } });
1547
2038
  };
1548
2039
 
@@ -1594,6 +2085,9 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1594
2085
  const record = undoRef.current;
1595
2086
  if (record === null || record === undefined) return;
1596
2087
  undoRef.current = null;
2088
+ clearPendingImageDeletes();
2089
+ setSegmentUndo(new Map());
2090
+ setBatchUndoState(null);
1597
2091
  commitSegments(record.snapshot);
1598
2092
  setToast(null);
1599
2093
  };
@@ -1604,6 +2098,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1604
2098
  undoRef.current = { snapshot: segmentsRef.current };
1605
2099
  const next = segmentsRef.current.filter((segment) => segment.id !== id);
1606
2100
  commitSegments(next);
2101
+ scheduleImageCleanup(target.images);
1607
2102
  showToast(t('deletedToast'), { label: t('undo'), onClick: undoLast });
1608
2103
  };
1609
2104
 
@@ -1613,9 +2108,288 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1613
2108
  undoRef.current = { snapshot: segmentsRef.current };
1614
2109
  const next = segmentsRef.current.filter((segment) => segment.archived !== true);
1615
2110
  commitSegments(next);
2111
+ scheduleImageCleanup(archived.flatMap((segment) => (Array.isArray(segment.images) ? segment.images : [])));
1616
2112
  showToast(t('clearedArchive', { n: archived.length }), { label: t('undo'), onClick: undoLast });
1617
2113
  };
1618
2114
 
2115
+ // Merge / reorder -----------------------------------------------------
2116
+ const mergeSelected = () => {
2117
+ const ids = new Set(
2118
+ segmentsRef.current
2119
+ .filter((segment) => segment.archived !== true && selectedRef.current.has(segment.id))
2120
+ .map((segment) => segment.id),
2121
+ );
2122
+ if (ids.size < 2) {
2123
+ showToast(t('mergeHint'));
2124
+ return;
2125
+ }
2126
+ const chosen = segmentsRef.current.filter((segment) => ids.has(segment.id));
2127
+ const firstIndex = segmentsRef.current.findIndex((segment) => ids.has(segment.id));
2128
+ let insertAt = 0;
2129
+ for (let index = 0; index < firstIndex; index += 1) {
2130
+ if (!ids.has(segmentsRef.current[index].id)) insertAt += 1;
2131
+ }
2132
+ const seenPaths = new Set();
2133
+ const images = [];
2134
+ for (const segment of chosen) {
2135
+ for (const image of Array.isArray(segment.images) ? segment.images : []) {
2136
+ if (seenPaths.has(image.path) || images.length >= MAX_IMAGES) continue;
2137
+ seenPaths.add(image.path);
2138
+ images.push(image);
2139
+ }
2140
+ }
2141
+ const merged = {
2142
+ id: newSegmentId(),
2143
+ text: chosen.map((segment) => segment.text).filter((text) => text.trim() !== '').join('\n\n'),
2144
+ title: chosen
2145
+ .map((segment) => (typeof segment.title === 'string' ? segment.title.trim() : ''))
2146
+ .filter((title) => title !== '')
2147
+ .join(' · ')
2148
+ .slice(0, MAX_TITLE_CHARS),
2149
+ createdAt: nowIso(),
2150
+ images,
2151
+ archived: false,
2152
+ archivedAt: null,
2153
+ };
2154
+ undoRef.current = { snapshot: segmentsRef.current };
2155
+ const next = segmentsRef.current.filter((segment) => !ids.has(segment.id));
2156
+ next.splice(Math.min(insertAt, next.length), 0, merged);
2157
+ setSelection(new Set());
2158
+ commitSegments(next, { focus: { id: merged.id, caret: merged.text.length } });
2159
+ showToast(t('mergedToast', { n: chosen.length }), { label: t('undo'), onClick: undoLast });
2160
+ };
2161
+
2162
+ const reorderSegments = (sourceId, targetId, after) => {
2163
+ if (sourceId === '' || sourceId === targetId) return;
2164
+ const active = segmentsRef.current.filter((segment) => segment.archived !== true);
2165
+ const from = active.findIndex((segment) => segment.id === sourceId);
2166
+ const to = active.findIndex((segment) => segment.id === targetId);
2167
+ if (from < 0 || to < 0) return;
2168
+ const moved = active.splice(from, 1)[0];
2169
+ let insertAt = to + (after ? 1 : 0);
2170
+ if (from < insertAt) insertAt -= 1;
2171
+ insertAt = Math.max(0, Math.min(insertAt, active.length));
2172
+ active.splice(insertAt, 0, moved);
2173
+ let cursor = 0;
2174
+ const next = segmentsRef.current.map((segment) =>
2175
+ (segment.archived === true ? segment : active[cursor++]));
2176
+ if (next.every((segment, index) => segment.id === segmentsRef.current[index].id)) return;
2177
+ commitSegments(next);
2178
+ };
2179
+
2180
+ const setReorderTarget = (target) => {
2181
+ reorderOverRef.current = target;
2182
+ setReorderOver(target);
2183
+ };
2184
+
2185
+ const onSegmentReorderStart = (event, id) => {
2186
+ reorderDragRef.current = id;
2187
+ setReorderDrag(id);
2188
+ try {
2189
+ event.dataTransfer?.setData('application/x-dsh-notepad-segment', id);
2190
+ if (event.dataTransfer !== undefined && event.dataTransfer !== null) event.dataTransfer.effectAllowed = 'move';
2191
+ } catch {
2192
+ /* Some browsers expose a read-only transfer. */
2193
+ }
2194
+ };
2195
+
2196
+ const onSegmentReorderOver = (event, id) => {
2197
+ const source = reorderDragRef.current;
2198
+ if (source === '' || source === id) return;
2199
+ const types = event?.dataTransfer?.types;
2200
+ if (types !== undefined && types !== null && !Array.from(types).includes('application/x-dsh-notepad-segment')) return;
2201
+ event.preventDefault?.();
2202
+ event.stopPropagation?.();
2203
+ const rect = typeof event.currentTarget?.getBoundingClientRect === 'function'
2204
+ ? event.currentTarget.getBoundingClientRect()
2205
+ : null;
2206
+ const after = rect !== null && Number(event.clientY) > rect.top + rect.height / 2;
2207
+ if (reorderOverRef.current?.id !== id || reorderOverRef.current?.after !== after) {
2208
+ setReorderTarget({ id, after });
2209
+ }
2210
+ };
2211
+
2212
+ const onSegmentReorderDrop = (event, id) => {
2213
+ const source = reorderDragRef.current
2214
+ || (() => {
2215
+ try {
2216
+ return event?.dataTransfer?.getData('application/x-dsh-notepad-segment') ?? '';
2217
+ } catch {
2218
+ return '';
2219
+ }
2220
+ })();
2221
+ const target = reorderOverRef.current?.id === id ? reorderOverRef.current : null;
2222
+ reorderDragRef.current = '';
2223
+ setReorderDrag('');
2224
+ setReorderTarget(null);
2225
+ if (source === '') return;
2226
+ event.preventDefault?.();
2227
+ event.stopPropagation?.();
2228
+ reorderSegments(source, id, target?.after === true);
2229
+ };
2230
+
2231
+ // AI optimization -----------------------------------------------------
2232
+ const markOptimizing = (id, active) => {
2233
+ const next = new Set(optimizingRef.current);
2234
+ if (active) next.add(id);
2235
+ else next.delete(id);
2236
+ optimizingRef.current = next;
2237
+ setOptimizing(next);
2238
+ };
2239
+
2240
+ /** Cache key: the prompt depends on the workspace, title and card text. */
2241
+ const optimizationCacheKey = (root, segment) =>
2242
+ `${root}\u0000${segment.title ?? ''}\u0000${segment.text}`;
2243
+
2244
+ const rememberOptimization = (key, value) => {
2245
+ const cache = optimizeCacheRef.current;
2246
+ cache.delete(key);
2247
+ cache.set(key, value);
2248
+ while (cache.size > OPTIMIZE_CACHE_LIMIT) {
2249
+ const oldest = cache.keys().next();
2250
+ if (oldest.done === true) break;
2251
+ cache.delete(oldest.value);
2252
+ }
2253
+ };
2254
+
2255
+ const requestOptimization = async (segment) => {
2256
+ const root = rootRef.current;
2257
+ const sessionId = rootSessionRef.current;
2258
+ if (root === '' || sessionId === '') throw new Error(t('optimizeUnavailable'));
2259
+ if (segment.text.length > MAX_OPTIMIZE_CHARS) throw new Error(t('optimizeTooLong'));
2260
+ const cacheKey = optimizationCacheKey(root, segment);
2261
+ const cached = optimizeCacheRef.current.get(cacheKey);
2262
+ if (typeof cached === 'string' && cached !== '') return cached;
2263
+ const data = await optimizeNoteText(root, sessionId, segment.text, segment.title ?? '');
2264
+ const value = typeof data.text === 'string' ? data.text.trim() : '';
2265
+ if (value === '') throw new Error(t('optimizeFailed'));
2266
+ rememberOptimization(cacheKey, value);
2267
+ return value;
2268
+ };
2269
+
2270
+ const optimizeSegment = async (id) => {
2271
+ if (optimizingRef.current.has(id)) return;
2272
+ const target = segmentsRef.current.find((segment) => segment.id === id && segment.archived !== true);
2273
+ if (target === undefined || target.text.trim() === '') {
2274
+ showToast(t('optimizeEmpty'));
2275
+ return;
2276
+ }
2277
+ const original = target.text;
2278
+ markOptimizing(id, true);
2279
+ try {
2280
+ const value = await requestOptimization(target);
2281
+ const current = segmentsRef.current.find((segment) => segment.id === id);
2282
+ if (current === undefined || current.text !== original || value === original) return;
2283
+ const next = segmentsRef.current.map((segment) =>
2284
+ (segment.id === id ? { ...segment, text: value } : segment));
2285
+ commitSegments(next);
2286
+ setSegmentUndo((previous) => {
2287
+ const updated = new Map(previous);
2288
+ updated.set(id, { before: original, after: value });
2289
+ return updated;
2290
+ });
2291
+ showToast(t('optimizedToast'));
2292
+ } catch (reason) {
2293
+ const message = String(reason?.message ?? reason);
2294
+ showToast(message === t('optimizeTooLong') || message === t('optimizeUnavailable')
2295
+ ? message
2296
+ : `${t('optimizeFailed')}: ${message}`);
2297
+ } finally {
2298
+ markOptimizing(id, false);
2299
+ }
2300
+ };
2301
+
2302
+ const undoOptimizeSegment = (id) => {
2303
+ const record = segmentUndo.get(id);
2304
+ const current = segmentsRef.current.find((segment) => segment.id === id);
2305
+ if (record !== undefined && current !== undefined && current.text === record.after) {
2306
+ const next = segmentsRef.current.map((segment) =>
2307
+ (segment.id === id ? { ...segment, text: record.before } : segment));
2308
+ commitSegments(next);
2309
+ setSegmentUndo((previous) => {
2310
+ if (!previous.has(id)) return previous;
2311
+ const updated = new Map(previous);
2312
+ updated.delete(id);
2313
+ return updated;
2314
+ });
2315
+ showToast(t('optimizeUndone'));
2316
+ return;
2317
+ }
2318
+ setSegmentUndo((previous) => {
2319
+ if (!previous.has(id)) return previous;
2320
+ const updated = new Map(previous);
2321
+ updated.delete(id);
2322
+ return updated;
2323
+ });
2324
+ };
2325
+
2326
+ /** Improve only the checked segments; identical text is answered from cache. */
2327
+ const optimizeSelectedSegments = async () => {
2328
+ if (optimizeBatchRef.current) return;
2329
+ const ids = new Set(selectedRef.current);
2330
+ const targets = segmentsRef.current.filter((segment) =>
2331
+ segment.archived !== true && ids.has(segment.id) && segment.text.trim() !== '');
2332
+ if (targets.length === 0) {
2333
+ showToast(t('optimizeEmpty'));
2334
+ return;
2335
+ }
2336
+ optimizeBatchRef.current = true;
2337
+ const snapshot = segmentsRef.current;
2338
+ let done = 0;
2339
+ let changed = 0;
2340
+ let failed = 0;
2341
+ setOptimizeBatch({ done, total: targets.length });
2342
+ try {
2343
+ for (const target of targets) {
2344
+ const current = segmentsRef.current.find((segment) =>
2345
+ segment.id === target.id && segment.archived !== true);
2346
+ if (current !== undefined && current.text === target.text) {
2347
+ markOptimizing(target.id, true);
2348
+ try {
2349
+ const value = await requestOptimization(current);
2350
+ if (value !== current.text) {
2351
+ const next = segmentsRef.current.map((segment) =>
2352
+ (segment.id === target.id && segment.text === current.text
2353
+ ? { ...segment, text: value }
2354
+ : segment));
2355
+ commitSegments(next);
2356
+ setSegmentUndo((previous) => {
2357
+ const updated = new Map(previous);
2358
+ updated.set(target.id, { before: current.text, after: value });
2359
+ return updated;
2360
+ });
2361
+ changed += 1;
2362
+ }
2363
+ } catch {
2364
+ failed += 1;
2365
+ } finally {
2366
+ markOptimizing(target.id, false);
2367
+ }
2368
+ }
2369
+ done += 1;
2370
+ setOptimizeBatch({ done, total: targets.length });
2371
+ }
2372
+ if (changed > 0) {
2373
+ setBatchUndoState({ snapshot });
2374
+ showToast(t('optimizedCount', { n: changed }));
2375
+ } else {
2376
+ showToast(failed > 0 ? t('optimizeFailed') : t('optimizedToast'));
2377
+ }
2378
+ } finally {
2379
+ optimizeBatchRef.current = false;
2380
+ setOptimizeBatch(null);
2381
+ }
2382
+ };
2383
+
2384
+ const undoBatchOptimization = () => {
2385
+ const record = batchUndoRef.current;
2386
+ if (record === null || record === undefined) return;
2387
+ setBatchUndoState(null);
2388
+ setSegmentUndo(new Map());
2389
+ commitSegments(record.snapshot);
2390
+ showToast(t('optimizeUndone'));
2391
+ };
2392
+
1619
2393
  // Selection -----------------------------------------------------------
1620
2394
  const toggleSelected = (id) => {
1621
2395
  const next = new Set(selectedRef.current);
@@ -1811,6 +2585,34 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1811
2585
  updateSegmentText(id, event.target?.value ?? '');
1812
2586
  };
1813
2587
 
2588
+ /** Focus a card's textarea and place the caret at an absolute offset. */
2589
+ const focusSegmentAt = (id, caret) => {
2590
+ const element = textareaRefs.current.get(id);
2591
+ if (element === null || element === undefined) return false;
2592
+ try {
2593
+ element.focus?.({ preventScroll: true });
2594
+ } catch {
2595
+ try {
2596
+ element.focus?.();
2597
+ } catch {
2598
+ /* ignore */
2599
+ }
2600
+ }
2601
+ try {
2602
+ const value = typeof element.value === 'string' ? element.value : '';
2603
+ const position = Math.max(0, Math.min(Number(caret) || 0, value.length));
2604
+ if (typeof element.setSelectionRange === 'function') element.setSelectionRange(position, position);
2605
+ } catch {
2606
+ /* A non-layout shim may not support ranges. */
2607
+ }
2608
+ try {
2609
+ element.scrollIntoView?.({ block: 'nearest' });
2610
+ } catch {
2611
+ /* ignore */
2612
+ }
2613
+ return true;
2614
+ };
2615
+
1814
2616
  const onSegmentKeyDown = (event, id) => {
1815
2617
  if (event.isComposing === true || event.nativeEvent?.isComposing === true) return;
1816
2618
  const chord = event.ctrlKey || event.metaKey;
@@ -1825,6 +2627,28 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1825
2627
  insertSegments(list);
1826
2628
  return;
1827
2629
  }
2630
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
2631
+ if (event.altKey || event.shiftKey || chord) return;
2632
+ const element = event.currentTarget ?? event.target;
2633
+ const value = typeof element?.value === 'string' ? element.value : '';
2634
+ const start = typeof element?.selectionStart === 'number' ? element.selectionStart : null;
2635
+ const end = typeof element?.selectionEnd === 'number' ? element.selectionEnd : start;
2636
+ if (start === null || start !== end) return;
2637
+ const firstBreak = value.indexOf('\n');
2638
+ const lastBreak = value.lastIndexOf('\n');
2639
+ const up = event.key === 'ArrowUp';
2640
+ const atEdge = up
2641
+ ? (firstBreak === -1 || start <= firstBreak)
2642
+ : (lastBreak === -1 || start > lastBreak);
2643
+ if (!atEdge) return;
2644
+ const active = segmentsRef.current.filter((segment) => segment.archived !== true);
2645
+ const position = active.findIndex((segment) => segment.id === id);
2646
+ const neighbor = position < 0 ? undefined : active[position + (up ? -1 : 1)];
2647
+ if (neighbor !== undefined && focusSegmentAt(neighbor.id, up ? neighbor.text.length : 0)) {
2648
+ event.preventDefault?.();
2649
+ }
2650
+ return;
2651
+ }
1828
2652
  if (event.key !== 'Enter' || event.shiftKey || event.altKey || chord) return;
1829
2653
  event.preventDefault();
1830
2654
  splitSegment(id, event.currentTarget ?? event.target);
@@ -1841,8 +2665,11 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1841
2665
  const vw = window.innerWidth || 1280;
1842
2666
  const vh = window.innerHeight || 800;
1843
2667
  const height = ui.minimized ? HEAD_H : ui.height;
1844
- const x = Math.min(Math.max(8, start.x + moveEvent.clientX - start.px), Math.max(8, vw - ui.width - 8));
1845
- const y = Math.min(Math.max(8, start.y + moveEvent.clientY - start.py), Math.max(8, vh - height - 8));
2668
+ const minY = hostChromeTop() + EDGE_GAP;
2669
+ const x = Math.min(Math.max(EDGE_GAP, start.x + moveEvent.clientX - start.px),
2670
+ Math.max(EDGE_GAP, vw - ui.width - EDGE_GAP));
2671
+ const y = Math.min(Math.max(minY, start.y + moveEvent.clientY - start.py),
2672
+ Math.max(minY, vh - height - EDGE_GAP));
1846
2673
  setUi((previous) => ({ ...previous, x, y }));
1847
2674
  };
1848
2675
  const stop = () => {
@@ -1868,17 +2695,20 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1868
2695
  const move = (moveEvent) => {
1869
2696
  const vw = window.innerWidth || 1280;
1870
2697
  const vh = window.innerHeight || 800;
1871
- const width = Math.min(Math.max(MIN_W, start.width + moveEvent.clientX - start.px), Math.max(MIN_W, vw - 16));
1872
- const height = Math.min(Math.max(MIN_H, start.height + moveEvent.clientY - start.py), Math.max(MIN_H, vh - 16));
2698
+ const minY = hostChromeTop() + EDGE_GAP;
2699
+ const width = Math.min(Math.max(MIN_W, start.width + moveEvent.clientX - start.px),
2700
+ Math.max(MIN_W, vw - EDGE_GAP * 2));
2701
+ const height = Math.min(Math.max(MIN_H, start.height + moveEvent.clientY - start.py),
2702
+ Math.max(MIN_H, vh - minY - EDGE_GAP));
1873
2703
  setUi((previous) => {
1874
- const maxX = Math.max(8, vw - width - 8);
1875
- const maxY = Math.max(8, vh - height - 8);
2704
+ const maxX = Math.max(EDGE_GAP, vw - width - EDGE_GAP);
2705
+ const maxY = Math.max(minY, vh - height - EDGE_GAP);
1876
2706
  return {
1877
2707
  ...previous,
1878
2708
  width,
1879
2709
  height,
1880
- x: typeof previous.x === 'number' ? Math.min(previous.x, maxX) : previous.x,
1881
- y: typeof previous.y === 'number' ? Math.min(previous.y, maxY) : previous.y,
2710
+ x: typeof previous.x === 'number' ? Math.min(Math.max(EDGE_GAP, previous.x), maxX) : previous.x,
2711
+ y: typeof previous.y === 'number' ? Math.min(Math.max(minY, previous.y), maxY) : previous.y,
1882
2712
  };
1883
2713
  });
1884
2714
  };
@@ -1902,11 +2732,13 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
1902
2732
  const selectedActive = activeSegments.filter((segment) => selected.has(segment.id));
1903
2733
  const selectedCount = selectedActive.length;
1904
2734
  const allSelected = activeSegments.length > 0 && selectedCount === activeSegments.length;
2735
+ const unsentCount = countUnsent(segments);
1905
2736
  const totalChars = segments.reduce((sum, segment) => sum + segment.text.length, 0);
1906
2737
  const displayWidth = ui.width;
1907
2738
  const displayHeight = ui.minimized ? HEAD_H : ui.height;
1908
- const fallbackX = Math.max(8, ((typeof window !== 'undefined' && window.innerWidth) || 1280) - displayWidth - 24);
1909
- const fallbackY = Math.max(8, ((typeof window !== 'undefined' && window.innerHeight) || 800) - displayHeight - 24);
2739
+ const fallbackX = Math.max(EDGE_GAP, ((typeof window !== 'undefined' && window.innerWidth) || 1280) - displayWidth - 24);
2740
+ const fallbackY = Math.max(hostChromeTop() + EDGE_GAP,
2741
+ ((typeof window !== 'undefined' && window.innerHeight) || 800) - displayHeight - 24);
1910
2742
  const left = typeof ui.x === 'number' ? ui.x : fallbackX;
1911
2743
  const top = typeof ui.y === 'number' ? ui.y : fallbackY;
1912
2744
 
@@ -2045,6 +2877,42 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2045
2877
  },
2046
2878
  h(SendIcon, { size: 14 }),
2047
2879
  ),
2880
+ h(
2881
+ 'button',
2882
+ {
2883
+ type: 'button',
2884
+ className: 'dsh-np-btn',
2885
+ title: selectedCount > 1 ? t('mergeSelected', { n: selectedCount }) : t('mergeHint'),
2886
+ 'aria-label': t('merge'),
2887
+ disabled: selectedCount < 2,
2888
+ onClick: mergeSelected,
2889
+ },
2890
+ h(MergeIcon, { size: 14 }),
2891
+ ),
2892
+ h(
2893
+ 'button',
2894
+ {
2895
+ type: 'button',
2896
+ className: 'dsh-np-btn',
2897
+ 'data-active': optimizeBatch !== null || batchUndo !== null ? 'true' : 'false',
2898
+ title: optimizeBatch !== null
2899
+ ? t('optimizeAllProgress', { done: optimizeBatch.done, total: optimizeBatch.total })
2900
+ : batchUndo !== null
2901
+ ? t('undoOptimize')
2902
+ : selectedCount > 0
2903
+ ? t('optimizeSelected', { n: selectedCount })
2904
+ : t('optimizeSelectHint'),
2905
+ 'aria-label': batchUndo !== null
2906
+ ? t('undoOptimize')
2907
+ : selectedCount > 0 ? t('optimizeSelected', { n: selectedCount }) : t('optimizeSelectHint'),
2908
+ disabled: optimizeBatch !== null
2909
+ || (batchUndo === null && selectedCount === 0),
2910
+ onClick: batchUndo !== null ? undoBatchOptimization : optimizeSelectedSegments,
2911
+ },
2912
+ optimizeBatch !== null
2913
+ ? h('span', { className: 'dsh-np-mini-spinner' })
2914
+ : batchUndo !== null ? h(UndoIcon, { size: 14 }) : h(SparkleIcon, { size: 14 }),
2915
+ ),
2048
2916
  h(
2049
2917
  'button',
2050
2918
  {
@@ -2131,6 +2999,15 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2131
2999
  const activeCards = activeSegments.map((segment, index) => {
2132
3000
  const images = Array.isArray(segment.images) ? segment.images : [];
2133
3001
  const empty = segment.text.trim() === '' && images.length === 0;
3002
+ const titleValue = segment.title ?? '';
3003
+ const showTitleInput = titleValue !== '' || titleEditing === segment.id;
3004
+ const undoRecord = segmentUndo.get(segment.id);
3005
+ const canUndoOptimize = undoRecord !== undefined && undoRecord.after === segment.text;
3006
+ const optimizingNow = optimizing.has(segment.id);
3007
+ const aiBusy = optimizingNow || optimizeBatch !== null;
3008
+ const aiTitle = aiBusy
3009
+ ? t('optimizing')
3010
+ : canUndoOptimize ? t('undoOptimize') : t('optimizeSegment');
2134
3011
  return h(
2135
3012
  'article',
2136
3013
  {
@@ -2139,6 +3016,14 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2139
3016
  'data-seg-id': segment.id,
2140
3017
  'data-archived': 'false',
2141
3018
  'data-selected': selected.has(segment.id) ? 'true' : 'false',
3019
+ 'data-reorder-drag': reorderDrag === segment.id ? 'true' : 'false',
3020
+ 'data-drop-before': reorderOver?.id === segment.id && reorderOver?.after === false ? 'true' : 'false',
3021
+ 'data-drop-after': reorderOver?.id === segment.id && reorderOver?.after === true ? 'true' : 'false',
3022
+ onDragOver: (event) => onSegmentReorderOver(event, segment.id),
3023
+ onDragLeave: () => {
3024
+ if (reorderOverRef.current?.id === segment.id) setReorderTarget(null);
3025
+ },
3026
+ onDrop: (event) => onSegmentReorderDrop(event, segment.id),
2142
3027
  },
2143
3028
  h('input', {
2144
3029
  key: 'file-input',
@@ -2170,8 +3055,62 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2170
3055
  onChange: () => toggleSelected(segment.id),
2171
3056
  }),
2172
3057
  ),
2173
- h('span', { className: 'dsh-np-index' }, String(index + 1).padStart(2, '0')),
2174
- h('span', { className: 'dsh-np-spacer' }),
3058
+ h(
3059
+ 'button',
3060
+ {
3061
+ type: 'button',
3062
+ className: 'dsh-np-index',
3063
+ title: t('indexHint'),
3064
+ 'aria-label': t('selectSegment'),
3065
+ 'aria-pressed': selected.has(segment.id) ? 'true' : 'false',
3066
+ draggable: true,
3067
+ onDragStart: (event) => onSegmentReorderStart(event, segment.id),
3068
+ onDragEnd: () => {
3069
+ reorderDragRef.current = '';
3070
+ setReorderDrag('');
3071
+ setReorderTarget(null);
3072
+ },
3073
+ onClick: () => toggleSelected(segment.id),
3074
+ },
3075
+ String(index + 1).padStart(2, '0'),
3076
+ ),
3077
+ showTitleInput
3078
+ ? h('input', {
3079
+ type: 'text',
3080
+ className: 'dsh-np-title-input',
3081
+ value: titleValue,
3082
+ placeholder: t('titlePlaceholder'),
3083
+ maxLength: MAX_TITLE_CHARS,
3084
+ autoFocus: titleValue === '',
3085
+ 'aria-label': t('segmentTitle'),
3086
+ spellCheck: false,
3087
+ onFocus: () => setTitleEditing(segment.id),
3088
+ onChange: (event) => updateSegmentTitle(segment.id, event.target?.value ?? ''),
3089
+ onKeyDown: (event) => {
3090
+ if (event.key !== 'Enter') return;
3091
+ event.preventDefault();
3092
+ try {
3093
+ event.currentTarget?.blur?.();
3094
+ } catch {
3095
+ /* ignore */
3096
+ }
3097
+ },
3098
+ onBlur: () => {
3099
+ if (dirtyRef.current) saveNowRef.current?.();
3100
+ if ((segment.title ?? '') === '') setTitleEditing('');
3101
+ },
3102
+ })
3103
+ : h(
3104
+ 'button',
3105
+ {
3106
+ type: 'button',
3107
+ className: 'dsh-np-title-add',
3108
+ title: t('segmentTitle'),
3109
+ 'aria-label': t('segmentTitle'),
3110
+ onClick: () => setTitleEditing(segment.id),
3111
+ },
3112
+ t('titlePlaceholder'),
3113
+ ),
2175
3114
  h(
2176
3115
  'button',
2177
3116
  {
@@ -2219,6 +3158,23 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2219
3158
  },
2220
3159
  h(SendIcon, { size: 14 }),
2221
3160
  ),
3161
+ h(
3162
+ 'button',
3163
+ {
3164
+ type: 'button',
3165
+ className: 'dsh-np-segbtn',
3166
+ 'data-ai': 'true',
3167
+ title: aiTitle,
3168
+ 'aria-label': aiTitle,
3169
+ disabled: aiBusy || (canUndoOptimize ? false : empty),
3170
+ onClick: canUndoOptimize
3171
+ ? () => undoOptimizeSegment(segment.id)
3172
+ : () => optimizeSegment(segment.id),
3173
+ },
3174
+ optimizingNow
3175
+ ? h('span', { className: 'dsh-np-mini-spinner' })
3176
+ : canUndoOptimize ? h(UndoIcon, { size: 14 }) : h(SparkleIcon, { size: 14 }),
3177
+ ),
2222
3178
  h(
2223
3179
  'button',
2224
3180
  {
@@ -2336,6 +3292,9 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2336
3292
  'div',
2337
3293
  { className: 'dsh-np-seg-head' },
2338
3294
  h('span', { className: 'dsh-np-archived-chip' }, t('archived')),
3295
+ typeof segment.title === 'string' && segment.title.trim() !== ''
3296
+ ? h('span', { className: 'dsh-np-seg-title' }, segment.title)
3297
+ : null,
2339
3298
  h('span', { className: 'dsh-np-spacer' }),
2340
3299
  h(
2341
3300
  'button',
@@ -2502,6 +3461,12 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
2502
3461
  onClick: toggleHidden,
2503
3462
  },
2504
3463
  h(NotepadIcon, { size: 16 }),
3464
+ ui.hidden && unsentCount > 0
3465
+ ? h('span', {
3466
+ className: 'dsh-np-restore-badge',
3467
+ title: t('unsent', { n: unsentCount }),
3468
+ }, unsentCount > 99 ? '99+' : String(unsentCount))
3469
+ : null,
2505
3470
  );
2506
3471
 
2507
3472
  return [card, restore];