2014-05-04 13:32:02 +00:00
2020-05-10 02:40:05 +00:00
// expresses order in the URL as well as type
const param _types = {
// settings
"eye-gaze-mode" : "bool" ,
"vertical-color-box-mode" : "bool" ,
2020-05-10 04:12:52 +00:00
"speech-recognition-mode" : "bool" ,
2020-05-10 02:40:05 +00:00
// sessions
"local" : "string" ,
"session" : "string" ,
"load" : "string" ,
} ;
2020-05-10 03:05:23 +00:00
const exclusive _params = [
"local" ,
"session" ,
"load" ,
] ;
2020-05-10 02:40:05 +00:00
function get _all _url _params ( ) {
const params = { } ;
2022-01-18 18:33:44 +00:00
location . hash . replace ( /^#/ , "" ) . split ( /,/ ) . forEach ( ( param _decl ) => {
2020-05-10 05:09:25 +00:00
// colon is used in param value for URLs so split(":") isn't good enough
const colon _index = param _decl . indexOf ( ":" ) ;
if ( colon _index === - 1 ) {
// boolean value, implicitly true because it's in the URL
const param _name = param _decl ;
params [ param _name ] = true ;
} else {
const param _name = param _decl . slice ( 0 , colon _index ) ;
const param _value = param _decl . slice ( colon _index + 1 ) ;
params [ param _name ] = decodeURIComponent ( param _value ) ;
}
2020-05-10 02:40:05 +00:00
} ) ;
for ( const [ param _name , param _type ] of Object . entries ( param _types ) ) {
if ( param _type === "bool" && ! params [ param _name ] ) {
params [ param _name ] = false ;
}
}
return params ;
}
function get _url _param ( param _name ) {
return get _all _url _params ( ) [ param _name ] ;
}
2022-01-18 18:33:44 +00:00
function change _url _param ( param _name , value , { replace _history _state = false } = { } ) {
change _some _url _params ( { [ param _name ] : value } , { replace _history _state } ) ;
2020-05-10 02:40:05 +00:00
}
2022-01-18 18:33:44 +00:00
function change _some _url _params ( updates , { replace _history _state = false } = { } ) {
2020-05-10 03:05:23 +00:00
for ( const exclusive _param of exclusive _params ) {
if ( updates [ exclusive _param ] ) {
2022-01-18 18:33:44 +00:00
exclusive _params . forEach ( ( param ) => {
2020-05-10 03:05:23 +00:00
if ( param !== exclusive _param ) {
2021-06-19 23:58:47 +00:00
updates [ param ] = null ; // must be enumerated (for Object.assign) but falsy, to get removed from the URL
2020-05-10 03:05:23 +00:00
}
} ) ;
}
}
2022-01-18 18:33:44 +00:00
set _all _url _params ( Object . assign ( { } , get _all _url _params ( ) , updates ) , { replace _history _state } ) ;
2020-05-10 02:40:05 +00:00
}
2022-01-18 18:33:44 +00:00
function set _all _url _params ( params , { replace _history _state = false } = { } ) {
2020-05-10 02:40:05 +00:00
let new _hash = "" ;
for ( const [ param _name , param _type ] of Object . entries ( param _types ) ) {
if ( params [ param _name ] ) {
if ( new _hash . length ) {
new _hash += "," ;
}
new _hash += encodeURIComponent ( param _name ) ;
if ( param _type !== "bool" ) {
new _hash += ":" + encodeURIComponent ( params [ param _name ] ) ;
}
}
}
// Note: gets rid of query string (?) portion of the URL
// This is desired for upgrading backwards compatibility URLs;
// may not be desired for future cases.
const new _url = ` ${ location . origin } ${ location . pathname } # ${ new _hash } ` ;
2021-01-30 02:34:13 +00:00
try {
// can fail when running from file: protocol
if ( replace _history _state ) {
history . replaceState ( null , document . title , new _url ) ;
} else {
history . pushState ( null , document . title , new _url ) ;
}
2022-01-18 18:33:44 +00:00
} catch ( error ) {
2021-01-30 02:34:13 +00:00
location . hash = new _hash ;
2020-05-10 02:40:05 +00:00
}
$G . triggerHandler ( "change-url-params" ) ;
}
2022-01-18 18:33:44 +00:00
function update _magnified _canvas _size ( ) {
2021-02-11 02:00:38 +00:00
$canvas . css ( "width" , main _canvas . width * magnification ) ;
$canvas . css ( "height" , main _canvas . height * magnification ) ;
2019-10-26 22:00:29 +00:00
update _canvas _rect ( ) ;
}
function update _canvas _rect ( ) {
2021-02-11 02:00:38 +00:00
canvas _bounding _client _rect = main _canvas . getBoundingClientRect ( ) ;
2019-10-26 22:00:29 +00:00
update _helper _layer ( ) ;
}
2019-10-29 20:29:38 +00:00
let helper _layer _update _queued ;
2021-12-18 03:03:47 +00:00
let info _for _updating _pointer ; // for updating the brush preview when the mouse stays in the same place,
// but its coordinates in the document change due to scrolling or browser zooming (handled with scroll and resize events)
function update _helper _layer ( e ) {
// e should be passed for pointer events, but not scroll or resize events
// e may be a synthetic event without clientX/Y, so ignore that (using isFinite)
// e may also be a timestamp from requestAnimationFrame callback; ignore that
2019-12-21 23:14:54 +00:00
if ( e && isFinite ( e . clientX ) ) {
2022-01-18 18:33:44 +00:00
info _for _updating _pointer = { clientX : e . clientX , clientY : e . clientY , devicePixelRatio } ;
2019-10-28 20:46:03 +00:00
}
2019-10-26 22:00:29 +00:00
if ( helper _layer _update _queued ) {
2019-12-14 22:49:23 +00:00
// window.console && console.log("update_helper_layer - nah, already queued");
2019-10-26 22:00:29 +00:00
return ;
} else {
2019-12-14 22:49:23 +00:00
// window.console && console.log("update_helper_layer");
2019-10-26 22:00:29 +00:00
}
helper _layer _update _queued = true ;
2022-01-18 18:33:44 +00:00
requestAnimationFrame ( ( ) => {
2019-10-26 22:00:29 +00:00
helper _layer _update _queued = false ;
update _helper _layer _immediately ( ) ;
} ) ;
2016-05-04 22:12:46 +00:00
}
2019-12-18 05:42:19 +00:00
function update _helper _layer _immediately ( ) {
2019-12-14 22:49:23 +00:00
// window.console && console.log("Update helper layer NOW");
2019-10-21 21:10:07 +00:00
if ( info _for _updating _pointer ) {
2019-10-29 20:29:38 +00:00
const rescale = info _for _updating _pointer . devicePixelRatio / devicePixelRatio ;
2019-10-21 21:10:07 +00:00
info _for _updating _pointer . clientX *= rescale ;
info _for _updating _pointer . clientY *= rescale ;
info _for _updating _pointer . devicePixelRatio = devicePixelRatio ;
2019-10-30 00:48:51 +00:00
pointer = to _canvas _coords ( info _for _updating _pointer ) ;
2019-10-02 19:37:45 +00:00
}
2019-10-29 20:29:38 +00:00
const scale = magnification * window . devicePixelRatio ;
2019-10-01 03:31:05 +00:00
if ( ! helper _layer ) {
2021-02-11 02:00:38 +00:00
helper _layer = new OnCanvasHelperLayer ( 0 , 0 , main _canvas . width , main _canvas . height , false , scale ) ;
2019-09-30 14:40:47 +00:00
}
2019-10-01 04:32:22 +00:00
2019-10-29 20:29:38 +00:00
const margin = 15 ;
const viewport _x = Math . floor ( Math . max ( $canvas _area . scrollLeft ( ) / magnification - margin , 0 ) ) ;
2020-12-17 02:35:20 +00:00
// Nevermind, canvas, isn't aligned to the right in RTL layout!
// const viewport_x =
// get_direction() === "rtl" ?
// // Note: $canvas_area.scrollLeft() can return negative numbers for RTL layout
// Math.floor(Math.max(($canvas_area.scrollLeft() - $canvas_area.innerWidth()) / magnification + canvas.width - margin, 0)) :
// Math.floor(Math.max($canvas_area.scrollLeft() / magnification - margin, 0));
2019-10-29 20:29:38 +00:00
const viewport _y = Math . floor ( Math . max ( $canvas _area . scrollTop ( ) / magnification - margin , 0 ) ) ;
2022-01-18 18:33:44 +00:00
const viewport _x2 = Math . floor ( Math . min ( viewport _x + $canvas _area . width ( ) / magnification + margin * 2 , main _canvas . width ) ) ;
const viewport _y2 = Math . floor ( Math . min ( viewport _y + $canvas _area . height ( ) / magnification + margin * 2 , main _canvas . height ) ) ;
2019-10-29 20:29:38 +00:00
const viewport _width = viewport _x2 - viewport _x ;
const viewport _height = viewport _y2 - viewport _y ;
const resolution _width = viewport _width * scale ;
const resolution _height = viewport _height * scale ;
2019-10-01 03:31:05 +00:00
if (
2021-12-03 23:45:15 +00:00
helper _layer . canvas . width !== resolution _width ||
helper _layer . canvas . height !== resolution _height
2019-10-01 03:31:05 +00:00
) {
2021-12-03 23:45:15 +00:00
helper _layer . canvas . width = resolution _width ;
helper _layer . canvas . height = resolution _height ;
helper _layer . canvas . ctx . disable _image _smoothing ( ) ;
2019-10-01 15:09:31 +00:00
helper _layer . width = viewport _width ;
helper _layer . height = viewport _height ;
2019-10-01 03:31:05 +00:00
}
2019-10-01 15:09:31 +00:00
helper _layer . x = viewport _x ;
helper _layer . y = viewport _y ;
2019-10-01 04:02:48 +00:00
helper _layer . position ( ) ;
2019-10-01 03:31:05 +00:00
2021-12-03 23:45:15 +00:00
render _canvas _view ( helper _layer . canvas , scale , viewport _x , viewport _y , true ) ;
if ( thumbnail _canvas && $thumbnail _window . is ( ":visible" ) ) {
// The thumbnail can be bigger or smaller than the viewport, depending on the magnification and thumbnail window size.
// So can the document.
2021-12-04 01:24:36 +00:00
// Ideally it should show the very corner if scrolled all the way to the corner,
// so that you can get a thumbnail of any location just by scrolling.
// But it's impossible if the thumbnail is smaller than the viewport. You have to resize the thumbnail window in that case.
// (And if the document is smaller than the viewport, there's no scrolling to indicate where you want to get a thumbnail of.)
// It gets clipped to the top left portion of the viewport if the thumbnail is too small.
// This works except for if there's a selection, it affects the scrollable area, and it shouldn't affect this calculation.
2021-12-04 02:14:39 +00:00
// const scroll_width = $canvas_area[0].scrollWidth - $canvas_area[0].clientWidth;
// const scroll_height = $canvas_area[0].scrollHeight - $canvas_area[0].clientHeight;
2021-12-04 01:24:36 +00:00
2021-12-06 23:20:23 +00:00
// These padding terms are negligible in comparison to the margin reserved for canvas handles,
// which I'm not accounting for (except for clamping below).
2021-12-04 01:24:36 +00:00
const padding _left = parseFloat ( $canvas _area . css ( "padding-left" ) ) ;
const padding _top = parseFloat ( $canvas _area . css ( "padding-top" ) ) ;
2021-12-04 02:14:39 +00:00
const scroll _width = main _canvas . clientWidth + padding _left - $canvas _area [ 0 ] . clientWidth ;
const scroll _height = main _canvas . clientHeight + padding _top - $canvas _area [ 0 ] . clientHeight ;
// Don't divide by less than one, or the thumbnail with disappear off to the top/left (or completely for NaN).
let scroll _x _fraction = $canvas _area [ 0 ] . scrollLeft / Math . max ( 1 , scroll _width ) ;
let scroll _y _fraction = $canvas _area [ 0 ] . scrollTop / Math . max ( 1 , scroll _height ) ;
2021-12-06 23:20:23 +00:00
// If the canvas is larger than the document view, but not by much, and you scroll to the bottom or right,
// the margin for the canvas handles can lead to the thumbnail being cut off or even showing
// just blank space without this clamping (due to the not quite accurate scrollable area calculation).
scroll _x _fraction = Math . min ( scroll _x _fraction , 1 ) ;
scroll _y _fraction = Math . min ( scroll _y _fraction , 1 ) ;
2021-12-04 01:24:36 +00:00
2021-12-04 01:00:12 +00:00
let viewport _x = Math . floor ( Math . max ( scroll _x _fraction * ( main _canvas . width - thumbnail _canvas . width ) , 0 ) ) ;
let viewport _y = Math . floor ( Math . max ( scroll _y _fraction * ( main _canvas . height - thumbnail _canvas . height ) , 0 ) ) ;
2021-12-04 01:24:36 +00:00
2021-12-04 03:05:06 +00:00
render _canvas _view ( thumbnail _canvas , 1 , viewport _x , viewport _y , false ) ; // devicePixelRatio?
2021-12-03 23:45:15 +00:00
}
}
function render _canvas _view ( hcanvas , scale , viewport _x , viewport _y , is _helper _layer ) {
update _fill _and _stroke _colors _and _lineWidth ( selected _tool ) ;
2022-01-18 18:33:44 +00:00
2021-12-06 17:14:26 +00:00
const grid _visible = show _grid && magnification >= 4 && ( window . devicePixelRatio * magnification ) >= 4 && is _helper _layer ;
2021-12-03 23:45:15 +00:00
const hctx = hcanvas . ctx ;
2019-10-01 03:31:05 +00:00
hctx . clearRect ( 0 , 0 , hcanvas . width , hcanvas . height ) ;
2021-12-03 23:45:15 +00:00
if ( ! is _helper _layer ) {
// Draw the actual document canvas (for the thumbnail)
// (For the main canvas view, the helper layer is separate from (and overlaid on top of) the document canvas)
hctx . drawImage ( main _canvas , viewport _x , viewport _y , hcanvas . width , hcanvas . height , 0 , 0 , hcanvas . width , hcanvas . height ) ;
}
2022-01-18 18:33:44 +00:00
2019-12-02 22:15:42 +00:00
var tools _to _preview = [ ... selected _tools ] ;
2021-02-09 04:07:12 +00:00
// Don't preview tools while dragging components/component windows
// (The magnifier preview is especially confusing looking together with the component preview!)
if ( $ ( "body" ) . hasClass ( "dragging" ) && ! pointer _active ) {
// tools_to_preview.length = 0;
2021-06-19 23:58:47 +00:00
// Curve and Polygon tools have a persistent state over multiple gestures,
2021-02-09 04:07:12 +00:00
// which is, as of writing, part of the "tool preview"; it's ugly,
// but at least they don't have ALSO a brush like preview, right?
// so we can just allow those thru
2022-01-18 18:33:44 +00:00
tools _to _preview = tools _to _preview . filter ( ( tool ) =>
2021-02-09 04:07:12 +00:00
tool . id === TOOL _CURVE ||
tool . id === TOOL _POLYGON
) ;
}
2019-12-02 22:15:42 +00:00
// the select box previews draw the document canvas onto the preview canvas
// so they have something to invert within the preview canvas
// but this means they block out anything earlier
2019-12-03 02:36:19 +00:00
// NOTE: sort Select after Free-Form Select,
// Brush after Eraser, as they are from the toolbar ordering
2022-01-18 18:33:44 +00:00
tools _to _preview . sort ( ( a , b ) => {
2019-12-02 22:15:42 +00:00
if ( a . selectBox && ! b . selectBox ) {
return - 1 ;
}
if ( ! a . selectBox && b . selectBox ) {
return 1 ;
}
return 0 ;
} ) ;
// two select box previews would just invert and cancel each other out
// so only render one if there's one or more
2022-01-18 18:33:44 +00:00
var select _box _index = tools _to _preview . findIndex ( ( tool ) => tool . selectBox ) ;
2019-12-02 22:15:42 +00:00
if ( select _box _index >= 0 ) {
2022-01-18 18:33:44 +00:00
tools _to _preview = tools _to _preview . filter ( ( tool , index ) => ! tool . selectBox || index == select _box _index ) ;
2019-12-02 22:15:42 +00:00
}
2022-01-18 18:33:44 +00:00
tools _to _preview . forEach ( ( tool ) => {
if ( tool . drawPreviewUnderGrid && pointer && pointers . length < 2 ) {
2019-12-02 17:05:48 +00:00
hctx . save ( ) ;
2019-12-02 22:15:42 +00:00
tool . drawPreviewUnderGrid ( hctx , pointer . x , pointer . y , grid _visible , scale , - viewport _x , - viewport _y ) ;
2019-12-02 17:05:48 +00:00
hctx . restore ( ) ;
2019-10-01 00:34:02 +00:00
}
} ) ;
2019-12-05 19:52:19 +00:00
if ( selection ) {
hctx . save ( ) ;
2022-01-18 18:33:44 +00:00
2019-12-05 19:52:19 +00:00
hctx . scale ( scale , scale ) ;
hctx . translate ( - viewport _x , - viewport _y ) ;
hctx . drawImage ( selection . canvas , selection . x , selection . y ) ;
2022-01-18 18:33:44 +00:00
2019-12-05 19:52:19 +00:00
hctx . restore ( ) ;
2021-12-04 01:05:52 +00:00
if ( ! is _helper _layer && ! selection . dragging ) {
// Draw the selection outline (for the thumbnail)
// (The main canvas view has the OnCanvasSelection object which has its own outline)
draw _selection _box ( hctx , selection . x , selection . y , selection . width , selection . height , scale , - viewport _x , - viewport _y ) ;
}
2019-12-05 19:52:19 +00:00
}
if ( textbox ) {
hctx . save ( ) ;
2022-01-18 18:33:44 +00:00
2019-12-05 19:52:19 +00:00
hctx . scale ( scale , scale ) ;
hctx . translate ( - viewport _x , - viewport _y ) ;
hctx . drawImage ( textbox . canvas , textbox . x , textbox . y ) ;
2022-01-18 18:33:44 +00:00
2019-12-05 19:52:19 +00:00
hctx . restore ( ) ;
2021-12-04 23:32:27 +00:00
if ( ! is _helper _layer && ! textbox . dragging ) {
// Draw the textbox outline (for the thumbnail)
// (The main canvas view has the OnCanvasTextBox object which has its own outline)
draw _selection _box ( hctx , textbox . x , textbox . y , textbox . width , textbox . height , scale , - viewport _x , - viewport _y ) ;
}
2019-12-05 19:52:19 +00:00
}
2019-10-01 16:27:17 +00:00
if ( grid _visible ) {
2019-10-01 03:31:05 +00:00
draw _grid ( hctx , scale ) ;
2019-10-01 00:34:02 +00:00
}
2022-01-18 18:33:44 +00:00
tools _to _preview . forEach ( ( tool ) => {
if ( tool . drawPreviewAboveGrid && pointer && pointers . length < 2 ) {
2019-12-02 17:05:48 +00:00
hctx . save ( ) ;
2019-12-02 22:15:42 +00:00
tool . drawPreviewAboveGrid ( hctx , pointer . x , pointer . y , grid _visible , scale , - viewport _x , - viewport _y ) ;
2019-12-02 17:05:48 +00:00
hctx . restore ( ) ;
2019-10-01 00:34:02 +00:00
}
} ) ;
2019-09-30 14:40:47 +00:00
}
2019-09-30 18:23:20 +00:00
function update _disable _aa ( ) {
2019-10-29 20:29:38 +00:00
const dots _per _canvas _px = window . devicePixelRatio * magnification ;
const round = Math . floor ( dots _per _canvas _px ) === dots _per _canvas _px ;
2019-10-02 17:43:16 +00:00
$canvas _area . toggleClass ( "disable-aa-for-things-at-main-canvas-scale" , dots _per _canvas _px >= 3 || round ) ;
2019-09-30 18:23:20 +00:00
}
2019-09-30 14:40:47 +00:00
2021-12-06 02:52:17 +00:00
function set _magnification ( new _scale , anchor _point ) {
// anchor_point is optional, and uses canvas coordinates;
2021-12-06 06:46:15 +00:00
// the default is the top-left of the $canvas_area viewport
2021-12-06 02:52:17 +00:00
// How this works is, you imagine "what if it was zoomed, where would the anchor point be?"
// Then to make it end up where it started, you simply shift the viewport by the difference.
// And actually you don't have to "imagine" zooming, you can just do the zoom.
2021-12-06 06:46:15 +00:00
anchor _point = anchor _point ? ? {
x : $canvas _area . scrollLeft ( ) / magnification ,
y : $canvas _area . scrollTop ( ) / magnification ,
} ;
const anchor _on _page = from _canvas _coords ( anchor _point ) ;
2019-10-26 18:15:43 +00:00
2021-12-06 02:52:17 +00:00
magnification = new _scale ;
if ( new _scale !== 1 ) {
return _to _magnification = new _scale ;
}
update _magnified _canvas _size ( ) ; // also updates canvas_bounding_client_rect used by from_canvas_coords()
2022-01-18 18:33:44 +00:00
2021-12-06 06:46:15 +00:00
const anchor _after _zoom = from _canvas _coords ( anchor _point ) ;
// Note: scrollBy() not scrollTo()
$canvas _area [ 0 ] . scrollBy ( {
left : anchor _after _zoom . clientX - anchor _on _page . clientX ,
top : anchor _after _zoom . clientY - anchor _on _page . clientY ,
behavior : "instant" ,
} ) ;
2019-10-26 18:15:43 +00:00
$G . triggerHandler ( "resize" ) ; // updates handles & grid
2019-10-27 05:14:10 +00:00
$G . trigger ( "option-changed" ) ; // updates options area
2023-02-14 10:13:31 +00:00
$G . trigger ( "magnification-changed" ) ; // updates custom zoom window
2014-11-29 02:27:51 +00:00
}
2019-10-29 20:29:38 +00:00
let $custom _zoom _window ;
2021-02-08 02:49:21 +00:00
let dev _custom _zoom = false ;
try {
dev _custom _zoom = localStorage . dev _custom _zoom === "true" ;
// eslint-disable-next-line no-empty
} catch ( error ) { }
if ( dev _custom _zoom ) {
2022-01-18 18:33:44 +00:00
$ ( ( ) => {
2021-02-08 02:49:21 +00:00
show _custom _zoom _window ( ) ;
$custom _zoom _window . css ( {
left : 80 ,
top : 50 ,
opacity : 0.5 ,
} ) ;
} ) ;
}
2019-10-01 18:25:33 +00:00
function show _custom _zoom _window ( ) {
if ( $custom _zoom _window ) {
$custom _zoom _window . close ( ) ;
}
2021-09-02 22:19:51 +00:00
const $w = new $DialogWindow ( localize ( "Custom Zoom" ) ) ;
2019-10-01 18:25:33 +00:00
$custom _zoom _window = $w ;
2021-02-08 02:49:21 +00:00
$w . addClass ( "custom-zoom-window" ) ;
$w . $main . append ( ` <div class='current-zoom'> ${ localize ( "Current zoom:" ) } <bdi> ${ magnification * 100 } %</bdi></div> ` ) ;
2023-02-14 10:13:31 +00:00
// update when zoom changes
$G . on ( "magnification-changed" , ( ) => {
$w . $main . find ( ".current-zoom bdi" ) . text ( ` ${ magnification * 100 } % ` ) ;
} ) ;
2019-10-01 18:25:33 +00:00
2019-10-29 20:29:38 +00:00
const $fieldset = $ ( E ( "fieldset" ) ) . appendTo ( $w . $main ) ;
2021-02-09 03:28:09 +00:00
$fieldset . append ( `
< legend > $ { localize ( "Zoom to" ) } < / l e g e n d >
< div class = "fieldset-body" >
2023-02-14 11:18:50 +00:00
< input type = "radio" name = "custom-zoom-radio" id = "zoom-option-1" aria - keyshortcuts = "Alt+1 1" value = "1" / > < label for = "zoom-option-1" > $ { display _hotkey ( "&100%" ) } < / l a b e l >
< input type = "radio" name = "custom-zoom-radio" id = "zoom-option-2" aria - keyshortcuts = "Alt+2 2" value = "2" / > < label for = "zoom-option-2" > $ { display _hotkey ( "&200%" ) } < / l a b e l >
< input type = "radio" name = "custom-zoom-radio" id = "zoom-option-4" aria - keyshortcuts = "Alt+4 4" value = "4" / > < label for = "zoom-option-4" > $ { display _hotkey ( "&400%" ) } < / l a b e l >
< input type = "radio" name = "custom-zoom-radio" id = "zoom-option-6" aria - keyshortcuts = "Alt+6 6" value = "6" / > < label for = "zoom-option-6" > $ { display _hotkey ( "&600%" ) } < / l a b e l >
< input type = "radio" name = "custom-zoom-radio" id = "zoom-option-8" aria - keyshortcuts = "Alt+8 8" value = "8" / > < label for = "zoom-option-8" > $ { display _hotkey ( "&800%" ) } < / l a b e l >
2021-12-07 04:06:50 +00:00
< input type = "radio" name = "custom-zoom-radio" id = "zoom-option-really-custom" value = "really-custom" / > < label for = "zoom-option-really-custom" > < input type = "number" min = "10" max = "1000" name = "really-custom-zoom-input" class = "inset-deep no-spinner" value = "" / > % < / l a b e l >
2021-02-09 03:28:09 +00:00
< / d i v >
` );
2019-10-29 20:29:38 +00:00
let is _custom = true ;
2022-01-18 18:33:44 +00:00
$fieldset . find ( "input[type=radio]" ) . get ( ) . forEach ( ( el ) => {
2019-10-01 18:25:33 +00:00
if ( parseFloat ( el . value ) === magnification ) {
el . checked = true ;
2023-02-14 01:25:41 +00:00
el . focus ( ) ;
2019-10-01 18:25:33 +00:00
is _custom = false ;
}
} ) ;
2019-10-29 20:29:38 +00:00
const $really _custom _radio _option = $fieldset . find ( "input[value='really-custom']" ) ;
const $really _custom _input = $fieldset . find ( "input[name='really-custom-zoom-input']" ) ;
2019-10-01 18:25:33 +00:00
2023-02-14 08:07:52 +00:00
$really _custom _input . closest ( "label" ) . on ( "click" , ( event ) => {
2019-10-01 18:25:33 +00:00
$really _custom _radio _option . prop ( "checked" , true ) ;
2023-02-14 10:11:31 +00:00
// If the user clicks on the input, let it get focus naturally, placing the caret where you click.
// If the user clicks outside it on the label, focus the input and select the text.
2023-02-14 08:07:52 +00:00
if ( $ ( event . target ) . closest ( "input" ) . length === 0 ) {
2023-02-14 10:11:31 +00:00
// Why does focusing this input programmatically not lead to the input
// being focused ultimately after the click?
// I'm working around this by using requestAnimationFrame (setTimeout would lead to a flicker).
// What am I working around, though? Is it my os-gui.js library? It has code to focus the
// last focused control in a window. I didn't see that code in the debugger, but I could've missed it.
// Debugging without time travel is hard. Maybe I should attack this problem with time travel, using replay.io.
requestAnimationFrame ( ( ) => {
$really _custom _input [ 0 ] . focus ( ) ;
$really _custom _input [ 0 ] . select ( ) ;
} ) ;
// Maybe this would all be a little simpler if I made the label point to the input.
// I want the label to have a larger click target, but maybe I can do that with CSS.
2023-02-14 08:07:52 +00:00
}
2019-10-01 18:25:33 +00:00
} ) ;
if ( is _custom ) {
$really _custom _input . val ( magnification * 100 ) ;
$really _custom _radio _option . prop ( "checked" , true ) ;
2023-02-14 01:25:41 +00:00
$really _custom _input . select ( ) ;
2019-10-01 18:25:33 +00:00
}
2023-02-14 01:42:31 +00:00
$really _custom _radio _option . on ( "keydown" , ( event ) => {
2023-02-14 04:08:19 +00:00
if ( event . key . match ( /^[0-9.]$/ ) ) {
// Can't set number input to invalid number "." or even "0.",
// but if we don't prevent the default keydown behavior of typing the letter,
// we can actually change the focus before the letter is typed!
// $really_custom_input.val(event.key === "." ? "0." : event.key);
// $really_custom_input.focus(); // should move caret to end
// event.preventDefault();
$really _custom _input . val ( "" ) . focus ( ) ;
2023-02-14 01:42:31 +00:00
}
} ) ;
2023-02-14 08:03:38 +00:00
// If you tab to the number input and type, it should select the radio button
// so that your input is actually used.
$really _custom _input . on ( "input" , ( event ) => {
$really _custom _radio _option . prop ( "checked" , true ) ;
} ) ;
2022-01-18 18:33:44 +00:00
$fieldset . find ( "label" ) . css ( { display : "block" } ) ;
2019-10-01 18:25:33 +00:00
2020-12-05 22:33:21 +00:00
$w . $Button ( localize ( "OK" ) , ( ) => {
2019-10-29 20:29:38 +00:00
let option _val = $fieldset . find ( "input[name='custom-zoom-radio']:checked" ) . val ( ) ;
let mag ;
2022-01-18 18:33:44 +00:00
if ( option _val === "really-custom" ) {
2019-10-01 18:25:33 +00:00
option _val = $really _custom _input . val ( ) ;
2022-01-18 18:33:44 +00:00
if ( ` ${ option _val } ` . match ( /\dx$/ ) ) { // ...you can't actually type an x; oh well...
2019-10-01 18:25:33 +00:00
mag = parseFloat ( option _val ) ;
2022-01-18 18:33:44 +00:00
} else if ( ` ${ option _val } ` . match ( /\d%?$/ ) ) {
2019-10-01 18:25:33 +00:00
mag = parseFloat ( option _val ) / 100 ;
}
2022-01-18 18:33:44 +00:00
if ( isNaN ( mag ) ) {
2021-04-01 14:56:57 +00:00
please _enter _a _number ( ) ;
2019-10-01 18:25:33 +00:00
return ;
}
2022-01-18 18:33:44 +00:00
} else {
2019-10-01 18:25:33 +00:00
mag = parseFloat ( option _val ) ;
}
set _magnification ( mag ) ;
$w . close ( ) ;
2023-02-14 01:25:41 +00:00
} , { type : "submit" } ) ;
2020-12-05 22:33:21 +00:00
$w . $Button ( localize ( "Cancel" ) , ( ) => {
2019-10-01 18:25:33 +00:00
$w . close ( ) ;
} ) ;
$w . center ( ) ;
2023-02-14 10:23:22 +00:00
2023-02-14 11:18:50 +00:00
handle _keyshortcuts ( $w ) ;
2019-10-01 18:25:33 +00:00
}
2020-05-30 13:35:46 +00:00
2019-09-30 15:56:07 +00:00
function toggle _grid ( ) {
show _grid = ! show _grid ;
// $G.trigger("option-changed");
2019-10-01 00:34:02 +00:00
update _helper _layer ( ) ;
2019-09-30 15:56:07 +00:00
}
2021-12-03 01:12:15 +00:00
function toggle _thumbnail ( ) {
show _thumbnail = ! show _thumbnail ;
if ( ! show _thumbnail ) {
$thumbnail _window . hide ( ) ;
} else {
if ( ! thumbnail _canvas ) {
thumbnail _canvas = make _canvas ( 108 , 92 ) ;
2021-12-04 03:06:57 +00:00
thumbnail _canvas . style . width = "100%" ;
thumbnail _canvas . style . height = "100%" ;
2021-12-03 01:12:15 +00:00
}
if ( ! $thumbnail _window ) {
$thumbnail _window = new $Window ( {
title : localize ( "Thumbnail" ) ,
toolWindow : true ,
resizable : true ,
innerWidth : thumbnail _canvas . width + 4 , // @TODO: should the border of $content be included in the definition of innerWidth/Height?
innerHeight : thumbnail _canvas . height + 4 ,
minInnerWidth : 52 + 4 ,
minInnerHeight : 36 + 4 ,
minOuterWidth : 0 , // @FIXME: this shouldn't be needed
minOuterHeight : 0 , // @FIXME: this shouldn't be needed
} ) ;
$thumbnail _window . addClass ( "thumbnail-window" ) ;
$thumbnail _window . $content . append ( thumbnail _canvas ) ;
$thumbnail _window . $content . addClass ( "inset-deep" ) ;
$thumbnail _window . $content . css ( { marginTop : "1px" } ) ; // @TODO: should this (or equivalent on titlebar) be for all windows?
2021-12-04 03:37:47 +00:00
$thumbnail _window . maximize = ( ) => { } ; // @TODO: disable maximize with an option
2021-12-04 03:05:06 +00:00
new ResizeObserver ( ( entries ) => {
const entry = entries [ 0 ] ;
2021-12-19 08:04:34 +00:00
let width , height ;
2021-12-04 03:05:06 +00:00
if ( "devicePixelContentBoxSize" in entry ) {
2021-12-04 03:06:57 +00:00
// console.log("devicePixelContentBoxSize", entry.devicePixelContentBoxSize);
// Firefox seems to support this, although I can't find any documentation that says it should
// I can't find an implementation bug or anything.
// So I had to disable this case to test the fallback case (in Firefox 94.0)
2021-12-19 08:04:34 +00:00
width = entry . devicePixelContentBoxSize [ 0 ] . inlineSize ;
height = entry . devicePixelContentBoxSize [ 0 ] . blockSize ;
2021-12-19 04:21:08 +00:00
} else if ( "contentBoxSize" in entry ) {
2021-12-04 03:06:57 +00:00
// console.log("contentBoxSize", entry.contentBoxSize);
// round() seems to line up with what Firefox does for device pixel alignment, which is great.
// In Chrome it's blurry at some zoom levels with round(), ceil(), or floor(), but it (documentedly) supports devicePixelContentBoxSize.
2021-12-19 08:04:34 +00:00
width = Math . round ( entry . contentBoxSize [ 0 ] . inlineSize * devicePixelRatio ) ;
height = Math . round ( entry . contentBoxSize [ 0 ] . blockSize * devicePixelRatio ) ;
2021-12-19 04:21:08 +00:00
} else {
// Safari on iPad doesn't support either of the above as of iOS 15.0.2
2021-12-19 08:04:34 +00:00
width = Math . round ( entry . contentRect . width * devicePixelRatio ) ;
height = Math . round ( entry . contentRect . height * devicePixelRatio ) ;
}
if ( width && height ) { // If it's hidden, and then shown, it gets a width and height of 0 briefly on iOS. (This would give IndexSizeError in drawImage.)
thumbnail _canvas . width = width ;
thumbnail _canvas . height = height ;
2021-12-04 03:05:06 +00:00
}
2021-12-03 01:12:15 +00:00
update _helper _layer _immediately ( ) ; // updates thumbnail (but also unnecessarily the helper layer)
2021-12-19 08:04:34 +00:00
} ) . observe ( thumbnail _canvas , { box : [ 'device-pixel-content-box' ] } ) ;
2021-12-03 01:12:15 +00:00
}
$thumbnail _window . show ( ) ;
2021-12-19 08:04:34 +00:00
$thumbnail _window . on ( "close" , ( e ) => {
e . preventDefault ( ) ;
$thumbnail _window . hide ( ) ;
show _thumbnail = false ;
} ) ;
2021-12-03 01:12:15 +00:00
}
// Currently the thumbnail updates with the helper layer. But it's not part of the helper layer, so this is a bit of a misnomer for now.
update _helper _layer ( ) ;
}
2022-01-18 18:33:44 +00:00
function reset _selected _colors ( ) {
2021-02-11 02:04:35 +00:00
selected _colors = {
2015-02-24 00:18:07 +00:00
foreground : "#000000" ,
background : "#ffffff" ,
ternary : "" ,
} ;
2014-11-20 20:11:36 +00:00
$G . trigger ( "option-changed" ) ;
2014-05-04 13:32:02 +00:00
}
2021-08-02 20:20:08 +00:00
function reset _file ( ) {
system _file _handle = null ;
2020-12-05 22:33:21 +00:00
file _name = localize ( "untitled" ) ;
2021-02-06 20:55:25 +00:00
file _format = "image/png" ;
2015-02-23 21:16:21 +00:00
saved = true ;
2021-08-12 01:24:46 +00:00
update _title ( ) ;
2014-11-29 02:27:51 +00:00
}
2022-01-18 18:33:44 +00:00
function reset _canvas _and _history ( ) {
2019-10-29 20:37:53 +00:00
undos . length = 0 ;
redos . length = 0 ;
2019-12-16 02:16:48 +00:00
current _history _node = root _history _node = make _history _node ( {
2021-01-30 15:18:50 +00:00
name : localize ( "New" ) ,
2019-12-16 02:16:48 +00:00
icon : get _help _folder _icon ( "p_blank.png" ) ,
} ) ;
2019-12-16 01:54:03 +00:00
history _node _to _cancel _to = null ;
2018-01-24 21:58:12 +00:00
2021-02-11 02:00:38 +00:00
main _canvas . width = Math . max ( 1 , my _canvas _width ) ;
main _canvas . height = Math . max ( 1 , my _canvas _height ) ;
main _ctx . disable _image _smoothing ( ) ;
2021-02-11 02:04:35 +00:00
main _ctx . fillStyle = selected _colors . background ;
2021-02-11 02:00:38 +00:00
main _ctx . fillRect ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2018-01-24 21:58:12 +00:00
2021-02-11 02:00:38 +00:00
current _history _node . image _data = main _ctx . getImageData ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2019-12-12 16:09:15 +00:00
2014-05-06 02:09:52 +00:00
$canvas _area . trigger ( "resize" ) ;
2019-12-14 21:27:36 +00:00
$G . triggerHandler ( "history-update" ) ; // update history view
2014-05-04 13:32:02 +00:00
}
2019-12-12 22:11:09 +00:00
function make _history _node ( {
parent = null ,
futures = [ ] ,
timestamp = Date . now ( ) ,
2019-12-13 05:42:48 +00:00
soft = false ,
2019-12-12 22:11:09 +00:00
image _data = null ,
2019-12-13 03:04:00 +00:00
selection _image _data = null ,
2019-12-13 04:13:42 +00:00
selection _x ,
selection _y ,
textbox _text ,
textbox _x ,
textbox _y ,
textbox _width ,
textbox _height ,
2019-12-15 02:57:42 +00:00
text _tool _font = null ,
tool _transparent _mode ,
foreground _color ,
background _color ,
ternary _color ,
2019-12-12 22:11:09 +00:00
name ,
icon = null ,
} ) {
2019-12-13 04:34:20 +00:00
return {
parent ,
futures ,
timestamp ,
2019-12-13 05:42:48 +00:00
soft ,
2019-12-13 04:34:20 +00:00
image _data ,
selection _image _data ,
selection _x ,
selection _y ,
textbox _text ,
textbox _x ,
textbox _y ,
textbox _width ,
textbox _height ,
2019-12-15 02:57:42 +00:00
text _tool _font ,
tool _transparent _mode ,
foreground _color ,
background _color ,
ternary _color ,
2019-12-13 04:34:20 +00:00
name ,
icon ,
} ;
2019-12-12 22:11:09 +00:00
}
2022-01-18 18:33:44 +00:00
function update _title ( ) {
2021-06-19 16:30:48 +00:00
document . title = ` ${ file _name } - ${ is _pride _month ? "June Solidarity " : "" } ${ localize ( "Paint" ) } ` ;
2019-11-10 15:17:32 +00:00
if ( is _pride _month ) {
$ ( "link[rel~='icon']" ) . attr ( "href" , "./images/icons/gay-es-paint-16x16-light-outline.png" ) ;
}
2021-08-12 01:24:46 +00:00
if ( window . setRepresentedFilename ) {
window . setRepresentedFilename ( system _file _handle ? ? "" ) ;
}
if ( window . setDocumentEdited ) {
window . setDocumentEdited ( ! saved ) ;
}
2014-05-04 13:32:02 +00:00
}
2021-02-12 16:12:42 +00:00
function get _uris ( text ) {
2019-09-17 20:31:49 +00:00
// parse text/uri-list
// get lines, discarding comments
2019-10-29 20:29:38 +00:00
const lines = text . split ( /[\n\r]+/ ) . filter ( line => line [ 0 ] !== "#" && line ) ;
2019-09-17 20:31:49 +00:00
// discard text with too many lines (likely pasted HTML or something) - may want to revisit this
if ( lines . length > 15 ) {
return [ ] ;
}
// parse URLs, discarding anything that parses as a relative URL
2019-10-29 20:29:38 +00:00
const uris = [ ] ;
2022-01-18 18:33:44 +00:00
for ( let i = 0 ; i < lines . length ; i ++ ) {
2021-07-11 02:31:37 +00:00
// Relative URLs will throw when no base URL is passed to the URL constructor.
2019-09-17 21:28:30 +00:00
try {
2019-10-29 20:29:38 +00:00
const url = new URL ( lines [ i ] ) ;
2019-09-17 20:31:49 +00:00
uris . push ( url . href ) ;
2022-01-18 18:33:44 +00:00
// eslint-disable-next-line no-empty
} catch ( e ) { }
2019-09-17 20:31:49 +00:00
}
return uris ;
}
2021-07-11 02:03:31 +00:00
async function load _image _from _uri ( uri ) {
// Cases to consider:
2021-07-11 02:31:37 +00:00
// - data URI
// - blob URI
2021-07-11 02:03:31 +00:00
// - blob URI from another domain
2021-07-11 02:31:37 +00:00
// - file URI
// - http URI
// - https URI
2021-07-11 02:03:31 +00:00
// - unsupported protocol, e.g. "ftp://example.com/image.png"
2021-07-11 02:31:37 +00:00
// - invalid URI
// - no protocol specified, e.g. "example.com/image.png"
// --> We can fix these up!
// - The user may be just trying to paste text, not an image.
2021-07-11 02:03:31 +00:00
// - non-CORS-enabled URI
2021-07-11 02:31:37 +00:00
// --> Use a CORS proxy! :)
2022-01-13 06:31:49 +00:00
// - In electron, using a CORS proxy 1. is silly, 2. maybe isn't working.
// --> Either proxy requests to the main process,
// or configure headers in the main process to make requests work.
// Probably the latter. @TODO
// https://stackoverflow.com/questions/51254618/how-do-you-handle-cors-in-an-electron-app
2021-07-11 02:03:31 +00:00
// - invalid image / unsupported image format
2021-07-11 02:31:37 +00:00
// - image is no longer available on the live web
// --> try loading from WayBack Machine :)
// - often swathes of URLs are redirected to a new site, and do not give a 404.
// --> make sure the flow of fallbacks accounts for this, and doesn't just see it as an unsupported file format.
2021-07-11 02:03:31 +00:00
// - localhost URI, e.g. "http://127.0.0.1/" or "http://localhost/"
2021-07-11 02:31:37 +00:00
// --> Don't try to proxy these, as it will just fail.
2021-07-11 02:03:31 +00:00
// - Some domain extensions are reserved, e.g. .localdomain (how official is this?)
// - There can also be arbitrary hostnames mapped to local servers, which we can't test for
// - already a proxy URI, e.g. "https://cors.bridged.cc/https://example.com/image.png"
2021-07-11 02:31:37 +00:00
// - file already downloaded
// --> maybe should cache downloads? maybe HTTP caching is good enough? maybe uncommon enough that it doesn't matter.
// - Pasting (Edit > Paste or Ctrl+V) vs Opening (drag & drop, File > Open, Ctrl+O, or File > Load From URL)
// --> make wording generic or specific to the context
2021-07-11 02:03:31 +00:00
const is _blob _uri = uri . match ( /^blob:/i ) ;
const is _download = ! uri . match ( /^(blob|data|file):/i ) ;
const is _localhost = uri . match ( /^(http|https):\/\/((127\.0\.0\.1|localhost)|.*(\.(local|localdomain|domain|lan|home|host|corp|invalid)))\b/i ) ;
2020-05-11 05:12:42 +00:00
if ( is _blob _uri && uri . indexOf ( ` blob: ${ location . origin } ` ) === - 1 ) {
const error = new Error ( "can't load blob: URI from another domain" ) ;
2021-02-17 16:22:10 +00:00
error . code = "cross-origin-blob-uri" ;
2021-02-17 13:45:41 +00:00
throw error ;
2020-05-11 05:12:42 +00:00
}
2021-07-11 02:03:31 +00:00
const uris _to _try = ( is _download && ! is _localhost ) ? [
2020-05-11 05:12:42 +00:00
uri ,
// work around CORS headers not sent by whatever server
2021-07-11 00:02:26 +00:00
` https://cors.bridged.cc/ ${ uri } ` ,
2020-05-11 05:12:42 +00:00
` https://jspaint-cors-proxy.herokuapp.com/ ${ uri } ` ,
// if the image isn't available on the live web, see if it's archived
` https://web.archive.org/ ${ uri } ` ,
] : [ uri ] ;
2021-02-12 20:53:04 +00:00
const fails = [ ] ;
2020-05-11 05:12:42 +00:00
2021-02-17 13:45:41 +00:00
for ( let index _to _try = 0 ; index _to _try < uris _to _try . length ; index _to _try += 1 ) {
2021-02-12 21:23:13 +00:00
const uri _to _try = uris _to _try [ index _to _try ] ;
2021-02-17 13:45:41 +00:00
try {
2021-02-12 19:29:33 +00:00
if ( is _download ) {
2021-02-17 13:45:41 +00:00
$status _text . text ( "Downloading picture..." ) ;
2021-02-12 19:29:33 +00:00
}
2022-01-18 18:33:44 +00:00
const show _progress = ( { loaded , total } ) => {
2021-02-17 13:45:41 +00:00
if ( is _download ) {
2022-01-18 18:33:44 +00:00
$status _text . text ( ` Downloading picture... ( ${ Math . round ( loaded / total * 100 ) } %) ` ) ;
2021-02-17 13:45:41 +00:00
}
} ;
2020-05-11 05:12:42 +00:00
if ( is _download ) {
2022-01-18 18:33:44 +00:00
console . log ( ` Try loading image from URI ( ${ index _to _try + 1 } / ${ uris _to _try . length } ): " ${ uri _to _try } " ` ) ;
2020-05-11 05:12:42 +00:00
}
2021-02-17 13:45:41 +00:00
const original _response = await fetch ( uri _to _try ) ;
let response _to _read = original _response ;
if ( ! original _response . ok ) {
2022-01-18 18:33:44 +00:00
fails . push ( { status : original _response . status , statusText : original _response . statusText , url : uri _to _try } ) ;
2021-02-17 13:45:41 +00:00
continue ;
2020-05-11 05:12:42 +00:00
}
2021-02-17 13:45:41 +00:00
if ( ! original _response . body ) {
2020-05-11 18:21:52 +00:00
if ( is _download ) {
console . log ( "ReadableStream not yet supported in this browser. Progress won't be shown for image requests." ) ;
}
2021-02-17 13:45:41 +00:00
} else {
// to access headers, server must send CORS header "Access-Control-Expose-Headers: content-encoding, content-length x-file-size"
// server must send custom x-file-size header if gzip or other content-encoding is used
const contentEncoding = original _response . headers . get ( "content-encoding" ) ;
const contentLength = original _response . headers . get ( contentEncoding ? "x-file-size" : "content-length" ) ;
if ( contentLength === null ) {
if ( is _download ) {
console . log ( "Response size header unavailable. Progress won't be shown for this image request." ) ;
}
} else {
const total = parseInt ( contentLength , 10 ) ;
let loaded = 0 ;
response _to _read = new Response (
new ReadableStream ( {
start ( controller ) {
const reader = original _response . body . getReader ( ) ;
2022-01-18 18:33:44 +00:00
2021-02-17 13:45:41 +00:00
read ( ) ;
function read ( ) {
2022-01-18 18:33:44 +00:00
reader . read ( ) . then ( ( { done , value } ) => {
2021-02-17 13:45:41 +00:00
if ( done ) {
controller . close ( ) ;
2022-01-18 18:33:44 +00:00
return ;
2021-02-17 13:45:41 +00:00
}
loaded += value . byteLength ;
2022-01-18 18:33:44 +00:00
show _progress ( { loaded , total } )
2021-02-17 13:45:41 +00:00
controller . enqueue ( value ) ;
read ( ) ;
} ) . catch ( error => {
console . error ( error ) ;
2022-01-18 18:33:44 +00:00
controller . error ( error )
2021-02-17 13:45:41 +00:00
} )
}
}
} )
) ;
2020-05-11 18:21:52 +00:00
}
2020-05-11 05:12:42 +00:00
}
2022-01-18 18:33:44 +00:00
2021-02-17 13:45:41 +00:00
const blob = await response _to _read . blob ( ) ;
2020-05-11 05:12:42 +00:00
if ( is _download ) {
2020-05-11 18:21:52 +00:00
console . log ( "Download complete." ) ;
2020-05-11 05:12:42 +00:00
$status _text . text ( "Download complete." ) ;
}
2021-02-12 02:07:41 +00:00
// @TODO: use headers to detect HTML, since a doctype is not guaranteed
// @TODO: fall back to WayBack Machine still for decode errors,
// since a website might start redirecting swathes of URLs regardless of what they originally pointed to,
// at which point they would likely point to a web page instead of an image.
// (But still show an error about it not being an image, if WayBack also fails.)
2022-01-18 18:33:44 +00:00
const info = await new Promise ( ( resolve , reject ) => {
read _image _file ( blob , ( error , info ) => {
2021-02-17 13:45:41 +00:00
if ( error ) {
reject ( error ) ;
} else {
resolve ( info ) ;
}
} ) ;
2021-02-12 02:07:41 +00:00
} ) ;
2021-02-17 13:45:41 +00:00
return info ;
} catch ( error ) {
2022-01-18 18:33:44 +00:00
fails . push ( { url : uri _to _try , error } ) ;
2021-02-17 13:45:41 +00:00
}
}
if ( is _download ) {
$status _text . text ( "Failed to download picture." ) ;
}
2022-01-18 18:33:44 +00:00
const error = new Error ( ` failed to fetch image from any of ${ uris _to _try . length } URI(s): \n ${ fails . map ( ( fail ) =>
2021-02-17 13:45:41 +00:00
( fail . statusText ? ` ${ fail . status } ${ fail . statusText } ` : "" ) + fail . url + ( fail . error ? ` \n ${ fail . error } ` : "" )
) . join ( "\n " ) } ` );
error . code = "access-failure" ;
error . fails = fails ;
throw error ;
2014-05-04 13:32:02 +00:00
}
2018-01-11 18:14:49 +00:00
2022-01-18 18:33:44 +00:00
function open _from _image _info ( info , callback , canceled , into _existing _session , from _session _load ) {
2021-12-06 18:36:20 +00:00
are _you _sure ( ( { canvas _modified _while _loading } = { } ) => {
2021-02-12 02:07:41 +00:00
deselect ( ) ;
cancel ( ) ;
2021-07-30 08:34:41 +00:00
if ( ! into _existing _session ) {
$G . triggerHandler ( "session-update" ) ; // autosave old session
new _local _session ( ) ;
}
2021-02-12 02:07:41 +00:00
reset _file ( ) ;
reset _selected _colors ( ) ;
reset _canvas _and _history ( ) ; // (with newly reset colors)
set _magnification ( default _magnification ) ;
2021-02-11 22:26:49 +00:00
main _ctx . copy ( info . image || info . image _data ) ;
2021-02-12 02:07:41 +00:00
apply _file _format _and _palette _info ( info ) ;
2021-02-18 13:01:58 +00:00
transparency = has _any _transparency ( main _ctx ) ;
2021-02-12 02:07:41 +00:00
$canvas _area . trigger ( "resize" ) ;
current _history _node . name = localize ( "Open" ) ;
current _history _node . image _data = main _ctx . getImageData ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2021-02-13 04:22:37 +00:00
current _history _node . icon = get _help _folder _icon ( "p_open.png" ) ;
2021-02-12 02:07:41 +00:00
2021-12-06 18:36:20 +00:00
if ( canvas _modified _while _loading || ! from _session _load ) {
// normally we don't want to autosave if we're loading a session,
// as this is redundant, but if the user has modified the canvas while loading a session,
// right now how it works is the session would be overwritten, so if you reloaded, it'd be lost,
// so we'd better save it.
// (and we want to save if this is a new session being initialized with an image)
2021-07-30 08:34:41 +00:00
$G . triggerHandler ( "session-update" ) ; // autosave
}
2021-02-12 02:07:41 +00:00
$G . triggerHandler ( "history-update" ) ; // update history view
if ( info . source _blob instanceof File ) {
file _name = info . source _blob . name ;
2021-08-02 20:20:08 +00:00
// file.path is available in Electron (see https://www.electronjs.org/docs/api/file-object#file-object)
system _file _handle = info . source _blob . path ;
2021-03-23 01:23:45 +00:00
}
if ( info . source _file _handle ) {
2021-08-02 20:20:08 +00:00
system _file _handle = info . source _file _handle ;
2021-02-12 02:07:41 +00:00
}
saved = true ;
2021-08-12 01:24:46 +00:00
update _title ( ) ;
2021-02-12 02:07:41 +00:00
callback && callback ( ) ;
2021-12-06 18:36:20 +00:00
} , canceled , from _session _load ) ;
2021-02-12 02:07:41 +00:00
}
2022-08-02 07:44:19 +00:00
// Note: This function is part of the API.
2021-08-02 20:20:08 +00:00
function open _from _file ( file , source _file _handle ) {
2021-08-02 18:07:43 +00:00
// The browser isn't very smart about MIME types.
// It seems to look at the file extension, but not the actual file contents.
// This is particularly problematic for files with no extension, where file.type gives an empty string.
// And the File Access API currently doesn't let us automatically append a file extension,
// so the user is likely to end up with files with no extension.
// It's better to look at the file content to determine file type.
// We do this for image files in read_image_file, and palette files in AnyPalette.js.
if ( file . name . match ( /\.theme(pack)?$/i ) ) {
2022-01-18 18:33:44 +00:00
file . text ( ) . then ( load _theme _from _text , ( error ) => {
2021-03-23 01:23:45 +00:00
show _error _message ( localize ( "Paint cannot open this file." ) , error ) ;
} ) ;
2021-08-02 18:07:43 +00:00
return
2019-10-29 21:00:31 +00:00
}
2021-08-02 18:07:43 +00:00
// Try loading as an image file first, then as a palette file, but show a combined error message if both fail.
2022-01-18 18:33:44 +00:00
read _image _file ( file , ( as _image _error , image _info ) => {
2021-08-02 18:07:43 +00:00
if ( as _image _error ) {
2022-01-18 18:33:44 +00:00
AnyPalette . loadPalette ( file , ( as _palette _error , new _palette ) => {
2021-08-02 18:07:43 +00:00
if ( as _palette _error ) {
2022-01-18 18:33:44 +00:00
show _file _format _errors ( { as _image _error , as _palette _error } ) ;
2021-08-02 18:07:43 +00:00
return ;
}
2022-01-18 18:33:44 +00:00
palette = new _palette . map ( ( color ) => color . toString ( ) ) ;
2021-08-02 18:07:43 +00:00
$colorbox . rebuild _palette ( ) ;
2022-01-18 18:33:44 +00:00
window . console && console . log ( ` Loaded palette: ${ palette . map ( ( ) => ` %câ–ˆ ` ) . join ( "" ) } ` , ... palette . map ( ( color ) => ` color: ${ color } ; ` ) ) ;
2021-08-02 18:07:43 +00:00
} ) ;
return ;
}
2021-08-02 20:20:08 +00:00
image _info . source _file _handle = source _file _handle
2021-08-02 18:07:43 +00:00
open _from _image _info ( image _info ) ;
} ) ;
2018-01-11 18:14:49 +00:00
}
2020-03-24 20:45:43 +00:00
2021-02-12 02:07:41 +00:00
function apply _file _format _and _palette _info ( info ) {
2021-12-08 05:27:51 +00:00
file _format = info . file _format ;
if ( ! enable _palette _loading _from _indexed _images ) {
return ;
}
2021-02-12 02:07:41 +00:00
if ( info . palette ) {
2022-01-18 18:33:44 +00:00
window . console && console . log ( ` Loaded palette from image file: ${ info . palette . map ( ( ) => ` %câ–ˆ ` ) . join ( "" ) } ` , ... info . palette . map ( ( color ) => ` color: ${ color } ; ` ) ) ;
2021-02-12 02:07:41 +00:00
palette = info . palette ;
selected _colors . foreground = palette [ 0 ] ;
selected _colors . background = palette . length === 14 * 2 ? palette [ 14 ] : palette [ 1 ] ; // first in second row for default sized palette, else second color (debatable behavior; should it find a dark and a light color?)
$G . trigger ( "option-changed" ) ;
} else if ( monochrome && ! info . monochrome ) {
palette = default _palette ;
reset _selected _colors ( ) ;
}
$colorbox . rebuild _palette ( ) ;
monochrome = info . monochrome ;
}
2021-02-06 17:12:11 +00:00
2021-02-12 16:12:42 +00:00
function load _theme _from _text ( fileText ) {
2020-03-24 20:45:43 +00:00
var cssProperties = parseThemeFileString ( fileText ) ;
2021-11-20 02:32:39 +00:00
if ( ! cssProperties ) {
show _error _message ( localize ( "Paint cannot open this file." ) ) ;
return ;
}
2021-11-20 05:02:10 +00:00
applyCSSProperties ( cssProperties , { recurseIntoIframes : true } ) ;
2020-03-24 20:45:43 +00:00
window . themeCSSProperties = cssProperties ;
2022-01-18 18:33:44 +00:00
2020-05-29 19:41:07 +00:00
$G . triggerHandler ( "theme-load" ) ;
2014-05-04 13:32:02 +00:00
}
2022-01-18 18:33:44 +00:00
function file _new ( ) {
2019-10-29 18:46:29 +00:00
are _you _sure ( ( ) => {
2019-12-08 14:07:31 +00:00
deselect ( ) ;
cancel ( ) ;
2018-01-24 21:58:12 +00:00
2021-07-30 08:34:41 +00:00
$G . triggerHandler ( "session-update" ) ; // autosave old session
new _local _session ( ) ;
2014-11-29 02:27:51 +00:00
reset _file ( ) ;
2021-02-12 02:07:41 +00:00
reset _selected _colors ( ) ;
2019-10-29 20:37:53 +00:00
reset _canvas _and _history ( ) ; // (with newly reset colors)
2019-10-30 17:38:16 +00:00
set _magnification ( default _magnification ) ;
2019-12-08 14:07:31 +00:00
$G . triggerHandler ( "session-update" ) ; // autosave
2014-11-29 02:27:51 +00:00
} ) ;
2014-05-04 13:32:02 +00:00
}
2022-01-18 18:33:44 +00:00
async function file _open ( ) {
const { file , fileHandle } = await systemHooks . showOpenFileDialog ( { formats : image _formats } )
2021-08-02 20:20:08 +00:00
open _from _file ( file , fileHandle ) ;
2014-05-04 13:32:02 +00:00
}
2019-10-29 20:29:38 +00:00
let $file _load _from _url _window ;
2022-01-18 18:33:44 +00:00
function file _load _from _url ( ) {
if ( $file _load _from _url _window ) {
2018-01-10 03:42:38 +00:00
$file _load _from _url _window . close ( ) ;
}
2021-12-07 22:07:12 +00:00
const $w = new $DialogWindow ( ) . addClass ( "horizontal-buttons" ) ;
2018-01-10 03:42:38 +00:00
$file _load _from _url _window = $w ;
$w . title ( "Load from URL" ) ;
2020-01-05 22:27:51 +00:00
// @TODO: URL validation (input has to be in a form (and we don't want the form to submit))
2021-12-07 22:42:52 +00:00
$w . $main . html ( `
< div style = 'padding: 10px;' >
< label style = "display: block; margin-bottom: 5px;" for = "url-input" > Paste or type the web address of an image : < / l a b e l >
< input type = "url" required value = "" id = "url-input" class = "inset-deep" style = "width: 300px;" / > < / l a b e l >
< / d i v >
` );
const $input = $w . $main . find ( "#url-input" ) ;
// $w.$Button("Load", () => {
$w . $Button ( localize ( "Open" ) , ( ) => {
2021-02-12 16:12:42 +00:00
const uris = get _uris ( $input . val ( ) ) ;
2019-09-17 20:31:49 +00:00
if ( uris . length > 0 ) {
2020-01-05 22:27:51 +00:00
// @TODO: retry loading if same URL entered
2019-09-17 22:51:10 +00:00
// actually, make it change the hash only after loading successfully
// (but still load from the hash when necessary)
// make sure it doesn't overwrite the old session before switching
$w . close ( ) ;
2020-05-10 02:40:05 +00:00
change _url _param ( "load" , uris [ 0 ] ) ;
2019-09-17 20:31:49 +00:00
} else {
show _error _message ( "Invalid URL. It must include a protocol (https:// or http://)" ) ;
}
2023-02-13 09:56:08 +00:00
} , { type : "submit" } ) ;
2020-12-05 22:33:21 +00:00
$w . $Button ( localize ( "Cancel" ) , ( ) => {
2018-01-10 03:42:38 +00:00
$w . close ( ) ;
} ) ;
$w . center ( ) ;
2019-09-28 17:16:18 +00:00
$input [ 0 ] . focus ( ) ;
2018-01-10 03:42:38 +00:00
}
2021-08-02 16:42:50 +00:00
// Native FS API / File Access API allows you to overwrite files, but people are not used to it.
// So we ask them to confirm it the first time.
2022-01-13 04:14:17 +00:00
let acknowledged _overwrite _capability = false ;
2021-08-02 16:42:50 +00:00
const confirmed _overwrite _key = "jspaint confirmed overwrite capable" ;
try {
2022-01-13 04:14:17 +00:00
acknowledged _overwrite _capability = localStorage [ confirmed _overwrite _key ] === "true" ;
2021-08-02 16:42:50 +00:00
} catch ( error ) {
// no localStorage
// In the year 2033, people will be more used to it, right?
// This will be known as the "Y2T bug"
2022-01-13 04:14:17 +00:00
acknowledged _overwrite _capability = Date . now ( ) >= 2000000000000 ;
2021-08-02 16:42:50 +00:00
}
2022-01-13 04:14:17 +00:00
async function confirm _overwrite _capability ( ) {
if ( acknowledged _overwrite _capability ) {
2022-01-13 04:37:46 +00:00
return true ;
2021-11-26 23:37:58 +00:00
}
const { $window , promise } = showMessageBox ( {
messageHTML : `
2021-08-02 16:42:50 +00:00
< p > JS Paint can now save over existing files . < / p >
< p > Do you want to overwrite the file ? < / p >
< p >
2024-01-30 04:34:50 +00:00
< input type = "checkbox" id = "do-not-ask-me-again-checkbox" / >
< label for = "do-not-ask-me-again-checkbox" > Don ' t ask me again < / l a b e l >
2021-08-02 16:42:50 +00:00
< / p >
2021-11-26 23:37:58 +00:00
` ,
buttons : [
{ label : localize ( "Yes" ) , value : "overwrite" , default : true } ,
{ label : localize ( "Cancel" ) , value : "cancel" } ,
] ,
2021-08-02 16:42:50 +00:00
} ) ;
2021-11-26 23:37:58 +00:00
const result = await promise ;
if ( result === "overwrite" ) {
2024-01-30 04:34:50 +00:00
acknowledged _overwrite _capability = $window . $content . find ( "#do-not-ask-me-again-checkbox" ) . prop ( "checked" ) ;
2021-11-26 23:37:58 +00:00
try {
2022-01-13 04:14:17 +00:00
localStorage [ confirmed _overwrite _key ] = acknowledged _overwrite _capability ;
2021-11-26 23:37:58 +00:00
} catch ( error ) {
// no localStorage... @TODO: don't show the checkbox in this case
}
2022-01-13 04:37:46 +00:00
return true ;
2021-11-26 23:37:58 +00:00
}
2022-01-13 04:37:46 +00:00
return false ;
2021-08-02 16:42:50 +00:00
}
2022-01-18 18:33:44 +00:00
function file _save ( maybe _saved _callback = ( ) => { } , update _from _saved = true ) {
2018-01-18 07:37:47 +00:00
deselect ( ) ;
2021-08-02 20:20:08 +00:00
// store and use file handle at this point in time, to avoid race conditions
const save _file _handle = system _file _handle ;
2022-01-18 18:33:44 +00:00
if ( ! save _file _handle || file _name . match ( /\.(svg|pdf)$/i ) ) {
2021-02-13 20:28:48 +00:00
return file _save _as ( maybe _saved _callback , update _from _saved ) ;
2021-01-29 23:48:40 +00:00
}
2021-08-02 20:20:08 +00:00
write _image _file ( main _canvas , file _format , async ( blob ) => {
await systemHooks . writeBlobToHandle ( save _file _handle , blob ) ;
if ( update _from _saved ) {
update _from _saved _file ( blob ) ;
2021-02-13 20:28:48 +00:00
}
2021-08-02 20:20:08 +00:00
maybe _saved _callback ( ) ;
2021-02-04 06:28:13 +00:00
} ) ;
2014-05-04 13:32:02 +00:00
}
2024-02-02 20:30:04 +00:00
function print _zebra ( ) {
write _image _file ( main _canvas , 'image/png' , ( blob ) => {
console . log ( blob )
2024-02-02 21:21:56 +00:00
fetch ( ` /print?printer=zebra ` , {
2024-02-02 20:30:04 +00:00
method : 'POST' ,
2024-02-02 21:21:56 +00:00
headers : { 'content-type' : 'image/png' } ,
2024-02-02 20:30:04 +00:00
body : blob ,
} ) . then ( response => {
console . log ( { response } )
} )
} ) ;
}
2022-01-18 18:33:44 +00:00
function file _save _as ( maybe _saved _callback = ( ) => { } , update _from _saved = true ) {
2018-01-18 07:37:47 +00:00
deselect ( ) ;
2021-08-03 10:28:19 +00:00
systemHooks . showSaveFileDialog ( {
2021-03-23 01:23:45 +00:00
dialogTitle : localize ( "Save As" ) ,
formats : image _formats ,
2021-03-25 02:40:55 +00:00
defaultFileName : file _name ,
2021-08-02 20:20:08 +00:00
defaultPath : typeof system _file _handle === "string" ? system _file _handle : null ,
2021-03-23 01:23:45 +00:00
defaultFileFormatID : file _format ,
2022-01-18 18:33:44 +00:00
getBlob : ( new _file _type ) => {
return new Promise ( ( resolve ) => {
write _image _file ( main _canvas , new _file _type , ( blob ) => {
2021-03-23 01:23:45 +00:00
resolve ( blob ) ;
} ) ;
} ) ;
} ,
2022-01-18 18:33:44 +00:00
savedCallbackUnreliable : ( { newFileName , newFileFormatID , newFileHandle , newBlob } ) => {
2021-03-23 01:23:45 +00:00
saved = true ;
2021-08-02 20:20:08 +00:00
system _file _handle = newFileHandle ;
2021-03-23 01:23:45 +00:00
file _name = newFileName ;
2021-04-01 04:17:11 +00:00
file _format = newFileFormatID ;
2021-03-23 01:23:45 +00:00
update _title ( ) ;
maybe _saved _callback ( ) ;
if ( update _from _saved ) {
update _from _saved _file ( newBlob ) ;
}
}
} ) ;
2014-05-04 13:32:02 +00:00
}
2021-12-06 18:36:20 +00:00
function are _you _sure ( action , canceled , from _session _load ) {
2021-11-26 23:37:58 +00:00
if ( saved ) {
2015-02-23 21:16:21 +00:00
action ( ) ;
2021-12-06 18:36:20 +00:00
} else if ( from _session _load ) {
showMessageBox ( {
message : localize ( "You've modified the document while an existing document was loading.\nSave the new document?" , file _name ) ,
buttons : [
{
// label: localize("Save"),
label : localize ( "Yes" ) ,
value : "save" ,
default : true ,
} ,
{
// label: "Discard",
label : localize ( "No" ) ,
value : "discard" ,
} ,
] ,
// @TODO: not closable with Escape or close button
} ) . then ( ( result ) => {
if ( result === "save" ) {
file _save ( ( ) => {
action ( ) ;
} , false ) ;
} else if ( result === "discard" ) {
action ( { canvas _modified _while _loading : true } ) ;
} else {
// should not ideally happen
// but prefer to preserve the previous document,
// as the user has only (probably) as small window to make changes while loading,
// whereas there could be any amount of work put into the document being loaded.
// @TODO: could show dialog again, but making it un-cancelable would be better.
action ( ) ;
}
} ) ;
2021-11-26 23:37:58 +00:00
} else {
showMessageBox ( {
message : localize ( "Save changes to %1?" , file _name ) ,
buttons : [
{
// label: localize("Save"),
label : localize ( "Yes" ) ,
value : "save" ,
default : true ,
} ,
{
// label: "Discard",
label : localize ( "No" ) ,
value : "discard" ,
} ,
{
label : localize ( "Cancel" ) ,
value : "cancel" ,
} ,
] ,
} ) . then ( ( result ) => {
if ( result === "save" ) {
file _save ( ( ) => {
action ( ) ;
} , false ) ;
} else if ( result === "discard" ) {
2020-12-18 19:18:08 +00:00
action ( ) ;
2021-11-26 23:37:58 +00:00
} else {
canceled ? . ( ) ;
}
2014-05-04 13:32:02 +00:00
} ) ;
}
}
2021-04-01 19:37:04 +00:00
function please _enter _a _number ( ) {
2021-11-26 23:37:58 +00:00
showMessageBox ( {
// title: "Invalid Value",
message : localize ( "Please enter a number." ) ,
} ) ;
2021-04-01 19:37:04 +00:00
}
2022-08-02 07:44:19 +00:00
// Note: This function is part of the API.
2021-11-26 23:37:58 +00:00
function show _error _message ( message , error ) {
2022-02-25 02:05:17 +00:00
// Test global error handling resiliency by enabling one or both of these:
// Promise.reject(new Error("EMIT EMIT EMIT"));
// throw new Error("EMIT EMIT EMIT");
// It should fall back to an alert.
// EMIT stands for "Error Message Itself Test".
2021-11-26 23:37:58 +00:00
const { $message } = showMessageBox ( {
iconID : "error" ,
message ,
// windowOptions: {
// innerWidth: 600,
// },
} ) ;
// $message.css("max-width", "600px");
2022-01-18 18:33:44 +00:00
if ( error ) {
2021-02-11 18:53:45 +00:00
const $details = $ ( "<details><summary><span>Details</span></summary></details>" )
2022-01-18 18:33:44 +00:00
. appendTo ( $message ) ;
2021-02-11 18:53:45 +00:00
2021-04-01 20:13:31 +00:00
// Chrome includes the error message in the error.stack string, whereas Firefox doesn't.
// Also note that there can be Exception objects that don't have a message (empty string) but a name,
// for instance Exception { message: "", name: "NS_ERROR_FAILURE", ... } for out of memory when resizing the canvas too large in Firefox.
2021-06-19 23:58:47 +00:00
// Chrome just lets you bring the system to a grating halt by trying to grab too much memory.
2021-04-01 20:13:31 +00:00
// Firefox does too sometimes.
2021-02-11 17:44:05 +00:00
let error _string = error . stack ;
if ( ! error _string ) {
error _string = error . toString ( ) ;
2021-04-01 20:13:31 +00:00
} else if ( error . message && error _string . indexOf ( error . message ) === - 1 ) {
2021-02-11 17:44:05 +00:00
error _string = ` ${ error . toString ( ) } \n \n ${ error _string } ` ;
2021-04-01 20:13:31 +00:00
} else if ( error . name && error _string . indexOf ( error . name ) === - 1 ) {
error _string = ` ${ error . name } \n \n ${ error _string } ` ;
2021-02-11 17:44:05 +00:00
}
2018-01-10 06:27:54 +00:00
$ ( E ( "pre" ) )
2022-01-18 18:33:44 +00:00
. text ( error _string )
. appendTo ( $details )
. css ( {
background : "white" ,
color : "#333" ,
// background: "#A00",
// color: "white",
fontFamily : "monospace" ,
width : "500px" ,
maxWidth : "100%" ,
overflow : "auto" ,
} ) ;
2018-01-10 06:27:54 +00:00
}
2019-12-03 03:35:31 +00:00
if ( error ) {
2021-11-26 23:37:58 +00:00
window . console ? . error ? . ( message , error ) ;
2019-12-03 03:35:31 +00:00
} else {
2021-11-26 23:37:58 +00:00
window . console ? . error ? . ( message ) ;
2019-12-03 03:35:31 +00:00
}
2017-01-31 22:36:09 +00:00
}
2020-01-05 22:27:51 +00:00
// @TODO: close are_you_sure windows and these Error windows when switching sessions
2018-01-11 07:45:26 +00:00
// because it can get pretty confusing
2022-01-18 18:33:44 +00:00
function show _resource _load _error _message ( error ) {
2021-11-26 23:37:58 +00:00
const { $window , $message } = showMessageBox ( { } ) ;
2020-05-11 05:12:42 +00:00
const firefox = navigator . userAgent . toLowerCase ( ) . indexOf ( "firefox" ) > - 1 ;
2021-02-12 20:53:04 +00:00
// @TODO: copy & paste vs download & open, more specific guidance
2021-02-17 16:22:10 +00:00
if ( error . code === "cross-origin-blob-uri" ) {
2021-11-26 23:37:58 +00:00
$message . html ( `
2020-05-11 05:12:42 +00:00
< p > Can ' t load image from address starting with "blob:" . < / p >
2022-01-18 18:33:44 +00:00
$ { firefox ?
` <p>Try "Copy Image" instead of "Copy Image Location".</p> ` :
` <p>Try "Copy image" instead of "Copy image address".</p> `
2020-05-11 05:12:42 +00:00
}
` );
2020-05-11 14:58:43 +00:00
} else if ( error . code === "html-not-image" ) {
2021-11-26 23:37:58 +00:00
$message . html ( `
2020-05-11 14:58:43 +00:00
< p > Address points to a web page , not an image file . < / p >
< p > Try copying and pasting an image instead of a URL . < / p >
` );
2021-02-12 20:53:04 +00:00
} else if ( error . code === "decoding-failure" ) {
2021-11-26 23:37:58 +00:00
$message . html ( `
2020-05-11 14:58:43 +00:00
< p > Address doesn ' t point to an image file of a supported format . < / p >
2020-05-11 06:01:58 +00:00
< p > Try copying and pasting an image instead of a URL . < / p >
` );
2021-02-12 20:53:04 +00:00
} else if ( error . code === "access-failure" ) {
if ( navigator . onLine ) {
2021-11-26 23:37:58 +00:00
$message . html ( `
2021-02-12 20:53:04 +00:00
< p > Failed to download image . < / p >
< p > Try copying and pasting an image instead of a URL . < / p >
` );
if ( error . fails ) {
2022-01-18 18:33:44 +00:00
$ ( "<ul>" ) . append ( error . fails . map ( ( { status , statusText , url } ) =>
2021-02-12 20:53:04 +00:00
$ ( "<li>" ) . text ( url ) . prepend ( $ ( "<b>" ) . text ( ` ${ status || "" } ${ statusText || "Failed" } ` ) )
2021-11-26 23:37:58 +00:00
) ) . appendTo ( $message ) ;
2021-02-12 20:53:04 +00:00
}
} else {
2021-11-26 23:37:58 +00:00
$message . html ( `
2021-02-12 20:53:04 +00:00
< p > Failed to download image . < / p >
< p > You ' re offline . Connect to the internet and try again . < / p >
< p > Or copy and paste an image instead of a URL , if possible . < / p >
` );
}
2020-05-11 05:12:42 +00:00
} else {
2021-11-26 23:37:58 +00:00
$message . html ( `
2020-05-11 05:12:42 +00:00
< p > Failed to load image from URL . < / p >
< p > Check your browser ' s devtools for details . < / p >
` );
}
2021-11-26 23:37:58 +00:00
$message . css ( { maxWidth : "500px" } ) ;
$window . center ( ) ; // after adding content
2018-01-11 07:45:26 +00:00
}
2021-08-02 18:07:43 +00:00
function show _file _format _errors ( { as _image _error , as _palette _error } ) {
let html = `
< p > $ { localize ( "Paint cannot open this file." ) } < / p >
` ;
if ( as _image _error ) {
// TODO: handle weird errors, only show invalid format error if that's what happened
html += `
< details >
< summary > $ { localize ( "Bitmap Image" ) } < / s u m m a r y >
< p > $ { localize ( "This is not a valid bitmap file, or its format is not currently supported." ) } < / p >
< / d e t a i l s >
` ;
}
var entity _map = {
'&' : '&' ,
'<' : '<' ,
'>' : '>' ,
'"' : '"' ,
"'" : ''' ,
'/' : '/' ,
'`' : '`' ,
'=' : '=' ,
} ;
2021-08-03 03:32:03 +00:00
const escape _html = ( string ) => String ( string ) . replace ( /[&<>"'`=/]/g , ( s ) => entity _map [ s ] ) ;
2021-08-02 19:23:07 +00:00
const uppercase _first = ( string ) => string . charAt ( 0 ) . toUpperCase ( ) + string . slice ( 1 ) ;
2021-08-02 18:07:43 +00:00
2021-08-03 00:25:48 +00:00
const only _palette _error = as _palette _error && ! as _image _error ; // update me if there are more error types
2021-08-02 18:07:43 +00:00
if ( as _palette _error ) {
let details = "" ;
2021-08-02 19:28:44 +00:00
if ( as _palette _error . errors ) {
2021-08-02 19:23:07 +00:00
details = ` <ul dir="ltr"> ${ as _palette _error . errors . map ( ( error ) => {
const format = error . _ _PATCHED _LIB _TO _ADD _THIS _ _format ;
if ( format && error . error ) {
2021-08-03 00:25:48 +00:00
return ` <li><b> ${ escape _html ( ` ${ format . name } ` ) } </b>: ${ escape _html ( uppercase _first ( error . error . message ) ) } </li> ` ;
2021-08-02 19:23:07 +00:00
}
// Fallback for unknown errors
return ` <li> ${ escape _html ( error . message || error ) } </li> ` ;
} ) . join ( "\n" ) } < / u l > ` ;
2021-08-02 18:07:43 +00:00
} else {
2021-08-02 19:28:44 +00:00
// Fallback for unknown errors
2021-08-02 18:07:43 +00:00
details = ` <p> ${ escape _html ( as _palette _error . message || as _palette _error ) } </p> ` ;
}
html += `
< details >
2021-08-03 00:25:48 +00:00
< summary > $ { only _palette _error ? "Details" : localize ( "Palette|*.pal|" ) . split ( "|" ) [ 0 ] } < / s u m m a r y >
2021-08-02 18:07:43 +00:00
< p > $ { localize ( "Unexpected file format." ) } < / p >
$ { details }
< / d e t a i l s >
` ;
}
2021-11-26 23:37:58 +00:00
showMessageBox ( {
messageHTML : html ,
} ) ;
2021-08-02 18:07:43 +00:00
}
2018-01-11 07:45:26 +00:00
2019-10-29 20:29:38 +00:00
let $about _paint _window ;
const $about _paint _content = $ ( "#about-paint" ) ;
2021-08-01 22:08:31 +00:00
2019-10-29 20:29:38 +00:00
let $news _window ;
const $this _version _news = $ ( "#news" ) ;
let $latest _news = $this _version _news ;
2019-10-10 03:33:39 +00:00
// not included directly in the HTML as a simple way of not showing it if it's loaded with fetch
// (...not sure how to phrase this clearly and concisely...)
2019-10-26 21:28:48 +00:00
// "Showing the news as of this version of JS Paint. For the latest, see <a href='https://jspaint.app'>jspaint.app</a>"
2019-12-22 05:09:24 +00:00
if ( location . origin !== "https://jspaint.app" ) {
$this _version _news . prepend (
$ ( "<p>For the latest news, visit <a href='https://jspaint.app'>jspaint.app</a></p>" )
2022-01-18 18:33:44 +00:00
. css ( { padding : "8px 15px" } )
2019-12-22 05:09:24 +00:00
) ;
}
2019-10-10 03:33:39 +00:00
2022-01-18 18:33:44 +00:00
function show _about _paint ( ) {
if ( $about _paint _window ) {
2018-07-06 05:45:51 +00:00
$about _paint _window . close ( ) ;
}
2021-09-02 22:19:51 +00:00
$about _paint _window = $Window ( {
title : localize ( "About Paint" ) ,
resizable : false ,
maximizeButton : false ,
minimizeButton : false ,
} ) ;
2021-01-30 01:06:31 +00:00
$about _paint _window . addClass ( "about-paint squish" ) ;
2019-11-10 15:17:32 +00:00
if ( is _pride _month ) {
$ ( "#paint-32x32" ) . attr ( "src" , "./images/icons/gay-es-paint-32x32-light-outline.png" ) ;
}
2019-10-10 03:33:39 +00:00
2022-01-18 18:33:44 +00:00
$about _paint _window . $content . append ( $about _paint _content . show ( ) ) . css ( { padding : "15px" } ) ;
2019-10-10 16:19:20 +00:00
2019-10-10 03:33:39 +00:00
$ ( "#maybe-outdated-view-project-news" ) . removeAttr ( "hidden" ) ;
2019-10-10 16:19:20 +00:00
2019-10-10 03:33:39 +00:00
$ ( "#failed-to-check-if-outdated" ) . attr ( "hidden" , "hidden" ) ;
2019-10-10 16:19:20 +00:00
$ ( "#outdated" ) . attr ( "hidden" , "hidden" ) ;
2019-10-10 03:33:39 +00:00
2018-07-06 05:45:51 +00:00
$about _paint _window . center ( ) ;
2020-01-05 22:27:51 +00:00
$about _paint _window . center ( ) ; // @XXX - but it helps tho
2019-10-10 03:33:39 +00:00
2021-08-01 22:08:31 +00:00
$about _paint _window . $Button ( localize ( "OK" ) , ( ) => {
$about _paint _window . close ( ) ;
} )
2021-08-01 22:29:13 +00:00
. attr ( "id" , "close-about-paint" )
2021-08-01 22:08:31 +00:00
. focus ( )
. css ( {
float : "right" ,
marginBottom : "10px" ,
} ) ;
2022-01-18 18:33:44 +00:00
$ ( "#refresh-to-update" ) . on ( "click" , ( event ) => {
2019-12-18 05:42:19 +00:00
event . preventDefault ( ) ;
2021-11-29 04:12:43 +00:00
are _you _sure ( ( ) => {
2021-12-19 18:58:08 +00:00
exit _fullscreen _if _ios ( ) ;
2021-11-29 04:12:43 +00:00
location . reload ( ) ;
} ) ;
2019-10-10 04:31:32 +00:00
} ) ;
2022-01-18 18:33:44 +00:00
2021-08-01 22:08:31 +00:00
$ ( "#view-project-news" ) . on ( "click" , ( ) => {
2019-10-10 04:31:32 +00:00
show _news ( ) ;
2021-08-01 22:08:31 +00:00
} ) ; //.focus();
2022-01-18 18:33:44 +00:00
2019-10-10 15:23:51 +00:00
$ ( "#checking-for-updates" ) . removeAttr ( "hidden" ) ;
2019-10-29 20:29:38 +00:00
const url =
2019-10-10 03:33:39 +00:00
// ".";
// "test-news-newer.html";
"https://jspaint.app" ;
fetch ( url )
2022-01-18 18:33:44 +00:00
. then ( ( response ) => response . text ( ) )
. then ( ( text ) => {
const parser = new DOMParser ( ) ;
const htmlDoc = parser . parseFromString ( text , "text/html" ) ;
$latest _news = $ ( htmlDoc ) . find ( "#news" ) ;
const $latest _entries = $latest _news . find ( ".news-entry" ) ;
const $this _version _entries = $this _version _news . find ( ".news-entry" ) ;
if ( ! $latest _entries . length ) {
$latest _news = $this _version _news ;
throw new Error ( ` No news found at fetched site ( ${ url } ) ` ) ;
}
2019-10-10 03:33:39 +00:00
2022-01-18 18:33:44 +00:00
function entries _contains _update ( $entries , id ) {
return $entries . get ( ) . some ( ( el _from _this _version ) =>
id === el _from _this _version . id
) ;
}
2019-10-10 03:33:39 +00:00
2022-01-18 18:33:44 +00:00
// @TODO: visibly mark entries that overlap
const entries _newer _than _this _version =
$latest _entries . get ( ) . filter ( ( el _from _latest ) =>
! entries _contains _update ( $this _version _entries , el _from _latest . id )
) ;
2019-10-10 03:33:39 +00:00
2022-01-18 18:33:44 +00:00
const entries _new _in _this _version = // i.e. in development, when updating the news
$this _version _entries . get ( ) . filter ( ( el _from _latest ) =>
! entries _contains _update ( $latest _entries , el _from _latest . id )
) ;
2019-10-10 03:33:39 +00:00
2022-01-18 18:33:44 +00:00
if ( entries _newer _than _this _version . length > 0 ) {
$ ( "#outdated" ) . removeAttr ( "hidden" ) ;
} else if ( entries _new _in _this _version . length > 0 ) {
$latest _news = $this _version _news ; // show this version's news for development
}
2019-10-10 03:33:39 +00:00
2022-01-18 18:33:44 +00:00
$ ( "#checking-for-updates" ) . attr ( "hidden" , "hidden" ) ;
update _css _classes _for _conditional _messages ( ) ;
} ) . catch ( ( exception ) => {
$ ( "#failed-to-check-if-outdated" ) . removeAttr ( "hidden" ) ;
$ ( "#checking-for-updates" ) . attr ( "hidden" , "hidden" ) ;
update _css _classes _for _conditional _messages ( ) ;
window . console && console . log ( "Couldn't check for updates." , exception ) ;
} ) ;
2018-07-06 05:45:51 +00:00
}
2021-12-19 18:58:08 +00:00
function exit _fullscreen _if _ios ( ) {
if ( $ ( "body" ) . hasClass ( "ios" ) ) {
try {
if ( document . exitFullscreen ) {
document . exitFullscreen ( ) ;
} else if ( document . webkitExitFullscreen ) {
document . webkitExitFullscreen ( ) ;
} else if ( document . mozCancelFullScreen ) {
document . mozCancelFullScreen ( ) ;
} else if ( document . msExitFullscreen ) {
document . msExitFullscreen ( ) ;
}
} catch ( error ) {
// not important, just trying to prevent broken fullscreen after refresh
2021-12-20 07:20:24 +00:00
// (:fullscreen and document.fullscreenElement stops working because it's not "requested by the page" anymore)
2021-12-19 18:58:08 +00:00
// (the fullscreen styling is not generally obtrusive, but it is obtrusive when it DOESN'T work)
2021-12-20 07:20:24 +00:00
//
// alternatives:
// - detect reload-while-fullscreen by storing a timestamp on unload when fullscreen,
// and apply the fullscreen class if timestamp is within a few seconds during load.
// - This doesn't have an answer for detecting leaving fullscreen,
// and if it keeps thinking it's fullscreen, it'll keep storing the timestamp, and get stuck.
// Unless it only stores the timestamp if it knows it's fullscreen? (i.e. page-requested fullscreen)
// Then it would only work for one reload.
// So ideally it would have the below anyway, in which case this would be unnecessary.
// - detect fullscreen state without fullscreen API, using viewport size
// - If this is possible, why don't browsers just expose this information in the fullscreen API? :(
// - iPad resets the zoom level when going fullscreen, and then when reloading,
// the zoom level is reset to the user-set zoom level.
// Safari doesn't update devicePixelRatio based on the zoom level,
// and doesn't support ResizeObserver for device pixels.
// It does support https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API
// though, so maybe something can be done with that.
// - prompt to add to homescreen
2021-12-19 18:58:08 +00:00
}
}
}
2019-10-10 03:33:39 +00:00
// show_about_paint(); // for testing
function update _css _classes _for _conditional _messages ( ) {
$ ( ".on-dev-host, .on-third-party-host, .on-official-host" ) . hide ( ) ;
if ( location . hostname . match ( /localhost|127.0.0.1/ ) ) {
$ ( ".on-dev-host" ) . show ( ) ;
} else if ( location . hostname . match ( /jspaint.app/ ) ) {
$ ( ".on-official-host" ) . show ( ) ;
} else {
$ ( ".on-third-party-host" ) . show ( ) ;
}
$ ( ".navigator-online, .navigator-offline" ) . hide ( ) ;
if ( navigator . onLine ) {
$ ( ".navigator-online" ) . show ( ) ;
} else {
$ ( ".navigator-offline" ) . show ( ) ;
}
}
2022-01-18 18:33:44 +00:00
function show _news ( ) {
if ( $news _window ) {
2019-10-10 03:33:39 +00:00
$news _window . close ( ) ;
}
2021-09-02 22:19:51 +00:00
$news _window = $Window ( {
title : "Project News" ,
maximizeButton : false ,
minimizeButton : false ,
resizable : false ,
} ) ;
2021-01-30 01:06:31 +00:00
$news _window . addClass ( "news-window squish" ) ;
2019-10-10 03:33:39 +00:00
2019-11-03 15:36:34 +00:00
// const $latest_entries = $latest_news.find(".news-entry");
// const latest_entry = $latest_entries[$latest_entries.length - 1];
2019-12-14 22:49:23 +00:00
// window.console && console.log("LATEST MEWS:", $latest_news);
// window.console && console.log("LATEST ENTRY:", latest_entry);
2019-10-10 03:33:39 +00:00
2019-12-22 04:42:31 +00:00
const $latest _news _style = $latest _news . find ( "style" ) ;
2019-10-10 04:02:55 +00:00
$this _version _news . find ( "style" ) . remove ( ) ;
$latest _news . append ( $latest _news _style ) ; // in case $this_version_news is $latest_news
2019-10-10 03:33:39 +00:00
$news _window . $content . append ( $latest _news . removeAttr ( "hidden" ) ) ;
$news _window . center ( ) ;
2020-01-05 22:27:51 +00:00
$news _window . center ( ) ; // @XXX - but it helps tho
2021-04-01 19:07:46 +00:00
$latest _news . attr ( "tabIndex" , "-1" ) . focus ( ) ;
2019-10-10 03:33:39 +00:00
}
2018-07-06 05:45:51 +00:00
2020-01-05 22:27:51 +00:00
// @TODO: DRY between these functions and open_from_* functions further?
2018-01-11 07:45:26 +00:00
2022-01-18 18:33:44 +00:00
function paste _image _from _file ( blob ) {
2021-02-12 02:07:41 +00:00
read _image _file ( blob , ( error , info ) => {
if ( error ) {
2022-01-18 18:33:44 +00:00
show _file _format _errors ( { as _image _error : error } ) ;
2021-02-12 02:07:41 +00:00
return ;
}
2021-02-11 22:26:49 +00:00
paste ( info . image || make _canvas ( info . image _data ) ) ;
2018-01-11 18:14:49 +00:00
} ) ;
2014-08-18 19:40:34 +00:00
}
2021-02-12 02:07:41 +00:00
// Edit > Paste From
2021-03-23 01:23:45 +00:00
async function choose _file _to _paste ( ) {
2022-01-18 18:33:44 +00:00
const { file } = await systemHooks . showOpenFileDialog ( { formats : image _formats } ) ;
2021-07-10 21:57:33 +00:00
if ( file . type . match ( /^image|application\/pdf/ ) ) {
2021-03-23 01:23:45 +00:00
paste _image _from _file ( file ) ;
return ;
}
show _error _message ( localize ( "This is not a valid bitmap file, or its format is not currently supported." ) ) ;
2014-08-18 19:40:34 +00:00
}
2022-01-18 18:33:44 +00:00
function paste ( img _or _canvas ) {
2018-01-24 21:58:12 +00:00
2022-01-18 18:33:44 +00:00
if ( img _or _canvas . width > main _canvas . width || img _or _canvas . height > main _canvas . height ) {
2021-11-26 23:37:58 +00:00
showMessageBox ( {
message : localize ( "The image in the clipboard is larger than the bitmap." ) + "\n" +
localize ( "Would you like the bitmap enlarged?" ) ,
iconID : "question" ,
windowOptions : {
icons : {
16 : "images/windows-16x16.png" ,
32 : "images/windows-32x32.png" ,
} ,
} ,
buttons : [
2020-12-04 06:09:43 +00:00
{
2021-11-26 23:37:58 +00:00
// label: "Enlarge",
label : localize ( "Yes" ) ,
value : "enlarge" ,
default : true ,
} ,
{
// label: "Crop",
label : localize ( "No" ) ,
value : "crop" ,
} ,
{
label : localize ( "Cancel" ) ,
value : "cancel" ,
} ,
] ,
} ) . then ( ( result ) => {
if ( result === "enlarge" ) {
// The resize gets its own undoable, as in mspaint
resize _canvas _and _save _dimensions (
Math . max ( main _canvas . width , img _or _canvas . width ) ,
Math . max ( main _canvas . height , img _or _canvas . height ) ,
{
name : "Enlarge Canvas For Paste" ,
icon : get _help _folder _icon ( "p_stretch_both.png" ) ,
}
) ;
do _the _paste ( ) ;
$canvas _area . trigger ( "resize" ) ;
} else if ( result === "crop" ) {
do _the _paste ( ) ;
}
2015-10-12 15:36:25 +00:00
} ) ;
2022-01-18 18:33:44 +00:00
} else {
2018-01-11 07:45:26 +00:00
do _the _paste ( ) ;
2014-08-18 19:40:34 +00:00
}
2018-01-24 21:58:12 +00:00
2022-01-18 18:33:44 +00:00
function do _the _paste ( ) {
2019-12-03 18:20:17 +00:00
deselect ( ) ;
2020-12-10 22:59:27 +00:00
select _tool ( get _tool _by _id ( TOOL _SELECT ) ) ;
2022-01-18 18:33:44 +00:00
2019-10-29 20:29:38 +00:00
const x = Math . max ( 0 , Math . ceil ( $canvas _area . scrollLeft ( ) / magnification ) ) ;
2020-12-17 02:35:20 +00:00
const y = Math . max ( 0 , Math . ceil ( ( $canvas _area . scrollTop ( ) ) / magnification ) ) ;
// Nevermind, canvas, isn't aligned to the right in RTL layout!
// let x = Math.max(0, Math.ceil($canvas_area.scrollLeft() / magnification));
// if (get_direction() === "rtl") {
// // magic number 8 is a guess, I guess based on the scrollbar width which shows on the left in RTL layout
// // x = Math.max(0, Math.ceil(($canvas_area.innerWidth() - canvas.width + $canvas_area.scrollLeft() + 8) / magnification));
2021-02-15 18:02:39 +00:00
// const scrollbar_width = $canvas_area[0].offsetWidth - $canvas_area[0].clientWidth; // maybe??
2020-12-17 02:35:20 +00:00
// console.log("scrollbar_width", scrollbar_width);
// x = Math.max(0, Math.ceil((-$canvas_area.innerWidth() + $canvas_area.scrollLeft() + scrollbar_width) / magnification + canvas.width));
// }
2019-12-13 15:48:57 +00:00
undoable ( {
2021-01-30 15:18:50 +00:00
name : localize ( "Paste" ) ,
2019-12-16 03:21:47 +00:00
icon : get _help _folder _icon ( "p_paste.png" ) ,
2019-12-13 15:48:57 +00:00
soft : true ,
2022-01-18 18:33:44 +00:00
} , ( ) => {
2021-02-11 22:26:49 +00:00
selection = new OnCanvasSelection ( x , y , img _or _canvas . width , img _or _canvas . height , img _or _canvas ) ;
2019-12-13 15:48:57 +00:00
} ) ;
2014-08-18 19:40:34 +00:00
}
}
2022-01-18 18:33:44 +00:00
function render _history _as _gif ( ) {
2021-09-02 22:19:51 +00:00
const $win = $DialogWindow ( ) ;
2014-05-04 13:32:02 +00:00
$win . title ( "Rendering GIF" ) ;
2022-01-18 18:33:44 +00:00
2019-10-29 20:29:38 +00:00
const $output = $win . $main ;
2021-02-01 21:08:23 +00:00
const $progress = $ ( E ( "progress" ) ) . appendTo ( $output ) . addClass ( "inset-deep" ) ;
2019-10-29 20:29:38 +00:00
const $progress _percent = $ ( E ( "span" ) ) . appendTo ( $output ) . css ( {
2014-10-15 22:05:59 +00:00
width : "2.3em" ,
display : "inline-block" ,
textAlign : "center" ,
} ) ;
2022-01-18 18:33:44 +00:00
$win . $main . css ( { padding : 5 } ) ;
2018-01-24 21:58:12 +00:00
2019-10-29 20:29:38 +00:00
const $cancel = $win . $Button ( 'Cancel' , ( ) => {
2015-06-29 04:19:22 +00:00
$win . close ( ) ;
2021-04-01 19:07:46 +00:00
} ) . focus ( ) ;
2018-01-24 21:58:12 +00:00
2021-02-01 21:08:37 +00:00
$win . center ( ) ;
2022-01-18 18:33:44 +00:00
try {
2021-02-11 02:00:38 +00:00
const width = main _canvas . width ;
const height = main _canvas . height ;
2019-11-03 15:36:34 +00:00
const gif = new GIF ( {
2014-10-15 16:56:00 +00:00
//workers: Math.min(5, Math.floor(undos.length/50)+1),
2015-06-29 04:19:22 +00:00
workerScript : "lib/gif.js/gif.worker.js" ,
2019-10-29 22:22:32 +00:00
width ,
height ,
2014-10-15 16:56:00 +00:00
} ) ;
2018-01-24 21:58:12 +00:00
2019-11-03 15:36:34 +00:00
$win . on ( 'close' , ( ) => {
gif . abort ( ) ;
} ) ;
2022-01-18 18:33:44 +00:00
2019-10-29 18:46:29 +00:00
gif . on ( "progress" , p => {
2014-10-15 16:56:00 +00:00
$progress . val ( p ) ;
2022-01-18 18:33:44 +00:00
$progress _percent . text ( ` ${ ~ ~ ( p * 100 ) } % ` ) ;
2014-10-15 16:56:00 +00:00
} ) ;
2018-01-24 21:58:12 +00:00
2019-10-29 18:46:29 +00:00
gif . on ( "finished" , blob => {
2014-10-15 16:56:00 +00:00
$win . title ( "Rendered GIF" ) ;
2021-02-14 00:45:11 +00:00
const blob _url = URL . createObjectURL ( blob ) ;
2014-10-15 16:56:00 +00:00
$output . empty ( ) . append (
2021-12-07 22:58:56 +00:00
$ ( E ( "div" ) ) . addClass ( "inset-deep" ) . append (
$ ( E ( "img" ) ) . attr ( {
src : blob _url ,
width ,
height ,
} ) . css ( {
display : "block" , // prevent margin below due to inline display (vertical-align can also be used)
} ) ,
) . css ( {
overflow : "auto" ,
maxHeight : "70vh" ,
maxWidth : "70vw" ,
2014-10-15 16:56:00 +00:00
} )
) ;
2022-01-18 18:33:44 +00:00
$win . on ( "close" , ( ) => {
2021-02-14 00:45:11 +00:00
// revoking on image load(+error) breaks right click > "Save image as" and "Open image in new tab"
URL . revokeObjectURL ( blob _url ) ;
} ) ;
2019-10-29 18:46:29 +00:00
$win . $Button ( "Upload to Imgur" , ( ) => {
2018-08-13 06:59:21 +00:00
$win . close ( ) ;
2019-10-29 18:46:29 +00:00
sanity _check _blob ( blob , ( ) => {
2018-08-13 06:59:21 +00:00
show _imgur _uploader ( blob ) ;
} ) ;
2021-04-01 19:07:46 +00:00
} ) . focus ( ) ;
2020-12-10 22:17:10 +00:00
$win . $Button ( localize ( "Save" ) , ( ) => {
2015-06-29 04:19:22 +00:00
$win . close ( ) ;
2019-10-29 18:46:29 +00:00
sanity _check _blob ( blob , ( ) => {
2021-03-25 02:41:00 +00:00
const suggested _file _name = ` ${ file _name . replace ( /\.(bmp|dib|a?png|gif|jpe?g|jpe|jfif|tiff?|webp|raw)$/i , "" ) } history.gif ` ;
2021-08-03 10:28:19 +00:00
systemHooks . showSaveFileDialog ( {
2021-03-23 01:23:45 +00:00
dialogTitle : localize ( "Save As" ) , // localize("Save Animation As"),
2022-01-18 18:33:44 +00:00
getBlob : ( ) => blob ,
2021-03-25 02:41:00 +00:00
defaultFileName : suggested _file _name ,
2021-08-02 20:20:08 +00:00
defaultPath : typeof system _file _handle === "string" ? ` ${ system _file _handle . replace ( /[/\\][^/\\]*/ , "" ) } / ${ suggested _file _name } ` : null ,
2021-03-23 01:23:45 +00:00
defaultFileFormatID : "image/gif" ,
formats : [ {
2021-02-18 12:12:39 +00:00
formatID : "image/gif" ,
mimeType : "image/gif" ,
name : localize ( "Animated GIF (*.gif)" ) . replace ( /\s+\([^(]+$/ , "" ) ,
nameWithExtensions : localize ( "Animated GIF (*.gif)" ) ,
extensions : [ "gif" ] ,
} ] ,
2021-03-23 01:23:45 +00:00
} ) ;
2018-06-30 04:10:44 +00:00
} ) ;
2015-06-29 04:19:22 +00:00
} ) ;
$cancel . appendTo ( $win . $buttons ) ;
$win . center ( ) ;
2014-10-15 16:56:00 +00:00
} ) ;
2018-01-24 21:58:12 +00:00
2019-10-29 21:43:46 +00:00
const gif _canvas = make _canvas ( width , height ) ;
2019-12-15 02:29:38 +00:00
const frame _history _nodes = [ ... undos , current _history _node ] ;
2022-01-18 18:33:44 +00:00
for ( const frame _history _node of frame _history _nodes ) {
Save undos/redos as ImageData & fix Free-Form Select
- (PARTIALLY avoid a browser bug in chrome. When you zoom to a non-integer scale, there's this weird quantum antialiasing due to the canvas having a backing store which is higher density than the canvas's logical pixels (and redraw regions come into play as well). Switching to storing ImageData instead of canvases for undos/redos doesn't eliminate much of this problem, but it avoids having the undos/redos also store some high-DPI state and thereby SOMETIMES restore a state of whether antialiasing is happening or not. So it's a little less weird now, but it doesn't really solve that bugginess.)
- Protect against data loss when running low on memory. The browser (chrome at least) can clear canvases when low on memory. If the data is erased, and you undo, or do anything to the canvas, jspaint saves over the autosave. Ideally there should be multiple autosaves, but for now this is catastrophic in terms of data loss. Using ImageData instead of canvases, hopefully the browser is less willing to destroy this data, since it's more like a plain data structure in your program, and you would hope it wouldn't just delete arbitrary data in your program. A crash should be better than losing the canvas data (undos/redos) because in that case the autosave should still be in tact, altho this doesn't protect against the case where the main canvas is cleared by the browser, and then you do something to interact with the canvas other than undo/redo, and then either the page crashes or you refresh, and the autosave will still be gone.
- Behavior change or Regression: Now if the document is transparent, but the document mode is opaque, and you paste something larger than the canvas, it'll keep the transparency in the area of the original document, because it's using putImageData instead of drawImage.
- Regression: When you choose Opaque in the Image > Attributes... it no longer makes the document opaque because it's using putImageData instead of drawImage.
- Fix: Rewrite the Free-Form Select's temporary shape preview to use a proper layer instead of abusing the undo stack. This reduces the number of undo states created, and should make it easier to implement passive selections in the future. (Selections shouldn't create an undo state until you start dragging them.) This should also fix a bug in multiplayer where "inverty brush" could be left behind.
2019-09-30 05:21:32 +00:00
gif _canvas . ctx . clearRect ( 0 , 0 , gif _canvas . width , gif _canvas . height ) ;
2019-12-15 02:29:38 +00:00
gif _canvas . ctx . putImageData ( frame _history _node . image _data , 0 , 0 ) ;
if ( frame _history _node . selection _image _data ) {
const selection _canvas = make _canvas ( frame _history _node . selection _image _data ) ;
gif _canvas . ctx . drawImage ( selection _canvas , frame _history _node . selection _x , frame _history _node . selection _y ) ;
}
2022-01-18 18:33:44 +00:00
gif . addFrame ( gif _canvas , { delay : 200 , copy : true } ) ;
2014-10-15 16:56:00 +00:00
}
gif . render ( ) ;
2018-01-24 21:58:12 +00:00
2022-01-18 18:33:44 +00:00
} catch ( err ) {
2017-01-31 22:36:09 +00:00
$win . close ( ) ;
2021-02-11 18:53:45 +00:00
show _error _message ( "Failed to render GIF." , err ) ;
2014-05-04 13:32:02 +00:00
}
}
2021-12-05 01:01:57 +00:00
function go _to _history _node ( target _history _node , canceling , discard _document _state ) {
2019-12-15 02:11:09 +00:00
const from _history _node = current _history _node ;
2019-12-08 20:12:12 +00:00
if ( ! target _history _node . image _data ) {
2019-12-12 04:58:19 +00:00
if ( ! canceling ) {
show _error _message ( "History entry has no image data." ) ;
2019-12-14 22:49:23 +00:00
window . console && console . log ( "Target history entry has no image data:" , target _history _node ) ;
2019-12-12 04:58:19 +00:00
}
2019-12-08 19:45:02 +00:00
return ;
}
2021-12-04 18:16:16 +00:00
/ * F o r p e r f o r m a n c e ( e s p e c i a l l y w i t h t w o f i n g e r p a n n i n g ) , I ' m d i s a b l i n g t h i s s a f e t y c h e c k t h a t p r e s e r v e s c e r t a i n d o c u m e n t s t a t e s i n t h e h i s t o r y .
2021-02-11 02:00:38 +00:00
const current _image _data = main _ctx . getImageData ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2019-12-21 16:37:54 +00:00
if ( ! current _history _node . image _data || ! image _data _match ( current _history _node . image _data , current _image _data , 5 ) ) {
2019-12-14 22:49:23 +00:00
window . console && console . log ( "Canvas image data changed outside of undoable" , current _history _node , "current_history_node.image_data:" , current _history _node . image _data , "document's current image data:" , current _image _data ) ;
2021-06-19 23:58:47 +00:00
undoable ( { name : "Unknown [go_to_history_node]" , use _loose _canvas _changes : true } , ( ) => { } ) ;
2019-12-12 17:09:59 +00:00
}
2021-12-04 18:16:16 +00:00
* /
2019-12-08 22:42:52 +00:00
current _history _node = target _history _node ;
2022-01-18 18:33:44 +00:00
2019-12-13 03:48:32 +00:00
deselect ( true ) ;
2019-12-09 02:13:23 +00:00
if ( ! canceling ) {
2019-12-12 17:09:59 +00:00
cancel ( true ) ;
2019-12-09 02:13:23 +00:00
}
2019-12-08 19:45:02 +00:00
saved = false ;
2021-08-12 01:24:46 +00:00
update _title ( ) ;
2019-12-08 19:45:02 +00:00
2021-02-11 02:00:38 +00:00
main _ctx . copy ( target _history _node . image _data ) ;
2019-12-13 03:04:00 +00:00
if ( target _history _node . selection _image _data ) {
if ( selection ) {
selection . destroy ( ) ;
}
2020-01-05 22:27:51 +00:00
// @TODO maybe: could store whether a selection is from Free-Form Select
2019-12-15 03:08:12 +00:00
// so it selects Free-Form Select when you jump to e.g. Move Selection
// (or could traverse history to figure it out)
2020-12-05 22:33:21 +00:00
if ( target _history _node . name === localize ( "Free-Form Select" ) ) {
2020-12-10 22:59:27 +00:00
select _tool ( get _tool _by _id ( TOOL _FREE _FORM _SELECT ) ) ;
2019-12-15 03:08:12 +00:00
} else {
2020-12-10 22:59:27 +00:00
select _tool ( get _tool _by _id ( TOOL _SELECT ) ) ;
2019-12-15 03:08:12 +00:00
}
2019-12-13 03:04:00 +00:00
selection = new OnCanvasSelection (
target _history _node . selection _x ,
target _history _node . selection _y ,
target _history _node . selection _image _data . width ,
target _history _node . selection _image _data . height ,
target _history _node . selection _image _data ,
) ;
}
2019-12-15 02:57:42 +00:00
if ( target _history _node . textbox _text != null ) {
2019-12-13 04:13:42 +00:00
if ( textbox ) {
textbox . destroy ( ) ;
}
2019-12-15 02:57:42 +00:00
// @# text_tool_font =
for ( const [ k , v ] of Object . entries ( target _history _node . text _tool _font ) ) {
2019-12-13 04:13:42 +00:00
text _tool _font [ k ] = v ;
}
2022-01-18 18:33:44 +00:00
2021-02-11 02:04:35 +00:00
selected _colors . foreground = target _history _node . foreground _color ;
selected _colors . background = target _history _node . background _color ;
2019-12-15 02:57:42 +00:00
tool _transparent _mode = target _history _node . tool _transparent _mode ;
$G . trigger ( "option-changed" ) ;
2020-12-10 22:59:27 +00:00
select _tool ( get _tool _by _id ( TOOL _TEXT ) ) ;
2019-12-13 04:13:42 +00:00
textbox = new OnCanvasTextBox (
target _history _node . textbox _x ,
target _history _node . textbox _y ,
target _history _node . textbox _width ,
target _history _node . textbox _height ,
target _history _node . textbox _text ,
) ;
}
2019-12-08 19:45:02 +00:00
2019-12-15 02:11:09 +00:00
const ancestors _of _target = get _history _ancestors ( target _history _node ) ;
2019-12-08 19:45:02 +00:00
2019-12-15 02:11:09 +00:00
undos = [ ... ancestors _of _target ] ;
2019-12-08 22:40:02 +00:00
undos . reverse ( ) ;
2019-12-08 22:32:50 +00:00
2019-12-15 02:11:09 +00:00
const old _history _path =
redos . length > 0 ?
[ redos [ 0 ] , ... get _history _ancestors ( redos [ 0 ] ) ] :
[ from _history _node , ... get _history _ancestors ( from _history _node ) ] ;
2019-12-08 22:32:50 +00:00
2019-12-15 02:11:09 +00:00
// window.console && console.log("target_history_node:", target_history_node);
// window.console && console.log("ancestors_of_target:", ancestors_of_target);
// window.console && console.log("old_history_path:", old_history_path);
2019-12-08 22:40:02 +00:00
redos . length = 0 ;
2019-12-08 22:32:50 +00:00
let latest _node = target _history _node ;
while ( latest _node . futures . length > 0 ) {
2019-12-08 22:40:02 +00:00
const futures = [ ... latest _node . futures ] ;
2022-01-18 18:33:44 +00:00
futures . sort ( ( a , b ) => {
if ( old _history _path . indexOf ( a ) > - 1 ) {
2019-12-08 22:40:02 +00:00
return - 1 ;
2019-12-08 22:32:50 +00:00
}
2022-01-18 18:33:44 +00:00
if ( old _history _path . indexOf ( b ) > - 1 ) {
2019-12-08 22:40:02 +00:00
return + 1 ;
2019-12-08 22:32:50 +00:00
}
2019-12-08 22:40:02 +00:00
return 0 ;
} ) ;
latest _node = futures [ 0 ] ;
redos . unshift ( latest _node ) ;
2019-12-08 22:32:50 +00:00
}
2019-12-15 02:11:09 +00:00
// window.console && console.log("new undos:", undos);
// window.console && console.log("new redos:", redos);
2019-12-08 19:45:02 +00:00
$canvas _area . trigger ( "resize" ) ;
$G . triggerHandler ( "session-update" ) ; // autosave
$G . triggerHandler ( "history-update" ) ; // update history view
}
2022-08-02 07:44:19 +00:00
// Note: This function is part of the API.
2022-01-18 18:33:44 +00:00
function undoable ( { name , icon , use _loose _canvas _changes , soft } , callback ) {
2019-12-13 16:23:19 +00:00
if ( ! use _loose _canvas _changes ) {
2021-12-04 18:16:16 +00:00
/ * F o r p e r f o r m a n c e ( e s p e c i a l l y w i t h t w o f i n g e r p a n n i n g ) , I ' m d i s a b l i n g t h i s s a f e t y c h e c k t h a t p r e s e r v e s c e r t a i n d o c u m e n t s t a t e s i n t h e h i s t o r y .
2021-02-11 02:00:38 +00:00
const current _image _data = main _ctx . getImageData ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2019-12-21 16:37:54 +00:00
if ( ! current _history _node . image _data || ! image _data _match ( current _history _node . image _data , current _image _data , 5 ) ) {
2019-12-14 22:49:23 +00:00
window . console && console . log ( "Canvas image data changed outside of undoable" , current _history _node , "current_history_node.image_data:" , current _history _node . image _data , "document's current image data:" , current _image _data ) ;
2019-12-13 16:23:19 +00:00
undoable ( { name : "Unknown [undoable]" , use _loose _canvas _changes : true } , ( ) => { } ) ;
2019-12-12 17:09:59 +00:00
}
2021-12-04 18:16:16 +00:00
* /
2019-12-12 15:51:51 +00:00
}
2019-12-12 04:58:19 +00:00
2015-02-23 21:16:21 +00:00
saved = false ;
2021-08-12 01:24:46 +00:00
update _title ( ) ;
2019-12-08 15:51:37 +00:00
2019-12-16 01:54:03 +00:00
const before _callback _history _node = current _history _node ;
2019-12-12 04:58:19 +00:00
callback && callback ( ) ;
2019-12-16 01:54:03 +00:00
if ( current _history _node !== before _callback _history _node ) {
show _error _message ( ` History node switched during undoable callback for ${ name } . This shouldn't happen. ` ) ;
window . console && console . log ( ` History node switched during undoable callback for ${ name } , from ` , before _callback _history _node , "to" , current _history _node ) ;
}
2019-12-12 04:58:19 +00:00
2021-02-11 02:00:38 +00:00
const image _data = main _ctx . getImageData ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2019-12-08 15:51:37 +00:00
2019-12-08 20:12:12 +00:00
redos . length = 0 ;
2019-12-08 22:42:52 +00:00
undos . push ( current _history _node ) ;
2019-12-08 20:12:12 +00:00
2019-12-12 22:11:09 +00:00
const new _history _node = make _history _node ( {
2019-12-08 23:57:38 +00:00
image _data ,
2019-12-13 03:04:00 +00:00
selection _image _data : selection && selection . canvas . ctx . getImageData ( 0 , 0 , selection . canvas . width , selection . canvas . height ) ,
selection _x : selection && selection . x ,
selection _y : selection && selection . y ,
2019-12-13 04:13:42 +00:00
textbox _text : textbox && textbox . $editor . val ( ) ,
textbox _x : textbox && textbox . x ,
textbox _y : textbox && textbox . y ,
textbox _width : textbox && textbox . width ,
textbox _height : textbox && textbox . height ,
2019-12-15 02:57:42 +00:00
text _tool _font : JSON . parse ( JSON . stringify ( text _tool _font ) ) ,
tool _transparent _mode ,
2021-02-11 02:04:35 +00:00
foreground _color : selected _colors . foreground ,
background _color : selected _colors . background ,
ternary _color : selected _colors . ternary ,
2019-12-08 23:57:38 +00:00
parent : current _history _node ,
2019-12-13 04:56:46 +00:00
name ,
2019-12-08 23:57:38 +00:00
icon ,
2019-12-13 05:42:48 +00:00
soft ,
2019-12-12 22:11:09 +00:00
} ) ;
2019-12-08 22:42:52 +00:00
current _history _node . futures . push ( new _history _node ) ;
current _history _node = new _history _node ;
2019-12-08 15:51:37 +00:00
$G . triggerHandler ( "history-update" ) ; // update history view
2018-01-24 21:58:12 +00:00
2019-12-11 02:05:19 +00:00
$G . triggerHandler ( "session-update" ) ; // autosave
2014-05-04 13:32:02 +00:00
}
2019-12-13 04:56:46 +00:00
function make _or _update _undoable ( undoable _meta , undoable _action ) {
2019-12-13 16:37:30 +00:00
if ( current _history _node . futures . length === 0 && undoable _meta . match ( current _history _node ) ) {
2019-12-13 04:56:46 +00:00
undoable _action ( ) ;
2021-02-11 02:00:38 +00:00
current _history _node . image _data = main _ctx . getImageData ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2019-12-13 03:48:32 +00:00
current _history _node . selection _image _data = selection && selection . canvas . ctx . getImageData ( 0 , 0 , selection . canvas . width , selection . canvas . height ) ;
current _history _node . selection _x = selection && selection . x ;
current _history _node . selection _y = selection && selection . y ;
2019-12-14 21:24:04 +00:00
if ( undoable _meta . update _name ) {
current _history _node . name = undoable _meta . name ;
}
$G . triggerHandler ( "history-update" ) ; // update history view
2019-12-13 03:48:32 +00:00
} else {
2019-12-13 04:56:46 +00:00
undoable ( undoable _meta , undoable _action ) ;
2019-12-13 03:48:32 +00:00
}
}
2022-01-18 18:33:44 +00:00
function undo ( ) {
if ( undos . length < 1 ) { return false ; }
2019-12-08 14:07:31 +00:00
2019-12-08 22:42:52 +00:00
redos . push ( current _history _node ) ;
2019-12-13 05:42:48 +00:00
let target _history _node = undos . pop ( ) ;
while ( target _history _node . soft && undos . length ) {
redos . push ( target _history _node ) ;
target _history _node = undos . pop ( ) ;
}
2019-12-13 05:58:34 +00:00
go _to _history _node ( target _history _node ) ;
2018-01-24 21:58:12 +00:00
2014-05-04 13:32:02 +00:00
return true ;
}
2019-12-08 15:51:37 +00:00
2021-11-26 23:37:58 +00:00
// @TODO: use Clippy.js instead for potentially annoying tips
2019-12-08 15:51:37 +00:00
let $document _history _prompt _window ;
2022-01-18 18:33:44 +00:00
function redo ( ) {
if ( redos . length < 1 ) {
2019-12-08 15:51:37 +00:00
if ( $document _history _prompt _window ) {
$document _history _prompt _window . close ( ) ;
}
2019-12-09 01:32:29 +00:00
if ( ! $document _history _window || $document _history _window . closed ) {
2021-11-26 23:37:58 +00:00
$document _history _prompt _window = showMessageBox ( {
title : "Redo" ,
messageHTML : ` To view all branches of the history tree, click <b>Edit > History</b>. ` ,
iconID : "info" ,
} ) . $window ;
2019-12-09 01:32:29 +00:00
}
2019-12-08 15:51:37 +00:00
return false ;
}
2019-12-08 22:42:52 +00:00
undos . push ( current _history _node ) ;
2019-12-13 05:42:48 +00:00
let target _history _node = redos . pop ( ) ;
while ( target _history _node . soft && redos . length ) {
undos . push ( target _history _node ) ;
target _history _node = redos . pop ( ) ;
}
go _to _history _node ( target _history _node ) ;
2018-01-24 21:58:12 +00:00
2014-05-04 13:32:02 +00:00
return true ;
}
2019-12-08 15:51:37 +00:00
2019-12-08 20:12:12 +00:00
function get _history _ancestors ( node ) {
const ancestors = [ ] ;
for ( node = node . parent ; node ; node = node . parent ) {
ancestors . push ( node ) ;
}
return ancestors ;
}
2019-12-08 15:51:37 +00:00
let $document _history _window ;
2021-01-31 16:29:56 +00:00
// setTimeout(show_document_history, 100);
2019-12-08 15:51:37 +00:00
function show _document _history ( ) {
2019-12-09 01:32:04 +00:00
if ( $document _history _prompt _window ) {
$document _history _prompt _window . close ( ) ;
}
2019-12-08 15:51:37 +00:00
if ( $document _history _window ) {
$document _history _window . close ( ) ;
}
2021-09-02 22:19:51 +00:00
const $w = $document _history _window = new $Window ( {
title : "Document History" ,
resizable : false ,
maximizeButton : false ,
minimizeButton : false ,
} ) ;
2021-01-31 16:29:56 +00:00
// $w.prependTo("body").css({position: ""});
2021-01-30 01:06:31 +00:00
$w . addClass ( "history-window squish" ) ;
2019-12-08 15:51:37 +00:00
$w . $content . html ( `
2021-12-05 02:40:04 +00:00
< label >
2021-12-07 18:08:30 +00:00
< select id = "history-view-mode" class = "inset-deep" >
2021-12-05 02:46:09 +00:00
< option value = "linear" > Linear timeline < / o p t i o n >
< option value = "tree" > Tree < / o p t i o n >
< / s e l e c t >
2021-12-05 02:40:04 +00:00
< / l a b e l >
2021-04-02 01:18:56 +00:00
< div class = "history-view" tabIndex = "0" > < / d i v >
2019-12-08 15:51:37 +00:00
` );
2019-12-18 05:30:25 +00:00
const $history _view = $w . $content . find ( ".history-view" ) ;
2021-04-02 01:18:56 +00:00
$history _view . focus ( ) ;
2019-12-08 15:51:37 +00:00
2019-12-09 01:25:41 +00:00
let previous _scroll _position = 0 ;
2019-12-12 20:37:17 +00:00
let rendered _$entries = [ ] ;
2021-04-02 01:18:56 +00:00
let current _$entry ;
2019-12-12 20:37:17 +00:00
2021-12-05 02:46:09 +00:00
let $mode _select = $w . $content . find ( "#history-view-mode" ) ;
$mode _select . css ( {
margin : "10px" ,
} ) ;
let mode = $mode _select . val ( ) ;
$mode _select . on ( "change" , ( ) => {
mode = $mode _select . val ( ) ;
2021-12-05 02:40:04 +00:00
render _tree ( ) ;
} ) ;
2019-12-12 20:37:17 +00:00
function render _tree _from _node ( node ) {
const $entry = $ ( `
2021-04-02 01:18:56 +00:00
< div class = "history-entry" >
2019-12-12 20:37:17 +00:00
< div class = "history-entry-icon-area" > < / d i v >
< div class = "history-entry-name" > < / d i v >
2019-12-08 23:57:38 +00:00
< / d i v >
` );
2019-12-13 05:42:48 +00:00
// $entry.find(".history-entry-name").text((node.name || "Unknown") + (node.soft ? " (soft)" : ""));
2021-01-30 22:37:38 +00:00
$entry . find ( ".history-entry-name" ) . text ( ( node . name || "Unknown" ) + ( node === root _history _node ? " (Start of History)" : "" ) ) ;
2019-12-08 23:57:38 +00:00
$entry . find ( ".history-entry-icon-area" ) . append ( node . icon ) ;
2021-12-05 02:46:09 +00:00
if ( mode === "tree" ) {
2021-12-05 02:40:04 +00:00
let dist _to _root = 0 ;
for ( let ancestor = node . parent ; ancestor ; ancestor = ancestor . parent ) {
dist _to _root ++ ;
}
$entry . css ( {
marginInlineStart : ` ${ dist _to _root * 8 } px ` ,
} ) ;
}
2019-12-08 22:42:52 +00:00
if ( node === current _history _node ) {
2019-12-08 15:51:37 +00:00
$entry . addClass ( "current" ) ;
2021-04-02 01:18:56 +00:00
current _$entry = $entry ;
2022-01-18 18:33:44 +00:00
requestAnimationFrame ( ( ) => {
2021-01-31 16:29:56 +00:00
// scrollIntoView causes <html> to scroll when the window is partially offscreen,
// despite overflow: hidden on html and body, so it's not an option.
$history _view [ 0 ] . scrollTop =
Math . min (
$entry [ 0 ] . offsetTop ,
Math . max (
previous _scroll _position ,
$entry [ 0 ] . offsetTop - $history _view [ 0 ] . clientHeight + $entry . outerHeight ( )
)
) ;
2019-12-09 01:25:41 +00:00
} ) ;
2019-12-08 20:12:12 +00:00
} else {
2019-12-08 22:42:52 +00:00
const history _ancestors = get _history _ancestors ( current _history _node ) ;
2019-12-08 20:12:12 +00:00
if ( history _ancestors . indexOf ( node ) > - 1 ) {
$entry . addClass ( "ancestor-of-current" ) ;
}
2019-12-08 15:51:37 +00:00
}
for ( const sub _node of node . futures ) {
2019-12-12 20:37:17 +00:00
render _tree _from _node ( sub _node ) ;
2019-12-08 15:51:37 +00:00
}
2022-01-18 18:33:44 +00:00
$entry . on ( "click" , ( ) => {
2019-12-08 19:45:02 +00:00
go _to _history _node ( node ) ;
} ) ;
2019-12-12 20:37:17 +00:00
$entry . history _node = node ;
rendered _$entries . push ( $entry ) ;
2019-12-08 15:51:37 +00:00
}
2022-01-18 18:33:44 +00:00
const render _tree = ( ) => {
2019-12-09 01:25:41 +00:00
previous _scroll _position = $history _view . scrollTop ( ) ;
2019-12-08 15:51:37 +00:00
$history _view . empty ( ) ;
2019-12-12 20:37:17 +00:00
rendered _$entries = [ ] ;
render _tree _from _node ( root _history _node ) ;
2021-12-05 02:46:09 +00:00
if ( mode === "linear" ) {
2021-12-05 02:40:04 +00:00
rendered _$entries . sort ( ( $a , $b ) => {
if ( $a . history _node . timestamp < $b . history _node . timestamp ) {
return - 1 ;
}
if ( $b . history _node . timestamp < $a . history _node . timestamp ) {
return + 1 ;
}
return 0 ;
} ) ;
} else {
rendered _$entries . reverse ( ) ;
}
2022-01-18 18:33:44 +00:00
rendered _$entries . forEach ( ( $entry ) => {
2019-12-12 20:37:17 +00:00
$history _view . append ( $entry ) ;
} ) ;
2019-12-08 15:51:37 +00:00
} ;
render _tree ( ) ;
2021-04-02 01:18:56 +00:00
// This is different from Ctrl+Z/Ctrl+Shift+Z because it goes over all branches of the history tree, chronologically,
// not just one branch.
2022-01-18 18:33:44 +00:00
const go _by = ( index _delta ) => {
2021-04-02 01:18:56 +00:00
const from _index = rendered _$entries . indexOf ( current _$entry ) ;
const to _index = from _index + index _delta ;
if ( rendered _$entries [ to _index ] ) {
rendered _$entries [ to _index ] . click ( ) ;
}
} ;
2022-01-18 18:33:44 +00:00
$history _view . on ( "keydown" , ( event ) => {
2021-04-02 01:18:56 +00:00
if ( ! event . ctrlKey && ! event . altKey && ! event . shiftKey && ! event . metaKey ) {
if ( event . key === "ArrowDown" || event . key === "Down" ) {
go _by ( 1 ) ;
event . preventDefault ( ) ;
} else if ( event . key === "ArrowUp" || event . key === "Up" ) {
go _by ( - 1 ) ;
event . preventDefault ( ) ;
}
}
2021-04-01 19:07:46 +00:00
} ) ;
2019-12-08 15:51:37 +00:00
$G . on ( "history-update" , render _tree ) ;
2022-01-18 18:33:44 +00:00
$w . on ( "close" , ( ) => {
2019-12-08 15:51:37 +00:00
$G . off ( "history-update" , render _tree ) ;
} ) ;
2019-12-08 20:12:12 +00:00
$w . center ( ) ;
2019-12-08 15:51:37 +00:00
}
2021-12-05 05:14:19 +00:00
function cancel ( going _to _history _node , discard _document _state ) {
2019-12-08 15:40:13 +00:00
// Note: this function should be idempotent.
// `cancel(); cancel();` should do the same thing as `cancel();`
2021-07-30 08:34:41 +00:00
if ( ! history _node _to _cancel _to ) {
return ;
}
2021-12-05 01:01:57 +00:00
// For two finger panning, I want to prevent history nodes from being created,
2021-12-06 22:16:28 +00:00
// for performance, and to avoid cluttering the history.
// (And also so if you undo and then pan, you can still redo (without accessing the nonlinear history window).)
// Most tools create undoables on pointerup, in which case we can prevent them from being created,
2021-12-05 01:01:57 +00:00
// but Fill tool creates on pointerdown, so we need to delete a history node in that case.
2021-12-06 22:16:28 +00:00
// Select tool can create multiple undoables before being cancelled (for moving/resizing/inverting/smearing),
// but only the last should be discarded due to panning. (All of them should be undone you hit Esc. But not deleted.)
const history _node _to _discard = (
discard _document _state &&
current _history _node . parent && // can't discard the root node
current _history _node !== history _node _to _cancel _to && // can't discard what will be the active node
current _history _node . futures . length === 0 // prevent discarding whole branches of history if you go back in history and then pan / hit Esc
) ? current _history _node : null ;
2021-12-05 01:01:57 +00:00
// console.log("history_node_to_discard", history_node_to_discard, "current_history_node", current_history_node, "history_node_to_cancel_to", history_node_to_cancel_to);
2021-07-30 08:34:41 +00:00
// history_node_to_cancel_to = history_node_to_cancel_to || current_history_node;
2021-12-05 01:01:57 +00:00
$G . triggerHandler ( "pointerup" , [ "canceling" , discard _document _state ] ) ;
2019-12-03 17:50:51 +00:00
for ( const selected _tool of selected _tools ) {
selected _tool . cancel && selected _tool . cancel ( ) ;
}
2019-12-12 17:09:59 +00:00
if ( ! going _to _history _node ) {
2019-12-15 01:51:50 +00:00
// Note: this will revert any changes from other users in multi-user sessions
// which isn't good, but there's no real conflict resolution in multi-user mode anyways
go _to _history _node ( history _node _to _cancel _to , true ) ;
2021-12-05 01:01:57 +00:00
if ( history _node _to _discard ) {
2021-12-06 22:16:28 +00:00
const index = history _node _to _discard . parent . futures . indexOf ( history _node _to _discard ) ;
if ( index === - 1 ) {
show _error _message ( "History node not found. Please report this bug." ) ;
2021-12-05 01:01:57 +00:00
console . log ( "history_node_to_discard" , history _node _to _discard ) ;
2021-12-06 22:16:28 +00:00
console . log ( "current_history_node" , current _history _node ) ;
console . log ( "history_node_to_discard.parent" , history _node _to _discard . parent ) ;
} else {
history _node _to _discard . parent . futures . splice ( index , 1 ) ;
$G . triggerHandler ( "history-update" ) ; // update history view (don't want you to be able to click on the excised node)
// (@TODO: prevent duplicate update, here vs go_to_history_node)
2021-12-05 01:01:57 +00:00
}
}
2019-12-12 17:09:59 +00:00
}
2019-12-16 01:54:03 +00:00
history _node _to _cancel _to = null ;
2019-10-03 20:37:44 +00:00
update _helper _layer ( ) ;
2014-05-04 13:32:02 +00:00
}
2019-12-13 16:23:19 +00:00
function meld _selection _into _canvas ( going _to _history _node ) {
selection . draw ( ) ;
selection . destroy ( ) ;
selection = null ;
if ( ! going _to _history _node ) {
undoable ( {
name : "Deselect" ,
2020-12-10 22:59:27 +00:00
icon : get _icon _for _tool ( get _tool _by _id ( TOOL _SELECT ) ) ,
2020-01-05 22:27:51 +00:00
use _loose _canvas _changes : true , // HACK; @TODO: make OnCanvasSelection not change the canvas outside undoable, same rules as tools
2022-01-18 18:33:44 +00:00
} , ( ) => { } ) ;
2019-12-13 16:23:19 +00:00
}
}
function meld _textbox _into _canvas ( going _to _history _node ) {
const text = textbox . $editor . val ( ) ;
if ( text && ! going _to _history _node ) {
undoable ( {
2020-12-05 22:33:21 +00:00
name : localize ( "Text" ) ,
2020-12-10 22:59:27 +00:00
icon : get _icon _for _tool ( get _tool _by _id ( TOOL _TEXT ) ) ,
2019-12-13 16:23:19 +00:00
soft : true ,
2022-01-18 18:33:44 +00:00
} , ( ) => { } ) ;
2019-12-13 16:23:19 +00:00
undoable ( {
name : "Finish Text" ,
2020-12-10 22:59:27 +00:00
icon : get _icon _for _tool ( get _tool _by _id ( TOOL _TEXT ) ) ,
2019-12-13 16:23:19 +00:00
} , ( ) => {
2021-02-11 02:00:38 +00:00
main _ctx . drawImage ( textbox . canvas , textbox . x , textbox . y ) ;
2019-12-13 16:23:19 +00:00
textbox . destroy ( ) ;
textbox = null ;
} ) ;
} else {
textbox . destroy ( ) ;
textbox = null ;
}
}
2022-01-18 18:33:44 +00:00
function deselect ( going _to _history _node ) {
if ( selection ) {
2019-12-13 16:23:19 +00:00
meld _selection _into _canvas ( going _to _history _node ) ;
2014-05-04 13:32:02 +00:00
}
2022-01-18 18:33:44 +00:00
if ( textbox ) {
2019-12-13 16:23:19 +00:00
meld _textbox _into _canvas ( going _to _history _node ) ;
2014-08-10 05:23:28 +00:00
}
2019-12-03 18:20:17 +00:00
for ( const selected _tool of selected _tools ) {
2021-02-11 02:00:38 +00:00
selected _tool . end && selected _tool . end ( main _ctx ) ;
2014-10-29 02:54:55 +00:00
}
2014-05-04 13:32:02 +00:00
}
2022-01-18 18:33:44 +00:00
function delete _selection ( meta = { } ) {
if ( selection ) {
2019-12-13 04:56:46 +00:00
undoable ( {
2021-01-30 15:18:50 +00:00
name : meta . name || localize ( "Clear Selection" ) , //"Delete", (I feel like "Clear Selection" is unclear, could mean "Deselect")
2019-12-16 03:46:17 +00:00
icon : meta . icon || get _help _folder _icon ( "p_delete.png" ) ,
2020-01-05 22:27:51 +00:00
// soft: @TODO: conditionally soft?,
2022-01-18 18:33:44 +00:00
} , ( ) => {
2019-12-13 03:48:32 +00:00
selection . destroy ( ) ;
selection = null ;
2019-12-13 04:56:46 +00:00
} ) ;
2014-05-04 13:32:02 +00:00
}
}
2022-01-18 18:33:44 +00:00
function select _all ( ) {
2019-12-03 18:20:17 +00:00
deselect ( ) ;
2020-12-10 22:59:27 +00:00
select _tool ( get _tool _by _id ( TOOL _SELECT ) ) ;
2018-01-24 21:58:12 +00:00
2019-12-13 15:48:57 +00:00
undoable ( {
2021-01-30 15:18:50 +00:00
name : localize ( "Select All" ) ,
2020-12-10 22:59:27 +00:00
icon : get _icon _for _tool ( get _tool _by _id ( TOOL _SELECT ) ) ,
2019-12-13 15:48:57 +00:00
soft : true ,
2022-01-18 18:33:44 +00:00
} , ( ) => {
2021-02-11 02:00:38 +00:00
selection = new OnCanvasSelection ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2019-12-13 15:48:57 +00:00
} ) ;
2014-05-04 13:32:02 +00:00
}
2021-02-17 00:25:39 +00:00
const ctrlOrCmd = /(Mac|iPhone|iPod|iPad)/i . test ( navigator . platform ) ? "⌘" : "Ctrl" ;
const recommendationForClipboardAccess = ` Please use the keyboard: ${ ctrlOrCmd } +C to copy, ${ ctrlOrCmd } +X to cut, ${ ctrlOrCmd } +V to paste. If keyboard is not an option, try using Chrome version 76 or higher. ` ;
2019-09-17 21:51:17 +00:00
function try _exec _command ( commandId ) {
if ( document . queryCommandEnabled ( commandId ) ) { // not a reliable source for whether it'll work, if I recall
document . execCommand ( commandId ) ;
2019-10-29 20:26:38 +00:00
if ( ! navigator . userAgent . includes ( "Firefox" ) || commandId === "paste" ) {
2021-02-17 00:25:39 +00:00
return show _error _message ( ` That ${ commandId } probably didn't work. ${ recommendationForClipboardAccess } ` ) ;
2019-09-17 22:37:00 +00:00
}
2019-09-17 21:51:17 +00:00
} else {
2021-02-17 00:25:39 +00:00
return show _error _message ( ` Cannot perform ${ commandId } . ${ recommendationForClipboardAccess } ` ) ;
2019-09-17 21:51:17 +00:00
}
}
2019-09-21 14:43:20 +00:00
function getSelectionText ( ) {
2019-10-29 20:29:38 +00:00
let text = "" ;
const activeEl = document . activeElement ;
const activeElTagName = activeEl ? activeEl . tagName . toLowerCase ( ) : null ;
2019-10-29 19:27:49 +00:00
if (
2022-01-18 18:42:45 +00:00
( activeElTagName == "textarea" ) || (
activeElTagName == "input" &&
/^(?:text|search|password|tel|url)$/i . test ( activeEl . type )
) &&
2019-09-21 16:35:27 +00:00
( typeof activeEl . selectionStart == "number" )
2019-10-29 19:27:49 +00:00
) {
text = activeEl . value . slice ( activeEl . selectionStart , activeEl . selectionEnd ) ;
} else if ( window . getSelection ) {
text = window . getSelection ( ) . toString ( ) ;
}
return text ;
2019-09-21 14:43:20 +00:00
}
2022-01-18 18:33:44 +00:00
function edit _copy ( execCommandFallback ) {
2019-10-29 20:29:38 +00:00
const text = getSelectionText ( ) ;
2019-09-21 14:43:20 +00:00
if ( text . length > 0 ) {
if ( ! navigator . clipboard || ! navigator . clipboard . writeText ) {
if ( execCommandFallback ) {
return try _exec _command ( "copy" ) ;
} else {
2021-02-17 00:25:39 +00:00
throw new Error ( ` ${ localize ( "Error getting the Clipboard Data!" ) } ${ recommendationForClipboardAccess } ` ) ;
2020-12-11 17:09:29 +00:00
// throw new Error(`The Async Clipboard API is not supported by this browser. ${browserRecommendationForClipboardAccess}`);
2019-09-21 14:43:20 +00:00
}
2019-09-13 20:50:46 +00:00
}
2019-09-21 14:43:20 +00:00
navigator . clipboard . writeText ( text ) ;
2022-01-18 18:33:44 +00:00
} else if ( selection && selection . canvas ) {
2019-09-21 14:43:20 +00:00
if ( ! navigator . clipboard || ! navigator . clipboard . write ) {
if ( execCommandFallback ) {
return try _exec _command ( "copy" ) ;
} else {
2021-02-17 00:25:39 +00:00
throw new Error ( ` ${ localize ( "Error getting the Clipboard Data!" ) } ${ recommendationForClipboardAccess } ` ) ;
2020-12-11 17:09:29 +00:00
// throw new Error(`The Async Clipboard API is not supported by this browser. ${browserRecommendationForClipboardAccess}`);
2019-09-21 14:43:20 +00:00
}
}
2019-10-29 18:46:29 +00:00
selection . canvas . toBlob ( blob => {
sanity _check _blob ( blob , ( ) => {
2019-09-21 14:43:20 +00:00
navigator . clipboard . write ( [
new ClipboardItem ( Object . defineProperty ( { } , blob . type , {
value : blob ,
enumerable : true ,
} ) )
2019-10-29 18:46:29 +00:00
] ) . then ( ( ) => {
2019-12-14 22:49:23 +00:00
window . console && console . log ( "Copied image to the clipboard." ) ;
2019-10-29 18:46:29 +00:00
} , error => {
2019-09-21 14:43:20 +00:00
show _error _message ( "Failed to copy to the Clipboard." , error ) ;
} ) ;
2019-09-13 20:50:46 +00:00
} ) ;
} ) ;
2019-09-21 14:43:20 +00:00
}
2019-09-13 20:50:46 +00:00
}
2022-01-18 18:33:44 +00:00
function edit _cut ( execCommandFallback ) {
2019-09-17 21:34:58 +00:00
if ( ! navigator . clipboard || ! navigator . clipboard . write ) {
2019-09-13 20:50:46 +00:00
if ( execCommandFallback ) {
2019-09-17 21:51:17 +00:00
return try _exec _command ( "cut" ) ;
2019-09-13 20:50:46 +00:00
} else {
2021-02-17 00:25:39 +00:00
throw new Error ( ` ${ localize ( "Error getting the Clipboard Data!" ) } ${ recommendationForClipboardAccess } ` ) ;
2020-12-11 17:09:29 +00:00
// throw new Error(`The Async Clipboard API is not supported by this browser. ${browserRecommendationForClipboardAccess}`);
2019-09-13 20:50:46 +00:00
}
}
edit _copy ( ) ;
2019-12-16 03:21:47 +00:00
delete _selection ( {
2021-01-30 15:18:50 +00:00
name : localize ( "Cut" ) ,
2019-12-16 03:21:47 +00:00
icon : get _help _folder _icon ( "p_cut.png" ) ,
} ) ;
2019-09-13 20:50:46 +00:00
}
2022-01-18 18:33:44 +00:00
async function edit _paste ( execCommandFallback ) {
if (
2019-09-21 14:43:20 +00:00
document . activeElement instanceof HTMLInputElement ||
document . activeElement instanceof HTMLTextAreaElement
2022-01-18 18:33:44 +00:00
) {
2019-09-21 14:43:20 +00:00
if ( ! navigator . clipboard || ! navigator . clipboard . readText ) {
if ( execCommandFallback ) {
return try _exec _command ( "paste" ) ;
} else {
2021-02-17 00:25:39 +00:00
throw new Error ( ` ${ localize ( "Error getting the Clipboard Data!" ) } ${ recommendationForClipboardAccess } ` ) ;
2020-12-11 17:09:29 +00:00
// throw new Error(`The Async Clipboard API is not supported by this browser. ${browserRecommendationForClipboardAccess}`);
2019-09-21 14:43:20 +00:00
}
}
const clipboardText = await navigator . clipboard . readText ( ) ;
document . execCommand ( "InsertText" , false , clipboardText ) ;
return ;
}
2019-09-17 21:34:58 +00:00
if ( ! navigator . clipboard || ! navigator . clipboard . read ) {
2019-09-13 20:50:46 +00:00
if ( execCommandFallback ) {
2019-09-17 21:51:17 +00:00
return try _exec _command ( "paste" ) ;
2019-09-13 20:50:46 +00:00
} else {
2021-02-17 00:25:39 +00:00
throw new Error ( ` ${ localize ( "Error getting the Clipboard Data!" ) } ${ recommendationForClipboardAccess } ` ) ;
2020-12-11 17:09:29 +00:00
// throw new Error(`The Async Clipboard API is not supported by this browser. ${browserRecommendationForClipboardAccess}`);
2019-09-13 20:50:46 +00:00
}
}
try {
const clipboardItems = await navigator . clipboard . read ( ) ;
const blob = await clipboardItems [ 0 ] . getType ( "image/png" ) ;
paste _image _from _file ( blob ) ;
2022-01-18 18:33:44 +00:00
} catch ( error ) {
2019-09-13 20:50:46 +00:00
if ( error . name === "NotFoundError" ) {
try {
const clipboardText = await navigator . clipboard . readText ( ) ;
2022-01-18 18:33:44 +00:00
if ( clipboardText ) {
2021-02-12 16:12:42 +00:00
const uris = get _uris ( clipboardText ) ;
2019-09-17 20:31:49 +00:00
if ( uris . length > 0 ) {
2021-02-17 13:45:41 +00:00
load _image _from _uri ( uris [ 0 ] ) . then ( ( info ) => {
2021-02-11 22:26:49 +00:00
paste ( info . image || make _canvas ( info . image _data ) ) ;
2021-02-17 13:45:41 +00:00
} , ( error ) => {
show _resource _load _error _message ( error ) ;
2019-09-17 20:31:49 +00:00
} ) ;
} else {
2021-02-11 15:23:18 +00:00
// @TODO: should I just make a textbox instead?
2019-09-17 20:31:49 +00:00
show _error _message ( "The information on the Clipboard can't be inserted into Paint." ) ;
}
2019-09-13 20:50:46 +00:00
} else {
2019-09-17 20:31:49 +00:00
show _error _message ( "The information on the Clipboard can't be inserted into Paint." ) ;
2019-09-13 20:50:46 +00:00
}
2022-01-18 18:33:44 +00:00
} catch ( error ) {
2021-02-11 15:23:18 +00:00
show _error _message ( localize ( "Error getting the Clipboard Data!" ) , error ) ;
2019-09-13 20:50:46 +00:00
}
} else {
2021-02-11 15:23:18 +00:00
show _error _message ( localize ( "Error getting the Clipboard Data!" ) , error ) ;
2019-09-13 20:50:46 +00:00
}
}
}
2022-01-18 18:33:44 +00:00
function image _invert _colors ( ) {
2019-12-15 04:11:30 +00:00
apply _image _transformation ( {
2021-01-30 15:18:50 +00:00
name : localize ( "Invert Colors" ) ,
2019-12-15 04:14:50 +00:00
icon : get _help _folder _icon ( "p_invert.png" ) ,
2019-12-15 04:11:30 +00:00
} , ( original _canvas , original _ctx , new _canvas , new _ctx ) => {
2021-02-10 19:53:57 +00:00
const monochrome _info = monochrome && detect _monochrome ( original _ctx ) ;
if ( monochrome && monochrome _info . isMonochrome ) {
invert _monochrome ( original _ctx , new _ctx , monochrome _info ) ;
2021-02-08 05:34:14 +00:00
} else {
invert _rgb ( original _ctx , new _ctx ) ;
}
2014-06-07 15:39:22 +00:00
} ) ;
2014-05-04 13:32:02 +00:00
}
2014-05-23 21:49:55 +00:00
2022-01-18 18:33:44 +00:00
function clear ( ) {
2019-12-16 01:54:03 +00:00
deselect ( ) ;
cancel ( ) ;
2019-12-16 02:16:48 +00:00
undoable ( {
2021-01-30 15:18:50 +00:00
name : localize ( "Clear Image" ) ,
2019-12-16 02:16:48 +00:00
icon : get _help _folder _icon ( "p_blank.png" ) ,
} , ( ) => {
2019-12-08 14:07:31 +00:00
saved = false ;
2021-08-12 01:24:46 +00:00
update _title ( ) ;
2018-01-24 21:58:12 +00:00
2022-01-18 18:33:44 +00:00
if ( transparency ) {
2021-02-11 02:00:38 +00:00
main _ctx . clearRect ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2022-01-18 18:33:44 +00:00
} else {
2021-02-11 02:04:35 +00:00
main _ctx . fillStyle = selected _colors . background ;
2021-02-11 02:00:38 +00:00
main _ctx . fillRect ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2014-08-18 17:34:47 +00:00
}
} ) ;
}
2022-01-18 18:33:44 +00:00
let cleanup _bitmap _view = ( ) => { } ;
2021-12-07 19:09:30 +00:00
function view _bitmap ( ) {
2021-12-07 20:07:40 +00:00
cleanup _bitmap _view ( ) ;
bitmap _view _div = document . createElement ( "div" ) ;
bitmap _view _div . classList . add ( "bitmap-view" , "inset-deep" ) ;
document . body . appendChild ( bitmap _view _div ) ;
$ ( bitmap _view _div ) . css ( {
display : "flex" ,
alignItems : "center" ,
justifyContent : "center" ,
position : "fixed" ,
top : "0" ,
left : "0" ,
width : "100%" ,
height : "100%" ,
zIndex : "9999" ,
background : "var(--Background)" ,
} ) ;
if ( bitmap _view _div . requestFullscreen ) { bitmap _view _div . requestFullscreen ( ) ; }
else if ( bitmap _view _div . webkitRequestFullscreen ) { bitmap _view _div . webkitRequestFullscreen ( ) ; }
let blob _url ;
let got _fullscreen = false ;
let iid = setInterval ( ( ) => {
// In Chrome, if the page is already fullscreen, and you requestFullscreen,
// hitting Esc will change document.fullscreenElement without triggering the fullscreenchange event!
// It doesn't trigger a keydown either.
2021-12-18 03:38:41 +00:00
if ( document . fullscreenElement === bitmap _view _div || document . webkitFullscreenElement === bitmap _view _div ) {
2021-12-07 20:07:40 +00:00
got _fullscreen = true ;
} else if ( got _fullscreen ) {
cleanup _bitmap _view ( ) ;
}
} , 100 ) ;
cleanup _bitmap _view = ( ) => {
document . removeEventListener ( "fullscreenchange" , onFullscreenChange , { once : true } ) ;
document . removeEventListener ( "webkitfullscreenchange" , onFullscreenChange , { once : true } ) ;
document . removeEventListener ( "keydown" , onKeyDown ) ;
document . removeEventListener ( "mousedown" , onMouseDown ) ;
// If you have e.g. the Help window open,
// and right click to close the View Bitmap, with the mouse over the window,
// this needs a delay to cancel the context menu.
setTimeout ( ( ) => {
document . removeEventListener ( "contextmenu" , onContextMenu ) ;
} , 100 ) ;
URL . revokeObjectURL ( blob _url ) ;
clearInterval ( iid ) ;
2021-12-18 03:38:41 +00:00
if ( document . fullscreenElement === bitmap _view _div || document . webkitFullscreenElement === bitmap _view _div ) {
2021-12-07 20:07:40 +00:00
if ( document . exitFullscreen ) {
document . exitFullscreen ( ) ; // avoid warning in Firefox
} else if ( document . msExitFullscreen ) {
document . msExitFullscreen ( ) ;
} else if ( document . mozCancelFullScreen ) {
document . mozCancelFullScreen ( ) ;
} else if ( document . webkitExitFullscreen ) {
document . webkitExitFullscreen ( ) ;
}
}
bitmap _view _div . remove ( ) ;
cleanup _bitmap _view = ( ) => { } ;
} ;
document . addEventListener ( "fullscreenchange" , onFullscreenChange , { once : true } ) ;
document . addEventListener ( "webkitfullscreenchange" , onFullscreenChange , { once : true } ) ;
document . addEventListener ( "keydown" , onKeyDown ) ;
document . addEventListener ( "mousedown" , onMouseDown ) ;
document . addEventListener ( "contextmenu" , onContextMenu ) ;
function onFullscreenChange ( ) {
2021-12-18 03:38:41 +00:00
if ( document . fullscreenElement !== bitmap _view _div && document . webkitFullscreenElement !== bitmap _view _div ) {
2021-12-07 20:07:40 +00:00
cleanup _bitmap _view ( ) ;
}
}
let repeating _f = false ;
function onKeyDown ( event ) {
// console.log(event.key, event.repeat);
repeating _f = repeating _f || event . repeat && ( event . key === "f" || event . key === "F" ) ;
if ( event . repeat ) { return ; }
if ( repeating _f && ( event . key === "f" || event . key === "F" ) ) {
repeating _f = false ;
return ; // Chrome sends an F keydown with repeat=false if you release Ctrl before F, while repeating.
// This is a slightly overkill, and slightly overzealous workaround (can ignore one normal F before handling F as exit)
}
2021-12-07 20:44:45 +00:00
// Prevent also toggling View Bitmap on while toggling off, with Ctrl+F+F.
// That is, if you hold Ctrl and press F twice, the second F should close View Bitmap and not reopen it immediately.
// This relies on the keydown handler handling event.defaultPrevented (or isDefaultPrevented() if it's using jQuery)
event . preventDefault ( ) ;
2021-12-07 20:07:40 +00:00
// Note: in mspaint, Esc is the only key that DOESN'T close the bitmap view,
// but it also doesn't do anything else — other than changing the cursor. Stupid.
cleanup _bitmap _view ( ) ;
}
function onMouseDown ( event ) {
// Note: in mspaint, only left click exits View Bitmap mode.
// Right click can show a useless context menu.
cleanup _bitmap _view ( ) ;
}
function onContextMenu ( event ) {
event . preventDefault ( ) ;
cleanup _bitmap _view ( ) ; // not needed
}
2021-12-07 19:09:30 +00:00
// @TODO: include selection in the bitmap
// I believe mspaint uses a similar code path to the Thumbnail,
// considering that if you right click on the image in View Bitmap mode,
// it shows the silly "Thumbnail" context menu item.
// (It also shows the selection, in a meaningless place, similar to the Thumbnail's bugs)
main _canvas . toBlob ( blob => {
2021-12-07 20:07:40 +00:00
blob _url = URL . createObjectURL ( blob ) ;
2021-12-07 19:09:30 +00:00
const img = document . createElement ( "img" ) ;
2021-12-07 20:07:40 +00:00
img . src = blob _url ;
2021-12-07 19:09:30 +00:00
bitmap _view _div . appendChild ( img ) ;
} , "image/png" ) ;
2014-05-23 21:49:55 +00:00
}
2014-06-09 22:46:32 +00:00
2022-01-18 18:33:44 +00:00
function get _tool _by _id ( id ) {
for ( let i = 0 ; i < tools . length ; i ++ ) {
if ( tools [ i ] . id == id ) {
2018-02-17 06:45:06 +00:00
return tools [ i ] ;
2014-09-22 02:57:24 +00:00
}
}
2022-01-18 18:33:44 +00:00
for ( let i = 0 ; i < extra _tools . length ; i ++ ) {
if ( extra _tools [ i ] . id == id ) {
2018-02-17 06:45:06 +00:00
return extra _tools [ i ] ;
}
}
}
2019-09-21 05:59:56 +00:00
// hacky but whatever
// this whole "multiple tools" thing is hacky for now
function select _tools ( tools ) {
2022-01-18 18:33:44 +00:00
for ( let i = 0 ; i < tools . length ; i ++ ) {
2019-09-21 05:59:56 +00:00
select _tool ( tools [ i ] , i > 0 ) ;
}
2019-10-26 17:53:16 +00:00
update _helper _layer ( ) ;
2019-09-21 05:59:56 +00:00
}
2022-01-18 18:33:44 +00:00
function select _tool ( tool , toggle ) {
2019-12-03 18:20:17 +00:00
deselect ( ) ;
2022-01-18 18:33:44 +00:00
if ( ! ( selected _tools . length === 1 && selected _tool . deselect ) ) {
2019-09-21 05:59:56 +00:00
return _to _tools = [ ... selected _tools ] ;
2018-02-17 06:45:06 +00:00
}
2019-09-20 15:04:42 +00:00
if ( toggle ) {
2019-10-29 20:29:38 +00:00
const index = selected _tools . indexOf ( tool ) ;
2019-09-20 15:04:42 +00:00
if ( index === - 1 ) {
selected _tools . push ( tool ) ;
2022-01-18 18:33:44 +00:00
selected _tools . sort ( ( a , b ) => {
2019-09-20 15:58:22 +00:00
if ( tools . indexOf ( a ) < tools . indexOf ( b ) ) {
return - 1 ;
}
if ( tools . indexOf ( a ) > tools . indexOf ( b ) ) {
return + 1 ;
}
return 0 ;
} ) ;
2019-09-20 15:04:42 +00:00
} else {
selected _tools . splice ( index , 1 ) ;
}
if ( selected _tools . length > 0 ) {
selected _tool = selected _tools [ selected _tools . length - 1 ] ;
} else {
2019-10-30 17:38:16 +00:00
selected _tool = default _tool ;
2019-09-20 15:04:42 +00:00
selected _tools = [ selected _tool ] ;
}
2019-09-20 05:36:39 +00:00
} else {
2019-09-20 15:04:42 +00:00
selected _tool = tool ;
2019-09-20 05:36:39 +00:00
selected _tools = [ tool ] ;
}
2022-01-18 18:33:44 +00:00
if ( tool . preload ) {
2019-09-20 15:04:42 +00:00
tool . preload ( ) ;
2014-10-29 02:54:55 +00:00
}
2022-01-18 18:33:44 +00:00
2018-02-17 06:45:06 +00:00
$toolbox . update _selected _tool ( ) ;
// $toolbox2.update_selected_tool();
2014-09-22 02:57:24 +00:00
}
2019-09-21 06:04:59 +00:00
function has _any _transparency ( ctx ) {
2014-09-29 21:12:32 +00:00
// @TODO Optimization: Assume JPEGs and some other file types are opaque.
2015-02-23 21:16:21 +00:00
// Raster file formats that SUPPORT transparency include GIF, PNG, BMP and TIFF
// (Yes, even BMPs support transparency!)
2021-02-11 02:00:38 +00:00
const id = ctx . getImageData ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2022-01-18 18:33:44 +00:00
for ( let i = 0 , l = id . data . length ; i < l ; i += 4 ) {
2021-03-25 23:16:05 +00:00
// I've seen firefox give [ 254, 254, 254, 254 ] for get_rgba_from_color("#fff")
// or other values
2022-01-18 18:33:44 +00:00
if ( id . data [ i + 3 ] < 253 ) {
2019-09-21 06:04:59 +00:00
return true ;
2014-09-29 21:12:32 +00:00
}
}
2019-09-21 06:04:59 +00:00
return false ;
}
2021-02-07 03:57:55 +00:00
function detect _monochrome ( ctx ) {
2021-06-20 02:40:53 +00:00
// Note: Brave browser, and DuckDuckGo Privacy Essentials browser extension
// implement a privacy technique known as "farbling", which breaks this code.
// (I've implemented workarounds in many places, but not here yet.)
// This function currently returns the set of one or two colors if applicable,
// and things outside would need to be changed to handle a "near-monochrome" state.
2021-02-10 04:34:18 +00:00
const id = ctx . getImageData ( 0 , 0 , ctx . canvas . width , ctx . canvas . height ) ;
2021-02-07 03:07:36 +00:00
const pixelArray = new Uint32Array ( id . data . buffer ) ; // to access as whole pixels (for greater efficiency & simplicity)
2021-02-07 03:57:55 +00:00
// Note: values in pixelArray may be different on big endian vs little endian machines.
// Use id.data, which is guaranteed to be in RGBA order, for getting color information.
// Only use the Uint32Array for comparing pixel equality (faster than comparing each color component).
2021-02-10 19:53:57 +00:00
const colorUint32s = [ ] ;
2021-02-07 03:57:55 +00:00
const colorRGBAs = [ ] ;
2021-02-10 19:53:57 +00:00
let anyTransparency = false ;
2022-01-18 18:33:44 +00:00
for ( let i = 0 , len = pixelArray . length ; i < len ; i += 1 ) {
2021-03-25 23:16:05 +00:00
// @TODO: should this threshold not mirror has_any_transparency?
// seems to have different notions of "any transparency"
// has_any_transparency is "has any pixels not fully opaque"
// detect_monochrome's anyTransparency means "has any pixels fully transparent"
2022-01-18 18:33:44 +00:00
if ( id . data [ i * 4 + 3 ] > 1 ) {
2021-02-10 19:53:57 +00:00
if ( ! colorUint32s . includes ( pixelArray [ i ] ) ) {
if ( colorUint32s . length < 2 ) {
colorUint32s . push ( pixelArray [ i ] ) ;
2022-01-18 18:33:44 +00:00
colorRGBAs . push ( id . data . slice ( i * 4 , ( i + 1 ) * 4 ) ) ;
2021-02-10 19:53:57 +00:00
} else {
2022-01-18 18:33:44 +00:00
return { isMonochrome : false } ;
2021-02-10 19:53:57 +00:00
}
2021-02-07 03:07:36 +00:00
}
2021-02-10 19:53:57 +00:00
} else {
anyTransparency = true ;
2020-01-04 04:17:01 +00:00
}
}
2021-02-10 19:53:57 +00:00
return {
isMonochrome : true ,
presentNonTransparentRGBAs : colorRGBAs ,
presentNonTransparentUint32s : colorUint32s ,
monochromeWithTransparency : anyTransparency ,
} ;
2020-01-04 04:17:01 +00:00
}
2022-01-18 18:33:44 +00:00
function make _monochrome _pattern ( lightness , rgba1 = [ 0 , 0 , 0 , 255 ] , rgba2 = [ 255 , 255 , 255 , 255 ] ) {
2018-01-24 21:58:12 +00:00
2022-01-18 18:33:44 +00:00
const dither _threshold _table = Array . from ( { length : 64 } , ( _undefined , p ) => {
2019-10-29 20:29:38 +00:00
const q = p ^ ( p >> 3 ) ;
2017-06-24 06:22:29 +00:00
return (
( ( p & 4 ) >> 2 ) | ( ( q & 4 ) >> 1 ) |
( ( p & 2 ) << 1 ) | ( ( q & 2 ) << 2 ) |
( ( p & 1 ) << 4 ) | ( ( q & 1 ) << 5 )
) / 64 ;
} ) ;
2019-10-29 20:29:38 +00:00
const pattern _canvas = document . createElement ( "canvas" ) ;
const pattern _ctx = pattern _canvas . getContext ( "2d" ) ;
2018-01-24 21:58:12 +00:00
2017-06-24 06:22:29 +00:00
pattern _canvas . width = 8 ;
pattern _canvas . height = 8 ;
2018-01-24 21:58:12 +00:00
2021-02-11 02:00:38 +00:00
const pattern _image _data = main _ctx . createImageData ( pattern _canvas . width , pattern _canvas . height ) ;
2018-01-24 21:58:12 +00:00
2022-01-18 18:33:44 +00:00
for ( let x = 0 ; x < pattern _canvas . width ; x += 1 ) {
for ( let y = 0 ; y < pattern _canvas . height ; y += 1 ) {
2019-10-29 20:29:38 +00:00
const map _value = dither _threshold _table [ ( x & 7 ) + ( ( y & 7 ) << 3 ) ] ;
const px _white = lightness > map _value ;
2019-12-19 21:33:48 +00:00
const index = ( ( y * pattern _image _data . width ) + x ) * 4 ;
2021-02-07 02:29:47 +00:00
pattern _image _data . data [ index + 0 ] = px _white ? rgba2 [ 0 ] : rgba1 [ 0 ] ;
pattern _image _data . data [ index + 1 ] = px _white ? rgba2 [ 1 ] : rgba1 [ 1 ] ;
pattern _image _data . data [ index + 2 ] = px _white ? rgba2 [ 2 ] : rgba1 [ 2 ] ;
2021-07-10 14:29:02 +00:00
pattern _image _data . data [ index + 3 ] = ( px _white ? rgba2 [ 3 ] : rgba1 [ 3 ] ) ? ? 255 ; // handling also 3-length arrays (RGB)
2017-06-24 06:22:29 +00:00
}
}
2018-01-24 21:58:12 +00:00
2017-06-24 06:22:29 +00:00
pattern _ctx . putImageData ( pattern _image _data , 0 , 0 ) ;
2018-01-24 21:58:12 +00:00
2021-02-11 02:00:38 +00:00
return main _ctx . createPattern ( pattern _canvas , "repeat" ) ;
2017-06-24 06:22:29 +00:00
}
2022-01-18 18:33:44 +00:00
function make _monochrome _palette ( rgba1 = [ 0 , 0 , 0 , 255 ] , rgba2 = [ 255 , 255 , 255 , 255 ] ) {
2019-10-29 20:29:38 +00:00
const palette = [ ] ;
const n _colors _per _row = 14 ;
const n _colors = n _colors _per _row * 2 ;
2022-01-18 18:33:44 +00:00
for ( let i = 0 ; i < n _colors _per _row ; i ++ ) {
2019-09-21 15:59:30 +00:00
let lightness = i / n _colors ;
2021-02-07 02:29:47 +00:00
palette . push ( make _monochrome _pattern ( lightness , rgba1 , rgba2 ) ) ;
2017-06-24 06:22:29 +00:00
}
2022-01-18 18:33:44 +00:00
for ( let i = 0 ; i < n _colors _per _row ; i ++ ) {
2019-09-21 15:59:30 +00:00
let lightness = 1 - i / n _colors ;
2021-02-07 02:29:47 +00:00
palette . push ( make _monochrome _pattern ( lightness , rgba1 , rgba2 ) ) ;
2017-06-24 06:22:29 +00:00
}
2018-01-24 21:58:12 +00:00
2017-06-24 18:28:23 +00:00
return palette ;
}
2022-01-18 18:33:44 +00:00
function make _stripe _pattern ( reverse , colors , stripe _size = 4 ) {
2019-12-19 21:33:48 +00:00
const rgba _colors = colors . map ( get _rgba _from _color ) ;
const pattern _canvas = document . createElement ( "canvas" ) ;
const pattern _ctx = pattern _canvas . getContext ( "2d" ) ;
pattern _canvas . width = colors . length * stripe _size ;
pattern _canvas . height = colors . length * stripe _size ;
2021-02-11 02:00:38 +00:00
const pattern _image _data = main _ctx . createImageData ( pattern _canvas . width , pattern _canvas . height ) ;
2019-12-19 21:33:48 +00:00
2022-01-18 18:33:44 +00:00
for ( let x = 0 ; x < pattern _canvas . width ; x += 1 ) {
for ( let y = 0 ; y < pattern _canvas . height ; y += 1 ) {
2019-12-19 21:33:48 +00:00
const pixel _index = ( ( y * pattern _image _data . width ) + x ) * 4 ;
// +1000 to avoid remainder on negative numbers
2019-12-20 16:31:01 +00:00
const pos = reverse ? ( x - y ) : ( x + y ) ;
const color _index = Math . floor ( ( pos + 1000 ) / stripe _size ) % colors . length ;
2019-12-19 21:33:48 +00:00
const rgba = rgba _colors [ color _index ] ;
pattern _image _data . data [ pixel _index + 0 ] = rgba [ 0 ] ;
pattern _image _data . data [ pixel _index + 1 ] = rgba [ 1 ] ;
pattern _image _data . data [ pixel _index + 2 ] = rgba [ 2 ] ;
pattern _image _data . data [ pixel _index + 3 ] = rgba [ 3 ] ;
}
}
pattern _ctx . putImageData ( pattern _image _data , 0 , 0 ) ;
2021-02-11 02:00:38 +00:00
return main _ctx . createPattern ( pattern _canvas , "repeat" ) ;
2019-12-19 21:33:48 +00:00
}
2022-01-18 18:33:44 +00:00
function switch _to _polychrome _palette ( ) {
2018-01-24 21:58:12 +00:00
2017-06-24 06:22:29 +00:00
}
2019-12-19 20:01:15 +00:00
function make _opaque ( ) {
undoable ( {
name : "Make Opaque" ,
icon : get _help _folder _icon ( "p_make_opaque.png" ) ,
2022-01-18 18:33:44 +00:00
} , ( ) => {
2021-02-11 02:00:38 +00:00
main _ctx . save ( ) ;
main _ctx . globalCompositeOperation = "destination-atop" ;
2019-12-21 05:51:23 +00:00
2021-02-11 02:04:35 +00:00
main _ctx . fillStyle = selected _colors . background ;
2021-02-11 02:00:38 +00:00
main _ctx . fillRect ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2022-01-18 18:33:44 +00:00
2019-12-21 05:58:36 +00:00
// in case the selected background color is transparent/translucent
2021-02-11 02:00:38 +00:00
main _ctx . fillStyle = "white" ;
main _ctx . fillRect ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
2019-12-19 20:01:15 +00:00
2021-02-11 02:00:38 +00:00
main _ctx . restore ( ) ;
2019-12-19 20:01:15 +00:00
} ) ;
}
2022-01-18 18:33:44 +00:00
function resize _canvas _without _saving _dimensions ( unclamped _width , unclamped _height , undoable _meta = { } ) {
2019-12-16 05:13:48 +00:00
const new _width = Math . max ( 1 , unclamped _width ) ;
const new _height = Math . max ( 1 , unclamped _height ) ;
2021-02-11 02:00:38 +00:00
if ( main _canvas . width !== new _width || main _canvas . height !== new _height ) {
2019-12-16 06:11:48 +00:00
undoable ( {
2019-12-19 20:01:15 +00:00
name : undoable _meta . name || "Resize Canvas" ,
icon : undoable _meta . icon || get _help _folder _icon ( "p_stretch_both.png" ) ,
2019-12-16 06:11:48 +00:00
} , ( ) => {
2021-04-01 20:13:31 +00:00
try {
const image _data = main _ctx . getImageData ( 0 , 0 , new _width , new _height ) ;
main _canvas . width = new _width ;
main _canvas . height = new _height ;
main _ctx . disable _image _smoothing ( ) ;
2022-01-18 18:33:44 +00:00
if ( ! transparency ) {
2021-04-01 20:13:31 +00:00
main _ctx . fillStyle = selected _colors . background ;
main _ctx . fillRect ( 0 , 0 , main _canvas . width , main _canvas . height ) ;
}
2019-12-16 04:37:03 +00:00
2021-04-01 20:13:31 +00:00
const temp _canvas = make _canvas ( image _data ) ;
main _ctx . drawImage ( temp _canvas , 0 , 0 ) ;
} catch ( exception ) {
if ( exception . name === "NS_ERROR_FAILURE" ) {
// or localize("There is not enough memory or resources to complete operation.")
show _error _message ( localize ( "Insufficient memory to perform operation." ) , exception ) ;
} else {
show _error _message ( localize ( "An unknown error has occurred." ) , exception ) ;
}
// @TODO: undo and clean up undoable
// maybe even keep Attributes dialog open if that's what's triggering the resize
return ;
}
2019-12-16 04:37:03 +00:00
2019-12-16 05:13:48 +00:00
$canvas _area . trigger ( "resize" ) ;
} ) ;
}
2019-12-19 20:01:15 +00:00
}
2022-01-18 18:33:44 +00:00
function resize _canvas _and _save _dimensions ( unclamped _width , unclamped _height , undoable _meta = { } ) {
2019-12-19 20:01:15 +00:00
resize _canvas _without _saving _dimensions ( unclamped _width , unclamped _height , undoable _meta ) ;
2019-12-16 05:13:48 +00:00
storage . set ( {
2021-02-11 02:00:38 +00:00
width : main _canvas . width ,
height : main _canvas . height ,
2019-12-18 05:42:19 +00:00
} , ( /*error*/ ) => {
2019-12-16 05:13:48 +00:00
// oh well
} )
2019-12-16 04:37:03 +00:00
}
2022-01-18 18:33:44 +00:00
function image _attributes ( ) {
if ( image _attributes . $window ) {
2014-10-02 21:17:43 +00:00
image _attributes . $window . close ( ) ;
}
2021-09-02 22:19:51 +00:00
const $w = image _attributes . $window = new $DialogWindow ( localize ( "Attributes" ) ) ;
2020-12-17 19:06:02 +00:00
$w . addClass ( "attributes-window" ) ;
2018-01-24 21:58:12 +00:00
2019-10-29 20:29:38 +00:00
const $main = $w . $main ;
2018-01-24 21:58:12 +00:00
2014-10-02 21:17:43 +00:00
// Information
2018-01-24 21:58:12 +00:00
2019-10-29 20:29:38 +00:00
const table = {
2020-12-07 23:00:27 +00:00
[ localize ( "File last saved:" ) ] : localize ( "Not Available" ) , // @TODO: make available?
[ localize ( "Size on disk:" ) ] : localize ( "Not Available" ) , // @TODO: make available?
2020-12-17 19:06:02 +00:00
[ localize ( "Resolution:" ) ] : "72 x 72 dots per inch" , // if localizing this, remove "direction" setting below
2014-10-02 21:17:43 +00:00
} ;
2019-10-29 20:29:38 +00:00
const $table = $ ( E ( "table" ) ) . appendTo ( $main ) ;
2022-01-18 18:33:44 +00:00
for ( const k in table ) {
2019-10-29 20:29:38 +00:00
const $tr = $ ( E ( "tr" ) ) . appendTo ( $table ) ;
2020-12-07 04:27:03 +00:00
const $key = $ ( E ( "td" ) ) . appendTo ( $tr ) . text ( k ) ;
2019-10-29 20:29:38 +00:00
const $value = $ ( E ( "td" ) ) . appendTo ( $tr ) . text ( table [ k ] ) ;
2020-12-17 19:06:02 +00:00
if ( table [ k ] . indexOf ( "72" ) !== - 1 ) {
$value . css ( "direction" , "ltr" ) ;
}
2014-10-02 21:17:43 +00:00
}
2018-01-24 21:58:12 +00:00
2014-10-02 21:17:43 +00:00
// Dimensions
2018-01-24 21:58:12 +00:00
2022-01-18 18:33:44 +00:00
const unit _sizes _in _px = { px : 1 , in : 72 , cm : 28.3465 } ;
2019-10-29 20:29:38 +00:00
let current _unit = image _attributes . unit = image _attributes . unit || "px" ;
2021-02-11 02:00:38 +00:00
let width _in _px = main _canvas . width ;
let height _in _px = main _canvas . height ;
2018-01-24 21:58:12 +00:00
2023-02-14 11:54:49 +00:00
const $width _label = $ ( E ( "label" ) ) . appendTo ( $main ) . html ( display _hotkey ( localize ( "&Width:" ) ) ) ;
const $height _label = $ ( E ( "label" ) ) . appendTo ( $main ) . html ( display _hotkey ( localize ( "&Height:" ) ) ) ;
2023-02-14 12:49:04 +00:00
const $width = $ ( E ( "input" ) ) . attr ( { type : "number" , min : 1 , "aria-keyshortcuts" : "Alt+W W W" } ) . addClass ( "no-spinner inset-deep" ) . appendTo ( $width _label ) ;
const $height = $ ( E ( "input" ) ) . attr ( { type : "number" , min : 1 , "aria-keyshortcuts" : "Alt+H H H" } ) . addClass ( "no-spinner inset-deep" ) . appendTo ( $height _label ) ;
2018-01-24 21:58:12 +00:00
2015-06-19 01:11:40 +00:00
$main . find ( "input" )
2022-01-18 18:33:44 +00:00
. css ( { width : "40px" } )
. on ( "change keyup keydown keypress pointerdown pointermove paste drop" , ( ) => {
2019-11-03 04:57:11 +00:00
width _in _px = $width . val ( ) * unit _sizes _in _px [ current _unit ] ;
height _in _px = $height . val ( ) * unit _sizes _in _px [ current _unit ] ;
2014-10-02 21:17:43 +00:00
} ) ;
2018-01-24 21:58:12 +00:00
2014-10-02 21:17:43 +00:00
// Fieldsets
2018-01-24 21:58:12 +00:00
2021-02-08 03:23:16 +00:00
const $units = $ ( E ( "fieldset" ) ) . appendTo ( $main ) . append ( `
< legend > $ { localize ( "Units" ) } < / l e g e n d >
< div class = "fieldset-body" >
2023-02-14 12:49:04 +00:00
< input type = "radio" name = "units" id = "unit-in" value = "in" aria - keyshortcuts = "Alt+I I" > < label for = "unit-in" > $ { display _hotkey ( localize ( "&Inches" ) ) } < / l a b e l >
< input type = "radio" name = "units" id = "unit-cm" value = "cm" aria - keyshortcuts = "Alt+M M" > < label for = "unit-cm" > $ { display _hotkey ( localize ( "C&m" ) ) } < / l a b e l >
< input type = "radio" name = "units" id = "unit-px" value = "px" aria - keyshortcuts = "Alt+P P" > < label for = "unit-px" > $ { display _hotkey ( localize ( "&Pixels" ) ) } < / l a b e l >
2021-02-08 03:23:16 +00:00
< / d i v >
` );
2022-01-18 18:33:44 +00:00
$units . find ( ` [value= ${ current _unit } ] ` ) . attr ( { checked : true } ) ;
2019-10-29 18:46:29 +00:00
$units . on ( "change" , ( ) => {
2019-10-29 20:29:38 +00:00
const new _unit = $units . find ( ":checked" ) . val ( ) ;
2014-10-02 21:17:43 +00:00
$width . val ( width _in _px / unit _sizes _in _px [ new _unit ] ) ;
$height . val ( height _in _px / unit _sizes _in _px [ new _unit ] ) ;
current _unit = new _unit ;
} ) . triggerHandler ( "change" ) ;
2018-01-24 21:58:12 +00:00
2021-02-08 03:23:16 +00:00
const $colors = $ ( E ( "fieldset" ) ) . appendTo ( $main ) . append ( `
< legend > $ { localize ( "Colors" ) } < / l e g e n d >
< div class = "fieldset-body" >
2023-02-14 12:49:04 +00:00
< input type = "radio" name = "colors" id = "attribute-monochrome" value = "monochrome" aria - keyshortcuts = "Alt+B B" > < label for = "attribute-monochrome" > $ { display _hotkey ( localize ( "&Black and white" ) ) } < / l a b e l >
< input type = "radio" name = "colors" id = "attribute-polychrome" value = "polychrome" aria - keyshortcuts = "Alt+L L" > < label for = "attribute-polychrome" > $ { display _hotkey ( localize ( "Co&lors" ) ) } < / l a b e l >
2021-02-08 03:23:16 +00:00
< / d i v >
` );
2022-01-18 18:33:44 +00:00
$colors . find ( ` [value= ${ monochrome ? "monochrome" : "polychrome" } ] ` ) . attr ( { checked : true } ) ;
2018-01-24 21:58:12 +00:00
2021-02-08 03:23:16 +00:00
const $transparency = $ ( E ( "fieldset" ) ) . appendTo ( $main ) . append ( `
< legend > $ { localize ( "Transparency" ) } < / l e g e n d >
< div class = "fieldset-body" >
2021-12-07 04:06:50 +00:00
< input type = "radio" name = "transparency" id = "attribute-transparent" value = "transparent" > < label for = "attribute-transparent" > $ { localize ( "Transparent" ) } < / l a b e l >
< input type = "radio" name = "transparency" id = "attribute-opaque" value = "opaque" > < label for = "attribute-opaque" > $ { localize ( "Opaque" ) } < / l a b e l >
2021-02-08 03:23:16 +00:00
< / d i v >
` );
2022-01-18 18:33:44 +00:00
$transparency . find ( ` [value= ${ transparency ? "transparent" : "opaque" } ] ` ) . attr ( { checked : true } ) ;
2018-01-24 21:58:12 +00:00
2014-10-02 21:17:43 +00:00
// Buttons on the right
2018-01-24 21:58:12 +00:00
2020-12-05 22:33:21 +00:00
$w . $Button ( localize ( "OK" ) , ( ) => {
2019-10-29 20:29:38 +00:00
const transparency _option = $transparency . find ( ":checked" ) . val ( ) ;
const colors _option = $colors . find ( ":checked" ) . val ( ) ;
const unit = $units . find ( ":checked" ) . val ( ) ;
2018-01-24 21:58:12 +00:00
2019-10-29 20:29:38 +00:00
const was _monochrome = monochrome ;
2021-02-10 20:42:31 +00:00
let monochrome _info ;
2018-01-24 21:58:12 +00:00
2014-10-02 21:17:43 +00:00
image _attributes . unit = unit ;
2017-06-24 06:22:29 +00:00
transparency = ( transparency _option == "transparent" ) ;
monochrome = ( colors _option == "monochrome" ) ;
2018-01-24 21:58:12 +00:00
2022-01-18 18:33:44 +00:00
if ( monochrome != was _monochrome ) {
2021-02-10 20:42:31 +00:00
if ( selection ) {
// want to detect monochrome based on selection + canvas
// simplest way to do that is to meld them together
meld _selection _into _canvas ( ) ;
}
2021-02-11 02:00:38 +00:00
monochrome _info = detect _monochrome ( main _ctx ) ;
2021-02-10 20:42:31 +00:00
2022-01-18 18:33:44 +00:00
if ( monochrome ) {
if ( monochrome _info . isMonochrome && monochrome _info . presentNonTransparentRGBAs . length === 2 ) {
2021-02-10 19:53:57 +00:00
palette = make _monochrome _palette ( ... monochrome _info . presentNonTransparentRGBAs ) ;
2022-01-18 18:33:44 +00:00
} else {
2021-02-07 03:57:55 +00:00
palette = monochrome _palette ;
}
2022-01-18 18:33:44 +00:00
} else {
2017-06-24 18:28:23 +00:00
palette = polychrome _palette ;
}
2021-02-11 02:04:35 +00:00
selected _colors . foreground = palette [ 0 ] ;
selected _colors . background = palette [ 14 ] ; // first in second row
selected _colors . ternary = "" ;
2017-06-24 18:28:23 +00:00
$colorbox . rebuild _palette ( ) ;
2021-02-07 03:57:55 +00:00
$G . trigger ( "option-changed" ) ;
2017-06-24 06:22:29 +00:00
}
2018-01-24 21:58:12 +00:00
2019-10-29 20:29:38 +00:00
const unit _to _px = unit _sizes _in _px [ unit ] ;
const width = $width . val ( ) * unit _to _px ;
const height = $height . val ( ) * unit _to _px ;
2019-12-16 04:37:03 +00:00
resize _canvas _and _save _dimensions ( ~ ~ width , ~ ~ height ) ;
2018-01-24 21:58:12 +00:00
2021-02-11 02:00:38 +00:00
if ( ! transparency && has _any _transparency ( main _ctx ) ) {
2019-12-19 20:01:15 +00:00
make _opaque ( ) ;
}
2021-02-08 05:13:35 +00:00
// 1. Must be after canvas resize to avoid weird undoable interaction and such.
2021-06-19 23:58:47 +00:00
// 2. Check that monochrome option changed, same as above.
2021-02-10 20:42:31 +00:00
// a) for monochrome_info variable to be available
// b) Consider the case where color is introduced to the canvas while in monochrome mode.
// We only want to show this dialog if it would also change the palette (above), never leave you on an outdated palette.
// c) And it's nice to be able to change other options without worrying about it trying to convert the document to monochrome.
2022-01-18 18:33:44 +00:00
if ( monochrome != was _monochrome ) {
2021-02-10 19:53:57 +00:00
if ( monochrome && ! monochrome _info . isMonochrome ) {
2021-02-08 05:13:35 +00:00
show _convert _to _black _and _white ( ) ;
}
2021-02-08 04:21:27 +00:00
}
2014-10-02 21:17:43 +00:00
image _attributes . $window . close ( ) ;
2023-02-13 09:56:08 +00:00
} , { type : "submit" } ) ;
2018-01-24 21:58:12 +00:00
2020-12-05 22:33:21 +00:00
$w . $Button ( localize ( "Cancel" ) , ( ) => {
2014-10-02 21:17:43 +00:00
image _attributes . $window . close ( ) ;
} ) ;
2018-01-24 21:58:12 +00:00
2023-02-14 13:17:02 +00:00
// Parsing HTML with jQuery; $Button takes text (not HTML) or Node/DocumentFragment
$w . $Button ( $ . parseHTML ( display _hotkey ( localize ( "&Default" ) ) ) [ 0 ] , ( ) => {
2014-10-02 21:17:43 +00:00
width _in _px = default _canvas _width ;
height _in _px = default _canvas _height ;
$width . val ( width _in _px / unit _sizes _in _px [ current _unit ] ) ;
$height . val ( height _in _px / unit _sizes _in _px [ current _unit ] ) ;
2023-02-14 12:49:04 +00:00
} ) . attr ( "aria-keyshortcuts" , "Alt+D D" ) ;
2023-02-14 11:54:49 +00:00
handle _keyshortcuts ( $w ) ;
2018-01-24 21:58:12 +00:00
2023-02-13 08:31:23 +00:00
// Default focus
$width . select ( ) ;
2014-10-02 21:17:43 +00:00
// Reposition the window
2018-01-24 21:58:12 +00:00
2014-10-02 21:17:43 +00:00
image _attributes . $window . center ( ) ;
}
2020-01-04 04:17:01 +00:00
function show _convert _to _black _and _white ( ) {
2021-09-02 22:19:51 +00:00
const $w = new $DialogWindow ( "Convert to Black and White" ) ;
2020-01-04 04:17:01 +00:00
$w . addClass ( "convert-to-black-and-white" ) ;
2020-12-17 19:16:09 +00:00
$w . $main . append ( "<fieldset><legend>Threshold:</legend><input type='range' min='0' max='1' step='0.01' value='0.5'></fieldset>" ) ;
2020-01-04 04:17:01 +00:00
const $slider = $w . $main . find ( "input[type='range']" ) ;
2021-02-11 02:00:38 +00:00
const original _canvas = make _canvas ( main _canvas ) ;
2020-01-04 04:17:01 +00:00
let threshold ;
2022-01-18 18:33:44 +00:00
const update _threshold = ( ) => {
2020-01-04 04:17:01 +00:00
make _or _update _undoable ( {
name : "Make Monochrome" ,
2022-01-18 18:33:44 +00:00
match : ( history _node ) => history _node . name === "Make Monochrome" ,
2020-01-04 04:17:01 +00:00
icon : get _help _folder _icon ( "p_monochrome.png" ) ,
2022-01-18 18:33:44 +00:00
} , ( ) => {
2020-01-04 04:17:01 +00:00
threshold = $slider . val ( ) ;
2021-02-11 02:00:38 +00:00
main _ctx . copy ( original _canvas ) ;
threshold _black _and _white ( main _ctx , threshold ) ;
2020-01-04 04:17:01 +00:00
} ) ;
} ;
update _threshold ( ) ;
2020-01-05 16:52:41 +00:00
$slider . on ( "input" , debounce ( update _threshold , 100 ) ) ;
2020-01-04 04:17:01 +00:00
2022-01-18 18:33:44 +00:00
$w . $Button ( localize ( "OK" ) , ( ) => {
2020-01-04 04:17:01 +00:00
$w . close ( ) ;
2023-02-13 09:56:08 +00:00
} , { type : "submit" } ) . focus ( ) ;
2022-01-18 18:33:44 +00:00
$w . $Button ( localize ( "Cancel" ) , ( ) => {
2020-01-04 04:17:01 +00:00
if ( current _history _node . name === "Make Monochrome" ) {
undo ( ) ;
} else {
undoable ( {
name : "Cancel Make Monochrome" ,
2021-02-13 17:54:44 +00:00
icon : get _help _folder _icon ( "p_color.png" ) ,
2022-01-18 18:33:44 +00:00
} , ( ) => {
2021-02-11 02:00:38 +00:00
main _ctx . copy ( original _canvas ) ;
2020-01-04 04:17:01 +00:00
} ) ;
}
$w . close ( ) ;
} ) ;
$w . center ( ) ;
}
2022-01-18 18:33:44 +00:00
function image _flip _and _rotate ( ) {
2021-09-02 22:19:51 +00:00
const $w = new $DialogWindow ( localize ( "Flip and Rotate" ) ) ;
2019-12-18 14:38:01 +00:00
$w . addClass ( "flip-and-rotate" ) ;
2018-01-24 21:58:12 +00:00
2019-10-29 20:29:38 +00:00
const $fieldset = $ ( E ( "fieldset" ) ) . appendTo ( $w . $main ) ;
2020-05-28 03:48:11 +00:00
$fieldset . append ( `
2020-12-07 05:43:48 +00:00
< legend > $ { localize ( "Flip or rotate" ) } < / l e g e n d >
2021-04-02 16:46:01 +00:00
< div class = "radio-wrapper" >
< input
type = "radio"
name = "flip-or-rotate"
id = "flip-horizontal"
value = "flip-horizontal"
aria - keyshortcuts = "Alt+F"
checked
/ > < l a b e l f o r = " f l i p - h o r i z o n t a l " > $ { d i s p l a y _ h o t k e y ( l o c a l i z e ( " & F l i p h o r i z o n t a l " ) ) } < / l a b e l >
< / d i v >
< div class = "radio-wrapper" >
< input
type = "radio"
name = "flip-or-rotate"
id = "flip-vertical"
value = "flip-vertical"
aria - keyshortcuts = "Alt+V"
/ > < l a b e l f o r = " f l i p - v e r t i c a l " > $ { d i s p l a y _ h o t k e y ( l o c a l i z e ( " F l i p & v e r t i c a l " ) ) } < / l a b e l >
< / d i v >
< div class = "radio-wrapper" >
< input
type = "radio"
name = "flip-or-rotate"
id = "rotate-by-angle"
value = "rotate-by-angle"
aria - keyshortcuts = "Alt+R"
/ > < l a b e l f o r = " r o t a t e - b y - a n g l e " > $ { d i s p l a y _ h o t k e y ( l o c a l i z e ( " & R o t a t e b y a n g l e " ) ) } < / l a b e l >
< / d i v >
2020-05-28 03:48:11 +00:00
` );
2018-01-24 21:58:12 +00:00
2020-04-23 01:36:30 +00:00
const $rotate _by _angle = $ ( E ( "div" ) ) . appendTo ( $fieldset ) ;
2020-12-17 19:16:09 +00:00
$rotate _by _angle . addClass ( "sub-options" ) ;
2021-04-02 16:46:01 +00:00
for ( const label _with _hotkey of [
"&90°" ,
"&180°" ,
"&270°" ,
] ) {
const degrees = parseInt ( remove _hotkey ( label _with _hotkey ) , 10 ) ;
$rotate _by _angle . append ( `
< div class = "radio-wrapper" >
< input
type = "radio"
name = "rotate-by-angle"
value = "${degrees}"
id = "rotate-${degrees}"
aria - keyshortcuts = "Alt+${get_hotkey(label_with_hotkey).toUpperCase()}"
/ > < l a b e l
for = "rotate-${degrees}"
> $ { display _hotkey ( label _with _hotkey ) } < / l a b e l >
< / d i v >
` );
}
2020-05-28 03:48:11 +00:00
$rotate _by _angle . append ( `
2021-04-02 16:46:01 +00:00
< div class = "radio-wrapper" >
< input
type = "radio"
name = "rotate-by-angle"
value = "arbitrary"
/ > < i n p u t
type = "number"
min = "-360"
max = "360"
name = "rotate-by-arbitrary-angle"
id = "custom-degrees"
value = ""
class = "no-spinner inset-deep"
style = "width: 50px"
/ >
< label for = "custom-degrees" > $ { localize ( "Degrees" ) } < / l a b e l >
< / d i v >
2020-05-28 03:48:11 +00:00
` );
2022-01-18 18:33:44 +00:00
$rotate _by _angle . find ( "#rotate-90" ) . attr ( { checked : true } ) ;
2021-02-08 19:27:54 +00:00
// Disabling inputs makes them not even receive mouse events,
2021-04-02 03:55:47 +00:00
// and so pointer-events: none is needed to respond to events on the parent.
2022-01-18 18:33:44 +00:00
$rotate _by _angle . find ( "input" ) . attr ( { disabled : true } ) ;
2021-04-02 03:55:47 +00:00
$fieldset . find ( "input" ) . on ( "change" , ( ) => {
const action = $fieldset . find ( "input[name='flip-or-rotate']:checked" ) . val ( ) ;
$rotate _by _angle . find ( "input" ) . attr ( {
disabled : action !== "rotate-by-angle"
} ) ;
} ) ;
2022-01-18 18:33:44 +00:00
$rotate _by _angle . find ( ".radio-wrapper" ) . on ( "click" , ( e ) => {
2014-12-08 14:48:46 +00:00
// Select "Rotate by angle" and enable subfields
$fieldset . find ( "input[value='rotate-by-angle']" ) . prop ( "checked" , true ) ;
$fieldset . find ( "input" ) . triggerHandler ( "change" ) ;
2018-01-24 21:58:12 +00:00
2021-04-02 03:55:47 +00:00
const $wrapper = $ ( e . target ) . closest ( ".radio-wrapper" ) ;
2014-12-08 14:48:46 +00:00
// Focus the numerical input if this field has one
2021-04-02 03:55:47 +00:00
const num _input = $wrapper . find ( "input[type='number']" ) [ 0 ] ;
2019-09-28 17:16:18 +00:00
if ( num _input ) {
num _input . focus ( ) ;
}
2014-12-08 14:48:46 +00:00
// Select the radio for this field
2021-04-02 03:55:47 +00:00
$wrapper . find ( "input[type='radio']" ) . prop ( "checked" , true ) ;
2014-12-08 14:48:46 +00:00
} ) ;
2018-01-24 21:58:12 +00:00
2022-01-18 18:33:44 +00:00
$fieldset . find ( "input[name='rotate-by-arbitrary-angle']" ) . on ( "input" , ( ) => {
2021-04-02 04:30:49 +00:00
$fieldset . find ( "input[value='rotate-by-angle']" ) . prop ( "checked" , true ) ;
$fieldset . find ( "input[value='arbitrary']" ) . prop ( "checked" , true ) ;
} ) ;
2020-12-05 22:33:21 +00:00
$w . $Button ( localize ( "OK" ) , ( ) => {
2019-10-29 20:29:38 +00:00
const action = $fieldset . find ( "input[name='flip-or-rotate']:checked" ) . val ( ) ;
2022-01-18 18:33:44 +00:00
switch ( action ) {
2014-12-08 02:45:23 +00:00
case "flip-horizontal" :
flip _horizontal ( ) ;
break ;
case "flip-vertical" :
flip _vertical ( ) ;
break ;
2020-12-07 05:31:05 +00:00
case "rotate-by-angle" : {
let angle _val = $fieldset . find ( "input[name='rotate-by-angle']:checked" ) . val ( ) ;
2022-01-18 18:33:44 +00:00
if ( angle _val === "arbitrary" ) {
2020-12-07 05:31:05 +00:00
angle _val = $fieldset . find ( "input[name='rotate-by-arbitrary-angle']" ) . val ( ) ;
}
const angle _deg = parseFloat ( angle _val ) ;
const angle = angle _deg / 360 * TAU ;
2022-01-18 18:33:44 +00:00
if ( isNaN ( angle ) ) {
2021-04-01 14:56:57 +00:00
please _enter _a _number ( ) ;
2020-12-07 05:31:05 +00:00
return ;
}
2014-12-08 02:45:23 +00:00
rotate ( angle ) ;
break ;
2020-12-07 05:31:05 +00:00
}
2014-12-08 02:45:23 +00:00
}
2017-10-30 21:02:45 +00:00
$canvas _area . trigger ( "resize" ) ;
2018-01-24 21:58:12 +00:00
2014-10-02 21:17:43 +00:00
$w . close ( ) ;
2023-02-14 13:27:00 +00:00
} , { type : "submit" } ) ;
2020-12-05 22:33:21 +00:00
$w . $Button ( localize ( "Cancel" ) , ( ) => {
2014-10-02 21:17:43 +00:00
$w . close ( ) ;
} ) ;
2018-01-24 21:58:12 +00:00
2023-02-14 13:27:00 +00:00
$fieldset . find ( "input[type='radio']" ) . first ( ) . focus ( ) ;
2014-10-02 21:17:43 +00:00
$w . center ( ) ;
2021-04-02 05:00:31 +00:00
2023-02-14 11:18:50 +00:00
handle _keyshortcuts ( $w ) ;
2014-10-02 21:17:43 +00:00
}
2022-01-18 18:33:44 +00:00
function image _stretch _and _skew ( ) {
2021-09-02 22:19:51 +00:00
const $w = new $DialogWindow ( localize ( "Stretch and Skew" ) ) ;
2020-12-17 19:16:09 +00:00
$w . addClass ( "stretch-and-skew" ) ;
2018-01-24 21:58:12 +00:00
2019-10-29 20:29:38 +00:00
const $fieldset _stretch = $ ( E ( "fieldset" ) ) . appendTo ( $w . $main ) ;
2020-12-07 04:27:03 +00:00
$fieldset _stretch . append ( ` <legend> ${ localize ( "Stretch" ) } </legend><table></table> ` ) ;
2019-10-29 20:29:38 +00:00
const $fieldset _skew = $ ( E ( "fieldset" ) ) . appendTo ( $w . $main ) ;
2020-12-07 04:27:03 +00:00
$fieldset _skew . append ( ` <legend> ${ localize ( "Skew" ) } </legend><table></table> ` ) ;
2018-01-24 21:58:12 +00:00
2021-04-02 05:33:40 +00:00
const $RowInput = ( $table , img _src , label _with _hotkey , default _value , label _unit , min , max ) => {
2019-10-29 20:29:38 +00:00
const $tr = $ ( E ( "tr" ) ) . appendTo ( $table ) ;
const $img = $ ( E ( "img" ) ) . attr ( {
2019-11-05 00:18:37 +00:00
src : ` images/transforms/ ${ img _src } .png ` ,
width : 32 ,
height : 32 ,
2014-10-02 21:17:43 +00:00
} ) . css ( {
marginRight : "20px"
} ) ;
2020-04-23 01:44:16 +00:00
const input _id = ( "input" + Math . random ( ) + Math . random ( ) ) . replace ( /\./ , "" ) ;
2019-10-29 20:29:38 +00:00
const $input = $ ( E ( "input" ) ) . attr ( {
2020-05-11 20:44:03 +00:00
type : "number" ,
min ,
max ,
2020-04-23 01:44:16 +00:00
value : default _value ,
id : input _id ,
2021-04-02 05:33:40 +00:00
"aria-keyshortcuts" : ` Alt+ ${ get _hotkey ( label _with _hotkey ) . toUpperCase ( ) } ` ,
2014-10-02 21:17:43 +00:00
} ) . css ( {
width : "40px"
2021-01-29 22:29:57 +00:00
} ) . addClass ( "no-spinner inset-deep" ) ;
2014-10-02 21:17:43 +00:00
$ ( E ( "td" ) ) . appendTo ( $tr ) . append ( $img ) ;
2021-04-02 05:33:40 +00:00
$ ( E ( "td" ) ) . appendTo ( $tr ) . append ( $ ( E ( "label" ) ) . html ( display _hotkey ( label _with _hotkey ) ) . attr ( "for" , input _id ) ) ;
2014-10-02 21:17:43 +00:00
$ ( E ( "td" ) ) . appendTo ( $tr ) . append ( $input ) ;
$ ( E ( "td" ) ) . appendTo ( $tr ) . text ( label _unit ) ;
2018-01-24 21:58:12 +00:00
2014-10-02 21:17:43 +00:00
return $input ;
} ;
2018-01-24 21:58:12 +00:00
2021-04-02 05:33:40 +00:00
const stretch _x = $RowInput ( $fieldset _stretch . find ( "table" ) , "stretch-x" , localize ( "&Horizontal:" ) , 100 , "%" , 1 , 5000 ) ;
const stretch _y = $RowInput ( $fieldset _stretch . find ( "table" ) , "stretch-y" , localize ( "&Vertical:" ) , 100 , "%" , 1 , 5000 ) ;
const skew _x = $RowInput ( $fieldset _skew . find ( "table" ) , "skew-x" , localize ( "H&orizontal:" ) , 0 , localize ( "Degrees" ) , - 90 , 90 ) ;
const skew _y = $RowInput ( $fieldset _skew . find ( "table" ) , "skew-y" , localize ( "V&ertical:" ) , 0 , localize ( "Degrees" ) , - 90 , 90 ) ;
2018-01-24 21:58:12 +00:00
2020-12-05 22:33:21 +00:00
$w . $Button ( localize ( "OK" ) , ( ) => {
2022-01-18 18:33:44 +00:00
const x _scale = parseFloat ( stretch _x . val ( ) ) / 100 ;
const y _scale = parseFloat ( stretch _y . val ( ) ) / 100 ;
const h _skew = parseFloat ( skew _x . val ( ) ) / 360 * TAU ;
const v _skew = parseFloat ( skew _y . val ( ) ) / 360 * TAU ;
2021-06-19 23:58:47 +00:00
if ( isNaN ( x _scale ) || isNaN ( y _scale ) || isNaN ( h _skew ) || isNaN ( v _skew ) ) {
2021-04-01 14:56:57 +00:00
please _enter _a _number ( ) ;
return ;
}
2021-04-01 20:13:31 +00:00
try {
2021-06-19 23:58:47 +00:00
stretch _and _skew ( x _scale , y _scale , h _skew , v _skew ) ;
2021-04-01 20:13:31 +00:00
} catch ( exception ) {
if ( exception . name === "NS_ERROR_FAILURE" ) {
// or localize("There is not enough memory or resources to complete operation.")
show _error _message ( localize ( "Insufficient memory to perform operation." ) , exception ) ;
} else {
show _error _message ( localize ( "An unknown error has occurred." ) , exception ) ;
}
2024-02-02 20:30:04 +00:00
// @TODO: undo and clean up undoable
2021-04-01 20:13:31 +00:00
return ;
}
2017-10-30 21:02:45 +00:00
$canvas _area . trigger ( "resize" ) ;
2014-10-02 21:17:43 +00:00
$w . close ( ) ;
2023-02-14 13:32:15 +00:00
} , { type : "submit" } ) ;
2018-01-24 21:58:12 +00:00
2020-12-05 22:33:21 +00:00
$w . $Button ( localize ( "Cancel" ) , ( ) => {
2014-10-02 21:17:43 +00:00
$w . close ( ) ;
} ) ;
2018-01-24 21:58:12 +00:00
2023-02-14 13:32:15 +00:00
$w . $main . find ( "input" ) . first ( ) . focus ( ) . select ( ) ;
2014-10-02 21:17:43 +00:00
$w . center ( ) ;
2021-04-02 05:33:40 +00:00
2023-02-14 11:18:50 +00:00
handle _keyshortcuts ( $w ) ;
2021-04-02 16:46:01 +00:00
}
2023-02-14 11:18:50 +00:00
function handle _keyshortcuts ( $container ) {
2023-02-14 12:49:04 +00:00
// This function implements shortcuts defined with aria-keyshortcuts.
// It also modifies aria-keyshortcuts to remove shortcuts that don't
// contain a modifier (other than shift) when an input field is focused,
// in order to avoid conflicts with typing.
// It stores the original aria-keyshortcuts (indefinitely), so if aria-keyshortcuts
// is ever to be modified at runtime (externally), the code here may need to be changed.
2022-01-18 18:33:44 +00:00
$container . on ( "keydown" , ( event ) => {
2023-02-14 11:18:50 +00:00
const $targets = $container . find ( "[aria-keyshortcuts]" ) ;
for ( let shortcut _target of $targets ) {
const shortcuts = $ ( shortcut _target ) . attr ( "aria-keyshortcuts" ) . split ( " " ) ;
for ( const shortcut of shortcuts ) {
// TODO: should we use code instead of key? need examples
if (
! ! shortcut . match ( /Alt\+/i ) === event . altKey &&
! ! shortcut . match ( /Ctrl\+/i ) === event . ctrlKey &&
! ! shortcut . match ( /Meta\+/i ) === event . metaKey &&
! ! shortcut . match ( /Shift\+/i ) === event . shiftKey &&
shortcut . split ( "+" ) . pop ( ) . toUpperCase ( ) === event . key . toUpperCase ( )
) {
event . preventDefault ( ) ;
event . stopPropagation ( ) ;
if ( shortcut _target . disabled ) {
shortcut _target = shortcut _target . closest ( ".radio-wrapper" ) ;
}
shortcut _target . click ( ) ;
shortcut _target . focus ( ) ;
return ;
2021-04-02 16:46:01 +00:00
}
2021-04-02 05:33:40 +00:00
}
}
} ) ;
2023-02-14 12:49:04 +00:00
// Prevent keyboard shortcuts from interfering with typing in text fields.
// Rather than conditionally handling the shortcut, I'm conditionally removing it,
// because _theoretically_ it's better for assistive technology to know that the shortcut isn't available.
// (Theoretically I should also remove aria-keyshortcuts when the window isn't focused...)
$container . on ( "focusin focusout" , ( event ) => {
if ( $ ( event . target ) . is ( 'textarea, input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"]):not([type="file"]):not([type="color"]):not([type="range"])' ) ) {
for ( const control of $container . find ( "[aria-keyshortcuts]" ) ) {
control . _original _aria _keyshortcuts = control . _original _aria _keyshortcuts ? ? control . getAttribute ( "aria-keyshortcuts" ) ;
// Remove shortcuts without modifiers.
control . setAttribute ( "aria-keyshortcuts" ,
control . getAttribute ( "aria-keyshortcuts" )
. split ( " " )
. filter ( ( shortcut ) => shortcut . match ( /(Alt|Ctrl|Meta)\+/i ) )
. join ( " " )
) ;
}
} else {
// Restore shortcuts.
for ( const control of $container . find ( "[aria-keyshortcuts]" ) ) {
if ( control . _original _aria _keyshortcuts ) {
control . setAttribute ( "aria-keyshortcuts" , control . _original _aria _keyshortcuts ) ;
}
}
}
} ) ;
2014-10-02 21:17:43 +00:00
}
2021-04-01 04:45:07 +00:00
function save _as _prompt ( {
2022-01-18 18:33:44 +00:00
dialogTitle = localize ( "Save As" ) ,
defaultFileName = "" ,
2021-04-01 04:45:07 +00:00
defaultFileFormatID ,
formats ,
2022-01-18 18:33:44 +00:00
promptForName = true ,
2021-04-01 04:45:07 +00:00
} ) {
2022-01-18 18:33:44 +00:00
return new Promise ( ( resolve ) => {
2021-09-02 22:19:51 +00:00
const $w = new $DialogWindow ( dialogTitle ) ;
2021-04-01 14:01:38 +00:00
$w . addClass ( "save-as" ) ;
2021-01-28 19:53:40 +00:00
2021-12-08 12:46:18 +00:00
// This is needed to prevent the keyboard from closing when you tap the file name input! in FF mobile
// @TODO: Investigate this in os-gui.js; is it literally just the browser default behavior to focus a div with tabindex that's the parent of an input?
// That'd be crazy, right?
$w . $content . attr ( "tabIndex" , null ) ;
2021-04-01 14:01:38 +00:00
// @TODO: hotkeys (N, T, S, Enter, Esc)
if ( promptForName ) {
$w . $main . append ( `
< label >
File name :
< input type = "text" class = "file-name inset-deep" / >
< / l a b e l >
` );
}
2021-04-01 04:45:07 +00:00
$w . $main . append ( `
< label >
2021-04-01 14:01:38 +00:00
Save as type :
2021-12-07 18:08:30 +00:00
< select class = "file-type-select inset-deep" > < / s e l e c t >
2021-04-01 04:45:07 +00:00
< / l a b e l >
` );
2021-04-01 14:01:38 +00:00
const $file _type = $w . $main . find ( ".file-type-select" ) ;
const $file _name = $w . $main . find ( ".file-name" ) ;
2021-01-28 19:53:40 +00:00
2021-04-01 14:01:38 +00:00
for ( const format of formats ) {
$file _type . append ( $ ( "<option>" ) . val ( format . formatID ) . text ( format . nameWithExtensions ) ) ;
}
2021-01-28 19:53:40 +00:00
2021-04-01 14:01:38 +00:00
if ( promptForName ) {
$file _name . val ( defaultFileName ) ;
}
2021-01-28 19:53:40 +00:00
2022-01-18 18:33:44 +00:00
const get _selected _format = ( ) => {
2021-04-01 14:01:38 +00:00
const selected _format _id = $file _type . val ( ) ;
for ( const format of formats ) {
if ( format . formatID === selected _format _id ) {
return format ;
}
2021-02-04 06:28:13 +00:00
}
2021-04-01 14:01:38 +00:00
} ;
// Select file type when typing file name
2022-01-18 18:33:44 +00:00
const select _file _type _from _file _name = ( ) => {
2021-04-01 14:01:38 +00:00
const extension _match = ( promptForName ? $file _name . val ( ) : defaultFileName ) . match ( /\.([\w\d]+)$/ ) ;
if ( extension _match ) {
const selected _format = get _selected _format ( ) ;
const matched _ext = extension _match [ 1 ] . toLowerCase ( ) ;
if ( selected _format && selected _format . extensions . includes ( matched _ext ) ) {
// File extension already matches selected file type.
// Don't select a different file type with the same extension.
return ;
}
for ( const format of formats ) {
if ( format . extensions . includes ( matched _ext ) ) {
$file _type . val ( format . formatID ) ;
}
}
}
} ;
if ( promptForName ) {
$file _name . on ( "input" , select _file _type _from _file _name ) ;
}
2022-01-18 18:33:44 +00:00
if ( defaultFileFormatID && formats . some ( ( format ) => format . formatID === defaultFileFormatID ) ) {
2021-04-01 14:01:38 +00:00
$file _type . val ( defaultFileFormatID ) ;
} else {
select _file _type _from _file _name ( ) ;
2021-02-04 06:28:13 +00:00
}
2021-04-01 14:01:38 +00:00
// Change file extension when selecting file type
// allowing non-default extension like .dib vs .bmp, .jpg vs .jpeg to stay
2022-01-18 18:33:44 +00:00
const update _extension _from _file _type = ( add _extension _if _absent ) => {
2021-04-01 14:01:38 +00:00
if ( ! promptForName ) {
return ;
}
let file _name = $file _name . val ( ) ;
2021-02-04 06:28:13 +00:00
const selected _format = get _selected _format ( ) ;
2021-04-01 14:01:38 +00:00
if ( ! selected _format ) {
2021-01-29 05:18:51 +00:00
return ;
2021-01-29 05:17:14 +00:00
}
2021-04-01 14:01:38 +00:00
const extensions _for _type = selected _format . extensions ;
const primary _extension _for _type = extensions _for _type [ 0 ] ;
// This way of removing the file extension doesn't scale very well! But I don't want to delete text the user wanted like in case of a version number...
const without _extension = file _name . replace ( /\.(\w{1,3}|apng|jpeg|jfif|tiff|webp|psppalette|sketchpalette|gimp|colors|scss|sass|less|styl|html|theme|themepack)$/i , "" ) ;
const extension _present = without _extension !== file _name ;
const extension = file _name . slice ( without _extension . length + 1 ) . toLowerCase ( ) ; // without dot
if (
( add _extension _if _absent || extension _present ) &&
extensions _for _type . indexOf ( extension ) === - 1
) {
file _name = ` ${ without _extension } . ${ primary _extension _for _type } ` ;
$file _name . val ( file _name ) ;
2021-01-28 19:53:40 +00:00
}
2021-04-01 14:01:38 +00:00
} ;
2022-01-18 18:33:44 +00:00
$file _type . on ( "change" , ( ) => {
2021-04-01 14:01:38 +00:00
update _extension _from _file _type ( false ) ;
} ) ;
// and initially
2021-01-29 00:55:21 +00:00
update _extension _from _file _type ( false ) ;
2021-01-28 19:53:40 +00:00
2021-04-01 14:40:03 +00:00
const $save = $w . $Button ( localize ( "Save" ) , ( ) => {
2021-04-01 04:17:11 +00:00
$w . close ( ) ;
update _extension _from _file _type ( true ) ;
2021-04-01 04:45:07 +00:00
resolve ( {
newFileName : promptForName ? $file _name . val ( ) : defaultFileName ,
newFileFormatID : $file _type . val ( ) ,
} ) ;
2023-02-13 09:56:08 +00:00
} , { type : "submit" } ) ;
2021-04-01 04:17:11 +00:00
$w . $Button ( localize ( "Cancel" ) , ( ) => {
$w . close ( ) ;
} ) ;
2021-01-28 19:53:40 +00:00
2021-04-01 04:17:11 +00:00
$w . center ( ) ;
// For mobile devices with on-screen keyboards, move the window to the top
if ( window . innerWidth < 500 || window . innerHeight < 700 ) {
$w . css ( { top : 20 } ) ;
}
2021-02-01 19:11:08 +00:00
2021-04-01 04:45:07 +00:00
if ( promptForName ) {
$file _name . focus ( ) . select ( ) ;
2021-04-01 14:40:03 +00:00
} else {
// $file_type.focus(); // most of the time you don't want to change the type from PNG
$save . focus ( ) ;
2021-04-01 04:45:07 +00:00
}
2021-04-01 04:17:11 +00:00
} ) ;
2021-01-28 19:53:40 +00:00
}
2021-02-04 06:28:13 +00:00
function write _image _file ( canvas , mime _type , blob _callback ) {
2021-03-23 01:23:45 +00:00
const bmp _match = mime _type . match ( /^image\/(?:x-)?bmp\s*(?:-(\d+)bpp)?/ ) ;
2021-02-05 02:32:22 +00:00
if ( bmp _match ) {
2021-03-23 01:23:45 +00:00
const file _content = encodeBMP ( canvas . ctx . getImageData ( 0 , 0 , canvas . width , canvas . height ) , parseInt ( bmp _match [ 1 ] || "24" , 10 ) ) ;
2021-02-04 06:28:13 +00:00
const blob = new Blob ( [ file _content ] ) ;
sanity _check _blob ( blob , ( ) => {
blob _callback ( blob ) ;
} ) ;
2021-07-10 04:11:25 +00:00
} else if ( mime _type === "image/png" ) {
// UPNG.js gives better compressed PNGs than the built-in browser PNG encoder
// In fact you can use it as a minifier! http://upng.photopea.com/
const image _data = canvas . ctx . getImageData ( 0 , 0 , canvas . width , canvas . height ) ;
const array _buffer = UPNG . encode ( [ image _data . data . buffer ] , image _data . width , image _data . height ) ;
const blob = new Blob ( [ array _buffer ] ) ;
sanity _check _blob ( blob , ( ) => {
blob _callback ( blob ) ;
} ) ;
2021-07-30 00:12:45 +00:00
} else if ( mime _type === "image/tiff" ) {
const image _data = canvas . ctx . getImageData ( 0 , 0 , canvas . width , canvas . height ) ;
const metadata = {
t305 : [ "jspaint (UTIF.js)" ] ,
} ;
const array _buffer = UTIF . encodeImage ( image _data . data . buffer , image _data . width , image _data . height , metadata ) ;
const blob = new Blob ( [ array _buffer ] ) ;
sanity _check _blob ( blob , ( ) => {
blob _callback ( blob ) ;
} ) ;
2021-02-04 06:28:13 +00:00
} else {
canvas . toBlob ( blob => {
// Note: could check blob.type (mime type) instead
const png _magic _bytes = [ 0x89 , 0x50 , 0x4E , 0x47 , 0x0D , 0x0A , 0x1A , 0x0A ] ;
sanity _check _blob ( blob , ( ) => {
blob _callback ( blob ) ;
} , png _magic _bytes , mime _type === "image/png" ) ;
} , mime _type ) ;
}
}
2021-02-12 02:07:41 +00:00
function read _image _file ( blob , callback ) {
// @TODO: handle SVG (might need to keep track of source URL, for relative resources)
2021-07-10 14:29:02 +00:00
// @TODO: read palette from GIF files
2021-02-11 22:26:49 +00:00
let file _format ;
let palette ;
let monochrome = false ;
2022-01-18 18:33:44 +00:00
blob . arrayBuffer ( ) . then ( ( arrayBuffer ) => {
2021-02-11 22:26:49 +00:00
// Helpers:
// "GIF".split("").map(c=>"0x"+c.charCodeAt(0).toString("16")).join(", ")
// [0x47, 0x49, 0x46].map(c=>String.fromCharCode(c)).join("")
const magics = {
png : [ 0x89 , 0x50 , 0x4E , 0x47 , 0x0D , 0x0A , 0x1A , 0x0A ] ,
bmp : [ 0x42 , 0x4D ] , // "BM" in ASCII
jpeg : [ 0xFF , 0xD8 , 0xFF ] ,
gif : [ 0x47 , 0x49 , 0x46 , 0x38 ] , // "GIF8" in ASCII, fully either "GIF87a" or "GIF89a"
webp : [ 0x57 , 0x45 , 0x42 , 0x50 ] , // "WEBP" in ASCII
tiff _be : [ 0x4D , 0x4D , 0x0 , 0x2A ] ,
tiff _le : [ 0x49 , 0x49 , 0x2A , 0x0 ] ,
ico : [ 0x00 , 0x00 , 0x01 , 0x00 ] ,
cur : [ 0x00 , 0x00 , 0x02 , 0x00 ] ,
icns : [ 0x69 , 0x63 , 0x6e , 0x73 ] , // "icns" in ASCII
2021-02-12 02:07:41 +00:00
} ;
2021-02-11 22:26:49 +00:00
const file _bytes = new Uint8Array ( arrayBuffer ) ;
let detected _type _id ;
for ( const [ type _id , magic _bytes ] of Object . entries ( magics ) ) {
2022-01-18 18:33:44 +00:00
const magic _found = magic _bytes . every ( ( byte , index ) => byte === file _bytes [ index ] ) ;
2021-02-11 22:26:49 +00:00
if ( magic _found ) {
detected _type _id = type _id ;
2021-02-12 02:07:41 +00:00
}
2021-02-11 22:26:49 +00:00
}
2021-07-10 21:57:33 +00:00
if ( ! detected _type _id ) {
if ( String . fromCharCode ( ... file _bytes . slice ( 0 , 1024 ) ) . includes ( "%PDF" ) ) {
detected _type _id = "pdf" ;
}
}
2021-02-11 22:26:49 +00:00
if ( detected _type _id === "bmp" ) {
2022-01-18 18:33:44 +00:00
const { colorTable , bitsPerPixel , imageData } = decodeBMP ( arrayBuffer ) ;
2021-03-23 01:23:45 +00:00
file _format = bitsPerPixel === 24 ? "image/bmp" : ` image/bmp;bpp= ${ bitsPerPixel } ` ;
2021-02-11 22:26:49 +00:00
if ( colorTable . length >= 2 ) {
if ( colorTable . length === 2 ) {
2022-01-18 18:33:44 +00:00
palette = make _monochrome _palette ( ... colorTable . map ( ( color ) => [ color . r , color . g , color . b , 255 ] ) ) ;
2021-02-11 22:26:49 +00:00
monochrome = true ;
} else {
2022-01-18 18:33:44 +00:00
palette = colorTable . map ( ( color ) => ` rgb( ${ color . r } , ${ color . g } , ${ color . b } ) ` ) ;
2021-02-11 22:26:49 +00:00
monochrome = false ;
}
}
// if (bitsPerPixel !== 32 && bitsPerPixel !== 16) {
// for (let i = 3; i < imageData.data.length; i += 4) {
// imageData.data[i] = 255;
// }
// }
2022-01-18 18:33:44 +00:00
callback ( null , { file _format , monochrome , palette , image _data : imageData , source _blob : blob } ) ;
2021-07-10 14:29:02 +00:00
} else if ( detected _type _id === "png" ) {
2022-01-18 18:33:44 +00:00
const decoded = UPNG . decode ( arrayBuffer ) ;
2021-07-10 14:29:02 +00:00
const rgba = UPNG . toRGBA8 ( decoded ) [ 0 ] ;
const { width , height , tabs , ctype } = decoded ;
// If it's a palettized PNG, load the palette for the Colors box.
// Note: PLTE (palette) chunk must be present for palettized PNGs,
// but can also be present as a recommended set of colors in true-color mode.
// tRNs (transparency) chunk can provide alpha data associated with each color in the PLTE chunk.
// It may contain as many transparency entries as there are palette entries, or as few as one.
// tRNS chunk can also be used to specify a single color to be considered fully transparent in true-color mode.
if ( tabs . PLTE && tabs . PLTE . length >= 3 * 2 && ctype === 3 /* palettized */ ) {
if ( tabs . PLTE . length === 3 * 2 ) {
palette = make _monochrome _palette (
2021-07-10 20:27:38 +00:00
[ ... tabs . PLTE . slice ( 0 , 3 ) , tabs . tRNS ? . [ 0 ] ? ? 255 ] ,
[ ... tabs . PLTE . slice ( 3 , 6 ) , tabs . tRNS ? . [ 1 ] ? ? 255 ]
2021-07-10 14:29:02 +00:00
) ;
monochrome = true ;
} else {
palette = new Array ( tabs . PLTE . length / 3 ) ;
for ( let i = 0 ; i < palette . length ; i ++ ) {
if ( tabs . tRNS && tabs . tRNS . length >= i + 1 ) {
palette [ i ] = ` rgba( ${ tabs . PLTE [ i * 3 + 0 ] } , ${ tabs . PLTE [ i * 3 + 1 ] } , ${ tabs . PLTE [ i * 3 + 2 ] } , ${ tabs . tRNS [ i ] / 255 } ) ` ;
} else {
palette [ i ] = ` rgb( ${ tabs . PLTE [ i * 3 + 0 ] } , ${ tabs . PLTE [ i * 3 + 1 ] } , ${ tabs . PLTE [ i * 3 + 2 ] } ) ` ;
}
}
monochrome = false ;
}
}
file _format = "image/png" ;
const image _data = new ImageData ( new Uint8ClampedArray ( rgba ) , width , height ) ;
2021-07-29 22:48:31 +00:00
callback ( null , { file _format , monochrome , palette , image _data , source _blob : blob } ) ;
} else if ( detected _type _id === "tiff_be" || detected _type _id === "tiff_le" ) {
// IFDs = image file directories
// VSNs = ???
2024-02-02 20:30:04 +00:00
// This code is based on UTIF.bufferToURI
2021-07-29 22:48:31 +00:00
var ifds = UTIF . decode ( arrayBuffer ) ;
//console.log(ifds);
var vsns = ifds , ma = 0 , page = vsns [ 0 ] ;
if ( ifds [ 0 ] . subIFD ) {
vsns = vsns . concat ( ifds [ 0 ] . subIFD ) ;
}
for ( var i = 0 ; i < vsns . length ; i ++ ) {
var img = vsns [ i ] ;
if ( img [ "t258" ] == null || img [ "t258" ] . length < 3 ) continue ;
var ar = img [ "t256" ] * img [ "t257" ] ;
if ( ar > ma ) { ma = ar ; page = img ; }
}
UTIF . decodeImage ( arrayBuffer , page , ifds ) ;
var rgba = UTIF . toRGBA8 ( page ) ;
var image _data = new ImageData ( new Uint8ClampedArray ( rgba . buffer ) , page . width , page . height ) ;
file _format = "image/tiff" ;
2022-01-18 18:33:44 +00:00
callback ( null , { file _format , monochrome , palette , image _data , source _blob : blob } ) ;
2021-07-10 21:57:33 +00:00
} else if ( detected _type _id === "pdf" ) {
file _format = "application/pdf" ;
const pdfjs = window [ 'pdfjs-dist/build/pdf' ] ;
2022-01-18 18:33:44 +00:00
2021-07-10 21:57:33 +00:00
pdfjs . GlobalWorkerOptions . workerSrc = 'lib/pdf.js/build/pdf.worker.js' ;
const file _bytes = new Uint8Array ( arrayBuffer ) ;
const loadingTask = pdfjs . getDocument ( {
data : file _bytes ,
cMapUrl : "lib/pdf.js/web/cmaps/" ,
cMapPacked : true ,
} ) ;
2022-01-18 18:33:44 +00:00
loadingTask . promise . then ( ( pdf ) => {
2021-07-10 21:57:33 +00:00
console . log ( 'PDF loaded' ) ;
// Fetch the first page
// TODO: maybe concatenate all pages into one image?
var pageNumber = 1 ;
2022-01-18 18:33:44 +00:00
pdf . getPage ( pageNumber ) . then ( ( page ) => {
2021-07-10 21:57:33 +00:00
console . log ( 'Page loaded' ) ;
var scale = 1.5 ;
var viewport = page . getViewport ( { scale } ) ;
// Prepare canvas using PDF page dimensions
var canvas = make _canvas ( viewport . width , viewport . height ) ;
// Render PDF page into canvas context
var renderContext = {
canvasContext : canvas . ctx ,
viewport ,
} ;
var renderTask = page . render ( renderContext ) ;
renderTask . promise . then ( ( ) => {
console . log ( 'Page rendered' ) ;
const image _data = canvas . ctx . getImageData ( 0 , 0 , canvas . width , canvas . height ) ;
2022-01-18 18:33:44 +00:00
callback ( null , { file _format , monochrome , palette , image _data , source _blob : blob } ) ;
2021-07-10 21:57:33 +00:00
} ) ;
} ) ;
} , ( reason ) => {
callback ( new Error ( ` Failed to load PDF. ${ reason } ` ) ) ;
} ) ;
2021-02-11 22:26:49 +00:00
} else {
monochrome = false ;
file _format = {
// bmp: "image/bmp",
png : "image/png" ,
webp : "image/webp" ,
jpeg : "image/jpeg" ,
gif : "image/gif" ,
tiff _be : "image/tiff" ,
tiff _le : "image/tiff" , // can also be image/x-canon-cr2 etc.
ico : "image/x-icon" ,
cur : "image/x-win-bitmap" ,
icns : "image/icns" ,
} [ detected _type _id ] || blob . type ;
const blob _uri = URL . createObjectURL ( blob ) ;
const img = new Image ( ) ;
// img.crossOrigin = "Anonymous";
2022-01-18 18:33:44 +00:00
const handle _decode _fail = ( ) => {
2021-02-11 22:26:49 +00:00
URL . revokeObjectURL ( blob _uri ) ;
2022-01-18 18:33:44 +00:00
blob . text ( ) . then ( ( file _text ) => {
2021-02-13 21:13:29 +00:00
const error = new Error ( "failed to decode blob as an image" ) ;
error . code = file _text . match ( /^\s*<!doctype\s+html/i ) ? "html-not-image" : "decoding-failure" ;
callback ( error ) ;
2022-01-18 18:33:44 +00:00
} , ( err ) => {
2021-02-11 22:26:49 +00:00
const error = new Error ( "failed to decode blob as image or text" ) ;
error . code = "decoding-failure" ;
callback ( error ) ;
2021-02-13 21:13:29 +00:00
} ) ;
2021-02-11 22:26:49 +00:00
} ;
2022-01-18 18:33:44 +00:00
img . onload = ( ) => {
2021-02-11 22:26:49 +00:00
URL . revokeObjectURL ( blob _uri ) ;
if ( ! img . complete || typeof img . naturalWidth == "undefined" || img . naturalWidth === 0 ) {
handle _decode _fail ( ) ;
return ;
}
2022-01-18 18:33:44 +00:00
callback ( null , { file _format , monochrome , palette , image : img , source _blob : blob } ) ;
2021-02-11 22:26:49 +00:00
} ;
img . onerror = handle _decode _fail ;
img . src = blob _uri ;
}
2022-01-18 18:33:44 +00:00
} , ( error ) => {
2021-02-13 21:13:29 +00:00
callback ( error ) ;
} ) ;
2021-02-12 02:07:41 +00:00
}
function update _from _saved _file ( blob ) {
2022-01-18 18:33:44 +00:00
read _image _file ( blob , ( error , info ) => {
2021-02-12 02:07:41 +00:00
if ( error ) {
2021-02-10 22:33:35 +00:00
show _error _message ( "The file has been saved, however... " + localize ( "Paint cannot read this file." ) , error ) ;
2021-02-12 02:07:41 +00:00
return ;
}
apply _file _format _and _palette _info ( info ) ;
2022-01-18 18:33:44 +00:00
const format = image _formats . find ( ( { mimeType } ) => mimeType === info . file _format ) ;
2021-02-12 02:07:41 +00:00
undoable ( {
name : ` ${ localize ( "Save As" ) } ${ format ? format . name : info . file _format } ` ,
2021-02-13 04:22:37 +00:00
icon : get _help _folder _icon ( "p_save.png" ) ,
2022-01-18 18:33:44 +00:00
} , ( ) => {
2021-02-11 22:26:49 +00:00
main _ctx . copy ( info . image || info . image _data ) ;
2021-02-10 22:33:35 +00:00
} ) ;
} ) ;
}
2022-01-18 18:33:44 +00:00
function save _selection _to _file ( ) {
if ( selection && selection . canvas ) {
2021-08-03 10:28:19 +00:00
systemHooks . showSaveFileDialog ( {
2021-03-23 01:23:45 +00:00
dialogTitle : localize ( "Save As" ) ,
defaultName : "selection.png" ,
defaultFileFormatID : "image/png" ,
formats : image _formats ,
2022-01-18 18:33:44 +00:00
getBlob : ( new _file _type ) => {
return new Promise ( ( resolve ) => {
write _image _file ( selection . canvas , new _file _type , ( blob ) => {
2021-03-23 01:23:45 +00:00
resolve ( blob ) ;
} ) ;
} ) ;
} ,
2019-02-16 06:13:16 +00:00
} ) ;
2014-10-02 21:17:43 +00:00
}
2014-11-29 02:27:51 +00:00
}
2018-06-30 04:10:44 +00:00
2022-01-18 18:33:44 +00:00
function sanity _check _blob ( blob , okay _callback , magic _number _bytes , magic _wanted = true ) {
if ( blob . size > 0 ) {
2021-01-29 01:47:17 +00:00
if ( magic _number _bytes ) {
2022-01-18 18:33:44 +00:00
blob . arrayBuffer ( ) . then ( ( arrayBuffer ) => {
2021-02-13 21:13:29 +00:00
const file _bytes = new Uint8Array ( arrayBuffer ) ;
2022-01-18 18:33:44 +00:00
const magic _found = magic _number _bytes . every ( ( byte , index ) => byte === file _bytes [ index ] ) ;
2021-01-29 01:47:17 +00:00
// console.log(file_bytes, magic_number_bytes, magic_found, magic_wanted);
if ( magic _found === magic _wanted ) {
2021-01-29 01:31:26 +00:00
okay _callback ( ) ;
} else {
2021-11-26 23:37:58 +00:00
showMessageBox ( {
// hackily combining messages that are already localized, in ways they were not meant to be used.
// you may have to do some deduction to understand this message.
// messageHTML: `
// <p>${localize("Unexpected file format.")}</p>
// <p>${localize("An unsupported operation was attempted.")}</p>
// `,
message : "Your browser does not support writing images in this file format." ,
iconID : "error" ,
} ) ;
2021-01-29 01:31:26 +00:00
}
2022-01-18 18:33:44 +00:00
} , ( error ) => {
2021-02-13 21:13:29 +00:00
show _error _message ( localize ( "An unknown error has occurred." ) , error ) ;
} ) ;
2021-01-29 01:31:26 +00:00
} else {
okay _callback ( ) ;
}
2022-01-18 18:33:44 +00:00
} else {
2021-01-30 15:18:50 +00:00
show _error _message ( localize ( "Failed to save document." ) ) ;
2018-06-30 04:10:44 +00:00
}
}
2020-05-14 04:10:40 +00:00
2022-01-18 18:33:44 +00:00
function show _multi _user _setup _dialog ( from _current _document ) {
2021-12-07 22:07:12 +00:00
const $w = $DialogWindow ( ) . title ( "Multi-User Setup" ) . addClass ( "horizontal-buttons" ) ;
2020-05-14 04:10:40 +00:00
$w . $main . html ( `
$ { from _current _document ? "<p>This will make the current document public.</p>" : "" }
< p >
<!-- Choose a name for the multi - user session , included in the URL for sharing : -- >
Enter the session name that will be used in the URL for sharing :
< / p >
< p >
< label >
< span class = "partial-url-label" > jspaint . app / # session : < / s p a n >
< input
type = "text"
id = "session-name"
aria - label = "session name"
pattern = "[-0-9A-Za-z\\u00c0-\\u00d6\\u00d8-\\u00f6\\u00f8-\\u02af\\u1d00-\\u1d25\\u1d62-\\u1d65\\u1d6b-\\u1d77\\u1d79-\\u1d9a\\u1e00-\\u1eff\\u2090-\\u2094\\u2184-\\u2184\\u2488-\\u2490\\u271d-\\u271d\\u2c60-\\u2c7c\\u2c7e-\\u2c7f\\ua722-\\ua76f\\ua771-\\ua787\\ua78b-\\ua78c\\ua7fb-\\ua7ff\\ufb00-\\ufb06]+"
title = "Numbers, letters, and hyphens are allowed."
2021-01-29 22:29:57 +00:00
class = "inset-deep"
2020-05-14 04:10:40 +00:00
>
< / l a b e l >
< / p >
` );
const $session _name = $w . $main . find ( "#session-name" ) ;
2022-01-18 18:33:44 +00:00
$w . $main . css ( { maxWidth : "500px" } ) ;
2020-05-14 04:10:40 +00:00
$w . $Button ( "Start" , ( ) => {
let name = $session _name . val ( ) . trim ( ) ;
2022-01-18 18:33:44 +00:00
if ( name == "" ) {
2020-05-14 04:10:40 +00:00
show _error _message ( "The session name cannot be empty." ) ;
2022-01-18 18:33:44 +00:00
} else if ( $session _name . is ( ":invalid" ) ) {
2020-05-14 04:10:40 +00:00
show _error _message ( "The session name must be made from only numbers, letters, and hyphens." ) ;
2022-01-18 18:33:44 +00:00
} else {
2020-05-14 04:10:40 +00:00
if ( from _current _document ) {
change _url _param ( "session" , name ) ;
} else {
// @TODO: load new empty session in the same browser tab
// (or at least... keep settings like vertical-color-box-mode?)
window . open ( ` ${ location . origin } ${ location . pathname } #session: ${ name } ` ) ;
}
$w . close ( ) ;
}
2023-02-13 09:56:08 +00:00
} , { type : "submit" } ) ;
2020-12-05 22:33:21 +00:00
$w . $Button ( localize ( "Cancel" ) , ( ) => {
2020-05-14 04:10:40 +00:00
$w . close ( ) ;
} ) ;
$w . center ( ) ;
$session _name . focus ( ) ;
}