Improve message boxes

- Add icons
- Add audio
- Add padding
- Add "default button" styling logic, where the default or focused button gets a bolder border.
- Tweak some dialogs, favoring localized and accurate renditions, rather than more modern, more specific button labels.
main
Isaiah Odhner 2021-11-26 18:37:58 -05:00
parent 372da0a041
commit 9c88f205b2
24 changed files with 313 additions and 191 deletions

View File

@ -188,7 +188,7 @@ Functionality:
* JS
* Organize things into files better; "functions.js" is like ONE step above saying "code.js"
* `$ToolWindow` has a `$Button` facility; `$DialogWindow` overrides it with essentially a better one
* `$ToolWindow` has a `$Button` facility; `$DialogWindow` overrides it with essentially a better one; now there's `showMessageBox` too! and `$ToolWindow` is a wrapper for OS-GUI's `$Window`, and should be removed at some point; btw, should `show_error_message` functionality be folded into `showMessageBox`?
* Make code clearer / improve code quality
* https://codeclimate.com/github/1j01/jspaint

BIN
audio/chord.wav Normal file

Binary file not shown.

BIN
images/error-16x16-8bpp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 B

BIN
images/error-32x32-1bpp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 B

BIN
images/error-32x32-8bpp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

BIN
images/info-16x16-8bpp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

BIN
images/info-32x32-1bpp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

BIN
images/info-32x32-8bpp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

BIN
images/nuke-32x32-8bpp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

BIN
images/windows-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 B

BIN
images/windows-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

View File

@ -396,13 +396,14 @@
<script src="lib/imagetracer_v1.2.5.js"></script>
<script src="src/app-localization.js"></script> <!-- must not be async/deferred, as it uses document.write(); and must come before other app code which uses localization functions -->
<script src="src/msgbox.js"></script>
<script src="src/functions.js"></script>
<script src="src/helpers.js"></script>
<script src="src/storage.js"></script>
<script src="src/$Component.js"></script>
<script src="src/$ToolWindow.js"></script>
<script>
// after show_error_message, $DialogWindow, and localize are defined,
// after show_error_message, showMessageBox, and localize are defined,
// set up better global error handling
const old_onerror = window.onerror;
window.onerror = (message, source, lineno, colno, error)=> {

View File

@ -1088,24 +1088,27 @@ function loaded_localizations(language, mapping) {
current_language = language;
}
function set_language(language) {
const $w = $DialogWindow().title("Reload Required").addClass("dialogue-window");
$w.$main.text("The application needs to reload to change the language.");
$w.$main.css("max-width", "600px");
$w.$Button(localize("OK"), () => {
$w.close();
are_you_sure(() => {
try {
localStorage[language_storage_key] = language;
location.reload();
} catch(error) {
show_error_message("Failed to store language preference. Make sure cookies / local storage is enabled in your browser settings.", error);
}
});
}).focus();
$w.$Button(localize("Cancel"), () => {
$w.close();
showMessageBox({
title: "Reload Required",
message: "The application needs to reload to change the language.",
buttons: [
{ label: localize("OK"), value: "reload", default: true },
{ label: localize("Cancel"), value: "cancel" },
],
windowOptions: {
innerWidth: 450,
},
}).then((result) => {
if (result === "reload") {
are_you_sure(() => {
try {
localStorage[language_storage_key] = language;
location.reload();
} catch (error) {
show_error_message("Failed to store language preference. Make sure cookies / local storage is enabled in your browser settings.", error);
}
});
}
});
$w.center();
// load_language(language);
}
load_language(current_language);

View File

@ -152,12 +152,12 @@ window.systemHookDefaults = {
newFileName = newHandle.name;
const newFileExtension = get_file_extension(newFileName);
if (!newFileExtension) {
show_error_message(`Missing file extension - try adding .${new_format.extensions[0]} to the name.`);
show_error_message(`Missing file extension.\n\nTry adding .${new_format.extensions[0]} to the name.`);
return;
}
if (!new_format.extensions.includes(newFileExtension)) {
// Closest translation: "Paint cannot save to the same filename with a different file type."
show_error_message(`Wrong file extension for selected file type - try adding .${new_format.extensions[0]} to the name.`);
show_error_message(`Wrong file extension for selected file type.\n\nTry adding .${new_format.extensions[0]} to the name.`);
return;
}
// const new_format =

View File

@ -829,35 +829,32 @@ try {
// This will be known as the "Y2T bug"
confirmed_overwrite = Date.now() >= 2000000000000;
}
function confirm_overwrite() {
return new Promise((resolve) => {
if (confirmed_overwrite) {
resolve();
return;
}
const $w = new $DialogWindow().addClass("dialogue-window");
$w.title(localize("Paint"));
$w.$main.html(`
async function confirm_overwrite() {
if (confirmed_overwrite) {
return;
}
const { $window, promise } = showMessageBox({
messageHTML: `
<p>JS Paint can now save over existing files.</p>
<p>Do you want to overwrite the file?</p>
<p>
<label><input type='checkbox'/> Don't ask me again</label>
</p>
`);
$w.$Button(localize("OK"), () => {
$w.close();
confirmed_overwrite = $w.$main.find("input[type='checkbox']").prop("checked");
try {
localStorage[confirmed_overwrite_key] = confirmed_overwrite;
} catch (error) {
// no localStorage
}
}).focus();
$w.$Button(localize("Cancel"), () => {
$w.close();
});
$w.center();
`,
buttons: [
{ label: localize("Yes"), value: "overwrite", default: true },
{ label: localize("Cancel"), value: "cancel" },
],
});
const result = await promise;
if (result === "overwrite") {
confirmed_overwrite = $window.$content.find("input[type='checkbox']").prop("checked");
try {
localStorage[confirmed_overwrite_key] = confirmed_overwrite;
} catch (error) {
// no localStorage... @TODO: don't show the checkbox in this case
}
}
}
@ -908,49 +905,62 @@ function file_save_as(maybe_saved_callback=()=>{}, update_from_saved=true){
}
function are_you_sure(action, canceled){
if(saved){
function are_you_sure(action, canceled) {
if (saved) {
action();
}else{
const $w = new $DialogWindow().addClass("dialogue-window");
$w.title(localize("Paint"));
$w.$main.text(localize("Save changes to %1?", file_name));
$w.$Button(localize("Save"), () => {
$w.close();
file_save(()=> {
} 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") {
action();
}, false);
})[0].focus();
$w.$Button("Discard", () => {
$w.close();
action();
} else {
canceled?.();
}
});
$w.$Button(localize("Cancel"), () => {
$w.close();
canceled && canceled();
});
$w.$x.on("click", () => {
canceled && canceled();
});
$w.center();
}
}
function please_enter_a_number() {
const $w = new $DialogWindow("Invalid Value").addClass("dialogue-window");
$w.$main.text(localize("Please enter a number."));
$w.$Button(localize("OK"), () => {
$w.close();
}).focus();
showMessageBox({
// title: "Invalid Value",
message: localize("Please enter a number."),
});
}
function show_error_message(message, error){
const $w = $DialogWindow().title(localize("Paint")).addClass("dialogue-window squish");
$w.$main.text(message);
$w.$main.css("max-width", "600px");
function show_error_message(message, error) {
const { $message } = showMessageBox({
iconID: "error",
message,
// windowOptions: {
// innerWidth: 600,
// },
});
// $message.css("max-width", "600px");
if(error){
const $details = $("<details><summary><span>Details</span></summary></details>")
.appendTo($w.$main);
.appendTo($message);
// 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,
@ -979,25 +989,21 @@ function show_error_message(message, error){
overflow: "auto",
});
}
$w.$Button(localize("OK"), () => {
$w.close();
}).focus();
$w.center();
if (error) {
window.console && console.error(message, error);
window.console?.error?.(message, error);
} else {
window.console && console.error(message);
window.console?.error?.(message);
}
}
// @TODO: close are_you_sure windows and these Error windows when switching sessions
// because it can get pretty confusing
function show_resource_load_error_message(error){
const $w = $DialogWindow().title(localize("Paint")).addClass("dialogue-window");
const { $window, $message } = showMessageBox({});
const firefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
// @TODO: copy & paste vs download & open, more specific guidance
if (error.code === "cross-origin-blob-uri") {
$w.$main.html(`
$message.html(`
<p>Can't load image from address starting with "blob:".</p>
${
firefox ?
@ -1006,47 +1012,43 @@ function show_resource_load_error_message(error){
}
`);
} else if (error.code === "html-not-image") {
$w.$main.html(`
$message.html(`
<p>Address points to a web page, not an image file.</p>
<p>Try copying and pasting an image instead of a URL.</p>
`);
} else if (error.code === "decoding-failure") {
$w.$main.html(`
$message.html(`
<p>Address doesn't point to an image file of a supported format.</p>
<p>Try copying and pasting an image instead of a URL.</p>
`);
} else if (error.code === "access-failure") {
if (navigator.onLine) {
$w.$main.html(`
$message.html(`
<p>Failed to download image.</p>
<p>Try copying and pasting an image instead of a URL.</p>
`);
if (error.fails) {
$("<ul>").append(error.fails.map(({status, statusText, url})=>
$("<li>").text(url).prepend($("<b>").text(`${status || ""} ${statusText || "Failed"} `))
)).appendTo($w.$main);
)).appendTo($message);
}
} else {
$w.$main.html(`
$message.html(`
<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>
`);
}
} else {
$w.$main.html(`
$message.html(`
<p>Failed to load image from URL.</p>
<p>Check your browser's devtools for details.</p>
`);
}
$w.$main.css({maxWidth: "500px"});
$w.$Button(localize("OK"), () => {
$w.close();
}).focus();
$w.center();
$message.css({ maxWidth: "500px" });
$window.center(); // after adding content
}
function show_file_format_errors({ as_image_error, as_palette_error }) {
const $w = $DialogWindow().title(localize("Paint")).addClass("dialogue-window");
let html = `
<p>${localize("Paint cannot open this file.")}</p>
`;
@ -1096,10 +1098,9 @@ function show_file_format_errors({ as_image_error, as_palette_error }) {
</details>
`;
}
$w.$main.html(html);
$w.$Button(localize("OK"), () => {
$w.close();
}).focus();
showMessageBox({
messageHTML: html,
});
}
function show_read_image_file_error(error) {
// @TODO: similar friendly messages to show_resource_load_error_message,
@ -1300,34 +1301,50 @@ async function choose_file_to_paste() {
function paste(img_or_canvas){
if(img_or_canvas.width > main_canvas.width || img_or_canvas.height > main_canvas.height){
const $w = new $DialogWindow().addClass("dialogue-window");
$w.title(localize("Paint"));
$w.$main.html(`
${localize("The image in the clipboard is larger than the bitmap.")}<br>
${localize("Would you like the bitmap enlarged?")}<br>
`);
$w.$Button("Enlarge", () => {
$w.close();
// 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),
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: [
{
name: "Enlarge Canvas For Paste",
icon: get_help_folder_icon("p_stretch_both.png"),
}
);
do_the_paste();
$canvas_area.trigger("resize");
})[0].focus();
$w.$Button("Crop", () => {
$w.close();
do_the_paste();
// 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();
}
});
$w.$Button(localize("Cancel"), () => {
$w.close();
});
$w.center();
}else{
do_the_paste();
}
@ -1647,6 +1664,7 @@ function undo(){
return true;
}
// @TODO: use Clippy.js instead for potentially annoying tips
let $document_history_prompt_window;
function redo(){
if(redos.length<1){
@ -1654,13 +1672,11 @@ function redo(){
$document_history_prompt_window.close();
}
if (!$document_history_window || $document_history_window.closed) {
const $w = $document_history_prompt_window = new $DialogWindow();
$w.title("Redo");
$w.$main.html("To view all branches of the history tree, click <b>Edit > History</b>.").css({padding: 10});
// $w.$Button("Show History", show_document_history).css({margin: 10}).focus();
// $w.$Button(localize("Cancel"), ()=> { $w.close(); }).css({margin: 10});
$w.$Button(localize("OK"), ()=> { $w.close(); }).css({margin: 10}).focus();
$w.center();
$document_history_prompt_window = showMessageBox({
title: "Redo",
messageHTML: `To view all branches of the history tree, click <b>Edit > History</b>.`,
iconID: "info",
}).$window;
}
return false;
}
@ -3196,18 +3212,16 @@ function sanity_check_blob(blob, okay_callback, magic_number_bytes, magic_wanted
if (magic_found === magic_wanted) {
okay_callback();
} else {
const $w = $DialogWindow().title(localize("Paint")).addClass("dialogue-window");
// hackily combining messages that are already localized
// you have to do some deduction to understand this message
$w.$main.html(`
<p>${localize("Unexpected file format.")}</p>
<p>${localize("An unsupported operation was attempted.")}</p>
`);
$w.$main.css({maxWidth: "500px"});
$w.$Button(localize("OK"), () => {
$w.close();
}).focus();
$w.center();
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",
});
}
}, (error)=> {
show_error_message(localize("An unknown error has occurred."), error);

View File

@ -255,17 +255,19 @@ function open_help_viewer(options){
}, (/* error */)=> {
// access to error message is not allowed either, basically
if (location.protocol === "file:") {
const $w = $DialogWindow().title(localize("Paint")).addClass("dialogue-window");
$w.$main.html(`
<p>${localize("Failed to launch help.")}</p>
<p>This feature is not available when running from the <code>file:</code> protocol.</p>
<p>To use this feature, start a web server. If you have Python, you can use <code>python -m SimpleHTTPServer</code></p>
`);
$w.$main.css({maxWidth: "500px"});
$w.$Button(localize("OK"), () => {
$w.close();
}).focus();
$w.center();
showMessageBox({
// <p>${localize("Failed to launch help.")}</p>
// but it's already launched at this point
// what's a good tutorial for starting a web server?
// https://gist.github.com/willurd/5720255 - impressive list, but not a tutorial
// https://attacomsian.com/blog/local-web-server - OK, good enough
messageHTML: `
<p>Help is not available when running from the <code>file:</code> protocol.</p>
<p>To use this feature, <a href="https://attacomsian.com/blog/local-web-server">start a web server</a>.</p>
`,
iconID: "error",
});
} else {
show_error_message(`${localize("Failed to launch help.")} ${localize("Access to %1 was denied.", options.contentsFile)}`);
}

View File

@ -3,7 +3,7 @@ let $storage_manager;
let $quota_exceeded_window;
let ignoring_quota_exceeded = false;
function storage_quota_exceeded(){
async function storage_quota_exceeded(){
if($quota_exceeded_window){
$quota_exceeded_window.close();
$quota_exceeded_window = null;
@ -11,27 +11,27 @@ function storage_quota_exceeded(){
if(ignoring_quota_exceeded){
return;
}
const $w = $DialogWindow().title("Storage Error").addClass("dialogue-window squish");
$w.$main.html(
"<p>JS Paint stores images as you work on them so that if you " +
"close your browser or tab or reload the page " +
"your images are usually safe.</p>" +
"<p>However, it has run out of space to do so.</p>" +
"<p>You can still save the current image with <b>File > Save</b>. " +
"You should save frequently, or free up enough space to keep the image safe.</p>"
);
$w.$Button("Manage Storage", () => {
$w.close();
const { promise, $window } = showMessageBox({
title: "Storage Error",
messageHTML: `
<p>JS Paint stores images as you work on them so that if you close your browser or tab or reload the page your images are usually safe.</p>
<p>However, it has run out of space to do so.</p>
<p>You can still save the current image with <b>File > Save</b>. You should save frequently, or free up enough space to keep the image safe.</p>
`,
buttons: [
{ label: "Manage Storage", value: "manage", default: true },
{ label: "Ignore", value: "ignore" },
],
iconID: "warning",
});
$quota_exceeded_window = $window;
const result = await promise;
if (result === "ignore") {
ignoring_quota_exceeded = true;
} else if (result === "manage") {
ignoring_quota_exceeded = false;
manage_storage();
}).focus();
$w.$Button("Ignore", () => {
$w.close();
ignoring_quota_exceeded = true;
});
$w.$content.width(500);
$w.center();
$quota_exceeded_window = $w;
}
}
function manage_storage(){

105
src/msgbox.js Normal file
View File

@ -0,0 +1,105 @@
// Prefer a function injected from outside an iframe,
// which will make dialogs that can go outside the iframe,
// for 98.js.org integration.
// Note that this API must be kept in sync with the version in 98.js.org.
// Note `defaultMessageBoxTitle` handling in make_iframe_window
// Any other default parameters need to be handled there (as it works now)
window.defaultMessageBoxTitle = localize("Paint");
var chord_audio = new Audio("audio/chord.wav");
window.showMessageBox = window.showMessageBox || (({
title = window.defaultMessageBoxTitle ?? "Alert",
message,
messageHTML,
buttons = [{ label: "OK", value: "ok", default: true }],
iconID = "warning", // "error", "warning", "info", or "nuke" for deleting files/folders
windowOptions = {}, // for controlling width, etc.
}) => {
let $window, $message;
const promise = new Promise((resolve, reject) => {
$window = make_window_supporting_scale(Object.assign({
title,
resizable: false,
innerWidth: 400,
maximizeButton: false,
minimizeButton: false,
}, windowOptions));
// $window.addClass("dialogue-window");
$message =
$("<div>").css({
textAlign: "left",
fontFamily: "MS Sans Serif, Arial, sans-serif",
fontSize: "14px",
marginTop: "22px",
flex: 1,
minWidth: 0, // Fixes hidden overflow, see https://css-tricks.com/flexbox-truncated-text/
whiteSpace: "normal", // overriding .window:not(.squish)
});
if (messageHTML) {
$message.html(messageHTML);
} else if (message) { // both are optional because you may populate later with dynamic content
$message.text(message).css({
whiteSpace: "pre-wrap",
wordWrap: "break-word",
});
}
$("<div>").append(
$("<img width='32' height='32'>").attr("src", `images/${iconID}-32x32-8bpp.png`).css({
margin: "16px",
display: "block",
}),
$message
).css({
display: "flex",
flexDirection: "row",
}).appendTo($window.$content);
$window.$content.css({
textAlign: "center",
});
for (const button of buttons) {
const $button = $window.$Button(button.label, () => {
button.action?.(); // API may be required for using user gesture requiring APIs
resolve(button.value);
$window.close(); // actually happens automatically
});
if (button.default) {
$button.addClass("default");
$button.focus();
setTimeout(() => $button.focus(), 0); // @TODO: why is this needed? does it have to do with the iframe window handling?
}
$button.css({
minWidth: 75,
height: 23,
margin: "16px 2px",
});
}
$window.on("focusin", "button", (event) => {
$(event.currentTarget).addClass("default");
});
$window.on("focusout", "button", (event) => {
$(event.currentTarget).removeClass("default");
});
$window.on("closed", () => {
resolve("closed"); // or "cancel"? do you need to distinguish?
});
$window.center();
});
promise.$window = $window;
promise.$message = $message;
promise.promise = promise; // for easy destructuring
try {
chord_audio.play();
} catch (error) {
console.log(`Failed to play ${chord_audio.src}: `, error);
}
return promise;
});
window.alert = (message) => {
showMessageBox({ message });
};

View File

@ -233,20 +233,17 @@
}
start() {
// @TODO: how do you actually detect if it's failing???
const $w = $DialogWindow().title(localize("Paint")).addClass("dialogue-window");
$w.$main.html("<p>The document may not load. Changes may not save.</p>" +
"<p>Multiuser sessions are public. There is no security.</p>"
// "<p>The document may not load. Changes may not save. If it does save, it's public. There is no security.</p>"// +
// "<p>I haven't found a way to detect Firebase quota limits being exceeded, " +
// "so for now I'm showing this message regardless of whether it's working.</p>" +
// "<p>If you're interested in using multiuser mode, please thumbs-up " +
// "<a href='https://github.com/1j01/jspaint/issues/68'>this issue</a> to show interest, and/or subscribe for updates.</p>"
);
$w.$main.css({ maxWidth: "500px" });
$w.$Button(localize("OK"), () => {
$w.close();
}).focus();
$w.center();
showMessageBox({
messageHTML: `
<p>The document may not load. Changes may not save.</p>
<p>Multiuser sessions are public. There is no security.</p>
`
});
// "<p>The document may not load. Changes may not save. If it does save, it's public. There is no security.</p>"// +
// "<p>I haven't found a way to detect Firebase quota limits being exceeded, " +
// "so for now I'm showing this message regardless of whether it's working.</p>" +
// "<p>If you're interested in using multiuser mode, please thumbs-up " +
// "<a href='https://github.com/1j01/jspaint/issues/68'>this issue</a> to show interest, and/or subscribe for updates.</p>"
// Wrap the Firebase API because they don't
// provide a great way to clean up event listeners