diff --git a/AGENTS.md b/AGENTS.md
index 485d0e3..329bece 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -63,6 +63,11 @@ The product is the generated class. It must:
in `src/ui/store.js`, the same list in `preview.html`, which takes any
host only when the editor frames it). Any other host comes from the UI5
field, typed by the user. Never widen the list for a link.
+- An edit on the selection (delete, duplicate, move, wrap, select the
+ parent) is written once, in `src/ui/actions.js`, and works on every
+ selected control; the keys, the command palette, the canvas toolbar and
+ the context menu (`src/ui/context.js`) call it. A question to the user goes through `choose( )` in
+ `src/ui/dom.js`, never the browser's `confirm( )`.
- The preview module (`src/preview/init.js`) runs inside UI5 1.71: ES5
style, `sap.ui.define`, no API newer than 1.71.
diff --git a/README.md b/README.md
index ac83794..f52f316 100644
--- a/README.md
+++ b/README.md
@@ -97,7 +97,9 @@ Also in the editor:
attributes the data, `client->_event( )` the event names. Export writes
the chain back into that class and leaves every other line as it was;
new attributes go to the end of the public section, new events get a
- `WHEN` branch in `on_event`. A view attribute the editor cannot edit
+ `WHEN` branch in `on_event`. The *Changes* tab next to the class shows,
+ line by line, what the export changes in the class as it was opened. A
+ view attribute the editor cannot edit
(`client->check_app_prev_stack( )`, a `COND #( )`, an `id`) is kept as
ABAP on its control and written back unchanged; the dialog after the
import lists it, and lists what could not be imported at all (a public
@@ -113,19 +115,63 @@ Also in the editor:
extension (`vscode://abap2ui5.abap2ui5/open`), which opens it as an
unsaved `.clas.abap` and offers to run it.
- **Save / Open** stores the design as JSON so you can continue later. The
- design is also kept in the browser between visits; what New, Open or
- Import replaced is under *Recent designs*. **Copy share link** puts the
- whole design into a URL - open it anywhere, the editor loads it.
-- **Undo/redo** with Ctrl+Z and Ctrl+Shift+Z, **Delete** or **Backspace**
- removes the selection, **Ctrl+D** duplicates it, **Ctrl+C / Ctrl+X /
- Ctrl+V** copy, cut and paste it (also into another design or tab),
- **Alt+↑** selects the parent, **Escape** the view, **Ctrl+S** saves the
- design. In the outline the arrow keys walk the tree.
+ design is also kept in the browser between visits (when the browser
+ refuses to keep it, the editor says so; when another tab of the editor
+ saves over it, too, with a button to load that one). *Save in browser*
+ keeps it there under its class name, next to others; *Designs in this
+ browser* lists those, and what New, Open or Import replaced. **Copy share
+ link** puts the whole design into a URL - open it anywhere, the editor
+ loads it (a link of more than 8 kB says so: some chats and mail cut it).
+- **Undo/redo** with Ctrl+Z and Ctrl+Shift+Z - each step brings back the
+ control that was selected. **Delete** or **Backspace** removes the
+ selection, **Ctrl+D** duplicates it, **Ctrl+↑ / Ctrl+↓** move it among
+ its siblings, **Ctrl+C / Ctrl+X / Ctrl+V** copy, cut and paste it (also
+ into another design or tab), **Alt+↑** selects the parent, **Escape**
+ the view, **Ctrl+S** saves the design. In the outline the arrow keys walk
+ the tree and a letter jumps to the next control of that name; in the
+ control search Enter inserts the first match; the tabs of the drawer go
+ by arrow key too. **?** lists every shortcut.
+- **Several controls at once**: Ctrl+click (or Shift+click) on the canvas,
+ in the outline or in the code adds a control to the selection. Delete,
+ duplicate, move, copy and cut act on all of them; *Wrap in…* puts
+ controls side by side into a new VBox, HBox, FlexBox or Panel. Selected
+ rows of the outline drag together.
+- A **right-click** on a control - on the canvas or in the outline, or
+ Shift+F10 - opens its context menu: move, copy, cut, paste, duplicate,
+ wrap, delete, and *Copy properties* / *Paste properties*, which set the
+ properties of one control on others that have them.
+- A **double-click** on a control on the canvas edits its text right
+ there (text, title, header text, …): Enter keeps it, Escape takes it
+ back.
+- *Controls* lists the controls inserted last first, under *Recently
+ used*. An empty container gets room and a dashed frame on the canvas, so
+ there is something to drop into.
+- The **outline filter** shows the controls whose type or text matches, and
+ the way down to them.
- A drop on the canvas lands before, into or after the control under the
pointer: the upper quarter is before, the lower after. The panels on the
left and right can be dragged wider.
- **Checks** lists what would break the class or the view, and inputs
- whose entry goes nowhere.
+ whose entry goes nowhere. A click on a line selects the control and
+ focuses the property it is about; where there is one fix, a button does
+ it: create the attribute a binding names, remove an unused one, reset a
+ value UI5 would refuse, build the template of a bound list, bind an input
+ to a new attribute.
+- **Properties** show the default of the canvas's UI5 where nothing is
+ set, and an event field offers the names the design raises already - two
+ buttons, one `WHEN` branch.
+- **Data**: attributes and fields move up and down (their order is the
+ order of the declarations), and *Paste rows…* takes sample rows copied
+ from a spreadsheet or CSV - with a header line, the columns go to the
+ fields of the same name, and a new name becomes a new field.
+- **Preview** mode lists the events the view raised, with what the user
+ had changed in the model by then - what `on_event` would read.
+- The editor itself is light, dark or as the system (◐ in the bar at the
+ top); the canvas keeps the UI5 theme chosen for it. The canvas zooms out
+ to 75% or 50% for a large view at a glance - the device width stays the
+ width the view gets.
+- The outline marks controls bound to data (●) and controls that raise an
+ event (⚡); *Copy* above the code copies what the tab shows.
- The canvas can show a phone or tablet width, the Horizon, Quartz or
Belize themes, and any UI5 version. Enter `1.71.80` to see the view on the
oldest release abap2UI5 supports.
@@ -246,9 +292,10 @@ runtime. Run it when the palette gains a control.
| `src/model.js` | The design document and every edit on it |
| `src/abap.js` | Design → the ABAP class and its abapGit files |
| `src/xml.js` | Design → XML view (preview, export), and XML view → design (import) |
+| `src/diff.js` | The line diff of the *Changes* tab |
| `src/app.js` | The editor UI: wires the panels and renders them on every change |
| `src/ui/store.js` | The state, commit( ) with undo and the browser store, selection |
-| `src/ui/*.js` | One module per panel: insert, layers, inspector, data, code, preview, palette, files, keys, edit (drop and clipboard) |
+| `src/ui/*.js` | One module per panel: insert, layers, inspector, data, code, preview, palette, files, keys, edit (drop and clipboard), actions (the edits on the selection, for keys, palette, canvas toolbar and context menu), context (the context menu), inline (a text edited on the canvas) |
| `src/preview/init.js` | The preview frame (a UI5 module) |
| `tools/` | Dev server, metadata dump, class generation, runtime verification |
| `test/` | Unit tests, fixtures, the builder model, browser tests |
diff --git a/index.html b/index.html
index 999c083..2aaf524 100644
--- a/index.html
+++ b/index.html
@@ -31,11 +31,13 @@
New from template…
Open design (.json)…
Save design (.json)
+ Save in browser
Open ABAP class…
Import UI5 XML view…
- Recent designs…
+ Designs in this browser…
+ ◐
↶
↷
+
+ 100%
+ 75%
+ 50%
+
Horizon
Quartz Light
@@ -112,14 +122,17 @@
+
ABAP class
XML view
+ Changes
+
Copy
diff --git a/preview.html b/preview.html
index 1853191..96ee704 100644
--- a/preview.html
+++ b/preview.html
@@ -9,6 +9,9 @@
#content { height: 100%; }
.dsg-hover { outline: 1px dashed #0064d9 !important; outline-offset: -1px; }
.dsg-sel { outline: 2px solid #0064d9 !important; outline-offset: -2px; }
+ .dsg-empty { min-height: 2.75rem !important; min-width: 5rem; box-sizing: border-box; outline: 1px dashed #8396a8; outline-offset: -1px;
+ background: repeating-linear-gradient(-45deg, transparent 0 6px, rgba(131, 150, 168, 0.08) 6px 12px) !important; }
+ .dsg-also { outline: 2px solid #0064d9 !important; outline-offset: -2px; background-color: rgba(0, 100, 217, 0.05) !important; }
.dsg-drop { outline: 2px dashed #0064d9 !important; outline-offset: -2px; background-color: rgba(0, 100, 217, 0.06) !important; }
.dsg-drop-before { box-shadow: 0 -3px 0 0 #0064d9 !important; }
.dsg-drop-after { box-shadow: 0 3px 0 0 #0064d9 !important; }
diff --git a/src/app.js b/src/app.js
index e6e04d2..01cf42c 100644
--- a/src/app.js
+++ b/src/app.js
@@ -8,21 +8,21 @@
// design document (model.js) in src/ui/store.js; every edit goes through
// commit( ), which records undo, saves to localStorage and tells this
// module, which renders the panels (src/ui/*.js).
-import { validate, checkClassName } from './model.js';
+import { validate, checkClassName, normalizeDesign } from './model.js';
import { generateClass, fileName } from './abap.js';
import { generateClassSource } from './clas.js';
import { toXml } from './xml.js';
-import { state, on, loadPrefs, savePrefs, loadDesign, designFromLink, replaceDesign, commit, undo, redo, select, showPanel, labelOfSelection } from './ui/store.js';
+import { state, on, loadPrefs, savePrefs, loadDesign, designFromLink, replaceDesign, commit, undo, redo, select, showPanel, labelOfSelection, isUi5Url, onOtherTab } from './ui/store.js';
import { $, toast, download } from './ui/dom.js';
-import { renderInsert, renderInsertTarget } from './ui/insert.js';
+import { renderInsert, renderInsertTarget, onInsertKey } from './ui/insert.js';
import { renderLayers, onLayersKey, clearDropMarks, renderCrumbs } from './ui/layers.js';
import { renderInspector } from './ui/inspector.js';
import { renderData, renderIssues } from './ui/data.js';
-import { renderCode, highlightCode, setCode, setView, copyText } from './ui/code.js';
-import { loadPreview, postPreview, sendPreview } from './ui/preview.js';
-import { handleDrop, copySelection, pasteSelection, addControlByName } from './ui/edit.js';
+import { renderCode, highlightCode, setCode, setView, copyText, codeText } from './ui/code.js';
+import { loadPreview, postPreview, sendPreview, renderEventLog } from './ui/preview.js';
+import { handleDrop, copySelection, pasteSelection, addControlByName, addControl } from './ui/edit.js';
import { openPalette } from './ui/palette.js';
-import { newDialog, importDialog, openClassDialog, openClassFromUrl, recentDialog, shareLink, openFile, saveDesign, exportZip, openInPlayground, openInVscode } from './ui/files.js';
+import { newDialog, importDialog, openClassDialog, openClassFromUrl, recentDialog, shareLink, openFile, saveDesign, saveToBrowser, exportZip, openInPlayground, openInVscode } from './ui/files.js';
import { onKey } from './ui/keys.js';
@@ -47,6 +47,16 @@ function wireMenu(button, menu) {
}
+// the editor's own appearance: as the system, light or dark - the canvas
+// keeps the UI5 theme chosen for it
+const APPEARANCE = { '': 'as the system', light: 'light', dark: 'dark' };
+function applyAppearance() {
+ const a = state.prefs.appearance || '';
+ if (a) document.documentElement.dataset.theme = a; else delete document.documentElement.dataset.theme;
+ $('#btnTheme').title = `Appearance of the editor: ${APPEARANCE[a]} - click for ${APPEARANCE[{ '': 'light', light: 'dark', dark: '' }[a]]}`;
+ $('#btnTheme').textContent = { '': '◐', light: '☀', dark: '☾' }[a];
+}
+
function applyWidths() {
const ws = $('.workspace');
for (const [key, prop] of [['drawerW', '--drawer-w'], ['inspectorW', '--inspector-w']]) {
@@ -98,25 +108,47 @@ function renderAll(opts = {}) {
cn.classList.toggle('invalid', !!checkClassName(d.className));
if (document.activeElement !== $('#description')) $('#description').value = d.description || '';
$('#abapRelease').value = d.release || '';
- renderLayers();
+ // what is not on screen renders when it comes on screen (renderPanel,
+ // setView) - the outline, the data and the class of a large design are
+ // the cost of every keystroke otherwise
+ if (state.panel === 'layers') renderLayers();
// keep the input being typed into: re-rendering its panel would drop it
if (!opts.keepFocus || !$('#inspector').contains(document.activeElement)) renderInspector();
- if (!opts.keepFocus || !$('#dataPanel').contains(document.activeElement)) renderData();
+ if (state.panel === 'data' && (!opts.keepFocus || !$('#dataPanel').contains(document.activeElement))) renderData();
renderIssues();
renderCrumbs();
renderInsertTarget();
- renderCode();
+ if (state.view !== 'canvas') renderCode();
sendPreview();
$('#btnUndo').disabled = !state.undo.length;
$('#btnRedo').disabled = !state.redo.length;
}
-// a UI5 of your own for the canvas: the folder that holds resources/
-const isUi5Url = (v) => /^(https?:\/\/|\.{0,2}\/)[^"'<>\s]*$/.test(v);
+// the tab of the drawer that just opened shows the design as it is now
+function renderPanel() {
+ if (state.panel === 'layers') renderLayers();
+ else if (state.panel === 'data') renderData();
+ else if (state.panel === 'issues') renderIssues();
+}
+
+// the tabs of the drawer: arrow keys go from tab to tab, Home and End to
+// the first and the last, as a tablist does
+function onRailKey(e) {
+ const tabs = [...document.querySelectorAll('.rail-btn')];
+ const i = tabs.indexOf(document.activeElement);
+ if (i < 0) return;
+ const next = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1, ArrowUp: i - 1, Home: 0, End: tabs.length - 1 }[e.key];
+ if (next === undefined) return;
+ e.preventDefault();
+ const t = tabs[(next + tabs.length) % tabs.length];
+ showPanel(t.dataset.panel);
+ t.focus();
+}
function wire() {
$('#insertSearch').addEventListener('input', renderInsert);
+ $('#panel-insert').addEventListener('keydown', onInsertKey);
$('#className').addEventListener('input', (e) => {
const v = e.target.value.toUpperCase().replace(/\s/g, '');
// the field shows the name the class gets, the caret stays in place
@@ -137,18 +169,28 @@ function wire() {
$('#btnPlayground').onclick = openInPlayground;
$('#btnVscode').onclick = openInVscode;
$('#btnSave').onclick = saveDesign;
+ $('#btnSaveBrowser').onclick = saveToBrowser;
$('#btnImport').onclick = importDialog;
$('#btnRecent').onclick = recentDialog;
$('#btnShare').onclick = shareLink;
$('#btnUndo').onclick = undo;
+ $('#btnTheme').onclick = () => {
+ state.prefs.appearance = { '': 'light', light: 'dark', dark: '' }[state.prefs.appearance || ''];
+ savePrefs();
+ applyAppearance();
+ };
+ applyAppearance();
$('#btnRedo').onclick = redo;
$('#btnCmd').onclick = openPalette;
$('#btnZip').onclick = exportZip;
$('#btnZip2').onclick = exportZip;
$('#btnCopy').onclick = () => copyText(generateClassSource(state.design), 'ABAP');
$('#btnCopyXml').onclick = () => copyText(toXml(state.design), 'XML');
+ $('#btnCopyCode').onclick = () => { const c = codeText(); copyText(c.text, c.what); };
$('#btnDownloadAbap').onclick = () => download(fileName(state.design.className, 'clas.abap'), generateClassSource(state.design), 'text/plain');
for (const b of document.querySelectorAll('.rail-btn')) b.onclick = () => showPanel(b.dataset.panel);
+ $('.rail').addEventListener('keydown', onRailKey);
+ $('#layersFilter').addEventListener('input', (e) => { state.layersFilter = e.target.value; renderLayers(); });
for (const b of document.querySelectorAll('[data-view]')) b.onclick = () => setView(b.dataset.view);
for (const b of document.querySelectorAll('[data-code]')) b.onclick = () => setCode(b.dataset.code);
for (const b of document.querySelectorAll('[data-mode]')) {
@@ -156,20 +198,31 @@ function wire() {
state.mode = b.dataset.mode;
for (const x of document.querySelectorAll('[data-mode]')) x.classList.toggle('active', x === b);
postPreview({ type: 'mode', mode: state.mode });
+ renderEventLog();
};
}
// a click on a line of the class selects the control it writes
$('#code').addEventListener('click', (e) => {
const ln = e.target.closest('.ln[data-node]');
- if (ln && !getSelection().toString()) select(ln.dataset.node, { fromCode: true });
+ if (ln && !getSelection().toString()) select(ln.dataset.node, { fromCode: true, extend: e.ctrlKey || e.metaKey || e.shiftKey });
});
+ // a device width is the width the view gets: zoomed out, the frame on
+ // screen is that much narrower, the view inside as wide as before
const setDevice = (w) => {
state.prefs.device = w;
- $('#deviceFrame').style.width = w;
+ const z = Number(state.prefs.zoom) || 1;
+ $('#deviceFrame').style.width = w.endsWith('px') ? `${parseFloat(w) * z}px` : w;
+ const f = $('#preview');
+ f.style.zoom = z === 1 ? '' : String(z);
+ f.style.width = z === 1 ? '' : `${100 / z}%`;
+ f.style.height = z === 1 ? '' : `${100 / z}%`;
for (const b of document.querySelectorAll('[data-device]')) b.classList.toggle('active', b.dataset.device === w);
};
setDevice(state.prefs.device);
for (const b of document.querySelectorAll('[data-device]')) b.onclick = () => { setDevice(b.dataset.device); savePrefs(); };
+ const zoom = $('#zoom');
+ zoom.value = String(Number(state.prefs.zoom) || 1);
+ zoom.onchange = () => { state.prefs.zoom = Number(zoom.value); setDevice(state.prefs.device); savePrefs(); };
const theme = $('#theme');
theme.value = state.prefs.theme;
theme.onchange = () => { state.prefs.theme = theme.value; savePrefs(); loadPreview(); };
@@ -204,13 +257,19 @@ function wire() {
// ------------------------------------------------------------- start
on('change', scheduleRender);
+// two tabs of the editor share one browser store: the last edit wins
+onOtherTab((json) => toast('Another tab of the editor saved its design over this one - your next edit here wins', {
+ label: 'Load theirs',
+ run: () => { try { replaceDesign(normalizeDesign(JSON.parse(json))); } catch { /* not a design */ } },
+}));
+on('panel', renderPanel);
on('select', (opts) => {
- renderLayers();
+ if (state.panel === 'layers') renderLayers();
renderInspector();
renderCrumbs();
renderInsertTarget();
highlightCode(!opts.fromCode);
- postPreview({ type: 'select', selected: state.selected, label: labelOfSelection() });
+ postPreview({ type: 'select', selected: state.selected, also: state.also, label: labelOfSelection() });
});
loadPrefs();
@@ -240,4 +299,4 @@ openLinkedDesign();
window.addEventListener('hashchange', openLinkedDesign);
// for the browser tests and the curious: the state and the generators
-window.designer = { state, generateClass, generateClassSource, toXml, validate, select, commit, handleDrop, openPalette, copySelection, pasteSelection, addControlByName };
+window.designer = { state, generateClass, generateClassSource, toXml, validate, select, commit, handleDrop, openPalette, copySelection, pasteSelection, addControlByName, addControl };
diff --git a/src/diff.js b/src/diff.js
new file mode 100644
index 0000000..a87767b
--- /dev/null
+++ b/src/diff.js
@@ -0,0 +1,65 @@
+// A line diff: what the export changes in a class that was opened - the
+// longest common subsequence of the lines, then hunks with a few lines of
+// context around each change. Classes are a few hundred lines; the table
+// is rows x columns of small numbers, and only the lines between the
+// common head and tail take part.
+
+// [{ op: ' ' | '-' | '+', text }] for every line of both
+export function diffLines(a, b) {
+ let head = 0;
+ while (head < a.length && head < b.length && a[head] === b[head]) head++;
+ let tail = 0;
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
+ const x = a.slice(head, a.length - tail);
+ const y = b.slice(head, b.length - tail);
+ // lcs[i][j]: the length of the common subsequence of x[i..] and y[j..]
+ const lcs = Array.from({ length: x.length + 1 }, () => new Uint32Array(y.length + 1));
+ for (let i = x.length - 1; i >= 0; i--) {
+ for (let j = y.length - 1; j >= 0; j--) {
+ lcs[i][j] = x[i] === y[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
+ }
+ }
+ const out = a.slice(0, head).map((text) => ({ op: ' ', text }));
+ let i = 0;
+ let j = 0;
+ while (i < x.length || j < y.length) {
+ if (i < x.length && j < y.length && x[i] === y[j]) { out.push({ op: ' ', text: x[i] }); i++; j++; }
+ // what goes out before what comes in, as diffs read
+ else if (i < x.length && (j === y.length || lcs[i + 1][j] >= lcs[i][j + 1])) { out.push({ op: '-', text: x[i] }); i++; }
+ else { out.push({ op: '+', text: y[j] }); j++; }
+ }
+ for (const text of a.slice(a.length - tail)) out.push({ op: ' ', text });
+ return out;
+}
+
+// the diff in hunks: changed lines with `context` lines around them, the
+// unchanged runs between as { op: '…', count } - and the line numbers of
+// both sides on every line
+export function diffHunks(a, b, context = 3) {
+ const lines = diffLines(a, b);
+ let na = 0;
+ let nb = 0;
+ for (const l of lines) {
+ if (l.op !== '+') l.a = ++na;
+ if (l.op !== '-') l.b = ++nb;
+ }
+ const keep = lines.map(() => false);
+ lines.forEach((l, k) => {
+ if (l.op === ' ') return;
+ for (let m = Math.max(0, k - context); m <= Math.min(lines.length - 1, k + context); m++) keep[m] = true;
+ });
+ const out = [];
+ let skipped = 0;
+ lines.forEach((l, k) => {
+ if (keep[k]) {
+ if (skipped) { out.push({ op: '…', count: skipped }); skipped = 0; }
+ out.push(l);
+ } else skipped++;
+ });
+ if (skipped) out.push({ op: '…', count: skipped });
+ return {
+ lines: out,
+ added: lines.filter((l) => l.op === '+').length,
+ removed: lines.filter((l) => l.op === '-').length,
+ };
+}
diff --git a/src/model.js b/src/model.js
index 6b992f3..0ab698f 100644
--- a/src/model.js
+++ b/src/model.js
@@ -274,6 +274,39 @@ export function moveNode(design, id, parentId, agg, index) {
return id;
}
+// several nodes to one place, together and in the order of the view:
+// before the node that is at `index` of the target list now (the end
+// when null) - returns the id of the last, or null when the target takes
+// one of them not, or would end up inside one of them
+export function moveNodes(design, ids, parentId, agg, index) {
+ const order = [];
+ walk(design.root, (n) => { order.push(n.id); return true; });
+ const list = [...new Set(ids)].filter((id) => !ids.some((o) => o !== id && isAncestor(design.root, o, id)))
+ .map((id) => findNode(design.root, id)).filter((x) => x && x.parent)
+ .sort((a, b) => order.indexOf(a.node.id) - order.indexOf(b.node.id));
+ if (!list.length) return null;
+ if (list.some((x) => x.node.id === parentId || isAncestor(design.root, x.node.id, parentId))) return null;
+ const p = findNode(design.root, parentId);
+ if (!p) return null;
+ // a marker holds the place while the nodes leave their lists
+ const marker = { id: '\u0000' };
+ p.node.aggs[agg] = p.node.aggs[agg] || [];
+ const target = p.node.aggs[agg];
+ target.splice(index == null || index > target.length ? target.length : index, 0, marker);
+ for (const x of list) {
+ const h = findNode(design.root, x.node.id);
+ h.parent.aggs[h.agg].splice(h.index, 1);
+ if (!h.parent.aggs[h.agg].length && h.parent.aggs[h.agg] !== target) delete h.parent.aggs[h.agg];
+ }
+ const at = target.indexOf(marker);
+ target.splice(at, 1);
+ for (const [i, x] of list.entries()) {
+ if (!aggAccepts(p.node, agg, x.node.type)) throw new Error(`${p.node.type} takes no ${x.node.type}${agg ? ` in ${agg}` : ''}`);
+ target.splice(at + i, 0, x.node);
+ }
+ return list[list.length - 1].node.id;
+}
+
export function shiftNode(design, id, delta) {
const hit = findNode(design.root, id);
if (!hit || !hit.parent) return null;
@@ -296,6 +329,93 @@ export function duplicateNode(design, id) {
return copy.id;
}
+// the controls `ids`, side by side in one parent, into a new control of
+// `type` in their place - returns its id; throws when they do not sit in
+// one aggregation of one parent, or the parent or the wrapper refuses
+export function wrapNodes(design, ids, type) {
+ const hits = [...new Set(ids)].map((id) => findNode(design.root, id)).filter((x) => x && x.parent);
+ if (!hits.length) throw new Error('Nothing to wrap');
+ const { parent, agg } = hits[0];
+ if (hits.some((x) => x.parent.id !== parent.id || x.agg !== agg)) throw new Error('Only controls side by side in one parent can be wrapped together');
+ hits.sort((a, b) => a.index - b.index);
+ const wrapper = createNode(type);
+ wrapper.aggs = {};
+ const into = defaultAggFor(type, hits[0].node.type);
+ if (!into || hits.some((x) => !accepts(type, into, x.node.type))) throw new Error(`${type} takes no ${hits.find((x) => !into || !accepts(type, into, x.node.type)).node.type}`);
+ const list = parent.aggs[agg];
+ for (const x of [...hits].reverse()) list.splice(x.index, 1);
+ if (!aggAccepts(parent, agg, type)) throw new Error(`${parent.type} takes no ${type}${agg ? ` in ${agg}` : ''}`);
+ list.splice(hits[0].index, 0, wrapper);
+ wrapper.aggs[into] = hits.map((x) => x.node);
+ return wrapper.id;
+}
+
+// an entry of a list one place up (-1) or down (+1): attributes, fields
+export function moveItem(list, index, delta) {
+ const j = index + delta;
+ if (!list || j < 0 || j >= list.length) return false;
+ [list[index], list[j]] = [list[j], list[index]];
+ return true;
+}
+
+// Sample rows pasted as text - from a spreadsheet (tab separated) or CSV
+// (`;` or `,`, "quoted" cells). `header`: the first line names the
+// fields; a name the table has not becomes a new string field. Without a
+// header the columns fill the scalar fields in their order, and what is
+// left over is counted in `ignored`. Returns { rows, added, ignored }.
+export function parseSampleRows(text, fields, header) {
+ const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n');
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
+ if (!lines.length) return { rows: [], added: [], ignored: 0 };
+ const sep = lines.some((l) => l.includes('\t')) ? '\t' : lines[0].includes(';') ? ';' : ',';
+ const cells = (line) => {
+ if (sep === '\t') return line.split('\t');
+ const out = [];
+ let cur = '';
+ let quoted = false;
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i];
+ if (quoted) {
+ if (ch === '"' && line[i + 1] === '"') { cur += '"'; i++; } else if (ch === '"') quoted = false; else cur += ch;
+ } else if (ch === '"' && !cur.trim()) { quoted = true; cur = ''; } else if (ch === sep) { out.push(cur); cur = ''; } else cur += ch;
+ }
+ out.push(cur);
+ return out;
+ };
+ const scalar = fields.filter((f) => f.type !== 'table');
+ const added = [];
+ let columns;
+ let body = lines;
+ if (header) {
+ body = lines.slice(1);
+ columns = cells(lines[0]).map((raw) => {
+ let name = raw.trim().toUpperCase().replace(/[^A-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
+ if (!name) return null;
+ if (!/^[A-Z]/.test(name)) name = `F_${name}`;
+ name = name.slice(0, 30);
+ const known = fields.find((f) => f.name === name) || added.find((f) => f.name === name);
+ if (known) return known.type === 'table' ? null : known;
+ const f = { name, type: 'string' };
+ added.push(f);
+ return f;
+ });
+ } else {
+ columns = scalar;
+ }
+ let ignored = 0;
+ const rows = body.filter((l) => l.trim()).map((l) => {
+ const row = {};
+ cells(l).forEach((v, i) => {
+ const f = columns[i];
+ if (!f) { if (v.trim()) ignored += 1; return; }
+ const t = v.trim();
+ row[f.name] = f.type === 'bool' ? /^(x|true|1|yes|ja)$/i.test(t) : t;
+ });
+ return row;
+ });
+ return { rows, added, ignored };
+}
+
// ------------------------------------------------------------- data binding
// The table attribute an aggregation's rows come from, for the node whose
@@ -579,7 +699,7 @@ export function validate(design) {
}
const attrByName = new Map(design.attributes.map((a) => [a.name, a]));
for (const a of design.attributes) {
- if (!attributeUsage(design, a.name)) issues.push({ level: 'warning', text: `Attribute ${a.name} is public, but nothing binds it - bind it or remove it` });
+ if (!attributeUsage(design, a.name)) issues.push({ level: 'warning', text: `Attribute ${a.name} is public, but nothing binds it - bind it or remove it`, fix: { kind: 'removeAttribute', name: a.name, label: 'Remove' } });
const rowValues = (fields, rows, where) => (rows || []).flatMap((r) => fields.flatMap((f) => (f.type === 'table'
? rowValues(f.fields || [], r[f.name], `${where}-${f.name}`)
: [[f.type, r[f.name], `${where}-${f.name}`]])));
@@ -598,9 +718,16 @@ export function validate(design) {
const w = checkStatic(pd, v.v);
// a number or an enum value UI5 cannot parse fails the view; a size
// or a flag it logs and replaces by the default
- if (w) issues.push({ level: ['int', 'float', 'enum'].includes(pd.type) ? 'error' : 'warning', text: `${n.type}.${p}: ${w}`, node: n.id });
+ if (w) issues.push({ level: ['int', 'float', 'enum'].includes(pd.type) ? 'error' : 'warning', text: `${n.type}.${p}: ${w}`, node: n.id, prop: p, fix: { kind: 'resetProp', node: n.id, prop: p, label: 'Reset to default' } });
+ }
+ if (v.m === 'b' && !names.has(v.v)) {
+ const isAgg = bindableAggs(n).includes(p);
+ const pd = nodeDef && nodeDef.props && nodeDef.props[p];
+ issues.push({
+ level: 'error', text: `${n.type}.${p} is bound to ${v.v}, which is not an attribute`, node: n.id, prop: p,
+ fix: v.v && !checkAttributeName(v.v) ? { kind: 'createAttribute', name: v.v, type: isAgg ? 'table' : attrTypeFor(pd), label: `Create ${v.v}` } : undefined,
+ });
}
- if (v.m === 'b' && !names.has(v.v)) issues.push({ level: 'error', text: `${n.type}.${p} is bound to ${v.v}, which is not an attribute`, node: n.id });
else if (v.m === 'b') {
const attr = attrByName.get(v.v);
const isAgg = bindableAggs(n).includes(p);
@@ -628,10 +755,18 @@ export function validate(design) {
const b = n.props[a];
const def = getControl(n.type).aggs[a];
if (b && (b.m === 'b' || b.m === 'f') && !def.noTemplate && (n.aggs[a] || []).length !== 1) {
- issues.push({ level: 'warning', text: `${n.type}: a bound ${a} aggregation needs exactly one template item`, node: n.id });
+ const many = (n.aggs[a] || []).length > 1;
+ // a template built from the fields replaces columns built by hand,
+ // and needs a table to build from: offered only where neither stands
+ // in its way
+ const fromTable = b.m === 'f' || (attrByName.get(b.v) && attrByName.get(b.v).type === 'table');
+ const ownColumns = n.type === 'Table' && (n.aggs.columns || []).length > 0;
+ const fix = many ? { kind: 'template', node: n.id, agg: a, label: 'Keep the first as template' }
+ : fromTable && !ownColumns ? { kind: 'template', node: n.id, agg: a, label: 'Build a template' } : undefined;
+ issues.push({ level: 'warning', text: `${n.type}: a bound ${a} aggregation needs exactly one template item`, node: n.id, fix });
}
if (n.type === 'Tree' && !(b && (b.m === 'b' || b.m === 'f' || b.m === 'x'))) {
- issues.push({ level: 'error', text: 'Tree: its items have to be bound to a table - UI5 fails on static tree items', node: n.id });
+ issues.push({ level: 'error', text: 'Tree: its items have to be bound to a table - UI5 fails on static tree items', node: n.id, fix: { kind: 'nodesTable', node: n.id, agg: a, label: 'Create nodes table' } });
}
if (b && b.arr) {
const ctx = b.m === 'b' ? attrByName.get(b.v) : null;
@@ -642,7 +777,7 @@ export function validate(design) {
if (c && c.input && !rowContext(design, n.id)) {
const v = n.props[c.input];
const wired = (v && (v.m === 'b' || v.m === 'x')) || Object.values(n.events || {}).some(Boolean);
- if (!wired) issues.push({ level: 'info', text: `${n.type}: what the user enters goes nowhere - bind ${c.input} to an attribute (or name an event)`, node: n.id });
+ if (!wired) issues.push({ level: 'info', text: `${n.type}: what the user enters goes nowhere - bind ${c.input} to an attribute (or name an event)`, node: n.id, prop: c.input, fix: { kind: 'bindNew', node: n.id, prop: c.input, label: 'Bind to a new attribute' } });
}
if (parent && !CONTROLS[n.type] && n.type !== 'mvc:View') {
issues.push({ level: 'info', text: `${n.type} is not in the palette - not checked against UI5 1.71`, node: n.id });
@@ -689,6 +824,14 @@ export function checkStatic(def, v) {
// Does an attribute of `attrType` fit a property of `def`? A boolean takes
// a flag, a number a number, text takes anything that prints
+// the attribute type a property binds best: a flag, a number, a text
+export function attrTypeFor(def) {
+ if (def && def.type === 'boolean') return 'bool';
+ if (def && def.type === 'int') return 'int';
+ if (def && def.type === 'float') return 'decimal';
+ return 'string';
+}
+
export function attributeFits(def, attrType) {
if (attrType === 'table') return false;
if (def.type === 'boolean') return attrType === 'bool';
diff --git a/src/preview/init.js b/src/preview/init.js
index 756c553..b5b8c1a 100644
--- a/src/preview/init.js
+++ b/src/preview/init.js
@@ -28,14 +28,20 @@ sap.ui.define([
var rendering = false;
var mode = 'design';
var selected = null;
+ var also = [];
+ var empty = [];
var selectedLabel = '';
var tools = null;
var lastXml = null;
var PreviewController = Controller.extend('designer.PreviewController', {
// every event of the view lands here: a toast, as on_event's would
+ // - and the designer learns of it, with the model as the user left it
onEvent: function (name) {
MessageToast.show('Event ' + name + ' - handled in on_event');
+ var data = null;
+ try { data = view && view.getModel() ? JSON.parse(JSON.stringify(view.getModel().getData())) : null; } catch (e) { data = null; }
+ post({ type: 'event', name: name, model: data });
},
});
@@ -56,9 +62,22 @@ sap.ui.define([
// ids, inside the view's id space (dsg0--), and a list's clones
// carry their template's id first (dsg0---dsg0---0)
function nodeIdOf(el) {
+ var hit = elementOf(el);
+ return hit ? hit.id : null;
+ }
+ // the element of that node under `el`: its outermost one (a Button's
+ // inner parts carry its id too) - in a bound list the clone of the row
+ // that was hit, not the first
+ var NODE_ID = /(?:^|-)dsg\d+--(n[0-9a-z]+)/;
+ function elementOf(el) {
while (el && el !== document.body) {
- var m = el.id && /(?:^|-)dsg\d+--(n[0-9a-z]+)/.exec(el.id);
- if (m) return m[1];
+ var m = el.id && NODE_ID.exec(el.id);
+ if (m) {
+ var up = el.parentElement;
+ var n;
+ while (up && up !== document.body && up.id && (n = NODE_ID.exec(up.id)) && n[1] === m[1]) { el = up; up = el.parentElement; }
+ return { id: m[1], el: el };
+ }
el = el.parentElement;
}
return null;
@@ -76,9 +95,29 @@ sap.ui.define([
if (el) el.classList.add(cls);
}
+ // an empty container takes no room, so nothing could be dropped into
+ // it: in design mode it gets some, with a dashed frame
+ function markEmpty() {
+ var old = document.querySelectorAll('.dsg-empty');
+ for (var i = 0; i < old.length; i++) old[i].classList.remove('dsg-empty');
+ if (mode !== 'design') return;
+ for (var j = 0; j < empty.length; j++) {
+ var el = domOf(empty[j]);
+ if (el) el.classList.add('dsg-empty');
+ }
+ }
+
function highlight(scroll) {
+ markEmpty();
var el = domOf(selected);
mark('dsg-sel', el);
+ // the rest of a selection of several controls
+ var old = document.querySelectorAll('.dsg-also');
+ for (var i = 0; i < old.length; i++) old[i].classList.remove('dsg-also');
+ for (var j = 0; j < also.length; j++) {
+ var a = domOf(also[j]);
+ if (a) a.classList.add('dsg-also');
+ }
if (el && scroll && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
placeTools();
}
@@ -120,7 +159,10 @@ sap.ui.define([
tools.style.top = Math.max(2, top) + 'px';
tools.style.left = Math.max(2, Math.min(r.left, window.innerWidth - tools.offsetWidth - 2)) + 'px';
}
- window.addEventListener('scroll', function () { if (tools) placeTools(); }, true);
+ window.addEventListener('scroll', function () {
+ if (tools) placeTools();
+ post({ type: 'scroll' });
+ }, true);
window.addEventListener('resize', function () { if (tools) placeTools(); });
// Every render ends - in a view on the canvas, or in an error the
@@ -230,6 +272,24 @@ sap.ui.define([
});
}
+ // the default values of a control class's properties, as text: what a
+ // property is when the view does not set it
+ function defaults(msg) {
+ sap.ui.require([msg.module], function (C) {
+ var out = {};
+ try {
+ var all = C.getMetadata().getAllProperties();
+ Object.keys(all).forEach(function (n) {
+ var v = all[n].defaultValue;
+ if (v !== null && v !== undefined && typeof v !== 'object') out[n] = String(v);
+ });
+ } catch (err) { /* no metadata - no defaults */ }
+ post({ type: 'defaults', key: msg.key, defaults: out });
+ }, function () {
+ post({ type: 'defaults', key: msg.key, defaults: {} });
+ });
+ }
+
window.addEventListener('message', function (e) {
var msg = e.data || {};
if (msg.target !== 'dsg-preview') return;
@@ -241,15 +301,21 @@ sap.ui.define([
} else if (msg.type === 'render') {
mode = msg.mode || mode;
selected = msg.selected || null;
+ also = msg.also || [];
+ empty = msg.empty || [];
selectedLabel = msg.label || '';
render(msg);
+ } else if (msg.type === 'defaults') {
+ defaults(msg);
} else if (msg.type === 'select') {
selected = msg.selected;
+ also = msg.also || [];
selectedLabel = msg.label || '';
highlight(true);
} else if (msg.type === 'mode') {
mode = msg.mode;
mark('dsg-hover', null);
+ markEmpty();
placeTools();
}
});
@@ -261,14 +327,33 @@ sap.ui.define([
if (errorBox.contains(e.target) || (tools && tools.contains(e.target))) return;
e.preventDefault();
e.stopPropagation();
+ // a double-click edits the control's text, over the canvas
+ if (type === 'dblclick') {
+ var hit = elementOf(e.target);
+ if (hit) {
+ var r = hit.el.getBoundingClientRect();
+ post({ type: 'inline', id: hit.id, rect: { left: r.left, top: r.top, width: r.width, height: r.height } });
+ }
+ }
if (type === 'click') {
var id = nodeIdOf(e.target);
- if (id) { selected = id; highlight(false); }
- post({ type: 'select', id: id || 'root' });
+ var extend = !!(e.ctrlKey || e.metaKey || e.shiftKey);
+ if (id && !extend) { selected = id; also = []; highlight(false); }
+ post({ type: 'select', id: id || 'root', extend: extend });
}
}, true);
});
+ // the context menu is the designer's, for the control under the pointer
+ document.addEventListener('contextmenu', function (e) {
+ if (mode !== 'design') return;
+ var id = nodeIdOf(e.target);
+ if (!id) return;
+ e.preventDefault();
+ e.stopPropagation();
+ post({ type: 'context', id: id, x: e.clientX, y: e.clientY });
+ }, true);
+
document.addEventListener('mouseover', function (e) {
if (mode !== 'design' || (tools && tools.contains(e.target))) return;
mark('dsg-hover', domOf(nodeIdOf(e.target)));
diff --git a/src/ui/actions.js b/src/ui/actions.js
new file mode 100644
index 0000000..020cb5c
--- /dev/null
+++ b/src/ui/actions.js
@@ -0,0 +1,77 @@
+// The edits on the selection - delete, duplicate, move, select the parent,
+// wrap - one implementation for the keys, the command palette and the
+// toolbar on the canvas. Each takes the selection, or the ids it is given.
+import { findNode, isAncestor, removeNode, duplicateNode, shiftNode, wrapNodes } from '../model.js';
+import { state, commit, select, selectMany, selectedIds } from './store.js';
+import { toast, displayType } from './dom.js';
+
+// the controls a container can wrap a selection in
+export const WRAPPERS = ['VBox', 'HBox', 'FlexBox', 'Panel'];
+
+// the selected controls without those inside another selected one: what
+// happens to a control happens to its children anyway
+function topmost(ids) {
+ return ids.filter((id) => !ids.some((o) => o !== id && isAncestor(state.design.root, o, id)));
+}
+
+export function deleteSelection(ids = selectedIds()) {
+ const list = topmost(ids.filter((id) => id !== 'root'));
+ if (!list.length) return;
+ commit((d) => {
+ let next;
+ for (const id of list) next = removeNode(d, id) || next;
+ // the next selection is never one of the deleted
+ return next && findNode(d.root, next) ? next : 'root';
+ });
+}
+
+export function duplicateSelection(ids = selectedIds()) {
+ const list = topmost(ids.filter((id) => id !== 'root'));
+ if (!list.length) return;
+ const copies = [];
+ commit((d) => {
+ for (const id of list) {
+ const c = duplicateNode(d, id);
+ if (c) copies.push(c);
+ }
+ return copies[copies.length - 1];
+ });
+ if (!copies.length) toast(list.length > 1 ? 'They cannot be duplicated: their slot takes one control' : 'It cannot be duplicated: its slot takes one control');
+ else if (copies.length > 1) selectMany(copies);
+}
+
+// one place up (-1) or down (+1) among the siblings; several selected
+// controls move together, in the order that keeps them in order
+export function moveSelection(delta, ids = selectedIds()) {
+ const list = topmost(ids.filter((id) => id !== 'root'));
+ if (!list.length) return;
+ const keep = [...list];
+ commit((d) => {
+ const hits = list.map((id) => findNode(d.root, id)).filter(Boolean);
+ hits.sort((a, b) => (delta < 0 ? a.index - b.index : b.index - a.index));
+ for (const x of hits) {
+ const h = findNode(d.root, x.node.id);
+ const sib = h.parent.aggs[h.agg][h.index + delta];
+ // a selected neighbour that could not move holds the next one back
+ if (sib && list.includes(sib.id)) continue;
+ shiftNode(d, x.node.id, delta);
+ }
+ return keep.length === 1 ? keep[0] : undefined;
+ });
+ if (keep.length > 1) selectMany(keep);
+}
+
+export function selectParent(id = state.selected) {
+ const hit = findNode(state.design.root, id);
+ if (hit && hit.parent) select(hit.parent.id);
+}
+
+export function wrapSelection(type, ids = selectedIds()) {
+ const list = topmost(ids.filter((id) => id !== 'root'));
+ if (!list.length) { toast('Select the controls to wrap first'); return; }
+ // a wrap that failed has said why (commit( ) shows the error) - and
+ // changed nothing
+ const rev = state.rev;
+ commit((d) => wrapNodes(d, list, type));
+ if (state.rev !== rev) toast(`Wrapped in ${displayType(type)}`);
+}
diff --git a/src/ui/code.js b/src/ui/code.js
index 4c6df16..d39b5b9 100644
--- a/src/ui/code.js
+++ b/src/ui/code.js
@@ -5,6 +5,7 @@ import { $, toast } from './dom.js';
import { fileName } from '../abap.js';
import { classSource } from '../clas.js';
import { toXml } from '../xml.js';
+import { diffHunks } from '../diff.js';
const ABAP_KW = /\b(CLASS|DEFINITION|PUBLIC|PROTECTED|PRIVATE|SECTION|INTERFACES|TYPES|BEGIN|OF|END|TYPE|DATA|STANDARD|TABLE|WITH|EMPTY|KEY|REF|TO|METHODS|ENDCLASS|IMPLEMENTATION|METHOD|ENDMETHOD|IF|ELSEIF|ELSE|ENDIF|CASE|WHEN|ENDCASE|VALUE|LENGTH|DECIMALS|ME)\b/g;
@@ -26,11 +27,19 @@ let codeCache = { key: null };
export function renderCode() {
const d = state.design;
const pre = $('#code');
+ // Changes exist for a class that was opened, and only for it
+ const opened = !!(d.abap && d.abap.source);
+ $('#btnDiff').hidden = !opened;
+ if (state.code === 'diff' && !opened) { setCode('abap'); return; }
const key = `${state.code}:${state.rev}`;
if (codeCache.key === key) { highlightCode(false); return; }
codeCache = { key };
let lines;
let nodeIds;
+ if (state.code === 'diff') {
+ renderDiff(pre, d);
+ return;
+ }
if (state.code === 'abap') {
const map = classSource(d);
lines = map.source.replace(/\n$/, '').split('\n');
@@ -51,10 +60,45 @@ export function renderCode() {
highlightCode(false);
}
+// the class as it was opened against the class the export writes
+function renderDiff(pre, d) {
+ const before = d.abap.source.replace(/\n$/, '').split('\n');
+ const after = classSource(d).source.replace(/\n$/, '').split('\n');
+ const h = diffHunks(before, after);
+ // the lines compare without the end of the file: whether it ends in a
+ // newline is said on its own
+ const eol = d.abap.source.endsWith('\n') !== classSource(d).source.endsWith('\n');
+ const eolNote = eol ? ' · the file gets a newline at its end' : '';
+ $('#codeInfo').textContent = h.added || h.removed
+ ? `+${h.added} −${h.removed} lines against the class as it was opened${eolNote}`
+ : eol ? 'only the end of the file changes: it gets a newline' : 'the export writes the class as it was opened';
+ if (!h.added && !h.removed) {
+ pre.innerHTML = eol ? 'Every line stays as it was - only a newline is added at the end of the file. '
+ : 'No change - every line stays as it was. ';
+ return;
+ }
+ pre.innerHTML = h.lines.map((l) => (l.op === '…'
+ ? `⋯ ${l.count} unchanged line${l.count === 1 ? '' : 's'} `
+ : `${highlightAbapLine(l.text) || ' '} `)).join('');
+}
+
+// what the tab shows, as text: the class, the view, or the changes as a
+// unified diff
+export function codeText() {
+ const d = state.design;
+ if (state.code === 'xml') return { text: toXml(d), what: 'XML' };
+ if (state.code === 'diff' && d.abap && d.abap.source) {
+ const h = diffHunks(d.abap.source.replace(/\n$/, '').split('\n'), classSource(d).source.replace(/\n$/, '').split('\n'));
+ return { text: h.lines.map((l) => (l.op === '…' ? `@@ ${l.count} unchanged @@` : `${l.op}${l.text}`)).join('\n'), what: 'Changes' };
+ }
+ return { text: classSource(d).source, what: 'ABAP' };
+}
+
// mark the selected control's lines, and bring the first into view
export function highlightCode(scroll) {
const pre = $('#code');
- for (const el of pre.querySelectorAll('.ln.hi')) el.classList.remove('hi');
+ for (const el of pre.querySelectorAll('.ln.hi, .ln.hi-also')) el.classList.remove('hi', 'hi-also');
+ for (const id of state.also) for (const el of pre.querySelectorAll(`.ln[data-node="${CSS.escape(id)}"]`)) el.classList.add('hi-also');
const lines = pre.querySelectorAll(`.ln[data-node="${CSS.escape(state.selected)}"]`);
lines.forEach((el) => el.classList.add('hi'));
if (scroll && lines[0] && state.view !== 'canvas') {
@@ -75,17 +119,21 @@ export function setView(view) {
savePrefs();
for (const b of document.querySelectorAll('[data-view]')) b.classList.toggle('active', b.dataset.view === view);
$('#stageBody').className = `stage-body view-${view}`;
+ // the class is not built while it is hidden - it is now
+ if (view !== 'canvas') renderCode();
highlightCode(true);
}
+// true when the text is in the clipboard
export async function copyText(text, what) {
try {
await navigator.clipboard.writeText(text);
toast(`${what} copied`);
+ return true;
} catch {
// clipboard refused (no permission, not a secure context): show the
// code and select it, so Ctrl+C works
- setCode(what === 'XML' ? 'xml' : 'abap');
+ setCode(what === 'XML' ? 'xml' : what === 'Changes' ? 'diff' : 'abap');
if (state.view === 'canvas') setView('split');
const range = document.createRange();
range.selectNodeContents($('#code'));
@@ -93,5 +141,6 @@ export async function copyText(text, what) {
sel.removeAllRanges();
sel.addRange(range);
toast('Press Ctrl+C to copy the selection');
+ return false;
}
}
diff --git a/src/ui/context.js b/src/ui/context.js
new file mode 100644
index 0000000..b65063b
--- /dev/null
+++ b/src/ui/context.js
@@ -0,0 +1,83 @@
+// The context menu of a control - on the canvas, in the outline, or by
+// Shift+F10: the edits of src/ui/actions.js and the clipboard, for the
+// selection the control is part of.
+import { findNode } from '../model.js';
+import { state, select, selectedIds } from './store.js';
+import { $, h } from './dom.js';
+import { WRAPPERS, deleteSelection, duplicateSelection, moveSelection, selectParent, wrapSelection } from './actions.js';
+import { copySelection, pasteSelection, copyProperties, pasteProperties } from './edit.js';
+
+let open = null;
+
+export function closeContextMenu() {
+ if (!open) return;
+ open.remove();
+ open = null;
+}
+
+// at (x, y) of the editor's page, for control `id` - which joins the
+// selection unless it is in it already
+export function openContextMenu(x, y, id) {
+ closeContextMenu();
+ if (!findNode(state.design.root, id) || id === 'root') return;
+ if (!selectedIds().includes(id)) select(id);
+ const n = selectedIds().length;
+ const hit = findNode(state.design.root, state.selected);
+ const item = (text, run, key, cls) => h('button', {
+ role: 'menuitem', class: cls || null,
+ onclick: () => { closeContextMenu(); run(); },
+ }, h('span', { text }), key ? h('kbd', { text: key }) : null);
+ const sep = () => h('div', { class: 'sep', role: 'separator' });
+ const menu = h('div', { class: 'menu ctx', role: 'menu', 'aria-label': n > 1 ? `${n} controls` : 'Control' },
+ hit && hit.parent && hit.parent.id !== 'root' ? item('Select parent', () => selectParent(), 'Alt+↑') : null,
+ item('Move up', () => moveSelection(-1), 'Ctrl+↑'),
+ item('Move down', () => moveSelection(1), 'Ctrl+↓'),
+ sep(),
+ item('Copy', () => copySelection(false), 'Ctrl+C'),
+ item('Cut', () => copySelection(true), 'Ctrl+X'),
+ item('Paste', () => pasteSelection(), 'Ctrl+V'),
+ item('Duplicate', () => duplicateSelection(), 'Ctrl+D'),
+ sep(),
+ item('Copy properties', () => copyProperties()),
+ state.propClipboard ? item(`Paste properties${n > 1 ? ` onto ${n}` : ''}`, () => pasteProperties()) : null,
+ sep(),
+ h('div', { class: 'ctx-label', text: 'Wrap in' }),
+ h('div', { class: 'ctx-row' }, WRAPPERS.map((w) => item(w, () => wrapSelection(w)))),
+ sep(),
+ item(n > 1 ? `Delete ${n} controls` : 'Delete', () => deleteSelection(), 'Del', 'danger'));
+ document.body.append(menu);
+ open = menu;
+ // inside the window, whatever the corner
+ const r = menu.getBoundingClientRect();
+ menu.style.left = `${Math.max(4, Math.min(x, innerWidth - r.width - 4))}px`;
+ menu.style.top = `${Math.max(4, Math.min(y, innerHeight - r.height - 4))}px`;
+ const items = () => [...menu.querySelectorAll('button')];
+ items()[0].focus();
+ menu.addEventListener('keydown', (e) => {
+ // the menu has the keys: Delete here is no Delete of the selection
+ e.stopPropagation();
+ const list = items();
+ const i = list.indexOf(document.activeElement);
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeContextMenu(); return; }
+ const next = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: list.length - 1 }[e.key];
+ if (next === undefined) return;
+ e.preventDefault();
+ list[(next + list.length) % list.length].focus();
+ });
+}
+
+// at the selected control's row of the outline, or the middle of the canvas
+export function openContextMenuAtSelection() {
+ if (state.selected === 'root') return;
+ const row = $(`#layers .lrow[data-id="${CSS.escape(state.selected)}"]`);
+ const r = (row && row.offsetParent ? row : $('#canvasPane')).getBoundingClientRect();
+ openContextMenu(r.left + Math.min(40, r.width / 2), r.top + (row ? r.height : r.height / 3), state.selected);
+}
+
+if (typeof window !== 'undefined') {
+ document.addEventListener('pointerdown', (e) => { if (open && !open.contains(e.target)) closeContextMenu(); }, true);
+ window.addEventListener('blur', closeContextMenu);
+ window.addEventListener('resize', closeContextMenu);
+ // a wheel, not a scroll: selecting the control scrolls the code to it
+ document.addEventListener('wheel', (e) => { if (open && !open.contains(e.target)) closeContextMenu(); }, { capture: true, passive: true });
+}
diff --git a/src/ui/data.js b/src/ui/data.js
index be8c389..f469974 100644
--- a/src/ui/data.js
+++ b/src/ui/data.js
@@ -1,8 +1,14 @@
// The Data panel: the attributes of the class with their sample values,
// and the Checks panel.
-import { walk, rowContext, attributeUsage, renameAttribute, validate, checkAttributeName, checkFieldName, SCALAR_TYPES, ATTRIBUTE_TYPES } from '../model.js';
+import {
+ walk, findNode, rowContext, attributeUsage, renameAttribute, validate, checkAttributeName, checkFieldName, ATTRIBUTE_TYPES,
+ moveItem, parseSampleRows, bindListToTable,
+} from '../model.js';
+import { getControl } from '../catalog.js';
import { state, commit, select } from './store.js';
-import { $, h, options, toast, rememberFocus, restoreFocus } from './dom.js';
+import { $, h, options, toast, choose, dialog, rememberFocus, restoreFocus } from './dom.js';
+import { newTableAttribute, newAttributeFor } from './edit.js';
+import { focusProp } from './inspector.js';
export function renderData() {
@@ -59,12 +65,17 @@ function attrCard(a, idx) {
class: 'icon danger',
title: uses ? `Delete - ${uses} binding(s) use it` : 'Delete',
'aria-label': 'Delete attribute',
- onclick: () => {
- if (uses && !confirm(`${a.name} is bound ${uses} time(s). Delete it anyway? The bindings stay and show up under Checks.`)) return;
+ onclick: async () => {
+ if (uses && !await choose(`Delete ${a.name}?`, `${a.name} is bound ${uses} time(s). The bindings stay and show up under Checks.`, [
+ { text: 'Cancel', value: false }, { text: 'Delete', value: true, primary: true },
+ ])) return;
commit((dd) => { dd.attributes.splice(idx, 1); });
},
}, '✕');
- const card = h('div', { class: 'attr' }, h('div', { class: 'attr-head' }, nameIn, typeSel, del));
+ // the order of the attributes is the order of their declarations
+ const last = d.attributes.length - 1;
+ const card = h('div', { class: 'attr' }, h('div', { class: 'attr-head' }, nameIn, typeSel,
+ moveButtons('attribute', idx, last, (delta) => commit((dd) => { moveItem(dd.attributes, idx, delta); })), del));
if (uses) card.append(h('div', { class: 'uses', text: `bound ${uses}×` }));
if (a.type !== 'table') {
card.append(h('div', { class: 'avalue' }, h('span', { class: 'hint', style: 'margin:0', text: 'Value' }), scalarInput(a.type, a.value, (v) => upd((at) => { at.value = v; }, `a${idx}-v`), `a${idx}-v`)));
@@ -104,6 +115,7 @@ function attrCard(a, idx) {
}
}),
}, options(ATTRIBUTE_TYPES, f.type)),
+ moveButtons('field', fi, fieldList.length - 1, (delta) => commit((dd) => { moveItem(fieldsAt(dd.attributes[idx], fieldPath), fi, delta); })),
h('button', { class: 'icon danger', 'aria-label': 'Delete field', title: 'Delete field', onclick: () => commit((dd) => { fieldsAt(dd.attributes[idx], fieldPath).splice(fi, 1); }) }, '✕')));
if (f.type === 'table') box.append(fieldsEditor(f.fields || [], [...fieldPath, f.name]));
});
@@ -136,11 +148,49 @@ function attrCard(a, idx) {
};
card.append(h('div', { class: 'hint', style: 'margin:8px 0 0', text: 'Sample rows - they seed model_init and the canvas' }),
h('div', { class: 'rows-wrap' }, rowsTable(a.fields, a.rows, [])),
- h('button', { style: 'margin-top:6px', onclick: () => commit((dd) => { (dd.attributes[idx].rows = dd.attributes[idx].rows || []).push({}); }) }, '+ Row'));
+ h('div', { class: 'add-row', style: 'margin-top:6px' },
+ h('button', { onclick: () => commit((dd) => { (dd.attributes[idx].rows = dd.attributes[idx].rows || []).push({}); }) }, '+ Row'),
+ h('button', { title: 'Rows copied from a spreadsheet, or CSV', onclick: () => pasteRowsDialog(idx) }, 'Paste rows…')));
}
return card;
}
+// ↑ and ↓ of an entry of a list
+function moveButtons(what, i, last, move) {
+ return h('span', { class: 'move' },
+ h('button', { class: 'icon', title: `Move ${what} up`, 'aria-label': `Move ${what} up`, disabled: i === 0 ? 'disabled' : null, onclick: () => move(-1) }, '↑'),
+ h('button', { class: 'icon', title: `Move ${what} down`, 'aria-label': `Move ${what} down`, disabled: i === last ? 'disabled' : null, onclick: () => move(1) }, '↓'));
+}
+
+// rows from a spreadsheet or a CSV, into the sample rows of a table
+function pasteRowsDialog(idx) {
+ const at = state.design.attributes[idx];
+ const ta = h('textarea', { placeholder: at.fields.filter((f) => f.type !== 'table').map((f) => f.name).join('\t'), spellcheck: 'false', 'aria-label': 'Rows' });
+ const head = h('input', { type: 'checkbox', checked: true });
+ const mode = h('select', { 'aria-label': 'Replace or append' }, options(['replace', 'append'], 'replace', { replace: 'Replace the rows', append: 'Append to the rows' }));
+ dialog(`Paste rows into ${at.name}`, h('div', {},
+ h('p', { class: 'hint', text: 'Copy cells from a spreadsheet, or paste CSV. With a header line, the columns go to the fields of the same name, and a name the table has not becomes a new field; without one, they fill the fields in their order.' }),
+ ta,
+ h('div', { class: 'add-row' }, h('label', {}, head, ' First line names the fields'), mode)), [
+ { text: 'Cancel', action: () => true },
+ {
+ text: 'Paste', primary: true,
+ action: () => {
+ const r = parseSampleRows(ta.value, at.fields, head.checked);
+ if (!r.rows.length) { toast('No rows in the text'); return false; }
+ commit((d) => {
+ const t = d.attributes[idx];
+ t.fields.push(...r.added);
+ t.rows = mode.value === 'append' ? [...(t.rows || []), ...r.rows] : r.rows;
+ });
+ toast(`${r.rows.length} row(s) pasted${r.added.length ? `, new field(s) ${r.added.map((f) => f.name).join(', ')}` : ''}${r.ignored ? ` - ${r.ignored} cell(s) had no field` : ''}`);
+ return true;
+ },
+ },
+ ]);
+ ta.focus();
+}
+
// every row at the depth of a list of fields: the rows of the table, or
// of the table field `fieldPath` down, in every row above it
function rowsOfLevel(rows, fieldPath) {
@@ -186,21 +236,70 @@ function scalarInput(type, value, onValue, focusKey) {
}
+// A quick fix from the Checks: what validate( ) proposes, done
+function applyFix(fix) {
+ if (fix.kind === 'createAttribute') {
+ commit((d) => {
+ if (d.attributes.some((a) => a.name === fix.name)) return;
+ d.attributes.push(fix.type === 'table'
+ ? { name: fix.name, type: 'table', fields: [{ name: 'TEXT', type: 'string' }], rows: [{ TEXT: 'Row 1' }] }
+ : { name: fix.name, type: fix.type, value: fix.type === 'bool' ? false : '' });
+ });
+ } else if (fix.kind === 'removeAttribute') {
+ commit((d) => { d.attributes = d.attributes.filter((a) => a.name !== fix.name); });
+ } else if (fix.kind === 'resetProp') {
+ commit((d) => { delete findNode(d.root, fix.node).node.props[fix.prop]; });
+ } else if (fix.kind === 'template') {
+ commit((d) => {
+ const n = findNode(d.root, fix.node).node;
+ const items = n.aggs[fix.agg] || [];
+ const b = n.props[fix.agg];
+ // more than one: the first is the template; none: built from the fields
+ if (items.length > 1) n.aggs[fix.agg] = items.slice(0, 1);
+ else bindListToTable(d, n.id, fix.agg, b.v, true, b.m === 'f');
+ return n.id;
+ });
+ } else if (fix.kind === 'nodesTable') {
+ commit((d) => {
+ bindListToTable(d, fix.node, fix.agg, newTableAttribute(d, true));
+ return fix.node;
+ });
+ } else if (fix.kind === 'bindNew') {
+ commit((d) => {
+ const n = findNode(d.root, fix.node).node;
+ const c = getControl(n.type);
+ newAttributeFor(d, n.id, fix.prop, c && c.props && c.props[fix.prop]);
+ return n.id;
+ });
+ }
+}
+
export function renderIssues() {
const issues = validate(state.design);
- const box = $('#issuesPanel');
- box.replaceChildren();
const errors = issues.filter((i) => i.level === 'error').length;
const warns = issues.filter((i) => i.level === 'warning').length;
const badge = $('#issueCount');
badge.textContent = String(errors || warns);
badge.classList.toggle('hidden', !(errors || warns));
badge.classList.toggle('warn', !errors);
+ $('#btnZip').title = errors ? `${errors} problem(s) - the class may not activate. See Checks.` : "The class with its abapGit files - abapGit: New Offline, Import ZIP";
+ // the list itself only while the tab is open
+ if (state.panel !== 'issues') return issues;
+ const box = $('#issuesPanel');
+ box.replaceChildren();
if (!issues.length) box.append(h('div', { class: 'empty', text: 'All clear - the class is ready to export.' }));
for (const i of issues) {
- box.append(h('div', { class: `issue ${i.level}`, onclick: () => { if (i.node) select(i.node); } },
- h('span', { class: 'lvl', text: i.level === 'error' ? '✕' : i.level === 'warning' ? '!' : 'i' }), h('span', { text: i.text })));
+ box.append(h('div', {
+ class: `issue ${i.level}${i.node ? ' at' : ''}`,
+ onclick: () => {
+ if (!i.node) return;
+ select(i.node);
+ // the property the line is about, in view and focused
+ if (i.prop) focusProp(i.prop);
+ },
+ },
+ h('span', { class: 'lvl', text: i.level === 'error' ? '✕' : i.level === 'warning' ? '!' : 'i' }), h('span', { class: 'itext', text: i.text }),
+ i.fix ? h('button', { class: 'fix', onclick: (e) => { e.stopPropagation(); applyFix(i.fix); } }, i.fix.label) : null));
}
- $('#btnZip').title = errors ? `${errors} problem(s) - the class may not activate. See Checks.` : "The class with its abapGit files - abapGit: New Offline, Import ZIP";
return issues;
}
diff --git a/src/ui/dom.js b/src/ui/dom.js
index 2a91991..c5fad61 100644
--- a/src/ui/dom.js
+++ b/src/ui/dom.js
@@ -34,12 +34,25 @@ let toastTimer = null;
export function toast(text, action) {
const t = $('#toast');
t.replaceChildren(text);
+ t.classList.remove('error');
+ t.setAttribute('role', 'status');
if (action) t.append(h('button', { class: 'toast-action', onclick: () => { t.classList.remove('show'); action.run(); } }, action.label));
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), action ? 6000 : 2200);
}
+// what went wrong stays until it is read: closed by its button, or
+// replaced by the next message
+export function toastError(text) {
+ const t = $('#toast');
+ t.replaceChildren(h('span', { text }), h('button', { class: 'toast-close', 'aria-label': 'Close', title: 'Close', onclick: () => t.classList.remove('show') }, '✕'));
+ t.classList.add('show', 'error');
+ t.setAttribute('role', 'alert');
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => t.classList.remove('show'), 20000);
+}
+
export function download(name, data, type) {
const blob = data instanceof Blob ? data : new Blob([data], { type });
const url = URL.createObjectURL(blob);
@@ -58,6 +71,24 @@ export function dialog(title, body, buttons) {
dlg.showModal();
}
+// A question in the editor's own dialog instead of the browser's
+// confirm( ): resolves with the `value` of the button pressed, or null
+// when the dialog is closed (Escape) - buttons: [{ text, value, primary }]
+export function choose(title, text, buttons) {
+ return new Promise((resolve) => {
+ const dlg = $('#dlg');
+ let answer = null;
+ const body = h('div', {}, (Array.isArray(text) ? text : [text]).map((t) => (t instanceof Node ? t : h('p', { text: t }))));
+ dlg.replaceChildren(h('header', { text: title }), h('div', { class: 'body' }, body),
+ h('footer', {}, buttons.map((b) => h('button', { class: b.primary ? 'primary' : b.danger ? 'danger' : '', 'data-value': String(b.value), onclick: () => { answer = b.value; dlg.close(); } }, b.text))));
+ dlg.addEventListener('close', () => resolve(answer), { once: true });
+ // asked from inside a dialog: the question takes its place
+ if (!dlg.open) dlg.showModal();
+ const primary = dlg.querySelector('footer .primary');
+ if (primary) primary.focus();
+ });
+}
+
export function rememberFocus(box) {
const a = document.activeElement;
if (!a || !box.contains(a) || !a.dataset.focus) return null;
diff --git a/src/ui/edit.js b/src/ui/edit.js
index 4c1a9e7..b4f7d2f 100644
--- a/src/ui/edit.js
+++ b/src/ui/edit.js
@@ -1,12 +1,25 @@
// Edits that start from the UI: inserting a control or a building block
// at the selection or at a drop, moving by drop, and the clipboard.
-import { createNode, clone, walk, findNode, newId, aggAccepts, insertNode, dropTarget, removeNode, moveNode, bindableAggs, bindListToTable } from '../model.js';
+import {
+ createNode, clone, walk, findNode, newId, aggAccepts, insertNode, dropTarget, removeNode, moveNode, bindableAggs, bindListToTable,
+ isAncestor, attrTypeFor, checkAttributeName, moveNodes,
+} from '../model.js';
+import { splitType, getControl } from '../catalog.js';
import { PATTERNS } from '../patterns.js';
-import { state, commit, ancestors } from './store.js';
+import { state, commit, ancestors, selectedIds, selectMany, savePrefs } from './store.js';
import { toast, displayType } from './dom.js';
import { describeControl } from './preview.js';
import { renderInsert } from './insert.js';
+// the controls inserted last, first in the Controls panel
+function rememberType(type) {
+ const list = [type, ...(state.prefs.recentTypes || []).filter((t) => t !== type)].slice(0, 6);
+ if (list.join() === (state.prefs.recentTypes || []).join()) return;
+ state.prefs.recentTypes = list;
+ savePrefs();
+ renderInsert();
+}
+
export function addControl(type, onId = state.selected) {
let id = null;
commit((d) => {
@@ -18,7 +31,7 @@ export function addControl(type, onId = state.selected) {
id = insertNode(d, createNode(type), t.parentId, t.agg, t.index);
return id;
});
- if (id) offerRows(id);
+ if (id) { rememberType(type); offerRows(id); }
}
// A control that repeats rows (a list, a table, a tree) just inserted:
@@ -136,7 +149,19 @@ export function handleDrop(data, onId, place, zone) {
id = insertNode(d, createNode(what), place.parentId, place.agg, place.index);
return id;
});
- if (id) offerRows(id);
+ if (id) { rememberType(what); offerRows(id); }
+ } else if (what.includes(',')) {
+ // several selected controls dragged together
+ const ids = what.split(',');
+ commit((d) => {
+ const first = findNode(d.root, ids[0]);
+ if (!first) return undefined;
+ const t = place || dropTarget(d, onId, first.node.type);
+ const r = t && moveNodes(d, ids, t.parentId, t.agg, t.index);
+ if (!r) toast('They cannot go there');
+ return r || undefined;
+ });
+ if (ids.every((id) => findNode(state.design.root, id))) selectMany(ids);
} else {
commit((d) => {
const hit = findNode(d.root, what);
@@ -151,19 +176,50 @@ export function handleDrop(data, onId, place, zone) {
}
-// the selection as text: a design node in JSON, with a marker so a paste
-// knows it - the system clipboard carries it between designs and tabs, the
-// editor's own copy covers a browser that refuses to read the clipboard
+// a new attribute for property `p` of a node, named after both, typed
+// after the property, with its value as the start - the property is bound
+// to it; returns the name
+export function newAttributeFor(d, nodeId, p, def) {
+ const n = findNode(d.root, nodeId).node;
+ const base = `${splitType(n.type).name}_${p}`.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().replace(/[^a-z0-9_]/g, '_').slice(0, 26);
+ let name = base;
+ let i = 1;
+ while (d.attributes.some((a) => a.name === name) || checkAttributeName(name)) name = `${base}${++i}`;
+ const cur = n.props[p];
+ const type = attrTypeFor(def);
+ d.attributes.push({ name, type, value: cur && cur.m === 's' ? (type === 'bool' ? cur.v === 'true' : cur.v) : type === 'bool' ? false : '' });
+ n.props[p] = { m: 'b', v: name };
+ return name;
+}
+
+
+// the selection as text: design nodes in JSON, with a marker so a paste
+// knows them - the system clipboard carries them between designs and tabs,
+// the editor's own copy covers a browser that refuses to read the clipboard
const CLIP_MARK = 'abap2ui5-designer:node';
export function copySelection(cut) {
- const hit = findNode(state.design.root, state.selected);
- if (!hit || !hit.parent) return;
- const text = JSON.stringify({ [CLIP_MARK]: 1, node: hit.node });
+ const root = state.design.root;
+ const ids = selectedIds().filter((id) => !selectedIds().some((o) => o !== id && isAncestor(root, o, id)));
+ const hits = ids.map((id) => findNode(root, id)).filter((x) => x && x.parent);
+ if (!hits.length) return;
+ // in the order of the view, whatever the order of the clicks
+ const order = [];
+ walk(root, (n) => { order.push(n.id); return true; });
+ hits.sort((a, b) => order.indexOf(a.node.id) - order.indexOf(b.node.id));
+ const nodes = hits.map((x) => x.node);
+ const text = JSON.stringify(nodes.length === 1 ? { [CLIP_MARK]: 1, node: nodes[0] } : { [CLIP_MARK]: 1, nodes });
state.clipboard = text;
try { navigator.clipboard.writeText(text).catch(() => {}); } catch { /* no clipboard */ }
- if (cut) commit((d) => removeNode(d, hit.node.id));
- toast(`${displayType(hit.node.type)} ${cut ? 'cut' : 'copied'}`);
+ if (cut) {
+ commit((d) => {
+ let next;
+ for (const n of nodes) next = removeNode(d, n.id) || next;
+ return next && findNode(d.root, next) ? next : 'root';
+ });
+ }
+ const what = nodes.length === 1 ? displayType(nodes[0].type) : `${nodes.length} controls`;
+ toast(`${what} ${cut ? 'cut' : 'copied'}`);
}
export async function pasteSelection() {
@@ -177,19 +233,66 @@ export async function pasteSelection() {
}
function pasteNodes(onId, place, text = state.clipboard) {
- let node;
+ let nodes;
try {
const parsed = JSON.parse(text);
- node = parsed && parsed[CLIP_MARK] ? parsed.node : null;
- } catch { node = null; }
- if (!node) { toast('Nothing to paste'); return; }
- const fresh = clone(node);
- walk(fresh, (n) => { n.id = newId(); return true; });
+ nodes = parsed && parsed[CLIP_MARK] ? parsed.nodes || (parsed.node ? [parsed.node] : null) : null;
+ } catch { nodes = null; }
+ if (!nodes || !nodes.length) { toast('Nothing to paste'); return; }
+ const fresh = nodes.map((node) => {
+ const f = clone(node);
+ walk(f, (n) => { n.id = newId(); return true; });
+ return f;
+ });
+ const ids = [];
commit((d) => {
- const t = place || dropTarget(d, onId || 'root', fresh.type);
- if (!t) { toast(`${displayType(fresh.type)} does not fit here`); return undefined; }
+ const t = place || dropTarget(d, onId || 'root', fresh[0].type);
+ if (!t) { toast(`${displayType(fresh[0].type)} does not fit here`); return undefined; }
const p = findNode(d.root, t.parentId);
- if (!p || !aggAccepts(p.node, t.agg, fresh.type)) { toast(`${displayType(fresh.type)} does not fit here`); return undefined; }
- return insertNode(d, fresh, t.parentId, t.agg, t.index);
+ if (!p || !aggAccepts(p.node, t.agg, fresh[0].type)) { toast(`${displayType(fresh[0].type)} does not fit here`); return undefined; }
+ let index = t.index;
+ for (const f of fresh) {
+ if (!p || !aggAccepts(p.node, t.agg, f.type)) {
+ // nothing of a paste that does not fit as a whole
+ throw new Error(`${displayType(f.type)} does not fit here`);
+ }
+ ids.push(insertNode(d, f, t.parentId, t.agg, index));
+ if (index != null) index += 1;
+ }
+ return ids[ids.length - 1];
+ });
+ if (ids.length > 1 && findNode(state.design.root, ids[0])) selectMany(ids);
+}
+
+
+// The properties of the selected control, to set on others: a format
+// painter. A list's row binding stays behind - it belongs to its template.
+export function copyProperties() {
+ const hit = findNode(state.design.root, state.selected);
+ if (!hit || !hit.parent) return;
+ const skip = bindableAggs(hit.node);
+ const props = Object.fromEntries(Object.entries(hit.node.props).filter(([p]) => !skip.includes(p)));
+ state.propClipboard = { type: hit.node.type, props: clone(props) };
+ toast(`${Object.keys(props).length} propert${Object.keys(props).length === 1 ? 'y' : 'ies'} of ${displayType(hit.node.type)} copied`);
+}
+
+// onto every selected control, each property that control has
+export function pasteProperties() {
+ const clip = state.propClipboard;
+ if (!clip) { toast('Copy the properties of a control first'); return; }
+ let set = 0;
+ commit((d) => {
+ for (const id of selectedIds()) {
+ const n = findNode(d.root, id)?.node;
+ if (!n) continue;
+ const c = getControl(n.type);
+ const skip = bindableAggs(n);
+ for (const [p, v] of Object.entries(clip.props)) {
+ if (skip.includes(p) || (c && c.props && !c.props[p] && n.type !== clip.type)) continue;
+ n.props[p] = clone(v);
+ set += 1;
+ }
+ }
});
+ toast(set ? `${set} propert${set === 1 ? 'y' : 'ies'} set` : 'None of them fits the selected control');
}
diff --git a/src/ui/files.js b/src/ui/files.js
index ac19a38..028646f 100644
--- a/src/ui/files.js
+++ b/src/ui/files.js
@@ -7,8 +7,8 @@ import { fileName } from '../abap.js';
import { fromClass, classReport, classFiles, generateClassSource, sourceUrl, repoClassUrl } from '../clas.js';
import { playgroundLink, vscodeLink, PLAYGROUND_URL } from '../handoff.js';
import { zip } from '../zip.js';
-import { state, commit, replaceDesign, showPanel, recentDesigns, designToLink } from './store.js';
-import { $, h, toast, download, dialog } from './dom.js';
+import { state, commit, replaceDesign, showPanel, recentDesigns, designToLink, libraryDesigns, saveToLibrary, removeFromLibrary } from './store.js';
+import { $, h, toast, toastError, download, dialog, choose } from './dom.js';
import { addControlByName } from './edit.js';
export function newDialog() {
@@ -20,28 +20,63 @@ export function newDialog() {
}, h('b', { text: s.title }), h('span', { text: s.text }))))), [{ text: 'Cancel', action: () => true }]);
}
-// the designs New, Open, Import or a link replaced - back with one click
+// The designs of this browser: those saved under their class name, and
+// those New, Open, Import or a link replaced - back with one click
export function recentDialog() {
- const list = recentDesigns();
const when = (t) => new Date(t).toLocaleString();
- dialog('Recent designs', list.length
- ? h('div', { class: 'starters' }, list.map((r) => h('button', {
- class: 'starter',
- onclick: () => {
- try { replaceDesign(normalizeDesign(JSON.parse(r.json))); $('#dlg').close(); toast(`Back to ${r.className}`); } catch (err) { toast(err.message); }
- },
- }, h('b', { text: r.className }), h('span', { text: r.description || ' ' }), h('span', { class: 'muted', text: when(r.at) }))))
- : h('p', { class: 'hint', text: 'Nothing yet - a design lands here when New, Open, Import or a shared link replaces it.' }),
- [{ text: 'Close', action: () => true }]);
+ const open = (r, what) => {
+ try { replaceDesign(normalizeDesign(JSON.parse(r.json))); $('#dlg').close(); toast(`${what} ${r.className}`); } catch (err) { toastError(err.message); }
+ };
+ const render = () => {
+ const saved = libraryDesigns();
+ const recent = recentDesigns();
+ const card = (r, what, extra) => h('div', { class: 'starter-wrap' },
+ h('button', { class: 'starter', onclick: () => open(r, what) },
+ h('b', { text: r.className }), h('span', { text: r.description || ' ' }), h('span', { class: 'muted', text: when(r.at) })),
+ extra || null);
+ return h('div', {},
+ h('h3', { class: 'dlg-sub', text: 'Saved in this browser' }),
+ saved.length
+ ? h('div', { class: 'starters' }, saved.map((r) => card(r, 'Opened', h('button', {
+ class: 'icon danger remove', title: `Remove ${r.className} from this browser`, 'aria-label': `Remove ${r.className}`,
+ onclick: async () => {
+ const ok = await choose(`Remove ${r.className}?`, 'The design is removed from this browser. A file saved with Save design (.json) stays.', [
+ { text: 'Cancel', value: false }, { text: 'Remove', value: true, primary: true },
+ ]);
+ if (ok) removeFromLibrary(r.className);
+ recentDialog();
+ },
+ }, '✕'))))
+ : h('p', { class: 'hint', text: 'Nothing yet - File, Save in browser keeps the open design here under its class name.' }),
+ h('h3', { class: 'dlg-sub', text: 'Recent - replaced by New, Open, Import or a link' }),
+ recent.length
+ ? h('div', { class: 'starters' }, recent.map((r) => card(r, 'Back to')))
+ : h('p', { class: 'hint', text: 'Nothing yet - a design lands here when New, Open, Import or a shared link replaces it.' }));
+ };
+ dialog('Designs in this browser', render(), [{ text: 'Close', action: () => true }]);
}
-// a link that carries the design - open it anywhere, the editor loads it
+export function saveToBrowser() {
+ try {
+ const had = saveToLibrary(state.design);
+ toast(`${state.design.className} ${had ? 'updated' : 'saved'} in this browser`, { label: 'Show all', run: recentDialog });
+ } catch (err) {
+ toastError(`Not saved in this browser: ${err.message}`);
+ }
+}
+
+// a link that carries the design - open it anywhere, the editor loads it;
+// a link longer than some browsers, chats and mail take says so
+const LONG_LINK = 8000;
export async function shareLink() {
try {
const url = await designToLink(state.design);
- await copyText(url, 'Link');
+ const copied = await copyText(url, 'Link');
+ if (copied && url.length > LONG_LINK) {
+ toast(`Link copied - ${Math.round(url.length / 1000)} kB long; some chats and mail cut such links. A file is safer.`, { label: 'Save .json', run: saveDesign });
+ }
} catch (err) {
- toast(`No link: ${err.message}`);
+ toastError(`No link: ${err.message}`);
}
}
@@ -195,7 +230,7 @@ async function fetchClass(raw) {
if (!/^\s*(?:"|\*|CLASS\b)/im.test(text)) throw new Error(`${href} is not an ABAP class`);
importClassText(text);
} catch (err) {
- toast(`Cannot open the class: ${err.message}`);
+ toastError(`Cannot open the class: ${err.message}`);
}
}
@@ -239,7 +274,7 @@ export async function openInPlayground() {
const f = classFile();
window.open(await playgroundLink(f.name, f.source, playgroundBase()), '_blank', 'noopener');
} catch (err) {
- toast(`No playground link: ${err.message}`);
+ toastError(`No playground link: ${err.message}`);
}
}
@@ -250,7 +285,7 @@ export async function openInVscode() {
location.assign(href);
toast('Opening in VS Code - the abap2UI5 extension takes the class', { label: 'Copy link', run: () => copyText(href, 'Link') });
} catch (err) {
- toast(`No VS Code link: ${err.message}`);
+ toastError(`No VS Code link: ${err.message}`);
}
}
@@ -276,7 +311,7 @@ export function openFile(accept) {
throw new Error('not a design (.json), a view (.xml) or a class (.abap)');
}
} catch (err) {
- toast(`Cannot open ${f.name}: ${err.message}`);
+ toastError(`Cannot open ${f.name}: ${err.message}`);
}
};
inp.click();
@@ -288,10 +323,20 @@ export function saveDesign() {
}
-export function exportZip() {
+export async function exportZip() {
const d = state.design;
const errors = validate(d).filter((i) => i.level === 'error');
- if (errors.length && !confirm(`${errors.length} problem(s) - the class may not activate:\n\n${errors.slice(0, 5).map((e) => '• ' + e.text).join('\n')}\n\nExport anyway?`)) return;
+ if (errors.length) {
+ const go = await choose(`${errors.length} problem(s) - the class may not activate`, [
+ h('ul', {}, errors.slice(0, 5).map((e) => h('li', { text: e.text }))),
+ errors.length > 5 ? `… and ${errors.length - 5} more under Checks.` : '',
+ ].filter(Boolean), [
+ { text: 'Show the checks', value: 'checks' },
+ { text: 'Export anyway', value: 'export', primary: true },
+ ]);
+ if (go === 'checks') showPanel('issues');
+ if (go !== 'export') return;
+ }
const bytes = zip(classFiles(d));
download(`${d.className.toLowerCase().replace(/\//g, '#')}.zip`, new Blob([bytes], { type: 'application/zip' }));
toast('Exported - abapGit: New Offline, Import ZIP');
diff --git a/src/ui/inline.js b/src/ui/inline.js
new file mode 100644
index 0000000..66d4b59
--- /dev/null
+++ b/src/ui/inline.js
@@ -0,0 +1,76 @@
+// Editing the text of a control where it is: a double-click on the canvas
+// opens a field over the control for its text (title, header text, …);
+// every keystroke is an edit, Enter or a click elsewhere keeps it, Escape
+// takes it back.
+import { getControl } from '../catalog.js';
+import { findNode } from '../model.js';
+import { state, commit, undo } from './store.js';
+import { $, h, toast, displayType } from './dom.js';
+import { frameToPage } from './preview.js';
+
+// the properties that are a control's text, the first it has wins
+const TEXT_PROPS = ['text', 'title', 'headerText', 'label', 'placeholder', 'value', 'number', 'intro'];
+
+export function inlineProp(node) {
+ const c = getControl(node.type);
+ if (!c || !c.props) return null;
+ return TEXT_PROPS.find((p) => c.props[p] && c.props[p].type === 'string') || null;
+}
+
+let field = null;
+
+export function closeInlineEdit() { close(); }
+if (typeof window !== 'undefined') window.addEventListener('resize', () => close());
+
+function close() {
+ if (!field) return;
+ const f = field;
+ field = null;
+ f.remove();
+}
+
+// `rect`: the control's box in the frame's coordinates
+export function openInlineEdit(id, rect) {
+ close();
+ const hit = findNode(state.design.root, id);
+ if (!hit || id === 'root') return;
+ const p = inlineProp(hit.node);
+ if (!p) { toast(`${displayType(hit.node.type)} has no text to edit here - see Properties`); return; }
+ const cur = hit.node.props[p];
+ if (cur && cur.m !== 's') { toast(`${displayType(hit.node.type)}.${p} is bound - change it in Properties`); return; }
+ const start = cur ? cur.v : '';
+ const frame = $('#preview').getBoundingClientRect();
+ const at = frameToPage(Math.max(0, rect.left), Math.max(0, rect.top));
+ let edited = false;
+ const input = h('input', {
+ class: 'inline-edit',
+ value: start,
+ spellcheck: 'true',
+ 'aria-label': `${displayType(hit.node.type)} ${p}`,
+ oninput: (e) => {
+ const v = e.target.value;
+ // only an edit that changed the design is one Escape takes back
+ const rev = state.rev;
+ commit((d) => {
+ const n = findNode(d.root, id).node;
+ if (v !== '') n.props[p] = { m: 's', v }; else delete n.props[p];
+ }, { mergeKey: `inline:${id}:${p}`, keepFocus: true });
+ if (state.rev !== rev) edited = true;
+ },
+ onkeydown: (e) => {
+ e.stopPropagation();
+ if (e.key === 'Enter') { e.preventDefault(); close(); }
+ // Escape: the text as it was - the edit is one undo step, taken back
+ else if (e.key === 'Escape') { e.preventDefault(); const back = edited; close(); if (back) undo(); }
+ },
+ onblur: () => close(),
+ });
+ input.style.left = `${at.x}px`;
+ input.style.top = `${at.y}px`;
+ input.style.width = `${Math.max(140, Math.min(rect.width * at.z, frame.right - at.x))}px`;
+ input.style.height = `${Math.max(28, Math.min(rect.height * at.z, 44))}px`;
+ document.body.append(input);
+ field = input;
+ input.focus();
+ input.select();
+}
diff --git a/src/ui/insert.js b/src/ui/insert.js
index a47fce9..c897476 100644
--- a/src/ui/insert.js
+++ b/src/ui/insert.js
@@ -1,6 +1,6 @@
// The Controls panel: building blocks and the palette, by category, to
// drag or click.
-import { CONTROLS, CATEGORIES, DESCRIPTIONS, RUNTIME } from '../catalog.js';
+import { CONTROLS, CATEGORIES, DESCRIPTIONS, RUNTIME, getControl } from '../catalog.js';
import { PATTERNS } from '../patterns.js';
import { canHaveChildren, findNode } from '../model.js';
import { state } from './store.js';
@@ -16,12 +16,33 @@ export function renderInsert() {
box.replaceChildren();
const match = (...texts) => !q || texts.some((t) => t && t.toLowerCase().includes(q));
+ const item = (type, glyph, cls) => h('div', {
+ class: `ins-item${cls ? ` ${cls}` : ''}`,
+ draggable: 'true',
+ tabindex: '0',
+ role: 'button',
+ // the copy under Recently used is not the control's entry of the palette
+ [cls === 'recent' ? 'data-recent' : 'data-type']: type,
+ title: `${RUNTIME[type] ? RUNTIME[type].className : type} - drag onto the canvas or the outline, or click to insert at the selection`,
+ ondragstart: (e) => { e.dataTransfer.setData('text/plain', `dsg:new:${type}`); e.dataTransfer.effectAllowed = 'copy'; },
+ onclick: () => addControl(type),
+ }, h('span', { class: 'ig', text: glyph }), h('span', { class: 'in', text: displayType(type) }), h('span', { class: 'id', text: DESCRIPTIONS[type] || (RUNTIME[type] ? RUNTIME[type].className : '') }));
+
+ // the controls inserted last - only without a search, which finds them anyway
+ const recent = q ? [] : (state.prefs.recentTypes || []).filter((t) => getControl(t));
+ if (recent.length) {
+ box.append(h('div', { class: 'ins-group recent' }, h('h3', { text: 'Recently used' }),
+ recent.map((t) => item(t, CONTROLS[t] ? GLYPHS[CONTROLS[t].category] : '⚙', 'recent'))));
+ }
+
const pats = PATTERNS.filter((p) => match(p.title, p.text));
if (pats.length) {
box.append(h('div', { class: 'ins-group' }, h('h3', { text: 'Building blocks' }),
pats.map((p) => h('div', {
class: 'ins-item pattern',
draggable: 'true',
+ tabindex: '0',
+ role: 'button',
'data-pattern': p.id,
title: 'Drag onto the canvas or the outline, or click to insert at the selection',
ondragstart: (e) => { e.dataTransfer.setData('text/plain', `dsg:pattern:${p.id}`); e.dataTransfer.effectAllowed = 'copy'; },
@@ -33,26 +54,12 @@ export function renderInsert() {
if (!items.length) continue;
box.append(h('div', { class: 'ins-group' },
h('h3', { text: cat === 'Parts' ? 'Parts - inside their parent' : cat }),
- items.map((type) => h('div', {
- class: 'ins-item',
- draggable: 'true',
- 'data-type': type,
- title: `${type} - drag onto the canvas or the outline, or click to insert at the selection`,
- ondragstart: (e) => { e.dataTransfer.setData('text/plain', `dsg:new:${type}`); e.dataTransfer.effectAllowed = 'copy'; },
- onclick: () => addControl(type),
- }, h('span', { class: 'ig', text: GLYPHS[cat] }), h('span', { class: 'in', text: displayType(type) }), h('span', { class: 'id', text: DESCRIPTIONS[type] || '' })))));
+ items.map((type) => item(type, GLYPHS[cat]))));
}
const runtime = Object.keys(RUNTIME).filter((k) => match(k, RUNTIME[k].className));
if (runtime.length) {
box.append(h('div', { class: 'ins-group' }, h('h3', { text: 'From the UI5 runtime - not checked against 1.71' }),
- runtime.map((type) => h('div', {
- class: 'ins-item runtime',
- draggable: 'true',
- 'data-type': type,
- title: `${RUNTIME[type].className} - drag onto the canvas or the outline, or click to insert at the selection`,
- ondragstart: (e) => { e.dataTransfer.setData('text/plain', `dsg:new:${type}`); e.dataTransfer.effectAllowed = 'copy'; },
- onclick: () => addControl(type),
- }, h('span', { class: 'ig', text: '⚙' }), h('span', { class: 'in', text: displayType(type) }), h('span', { class: 'id', text: RUNTIME[type].className })))));
+ runtime.map((type) => item(type, '⚙', 'runtime'))));
}
if (!box.childElementCount) box.append(h('div', { class: 'empty', text: 'Nothing matches.' }));
// any control UI5 has, by its class name
@@ -64,6 +71,22 @@ export function renderInsert() {
h('p', { class: 'hint', text: 'The canvas asks its UI5 for the control; its properties, aggregations and events come from there. Not checked against 1.71.' })));
}
+// The keys of the Controls panel: Enter in the search inserts the first
+// match, ↓ goes into the list; there ↑ ↓ walk, Enter or Space inserts
+export function onInsertKey(e) {
+ const items = [...$('#insertList').querySelectorAll('.ins-item')];
+ if (e.target.id === 'insertSearch') {
+ if (e.key === 'Enter' && items[0]) { e.preventDefault(); items[0].click(); }
+ else if (e.key === 'ArrowDown' && items[0]) { e.preventDefault(); items[0].focus(); }
+ return;
+ }
+ const i = items.indexOf(e.target);
+ if (i < 0) return;
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.target.click(); }
+ else if (e.key === 'ArrowDown') { e.preventDefault(); (items[i + 1] || items[i]).focus(); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); (items[i - 1] || $('#insertSearch')).focus(); }
+}
+
// where a click in the insert panel puts the control
export function renderInsertTarget() {
const box = $('#insertTarget');
diff --git a/src/ui/inspector.js b/src/ui/inspector.js
index 2389737..166475e 100644
--- a/src/ui/inspector.js
+++ b/src/ui/inspector.js
@@ -3,11 +3,13 @@
import { CONTROLS, DESCRIPTIONS, getControl, splitType, NAMESPACES } from '../catalog.js';
import {
createNode, findNode, insertNode, aggAccepts, rowContext, bindableAggs, bindListToTable, hasOwnTemplate, unbindList,
- attributeFits, checkAttributeName, checkEventName, checkStatic,
+ attributeFits, checkEventName, checkStatic, collectEvents,
} from '../model.js';
-import { state, commit } from './store.js';
-import { $, h, options, toast, displayType, rememberFocus, restoreFocus } from './dom.js';
-import { newTableAttribute, offerRows } from './edit.js';
+import { state, commit, selectedIds } from './store.js';
+import { $, h, options, toast, choose, displayType, rememberFocus, restoreFocus } from './dom.js';
+import { newTableAttribute, offerRows, newAttributeFor } from './edit.js';
+import { WRAPPERS, wrapSelection, deleteSelection } from './actions.js';
+import { propertyDefaults } from './preview.js';
const KINDS = { s: 'Aa', b: '@', f: '#', x: 'ƒx' };
@@ -43,6 +45,14 @@ export function renderInspector() {
const node = hit.node;
const c = getControl(node.type);
const link = apiLink(node.type);
+ const many = selectedIds();
+ if (many.length > 1) box.append(multiBar(many));
+ // the defaults of the canvas's UI5, asked once per control class
+ const defaults = propertyDefaults(node.type, () => {
+ // whichever control of that type is selected by the time they come
+ const now = findNode(state.design.root, state.selected);
+ if (now && now.node.type === node.type) renderInspector();
+ }) || {};
const ctx = rowContext(d, node.id);
const filter = h('input', {
@@ -67,10 +77,10 @@ export function renderInspector() {
h('div', { class: 'add-row' },
h('select', {
'aria-label': `Bind ${agg} to a table`,
- onchange: (e) => {
+ onchange: async (e) => {
const v = e.target.value;
- const replace = !v || !hasOwnTemplate(node, agg, d)
- || confirm(`Replace the ${agg} of this ${displayType(node.type)} with a template built from the fields of ${v}?\n\nCancel keeps them and only sets the binding.`);
+ const replace = !v || !hasOwnTemplate(node, agg, d) || await askReplace(node, agg, v);
+ if (replace === null) { renderInspector(); return; }
commit((dd) => {
if (v.startsWith('#')) bindListToTable(dd, node.id, agg, v.slice(1), replace, true);
else if (v) bindListToTable(dd, node.id, agg, v, replace);
@@ -97,7 +107,7 @@ export function renderInspector() {
for (const [title, test] of PROP_GROUPS) {
const rows = defs.filter(([, def]) => test(def));
if (!rows.length) continue;
- box.append(group(title, h('div', {}, title === 'Content' ? h('p', { class: 'hint', text: KIND_TITLE }) : null, rows.map(([p, def]) => propRow(node, p, def, ctx)))));
+ box.append(group(title, h('div', {}, title === 'Content' ? h('p', { class: 'hint', text: KIND_TITLE }) : null, rows.map(([p, def]) => propRow(node, p, def, ctx, defaults[p])))));
}
// what an import brought along that the catalogue does not know: shown
// as text, written to the class as it is, and removable
@@ -127,7 +137,9 @@ export function renderInspector() {
if (evs.length) {
box.append(group('Events', h('div', {},
h('p', { class: 'hint', text: 'A name becomes client->_event( ) in the view and a WHEN branch in on_event.' }),
- evs.map((ev) => eventRow(node, ev)))));
+ evs.map((ev) => eventRow(node, ev)),
+ // the names the design raises already: one WHEN branch for two controls
+ h('datalist', { id: 'eventNames' }, collectEvents(d).map((n) => h('option', { value: n }))))));
}
}
@@ -171,6 +183,8 @@ function eventRow(node, ev) {
spellcheck: 'false',
'aria-label': `Event ${ev}`,
'data-focus': `ev-${ev}`,
+ list: 'eventNames',
+ autocomplete: 'off',
oninput: (e) => {
const v = e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
// the field shows what the class gets, the caret stays where it was
@@ -206,11 +220,22 @@ function suggestEvent(node, ev) {
return ev === 'press' ? (base || t) : `${base || t}_${ev.toUpperCase()}`;
}
-function newTableFor(node, agg) {
+// Replace a template built by hand? true: replace, false: keep it and
+// only bind, null: neither (the dialog was closed)
+function askReplace(node, agg, table) {
+ return choose(`Bind the ${agg} to ${table}`, [
+ `This ${displayType(node.type)} has ${agg} of its own. Replace them with a template built from the fields of ${table}, or keep them and only set the binding?`,
+ ], [
+ { text: 'Keep, only bind', value: false },
+ { text: 'Replace with a template', value: true, primary: true },
+ ]);
+}
+
+async function newTableFor(node, agg) {
// a template built by hand stays unless the user lets it go - as when
// the rows are bound to a table that is there
- const replace = !hasOwnTemplate(node, agg, state.design)
- || confirm(`Replace the ${agg} of this ${displayType(node.type)} with a template built from the fields of the new table?\n\nCancel keeps them and only sets the binding.`);
+ const replace = !hasOwnTemplate(node, agg, state.design) || await askReplace(node, agg, 'a new table');
+ if (replace === null) return;
commit((d) => {
const name = newTableAttribute(d, node.type === 'Tree');
bindListToTable(d, node.id, agg, name, replace);
@@ -219,11 +244,16 @@ function newTableFor(node, agg) {
toast('Table attribute created - edit it on the Data tab');
}
-function attrTypeFor(def) {
- if (def.type === 'boolean') return 'bool';
- if (def.type === 'int') return 'int';
- if (def.type === 'float') return 'decimal';
- return 'string';
+// several controls selected: what can be done with all of them
+function multiBar(ids) {
+ const wrap = h('select', {
+ 'aria-label': 'Wrap the selection',
+ onchange: (e) => { if (e.target.value) wrapSelection(e.target.value); },
+ }, h('option', { value: '', text: 'Wrap in…' }), options(WRAPPERS, null));
+ return h('div', { class: 'multi-bar' },
+ h('b', { text: `${ids.length} controls selected` }),
+ h('span', { class: 'hint', text: 'Ctrl+click adds or removes one. The properties are those of the last.' }),
+ h('div', { class: 'add-row' }, wrap, h('button', { class: 'danger', onclick: () => deleteSelection() }, 'Delete all')));
}
// a value as the UI5 expression that says the same: a binding keeps its
@@ -236,7 +266,7 @@ function asExpression(value) {
return value.v;
}
-function propRow(node, p, def, ctx) {
+function propRow(node, p, def, ctx, dflt) {
const d = state.design;
const value = node.props[p];
const m = value ? value.m : 's';
@@ -248,6 +278,7 @@ function propRow(node, p, def, ctx) {
class: `kind k-${m}`,
title: KIND_TITLE,
'aria-label': `${p}: kind of value`,
+ 'data-focus': `p-${p}-kind`,
onchange: (e) => update((n) => {
const mm = e.target.value;
if (mm === 's') delete n.props[p];
@@ -267,8 +298,9 @@ function propRow(node, p, def, ctx) {
const vals = def.type === 'boolean' ? ['true', 'false'] : def.values;
editor = h('select', {
'aria-label': p,
+ 'data-focus': focusKey,
onchange: (e) => update((n) => { if (e.target.value) n.props[p] = { m: 's', v: e.target.value }; else delete n.props[p]; }),
- }, h('option', { value: '', text: '— default —' }), options(vals, v));
+ }, h('option', { value: '', text: dflt != null && dflt !== '' ? `— default (${dflt}) —` : '— default —' }), options(vals, v));
} else {
const bad = checkStatic(def, v);
editor = h('input', {
@@ -278,7 +310,7 @@ function propRow(node, p, def, ctx) {
'aria-label': p,
'data-focus': focusKey,
spellcheck: def.type === 'string' ? 'true' : 'false',
- placeholder: def.hint || (def.type === 'cssSize' ? 'e.g. 10rem' : def.type === 'icon' ? 'sap-icon://…' : ''),
+ placeholder: dflt != null && dflt !== '' ? `default: ${dflt}` : def.hint || (def.type === 'cssSize' ? 'e.g. 10rem' : def.type === 'icon' ? 'sap-icon://…' : ''),
oninput: (e) => {
// a value UI5 would refuse shows as one while it is typed
const w = checkStatic(def, e.target.value);
@@ -293,6 +325,7 @@ function propRow(node, p, def, ctx) {
editor = h('div', { class: 'val' },
h('select', {
'aria-label': `${p}: attribute`,
+ 'data-focus': `${focusKey}-attr`,
onchange: (e) => update((n) => { n.props[p].v = e.target.value; }),
}, value.v && !attrs.some((a) => a.name === value.v) ? h('option', { value: value.v, text: `${value.v} (missing)` }) : null,
options(attrs.map((a) => a.name), value.v, Object.fromEntries(attrs.map((a) => [a.name, `${a.name} : ${a.type}`])))),
@@ -301,6 +334,7 @@ function propRow(node, p, def, ctx) {
const fields = ctx ? ctx.fields : [];
editor = h('select', {
'aria-label': `${p}: field`,
+ 'data-focus': `${focusKey}-field`,
onchange: (e) => update((n) => { n.props[p].v = e.target.value; }),
}, value.v && !fields.some((f) => f.name === value.v) ? h('option', { value: value.v, text: `${value.v} (missing)` }) : null,
options(fields.map((f) => f.name), value.v));
@@ -319,15 +353,25 @@ function propRow(node, p, def, ctx) {
}
function newAttrFor(node, p, def) {
- commit((d) => {
- const base = `${splitType(node.type).name}_${p}`.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().replace(/[^a-z0-9_]/g, '_').slice(0, 26);
- let name = base;
- let i = 1;
- while (d.attributes.some((a) => a.name === name) || checkAttributeName(name)) name = `${base}${++i}`;
- const n = findNode(d.root, node.id).node;
- const cur = n.props[p];
- d.attributes.push({ name, type: attrTypeFor(def), value: cur && cur.m === 's' ? cur.v : '' });
- n.props[p] = { m: 'b', v: name };
- });
+ commit((d) => { newAttributeFor(d, node.id, p, def); });
toast('Attribute created on the Data tab');
}
+
+// a property brought into view and focused - from a line of the Checks:
+// the filter that would hide it is cleared, its group opened
+export function focusProp(p) {
+ if (state.inspector.filter || state.inspector.onlySet) {
+ state.inspector.filter = '';
+ state.inspector.onlySet = false;
+ renderInspector();
+ }
+ const row = $('#inspector').querySelector(`.prop[data-prop="${CSS.escape(p)}"]`);
+ if (!row) return;
+ const g = row.closest('details');
+ if (g) g.open = true;
+ row.scrollIntoView({ block: 'center' });
+ row.classList.add('flash');
+ setTimeout(() => row.classList.remove('flash'), 1200);
+ const ed = row.querySelector('.val input, .val select');
+ if (ed) ed.focus();
+}
diff --git a/src/ui/keys.js b/src/ui/keys.js
index b9035e2..99e2379 100644
--- a/src/ui/keys.js
+++ b/src/ui/keys.js
@@ -1,10 +1,36 @@
// The keyboard shortcuts of the editor - also reached from the canvas.
-import { findNode, removeNode, duplicateNode } from '../model.js';
-import { state, select, commit, undo, redo } from './store.js';
-import { toast } from './dom.js';
+import { state, select, undo, redo, selectedIds } from './store.js';
import { openPalette } from './palette.js';
import { copySelection, pasteSelection } from './edit.js';
import { saveDesign } from './files.js';
+import { deleteSelection, duplicateSelection, moveSelection, selectParent } from './actions.js';
+import { openContextMenuAtSelection } from './context.js';
+import { h, dialog } from './dom.js';
+
+// every shortcut, for the dialog ? opens - the one place they are listed
+export const SHORTCUTS = [
+ ['Ctrl+K', 'Command palette: insert, jump to or run anything'],
+ ['Ctrl+Z / Ctrl+Shift+Z', 'Undo / redo'],
+ ['Ctrl+S', 'Save the design as a file'],
+ ['Delete', 'Delete the selection'],
+ ['Ctrl+D', 'Duplicate the selection'],
+ ['Ctrl+C / Ctrl+X / Ctrl+V', 'Copy, cut, paste the selection'],
+ ['Ctrl+↑ / Ctrl+↓', 'Move the selection among its siblings'],
+ ['Alt+↑', 'Select the parent'],
+ ['Escape', 'Select the view (with several selected: only the last)'],
+ ['Ctrl+click / Shift+click', 'Add a control to the selection, or take it out'],
+ ['Shift+F10, right-click', 'Context menu of the selection'],
+ ['Double-click', 'Edit the text of a control on the canvas'],
+ ['↑ ↓ ← → Home End', 'Walk the outline; a letter jumps to the next control of that name'],
+ ['Enter', 'From the outline into the properties; in the control search: insert the first match'],
+ ['?', 'This list'],
+];
+
+export function shortcutsDialog() {
+ dialog('Keyboard shortcuts', h('table', { class: 'keys' },
+ SHORTCUTS.map(([k, t]) => h('tr', {}, h('td', {}, k.split(' / ').flatMap((x, i) => [i ? ' / ' : null, h('kbd', { text: x })])), h('td', { text: t })))),
+ [{ text: 'Close', action: () => true }]);
+}
export function onKey(e) {
@@ -19,12 +45,16 @@ export function onKey(e) {
if (ctrl && !typing && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; }
if (ctrl && !typing && (e.key === 'y' || e.key === 'Y')) { e.preventDefault(); redo(); return; }
if (typing) return;
- const sel = state.selected;
- if (ctrl && !e.shiftKey && (e.key === 'c' || e.key === 'C') && sel !== 'root' && !getSelection().toString()) { e.preventDefault(); copySelection(false); return; }
- if (ctrl && (e.key === 'x' || e.key === 'X') && sel !== 'root') { e.preventDefault(); copySelection(true); return; }
+ if (e.key === '?' && !ctrl) { e.preventDefault(); shortcutsDialog(); return; }
+ const any = selectedIds().length > 0;
+ if (ctrl && !e.shiftKey && (e.key === 'c' || e.key === 'C') && any && !getSelection().toString()) { e.preventDefault(); copySelection(false); return; }
+ if (ctrl && (e.key === 'x' || e.key === 'X') && any) { e.preventDefault(); copySelection(true); return; }
if (ctrl && (e.key === 'v' || e.key === 'V')) { e.preventDefault(); pasteSelection(); return; }
- if ((e.key === 'Delete' || e.key === 'Backspace') && sel !== 'root') { e.preventDefault(); commit((d) => removeNode(d, sel)); }
- if (ctrl && (e.key === 'd' || e.key === 'D') && sel !== 'root') { e.preventDefault(); commit((d) => duplicateNode(d, sel) || toast('It cannot be duplicated: its slot takes one control')); }
- if (e.altKey && e.key === 'ArrowUp') { e.preventDefault(); const hit = findNode(state.design.root, sel); if (hit && hit.parent) select(hit.parent.id); }
- if (e.key === 'Escape') select('root');
+ if ((e.key === 'Delete' || e.key === 'Backspace') && any) { e.preventDefault(); deleteSelection(); return; }
+ if (ctrl && (e.key === 'd' || e.key === 'D') && any) { e.preventDefault(); duplicateSelection(); return; }
+ // Ctrl+↑ / Ctrl+↓ move the selection among its siblings
+ if (ctrl && !e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && any) { e.preventDefault(); moveSelection(e.key === 'ArrowUp' ? -1 : 1); return; }
+ if (e.altKey && e.key === 'ArrowUp') { e.preventDefault(); selectParent(); return; }
+ if ((e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) && any) { e.preventDefault(); openContextMenuAtSelection(); return; }
+ if (e.key === 'Escape') select(state.also.length ? state.selected : 'root');
}
diff --git a/src/ui/layers.js b/src/ui/layers.js
index 7b302bc..d9ae931 100644
--- a/src/ui/layers.js
+++ b/src/ui/layers.js
@@ -1,35 +1,63 @@
// The Outline: the tree of the view, with drag and drop and the keys, and
// the breadcrumbs below the canvas.
import { getControl } from '../catalog.js';
-import { canHaveChildren, aggNames, findNode } from '../model.js';
+import { canHaveChildren, aggNames, findNode, walk } from '../model.js';
import { state, select, ancestors } from './store.js';
import { $, h, displayType, nodeLabel } from './dom.js';
import { handleDrop } from './edit.js';
+import { openContextMenu } from './context.js';
+// the nodes a filter of the outline shows: those whose type or label
+// match, and the way down to them - null without a filter
+function filtered() {
+ const q = (state.layersFilter || '').trim().toLowerCase();
+ if (!q) return null;
+ const show = new Set(['root']);
+ walk(state.design.root, (n) => {
+ if (n.id !== 'root' && `${n.type} ${nodeLabel(n)}`.toLowerCase().includes(q)) {
+ show.add(n.id);
+ for (const a of ancestors(n.id)) show.add(a.id);
+ }
+ return true;
+ });
+ return show;
+}
+
export function renderLayers() {
const box = $('#layers');
const scrollTop = box.scrollTop;
box.replaceChildren();
+ const show = filtered();
const row = (node, depth, extra) => {
const isRoot = node.id === 'root';
const kids = canHaveChildren(node) && aggNames(node).some((a) => (node.aggs[a] || []).length);
- const collapsed = state.collapsed.has(node.id);
+ const collapsed = !show && state.collapsed.has(node.id);
+ const also = state.also.includes(node.id);
const bound = Object.values(node.props || {}).some((v) => v && (v.m === 'b' || v.m === 'f'));
const lbl = isRoot ? '' : nodeLabel(node);
const el = h('div', {
- class: `lrow${state.selected === node.id ? ' sel' : ''}`,
+ class: `lrow${state.selected === node.id ? ' sel' : ''}${also ? ' sel also' : ''}`,
role: 'treeitem',
tabindex: state.selected === node.id ? '0' : '-1',
- 'aria-selected': state.selected === node.id ? 'true' : 'false',
+ 'aria-selected': state.selected === node.id || also ? 'true' : 'false',
'aria-expanded': kids ? String(!collapsed) : null,
'aria-level': String(depth + 1),
style: `padding-left:${4 + depth * 14}px`,
draggable: isRoot ? null : 'true',
'data-id': node.id,
- onclick: (e) => { e.stopPropagation(); select(node.id); },
- ondragstart: (e) => { e.stopPropagation(); e.dataTransfer.setData('text/plain', `dsg:move:${node.id}`); e.dataTransfer.effectAllowed = 'move'; },
+ // Ctrl or Shift: the control joins the selection, or leaves it
+ onclick: (e) => { e.stopPropagation(); select(node.id, { extend: e.ctrlKey || e.metaKey || e.shiftKey }); },
+ // a selected row drags the whole selection along
+ ondragstart: (e) => {
+ e.stopPropagation();
+ const all = [...state.also, state.selected];
+ const ids = all.length > 1 && all.includes(node.id) ? all : [node.id];
+ e.dataTransfer.setData('text/plain', `dsg:move:${ids.join(',')}`);
+ e.dataTransfer.effectAllowed = 'move';
+ },
+ oncontextmenu: (e) => { if (isRoot) return; e.preventDefault(); e.stopPropagation(); openContextMenu(e.clientX, e.clientY, node.id); },
},
h('button', {
class: 'caret',
@@ -39,26 +67,31 @@ export function renderLayers() {
onclick: (e) => {
e.stopPropagation();
if (!kids) return;
+ if (show) return;
if (collapsed) state.collapsed.delete(node.id); else state.collapsed.add(node.id);
renderLayers();
},
}),
h('span', { class: 'ltype', text: isRoot ? 'View' : displayType(node.type) }),
bound ? h('span', { class: 'bind', title: 'bound to data', text: '●' }) : null,
+ Object.values(node.events || {}).some(Boolean) ? h('span', { class: 'evt', title: `raises ${Object.values(node.events).filter(Boolean).join(', ')}`, text: '⚡' }) : null,
lbl ? h('span', { class: 'lmeta', text: lbl }) : null);
wireTreeDrop(el, node, extra);
return el;
};
const out = (node, depth, parent, agg, index) => {
+ if (show && !show.has(node.id)) return;
box.append(row(node, depth, { parent, agg, index }));
- if (state.collapsed.has(node.id)) return;
+ if (!show && state.collapsed.has(node.id)) return;
const c = getControl(node.type);
const aggRows = node.type !== 'mvc:View' && c && c.aggs && Object.keys(c.aggs).length > 1;
for (const a of aggNames(node)) {
const kids = node.aggs[a] || [];
// an empty aggregation shows only on the selected control - enough to
- // drop into, without a tree full of empty slots
+ // drop into, without a tree full of empty slots; under a filter only
+ // what holds a match
+ if (show && !kids.some((k) => show.has(k.id))) continue;
if (!kids.length && (!aggRows || state.selected !== node.id)) continue;
let childDepth = depth + 1;
if (aggRows || (!c && a)) {
@@ -74,34 +107,63 @@ export function renderLayers() {
}
};
out(state.design.root, 0, null, null, 0);
+ if (show && show.size === 1) box.append(h('div', { class: 'empty', text: 'No control matches.' }));
box.scrollTop = scrollTop;
- const sel = box.querySelector('.lrow.sel');
+ // the row of the control the properties show, not another selected one
+ const sel = box.querySelector('.lrow.sel:not(.also)');
if (sel && !state.layersNoScroll) sel.scrollIntoView({ block: 'nearest' });
if (state.focusLayers) { state.focusLayers = false; if (sel) sel.focus(); }
}
+// a letter typed in the outline: the next row whose control starts so
+// (letters typed quickly one after another are one word)
+let typed = '';
+let typedAt = 0;
+function typeahead(rows, i, key) {
+ const now = Date.now();
+ typed = now - typedAt < 700 ? typed + key.toLowerCase() : key.toLowerCase();
+ typedAt = now;
+ const text = (r) => `${r.querySelector('.ltype')?.textContent || ''}`.toLowerCase();
+ const label = (r) => `${r.querySelector('.lmeta')?.textContent || ''}`.toLowerCase();
+ // a word in progress may stay on this row; a new letter goes on
+ const from = typed.length > 1 ? i : i + 1;
+ for (let k = 0; k < rows.length; k++) {
+ const r = rows[(from + k) % rows.length];
+ if (text(r).startsWith(typed) || label(r).startsWith(typed)) return rows.indexOf(r);
+ }
+ return -1;
+}
+
// the keys of the outline: up and down walk the visible rows, left
// collapses or goes to the parent, right expands or goes to the first
// child, Enter opens the properties
export function onLayersKey(e) {
+ // Ctrl, Alt and Meta with an arrow are the editor's (move, parent)
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
const rows = [...$('#layers').querySelectorAll('.lrow[data-id]')];
const i = rows.findIndex((r) => r.dataset.id === state.selected);
if (i < 0) return;
const go = (j) => { if (rows[j]) { state.focusLayers = true; select(rows[j].dataset.id); } };
const node = findNode(state.design.root, state.selected);
- const open = node && rows[i].getAttribute('aria-expanded') === 'true';
+ // under a filter every branch shows open: the arrows only walk
+ const filtering = !!(state.layersFilter || '').trim();
+ const open = node && !filtering && rows[i].getAttribute('aria-expanded') === 'true';
if (e.key === 'ArrowDown') { e.preventDefault(); go(i + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); go(i - 1); }
else if (e.key === 'Home') { e.preventDefault(); go(0); }
else if (e.key === 'End') { e.preventDefault(); go(rows.length - 1); }
+ else if (e.key.length === 1 && /[\p{L}\p{N}]/u.test(e.key)) {
+ const j = typeahead(rows, i, e.key);
+ if (j >= 0) { e.preventDefault(); go(j); }
+ }
else if (e.key === 'ArrowLeft') {
e.preventDefault();
if (open) { state.collapsed.add(state.selected); state.focusLayers = true; renderLayers(); }
else if (node && node.parent) go(rows.findIndex((r) => r.dataset.id === node.parent.id));
} else if (e.key === 'ArrowRight') {
e.preventDefault();
- if (rows[i].getAttribute('aria-expanded') === 'false') { state.collapsed.delete(state.selected); state.focusLayers = true; renderLayers(); }
- else if (open) go(i + 1);
+ if (!filtering && rows[i].getAttribute('aria-expanded') === 'false') { state.collapsed.delete(state.selected); state.focusLayers = true; renderLayers(); }
+ else if (open || (filtering && rows[i].hasAttribute('aria-expanded'))) go(i + 1);
} else if (e.key === 'Enter') {
e.preventDefault();
const first = $('#inspector').querySelector('input, select');
diff --git a/src/ui/palette.js b/src/ui/palette.js
index 62eef19..2ec8803 100644
--- a/src/ui/palette.js
+++ b/src/ui/palette.js
@@ -1,13 +1,15 @@
// The command palette (Ctrl+K): insert, jump to or run anything.
import { CONTROLS, DESCRIPTIONS } from '../catalog.js';
import { PATTERNS } from '../patterns.js';
-import { walk, findNode, removeNode, duplicateNode } from '../model.js';
+import { walk, findNode } from '../model.js';
import { generateClassSource } from '../clas.js';
-import { state, select, commit, undo, redo } from './store.js';
-import { $, h, toast, displayType, nodeLabel } from './dom.js';
-import { addControl, insertPattern, copySelection, pasteSelection } from './edit.js';
+import { state, select, undo, redo, selectedIds } from './store.js';
+import { $, h, displayType, nodeLabel } from './dom.js';
+import { addControl, insertPattern, copySelection, pasteSelection, copyProperties, pasteProperties } from './edit.js';
import { setView, copyText } from './code.js';
-import { exportZip, newDialog, importDialog, recentDialog, shareLink, insertByNameDialog, saveDesign, openFile } from './files.js';
+import { exportZip, newDialog, importDialog, recentDialog, shareLink, insertByNameDialog, saveDesign, saveToBrowser, openFile } from './files.js';
+import { shortcutsDialog } from './keys.js';
+import { WRAPPERS, deleteSelection, duplicateSelection, moveSelection, selectParent, wrapSelection } from './actions.js';
function commands() {
@@ -26,21 +28,30 @@ function commands() {
action('New from template', newDialog);
action('Import UI5 XML view', importDialog);
action('Save design', saveDesign);
+ action('Save design in this browser', saveToBrowser, 'under its class name');
action('Open design, view or class', () => openFile('.json,.xml,.abap'));
- action('Recent designs', recentDialog);
+ action('Designs in this browser', recentDialog, 'saved and recent');
action('Insert any UI5 control by class name', insertByNameDialog, 'e.g. sap.m.Wizard');
action('Copy share link', shareLink, 'the design in a URL');
action('Show layout only', () => setView('canvas'));
action('Show code only', () => setView('code'));
action('Paste', pasteSelection, 'Ctrl+V');
+ action('Keyboard shortcuts', shortcutsDialog, '?');
action('Undo', undo, 'Ctrl+Z');
action('Redo', redo, 'Ctrl+Shift+Z');
if (sel && sel.parent) {
- action('Delete selection', () => commit((d) => removeNode(d, state.selected)), 'Del');
- action('Duplicate selection', () => commit((d) => duplicateNode(d, state.selected) || toast('It cannot be duplicated: its slot takes one control')), 'Ctrl+D');
- action('Copy selection', () => copySelection(false), 'Ctrl+C');
- action('Cut selection', () => copySelection(true), 'Ctrl+X');
- action('Select parent', () => select(sel.parent.id), 'Alt+↑');
+ const n = selectedIds().length;
+ const what = n > 1 ? `${n} selected controls` : 'selection';
+ action(`Delete ${what}`, () => deleteSelection(), 'Del');
+ action(`Duplicate ${what}`, () => duplicateSelection(), 'Ctrl+D');
+ action(`Copy ${what}`, () => copySelection(false), 'Ctrl+C');
+ action(`Cut ${what}`, () => copySelection(true), 'Ctrl+X');
+ action(`Move ${what} up`, () => moveSelection(-1), 'Ctrl+↑');
+ action(`Move ${what} down`, () => moveSelection(1), 'Ctrl+↓');
+ action('Select parent', () => selectParent(), 'Alt+↑');
+ action('Copy properties', copyProperties, 'to set them on another control');
+ if (state.propClipboard) action(`Paste properties onto ${what}`, pasteProperties, `of ${displayType(state.propClipboard.type)}`);
+ for (const w of WRAPPERS) action(`Wrap ${what} in ${w}`, () => wrapSelection(w));
}
return list;
}
@@ -55,7 +66,17 @@ export function openPalette() {
const render = () => {
const q = input.value.trim().toLowerCase();
const words = q.split(/\s+/).filter(Boolean);
- shown = all.filter((c) => words.every((w) => `${c.kind} ${c.title} ${c.detail || ''}`.toLowerCase().includes(w))).slice(0, 60);
+ // what starts with the words first, then what has them in its title,
+ // then the rest - in the order of the list within each
+ const rank = (c) => {
+ const t = c.title.toLowerCase();
+ if (!q) return 0;
+ if (t.startsWith(q) || `${c.kind} ${t}`.startsWith(q)) return 0;
+ if (words.every((w) => t.includes(w))) return 1;
+ return 2;
+ };
+ shown = all.filter((c) => words.every((w) => `${c.kind} ${c.title} ${c.detail || ''}`.toLowerCase().includes(w)))
+ .map((c, i) => ({ c, i, r: rank(c) })).sort((a, b) => a.r - b.r || a.i - b.i).map((x) => x.c).slice(0, 60);
active = Math.min(active, Math.max(0, shown.length - 1));
listBox.replaceChildren(...shown.map((c, i) => h('div', {
class: `cmdk-item${i === active ? ' active' : ''}`,
diff --git a/src/ui/preview.js b/src/ui/preview.js
index 46cac4b..4ab9617 100644
--- a/src/ui/preview.js
+++ b/src/ui/preview.js
@@ -1,12 +1,15 @@
// The canvas: the preview frame (preview.html, src/preview/init.js) gets
// the view and the model, reports what is clicked, dropped and typed.
-import { previewModel, findNode, shiftNode, duplicateNode, removeNode, walk, typeFor } from '../model.js';
+import { previewModel, walk, typeFor, canHaveChildren } from '../model.js';
import { toXml, namespaceUri } from '../xml.js';
import { getControl, registerControl, runtimeDef, parseClassName, splitType } from '../catalog.js';
-import { state, select, commit, labelOfSelection, emitChange } from './store.js';
-import { $, h, toast } from './dom.js';
+import { state, select, labelOfSelection, emitChange } from './store.js';
+import { $, h } from './dom.js';
import { handleDrop } from './edit.js';
import { onKey } from './keys.js';
+import { deleteSelection, duplicateSelection, moveSelection, selectParent } from './actions.js';
+import { openContextMenu, closeContextMenu } from './context.js';
+import { openInlineEdit, closeInlineEdit } from './inline.js';
let previewTimer = null;
@@ -18,8 +21,19 @@ function previewUrl() {
return `preview.html?${q}`;
}
+// a point of the canvas frame (its own CSS pixels) on the editor's page:
+// the frame may be zoomed out
+export function frameToPage(x, y) {
+ const r = $('#preview').getBoundingClientRect();
+ const z = Number(state.prefs.zoom) || 1;
+ return { x: r.left + x * z, y: r.top + y * z, z };
+}
+
export function loadPreview() {
state.previewReady = false;
+ // another UI5 may have other defaults; what the old one was asked is gone
+ defaultsOf.clear();
+ asking.clear();
previewStatus(`Loading UI5${state.prefs.ui5 ? ` ${state.prefs.ui5}` : ''}…`);
$('#preview').src = previewUrl();
}
@@ -40,22 +54,42 @@ export function postPreview(msg) {
f.contentWindow.postMessage(Object.assign({ target: 'dsg-preview' }, msg), '*');
}
+// the containers without a child: the canvas gives them room to drop into
+// - not a control whose children are its values (a list line's
+// attributes, a select's items, a column's header)
+function holdsControls(n) {
+ const c = getControl(n.type);
+ if (!c || c.category === 'Input' || c.input) return false;
+ const { name } = splitType(n.type);
+ if (/ListItem$/.test(name) && name !== 'CustomListItem') return false;
+ return !['Column', 'MenuItem', 'ObjectHeader', 'ObjectStatus'].includes(name);
+}
+function emptyContainers() {
+ const out = [];
+ walk(state.design.root, (n) => {
+ if (n.id !== 'root' && canHaveChildren(n) && holdsControls(n) && !Object.values(n.aggs || {}).some((l) => l.length)) out.push(n.id);
+ return true;
+ });
+ return out;
+}
+
export function sendPreview(now) {
clearTimeout(previewTimer);
const send = () => postPreview({
- type: 'render', xml: toXml(state.design, { preview: true }), model: previewModel(state.design), selected: state.selected, mode: state.mode, label: labelOfSelection(),
+ type: 'render', xml: toXml(state.design, { preview: true }), model: previewModel(state.design), selected: state.selected, also: state.also, mode: state.mode, label: labelOfSelection(), empty: emptyContainers(),
});
if (now) send(); else previewTimer = setTimeout(send, 120);
}
+// the toolbar acts on the control it sits on - and on the rest of the
+// selection with it, when that control is part of it
function onToolbarAction(action, id) {
- if (action === 'parent') {
- const hit = findNode(state.design.root, id);
- if (hit && hit.parent) select(hit.parent.id);
- } else if (action === 'up') commit((d) => shiftNode(d, id, -1));
- else if (action === 'down') commit((d) => shiftNode(d, id, 1));
- else if (action === 'dup') commit((d) => duplicateNode(d, id) || toast('It cannot be duplicated: its slot takes one control'));
- else if (action === 'del') commit((d) => removeNode(d, id));
+ const ids = [...state.also, state.selected].includes(id) ? [...state.also, state.selected] : [id];
+ if (action === 'parent') selectParent(id);
+ else if (action === 'up') moveSelection(-1, ids);
+ else if (action === 'down') moveSelection(1, ids);
+ else if (action === 'dup') duplicateSelection(ids);
+ else if (action === 'del') deleteSelection(ids);
}
// Ask the UI5 runtime of the canvas what a control class is: its
@@ -77,6 +111,53 @@ export function describeControl(className) {
});
}
+// The default values of a control class's properties, from the canvas's
+// UI5: null until they are there - `then` runs once they are
+const defaultsOf = new Map();
+const asking = new Map();
+let defaultsSeq = 0;
+export function propertyDefaults(type, then) {
+ if (defaultsOf.has(type)) return defaultsOf.get(type);
+ if (!state.previewReady) return null;
+ // asked already: this caller waits for the same answer
+ const pending = [...asking.values()].find((a) => a.type === type);
+ if (pending) { if (then) pending.then.push(then); return null; }
+ const { ns, name } = splitType(type);
+ const lib = namespaceUri(state.design, ns);
+ if (!lib || type === 'mvc:View') return null;
+ const parsed = parseClassName(`${lib}.${name}`);
+ if (!parsed) return null;
+ const key = `f${++defaultsSeq}`;
+ asking.set(key, { type, then: then ? [then] : [] });
+ postPreview({ type: 'defaults', key, module: parsed.module });
+ setTimeout(() => asking.delete(key), 15000);
+ return null;
+}
+
+// What the events of Preview mode did: their names, and what the user
+// had changed in the model by then - the values on_event would read
+const eventLog = [];
+function changesOf(model) {
+ const start = previewModel(state.design);
+ const out = [];
+ for (const [k, v] of Object.entries(model || {})) {
+ if (JSON.stringify(v) === JSON.stringify(start[k])) continue;
+ out.push(Array.isArray(v) ? `${k.toLowerCase()} (table changed)` : `${k.toLowerCase()} = ${JSON.stringify(v)}`);
+ }
+ return out;
+}
+export function renderEventLog() {
+ const box = $('#eventLog');
+ if (!box) return;
+ box.classList.toggle('hidden', state.mode !== 'interact' || !eventLog.length);
+ box.replaceChildren(h('div', { class: 'ev-head' },
+ h('b', { text: 'Events - what on_event would get' }),
+ h('button', { class: 'link', onclick: () => { eventLog.length = 0; renderEventLog(); } }, 'Clear')),
+ ...eventLog.slice().reverse().map((e) => h('div', { class: 'ev-line' },
+ h('code', { text: e.name }),
+ h('span', { class: 'muted', text: e.changes.length ? e.changes.join(' · ') : 'the model as the class sent it' }))));
+}
+
// every control of the design the catalogue does not know, described
// anew - a design opened from a file or the browser store lost them
function describeUnknown() {
@@ -102,6 +183,8 @@ window.addEventListener('message', (e) => {
$('#ui5version').placeholder = `UI5 ${msg.version}`;
sendPreview(true);
describeUnknown();
+ // the properties shown before UI5 was there learn their defaults
+ emitChange();
} else if (msg.type === 'described') {
const d = describing.get(msg.key);
if (!d) return;
@@ -110,7 +193,27 @@ window.addEventListener('message', (e) => {
registerControl(d.type, runtimeDef(msg.meta));
d.resolve(d.type);
} else if (msg.type === 'select') {
- select(msg.id);
+ closeContextMenu();
+ select(msg.id, { extend: !!msg.extend });
+ } else if (msg.type === 'defaults') {
+ const a = asking.get(msg.key);
+ if (!a) return;
+ asking.delete(msg.key);
+ defaultsOf.set(a.type, msg.defaults || {});
+ for (const fn of a.then) fn();
+ } else if (msg.type === 'event') {
+ eventLog.push({ name: msg.name, changes: changesOf(msg.model) });
+ if (eventLog.length > 20) eventLog.shift();
+ renderEventLog();
+ } else if (msg.type === 'inline') {
+ openInlineEdit(msg.id, msg.rect);
+ } else if (msg.type === 'scroll') {
+ // the field over a control stays over it, or goes
+ closeInlineEdit();
+ } else if (msg.type === 'context') {
+ // the frame's coordinates, on the editor's page
+ const at = frameToPage(msg.x, msg.y);
+ openContextMenu(at.x, at.y, msg.id);
} else if (msg.type === 'action') {
onToolbarAction(msg.action, msg.id);
} else if (msg.type === 'drop') {
diff --git a/src/ui/store.js b/src/ui/store.js
index db9d010..eeddf7b 100644
--- a/src/ui/store.js
+++ b/src/ui/store.js
@@ -7,11 +7,12 @@
import { findNode, normalizeDesign } from '../model.js';
import { STARTERS } from '../starters.js';
import { toBase64Url } from '../handoff.js';
-import { $, displayType, toast } from './dom.js';
+import { $, displayType, toastError } from './dom.js';
-const listeners = { change: [], select: [] };
+const listeners = { change: [], select: [], panel: [] };
// on('change', fn(opts)) after every edit, on('select', fn(opts)) after
-// every change of selection
+// every change of selection, on('panel', fn( )) when another tab of the
+// drawer opens
export function on(event, fn) {
listeners[event].push(fn);
}
@@ -33,6 +34,9 @@ const PREFS_KEY = 'abap2ui5-designer:prefs';
export const state = {
design: null,
selected: 'root',
+ // the other selected controls, when Ctrl or Shift extends the selection
+ // - `selected` is the one the properties show
+ also: [],
undo: [],
redo: [],
panel: 'insert',
@@ -58,6 +62,11 @@ export function isLinkableUi5(v) {
} catch { return false; }
}
+// a UI5 of your own for the canvas, as typed: the folder that holds
+// resources/ - an http(s) URL or a path on this server (preview.html
+// checks the same shape, and the hosts above, in its own script)
+export const isUi5Url = (v) => /^(https?:\/\/|\.{0,2}\/)[^"'<>\s]*$/.test(v);
+
export function loadPrefs() {
try { Object.assign(state.prefs, JSON.parse(localStorage.getItem(PREFS_KEY) || '{}')); } catch { /* defaults */ }
// ?ui5= overrides the stored version, for links and tests
@@ -104,16 +113,68 @@ export async function designFromLink(hash) {
return normalizeDesign(JSON.parse(json));
}
-// `json`: the design as text when the caller has it already
-function persist(json) {
- try { localStorage.setItem(STORE_KEY, json || JSON.stringify(state.design)); } catch { /* quota or private mode */ }
+// the design as text, kept per revision: a commit needs the text of the
+// design before it for undo, and that is the text of the last commit
+let snap = { rev: -1, json: '' };
+function snapshot() {
+ if (snap.rev !== state.rev) snap = { rev: state.rev, json: JSON.stringify(state.design) };
+ return snap.json;
+}
+
+// the browser store: typing into a field (`later`) writes it once the
+// burst is over, and at the latest when the page goes away - every other
+// edit at once
+let persistTimer = null;
+function persist(later) {
+ clearTimeout(persistTimer);
+ persistTimer = setTimeout(flushPersist, 250);
+ if (!later) flushPersist();
+}
+export function flushPersist() {
+ if (persistTimer === null) return;
+ clearTimeout(persistTimer);
+ persistTimer = null;
+ try {
+ localStorage.setItem(STORE_KEY, snapshot());
+ storeFailed = false;
+ } catch {
+ // quota or private mode: said once, not on every keystroke
+ if (!storeFailed) toastError('This browser does not keep the design (its storage is full or off) - save it as a file to keep it.');
+ storeFailed = true;
+ }
+}
+let storeFailed = false;
+
+// another tab of the editor saved its design over this one's: say so -
+// the next edit here would overwrite it in turn
+const otherTab = [];
+export function onOtherTab(fn) { otherTab.push(fn); }
+if (typeof window !== 'undefined') {
+ window.addEventListener('storage', (e) => {
+ if (e.key !== STORE_KEY || !e.newValue || e.newValue === snapshot()) return;
+ for (const fn of otherTab) fn(e.newValue);
+ });
+}
+if (typeof window !== 'undefined') {
+ window.addEventListener('pagehide', flushPersist);
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushPersist(); });
+}
+
+// an undo step: the design and what was selected in it
+const step = () => ({ json: snapshot(), selected: state.selected });
+
+// the extra selection keeps only controls that are still there
+function pruneSelection() {
+ if (!findNode(state.design.root, state.selected)) state.selected = 'root';
+ state.also = state.also.filter((id) => id !== state.selected && id !== 'root' && findNode(state.design.root, id));
}
// Every change: snapshot for undo, apply, then re-render. `fn` may return
// the id to select next. Typing into one field merges into one undo step
// (`mergeKey`) and keeps the focus where it is (`keepFocus`).
export function commit(fn, opts = {}) {
- const before = JSON.stringify(state.design);
+ const before = snapshot();
+ const selectedBefore = state.selected;
let next;
try {
next = fn(state.design);
@@ -121,7 +182,7 @@ export function commit(fn, opts = {}) {
// an edit that fails half way leaves nothing behind: the design is
// the one before it, and the panels never saw the half
state.design = JSON.parse(before);
- toast(err.message);
+ toastError(err.message);
return;
}
const after = JSON.stringify(state.design);
@@ -130,36 +191,42 @@ export function commit(fn, opts = {}) {
return;
}
if (!opts.mergeKey || state.lastMergeKey !== opts.mergeKey) {
- state.undo.push(before);
+ state.undo.push({ json: before, selected: selectedBefore });
if (state.undo.length > 200) state.undo.shift();
}
state.lastMergeKey = opts.mergeKey || null;
state.redo = [];
- if (typeof next === 'string') state.selected = next;
- if (!findNode(state.design.root, state.selected)) state.selected = 'root';
+ if (typeof next === 'string') { state.selected = next; state.also = []; }
state.rev += 1;
- persist(after);
+ snap = { rev: state.rev, json: after };
+ pruneSelection();
+ persist(!!opts.mergeKey);
emit('change', opts);
}
-function restore(json) {
- state.design = JSON.parse(json);
+function restore(entry) {
+ state.design = JSON.parse(entry.json);
state.lastMergeKey = null;
- if (!findNode(state.design.root, state.selected)) state.selected = 'root';
+ // the control that was selected when the step was taken
+ if (entry.selected) state.selected = entry.selected;
+ state.also = [];
state.rev += 1;
- persist(json);
+ snap = { rev: state.rev, json: entry.json };
+ pruneSelection();
+ persist();
emit('change', {});
+ emit('select', {});
}
export function undo() {
if (!state.undo.length) return;
- state.redo.push(JSON.stringify(state.design));
+ state.redo.push(step());
restore(state.undo.pop());
}
export function redo() {
if (!state.redo.length) return;
- state.undo.push(JSON.stringify(state.design));
+ state.undo.push(step());
restore(state.redo.pop());
}
@@ -179,10 +246,11 @@ function rememberRecent(d) {
export function replaceDesign(d) {
rememberRecent(state.design);
- state.undo.push(JSON.stringify(state.design));
+ state.undo.push(step());
state.redo = [];
state.design = d;
state.selected = d.root.aggs.content && d.root.aggs.content[0] ? d.root.aggs.content[0].id : 'root';
+ state.also = [];
// typing into the new design is a step of its own, never merged into
// the replacement
state.lastMergeKey = null;
@@ -192,16 +260,68 @@ export function replaceDesign(d) {
emit('change', {});
}
+// The designs kept in this browser under their class name - a library
+// next to the one design that is open
+const LIBRARY_KEY = 'abap2ui5-designer:library';
+export function libraryDesigns() {
+ try { return JSON.parse(localStorage.getItem(LIBRARY_KEY) || '[]'); } catch { return []; }
+}
+function writeLibrary(list) {
+ localStorage.setItem(LIBRARY_KEY, JSON.stringify(list));
+}
+// true when a design of that class name was there and is replaced
+export function saveToLibrary(d) {
+ const list = libraryDesigns();
+ const had = list.some((r) => r.className === d.className);
+ const rest = list.filter((r) => r.className !== d.className);
+ rest.unshift({ className: d.className, description: d.description || '', at: Date.now(), json: JSON.stringify(d) });
+ writeLibrary(rest);
+ return had;
+}
+export function removeFromLibrary(className) {
+ writeLibrary(libraryDesigns().filter((r) => r.className !== className));
+}
+
+// `opts.extend`: Ctrl or Shift held - the control joins the selection,
+// or leaves it when it is in it already
export function select(id, opts = {}) {
if (!findNode(state.design.root, id)) id = 'root';
- state.selected = id;
+ if (opts.extend && id !== 'root' && state.selected !== 'root') {
+ const all = selectedIds();
+ if (all.includes(id)) {
+ const rest = all.filter((x) => x !== id);
+ if (rest.length) { state.selected = rest[rest.length - 1]; state.also = rest.slice(0, -1); }
+ } else {
+ state.also = all;
+ state.selected = id;
+ }
+ } else {
+ state.selected = id;
+ state.also = [];
+ }
// typing after a change of selection is a new undo step
state.lastMergeKey = null;
// a selected node is never hidden in a collapsed branch
- for (const a of ancestors(id)) state.collapsed.delete(a.id);
+ for (const x of selectedIds()) for (const a of ancestors(x)) state.collapsed.delete(a.id);
emit('select', opts);
}
+// several controls at once, the last one shown in the properties
+export function selectMany(ids) {
+ const ok = ids.filter((id) => id !== 'root' && findNode(state.design.root, id));
+ if (!ok.length) { select('root'); return; }
+ state.selected = ok[ok.length - 1];
+ state.also = ok.slice(0, -1);
+ state.lastMergeKey = null;
+ for (const x of ok) for (const a of ancestors(x)) state.collapsed.delete(a.id);
+ emit('select', {});
+}
+
+// every selected control, the one the properties show last
+export function selectedIds() {
+ return state.selected === 'root' ? [] : [...state.also, state.selected];
+}
+
export function ancestors(id) {
const out = [];
let hit = findNode(state.design.root, id);
@@ -228,4 +348,5 @@ export function showPanel(panel) {
b.tabIndex = on ? 0 : -1;
}
for (const p of ['insert', 'layers', 'data', 'issues']) $(`#panel-${p}`).classList.toggle('hidden', p !== panel);
+ emit('panel');
}
diff --git a/styles.css b/styles.css
index 2ed5c20..be32b96 100644
--- a/styles.css
+++ b/styles.css
@@ -34,10 +34,18 @@
/* Horizon Evening */
--bg: #12171c; --panel: #1d232a; --panel-2: #12171c; --line: #2f3a45; --line-strong: #4e5b68; --field: #8396a8; --text: #eaecee; --muted: #a9b4be;
--accent: #4db1ff; --accent-strong: #8cccff; --accent-soft: #1c3b5a; --emphasized: #0070f2; --focus: #8cccff; --appbar: #1d232a; --appbar-text: #eaecee;
- --code-bg: #171d23; --code-hi: #3a3218; --kw: #c99bff; --str: #7fd49b; --cmt: #8396a8;
+ --code-bg: #171d23; --code-hi: #3a3218; --kw: #c99bff; --str: #7fd49b; --cmt: #8396a8; --ok: #6ed482; --error: #ff8888; --warn: #ffab1d; --code-hi-line: #ffab1d;
color-scheme: dark;
}
}
+/* chosen in the app bar, whatever the system says */
+:root[data-theme="dark"] {
+ /* Horizon Evening */
+ --bg: #12171c; --panel: #1d232a; --panel-2: #12171c; --line: #2f3a45; --line-strong: #4e5b68; --field: #8396a8; --text: #eaecee; --muted: #a9b4be;
+ --accent: #4db1ff; --accent-strong: #8cccff; --accent-soft: #1c3b5a; --emphasized: #0070f2; --focus: #8cccff; --appbar: #1d232a; --appbar-text: #eaecee;
+ --code-bg: #171d23; --code-hi: #3a3218; --kw: #c99bff; --str: #7fd49b; --cmt: #8396a8; --ok: #6ed482; --error: #ff8888; --warn: #ffab1d; --code-hi-line: #ffab1d;
+ color-scheme: dark;
+}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
@@ -143,6 +151,7 @@ button.cmd { display: flex; gap: 10px; align-items: center; min-width: 210px; ju
.lrow:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
.lrow.sel { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); color: var(--text); }
.lrow.sel .ltype { font-weight: 700; }
+.lrow.sel.also { background: color-mix(in srgb, var(--accent-soft) 60%, transparent); }
.lrow .caret { width: 14px; text-align: center; color: var(--muted); font-size: 10px; flex: none; border: none; background: none; padding: 0; }
.lrow .ltype { font-weight: 500; }
.lrow .lmeta { color: var(--muted); overflow: hidden; text-overflow: ellipsis; font-size: 12px; }
@@ -185,6 +194,7 @@ button.cmd { display: flex; gap: 10px; align-items: center; min-width: 210px; ju
.codeblock .ln[data-node] { cursor: pointer; }
.codeblock .ln[data-node]:hover { background: color-mix(in srgb, var(--accent) 7%, transparent); }
.codeblock .ln.hi { background: var(--code-hi); box-shadow: inset 3px 0 0 var(--code-hi-line); }
+.codeblock .ln.hi-also { background: color-mix(in srgb, var(--code-hi) 55%, transparent); box-shadow: inset 3px 0 0 var(--code-hi-line); }
.codeblock .kw { color: var(--kw); font-weight: 600; }
.codeblock .str { color: var(--str); }
.codeblock .cmt { color: var(--cmt); font-style: italic; }
@@ -235,13 +245,15 @@ button.cmd { display: flex; gap: 10px; align-items: center; min-width: 210px; ju
/* data */
.attr { border: 1px solid var(--line); border-radius: 10px; padding: 8px; margin-bottom: 8px; background: var(--panel); }
-.attr-head { display: grid; grid-template-columns: minmax(0, 1fr) 84px 28px; gap: 4px; align-items: center; }
+.attr-head { display: grid; grid-template-columns: minmax(0, 1fr) 84px auto 28px; gap: 4px; align-items: center; }
+.move { display: inline-flex; gap: 2px; }
+.move button.icon { width: 22px; padding: 0; }
.attr-head input.aname { font-family: var(--mono); }
.attr .uses { color: var(--muted); font-size: 11px; margin: 4px 0 0 2px; }
.attr .avalue { margin-top: 6px; display: flex; gap: 6px; align-items: center; }
.attr .avalue input:not([type=checkbox]) { flex: 1; }
.fields { margin-top: 8px; }
-.frow { display: grid; grid-template-columns: minmax(0, 1fr) 84px 28px; gap: 4px; margin-bottom: 4px; }
+.frow { display: grid; grid-template-columns: minmax(0, 1fr) 84px auto 28px; gap: 4px; margin-bottom: 4px; }
.frow input { font-family: var(--mono); text-transform: uppercase; }
.rows-wrap { overflow-x: auto; margin-top: 6px; }
table.rows { border-collapse: collapse; font-size: 12px; }
@@ -257,6 +269,10 @@ table.rows td input[type=checkbox] { width: auto; }
.issue.error .lvl { color: var(--error); }
.issue.warning .lvl { color: var(--warn); }
.issue.info .lvl { color: var(--muted); }
+.issue { cursor: default; }
+.issue.at { cursor: pointer; }
+.issue .itext { flex: 1; min-width: 0; }
+.issue button.fix { flex: none; padding: 2px 8px; font-size: 12px; }
/* dialog, command palette, toast */
.dialog { border: 1px solid var(--line); border-radius: 12px; padding: 0; width: min(720px, calc(100vw - 32px)); background: var(--panel); color: var(--text); }
@@ -281,6 +297,9 @@ table.rows td input[type=checkbox] { width: auto; }
background: #1d2d3e; color: #fff; padding: 8px 14px; border-radius: 8px; transition: all .2s; z-index: 50; max-width: 80vw;
}
.toast.show { opacity: 1; transform: translateX(-50%); pointer-events: auto; }
+.toast.error { background: var(--error); display: flex; align-items: center; gap: 10px; }
+.toast-close { background: transparent; border: 0; color: #fff; padding: 0 2px; font-size: 13px; }
+.toast-close:hover { color: #fff; opacity: .75; }
.toast-action { margin-left: 12px; background: var(--emphasized); border-color: var(--emphasized); color: #fff; font-weight: 700; padding: 3px 10px; }
table.rows td.nested { vertical-align: top; }
table.rows .nested-rows { margin: 2px 0 2px 12px; border-left: 2px solid var(--line-strong); padding-left: 6px; }
@@ -300,3 +319,46 @@ table.rows .nested-rows { margin: 2px 0 2px 12px; border-left: 2px solid var(--l
button.cmd span { display: none; }
button.cmd { min-width: 0; }
}
+
+/* several controls selected */
+.multi-bar { margin: 10px 14px 0; padding: 8px 10px; border: 1px solid var(--accent); border-radius: var(--radius); background: var(--accent-soft); display: flex; flex-direction: column; gap: 4px; }
+.multi-bar .hint { margin: 0; }
+/* a property the Checks pointed at */
+.prop.flash { animation: flash 1.2s ease-out; }
+@keyframes flash { from { background: var(--code-hi); } to { background: transparent; } }
+/* the events of Preview mode */
+.event-log { position: absolute; right: 20px; bottom: 20px; z-index: 3; width: min(360px, calc(100% - 40px)); max-height: 40%; overflow: auto;
+ background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: 0 2px 10px rgba(0,0,0,.15); padding: 8px 10px; font-size: 12px; }
+.event-log .ev-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
+.event-log .ev-line { display: flex; flex-direction: column; padding: 4px 0; border-top: 1px solid var(--line); }
+.event-log code { font: 12px var(--mono); font-weight: 700; }
+/* the designs of this browser */
+.dlg-sub { margin: 10px 0 6px; }
+.starter-wrap { position: relative; display: flex; }
+.starter-wrap .starter { flex: 1; }
+.starter-wrap .remove { position: absolute; top: 6px; right: 6px; }
+/* the context menu of a control */
+.menu.ctx { position: fixed; top: 0; left: 0; z-index: 60; min-width: 220px; }
+.menu.ctx button { display: flex; justify-content: space-between; gap: 16px; }
+.menu.ctx button kbd { opacity: .55; border: none; padding: 0; }
+.menu.ctx button.danger { color: var(--error); }
+.menu.ctx .sep { height: 1px; background: var(--line); margin: 4px 2px; }
+.menu.ctx .ctx-label { font-size: 11px; color: var(--muted); padding: 2px 10px 0; }
+.menu.ctx .ctx-row { display: flex; flex-wrap: wrap; gap: 2px; padding: 0 4px; }
+.menu.ctx .ctx-row button { padding: 5px 8px; }
+/* the controls inserted last */
+.ins-group.recent .ins-item { background: var(--panel); }
+/* the Changes of an opened class */
+.codeblock .ln.add { background: color-mix(in srgb, var(--ok) 13%, transparent); box-shadow: inset 3px 0 0 var(--ok); }
+.codeblock .ln.del { background: color-mix(in srgb, var(--error) 11%, transparent); box-shadow: inset 3px 0 0 var(--error); text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--error) 50%, transparent); }
+.codeblock .ln.skip { color: var(--muted); font-style: italic; padding-top: 4px; padding-bottom: 4px; }
+/* the text of a control, edited on the canvas */
+.inline-edit { position: fixed; z-index: 55; padding: 4px 8px; border: 2px solid var(--accent); border-radius: 6px; background: var(--panel); color: var(--text);
+ box-shadow: 0 4px 16px rgba(0,0,0,.2); font-size: 14px; outline: none; }
+/* the keyboard shortcuts */
+table.keys { border-collapse: collapse; width: 100%; }
+table.keys td { padding: 5px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
+table.keys td:first-child { white-space: nowrap; width: 1%; }
+.lrow .evt { color: var(--warn); font-size: 10px; }
+.code-head #btnCopyCode { margin-left: auto; padding: 2px 10px; font-size: 12px; border-color: transparent; background: transparent; color: var(--accent); }
+.code-head #btnCopyCode:hover { background: var(--accent-soft); }
diff --git a/test/diff.test.mjs b/test/diff.test.mjs
new file mode 100644
index 0000000..10267bc
--- /dev/null
+++ b/test/diff.test.mjs
@@ -0,0 +1,37 @@
+// The line diff of the Changes view (src/diff.js).
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { diffLines, diffHunks } from '../src/diff.js';
+
+const apply = (d) => d.filter((l) => l.op !== '-').map((l) => l.text);
+const original = (d) => d.filter((l) => l.op !== '+').map((l) => l.text);
+
+test('a diff gives back both sides, and changes no more than it has to', () => {
+ const a = ['a', 'b', 'c', 'd', 'e'];
+ const b = ['a', 'x', 'c', 'd', 'e', 'f'];
+ const d = diffLines(a, b);
+ assert.deepEqual(original(d), a);
+ assert.deepEqual(apply(d), b);
+ assert.equal(d.filter((l) => l.op !== ' ').length, 3, 'b out, x in, f in');
+});
+
+test('equal sides have no change, empty sides are all one kind', () => {
+ assert.ok(diffLines(['a', 'b'], ['a', 'b']).every((l) => l.op === ' '));
+ assert.deepEqual(diffLines([], ['a']).map((l) => l.op), ['+']);
+ assert.deepEqual(diffLines(['a'], []).map((l) => l.op), ['-']);
+});
+
+test('hunks keep the context around a change, count what they leave out, number both sides', () => {
+ const a = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
+ const b = [...a];
+ b[9] = 'changed';
+ const h = diffHunks(a, b, 2);
+ assert.equal(h.added, 1);
+ assert.equal(h.removed, 1);
+ assert.deepEqual(h.lines.map((l) => l.op), ['…', ' ', ' ', '-', '+', ' ', ' ', '…']);
+ assert.equal(h.lines[0].count, 7);
+ assert.equal(h.lines.at(-1).count, 8);
+ assert.equal(h.lines[3].a, 10);
+ assert.equal(h.lines[4].b, 10);
+ assert.deepEqual(diffHunks(a, a).lines, [{ op: '…', count: 20 }]);
+});
diff --git a/test/e2e/workflow.spec.mjs b/test/e2e/workflow.spec.mjs
new file mode 100644
index 0000000..493ae51
--- /dev/null
+++ b/test/e2e/workflow.spec.mjs
@@ -0,0 +1,415 @@
+// Browser tests of the editor's working aids: undo that keeps the
+// selection, quick fixes from the Checks, several controls at once, the
+// outline filter, the data tab's order and pasted rows, the defaults of
+// the canvas's UI5, the events of Preview mode, the designs of this browser.
+import { test, expect, waitRendered, abapOf } from './fixtures.mjs';
+
+const preview = (page) => page.frameLocator('#preview');
+const nodeIds = (page, type) => page.evaluate((t) => {
+ const out = [];
+ const walk = (n) => { if (n.type === t) out.push(n.id); for (const l of Object.values(n.aggs || {})) l.forEach(walk); };
+ walk(window.designer.state.design.root);
+ return out;
+}, type);
+const selectedType = (page) => page.locator('#inspector .insp-title b');
+const prop = (page, name) => page.locator(`#inspector .prop[data-prop="${name}"]`);
+const selected = (page) => page.evaluate(() => window.designer.state.selected);
+
+test('undo brings back the control that was selected, redo the one after', async ({ designer: page }) => {
+ const sw = (await nodeIds(page, 'Switch'))[0];
+ await page.evaluate((id) => window.designer.select(id), sw);
+ await page.keyboard.press('Delete');
+ await expect.poll(() => nodeIds(page, 'Switch')).toEqual([]);
+ expect(await selected(page)).not.toBe(sw);
+ await page.keyboard.press('Control+Z');
+ await expect.poll(() => selected(page)).toBe(sw);
+ await expect(selectedType(page)).toHaveText('Switch');
+});
+
+test('Ctrl+arrow moves the selection among its siblings, also from the outline', async ({ designer: page }) => {
+ const order = () => page.evaluate(() => {
+ const form = window.designer.state.design.root.aggs.content[0].aggs.content.find((n) => n.type === 'form:SimpleForm');
+ return form.aggs.content.map((n) => n.id);
+ });
+ const before = await order();
+ await page.evaluate((id) => window.designer.select(id), before[0]);
+ await page.getByRole('tab', { name: 'Outline' }).click();
+ await page.locator('#layers .lrow.sel').focus();
+ await page.keyboard.press('Control+ArrowDown');
+ const after = await order();
+ expect(after[1]).toBe(before[0]);
+ expect(await selected(page)).toBe(before[0]);
+});
+
+test('the tabs of the drawer go by arrow key', async ({ designer: page }) => {
+ await page.getByRole('tab', { name: 'Controls' }).focus();
+ await page.keyboard.press('ArrowRight');
+ await expect(page.getByRole('tab', { name: 'Outline' })).toBeFocused();
+ await expect(page.locator('#panel-layers')).toBeVisible();
+ await page.keyboard.press('End');
+ await expect(page.locator('#panel-issues')).toBeVisible();
+});
+
+test('a check with a fix: one click creates the attribute a binding names', async ({ designer: page }) => {
+ await page.evaluate(() => window.designer.commit((d) => {
+ const input = d.root.aggs.content[0].aggs.content.find((n) => n.type === 'form:SimpleForm').aggs.content.find((n) => n.type === 'Input');
+ input.props.value = { m: 'b', v: 'customer_city' };
+ }));
+ await page.getByRole('tab', { name: 'Checks' }).click();
+ const line = page.locator('#issuesPanel .issue', { hasText: 'customer_city, which is not an attribute' });
+ await expect(line).toBeVisible();
+ // a click on the line shows the property it is about
+ await line.locator('.itext').click();
+ await expect(selectedType(page)).toHaveText('Input');
+ await expect(prop(page, 'value').locator('select').first()).toBeFocused();
+ await line.getByRole('button', { name: 'Create customer_city' }).click();
+ await expect(line).toBeHidden();
+ expect(await abapOf(page)).toMatch(/DATA customer_city +TYPE string\./);
+});
+
+test('an export with errors asks in the editor, and can show the checks instead', async ({ designer: page }) => {
+ await page.evaluate(() => window.designer.commit((d) => { d.className = '1BAD'; }));
+ await page.locator('#btnZip').click();
+ const dlg = page.locator('#dlg');
+ await expect(dlg).toContainText('the class may not activate');
+ await dlg.getByRole('button', { name: 'Show the checks' }).click();
+ await expect(dlg).toBeHidden();
+ await expect(page.locator('#panel-issues')).toBeVisible();
+});
+
+test('an edit that fails says so until it is closed', async ({ designer: page }) => {
+ await page.evaluate(() => window.designer.commit(() => { throw new Error('Nothing works today'); }));
+ const t = page.locator('#toast.error');
+ await expect(t).toContainText('Nothing works today');
+ await page.waitForTimeout(3000);
+ await expect(t).toBeVisible();
+ await t.getByRole('button', { name: 'Close' }).click();
+ await expect(page.locator('#toast')).not.toHaveClass(/show/);
+});
+
+test('an event name the design has already is offered again', async ({ designer: page }) => {
+ await page.locator('.ins-item[data-type="Button"]').click();
+ await page.locator('#inspector .event input').first().fill('SAVE_ALL');
+ await page.locator('.ins-item[data-type="Button"]').click();
+ await expect(page.locator('#inspector datalist#eventNames option[value="SAVE_ALL"]')).toBeAttached();
+ await expect(page.locator('#inspector .event input').first()).toHaveAttribute('list', 'eventNames');
+});
+
+test('the properties show the default of the canvas\'s UI5', async ({ designer: page }) => {
+ await page.locator('.ins-item[data-type="Button"]').click();
+ await expect(prop(page, 'type').locator('.val select option').first()).toHaveText('— default (Default) —');
+ await expect(prop(page, 'enabled').locator('.val select option').first()).toHaveText('— default (true) —');
+});
+
+test('the outline filter shows the matches and the way to them', async ({ designer: page }) => {
+ await page.getByRole('tab', { name: 'Outline' }).click();
+ await page.locator('#layersFilter').fill('switch');
+ await expect(page.locator('#layers .lrow[data-id]', { hasText: 'Switch' })).toHaveCount((await nodeIds(page, 'Switch')).length);
+ await expect(page.locator('#layers .lrow[data-id]', { hasText: /^.?Label/ })).toHaveCount(0);
+ await expect(page.locator('#layers .lrow[data-id]', { hasText: 'Page' }).first()).toBeVisible();
+ await page.locator('#layersFilter').fill('nothing-is-called-so');
+ await expect(page.locator('#layers')).toContainText('No control matches.');
+});
+
+test('several controls: Ctrl+click in the outline, wrap them, delete them', async ({ designer: page }) => {
+ const labels = await page.evaluate(() => {
+ const form = window.designer.state.design.root.aggs.content[0].aggs.content.find((n) => n.type === 'form:SimpleForm');
+ return form.aggs.content.slice(0, 2).map((n) => n.id);
+ });
+ await page.getByRole('tab', { name: 'Outline' }).click();
+ await page.locator(`#layers .lrow[data-id="${labels[0]}"]`).click();
+ await page.locator(`#layers .lrow[data-id="${labels[1]}"]`).click({ modifiers: ['Control'] });
+ await expect(page.locator('#layers .lrow.sel')).toHaveCount(2);
+ await expect(page.locator('#inspector .multi-bar')).toContainText('2 controls selected');
+ await expect(preview(page).locator('.dsg-also')).toHaveCount(1);
+ await page.locator('#inspector .multi-bar select').selectOption('HBox');
+ const box = await page.evaluate(() => {
+ const hit = window.designer.state.design;
+ const walk = (n) => { if (n.type === 'HBox') return n; for (const l of Object.values(n.aggs || {})) for (const c of l) { const r = walk(c); if (r) return r; } return null; };
+ const b = walk(hit.root);
+ return b && b.aggs.items.map((n) => n.id);
+ });
+ expect(box).toEqual(labels);
+ await waitRendered(page);
+ // and two again, deleted with one key
+ await page.locator(`#layers .lrow[data-id="${labels[0]}"]`).click();
+ await page.locator(`#layers .lrow[data-id="${labels[1]}"]`).click({ modifiers: ['Shift'] });
+ await page.keyboard.press('Delete');
+ await expect.poll(() => page.evaluate((ids) => ids.filter((id) => document.querySelector(`#layers .lrow[data-id="${id}"]`)).length, labels)).toBe(0);
+});
+
+test('several controls copied go into the clipboard together', async ({ designer: page, context }) => {
+ await context.grantPermissions(['clipboard-read', 'clipboard-write']);
+ const sw = await nodeIds(page, 'Switch');
+ const before = (await nodeIds(page, 'Label')).length;
+ const ids = await page.evaluate(() => {
+ const form = window.designer.state.design.root.aggs.content[0].aggs.content.find((n) => n.type === 'form:SimpleForm');
+ return form.aggs.content.filter((n) => n.type === 'Label').slice(0, 2).map((n) => n.id);
+ });
+ await page.evaluate((x) => { window.designer.select(x[0]); window.designer.select(x[1], { extend: true }); }, ids);
+ await page.locator('body').click({ position: { x: 5, y: 300 } }).catch(() => {});
+ await page.evaluate(() => window.designer.copySelection(false));
+ await page.evaluate(() => window.designer.pasteSelection());
+ await expect.poll(async () => (await nodeIds(page, 'Label')).length).toBe(before + 2);
+ expect((await nodeIds(page, 'Switch')).length).toBe(sw.length);
+});
+
+test('attributes change their order, rows come pasted from a spreadsheet', async ({ designer: page }) => {
+ await page.getByRole('tab', { name: 'Data' }).click();
+ await page.locator('#dataPanel').getByRole('button', { name: '+ Table' }).click();
+ const card = page.locator('#dataPanel .attr').last();
+ const name = await card.locator('.aname').inputValue();
+ await card.getByRole('button', { name: 'Move attribute up' }).click();
+ await expect(page.locator('#dataPanel .attr').nth(-2).locator('.aname')).toHaveValue(name);
+ await page.locator('#dataPanel .attr').nth(-2).getByRole('button', { name: 'Paste rows…' }).click();
+ await page.locator('#dlg textarea').fill('Text\tCity\nAnna\tBerlin\nBen\tParis');
+ await page.locator('#dlg').getByRole('button', { name: 'Paste' }).click();
+ const at = await page.evaluate((n) => window.designer.state.design.attributes.find((a) => a.name === n), name);
+ expect(at.fields.map((f) => f.name)).toEqual(['TEXT', 'CITY']);
+ expect(at.rows).toEqual([{ TEXT: 'Anna', CITY: 'Berlin' }, { TEXT: 'Ben', CITY: 'Paris' }]);
+});
+
+test('Preview mode lists the events and what the user changed', async ({ designer: page }) => {
+ await page.locator('.ins-item[data-type="Button"]').click();
+ await page.locator('#inspector .event input').first().fill('GO');
+ await waitRendered(page);
+ await page.locator('[data-mode="interact"]').click();
+ await preview(page).getByRole('button', { name: 'Button' }).last().click();
+ await expect(page.locator('#eventLog')).toBeVisible();
+ await expect(page.locator('#eventLog')).toContainText('GO');
+ await page.locator('[data-mode="design"]').click();
+ await expect(page.locator('#eventLog')).toBeHidden();
+});
+
+test('a design saved in this browser opens again from the list', async ({ designer: page }) => {
+ await page.evaluate(() => window.designer.commit((d) => { d.className = 'ZCL_KEPT'; }));
+ await page.locator('#btnFile').click();
+ await page.locator('#btnSaveBrowser').click();
+ await page.evaluate(() => window.designer.commit((d) => { d.className = 'ZCL_OTHER'; }));
+ await page.locator('#btnFile').click();
+ await page.locator('#btnRecent').click();
+ await page.locator('#dlg .starter', { hasText: 'ZCL_KEPT' }).click();
+ await expect(page.locator('#className')).toHaveValue('ZCL_KEPT');
+});
+
+test('the context menu of the outline wraps, the one of the canvas deletes', async ({ designer: page }) => {
+ const sw = (await nodeIds(page, 'Switch'))[0];
+ await page.getByRole('tab', { name: 'Outline' }).click();
+ await page.evaluate((id) => window.designer.select(id), sw);
+ await page.locator(`#layers .lrow[data-id="${sw}"]`).click({ button: 'right' });
+ const menu = page.locator('.menu.ctx');
+ await expect(menu).toBeVisible();
+ await expect(menu.getByRole('menuitem').first()).toBeFocused();
+ await menu.getByRole('menuitem', { name: 'Panel' }).click();
+ await expect(menu).toBeHidden();
+ expect(await page.evaluate((id) => {
+ const walk = (n, parent) => { if (n.id === id) return parent && parent.type; for (const l of Object.values(n.aggs || {})) for (const c of l) { const r = walk(c, n); if (r) return r; } return null; };
+ return walk(window.designer.state.design.root, null);
+ }, sw)).toBe('Panel');
+ await waitRendered(page);
+ await preview(page).locator('[id*="--n"][role="switch"]').first().click({ button: 'right' });
+ await expect(menu).toBeVisible();
+ await menu.getByRole('menuitem', { name: 'Delete' }).click();
+ await expect.poll(() => nodeIds(page, 'Switch')).toEqual([]);
+ // and by key: Shift+F10 opens it at the selection, Escape closes it
+ await page.locator('#layers .lrow.sel').focus();
+ await page.keyboard.press('Shift+F10');
+ await expect(menu).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(menu).toBeHidden();
+});
+
+test('selected rows of the outline drag together', async ({ designer: page }) => {
+ const ids = await page.evaluate(() => {
+ const form = window.designer.state.design.root.aggs.content[0].aggs.content.find((n) => n.type === 'form:SimpleForm');
+ return form.aggs.content.slice(0, 2).map((n) => n.id);
+ });
+ await page.evaluate(() => window.designer.addControl('VBox', 'root'));
+ const box = (await nodeIds(page, 'VBox')).at(-1);
+ await page.getByRole('tab', { name: 'Outline' }).click();
+ await page.evaluate((x) => { window.designer.select(x[0]); window.designer.select(x[1], { extend: true }); }, ids);
+ await page.locator(`#layers .lrow[data-id="${ids[1]}"]`).dragTo(page.locator(`#layers .lrow[data-id="${box}"]`));
+ await expect.poll(() => page.evaluate((id) => {
+ const walk = (n) => { if (n.id === id) return n; for (const l of Object.values(n.aggs || {})) for (const c of l) { const r = walk(c); if (r) return r; } return null; };
+ return (walk(window.designer.state.design.root).aggs.items || []).map((n) => n.id);
+ }, box)).toEqual(ids);
+});
+
+test('the controls inserted last come first, the properties of one go onto another', async ({ designer: page }) => {
+ await page.locator('.ins-item[data-type="Button"]').click();
+ await page.locator('.ins-item[data-type="Title"]').click();
+ await expect(page.locator('.ins-group.recent .ins-item')).toHaveText([/Title/, /Button/]);
+ // a Button with a type, its properties onto a new Button
+ await page.locator('.ins-group.recent [data-recent="Button"]').click();
+ await prop(page, 'type').locator('.val select').selectOption('Emphasized');
+ await page.keyboard.press('Control+K');
+ await page.locator('#cmdkInput').fill('copy properties');
+ await page.keyboard.press('Enter');
+ await page.locator('.ins-group.recent [data-recent="Button"]').click();
+ await page.keyboard.press('Control+K');
+ await page.locator('#cmdkInput').fill('paste properties');
+ await page.keyboard.press('Enter');
+ await expect(prop(page, 'type').locator('.val select')).toHaveValue('Emphasized');
+});
+
+test('an empty container has room on the canvas to drop into', async ({ designer: page }) => {
+ await page.evaluate(() => window.designer.addControl('VBox', 'root'));
+ const box = (await nodeIds(page, 'VBox')).at(-1);
+ const el = preview(page).locator(`[id$="--${box}"]`);
+ await expect(el).toHaveClass(/dsg-empty/);
+ expect((await el.boundingBox()).height).toBeGreaterThan(30);
+ await page.locator('[data-mode="interact"]').click();
+ await expect(el).not.toHaveClass(/dsg-empty/);
+});
+
+test('a design saved by another tab is announced, and can be loaded', async ({ designer: page, context }) => {
+ const other = await context.newPage();
+ await other.goto(page.url());
+ await waitRendered(other);
+ await other.evaluate(() => window.designer.commit((d) => { d.className = 'ZCL_FROM_THE_OTHER_TAB'; }));
+ await expect(page.locator('#toast')).toContainText('Another tab of the editor');
+ await page.locator('#toast').getByRole('button', { name: 'Load theirs' }).click();
+ await expect(page.locator('#className')).toHaveValue('ZCL_FROM_THE_OTHER_TAB');
+ await other.close();
+});
+
+test('an opened class shows what the export changes in it', async ({ designer: page }) => {
+ await expect(page.locator('#btnDiff')).toBeHidden();
+ const original = await abapOf(page);
+ await page.locator('#btnFile').click();
+ await page.locator('#btnOpenClass').click();
+ await page.locator('#dlg textarea').fill(original);
+ await page.locator('#dlg').getByRole('button', { name: 'Open', exact: true }).click();
+ await page.locator('#dlg').getByRole('button', { name: 'OK' }).click();
+ await page.locator('#btnDiff').click();
+ await expect(page.locator('#code')).toContainText('Every line stays as it was');
+ await page.evaluate(() => window.designer.commit((d) => { d.root.aggs.content[0].props.title = { m: 's', v: 'Changed title' }; }));
+ await expect(page.locator('#codeInfo')).toContainText('+1 −1');
+ await expect(page.locator('#code .ln.add')).toContainText('Changed title');
+ await expect(page.locator('#code .ln.del')).toHaveCount(1);
+});
+
+test('a double-click on the canvas edits the text of a control there', async ({ designer: page }) => {
+ await page.locator('.ins-item[data-type="Button"]').click();
+ const id = await selected(page);
+ await waitRendered(page);
+ await preview(page).locator(`[id$="--${id}"]`).dblclick();
+ const field = page.locator('.inline-edit');
+ await expect(field).toBeFocused();
+ await expect(field).toHaveValue('Button');
+ await field.fill('Ship it');
+ await page.keyboard.press('Enter');
+ await expect(field).toHaveCount(0);
+ await expect(preview(page).getByText('Ship it')).toBeVisible();
+ // Escape takes the edit back
+ await preview(page).locator(`[id$="--${id}"]`).dblclick();
+ await page.locator('.inline-edit').fill('Not this');
+ await page.keyboard.press('Escape');
+ await expect.poll(() => page.evaluate((x) => {
+ const walk = (n) => { if (n.id === x) return n; for (const l of Object.values(n.aggs || {})) for (const c of l) { const r = walk(c); if (r) return r; } return null; };
+ return walk(window.designer.state.design.root).props.text.v;
+ }, id)).toBe('Ship it');
+});
+
+test('the editor is light, dark or as the system, and keeps the choice', async ({ designer: page }) => {
+ const theme = () => page.evaluate(() => document.documentElement.dataset.theme || '');
+ expect(await theme()).toBe('');
+ await page.locator('#btnTheme').click();
+ expect(await theme()).toBe('light');
+ await page.locator('#btnTheme').click();
+ expect(await theme()).toBe('dark');
+ expect(await page.evaluate(() => getComputedStyle(document.body).backgroundColor)).toBe('rgb(18, 23, 28)');
+ // kept with the preferences (a reload here would start from a cleared store)
+ expect(await page.evaluate(() => JSON.parse(localStorage.getItem('abap2ui5-designer:prefs')).appearance)).toBe('dark');
+});
+
+test('a wrap that cannot be done says why, never that it was done', async ({ designer: page }) => {
+ await page.evaluate(() => { window.designer.addControl('VBox', 'root'); });
+ const outer = (await nodeIds(page, 'VBox')).at(-1);
+ await page.evaluate((id) => window.designer.addControl('VBox', id), outer);
+ const inner = (await nodeIds(page, 'VBox')).at(-1);
+ // a VBox inside another and a Switch elsewhere: not side by side
+ const sw = (await nodeIds(page, 'Switch'))[0];
+ await page.evaluate((x) => { window.designer.select(x[0]); window.designer.select(x[1], { extend: true }); }, [inner, sw]);
+ await page.keyboard.press('Control+K');
+ await page.locator('#cmdkInput').fill('wrap in panel');
+ await page.keyboard.press('Enter');
+ await expect(page.locator('#toast.error')).toContainText('side by side');
+ await expect(page.locator('#toast')).not.toContainText('Wrapped');
+});
+
+test('a list line without content is no empty container on the canvas', async ({ designer: page }) => {
+ await page.locator('.ins-item[data-type="List"]').click();
+ const list = await selected(page);
+ await page.evaluate((id) => window.designer.addControl('ObjectListItem', id), list);
+ const item = (await nodeIds(page, 'ObjectListItem')).at(-1);
+ await waitRendered(page);
+ await expect(preview(page).locator(`[id$="--${item}"]`).first()).not.toHaveClass(/dsg-empty/);
+});
+
+test('the canvas zooms out, and a double-click still edits the control under the pointer', async ({ designer: page }) => {
+ await page.locator('.ins-item[data-type="Button"]').click();
+ const id = await selected(page);
+ await page.locator('#zoom').selectOption('0.5');
+ await expect(page.locator('#preview')).toHaveCSS('zoom', '0.5');
+ await waitRendered(page);
+ // where the button is on the editor's page: the frame's box, and the
+ // button's box in the frame, zoomed (Playwright's own boxes do not know
+ // the zoom of a frame)
+ const at = await page.evaluate((x) => {
+ const f = document.querySelector('#preview');
+ const r = f.getBoundingClientRect();
+ const b = f.contentDocument.querySelector(`[id$="--${x}"]`).getBoundingClientRect();
+ return { x: r.left + b.left * 0.5, y: r.top + b.top * 0.5, w: b.width * 0.5, h: b.height * 0.5 };
+ }, id);
+ await page.mouse.dblclick(at.x + at.w / 2, at.y + at.h / 2);
+ const field = await page.locator('.inline-edit').boundingBox();
+ expect(Math.abs(field.x - at.x)).toBeLessThan(4);
+ expect(Math.abs(field.y - at.y)).toBeLessThan(4);
+ await page.keyboard.press('Escape');
+ await page.locator('#zoom').selectOption('1');
+ await expect(page.locator('#preview')).not.toHaveCSS('zoom', '0.5');
+});
+
+test('? lists the keyboard shortcuts', async ({ designer: page }) => {
+ await page.locator('#crumbs').click();
+ await page.keyboard.press('?');
+ await expect(page.locator('#dlg')).toContainText('Keyboard shortcuts');
+ await expect(page.locator('#dlg table.keys')).toContainText('Ctrl+K');
+ await page.keyboard.press('Escape');
+ await expect(page.locator('#dlg')).toBeHidden();
+});
+
+test('the keyboard finds its way: a letter in the outline, Enter in the control search', async ({ designer: page }) => {
+ await page.getByRole('tab', { name: 'Outline' }).click();
+ await page.locator('#layers .lrow.sel').focus();
+ await page.keyboard.press('s');
+ await expect(selectedType(page)).toHaveText(/^S/);
+ await page.getByRole('tab', { name: 'Controls' }).click();
+ const before = (await nodeIds(page, 'RatingIndicator')).length;
+ await page.locator('#insertSearch').fill('rating');
+ await page.keyboard.press('Enter');
+ await expect.poll(async () => (await nodeIds(page, 'RatingIndicator')).length).toBe(before + 1);
+ await page.locator('#insertSearch').focus();
+ await page.keyboard.press('ArrowDown');
+ await expect(page.locator('#insertList .ins-item').first()).toBeFocused();
+});
+
+test('the command palette puts what starts with the words first', async ({ designer: page }) => {
+ await page.keyboard.press('Control+K');
+ await page.locator('#cmdkInput').fill('button');
+ await expect(page.locator('.cmdk-item').first()).toContainText('Button');
+ await expect(page.locator('.cmdk-item').first().locator('.ck')).toHaveText(/insert|go to/);
+});
+
+test('the outline marks controls that raise events, the code tab copies what it shows', async ({ designer: page, context }) => {
+ await context.grantPermissions(['clipboard-read', 'clipboard-write']);
+ await page.locator('.ins-item[data-type="Button"]').click();
+ const id = await selected(page);
+ await page.locator('#inspector .event input').first().fill('GO');
+ await page.getByRole('tab', { name: 'Outline' }).click();
+ await expect(page.locator(`#layers .lrow[data-id="${id}"] .evt`)).toHaveAttribute('title', 'raises GO');
+ await page.locator('[data-code="xml"]').click();
+ await page.locator('#btnCopyCode').click();
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toContain(' {
+ const { wrapNodes } = await import('../src/model.js');
+ const d = createDesign();
+ const p = page(d);
+ const a = createNode('Button');
+ const b = createNode('Text');
+ const c = createNode('Input');
+ for (const n of [a, b, c]) insertNode(d, n, p.id);
+ const before = p.aggs.content.length;
+ const id = wrapNodes(d, [c.id, a.id], 'HBox');
+ const box = findNode(d.root, id);
+ assert.equal(box.node.type, 'HBox');
+ assert.deepEqual(box.node.aggs.items.map((n) => n.id), [a.id, c.id], 'in the order of the view, not of the clicks');
+ assert.equal(p.aggs.content.length, before - 1);
+ assert.equal(p.aggs.content.indexOf(box.node), before - 3, 'where the first of them was');
+ assert.equal(findNode(d.root, b.id).parent.id, p.id);
+});
+
+test('a wrap refuses what does not sit side by side, or what the wrapper does not take', async () => {
+ const { wrapNodes } = await import('../src/model.js');
+ const d = createDesign();
+ const p = page(d);
+ const box = createNode('VBox');
+ insertNode(d, box, p.id);
+ const inner = createNode('Button');
+ insertNode(d, inner, box.id);
+ const outer = createNode('Button');
+ insertNode(d, outer, p.id);
+ assert.throws(() => wrapNodes(d, [inner.id, outer.id], 'HBox'), /side by side/);
+ const bar = createNode('IconTabBar');
+ insertNode(d, bar, p.id);
+ const tab = Object.values(bar.aggs).flat().find((n) => n.type === 'IconTabFilter');
+ assert.throws(() => wrapNodes(d, [tab.id], 'VBox'), /takes no/, 'a tab is a part of its bar, no VBox takes it');
+ assert.ok(findNode(d.root, tab.id), 'a refused wrap leaves the design as it was - commit( ) rolls back what it did');
+});
+
+test('moveItem swaps an entry with its neighbour, and refuses the ends', async () => {
+ const { moveItem } = await import('../src/model.js');
+ const list = ['a', 'b', 'c'];
+ assert.equal(moveItem(list, 0, -1), false);
+ assert.equal(moveItem(list, 2, 1), false);
+ assert.equal(moveItem(list, 1, -1), true);
+ assert.deepEqual(list, ['b', 'a', 'c']);
+});
+
+test('the order of the attributes is the order of their declarations', async () => {
+ const { moveItem } = await import('../src/model.js');
+ const d = createDesign();
+ d.attributes = [{ name: 'first', type: 'string', value: '' }, { name: 'second', type: 'string', value: '' }];
+ moveItem(d.attributes, 1, -1);
+ const src = generateClass(d);
+ assert.ok(src.indexOf('DATA second') < src.indexOf('DATA first'));
+});
+
+test('sample rows pasted from a spreadsheet or CSV', async () => {
+ const { parseSampleRows } = await import('../src/model.js');
+ const fields = [{ name: 'TITLE', type: 'string' }, { name: 'DONE', type: 'bool' }, { name: 'KIDS', type: 'table', fields: [] }];
+ // a spreadsheet: tabs, a header that names a field the table has not
+ let r = parseSampleRows('Title\tDone\tCity name\nFirst\tX\tBerlin\nSecond\t\tParis\n', fields, true);
+ assert.deepEqual(r.added, [{ name: 'CITY_NAME', type: 'string' }]);
+ assert.deepEqual(r.rows, [{ TITLE: 'First', DONE: true, CITY_NAME: 'Berlin' }, { TITLE: 'Second', DONE: false, CITY_NAME: 'Paris' }]);
+ // CSV without a header: the scalar fields in their order, the rest counted
+ r = parseSampleRows('"A, quoted";true;extra\r\nB;0\r\n', fields, false);
+ assert.deepEqual(r.rows, [{ TITLE: 'A, quoted', DONE: true }, { TITLE: 'B', DONE: false }]);
+ assert.equal(r.ignored, 1);
+ assert.deepEqual(r.added, []);
+ // a header that names a table field leaves that column out
+ r = parseSampleRows('KIDS,TITLE\nx,y', fields, true);
+ assert.deepEqual(r.rows, [{ TITLE: 'y' }]);
+ assert.deepEqual(parseSampleRows(' \n', fields, true).rows, []);
+});
+
+test('a check that can be fixed says how - and the fix is data, not code', () => {
+ const d = createDesign();
+ d.attributes = [{ name: 'unused', type: 'string', value: '' }];
+ const input = createNode('Input');
+ input.props.value = { m: 'b', v: 'missing_name' };
+ insertNode(d, input, page(d).id);
+ const tree = createNode('Tree');
+ insertNode(d, tree, page(d).id);
+ const issues = validate(d);
+ const fix = (re) => issues.find((i) => re.test(i.text))?.fix;
+ assert.deepEqual(fix(/is bound to missing_name/), { kind: 'createAttribute', name: 'missing_name', type: 'string', label: 'Create missing_name' });
+ assert.equal(issues.find((i) => /is bound to missing_name/.test(i.text)).prop, 'value');
+ assert.equal(fix(/unused is public/).kind, 'removeAttribute');
+ assert.equal(fix(/Tree: its items/).kind, 'nodesTable');
+ assert.doesNotThrow(() => JSON.stringify(issues));
+});
+
+test('several nodes move together, in the order of the view, before the node at the index', async () => {
+ const { moveNodes } = await import('../src/model.js');
+ const d = createDesign();
+ const p = page(d);
+ p.aggs.content = [];
+ const [a, b, c, e] = ['Button', 'Text', 'Input', 'Title'].map((t) => { const n = createNode(t); insertNode(d, n, p.id); return n; });
+ const box = createNode('VBox');
+ insertNode(d, box, p.id);
+ // c and a, clicked in that order, into the VBox
+ assert.equal(moveNodes(d, [c.id, a.id], box.id, 'items', null), c.id);
+ assert.deepEqual(box.aggs.items.map((n) => n.id), [a.id, c.id]);
+ assert.deepEqual(p.aggs.content.map((n) => n.id), [b.id, e.id, box.id]);
+ // back, before e - an index of the list as it is before the move
+ assert.equal(moveNodes(d, [a.id, c.id], p.id, 'content', 1), c.id);
+ assert.deepEqual(p.aggs.content.map((n) => n.id), [b.id, a.id, c.id, e.id, box.id]);
+ // never into one of the moved
+ assert.equal(moveNodes(d, [box.id, b.id], box.id, 'items', null), null);
+});
+
+test('"Build a template" is offered only where it replaces nothing built by hand and has a table to build from', () => {
+ const d = createDesign();
+ d.attributes = [table('t_items', ['TITLE'], [])];
+ const t = createNode('Table');
+ insertNode(d, t, page(d).id);
+ bindListToTable(d, t.id, 'items', 't_items');
+ const fixOf = () => validate(d).find((i) => /exactly one template/.test(i.text))?.fix;
+ t.aggs.items = [];
+ assert.equal(fixOf(), undefined, 'the columns of the table are its own');
+ delete t.aggs.columns;
+ assert.equal(fixOf().label, 'Build a template');
+ t.props.items.v = 'missing';
+ assert.equal(fixOf(), undefined, 'no table to build from');
+});
|