diff --git a/README.md b/README.md index 477e955c3..39018b1a3 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ It also highlights a DOM-based custom external editor triggered via hypergrid ev * [Roadmap](#roadmap) * [Contributing](#contributors) -### Current Release (3.1.0 - 29 September 2018) +### Current Release (3.2.0 - 17 November 2018) -**Hypergrid 3.1 includes 3.0’s revised data model with some breaking changes.** +> **CAUTION:** For those considering upgrading directly from v2, be advised Hypergrid v3 introduced a revised data model _with breaking changes._ The impact of these changes has been intentionally minimized and should not affect the vast majority of users. See the [v3.0.0 release notes](https://github.com/fin-hypergrid/core/releases/tag/v3.0.0) for more information. _For a complete list of changes, see the [release notes](https://github.com/fin-hypergrid/core/releases)._ @@ -25,7 +25,7 @@ _For a complete list of changes, see the [release notes](https://github.com/fin- #### npm module _(recommended)_ Published as a CommonJS module to npmjs.org. -Specify a SEMVER of `"fin-hypergrid": "3.1.0"` (or `"^3.1.0"`) in your package.json file, +Specify a SEMVER of `"fin-hypergrid": "3.2.0"` (or `"^3.2.0"`) in your package.json file, issue the `npm install` command, and let your bundler (wepback, Browserify) create a single file containing both Hypergrid and your application. diff --git a/gulpfile.js b/gulpfile.js index ae6bedf2b..86b9a5477 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -70,7 +70,7 @@ function clearBashScreen() { function swallowImages() { var config = { src: { - globs: [ 'images/*.png', 'images/*.gif','images/*.jpeg', 'images/*.jpg' ], + globs: 'images/*.{gif,png,jpg,jpeg,svg,ico}', options: {} }, transform: { diff --git a/images/index.js b/images/index.js index fc0b692d5..723fb52f4 100644 --- a/images/index.js +++ b/images/index.js @@ -12,6 +12,7 @@ 'use strict'; var _ = require('object-iterators'); +var svgThemer = require('svg-themer'); var images = require('./images'); // this is the file generated by gulpfile.js (and ignored by git) @@ -72,38 +73,148 @@ images['checkbox-on'] = images.checked; images['checkbox-off'] = images.unchecked; /** - * @name add * @method - * @param {string} key + * @param {string} name * @param {HTMLImageElement} img + * @param {boolean} [themeable] - If truthy, the image will be themed by {@link module:images.setTheme images.setTheme}, called by {@link Hypergrid.applyTheme}. + * If falsy, the image won't be themed until `images[name].themeable` is set to `true`. + * In any case the remaining parameters are processed. + * @param {function} [setSvgProps=svgThemer.setSvgProps] - Optional custom theming code for this image and the rules implied by `styles`. _If omitted, `styles` is promoted 2nd parameter position._ + * @param {boolean|string[]} [styles] - Optional list style names with which to create CSS rules. + * * If falsy (or omitted), no rules are created. + * * Else if truthy but not an array, create a single rule: + * ```css + * `.hypergrid-background-image-name { background-image: url(...) }` + * where _name_ is the value of the `name` parameter. + * * Else if an array, create a CSS rule for each style named therein. + * + * For each rule thus created: + * * Inserted into `style#injected-stylesheet-grid`. + * * Selector is `.hypergrid-style-name` (where `style` is element value and `name` is image name). + * (If a rule with that selector already exists, it is replaced.) + * * Contains the named style with a value of `url(...)` where `...` is the image data. + * Possible styles must be one of those listed in {*link https://github.com/joneit/svg-themer/blob/master/README.md#cssimagepropertynames svgThemer.cssImagePropertyNames} (which you can extend if needed). + * * Will be automatically themed when the grid is themed (which is the whole point). + * + * @see {@link https://github.com/joneit/svg-themer} + * @memberOf module:images + */ +function add(name, img, themeable, setSvgProps, styles) { + if (/^data:image\/svg\+xml|\.svg/.test(img.src)) { + img.themeable = !!themeable; + if (typeof setSvgProps === 'object') { + styles = setSvgProps; + setSvgProps = undefined; + } + if (setSvgProps) { + img.setSvgProps = setSvgProps; + } + if (styles) { + img.themeableRules = createThemeableRules(name, img, setSvgProps, styles); + } + } + return (images[name] = img); +} + +function createThemeableRules(key, img, setSvgProps, styles) { + // find or create stylesheet as needed + var styleEl = document.querySelector('style#injected-stylesheet-themeables'); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = 'injected-stylesheet-themeables'; + document.head.appendChild(styleEl); + } + var sheet = styleEl.sheet; + + return (styles.length ? styles : ['background-image']).reduce(function(rules, styleName) { + var selectorText = '.hypergrid-' + styleName + '-' + key; + + // find and delete existing rule, if any + var ruleIndex = Array.prototype.findIndex.call(sheet.cssRules, function(rule) { + return rule.selectorText === selectorText; + }); + if (ruleIndex !== -1) { + sheet.deleteRule(ruleIndex); + } + + // create and insert new rule consisting of selector + style "collection" + var ruleStyles = {}; + + // add image data style + ruleStyles[styleName] = 'url(' + img.src + ')'; + + // add dimensions if known + if (img.width) { ruleStyles.width = img.width + 'px'; } + if (img.height) { ruleStyles.height = img.height + 'px'; } + + // combine the above styles into a semi-colon-separated "collection" + var styleCollection = Object.keys(ruleStyles).map(function(key) { + return key + ':' + ruleStyles[key]; + }).join(';'); + + var ruleText = '{' + styleCollection + '}'; + sheet.insertRule(selectorText + ruleText); + + var themeableRule = { + rule: sheet.cssRules[0] + }; + if (setSvgProps) { + themeableRule.setSvgProps = setSvgProps; + } + rules.push(themeableRule); + return rules; + }, []); +} + +/** + * @param {object} theme * @memberOf module:images */ -images.add = function(key, img) { - return images[key] = img; -}; +function setTheme(theme) { + Object.keys(images).forEach(function(name) { + var img = images[name]; + if (img.themeable) { + svgThemer.setImgSvgProps.call(img, theme, img.setSvgProps); + } + if (img.themeableRules) { + img.themeableRules.forEach(function(themeable) { + var selectorText = themeable.rule.selectorText; + // extract style name using list of possible names + var regex = new RegExp('^\.hypergrid-(' + svgThemer.cssImagePropertyNames.join('|') + ')-.*$'); + var styleName = selectorText.replace(regex, '$1'); + svgThemer.setRuleSvgProps.call(themeable.rule, theme, img.setSvgProps, styleName); + }); + } + }); +} /** * Convenience function. - * @name checkbox - * @method * @param {boolean} state * @returns {HTMLImageElement} {@link module:images.checked|checked} when `state` is truthy or {@link module:images.unchecked|unchecked} otherwise. * @memberOf module:images */ -images.checkbox = function(state) { +function checkbox(state) { return images[state ? 'checked' : 'unchecked']; -}; +} /** * Convenience function. - * @name filter - * @method * @param {boolean} state * @returns {HTMLImageElement} {@link module:images.filter-off|filter-off} when `state` is truthy or {@link module:images.filter-on|filter-on} otherwise. * @memberOf module:images */ -images.filter = function(state) { +function filter(state) { return images[state ? 'filter-on' : 'filter-off']; -}; +} + +// add methods as non-enumerable members so member images can be enumerated +Object.defineProperties(images, { + add: { value: add }, + setTheme: { value: setTheme }, + checkbox: { value: checkbox }, + filter: { value: filter } +}); + module.exports = images; diff --git a/package.json b/package.json index 0d8f059cf..574219574 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fin-hypergrid", - "version": "3.1.0", + "version": "3.2.0", "description": "Canvas-based high-performance grid", "main": "src/Hypergrid", "repository": { @@ -32,6 +32,7 @@ "overrider": "^0", "rectangular": "1.0.1", "sparse-boolean-array": "1.0.1", + "svg-themer": "^1.1.2", "synonomous": "^2.1.2" }, "devDependencies": { @@ -41,7 +42,7 @@ "gulp-eslint": "^4.0.2", "gulp-footer": "^1.1.1", "gulp-header": "^1.8.2", - "gulp-imagine-64": "^1.0.1", + "gulp-imagine-64": "^2.0.1", "gulp-load-plugins": "^1.1.0", "gulp-mocha": "^6.0.0", "run-sequence": "^1.1.4" diff --git a/src/Base.js b/src/Base.js index 40fb2c335..7bb5eecf3 100644 --- a/src/Base.js +++ b/src/Base.js @@ -19,14 +19,16 @@ Object.defineProperty(Base.prototype, 'version', { value: require('../package.json').version }); -Base.prototype.atLeastVersion = function(neededVersion) { - var neededParts = neededVersion.split('.'), - thisParts = this.version.split('.'), - delta; - neededParts.find(function(neededPart, i) { - return (delta = neededPart - thisParts[i]); - }); - return delta >= 0; +Base.prototype.versionAtLeast = function(neededVersion) { + var neededParts = neededVersion.split('.').map(Number), + delta = this.version.split('.').map(function(part, i) { return Number(part) - neededParts[i]; }); + return ( + delta[0] > 0 || + delta[0] === 0 && ( + delta[1] > 0 || + delta[1] === 0 && delta[2] >= 0 + ) + ); }; Base.prototype.deprecated = require('./lib/deprecated'); diff --git a/src/Hypergrid/events.js b/src/Hypergrid/events.js index 0f0f8db20..1128c27fb 100644 --- a/src/Hypergrid/events.js +++ b/src/Hypergrid/events.js @@ -458,6 +458,23 @@ exports.mixin = { writable: true } ); + + // add some interesting mouse offsets + var drilldown; + if ((drilldown = primitiveEvent.primitiveEvent && primitiveEvent.primitiveEvent.detail)) { + decoratedEvent.gridPoint = drilldown.mouse; + if ((drilldown = drilldown.primitiveEvent)) { + decoratedEvent.clientPoint = { + x: drilldown.clientX, + y: drilldown.clientY + }; + decoratedEvent.pagePoint = { + x: drilldown.clientX + window.scrollX, + y: drilldown.clientY + window.scrollY + }; + } + } + cb.call(grid, decoratedEvent); } } @@ -576,7 +593,7 @@ exports.mixin = { }); this.addInternalEventListener('fin-canvas-context-menu', function(e) { - handleMouseEvent(e, function(mouseEvent){ + handleMouseEvent(e, function(mouseEvent) { grid.delegateContextMenu(mouseEvent); grid.fireSyntheticContextMenuEvent(mouseEvent); }); diff --git a/src/Hypergrid/themes.js b/src/Hypergrid/themes.js index d66a9617c..d5f819737 100644 --- a/src/Hypergrid/themes.js +++ b/src/Hypergrid/themes.js @@ -11,6 +11,7 @@ var _ = require('object-iterators'); // fyi: installs the Array.prototype.find p var defaults = require('../defaults'); var dynamicPropertyDescriptors = require('../lib/dynamicProperties'); var HypergridError = require('../lib/error'); +var images = require('../../images'); var styles = [ 'BackgroundColor', @@ -73,15 +74,9 @@ stylers.reduce(function(theme, styler) { var registry = Object.create(null, { default: { value: defaultTheme } }); -var pseudopropAdvice = { - showRowNumbers: 'rowHeaderCheckboxes and rowHeaderNumbers', - lineColor: 'gridLinesHColor and gridLinesVColor', - lineWidth: 'gridLinesHWidth and gridLinesVWidth', - gridBorder: 'gridBorderLeft, gridBorderRight, gridBorderTop, and gridBorderBottom' -}; function applyTheme(theme) { - var themeLayer, grids, props; + var themeLayer, grids, props, themeObject; if (theme && typeof theme === 'object' && !Object.getOwnPropertyNames(theme).length) { theme = null; @@ -117,45 +112,50 @@ function applyTheme(theme) { theme = theme || 'default'; } - if (typeof theme === 'string') { - if (!registry[theme]) { - throw new HypergridError('Unknown theme "' + theme + '"'); - } - theme = registry[theme]; + if (typeof theme === 'object') { + themeObject = theme; + } else if (!registry[theme]) { + throw new HypergridError('Unknown theme "' + theme + '"'); + } else { + themeObject = registry[theme]; } - if (theme) { + if (themeObject) { // When no theme name, set it to explicit `undefined` (to mask defaults.themeName). - if (!theme.themeName) { - theme.themeName = undefined; + if (!themeObject.themeName) { + themeObject.themeName = undefined; } - Object.keys(theme).forEach(function(key) { + Object.keys(themeObject).forEach(function(key) { if (key in dynamicPropertyDescriptors) { if (key in dynamicCosmetics) { grids.forEach(function(grid) { - grid.properties[key] = theme[key]; + grid.properties[key] = themeObject[key]; }); } else { // Dynamic properties are defined on properties layer; defining these // r-values on the theme layer is ineffective so let's not allow it. - var message = pseudopropAdvice[key]; - message = message - ? 'Ignoring unexpected pseudo-prop ' + key + ' in theme object. Use actual props ' + message + ' instead.' - : 'Ignoring invalid property ' + key + ' in theme object.'; - console.warn(message); - delete theme[key]; + switch (key) { + case 'lineColor': + themeObject.gridLinesHColor = themeObject.gridLinesVColor = themeObject[key]; + break; + default: + console.warn('Ignoring unexpected dynamic property ' + key + ' from theme object.'); + } + // delete themeObject[key]; } } }); // No .assign() because themeName is read-only in defaults layer - Object.defineProperties(themeLayer, Object.getOwnPropertyDescriptors(theme)); + Object.defineProperties(themeLayer, Object.getOwnPropertyDescriptors(themeObject)); } grids.forEach(function(grid) { grid.repaint(); }); + + return themeObject; } @@ -185,8 +185,8 @@ var mixin = { * @this {Hypergrid} * @param {object|string} [theme] - One of: * * **string:** A registered theme name. - * * **object:** A unregistered (anonymous) theme object. Empty object removes grid theme, exposing global theme. - * * _falsy value:_ Also removes grid theme. + * * **object:** An anonymous (unregistered) theme object. Empty object removes grid theme, exposing global theme. + * * _falsy value:_ Also removes grid theme (like empty object). * @param {string|undefined} [theme.themeName=undefined] * @memberOf Hypergrid# */ @@ -213,7 +213,7 @@ var mixin = { }; Object.defineProperty(mixin, 'theme', { enumerable: true, - set: mixin.applyTheme, + set: applyTheme, get: mixin.getTheme }); @@ -234,7 +234,8 @@ var sharedMixin = { * ```javascript * var myTheme = require('fin-hypergrid-themes').buildTheme(); * ``` - * If omitted, the theme named in the first parameter is unregistered. + * If omitted, unregister the theme named in the first parameter. + * * Grid instances that have previously applied the named theme are unaffected by this action (whether re-registering or unregistering). * @memberOf Hypergrid. */ @@ -284,7 +285,6 @@ var sharedMixin = { * @summary Apply global theme. * @desc Apply props from the given theme object to the global theme object, * the `defaults` layer at the bottom of the properties hierarchy. - * @this {Hypergrid.} * @param {object|string} [theme=registry.default] - One of: * * **string:** A registered theme name. * * **object:** A theme object. Empty object removes global them, restoring defaults. @@ -292,11 +292,14 @@ var sharedMixin = { * @param {string|undefined} [theme.themeName=undefined] * @memberOf Hypergrid. */ - applyTheme: applyTheme + applyTheme: function(theme) { + var themeObject = applyTheme.call(this, theme); + images.setTheme(themeObject); + } }; Object.defineProperty(sharedMixin, 'theme', { // global theme setter/getter enumerable: true, - set: applyTheme, + set: sharedMixin.applyTheme, get: function() { return defaults; } // the defaults layer *is* the global theme layer }); diff --git a/src/cellRenderers/SimpleCell.js b/src/cellRenderers/SimpleCell.js index 51d37d446..fe01b0d9d 100644 --- a/src/cellRenderers/SimpleCell.js +++ b/src/cellRenderers/SimpleCell.js @@ -141,7 +141,7 @@ var SimpleCell = CellRenderer.extend('SimpleCell', { // Measure & draw center icon iyoffset = Math.round((height - centerIcon.height) / 2); ixoffset = width - Math.round((width - centerIcon.width) / 2) - centerIcon.width; - gc.drawImage(centerIcon, x + ixoffset, y + iyoffset); + gc.drawImage(centerIcon, x + ixoffset, y + iyoffset, centerIcon.width, centerIcon.height); // see [SIZE NOTE]! valWidth = iconPadding + centerIcon.width + iconPadding; if (config.hotIcon === 'center') { config.clickRect = new Rectangle(ixoffset, iyoffset, centerIcon.width, centerIcon.height); @@ -152,7 +152,7 @@ var SimpleCell = CellRenderer.extend('SimpleCell', { if (leftIcon) { // Draw left icon iyoffset = Math.round((height - leftIcon.height) / 2); - gc.drawImage(leftIcon, x + iconPadding, y + iyoffset); + gc.drawImage(leftIcon, x + iconPadding, y + iyoffset, leftIcon.width, leftIcon.height); // see [SIZE NOTE]! if (config.hotIcon === 'left') { config.clickRect = new Rectangle(iconPadding, iyoffset, leftIcon.width, leftIcon.height); } @@ -171,7 +171,7 @@ var SimpleCell = CellRenderer.extend('SimpleCell', { // Draw right icon iyoffset = Math.round((height - rightIcon.height) / 2); - gc.drawImage(rightIcon, rightX, y + iyoffset); + gc.drawImage(rightIcon, rightX, y + iyoffset, rightIcon.width, rightIcon.height); // see [SIZE NOTE]! if (config.hotIcon === 'right') { config.clickRect = new Rectangle(ixoffset, iyoffset, rightIcon.width, rightIcon.height); } @@ -190,6 +190,16 @@ var SimpleCell = CellRenderer.extend('SimpleCell', { } }); +/* [SIZE NOTE] (11/1/2018): Always call `drawImage` with explicit width and height overload. + * Possible browser bug: Although 3rd and 4th parameters to `drawImage` are optional, + * when image data derived from SVG source, some browsers (e.g., Chrome 70) implementation + * of `drawImage` only respects _implicit_ `width` x `height` specified in the root + * element `width` & `height` attributes. Otherwise, image is copied into canvas using its + * `naturalWidth` x `naturalHeight`. That is, _explict_ settings of `width` & `height` + * (i.e, via property assignment, calling setAttribute, or in `new Image` call) have no + * effect on `drawImage` in the case of SVGs on these browsers. + */ + /** * @summary Renders single line text. * @param {CanvasRenderingContext2D} gc diff --git a/src/lib/Registry.js b/src/lib/Registry.js index 6321dc1a3..b5bbec977 100644 --- a/src/lib/Registry.js +++ b/src/lib/Registry.js @@ -32,6 +32,7 @@ var Registry = Base.extend('Registry', { return; } + // getClassName defined if new item derived from extend-me name = name || item.getClassName && item.getClassName(); if (!name) { @@ -60,6 +61,16 @@ var Registry = Base.extend('Registry', { return (this.items[synonymName] = this.items[existingName]); }, + /** + * Create a new item extended from base class. For formal parameters, see {@link Registry#add add}. + * @memberOf Registry# + */ + make: function(name, prototype) { + var last = arguments.length - 1; + arguments[last] = this.BaseClass.extend(arguments[last]); + this.add.apply(this, arguments); + }, + /** * Fetch a registered item. * @param {string} [name] diff --git a/src/lib/dispatchGridEvent.js b/src/lib/dispatchGridEvent.js index 80a5a626d..a71c1320e 100644 --- a/src/lib/dispatchGridEvent.js +++ b/src/lib/dispatchGridEvent.js @@ -6,6 +6,9 @@ var details = [ 'gridCell', 'dataCell', 'mousePoint', + 'gridPoint', + 'clientPoint', + 'pagePoint', 'keys', 'row' ];