-
Notifications
You must be signed in to change notification settings - Fork 2.1k
feat(shape): Adding MDCShape, an experimental subsystem #1448
Conversation
Also updated our webpack config and dependency test to allow demo-only JS.
demos/shape/heart/foundation.js
Outdated
const flatHeight = this.generateBaseFlatHeight_(height, padding); | ||
const curveHeight = this.generateBaseHeight_(height, padding) - flatHeight; | ||
const realWidth = this.generateBaseWidth_(width, padding) - flatWidth; | ||
return 'c 0,' + (curveHeight - this.generateBaseCurveHeight2_(height, padding)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like a good place to use template strings in JS to make this return statement a bit simpler.
demos/shape/heart/foundation.js
Outdated
+ (-1 * (realWidth - this.generateBaseCurveWidth_(width, padding))) | ||
+ ',' + (curveHeight - this.generateBaseCurveHeight_(height, padding)) | ||
+ (-1 * realWidth) + ',' + curveHeight | ||
+'l ' + (-1 * flatWidth) + ',' + flatHeight; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: space after first +
operator.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another nit... do we not prefer trailing operators rather than leading? (Maybe moot if we reorganize this and use a template string.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My Outlined Text Field prototype uses leading operators, and the king of SVG on the web Mike Bostock always uses leading operators. I am open to more clearly defining our convention here, and am not married to leading operators.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess it comes down to whether we want to adhere to Google's JS style guide for this, which promotes breaking after the operator, not before.
If we want to enforce this, we can use https://eslint.org/docs/rules/operator-linebreak
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the closer we are to the Google style guide the better. Since that is the companies encouraged policy on coding style.
However, I still think shifting these to template literals would net a simpler codebase to maintain long-term.
packages/mdc-shape/_variables.scss
Outdated
// | ||
|
||
$mdc-shape-shadow-padding: 56px; | ||
$mdc-shape-shadow-negative-padding: -1 * $mdc-shape-shadow-padding; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a motive behind making this a variable rather than simply doing -1 * mdc-shape-shadow-padding in the 2 cases where this is needed? The presence of a separate variable makes me think there'd be more to it than simply negating the other one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
uh nope, this was silly. I removed it.
packages/mdc-shape/foundation.js
Outdated
} | ||
} | ||
|
||
const UMBRA_OFFSET_Y = [ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are these constants here rather than in a constants file? Also, are these more directly related to elevation rather than shapes? I suspect these correspond to styles in mdc-elevation/_variables.scss and it would be really easy for these to get out of sync with those, being in a completely separate package.
Is there any other stuff here that should be considered for mdc-elevation instead? Should shapes be dependent on elevation?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Plus One
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved it to mdc-elevation!
packages/mdc-shape/component.js
Outdated
|
||
initialSyncWithDOM() { | ||
this.foundation_.setBackground(getBackgroundFromDOM(this.root_), false); | ||
this.foundation_.setElevation(getElevationFromDOM(this.root_), false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AFAICT, getElevationFromDOM
assumes that CSS custom properties work, which in turn makes me suspect we need to be feature-detecting this the same way that we're doing in ripple (and IIRC a couple of other places, that probably need updating)...
Maybe it's time for #1104?
What's our plan for browsers that don't support custom CSS properties?
OTOH, is there a reason that setting elevation needs to be done this way rather than actually reusing mdc-elevation classes (esp. if there's any desire to support no-JS shapes), or just using a data attribute if this is solely for the purpose of initialization via JS?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please... With the initialisms...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Theres no support for no-JS shapes...Shape JS has to draw the shape on a canvas, and it has to draw all the shadows on the canvas. The existing Sass mixins for elevation won't work with shapes because the shadows won't properly follow the irregular edges of a shape.
packages/mdc-shape/foundation.js
Outdated
this.elevating_ = false; | ||
this.elevation_ = value; | ||
|
||
if (!animate) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: flip the if/else so that the if
performs a positive comparison?
scripts/check-pkg-for-release.js
Outdated
|
||
main(); | ||
|
||
function main() { | ||
checkPublicConfigForNewComponent(); | ||
if (pkg.name !== MASTER_PKG.name) { | ||
checkNameIsPresentInAllowedScope(); | ||
} | ||
if (NOT_PUBLISHABLE.indexOf(pkg.name) !== -1) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Uh... is this inverted? IIUC this will only execute the statements below if the package is not publishable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oops, fixed this up
demos/shape/shape.scss
Outdated
} | ||
.mdc-shape { | ||
--mdc-shape-elevation: 4; | ||
--mdc-shape-background: #FF00FF; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit (here and anywhere else applicable): lowercase hex values
|
||
```html | ||
<div class="mdc-shape"> | ||
<canvas class="mdc-shape__canvas"></canvas> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this a canvas and does it always have to be? Is shape expected to eventually work on other components?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does always have to be a canvas.
Initially we had an implementation that clipped divs with clip-path, then applied a drop-shadow filter. This has a couple performance problems
- Calculating drop-shadow requires iterating over every pixel within that div. So the larger the div the slower the performance
- The drop-shadow is repainted (and therefore recalculated) whenever the content underneath the clipped div changed. (Technically, whenever any content in the same GPU tile changes.) This means if you were scrolling content under the clipped div, the scroll could become janky.
Also for working with other components...yes some day. Since this is still experimental we've only built some offhand prototypes (like for button). In the future most components will probably have some "shape" version, in addition to the default version, which has a modified HTML structure to include canvas. Obviously, these will be very heavy weight components and should be used with discretion.
packages/mdc-shape/mdc-shape.scss
Outdated
|
||
@import "./variables"; | ||
|
||
$mdc-shape-shadow-double-padding: 2 * $mdc-shape-shadow-padding; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm confused why we're doing this here yet we have mdc-shape-shadow-negative-padding in variables.scss. For that matter, maybe it'd be easier to understand if we simply set all of top/left/bottom/right to the same thing, instead of requiring this math?
I'm also not sure I understand what the padding on each side of the canvas is useful for?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I was struggling with how to use a Sass variable within a calc function....fixed it now
The padding is to make sure the canvas is large enough to cover the highest elevation (24) with the largest blur (56px). This way there will always be enough room on the canvas to draw the shadow, regardless of what elevation a shape is at.
packages/mdc-shape/util.js
Outdated
elevation = parseInt(mdcShapeElevation); | ||
} | ||
|
||
return elevation; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is going to silently return 0 in the case where elevation can't be computed by these means. Is that what we want?
packages/mdc-shape/README.md
Outdated
mdcShape.elevation = 4; | ||
``` | ||
|
||
The second way is to use custom CSS properties. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we really expose 2 ways to do the same thing, especially if the 2nd way won't work in all browsers? (I have more thoughts on the CSS custom properties approach in another comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair enough, I removed the custom CSS properties approach.
demos/shape/heart/foundation.js
Outdated
+this.generateLeftBase_(width, height, padding) | ||
+this.generateTopLeftCurve_(width, height, padding) | ||
+this.generateTopRightCurve_(width, height, padding) | ||
+this.generateRightBase_(width, height, padding); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we are adding a space after the operator elsewhere, we should do the same here.
demos/shape/heart/foundation.js
Outdated
+this.generateRightBase_(width, height, padding); | ||
} | ||
generateBaseWidth_(width, padding) { | ||
return ((width - (padding * 2)) / 2); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parens around padding * 2
is unnecessary.
demos/shape/heart/foundation.js
Outdated
return this.generateBaseWidth_(width, padding) * 0.52; | ||
} | ||
generateBaseHeight_(height, padding) { | ||
return (height - (padding * 2)) * 0.67; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment, remove parens around padding * 2
demos/shape/heart/foundation.js
Outdated
+'l ' + (-1 * flatWidth) + ',' + flatHeight; | ||
} | ||
generateOutsideWidth_(width, padding) { | ||
return ((width - (padding * 2)) / 2) * 0.55; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove parens around padding * 2
demos/shape/heart/foundation.js
Outdated
return this.generateOutsideWidth_(width, padding) * 0.44; | ||
} | ||
generateInsideWidth_(width, padding) { | ||
return ((width - (padding * 2)) / 2) * 0.45; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove parens around padding * 2
|
||
generateClipPath_() { | ||
return this.generatePath_( | ||
this.adapter_.getCanvasWidth() - (numbers.SHADOW_PADDING * 2), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SHADOW_PADDING
is so metal. 🤘
finished = true; | ||
} | ||
|
||
this.redraw(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like stuff is happening in init()
that doesn't need to happen every time redraw()
is called. Doesn't seem destructive yet, just seems unnecessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I moved some of the canvas stuff to a separate method. This way we don't re-create2dRenderingContext on every call to redraw.
The sync with canvas is important though, because the canvas could have changed size.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Under what circumstances do we expect redraw
to be called? We have a layout
method in a lot of components intended to handle resizes, should we do the sync with canvas there instead, or is this method effectively serving a similar purpose to layout
in other components?
this.redraw(); | ||
|
||
if (!finished && this.elevating_) { | ||
this.animationFrameId_ = requestAnimationFrame(() => this.elevate_()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this going to be hard to test?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wrote a test!
} | ||
|
||
clear_() { | ||
this.ctx_.clearRect(0, 0, this.adapter_.getCanvasWidth(), this.adapter_.getCanvasHeight()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should rethink redraw()
:)
packages/mdc-shape/foundation.js
Outdated
} | ||
} | ||
|
||
const UMBRA_OFFSET_Y = [ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Plus One
Resolves #1535 |
``` | ||
|
||
We recommend you put any content for the shape inside the "clipped" element *after* the `mdc-shape__canvas` element. The "clipped" element must contain the style `clip-path:url(#FOO_ID)`, where `FOO_ID` corresponds to the value you assign to the `clipPath` element's `id` attribute. Using the same ID between the "clipped" element and the `clipPath` element effectively clips any content to the shape. It is important `mdc-shape__canvas` is before the "clipped" element, otherwise the clipping effect will clip the shadows drawn by `MDCShapeFoundation`. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clipped seems like an appropriate word to me, but maybe introduce the vocabulary by outlining the purpose that each of the 3 elements serves in a bulleted list, then we don't have to air-quote "clipped" everywhere?
|
||
Returns an object which has the shape of a CanvasRenderingContext2d instance. An easy way to achieve this is simply `this.root_.querySelector(mdc.shape.MDCShapeFoundation.SHAPE_SELECTOR).getContext('2d');` | ||
|
||
The full API must cover: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: "cover" -> "include"? Might sound a bit more natural.
|
||
### Shape Customization | ||
|
||
To customize your shape's elevation and background color, use the MDCShape's API. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: "use the MDCShape's API" sounds a bit awkward... "use MDCShape's API"? "use the MDCShape API"? "use the methods on the MDCShape instance"?
To modify the elevation of a shape, set the background | ||
|
||
``` | ||
mdcShape.background = '#FOO'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: lowercase f in the color hex value
} | ||
|
||
createAdapter() { | ||
return {setCanvasWidth: (value) => this.canvas_.width = value, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: newline after {
? (Not sure if there's a way to get lint to catch this without complaining about e.g. single-line cases)
|
||
drawAmbientShadow_(devicePixelRatio, noSpreadPath) { | ||
this.ctx_.fillStyle = '#FFF'; | ||
this.ctx_.shadowColor = 'rgba(0, 0, 0, 0.12)'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should these alpha values really be hard-coded here? Do they correspond to elevation constants we've added?
For that matter, is shape shadow color supposed to be parameterizable?
* limitations under the License. | ||
*/ | ||
import {MDCFoundation} from '@material/base'; | ||
import {constants} from '@material/elevation'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might suggest making this have a more specific name like elevationConstants
since this package also has a constants
module (though we're importing specific members from that). WDYT?
const renderingContext = MDCShapeFoundation.defaultAdapter.create2dRenderingContext(); | ||
const renderingContextMethods = | ||
Object.keys(renderingContext).filter((k) => typeof renderingContext[k] === 'function'); | ||
assert.deepEqual(renderingContextMethods.slice().sort(), ['scale', 'clearRect', 'fill'].slice().sort()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is either slice
call here required?
|
||
assert.equal(fillCount, 4); | ||
assert.equal(shadowBlurs[0], 1); | ||
assert.equal(shadowBlurs[1], 0.4); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should some of these values (either here or below after flushing) be looked up from the constants in mdc-elevation?
td.verify(mockAdapter.createPath2D('m 56.2 56.2 h 100 v 200')); | ||
|
||
// requires 9 frames to finish animation | ||
for (let i=0; i<9; i++) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit confused why this is necessary... isn't our mock raf implementation supposed to flush all pending animation frames at once?
(IE11 and Edge lack full Path2D support though)
Also updated our webpack config and dependency test to allow demo-only JS.