@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.
- package/gen-ui.js +164 -87
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<
|
|
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 {
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
this.shadowRoot.addEventListener('click', (e) => {
|
|
176
|
+
if (!e.target.closest('#context-menu')) {
|
|
177
|
+
this.#hideContextMenu();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
171
180
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
: 'クリップボードにコピーしますか?';
|
|
189
|
-
if (!confirm(msg)) return;
|
|
210
|
+
#showContextMenu(x, y) {
|
|
211
|
+
const menu = this.#elements.contextMenu;
|
|
212
|
+
const rect = this.getBoundingClientRect();
|
|
190
213
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
419
|
-
4.
|
|
420
|
-
5.
|
|
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.
|
|
426
|
-
|
|
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: ${
|
|
520
|
+
- 対象HTML: ${targetHtml}
|
|
521
|
+
- 親ページのHTML: ${parentHtml}
|
|
445
522
|
|
|
446
523
|
## 出力指示子
|
|
447
524
|
- 回答は必ずJSON形式でなければなりません。
|