@tosk/gen-ui 1.0.1 → 1.0.3

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 +391 -302
  2. package/package.json +2 -2
package/gen-ui.js CHANGED
@@ -1,382 +1,471 @@
1
- class GenerativeUi extends HTMLElement {
2
- // 1. 定数
3
-
4
- static API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent';
1
+ class GenUi extends HTMLElement {
2
+ // ==================================================================================
3
+ // Static Configuration
4
+ // ==================================================================================
5
+ static API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent';
6
+ static COLLECTION_NAME = 'gen-ui';
7
+ static PRETTIER_CONFIG = {
8
+ urls: {
9
+ main: 'https://unpkg.com/prettier@3.1.1/standalone.mjs',
10
+ html: 'https://unpkg.com/prettier@3.1.1/plugins/html.mjs',
11
+ css: 'https://unpkg.com/prettier@3.1.1/plugins/postcss.mjs',
12
+ js: 'https://unpkg.com/prettier@3.1.1/plugins/babel.mjs',
13
+ estree: 'https://unpkg.com/prettier@3.1.1/plugins/estree.mjs',
14
+ }
15
+ };
5
16
 
6
- static COPY_ICON_SVG = `
7
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
8
- <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
9
- </svg>
10
- `;
17
+ static SELECTORS = {
18
+ loadingOverlay: '#loading-overlay',
19
+ previewOutput: '#preview-output',
20
+ editBtn: '#edit-btn',
21
+ completeBtn: '#complete-btn',
22
+ chatWindow: '#chat-window',
23
+ chatInput: '#chat-input',
24
+ chatSubmit: '#chat-submit',
25
+ chatCancel: '#chat-cancel',
26
+ connectBtn: '#connect-btn',
27
+ uiTitle: '#ui-title',
28
+ };
11
29
 
12
30
  static TEMPLATE = (() => {
13
31
  const template = document.createElement('template');
14
32
  template.innerHTML = `
15
33
  <style>
16
- :host {
17
- display: block;
18
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
19
- border: 1px solid #d1d5db;
20
- border-radius: 0.5rem;
21
- padding: 1.5rem;
22
- max-width: 800px;
23
- box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
24
- min-height: 200px;
25
- position: relative;
26
- }
27
- .loading-overlay {
28
- position: absolute; top: 0; left: 0; right: 0; bottom: 0;
29
- background: rgba(255, 255, 255, 0.8);
30
- display: flex; flex-direction: column; align-items: center; justify-content: center;
31
- gap: 1rem; font-weight: 500; color: #333;
32
- border-radius: 0.5rem; z-index: 20;
33
- }
34
- .spinner {
35
- border: 4px solid rgba(0, 0, 0, .1);
36
- border-left-color: #2563eb;
37
- border-radius: 50%;
38
- width: 36px; height: 36px;
39
- animation: spin 1s linear infinite;
40
- }
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%; }
50
+ 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; }
52
+ .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; }
41
53
  @keyframes spin { to { transform: rotate(360deg); } }
42
- .tabs { display: flex; border-bottom: 1px solid #d1d5db; }
43
- .tab { padding: 0.5rem 1rem; cursor: pointer; border: 1px solid transparent; border-bottom: none; margin-bottom: -1px; }
44
- .tab.active { border-color: #d1d5db; border-bottom-color: white; border-radius: 0.375rem 0.375rem 0 0; background-color: white; }
45
- .tab-content { display: none; border: 1px solid #d1d5db; border-top: none; padding: 1rem; border-radius: 0 0 0.375rem 0.375rem; }
46
- .tab-content.active { display: block; }
47
- .code-area { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
48
- .output-box { position: relative; }
49
- h3 { margin-top: 0; }
50
- pre { background-color: #f3f4f6; padding: 1rem; border-radius: 0.375rem; max-height: 300px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; }
51
- .copy-btn { position: absolute; top: 0.5rem; right: 0.5rem; padding: 0.25rem; background-color: #e5e7eb; border: 1px solid #d1d5db; border-radius: 0.25rem; cursor: pointer; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; transition: background-color 0.2s; z-index: 10; }
52
- .copy-btn:hover { background-color: #d1d5db; }
53
- .copy-btn svg { width: 16px; height: 16px; }
54
- .copy-feedback { position: absolute; top: 0.5rem; right: 38px; background-color: #333; color: white; padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; opacity: 0; transition: opacity 0.3s ease-in-out; pointer-events: none; white-space: nowrap; }
55
- .copy-feedback.show { opacity: 1; }
56
- #preview-output { width: 100%; height: 400px; border: 1px solid #d1d5db; border-radius: 0.375rem; }
57
- #error-display { color: #ef4444; font-weight: 500; padding: 1rem; }
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; }
55
+ .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
+ .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
+ .chat-actions { display: flex; justify-content: flex-end; gap: 10px; }
58
+ button.btn { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; }
59
+ .btn-primary { background: #3b82f6; color: white; }
60
+ .btn-primary:hover { opacity: 0.9; }
61
+ .btn-cancel { background: #eee; color: #333; }
58
62
  .hidden { display: none !important; }
59
-
60
- #response-time-display {
61
- position: absolute;
62
- bottom: 0.5rem;
63
- right: 0.5rem;
64
- font-size: 0.75rem;
65
- color: #6b7280;
66
- background-color: #f3f4f6;
67
- padding: 0.25rem 0.5rem;
68
- border-radius: 0.25rem;
69
- z-index: 10;
70
- }
71
63
  </style>
72
- <div class="container">
73
- <div id="loading-overlay" class="loading-overlay hidden">
74
- <div class="spinner"></div>
75
- <div>UIを生成中...</div>
76
- </div>
77
- <div id="error-display" class="hidden"></div>
78
- <div id="output-container" class="hidden">
79
- <div class="tabs">
80
- <div class="tab active" data-tab="code">コード</div>
81
- <div class="tab" data-tab="preview">プレビュー</div>
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>
82
72
  </div>
83
- <div id="code" class="tab-content active">
84
- <div class="code-area">
85
- <div class="output-box">
86
- <h3>HTML</h3>
87
- <button class="copy-btn" data-target="html-output" aria-label="HTMLコードをコピー">
88
- ${GeminiComponent.COPY_ICON_SVG}
89
- </button>
90
- <div class="copy-feedback" data-for="html-output">コピーしました</div>
91
- <pre><code id="html-output"></code></pre>
92
- </div>
93
- <div class="output-box">
94
- <h3>CSS</h3>
95
- <button class="copy-btn" data-target="css-output" aria-label="CSSコードをコピー">
96
- ${GeminiComponent.COPY_ICON_SVG}
97
- </button>
98
- <div class="copy-feedback" data-for="css-output">コピーしました</div>
99
- <pre><code id="css-output"></code></pre>
100
- </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>
101
85
  </div>
102
86
  </div>
103
- <div id="preview" class="tab-content">
104
- <iframe id="preview-output" title="生成されたUIのプレビュー"></iframe>
105
- </div>
106
87
  </div>
107
88
  </div>
108
89
  `;
109
90
  return template;
110
91
  })();
111
92
 
112
- static SELECTORS = {
113
- loadingOverlay: '#loading-overlay',
114
- errorDisplay: '#error-display',
115
- outputContainer: '#output-container',
116
- htmlOutput: '#html-output',
117
- cssOutput: '#css-output',
118
- previewOutput: '#preview-output',
119
- copyButtons: '.copy-btn',
120
- tabs: '.tab',
121
- tabContents: '.tab-content',
122
- responseTimeDisplay: '#response-time-display',
123
- };
124
-
125
- static CLASSES = {
126
- ACTIVE: 'active',
127
- HIDDEN: 'hidden',
128
- SHOW: 'show',
129
- }
130
-
131
- static UI_STATES = {
132
- LOADING: 'LOADING',
133
- SUCCESS: 'SUCCESS',
134
- ERROR: 'ERROR',
135
- };
136
-
137
- // 2. プライベートプロパティ
138
-
93
+ // ==================================================================================
94
+ // Private Fields
95
+ // ==================================================================================
139
96
  #apiKey = null;
140
97
  #requestPrompt = null;
141
98
  #originalHtml = '';
99
+ #loadKey = null;
100
+ #saveKey = null;
142
101
  #elements = {};
143
102
  #abortController = null;
144
-
145
- // 3. ライフサイクル
103
+ #fileHandle = null;
104
+ #currentCode = { html: '', css: '', javascript: '' };
105
+ static #prettierModules = null; // Cache for Prettier
146
106
 
147
107
  constructor() {
148
108
  super();
149
109
  this.attachShadow({ mode: 'open' });
150
- this.shadowRoot.appendChild(GeminiComponent.TEMPLATE.content.cloneNode(true));
151
- this.#cacheElements();
110
+ this.shadowRoot.appendChild(GenUi.TEMPLATE.content.cloneNode(true));
111
+ Object.entries(GenUi.SELECTORS).forEach(([key, selector]) => {
112
+ this.#elements[key] = this.shadowRoot.querySelector(selector);
113
+ });
114
+ this.#setupInteractions();
152
115
  }
153
116
 
117
+ // ==================================================================================
118
+ // Lifecycle Methods
119
+ // ==================================================================================
154
120
  connectedCallback() {
155
- this.#initializeProperties();
156
-
157
- if (this.#validateAttributes()) {
158
- this.#addEventListeners();
159
- this.#processRequest();
160
- }
121
+ if (!this.isConnected) return;
122
+ setTimeout(() => {
123
+ if (this.querySelector('template')) {
124
+ this.#hydrateExistingContent();
125
+ } else {
126
+ this.#initialize();
127
+ }
128
+ }, 0);
161
129
  }
162
130
 
163
131
  disconnectedCallback() {
164
- this.#removeEventListeners();
165
132
  this.#abortController?.abort();
166
133
  }
167
134
 
168
- // 4. イベントハンドラ
169
-
170
- #handleCopy = async(event) => {
171
- const button = event.currentTarget;
172
- const targetId = button.dataset.target;
173
- const codeElement = this.shadowRoot.querySelector(`#${targetId}`);
174
- if (!codeElement) return;
135
+ #initialize() {
136
+ this.#apiKey = this.getAttribute('api-key');
137
+ this.#requestPrompt = this.getAttribute('request');
138
+ this.#loadKey = this.getAttribute('load-key');
139
+ this.#saveKey = this.getAttribute('save-key');
140
+ this.#originalHtml = this.innerHTML.trim();
175
141
 
176
- const feedbackElement = this.shadowRoot.querySelector(`.copy-feedback[data-for="${targetId}"]`);
142
+ if (!this.#apiKey) return console.error('GenUi: "api-key" attribute is required.');
143
+ if (this.#loadKey) return this.#loadFromFirestore();
144
+ if (this.#requestPrompt) this.#processRequest();
145
+ }
177
146
 
178
- try {
179
- await navigator.clipboard.writeText(codeElement.textContent);
180
- feedbackElement.classList.add(GeminiComponent.CLASSES.SHOW);
181
- setTimeout(() => feedbackElement.classList.remove(GeminiComponent.CLASSES.SHOW), 2000);
182
- } catch (err) {
183
- console.error('コピーに失敗しました: ', err);
184
- }
185
- };
147
+ // ==================================================================================
148
+ // Event Handlers
149
+ // ==================================================================================
150
+ #setupInteractions() {
151
+ const { editBtn, completeBtn, chatWindow, chatCancel, chatSubmit, chatInput, connectBtn } = this.#elements;
186
152
 
187
- #handleTabClick = (event) => {
188
- const tabName = event.currentTarget.dataset.tab;
189
- this.#switchTab(tabName);
190
- }
153
+ editBtn.addEventListener('click', () => {
154
+ chatWindow.classList.remove('hidden');
155
+ chatInput.focus();
156
+ });
191
157
 
192
- // 5. コアロジック
158
+ chatCancel.addEventListener('click', () => chatWindow.classList.add('hidden'));
193
159
 
194
- #processRequest = async () => {
195
- this.#updateUIState(GeminiComponent.UI_STATES.LOADING);
196
- this.#abortController = new AbortController();
160
+ chatSubmit.addEventListener('click', () => {
161
+ const instruction = chatInput.value.trim();
162
+ if (!instruction) return;
163
+ chatWindow.classList.add('hidden');
164
+ chatInput.value = '';
165
+ this.#processRefinement(instruction);
166
+ });
197
167
 
198
- const startTime = performance.now();
168
+ connectBtn.addEventListener('click', () => this.#handleFileConnect());
169
+ completeBtn.addEventListener('click', () => this.#handleCompletion());
170
+ }
199
171
 
172
+ async #handleFileConnect() {
200
173
  try {
201
- const prompt = this.#buildPrompt(this.#originalHtml, this.#requestPrompt);
202
- const responseText = await this.#callGeminiApi(prompt, this.#abortController.signal);
203
- const jsonResponse = this.#parseApiResponse(responseText);
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 */ }
183
+ }
204
184
 
205
- this.#updateUIState(GeminiComponent.UI_STATES.SUCCESS, jsonResponse);
206
- } catch (error) {
207
- if (error.name !== 'AboutError') {
208
- console.error("処理中にエラーが発生しました:", error);
209
- this.#updateUIState(GeminiComponent.UI_STATES.ERROR, { message: error.message });
210
- }
211
- } finally {
212
- const endTime = performance.now();
213
- const duration = ((endTime - startTime) / 1000).toFixed(2);
214
- if (this.#elements.responseTimeDisplay) {
215
- this.#elements.responseTimeDisplay.textContent = `応答速度: ${duration}秒`;
216
- }
217
- this.#abortController = null;
185
+ async #handleCompletion() {
186
+ const msg = this.#fileHandle
187
+ ? '連携中のファイルを書き換えますか?\n(Git等でバックアップを推奨)'
188
+ : 'クリップボードにコピーしますか?';
189
+ if (!confirm(msg)) return;
190
+
191
+ if (this.#fileHandle) {
192
+ await this.#directWriteToFile();
193
+ } else {
194
+ await this.#copyToClipboard();
195
+ alert('コピーしました。');
218
196
  }
219
197
  }
220
198
 
221
- async #callGeminiApi(prompt, signal) {
222
- const url = `${GeminiComponent.API_BASE_URL}?key=${this.#apiKey}`;
223
- const body = {
224
- contents: [{ parts: [{ text: prompt }] }],
225
- generationConfig: { responseMimeType: "application/json" },
226
- };
227
-
228
- const response = await fetch(url, {
229
- method: 'POST',
230
- headers: { 'Content-Type': 'application/json' },
231
- body: JSON.stringify(body),
232
- signal,
233
- });
234
-
235
- if (!response.ok) {
236
- const errorData = await response.json().catch(() => ({}));
237
- throw new Error(`APIエラー (${response.status}): ${errorData?.error?.message || '不明なエラー'}`);
199
+ // ==================================================================================
200
+ // Core Logic: File Operations & Code Assembly
201
+ // ==================================================================================
202
+ async #directWriteToFile() {
203
+ try {
204
+ const file = await this.#fileHandle.getFile();
205
+ const originalContent = await file.text();
206
+
207
+ const { targetRegex, myId } = this.#identifyTargetTag(originalContent);
208
+ const prettierData = await this.#loadPrettier();
209
+ const rawCode = this.#assembleFinalCode('web-component', myId);
210
+
211
+ // Prettier formatting ensures the inserted block is clean and consistently indented,
212
+ // reducing 'messy' diffs in the editor's Undo/Redo stack.
213
+ const formattedCode = await this.#formatCodeWithPrettier(rawCode, prettierData);
214
+
215
+ const newContent = originalContent.replace(targetRegex, formattedCode.trim());
216
+
217
+ const writable = await this.#fileHandle.createWritable();
218
+ await writable.write(newContent);
219
+ await writable.close();
220
+
221
+ alert('書き換え完了!Prettierで整形しました✨');
222
+ if (confirm('反映のためにリロードしますか?')) location.reload();
223
+ } catch (err) {
224
+ console.error(err);
225
+ alert(`エラー: ${err.message}`);
238
226
  }
227
+ }
239
228
 
240
- const data = await response.json();
241
- return data?.candidates?.[0]?.content?.parts?.[0]?.text || null;
229
+ async #copyToClipboard() {
230
+ const finalCode = this.#assembleFinalCode('simple-embed');
231
+ await navigator.clipboard.writeText(finalCode.trim());
242
232
  }
243
233
 
244
- #parseApiResponse(responseText) {
245
- if (!responseText) {
246
- throw new Error("APIは空のレスポンスを返しました。")
234
+ #identifyTargetTag(content) {
235
+ let myId = this.getAttribute('id');
236
+ let targetRegex;
237
+
238
+ if (myId) {
239
+ // Improved Regex: Ensures we capture the full opening tag even with multiline attributes
240
+ // and lazily matches content up to the closing tag if it exists.
241
+ targetRegex = new RegExp(`<gen-ui[^>]*id=["']${myId}["'][^>]*>([\\s\\S]*?<\\/gen-ui>)?`, 'i');
242
+ if (!targetRegex.test(content)) throw new Error(`ID="${myId}" が見つかりません。`);
243
+ } else {
244
+ const allTags = content.match(/<gen-ui/gi);
245
+ if (!allTags || !allTags.length) throw new Error("タグが見つかりません。");
246
+ if (allTags.length > 1) throw new Error("IDのないタグが複数あります。id属性を追加してください。");
247
+ targetRegex = /<gen-ui[\s\S]*?<\/gen-ui>/i;
248
+ myId = 'gen-' + Math.random().toString(36).substring(2, 9);
247
249
  }
250
+ return { targetRegex, myId };
251
+ }
248
252
 
249
- try {
250
- const jsonResponse = JSON.parse(responseText);
251
- if (typeof jsonResponse.html !== 'string' || typeof jsonResponse.css !== 'string') {
252
- throw new Error("APIからのJSONフォーマットが無効です。「html」と「css」のキーが必要です。")
253
- }
254
- return jsonResponse;
255
- } catch (error) {
256
- console.error("APIレスポンスの解析に失敗しました: ", responseText);
257
- throw new Error("APIからの応答を解析できませんでした。")
253
+ #assembleFinalCode(mode, componentId = '') {
254
+ const { html, css, javascript } = this.#currentCode;
255
+ const resetCss = `*, *::before, *::after { box-sizing: border-box; }`;
256
+
257
+ if (mode === 'web-component') {
258
+ const processedJs = javascript.replace(/document\.(querySelector|querySelectorAll|getElementById)/g, 'root.$1');
259
+ // Intentionally unindented to let Prettier handle the final layout
260
+ return `
261
+ <gen-ui id="${componentId}">
262
+ <template>
263
+ <style>
264
+ ${resetCss}
265
+ ${css}
266
+ </style>
267
+ ${html}
268
+ <script>
269
+ (() => {
270
+ const root = document.getElementById('${componentId}').shadowRoot;
271
+ try {
272
+ ${this.#extractJsContent(processedJs)}
273
+ } catch (e) { console.error('GenUI Script Error:', e); }
274
+ })();
275
+ <\/script>
276
+ </template>
277
+ </gen-ui>`;
258
278
  }
259
- }
260
279
 
261
- // 6. UI操作
280
+ return `
281
+ <style>${resetCss}${css}</style>
282
+ ${html}
283
+ <script>(() => { try { ${javascript} } catch (e) { console.error(e); } })();<\/script>`;
284
+ }
262
285
 
263
- #updateUIState(state, payload = {}) {
264
- const { loadingOverlay, outputContainer, errorDisplay, htmlOutput, cssOutput, previewOutput, responseTimeDisplay } = this.#elements;
265
- const { HIDDEN } = GeminiComponent.CLASSES;
286
+ #extractJsContent(jsCode) {
287
+ return jsCode.replace(/document\.addEventListener\s*\(\s*['"]DOMContentLoaded['"]\s*,\s*\(\s*\)\s*=>\s*\{([\s\S]*)\}\s*\);?/g, '$1');
288
+ }
266
289
 
267
- loadingOverlay.classList.add(HIDDEN)
268
- outputContainer.classList.add(HIDDEN);
269
- errorDisplay.classList.add(HIDDEN);
290
+ async #loadPrettier() {
291
+ if (GenUi.#prettierModules) return GenUi.#prettierModules;
292
+ const { urls } = GenUi.PRETTIER_CONFIG;
293
+ const [prettier, html, css, js, estree] = await Promise.all([
294
+ import(urls.main), import(urls.html), import(urls.css), import(urls.js), import(urls.estree)
295
+ ]);
296
+ GenUi.#prettierModules = {
297
+ prettier: prettier.default,
298
+ plugins: [html.default, css.default, js.default, estree.default]
299
+ };
300
+ return GenUi.#prettierModules;
301
+ }
270
302
 
271
- if (responseTimeDisplay) {
272
- responseTimeDisplay.classList.add(HIDDEN);
273
- }
303
+ async #formatCodeWithPrettier(code, { prettier, plugins }) {
304
+ return await prettier.format(code, {
305
+ parser: "html",
306
+ plugins: plugins,
307
+ tabWidth: 2,
308
+ printWidth: 120,
309
+ });
310
+ }
274
311
 
275
- switch (state) {
276
- case GeminiComponent.UI_STATES.LOADING:
277
- loadingOverlay.classList.remove(HIDDEN);
278
- break;
279
-
280
- case GeminiComponent.UI_STATES.ERROR:
281
- errorDisplay.textContent = `エラー: ${payload.message}`;
282
- errorDisplay.classList.remove(HIDDEN);
283
- if (responseTimeDisplay) {
284
- responseTimeDisplay.classList.remove(HIDDEN);
285
- }
286
- break;
287
-
288
- case GeminiComponent.UI_STATES.SUCCESS:
289
- const { html, css } = payload;
290
- htmlOutput.textContent = html;
291
- cssOutput.textContent = css;
292
- previewOutput.srcdoc = this.#createPreviewDoc(html, css);
293
- outputContainer.classList.remove(HIDDEN);
294
- this.#switchTab('code');
295
- if (responseTimeDisplay) {
296
- responseTimeDisplay.classList.remove(HIDDEN);
297
- }
298
- break;
299
- }
312
+ #hydrateExistingContent() {
313
+ const template = this.querySelector('template');
314
+ if (!template) return;
315
+ this.shadowRoot.innerHTML = '';
316
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
317
+ // Re-activate scripts by cloning them
318
+ this.shadowRoot.querySelectorAll('script').forEach(old => {
319
+ const fresh = document.createElement('script');
320
+ fresh.textContent = old.textContent;
321
+ old.replaceWith(fresh);
322
+ });
300
323
  }
301
324
 
302
- #switchTab = (tabName) => {
303
- const { ACTIVE } = GeminiComponent.CLASSES;
304
- this.#elements.tabs.forEach(tab => tab.classList.toggle(ACTIVE, tab.dataset.tab === tabName));
305
- this.#elements.tabContents.forEach(content => content.classList.toggle(ACTIVE, content.id === tabName));
306
- };
325
+ // ==================================================================================
326
+ // UI & Rendering Logic
327
+ // ==================================================================================
328
+ #updateUIState(state) {
329
+ const { loadingOverlay, previewOutput, uiTitle } = this.#elements;
330
+ const isLoading = state === 'LOADING';
331
+ loadingOverlay.classList.toggle('hidden', !isLoading);
332
+ previewOutput.style.opacity = isLoading ? '0.5' : '1';
333
+ uiTitle.classList.toggle('loading', isLoading);
334
+ if (isLoading) uiTitle.textContent = 'Generating...';
335
+ }
307
336
 
308
- // 7. ヘルパー
337
+ #renderPreview(html, css, javascript, title) {
338
+ 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>`;
340
+ this.#elements.uiTitle.textContent = title || this.#requestPrompt || 'No Title';
341
+ }
309
342
 
310
- #cacheElements() {
311
- for (const key in GeminiComponent.SELECTORS) {
312
- const elements = this.shadowRoot.querySelectorAll(GeminiComponent.SELECTORS[key]);
313
- this.#elements[key] = elements.length > 1 ? Array.from(elements) : elements[0];
343
+ // ==================================================================================
344
+ // API & Data Logic
345
+ // ==================================================================================
346
+ async #loadFromFirestore() {
347
+ this.#updateUIState('LOADING');
348
+ try {
349
+ if (typeof firebase === 'undefined') throw new Error('Firebase SDK missing');
350
+ const doc = await firebase.firestore().collection(GenUi.COLLECTION_NAME).doc(this.#loadKey).get();
351
+ if (!doc.exists) throw new Error('Document not found');
352
+ const data = doc.data();
353
+ this.#renderPreview(data.html, data.css, data.javascript, data.title);
354
+ } catch (error) {
355
+ console.error(error);
356
+ } finally {
357
+ this.#updateUIState('SUCCESS');
314
358
  }
315
359
  }
316
360
 
317
- #initializeProperties() {
318
- this.#apiKey = this.getAttribute('api-key');
319
- this.#requestPrompt = this.getAttribute('request');
320
- this.#originalHtml = this.innerHTML.trim();
361
+ async #saveToFirestore(data) {
362
+ if (typeof firebase === 'undefined') return;
363
+ const docId = this.#loadKey || this.#saveKey || Math.random().toString(36).substring(2, 10);
364
+ try {
365
+ await firebase.firestore().collection(GenUi.COLLECTION_NAME).doc(docId).set({
366
+ id: docId, ...data, request: this.#requestPrompt, createdAt: firebase.firestore.FieldValue.serverTimestamp(),
367
+ });
368
+ console.log(`Saved: ${docId}`);
369
+ } catch (e) { console.error(e); }
321
370
  }
322
371
 
323
- #validateAttributes() {
324
- if (!this.#apiKey) {
325
- this.#updateUIState(GeminiComponent.UI_STATES.ERROR, { message: '「api-key」属性が必要です。'});
326
- return false;
327
- }
328
- if (!this.#requestPrompt) {
329
- this.#updateUIState(GeminiComponent.UI_STATES.ERROR, { message: '「request」属性が必要です。'});
330
- return false;
331
- }
332
- if (!this.#originalHtml) {
333
- this.#updateUIState(GeminiComponent.UI_STATES.ERROR, { message: 'HTMLの子要素が必要です。'});
334
- return false;
335
- }
336
- return true;
372
+ 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));
337
374
  }
338
375
 
339
- #addEventListeners() {
340
- this.#elements.copyButtons.forEach(btn => btn.addEventListener('click', this.#handleCopy));
341
- this.#elements.tabs.forEach(tab => tab.addEventListener('click', this.#handleTabClick));
376
+ async #processRefinement(instruction) {
377
+ await this.#executeGemini(() => this.#buildPrompt(this.#currentCode.html, instruction));
342
378
  }
343
379
 
344
- #removeEventListeners() {
345
- this.#elements.copyButtons.forEach(btn => btn.removeEventListener('click', this.#handleCopy));
346
- this.#elements.tabs.forEach(tab => tab.removeEventListener('click', this.#handleTabClick));
380
+ async #executeGemini(promptBuilder) {
381
+ this.#updateUIState('LOADING');
382
+ this.#abortController = new AbortController();
383
+ try {
384
+ const prompt = promptBuilder(this.#originalHtml);
385
+ const url = `${GenUi.API_BASE_URL}?key=${this.#apiKey}`;
386
+ const response = await fetch(url, {
387
+ method: 'POST',
388
+ headers: { 'Content-Type': 'application/json' },
389
+ body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { responseMimeType: "application/json", temperature: 0.2 } }),
390
+ signal: this.#abortController.signal,
391
+ });
392
+ if (!response.ok) throw new Error(`API Error: ${response.status}`);
393
+ const json = JSON.parse((await response.json())?.candidates?.[0]?.content?.parts?.[0]?.text);
394
+ if (!json) throw new Error("Empty response");
395
+
396
+ this.#saveToFirestore(json);
397
+ this.#renderPreview(json.html, json.css, json.javascript, json.title);
398
+ } catch (err) {
399
+ console.error(err);
400
+ if (err.name !== 'AbortError') alert('生成エラー');
401
+ } finally {
402
+ this.#updateUIState('SUCCESS');
403
+ this.#abortController = null;
404
+ }
347
405
  }
348
406
 
349
407
  #buildPrompt(html, request) {
408
+ const htmlContent = html ? `${html}` : `なし。指示に基づき新規生成`;
350
409
  return `
351
- あなたはプロのフロントエンジニアです。
352
- 入力としてユーザーからの指示と、変更対象となるHTMLを受け取ります。
353
- あなたはその指示に従って、新しいHTMLと、そのHTMLを装飾するためのCSSコードを生成してください。
354
- 出力は以下のルールに厳密に従ってください
355
- - 回答は必ずJSON形式でなければなりません。
356
- - JSONオブジェクトは 'html' と 'css' の2つのキーを持つ必要があります。
357
- - 'html' の値は変更後のHTMLコード(文字列)です。
358
- - 'css' の値は生成されたCSSコード(文字列)です。
359
- - JSONを囲む\`\`\`jsonや\`\`\`のようなMarkdownのコードブロック識別子を絶対に含めないでください。
360
- - 回答には純粋なJSONオブジェクトのみとしてください。説明や他のテキストは一切不要です。
361
- ユーザーからの指示: ${request}
362
- 対象のHTML: ${html}
363
- `;
364
- }
365
-
366
- #createPreviewDoc(html, css) {
367
- return `
368
- <!DOCTYPE html>
369
- <html lang="ja">
370
- <head>
371
- <meta charset="UTF-8">
372
- <style>body { font-family: sans-serif; } ${css}</style>
373
- </head>
374
- <body>
375
- ${html}
376
- </body>
377
- </html>
410
+ ## 命令
411
+ あなたは世界トップクラスのUIエンジニアです。
412
+ 「ユーザーからの指示」と、「対象HTML」に基づき、HTMLCSS、JavaScriptコードを生成してください。
413
+
414
+ ## 文脈
415
+ ### HTML
416
+ 1. 「対象HTML」があれば、セマンティックHTML(\`main\`や\`header\`等)を使用して意味的に正しくリファクタリングしてください。
417
+ 2. 「対象HTML」がなければ、指示に基づき最適なHTMLを新規生成してください。
418
+ 3. 正しいARIAロールと属性を必ず使用してください。
419
+ 4. 純粋に装飾目的の画像、またはスクリーンリーダーにとって繰り返しになる場合を除き、すべての画像に代替テキストを追加してください。
420
+ 5. 配置用の親要素(divやwrapper等)で囲まず、コンポーネント本体をルート要素として出力してください。
421
+
422
+ ### CSS
423
+ 1. CSSはマテリアルデザインの原則に従ってください。
424
+ 2. CSSはレスポンシブデザインを実装してください。
425
+ 3. 画像は、アスペクト比を維持し、画像全体が表示されるようにしてください。意図しないトリミングが発生する \`object-fit: cover;\` は避け、必要であれば \`object-fit: contain;\` や \`height: auto;\` を使用して、画像が途切れないようにしてください。
426
+ 4. \`object-fit: contain;\` や \`height: auto;\` を使用して画像が途切れないようにする場合、画像コンテナの背景色は、コンポーネント全体の背景色(通常は \`#ffffff\`)と一致させるか、透明 (\`transparent\`) に設定し、余白部分の色が浮かないようにしてください。
427
+ 5. CSSセレクタは、可能な限り特定のクラス名を使用し、bodyやhtmlタグへの直接的なスタイル適用は避けてください。
428
+ 6. 画面中央揃えやbodyへのレイアウト指定は禁止です。コンポーネント内部のスタイルのみ記述してください。
429
+
430
+ ### JavaScript
431
+ 1. バニラJavaScriptのみを使用してください。外部ライブラリは禁止です。
432
+ 2. 生成されたHTML要素に対して、必要なインタラクション(クリックイベント、計算、DOM操作など)を実装してください。
433
+ 3. コードは \`document.addEventListener('DOMContentLoaded', () => { ... })\` 内に記述し、DOM読み込み後に実行されるようにしてください。
434
+ 4. エラーハンドリング(try-catch等)を適切に行い、コンソールエラーが出ないように配慮してください。
435
+
436
+ ### 画像リソースのルール(重要)
437
+ 画像(imgタグやbackground-image等)が必要な場合は、架空のパスではなく、以下のルールに従ってください
438
+ 1. 一般的な画像(背景、商品、記事サムネイルなど)
439
+ - 書式: "https://picsum.photos/{width}/{height}?random={unique_id}"
440
+ - {width}, {height} は必要なサイズ(例: 800/600)に置き換える。
441
+ - {unique_id} には要素ごとに異なるランダムな数字(1, 2, 3...)を入れる。
442
+ - 例: <img src="https://picsum.photos/400/300?random=1" alt="記事画像">
443
+
444
+ 2. ユーザープロフィール画像(アバター・アイコン):
445
+ - 書式: "https://i.pravatar.cc/{size}?img={1-70}"
446
+ - {size} はサイズ(例: 150)。imgパラメータには1〜70のランダムな数字を入れる。
447
+ - 例: <img src="https://i.pravatar.cc/150?img=12" alt="ユーザーアイコン" style="border-radius: 50%;">
448
+
449
+ ### 制約条件
450
+ 1. CSSは、外部のライブラリやフレームワーク(Tailwind CSS や Bootstrap等)に依存してはいけません。
451
+ 2. CSS内に外部リソース (@import等) を含めないでください。
452
+ 3. <iframe>, <video>, <audio> の使用は避けてください。
453
+ 4. HTML内に直接 <script> タグを書かず、JavaScriptコードはJSONの 'javascript' キーに分離して出力してください。
454
+
455
+ ## 入力データ
456
+ - ユーザーからの指示: ${request}
457
+ - 対象HTML: ${htmlContent}
458
+
459
+ ## 出力指示子
460
+ - 回答は必ずJSON形式でなければなりません。
461
+ - JSONオブジェクトは 'html', 'css', 'javascript', 'title' の4つのキーのみを持つ必要があります。
462
+ - 'html'の値: 生成されたHTMLコード(文字列)。<script>タグは含めないでください。
463
+ - 'css' の値: 生成された純粋なCSSコード(文字列)。
464
+ - 'javascript'の値: 生成されたJavaScriptコード(文字列)。
465
+ - 'title' の値: 必ず生成してください。「ユーザーからの指示」を要約した、10文字程度の簡潔な日本語のタイトル(例: "ログインフォーム", "商品一覧カード")。空文字は禁止です。
466
+ - JSONを囲む \`\`\`json や \`\`\` のようなMarkdownのコードブロック識別子を絶対に含めないでください。
467
+ - 回答は純粋なJSONオブジェクトのみとしてください。挨拶、説明、その他のテキストは一切不要です。
378
468
  `;
379
469
  }
380
470
  }
381
-
382
- customElements.define('gen-ui', GenerativeUi);
471
+ customElements.define('gen-ui', GenUi);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tosk/gen-ui",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "main": "gen-ui.js",
5
5
  "files": [
6
6
  "gen-ui.js"
@@ -13,6 +13,6 @@
13
13
  "license": "ISC",
14
14
  "description": "",
15
15
  "dependencies": {
16
- "@tosk/gemini-test": "^1.0.0"
16
+ "firebase": "^12.6.0"
17
17
  }
18
18
  }