diff --git a/app/patternfront.html b/app/patternfront.html index 09a86c4..ab962aa 100644 --- a/app/patternfront.html +++ b/app/patternfront.html @@ -154,14 +154,26 @@ border:0.1rem solid var(--edge-dim)} input[type=range]::-moz-range-thumb{width:0.8rem;height:1.4rem;border-radius:0; background:var(--accent);border:0.1rem solid var(--accent-ink)} -input[type=text]{height:1.8rem;background:var(--well);color:var(--text); +/* One box for every field you type into. An attribute selector matches the + ATTRIBUTE, not the resolved type, so `input[type=text]` on its own skips + `type=password` and skips an written without any type at all — both + of which are still text fields. Naming all three is what lets the markup + stop carrying inline copies of this rule. */ +input[type=text],input[type=password],input:not([type]), +input[type=number],select{height:1.8rem;background:var(--well);color:var(--text); border:0.1rem solid var(--edge-dim);padding:0 0.4rem; font:400 var(--lbl)/1 var(--mono);min-width:0} -input[type=number],select{height:1.8rem;background:var(--well);color:var(--text); - border:0.1rem solid var(--edge-dim);padding:0 0.4rem;font:400 var(--lbl)/1 var(--mono); - font-variant-numeric:tabular-nums} +input[type=number],select{font-variant-numeric:tabular-nums} input[type=number]{width:4.2rem;text-align:center} -input[type=number].wide{flex:1;width:auto;min-width:0} +/* "Fill the rest of the row." Deliberately type-agnostic: as + `input[type=number].wide` it quietly did nothing for the API-key, prompt and + pattern-name fields, which sat at the default 20-character width with half + their row empty beside them. */ +.wide{flex:1;width:auto;min-width:0} +/* body{user-select:none} stops a drag across the chrome from selecting labels, + but it is inherited into the form fields too — where it means you cannot + select the text you just typed, or double-click a pasted key to replace it. */ +input,textarea{-webkit-user-select:text;user-select:text} /* Flex children must be allowed to shrink, or a narrow column overflows instead of compressing. */ .r>.b,.duo>.b,.r>select{min-width:0} @@ -556,15 +568,11 @@ Anthropic, never to this project. The program it writes lands in the box above, where you can read and edit it before rendering.
+ autocomplete="off" spellcheck="false">
@@ -594,9 +602,7 @@ -
+
@@ -2853,6 +2859,13 @@ // The menu drives the same functions the buttons do — one implementation each, // so a menu item cannot drift from its on-screen twin. +// True while a text field has focus. The desktop Edit menu owns Cmd+Z/X/C/V/A +// for both the canvas and the fields; it has already asked the web contents to +// do the text-editing half, so the canvas half must stand down — otherwise +// fixing a typo in the key box undoes a brush stroke behind it. +const typing=()=>{const a=document.activeElement; + const t=((a&&a.tagName)||'').toLowerCase(); + return t==='input'||t==='textarea'||t==='select';}; const COMMANDS={ 'file.new': ()=>{startFresh();markClean();showPath(null);}, 'file.open': ()=>openDocument(), @@ -2868,12 +2881,15 @@ 'export.sheet': ()=>$('exSheet').click(), 'export.gif': ()=>$('exGIF').click(), 'export.pattern':()=>{openOv('ovExp');refreshOF();}, - 'edit.undo': ()=>undo(), - 'edit.redo': ()=>redo(), + 'edit.undo': ()=>{if(!typing())undo();}, + 'edit.redo': ()=>{if(!typing())redo();}, + 'edit.copy': ()=>{if(!typing()&&sel)copySel();}, + 'edit.paste': ()=>{if(!typing()&&clipboard)pasteSel();}, 'edit.clear': ()=>clearArt(false), 'edit.clearAll': ()=>clearArt(true), - 'edit.selectAll':()=>{sel={x:0,y:0,w:doc.w,h:doc.h};overlay();syncStatus();}, - 'edit.deselect': ()=>{sel=null;overlay();syncStatus();}, + 'edit.selectAll':()=>{if(typing())return; + sel={x:0,y:0,w:doc.w,h:doc.h};overlay();syncStatus();}, + 'edit.deselect': ()=>{if(typing())return;sel=null;overlay();syncStatus();}, 'view.zoomIn': ()=>{zoom=Math.min(48,zoom+1);$('zoom').value=zoom; $('zoomO').textContent=zoom;layout();}, 'view.zoomOut': ()=>{zoom=Math.max(1,zoom-1);$('zoom').value=zoom; @@ -2887,7 +2903,10 @@ // Keys the native menu owns. Electron fires the accelerator BEFORE the page // sees the keydown, so leaving these enabled here would undo twice per press. -const MENU_KEYS=new Set(['z','y','a','d','n','o','s']); +// x/c/v joined the list when the Edit menu gained Cut/Copy/Paste — without +// those items macOS had no responder for Cmd+V and no input in the app could +// be pasted into. +const MENU_KEYS=new Set(['z','y','a','d','n','o','s','x','c','v']); if(native()){ window.pfNative.onCommand((id,arg)=>{ diff --git a/electron/main.js b/electron/main.js index 3117f18..c69c419 100644 --- a/electron/main.js +++ b/electron/main.js @@ -13,7 +13,7 @@ const fs = require('fs/promises'); const path = require('path'); -const { app, BrowserWindow, dialog, ipcMain, protocol, net, shell } = require('electron'); +const { app, BrowserWindow, Menu, MenuItem, dialog, ipcMain, protocol, net, shell } = require('electron'); const docs = require('./documents'); const menu = require('./menu'); @@ -133,6 +133,30 @@ function createWindow() { }); win.webContents.on('will-attach-webview', e => e.preventDefault()); + // Electron ships no context menu of its own, and the canvas has its own. That + // leaves a text field with neither — right-clicking the API-key box offered + // nothing at all, which is half of why pasting a key felt impossible. Built + // per event from the flags Chromium reports, so the items are only ever + // offered when they would actually do something. + win.webContents.on('context-menu', (_e, params) => { + if (!params.isEditable) return; + const f = params.editFlags; + const m = new Menu(); + for (const [label, role, enabled] of [ + ['Undo', 'undo', f.canUndo], ['Redo', 'redo', f.canRedo], + [null, null, null], + ['Cut', 'cut', f.canCut], ['Copy', 'copy', f.canCopy], + ['Paste', 'paste', f.canPaste], + [null, null, null], + ['Select All', 'selectAll', f.canSelectAll], + ]) { + m.append(label + ? new MenuItem({ label, role, enabled: !!enabled }) + : new MenuItem({ type: 'separator' })); + } + m.popup({ window: win }); + }); + win.on('close', onClose); win.on('closed', () => { win = null; }); diff --git a/electron/menu.js b/electron/menu.js index ad975d8..81be9ec 100644 --- a/electron/menu.js +++ b/electron/menu.js @@ -23,6 +23,28 @@ function build({ send, recent, openRecent, quit }) { label, accelerator, click: () => send(id), ...extra, }); + // Undo, cut, copy, paste and select-all mean one thing over the canvas and + // another inside a text field, and a custom application menu is what decides + // which. Setting one replaces the default menu wholesale, and on macOS the + // clipboard shortcuts in web content are delivered by the menu's native + // first-responder items — so a menu without them leaves Cmd+V with nowhere to + // go and pasting into any input in the app silently does nothing. + // + // Each item here does both halves. `webContents[action]()` is the text-field + // half and is a no-op unless something editable has focus; `send(id)` is the + // canvas half, which the renderer drops while a field has focus. One key, + // whichever meaning the focus implies, and the menu item does the same thing + // as its accelerator. + const edit = (label, id, accelerator, action) => ({ + id: `edit-${action}`, + label, + accelerator, + click: (item, win) => { + if (win && !win.isDestroyed()) win.webContents[action](); + if (id) send(id); + }, + }); + const recentItems = recent.list.length ? [ ...recent.list.map(file => ({ @@ -76,14 +98,26 @@ function build({ send, recent, openRecent, quit }) { { label: '&Edit', submenu: [ - cmd('Undo', 'edit.undo', 'CmdOrCtrl+Z'), - cmd('Redo', 'edit.redo', isMac ? 'Cmd+Shift+Z' : 'Ctrl+Y'), + edit('Undo', 'edit.undo', 'CmdOrCtrl+Z', 'undo'), + edit('Redo', 'edit.redo', isMac ? 'Cmd+Shift+Z' : 'Ctrl+Y', 'redo'), + { type: 'separator' }, + // Cut has no canvas twin, so it stays text-only rather than inventing + // one; copy and paste carry the selection commands the editor already + // has. All three exist mainly so a text field behaves like a text field. + edit('Cut', null, 'CmdOrCtrl+X', 'cut'), + edit('Copy', 'edit.copy', 'CmdOrCtrl+C', 'copy'), + edit('Paste', 'edit.paste', 'CmdOrCtrl+V', 'paste'), { type: 'separator' }, - cmd('Select All', 'edit.selectAll', 'CmdOrCtrl+A'), + edit('Select All', 'edit.selectAll', 'CmdOrCtrl+A', 'selectAll'), cmd('Deselect', 'edit.deselect', 'CmdOrCtrl+D'), { type: 'separator' }, - cmd('Clear', 'edit.clear', 'Delete'), - cmd('Clear Every Layer', 'edit.clearAll', 'Shift+Delete'), + // No accelerator on purpose. A bare, unmodified key in the menu is + // swallowed application-wide, so `Delete` here made the key stop + // deleting characters in every text field — and fired alongside the + // renderer's own handler, clearing twice per press. The renderer owns + // Delete and Backspace, and already ignores them while a field has focus. + cmd('Clear', 'edit.clear'), + cmd('Clear Every Layer', 'edit.clearAll'), ], }, diff --git a/package-lock.json b/package-lock.json index 0eac301..ef9c15f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "patternfront", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "patternfront", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "devDependencies": { "electron": "^43.4.0", diff --git a/package.json b/package.json index 2988132..613701c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "patternfront", "productName": "PatternFront", - "version": "0.2.0", + "version": "0.2.1", "description": "A 1-bit pattern editor for OpenFront territory patterns \u2014 desktop app for macOS and Windows.", "keywords": [ "pixel-art", diff --git a/tools/verify-electron.py b/tools/verify-electron.py index 98be760..a411099 100755 --- a/tools/verify-electron.py +++ b/tools/verify-electron.py @@ -110,6 +110,28 @@ def main() -> int: "fileAssociations" in read_root("electron-builder.yml") and "ext: patternfront" in read_root("electron-builder.yml")) + # Setting a custom application menu replaces the default one wholesale, and + # on macOS the clipboard shortcuts in web content are delivered by the + # menu. A menu with no Paste item leaves Cmd+V with nowhere to go and no + # input in the app can be pasted into — which is how an API key becomes + # untypeable. Every text-editing item must drive the web contents. + chk("Edit items drive the web contents, not just the canvas", + "win.webContents[action]()" in menu) + for label, accel in (("Undo", "CmdOrCtrl+Z"), ("Cut", "CmdOrCtrl+X"), + ("Copy", "CmdOrCtrl+C"), ("Paste", "CmdOrCtrl+V"), + ("Select All", "CmdOrCtrl+A")): + chk(f"{label} is in the Edit menu on {accel}", + re.search(rf"edit\('{re.escape(label)}',[^)]*{re.escape(accel)}", menu) is not None) + # A plain, unmodified key in the menu is swallowed application-wide, so a + # bare `Delete` accelerator stops the key deleting characters in every text + # field. The renderer's keydown owns it and already skips text fields. + chk("no bare Delete accelerator to eat the key in text fields", + "'Delete'" not in menu) + # Electron ships no context menu; the canvas has its own, so without this a + # text field gets neither and right-click offers no way to paste. + chk("right-click offers paste inside a text field", + "params.isEditable" in main_js and "'Paste', 'paste'" in main_js) + print("\n=== packaging ===") yml = read_root("electron-builder.yml") chk("mac builds both architectures", "arch: [arm64, x64]" in yml) diff --git a/tools/verify-ui.py b/tools/verify-ui.py index 046a3c5..d7fddc2 100644 --- a/tools/verify-ui.py +++ b/tools/verify-ui.py @@ -153,6 +153,24 @@ def main() -> int: chk("column widths default to 152 / 240 / 190 @1x", "--wL:15.2rem" in css and "--wP:24rem" in css and "--wR:19rem" in css) chk("24px bars @1x", "height:2.4rem" in css) + # `.wide` means "fill the row". It was written as `input[type=number].wide`, + # which silently skips every text-ish field: an attribute selector matches + # the attribute, so it misses `type=password` AND an with no type at + # all. The API-key and prompt fields wore the class and stayed 195px wide in + # a 491px row. Keep the selector type-agnostic. + wide_rule = re.search(r"(^|[,\s{}])\.wide\s*\{", css, re.M) + chk("`.wide` fills its row for every input type, not just numbers", + wide_rule is not None and "input[type=number].wide{" not in css) + # Text fields need the same box as the numeric ones. `input[type=text]` + # alone misses password fields and typeless inputs, which is why three of + # them carried duplicated inline styles instead. + chk("text, password and typeless inputs share one rule", + "input[type=password]" in css and "input:not([type])" in css) + # body{user-select:none} keeps drags from selecting the chrome, but it is + # inherited straight into every form field, so you cannot select what you + # just typed — or double-click a pasted key to replace it. + chk("form fields opt back in to text selection", + re.search(r"input[^{]*,?[^{]*textarea[^{]*\{[^}]*user-select:\s*text", css) is not None) print("\n=== interface scale ===") chk("root font-size drives the scale", "font-size:calc(10px * var(--scale))" in css)