pgkiln 0.46.0 → 0.48.0
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/db/migrations/108_sql_history.sql +25 -0
- package/db/migrations/109_builder_pins.sql +19 -0
- package/db/migrations/110_sql_charts.sql +23 -0
- package/db/migrations/111_sql_diagram.sql +24 -0
- package/db/migrations/113_rest_objects.sql +49 -0
- package/docs/apex-feature-parity.md +28 -2
- package/docs/guide/03-builder.md +178 -21
- package/docs/guide/08-security.md +19 -1
- package/docs/guide/10-tutorial.md +1 -1
- package/docs/guide/12-development.md +46 -2
- package/docs/guide/13-rest-api.md +74 -9
- package/docs/guide/16-files.md +2 -2
- package/package.json +1 -1
- package/public/builder.css +22 -0
- package/public/data-modeler.css +56 -0
- package/public/data-modeler.js +367 -0
- package/public/palette.js +133 -0
- package/public/worksheet.css +293 -0
- package/public/worksheet.js +669 -0
- package/src/app.ts +4 -0
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/autorest.ts +362 -0
- package/src/builder/data-modeler.ts +382 -0
- package/src/builder/db-users.ts +472 -0
- package/src/builder/launchpad.ts +140 -0
- package/src/builder/object-actions.ts +167 -0
- package/src/builder/object-browser.ts +47 -26
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/sampledata.ts +2 -2
- package/src/builder/scripts.ts +3 -3
- package/src/builder/sql-charts.ts +203 -0
- package/src/builder/sql-tools.ts +49 -0
- package/src/builder/sql.ts +28 -81
- package/src/builder/table-editor.ts +219 -0
- package/src/builder/ui.ts +21 -15
- package/src/builder/unload.ts +8 -4
- package/src/builder/worksheet.ts +592 -0
- package/src/builder/workspaces.ts +5 -3
- package/src/cli/replace.ts +3 -1
- package/src/datamodel.ts +324 -0
- package/src/dbroles.ts +215 -0
- package/src/runtime/autorest.ts +336 -0
- package/src/runtime/rest.ts +4 -4
- package/src/session.ts +2 -0
- package/src/sqlformat.ts +208 -0
- package/src/sqlplan.ts +133 -0
- package/src/sqlscript.ts +6 -2
- package/src/sqlworksheet.ts +278 -0
- package/src/tabledef.ts +226 -0
- package/src/unload.ts +14 -3
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// pgkiln builder: the SQL Workshop's Data Modeler (sprint 98, src/builder/data-modeler.ts, src/datamodel.ts).
|
|
2
|
+
//
|
|
3
|
+
// Progressive enhancement of the server-rendered SVG diagram (the boxes at their saved positions):
|
|
4
|
+
// - drag a box (mouse or touch), or focus it and use the arrow keys (Shift: bigger steps); the foreign key
|
|
5
|
+
// lines follow (fkPath: the same geometry as src/datamodel.ts)
|
|
6
|
+
// - Save (Ctrl+S) posts the positions; Undo (Ctrl+Z) and Redo (Ctrl+Y, Ctrl+Shift+Z); unsaved changes are
|
|
7
|
+
// shown and leaving asks first
|
|
8
|
+
// - Zoom in / out, Fit, Auto layout (the server's automatic positions, data-ax/ay; undoable), a mini-map
|
|
9
|
+
// - Export SVG and PNG of the diagram as it is on the screen, Print
|
|
10
|
+
// Strict CSP: no inline styles; geometry goes into SVG attributes, sizes through the CSSOM.
|
|
11
|
+
(() => {
|
|
12
|
+
'use strict';
|
|
13
|
+
// the data dictionary's Print button
|
|
14
|
+
for (const b of document.querySelectorAll('.dm-dict-actions [data-dm=print]')) {
|
|
15
|
+
b.hidden = false;
|
|
16
|
+
b.addEventListener('click', () => window.print());
|
|
17
|
+
}
|
|
18
|
+
const stage = document.querySelector('[data-dm-stage]');
|
|
19
|
+
const svg = document.getElementById('dm-svg');
|
|
20
|
+
if (!stage || !svg) return;
|
|
21
|
+
const canvas = stage.querySelector('.dm-canvas');
|
|
22
|
+
const toolbar = document.querySelector('.dm-toolbar');
|
|
23
|
+
const saveForm = toolbar.querySelector('.dm-save-form');
|
|
24
|
+
const status = toolbar.querySelector('.dm-status');
|
|
25
|
+
const btn = (name) => toolbar.querySelector(`[data-dm="${name}"]`);
|
|
26
|
+
for (const b of toolbar.querySelectorAll('[data-dm][hidden]')) b.hidden = false;
|
|
27
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
28
|
+
|
|
29
|
+
const boxes = new Map([...svg.querySelectorAll('.dm-box')].map((g) => [g.dataset.name, {
|
|
30
|
+
g, x: Number(g.dataset.x), y: Number(g.dataset.y), w: Number(g.dataset.w), h: Number(g.dataset.h),
|
|
31
|
+
ax: g.dataset.ax === undefined ? null : Number(g.dataset.ax), ay: g.dataset.ay === undefined ? null : Number(g.dataset.ay),
|
|
32
|
+
}]));
|
|
33
|
+
const fks = [...svg.querySelectorAll('.dm-fk')];
|
|
34
|
+
let W = Number(svg.getAttribute('width'));
|
|
35
|
+
let H = Number(svg.getAttribute('height'));
|
|
36
|
+
let zoom = 1;
|
|
37
|
+
|
|
38
|
+
// ------------------------------------------------------------ geometry (as fkPath in src/datamodel.ts)
|
|
39
|
+
function fkPath(a, ay, b, by) {
|
|
40
|
+
const y1 = a.y + ay;
|
|
41
|
+
const y2 = b.y + by;
|
|
42
|
+
let x1, x2, d1, d2, path;
|
|
43
|
+
if (a.x + a.w + 40 <= b.x) {
|
|
44
|
+
[x1, x2, d1, d2] = [a.x + a.w, b.x, 1, -1];
|
|
45
|
+
const mx = Math.round((x1 + x2) / 2);
|
|
46
|
+
path = `M ${x1} ${y1} H ${mx} V ${y2} H ${x2}`;
|
|
47
|
+
} else if (b.x + b.w + 40 <= a.x) {
|
|
48
|
+
[x1, x2, d1, d2] = [a.x, b.x + b.w, -1, 1];
|
|
49
|
+
const mx = Math.round((x1 + x2) / 2);
|
|
50
|
+
path = `M ${x1} ${y1} H ${mx} V ${y2} H ${x2}`;
|
|
51
|
+
} else {
|
|
52
|
+
[x1, x2, d1, d2] = [a.x + a.w, b.x + b.w, 1, 1];
|
|
53
|
+
const lx = Math.max(x1, x2) + (a === b ? 18 : 30);
|
|
54
|
+
const yy = a === b && y1 === y2 ? y2 + 10 : y2;
|
|
55
|
+
path = `M ${x1} ${y1} H ${lx} V ${yy} H ${x2}`;
|
|
56
|
+
}
|
|
57
|
+
const foot = `M ${x1 + d1 * 14} ${y1} L ${x1} ${y1 - 7} M ${x1 + d1 * 14} ${y1} L ${x1} ${y1 + 7} M ${x1 + d1 * 14} ${y1 - 7} V ${y1 + 7}`;
|
|
58
|
+
const bar = `M ${x2 + d2 * 10} ${y2 - 7} V ${y2 + 7}`;
|
|
59
|
+
return { path, ends: `${foot} ${bar}`, circle: { cx: x2 + d2 * 20, cy: y2 } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function draw() {
|
|
63
|
+
for (const b of boxes.values()) b.g.setAttribute('transform', `translate(${b.x} ${b.y})`);
|
|
64
|
+
for (const l of fks) {
|
|
65
|
+
const a = boxes.get(l.dataset.from);
|
|
66
|
+
const b = boxes.get(l.dataset.to);
|
|
67
|
+
if (!a || !b) continue;
|
|
68
|
+
const p = fkPath(a, Number(l.dataset.fy), b, Number(l.dataset.ty));
|
|
69
|
+
l.querySelector('.dm-line').setAttribute('d', p.path);
|
|
70
|
+
l.querySelector('.dm-end').setAttribute('d', p.ends);
|
|
71
|
+
const c = l.querySelector('.dm-opt');
|
|
72
|
+
if (c) {
|
|
73
|
+
c.setAttribute('cx', String(p.circle.cx));
|
|
74
|
+
c.setAttribute('cy', String(p.circle.cy));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const all = [...boxes.values()];
|
|
78
|
+
W = Math.max(400, ...all.map((b) => b.x + b.w + 60));
|
|
79
|
+
H = Math.max(240, ...all.map((b) => b.y + b.h + 40));
|
|
80
|
+
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
81
|
+
sizeSvg();
|
|
82
|
+
}
|
|
83
|
+
function sizeSvg() {
|
|
84
|
+
svg.setAttribute('width', String(Math.round(W * zoom)));
|
|
85
|
+
svg.setAttribute('height', String(Math.round(H * zoom)));
|
|
86
|
+
updateMinimap();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ------------------------------------------------------------ undo, redo, unsaved changes
|
|
90
|
+
const snapshot = () => [...boxes].map(([name, b]) => [name, b.x, b.y]);
|
|
91
|
+
const restore = (snap) => {
|
|
92
|
+
for (const [name, x, y] of snap) {
|
|
93
|
+
const b = boxes.get(name);
|
|
94
|
+
if (b) [b.x, b.y] = [x, y];
|
|
95
|
+
}
|
|
96
|
+
draw();
|
|
97
|
+
};
|
|
98
|
+
const undo = [];
|
|
99
|
+
const redo = [];
|
|
100
|
+
let saved = JSON.stringify(snapshot());
|
|
101
|
+
const dirty = () => JSON.stringify(snapshot()) !== saved;
|
|
102
|
+
function changed() {
|
|
103
|
+
btn('undo').disabled = !undo.length;
|
|
104
|
+
btn('redo').disabled = !redo.length;
|
|
105
|
+
const d = dirty();
|
|
106
|
+
btn('save').classList.toggle('btn-hot', d);
|
|
107
|
+
status.textContent = d ? 'Unsaved changes' : '';
|
|
108
|
+
}
|
|
109
|
+
function remember(before) {
|
|
110
|
+
undo.push(before);
|
|
111
|
+
if (undo.length > 200) undo.shift();
|
|
112
|
+
redo.length = 0;
|
|
113
|
+
changed();
|
|
114
|
+
}
|
|
115
|
+
btn('undo').addEventListener('click', () => {
|
|
116
|
+
if (!undo.length) return;
|
|
117
|
+
redo.push(snapshot());
|
|
118
|
+
restore(undo.pop());
|
|
119
|
+
changed();
|
|
120
|
+
});
|
|
121
|
+
btn('redo').addEventListener('click', () => {
|
|
122
|
+
if (!redo.length) return;
|
|
123
|
+
undo.push(snapshot());
|
|
124
|
+
restore(redo.pop());
|
|
125
|
+
changed();
|
|
126
|
+
});
|
|
127
|
+
window.addEventListener('beforeunload', (e) => {
|
|
128
|
+
if (!dirty()) return;
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
e.returnValue = '';
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
async function save() {
|
|
134
|
+
const body = new URLSearchParams({
|
|
135
|
+
__csrf: saveForm.querySelector('input[name=__csrf]').value,
|
|
136
|
+
format: 'json',
|
|
137
|
+
positions: JSON.stringify([...boxes].map(([name, b]) => ({ name, x: Math.round(b.x), y: Math.round(b.y) }))),
|
|
138
|
+
});
|
|
139
|
+
status.textContent = 'Saving…';
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(saveForm.action, { method: 'POST', body, credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
142
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
143
|
+
saved = JSON.stringify(snapshot());
|
|
144
|
+
changed();
|
|
145
|
+
status.textContent = 'Saved';
|
|
146
|
+
} catch (e) {
|
|
147
|
+
status.textContent = `Not saved (${e.message}): try again`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
btn('save').addEventListener('click', save);
|
|
151
|
+
saveForm.addEventListener('submit', (e) => {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
save();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ------------------------------------------------------------ moving boxes
|
|
157
|
+
const moveBox = (b, x, y) => {
|
|
158
|
+
b.x = Math.max(0, Math.min(20000, Math.round(x)));
|
|
159
|
+
b.y = Math.max(0, Math.min(20000, Math.round(y)));
|
|
160
|
+
draw();
|
|
161
|
+
};
|
|
162
|
+
let dragged = false;
|
|
163
|
+
for (const b of boxes.values()) {
|
|
164
|
+
b.g.addEventListener('pointerdown', (e) => {
|
|
165
|
+
if (e.button !== 0) return;
|
|
166
|
+
const start = { x: e.clientX, y: e.clientY, bx: b.x, by: b.y };
|
|
167
|
+
const before = snapshot();
|
|
168
|
+
let moved = false;
|
|
169
|
+
const move = (ev) => {
|
|
170
|
+
const dx = (ev.clientX - start.x) / zoom;
|
|
171
|
+
const dy = (ev.clientY - start.y) / zoom;
|
|
172
|
+
if (!moved && Math.abs(dx) + Math.abs(dy) < 4) return;
|
|
173
|
+
if (!moved) {
|
|
174
|
+
moved = true;
|
|
175
|
+
// captured only once it moves: a plain click still reaches the box's name (its link)
|
|
176
|
+
b.g.setPointerCapture(ev.pointerId);
|
|
177
|
+
b.g.classList.add('dm-dragging');
|
|
178
|
+
// on top of the others
|
|
179
|
+
b.g.parentNode.append(b.g);
|
|
180
|
+
}
|
|
181
|
+
moveBox(b, start.bx + dx, start.by + dy);
|
|
182
|
+
};
|
|
183
|
+
const up = () => {
|
|
184
|
+
b.g.removeEventListener('pointermove', move);
|
|
185
|
+
b.g.removeEventListener('pointerup', up);
|
|
186
|
+
b.g.removeEventListener('pointercancel', up);
|
|
187
|
+
b.g.classList.remove('dm-dragging');
|
|
188
|
+
if (moved) {
|
|
189
|
+
dragged = true;
|
|
190
|
+
setTimeout(() => (dragged = false), 0);
|
|
191
|
+
remember(before);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
b.g.addEventListener('pointermove', move);
|
|
195
|
+
b.g.addEventListener('pointerup', up);
|
|
196
|
+
b.g.addEventListener('pointercancel', up);
|
|
197
|
+
});
|
|
198
|
+
b.g.addEventListener('dragstart', (e) => e.preventDefault());
|
|
199
|
+
// a drag that ends over the box's name doesn't open the table
|
|
200
|
+
b.g.addEventListener('click', (e) => {
|
|
201
|
+
if (dragged) e.preventDefault();
|
|
202
|
+
}, true);
|
|
203
|
+
b.g.addEventListener('keydown', (e) => {
|
|
204
|
+
if (e.target !== b.g) return;
|
|
205
|
+
const step = e.shiftKey ? 50 : 10;
|
|
206
|
+
const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
|
|
207
|
+
if (!d) return;
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
const before = snapshot();
|
|
210
|
+
moveBox(b, b.x + d[0], b.y + d[1]);
|
|
211
|
+
remember(before);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ------------------------------------------------------------ auto layout
|
|
216
|
+
const auto = btn('auto');
|
|
217
|
+
auto.closest('form').addEventListener('submit', (e) => {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
const before = snapshot();
|
|
220
|
+
for (const b of boxes.values()) if (b.ax !== null) [b.x, b.y] = [b.ax, b.ay];
|
|
221
|
+
draw();
|
|
222
|
+
remember(before);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ------------------------------------------------------------ zoom
|
|
226
|
+
const setZoom = (z) => {
|
|
227
|
+
zoom = Math.max(0.2, Math.min(3, Math.round(z * 100) / 100));
|
|
228
|
+
sizeSvg();
|
|
229
|
+
status.textContent = `${Math.round(zoom * 100)}%${dirty() ? ' · unsaved changes' : ''}`;
|
|
230
|
+
};
|
|
231
|
+
btn('zoom-in').addEventListener('click', () => setZoom(zoom * 1.25));
|
|
232
|
+
btn('zoom-out').addEventListener('click', () => setZoom(zoom / 1.25));
|
|
233
|
+
btn('fit').addEventListener('click', () => setZoom(Math.min(2, (canvas.clientWidth - 4) / W, (canvas.clientHeight - 4) / H)));
|
|
234
|
+
canvas.addEventListener('wheel', (e) => {
|
|
235
|
+
if (!e.ctrlKey) return;
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
setZoom(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
|
|
238
|
+
}, { passive: false });
|
|
239
|
+
|
|
240
|
+
// ------------------------------------------------------------ mini-map
|
|
241
|
+
const content = svg.querySelector('.dm-content');
|
|
242
|
+
content.id = 'dm-content';
|
|
243
|
+
let mini = null;
|
|
244
|
+
let view = null;
|
|
245
|
+
function updateMinimap() {
|
|
246
|
+
if (!mini) return;
|
|
247
|
+
mini.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
248
|
+
view.setAttribute('x', String(canvas.scrollLeft / zoom));
|
|
249
|
+
view.setAttribute('y', String(canvas.scrollTop / zoom));
|
|
250
|
+
view.setAttribute('width', String(Math.min(W, canvas.clientWidth / zoom)));
|
|
251
|
+
view.setAttribute('height', String(Math.min(H, canvas.clientHeight / zoom)));
|
|
252
|
+
}
|
|
253
|
+
const mm = btn('minimap');
|
|
254
|
+
mm.setAttribute('aria-pressed', 'false');
|
|
255
|
+
mm.addEventListener('click', () => {
|
|
256
|
+
if (mini) {
|
|
257
|
+
mini.remove();
|
|
258
|
+
mini = null;
|
|
259
|
+
mm.setAttribute('aria-pressed', 'false');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
mini = document.createElementNS(NS, 'svg');
|
|
263
|
+
mini.setAttribute('class', 'dm-minimap');
|
|
264
|
+
mini.setAttribute('aria-hidden', 'true');
|
|
265
|
+
mini.setAttribute('preserveAspectRatio', 'xMinYMin meet');
|
|
266
|
+
const bg = document.createElementNS(NS, 'rect');
|
|
267
|
+
bg.setAttribute('class', 'dm-minimap-bg');
|
|
268
|
+
bg.setAttribute('width', '100%');
|
|
269
|
+
bg.setAttribute('height', '100%');
|
|
270
|
+
const use = document.createElementNS(NS, 'use');
|
|
271
|
+
use.setAttribute('href', '#dm-content');
|
|
272
|
+
view = document.createElementNS(NS, 'rect');
|
|
273
|
+
view.setAttribute('class', 'dm-minimap-view');
|
|
274
|
+
mini.append(bg, use, view);
|
|
275
|
+
stage.append(mini);
|
|
276
|
+
mm.setAttribute('aria-pressed', 'true');
|
|
277
|
+
// click or drag in the mini-map: that part of the diagram in the middle
|
|
278
|
+
const go = (e) => {
|
|
279
|
+
const r = mini.getBoundingClientRect();
|
|
280
|
+
const scale = Math.min(r.width / W, r.height / H);
|
|
281
|
+
const x = (e.clientX - r.left) / scale;
|
|
282
|
+
const y = (e.clientY - r.top) / scale;
|
|
283
|
+
canvas.scrollLeft = x * zoom - canvas.clientWidth / 2;
|
|
284
|
+
canvas.scrollTop = y * zoom - canvas.clientHeight / 2;
|
|
285
|
+
};
|
|
286
|
+
mini.addEventListener('pointerdown', (e) => {
|
|
287
|
+
mini.setPointerCapture(e.pointerId);
|
|
288
|
+
go(e);
|
|
289
|
+
const move = (ev) => go(ev);
|
|
290
|
+
mini.addEventListener('pointermove', move);
|
|
291
|
+
mini.addEventListener('pointerup', () => mini.removeEventListener('pointermove', move), { once: true });
|
|
292
|
+
});
|
|
293
|
+
updateMinimap();
|
|
294
|
+
});
|
|
295
|
+
canvas.addEventListener('scroll', updateMinimap);
|
|
296
|
+
window.addEventListener('resize', updateMinimap);
|
|
297
|
+
|
|
298
|
+
// ------------------------------------------------------------ export and print
|
|
299
|
+
const fileName = (stage.dataset.name || 'diagram').replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 80);
|
|
300
|
+
/** The diagram as a standalone SVG document (its presentation attributes carry the colours). */
|
|
301
|
+
function svgText() {
|
|
302
|
+
const copy = svg.cloneNode(true);
|
|
303
|
+
copy.removeAttribute('id');
|
|
304
|
+
copy.setAttribute('width', String(W));
|
|
305
|
+
copy.setAttribute('height', String(H));
|
|
306
|
+
copy.querySelector('.dm-content').removeAttribute('id');
|
|
307
|
+
for (const a of [...copy.querySelectorAll('a')]) a.replaceWith(...a.childNodes);
|
|
308
|
+
for (const g of copy.querySelectorAll('[tabindex]')) g.removeAttribute('tabindex');
|
|
309
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(copy)}\n`;
|
|
310
|
+
}
|
|
311
|
+
const download = (blob, name) => {
|
|
312
|
+
const a = document.createElement('a');
|
|
313
|
+
a.href = URL.createObjectURL(blob);
|
|
314
|
+
a.download = name;
|
|
315
|
+
document.body.append(a);
|
|
316
|
+
a.click();
|
|
317
|
+
a.remove();
|
|
318
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
319
|
+
};
|
|
320
|
+
btn('svg').addEventListener('click', (e) => {
|
|
321
|
+
e.preventDefault();
|
|
322
|
+
download(new Blob([svgText()], { type: 'image/svg+xml' }), `${fileName}.svg`);
|
|
323
|
+
});
|
|
324
|
+
btn('png').addEventListener('click', () => {
|
|
325
|
+
const img = new Image();
|
|
326
|
+
img.onload = () => {
|
|
327
|
+
const scale = 2;
|
|
328
|
+
const c = document.createElement('canvas');
|
|
329
|
+
c.width = Math.round(W * scale);
|
|
330
|
+
c.height = Math.round(H * scale);
|
|
331
|
+
const ctx = c.getContext('2d');
|
|
332
|
+
ctx.scale(scale, scale);
|
|
333
|
+
ctx.drawImage(img, 0, 0, W, H);
|
|
334
|
+
c.toBlob((blob) => blob && download(blob, `${fileName}.png`), 'image/png');
|
|
335
|
+
};
|
|
336
|
+
img.onerror = () => (status.textContent = 'The PNG could not be made: export SVG instead');
|
|
337
|
+
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText())}`;
|
|
338
|
+
});
|
|
339
|
+
btn('print').addEventListener('click', () => {
|
|
340
|
+
const z = zoom;
|
|
341
|
+
setZoom(1);
|
|
342
|
+
window.print();
|
|
343
|
+
setZoom(z);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
document.addEventListener('keydown', (e) => {
|
|
347
|
+
const key = e.key.toLowerCase();
|
|
348
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
349
|
+
if (!mod) return;
|
|
350
|
+
if (key === 's') {
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
save();
|
|
353
|
+
} else if (/^(input|textarea|select)$/i.test(document.activeElement?.tagName || '')) {
|
|
354
|
+
return;
|
|
355
|
+
} else if (key === 'z' && !e.shiftKey) {
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
btn('undo').click();
|
|
358
|
+
} else if (key === 'y' || (key === 'z' && e.shiftKey)) {
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
btn('redo').click();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
canvas.classList.add('dm-live');
|
|
365
|
+
changed();
|
|
366
|
+
status.textContent = '';
|
|
367
|
+
})();
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// pgkiln builder: the search (Ctrl+K) and the SQL Workshop Launchpad (sprint 96, src/builder/launchpad.ts).
|
|
2
|
+
//
|
|
3
|
+
// - Ctrl+K (⌘K) on any builder page, or focusing the header's search field, opens a search dialog: tools,
|
|
4
|
+
// applications, pages and database objects by name (GET /builder/palette?format=json), arrow keys and Enter
|
|
5
|
+
// to open one. Without JavaScript the header's field posts to the results page.
|
|
6
|
+
// - SQL Workshop pages are remembered as "Recently visited" in this browser and listed on the home page.
|
|
7
|
+
// - The home page's tool groups (Development, Administration, Monitoring) become tabs.
|
|
8
|
+
// Results are built as text nodes, never parsed as HTML.
|
|
9
|
+
(() => {
|
|
10
|
+
'use strict';
|
|
11
|
+
const store = {
|
|
12
|
+
get(k) { try { return JSON.parse(localStorage.getItem(`pgkiln.${k}`) || 'null'); } catch { return null; } },
|
|
13
|
+
set(k, v) { try { localStorage.setItem(`pgkiln.${k}`, JSON.stringify(v)); } catch { /* private mode */ } },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// ------------------------------------------------------------ recently visited SQL Workshop pages
|
|
17
|
+
const path = location.pathname;
|
|
18
|
+
if (/^\/builder\/sql\/./.test(path) && !/\/(panel|navigator|action)$/.test(path)) {
|
|
19
|
+
const title = document.title.replace(/ · Builder$/, '');
|
|
20
|
+
const href = path + location.search;
|
|
21
|
+
const list = (store.get('sw.recent') || []).filter((r) => r && r.href !== href);
|
|
22
|
+
list.unshift({ href, title });
|
|
23
|
+
store.set('sw.recent', list.slice(0, 8));
|
|
24
|
+
}
|
|
25
|
+
const recent = document.querySelector('[data-lp-recent]');
|
|
26
|
+
if (recent) {
|
|
27
|
+
const list = store.get('sw.recent') || [];
|
|
28
|
+
for (const r of list) {
|
|
29
|
+
if (typeof r?.href !== 'string' || !r.href.startsWith('/builder/')) continue;
|
|
30
|
+
const li = document.createElement('li');
|
|
31
|
+
const a = document.createElement('a');
|
|
32
|
+
a.href = r.href;
|
|
33
|
+
a.textContent = String(r.title || r.href);
|
|
34
|
+
li.append(a);
|
|
35
|
+
recent.append(li);
|
|
36
|
+
}
|
|
37
|
+
recent.hidden = !recent.children.length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ------------------------------------------------------------ the Launchpad's tabs
|
|
41
|
+
const tabs = document.querySelector('.lp-tabs');
|
|
42
|
+
if (tabs) {
|
|
43
|
+
tabs.hidden = false;
|
|
44
|
+
const show = (g) => {
|
|
45
|
+
for (const b of tabs.querySelectorAll('[data-lp-tab]')) b.setAttribute('aria-selected', String(b.dataset.lpTab === g));
|
|
46
|
+
for (const sct of document.querySelectorAll('[data-lp-group]')) sct.hidden = sct.dataset.lpGroup !== g;
|
|
47
|
+
store.set('lp.tab', g);
|
|
48
|
+
};
|
|
49
|
+
show(store.get('lp.tab') || 'development');
|
|
50
|
+
tabs.addEventListener('click', (e) => { const b = e.target.closest('[data-lp-tab]'); if (b) show(b.dataset.lpTab); });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ------------------------------------------------------------ the search dialog
|
|
54
|
+
let dlg = null, input, listbox, status, items = [], active = -1, timer = 0, seq = 0;
|
|
55
|
+
const build = () => {
|
|
56
|
+
dlg = document.createElement('dialog');
|
|
57
|
+
dlg.className = 'palette';
|
|
58
|
+
dlg.setAttribute('aria-label', 'Search the builder');
|
|
59
|
+
input = Object.assign(document.createElement('input'), { type: 'search', placeholder: 'Tables, views, functions, applications, pages, tools…', autocomplete: 'off', id: 'palette-q' });
|
|
60
|
+
input.setAttribute('role', 'combobox');
|
|
61
|
+
input.setAttribute('aria-expanded', 'true');
|
|
62
|
+
input.setAttribute('aria-controls', 'palette-list');
|
|
63
|
+
input.setAttribute('aria-label', 'Search');
|
|
64
|
+
listbox = Object.assign(document.createElement('ul'), { id: 'palette-list', className: 'palette-list' });
|
|
65
|
+
listbox.setAttribute('role', 'listbox');
|
|
66
|
+
status = Object.assign(document.createElement('p'), { className: 'palette-status muted small' });
|
|
67
|
+
status.setAttribute('aria-live', 'polite');
|
|
68
|
+
dlg.append(input, listbox, status);
|
|
69
|
+
document.body.append(dlg);
|
|
70
|
+
input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(query, 150); });
|
|
71
|
+
input.addEventListener('keydown', (e) => {
|
|
72
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
if (items.length) select((active + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length);
|
|
75
|
+
} else if (e.key === 'Enter') {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
const r = items[active] || items[0];
|
|
78
|
+
if (r) location.href = r.href;
|
|
79
|
+
else if (input.value.trim()) location.href = `/builder/palette?q=${encodeURIComponent(input.value.trim())}`;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
listbox.addEventListener('click', (e) => { const li = e.target.closest('[role=option]'); if (li) location.href = items[Number(li.dataset.i)].href; });
|
|
83
|
+
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
|
|
84
|
+
};
|
|
85
|
+
const select = (i) => {
|
|
86
|
+
active = i;
|
|
87
|
+
for (const li of listbox.children) li.setAttribute('aria-selected', String(Number(li.dataset.i) === i));
|
|
88
|
+
const li = listbox.children[i];
|
|
89
|
+
if (li) { input.setAttribute('aria-activedescendant', li.id); li.scrollIntoView({ block: 'nearest' }); }
|
|
90
|
+
};
|
|
91
|
+
const query = async () => {
|
|
92
|
+
const q = input.value.trim();
|
|
93
|
+
const mine = ++seq;
|
|
94
|
+
if (!q) { listbox.replaceChildren(); items = []; status.textContent = 'Type to search.'; return; }
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(`/builder/palette?format=json&q=${encodeURIComponent(q)}`, { credentials: 'same-origin' });
|
|
97
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
98
|
+
const data = await res.json();
|
|
99
|
+
if (mine !== seq) return; // a later query is on its way
|
|
100
|
+
items = data.results || [];
|
|
101
|
+
listbox.replaceChildren(...items.map((r, i) => {
|
|
102
|
+
const li = document.createElement('li');
|
|
103
|
+
li.setAttribute('role', 'option');
|
|
104
|
+
li.id = `palette-${i}`;
|
|
105
|
+
li.dataset.i = String(i);
|
|
106
|
+
const label = Object.assign(document.createElement('strong'), { textContent: r.label });
|
|
107
|
+
const detail = Object.assign(document.createElement('small'), { className: 'muted', textContent: ` ${r.kind}${r.detail ? ` · ${r.detail}` : ''}` });
|
|
108
|
+
li.append(label, detail);
|
|
109
|
+
return li;
|
|
110
|
+
}));
|
|
111
|
+
status.textContent = items.length ? `${items.length} found.` : 'Nothing found.';
|
|
112
|
+
select(items.length ? 0 : -1);
|
|
113
|
+
} catch {
|
|
114
|
+
status.textContent = 'The search failed.';
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const open = (text = '') => {
|
|
118
|
+
if (!dlg) build();
|
|
119
|
+
if (!dlg.open) dlg.showModal();
|
|
120
|
+
input.value = text;
|
|
121
|
+
input.focus();
|
|
122
|
+
query();
|
|
123
|
+
};
|
|
124
|
+
document.addEventListener('keydown', (e) => {
|
|
125
|
+
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
open();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
const field = document.querySelector('form[data-palette] input[name=q]');
|
|
131
|
+
field?.addEventListener('focus', () => { const v = field.value; field.blur(); open(v); });
|
|
132
|
+
window.pgkilnPalette = { open };
|
|
133
|
+
})();
|