├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── libvite.config.js ├── package.json ├── public ├── favicon.ico ├── icon.png ├── icons │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ └── favicon-32x32.png ├── manifest.json ├── robots.txt └── samples │ ├── gradient.png │ ├── perspective2.jpg │ ├── seagull-8547189_1280.png │ ├── snail-8577681_1280.jpg │ ├── sunset-815270_1280.jpg │ └── water-8100724_1280.jpg ├── screenshot.jpg ├── src ├── __section.js ├── _adjustments.js ├── _blender.js ├── _blur.js ├── _composition.js ├── _curves.js ├── _filters.js ├── _perspective.js ├── _recipes.js ├── app.css ├── app.js ├── assets │ ├── LUT │ │ ├── LUT_1977.png │ │ ├── LUT_aden.png │ │ ├── LUT_amaro.png │ │ ├── LUT_clarendon1.png │ │ ├── LUT_clarendon2.png │ │ ├── LUT_crema.png │ │ ├── LUT_gingham1.png │ │ ├── LUT_gingham_lgg.png │ │ ├── LUT_juno.png │ │ ├── LUT_lark.png │ │ ├── LUT_ludwig.png │ │ ├── LUT_moon1.png │ │ ├── LUT_moon2.png │ │ ├── LUT_perpetua.png │ │ ├── LUT_perpetua_overlay.png │ │ ├── LUT_reyes.png │ │ ├── LUT_slumber.png │ │ └── LUT_xpro.png │ ├── icon.png │ ├── icon_flip.svg │ ├── icon_github.png │ ├── icon_histo.svg │ ├── icon_info.svg │ ├── icon_rotate.svg │ ├── icon_shutter_rotate.svg │ ├── icon_skew.svg │ ├── icon_split.svg │ ├── info-circle-svgrepo-com.svg │ ├── split-v-svgrepo-com.svg │ └── ui_downarrow.svg ├── components │ ├── canvasmouse.js │ ├── clickdropFile.js │ ├── colorcurve.css │ ├── colorcurve.js │ ├── cropper.css │ ├── cropper.js │ ├── downloadImage.js │ ├── fullscreen.js │ ├── gpsmap.js │ ├── histogram.js │ ├── histogram_worker.js │ ├── perspective.js │ ├── perspective2.js │ ├── splitview.css │ ├── splitview.js │ └── themetoggle.js ├── editor.css ├── js │ ├── tools.js │ └── zoom_pan.js ├── main.css └── main.js └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.lock 3 | node_modules 4 | dist 5 | lib 6 | dist-ssr 7 | *.local 8 | .env* 9 | package-lock.json 10 | TODO.txt 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 xdadda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mini-img-editor 2 | 3 | Online webgl2 photo editor 4 | (https://mini2-photo-editor.netlify.app) 5 | 6 | 7 | ![Screenshot](screenshot.jpg) 8 | 9 | 100% privacy! images are edited locally in the browser, no uploads to backend server. 10 | 11 | Current features: 12 | * Crop 13 | * Perspective correction 14 | * Image resize 15 | * Lights and colors adjustments 16 | * Vignette 17 | * Clarity/ sharpening 18 | * Noise reduction 19 | * Color curves 20 | * Insta-like filters 21 | * Image blender 22 | * Bokeh/lens and gaussian blur 23 | * Split view before-after 24 | * Color histogram 25 | * Exif/Tiff/GPS info 26 | * Display-P3 color space 27 | * sRGB correct workflow (linear sRGB) 28 | 29 | 30 | Notes: 31 | * file formats support depends on the browser/ platform being used (eg HEIC open natively in MacOS Safari, JPEG-XL and AVIF in Safari and Chrome, ...) 32 | * 16-bit images can be opened but shaders and download is currently limited to 8-bit due to webgl limitations 33 | 34 | Please leave feature requests in the issues section (ideally showing a real life example) and I'll see what I can do. 35 | 36 | 37 | Powered by [mini-js](https://github.com/xdadda/minijs), [mini-gl](https://github.com/xdadda/mini-gl) and [mini-exif](https://github.com/xdadda/mini-exif) 38 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /libvite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { minifyTemplateLiterals } from "rollup-plugin-minify-template-literals"; 3 | import path from "path"; 4 | 5 | import { dirname, resolve } from 'node:path' 6 | import { fileURLToPath } from 'node:url' 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)) 9 | 10 | export default defineConfig(({isSsrBuild, mode})=>{ 11 | 12 | return { 13 | plugins: [ 14 | {...minifyTemplateLiterals(),apply:'build'} 15 | ], 16 | build: { 17 | target: 'esnext', 18 | minify: true, //in production to reduce size 19 | sourcemap: false, //unless required during development to debug production code artifacts 20 | modulePreload: { polyfill: false }, //not needed for modern browsers 21 | cssCodeSplit:false, //if small enough it's better to have it in one file to avoid flickering during suspend 22 | lib: { 23 | entry: { 24 | 'mini-img-editor':resolve(__dirname, 'src/app.js'), 25 | }, 26 | name: 'mini-img-editor', 27 | }, 28 | outDir: path.join(__dirname, "lib"), 29 | rollupOptions: { 30 | // make sure to externalize deps that shouldn't be bundled 31 | // into your library 32 | // external: ['@xdadda/mini','@xdadda/mini/store','@xdadda/mini/components','@xdadda/mini/components.css','@xdadda/mini/router','@xdadda/mini-exif','@xdadda/mini-gl','ismobilejs'], 33 | } 34 | } 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-img-editor", 3 | "version": "0.1.1", 4 | "description": "", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --port 5174 --host 0.0.0.0", 8 | "build": "vite build", 9 | "buildlib": "vite build --config libvite.config.js", 10 | "serve": "vite preview" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@xdadda/mini": "^0.2.13", 16 | "@xdadda/mini-exif": "^0.2.2", 17 | "@xdadda/mini-gl": "^0.1.14", 18 | "ismobilejs": "^1.1.1" 19 | }, 20 | "devDependencies": { 21 | "rollup-plugin-minify-template-literals": "^1.1.7", 22 | "vite": "^6.2.2" 23 | }, 24 | "exports": { 25 | ".": { 26 | "import": "./lib/mini-img-editor.js", 27 | "require": "./lib/mini-img-editor.umd.cjs" 28 | }, 29 | "./styles.css": "./lib/mini-img-editor.css" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/favicon.ico -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/icon.png -------------------------------------------------------------------------------- /public/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MiNi PhotoEditor", 3 | "short_name": "MiNiPhoto", 4 | "icons": [ 5 | {"src":"/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"}, 6 | {"src":"/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"} 7 | ], 8 | "start_url":"/", 9 | "theme_color": "#ffffff", 10 | "background_color": "#ffffff", 11 | "display": "standalone" 12 | } -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /public/samples/gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/samples/gradient.png -------------------------------------------------------------------------------- /public/samples/perspective2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/samples/perspective2.jpg -------------------------------------------------------------------------------- /public/samples/seagull-8547189_1280.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/samples/seagull-8547189_1280.png -------------------------------------------------------------------------------- /public/samples/snail-8577681_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/samples/snail-8577681_1280.jpg -------------------------------------------------------------------------------- /public/samples/sunset-815270_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/samples/sunset-815270_1280.jpg -------------------------------------------------------------------------------- /public/samples/water-8100724_1280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/public/samples/water-8100724_1280.jpg -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/screenshot.jpg -------------------------------------------------------------------------------- /src/__section.js: -------------------------------------------------------------------------------- 1 | import { html, reactive } from '@xdadda/mini' 2 | 3 | 4 | //Note: if onEnable notdefined/null the enable/disable button will not be visible 5 | export default function section(sectionname, height, $selection, params, onEnable, onReset, sectionComponent){ 6 | 7 | 8 | function resetSection(){ 9 | if(params[sectionname]?.$skip) return 10 | if(onReset) onReset(sectionname) 11 | } 12 | 13 | function handleSkipSection(e){ 14 | e.preventDefault() 15 | e.stopPropagation() 16 | const el_btn = document.getElementById('btn_skip_'+sectionname) 17 | const el_sec = document.getElementById(sectionname) 18 | const el_div = document.getElementById(sectionname+'_content') 19 | 20 | if(!params[sectionname].$skip) { 21 | //disable section 22 | params[sectionname].$skip=true 23 | el_btn?.setAttribute('disabled',true) 24 | el_sec?.setAttribute('skipped',true) 25 | el_div?.classList.add('skip') 26 | onEnable(false) 27 | } 28 | else { 29 | //enable section 30 | params[sectionname].$skip=false 31 | el_btn?.removeAttribute('disabled') 32 | el_sec?.removeAttribute('skipped') 33 | el_div?.classList.remove('skip') 34 | el_sec.style.opacity='' 35 | onEnable(true) 36 | } 37 | } 38 | 39 | return html` 40 |
41 |
42 | ${!!onEnable && html`\u2609`} 43 | 44 | ${!!onReset && html`\u00D8`} 45 |
46 | 47 | ${()=>$selection.value===sectionname && html` 48 |
49 |
50 |
51 | 52 | ${sectionComponent} 53 |
54 |
55 | `} 56 |
` 57 | } -------------------------------------------------------------------------------- /src/_adjustments.js: -------------------------------------------------------------------------------- 1 | import { html, reactive } from '@xdadda/mini' 2 | import section from './__section.js' 3 | import {debounce} from './js/tools.js' 4 | 5 | 6 | export default function adjustments($selection, params, onUpdate){ 7 | 8 | const heights={ 9 | lights:190, 10 | colors:150, 11 | effects:105 12 | } 13 | 14 | reactive(()=>{ 15 | if($selection.value===null) { 16 | //app has been reset, new values loaded 17 | ['lights','colors','effects'].forEach(s=>updateResetBtn(s)) 18 | } 19 | },{effect:true}) 20 | 21 | ///////////////////////////// 22 | 23 | ///// SECTION HANDLING FN //////// 24 | function checkParamszero(section) { 25 | return Object.values(params[section]).reduce((p,v)=>p+=v,0)===0 26 | } 27 | function resetParamsToZero(section) { 28 | for (const key of Object.keys(params[section])) { 29 | params[section][key]=0 30 | updateParamCtrl(section+'_'+key) 31 | } 32 | } 33 | 34 | function resetSection(section){ 35 | resetParamsToZero(section) 36 | onUpdate() 37 | updateResetBtn(section) 38 | } 39 | 40 | function updateResetBtn(section){ 41 | const el=document.getElementById('btn_reset_'+section) 42 | if(!el) return 43 | //if all section's params are set to default values disable reset 44 | if(checkParamszero(section)) el.setAttribute('disabled',true) 45 | else el.removeAttribute('disabled') 46 | } 47 | ///////////////////////////// 48 | 49 | 50 | ///// RANGE INPUT FN //////// 51 | function _setParam(e){ 52 | debounce('param',()=>setParam.call(this,e),30) 53 | } 54 | function setParam(e){ //id= "section_field" 55 | const value = e.target.value 56 | const id = this.id.split('_') 57 | params[id[0]][id[1]]=parseFloat(value) 58 | updateParamCtrl(this.id) 59 | onUpdate() 60 | updateResetBtn(id[0]) 61 | } 62 | 63 | function updateParamCtrl(_id){ 64 | const el = document.getElementById(_id) 65 | if(!el) return 66 | const id = _id.split('_') 67 | el.value=params[id[0]][id[1]] 68 | if(id.length===3){//it's the number input 69 | el.previousElementSibling.value=el.value 70 | } 71 | else {//it's the range input 72 | el.nextElementSibling.value=el.value 73 | } 74 | } 75 | 76 | function resetParamCtrl(){ 77 | if(!this) return 78 | const id = this.id.split('_') 79 | params[id[0]][id[1]]=0 80 | updateParamCtrl(this.id) 81 | onUpdate() 82 | updateResetBtn(id[0]) 83 | } 84 | ///////////////////////////// 85 | 86 | 87 | return html` 88 | ${['lights','colors','effects'].map(secname=>html` 89 | 90 | ${section( 91 | secname, //section's name 92 | heights[secname], // section's height once open 93 | $selection, //signal with active sectioname, that opens/closes section 94 | params, //section's params obj of which $skip field will be set on/off 95 | onUpdate, //called when section is enabled/disabled 96 | resetSection, //section name provided to onReset 97 | ()=>html`${Object.keys(params[secname]).filter(e=>!e.startsWith('$')).map(e=>html` 98 | /* RANGE INPUTS */ 99 |
100 |
${e}
101 | 102 | 103 |
104 | 105 | `)} 106 | `)} 107 | 108 | 109 | 110 | `)} 111 | ` 112 | } 113 | 114 | 115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /src/_blender.js: -------------------------------------------------------------------------------- 1 | import { html, reactive } from '@xdadda/mini' 2 | import clickdropFile from './components/clickdropFile.js' 3 | import { readImage } from './js/tools.js' 4 | import section from './__section.js' 5 | import {debounce} from './js/tools.js' 6 | 7 | export default function blender($selection, _params, onUpdate){ 8 | const params = _params.blender 9 | const disablerange=reactive(!params.blendmap) 10 | const blendname = reactive('') 11 | 12 | function resetBlender(){ 13 | if(params.$skip) return 14 | params.blendmap=0 15 | params.blendmix=0.5 16 | updateParamCtrl('blender_blendmix') 17 | disablerange.value=true 18 | blendname.value='' 19 | if(onUpdate) onUpdate() 20 | updateResetBtn('blender') 21 | } 22 | 23 | function onBlend(arrayBuffer, filedata, img){ 24 | if(!img) return 25 | img.filename = filedata?.name 26 | params.blendmap=img 27 | blendname.value=filedata?.name 28 | params.blendmix=0.5 29 | disablerange.value=false 30 | if(onUpdate) onUpdate() 31 | updateResetBtn('blender') 32 | } 33 | 34 | function updateResetBtn(section){ 35 | //if all section's adjustments are set to 0 disable reset 36 | const el=document.getElementById('btn_reset_'+section) 37 | if(params.blendmap===0){ 38 | if(el) el.setAttribute('disabled',true) 39 | } 40 | else { 41 | if(el) el.removeAttribute('disabled') 42 | } 43 | } 44 | 45 | ///// RANGE INPUT FN //////// 46 | function _setParam(e){ 47 | debounce('param',()=>setParam.call(this,e),30) 48 | } 49 | function setParam(e){ //id= "section_param" 50 | const value = e.target.value 51 | const id = this.id.split('_') 52 | params[id[1]]=parseFloat(value) 53 | updateParamCtrl(this.id) 54 | onUpdate() 55 | updateResetBtn(id[0]) 56 | } 57 | 58 | function updateParamCtrl(_id){ 59 | const el = document.getElementById(_id) 60 | if(!el) return 61 | const id = _id.split('_') 62 | el.value=params[id[1]] 63 | if(id.length===3){//it's the number input 64 | el.previousElementSibling.value=el.value 65 | } 66 | else {//it's the range input 67 | el.nextElementSibling.value=el.value 68 | } 69 | } 70 | 71 | function resetParamCtrl(){ 72 | if(!this) return 73 | const id = this.id.split('_') 74 | params[id[1]]=0.5 75 | updateParamCtrl(this.id) 76 | onUpdate() 77 | updateResetBtn(id[0]) 78 | } 79 | ///////////////////////////// 80 | 81 | return html` 82 | ${section( 83 | 'blender', 84 | 100, 85 | $selection, 86 | _params, 87 | onUpdate, 88 | resetBlender, 89 | html`
90 | ${()=>!blendname.value 91 | ? html`${clickdropFile('click or drop
to blend file', 'image/*', (file)=>readImage(file, onBlend), 'width:90%; height:50px;')} ` 92 | : html` 93 | 94 | /* RANGE INPUT */ 95 |
96 |
blend mix
97 | 98 | 99 |
100 | ` 101 | } 102 |
` 103 | )} 104 | ` 105 | } 106 | 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /src/_blur.js: -------------------------------------------------------------------------------- 1 | import { html, reactive } from '@xdadda/mini' 2 | import section from './__section.js' 3 | import {debounce} from './js/tools.js' 4 | import canvasMouse from './components/canvasmouse.js' 5 | import {centerCanvas} from './app.js' 6 | 7 | export default function blur($selection, params, onUpdate){ 8 | 9 | const paramszero = { bokehstrength:0, bokehlensout:0.5, gaussianstrength:0, centerX:0.5, centerY:0.5} 10 | //first setup 11 | if(!checkParamszero('blur')) resetParamsToZero('blur') 12 | 13 | const showmouse=reactive(false) 14 | reactive(()=>{ 15 | if($selection.value==='blur'){ 16 | showmouse.value=[[params.blur.centerX,params.blur.centerY]] 17 | centerCanvas() 18 | } 19 | else { 20 | showmouse.value=false 21 | if($selection.value===null) updateResetBtn('blur') 22 | } 23 | },{effect:true}) 24 | 25 | function onMouseMove(pts){ 26 | params.blur.centerX=pts[0][0] 27 | params.blur.centerY=pts[0][1] 28 | onUpdate() 29 | } 30 | 31 | ///////////////////////////// 32 | 33 | ///// SECTION HANDLING FN //////// 34 | function checkParamszero(section) { 35 | for (const key of Object.keys(paramszero)) { 36 | if (!(key in params[section]) || params[section][key] !== paramszero[key]) return false 37 | } 38 | return true 39 | } 40 | function resetParamsToZero(section) { 41 | for (const key of Object.keys(paramszero)) { 42 | if (params[section][key] !== paramszero[key]) { 43 | params[section][key]=paramszero[key] 44 | updateParamCtrl(section+'_'+key) 45 | }; 46 | } 47 | } 48 | 49 | function resetSection(section){ 50 | resetParamsToZero(section) 51 | onUpdate() 52 | updateResetBtn(section) 53 | //switch mousefilter on/off to update position .. bit hacky but for now it works 54 | showmouse.value=false 55 | showmouse.value=[[params.blur.centerX,params.blur.centerY]] 56 | } 57 | 58 | function updateResetBtn(section){ 59 | const el=document.getElementById('btn_reset_'+section) 60 | if(!el) return 61 | //if all section's params are set to default values disable reset 62 | if(checkParamszero(section)) el.setAttribute('disabled',true) 63 | else el.removeAttribute('disabled') 64 | } 65 | 66 | //flag: true when section enabled, false when disabled 67 | function onEnableSection(flag){ 68 | if(flag) centerCanvas() 69 | onUpdate() 70 | } 71 | ///////////////////////////// 72 | 73 | ///// RANGE INPUT FN //////// 74 | function _setParam(e){ 75 | debounce('param',()=>setParam.call(this,e),30) 76 | } 77 | function setParam(e){ //id= "section_field" 78 | const value = e.target.value 79 | const id = this.id.split('_') 80 | params[id[0]][id[1]]=parseFloat(value) 81 | updateParamCtrl(this.id) 82 | onUpdate() 83 | updateResetBtn(id[0]) 84 | } 85 | 86 | function updateParamCtrl(_id){ 87 | const el = document.getElementById(_id) 88 | if(!el) return 89 | const id = _id.split('_') 90 | el.value=params[id[0]][id[1]] 91 | if(id.length===3){//it's the number input 92 | el.previousElementSibling.value=el.value 93 | } 94 | else {//it's the range input 95 | el.nextElementSibling.value=el.value 96 | } 97 | } 98 | 99 | function resetParamCtrl(){ 100 | if(!this) return 101 | const id = this.id.split('_') 102 | params[id[0]][id[1]]=0 103 | updateParamCtrl(this.id) 104 | onUpdate() 105 | updateResetBtn(id[0]) 106 | } 107 | ///////////////////////////// 108 | 109 | 110 | return html` 111 | ${section( 112 | 'blur', 113 | 125, 114 | $selection, //signal with active sectioname, that opens/closes section 115 | params, //section's params obj of which $skip field will be set on/off 116 | onEnableSection, //called when section is enabled/disabled 117 | resetSection, //section name provided to onReset 118 | ()=>html` /* mouse canvas */ 119 | 120 | ${()=>showmouse.value && canvasMouse(canvas,showmouse.value,onMouseMove)} 121 | 122 | ${['bokehstrength','gaussianstrength','bokehlensout'].filter(e=>!e.startsWith('$')).map((e,idx)=>html` 123 | /* RANGE INPUTS */ 124 |
125 |
${['bokeh strength','gauss strength','cirble radius'][idx]}
126 | 127 | 128 |
129 | 130 | `)} 131 |
(center red dot)
132 | `)} 133 | ` 134 | } 135 | 136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /src/_composition.js: -------------------------------------------------------------------------------- 1 | import { html, reactive, onMount } from '@xdadda/mini' 2 | import icon_rotate from './assets/icon_rotate.svg?raw' 3 | import icon_flip from './assets/icon_flip.svg?raw' 4 | import icon_skew from './assets/icon_skew.svg?raw' 5 | 6 | import Quad from './components/perspective.js' 7 | import section from './__section.js' 8 | 9 | 10 | export default function composition($selection, adj, onUpdate, get_minigl, centerCanvas){ 11 | let _minigl 12 | let prevselection 13 | 14 | reactive(()=>{ 15 | 16 | if($selection.value==='composition'){ 17 | _minigl=get_minigl() 18 | _minigl.resetCrop() //resetCrop will restore original/ resized image size 19 | 20 | //get current aspect ratio and save it in the pic - 1/pic AR settings 21 | arsvalues[1]=_minigl.gl.canvas.width/_minigl.gl.canvas.height 22 | arsvalues[2]=1/arsvalues[1] 23 | updateCanvasAngle() 24 | onUpdate() 25 | prevselection=$selection.value 26 | } 27 | else { 28 | if(prevselection==='composition') { 29 | hidePerspective() 30 | prevselection=undefined 31 | handleCrop() 32 | } 33 | } 34 | },{effect:true}) 35 | 36 | async function handleCrop(){ 37 | const params=adj 38 | if(!croprect) return //croprect is the DOM element with the crop size 39 | crop.style.display='' //if it was hidden by perspective 40 | const ratio=canvas.width/crop.offsetWidth 41 | params.crop.glcrop={ 42 | left:Math.round((croprect.offsetLeft)*ratio), 43 | top:Math.round((croprect.offsetTop)*ratio), 44 | width:Math.round((croprect.offsetWidth)*ratio), 45 | height:Math.round((croprect.offsetHeight)*ratio) 46 | } 47 | onUpdate() 48 | centerCanvas() 49 | } 50 | 51 | ///// CROP 52 | 53 | function resetComposition(){ 54 | const croprect = document.getElementById('croprect') 55 | if(!croprect) return 56 | 57 | Object.keys(adj['crop']).forEach(e=>{ 58 | adj['crop'][e]=0 59 | }) 60 | setCropAR(0) 61 | updateCanvasAngle() 62 | 63 | Object.keys(adj['trs']).forEach(e=>{ 64 | adj['trs'][e]=0 65 | setTRSCtrl('trs_'+e) 66 | }) 67 | 68 | Object.keys(adj['perspective']).forEach(e=>{ 69 | adj['perspective'][e]=0 70 | }) 71 | 72 | const fliph=document.getElementById('fliph') 73 | const flipv=document.getElementById('flipv') 74 | fliph.removeAttribute('selected') 75 | flipv.removeAttribute('selected') 76 | 77 | //if(adj.perspective?.resetFn) adj.perspective.resetFn() 78 | //persp.value=false 79 | hidePerspective() 80 | 81 | resetCropRect() 82 | resetResizer() 83 | updateResetBtn() 84 | 85 | //showCrop() 86 | onUpdate() 87 | } 88 | 89 | function resetCropRect(currentc){ 90 | const crop = document.getElementById('crop') 91 | const croprect=document.getElementById('croprect') 92 | crop.style.width= Math.round(canvas.getBoundingClientRect().width)+'px' 93 | crop.style.height = Math.round(canvas.getBoundingClientRect().height)+'px' 94 | if(adj.crop.ar) croprect.style.aspectRatio=adj.crop.ar 95 | croprect.style.inset='0' 96 | adj.crop.currentcrop=0 97 | } 98 | 99 | 100 | function flip(dir){ 101 | if(dir==='v') { 102 | adj.trs.flipv=!adj.trs.flipv 103 | if(adj.trs.flipv) flipv.setAttribute('selected',true) 104 | else flipv.removeAttribute('selected') 105 | } 106 | else { 107 | adj.trs.fliph=!adj.trs.fliph 108 | if(adj.trs.fliph) fliph.setAttribute('selected',true) 109 | else fliph.removeAttribute('selected') 110 | } 111 | updateResetBtn() 112 | onUpdate() 113 | } 114 | 115 | 116 | 117 | function updateResetBtn(){ 118 | const flag = Object.values(adj.trs).reduce((p,v)=>p+=v,0)===0 && Object.values(adj.crop).reduce((p,v)=>p+=v,0)===0 && adj.perspective.modified==0 && adj.resizer.width===0 119 | if(flag) btn_reset_composition.setAttribute('disabled',true) 120 | else btn_reset_composition.removeAttribute('disabled') 121 | } 122 | 123 | const ars=['free','pic','1:pic','1:1','4:3','16:9','3:4','9:16'] 124 | let arsvalues=[0,0,0,1,4/3,16/9,3/4,9/16] 125 | function setCropAR(idx){ 126 | hidePerspective() 127 | adj.crop.arindex=idx 128 | adj.crop.ar=arsvalues[idx] 129 | if(croprect) croprect.style.aspectRatio=arsvalues[idx] 130 | const aspects = document.getElementById('aspects') 131 | if(aspects){ 132 | aspects.querySelector('[selected]')?.removeAttribute('selected') 133 | aspects.querySelector('#ar_'+idx)?.setAttribute('selected',true) 134 | } 135 | updateResetBtn() 136 | } 137 | ///////////////// 138 | 139 | ///// ROTATE CANVAS 140 | 141 | function updateCanvasAngle(){ 142 | const {width,height} = _minigl 143 | if(adj.crop.canvas_angle%180){ 144 | _minigl.gl.canvas.width=height 145 | _minigl.gl.canvas.height=width 146 | } 147 | else { 148 | _minigl.gl.canvas.width=width 149 | _minigl.gl.canvas.height=height 150 | } 151 | _minigl.setupFiltersTextures() //recreacte working textures with new canvas size! 152 | //ensure canvas is centered 153 | centerCanvas() 154 | } 155 | 156 | function rotateCanvas(deg){ 157 | adj.crop.canvas_angle = (adj.crop.canvas_angle+deg) % 360 158 | updateCanvasAngle() 159 | //reset crop view 160 | crop.style.width= Math.round(canvas.getBoundingClientRect().width)+'px' 161 | crop.style.height = Math.round(canvas.getBoundingClientRect().height)+'px' 162 | croprect.style.inset='0' 163 | hidePerspective() 164 | 165 | updateResetBtn() 166 | onUpdate() 167 | } 168 | 169 | function setTRS(e){ //id= "section:adj" 170 | const value = e.target.value 171 | const id = this.id.split('_') 172 | adj[id[0]][id[1]]=parseFloat(value) 173 | if(id.length===3){//it's the number input 174 | this.nextElementSibling.value=value 175 | } 176 | else {//it's the range input 177 | this.previousElementSibling.value=value 178 | } 179 | 180 | if(id[1]==='angle'){ 181 | const rad = parseFloat(Math.abs(value)) * Math.PI / 180.0 182 | const newwidth=canvas.width*Math.cos(rad)+canvas.height*Math.sin(rad) 183 | const newheight=canvas.width*Math.sin(rad)+canvas.height*Math.cos(rad) 184 | const zoom=Math.max(newwidth/canvas.width-1, newheight/canvas.height-1) 185 | adj.trs.scale=zoom 186 | updateResetBtn() 187 | //setAdjCtrl('trs_scale') 188 | } 189 | onUpdate() 190 | } 191 | 192 | 193 | function setTRSCtrl(_id){ 194 | const el = document.getElementById(_id) 195 | if(!el) return 196 | const id = _id.split('_') 197 | el.value=adj[id[0]][id[1]] 198 | el.previousElementSibling.value=el.value 199 | } 200 | 201 | function resetTRSCtrl(){ 202 | if(!this) return 203 | const id = this.id.split('_') 204 | adj[id[0]][id[1]]=0 205 | setTRSCtrl(this.id) 206 | 207 | if(id[1]==='angle'){ 208 | adj.trs.scale=0 209 | } 210 | 211 | updateResetBtn() 212 | onUpdate() 213 | } 214 | ///////////////// 215 | 216 | ///// PERSPECTIVE 217 | let persp=reactive(false) 218 | //let perspel 219 | async function showPerspective(){ 220 | persp.value=adj.perspective 221 | //perspel = await render(plcquad,()=>Quad(canvas,adj.perspective,()=>{updateResetBtn();onUpdate()})) 222 | crop.style.display='none' 223 | } 224 | function hidePerspective(){ 225 | //if(!perspel) return 226 | //perspel.destroy() 227 | //perspel=undefined 228 | const crop=document.getElementById('crop') 229 | if(crop) crop.style.display='' //will be set by handleCrop 230 | persp.value=false 231 | } 232 | function togglePerspective(){ 233 | if(persp.value) hidePerspective() 234 | else showPerspective() 235 | } 236 | ///////////////// 237 | 238 | ///// RESIZER 239 | const resizeperc=reactive(100) 240 | 241 | function resize(newwidth,newheight){ 242 | resize_width.value=adj.resizer.width=newwidth 243 | resize_height.value=adj.resizer.height=newheight 244 | //centerCanvas() 245 | _minigl.resize(newwidth,newheight) 246 | resizeperc.value=Math.round(newwidth/_minigl.img.width*1000)/10 247 | updateCanvasAngle() 248 | resetCropRect() 249 | updateResetBtn() 250 | onUpdate() 251 | } 252 | 253 | function resetResizer(){ 254 | _minigl.resetResize() 255 | adj.resizer.width=0 256 | adj.resizer.height=0 257 | resize_width.value=_minigl.width 258 | resize_height.value=_minigl.height 259 | resizeperc.value=100 260 | } 261 | 262 | function setWidth(){ 263 | const ar = arsvalues[1] 264 | const width=Math.max(100,this.value) 265 | const height=Math.floor(width/ar) 266 | resize(width,height) 267 | } 268 | function setHeight(){ 269 | const ar = arsvalues[1] 270 | const height=Math.max(100,this.value) 271 | const width=Math.floor(height*ar) 272 | resize(width,height) 273 | } 274 | ///////////////// 275 | 276 | return html` 277 | ${section( 278 | 'composition', 279 | 235, 280 | $selection, //signal with active sectioname, that opens/closes section 281 | adj, //section's params obj of which $skip field will be set on/off 282 | null, //called when section is enabled/disabled 283 | resetComposition, //section name provided to onReset 284 | ()=>html` 285 | 298 | 299 | 300 |
301 |
302 | rotation 303 | 304 | 305 | 306 |
307 | 308 | 309 | 310 | 311 | 312 |
313 |
314 |
crop ratio
315 |
316 | ${ars.map((e,i)=>html` 317 | 318 | `)} 319 |
320 |
321 |
image size
322 |
323 |
(${()=>resizeperc.value+'%'})
324 | 325 | x 326 | 327 |
328 | 329 | ${()=>persp.value && html`${Quad(canvas,persp.value,()=>{updateResetBtn();onUpdate()})}`} 330 | 331 | `)} 332 | 333 | 334 | 335 | ` 336 | } 337 | -------------------------------------------------------------------------------- /src/_curves.js: -------------------------------------------------------------------------------- 1 | import { html, reactive } from '@xdadda/mini' 2 | import CC from './components/colorcurve.js' 3 | 4 | import section from './__section.js' 5 | 6 | export default function curves($selection, _params, onUpdate){ 7 | let params=_params.curve 8 | 9 | ///// COLOR CURVE 10 | let curve = { 11 | space:0, //0=all, 1=R, 2=G, 3=B, 12 | numpoints:5, 13 | curvepoints: _params.curve?.curvepoints || null, //[ [[x,y],...], null, null, null ] 14 | modifiedflag: null, //for each colorspace indicates if curve is reset/linear 15 | resetFn: null, 16 | } 17 | 18 | reactive(()=>{ 19 | if($selection.value===null) { 20 | if(_params.curve?.curvepoints) { 21 | params=_params.curve 22 | setCurve(params.curvepoints,[true,true,true,true]) 23 | } 24 | //updateResetBtn() 25 | } 26 | },{effect:true}) 27 | 28 | 29 | function setCurve(_curvepoints, _curvemodified){ 30 | //just record modified arrays 31 | curve.curvepoints = _curvemodified.map((e,i)=>e&&_curvepoints[i]) 32 | if(curve.curvepoints.reduce((p,v)=>p+=v,0)===0) params.curvepoints=0 //if all null 33 | else params.curvepoints=curve.curvepoints 34 | updateResetBtn() 35 | onUpdate() 36 | } 37 | 38 | function resetCurve(){ 39 | if(curve.resetFn) curve.resetFn() 40 | } 41 | 42 | function updateResetBtn(){ 43 | const el=document.getElementById('btn_reset_curve') 44 | if(!el) return 45 | if(curve?.curvepoints?.reduce((p,v)=>p+=v,0) === 0){ 46 | btn_reset_curve?.setAttribute('disabled',true) 47 | } 48 | else btn_reset_curve?.removeAttribute('disabled') 49 | } 50 | ///////////////// 51 | 52 | 53 | return html` 54 | ${section( 55 | 'curve', 56 | 190, //height 57 | $selection, 58 | _params, 59 | onUpdate, 60 | resetCurve, 61 | ()=>html`
62 | ${()=>CC(curve,setCurve)} 63 |
` 64 | )} 65 | ` 66 | } 67 | -------------------------------------------------------------------------------- /src/_filters.js: -------------------------------------------------------------------------------- 1 | import { html, reactive } from '@xdadda/mini' 2 | import icon_shutter_rotate from './assets/icon_shutter_rotate.svg?raw' 3 | 4 | import section from './__section.js' 5 | 6 | 7 | const filtersLUT = [ 8 | {type:'1',label:'aden', map1: async()=> import('./assets/LUT/LUT_aden.png')}, 9 | {type:'1',label:'crema', map1: async()=> import('./assets/LUT/LUT_crema.png')}, 10 | {type:'2',label:'clarendon', map1: async()=> import('./assets/LUT/LUT_clarendon1.png'), map2: async()=> import('./assets/LUT/LUT_clarendon2.png')}, 11 | {type:'3',label:'gingham', map1: async()=> import('./assets/LUT/LUT_gingham1.png'), map2: async()=> import('./assets/LUT/LUT_gingham_lgg.png')}, 12 | {type:'1',label:'juno', map1: async()=> import('./assets/LUT/LUT_juno.png')}, 13 | {type:'1',label:'lark', map1: async()=> import('./assets/LUT/LUT_lark.png')}, 14 | {type:'1',label:'ludwig', map1: async()=> import('./assets/LUT/LUT_ludwig.png')}, 15 | {type:'4',label:'moon', map1: async()=> import('./assets/LUT/LUT_moon1.png'), map2: async()=> import('./assets/LUT/LUT_moon2.png')}, 16 | {type:'1',label:'reyes', map1: async()=> import('./assets/LUT/LUT_reyes.png')}, 17 | {type:'MTX',label:'polaroid', mtx: 'polaroid'}, 18 | {type:'MTX',label:'kodak', mtx: 'kodachrome'}, 19 | {type:'MTX',label:'browni', mtx: 'browni'}, 20 | {type:'MTX',label:'vintage', mtx: 'vintage'}, 21 | ] 22 | 23 | async function loadFilterLUT(url){ 24 | const img = new Image(); 25 | img.src = url; 26 | await img.decode(); 27 | return img 28 | } 29 | 30 | export default function filters($selection, _params, onUpdate){ 31 | const params=_params.filters 32 | 33 | let selected=reactive(false) 34 | 35 | reactive(async()=>{ 36 | if($selection.value===null) { 37 | //load from recipe 38 | if(_params.filters?.label) { 39 | const idx = filtersLUT.findIndex(e=>e.label===_params.filters.label) 40 | selectFilter(idx) 41 | } 42 | } 43 | },{effect:true}) 44 | 45 | 46 | async function setFilter(idx){ 47 | const loader = document.getElementById('loader') 48 | let t 49 | if(loader) setTimeout(()=>loader.style.display='',20) //show loader only if it's taking more than 20ms 50 | const _f=filtersLUT[parseInt(idx)] 51 | if(_f.map1 && typeof _f.map1==='function') _f.map1=await loadFilterLUT((await _f.map1()).default) 52 | if(_f.map2 && typeof _f.map2==='function') _f.map2=await loadFilterLUT((await _f.map2()).default) 53 | //await new Promise(r => setTimeout(r,1000)) 54 | const {type,mtx,map1,map2,label} = _f 55 | params.opt={type,mtx,map1,map2,label} 56 | if(t) clearTimeout(t) 57 | if(loader) loader.style.display='none' 58 | } 59 | 60 | async function selectFilter(idx){ 61 | if(selected.value!==idx){ 62 | //select 63 | selected.value=idx 64 | btn_reset_filters?.removeAttribute('disabled') 65 | await setFilter(idx) 66 | onUpdate() 67 | } 68 | else { 69 | //deselect 70 | resetFilters() 71 | } 72 | } 73 | 74 | function resetFilters(){ 75 | btn_reset_filters?.setAttribute('disabled',true) 76 | selected.value=false 77 | params.opt=0 78 | onUpdate() 79 | } 80 | 81 | return html` 82 | 83 | 84 | 85 | ${section( 86 | 'filters', //section name 87 | 235, //section height 88 | $selection, 89 | _params, 90 | onUpdate, 91 | resetFilters, 92 | ()=>html` 93 | ${filtersLUT.map((f,idx)=>html` 94 | 95 | `)} 96 | `)} 97 | ` 98 | 99 | } 100 | 101 | 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /src/_perspective.js: -------------------------------------------------------------------------------- 1 | import { html, reactive, onMount } from '@xdadda/mini' 2 | import section from './__section.js' 3 | import Perspective from './components/perspective2.js' 4 | 5 | 6 | export default function _perspective($selection, params, onUpdate){ 7 | let prevselection 8 | 9 | reactive(()=>{ 10 | 11 | if($selection.value==='perspective2'){ 12 | prevselection=$selection.value 13 | //Do something 14 | //center canvas 15 | } 16 | else if(prevselection==='perspective2') { 17 | //Do something else 18 | } 19 | },{effect:true}) 20 | 21 | 22 | ///// SECTION HANDLING FN //////// 23 | function checkParamszero(section) { 24 | return Object.values(params[section]).reduce((p,v)=>p+=v,0)===0 25 | } 26 | function resetParamsToZero(section) { 27 | for (const key of Object.keys(params[section])) { 28 | params[section][key]=0 29 | updateParamCtrl(section+'_'+key) 30 | } 31 | } 32 | function resetSection(section){ 33 | resetParamsToZero(section) 34 | onUpdate() 35 | updateResetBtn(section) 36 | } 37 | function updateResetBtn(section){ 38 | const el=document.getElementById('btn_reset_'+section) 39 | if(!el) return 40 | if(checkParamszero(section)) el.setAttribute('disabled',true) 41 | else el.removeAttribute('disabled') 42 | } 43 | ///////////////// 44 | 45 | ///// PERSPECTIVE v2 46 | let persp2=reactive(false) 47 | //let perspel 48 | async function showPerspective2(){ 49 | console.log('show',params.perspective2) 50 | persp2.value=params.perspective2 51 | } 52 | function hidePerspective2(){ 53 | persp2.value=false 54 | } 55 | function togglePerspective2(){ 56 | console.log('toggle') 57 | if(persp2.value) hidePerspective2() 58 | else showPerspective2() 59 | } 60 | function lockPerspective2(){ 61 | console.log('lock',params.perspective2) 62 | if(!params.perspective2.before) return 63 | params.perspective2.after=0 64 | persp2.value=false 65 | persp2.value=params.perspective2 66 | } 67 | function unlockPerspective2(){ 68 | params.perspective2.before=0 69 | params.perspective2.after=0 70 | persp2.value=false 71 | persp2.value=params.perspective2 72 | } 73 | ///////////////// 74 | 75 | 76 | return html` 77 | ${section( 78 | 'perspective2', 79 | 150, 80 | $selection, //signal with active sectioname, that opens/closes section 81 | params, //section's params obj of which $skip field will be set on/off 82 | null, //called when section is enabled/disabled 83 | resetSection, //section name provided to onReset 84 | ()=>html` 85 | 88 | 89 | 90 | 91 |
92 | /* PERSPECTIVE V2 */ 93 | Perspective 94 | 95 | ${()=>persp2.value && html`${Perspective(canvas,persp2.value,()=>{updateResetBtn();onUpdate()})}`} 96 | 97 | 98 | `)} 99 | 100 | 101 | 102 | ` 103 | } 104 | -------------------------------------------------------------------------------- /src/_recipes.js: -------------------------------------------------------------------------------- 1 | import { html, reactive } from '@xdadda/mini' 2 | import { confirm } from '@xdadda/mini/components' 3 | import section from './__section.js' 4 | import {openFile, downloadFile} from './js/tools.js' 5 | 6 | export default function recipes($selection, params, onUpdate){ 7 | 8 | let save_btn_disabled=true 9 | 10 | reactive(()=>{ 11 | if($selection.value==="recipes"){ 12 | const recipe=buildRecipe() 13 | if(Object.keys(recipe).length) save_btn_disabled=false 14 | else save_btn_disabled=true 15 | } 16 | },{effect:true}) 17 | ///////////////////////////// 18 | 19 | function buildRecipe(){ 20 | //const recipe = {} 21 | const recipe = {} 22 | const list = ['colors','curve','lights','effects'].forEach(e=>{ 23 | const x = Object.keys(params[e]).reduce((p,v)=>{ if(params[e][v]) p[v]=params[e][v];return p }, {}) 24 | if(Object.keys(x).length) recipe[e]=x 25 | }) 26 | 27 | if(params.blur.bokehstrength || params.blur.gaussianstrength) recipe.blur=params.blur 28 | if(params.filters?.opt?.label) recipe.filters=params.filters.opt.label 29 | return recipe 30 | } 31 | 32 | async function saveRecipe(){ 33 | 34 | const recipe=buildRecipe() 35 | if(!Object.keys(recipe).length) return 36 | 37 | const newfilename=reactive('recipe_'+new Date().toISOString().split('T')[0]+'.json') 38 | 39 | const resp = await confirm(()=>html` 40 |
41 |
Download recipe
42 | 43 |
44 |
45 | 46 |
47 |
48 |
`) 49 | if(!resp) return 50 | 51 | const bytes = new TextEncoder().encode(JSON.stringify(recipe)); 52 | const blob = new Blob([bytes], { 53 | type: "application/json;charset=utf-8" 54 | }); 55 | downloadFile(blob,newfilename.value) 56 | } 57 | 58 | async function loadRecipe(){ 59 | const f = await openFile('application/json') 60 | if(!f) return 61 | const reader= new FileReader() 62 | await new Promise(r=> reader.onload=r, reader.readAsText(f)) 63 | 64 | const json = JSON.parse(reader.result) 65 | 66 | const list = ['colors','curve','lights','effects','blur'].forEach(e=>{ 67 | if(json[e]) params[e] = {...params[e],...json[e]} 68 | }) 69 | if(json.filters) params.filters.label=json.filters 70 | $selection.value=null 71 | onUpdate() 72 | } 73 | 74 | 75 | return html` 76 | ${section( 77 | 'recipes', 78 | 125, 79 | $selection, //signal with active sectioname, that opens/closes section 80 | params, //section's params obj of which $skip field will be set on/off 81 | null, //called when section is enabled/disabled; null to hide disable button 82 | null, //section name provided to onReset 83 | ()=>html` 84 |
85 | 86 | 87 |
88 |
will save: lights, colors, effects, curve, filters and blur
89 | `)} 90 | ` 91 | } 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | .minieditor .app { 2 | position: absolute; 3 | overflow: hidden; 4 | top:0; 5 | left:0; 6 | height: calc(100dvh - env(safe-area-inset-bottom)); 7 | width: 100vw; 8 | bottom:0; 9 | 10 | display: flex; 11 | flex-direction: column; 12 | overscroll-behavior: contain; /* required to fix broken ios scroll in fixed element https://stackoverflow.com/questions/64344560/ios-safari-scrolling-is-broken-inside-position-fixed-elements */ 13 | text-align: center; 14 | 15 | color: inherit; 16 | background-color: inherit; 17 | user-select: none; 18 | touch-action: none; 19 | 20 | .header, .footer{ 21 | flex: 0 0 auto; 22 | height: 38px; 23 | line-height: 38px; 24 | z-index: 2; 25 | text-overflow: ellipsis; 26 | white-space: nowrap; 27 | overflow: auto hidden; 28 | display: flex; 29 | justify-content: space-between; 30 | padding-left: env(safe-area-inset-left); 31 | padding-right: env(safe-area-inset-right); 32 | backdrop-filter: unset; 33 | } 34 | .header { 35 | padding-top: env(safe-area-inset-top); 36 | mask: linear-gradient(to bottom, rgba(0,0,0,1) 90%, rgba(0,0,0,0)); 37 | } 38 | .footer { 39 | height:30px; 40 | /*padding-bottom: env(safe-area-inset-bottom);*/ 41 | mask: linear-gradient(to top, rgba(0,0,0,1) 90%, rgba(0,0,0,0)); 42 | align-items: center; 43 | justify-content: flex-end; 44 | } 45 | .main { 46 | flex: 1 1 auto; 47 | overflow: hidden auto; 48 | display: flex; 49 | flex-direction: column; 50 | align-items: center; 51 | } 52 | 53 | .btn_theme { 54 | position:absolute; 55 | top: env(safe-area-inset-top); 56 | right:env(safe-area-inset-right); 57 | z-index:999; 58 | width:50px; 59 | } 60 | .btn_fullscreen { 61 | position:absolute; 62 | top: env(safe-area-inset-top); 63 | right:calc(env(safe-area-inset-right) + 30px); 64 | z-index:999; 65 | width:50px; 66 | } 67 | .btn_theme:hover,.btn_fullscreen:hover{ 68 | filter:brightness(0.8); 69 | } 70 | 71 | @media (orientation: portrait) { 72 | .footer { 73 | height: 1px; 74 | opacity: 0; 75 | } 76 | } 77 | } 78 | 79 | @media (orientation: portrait) { 80 | .alert-message .msg { 81 | max-width: 90vw !important; 82 | } 83 | } 84 | 85 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | 2 | import { html, reactive, onMount, onUnmount} from '@xdadda/mini' 3 | import { alert } from '@xdadda/mini/components' 4 | //import '@xdadda/mini/components.css' 5 | import store from '@xdadda/mini/store' 6 | import './app.css' 7 | import './editor.css' 8 | 9 | import miniExif from '@xdadda/mini-exif' 10 | import { minigl} from '@xdadda/mini-gl' 11 | import logo from './assets/icon.png' 12 | import github from './assets/icon_github.png' 13 | import icon_split from './assets/icon_split.svg?raw' 14 | import icon_histo from './assets/icon_histo.svg?raw' 15 | import icon_info from './assets/icon_info.svg?raw' 16 | 17 | import { zoom_pan } from './js/zoom_pan.js' 18 | import { readImage, downloadFile, filesizeString } from './js/tools.js' 19 | 20 | import ThemeToggle from './components/themetoggle.js' 21 | import FullScreen from './components/fullscreen.js' 22 | 23 | 24 | import Histogram from './components/histogram.js' 25 | import GPSMap from './components/gpsmap.js' 26 | import Cropper from './components/cropper.js' 27 | import SplitView from './components/splitview.js' 28 | import clickdropFile from './components/clickdropFile.js' 29 | import downloadImage from './components/downloadImage.js' 30 | 31 | 32 | import composition from './_composition.js' 33 | //import perspective from './_perspective.js' 34 | import adjustments from './_adjustments.js' 35 | import curves from './_curves.js' 36 | import filters from './_filters.js' 37 | import blender from './_blender.js' 38 | import blur from './_blur.js' 39 | import recipes from './_recipes.js' 40 | 41 | const initstate = { 42 | appname:'MiNi PhotoEditor', 43 | } 44 | 45 | 46 | 47 | ////////////////////////////////////////////////// 48 | //keep this function pure 49 | export function centerCanvas(){ 50 | const canvas = document.getElementById("canvas") 51 | const editor = document.getElementById("editor") 52 | //little trick to keep the canvas centered in container 53 | const canvasAR = canvas.width/canvas.height 54 | if(editor.offsetWidth/canvasAR > editor.offsetHeight) { 55 | canvas.style.height='99%' 56 | canvas.style.width='' 57 | } 58 | else { 59 | canvas.style.width='99%' 60 | canvas.style.height='' 61 | } 62 | 63 | //reset canvas position 64 | zoomable.style.transform='' 65 | pannable.style.transform='' 66 | } 67 | ////////////////////////////////////////////////// 68 | /* 69 | input = { 70 | "url": "http://127.0.0.1:5170/api/$streamfile?file=%2Fexif.png", 71 | "name": "exif.png" 72 | } 73 | */ 74 | export function Editor(input=false){ 75 | store(initstate) 76 | let sample=true 77 | if(input?.sample===false) sample=false 78 | 79 | let _exif, _minigl, zp 80 | const $file = reactive(false) 81 | const $canvas = reactive() 82 | 83 | let params={ 84 | trs: { translateX:0, translateY:0, angle:0, scale:0, flipv:0, fliph:0}, 85 | crop: {currentcrop:0, glcrop:0, canvas_angle:0, ar:0, arindex:0}, 86 | lights: { brightness:0, exposure:0, gamma:0, contrast:0, shadows:0, highlights:0, bloom:0, }, 87 | colors: { temperature:0, tint:0, vibrance:0, saturation:0, sepia:0, }, 88 | effects: { clarity:0, noise:0, vignette:0, }, 89 | curve: {curvepoints: 0}, 90 | filters: { opt:0, mix:0 }, 91 | perspective: {quad:0, modified:0}, 92 | perspective2: {before:0, after:0, modified:0}, 93 | blender: {blendmap:0, blendmix:0.5}, 94 | resizer: {width:0, height:0}, 95 | blur: { bokehstrength:0, bokehlensout:0.5, gaussianstrength:0, gaussianlensout:0.5, centerX:0.5, centerY:0.5}, 96 | } 97 | 98 | ///// INPUT/SAVE FUNCTIONs (for future integrations) 99 | //@data: Image, Blob, ArrayBuffer, url it's an image feeded programmatically 100 | async function openInput(data, name){ 101 | if(!data) return 102 | try { 103 | let arrayBuffer, blob, img, info={name} 104 | if(typeof data === 'string' && data.startsWith('http')) { 105 | const resp = await fetch(data) 106 | if(resp.status!==200) return console.error(await resp.json()) 107 | arrayBuffer = await resp.arrayBuffer() 108 | } 109 | else if(data instanceof Image) { 110 | const resp = await fetch(data.src) 111 | if(resp.error) return console.error(resp.error) 112 | arrayBuffer = await resp.arrayBuffer() 113 | img=data 114 | } 115 | else if(data instanceof ArrayBuffer) { 116 | arrayBuffer = data 117 | } 118 | else if(data instanceof Blob) { 119 | blob=data 120 | arrayBuffer = await data.arrayBuffer() 121 | } 122 | else return console.error('Unknown data type') 123 | info.size=arrayBuffer.byteLength 124 | 125 | if(!blob) blob = new Blob( [ arrayBuffer ] ) 126 | if(!img) { 127 | img = new Image(); 128 | img.src=URL.createObjectURL(blob) 129 | await img.decode(); 130 | } 131 | onImageLoaded(arrayBuffer, info, img) 132 | } 133 | catch(e){ 134 | console.error(e) 135 | await alert(`
${e}
`) 136 | history.back() 137 | } 138 | } 139 | if(input?.data) openInput(input.data,input.name) 140 | ///////////////// 141 | 142 | ///// SETUP 143 | 144 | async function onImageLoaded(arrayBuffer, filedata, img){ 145 | if($file._value) resetAll() 146 | try{ 147 | _exif=await miniExif(arrayBuffer) 148 | } 149 | catch(e){console.error(e)} 150 | 151 | let meta=_exif?.read() 152 | if(!meta) meta={} 153 | //const parser = new DOMParser(); 154 | //if(meta.xml) meta.xml = parser.parseFromString(meta.xml.slice(meta.xml.indexOf('<')), 'application/xml'); 155 | if(meta.xml) { 156 | meta.xml = meta.xml.slice(meta.xml.indexOf('<')).replace(/ +(?= )/g,'').replace(/\r\n|\n|\r/gm,'') 157 | } 158 | meta.file = {...filedata, hsize:filesizeString(filedata.size), width:img?.width || img?.videoWidth || '-', height:img?.height || img?.videoHeight || '-'}; 159 | meta.img = img; 160 | meta.colorspace = meta.icc?.ColorProfile?.[0].includes("P3") ? "display-p3" : "srgb" 161 | //if(meta.format==='JXL') meta.colorspace="display-p3" 162 | console.log('metadata',{...meta}); 163 | $file.value=meta; 164 | } 165 | 166 | function resetAll(){ 167 | $selection.value=null 168 | hideHisto() 169 | hideSplitView() 170 | splitwidth=0.5 171 | for(const s in params){ 172 | for(const v in params[s]) params[s][v]=0 173 | } 174 | } 175 | 176 | //SETUP canvas and initiate minigl 177 | reactive(()=>{ 178 | if($canvas.value){ 179 | const meta = $file._value 180 | try { 181 | if(_minigl) _minigl.destroy() 182 | _minigl = minigl(document.getElementById("canvas"), meta.img, meta.colorspace) 183 | 184 | //setup zoom&pan for canvas 185 | if(zp) zp() //clean previous events 186 | zp = zoom_pan(zoomable,pannable) 187 | 188 | updateGL() 189 | centerCanvas() 190 | } 191 | catch(e){console.error(e)} 192 | } 193 | },{effect:true}) 194 | ///////////////// 195 | 196 | ///// RUN GL PIPELINE 197 | 198 | async function updateGL(){ 199 | //load image's texture 200 | _minigl.loadImage() 201 | 202 | if(params.perspective2.after) { 203 | let before = params.perspective2.before.map(e=>[(e[0]*canvas.width),(e[1]*canvas.height)]) 204 | let after = params.perspective2.after.map(e=>[(e[0]*canvas.width),(e[1]*canvas.height)]) 205 | _minigl.filterPerspective(before,after, false, false) 206 | } 207 | 208 | 209 | // TRANSLATE/ROTATE/SCALE filter 210 | if(cropping || params.crop.glcrop){ 211 | 212 | 213 | params.trs.angle+=params.crop.canvas_angle 214 | _minigl.filterMatrix(params.trs) 215 | params.trs.angle-=params.crop.canvas_angle 216 | 217 | // PERSPECTIVE correction 218 | if(params.perspective.quad) { 219 | let before=[[0.25,0.25], [0.75,0.25], [0.75,0.75],[0.25,0.75]] 220 | before = before.map(e=>[(e[0]*canvas.width),(e[1]*canvas.height)]) 221 | let after = (params.perspective.quad).map(e=>[(e[0]*canvas.width),(e[1]*canvas.height)]) 222 | _minigl.filterPerspective(before,after, false, false) 223 | } 224 | 225 | 226 | } 227 | 228 | // RUN CROP when set (crop image after TRS but before other filters) 229 | if(params.crop.glcrop) { 230 | _minigl.crop(params.crop.glcrop) 231 | params.crop.glcrop=0 232 | return updateGL() 233 | } 234 | 235 | //blend here so that following filters apply to both images 236 | if(!params.blender.$skip && params.blender.blendmap) _minigl.filterBlend(params.blender.blendmap,params.blender.blendmix) 237 | 238 | /////////// adjustment filters 239 | let adjparams = {} 240 | if(!params.lights.$skip) adjparams = {...adjparams, ...params.lights} 241 | if(!params.colors.$skip) adjparams = {...adjparams, ...params.colors} 242 | if(!params.effects.$skip) adjparams = {...adjparams, ...params.effects} 243 | _minigl.filterAdjustments({...adjparams}) 244 | 245 | if(adjparams.bloom) _minigl.filterBloom(adjparams.bloom) 246 | if(adjparams.noise) _minigl.filterNoise(adjparams.noise) 247 | if(adjparams.shadows||adjparams.highlights) _minigl.filterHighlightsShadows(adjparams.highlights||0,-adjparams.shadows||0) 248 | /////////// 249 | 250 | if(!params.curve.$skip && params.curve.curvepoints) _minigl.filterCurves(params.curve.curvepoints) 251 | if(!params.filters.$skip && params.filters.opt) _minigl.filterInsta(params.filters.opt,params.filters.mix) 252 | 253 | if(!params.blur.$skip && params.blur.bokehstrength) { 254 | _minigl.filterBlurBokeh(params.blur) 255 | } 256 | if(!params.blur.$skip && params.blur.gaussianstrength) { 257 | params.blur.gaussianlensout = params.blur.bokehlensout 258 | _minigl.filterBlurGaussian(params.blur) 259 | } 260 | 261 | //draw to canvas 262 | _minigl.paintCanvas(); 263 | 264 | if(updateHistogram) updateHistogram() 265 | } 266 | ///////////////// 267 | 268 | ///// CENTER CANVAS 269 | function canvas_dblclick(e){ 270 | e?.preventDefault() 271 | centerCanvas() 272 | } 273 | 274 | let lastclick=0 275 | function canvas_click(e){ 276 | e.preventDefault() 277 | //mobile can't intercept double-tap as a double click ... handle it here with a timer! yuk 278 | if(lastclick && (Date.now()-lastclick)<200) return canvas_dblclick(e) 279 | lastclick=Date.now() 280 | } 281 | function sidebar_click(e){ 282 | e.preventDefault() 283 | $selection.value='' 284 | } 285 | ///////////////// 286 | 287 | ///// INFO 288 | async function showInfo(e){ 289 | e?.stopPropagation() 290 | const meta = $file.value 291 | await alert(()=>html` 292 |
293 |
FILE
294 |
name: ${meta.file.name}
295 |
size: ${meta.file.width} x ${meta.file.height} (${meta.file.hsize})
296 |
date: ${meta.exif?.DateTimeOriginal?.value || new Date(meta.file.lastModified).toLocaleString('en-UK')}
297 |
prof: ${meta.colorspace}
298 | 299 | ${meta.tiff && html`
TIFF
`} 300 | ${meta.tiff && Object.entries(meta.tiff) 301 | .sort((a,b)=>a[0]?.toString().localeCompare(b[0]?.toString())) 302 | .map(e=>html` 303 |
${e[0]}: ${e[1].hvalue || e[1].value}
304 | `)} 305 | 306 | ${meta.gps && html`
GPS
`} 307 | ${()=>meta.gps&&GPSMap([meta.gps.GPSLongitude.hvalue,meta.gps.GPSLatitude.hvalue])} 308 | 309 | ${meta.exif && html`
EXIF
`} 310 | ${meta.exif && Object.entries(meta.exif) 311 | .sort((a,b)=>a[0]?.toString().localeCompare(b[0]?.toString())) 312 | .map(e=>html` 313 |
${e[0]}: ${e[1].hvalue || e[1].value}
314 | `)} 315 | 316 |
` 317 | ,400) 318 | } 319 | ///////////////// 320 | 321 | ///// SELECTION 322 | const $selection = reactive() 323 | let currentselection=null 324 | 325 | //reactive effect to handle selection changes 326 | reactive(()=>{ 327 | 328 | //disable-enable UI elements when cropping 329 | if($selection.value==='composition') onshowCrop() 330 | else if(currentselection==='composition') onhideCrop() 331 | 332 | currentselection=$selection.value 333 | 334 | },{effect:true}) 335 | ///////////////// 336 | 337 | ///// CROP 338 | let cropping=false 339 | function onshowCrop(){ 340 | hideSplitView() 341 | hideHisto() 342 | centerCanvas() 343 | cropping=true 344 | btn_info.setAttribute('disabled',true) 345 | btn_histo.setAttribute('disabled',true) 346 | btn_split.setAttribute('disabled',true) 347 | if(zp) zp() //DISABLE ZOOMPAN 348 | } 349 | 350 | function onhideCrop(){ 351 | cropping=false 352 | btn_info.removeAttribute('disabled') 353 | btn_histo.removeAttribute('disabled') 354 | btn_split.removeAttribute('disabled') 355 | zp = zoom_pan(zoomable,pannable) //ENABLE ZOOMPAN 356 | } 357 | 358 | function onCropUpdate(){ 359 | ////this is a UI hack, need to change a button inside Composition component ... sorry 360 | //toggle btn_reset_comp 361 | if(Object.values(params.trs).reduce((p,v)=>p+=v,0)===0 && Object.values(params.crop).reduce((p,v)=>p+=v,0)===0 && params.perspective.modified==0 && params.resizer.width===0) btn_reset_composition.setAttribute('disabled',true) 362 | else btn_reset_composition.removeAttribute('disabled') 363 | } 364 | ///////////////// 365 | 366 | ///// HISTOGRAM 367 | let updateHistogram //will receive the "updateHisto" function from the component 368 | const $showhisto=reactive(false) 369 | 370 | function toggleHisto(e){ 371 | e?.stopPropagation() 372 | if(cropping) return 373 | if($showhisto.value) $showhisto.value=false 374 | else $showhisto.value=true 375 | } 376 | function hideHisto(){ 377 | $showhisto.value=false 378 | } 379 | ///////////////// 380 | 381 | ///// SPLIT VIEW 382 | let splitwidth, splitimage 383 | const $showsplit = reactive(false) 384 | 385 | function hideSplitView(){ 386 | $showsplit.value = false 387 | } 388 | function toggleSplitView(e){ 389 | e?.stopPropagation() 390 | if(cropping) return 391 | if($showsplit._value) $showsplit.value = false 392 | else { 393 | splitimage = _minigl.img_cropped || _minigl.img 394 | $showsplit.value = true 395 | } 396 | } 397 | function onSplitUpdate(sw){ 398 | splitwidth=sw 399 | } 400 | ///////////////// 401 | 402 | ///// SAMPLE IMAGES 403 | async function samples(){ 404 | await alert((handleClose)=>html` 405 |
406 | 407 | 408 | 409 | 410 |
411 | `,460) 412 | } 413 | 414 | function openSample(){ 415 | openInput(this.src, this.id) 416 | //quick hack to close the samples window 417 | root.lastElementChild.remove() 418 | } 419 | ///////////////// 420 | 421 | 422 | return html` 423 |
424 |
425 | 426 | 427 | /******** LOADING PAGE ********/ 428 | ${()=>(!$file.value && !input.data) && html` 429 |
430 | logo 431 |

${store('appname')}

432 |
433 | ${clickdropFile('click or drop
to load file','image/*',(file)=>readImage(file, onImageLoaded),'height: 120px;')} 434 | ${sample && html``} 435 |
436 |
100% private and offline!
100% free and opensource
437 |
438 | `} 439 | 440 | /******** IMGEDITOR PAGE ********/ 441 | ${()=>$file.value && html` 442 | 443 | ${!input ? html` 444 |
445 | 448 |
449 |
450 |
${()=>FullScreen(null)}
451 |
${()=>ThemeToggle('dark',true)}
452 |
453 |
454 | ` : `
`} 455 | 456 |
457 | 458 |
459 | 460 |
461 |
462 |
463 | 464 | /******** PAINT CANVAS *******/ 465 | 466 | 467 | /******** SPLIT VIEW *******/ 468 | ${()=>$showsplit.value && SplitView(splitimage,canvas.style.width,canvas.style.height,splitwidth,onSplitUpdate)} 469 | 470 | /******** CROP CANVAS *******/ 471 | ${()=>$selection.value==='composition' && Cropper(canvas, params, onCropUpdate)} 472 | 473 | 474 |
475 |
476 |
477 | 478 | 525 |
526 | 527 | /******** HISTOGRAM *******/ 528 | ${()=>$showhisto.value && Histogram($file._value.colorspace, (fn)=>{updateHistogram=fn;updateGL();})} 529 | 530 |
531 | `} 532 | 533 | 536 | 537 |
538 |
539 | ` 540 | } 541 | 542 | /* 543 | ${perspective($selection, params, updateGL)} 544 | */ 545 | 546 | 547 | 548 | 549 | 550 | -------------------------------------------------------------------------------- /src/assets/LUT/LUT_1977.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_1977.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_aden.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_aden.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_amaro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_amaro.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_clarendon1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_clarendon1.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_clarendon2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_clarendon2.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_crema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_crema.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_gingham1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_gingham1.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_gingham_lgg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_gingham_lgg.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_juno.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_juno.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_lark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_lark.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_ludwig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_ludwig.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_moon1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_moon1.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_moon2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_moon2.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_perpetua.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_perpetua.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_perpetua_overlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_perpetua_overlay.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_reyes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_reyes.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_slumber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_slumber.png -------------------------------------------------------------------------------- /src/assets/LUT/LUT_xpro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/LUT/LUT_xpro.png -------------------------------------------------------------------------------- /src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/icon.png -------------------------------------------------------------------------------- /src/assets/icon_flip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/icon_github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xdadda/mini-photo-editor/f2931b7b22e085cd7cc36594accd032134e5db96/src/assets/icon_github.png -------------------------------------------------------------------------------- /src/assets/icon_histo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/icon_info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/icon_rotate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icon_shutter_rotate.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icon_skew.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icon_split.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/info-circle-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/split-v-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/ui_downarrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/components/canvasmouse.js: -------------------------------------------------------------------------------- 1 | import { html, onMount, onUnmount} from '@xdadda/mini' 2 | import {debounce} from '../js/tools.js' 3 | 4 | //el: element to "cover" with mouse grid 5 | //initpoints: Array of points to map [[pt1_x,pt1_y],[...]] 6 | export default function canvasMouse(el, initpoints, onUpdate, onReset){ 7 | 8 | const pointsize=45 9 | let points = initpoints.slice(0) // [[0.25,0.25], [0.75,0.25], [0.75,0.75],[0.25,0.75]] 10 | 11 | //position mousecontainer over element 12 | const {top,left}=el.getBoundingClientRect() 13 | let w = el.offsetWidth, 14 | h = el.offsetHeight 15 | let ctx, offset 16 | 17 | let dragging=false 18 | let pointselected 19 | 20 | 21 | onMount(()=>{ 22 | mousecontainer.addEventListener("pointerdown", dragstart); 23 | ctx = mousecanvas.getContext('2d'); //if we want to draw on it 24 | draw() 25 | }) 26 | 27 | onUnmount(()=>{ 28 | mousecontainer.removeEventListener("pointerdown", dragstart); 29 | }) 30 | 31 | function clamp(min,val,max){ 32 | return Math.max(min, Math.min(max, val)); 33 | } 34 | function mousePos(e){ 35 | //limit point movements 36 | var x = clamp(0,(e.offsetX) / w,1) 37 | var y = clamp(0,(e.offsetY) / h,1) 38 | points[pointselected] = [x,y] 39 | } 40 | 41 | 42 | function dragstop(e){ 43 | dragging=false 44 | pointselected=undefined 45 | mousecontainer.releasePointerCapture(e.pointerId) 46 | mousecontainer.removeEventListener("pointermove", dragmove); 47 | mousecontainer.removeEventListener("pointerup", dragstop); 48 | 49 | } 50 | 51 | function dragstart(e){ 52 | dragging=true 53 | const el = document.elementFromPoint(e.x,e.y) 54 | if(el.id.startsWith('mouse')) { 55 | pointselected=parseInt(el.id.replace('mouse','')) 56 | } 57 | mousecontainer.setPointerCapture(e.pointerId) 58 | mousecontainer.addEventListener("pointermove", dragmove); 59 | mousecontainer.addEventListener("pointerup", dragstop); 60 | const {left,top} = el.getBoundingClientRect() 61 | offset = {left,top} 62 | mousePos(e) 63 | } 64 | 65 | function dragmove(e){ 66 | if(dragging && pointselected!==undefined){ 67 | mousePos(e) 68 | debounce('mouse',()=>draw(),20) 69 | } 70 | } 71 | 72 | function draw(){ 73 | if(!document.getElementById('mouse'+0)) return 74 | //position draggable points 75 | points.forEach((e,i)=>{ 76 | const pt = document.getElementById('mouse'+i) 77 | const left = e[0]*w-pt.offsetWidth/2+'px' 78 | const top = e[1]*h-pt.offsetHeight/2+'px' 79 | if(pt.style.left!==left) pt.style.left=left 80 | if(pt.style.top!==top) pt.style.top=top 81 | }) 82 | 83 | if(onUpdate) onUpdate(points,ctx) 84 | } 85 | 86 | function reset(){ 87 | if(onReset) points = onReset(points) 88 | else points = initpoints.slice(0) 89 | draw() 90 | } 91 | 92 | 93 | return html` 94 | 99 |
100 | 101 | ${points?.map((e,i)=>html` 102 |
103 | `)} 104 |
105 | ` 106 | } -------------------------------------------------------------------------------- /src/components/clickdropFile.js: -------------------------------------------------------------------------------- 1 | import { html } from '@xdadda/mini' 2 | import { alert } from '@xdadda/mini/components' 3 | 4 | import { openFile } from '../js/tools.js' 5 | 6 | 7 | //filetype es 'image/*' 8 | export default function clickdropFile(txt, filetype, onFileOpened, cstyle){ 9 | 10 | 11 | ///// FILE CLICK 12 | async function handleClick(){ 13 | try { 14 | const file = await openFile(filetype) 15 | if(!file) return 16 | onFileOpened(file) 17 | } 18 | catch(e){console.error(e)} 19 | } 20 | 21 | 22 | ///// FILE DRAG&DROP 23 | function dropHandler(ev) { 24 | ev.preventDefault(); 25 | const btn = ev.target; 26 | btn.style.borderColor=''; 27 | let file; 28 | if (ev.dataTransfer.items) { 29 | const item = ev.dataTransfer.items[0]; 30 | if(!item.type.match("^"+filetype.split(',').map(e=>'^'+e).join('|'))) 31 | return alert('unknown format'); 32 | file = item.getAsFile(); 33 | } else { 34 | file = ev.dataTransfer.files[0]; 35 | } 36 | onFileOpened(file); 37 | } 38 | function dragOverHandler(ev) { 39 | ev.preventDefault(); 40 | const btn = ev.target; 41 | if(!btn.style.borderColor) btn.style.borderColor='darkorange'; 42 | } 43 | function dragLeaveHandler(ev) { 44 | ev.preventDefault(); 45 | const btn = ev.target; 46 | if(btn.style.borderColor) btn.style.borderColor=''; 47 | } 48 | ///////////////// 49 | 50 | 51 | return html` 52 | 61 | 62 | ` 63 | } -------------------------------------------------------------------------------- /src/components/colorcurve.css: -------------------------------------------------------------------------------- 1 | #cccolors { 2 | margin: 10px auto auto auto; 3 | } 4 | .clrspace{ 5 | width:20px;height:20px;border-radius:50%; 6 | border: 1px solid; 7 | } 8 | .clrspace.selected{ 9 | border: 3px solid; 10 | } 11 | 12 | button:focus.clrspace.selected{ 13 | border-color: inherit; 14 | } -------------------------------------------------------------------------------- /src/components/colorcurve.js: -------------------------------------------------------------------------------- 1 | import { html, reactive, onMount, onUnmount} from '@xdadda/mini' 2 | import {Spline} from '@xdadda/mini-gl' 3 | import './colorcurve.css' 4 | import { debounce } from '../js/tools.js' 5 | 6 | export default function CC(curve, onUpdate){ 7 | 8 | const size=256 9 | const pointsize=45 10 | const numpoints = reactive(curve?.numpoints || 5) 11 | 12 | let colorspace = curve?.space || 0 //0=all, 1=R, 2=G, 3=B, 13 | let points = curve?.curvepoints || new Array(4).fill(null) //one for each colorspace 14 | 15 | //setup monitoring of which colorspace is modified or not 16 | let modified = new Array(4) 17 | points.forEach((e,i)=>{ 18 | if(!e) modified[i]=null 19 | else modified[i]=true 20 | }) 21 | 22 | function resetOne(space){ 23 | points[space]=[] 24 | for (let i=0; i{ 39 | curvecontainer.addEventListener("pointerdown", dragstart); 40 | w = curvescanvas.offsetWidth 41 | h = curvescanvas.offsetHeight 42 | c = curvescanvas.getContext('2d'); 43 | 44 | setColorSpace('space'+colorspace) 45 | 46 | curve.resetFn = ()=>{ 47 | //RESET EVERYTHING 48 | curve.space=0 49 | curve.curvepoints= null //[null,null,null,null] 50 | 51 | colorspace=0 52 | points=new Array(4).fill(null) 53 | setColorSpace('space'+colorspace) 54 | }; 55 | }) 56 | 57 | onUnmount(()=>{ 58 | curvecontainer.removeEventListener("pointerdown", dragstart); 59 | }) 60 | 61 | 62 | function setColorSpace(id){ 63 | id=typeof id==='string'?id:this?.id 64 | const el=document.getElementById(id) 65 | if(!el) return //console.log('cant find',id) 66 | cccolors.getElementsByClassName('selected')[0]?.classList.remove('selected'); 67 | el.classList.add('selected'); 68 | 69 | colorspace=parseInt(id.replace('space','')) 70 | curve.space=colorspace 71 | if(points[colorspace]) { 72 | draw() 73 | } 74 | else { 75 | resetOne(colorspace); 76 | draw() 77 | } 78 | } 79 | 80 | 81 | /// CORE DRAWING & DRAGGING FUNCTIONS 82 | 83 | let dragging=false 84 | let pointselected 85 | 86 | function dragstop(e){ 87 | dragging=false 88 | curvecontainer.releasePointerCapture(e.pointerId) 89 | curvecontainer.removeEventListener("pointermove", drag); 90 | curvecontainer.removeEventListener("pointerup", dragstop); 91 | pointselected=undefined 92 | } 93 | 94 | function dragstart(e){ 95 | dragging=true 96 | curvecontainer.setPointerCapture(e.pointerId) 97 | curvecontainer.addEventListener("pointermove", drag); 98 | curvecontainer.addEventListener("pointerup", dragstop); 99 | 100 | w = curvescanvas.offsetWidth 101 | h = curvescanvas.offsetHeight 102 | 103 | const el = document.elementFromPoint(e.x,e.y) 104 | if(el.id.startsWith('pt')) { 105 | pointselected=parseInt(el.id.replace('pt','')) 106 | } 107 | modified[colorspace]=true 108 | mousePos(e) 109 | } 110 | 111 | function clamp(min,val,max){ 112 | return Math.max(min, Math.min(max, val)); 113 | } 114 | function mousePos(e){ 115 | //limit point movements 116 | const minx = pointselected ? points[colorspace][pointselected-1][0]+0.1 : 0 117 | const maxx = pointselecteddraw(),20) 128 | } 129 | } 130 | 131 | 132 | function draw(){ 133 | if(!points?.[colorspace]) return 134 | //position draggable points 135 | points[colorspace].forEach((e,i)=>{ 136 | const pt = document.getElementById('pt'+i) 137 | pt.style.left=e[0]*w-pointsize/2+'px' 138 | pt.style.bottom=e[1]*h-pointsize/2+'px' 139 | }) 140 | 141 | //draw spline 142 | const xs = points[colorspace].map(e=>e[0]) 143 | const ys = points[colorspace].map(e=>e[1]) 144 | //const spline = new Spline(xs,ys); 145 | const spline = new Spline(points[colorspace]); 146 | 147 | let y //,curve=[] 148 | c.clearRect(0, 0, size, size); 149 | c.lineWidth = 4; 150 | c.strokeStyle = '#4B4947'; 151 | c.beginPath(); 152 | for (var i = 0; i < size; i++) { 153 | if(ixs[xs.length-1]*size) y=ys[ys.length-1] 155 | else y = clamp(0,spline.at(i / (size-1)),1) 156 | //curve.push(y) 157 | c.lineTo(i, (1 - y ) * size); 158 | } 159 | c.stroke(); 160 | c.fillStyle = 'white'; 161 | if(onUpdate) onUpdate(points,modified) 162 | } 163 | 164 | 165 | return html` 166 | 171 |
172 |
173 | 174 | 175 | 176 | 177 |
178 |
179 | 180 | ${()=>numpoints.value && points[colorspace]?.map((e,i)=>html` 181 |
182 | `)} 183 |
184 |
185 | ` 186 | } 187 | 188 | 189 | 190 | -------------------------------------------------------------------------------- /src/components/cropper.css: -------------------------------------------------------------------------------- 1 | #crop { 2 | position:absolute; 3 | } 4 | .cropcorner{ 5 | z-index: 2 !important; 6 | } 7 | 8 | #croprect { 9 | border: solid 1px light-dark(black,white); 10 | background-size: 33.33333% 33.33333%; 11 | background-image: 12 | linear-gradient(to right, grey 1.5px, transparent 1px), 13 | linear-gradient(to bottom, grey 1.5px, transparent 1px); 14 | 15 | position:absolute; 16 | /*top:0px;left:0px;bottom:0px;right:0px;*/ 17 | box-shadow: 0 0 0 9999px light-dark(rgba(255, 255, 255, 0.8), rgba(0, 0, 0, 0.8)); 18 | cursor:move; 19 | 20 | max-height: 100%; 21 | max-width: 100%; 22 | } 23 | 24 | #croprect div{ 25 | position:absolute; 26 | width:40px;height:40px; 27 | border-color: light-dark(black,white); 28 | border-style: solid; 29 | } 30 | #croprect #top_left{ 31 | top:0px;left:0px; 32 | border-width: 3px 0px 0px 3px; 33 | cursor: nwse-resize; 34 | } 35 | #croprect #top_right{ 36 | top:0px;right:0px; 37 | border-width: 3px 3px 0px 0px; 38 | cursor: nesw-resize; 39 | } 40 | #croprect #bottom_right{ 41 | bottom:0px;right:0px; 42 | border-width: 0px 3px 3px 0px; 43 | cursor: nwse-resize; 44 | } 45 | #croprect #bottom_left{ 46 | bottom:0px;left:0px; 47 | border-width: 0px 0px 3px 3px; 48 | cursor: nesw-resize; 49 | } 50 | #croprect #top{ 51 | top:0px;right:calc(50% - 20px); 52 | border-width: 3px 0px 0px 0px; 53 | cursor: ns-resize; 54 | } 55 | #croprect #bottom{ 56 | bottom:0px;right:calc(50% - 20px); 57 | border-width: 0px 0px 3px 0px; 58 | cursor: ns-resize; 59 | } 60 | #croprect #left{ 61 | top:calc(50% - 20px);left:0px; 62 | border-width: 0px 0px 0px 3px; 63 | cursor: ew-resize; 64 | } 65 | #croprect #right{ 66 | top:calc(50% - 20px);right:0px; 67 | border-width: 0px 3px 0px 0px; 68 | cursor: ew-resize; 69 | } -------------------------------------------------------------------------------- /src/components/cropper.js: -------------------------------------------------------------------------------- 1 | import { html, reactive, onMount, onUnmount} from '@xdadda/mini' 2 | import './cropper.css' 3 | 4 | export default function Cropper(canvas, adj, onUpdate) { 5 | const params = adj.crop 6 | const trs = adj.trs 7 | const showtopbottom=reactive(true) 8 | 9 | 10 | onMount(()=>{ 11 | resetCropRect(params.currentcrop) 12 | crop.addEventListener("pointerdown", dragstart); 13 | }) 14 | 15 | onUnmount(()=>{ 16 | crop.removeEventListener("pointerdown", dragstart); 17 | }) 18 | 19 | 20 | let dragging=false 21 | let crop_mouse_pos 22 | let _left,_right,_top,_bottom 23 | let box, rect 24 | const hotspot=50, minsize=100 25 | 26 | function dragstop(e){ 27 | dragging=false 28 | crop.releasePointerCapture(e.pointerId) 29 | crop.removeEventListener("pointermove", drag); 30 | crop.removeEventListener("pointerup", dragstop); 31 | updateRect() 32 | params.currentcrop=rect 33 | if(onUpdate) onUpdate(rect) 34 | } 35 | 36 | function updateRect(){ 37 | rect=croprect.getBoundingClientRect() 38 | const {offsetTop, offsetLeft, offsetHeight, offsetWidth} = croprect 39 | rect={...JSON.parse(JSON.stringify(rect)), offsetTop, offsetLeft, offsetHeight, offsetWidth} 40 | rect.offsetBottom=box.height-offsetTop-offsetHeight 41 | rect.offsetRight=box.width-offsetLeft-offsetWidth 42 | } 43 | 44 | function dragstart(e){ 45 | dragging=true 46 | crop.setPointerCapture(e.pointerId) 47 | crop.addEventListener("pointermove", drag); 48 | crop.addEventListener("pointerup", dragstop); 49 | 50 | if(params.ar) { 51 | croprect.style.aspectRatio=params.ar 52 | showtopbottom.value=false 53 | } 54 | else { 55 | croprect.style.aspectRatio='' 56 | showtopbottom.value=true 57 | } 58 | 59 | crop_mouse_pos = {x:e.x, y:e.y} 60 | box=crop.getBoundingClientRect() 61 | updateRect() 62 | 63 | //CHECK POINTER pos vs hot spots 64 | const checkHotspot=(v)=>v>=0 && v<=hotspot 65 | _left = checkHotspot(crop_mouse_pos.x-rect.left+10) //distance from left border 66 | _right = checkHotspot(rect.right-crop_mouse_pos.x+10) //distance from right border 67 | _top = checkHotspot(crop_mouse_pos.y-rect.top+10) 68 | _bottom = checkHotspot(rect.bottom-crop_mouse_pos.y+10) 69 | 70 | //SET inset coordinates to avoid artifacts with 'auto' and aspectRatio 71 | croprect.style.top=croprect.offsetTop+'px' 72 | croprect.style.bottom=box.height - croprect.offsetTop - croprect.offsetHeight+'px' 73 | croprect.style.left=croprect.offsetLeft+'px' 74 | croprect.style.right=box.width - croprect.offsetLeft - croprect.offsetWidth+'px' 75 | } 76 | 77 | function drag(e){ 78 | if(dragging){ 79 | //calculate translation and new croprect size 80 | let dx = e.x-crop_mouse_pos.x 81 | let dy = e.y-crop_mouse_pos.y 82 | 83 | /* 84 | box 85 | ------------------- 86 | | _________ | 87 | | | rect | | 88 | | --------- | 89 | |___________________| 90 | */ 91 | 92 | // offsetHeight = box.height - offsetTop - offsetBottom +/- dx 93 | // offsetWidth = box.width - offsetRight - offsetLeft +/- dx 94 | 95 | 96 | let corner 97 | let ar = croprect.style.aspectRatio 98 | const ratio = ar.split('/')[0]/ar.split('/')[1] 99 | function clamp(lo, value, hi) { 100 | return Math.max(lo, Math.min(value, hi)); 101 | } 102 | 103 | if(_top) { 104 | if(!ar) croprect.style.top=clamp(0,rect.offsetTop+dy,rect.offsetTop+rect.offsetHeight-minsize)+'px' 105 | else{ 106 | if(_right||_left) { 107 | croprect.style.top='auto' 108 | croprect.style.bottom=box.height - croprect.offsetTop - croprect.offsetHeight + 'px' 109 | } 110 | } 111 | } 112 | if(_bottom) { 113 | if(!ar) croprect.style.bottom=clamp(0,rect.offsetBottom-dy,rect.offsetBottom+rect.offsetHeight-minsize)+'px' 114 | else { 115 | if(_right||_left) { 116 | croprect.style.top=croprect.offsetTop+'px' 117 | croprect.style.bottom='auto' 118 | } 119 | } 120 | } 121 | if(_left) { 122 | if(!ar) croprect.style.left=clamp(0,rect.offsetLeft+dx,rect.offsetLeft+rect.offsetWidth-minsize)+'px' 123 | else { 124 | if(_top) croprect.style.left=clamp(Math.max(0,box.width-rect.offsetRight-(rect.offsetTop+rect.offsetHeight)*ratio),rect.offsetLeft+dx,rect.offsetLeft+rect.offsetWidth-minsize)+'px' 125 | else croprect.style.left=clamp(Math.max(0,box.width-rect.offsetRight-(box.height-rect.offsetTop)*ratio),rect.offsetLeft+dx,rect.offsetLeft+rect.offsetWidth-minsize)+'px' 126 | } 127 | } 128 | if(_right) { 129 | if(!ar) croprect.style.right=clamp(0,rect.offsetRight-dx,rect.offsetRight+rect.offsetWidth-minsize)+'px' 130 | else { 131 | if(_top) croprect.style.right=clamp(Math.max(0,box.width-rect.offsetLeft-(rect.offsetTop+rect.offsetHeight)*ratio),rect.offsetRight-dx,rect.offsetRight+rect.offsetWidth-minsize)+'px' 132 | else croprect.style.right=clamp(Math.max(0,box.width-rect.offsetLeft-(box.height-rect.offsetTop)*ratio),rect.offsetRight-dx,rect.offsetRight+rect.offsetWidth-minsize)+'px' 133 | } 134 | } 135 | 136 | if(!_top&&!_bottom&&!_left&&!_right) { 137 | croprect.style.top=clamp(0,rect.offsetTop+dy, box.height-rect.offsetHeight)+'px' 138 | croprect.style.bottom=clamp(0,rect.offsetBottom-dy, box.height-rect.offsetHeight)+'px' 139 | croprect.style.left=clamp(0,rect.offsetLeft+dx, box.width-rect.offsetWidth)+'px' 140 | croprect.style.right=clamp(0,rect.offsetRight-dx, box.width-rect.offsetWidth)+'px' 141 | } 142 | } 143 | } 144 | 145 | function resetCropRect(currentc){ 146 | const crop = document.getElementById('crop') 147 | crop.style.width= Math.round(canvas.offsetWidth)+'px' 148 | crop.style.height = Math.round(canvas.offsetHeight)+'px' 149 | 150 | if(params.ar) croprect.style.aspectRatio=params.ar 151 | else croprect.style.aspectRatio='' 152 | 153 | if(!currentc) { 154 | croprect.style.inset='0' 155 | params.currentcrop=0 156 | } 157 | else { 158 | const c = currentc 159 | croprect.style.inset=`${c.offsetTop}px ${c.offsetRight}px ${c.offsetBottom}px ${c.offsetLeft}px` 160 | } 161 | if(onUpdate) onUpdate(currentc||0) 162 | } 163 | 164 | let lastclick=0 165 | function clickCropRect(e){ 166 | e.preventDefault() 167 | //mobile can't intercept double-tap as a double click ... handle it here with a timer! yuk 168 | if(lastclick && (Date.now()-lastclick)<200) return resetCropRect() 169 | lastclick=Date.now() 170 | } 171 | 172 | 173 | return html` 174 |
175 |
176 |
177 |
178 |
179 |
180 | ${()=>showtopbottom.value && html` 181 |
182 | 183 |
184 |
185 | `} 186 |
187 |
188 | ` 189 | 190 | } -------------------------------------------------------------------------------- /src/components/downloadImage.js: -------------------------------------------------------------------------------- 1 | import { html, reactive } from '@xdadda/mini' 2 | import { confirm } from '@xdadda/mini/components' 3 | import '@xdadda/mini/components.css' 4 | import { shareBlob } from '../js/tools.js' 5 | 6 | import isMobile from 'ismobilejs'; 7 | 8 | import miniExif from '@xdadda/mini-exif' 9 | 10 | async function base64ToArrayBuffer(dataURL) { 11 | const arr = dataURL.split(','); 12 | const mime = arr[0].match(/:(.*?);/)[1]; 13 | return (fetch(dataURL) 14 | .then(function (result) { 15 | return result.arrayBuffer(); 16 | })); 17 | } 18 | 19 | 20 | export default async function downloadImage($file,_exif,_minigl, onSave){ 21 | const meta = $file.value; 22 | const filename=meta.file.name 23 | const newfilename=reactive(filename.split('.')[0]) 24 | const format=reactive('jpeg') 25 | const quality=reactive('0.9') 26 | 27 | function handleSelect(e){ 28 | format.value=e.target.value 29 | //updateName() 30 | } 31 | 32 | const resp = await confirm(()=>html` 33 |
34 |
${onSave?'Save ':'Download '}image
35 | 36 |
37 |
38 | 39 | 43 |
44 |
45 | ${()=>format.value==='jpeg' &&html` 46 |
47 | 48 | 49 | ${()=>quality.value.padEnd(3,'.0')} 50 |
51 | `} 52 |
53 |
54 |
`) 55 | 56 | if(resp){ 57 | const currentExifData = _exif.extract() 58 | 59 | const img = _minigl.captureImage("image/"+format.value, format.value==='jpeg' && parseFloat(quality.value)) //return Image with src=image/jpeg dataUrl 60 | let imgdataurl = img.src 61 | const imgdataArrayBuffer = await base64ToArrayBuffer(imgdataurl) 62 | 63 | const _newexif = miniExif(imgdataArrayBuffer) 64 | if(currentExifData) { 65 | //insert original exif data 66 | _newexif.replace(currentExifData) 67 | //patch orientation to match canvas (if present in tiff!) 68 | //[Note: some HEIC viewers don't use EXIF Orientation but HEIC 'irot' data! ] 69 | if(meta?.tiff?.Orientation) _newexif.patch({area:'tiff',field:'Orientation',value:1}) 70 | } 71 | newfilename.value+='.'+format._value 72 | if(!onSave) { 73 | const mob = isMobile(window.navigator).any; 74 | if(mob) shareBlob(newfilename.value,new Blob([_newexif.image()])) 75 | else _newexif.download(newfilename.value) 76 | } 77 | else { 78 | onSave(filename,new Blob([_newexif.image()]),format._value) 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /src/components/fullscreen.js: -------------------------------------------------------------------------------- 1 | import { html } from '@xdadda/mini' 2 | import store from '@xdadda/mini/store' 3 | import isMobile from 'ismobilejs'; 4 | 5 | 6 | //calls onToggle(flag), where flag=true if entering fullscreen, false if leaving fullscreen mode 7 | export default function Fullscreen(onToggle=null){ 8 | 9 | const iphone = isMobile(window.navigator).apple.phone; 10 | if(iphone) return html`
` 11 | 12 | ///// FULLSCREEN 13 | async function toggleFullScreen() { 14 | if (!document.fullscreenElement) { 15 | await document.documentElement.requestFullscreen(); 16 | } else if (document.exitFullscreen) { 17 | await document.exitFullscreen(); 18 | } 19 | } 20 | 21 | function detectFullScreen(e) { 22 | //when in/out fullscreen reset view to ensure proper UI alignments 23 | if (document.fullscreenElement) { 24 | if(onToggle) onToggle(true) 25 | //log(`Element: ${document.fullscreenElement.id} entered fullscreen mode.`); 26 | } else { 27 | //log("Leaving fullscreen mode."); 28 | if(onToggle) onToggle(false) 29 | } 30 | } 31 | if(onToggle) document.addEventListener("fullscreenchange", detectFullScreen) 32 | 33 | ///////////////// 34 | 35 | return html` 36 |
\u26F6
37 | ` 38 | } 39 | -------------------------------------------------------------------------------- /src/components/gpsmap.js: -------------------------------------------------------------------------------- 1 | import { html, onMount, onUnmount} from '@xdadda/mini' 2 | 3 | //////// GPS MAP /////////////////////// 4 | 5 | 6 | export default function GPSMap(coord){ 7 | let map 8 | 9 | onMount(async()=>{ 10 | //console.log('MAP',coord) 11 | map = new maplibregl.Map({ 12 | container: 'map', 13 | style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', //', // stylesheet location 14 | center: coord, // starting position [lng, lat] 15 | zoom: 9 // starting zoom 16 | }); 17 | const marker = new maplibregl.Marker() 18 | .setLngLat(coord) 19 | .addTo(map); 20 | 21 | }) 22 | onUnmount(()=>{ 23 | map?.remove()} 24 | ) 25 | 26 | return html` 27 | 28 | 29 |
30 | ` 31 | } 32 | //////////////////////////////////////// -------------------------------------------------------------------------------- /src/components/histogram.js: -------------------------------------------------------------------------------- 1 | import { html, onMount, onUnmount } from '@xdadda/mini' 2 | 3 | import Worker from './histogram_worker.js?worker' 4 | import {handlePointer} from '../js/zoom_pan.js' 5 | 6 | 7 | export default function Histogram(colorspace, onSetup){ 8 | let cleanevt 9 | 10 | onMount(()=>{ 11 | setupHistogramWorker() 12 | if(onSetup) onSetup(drawHistogram) 13 | if(cleanevt) cleanevt() 14 | cleanevt = handlePointer({ 15 | el:histo, 16 | onMove: ({ev,x,y,el})=>{ 17 | ev.stopPropagation() 18 | const pos=el.style.transform.match(/translate\((.*?)\)/)?.[1].split(',').map(e=>parseFloat(e)) || [0,0] 19 | const scale=1 //args.el.parentElement.style.transform.match(/scale\((.*?)\)/)?.[1].split(',').map(e=>parseFloat(e))[0] || 1 20 | pos[0]+=x/scale 21 | pos[1]+=y/scale 22 | el.style.transform=`translate(${pos[0]}px,${pos[1]}px)` 23 | } 24 | }) 25 | 26 | }) 27 | onUnmount(()=>{ 28 | worker.terminate() 29 | cleanevt() 30 | }) 31 | 32 | 33 | let thumb, thumbctx, histoctx 34 | let worker, updating=false 35 | async function setupHistogramWorker(){ 36 | try{ 37 | //console.log('>>INIT webworker<<') 38 | thumb = new OffscreenCanvas(10,10) 39 | thumb.width = 350 40 | thumbctx = thumb.getContext('2d',{colorSpace: colorspace, willReadFrequently: true}) 41 | histoctx = document.getElementById('histogram').getContext("2d") 42 | worker = new Worker(); 43 | worker.onmessage = async (event) => { 44 | if(event.data.bitmap) { 45 | histoctx.clearRect(0, 0, histoctx.canvas.width, histoctx.canvas.height) 46 | histoctx.drawImage(event.data.bitmap,0,0) 47 | updating=false 48 | //setTimeout(()=>updating=false,10) //debounce 49 | } else console.log(event.data) 50 | }; 51 | worker.onerror = (error) => { 52 | console.error(`Worker error: ${error.message}`); 53 | throw error; 54 | }; 55 | worker.postMessage({init:true,width:histoctx.canvas.width,height:histoctx.canvas.height}) 56 | } 57 | catch(e){console.error(e)} 58 | } 59 | 60 | async function drawHistogram() { 61 | if(worker && !updating) { 62 | updating=true 63 | thumb.height = thumb.width / (canvas.width/canvas.height) 64 | thumbctx.drawImage(canvas,0,0,canvas.width,canvas.height,0,0,thumb.width,thumb.height) 65 | const pixels = thumbctx?.getImageData(0,0,thumbctx.canvas.width,thumbctx.canvas.height).data 66 | worker.postMessage({pixels}) 67 | } 68 | return 69 | } 70 | 71 | 72 | return html` 73 |
74 |
${colorspace}
75 | 76 |
77 | ` 78 | } -------------------------------------------------------------------------------- /src/components/histogram_worker.js: -------------------------------------------------------------------------------- 1 | 2 | let canvas, glcanvas, ctx, drawing 3 | self.onmessage = async (event) => { 4 | 5 | if(event.data.init && !canvas) { 6 | const {width,height} = event.data 7 | canvas = new OffscreenCanvas(width,height) 8 | ctx = canvas.getContext('2d') 9 | drawing=false 10 | //postMessage('worker ready') 11 | } 12 | 13 | else if(event.data.pixels) { 14 | //console.log('histo') 15 | if(drawing) return 16 | drawing=true 17 | //const xx=performance.now() 18 | const data = event.data.pixels 19 | DrawHistogram(data,ctx) 20 | //console.log('draw histo',performance.now()-xx) //ms 21 | createImageBitmap(canvas).then(t=>{ 22 | postMessage({bitmap:t}) 23 | drawing = false 24 | }) 25 | 26 | } 27 | }; 28 | 29 | 30 | const histogram = [ 31 | new Uint32Array(256), //RED 32 | new Uint32Array(256), //GREEN 33 | new Uint32Array(256), //BLUE 34 | ]; 35 | 36 | function DrawHistogram(data,ctx){ 37 | function Draw(color, colorarray, width, height, max) { 38 | ctx.beginPath(); 39 | ctx.moveTo(0, height); 40 | let x = 0; 41 | for (let c = 0; c < 256; c++) { 42 | const h = Math.round(colorarray[c] * height / max); 43 | x = Math.round(c * width / 255); 44 | ctx.lineTo(x, height - h); 45 | } 46 | ctx.lineTo(x, height); 47 | ctx.fillStyle = color; 48 | ctx.fill(); 49 | ctx.closePath(); 50 | } 51 | 52 | histogram[0].fill(0) 53 | histogram[1].fill(0) 54 | histogram[2].fill(0) 55 | const max = [0,0,0] 56 | for (let i = 0; i < data.length; ++i) { 57 | const pixel = data[i], col=i%4;//, alpha=4-col; 58 | //if(data[i+alpha]){ 59 | if(col<3&&pixel>2&&pixel<253) { 60 | ++histogram[col][pixel]; 61 | if(histogram[col][pixel]>max[col]) ++max[col]; 62 | } 63 | //} 64 | } 65 | 66 | const {width, height} = ctx.canvas; 67 | ctx.clearRect(0, 0, width, height); 68 | ctx.globalCompositeOperation = "lighter"; //"lighter" 69 | ctx.globalAlpha = 1; 70 | Draw("#c13119", histogram[0], width, height, max[0]); 71 | Draw("#0c9427", histogram[1], width, height, max[1]); 72 | Draw("#1e73be", histogram[2], width, height, max[2]); 73 | } 74 | -------------------------------------------------------------------------------- /src/components/perspective.js: -------------------------------------------------------------------------------- 1 | import { html, onMount, onUnmount} from '@xdadda/mini' 2 | import canvasMouse from './canvasmouse.js' 3 | 4 | export default function Quad(canvas, params, onUpdate){ 5 | 6 | let zeropoints = [[0.25,0.25], [0.75,0.25], [0.75,0.75],[0.25,0.75]] 7 | let initpoints = params?.quad || zeropoints 8 | 9 | let firstdraw=true 10 | function drawQuad(points, ctx){ 11 | ctx.clearRect(0, 0, mousecanvas.width, mousecanvas.height); 12 | ctx.lineWidth = 3; 13 | ctx.strokeStyle = 'red'; 14 | ctx.beginPath(); 15 | for (var i = 0; i < 4; i++) { 16 | const x = points[i][0]*mousecanvas.width 17 | const y = points[i][1]*mousecanvas.height 18 | ctx.lineTo(x,y) 19 | } 20 | ctx.closePath(); 21 | ctx.stroke(); 22 | if(firstdraw) firstdraw=false 23 | else if(!params.modified) params.modified=true 24 | params.quad=points 25 | if(onUpdate) onUpdate() 26 | } 27 | 28 | function resetQuad(points){ 29 | params.modified=false 30 | return zeropoints 31 | } 32 | 33 | 34 | return html` 35 | 38 | ${canvasMouse(canvas, initpoints, drawQuad, resetQuad)} 39 | ` 40 | } -------------------------------------------------------------------------------- /src/components/perspective2.js: -------------------------------------------------------------------------------- 1 | import { html, onMount, onUnmount} from '@xdadda/mini' 2 | import canvasMouse from './canvasmouse.js' 3 | 4 | export default function Perspective(canvas, params, onUpdate){ 5 | 6 | let zeropoints = [[0.25,0.25], [0.75,0.25], [0.75,0.75],[0.25,0.75]] 7 | let initpoints = params?.after || params?.before || zeropoints 8 | let status = 0 //0 =drawing before, 1= drawing after 9 | if(params?.before) status=1 10 | 11 | console.log('perspective',status,initpoints) 12 | let firstdraw=true 13 | function drawQuad(points, ctx){ 14 | ctx.clearRect(0, 0, mousecanvas.width, mousecanvas.height); 15 | ctx.lineWidth = 3; 16 | ctx.strokeStyle = 'red'; 17 | ctx.beginPath(); 18 | for (var i = 0; i < 4; i++) { 19 | const x = points[i][0]*mousecanvas.width 20 | const y = points[i][1]*mousecanvas.height 21 | ctx.lineTo(x,y) 22 | } 23 | ctx.closePath(); 24 | ctx.stroke(); 25 | if(firstdraw) firstdraw=false 26 | else if(!params.modified) params.modified=true 27 | 28 | if(!status) params.before=points 29 | else { 30 | params.after=points 31 | if(onUpdate) onUpdate() 32 | } 33 | } 34 | 35 | function resetQuad(points){ 36 | params.modified=false 37 | return zeropoints 38 | } 39 | 40 | 41 | return html` 42 | 45 | ${canvasMouse(canvas, initpoints, drawQuad, resetQuad)} 46 | ` 47 | } -------------------------------------------------------------------------------- /src/components/splitview.css: -------------------------------------------------------------------------------- 1 | #splitview_container { 2 | position: absolute; 3 | display: flex; 4 | } 5 | #splitview_container #splitview { 6 | width: 100%; 7 | height: 100%; 8 | pointer-events: none; 9 | } 10 | #splitview_container #splitview_bar { 11 | position: absolute; 12 | width: 5px; 13 | height: 100%; 14 | background-color: #607d8b8c; 15 | pointer-events: none; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/splitview.js: -------------------------------------------------------------------------------- 1 | import { html, onMount, onUnmount} from '@xdadda/mini' 2 | import './splitview.css' 3 | 4 | export default function SplitView(image, stylewidth, styleheight, splitwidth, onUpdate) { 5 | 6 | onMount(()=>{ 7 | resetSplitRect(splitwidth) 8 | splitview_container.addEventListener("pointerdown", dragstart); 9 | }) 10 | 11 | onUnmount(()=>{ 12 | splitview_container.removeEventListener("pointerdown", dragstart); 13 | }) 14 | 15 | function resetSplitRect(splitw){ 16 | if(!splitw) splitwidth=0.5 17 | 18 | splitview.src=image.src 19 | splitview.width=image.width 20 | splitview.height=image.height 21 | splitview_container.style.width=stylewidth 22 | splitview_container.style.height=styleheight 23 | splitview_container.style.aspectRatio='auto '+image.width+'/'+image.height 24 | splitview.style.clipPath=`inset(0px ${(1-splitwidth)*100}% 0px 0px)` 25 | splitview_bar.style.left=`calc(${(splitwidth)*100}% - 5px)` 26 | 27 | } 28 | 29 | let dragging=false, _x 30 | 31 | function dragstart(e){ 32 | dragging=true 33 | splitview_container.setPointerCapture(e.pointerId) 34 | splitview_container.addEventListener("pointermove", drag); 35 | splitview_container.addEventListener("pointerup", dragstop); 36 | _x=e.clientX 37 | } 38 | 39 | function dragstop(e){ 40 | dragging=false 41 | splitview_container.releasePointerCapture(e.pointerId) 42 | splitview_container.removeEventListener("pointermove", drag); 43 | splitview_container.removeEventListener("pointerup", dragstop); 44 | if(onUpdate) onUpdate(splitwidth) 45 | } 46 | 47 | function drag(e){ 48 | if(dragging){ 49 | e.preventDefault() 50 | e.stopPropagation() 51 | //RESIZE SPLITVIEW 52 | const splitscale = 1/splitview_container.clientWidth 53 | const parentscale=zoomable.style.transform.match(/scale\((.*?)\)/)?.[1].split(',').map(e=>parseFloat(e))[0] || 1 54 | //console.log(e.x,e.clientX,e.clientX-_x) 55 | splitwidth += (e.clientX-_x)*splitscale/parentscale 56 | _x=e.clientX 57 | splitwidth = Math.max(0.1,Math.min(0.9,splitwidth)) //clip 58 | splitview.style.clipPath=`inset(0px ${(1-splitwidth)*100}% 0px 0px)` 59 | splitview_bar.style.left=`calc(${(splitwidth)*100}% - 5px)` 60 | } 61 | } 62 | 63 | 64 | return html` 65 |
66 | 67 |
68 |
69 | ` 70 | } 71 | -------------------------------------------------------------------------------- /src/components/themetoggle.js: -------------------------------------------------------------------------------- 1 | import { html, reactive} from '@xdadda/mini' 2 | import store from '@xdadda/mini/store' 3 | 4 | 5 | function toggleMode(noauto){ 6 | const sysmode = window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light' 7 | const altmode = sysmode==='dark'?'light':'dark' 8 | const currmode = store('thememode').value 9 | 10 | if(currmode==='auto') { 11 | root.classList.add(altmode) 12 | root.classList.remove(sysmode) 13 | store('thememode').value=altmode 14 | } 15 | else if(currmode===altmode){ 16 | root.classList.add(sysmode) 17 | root.classList.remove(altmode) 18 | store('thememode').value=sysmode 19 | } 20 | else if(!noauto && currmode===sysmode) { 21 | root.classList.remove(altmode) 22 | root.classList.remove(sysmode) 23 | store('thememode').value='auto' 24 | } 25 | else if(noauto && currmode===sysmode){ 26 | root.classList.add(altmode) 27 | root.classList.remove(currmode) 28 | store('thememode').value=altmode 29 | } 30 | } 31 | 32 | export default function ThemeToggle(start='auto',noauto=false){ 33 | if(!store('thememode')) store('thememode',reactive(start)) 34 | 35 | if(start){ 36 | root.classList.add(start) 37 | } 38 | 39 | const icons = { 40 | auto:'\u273B', 41 | dark:'\u263E', 42 | light:'\u273A' 43 | } 44 | 45 | 46 | return html`
47 | 48 | ${()=>icons[store('thememode').value]} 49 | 50 |
` 51 | } 52 | 53 | -------------------------------------------------------------------------------- /src/editor.css: -------------------------------------------------------------------------------- 1 | .minieditor .banner { 2 | margin: 5px; 3 | display: flex; 4 | align-items: center; 5 | width: 180px; 6 | justify-content: space-between; 7 | } 8 | 9 | 10 | .minieditor .container { 11 | flex:1; 12 | height:100%; 13 | width:100%; 14 | overflow: hidden; 15 | 16 | display:flex; 17 | flex-direction: row; 18 | font-size:15px; 19 | 20 | .editor { 21 | flex: 1; 22 | margin: 5px; 23 | border-radius: 15px; 24 | overflow: hidden; 25 | 26 | display: flex; 27 | flex-direction: column; 28 | 29 | #zoomable{ 30 | width: 100%; 31 | height: 99%; /*100% created some artifacts on Safari?!?*/ 32 | overflow: hidden; 33 | 34 | display: flex; 35 | align-items: center; 36 | justify-content: center; 37 | } 38 | #pannable{ 39 | max-width: 100%; 40 | height: 100%; 41 | display: flex; 42 | align-items: center; 43 | justify-content: center; 44 | position: relative; 45 | } 46 | #canvas { 47 | cursor: move; 48 | } 49 | } 50 | 51 | 52 | .sidebar { 53 | width:360px; 54 | display: flex; 55 | flex-direction: column; 56 | margin: 15px; 57 | overflow-y: auto; 58 | position: relative; 59 | 60 | .menusections { 61 | margin-top: 25px; 62 | } 63 | } 64 | 65 | } 66 | 67 | 68 | 69 | 70 | 71 | .minieditor .section { 72 | background-color:light-dark(#e0e0e0,#222222); 73 | border-radius: 15px; 74 | padding: 10px; 75 | margin: 2px 0; 76 | font-size:14px; 77 | height: 23px; 78 | transition: height .3s; 79 | overflow: hidden; 80 | flex-shrink: 0; 81 | display: flex; 82 | flex-direction: column; 83 | position: relative; 84 | cursor: pointer; 85 | 86 | &:hover { 87 | background-color: light-dark( #ededed, #292929); 88 | } 89 | &[skipped] { 90 | opacity: 0.5; 91 | } 92 | &[selected] { 93 | background-color: light-dark( #d4d4d4, #292929); 94 | } 95 | &[selected] .section_header .section_label{ 96 | color:darkorange; 97 | } 98 | 99 | .section_header { 100 | display:flex; 101 | justify-content: space-between; 102 | cursor:pointer; 103 | 104 | .section_skip { 105 | width: 20px; 106 | color:darkorange; 107 | cursor:cell; 108 | 109 | &:hover { 110 | filter: brightness(1.2); 111 | } 112 | } 113 | 114 | .section_label { 115 | flex: 1; 116 | text-align: left; 117 | color:gray; 118 | } 119 | 120 | .reset_btn{ 121 | font-weight: bold; 122 | &[disabled]{ 123 | font-weight: normal; 124 | } 125 | } 126 | 127 | } 128 | 129 | .section_content { 130 | flex:1; 131 | 132 | .section_scroll { 133 | height: 100%; 134 | } 135 | .done_btn, .close_btn { 136 | position: absolute;top: 7px;right: 25px;height: 20px;width: 100px;padding: 0;background: darkorange; 137 | } 138 | .close_btn { 139 | width: 40px; 140 | display: none; 141 | } 142 | &.skip { 143 | opacity: 0.2; 144 | pointer-events: none; 145 | } 146 | } 147 | } 148 | 149 | 150 | 151 | 152 | .minieditor .sidebar .cc_container { 153 | display: flex; 154 | flex-direction: column; 155 | } 156 | 157 | .minieditor .sidebar .rangelabel { 158 | width:100px; 159 | text-align:left; 160 | color:gray; 161 | } 162 | 163 | 164 | .minieditor .sidebar input[type='range'] { 165 | appearance: none; 166 | cursor: pointer; 167 | border: 0; 168 | height: 4px; 169 | padding: 0; 170 | flex: 1; 171 | background-color: light-dark(#bbbbbb, #303030); 172 | 173 | 174 | &#lights_brightness { 175 | background: linear-gradient(90deg, #242424, #CCCCCC); 176 | } 177 | &#lights_exposure { 178 | background: linear-gradient(90deg, #242424, #CCCCCC); 179 | } 180 | &#lights_gamma { 181 | background: linear-gradient(90deg, #242424, #CCCCCC); 182 | } 183 | &#lights_contrast { 184 | background: linear-gradient(270deg, #242424, #CCCCCC); 185 | } 186 | &#lights_shadows { 187 | background: linear-gradient(270deg, #242424, #CCCCCC); 188 | } 189 | &#lights_highlights { 190 | background: linear-gradient(90deg, #242424, #CCCCCC); 191 | } 192 | &#lights_bloom { 193 | background: linear-gradient(90deg, #242424, #CCCCCC); 194 | } 195 | 196 | &#colors_temperature { 197 | background: linear-gradient(90deg, #2c75d3, #ddbc57); 198 | } 199 | &#colors_tint { 200 | background: linear-gradient(to left, rgb(58, 224, 0), rgb(150, 0, 229)); /*linear-gradient(90deg, #d32cca, #57dd67);*/ 201 | } 202 | &#colors_vibrance { 203 | background: linear-gradient(90deg, #7f7f7f, #827f7c, #818078, #798378, #6f867f, #69878b, #628a98, #48949b, #2d9d93, #27a57e, #43aa60, #74ac3e, #a8a823, #e0792a, #f8396b, #9900cd); 204 | } 205 | &#colors_saturation { 206 | background: linear-gradient(90deg, #7f7f7f, #827f7c, #818078, #798378, #6f867f, #69878b, #628a98, #48949b, #2d9d93, #27a57e, #43aa60, #74ac3e, #a8a823, #e0792a, #f8396b, #9900cd); 207 | } 208 | &#colors_sepia { 209 | background: linear-gradient(90deg, #242424, #ddbc57); 210 | } 211 | 212 | &#effects_vignette { 213 | background: linear-gradient(270deg, #242424, #CCCCCC); 214 | } 215 | 216 | 217 | &::-webkit-slider-thumb { 218 | -webkit-appearance: none; 219 | width: 16px; 220 | height: 16px; 221 | border-radius: 50%; 222 | background-color: gray; 223 | } 224 | &::-moz-range-thumb { 225 | background-color: gray; 226 | } 227 | } 228 | 229 | .minieditor .sidebar input[type=number].rangenumb { 230 | padding:0px; 231 | text-align: right; 232 | width:50px; 233 | color:gray; 234 | 235 | /* hide step buttons on Chrome/Safari*/ 236 | &::-webkit-inner-spin-button, 237 | &::-webkit-outer-spin-button { 238 | opacity: 0; 239 | } 240 | &:focus::-webkit-inner-spin-button, 241 | &:focus::-webkit-outer-spin-button, 242 | &:hover::-webkit-inner-spin-button, 243 | &:hover::-webkit-outer-spin-button { 244 | opacity: 1; 245 | } 246 | 247 | } 248 | 249 | 250 | 251 | @media (orientation: portrait) { 252 | .alert-message .msg { 253 | max-width: 90vw !important; 254 | } 255 | .minieditor .container { 256 | flex-direction: column; 257 | 258 | .sidebar { 259 | width:95%; 260 | height: 210px; 261 | margin: 5px auto 5px auto; 262 | justify-content: end; 263 | 264 | .menusections{ 265 | display: flex; 266 | flex-direction: row; 267 | overflow: auto hidden; 268 | -ms-overflow-style: none; /* for Internet Explorer, Edge */ 269 | scrollbar-width: none; /* for Firefox */ 270 | align-items: flex-end; 271 | height: 90px !important; 272 | 273 | } 274 | } 275 | 276 | } 277 | 278 | .minieditor .section { 279 | overflow: hidden; 280 | transition: unset; 281 | flex-direction: column-reverse; 282 | margin: 0 2px; 283 | position: unset; 284 | 285 | .section_skip { 286 | display: none; 287 | } 288 | .reset_btn{ 289 | margin-left:5px !important; 290 | } 291 | } 292 | 293 | .minieditor .section_content{ 294 | position: absolute; 295 | left: 0; 296 | bottom: 48px; 297 | right: 0; 298 | height: 120px; 299 | background-color: inherit; 300 | overflow: auto; 301 | padding: 30px 10px 10px 10px; 302 | border-radius: 15px; 303 | 304 | .section_scroll{ 305 | height:100%; 306 | overflow: hidden auto; 307 | -ms-overflow-style: none; /* for Internet Explorer, Edge */ 308 | scrollbar-width: none; /* for Firefox */ 309 | } 310 | .done_btn { 311 | top: 0px !important; 312 | right: 5px !important; 313 | margin: 0px; 314 | } 315 | .close_btn { 316 | top: 0px !important; 317 | right: 5px !important; 318 | margin: 0px; 319 | display: unset !important; 320 | } 321 | &::before{ 322 | content: ' .'; 323 | position: absolute; 324 | top: 0px; 325 | left: 0px; 326 | width: 100%; 327 | color: transparent; 328 | background-color: light-dark(#c2c2c2, #242424);; 329 | } 330 | hr:first-child{ 331 | display: none; 332 | } 333 | } 334 | 335 | #curve_content{ 336 | padding:10px; 337 | height: 140px; 338 | .section_scroll { 339 | overflow: hidden; 340 | } 341 | } 342 | 343 | 344 | .minieditor .sidebar input[type=number].rangenumb { 345 | pointer-events:none; 346 | } 347 | .minieditor .sidebar .cc_container { 348 | margin-top: 4px; 349 | } 350 | 351 | } 352 | 353 | 354 | 355 | 356 | -------------------------------------------------------------------------------- /src/js/tools.js: -------------------------------------------------------------------------------- 1 | import { alert } from '@xdadda/mini/components' 2 | //////// DEBOUNCE ////////////////////// 3 | export const timerids = new Map(); 4 | export function debounce(id,cb,delay=100){ 5 | if(timerids.has(id)) { 6 | //console.log('debouncing,...',id) 7 | } 8 | else { 9 | const t = setTimeout(()=>{cb();timerids.delete(id);},delay) 10 | timerids.set(id,t) 11 | } 12 | } 13 | //////////////////////////////////////// 14 | 15 | //////// FILE FUNCS //////////////////// 16 | 17 | export async function openFile(ext){ 18 | let input = document.createElement('input'); 19 | try{ 20 | input.setAttribute("hidden", ""); 21 | input.type = 'file'; 22 | input.value=null; 23 | if(ext) input.accept=ext; 24 | document.body.appendChild(input); // doesn't work reliably in Safari if the input is not in the DOM!? 25 | const ev=await new Promise(r=> {input.onchange=r; input.oncancel=r; input.click()} ); 26 | input.remove(); 27 | if(ev.type==='cancel') return 28 | const file = ev.target.files[0]; 29 | if(!file) return await alert('Unsupported file format!') 30 | //if(file.size>60*1024*1024) return await alert('Upload files smaller than 60MB!') 31 | return file 32 | } catch(error){ 33 | await alert('Error opening file') 34 | console.error(error) 35 | } 36 | } 37 | 38 | export function downloadFile(blob, name){ 39 | if(!blob || !name) return console.error('download missing inputs') 40 | try { 41 | var el = document.createElement('a') 42 | el.href = URL.createObjectURL(blob) 43 | el.download = name 44 | el.click() 45 | } catch(error){ 46 | console.error(error) 47 | } 48 | } 49 | 50 | export async function readImage(file, onLoaded=null){ 51 | try { 52 | if(!file) return 53 | const reader= new FileReader() 54 | await new Promise(r=> reader.onload=r, reader.readAsArrayBuffer(file)) 55 | const {name,size,type,lastModified} = file; 56 | const blob = new Blob([reader.result],{type}) 57 | const img = new Image(); 58 | img.src=URL.createObjectURL(blob); 59 | await img.decode(); 60 | 61 | if(onLoaded) onLoaded(reader.result, {name,size,type,lastModified}, img) 62 | 63 | } catch(error){ 64 | console.error(error) 65 | await alert('Unknown format') 66 | } 67 | } 68 | 69 | 70 | export function filesizeString(fileSizeInBytes) { 71 | var i = -1; 72 | var byteUnits = [' kB', ' MB', ' GB', ' TB', 'PB', 'EB', 'ZB', 'YB']; 73 | do {fileSizeInBytes /= 1024;i++;} while (fileSizeInBytes > 1024); 74 | return Math.max(fileSizeInBytes, 0.1).toFixed(1) + byteUnits[i]; 75 | } 76 | 77 | //////////////////////////////////////// 78 | 79 | 80 | //NOTE: it will not work in IOS Safari if not behind https!! so no localhost but don't worry 81 | export const shareBlob = async (filename, blob) => { 82 | //console.log(filename) 83 | const data = { 84 | files: [ 85 | new File([blob], filename, { 86 | type: blob.type, 87 | }), 88 | ], 89 | }; 90 | try { 91 | if (!navigator.canShare || !(navigator.canShare(data))) { 92 | throw new Error("Can't share data."); 93 | } 94 | await navigator.share(data); 95 | } catch (err) { 96 | if(err.message !== 'Share canceled') console.error(err.name,'>', err.message); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/js/zoom_pan.js: -------------------------------------------------------------------------------- 1 | export {handlePointer, zoom_pan} 2 | 3 | function handlePointer({el,onStart,onMove,onEnd,onZoom,onPinch,disableleave=false}){ 4 | //console.log('handlePointer',el) 5 | const evCache = []; //[] 6 | let prevDiff = 0; 7 | let _x,_y; 8 | let firstpinch=true; //avoids strange behaviour where first pinch/zoom is always wrong direction 9 | 10 | const start = (ev) => { 11 | evCache.push(ev); 12 | onStart&&onStart({el,ev}); 13 | _x=ev.clientX;_y=ev.clientY; 14 | firstpinch=true 15 | } 16 | const end = (ev) => { 17 | if(!evCache.length) return 18 | //remove event cache 19 | const index = evCache.findIndex( 20 | (cachedEv) => cachedEv.pointerId === ev.pointerId, 21 | ); 22 | evCache.splice(index, 1); 23 | onEnd&&onEnd({el,ev}); 24 | } 25 | const move = (ev) => { 26 | if(!evCache.length) return 27 | //update event cache 28 | const index = evCache.findIndex( 29 | (cachedEv) => cachedEv.pointerId === ev.pointerId, 30 | ); 31 | evCache[index] = ev; 32 | if(evCache.length === 1) { 33 | onMove&&onMove({el,ev,x:ev.clientX-_x,y:ev.clientY-_y}); 34 | _x=ev.clientX; 35 | _y=ev.clientY; 36 | } 37 | // If two pointers are down, check for pinch gestures 38 | else if (evCache.length === 2) { 39 | // Calculate the distance between the two pointers 40 | const curDiff = Math.abs(evCache[0].x - evCache[1].x); 41 | if (prevDiff > 0) { 42 | let deltaDiff = curDiff-prevDiff //if >0 zoom in, <0 zoom out 43 | if(firstpinch) { 44 | firstpinch=false 45 | deltaDiff*=-1 46 | } 47 | ev.preventDefault(); 48 | onPinch&&onPinch({el, ev0:evCache[0], ev1:evCache[1], diff:deltaDiff}) 49 | } 50 | // Cache the distance for the next move event 51 | prevDiff = curDiff; 52 | } 53 | 54 | } 55 | const prevent = (ev) => { 56 | if(ev.touches.length===2) ev.preventDefault(); 57 | } 58 | 59 | const dragStart = (ev) => {/*el.setPointerCapture(ev.pointerId); */start(ev);} 60 | const drag = (ev) => /*el.hasPointerCapture(ev.pointerId) && */move(ev); 61 | const dragEnd = (ev) => {/*el.releasePointerCapture(ev.pointerId); */end(ev);} 62 | const wheel = (ev) => onZoom&&onZoom({el,ev,zoom:ev.deltaY/100}); 63 | 64 | el.addEventListener("pointerdown", dragStart); 65 | el.addEventListener("pointermove", drag); 66 | el.addEventListener("pointerup", dragEnd); 67 | if(!disableleave){ 68 | el.addEventListener("pointercancel", dragEnd); 69 | el.addEventListener("pointerout", dragEnd); 70 | } 71 | el.addEventListener("pointerleave", dragEnd); 72 | el.addEventListener('touchstart', prevent); //to disable default safari zoom/pinch 73 | 74 | if(onZoom) el.addEventListener("wheel", wheel, { passive: false }); 75 | return ()=>{ 76 | //console.log('removing listeners') 77 | el.removeEventListener("pointerdown", dragStart); 78 | el.removeEventListener("pointermove", drag); 79 | el.removeEventListener("pointerup", dragEnd); 80 | if(!disableleave){ 81 | el.removeEventListener("pointercancel", dragEnd); 82 | el.removeEventListener("pointerout", dragEnd); 83 | } 84 | el.removeEventListener("pointerleave", dragEnd); 85 | el.removeEventListener("touchstart", prevent); 86 | if(onZoom) el.removeEventListener("wheel", wheel); 87 | } 88 | } 89 | 90 | /* 91 | @el Element to zoom 92 | @p {x:,y:} zoom center 93 | @delta -1/+1 to know if zoom IN or OUT 94 | @factor zoom factor //prefer x2 factor to avoid scaling artifacts 95 | @min_scale 96 | @max_scale 97 | */ 98 | function zoomOnPointer(el, p, delta, factor, min_scale, max_scale){ 99 | if(!el.style.transformOrigin) el.style.transformOrigin='0 0' 100 | 101 | //get current position and scale 102 | let pos=el.style.transform.match(/translate\((.*?)\)/)?.[1].split(',').map(e=>parseFloat(e)) || [0,0] 103 | let scale=el.style.transform.match(/scale\((.*?)\)/)?.[1].split(',').map(e=>parseFloat(e))[0] || 1 104 | var zoom_target = {x:0,y:0} 105 | var zoom_point = {x:0,y:0} 106 | zoom_point.x = p.x - el.parentElement.offsetLeft 107 | zoom_point.y = p.y - el.parentElement.offsetTop 108 | delta = Math.max(-1,Math.min(1,delta/10)) // cap the delta to [-1,1] for cross browser consistency 109 | if(!delta) return //critical in Safari apparently 110 | // determine the point on where the el is zoomed in 111 | zoom_target.x = (zoom_point.x - pos[0])/scale 112 | zoom_target.y = (zoom_point.y - pos[1])/scale 113 | // apply zoom 114 | scale += delta * factor * scale 115 | scale = Math.max(min_scale,Math.min(max_scale,scale)) 116 | // calculate x and y based on zoom 117 | pos[0] = -zoom_target.x * scale + zoom_point.x 118 | pos[1] = -zoom_target.y * scale + zoom_point.y 119 | //update 120 | el.style.transform='translate('+(pos[0])+'px,'+(pos[1])+'px) scale('+scale+','+scale+')' 121 | } 122 | 123 | 124 | //NOTE: if child's inner children stop mouse event (preventing zoom and pan), add "pointer-events: none;" to their css 125 | /* 126 | @parent eg div 127 | @child eg img or canvas 128 | 129 | */ 130 | function zoom_pan(parent,child){ 131 | //NOTE: it's easier to handle pan on child element and zoom/scale on parent element 132 | //PAN child 133 | const destroyChild = handlePointer({ 134 | el:child, 135 | onMove:(args)=>{ 136 | //only pan if cursor is on firschild (it's just a UX decision) 137 | const elp = document.elementFromPoint(args.ev.pageX,args.ev.pageY) 138 | if(elp===parent||elp===child) return 139 | //get element current position, where (0,0) = centered 140 | const pos=args.el.style.transform.match(/translate\((.*?)\)/)?.[1].split(',').map(e=>parseFloat(e)) || [0,0] 141 | //get parent's scale (which is used to zoom on point) 142 | const scale=args.el.parentElement.style.transform.match(/scale\((.*?)\)/)?.[1].split(',').map(e=>parseFloat(e))[0] || 1 143 | pos[0]+=args.x/scale 144 | pos[1]+=args.y/scale 145 | args.el.style.transform=`translate(${pos[0]}px,${pos[1]}px)` 146 | }, 147 | }) 148 | //ZOOM parent 149 | const destroyParent = handlePointer({ 150 | el:parent, 151 | onZoom:(args)=>{ 152 | const e = args.ev 153 | e.preventDefault(); //disables desktop browser's default zoom when pinch 154 | //find center of zoom 155 | const p={x:e.pageX,y:e.pageY} 156 | //only zoom if cursor is on firstchild (it's just a UX decision) 157 | const elp = document.elementFromPoint(p.x, p.y) 158 | if(elp===parent||elp===child) return 159 | var delta = e.wheelDelta || e.detail;// e.detail need for firefox 160 | zoomOnPointer(args.el,p,delta,0.06,0.9,8) 161 | }, 162 | onPinch:(args)=>{ 163 | const e0 = args.ev0, e1=args.ev1 164 | //find center of pinch 165 | const p={x:(e0.pageX+e1.pageX)/2,y:(e0.pageY+e1.pageY)/2} 166 | //only zoom if center is on firstchild (it's just a UX decision) 167 | const elp = document.elementFromPoint(p.x, p.y) 168 | if(elp===parent||elp===child) return 169 | var delta = args.diff //if>0 zoomIN, if <0 zoomOUT 170 | zoomOnPointer(args.el,p,delta,0.05*2,0.9,8) //zoom factor x2 wheel (from tests on iPhone vs MacBook) 171 | } 172 | }) 173 | 174 | return ()=>{ 175 | destroyChild() 176 | destroyParent() 177 | } 178 | } -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | 2 | html { 3 | overscroll-behavior: none; 4 | overflow: hidden; 5 | } 6 | body { 7 | margin: 0; 8 | user-select: none; 9 | -webkit-user-select: none; 10 | height: 100vh; 11 | } 12 | 13 | a { 14 | text-decoration: none; 15 | padding: 1px; 16 | color: inherit; 17 | cursor: pointer; 18 | } 19 | a:visited { color:inherit; } 20 | a[disabled] { color:grey; } 21 | 22 | button, .btn { 23 | outline: none; 24 | border-radius: 8px; 25 | border: 1px solid transparent; 26 | font-size: 1em; 27 | font-weight: 500; 28 | cursor: pointer; 29 | transition: border-color 0.25s; 30 | /*line-height: 40px;*/ 31 | height: 40px; 32 | margin: 5px; 33 | width: 150px; 34 | opacity: 0.8; 35 | } 36 | button[selected]{ 37 | border-color:#ff8c0080; 38 | opacity: 1; 39 | } 40 | button:focus{ 41 | /*border-color:rgba(255, 140, 0, 0.7) !important;*/ 42 | opacity: 1; 43 | } 44 | 45 | a:hover, button:hover, .btn:hover { 46 | outline:none; 47 | border-color: rgba(255, 140, 0, 0.6); 48 | } 49 | 50 | input, select{ 51 | appearance: none; 52 | outline: none; 53 | border-style:solid; 54 | border-radius:7px; 55 | border-width:1px; 56 | border-color:transparent; 57 | padding: 5px; 58 | background: transparent; 59 | opacity: 0.8; 60 | } 61 | input:hover, select:hover { 62 | border-color:rgba(255, 140, 0, 0.6); 63 | opacity: 1; 64 | } 65 | input:focus { 66 | border-color:rgba(255, 140, 0, 0.7) !important; 67 | opacity: 1; 68 | } 69 | 70 | select { 71 | background-image: url(./assets/ui_downarrow.svg); 72 | background-size: 8px; 73 | background-repeat: no-repeat; 74 | background-position: calc(100% - .35em) center; 75 | cursor: pointer; 76 | } 77 | 78 | 79 | .hidden { 80 | display: none !important; 81 | } 82 | 83 | .noscrollbar { 84 | -ms-overflow-style: none; /* for Internet Explorer, Edge */ 85 | scrollbar-width: none; /* for Firefox */ 86 | overflow-y: scroll; 87 | } 88 | .noscrollbar::-webkit-scrollbar { 89 | display: none; /* for Chrome, Safari, and Opera */ 90 | } 91 | 92 | .alert-message{ 93 | color: light-dark(rgba(0, 0, 0, 0.87),rgba(255, 255, 255, 0.87)); 94 | background-color: light-dark(#e0e0e0,#191919); 95 | } 96 | 97 | 98 | :root { 99 | font-family: -apple-system, system-ui; 100 | font-size: 1.2em; 101 | line-height: 1.5; 102 | font-weight: 400; 103 | 104 | font-synthesis: none; 105 | text-rendering: optimizeLegibility; 106 | -webkit-font-smoothing: antialiased; 107 | -moz-osx-font-smoothing: grayscale; 108 | -webkit-text-size-adjust: 100%; 109 | 110 | --sat:env(safe-area-inset-top); 111 | --sar:env(safe-area-inset-right); 112 | --sal:env(safe-area-inset-left); 113 | --sab:env(safe-area-inset-bottom); 114 | 115 | color-scheme: light dark; 116 | } 117 | 118 | /**************************************************/ 119 | /****************** COLOR SCHEME ******************/ 120 | /**************************************************/ 121 | 122 | #root,body { 123 | color: light-dark(#213547,rgba(255, 255, 255, 0.87)); 124 | background-color: light-dark(#ffffff,#191919); 125 | } 126 | 127 | button, .btn { 128 | background-color: light-dark(#b6b5b5,#353535); 129 | color:white; 130 | } 131 | button:disabled, .btn:disabled { 132 | color: light-dark(rgba(16, 16, 16, 0.3), rgba(255, 255, 255, 0.3)); 133 | fill:grey !important; 134 | 135 | } 136 | 137 | input, select { 138 | background-color: light-dark(#edecec,#303030); 139 | } 140 | input[type='range'] { 141 | background-color: light-dark(#bbbbbb, #303030); 142 | } 143 | hr { 144 | border: 1px solid; 145 | border-color: light-dark(#f0f0f0,#2e2e2e); 146 | } 147 | 148 | 149 | header { 150 | background: light-dark(linear-gradient(#fff, #fff0),linear-gradient(#000, #0000)); 151 | } 152 | footer { 153 | background: light-dark(linear-gradient(#fff0, #fff),linear-gradient(#0000, #000)); 154 | } 155 | 156 | .dark{ 157 | color-scheme: dark; 158 | } 159 | .light{ 160 | color-scheme: light; 161 | } 162 | 163 | .alert-message{ 164 | color: light-dark(rgba(0, 0, 0, 0.87),rgba(255, 255, 255, 0.87)); 165 | background-color: light-dark(#e0e0e0,#191919); 166 | } 167 | 168 | 169 | .hydra { 170 | background-color: #ffff00bf; 171 | border: thin dotted red !important; 172 | } 173 | #shadowroot{display:none;} 174 | 175 | #newroot{ 176 | border: solid red thin; 177 | display: none; 178 | } 179 | #derror { 180 | background-color: lightcoral; 181 | white-space: break-spaces; 182 | position: fixed; 183 | top:0; 184 | bottom:0; 185 | left:0; 186 | right:0; 187 | margin:5px; 188 | padding:10px; 189 | z-index: 99999; 190 | } 191 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { render } from '@xdadda/mini' 2 | import {Editor} from './app.js' 3 | import './main.css' 4 | 5 | await render( document.getElementById('root'), Editor ) //CSR 6 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from 'vite' 2 | import { minifyTemplateLiterals } from "rollup-plugin-minify-template-literals"; 3 | 4 | export default defineConfig(({isSsrBuild, mode})=>{ 5 | 6 | return { 7 | plugins: [ 8 | {...minifyTemplateLiterals(),apply:'build'} 9 | ], 10 | resolve: { 11 | alias: [ 12 | ], 13 | }, 14 | build: { 15 | target: 'esnext', 16 | minify: true, //in production to reduce size 17 | sourcemap: false, //unless required during development to debug production code artifacts 18 | modulePreload: { polyfill: false }, //not needed for modern browsers 19 | cssCodeSplit:false, //if small enough it's better to have it in one file to avoid flickering during suspend 20 | copyPublicDir: isSsrBuild?false:true, 21 | 22 | rollupOptions: { 23 | output: { 24 | manualChunks: { mini: ['@xdadda/mini','@xdadda/mini/store'] } 25 | } 26 | } 27 | 28 | }, 29 | } 30 | }) 31 | --------------------------------------------------------------------------------