├── .gitignore ├── LICENSE.md ├── README.md ├── demo └── demo.html ├── dist ├── index.cjs ├── index.d.ts ├── index.mjs └── index.umd.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── corners │ ├── FigmaSquircle.ts │ ├── Flat.ts │ ├── Inset.ts │ ├── Round.ts │ ├── RoundInverse.ts │ └── Squircle.ts ├── index.ts └── utils │ ├── ElementManager.ts │ ├── Path.ts │ └── Svg.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | doc/ 3 | 4 | # Ignore Sublime Text files 5 | *.sublime-project 6 | *.sublime-workspace -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2024 Monokai (monokai.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the “Software”), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Monoco 2 | 3 | Custom (squircle) corners for html elements. 4 | 5 | - [Demo](https://somonoco.com) 6 | - [React version](https://github.com/monokai/monoco-react) 7 | - [Svelte version](https://github.com/monokai/monoco-svelte) 8 | 9 | ## Installation 10 | 11 | ```sh 12 | npm install @monokai/monoco 13 | ``` 14 | 15 | ## Options 16 | 17 | | Option | Type | Description | 18 | | --- | --- | --- | 19 | | background | string | background color | 20 | | smoothing | number | Smoothing factor of the corners (between 0 and 1, default: 1) | 21 | | borderRadius | number \| number[] | Radius of the corners, or array [top left, top right, bottom right, bottom left] | 22 | | offset | number \| number[] | Offset of the corners, or array [top, right, bottom, left] | 23 | | border | [number, string][] | Border of the corners, or array of borders | 24 | | cornerType | {width:number, height:number, radii:number[], offsets:number[]} => (string\|number)[][] | Corner type (default: Squircle) | 25 | | clip | boolean | Use clip-path on element | 26 | | width | number | Width of the element (default: auto) | 27 | | height | number | Height of the element (default: auto) | 28 | | observe | boolean | Observe element for resize (default: true) | 29 | | onResize | (rect:DOMRect, element:HTMLElement) => void | Callback when element resizes | 30 | | precision | number | Precision of the path (default: 3) | 31 | | isRounded | boolean | Use rounded values for width and height (default: false) 32 | 33 | ## Notes 34 | 35 | Monoco generates an SVG representation of the provided options and stores it as a `background-image` on the element. This means that when the element already has a background color, it will show through. Any previously defined background image value will be overwritten. 36 | 37 | If you want to use a background color, you can omit the `background` option in Monoco and set the `clip` option to `true`. This will use `clip-path` instead of `background-image`. 38 | 39 | Using a clip path has some implications. If you want to use a `drop-shadow` css filter for example, you can't use `clip`, as clip paths also clip the shadow. 40 | 41 | Note that `box-shadow` css values are not supported, as it doesn't take the svg shape into account. You have to use something like this instead: 42 | 43 | ```css 44 | .element { 45 | filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.5)); 46 | } 47 | ``` 48 | 49 | ## Example usage 50 | 51 | ```ts 52 | import { 53 | addCorners, 54 | draw, 55 | unobserve, 56 | 57 | // corner types: 58 | FigmaSquircle, 59 | Flat, 60 | Inset 61 | Round, 62 | RoundInverse, 63 | Squircle 64 | } from '@monokai/monoco' 65 | 66 | const element = document.getElementById('element') 67 | 68 | // use clip 69 | addCorners(element, { 70 | smoothing: 1, 71 | borderRadius: 32, 72 | clip: true 73 | }) 74 | 75 | // use background svg 76 | addCorners(element, { 77 | background: '#f00', 78 | smoothing: 1, 79 | borderRadius: 32, 80 | }) 81 | 82 | // multi-borderRadius (top left, top right, bottom right, bottom left) 83 | addCorners(element, { 84 | background: '#f00', 85 | smoothing: 1, 86 | borderRadius: [32, 4, 8, 16], 87 | }) 88 | 89 | // offset 90 | addCorners(element, { 91 | background: '#f00', 92 | smoothing: 1, 93 | borderRadius: 32, 94 | offset: 16 95 | }) 96 | 97 | // multi-offset (top, right, bottom, left) 98 | addCorners(element, { 99 | background: '#f00', 100 | smoothing: 1, 101 | borderRadius: 32, 102 | offset: [16, 4, 2, 8] 103 | }) 104 | 105 | // border 106 | addCorners(element, { 107 | background: '#f00', 108 | smoothing: 1, 109 | borderRadius: 32, 110 | border: [4, '#f00'] 111 | }) 112 | 113 | // multi-border 114 | addCorners(element, { 115 | background: '#f00', 116 | smoothing: 1, 117 | borderRadius: 32, 118 | border: [ 119 | [4, '#f00'], 120 | [2, '#0f0'], 121 | [8, '#00f'], 122 | [16, '#ff0'] 123 | ] 124 | }) 125 | 126 | // corner type 127 | addCorners(element, { 128 | background: '#f00', 129 | smoothing: 1, 130 | borderRadius: 16, 131 | cornerType: RoundInverse 132 | }) 133 | 134 | // observe (default: true) redraws when element triggers resize observer, you can optionally turn this off 135 | addCorners(element, { 136 | background: '#f00', 137 | smoothing: 1, 138 | borderRadius: 32, 139 | observe: false 140 | }) 141 | 142 | // unobserve element, removes the element from the resize observer 143 | unobserve(element) 144 | 145 | // manual redraw corners on element, options are optional 146 | draw(element, options) 147 | 148 | // manual redraw corners on all elements 149 | draw() 150 | ``` 151 | 152 | ## Difference between the default squircle and the Figma squircle. 153 | 154 | The default corner type is `Squircle`. You can also use the `FigmaSquircle` corner type to match the corners as they are calculated in Figma. The difference is subtle, but it has to do with the fact the Figma preserves more of the round shape in certain conditions where the radius is big, whereas `Squircle` preserves more of the smooth squircle shape. 155 | 156 | ## Custom corner types 157 | 158 | You can define your own corner types by providing a function to the `cornerType`. The function receives an object with the following properties: 159 | 160 | ```ts 161 | { 162 | width: number, 163 | height: number, 164 | radii: number[], 165 | offsets: number[] 166 | } 167 | ``` 168 | 169 | You can optionally add your own properties to this options object and use them in your custom function. Your function should return an array of SVG path commands, for example: 170 | 171 | ```ts 172 | return [ 173 | ['M', 0, 0], 174 | ['L', width, 0], 175 | ['L', width, height], 176 | ['L', 0, height], 177 | ['Z'] 178 | ]; 179 | ``` 180 | -------------------------------------------------------------------------------- /demo/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | monoco 5 | 6 | 7 | 38 | 39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | 75 | 76 | -------------------------------------------------------------------------------- /dist/index.cjs: -------------------------------------------------------------------------------- 1 | "use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=new class{elements;observer;constructor(){this.elements=null,this.observer=null}onElementResize(e){for(const t of e){const e=t.target.getBoundingClientRect(),r=this.elements?.get(t.target);if(!r)continue;const{previousW:n,previousH:s,draw:i,onResize:o}=r;n===e.width&&s===e.height||(i?.({width:e.width,height:e.height}),o?.(e,t.target),r.previousW=e.width,r.previousH=e.height)}}getDrawOptions(e){return this.elements?.get(e)?.cornerOptions??null}setCornerOptions(e,t){const r=this.elements?.get(e);r&&(r.cornerOptions=t,this.elements?.set(e,r))}addElement(e,t,r){this.elements||(this.elements=new Map),this.observer||(this.observer=new ResizeObserver((e=>this.onElementResize(e)))),this.unobserve(e);const{observe:n=!0,onResize:s}=t;if(n){this.observer.observe(e);const n=null,i=null;this.elements.set(e,{draw:r,cornerOptions:t,onResize:s,previousW:n,previousH:i,element:e})}return r}draw(e,t){e?(t&&this.setCornerOptions(e,t),this.elements?.get(e)?.draw?.()):this.elements?.forEach((e=>e.draw?.()))}unobserve(e){const t=t=>{this.observer?.unobserve(e),this.elements?.delete(e)};e?t():this.elements?.keys().forEach((e=>t()))}};function t(e,t,r,n,s,i,o,a){return r?[e?["c",...n]:[],s?["a",r,r,0,0,t,...i.map((e=>e*s))]:[],e?["c",...o]:[]]:[["l",...a]]}function r({width:e,height:r,radii:n,offsets:s,smoothing:i=1,preserveSmoothing:o=!0,sweepFlag:a=1}){const[h,,,c]=s,[d,p,l,u]=n.map((t=>function(e,t,r,n){let s=(1+t)*e;r||(t=Math.min(t,n/e-1),s=Math.min(s,n));const i=.5*Math.PI*(1-t),o=Math.sin(i/2)*e*2**.5,a=.25*Math.PI*t,h=e*Math.tan(.25*(.5*Math.PI-i))*Math.cos(a),c=h*Math.tan(a);let d=(s-o-h-c)/3,p=2*d;if(r&&s>n){const e=n-c-o-h,t=e-e/6;d=Math.min(d,t),p=e-d,s=Math.min(s,n)}return{a:p,b:d,c:h,d:c,p:s,arcLength:o,radius:e,ab:p+d,bc:d+h,abc:p+d+h}}(t,i,o,Math.max(t,.5*Math.min(e,r)))));return[["M",e-p.p+c,h],...t(i,a,p.radius,[p.a,0,p.ab,0,p.abc,p.d],p.arcLength,[1,1],[p.d,p.c,p.d,p.bc,p.d,p.abc],[p.p,0]),["L",e+c,r-l.p+h],...t(i,a,l.radius,[0,l.a,0,l.ab,-l.d,l.abc],l.arcLength,[-1,1],[-l.c,l.d,-l.bc,l.d,-l.abc,l.d],[0,l.p]),["L",u.p+c,r+h],...t(i,a,u.radius,[-u.a,0,-u.ab,0,-u.abc,-u.d],u.arcLength,[-1,-1],[-u.d,-u.c,-u.d,-u.bc,-u.d,-u.abc],[-u.p,0]),["L",c,d.p+h],...t(i,a,d.radius,[0,-d.a,0,-d.ab,d.d,-d.abc],d.arcLength,[1,-1],[d.c,-d.d,d.bc,-d.d,d.abc,-d.d],[0,-d.p]),["Z"]]}function n({width:e=0,height:t=0,borderRadius:n=0,offset:s=0,smoothing:i=1,cornerType:o=r,precision:a=3,isArray:h=!1}){if(!e||!t)return h?[]:"";const c=Array.isArray(s)?s:[s,s,s,s],[d,p,l,u]=c,g=e-u-p,m=t-d-l;let f,b;if(Array.isArray(n)){const e=n.map(((e,t)=>e+n[(t+1)%4])),t=Math.min(...e.map(((e,t)=>(t%2==0?g:m)/e)));f=t<1?n.map((e=>e*t)):n}else f=[n,n,n,n].map(((e,t)=>Math.max(0,Math.min(e-c[t],.5*Math.min(g,m)))));return b=o?o({width:g,height:m,radii:f,offsets:c,smoothing:i}):[[]],b=b.filter((e=>e[0])).map((([e,...t])=>{const r=t.map((e=>Number.isFinite(e)?Number(e.toFixed(a)):e)),n=[e,h?r:r.join(" ")];return h?n:n.join("")})),h?b:b.join("")}exports.FigmaSquircle=function(e){return r({...e,preserveSmoothing:!1,sweepFlag:1})},exports.Flat=function({width:e,height:t,radii:r,offsets:n}){const[s,,,i]=n,[o,a,h,c]=r;return[["M",i+o,s],["L",e-a+i,s],["L",e+i,s+a],["L",e+i,t-h+s],["L",e-h+i,t+s],["L",i+c,t+s],["L",i,t-c+s],["L",i,s+o],["Z"]]},exports.Inset=function({width:e,height:t,radii:r,offsets:n}){const[s,,,i]=n,[o,a,h,c]=r;return[["M",i+c,s],["L",e-a+i,s],["L",e-a+i,s+a],["L",e+i,s+a],["L",e+i,t-h+s],["L",e-h+i,t-h+s],["L",e-h+i,t+s],["L",i+c,t+s],["L",i+c,t-c+s],["L",i,t-c+s],["L",i,s+o],["L",i+o,s+o],["L",i+o,s],["Z"]]},exports.Round=function(e){return r({...e,smoothing:0,preserveSmoothing:!1,sweepFlag:1})},exports.RoundInverse=function(e){return r({...e,smoothing:0,preserveSmoothing:!1,sweepFlag:0})},exports.Squircle=r,exports.addCorners=function(t,r){e.setCornerOptions(t,r);const s=r=>{const s=e.getDrawOptions(t)??{};if(!s.width||!s.height){const e=t.getBoundingClientRect();s.width=e.width,s.height=e.height}const i={...s,...r};i.isRounded&&(i.width=i.width?Math.round(i.width):void 0,i.height=i.height?Math.round(i.height):void 0),t.style.clipPath=s.clip?`path('${n(i)}')`:"",(s.background||s.border)&&(t.style.backgroundImage=function(e){const{border:t=[],offset:r=0,strokeDrawType:s=0,background:i,clip:o,clipID:a,width:h,height:c}=e,d=[],p=Array.isArray(t?.[0])?t:[t],l=Array.isArray(r)?r:[r,r,r,r],u=o?null:n(e);if(p?.length){let t=0;const a=[];for(let i=0;i`),t+=o)}i&&(o?d.push(``):d.push(``)),d.push(...a.reverse())}return d.length?((e,t,r="c")=>{return`url('data:image/svg+xml,${(e=>encodeURIComponent(e).replace(/%20/g," ").replace(/%3D/g,"=").replace(/%3A/g,":").replace(/%2F/g,"/").replace(/%2C/g,",").replace(/%3B/g,";"))((n=(t?[``,`${e.join("")}`]:e).join(""),`${n}`))}')`;var n})(d,u,a):"none"}(i))};return s(),e.addElement(t,r,s)},exports.createPath=n,exports.draw=function(t,r){e.draw(t,r)},exports.unobserve=function(t){e.unobserve(t)}; 2 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | interface RedrawOptions { 2 | width?: number; 3 | height?: number; 4 | } 5 | interface ElementOptions { 6 | observe?: boolean; 7 | onResize?: (rect?: DOMRect, element?: HTMLElement) => void; 8 | } 9 | 10 | interface BackgroundOptions extends PathOptions { 11 | background?: string; 12 | border?: [number, string] | [number, string][]; 13 | strokeDrawType?: number; 14 | clipID?: string; 15 | clip?: boolean; 16 | } 17 | interface DefaultCornerTypeOptions { 18 | width: number; 19 | height: number; 20 | radii: number[]; 21 | offsets: number[]; 22 | } 23 | declare function createPath$6({ width: w, height: h, borderRadius: radiusOrArray, offset: offsetOrArray, smoothing, cornerType, precision, isArray }: PathOptions): string | (string | (string | number | (string | number)[])[])[]; 24 | declare function addCorners(element: HTMLElement, cornerOptions: CornerOptions): (redrawOptions?: RedrawOptions) => void; 25 | 26 | declare function createPath$5(options: DefaultCornerTypeOptions & { 27 | smoothing?: number; 28 | }): (string | number)[][]; 29 | 30 | declare function createPath$4({ width, height, radii, offsets }: { 31 | width: number; 32 | height: number; 33 | radii: number[]; 34 | offsets: number[]; 35 | }): (string | number)[][]; 36 | 37 | declare function createPath$3({ width, height, radii, offsets }: { 38 | width: number; 39 | height: number; 40 | radii: number[]; 41 | offsets: number[]; 42 | }): (string | number)[][]; 43 | 44 | declare function createPath$2(options: DefaultCornerTypeOptions): (string | number)[][]; 45 | 46 | declare function createPath$1(options: DefaultCornerTypeOptions): (string | number)[][]; 47 | 48 | declare function createPath({ width, height, radii, offsets, smoothing, preserveSmoothing, sweepFlag }: { 49 | width: number; 50 | height: number; 51 | radii: number[]; 52 | offsets: number[]; 53 | smoothing?: number; 54 | preserveSmoothing?: boolean; 55 | sweepFlag?: number; 56 | }): (string | number)[][]; 57 | 58 | interface CornerOptions extends BackgroundOptions, ElementOptions { 59 | } 60 | interface CornerTypeOptions extends DefaultCornerTypeOptions { 61 | [_: string | number | symbol]: unknown; 62 | } 63 | interface DrawOptions { 64 | width?: number; 65 | height?: number; 66 | smoothing?: number; 67 | borderRadius?: number | number[]; 68 | offset?: number | number[]; 69 | cornerType?(options: CornerTypeOptions): (string | number)[][]; 70 | precision?: number; 71 | isRounded?: boolean; 72 | } 73 | interface PathOptions extends DrawOptions { 74 | isArray?: boolean; 75 | } 76 | 77 | declare function draw(element?: HTMLElement, options?: CornerOptions): void; 78 | declare function unobserve(element: HTMLElement): void; 79 | 80 | export { type CornerOptions, type CornerTypeOptions, createPath$5 as FigmaSquircle, createPath$4 as Flat, createPath$3 as Inset, type PathOptions, createPath$2 as Round, createPath$1 as RoundInverse, createPath as Squircle, addCorners, createPath$6 as createPath, draw, unobserve }; 81 | -------------------------------------------------------------------------------- /dist/index.mjs: -------------------------------------------------------------------------------- 1 | var e=new class{elements;observer;constructor(){this.elements=null,this.observer=null}onElementResize(e){for(const t of e){const e=t.target.getBoundingClientRect(),n=this.elements?.get(t.target);if(!n)continue;const{previousW:i,previousH:r,draw:s,onResize:o}=n;i===e.width&&r===e.height||(s?.({width:e.width,height:e.height}),o?.(e,t.target),n.previousW=e.width,n.previousH=e.height)}}getDrawOptions(e){return this.elements?.get(e)?.cornerOptions??null}setCornerOptions(e,t){const n=this.elements?.get(e);n&&(n.cornerOptions=t,this.elements?.set(e,n))}addElement(e,t,n){this.elements||(this.elements=new Map),this.observer||(this.observer=new ResizeObserver((e=>this.onElementResize(e)))),this.unobserve(e);const{observe:i=!0,onResize:r}=t;if(i){this.observer.observe(e);const i=null,s=null;this.elements.set(e,{draw:n,cornerOptions:t,onResize:r,previousW:i,previousH:s,element:e})}return n}draw(e,t){e?(t&&this.setCornerOptions(e,t),this.elements?.get(e)?.draw?.()):this.elements?.forEach((e=>e.draw?.()))}unobserve(e){const t=t=>{this.observer?.unobserve(e),this.elements?.delete(e)};e?t():this.elements?.keys().forEach((e=>t()))}};function t(e,t,n,i,r,s,o,a){return n?[e?["c",...i]:[],r?["a",n,n,0,0,t,...s.map((e=>e*r))]:[],e?["c",...o]:[]]:[["l",...a]]}function n({width:e,height:n,radii:i,offsets:r,smoothing:s=1,preserveSmoothing:o=!0,sweepFlag:a=1}){const[h,,,c]=r,[d,l,u,g]=i.map((t=>function(e,t,n,i){let r=(1+t)*e;n||(t=Math.min(t,i/e-1),r=Math.min(r,i));const s=.5*Math.PI*(1-t),o=Math.sin(s/2)*e*2**.5,a=.25*Math.PI*t,h=e*Math.tan(.25*(.5*Math.PI-s))*Math.cos(a),c=h*Math.tan(a);let d=(r-o-h-c)/3,l=2*d;if(n&&r>i){const e=i-c-o-h,t=e-e/6;d=Math.min(d,t),l=e-d,r=Math.min(r,i)}return{a:l,b:d,c:h,d:c,p:r,arcLength:o,radius:e,ab:l+d,bc:d+h,abc:l+d+h}}(t,s,o,Math.max(t,.5*Math.min(e,n)))));return[["M",e-l.p+c,h],...t(s,a,l.radius,[l.a,0,l.ab,0,l.abc,l.d],l.arcLength,[1,1],[l.d,l.c,l.d,l.bc,l.d,l.abc],[l.p,0]),["L",e+c,n-u.p+h],...t(s,a,u.radius,[0,u.a,0,u.ab,-u.d,u.abc],u.arcLength,[-1,1],[-u.c,u.d,-u.bc,u.d,-u.abc,u.d],[0,u.p]),["L",g.p+c,n+h],...t(s,a,g.radius,[-g.a,0,-g.ab,0,-g.abc,-g.d],g.arcLength,[-1,-1],[-g.d,-g.c,-g.d,-g.bc,-g.d,-g.abc],[-g.p,0]),["L",c,d.p+h],...t(s,a,d.radius,[0,-d.a,0,-d.ab,d.d,-d.abc],d.arcLength,[1,-1],[d.c,-d.d,d.bc,-d.d,d.abc,-d.d],[0,-d.p]),["Z"]]}function i(e){return n({...e,preserveSmoothing:!1,sweepFlag:1})}function r({width:e,height:t,radii:n,offsets:i}){const[r,,,s]=i,[o,a,h,c]=n;return[["M",s+o,r],["L",e-a+s,r],["L",e+s,r+a],["L",e+s,t-h+r],["L",e-h+s,t+r],["L",s+c,t+r],["L",s,t-c+r],["L",s,r+o],["Z"]]}function s({width:e,height:t,radii:n,offsets:i}){const[r,,,s]=i,[o,a,h,c]=n;return[["M",s+c,r],["L",e-a+s,r],["L",e-a+s,r+a],["L",e+s,r+a],["L",e+s,t-h+r],["L",e-h+s,t-h+r],["L",e-h+s,t+r],["L",s+c,t+r],["L",s+c,t-c+r],["L",s,t-c+r],["L",s,r+o],["L",s+o,r+o],["L",s+o,r],["Z"]]}function o(e){return n({...e,smoothing:0,preserveSmoothing:!1,sweepFlag:1})}function a(e){return n({...e,smoothing:0,preserveSmoothing:!1,sweepFlag:0})}function h({width:e=0,height:t=0,borderRadius:i=0,offset:r=0,smoothing:s=1,cornerType:o=n,precision:a=3,isArray:h=!1}){if(!e||!t)return h?[]:"";const c=Array.isArray(r)?r:[r,r,r,r],[d,l,u,g]=c,p=e-g-l,m=t-d-u;let f,b;if(Array.isArray(i)){const e=i.map(((e,t)=>e+i[(t+1)%4])),t=Math.min(...e.map(((e,t)=>(t%2==0?p:m)/e)));f=t<1?i.map((e=>e*t)):i}else f=[i,i,i,i].map(((e,t)=>Math.max(0,Math.min(e-c[t],.5*Math.min(p,m)))));return b=o?o({width:p,height:m,radii:f,offsets:c,smoothing:s}):[[]],b=b.filter((e=>e[0])).map((([e,...t])=>{const n=t.map((e=>Number.isFinite(e)?Number(e.toFixed(a)):e)),i=[e,h?n:n.join(" ")];return h?i:i.join("")})),h?b:b.join("")}function c(t,n){e.setCornerOptions(t,n);const i=n=>{const i=e.getDrawOptions(t)??{};if(!i.width||!i.height){const e=t.getBoundingClientRect();i.width=e.width,i.height=e.height}const r={...i,...n};r.isRounded&&(r.width=r.width?Math.round(r.width):void 0,r.height=r.height?Math.round(r.height):void 0),t.style.clipPath=i.clip?`path('${h(r)}')`:"",(i.background||i.border)&&(t.style.backgroundImage=function(e){const{border:t=[],offset:n=0,strokeDrawType:i=0,background:r,clip:s,clipID:o,width:a,height:c}=e,d=[],l=Array.isArray(t?.[0])?t:[t],u=Array.isArray(n)?n:[n,n,n,n],g=s?null:h(e);if(l?.length){let t=0;const o=[];for(let r=0;r`),t+=s)}r&&(s?d.push(``):d.push(``)),d.push(...o.reverse())}return d.length?((e,t,n="c")=>{return`url('data:image/svg+xml,${(e=>encodeURIComponent(e).replace(/%20/g," ").replace(/%3D/g,"=").replace(/%3A/g,":").replace(/%2F/g,"/").replace(/%2C/g,",").replace(/%3B/g,";"))((i=(t?[``,`${e.join("")}`]:e).join(""),`${i}`))}')`;var i})(d,g,o):"none"}(r))};return i(),e.addElement(t,n,i)}function d(t,n){e.draw(t,n)}function l(t){e.unobserve(t)}export{i as FigmaSquircle,r as Flat,s as Inset,o as Round,a as RoundInverse,n as Squircle,c as addCorners,h as createPath,d as draw,l as unobserve}; 2 | -------------------------------------------------------------------------------- /dist/index.umd.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).monoco={})}(this,(function(e){"use strict";var t=new class{elements;observer;constructor(){this.elements=null,this.observer=null}onElementResize(e){for(const t of e){const e=t.target.getBoundingClientRect(),n=this.elements?.get(t.target);if(!n)continue;const{previousW:i,previousH:r,draw:s,onResize:o}=n;i===e.width&&r===e.height||(s?.({width:e.width,height:e.height}),o?.(e,t.target),n.previousW=e.width,n.previousH=e.height)}}getDrawOptions(e){return this.elements?.get(e)?.cornerOptions??null}setCornerOptions(e,t){const n=this.elements?.get(e);n&&(n.cornerOptions=t,this.elements?.set(e,n))}addElement(e,t,n){this.elements||(this.elements=new Map),this.observer||(this.observer=new ResizeObserver((e=>this.onElementResize(e)))),this.unobserve(e);const{observe:i=!0,onResize:r}=t;if(i){this.observer.observe(e);const i=null,s=null;this.elements.set(e,{draw:n,cornerOptions:t,onResize:r,previousW:i,previousH:s,element:e})}return n}draw(e,t){e?(t&&this.setCornerOptions(e,t),this.elements?.get(e)?.draw?.()):this.elements?.forEach((e=>e.draw?.()))}unobserve(e){const t=t=>{this.observer?.unobserve(e),this.elements?.delete(e)};e?t():this.elements?.keys().forEach((e=>t()))}};function n(e,t,n,i,r,s,o,a){return n?[e?["c",...i]:[],r?["a",n,n,0,0,t,...s.map((e=>e*r))]:[],e?["c",...o]:[]]:[["l",...a]]}function i({width:e,height:t,radii:i,offsets:r,smoothing:s=1,preserveSmoothing:o=!0,sweepFlag:a=1}){const[h,,,c]=r,[d,l,u,p]=i.map((n=>function(e,t,n,i){let r=(1+t)*e;n||(t=Math.min(t,i/e-1),r=Math.min(r,i));const s=.5*Math.PI*(1-t),o=Math.sin(s/2)*e*2**.5,a=.25*Math.PI*t,h=e*Math.tan(.25*(.5*Math.PI-s))*Math.cos(a),c=h*Math.tan(a);let d=(r-o-h-c)/3,l=2*d;if(n&&r>i){const e=i-c-o-h,t=e-e/6;d=Math.min(d,t),l=e-d,r=Math.min(r,i)}return{a:l,b:d,c:h,d:c,p:r,arcLength:o,radius:e,ab:l+d,bc:d+h,abc:l+d+h}}(n,s,o,Math.max(n,.5*Math.min(e,t)))));return[["M",e-l.p+c,h],...n(s,a,l.radius,[l.a,0,l.ab,0,l.abc,l.d],l.arcLength,[1,1],[l.d,l.c,l.d,l.bc,l.d,l.abc],[l.p,0]),["L",e+c,t-u.p+h],...n(s,a,u.radius,[0,u.a,0,u.ab,-u.d,u.abc],u.arcLength,[-1,1],[-u.c,u.d,-u.bc,u.d,-u.abc,u.d],[0,u.p]),["L",p.p+c,t+h],...n(s,a,p.radius,[-p.a,0,-p.ab,0,-p.abc,-p.d],p.arcLength,[-1,-1],[-p.d,-p.c,-p.d,-p.bc,-p.d,-p.abc],[-p.p,0]),["L",c,d.p+h],...n(s,a,d.radius,[0,-d.a,0,-d.ab,d.d,-d.abc],d.arcLength,[1,-1],[d.c,-d.d,d.bc,-d.d,d.abc,-d.d],[0,-d.p]),["Z"]]}function r({width:e=0,height:t=0,borderRadius:n=0,offset:r=0,smoothing:s=1,cornerType:o=i,precision:a=3,isArray:h=!1}){if(!e||!t)return h?[]:"";const c=Array.isArray(r)?r:[r,r,r,r],[d,l,u,p]=c,g=e-p-l,f=t-d-u;let m,b;if(Array.isArray(n)){const e=n.map(((e,t)=>e+n[(t+1)%4])),t=Math.min(...e.map(((e,t)=>(t%2==0?g:f)/e)));m=t<1?n.map((e=>e*t)):n}else m=[n,n,n,n].map(((e,t)=>Math.max(0,Math.min(e-c[t],.5*Math.min(g,f)))));return b=o?o({width:g,height:f,radii:m,offsets:c,smoothing:s}):[[]],b=b.filter((e=>e[0])).map((([e,...t])=>{const n=t.map((e=>Number.isFinite(e)?Number(e.toFixed(a)):e)),i=[e,h?n:n.join(" ")];return h?i:i.join("")})),h?b:b.join("")}e.FigmaSquircle=function(e){return i({...e,preserveSmoothing:!1,sweepFlag:1})},e.Flat=function({width:e,height:t,radii:n,offsets:i}){const[r,,,s]=i,[o,a,h,c]=n;return[["M",s+o,r],["L",e-a+s,r],["L",e+s,r+a],["L",e+s,t-h+r],["L",e-h+s,t+r],["L",s+c,t+r],["L",s,t-c+r],["L",s,r+o],["Z"]]},e.Inset=function({width:e,height:t,radii:n,offsets:i}){const[r,,,s]=i,[o,a,h,c]=n;return[["M",s+c,r],["L",e-a+s,r],["L",e-a+s,r+a],["L",e+s,r+a],["L",e+s,t-h+r],["L",e-h+s,t-h+r],["L",e-h+s,t+r],["L",s+c,t+r],["L",s+c,t-c+r],["L",s,t-c+r],["L",s,r+o],["L",s+o,r+o],["L",s+o,r],["Z"]]},e.Round=function(e){return i({...e,smoothing:0,preserveSmoothing:!1,sweepFlag:1})},e.RoundInverse=function(e){return i({...e,smoothing:0,preserveSmoothing:!1,sweepFlag:0})},e.Squircle=i,e.addCorners=function(e,n){t.setCornerOptions(e,n);const i=n=>{const i=t.getDrawOptions(e)??{};if(!i.width||!i.height){const t=e.getBoundingClientRect();i.width=t.width,i.height=t.height}const s={...i,...n};s.isRounded&&(s.width=s.width?Math.round(s.width):void 0,s.height=s.height?Math.round(s.height):void 0),e.style.clipPath=i.clip?`path('${r(s)}')`:"",(i.background||i.border)&&(e.style.backgroundImage=function(e){const{border:t=[],offset:n=0,strokeDrawType:i=0,background:s,clip:o,clipID:a,width:h,height:c}=e,d=[],l=Array.isArray(t?.[0])?t:[t],u=Array.isArray(n)?n:[n,n,n,n],p=o?null:r(e);if(l?.length){let t=0;const a=[];for(let s=0;s`),t+=o)}s&&(o?d.push(``):d.push(``)),d.push(...a.reverse())}return d.length?((e,t,n="c")=>{return`url('data:image/svg+xml,${(e=>encodeURIComponent(e).replace(/%20/g," ").replace(/%3D/g,"=").replace(/%3A/g,":").replace(/%2F/g,"/").replace(/%2C/g,",").replace(/%3B/g,";"))((i=(t?[``,`${e.join("")}`]:e).join(""),`${i}`))}')`;var i})(d,p,a):"none"}(s))};return i(),t.addElement(e,n,i)},e.createPath=r,e.draw=function(e,n){t.draw(e,n)},e.unobserve=function(e){t.unobserve(e)},Object.defineProperty(e,"__esModule",{value:!0})})); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@monokai/monoco", 3 | "version": "0.2.2", 4 | "author": "Monokai", 5 | "license": "MIT", 6 | "description": "Custom (squircle) corners and borders for html elements", 7 | "keywords": [ 8 | "corner", 9 | "border", 10 | "squircle", 11 | "svg", 12 | "html" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/monokai/monoco.git" 17 | }, 18 | "type": "module", 19 | "sideEffects": false, 20 | "types": "./dist/index.d.ts", 21 | "main": "./dist/index.umd.js", 22 | "module": "./dist/index.mjs", 23 | "exports": { 24 | ".": { 25 | "types": "./dist/index.d.ts", 26 | "import": "./dist/index.mjs", 27 | "require": "./dist/index.cjs" 28 | }, 29 | "./source": "./src/index.ts" 30 | }, 31 | "files": [ 32 | "./dist", 33 | "./src" 34 | ], 35 | "scripts": { 36 | "build": "rollup -c", 37 | "build:production": "rollup -c --environment BUILD:production", 38 | "watch": "rollup src/index.ts --format umd --name monoco --watch --output.file=dist/umd/index.js" 39 | }, 40 | "devDependencies": { 41 | "@clarifynl/eslint-config-clarify": "^0.14.0", 42 | "@rollup/plugin-commonjs": "^28.0.2", 43 | "@rollup/plugin-node-resolve": "^16.0.0", 44 | "@rollup/plugin-terser": "^0.4.4", 45 | "@rollup/plugin-typescript": "^12.1.2", 46 | "rollup": "^4.28.1", 47 | "rollup-plugin-delete": "^2.0.0", 48 | "rollup-plugin-dts": "^6.1.1", 49 | "tslib": "^2.8.1", 50 | "typescript": "^5.7.2" 51 | }, 52 | "eslintConfig": { 53 | "extends": [ 54 | "@clarifynl/clarify" 55 | ], 56 | "rules": { 57 | "import/extensions": 0 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import { dts } from "rollup-plugin-dts"; 4 | import terser from '@rollup/plugin-terser'; 5 | import del from 'rollup-plugin-delete'; 6 | 7 | const dist = './dist'; 8 | const name = 'monoco'; 9 | 10 | const plugins = [ 11 | commonjs(), 12 | typescript(), 13 | terser({ 14 | compress: { 15 | drop_console: true, 16 | passes: 2 17 | } 18 | }) 19 | ] 20 | 21 | export default [ 22 | { 23 | input: './src/index.ts', 24 | output: [ 25 | { 26 | file: `${dist}/index.cjs`, 27 | format: 'cjs', 28 | esModule: true 29 | }, 30 | { 31 | format: 'esm', 32 | file: `${dist}/index.mjs` 33 | }, 34 | { 35 | file: `${dist}/index.umd.js`, 36 | format: 'umd', 37 | esModule: true, 38 | name 39 | } 40 | ], 41 | plugins: [ 42 | ...plugins, 43 | del({ 44 | targets: `${dist}/*` 45 | }) 46 | ] 47 | }, 48 | { 49 | input: [ 50 | 'src/index.ts' 51 | ], 52 | output: [ 53 | { 54 | dir: `${dist}`, 55 | format: 'esm' 56 | } 57 | ], 58 | plugins: [ 59 | typescript(), 60 | dts() 61 | ] 62 | } 63 | ]; -------------------------------------------------------------------------------- /src/corners/FigmaSquircle.ts: -------------------------------------------------------------------------------- 1 | import { DefaultCornerTypeOptions } from '../utils/Path' 2 | import { createPath as createSquirclePath } from './Squircle' 3 | 4 | export function createPath(options:DefaultCornerTypeOptions & { 5 | smoothing?:number 6 | }) { 7 | return createSquirclePath({ 8 | ...options, 9 | preserveSmoothing: false, 10 | sweepFlag: 1 11 | }) 12 | } -------------------------------------------------------------------------------- /src/corners/Flat.ts: -------------------------------------------------------------------------------- 1 | export function createPath({ 2 | width, 3 | height, 4 | radii, 5 | offsets 6 | }:{ 7 | width:number, 8 | height:number, 9 | radii:number[], 10 | offsets:number[] 11 | }) { 12 | const [ot,,, ol] = offsets 13 | const [rtl, rtr, rbr, rbl] = radii 14 | 15 | return [ 16 | ['M', ol + rtl, ot], 17 | ['L', width - rtr + ol, ot], 18 | ['L', width + ol, ot + rtr], 19 | ['L', width + ol, height - rbr + ot], 20 | ['L', width - rbr + ol, height + ot], 21 | ['L', ol + rbl, height + ot], 22 | ['L', ol, height - rbl + ot], 23 | ['L', ol, ot + rtl], 24 | ['Z'] 25 | ] 26 | } -------------------------------------------------------------------------------- /src/corners/Inset.ts: -------------------------------------------------------------------------------- 1 | export function createPath({ 2 | width, 3 | height, 4 | radii, 5 | offsets 6 | }:{ 7 | width:number, 8 | height:number, 9 | radii:number[], 10 | offsets:number[] 11 | }) { 12 | const [ot,,, ol] = offsets 13 | const [rtl, rtr, rbr, rbl] = radii 14 | 15 | return [ 16 | ['M', ol + rbl, ot], 17 | ['L', width - rtr + ol, ot], 18 | ['L', width - rtr + ol, ot + rtr], 19 | ['L', width + ol, ot + rtr], 20 | ['L', width + ol, height - rbr + ot], 21 | ['L', width - rbr + ol, height - rbr + ot], 22 | ['L', width - rbr + ol, height + ot], 23 | ['L', ol + rbl, height + ot], 24 | ['L', ol + rbl, height - rbl + ot], 25 | ['L', ol, height - rbl + ot], 26 | ['L', ol, ot + rtl], 27 | ['L', ol + rtl, ot + rtl], 28 | ['L', ol + rtl, ot], 29 | ['Z'] 30 | ] 31 | } -------------------------------------------------------------------------------- /src/corners/Round.ts: -------------------------------------------------------------------------------- 1 | import { DefaultCornerTypeOptions } from '../utils/Path' 2 | import { createPath as createSquirclePath } from './Squircle' 3 | 4 | export function createPath(options:DefaultCornerTypeOptions) { 5 | return createSquirclePath({ 6 | ...options, 7 | smoothing: 0, 8 | preserveSmoothing: false, 9 | sweepFlag: 1 10 | }) 11 | } -------------------------------------------------------------------------------- /src/corners/RoundInverse.ts: -------------------------------------------------------------------------------- 1 | import { DefaultCornerTypeOptions } from '../utils/Path' 2 | import { createPath as createSquirclePath } from './Squircle' 3 | 4 | export function createPath(options:DefaultCornerTypeOptions) { 5 | return createSquirclePath({ 6 | ...options, 7 | smoothing: 0, 8 | preserveSmoothing: false, 9 | sweepFlag: 0 10 | }) 11 | } -------------------------------------------------------------------------------- /src/corners/Squircle.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Adapted from "figma-squircle" (https://github.com/phamfoo/figma-squircle), 3 | refactored and optimized by Monokai 4 | 5 | --- 6 | 7 | MIT License 8 | 9 | Copyright (c) 2021 Tien Pham 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all 19 | copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | SOFTWARE. 28 | */ 29 | 30 | function getSquircleCorner( 31 | radius:number, 32 | smoothing:number, 33 | preserveSmoothing:boolean, 34 | maxSize:number 35 | ) { 36 | let p = (1 + smoothing) * radius 37 | 38 | if (!preserveSmoothing) { 39 | smoothing = Math.min(smoothing, maxSize / radius - 1) 40 | p = Math.min(p, maxSize) 41 | } 42 | 43 | const arc = Math.PI * 0.5 * (1 - smoothing) 44 | const arcLength = Math.sin(arc / 2) * radius * 2 ** 0.5 45 | const angle = Math.PI * 0.25 * smoothing 46 | const c = radius * Math.tan((Math.PI * 0.5 - arc) * 0.25) * Math.cos(angle) 47 | const d = c * Math.tan(angle) 48 | 49 | let b = (p - arcLength - c - d) / 3 50 | let a = 2 * b 51 | 52 | if (preserveSmoothing && p > maxSize) { 53 | const pDist2 = maxSize - d - arcLength - c 54 | const maxB = pDist2 - pDist2 / 6 55 | 56 | b = Math.min(b, maxB) 57 | a = pDist2 - b 58 | p = Math.min(p, maxSize) 59 | } 60 | 61 | return { 62 | a, 63 | b, 64 | c, 65 | d, 66 | p, 67 | arcLength, 68 | radius, 69 | ab: a + b, 70 | bc: b + c, 71 | abc: a + b + c 72 | } 73 | } 74 | 75 | 76 | function createSquircleCorner( 77 | smoothing:number, 78 | sweepFlag:number, 79 | radius:number, 80 | c1:number[], 81 | arcLength:number, 82 | arcMultiplier:number[], 83 | c2:number[], 84 | l:number[] 85 | ) { 86 | if (radius) { 87 | return [ 88 | ...[smoothing ? ['c', ...c1] : []], 89 | ...[arcLength ? ['a', radius, radius, 0, 0, sweepFlag, ...arcMultiplier.map(x => x * arcLength)] : []], 90 | ...[smoothing ? ['c', ...c2] : []], 91 | ] 92 | } 93 | 94 | return [['l', ...l]] 95 | } 96 | 97 | export function createPath({ 98 | width, 99 | height, 100 | radii, 101 | offsets, 102 | smoothing = 1, 103 | preserveSmoothing = true, 104 | sweepFlag = 1 105 | }:{ 106 | width:number, 107 | height:number, 108 | radii:number[], 109 | offsets:number[], 110 | smoothing?:number, 111 | preserveSmoothing?:boolean, 112 | sweepFlag?:number 113 | }) { 114 | const [ot,,, ol] = offsets 115 | const [c1, c2, c3, c4] = radii.map(radius => getSquircleCorner( 116 | radius, 117 | smoothing, 118 | preserveSmoothing, 119 | Math.max(radius, Math.min(width, height) * 0.5) 120 | )) 121 | 122 | return [ 123 | ['M', width - c2.p + ol, ot], 124 | ...createSquircleCorner( 125 | smoothing, 126 | sweepFlag, 127 | c2.radius, 128 | [c2.a, 0, c2.ab, 0, c2.abc, c2.d], 129 | c2.arcLength, [1, 1], 130 | [c2.d, c2.c, c2.d, c2.bc, c2.d, c2.abc], 131 | [c2.p, 0] 132 | ), 133 | ['L', width + ol, height - c3.p + ot], 134 | ...createSquircleCorner( 135 | smoothing, 136 | sweepFlag, 137 | c3.radius, 138 | [0, c3.a, 0, c3.ab, -c3.d, c3.abc], 139 | c3.arcLength, [-1, 1], 140 | [-c3.c, c3.d, -c3.bc, c3.d, -c3.abc, c3.d], 141 | [0, c3.p] 142 | ), 143 | ['L', c4.p + ol, height + ot], 144 | ...createSquircleCorner( 145 | smoothing, 146 | sweepFlag, 147 | c4.radius, 148 | [-c4.a, 0, -c4.ab, 0, -c4.abc, -c4.d], 149 | c4.arcLength, [-1, -1], 150 | [-c4.d, -c4.c, -c4.d, -c4.bc, -c4.d, -c4.abc], 151 | [-c4.p, 0] 152 | ), 153 | ['L', ol, c1.p + ot], 154 | ...createSquircleCorner( 155 | smoothing, 156 | sweepFlag, 157 | c1.radius, 158 | [0, -c1.a, 0, -c1.ab, c1.d, -c1.abc], 159 | c1.arcLength, [1, -1], 160 | [c1.c, -c1.d, c1.bc, -c1.d, c1.abc, -c1.d], 161 | [0, -c1.p] 162 | ), 163 | ['Z'] 164 | ] 165 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { BackgroundOptions, DefaultCornerTypeOptions } from "./utils/Path" 2 | import type { ElementOptions } from "./utils/ElementManager" 3 | 4 | import ElementManager from './utils/ElementManager' 5 | 6 | export { createPath as FigmaSquircle } from './corners/FigmaSquircle' 7 | export { createPath as Flat } from './corners/Flat' 8 | export { createPath as Inset } from './corners/Inset' 9 | export { createPath as Round } from './corners/Round' 10 | export { createPath as RoundInverse } from './corners/RoundInverse' 11 | export { createPath as Squircle } from './corners/Squircle' 12 | 13 | export interface CornerOptions extends BackgroundOptions, ElementOptions {} 14 | 15 | export interface CornerTypeOptions extends DefaultCornerTypeOptions { 16 | // allow any key / value combinations in addition to the default corner type options 17 | [_: string | number | symbol]: unknown; 18 | } 19 | 20 | interface DrawOptions { 21 | width?:number, 22 | height?:number, 23 | smoothing?:number, 24 | borderRadius?:number | number[], 25 | offset?:number | number[], 26 | cornerType?(options:CornerTypeOptions):(string | number)[][], 27 | precision?:number, 28 | isRounded?:boolean 29 | } 30 | 31 | export interface PathOptions extends DrawOptions { 32 | isArray?:boolean 33 | } 34 | 35 | export { 36 | createPath, 37 | addCorners 38 | } from './utils/Path' 39 | 40 | export function draw(element?:HTMLElement, options?:CornerOptions) { 41 | ElementManager.draw(element, options) 42 | } 43 | 44 | export function unobserve(element:HTMLElement) { 45 | ElementManager.unobserve(element) 46 | } -------------------------------------------------------------------------------- /src/utils/ElementManager.ts: -------------------------------------------------------------------------------- 1 | import { CornerOptions } from ".." 2 | 3 | export interface RedrawOptions { 4 | width?:number, 5 | height?:number 6 | } 7 | 8 | interface ElementSpecs { 9 | draw: (redrawOptions?:RedrawOptions) => void, 10 | cornerOptions: CornerOptions, 11 | onResize?: (rect?:DOMRect, element?:HTMLElement) => void, 12 | previousW: number | null, 13 | previousH: number | null, 14 | element: HTMLElement 15 | } 16 | 17 | export interface ElementOptions { 18 | observe?:boolean, 19 | onResize?:(rect?:DOMRect, element?:HTMLElement) => void 20 | } 21 | 22 | export default new class ElementManager { 23 | elements:Map | null 24 | observer:ResizeObserver | null 25 | 26 | constructor() { 27 | this.elements = null 28 | this.observer = null 29 | } 30 | 31 | onElementResize(resizeList:ResizeObserverEntry[]) { 32 | for (const entry of resizeList) { 33 | const rect = entry.target.getBoundingClientRect() 34 | const specs = this.elements?.get(entry.target as HTMLElement) 35 | 36 | if (!specs) { 37 | continue 38 | } 39 | 40 | const { 41 | previousW, 42 | previousH, 43 | draw, 44 | onResize 45 | } = specs 46 | 47 | if ( 48 | previousW !== rect.width || 49 | previousH !== rect.height 50 | ) { 51 | draw?.({ 52 | width: rect.width, 53 | height: rect.height 54 | }) 55 | 56 | onResize?.(rect, entry.target as HTMLElement) 57 | 58 | specs.previousW = rect.width 59 | specs.previousH = rect.height 60 | } 61 | } 62 | } 63 | 64 | getDrawOptions(element:HTMLElement):CornerOptions | null { 65 | return this.elements?.get(element)?.cornerOptions ?? null; 66 | } 67 | 68 | setCornerOptions(element:HTMLElement, cornerOptions:CornerOptions) { 69 | const specs = this.elements?.get(element); 70 | 71 | if (specs) { 72 | specs.cornerOptions = cornerOptions; 73 | 74 | this.elements?.set(element, specs); 75 | } 76 | } 77 | 78 | addElement(element:HTMLElement, cornerOptions:CornerOptions & ElementOptions, draw:(redrawOptions?:RedrawOptions) => void) { 79 | if (!this.elements) { 80 | this.elements = new Map() 81 | } 82 | 83 | if (!this.observer) { 84 | this.observer = new ResizeObserver(resizeList => this.onElementResize(resizeList)) 85 | } 86 | 87 | this.unobserve(element) 88 | 89 | const { 90 | observe = true, 91 | onResize 92 | } = cornerOptions 93 | 94 | if (observe) { 95 | this.observer.observe(element) 96 | 97 | const previousW = null 98 | const previousH = null 99 | 100 | this.elements.set(element, { 101 | draw, 102 | cornerOptions, 103 | onResize, 104 | previousW, 105 | previousH, 106 | element 107 | }) 108 | } 109 | 110 | return draw 111 | } 112 | 113 | draw(element?:HTMLElement, cornerOptions?:CornerOptions) { 114 | if (element) { 115 | if (cornerOptions) { 116 | this.setCornerOptions(element, cornerOptions) 117 | } 118 | 119 | this.elements?.get(element)?.draw?.() 120 | } else { 121 | this.elements?.forEach((o:ElementSpecs) => o.draw?.()) 122 | } 123 | } 124 | 125 | unobserve(element:HTMLElement) { 126 | const funk = (el:HTMLElement) => { 127 | this.observer?.unobserve(element) 128 | this.elements?.delete(element) 129 | } 130 | 131 | if (element) { 132 | funk(element) 133 | } else { 134 | this.elements?.keys().forEach(el => funk(el)) 135 | } 136 | } 137 | }() -------------------------------------------------------------------------------- /src/utils/Path.ts: -------------------------------------------------------------------------------- 1 | import type { CornerOptions } from '..' 2 | import type { RedrawOptions } from './ElementManager' 3 | 4 | import ElementManager from './ElementManager' 5 | import { createPath as Squircle } from '../corners/Squircle' 6 | import { createSVGDatURI } from './Svg' 7 | import { PathOptions } from '..' 8 | 9 | export interface BackgroundOptions extends PathOptions { 10 | background?:string, 11 | border?:[number, string] | [number, string][], 12 | strokeDrawType?:number 13 | clipID?:string, 14 | clip?:boolean 15 | } 16 | 17 | export interface DefaultCornerTypeOptions { 18 | width:number, 19 | height:number, 20 | radii:number[], 21 | offsets:number[] 22 | } 23 | 24 | function createBackground(options:BackgroundOptions) { 25 | const { 26 | border:borderOrArray = [], 27 | offset:offsetOrArray = 0, 28 | strokeDrawType = 0, 29 | background, 30 | clip, 31 | clipID, 32 | width, 33 | height 34 | } = options 35 | 36 | const paths:string[] = [] 37 | const borderArray = Array.isArray(borderOrArray?.[0]) ? borderOrArray : [borderOrArray] 38 | const offsetArray = Array.isArray(offsetOrArray) ? offsetOrArray : [offsetOrArray, offsetOrArray, offsetOrArray, offsetOrArray] 39 | const clipPath = clip ? null : createPath(options) 40 | 41 | if (borderArray?.length) { 42 | let totalBorderRadius = 0 43 | 44 | const borderPaths:string[] = [] 45 | 46 | for (let i = 0; i < borderArray.length; i++) { 47 | const [size, borderColor] = borderArray[i] as [number, string] 48 | 49 | const strokeWidth = strokeDrawType === 0 ? (totalBorderRadius + size) * 2 : size 50 | 51 | if (size) { 52 | borderPaths.push(``) 56 | 57 | totalBorderRadius += size 58 | } 59 | } 60 | 61 | if (background) { 62 | if (clip) { 63 | paths.push(``) 64 | } else { 65 | paths.push(``) 66 | } 67 | } 68 | 69 | paths.push(...borderPaths.reverse()) 70 | } 71 | 72 | return paths.length ? createSVGDatURI(paths, clipPath as string, clipID) : 'none' 73 | } 74 | 75 | export function createPath({ 76 | width:w = 0, 77 | height:h = 0, 78 | borderRadius:radiusOrArray = 0, 79 | offset:offsetOrArray = 0, 80 | smoothing = 1, 81 | cornerType = Squircle, 82 | precision = 3, 83 | isArray = false 84 | }:PathOptions) { 85 | if (!w || !h) { 86 | return isArray ? [] : '' 87 | } 88 | 89 | const offsets = Array.isArray(offsetOrArray) ? offsetOrArray : [offsetOrArray, offsetOrArray, offsetOrArray, offsetOrArray] 90 | const [ot, or, ob, ol] = offsets 91 | const width = w - ol - or 92 | const height = h - ot - ob 93 | 94 | let radii; 95 | 96 | if (Array.isArray(radiusOrArray)) { 97 | // https://drafts.csswg.org/css-backgrounds/#corner-overlap 98 | const sides = radiusOrArray.map((r, i) => r + radiusOrArray[(i + 1) % 4]) 99 | const f = Math.min(...sides.map((s, i) => (i % 2 === 0 ? width : height) / s)) 100 | 101 | if (f < 1) { 102 | radii = radiusOrArray.map(r => r * f); 103 | } else { 104 | radii = radiusOrArray; 105 | } 106 | } else { 107 | radii = [radiusOrArray, radiusOrArray, radiusOrArray, radiusOrArray].map((r, i) => Math.max( 108 | 0, 109 | Math.min(r - offsets[i], Math.min(width, height) * 0.5) 110 | )) 111 | } 112 | 113 | let path 114 | 115 | if (cornerType) { 116 | path = cornerType({ 117 | width, 118 | height, 119 | radii, 120 | offsets, 121 | smoothing 122 | }) 123 | } else { 124 | path = [[]] 125 | } 126 | 127 | path = path 128 | .filter(instructions => instructions[0]) 129 | .map(([command, ...parameters]) => { 130 | const p = parameters.map(x => Number.isFinite(x) ? Number((x as number).toFixed(precision)) : x) 131 | const result = [ 132 | command, 133 | isArray ? p : p.join(' ') 134 | ] 135 | 136 | return isArray ? result : result.join('') 137 | }) 138 | 139 | return isArray ? path : path.join('') 140 | } 141 | 142 | export function addCorners(element:HTMLElement, cornerOptions:CornerOptions) { 143 | ElementManager.setCornerOptions(element, cornerOptions) 144 | 145 | const drawFunk = (redrawOptions?:RedrawOptions) => { 146 | const options = ElementManager.getDrawOptions(element) ?? {} as CornerOptions 147 | 148 | if (!(options.width && options.height)) { 149 | const rect = element.getBoundingClientRect() 150 | 151 | options.width = rect.width 152 | options.height = rect.height 153 | } 154 | 155 | const combinedOptions = {...options, ...redrawOptions} 156 | 157 | if (combinedOptions.isRounded) { 158 | combinedOptions.width = combinedOptions.width ? Math.round(combinedOptions.width) : undefined 159 | combinedOptions.height = combinedOptions.height ? Math.round(combinedOptions.height) : undefined 160 | } 161 | 162 | element.style.clipPath = options.clip ? `path('${createPath(combinedOptions)}')` : '' 163 | 164 | if (options.background || options.border) { 165 | element.style.backgroundImage = createBackground(combinedOptions) 166 | } 167 | } 168 | 169 | drawFunk() 170 | 171 | return ElementManager.addElement(element, cornerOptions, drawFunk) 172 | } -------------------------------------------------------------------------------- /src/utils/Svg.ts: -------------------------------------------------------------------------------- 1 | const encode = (content:string) => encodeURIComponent(content) 2 | .replace(/%20/g, ' ') 3 | .replace(/%3D/g, '=') 4 | .replace(/%3A/g, ':') 5 | .replace(/%2F/g, '/') 6 | .replace(/%2C/g, ',') 7 | .replace(/%3B/g, ';') 8 | 9 | export const createSVG = (content:string) => `${content}` 10 | 11 | export const createSVGDatURI = (paths:string[], clipPath:string, id:string = 'c') => 12 | `url('data:image/svg+xml,${ 13 | encode(createSVG((clipPath ? [ 14 | ``, 15 | `${paths.join('')}` 16 | ] : paths).join(''))) 17 | }')` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "bundler", 4 | "removeComments": false, 5 | "target": "es2022", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "module": "esnext", 9 | "noEmitOnError": true, 10 | "lib": ["dom", "dom.iterable", "esnext"], 11 | "checkJs": true, 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "strict": true 17 | }, 18 | "include": ["src"] 19 | } --------------------------------------------------------------------------------