kingkont 0.7.88 → 0.7.90

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.7.88",
3
+ "version": "0.7.90",
4
4
  "description": "KingKont · Chatium — нод-редактор сцен с AI-генерацией (картинки/видео/голос/SFX/музыка/текст)",
5
5
  "main": "main.js",
6
6
  "bin": {
@@ -123,6 +123,25 @@ function renderLabelNodeBody(node, body) {
123
123
  ed.textContent = node.text || '';
124
124
  applyLabelStyle(ed, node.textStyle);
125
125
 
126
+ // Принудительно держим класс is-empty синхронизированным с textContent.
127
+ // CSS-селектор :empty не годится — после стирания всего текста браузер
128
+ // часто оставляет <br> внутри contenteditable, и :empty перестаёт
129
+ // матчить → плейсхолдер пропадал, инлайн-блок схлопывался по ширине,
130
+ // юзер кликал мимо и терял фокус. Класс на основе textContent надёжнее.
131
+ const syncEmpty = () => {
132
+ const empty = !ed.textContent || ed.textContent.length === 0;
133
+ ed.classList.toggle('is-empty', empty);
134
+ // Если контент стал «пустым», но в DOM остался шум (br/пустые text-node) —
135
+ // чистим innerHTML, чтобы курсор спокойно стоял в начале без артефактов.
136
+ // НЕ делаем если уже пусто — иначе зацикливание input-события.
137
+ if (empty && ed.innerHTML !== '' && ed.isContentEditable) {
138
+ // Сохраняем фокус: clearing innerHTML на сфокусированном
139
+ // contenteditable не должен дёргать blur, но сохраним ranged selection.
140
+ ed.innerHTML = '';
141
+ }
142
+ };
143
+ syncEmpty();
144
+
126
145
  const enterEditMode = () => {
127
146
  if (ed.isContentEditable) return;
128
147
  ed.contentEditable = 'plaintext-only';
@@ -141,6 +160,7 @@ function renderLabelNodeBody(node, body) {
141
160
  // closest('[contenteditable]') в обработчиках вне (dblclick/contextmenu)
142
161
  // не матчился — и ПКМ снова открывал наш node-menu.
143
162
  ed.removeAttribute('contenteditable');
163
+ syncEmpty();
144
164
  };
145
165
 
146
166
  ed.addEventListener('dblclick', e => {
@@ -148,7 +168,11 @@ function renderLabelNodeBody(node, body) {
148
168
  enterEditMode();
149
169
  });
150
170
  ed.addEventListener('blur', exitEditMode);
151
- ed.addEventListener('input', () => { node.text = ed.textContent; scheduleSave(); });
171
+ ed.addEventListener('input', () => {
172
+ node.text = ed.textContent;
173
+ syncEmpty();
174
+ scheduleSave();
175
+ });
152
176
  ed.addEventListener('keydown', e => {
153
177
  if (e.key === 'Escape') { e.preventDefault(); ed.blur(); }
154
178
  });
package/renderer/state.js CHANGED
@@ -86,7 +86,38 @@ async function fileExists(handle, name) {
86
86
  try { await handle.getFileHandle(name); return true; } catch { return false; }
87
87
  }
88
88
 
89
+ // Имена из внешних источников (drag-drop из Telegram/чатов, paste, импорт)
90
+ // часто содержат символы, недопустимые для File-System-Access-API: двоеточия
91
+ // (timestamp'ы вроде «IMAGE 16:48:03.jpg»), слэши, *, ? и control-символы.
92
+ // FS-Access-API строго следует правилам Windows-FS, поэтому даже на macOS
93
+ // getFileHandle('foo:bar.jpg') бросает 'Name is not allowed'.
94
+ function sanitizeFilename(name) {
95
+ if (!name) return 'file';
96
+ let safe = String(name)
97
+ .replace(/[\\/:*?"<>|\x00-\x1F]/g, '-') // зарезервированные + control-chars
98
+ .replace(/^\.+/, '') // имя не должно начинаться с точки
99
+ .replace(/[. ]+$/, '') // и не должно кончаться на точку/пробел (Windows)
100
+ .trim();
101
+ if (!safe) safe = 'file';
102
+ // Зарезервированные базовые имена Windows.
103
+ const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
104
+ if (reserved.test(safe)) safe = '_' + safe;
105
+ // Лимит длины (NTFS = 255, оставляем запас под суффикс _N).
106
+ if (safe.length > 200) {
107
+ const dot = safe.lastIndexOf('.');
108
+ if (dot > 0 && safe.length - dot < 20) {
109
+ safe = safe.slice(0, 200 - (safe.length - dot)) + safe.slice(dot);
110
+ } else {
111
+ safe = safe.slice(0, 200);
112
+ }
113
+ }
114
+ return safe;
115
+ }
116
+
89
117
  async function uniqueName(handle, name) {
118
+ // Always sanitize — uniqueName это «бутылочное горлышко» для всех импортов
119
+ // и записей с внешними именами. Если уже sanitized — операция идемпотентна.
120
+ name = sanitizeFilename(name);
90
121
  if (!(await fileExists(handle, name))) return name;
91
122
  const dot = name.lastIndexOf('.');
92
123
  const base = dot > 0 ? name.slice(0, dot) : name;
@@ -510,15 +510,23 @@
510
510
  всё равно имеет высоту хотя бы одной строки выбранного шрифта. */
511
511
  min-height: 1.5em;
512
512
  }
513
- .node.label-node .label-text:empty::before {
513
+ /* Плейсхолдер показываем по классу is-empty (а не :empty pseudo) —
514
+ selector :empty ломается когда браузер оставляет <br> в contenteditable
515
+ после Cmd+A/Backspace. JS ставит/снимает класс по textContent. */
516
+ .node.label-node .label-text.is-empty::before {
514
517
  content: attr(data-placeholder);
515
518
  color: rgba(200,200,200,0.55);
516
519
  }
520
+ /* Гарантированный размер пустой ноды — чтобы юзер не терял её визуально
521
+ (кликабельная область + плейсхолдер). */
522
+ .node.label-node .label-text.is-empty {
523
+ min-width: 240px;
524
+ }
517
525
  /* Когда label пустая — рисуем подсказывающий dashed-контур, чтобы юзер
518
526
  видел что нода существует (текст-плейсхолдер мог быть не замечен).
519
527
  При наличии текста — никакой рамки, чисто подпись на холсте. */
520
- .node.label-node:has(.label-text:empty) {
521
- outline: 1px dashed rgba(200,200,200,0.25);
528
+ .node.label-node:has(.label-text.is-empty) {
529
+ outline: 1px dashed rgba(200,200,200,0.35);
522
530
  outline-offset: 1px;
523
531
  border-radius: 3px;
524
532
  }