/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/** @module */
import {h} from "inferno-hyperscript";
import {VirtualDOMView} from "./VirtualDOMView";
import {getLanguages} from "./languages";
/** Type for Virtual DOM nodes.
*
* @external VNode
*/
/** Convert a value into an array.
*
* If the argument is already an array, it is returned as is.
* If it is not, wrap it in an array.
*
* @param {any} v - A value to convert.
* @returns {any[]} - An array.
*/
function asArray(v) {
return v instanceof Array ? v : [v];
}
/** Signals that the mode of the properties view has changed.
*
* @event module:view/Properties.modeChange */
/** Properties pane of the presentation editor.
*
* @extends module:view/VirtualDOMView.VirtualDOMView
*/
export class Properties extends VirtualDOMView {
/** Initialize a new properties view.
*
* @param {HTMLElement} container - The HTML element that will contain this preview area.
* @param {module:model/Selection.Selection} selection -
* @param {module:Controller.Controller} controller - The controller that manages the current editor.
*/
constructor(container, selection, controller) {
super(container, controller);
/** The object that manages the frame and layer selection.
*
* @type {module:model/Selection.Selection} */
this.selection = selection;
/** What the properties view shows.
*
* Acceptable values are: `"default"`, `"preferences"` and `"export"`.
*
* @default
* @type {string} */
this.mode = "default";
}
/** Toggle between presentation properties and preferences or export mode.
*
* @param {string} mode - The mode to toggle (`"preferences"`, `"export"`).
*
* @fires module:view/Properties.modeChange
*/
toggleMode(mode) {
this.mode = this.mode === mode ? "default" : mode;
this.emit("modeChange");
this.repaint();
}
/** @inheritdoc */
render() {
switch (this.mode) {
case "preferences": return this.renderPreferences();
case "export": return this.renderExportTool();
default: return this.renderPresentationProperties();
}
}
/** Render the properties view to edit the editor preferences.
*
* @returns {VNode} - A virtual DOM tree.
*/
renderPreferences() {
const controller = this.controller;
const _ = controller.gettext;
const ACTION_LABELS = {
autoselectOutlineElement: _("Autoselect outline element"),
resetLayer : _("Reset layer geometry"),
addFrame : _("Create a new frame"),
save : _("Save the presentation"),
redo : _("Redo"),
undo : _("Undo"),
focusTitleField : _("Focus the frame title"),
reload : _("Reload the SVG document"),
toggleFullscreen : _("Toggle full-screen mode"),
toggleDevTools : _("Toggle the developer tools")
};
let shortcuts = [];
for (let action in ACTION_LABELS) {
shortcuts.push(h("label", {for: `field-${action}`}, ACTION_LABELS[action]));
shortcuts.push(this.renderTextField(action, false, controller.getShortcut, controller.setShortcut, true));
}
const toDefaultMode = () => this.toggleMode("default");
return h("div.properties", [
h("div.back", {
title: _("Back to presentation properties"),
onClick() { toDefaultMode(); }
}, h("i.fas.fa-arrow-left", )),
h("h1", _("User interface")),
h("label", {for: "field-language"}, _("Language")),
this.renderSelectField("language", controller.getPreference, controller.setPreference, getLanguages(_)),
h("label", {for: "field-fontSize"}, _("Font size")),
this.renderNumberField("fontSize", false, controller.getPreference, controller.setPreference, false, 1, 1),
h("label.side-by-side", {for: "field-enableNotifications"}, [
_("Enable notifications on save and reload"),
this.renderToggleField(h("i.far.fa-check-square"), _("Enable notifications"), "enableNotifications", controller.getPreference, controller.setPreference)
]),
h("label", {for: "field-saveMode"}, _("Save the presentation")),
this.renderSelectField("saveMode", controller.getPreference, controller.setPreference, {
onblur: _("When Sozi loses the focus"),
manual: _("Manually")
}),
h("label", {for: "field-reloadMode"}, _("Reload the SVG document")),
this.renderSelectField("reloadMode", controller.getPreference, controller.setPreference, {
auto: _("Automatically"),
onfocus: _("When Sozi gets the focus"),
manual: _("Manually")
}),
h("h1", _("Behavior")),
h("label.side-by-side", {for: "field-animateTransitions"}, [
_("Preview transition animations"),
this.renderToggleField(h("i.far.fa-check-square"), _("Enable animated transitions"), "animateTransitions", controller.getPreference, controller.setPreference)
]),
h("h1", _("Keyboard shortcuts")),
shortcuts
]);
}
/** Render the properties view to edit the presentation properties.
*
* @returns {VNode} - A virtual DOM tree.
*/
renderPresentationProperties() {
const controller = this.controller;
const _ = controller.gettext;
const NOTES_HELP = [
_("Basic formatting supported:"),
"",
_("Ctrl+B: Bold"),
_("Ctrl+I: Italic"),
_("Ctrl+U: Underline"),
_("Ctrl+0: Paragraph"),
_("Ctrl+1: Big heading"),
_("Ctrl+2: Medium heading"),
_("Ctrl+3: Small heading"),
_("Ctrl+L: List"),
_("Ctrl+N: Numbered list")
].join("<br>");
const timeoutMsDisabled = controller.getFrameProperty("timeoutEnable").every(value => !value);
const showInFrameListDisabled = controller.getFrameProperty("showInFrameList").every(value => !value);
const layersToCopy = {
__select_a_layer__: _("Select a layer to copy")
};
if (this.controller.hasDefaultLayer) {
layersToCopy.__default__ = _("Default");
}
for (let layer of this.controller.editableLayers) {
layersToCopy[layer.groupId] = layer.label;
}
return h("div.properties", [
h("h1", _("Frame")),
h("div.btn-group", [
this.renderToggleField(h("i.fas.fa-list"), _("Show in frame list"), "showInFrameList", controller.getFrameProperty, controller.setFrameProperty),
this.renderToggleField(h("i.fas.fa-hashtag"), _("Show frame number"), "showFrameNumber", controller.getFrameProperty, controller.setFrameProperty)
]),
h("label", {for: "field-title"}, _("Title")),
this.renderTextField("title", false, controller.getFrameProperty, controller.setFrameProperty, true),
h("label", {for: "field-titleLevel"}, _("Title level in frame list")),
this.renderRangeField("titleLevel", showInFrameListDisabled, controller.getFrameProperty, controller.setFrameProperty, 0, 4, 1),
h("label", {for: "field-frameId"}, _("Id")),
this.renderTextField("frameId", false, controller.getFrameProperty, controller.setFrameProperty, false),
h("label.side-by-side", {for: "field-timeoutMs"}, [
_("Timeout (seconds)"),
this.renderToggleField(h("i.far.fa-clock"), _("Timeout enable"), "timeoutEnable", controller.getFrameProperty, controller.setFrameProperty)
]),
this.renderNumberField("timeoutMs", timeoutMsDisabled, controller.getFrameProperty, controller.setFrameProperty, false, 0.1, 1000),
h("h1", _("Layer")),
h("div.btn-group", [
this.renderToggleField(h("i.fas.fa-link"), _("Link to previous frame"), "link", controller.getLayerProperty, controller.setLayerProperty),
this.renderToggleField(h("i.fas.fa-crop"), _("Clip"), "clipped", controller.getCameraProperty, controller.setCameraProperty),
h("button", {
title: _("Reset layer geometry"),
onclick() { controller.resetLayer(); }
}, h("i.fas.fa-eraser"))
]),
h("label", {for: "field-layerToCopy"}, _("Copy layer")),
this.renderSelectField("layerToCopy", () => "__select_a_layer__", (prop, groupId) => {
controller.copyLayer(groupId);
document.getElementById("field-layerToCopy").firstChild.selected = true;
}, layersToCopy),
h("label.side-by-side", {for: "field-outlineElementId"}, [
_("Outline element Id"),
h("span.btn-group", [
h("button", {
title: _("Autoselect element"),
onclick() { controller.autoselectOutlineElement(); }
}, h("i.fas.fa-magic")),
this.renderToggleField(h("i.far.fa-eye-slash"), _("Hide element"), "outlineElementHide", controller.getLayerProperty, controller.setLayerProperty),
h("button", {
title: _("Fit to element"),
onclick() { controller.fitElement(); }
}, h("i.fas.fa-arrows-alt")),
])
]),
this.renderTextField("outlineElementId", false, controller.getLayerProperty, controller.setLayerProperty, true),
h("label", {for: "field-opacity"}, _("Layer opacity")),
this.renderRangeField("opacity", false, controller.getCameraProperty, controller.setCameraProperty, 0, 1, 0.1),
h("h1", [_("Transition"), this.renderHelp(_("Configure the animation when moving to the selected frames."))]),
h("label", {for: "field-transitionDurationMs"}, _("Duration (seconds)")),
this.renderNumberField("transitionDurationMs", false, controller.getFrameProperty, controller.setFrameProperty, false, 0.1, 1000),
h("label", {for: "field-transitionTimingFunction"}, _("Timing function")),
this.renderSelectField("transitionTimingFunction", controller.getLayerProperty, controller.setLayerProperty, {
"linear": _("Linear"),
"ease": _("Ease"),
"easeIn": _("Ease in"),
"easeOut": _("Ease out"),
"easeInOut": _("Ease in-out"),
"stepStart": _("Step start"),
"stepEnd": _("Step end"),
"stepMiddle": _("Step middle")
}),
h("label", {for: "field-transitionRelativeZoom"}, _("Relative zoom (%)")),
this.renderNumberField("transitionRelativeZoom", false, controller.getLayerProperty, controller.setLayerProperty, true, 1, 0.01),
h("label.side-by-side", {for: "field-transitionPathId"}, [
_("Path Id"),
this.renderToggleField(h("i.far.fa-eye-slash"), _("Hide path"), "transitionPathHide", controller.getLayerProperty, controller.setLayerProperty)
]),
this.renderTextField("transitionPathId", false, controller.getLayerProperty, controller.setLayerProperty, true),
h("h1", [_("Notes"), this.renderHelp(_("Edit presenter notes. Click here to show the list of formatting shortcuts."), () => controller.info(NOTES_HELP, true))]),
this.renderRichTextField("notes", false, controller.getFrameProperty, controller.setFrameProperty, true),
h("h1", _("Custom stylesheets and scripts")),
h("input.custom-css-js", {
type: "file",
accept: "text/css, text/javascript",
onChange(evt) {
if (evt.target.files.length) {
controller.addCustomFile(evt.target.files[0].path);
}
}
}),
h("table.custom-css-js", [
h("tr", [
h("th", controller.getCustomFiles().length ? _("CSS or JS file names") : _("Add CSS or JS files")),
h("td", [
h("button", {
title: _("Add a file"),
onClick() {
// Open the file chooser.
document.querySelector(".properties input.custom-css-js").dispatchEvent(new MouseEvent("click"));
}
}, h("i.fas.fa-plus"))
])
]),
controller.getCustomFiles().map((name, index) =>
h("tr", [
h("td", name),
h("td", [
h("button", {
title: _("Remove this file"),
onClick() { controller.removeCustomFile(index); }
}, h("i.fas.fa-trash"))
])
])
)
]),
h("h1", _("Player")),
h("div.side-by-side", [
_("Support the browser's \"Back\" button to move to the previous frame"),
this.renderToggleField(h("i.fas.fa-arrow-circle-left"), _("Moving from one frame to another will change the content of the location bar automatically."), "updateURLOnFrameChange", controller.getPresentationProperty, controller.setPresentationProperty)
]),
h("div.side-by-side", [
_("Allow to control the presentation"),
h("span.btn-group", [
this.renderToggleField(h("i.fas.fa-mouse-pointer"), _("using the mouse"), "enableMouseNavigation", controller.getPresentationProperty, controller.setPresentationProperty),
this.renderToggleField(h("i.fas.fa-keyboard"), _("using the keyboard"), "enableKeyboardNavigation", controller.getPresentationProperty, controller.setPresentationProperty)
])
]),
h("div.side-by-side", [
_("Allow to move the camera"),
this.renderToggleField(h("i.fas.fa-mouse-pointer"), _("using the mouse"), "enableMouseTranslation", controller.getPresentationProperty, controller.setPresentationProperty)
]),
h("div.side-by-side", [
_("Allow to rotate the camera"),
h("span.btn-group", [
this.renderToggleField(h("i.fas.fa-mouse-pointer"), _("using the mouse"), "enableMouseRotation", controller.getPresentationProperty, controller.setPresentationProperty),
this.renderToggleField(h("i.fas.fa-keyboard"), _("using the keyboard"), "enableKeyboardRotation", controller.getPresentationProperty, controller.setPresentationProperty)
])
]),
h("div.side-by-side", [
_("Allow to zoom"),
h("span.btn-group", [
this.renderToggleField(h("i.fas.fa-mouse-pointer"), _("using the mouse"), "enableMouseZoom", controller.getPresentationProperty, controller.setPresentationProperty),
this.renderToggleField(h("i.fas.fa-keyboard"), _("using the keyboard"), "enableKeyboardZoom", controller.getPresentationProperty, controller.setPresentationProperty)
])
])
]);
}
/** The HTML of the help message for include/exclude lists in export.
*
* @readonly
* @type {string}
*/
get exportListHelp() {
const _ = this.controller.gettext;
return [
_("Examples of frame lists to include/exclude in export"),
"",
_("Select frames 2, 5, and 12: \"2, 5, 12\""),
_("Select frames 5 to 8: \"5:8\""),
_("Select frames 5, 8, 11, 14, and 17: \"5:8:17\""),
_("Select frames 2, 5, and 10 to 15: \"2, 5, 10:15\"")
].join("<br>");
}
/** Render the properties view with the export tool.
*
* @returns {VNode} - A virtual DOM tree.
*/
renderExportTool() {
const controller = this.controller;
const _ = controller.gettext;
let exportFields, exportFn;
switch (controller.presentation.exportType) {
case "pdf":
exportFields = this.renderPDFExportFields();
exportFn = controller.exportToPDF;
break;
case "pptx":
exportFields = this.renderPPTXExportFields();
exportFn = controller.exportToPPTX;
break;
case "video":
exportFields = this.renderVideoExportFields();
exportFn = controller.exportToVideo;
break;
default:
exportFields = [];
exportFn = () => {}
}
const toDefaultMode = () => this.toggleMode("default");
return h("div.properties", [
h("div.back", {
title: _("Back to presentation properties"),
onClick() { toDefaultMode(); }
}, h("i.fas.fa-arrow-left", )),
h("h1", _("Export")),
h("label", {for: "field-exportType"}, _("Document type")),
this.renderSelectField("exportType", controller.getPresentationProperty, controller.setPresentationProperty, {
pdf: _("Portable Document Format (PDF)"),
pptx: _("Microsoft PowerPoint (PPTX)"),
video: _("Video")
}),
exportFields,
h("div.btn-group", [
h("button", {
title: _("Export the presentation"),
disabled: controller.exporting,
onClick() { exportFn.call(controller); }
}, [_("Export"), controller.exporting ? h("span.spinner") : null])
])
]);
}
/** Render the fields of the PDF export tool.
*
* @returns {VNode[]} - Virtual DOM nodes with the PDF-specific fields of the export tool.
*/
renderPDFExportFields() {
const controller = this.controller;
const _ = controller.gettext;
return [
h("label", {for: "field-exportToPDFPageSize"}, _("Page size")),
this.renderSelectField("exportToPDFPageSize", controller.getPresentationProperty, controller.setPresentationProperty, {
A3 : _("A3"),
A4 : _("A4"),
A5 : _("A5"),
Legal : _("Legal"),
Letter : _("Letter"),
Tabloid: _("Tabloid")
}),
h("label", {for: "field-exportToPDFPageOrientation"}, _("Page orientation")),
this.renderSelectField("exportToPDFPageOrientation", controller.getPresentationProperty, controller.setPresentationProperty, {
landscape: _("Landscape"),
portrait: _("Portrait")
}),
h("label.side-by-side", {for: "field-exportToPDFInclude"}, [
_("List of frames to include"),
this.renderHelp(_("Click here to see the syntax for this field"), () => controller.info(this.exportListHelp, true))
]),
this.renderTextField("exportToPDFInclude", false, controller.getPresentationProperty, controller.setPresentationProperty, true),
h("label.side-by-side", {for: "field-exportToPDFExclude"}, [
_("List of frames to exclude"),
this.renderHelp(_("Click here to see the syntax for this field"), () => controller.info(this.exportListHelp, true))
]),
this.renderTextField("exportToPDFExclude", false, controller.getPresentationProperty, controller.setPresentationProperty, true)
];
}
/** Render the fields of the PPTX export tool.
*
* @returns {VNode[]} - Virtual DOM nodes with the PPTX-specific fields of the export tool.
*/
renderPPTXExportFields() {
const controller = this.controller;
const _ = controller.gettext;
return [
h("label", {for: "field-exportToPPTXSlideSize"}, _("Slide size")),
this.renderSelectField("exportToPPTXSlideSize", controller.getPresentationProperty, controller.setPresentationProperty, {
"35mm" : _("35 mm"),
A3 : _("A3"),
A4 : _("A4"),
B4ISO : _("B4 (ISO)"),
B4JIS : _("B4 (JIS)"),
B5ISO : _("B5 (ISO)"),
B5JIS : _("B5 (JIS)"),
banner : _("Banner"),
hagakiCard : _("Hagaki Card"),
ledger : _("Tabloid"),
letter : _("Letter"),
overhead : _("Overhead"),
screen16x10: _("Screen 16:10"),
screen16x9 : _("Screen 16:9"),
screen4x3 : _("Screen 4:3")
}),
h("label.side-by-side", {for: "field-exportToPPTXInclude"}, [
_("List of frames to include"),
this.renderHelp(_("Click here to see the syntax for this field"), () => controller.info(this.exportListHelp, true))
]),
this.renderTextField("exportToPPTXInclude", false, controller.getPresentationProperty, controller.setPresentationProperty, true),
h("label.side-by-side", {for: "field-exportToPPTXExclude"}, [
_("List of frames to exclude"),
this.renderHelp(_("Click here to see the syntax for this field"), () => controller.info(this.exportListHelp, true))
]),
this.renderTextField("exportToPPTXExclude", false, controller.getPresentationProperty, controller.setPresentationProperty, true)
];
}
/** Render the fields of the video export tool.
*
* @returns {VNode[]} - Virtual DOM nodes with the video-specific fields of the export tool.
*/
renderVideoExportFields() {
const controller = this.controller;
const _ = controller.gettext;
return [
h("label", {for: "field-exportToVideoFormat"}, _("Format")),
this.renderSelectField("exportToVideoFormat", controller.getPresentationProperty, controller.setPresentationProperty, {
mp4 : _("MPEG-4 (.mp4)"),
ogv : _("Ogg Vorbis (.ogv)"),
webm : _("WebM (.webm)"),
wmv : _("Windows Media Video (.wmv)"),
png : _("Image sequence (.png)")
}),
h("label", {for: "field-exportToVideoWidth"}, _("Width (pixels)")),
this.renderNumberField("exportToVideoWidth", false, controller.getPresentationProperty, controller.setPresentationProperty, false, 1, 1),
h("label", {for: "field-exportToVideoHeight"}, _("Height (pixels)")),
this.renderNumberField("exportToVideoHeight", false, controller.getPresentationProperty, controller.setPresentationProperty, false, 1, 1),
h("label", {for: "field-exportToVideoFrameRate"}, _("Frame rate (frames/sec)")),
this.renderNumberField("exportToVideoFrameRate", false, controller.getPresentationProperty, controller.setPresentationProperty, false, 1, 1),
h("label", {for: "field-exportToVideoBitRate"}, _("Bit rate (kbits/sec)")),
this.renderNumberField("exportToVideoBitRate", false, controller.getPresentationProperty, controller.setPresentationProperty, false, 1, 1000),
];
}
/** Create a help widget.
*
* @param {string} text - The tooltip text to show.
* @param {Function} onclick - An event handler for click events.
* @returns {VNode} - A virtual DOM tree.
*/
renderHelp(text, onclick) {
return h("span.help", {title: text, onclick}, h("i.fas.fa-question-circle"));
}
/** Create a text input field.
*
* A text field will render as a simple HTML input element.
*
* @param {string} property - The name of a property of the model.
* @param {boolean} disabled - Is this field disabled?
* @param {Function} getter - A function that returns the current value of the property in the model.
* @param {Function} setter - A function that updates the value of the property in the model.
* @param {boolean} acceptsEmpty - Is an empty field a valid entry?
* @returns {VNode} - A virtuel DOM tree.
*/
renderTextField(property, disabled, getter, setter, acceptsEmpty) {
const controller = this.controller;
const values = asArray(getter.call(controller, property));
const className = values.length > 1 ? "multiple" : undefined;
this.state[property] = {value: values.length >= 1 ? values[values.length - 1] : ""};
return h("input", {
id: "field-" + property,
type: "text",
className,
disabled,
onchange() {
const value = this.value;
if (acceptsEmpty || value.length) {
setter.call(controller, property, value);
}
}
});
}
/** Create a rich text input field.
*
* A rich text field will render as a content-editable HTML element
* supporting basic formatting via keyboard shortcuts.
*
* @param {string} property - The name of a property of the model.
* @param {boolean} disabled - Is this field disabled?
* @param {Function} getter - A function that returns the current value of the property in the model.
* @param {Function} setter - A function that updates the value of the property in the model.
* @param {boolean} acceptsEmpty - Is an empty field a valid entry?
* @returns {VNode} - A virtuel DOM tree.
*/
renderRichTextField(property, disabled, getter, setter, acceptsEmpty) {
const controller = this.controller;
const values = asArray(getter.call(controller, property));
const className = values.length > 1 ? "multiple" : undefined;
this.state[property] = {innerHTML: values.length >= 1 ? values[values.length - 1] : ""};
return h("section", {
id: "field-" + property,
contentEditable: true,
className,
disabled,
onblur() {
const value = this.innerHTML;
if (acceptsEmpty || value.length) {
setter.call(controller, property, value);
}
},
onkeydown(evt) {
if (evt.ctrlKey) {
switch(evt.keyCode) {
case 48: // Ctrl+0
document.execCommand("formatBlock", false, "<P>");
break;
case 49: // Ctrl+1
document.execCommand("formatBlock", false, "<H1>");
break;
case 50: // Ctrl+2
document.execCommand("formatBlock", false, "<H2>");
break;
case 51: // Ctrl+3
document.execCommand("formatBlock", false, "<H3>");
break;
case 76: // Ctrl+L
document.execCommand("insertUnorderedList", false, null);
break;
case 78: // Ctrl+N
document.execCommand("insertOrderedList", false, null);
break;
default:
return;
// Natively supported shortcuts:
// Ctrl+B|I|U : Bold, Italic, Underline
// Ctrl+A : Select all
// Ctrl+C|X|V : Copy, Cut, Paste
}
evt.stopPropagation();
}
}
});
}
/** Create a number input field.
*
* @param {string} property - The name of a property of the model.
* @param {boolean} disabled - Is this field disabled?
* @param {Function} getter - A function that returns the current value of the property in the model.
* @param {Function} setter - A function that updates the value of the property in the model.
* @param {boolean} signed - Does this field acccept negative values?
* @param {number} step - The step between consecutive values.
* @param {number} factor - A conversion factor between field value and model property value.
* @returns {VNode} - A virtuel DOM tree.
*/
renderNumberField(property, disabled, getter, setter, signed, step, factor) {
const controller = this.controller;
const values = asArray(getter.call(controller, property));
const className = values.length > 1 ? "multiple" : undefined;
this.state[property] = {value: values.length >= 1 ? values[values.length - 1] / factor : 0}; // TODO use default value
return h("input", {
id: "field-" + property,
type: "number",
className,
disabled,
min: signed ? undefined : 0,
step,
pattern: "[+-]?\\d+(\\.\\d+)?",
onchange() {
const value = parseFloat(this.value);
if (!isNaN(value) && (signed || value >= 0)) {
setter.call(controller, property, value * factor);
}
}
});
}
/** Create a range input field.
*
* @param {string} property - The name of a property of the model.
* @param {boolean} disabled - Is this field disabled?
* @param {Function} getter - A function that returns the current value of the property in the model.
* @param {Function} setter - A function that updates the value of the property in the model.
* @param {number} min - The minimum supported value.
* @param {number} max - The maximum supported value.
* @param {number} step - The step between consecutive values.
* @returns {VNode} - A virtuel DOM tree.
*/
renderRangeField(property, disabled, getter, setter, min, max, step) {
const controller = this.controller;
const values = asArray(getter.call(controller, property));
const className = values.length > 1 ? "multiple" : undefined;
this.state[property] = {value: values.length >= 1 ? values[values.length - 1] : (min + max) / 2}; // TODO use default value
return h("input", {
id: "field-" + property,
type: "range",
title: this.state[property].value,
min,
max,
step,
className,
disabled,
onchange() {
const value = parseFloat(this.value);
if (!isNaN(value) && value >= min && value <= max) {
setter.call(controller, property, value);
}
}
});
}
/** Create a toggle button.
*
* @param {string} label - The label to show next to the button.
* @param {string} title - A tooltip for the button.
* @param {string} property - The name of a property of the model.
* @param {Function} getter - A function that returns the current value of the property in the model.
* @param {Function} setter - A function that updates the value of the property in the model.
* @returns {VNode} - A virtuel DOM tree.
*/
renderToggleField(label, title, property, getter, setter) {
const controller = this.controller;
const values = asArray(getter.call(controller, property));
let className = values.length > 1 ? "multiple" : "";
const value = values.length >= 1 ? values[values.length - 1] : false; // TODO use default value
if (value) {
className += " active";
}
return h("button", {
className,
title,
onclick() {
setter.call(controller, property, !value);
}
}, label);
}
/** Create a drop-down list.
*
* @param {string} property - The name of a property of the model.
* @param {Function} getter - A function that returns the current value of the property in the model.
* @param {Function} setter - A function that updates the value of the property in the model.
* @param {object} options - An object that maps option keys to option labels.
* @returns {VNode} - A virtuel DOM tree.
*/
renderSelectField(property, getter, setter, options) {
const controller = this.controller;
const values = asArray(getter.call(controller, property));
const className = values.length > 1 ? "multiple" : undefined;
const value = values.length >= 1 ? values[values.length - 1] : options[0];
return h("select", {
id: "field-" + property,
className,
onchange() {
setter.call(controller, property, this.value);
}
}, Object.keys(options).map(optionValue => h("option", {
value: optionValue,
selected: value === optionValue
}, options[optionValue])
)
);
}
}