├── .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 | 
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 | {e.stopPropagation();$selection.value=sectionname}}">
41 |
46 |
47 | ${()=>$selection.value===sectionname && html`
48 |
e.stopPropagation()}">
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 |
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 |
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 |
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 |
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`${icon_shutter_rotate}
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 | newfilename.value=e.target.value)}">
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 |

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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/assets/icon_info.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icon_rotate.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/assets/split-v-svgrepo-com.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/assets/ui_downarrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 | resetCropRect()}" @click="${clickCropRect}" style="width:${canvas?.offsetWidth}px;height:${canvas?.offsetHeight}px">
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 | newfilename.value=e.target.value)}" disabled="${!!onSave}">
39 |
43 |
44 |
45 | ${()=>format.value==='jpeg' &&html`
46 |
47 |
48 | quality.value=e.target.value}">
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 |
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``
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 |
--------------------------------------------------------------------------------