mirror of
https://github.com/alesimula/Murine-launcher.git
synced 2026-10-28 09:43:42 +00:00
WEBSITE - WIP - icon shapes - multi-shape editor
This commit is contained in:
1 parent
6bdfeb087c
commit
28bc4b3911
5 files changed
+555
-155
No files matched your search
+1
-1
@@ -5,7 +5,7 @@ description: >-
|
||||
url: "https://alesimula.github.io"
|
||||
baseurl: ""
|
||||
|
||||
version: "1.0.15" #Website version - increase to invalidate cached resources
|
||||
version: "1.0.16" #Website version - increase to invalidate cached resources
|
||||
github_repo: "alesimula/Murine-launcher"
|
||||
icon_url: "https://raw.githubusercontent.com/alesimula/Murine-launcher/refs/heads/main/image/app_icon/icon_browser_fullsize.svg"
|
||||
icon_freeform_url: "https://raw.githubusercontent.com/alesimula/Murine-launcher/refs/heads/main/image/app_icon/icon_freeform.svg"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Material Symbols: only the icons in use (~4 KB instead of the ~4 MB variable font). TODO Add every new icon here, list must stay alphabetical -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=android,calendar_today,chat,close,cloud_off,code,dark_mode,description,download,favorite,history,home,hourglass_top,light_mode,mail,new_releases,palette,policy,search,sell,smartphone&display=block">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=add,android,calendar_today,chat,close,cloud_off,code,dark_mode,delete,description,download,edit,favorite,fit_screen,history,home,hourglass_top,light_mode,mail,new_releases,open_with,palette,policy,search,sell,smartphone&display=block">
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="{{ site.icon_url }}">
|
||||
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}?v={{ site.version }}">
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/* polygon-clipping 0.15.7 | MIT License | https://github.com/mfogel/polygon-clipping */
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).polygonClipping=e()}(this,(function(){"use strict";
|
||||
/**
|
||||
* splaytree v3.1.2
|
||||
* Fast Splay tree for Node and browser
|
||||
*
|
||||
* @author Alexander Milevski <info@w8r.name>
|
||||
* @license MIT
|
||||
* @preserve
|
||||
*/
|
||||
/*! *****************************************************************************
|
||||
Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
this file except in compliance with the License. You may obtain a copy of the
|
||||
License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
|
||||
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
|
||||
MERCHANTABLITY OR NON-INFRINGEMENT.
|
||||
|
||||
See the Apache Version 2.0 License for specific language governing permissions
|
||||
and limitations under the License.
|
||||
***************************************************************************** */function t(t,e){var n,r,i,o,s={label:0,sent:function(){if(1&i[0])throw i[1];return i[1]},trys:[],ops:[]};return o={next:l(0),throw:l(1),return:l(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function l(o){return function(l){return function(o){if(n)throw new TypeError("Generator is already executing.");for(;s;)try{if(n=1,r&&(i=2&o[0]?r.return:o[0]?r.throw||((i=r.return)&&i.call(r),0):r.next)&&!(i=i.call(r,o[1])).done)return i;switch(r=0,i&&(o=[2&o[0],i.value]),o[0]){case 0:case 1:i=o;break;case 4:return s.label++,{value:o[1],done:!1};case 5:s.label++,r=o[1],o=[0];continue;case 7:o=s.ops.pop(),s.trys.pop();continue;default:if(!(i=s.trys,(i=i.length>0&&i[i.length-1])||6!==o[0]&&2!==o[0])){s=0;continue}if(3===o[0]&&(!i||o[1]>i[0]&&o[1]<i[3])){s.label=o[1];break}if(6===o[0]&&s.label<i[1]){s.label=i[1],i=o;break}if(i&&s.label<i[2]){s.label=i[2],s.ops.push(o);break}i[2]&&s.ops.pop(),s.trys.pop();continue}o=e.call(t,s)}catch(t){o=[6,t],r=0}finally{n=i=0}if(5&o[0])throw o[1];return{value:o[0]?o[1]:void 0,done:!0}}([o,l])}}}var e=function(t,e){this.next=null,this.key=t,this.data=e,this.left=null,this.right=null};function n(t,e){return t>e?1:t<e?-1:0}function r(t,n,r){for(var i=new e(null,null),o=i,s=i;;){var l=r(t,n.key);if(l<0){if(null===n.left)break;if(r(t,n.left.key)<0){var h=n.left;if(n.left=h.right,h.right=n,null===(n=h).left)break}s.left=n,s=n,n=n.left}else{if(!(l>0))break;if(null===n.right)break;if(r(t,n.right.key)>0){h=n.right;if(n.right=h.left,h.left=n,null===(n=h).right)break}o.right=n,o=n,n=n.right}}return o.right=n.left,s.left=n.right,n.left=i.right,n.right=i.left,n}function i(t,n,i,o){var s=new e(t,n);if(null===i)return s.left=s.right=null,s;var l=o(t,(i=r(t,i,o)).key);return l<0?(s.left=i.left,s.right=i,i.left=null):l>=0&&(s.right=i.right,s.left=i,i.right=null),s}function o(t,e,n){var i=null,o=null;if(e){var s=n((e=r(t,e,n)).key,t);0===s?(i=e.left,o=e.right):s<0?(o=e.right,e.right=null,i=e):(i=e.left,e.left=null,o=e)}return{left:i,right:o}}function s(t,e,n,r,i){if(t){r(e+(n?"└── ":"├── ")+i(t)+"\n");var o=e+(n?" ":"│ ");t.left&&s(t.left,o,!1,r,i),t.right&&s(t.right,o,!0,r,i)}}var l=function(){function l(t){void 0===t&&(t=n),this._root=null,this._size=0,this._comparator=t}return l.prototype.insert=function(t,e){return this._size++,this._root=i(t,e,this._root,this._comparator)},l.prototype.add=function(t,n){var i=new e(t,n);null===this._root&&(i.left=i.right=null,this._size++,this._root=i);var o=this._comparator,s=r(t,this._root,o),l=o(t,s.key);return 0===l?this._root=s:(l<0?(i.left=s.left,i.right=s,s.left=null):l>0&&(i.right=s.right,i.left=s,s.right=null),this._size++,this._root=i),this._root},l.prototype.remove=function(t){this._root=this._remove(t,this._root,this._comparator)},l.prototype._remove=function(t,e,n){var i;return null===e?null:0===n(t,(e=r(t,e,n)).key)?(null===e.left?i=e.right:(i=r(t,e.left,n)).right=e.right,this._size--,i):e},l.prototype.pop=function(){var t=this._root;if(t){for(;t.left;)t=t.left;return this._root=r(t.key,this._root,this._comparator),this._root=this._remove(t.key,this._root,this._comparator),{key:t.key,data:t.data}}return null},l.prototype.findStatic=function(t){for(var e=this._root,n=this._comparator;e;){var r=n(t,e.key);if(0===r)return e;e=r<0?e.left:e.right}return null},l.prototype.find=function(t){return this._root&&(this._root=r(t,this._root,this._comparator),0!==this._comparator(t,this._root.key))?null:this._root},l.prototype.contains=function(t){for(var e=this._root,n=this._comparator;e;){var r=n(t,e.key);if(0===r)return!0;e=r<0?e.left:e.right}return!1},l.prototype.forEach=function(t,e){for(var n=this._root,r=[],i=!1;!i;)null!==n?(r.push(n),n=n.left):0!==r.length?(n=r.pop(),t.call(e,n),n=n.right):i=!0;return this},l.prototype.range=function(t,e,n,r){for(var i=[],o=this._comparator,s=this._root;0!==i.length||s;)if(s)i.push(s),s=s.left;else{if(o((s=i.pop()).key,e)>0)break;if(o(s.key,t)>=0&&n.call(r,s))return this;s=s.right}return this},l.prototype.keys=function(){var t=[];return this.forEach((function(e){var n=e.key;return t.push(n)})),t},l.prototype.values=function(){var t=[];return this.forEach((function(e){var n=e.data;return t.push(n)})),t},l.prototype.min=function(){return this._root?this.minNode(this._root).key:null},l.prototype.max=function(){return this._root?this.maxNode(this._root).key:null},l.prototype.minNode=function(t){if(void 0===t&&(t=this._root),t)for(;t.left;)t=t.left;return t},l.prototype.maxNode=function(t){if(void 0===t&&(t=this._root),t)for(;t.right;)t=t.right;return t},l.prototype.at=function(t){for(var e=this._root,n=!1,r=0,i=[];!n;)if(e)i.push(e),e=e.left;else if(i.length>0){if(e=i.pop(),r===t)return e;r++,e=e.right}else n=!0;return null},l.prototype.next=function(t){var e=this._root,n=null;if(t.right){for(n=t.right;n.left;)n=n.left;return n}for(var r=this._comparator;e;){var i=r(t.key,e.key);if(0===i)break;i<Line truncated
|
||||
+425
-140
@@ -1,6 +1,10 @@
|
||||
// Icon shape editor for /icon-shapes/. Plain JS, no build step, no dependencies.
|
||||
// Icon shape editor for /icon-shapes/. Plain JS, no build step; polygon-clipping.min.js (loaded first) merges shapes.
|
||||
//
|
||||
// A shape is a polygon of points {x, y, r, s} in the launcher's 0..100 box:
|
||||
// An icon is a list of shapes that get merged into one outline. A shape is either
|
||||
// { pts: [{x, y, r, s}], mirror } an editable polygon with rounded corners, or
|
||||
// { fixed: name, a, x, y, mirror } a ready-made FIXED path, scaled by a and then moved by (x, y).
|
||||
// mirror adds copies flipped across the canvas centre lines: 'none', 'h' (left/right), 'v' (top/bottom), 'both'.
|
||||
// Polygon points live in the launcher's 0..100 box:
|
||||
// r = corner size (how far the rounding reaches along both edges)
|
||||
// s = smoothing, 0 = straight cut, 0.5 = circular arc, 1 = squircle-like
|
||||
// The output path is written for androidx graphics-shapes' SvgPathParser
|
||||
@@ -11,10 +15,11 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
// Points may sit outside the 0..100 box (rounding pulls the outline inward), so the canvas
|
||||
// shows a margin around it and grows when a fitted star pushes its tips further out.
|
||||
// shows a margin around it and grows when something reaches further out.
|
||||
const view = { lo: -20, hi: 120 };
|
||||
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
||||
const fmt = (n) => String(+n.toFixed(2)); // +"…" drops trailing zeros and "-0"
|
||||
const r2 = (n) => +n.toFixed(2);
|
||||
const dist = (a, b) => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
||||
|
||||
@@ -71,18 +76,13 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scales and centres the *rendered* outline so it fills the 0..100 box.
|
||||
const measure = (d) => { const m = $('se-measure-path'); m.setAttribute('d', d); return m.getBBox(); };
|
||||
|
||||
// Scales and centres the *rendered* outline of a polygon so it fills the 0..100 box.
|
||||
function fit(pts) {
|
||||
const m = $('se-measure-path');
|
||||
m.setAttribute('d', buildPath(pts));
|
||||
const bb = m.getBBox(), k = 100 / Math.max(bb.width, bb.height);
|
||||
const bb = measure(buildPath(pts)), k = 100 / Math.max(bb.width, bb.height);
|
||||
const ox = (100 - bb.width * k) / 2, oy = (100 - bb.height * k) / 2;
|
||||
return pts.map((p) => ({
|
||||
x: +((p.x - bb.x) * k + ox).toFixed(2),
|
||||
y: +((p.y - bb.y) * k + oy).toFixed(2),
|
||||
r: +(p.r * k).toFixed(2),
|
||||
s: p.s,
|
||||
}));
|
||||
return pts.map((p) => ({ x: r2((p.x - bb.x) * k + ox), y: r2((p.y - bb.y) * k + oy), r: r2(p.r * k), s: p.s }));
|
||||
}
|
||||
|
||||
// Regular polygon (inner = 1) or star (inner < 1, alternating outer/inner points).
|
||||
@@ -117,30 +117,142 @@
|
||||
Diamond: () => polygon(4, 1, 0, 16),
|
||||
};
|
||||
|
||||
// Ready-made paths that can't be expressed as one rounded polygon, so they aren't editable.
|
||||
// Ready-made paths that can't be expressed as one rounded polygon, so they aren't editable point by point.
|
||||
const FIXED = {
|
||||
// App icon head: outer silhouette + forehead + goatee fused into one contour (the launcher
|
||||
// only reads the first contour), eye/whisker cut-outs dropped, simplified to 0.05 units.
|
||||
Murine: 'M 9.38 59.57 L 10.34 60.94 L 11.76 62.66 L 13.13 64.1 L 14.6 65.44 L 14.91 65.86 L 15.1 66.52 L 15.08 67.03 L 14.82 67.67 L 14.38 68.15 L 15.92 69.39 L 17.76 70.65 L 19.7 71.81 L 22.13 73.01 L 24.4 74.01 L 28.59 75.67 L 32.42 78.33 L 34.36 79.44 L 36.36 80.43 L 37.27 80.84 L 37.71 80.54 L 38.2 80.38 L 38.72 80.38 L 39.36 80.6 L 39.88 81.05 L 40.21 81.62 L 40.97 82.54 L 42.8 84.45 L 44.66 86.14 L 46.74 87.76 L 47.54 87.52 L 48.25 87.62 L 48.73 87.87 L 49.92 88.77 L 51.22 87.83 L 51.68 87.58 L 52.36 87.48 L 53.26 87.7 L 55.15 86.26 L 56.9 84.65 L 58.61 82.8 L 59.67 81.51 L 60.04 80.87 L 60.45 80.53 L 60.93 80.32 L 61.63 80.28 L 62.14 80.43 L 62.64 80.78 L 64.15 80.17 L 67.84 78.1 L 71.36 75.7 L 77.07 73.41 L 80.63 71.67 L 83.17 70.11 L 84.98 68.81 L 85.74 68.16 L 85.3 67.67 L 85.02 67.03 L 85.02 66.33 L 85.19 65.84 L 85.49 65.41 L 86.97 64.07 L 88.32 62.63 L 89.42 61.29 L 90.59 59.67 L 90.48 59.53 L 85.59 57.99 L 85.14 57.72 L 84.9 57.47 L 84.63 57.03 L 84.51 56.52 L 84.53 56 L 84.71 55.51 L 86.56 53.36 L 87.65 51.76 L 84.31 51.74 L 83.64 51.54 L 83.11 51.1 L 82.84 50.65 L 82.7 49.96 L 82.78 49.45 L 83.17 48.5 L 83.53 48.1 L 83.82 47.91 L 86.26 47.22 L 88.31 46.38 L 90.26 45.29 L 92.07 43.96 L 93.54 42.63 L 95.04 40.98 L 96.35 39.19 L 97.49 37.26 L 98.5 35.01 L 99.27 32.65 L 99.72 30.45 L 99.97 27.99 L 99.95 25.51 L 99.67 23.3 L 99.13 21.14 L 98.41 19.28 L 97.46 17.53 L 96.1 15.78 L 94.66 14.4 L 92.83 13.12 L 90.84 12.15 L 88.94 11.57 L 86.96 11.27 L 84.73 11.28 L 82.53 11.64 L 80.18 12.4 L 77.94 13.47 L 75.65 14.93 L 73.69 16.46 L 71.85 18.13 L 69.18 21.06 L 66.81 24.13 L 66.41 24.48 L 65.92 24.69 L 65.4 24.74 L 65.05 24.69 L 64.56 24.49 L 64.27 24.28 L 63.94 23.87 L 63.79 23.55 L 63.7 22.83 L 62.22 23.17 L 58.96 24.1 L 55.59 25.25 L 52.37 24.93 L 49.72 24.84 L 47.44 24.92 L 44.26 25.26 L 41.24 24.21 L 36.13 22.81 L 35.93 23.6 L 35.5 24.14 L 34.9 24.48 L 34.21 24.56 L 33.7 24.44 L 33.12 24.07 L 31.48 21.93 L 30.05 20.22 L 27.44 17.58 L 26.51 16.75 L 24.97 15.5 L 23.55 14.49 L 22.07 13.58 L 20.74 12.89 L 19.14 12.22 L 17.48 11.7 L 15.79 11.37 L 14.31 11.24 L 12.8 11.27 L 11.35 11.47 L 9.91 11.85 L 8.5 12.38 L 6.57 13.45 L 4.99 14.68 L 3.45 16.3 L 2.18 18.13 L 1.22 20.14 L 0.48 22.5 L 0.11 24.71 L 0 27.18 L 0.1 28.91 L 0.33 30.63 L 0.75 32.56 L 1.27 34.23 L 2 36.07 L 2.77 37.62 L 3.68 39.1 L 4.7 40.53 L 5.8 41.85 L 6.83 42.93 L 9.11 44.85 L 10.77 45.91 L 12.33 46.68 L 13.96 47.29 L 16.14 47.92 L 16.56 48.25 L 16.79 48.53 L 17.21 49.53 L 17.26 50.06 L 17.09 50.75 L 16.65 51.32 L 16.2 51.6 L 15.51 51.76 L 12.27 51.78 L 12.29 51.93 L 13.3 53.26 L 15.22 55.55 L 15.39 56.04 L 15.42 56.56 L 15.29 57.06 L 15.02 57.51 L 14.78 57.75 L 14.34 58.02 Z',
|
||||
};
|
||||
|
||||
// ---------- shapes ----------
|
||||
|
||||
const MIRRORS = { none: [], h: [[-1, 1]], v: [[1, -1]], both: [[-1, 1], [1, -1], [-1, -1]] };
|
||||
// Rewrites every coordinate pair of one of our own paths (absolute M/L/C/Z, single spaces).
|
||||
const mapPath = (d, f) => d.replace(/(-?[\d.]+) (-?[\d.]+)/g, (_, x, y) => { const p = f(+x, +y); return fmt(p.x) + ' ' + fmt(p.y); });
|
||||
const shapePath = (sh) => (sh.fixed ? mapPath(FIXED[sh.fixed], (x, y) => ({ x: x * sh.a + sh.x, y: y * sh.a + sh.y })) : buildPath(sh.pts));
|
||||
// A shape's own outline followed by its mirrored copies.
|
||||
const copies = (sh) => {
|
||||
const d = shapePath(sh);
|
||||
return [d, ...MIRRORS[sh.mirror].map(([sx, sy]) => mapPath(d, (x, y) => ({ x: sx < 0 ? 100 - x : x, y: sy < 0 ? 100 - y : y })))];
|
||||
};
|
||||
// p -> (p - c) * k + t, for both kinds of shape (corner sizes scale too, so the look is kept).
|
||||
function transform(sh, k, cx, cy, tx, ty) {
|
||||
if (sh.fixed) return { ...sh, a: r2(sh.a * k), x: r2((sh.x - cx) * k + tx), y: r2((sh.y - cy) * k + ty) };
|
||||
return { ...sh, pts: sh.pts.map((p) => ({ x: r2((p.x - cx) * k + tx), y: r2((p.y - cy) * k + ty), r: r2(p.r * k), s: p.s })) };
|
||||
}
|
||||
// Scales the given shapes, mirrored copies included, until they touch the box. An axis with mirrored
|
||||
// copies is symmetric around the canvas centre, so it may only scale around 50, never shift.
|
||||
function fitted(list) {
|
||||
const bb = measure(list.flatMap(copies).join(' '));
|
||||
if (!(bb.width > 0.01 && bb.height > 0.01)) return list;
|
||||
const lockX = list.some((s) => s.mirror === 'h' || s.mirror === 'both');
|
||||
const lockY = list.some((s) => s.mirror === 'v' || s.mirror === 'both');
|
||||
const kx = lockX ? 50 / Math.max(50 - bb.x, bb.x + bb.width - 50) : 100 / bb.width;
|
||||
const ky = lockY ? 50 / Math.max(50 - bb.y, bb.y + bb.height - 50) : 100 / bb.height;
|
||||
const k = Math.min(kx, ky), cx = lockX ? 50 : bb.x + bb.width / 2, cy = lockY ? 50 : bb.y + bb.height / 2;
|
||||
return list.map((s) => transform(s, k, cx, cy, 50, 50));
|
||||
}
|
||||
// Puts `sh` where `old` was: same centre, longest side as long as old's.
|
||||
function placeLike(sh, old) {
|
||||
const a = measure(shapePath(old)), b = measure(shapePath(sh));
|
||||
const k = Math.max(a.width, a.height) / Math.max(b.width, b.height, 0.01);
|
||||
return { ...transform(sh, k, b.x + b.width / 2, b.y + b.height / 2, a.x + a.width / 2, a.y + a.height / 2), mirror: old.mirror };
|
||||
}
|
||||
const presetShapes = (name) => (Object.hasOwn(FIXED, name)
|
||||
? [{ fixed: name, a: 1, x: 0, y: 0, mirror: 'none' }]
|
||||
: [{ pts: fit(PRESETS[name]()), mirror: 'none' }]);
|
||||
const shapeName = (sh, i) => sh.fixed || `Shape ${i + 1}`;
|
||||
|
||||
// ---------- merging ----------
|
||||
|
||||
// Polyline of one of our own paths; curves become segments of at most ~1.5 units.
|
||||
function flatten(d) {
|
||||
const t = d.split(' '), ring = [];
|
||||
let i = 0, cx = 0, cy = 0;
|
||||
while (i < t.length) {
|
||||
const c = t[i++];
|
||||
if (c === 'M' || c === 'L') { cx = +t[i++]; cy = +t[i++]; ring.push([cx, cy]); }
|
||||
else if (c === 'C') {
|
||||
const [x1, y1, x2, y2, x, y] = t.slice(i, i + 6).map(Number);
|
||||
i += 6;
|
||||
const len = Math.hypot(x1 - cx, y1 - cy) + Math.hypot(x2 - x1, y2 - y1) + Math.hypot(x - x2, y - y2);
|
||||
const n = clamp(Math.ceil(len / 1.5), 2, 32);
|
||||
for (let k = 1; k <= n; k++) {
|
||||
const s = k / n, u = 1 - s, a = u * u * u, b = 3 * u * u * s, c2 = 3 * u * s * s, e = s * s * s;
|
||||
ring.push([a * cx + b * x1 + c2 * x2 + e * x, a * cy + b * y1 + c2 * y2 + e * y]);
|
||||
}
|
||||
cx = x; cy = y;
|
||||
}
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
// Ramer–Douglas–Peucker: drops points closer than tol to the line between their neighbours.
|
||||
function simplify(pts, tol) {
|
||||
if (pts.length < 4) return pts;
|
||||
const keep = new Uint8Array(pts.length), stack = [[0, pts.length - 1]];
|
||||
keep[0] = keep[pts.length - 1] = 1;
|
||||
while (stack.length) {
|
||||
const [a, b] = stack.pop(), [ax, ay] = pts[a], [bx, by] = pts[b], len = Math.hypot(bx - ax, by - ay) || 1;
|
||||
let idx = -1, max = tol;
|
||||
for (let i = a + 1; i < b; i++) {
|
||||
const d = Math.abs((bx - ax) * (ay - pts[i][1]) - (ax - pts[i][0]) * (by - ay)) / len;
|
||||
if (d > max) { max = d; idx = i; }
|
||||
}
|
||||
if (idx >= 0) { keep[idx] = 1; stack.push([a, idx], [idx, b]); }
|
||||
}
|
||||
return pts.filter((_, i) => keep[i]);
|
||||
}
|
||||
// The icon as one outline for the launcher: { d, error, note }. A lone shape keeps its exact path
|
||||
// (curves included); anything else is flattened, merged and simplified to straight segments.
|
||||
function iconPath() {
|
||||
const all = shapes.flatMap(copies);
|
||||
if (all.length === 1) return { d: all[0] };
|
||||
let merged;
|
||||
try { merged = polygonClipping.union(...all.map((d) => [flatten(d)])); } catch (e) { return { d: all.join(' '), error: "These shapes couldn't be merged." }; }
|
||||
const outline = (poly) => 'M ' + simplify(poly[0].slice(0, -1), 0.05).map(([x, y]) => fmt(x) + ' ' + fmt(y)).join(' L ') + ' Z';
|
||||
if (merged.length !== 1) {
|
||||
return { d: merged.map(outline).join(' '), error: "The shapes don't all touch. The launcher needs them to form one connected outline." };
|
||||
}
|
||||
return { d: outline(merged[0]), note: merged[0].length > 1 ? 'Gaps enclosed by the shapes are filled in: the launcher needs an outline without holes.' : '' };
|
||||
}
|
||||
|
||||
// ---------- state ----------
|
||||
|
||||
const STORE = 'murine-shape-editor';
|
||||
let pts, fixed = null, sel = -1, drag = false, sliding = false; // fixed: name of a FIXED shape in use instead of pts
|
||||
let shapes, active = 0, mode = 'edit', sel = -1, drag = null, sliding = false;
|
||||
let pts = null, fixed = null; // shortcuts into shapes[active]: its points, or its FIXED name
|
||||
const sync = () => {
|
||||
active = clamp(active, 0, shapes.length - 1);
|
||||
pts = shapes[active].pts || null;
|
||||
fixed = shapes[active].fixed || null;
|
||||
if (fixed) mode = 'move';
|
||||
};
|
||||
const undo = [], redo = [];
|
||||
|
||||
// Everything needed to resume editing. Undo, localStorage and edit links all go through this pair,
|
||||
// so a new piece of state only has to be added to snapshot() and restore().
|
||||
const snapshot = () => ({ pts, fixed });
|
||||
const snapshot = () => ({ shapes, active });
|
||||
// Also reads edit links, i.e. anything anyone can type into a URL: rebuild every field, keep nothing unchecked.
|
||||
function cleanShape(s) {
|
||||
const mirror = Object.hasOwn(MIRRORS, s?.mirror) ? s.mirror : 'none';
|
||||
if (typeof s?.fixed === 'string' && Object.hasOwn(FIXED, s.fixed)) {
|
||||
const sh = { fixed: s.fixed, a: clamp(+(s.a ?? 1), 0.01, 20), x: clamp(+(s.x ?? 0), -1000, 1000), y: clamp(+(s.y ?? 0), -1000, 1000), mirror };
|
||||
return [sh.a, sh.x, sh.y].every(Number.isFinite) ? sh : null;
|
||||
}
|
||||
if (!Array.isArray(s?.pts) || s.pts.length < 3 || s.pts.length > 200) return null;
|
||||
const p = s.pts.map((q) => ({ x: clamp(+q?.x, -500, 600), y: clamp(+q?.y, -500, 600), r: clamp(+q?.r, 0, 500), s: clamp(+q?.s, 0, 1) }));
|
||||
return p.every((q) => [q.x, q.y, q.r, q.s].every(Number.isFinite)) ? { pts: p, mirror } : null;
|
||||
}
|
||||
function restore(o) {
|
||||
if (!Array.isArray(o?.pts) || o.pts.length < 3 || o.pts.length > 200) return false;
|
||||
const p = o.pts.map((q) => ({ x: clamp(+q?.x, -500, 600), y: clamp(+q?.y, -500, 600), r: clamp(+q?.r, 0, 500), s: clamp(+q?.s, 0, 1) }));
|
||||
if (!p.every((q) => [q.x, q.y, q.r, q.s].every(Number.isFinite))) return false;
|
||||
pts = p;
|
||||
fixed = typeof o.fixed === 'string' && Object.hasOwn(FIXED, o.fixed) ? o.fixed : null;
|
||||
// Saves and links from before multiple shapes held one shape: { pts } or { pts, fixed }.
|
||||
if (o && !o.shapes && (o.pts || o.fixed)) o = { shapes: [o.fixed ? { fixed: o.fixed } : { pts: o.pts }], active: 0 };
|
||||
if (!Array.isArray(o?.shapes) || !o.shapes.length || o.shapes.length > 20) return false;
|
||||
const list = o.shapes.map(cleanShape);
|
||||
if (list.includes(null)) return false;
|
||||
shapes = list;
|
||||
active = Number.isInteger(o.active) ? o.active : 0;
|
||||
sel = -1;
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -171,7 +283,9 @@
|
||||
if (undo.length > 100) undo.shift();
|
||||
redo.length = 0; // a new edit starts a new branch, so there's nothing left to redo
|
||||
};
|
||||
const load = (p) => { checkpoint(); pts = p; fixed = null; sel = -1; render(); };
|
||||
// Whole icon (presets, Reset) or just the selected shape (Generate).
|
||||
const loadIcon = (list) => { checkpoint(); shapes = list; active = 0; sel = -1; mode = 'edit'; sync(); render(); };
|
||||
const replaceShape = (sh) => { checkpoint(); shapes[active] = sh; sel = -1; mode = 'edit'; sync(); render(); };
|
||||
const withSize = (i, r) => pts.map((p, k) => (k === i ? { ...p, r } : p));
|
||||
const neighbours = (i) => [(i + pts.length - 1) % pts.length, (i + 1) % pts.length];
|
||||
// Largest size corner i can take before it starts shrinking a neighbour's rounding. Neighbours only
|
||||
@@ -185,7 +299,6 @@
|
||||
for (let k = 0; k < 20; k++) { const m = (lo + hi) / 2; if (sum(m) < base - 0.01) hi = m; else lo = m; }
|
||||
return lo;
|
||||
}
|
||||
const loadFixed = (name) => { checkpoint(); fixed = name; sel = -1; render(); };
|
||||
|
||||
// ---------- rendering ----------
|
||||
|
||||
@@ -195,6 +308,13 @@
|
||||
for (const k in attrs) e.setAttribute(k, attrs[k]);
|
||||
return e;
|
||||
};
|
||||
// Grows or shrinks a container to n children. Everything on the canvas is updated in place, never
|
||||
// replaced: a replaced element under a finger is detached, and its touch events then never reach
|
||||
// the canvas listener that stops the page scrolling.
|
||||
const resize = (parent, n, make) => {
|
||||
while (parent.children.length > n) parent.lastChild.remove();
|
||||
while (parent.children.length < n) parent.append(make());
|
||||
};
|
||||
|
||||
function drawGrid() {
|
||||
const g = $('se-grid');
|
||||
@@ -208,9 +328,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
let activeBox = null; // bounding box of the selected shape's own outline, for Move mode
|
||||
const boxCorners = (b) => [{ x: b.x, y: b.y }, { x: b.x + b.width, y: b.y }, { x: b.x + b.width, y: b.y + b.height }, { x: b.x, y: b.y + b.height }];
|
||||
|
||||
function render() {
|
||||
const lines = shapes.map(copies); // per shape: own outline, then its mirrored copies
|
||||
if (!drag) {
|
||||
const xy = pts.flatMap((p) => [p.x, p.y]);
|
||||
const bb = measure(lines.flat().join(' '));
|
||||
const xy = [bb.x, bb.y, bb.x + bb.width, bb.y + bb.height, ...(pts ? pts.flatMap((p) => [p.x, p.y]) : [])];
|
||||
const lo = Math.floor(Math.min(-20, ...xy) - 6), hi = Math.ceil(Math.max(120, ...xy) + 6);
|
||||
if (lo !== view.lo || hi !== view.hi) {
|
||||
Object.assign(view, { lo, hi });
|
||||
@@ -218,125 +343,251 @@
|
||||
drawGrid();
|
||||
}
|
||||
}
|
||||
const d = fixed ? FIXED[fixed] : buildPath(pts), bad = !fixed && selfIntersects(pts);
|
||||
$('se-shape').setAttribute('d', d);
|
||||
$('se-outline').setAttribute('points', fixed ? '' : pts.map((p) => p.x + ',' + p.y).join(' '));
|
||||
const out = iconPath();
|
||||
const crossed = shapes.findIndex((sh) => sh.pts && selfIntersects(sh.pts));
|
||||
const bad = !!out.error || crossed >= 0;
|
||||
|
||||
// The merged icon is filled; every shape and mirrored copy is outlined on top, the selected one solid.
|
||||
$('se-merged').setAttribute('d', out.d);
|
||||
const order = shapes.map((_, i) => i).filter((i) => i !== active).concat(active); // selected shape on top
|
||||
const strokes = order.flatMap((i) => lines[i].map((d, c) => ({ d, cls: 'se-line' + (i === active ? ' active' : '') + (c ? ' copy' : '') })));
|
||||
const layer = $('se-lines');
|
||||
resize(layer, strokes.length, () => el('path', {}));
|
||||
strokes.forEach((s, k) => { layer.children[k].setAttribute('d', s.d); layer.children[k].setAttribute('class', s.cls); });
|
||||
$('se-outline').setAttribute('points', pts && mode === 'edit' ? pts.map((p) => p.x + ',' + p.y).join(' ') : '');
|
||||
|
||||
// Neighbours whose rounding the selected corner is currently eating into.
|
||||
const squeezed = new Set();
|
||||
if (!fixed && sel >= 0) {
|
||||
if (pts && sel >= 0) {
|
||||
const now = cornerSizes(pts), alone = cornerSizes(withSize(sel, 0));
|
||||
for (const j of neighbours(sel)) if (now[j] < alone[j] - 0.01) squeezed.add(j);
|
||||
}
|
||||
|
||||
const handles = $('se-handles'), adders = $('se-adders');
|
||||
const handles = $('se-handles'), adders = $('se-adders'), shown = pts && mode === 'edit' ? pts : [];
|
||||
const focused = document.activeElement?.dataset?.i !== undefined;
|
||||
handles.replaceChildren();
|
||||
adders.replaceChildren();
|
||||
(fixed ? [] : pts).forEach((p, i) => {
|
||||
const q = pts[(i + 1) % pts.length];
|
||||
if (!drag && dist(p, q) > 10) {
|
||||
const m = lerp(p, q, 0.5), g = el('g', { 'data-add': i, class: 'se-add' });
|
||||
g.append(el('circle', { cx: m.x, cy: m.y, r: 5, class: 'se-hit' })); // finger-sized target
|
||||
g.append(el('circle', { cx: m.x, cy: m.y, r: 2.6 }));
|
||||
g.append(el('path', { d: `M${m.x - 1.4} ${m.y}h2.8M${m.x} ${m.y - 1.4}v2.8` }));
|
||||
const t = el('title', {});
|
||||
t.textContent = 'Add a point here';
|
||||
g.append(t);
|
||||
adders.append(g);
|
||||
}
|
||||
const h = el('g', {
|
||||
'data-i': i, tabindex: 0, role: 'button', 'aria-label': `Point ${i + 1}`,
|
||||
class: 'se-handle' + (i === sel ? ' selected' : '') + (squeezed.has(i) ? ' squeezed' : ''),
|
||||
});
|
||||
h.append(el('circle', { cx: p.x, cy: p.y, r: 6, class: 'se-hit' }));
|
||||
h.append(el('circle', { cx: p.x, cy: p.y, r: 2.4 }));
|
||||
handles.append(h);
|
||||
resize(adders, shown.length, () => {
|
||||
const g = el('g', { class: 'se-add' }), t = el('title', {});
|
||||
t.textContent = 'Add a point here';
|
||||
g.append(el('circle', { r: 5, class: 'se-hit' }), el('circle', { r: 2.6 }), el('path', {}), t);
|
||||
return g;
|
||||
});
|
||||
if (focused && sel >= 0) handles.children[sel].focus();
|
||||
resize(handles, shown.length, () => {
|
||||
const g = el('g', { tabindex: 0, role: 'button' });
|
||||
g.append(el('circle', { r: 6, class: 'se-hit' }), el('circle', { r: 2.4 }));
|
||||
return g;
|
||||
});
|
||||
shown.forEach((p, i) => {
|
||||
const q = pts[(i + 1) % pts.length], m = lerp(p, q, 0.5), a = adders.children[i], h = handles.children[i];
|
||||
a.dataset.add = i;
|
||||
a.style.display = !drag && dist(p, q) > 10 ? '' : 'none'; // grabAt() uses the same rule
|
||||
for (const c of a.querySelectorAll('circle')) { c.setAttribute('cx', m.x); c.setAttribute('cy', m.y); }
|
||||
a.querySelector('path').setAttribute('d', `M${m.x - 1.4} ${m.y}h2.8M${m.x} ${m.y - 1.4}v2.8`);
|
||||
h.dataset.i = i;
|
||||
h.setAttribute('aria-label', `Point ${i + 1}`);
|
||||
h.setAttribute('class', 'se-handle' + (i === sel ? ' selected' : '') + (squeezed.has(i) ? ' squeezed' : ''));
|
||||
for (const c of h.children) { c.setAttribute('cx', p.x); c.setAttribute('cy', p.y); }
|
||||
});
|
||||
if (focused && sel >= 0 && document.activeElement !== handles.children[sel]) handles.children[sel]?.focus();
|
||||
|
||||
// Move mode: a box around the selected shape, with a resize handle on each corner.
|
||||
const moving = mode === 'move';
|
||||
$('se-move').style.display = moving ? '' : 'none';
|
||||
if (moving) {
|
||||
activeBox = measure(lines[active][0]);
|
||||
const r = $('se-move-box'), b = activeBox;
|
||||
r.setAttribute('x', b.x); r.setAttribute('y', b.y); r.setAttribute('width', b.width); r.setAttribute('height', b.height);
|
||||
boxCorners(b).forEach((c, i) => {
|
||||
const h = $('se-move').querySelectorAll('.se-resize')[i];
|
||||
h.setAttribute('x', c.x - 2); h.setAttribute('y', c.y - 2);
|
||||
});
|
||||
}
|
||||
$('se-fit-shape').hidden = $('se-mirror').hidden = !moving;
|
||||
for (const b of $('se-mirror').querySelectorAll('button')) b.setAttribute('aria-pressed', b.dataset.mirror === shapes[active].mirror);
|
||||
|
||||
renderCards();
|
||||
|
||||
// controls
|
||||
const cur = pts[Math.max(sel, 0)];
|
||||
$('se-corner-legend').textContent = sel >= 0 ? `Corner ${sel + 1}` : 'All corners';
|
||||
// The slider ends where the shape stops changing: two corners sharing an edge split it, so with every
|
||||
// corner the same size each one saturates at half its shorter edge; a lone corner can take more.
|
||||
const reach = (i) => Math.min(dist(pts[i], pts[(i + pts.length - 1) % pts.length]), dist(pts[i], pts[(i + 1) % pts.length]));
|
||||
const maxSize = sel >= 0 ? reach(sel) : Math.max(...pts.map((_, i) => reach(i) / 2));
|
||||
$('se-size').max = Math.max(0.1, Math.ceil(maxSize * 10) / 10); // set before value, which the browser clamps to it
|
||||
$('se-size').value = cur.r; $('se-size-v').textContent = fmt(Math.min(cur.r, +$('se-size').max));
|
||||
const max = +$('se-size').max, zone = sel >= 0 && !fixed ? squeezeStart(sel, max) : max, hasZone = zone < max - 0.05;
|
||||
$('se-size-track').classList.toggle('has-zone', hasZone);
|
||||
$('se-size-track').style.setProperty('--squeeze', zone / max);
|
||||
// Only the text changes while the slider is dragged (the zone depends on the neighbours, not on this
|
||||
// corner's size): showing or hiding an element under the thumb makes Chrome drop the drag.
|
||||
const warn = $('se-size-warn');
|
||||
warn.hidden = !hasZone;
|
||||
warn.classList.toggle('active', squeezed.size > 0);
|
||||
warn.textContent = squeezed.size ? 'Shrinking the highlighted neighbouring corners'
|
||||
: 'Past the red mark it also shrinks the neighbouring corners';
|
||||
$('se-smooth').value = Math.round(cur.s * 100); $('se-smooth-v').textContent = Math.round(cur.s * 100) + '%';
|
||||
$('se-apply-all').disabled = sel < 0;
|
||||
$('se-x').disabled = $('se-y').disabled = sel < 0;
|
||||
$('se-x').value = sel >= 0 ? fmt(cur.x) : ''; $('se-y').value = sel >= 0 ? fmt(cur.y) : '';
|
||||
$('se-remove').disabled = sel < 0 || pts.length <= 3;
|
||||
const editable = !!pts;
|
||||
$('se-corner-set').disabled = !editable;
|
||||
$('se-point-set').disabled = !editable || moving;
|
||||
$('se-fixed-note').hidden = editable;
|
||||
if (editable) {
|
||||
const cur = pts[Math.max(sel, 0)];
|
||||
$('se-corner-legend').textContent = sel >= 0 ? `Corner ${sel + 1}` : 'All corners';
|
||||
// The slider ends where the shape stops changing: two corners sharing an edge split it, so with every
|
||||
// corner the same size each one saturates at half its shorter edge; a lone corner can take more.
|
||||
const reach = (i) => Math.min(dist(pts[i], pts[(i + pts.length - 1) % pts.length]), dist(pts[i], pts[(i + 1) % pts.length]));
|
||||
const maxSize = sel >= 0 ? reach(sel) : Math.max(...pts.map((_, i) => reach(i) / 2));
|
||||
$('se-size').max = Math.max(0.1, Math.ceil(maxSize * 10) / 10); // set before value, which the browser clamps to it
|
||||
$('se-size').value = cur.r; $('se-size-v').textContent = fmt(Math.min(cur.r, +$('se-size').max));
|
||||
const max = +$('se-size').max, zone = sel >= 0 ? squeezeStart(sel, max) : max, hasZone = zone < max - 0.05;
|
||||
$('se-size-track').classList.toggle('has-zone', hasZone);
|
||||
$('se-size-track').style.setProperty('--squeeze', zone / max);
|
||||
// Only the text changes while the slider is dragged (the zone depends on the neighbours, not on this
|
||||
// corner's size): showing or hiding an element under the thumb makes Chrome drop the drag.
|
||||
const warn = $('se-size-warn');
|
||||
warn.hidden = !hasZone;
|
||||
warn.classList.toggle('active', squeezed.size > 0);
|
||||
warn.textContent = squeezed.size ? 'Shrinking the highlighted neighbouring corners'
|
||||
: 'Past the red mark it also shrinks the neighbouring corners';
|
||||
$('se-smooth').value = Math.round(cur.s * 100); $('se-smooth-v').textContent = Math.round(cur.s * 100) + '%';
|
||||
$('se-apply-all').disabled = sel < 0;
|
||||
$('se-x').disabled = $('se-y').disabled = sel < 0;
|
||||
$('se-x').value = sel >= 0 ? fmt(cur.x) : ''; $('se-y').value = sel >= 0 ? fmt(cur.y) : '';
|
||||
$('se-remove').disabled = sel < 0 || pts.length <= 3;
|
||||
}
|
||||
$('se-undo').disabled = undo.length === 0;
|
||||
$('se-redo').disabled = redo.length === 0;
|
||||
$('se-corner-set').disabled = $('se-point-set').disabled = $('se-fit').disabled = !!fixed;
|
||||
$('se-fixed-note').hidden = !fixed;
|
||||
|
||||
// output
|
||||
$('se-out').value = d;
|
||||
document.querySelectorAll('.se-preview path').forEach((p) => p.setAttribute('d', d));
|
||||
$('se-out').value = out.d;
|
||||
document.querySelectorAll('.se-preview path').forEach((p) => p.setAttribute('d', out.d));
|
||||
$('se-warn').hidden = !bad;
|
||||
$('se-warn').textContent = out.error || (crossed < 0 ? '' : (shapes.length > 1 ? shapeName(shapes[crossed], crossed) + ': e' : 'E')
|
||||
+ "dges cross each other. The launcher needs a shape that doesn't overlap itself.");
|
||||
$('se-note').hidden = !out.note;
|
||||
$('se-note').textContent = out.note || '';
|
||||
$('se-use').classList.toggle('se-disabled', bad);
|
||||
refreshLinks(d, bad);
|
||||
refreshLinks(out.d, bad);
|
||||
$('se-share-import').disabled = bad;
|
||||
try { localStorage.setItem(STORE, JSON.stringify(snapshot())); } catch (e) { /* private mode */ }
|
||||
}
|
||||
|
||||
// ---------- shape cards ----------
|
||||
|
||||
const icon = (cp) => `<span class="material-symbols-outlined" aria-hidden="true">&#x${cp};</span>`;
|
||||
let cardsKey = '';
|
||||
function renderCards() {
|
||||
const key = JSON.stringify([shapes, active, mode]);
|
||||
if (key === cardsKey) return;
|
||||
cardsKey = key;
|
||||
const cards = $('se-cards'), focus = document.activeElement?.closest?.('#se-cards [data-act]');
|
||||
const refocus = focus && focus.dataset.act + ':' + focus.dataset.card;
|
||||
cards.innerHTML = shapes.map((sh, i) => {
|
||||
const on = i === active, name = shapeName(sh, i);
|
||||
return `<div class="se-card${on ? ' active' : ''}" role="listitem">
|
||||
<button type="button" class="se-card-pick" data-act="pick" data-card="${i}" aria-pressed="${on}" aria-label="Select ${name}">
|
||||
<svg viewBox="-10 -10 120 120" aria-hidden="true"><rect class="se-card-box" x="0" y="0" width="100" height="100"/>
|
||||
${copies(sh).map((d, c) => `<path class="${c ? 'copy' : ''}" d="${d}"/>`).join('')}</svg>
|
||||
<span>${name}</span>
|
||||
</button>
|
||||
${shapes.length > 1 ? `<button type="button" class="se-card-del" data-act="del" data-card="${i}" title="Delete ${name}" aria-label="Delete ${name}">${icon('e92e')}</button>` : ''}
|
||||
${on ? `<div class="se-card-modes" role="group" aria-label="Mode">
|
||||
<button type="button" data-act="edit" data-card="${i}" aria-pressed="${mode === 'edit'}" title="Edit points" ${sh.fixed ? 'disabled' : ''}>${icon('f097')}</button>
|
||||
<button type="button" data-act="move" data-card="${i}" aria-pressed="${mode === 'move'}" title="Move and resize">${icon('e89f')}</button>
|
||||
</div>` : ''}
|
||||
</div>`;
|
||||
}).join('') + `<button type="button" class="se-card se-card-add" data-act="add" data-card="-1" title="Add a shape" aria-label="Add a shape">${icon('e145')}</button>`;
|
||||
if (refocus) cards.querySelector(`[data-act="${refocus.split(':')[0]}"][data-card="${refocus.split(':')[1]}"]`)?.focus();
|
||||
}
|
||||
$('se-cards').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-act]');
|
||||
if (!b) return;
|
||||
const i = +b.dataset.card;
|
||||
if (b.dataset.act === 'pick') { active = i; sel = -1; mode = 'edit'; sync(); render(); }
|
||||
else if (b.dataset.act === 'edit' || b.dataset.act === 'move') { mode = b.dataset.act; sel = -1; sync(); render(); }
|
||||
else if (b.dataset.act === 'del') {
|
||||
if (!confirm(`Delete ${shapeName(shapes[i], i)}? Undo can still bring it back.`)) return;
|
||||
checkpoint();
|
||||
shapes.splice(i, 1);
|
||||
if (active >= i && active > 0) active--;
|
||||
sel = -1; sync(); render();
|
||||
} else if (b.dataset.act === 'add') addDialog.open();
|
||||
});
|
||||
|
||||
// ---------- canvas interaction ----------
|
||||
|
||||
const toSvg = (e) => new DOMPoint(e.clientX, e.clientY).matrixTransform(svg.getScreenCTM().inverse());
|
||||
|
||||
// Touch: only a point or a "+" captures the finger; anywhere else on the canvas the page scrolls and
|
||||
// pinch-zooms as usual. A touch that starts on a point or "+" cancels scrolling in touchstart.
|
||||
let tapOnBackground = false;
|
||||
// What a press grabs, decided by distance rather than by the element under the finger: fingers land
|
||||
// well off a 6px dot, and touchstart (which stops the page scrolling) and pointerdown (which starts
|
||||
// the drag) have to agree. Edit mode grabs points and "+" marks, Move mode the selected shape's box.
|
||||
function grabAt(clientX, clientY, touch) {
|
||||
const at = toSvg({ clientX, clientY }), unitPx = svg.getBoundingClientRect().width / (view.hi - view.lo);
|
||||
const reach = Math.max(6, touch ? 28 / unitPx : 0); // 6 units ≈ the old hit circle; fingers get ≥ 28px
|
||||
let best = null, bestD = reach;
|
||||
if (mode === 'move') {
|
||||
if (!activeBox) return null;
|
||||
boxCorners(activeBox).forEach((c, i) => { const d = dist(at, c); if (d < bestD) { best = { resize: i }; bestD = d; } });
|
||||
const b = activeBox;
|
||||
return best || (at.x >= b.x && at.x <= b.x + b.width && at.y >= b.y && at.y <= b.y + b.height ? { move: true } : null);
|
||||
}
|
||||
if (!pts) return null;
|
||||
pts.forEach((q, i) => {
|
||||
const next = pts[(i + 1) % pts.length], dq = dist(at, q);
|
||||
if (dq < bestD) { best = { point: i }; bestD = dq; }
|
||||
// A "+" only exists on edges longer than 10 units, and counts as a bit further than it is so a
|
||||
// point next to it still wins.
|
||||
const dm = dist(at, lerp(q, next, 0.5)) * 1.3;
|
||||
if (dist(q, next) > 10 && dm < bestD) { best = { add: i }; bestD = dm; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
// The topmost shape whose outline (or a mirrored copy) contains the point, else -1.
|
||||
function shapeAt(e) {
|
||||
const p = toSvg(e), m = $('se-measure-path');
|
||||
for (let i = shapes.length - 1; i >= 0; i--) {
|
||||
for (const d of copies(shapes[i])) { m.setAttribute('d', d); if (m.isPointInFill(new DOMPoint(p.x, p.y))) return i; }
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Touch: anywhere but something grabbable, the page scrolls and pinch-zooms as usual.
|
||||
let tap = null;
|
||||
svg.addEventListener('touchstart', (e) => {
|
||||
if (!fixed && e.target.closest('[data-i], [data-add]')) e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
if (e.touches.length === 1 && grabAt(t.clientX, t.clientY, true)) e.preventDefault();
|
||||
}, { passive: false });
|
||||
// A tap (not a scroll, which ends in pointercancel instead of click) on the background deselects.
|
||||
// A tap (not a scroll, which ends in pointercancel instead of click) selects the shape under it, or
|
||||
// deselects the point when it lands on empty space.
|
||||
svg.addEventListener('click', () => {
|
||||
if (tapOnBackground && sel >= 0) { sel = -1; render(); }
|
||||
tapOnBackground = false;
|
||||
if (tap === null) return;
|
||||
if (tap >= 0 && tap !== active) { active = tap; sel = -1; mode = shapes[tap].fixed ? 'move' : mode; sync(); render(); }
|
||||
else if (sel >= 0) { sel = -1; render(); }
|
||||
tap = null;
|
||||
});
|
||||
|
||||
svg.addEventListener('pointerdown', (e) => {
|
||||
if (fixed) return;
|
||||
const h = e.target.closest('[data-i]'), add = e.target.closest('[data-add]');
|
||||
tapOnBackground = !h && !add;
|
||||
if (add) {
|
||||
checkpoint();
|
||||
const i = +add.dataset.add, a = pts[i], b = pts[(i + 1) % pts.length], m = lerp(a, b, 0.5);
|
||||
pts.splice(i + 1, 0, { x: snap(m.x), y: snap(m.y), r: a.r, s: a.s });
|
||||
sel = i + 1;
|
||||
} else if (h) {
|
||||
checkpoint();
|
||||
sel = +h.dataset.i;
|
||||
} else return;
|
||||
drag = true;
|
||||
const g = grabAt(e.clientX, e.clientY, e.pointerType === 'touch');
|
||||
tap = g ? null : shapeAt(e);
|
||||
if (!g) return;
|
||||
checkpoint();
|
||||
const at = toSvg(e);
|
||||
if (g.point !== undefined || g.add !== undefined) {
|
||||
if (g.add !== undefined) {
|
||||
const a = pts[g.add], m = lerp(a, pts[(g.add + 1) % pts.length], 0.5);
|
||||
pts.splice(g.add + 1, 0, { x: snap(m.x), y: snap(m.y), r: a.r, s: a.s });
|
||||
sel = g.add + 1;
|
||||
} else sel = g.point;
|
||||
drag = { kind: 'point', dx: pts[sel].x - at.x, dy: pts[sel].y - at.y }; // the point keeps its distance from the finger
|
||||
} else if (g.move) {
|
||||
drag = { kind: 'move', from: at, orig: shapes[active] };
|
||||
} else {
|
||||
const c = boxCorners(activeBox), anchor = c[(g.resize + 2) % 4];
|
||||
drag = { kind: 'resize', anchor, d0: dist(anchor, c[g.resize]), orig: shapes[active] };
|
||||
}
|
||||
svg.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
// Next frame: rendering now would replace the touched element before its touchstart fires, and a
|
||||
// detached element's touchstart never reaches the listener above that stops the page scrolling.
|
||||
requestAnimationFrame(render);
|
||||
render();
|
||||
});
|
||||
svg.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const p = toSvg(e);
|
||||
pts[sel].x = snap(p.x);
|
||||
pts[sel].y = snap(p.y);
|
||||
if (drag.kind === 'point') {
|
||||
pts[sel].x = snap(p.x + drag.dx);
|
||||
pts[sel].y = snap(p.y + drag.dy);
|
||||
} else if (drag.kind === 'move') {
|
||||
const step = (v) => (grid.snap ? Math.round(v / grid.step) * grid.step : v);
|
||||
shapes[active] = transform(drag.orig, 1, 0, 0, step(p.x - drag.from.x), step(p.y - drag.from.y));
|
||||
sync();
|
||||
} else {
|
||||
const k = Math.max(0.05, dist(drag.anchor, p) / drag.d0), a = drag.anchor;
|
||||
shapes[active] = transform(drag.orig, k, a.x, a.y, a.x, a.y);
|
||||
sync();
|
||||
}
|
||||
render();
|
||||
});
|
||||
const endDrag = () => { if (drag) { drag = false; render(); } };
|
||||
const endDrag = () => { if (drag) { drag = null; render(); } };
|
||||
svg.addEventListener('pointerup', endDrag);
|
||||
svg.addEventListener('pointercancel', endDrag);
|
||||
svg.addEventListener('focusin', (e) => {
|
||||
@@ -351,10 +602,16 @@
|
||||
e.preventDefault();
|
||||
return (key === 'y' || e.shiftKey ? $('se-redo') : $('se-undo')).click(); // Ctrl+Y, or Ctrl/Cmd+Shift+Z
|
||||
}
|
||||
if (sel < 0 || !svg.contains(document.activeElement)) return;
|
||||
if (!svg.contains(document.activeElement)) return;
|
||||
const step = grid.snap ? grid.step : e.shiftKey ? 5 : 0.5;
|
||||
const mv = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
|
||||
if (mv) {
|
||||
if (mode === 'move') { // arrows move the whole shape
|
||||
if (!mv) return;
|
||||
checkpoint();
|
||||
shapes[active] = transform(shapes[active], 1, 0, 0, mv[0] * step, mv[1] * step);
|
||||
sync();
|
||||
} else if (sel < 0) return;
|
||||
else if (mv) {
|
||||
checkpoint();
|
||||
pts[sel].x = snap(pts[sel].x + mv[0] * step);
|
||||
pts[sel].y = snap(pts[sel].y + mv[1] * step);
|
||||
@@ -370,6 +627,16 @@
|
||||
render();
|
||||
});
|
||||
|
||||
// Move mode's buttons on the canvas: fit this shape, and mirroring.
|
||||
$('se-fit-shape').onclick = () => { checkpoint(); shapes[active] = fitted([shapes[active]])[0]; sync(); render(); };
|
||||
$('se-mirror').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-mirror]');
|
||||
if (!b || b.dataset.mirror === shapes[active].mirror) return;
|
||||
checkpoint();
|
||||
shapes[active] = { ...shapes[active], mirror: b.dataset.mirror };
|
||||
sync(); render();
|
||||
});
|
||||
|
||||
// ---------- panel ----------
|
||||
|
||||
const corner = (key, v) => {
|
||||
@@ -396,7 +663,7 @@
|
||||
})
|
||||
);
|
||||
$('se-remove').onclick = () => { checkpoint(); pts.splice(sel, 1); sel = -1; render(); };
|
||||
$('se-fit').onclick = () => load(fit(pts));
|
||||
$('se-fit').onclick = () => { checkpoint(); shapes = fitted(shapes); sync(); render(); };
|
||||
// Undo and redo each move the current state onto the other stack.
|
||||
const travel = (from, to) => {
|
||||
if (!from.length) return;
|
||||
@@ -406,37 +673,44 @@
|
||||
$('se-undo').onclick = () => travel(undo, redo);
|
||||
$('se-redo').onclick = () => travel(redo, undo);
|
||||
$('se-reset').onclick = () => {
|
||||
if (confirm('Start over from the default squircle? Undo can still bring this shape back.')) load(fit(PRESETS.Squircle()));
|
||||
if (confirm('Start over from the default squircle? Undo can still bring this icon back.')) loadIcon(presetShapes('Squircle'));
|
||||
};
|
||||
$('se-gen').onclick = () => {
|
||||
const n = clamp(+$('se-sides').value || 6, 3, 24), inner = clamp(+$('se-inner').value || 100, 10, 100) / 100;
|
||||
const num = (id, max) => clamp(+$(id).value || 0, 0, max); // empty or junk input counts as 0
|
||||
load(fit(polygon(n, inner, +$('se-rot').value || 0, num('se-gen-r', 50), num('se-gen-ir', 50), num('se-gen-s', 100) / 100)));
|
||||
const made = { pts: fit(polygon(n, inner, +$('se-rot').value || 0, num('se-gen-r', 50), num('se-gen-ir', 50), num('se-gen-s', 100) / 100)), mirror: 'none' };
|
||||
replaceShape(placeLike(made, shapes[active])); // takes the selected shape's place and size
|
||||
};
|
||||
|
||||
$('se-show-grid').onchange = (e) => { grid.show = e.target.checked; drawGrid(); };
|
||||
$('se-snap').onchange = (e) => (grid.snap = e.target.checked);
|
||||
$('se-step').onchange = (e) => { grid.step = +e.target.value; drawGrid(); };
|
||||
|
||||
// ---------- share dialog (same markup and styles as the site's contact dialog) ----------
|
||||
// ---------- dialogs (same markup and styles as the site's contact dialog) ----------
|
||||
|
||||
const shareEl = $('se-share');
|
||||
document.body.append(shareEl); // out of <main>, so it covers the nav bar like the contact dialog does
|
||||
let shareOpener = null;
|
||||
const openShare = () => {
|
||||
shareOpener = document.activeElement;
|
||||
shareEl.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
shareEl.querySelector('button:not(:disabled):not([hidden])').focus();
|
||||
};
|
||||
const closeShare = () => {
|
||||
shareEl.setAttribute('aria-hidden', 'true');
|
||||
document.body.style.overflow = '';
|
||||
shareOpener?.focus();
|
||||
};
|
||||
$('se-share-open').onclick = openShare;
|
||||
shareEl.addEventListener('click', (e) => { if (e.target === shareEl || e.target.closest('[data-close]')) closeShare(); });
|
||||
shareEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeShare(); } });
|
||||
function dialog(id) {
|
||||
const box = $(id);
|
||||
document.body.append(box); // out of <main>, so it covers the nav bar like the contact dialog does
|
||||
let opener = null;
|
||||
const d = {
|
||||
open() {
|
||||
opener = document.activeElement;
|
||||
box.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
box.querySelector('button:not(:disabled):not([hidden]):not([data-close])')?.focus();
|
||||
},
|
||||
close() {
|
||||
box.setAttribute('aria-hidden', 'true');
|
||||
document.body.style.overflow = '';
|
||||
opener?.focus();
|
||||
},
|
||||
};
|
||||
box.addEventListener('click', (e) => { if (e.target === box || e.target.closest('[data-close]')) d.close(); });
|
||||
box.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); d.close(); } });
|
||||
return d;
|
||||
}
|
||||
const shareDialog = dialog('se-share'), addDialog = dialog('se-add');
|
||||
$('se-share-open').onclick = shareDialog.open;
|
||||
|
||||
const copyText = async (text) => {
|
||||
try { await navigator.clipboard.writeText(text); } catch (e) {
|
||||
@@ -445,7 +719,7 @@
|
||||
}
|
||||
};
|
||||
const shareTexts = { edit: () => links.edit, import: () => links.import, path: () => $('se-out').value };
|
||||
shareEl.querySelectorAll('[data-copy]').forEach((card) => {
|
||||
$('se-share').querySelectorAll('[data-copy]').forEach((card) => {
|
||||
const label = card.querySelector('.se-card-sub'), text = label.textContent;
|
||||
card.onclick = async () => {
|
||||
await copyText(shareTexts[card.dataset.copy]());
|
||||
@@ -462,23 +736,34 @@
|
||||
|
||||
// ---------- presets ----------
|
||||
|
||||
const presetsEl = $('se-presets');
|
||||
const addPreset = (name, d, onclick) => {
|
||||
// A preset in the grid replaces the whole icon; picked from the "+" card it is added as new shapes,
|
||||
// at half size in the middle and in Move mode, ready to be placed.
|
||||
const tile = (name) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'se-preset';
|
||||
b.innerHTML = `<svg viewBox="0 0 100 100" aria-hidden="true"><path d="${d}"/></svg><span>${name}</span>`;
|
||||
b.onclick = () => { onclick(); svg.scrollIntoView({ behavior: 'smooth', block: 'center' }); };
|
||||
presetsEl.append(b);
|
||||
b.innerHTML = `<svg viewBox="0 0 100 100" aria-hidden="true"><path d="${presetShapes(name).map(shapePath).join(' ')}"/></svg><span>${name}</span>`;
|
||||
return b;
|
||||
};
|
||||
for (const [name, make] of Object.entries(PRESETS)) addPreset(name, buildPath(fit(make())), () => load(fit(make())));
|
||||
for (const [name, d] of Object.entries(FIXED)) addPreset(name, d, () => loadFixed(name));
|
||||
for (const name of [...Object.keys(PRESETS), ...Object.keys(FIXED)]) {
|
||||
const b = tile(name);
|
||||
b.onclick = () => { loadIcon(presetShapes(name)); svg.scrollIntoView({ behavior: 'smooth', block: 'center' }); };
|
||||
$('se-presets').append(b);
|
||||
const a = tile(name);
|
||||
a.onclick = () => {
|
||||
checkpoint();
|
||||
shapes.push(...presetShapes(name).map((s) => transform(s, 0.5, 50, 50, 50, 50)));
|
||||
active = shapes.length - 1; sel = -1; mode = 'move';
|
||||
sync(); addDialog.close(); render();
|
||||
};
|
||||
$('se-add-grid').append(a);
|
||||
}
|
||||
|
||||
// ---------- start ----------
|
||||
|
||||
let saved = null;
|
||||
try { saved = JSON.parse(localStorage.getItem(STORE)); } catch (e) { /* private mode or garbage */ }
|
||||
if (!restore(saved)) pts = fit(PRESETS.Squircle());
|
||||
if (!restore(saved)) { shapes = presetShapes('Squircle'); sync(); }
|
||||
// An edit link replaces the current work, which stays one Undo away. Pasting a link into a tab that
|
||||
// already has the editor open only changes the hash, hence the hashchange listener.
|
||||
async function openEditLink() {
|
||||
@@ -486,8 +771,8 @@
|
||||
const data = location.hash.slice(6);
|
||||
history.replaceState(null, '', location.pathname + location.search); // a reload then keeps later edits
|
||||
let link = null;
|
||||
// Real states are well under 1 KB packed; the cap keeps a crafted link from inflating into megabytes.
|
||||
if (data.length < 8000) try { link = JSON.parse(await unpack(data)); } catch (e) { /* malformed link */ }
|
||||
// Real states are a few KB packed at most; the cap keeps a crafted link from inflating into megabytes.
|
||||
if (data.length < 16000) try { link = JSON.parse(await unpack(data)); } catch (e) { /* malformed link */ }
|
||||
const before = JSON.stringify(snapshot());
|
||||
if (restore(link)) { undo.push(before); redo.length = 0; }
|
||||
render();
|
||||
@@ -501,7 +786,7 @@
|
||||
openEditLink();
|
||||
|
||||
// ponytail: one self-check, runs on every load; the console says if the output format ever regresses
|
||||
for (const sample of [buildPath(fit(PRESETS['Cookie 7']())), ...Object.values(FIXED)]) {
|
||||
for (const sample of [buildPath(fit(PRESETS['Cookie 7']())), ...Object.values(FIXED), iconPath().d]) {
|
||||
console.assert(/^M [\d. -]+( [LC] [\d. -]+)* Z$/.test(sample) && !/[eE]|--|\.\d+\./.test(sample),
|
||||
'shape-editor: path not in launcher-safe format', sample);
|
||||
}
|
||||
|
||||
+104
-13
@@ -26,7 +26,14 @@ noindex: true # and out of search engines
|
||||
.se-grid-line { stroke: var(--md-outline-variant); stroke-width: .15; }
|
||||
.se-grid-mid { stroke: var(--md-outline); stroke-width: .25; }
|
||||
.se-bounds { fill: none; stroke: var(--md-outline); stroke-width: .35; stroke-dasharray: 1.5 1; }
|
||||
.se-shape { fill: var(--md-primary-container); fill-opacity: .85; stroke: var(--md-primary); stroke-width: .5; }
|
||||
.se-merged { fill: var(--md-primary-container); fill-opacity: .85; }
|
||||
.se-line { fill: none; stroke: var(--md-outline); stroke-width: .35; stroke-dasharray: 1.2 1; }
|
||||
.se-line.copy { stroke-dasharray: .4 .8; }
|
||||
.se-line.active { stroke: var(--md-primary); stroke-width: .5; stroke-dasharray: none; }
|
||||
.se-line.active.copy { stroke-width: .35; stroke-dasharray: 1.2 1; }
|
||||
.se-move-box { fill: transparent; stroke: var(--md-primary); stroke-width: .3; stroke-dasharray: 1 1; cursor: move; }
|
||||
.se-resize { fill: var(--md-surface-container-lowest); stroke: var(--md-primary); stroke-width: .5; }
|
||||
.se-move-box, .se-resize { touch-action: none; }
|
||||
.se-outline { fill: none; stroke: var(--md-on-surface-variant); stroke-width: .25; stroke-dasharray: 1 1; }
|
||||
.se-handle { cursor: grab; outline: none; }
|
||||
.se-handle circle:not(.se-hit) { fill: var(--md-surface-container-lowest); stroke: var(--md-primary); stroke-width: .7; }
|
||||
@@ -91,6 +98,54 @@ noindex: true # and out of search engines
|
||||
.se-share-card { width: 100%; border: 0; font: inherit; text-align: left; cursor: pointer; }
|
||||
.se-share-card[hidden] { display: none; }
|
||||
.se-share-card:disabled { opacity: .45; cursor: default; box-shadow: none; }
|
||||
/* Canvas overlay (Move mode): fit this shape top right, mirroring bottom left. */
|
||||
.se-left { min-width: 0; }
|
||||
.se-stage { position: relative; }
|
||||
.se-stage-btn, .se-mirror button { display: inline-flex; align-items: center; justify-content: center; padding: 0;
|
||||
border-radius: var(--md-shape-full); color: var(--md-primary); cursor: pointer; }
|
||||
.se-stage-btn { position: absolute; top: 10px; right: 10px; width: 40px; height: 40px;
|
||||
border: 1px solid var(--md-outline-variant); background: var(--md-surface-container-low); box-shadow: var(--md-shadow); }
|
||||
.se-mirror { position: absolute; left: 10px; bottom: 10px; display: flex; gap: 2px; padding: 3px;
|
||||
border-radius: var(--md-shape-full); border: 1px solid var(--md-outline-variant);
|
||||
background: var(--md-surface-container-low); box-shadow: var(--md-shadow); }
|
||||
.se-mirror button { width: 34px; height: 34px; border: 0; background: transparent; }
|
||||
.se-mirror button[aria-pressed="true"] { background: var(--md-primary-container); color: var(--md-on-primary-container); }
|
||||
.se-mirror svg { width: 20px; height: 20px; }
|
||||
.se-stage-btn:hover, .se-mirror button:hover { background: var(--md-surface-container-high); }
|
||||
.se-stage-btn[hidden], .se-mirror[hidden] { display: none; }
|
||||
/* Shape cards under the canvas. */
|
||||
.se-cards { display: flex; gap: 10px; overflow-x: auto; padding: 12px 2px 6px; }
|
||||
.se-card { position: relative; flex: 0 0 104px; display: flex; flex-direction: column; align-items: center;
|
||||
border: 1px solid var(--md-outline-variant); border-radius: var(--md-shape-md); background: var(--md-surface-container-low); }
|
||||
.se-card.active { border-color: var(--md-primary); box-shadow: 0 0 0 1px var(--md-primary); }
|
||||
.se-card-pick { display: flex; flex-direction: column; align-items: center; gap: 4px; width: 100%; padding: 12px 6px 8px;
|
||||
border: 0; background: transparent; color: var(--md-on-surface); font: inherit; font-size: .75rem; cursor: pointer;
|
||||
border-radius: var(--md-shape-md); }
|
||||
.se-card-pick svg { width: 56px; height: 56px; }
|
||||
.se-card-pick path { fill: var(--md-primary); }
|
||||
.se-card-pick path.copy { fill-opacity: .35; }
|
||||
.se-card-box { fill: none; stroke: var(--md-outline-variant); stroke-width: 2; stroke-dasharray: 6 4; }
|
||||
.se-card-del { position: absolute; top: 2px; right: 2px; width: 28px; height: 28px; display: flex; align-items: center;
|
||||
justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent;
|
||||
color: var(--md-on-surface-variant); cursor: pointer; }
|
||||
.se-card-del:hover { background: var(--md-surface-container-highest); color: var(--md-error); }
|
||||
.se-card-del .material-symbols-outlined, .se-card-modes .material-symbols-outlined { font-size: 18px; }
|
||||
.se-card-modes { display: flex; gap: 4px; padding: 0 6px 8px; }
|
||||
.se-card-modes button { width: 38px; height: 30px; display: flex; align-items: center; justify-content: center; padding: 0;
|
||||
border: 1px solid var(--md-outline-variant); border-radius: var(--md-shape-full); background: transparent;
|
||||
color: var(--md-on-surface-variant); cursor: pointer; }
|
||||
.se-card-modes button[aria-pressed="true"] { background: var(--md-primary-container); color: var(--md-on-primary-container); border-color: transparent; }
|
||||
.se-card-modes button:disabled { opacity: .35; cursor: default; }
|
||||
.se-card-add { justify-content: center; min-height: 104px; border-style: dashed; color: var(--md-primary); cursor: pointer; font: inherit; }
|
||||
.se-card-add .material-symbols-outlined { font-size: 32px; }
|
||||
.se-card-add:hover { background: var(--md-surface-container-high); }
|
||||
.se-note-out { color: var(--md-on-surface-variant); font-size: .85rem; margin: 6px 0 0; }
|
||||
/* The "add a shape" dialog reuses the contact dialog's classes too. */
|
||||
#se-add { line-height: 1.5; }
|
||||
#se-add h2 { margin: 0; }
|
||||
#se-add .contact-modal { max-width: 640px; }
|
||||
#se-add .contact-modal-desc { margin: 0 0 16px; }
|
||||
#se-add .se-presets { margin: 0; max-height: 60vh; overflow-y: auto; }
|
||||
#se-measure { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||
</style>
|
||||
|
||||
@@ -99,24 +154,45 @@ noindex: true # and out of search engines
|
||||
|
||||
<h2>Editor</h2>
|
||||
<div class="se-editor">
|
||||
<svg id="se-canvas" viewBox="-20 -20 140 140" tabindex="0" aria-label="Shape editor. Drag points to move them, use the plus marks to add points.">
|
||||
<g id="se-grid"></g>
|
||||
<rect class="se-bounds" x="0" y="0" width="100" height="100"/>
|
||||
<path id="se-shape" class="se-shape"/>
|
||||
<polygon id="se-outline" class="se-outline"/>
|
||||
<g id="se-adders"></g>
|
||||
<g id="se-handles"></g>
|
||||
</svg>
|
||||
<div class="se-left">
|
||||
<div class="se-stage">
|
||||
<svg id="se-canvas" viewBox="-20 -20 140 140" tabindex="0" aria-label="Shape editor. In Edit mode drag points to move them and the plus marks to add points; in Move mode drag the shape or its corners.">
|
||||
<g id="se-grid"></g>
|
||||
<rect class="se-bounds" x="0" y="0" width="100" height="100"/>
|
||||
<path id="se-merged" class="se-merged"/>
|
||||
<g id="se-lines"></g>
|
||||
<polygon id="se-outline" class="se-outline"/>
|
||||
<g id="se-adders"></g>
|
||||
<g id="se-handles"></g>
|
||||
<g id="se-move" style="display: none">
|
||||
<rect id="se-move-box" class="se-move-box"/>
|
||||
<!-- corners in the order the script fills them: top left, top right, bottom right, bottom left -->
|
||||
<rect class="se-resize" width="4" height="4" style="cursor: nwse-resize"/>
|
||||
<rect class="se-resize" width="4" height="4" style="cursor: nesw-resize"/>
|
||||
<rect class="se-resize" width="4" height="4" style="cursor: nwse-resize"/>
|
||||
<rect class="se-resize" width="4" height="4" style="cursor: nesw-resize"/>
|
||||
</g>
|
||||
</svg>
|
||||
<button id="se-fit-shape" class="se-stage-btn" type="button" title="Fit this shape to the box" aria-label="Fit this shape to the box" hidden><span class="material-symbols-outlined" aria-hidden="true"></span></button>
|
||||
<div id="se-mirror" class="se-mirror" role="group" aria-label="Mirror" hidden>
|
||||
<button type="button" data-mirror="none" title="No mirroring" aria-label="No mirroring"><svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l6 6-6 6z"/></svg></button>
|
||||
<button type="button" data-mirror="h" title="Mirror left and right" aria-label="Mirror left and right"><svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 2v16" stroke="currentColor" stroke-width="1.2" stroke-dasharray="2 1.6" fill="none"/><path d="M2 5l5 5-5 5zM18 5l-5 5 5 5z"/></svg></button>
|
||||
<button type="button" data-mirror="v" title="Mirror top and bottom" aria-label="Mirror top and bottom"><svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 10h16" stroke="currentColor" stroke-width="1.2" stroke-dasharray="2 1.6" fill="none"/><path d="M5 2l5 5 5-5zM5 18l5-5 5 5z"/></svg></button>
|
||||
<button type="button" data-mirror="both" title="Mirror both ways" aria-label="Mirror both ways"><svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 2v16M2 10h16" stroke="currentColor" stroke-width="1.2" stroke-dasharray="2 1.6" fill="none"/><path d="M2 2h5l-5 5zM18 2h-5l5 5zM2 18h5l-5-5zM18 18h-5l5-5z"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="se-cards" class="se-cards" role="list" aria-label="Shapes"></div>
|
||||
</div>
|
||||
|
||||
<div class="se-panel">
|
||||
<div class="se-row se-toolbar">
|
||||
<button id="se-undo" class="se-btn" type="button" title="Undo (Ctrl+Z)">Undo</button>
|
||||
<button id="se-redo" class="se-btn" type="button" title="Redo (Ctrl+Y or Ctrl+Shift+Z)">Redo</button>
|
||||
<button id="se-fit" class="se-btn" type="button" title="Scale and centre the shape until its longest side touches the box">Fit to box</button>
|
||||
<button id="se-fit" class="se-btn" type="button" title="Scale and centre the whole icon until its longest side touches the box">Fit to box</button>
|
||||
<button id="se-reset" class="se-btn" type="button">Reset</button>
|
||||
</div>
|
||||
<p id="se-fixed-note" class="se-hint se-note" hidden>This is a ready-made shape, so it can't be edited.
|
||||
Pick another preset, or use Generate or Reset to go back to editing.</p>
|
||||
<p id="se-fixed-note" class="se-hint se-note" hidden>This is a ready-made shape: you can move, resize and
|
||||
mirror it, but not edit its points. Generate replaces it with an editable one.</p>
|
||||
<fieldset id="se-corner-set">
|
||||
<legend id="se-corner-legend">All corners</legend>
|
||||
<label for="se-size">Size <span id="se-size-v"></span></label>
|
||||
@@ -173,7 +249,8 @@ noindex: true # and out of search engines
|
||||
</div>
|
||||
<div>
|
||||
<textarea id="se-out" readonly aria-label="Path data" spellcheck="false"></textarea>
|
||||
<p id="se-warn" class="se-warn" hidden>Edges cross each other. The launcher needs a shape that doesn't overlap itself.</p>
|
||||
<p id="se-warn" class="se-warn" hidden></p>
|
||||
<p id="se-note" class="se-note-out" hidden></p>
|
||||
<div class="se-actions">
|
||||
<a id="se-use" class="btn-filled" href="#">Use in Murine</a>
|
||||
<button id="se-share-open" class="btn-outlined" type="button" aria-haspopup="dialog">Share</button>
|
||||
@@ -211,6 +288,20 @@ noindex: true # and out of search engines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-modal-overlay" id="se-add" aria-hidden="true">
|
||||
<div class="contact-modal" role="dialog" aria-modal="true" aria-labelledby="se-add-title">
|
||||
<div class="contact-modal-header">
|
||||
<h2 id="se-add-title">Add a shape</h2>
|
||||
<button class="contact-modal-close" type="button" aria-label="Close" data-close>
|
||||
<span class="material-symbols-outlined"></span> <!-- close -->
|
||||
</button>
|
||||
</div>
|
||||
<p class="contact-modal-desc">Pick a starting point. It's added at half size in the middle, ready to move and resize.</p>
|
||||
<div id="se-add-grid" class="se-presets"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg id="se-measure" viewBox="0 0 100 100" aria-hidden="true"><path id="se-measure-path"/></svg>
|
||||
|
||||
<script src="{{ '/assets/js/polygon-clipping.min.js' | relative_url }}?v={{ site.version }}"></script>
|
||||
<script src="{{ '/assets/js/shape-editor.js' | relative_url }}?v={{ site.version }}"></script>
|
||||
Reference in new issue
Block a user