@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.
- package/gen-ui.js +391 -302
- package/package.json +2 -2
package/gen-ui.js
CHANGED
|
@@ -1,382 +1,471 @@
|
|
|
1
|
-
class
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
.
|
|
43
|
-
.
|
|
44
|
-
.
|
|
45
|
-
.
|
|
46
|
-
.
|
|
47
|
-
.
|
|
48
|
-
.
|
|
49
|
-
|
|
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
|
|
73
|
-
<
|
|
74
|
-
<div class="
|
|
75
|
-
<div
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<
|
|
94
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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(
|
|
151
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
153
|
+
editBtn.addEventListener('click', () => {
|
|
154
|
+
chatWindow.classList.remove('hidden');
|
|
155
|
+
chatInput.focus();
|
|
156
|
+
});
|
|
191
157
|
|
|
192
|
-
|
|
158
|
+
chatCancel.addEventListener('click', () => chatWindow.classList.add('hidden'));
|
|
193
159
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
168
|
+
connectBtn.addEventListener('click', () => this.#handleFileConnect());
|
|
169
|
+
completeBtn.addEventListener('click', () => this.#handleCompletion());
|
|
170
|
+
}
|
|
199
171
|
|
|
172
|
+
async #handleFileConnect() {
|
|
200
173
|
try {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
241
|
-
|
|
229
|
+
async #copyToClipboard() {
|
|
230
|
+
const finalCode = this.#assembleFinalCode('simple-embed');
|
|
231
|
+
await navigator.clipboard.writeText(finalCode.trim());
|
|
242
232
|
}
|
|
243
233
|
|
|
244
|
-
#
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
280
|
+
return `
|
|
281
|
+
<style>${resetCss}${css}</style>
|
|
282
|
+
${html}
|
|
283
|
+
<script>(() => { try { ${javascript} } catch (e) { console.error(e); } })();<\/script>`;
|
|
284
|
+
}
|
|
262
285
|
|
|
263
|
-
#
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
#
|
|
318
|
-
|
|
319
|
-
this.#
|
|
320
|
-
|
|
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
|
-
#
|
|
324
|
-
|
|
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
|
-
#
|
|
340
|
-
this.#
|
|
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
|
-
#
|
|
345
|
-
this.#
|
|
346
|
-
this.#
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
410
|
+
## 命令
|
|
411
|
+
あなたは世界トップクラスのUIエンジニアです。
|
|
412
|
+
「ユーザーからの指示」と、「対象HTML」に基づき、HTML、CSS、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.
|
|
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
|
-
"
|
|
16
|
+
"firebase": "^12.6.0"
|
|
17
17
|
}
|
|
18
18
|
}
|