@tosk/gen-ui 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/gen-ui.js +164 -87
  2. package/package.json +1 -1
package/gen-ui.js CHANGED
@@ -17,72 +17,84 @@ class GenUi extends HTMLElement {
17
17
  static SELECTORS = {
18
18
  loadingOverlay: '#loading-overlay',
19
19
  previewOutput: '#preview-output',
20
- editBtn: '#edit-btn',
21
- completeBtn: '#complete-btn',
22
20
  chatWindow: '#chat-window',
23
21
  chatInput: '#chat-input',
24
22
  chatSubmit: '#chat-submit',
25
23
  chatCancel: '#chat-cancel',
26
- connectBtn: '#connect-btn',
27
24
  uiTitle: '#ui-title',
25
+ contextMenu: '#context-menu',
26
+ ctxInsert: '#ctx-insert',
27
+ ctxCopy: '#ctx-copy',
28
+ ctxEdit: '#ctx-edit',
28
29
  };
29
30
 
30
31
  static TEMPLATE = (() => {
31
32
  const template = document.createElement('template');
32
33
  template.innerHTML = `
33
34
  <style>
34
- :host { display: block; width: 100%; height: 100vh; background: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
35
- #container { display: flex; flex-direction: column; height: 100%; }
36
- header { height: 64px; background: #ffffff; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); z-index: 50; box-sizing: border-box; }
37
- .header-left { flex: 1; }
38
- .header-center { flex: 2; display: flex; align-items: center; justify-content: center; }
39
- #ui-title { background-color: #f0f0f0; padding: 8px 24px; border-radius: 8px; font-weight: 600; color: #333; font-size: 1rem; min-width: 200px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: all 0.3s ease; }
40
- #ui-title.loading { color: #888; background-color: #f5f5f5; animation: pulse 1.5s infinite ease-in-out; }
41
- @keyframes pulse { 0% { opacity: 0.6; } 50% { opacity: 1; } 100% { opacity: 0.6; } }
42
- .header-right { flex: 1; display: flex; justify-content: flex-end; gap: 8px; align-items: center; }
43
- .icon-btn { background: transparent; border: 1px solid transparent; border-radius: 8px; cursor: pointer; padding: 10px 14px; font-size: 1.2rem; color: #555; transition: all 0.2s; display: flex; align-items: center; justify-content: center; }
44
- .icon-btn:hover { background-color: #f3f4f6; color: #111; }
45
- .icon-btn:active { background-color: #e5e7eb; }
46
- .btn-connect.active { color: #3b82f6; background: #eff6ff; border-color: #bfdbfe; }
47
- .btn-complete { color: #2ecc71; font-weight: bold; }
48
- .btn-complete:hover { background: #f0fdf4; color: #22c55e; }
49
- .content-area { flex: 1; position: relative; overflow: hidden; width: 100%; }
35
+ :host { display: block; width: 100%; height: 100vh; background: transparent; font-family: sans-serif; }
50
36
  iframe { width: 100%; height: 100%; border: none; display: block; }
51
- .loading-overlay { position: absolute; inset: 0; background: #f5f5f5; display: flex; align-items: center; justify-content: center; z-index: 20; }
37
+
38
+ .loading-overlay { position: absolute; inset: 0; background: rgba(255,255,255,0.8); display: flex; align-items: center; justify-content: center; z-index: 20; }
52
39
  .spinner { width: 24px; height: 24px; border: 3px solid rgba(0, 0, 0, 0.1); border-left-color: #3b82f6; border-radius: 50%; animation: spin 1s linear infinite; }
53
40
  @keyframes spin { to { transform: rotate(360deg); } }
54
- .chat-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(4px); z-index: 9999; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; }
41
+
42
+ .chat-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(2px); z-index: 9999; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; }
55
43
  .chat-box { width: 100%; max-width: 500px; display: flex; flex-direction: column; gap: 10px; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.15); }
56
44
  .chat-input { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px; font-family: inherit; resize: vertical; min-height: 100px; box-sizing: border-box; }
57
45
  .chat-actions { display: flex; justify-content: flex-end; gap: 10px; }
46
+
58
47
  button.btn { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; }
59
48
  .btn-primary { background: #3b82f6; color: white; }
60
49
  .btn-primary:hover { opacity: 0.9; }
61
50
  .btn-cancel { background: #eee; color: #333; }
51
+
62
52
  .hidden { display: none !important; }
53
+
54
+ .context-menu {
55
+ position: absolute;
56
+ background: white;
57
+ border: 1px solid #e0e0e0;
58
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
59
+ border-radius: 8px;
60
+ padding: 4px 0;
61
+ z-index: 1000;
62
+ min-width: 160px;
63
+ display: none;
64
+ flex-direction: column;
65
+ }
66
+ .context-menu.visible { display: flex; }
67
+ .context-menu-item {
68
+ padding: 8px 16px;
69
+ cursor: pointer;
70
+ font-size: 0.9rem;
71
+ color: #333;
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 8px;
75
+ transition: background 0.2s;
76
+ }
77
+ .context-menu-item:hover { background: #f5f5f5; }
78
+
79
+ #ui-title { display: none; }
63
80
  </style>
64
- <div id="container">
65
- <header>
66
- <div class="header-left"></div>
67
- <div class="header-center"><div id="ui-title"></div></div>
68
- <div class="header-right">
69
- <button id="connect-btn" class="icon-btn btn-connect" title="ファイル連携">🔗</button>
70
- <button id="edit-btn" class="icon-btn" title="修正指示">✏️</button>
71
- <button id="complete-btn" class="icon-btn btn-complete" title="確定">✅</button>
72
- </div>
73
- </header>
74
- <div class="content-area">
75
- <div id="loading-overlay" class="loading-overlay hidden"><div class="spinner"></div></div>
76
- <iframe id="preview-output" title="Generated UI"></iframe>
77
- </div>
78
- <div id="chat-window" class="chat-overlay hidden">
79
- <div class="chat-box">
80
- <p style="margin:0; font-weight:bold; color:#555;">修正指示を入力</p>
81
- <textarea id="chat-input" class="chat-input" placeholder="例: 背景を暗くして、文字を大きくして"></textarea>
82
- <div class="chat-actions">
83
- <button id="chat-cancel" class="btn btn-cancel">閉じる</button>
84
- <button id="chat-submit" class="btn btn-primary">修正する</button>
85
- </div>
81
+ <div id="loading-overlay" class="loading-overlay hidden"><div class="spinner"></div></div>
82
+ <iframe id="preview-output" title="Generated UI"></iframe>
83
+ <div id="ui-title"></div>
84
+
85
+ <div id="context-menu" class="context-menu">
86
+ <div id="ctx-insert" class="context-menu-item">挿入</div>
87
+ <div id="ctx-copy" class="context-menu-item">コピー</div>
88
+ <div id="ctx-edit" class="context-menu-item">修正</div>
89
+ </div>
90
+
91
+ <div id="chat-window" class="chat-overlay hidden">
92
+ <div class="chat-box">
93
+ <p style="margin:0; font-weight:bold; color:#555;">修正指示を入力</p>
94
+ <textarea id="chat-input" class="chat-input" placeholder="例: 背景を暗くして、文字を大きくして"></textarea>
95
+ <div class="chat-actions">
96
+ <button id="chat-cancel" class="btn btn-cancel">閉じる</button>
97
+ <button id="chat-submit" class="btn btn-primary">修正する</button>
86
98
  </div>
87
99
  </div>
88
100
  </div>
@@ -139,8 +151,8 @@ class GenUi extends HTMLElement {
139
151
  this.#saveKey = this.getAttribute('save-key');
140
152
  this.#originalHtml = this.innerHTML.trim();
141
153
 
142
- if (!this.#apiKey) return console.error('GenUi: "api-key" attribute is required.');
143
154
  if (this.#loadKey) return this.#loadFromFirestore();
155
+ if (!this.#apiKey) return console.error('GenUi: "api-key" attribute is required for generation.');
144
156
  if (this.#requestPrompt) this.#processRequest();
145
157
  }
146
158
 
@@ -148,15 +160,10 @@ class GenUi extends HTMLElement {
148
160
  // Event Handlers
149
161
  // ==================================================================================
150
162
  #setupInteractions() {
151
- const { editBtn, completeBtn, chatWindow, chatCancel, chatSubmit, chatInput, connectBtn } = this.#elements;
152
-
153
- editBtn.addEventListener('click', () => {
154
- chatWindow.classList.remove('hidden');
155
- chatInput.focus();
156
- });
163
+ const { chatWindow, chatCancel, chatSubmit, chatInput } = this.#elements;
164
+ const { ctxInsert, ctxCopy, ctxEdit } = this.#elements;
157
165
 
158
166
  chatCancel.addEventListener('click', () => chatWindow.classList.add('hidden'));
159
-
160
167
  chatSubmit.addEventListener('click', () => {
161
168
  const instruction = chatInput.value.trim();
162
169
  if (!instruction) return;
@@ -165,35 +172,57 @@ class GenUi extends HTMLElement {
165
172
  this.#processRefinement(instruction);
166
173
  });
167
174
 
168
- connectBtn.addEventListener('click', () => this.#handleFileConnect());
169
- completeBtn.addEventListener('click', () => this.#handleCompletion());
170
- }
175
+ this.shadowRoot.addEventListener('click', (e) => {
176
+ if (!e.target.closest('#context-menu')) {
177
+ this.#hideContextMenu();
178
+ }
179
+ });
171
180
 
172
- async #handleFileConnect() {
173
- try {
174
- const [handle] = await window.showOpenFilePicker({
175
- types: [{ description: 'HTML Files', accept: { 'text/html': ['.html'] } }],
176
- multiple: false,
177
- });
178
- this.#fileHandle = handle;
179
- this.#elements.connectBtn.classList.add('active');
180
- this.#elements.connectBtn.title = `連携中: ${handle.name}`;
181
- alert(`「${handle.name}」と連携しました。\n確定ボタンを押すと、このファイルが自動的に書き換えられます。`);
182
- } catch (err) { /* Cancelled */ }
181
+ ctxInsert.addEventListener('click', async () => {
182
+ this.#hideContextMenu();
183
+ try {
184
+ const [handle] = await window.showOpenFilePicker({
185
+ types: [{ description: 'HTML Files', accept: { 'text/html': ['.html'] } }],
186
+ multiple: false,
187
+ });
188
+
189
+ if (confirm(`${handle.name} に挿入しますか?`)) {
190
+ this.#fileHandle = handle;
191
+ await this.#directWriteToFile();
192
+ }
193
+ } catch (err) {
194
+ }
195
+ });
196
+
197
+ ctxCopy.addEventListener('click', () => {
198
+ this.#hideContextMenu();
199
+ this.#copyToClipboard();
200
+ alert('コピーしました。');
201
+ });
202
+
203
+ ctxEdit.addEventListener('click', () => {
204
+ this.#hideContextMenu();
205
+ chatWindow.classList.remove('hidden');
206
+ chatInput.focus();
207
+ });
183
208
  }
184
209
 
185
- async #handleCompletion() {
186
- const msg = this.#fileHandle
187
- ? '連携中のファイルを書き換えますか?\n(Git等でバックアップを推奨)'
188
- : 'クリップボードにコピーしますか?';
189
- if (!confirm(msg)) return;
210
+ #showContextMenu(x, y) {
211
+ const menu = this.#elements.contextMenu;
212
+ const rect = this.getBoundingClientRect();
190
213
 
191
- if (this.#fileHandle) {
192
- await this.#directWriteToFile();
193
- } else {
194
- await this.#copyToClipboard();
195
- alert('コピーしました。');
196
- }
214
+ let left = x;
215
+ let top = y;
216
+ if (left + 160 > rect.width) left = rect.width - 160;
217
+ if (top + 120 > rect.height) top = rect.height - 120;
218
+
219
+ menu.style.left = `${left}px`;
220
+ menu.style.top = `${top}px`;
221
+ menu.classList.add('visible');
222
+ }
223
+
224
+ #hideContextMenu() {
225
+ this.#elements.contextMenu.classList.remove('visible');
197
226
  }
198
227
 
199
228
  // ==================================================================================
@@ -336,8 +365,33 @@ ${html}
336
365
 
337
366
  #renderPreview(html, css, javascript, title) {
338
367
  this.#currentCode = { html, css, javascript };
339
- this.#elements.previewOutput.srcdoc = `<!DOCTYPE html><html lang="ja"><head><meta charset="UTF-8"><style>body{margin:0;padding:20px;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f5f5f5;}*,*::before,*::after{box-sizing:border-box;}${css}</style></head><body>${html}<script>try{${javascript||''}}catch(e){console.error(e)}<\/script></body></html>`;
368
+
369
+ // --- 変更点: iframe内のbodyスタイルからflex中央揃えを削除 ---
370
+ const iframeCss = `body{margin:0;padding:0;min-height:100vh;background:#fff;}*,*::before,*::after{box-sizing:border-box;}${css}`;
371
+
372
+ this.#elements.previewOutput.srcdoc = `<!DOCTYPE html><html lang="ja"><head><meta charset="UTF-8"><style>${iframeCss}</style></head><body>${html}<script>try{${javascript||''}}catch(e){console.error(e)}<\/script></body></html>`;
340
373
  this.#elements.uiTitle.textContent = title || this.#requestPrompt || 'No Title';
374
+
375
+ // --- 追加点: iframe内での右クリック検知 ---
376
+ this.#elements.previewOutput.onload = () => {
377
+ try {
378
+ const doc = this.#elements.previewOutput.contentDocument;
379
+
380
+ // 右クリックでメニュー表示
381
+ doc.addEventListener('contextmenu', (e) => {
382
+ e.preventDefault();
383
+ const iframeRect = this.#elements.previewOutput.getBoundingClientRect();
384
+ const x = e.clientX + iframeRect.left;
385
+ const y = e.clientY + iframeRect.top;
386
+ this.#showContextMenu(x, y);
387
+ });
388
+
389
+ // 左クリックでメニューを閉じる
390
+ doc.addEventListener('click', () => this.#hideContextMenu());
391
+ } catch (e) {
392
+ console.warn("Context menu access denied", e);
393
+ }
394
+ };
341
395
  }
342
396
 
343
397
  // ==================================================================================
@@ -370,15 +424,16 @@ ${html}
370
424
  }
371
425
 
372
426
  async #processRequest() {
373
- await this.#executeGemini((html) => this.#buildPrompt(`<style>${Array.from(document.querySelectorAll('style')).map(s=>s.textContent).join('\n')}</style>${html}`, this.#requestPrompt));
427
+ await this.#executeGemini((html) => this.#buildPrompt(`<style>${Array.from(document.querySelectorAll('style')).map(s=>s.textContent).join('\n')}</style>${html}`, this.#requestPrompt, document.body.innerHTML));
374
428
  }
375
429
 
376
430
  async #processRefinement(instruction) {
377
- await this.#executeGemini(() => this.#buildPrompt(this.#currentCode.html, instruction));
431
+ await this.#executeGemini(() => this.#buildPrompt(this.#currentCode.html, instruction, document.body.innerHTML));
378
432
  }
379
433
 
380
434
  async #executeGemini(promptBuilder) {
381
435
  this.#updateUIState('LOADING');
436
+
382
437
  this.#abortController = new AbortController();
383
438
  try {
384
439
  const prompt = promptBuilder(this.#originalHtml);
@@ -404,8 +459,10 @@ ${html}
404
459
  }
405
460
  }
406
461
 
407
- #buildPrompt(html, request) {
408
- const htmlContent = html ? `${html}` : `なし。指示に基づき新規生成`;
462
+ #buildPrompt(html, request, parent) {
463
+ const targetHtml = html ? `${html}` : `なし。指示に基づき新規生成`;
464
+ const parentHtml = parent ? `${parent}` : `なし`;
465
+
409
466
  return `
410
467
  ## 命令
411
468
  あなたは世界トップクラスのUIエンジニアです。
@@ -415,15 +472,18 @@ ${html}
415
472
  ### HTML
416
473
  1. 「対象HTML」があれば、セマンティックHTML(\`main\`や\`header\`等)を使用して意味的に正しくリファクタリングしてください。
417
474
  2. 「対象HTML」がなければ、指示に基づき最適なHTMLを新規生成してください。
418
- 3. 正しいARIAロールと属性を必ず使用してください。
419
- 4. 純粋に装飾目的の画像、またはスクリーンリーダーにとって繰り返しになる場合を除き、すべての画像に代替テキストを追加してください。
420
- 5. 配置用の親要素(divやwrapper等)で囲まず、コンポーネント本体をルート要素として出力してください。
475
+ 3. 「親ページのHTML」があれば、デザインとレイアウトの整合性を取るために参照してください。ただし生成するコードに含めないでください。
476
+ 4. 正しいARIAロールと属性を必ず使用してください。
477
+ 5. 純粋に装飾目的の画像、またはスクリーンリーダーにとって繰り返しになる場合を除き、すべての画像に代替テキストを追加してください。
478
+ 6. 配置用の親要素(divやwrapper等)で囲まず、コンポーネント本体をルート要素として出力してください。
421
479
 
422
480
  ### CSS
423
481
  1. CSSはマテリアルデザインの原則に従ってください。
424
482
  2. CSSはレスポンシブデザインを実装してください。
425
- 3. 画像は、アスペクト比を維持し、画像全体が表示されるようにしてください。意図しないトリミングが発生する \`object-fit: cover;\` は避け、必要であれば \`object-fit: contain;\` や \`height: auto;\` を使用して、画像が途切れないようにしてください。
426
- 4. \`object-fit: contain;\` や \`height: auto;\` を使用して画像が途切れないようにする場合、画像コンテナの背景色は、コンポーネント全体の背景色(通常は \`#ffffff\`)と一致させるか、透明 (\`transparent\`) に設定し、余白部分の色が浮かないようにしてください。
483
+ 3. 画像の表示(\`object-fit\`)は、画像の役割に応じて以下のように使い分けてください
484
+ - 背景・装飾・風景・アバター: コンテナ全体を埋めるために \`object-fit: cover;\` を使用し、余白が出ないようにしてください。
485
+ - 商品・図解・グラフ: 全体が見えることが重要な場合は \`object-fit: contain;\` を使用してください。ただし、その場合は余白が目立たないよう、画像コンテナの背景色を調整(透明または画像と馴染む色)してください。
486
+ 4. 画像エリアは、レイアウト崩れを防ぐために適切な高さ指定(\`height\`)またはアスペクト比(\`aspect-ratio\`)を設定してください。
427
487
  5. CSSセレクタは、可能な限り特定のクラス名を使用し、bodyやhtmlタグへの直接的なスタイル適用は避けてください。
428
488
  6. 画面中央揃えやbodyへのレイアウト指定は禁止です。コンポーネント内部のスタイルのみ記述してください。
429
489
 
@@ -433,6 +493,22 @@ ${html}
433
493
  3. コードは \`document.addEventListener('DOMContentLoaded', () => { ... })\` 内に記述し、DOM読み込み後に実行されるようにしてください。
434
494
  4. エラーハンドリング(try-catch等)を適切に行い、コンソールエラーが出ないように配慮してください。
435
495
 
496
+ ### 画像リソースのルール
497
+ 画像(imgタグやbackground-image等)の扱いは、以下の優先順位とルールを厳守してください。
498
+ 1. 既存パスの維持(最優先):
499
+ - 「対象HTML」に既に記述されている画像パスは、そのまま出力してください。
500
+ 2. 新規ダミー画像の生成:
501
+ - 指示により新しく画像要素を追加する場合や、元画像のパスが空の場合に限り、以下のURL形式を使用してください。
502
+ A. 一般的な画像(背景、商品、記事等):
503
+ - 書式: "https://picsum.photos/seed/{seed_id}/{width}/{height}"
504
+ - {width}, {height} は必要なサイズ(例: 800/600)に置き換える。
505
+ - {seed_id} には画像の文脈を表す固定の英単語(例: "nature", "city", "food")を入れてください。
506
+ - 例: <img src="https://picsum.photos/seed/nature/400/300" alt="風景">
507
+ B. ユーザープロフィール画像(アバター・アイコン):
508
+ - 書式: "https://i.pravatar.cc/{size}?u={unique_id}"
509
+ - {size} はサイズ(例: 150)。uパラメータには固定の文字列を入れる。
510
+ - 例: <img src="https://i.pravatar.cc/150?u=user1" alt="ユーザーアイコン" style="border-radius: 50%;">
511
+
436
512
  ### 制約条件
437
513
  1. CSSは、外部のライブラリやフレームワーク(Tailwind CSS や Bootstrap等)に依存してはいけません。
438
514
  2. CSS内に外部リソース (@import等) を含めないでください。
@@ -441,7 +517,8 @@ ${html}
441
517
 
442
518
  ## 入力データ
443
519
  - ユーザーからの指示: ${request}
444
- - 対象HTML: ${htmlContent}
520
+ - 対象HTML: ${targetHtml}
521
+ - 親ページのHTML: ${parentHtml}
445
522
 
446
523
  ## 出力指示子
447
524
  - 回答は必ずJSON形式でなければなりません。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tosk/gen-ui",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "main": "gen-ui.js",
5
5
  "files": [
6
6
  "gen-ui.js"