Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support CSSNumericValues as inset #177

Merged
merged 3 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/proxy-cssom.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { createAType, invertType, multiplyTypes, to, toSum } from "./numeric-values";
import { simplifyCalculation } from "./simplify-calculation";

export function installCSSOM() {
// Object for storing details associated with an object which are to be kept
Expand Down Expand Up @@ -68,7 +69,126 @@ export function installCSSOM() {
}
}

/**
* Parse a CSSUnitValue from the passed string
* @param {string} str
* @return {CSSUnitValue}
*/
function parseCSSUnitValue(str) {
const UNIT_VALUE_REGEXP = /^(-?\d*[.]?\d+)(r?em|r?ex|r?cap|r?ch|r?ic|r?lh|[sld]?v(w|h|i|b|min|max)|cm|mm|Q|in|pt|pc|px|%)?$/;
const match = str.match(UNIT_VALUE_REGEXP);
if (match) {
let [_, v, unit] = match;
if (typeof unit === 'undefined') {
unit = 'number';
} else if (unit === '%') {
unit = 'percent';
}
return new CSSUnitValue(parseFloat(v), unit);
} else {
throw new SyntaxError(`Unsupported syntax ${str}`);
}
}

/**
* Parse the string as a CSSMathProduct
* @param {string} str
* @return {CSSMathProduct}
*/
function parseCSSMultiplication(str) {
let values = [];
const tokens = str.split(/(?<!\([^\)]*)([*])(?![^\(]*\))/);
values.push(parseCSSDivision(tokens.shift()));
while (tokens.length) {
tokens.shift(); // Consume operator '*'
values.push(parseCSSDivision(tokens.shift()));
}
return new CSSMathProduct(...values);
}

/**
* Parse the string as a CSSMathProduct
* @param {string} str
* @return {CSSMathProduct}
*/
function parseCSSDivision(str) {
let values = [];
const tokens = str.split(/(?<!\([^\)]*)([/])(?![^\(]*\))/);
values.push(parseCSSNumericValue(tokens.shift()));
while (tokens.length) {
tokens.shift(); // Consume operator '/'
values.push(new CSSMathInvert(parseCSSNumericValue(tokens.shift())));
}
return new CSSMathProduct(...values);
}

/**
* Parse the string as a CSSMathSum
* @param {string} str
* @return {CSSMathSum}
*/
function parseCSSMathSum(str) {
let values = [];
const tokens = str.split(/(?<!\([^\)]*)(\s[+-]\s)(?![^\(]*\))/);
values.push(parseCSSMultiplication(tokens.shift()));
while (tokens.length) {
let op = tokens.shift();
let val = tokens.shift();
if (op.trim() === '+') {
values.push(parseCSSMultiplication(val));
} else if (op.trim() === '-') {
values.push(new CSSMathNegate(parseCSSMultiplication(val)));
}
}
return new CSSMathSum(...values);
}

/**
* Parse math function form the passed string and return a matching CSSMathValue
* @param {string} str
* @return {CSSMathValue}
*/
function parseMathFunction(str) {
const MATH_VALUE_REGEXP = /^(calc|min|max)?\((.*)\)$/;
const match = str.match(MATH_VALUE_REGEXP);
if (match) {
let [_, operation = 'parens', value] = match;
switch (operation) {
case 'calc':
case 'parens':
return parseCSSMathSum(value);
case 'min':
return new CSSMathMin(...value.split(',').map(parseCSSNumericValue));
case 'max':
return new CSSMathMax(...value.split(',').map(parseCSSNumericValue));
}
} else {
throw new SyntaxError(`Unsupported syntax ${str}`);
}
}

/**
* A naive parsing function parsing the input string and returning a CSSNumericValue.
* It supports simple expressions as 'calc(10em + 10px)'
*
* @param {string} value
* @return {CSSNumericValue}
*/
function parseCSSNumericValue(value) {
value = value.trim();
if (value.match(/^[a-z(]/i)) {
return parseMathFunction(value);
} else {
return parseCSSUnitValue(value);
}
}

const cssOMTypes = {
'CSSNumericValue': class {
static parse(value) {
return simplifyCalculation(parseCSSNumericValue(value), {});
}
},
'CSSUnitValue': class {
constructor(value, unit) {
privateDetails.set(this, {
Expand Down
145 changes: 99 additions & 46 deletions src/scroll-timeline-base.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@ export function measureSource (source) {
clientWidth: source.clientWidth,
clientHeight: source.clientHeight,
writingMode: style.writingMode,
direction: style.direction
direction: style.direction,
scrollPaddingTop: style.scrollPaddingTop,
scrollPaddingBottom: style.scrollPaddingBottom,
scrollPaddingLeft: style.scrollPaddingLeft,
scrollPaddingRight: style.scrollPaddingRight
};
}

Expand All @@ -199,11 +203,13 @@ export function measureSubject(source, subject) {
}
left -= source.offsetLeft + source.clientLeft;
top -= source.offsetTop + source.clientTop;
const style = getComputedStyle(subject);
return {
top,
left,
offsetWidth: subject.offsetWidth,
offsetHeight: subject.offsetHeight
offsetHeight: subject.offsetHeight,
fontSize: style.fontSize,
};
}

Expand Down Expand Up @@ -287,7 +293,7 @@ function updateSource(timeline, source) {
updateMeasurements(record.target);
}
});
mutationObserver.observe(source, {attributes: true, attributeFilter: ['style']});
mutationObserver.observe(source, {attributes: true, attributeFilter: ['style', 'class']});

const scrollListener = () => {
// Sample and store scroll pos
Expand All @@ -304,6 +310,7 @@ function updateSource(timeline, source) {
scrollEventSource(source).addEventListener("scroll", scrollListener);
details.disconnect = () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
scrollEventSource(source).removeEventListener("scroll", scrollListener);
};
}
Expand Down Expand Up @@ -368,7 +375,7 @@ export class ScrollTimeline {

// View timeline
subject: null,
inset: (options ? options.inset : null),
inset: null,

// Internal members
animations: [],
Expand Down Expand Up @@ -599,23 +606,29 @@ export function calculateRange(phase, sourceMeasurements, subjectMeasurements, a
const rtl = sourceMeasurements.direction == 'rtl' || sourceMeasurements.writingMode == 'vertical-rl';
let viewSize = undefined;
let viewPos = undefined;
let containerSize = undefined;
let sizes = {
fontSize: subjectMeasurements.fontSize
};
if (axis == 'x' ||
(axis == 'inline' && horizontalWritingMode) ||
(axis == 'block' && !horizontalWritingMode)) {
viewSize = subjectMeasurements.offsetWidth;
viewPos = subjectMeasurements.left;
if (rtl)
sizes.scrollPadding = [sourceMeasurements.scrollPaddingLeft, sourceMeasurements.scrollPaddingRight];
if (rtl) {
viewPos += sourceMeasurements.scrollWidth - sourceMeasurements.clientWidth;
containerSize = sourceMeasurements.clientWidth;
sizes.scrollPadding = [sourceMeasurements.scrollPaddingRight, sourceMeasurements.scrollPaddingLeft];
}
sizes.containerSize = sourceMeasurements.clientWidth;
} else {
// TODO: support sideways-lr
viewSize = subjectMeasurements.offsetHeight;
viewPos = subjectMeasurements.top;
containerSize = sourceMeasurements.clientHeight;
sizes.scrollPadding = [sourceMeasurements.scrollPaddingTop, sourceMeasurements.scrollPaddingBottom];
sizes.containerSize = sourceMeasurements.clientHeight;
}

const inset = parseInset(optionsInset, containerSize);
const inset = calculateInset(optionsInset, sizes);

// Cover:
// 0% progress represents the position at which the start border edge of the
Expand All @@ -624,7 +637,7 @@ export function calculateRange(phase, sourceMeasurements, subjectMeasurements, a
// 100% progress represents the position at which the end border edge of the
// element’s principal box coincides with the start edge of its view progress
// visibility range.
const coverStartOffset = viewPos - containerSize + inset.end;
const coverStartOffset = viewPos - sizes.containerSize + inset.end;
const coverEndOffset = viewPos + viewSize - inset.start;

// Contain:
Expand All @@ -647,7 +660,7 @@ export function calculateRange(phase, sourceMeasurements, subjectMeasurements, a

let startOffset = undefined;
let endOffset = undefined;
const targetIsTallerThanContainer = viewSize > containerSize ? true : false;
const targetIsTallerThanContainer = viewSize > sizes.containerSize ? true : false;

switch(phase) {
case 'cover':
Expand Down Expand Up @@ -683,47 +696,74 @@ export function calculateRange(phase, sourceMeasurements, subjectMeasurements, a
return { start: startOffset, end: endOffset };
}

function validateInset(value) {
// Validating insets when constructing ViewTimeline by running the parse function.
// TODO: parse insets to CSSNumericValue when constructing ViewTimeline
parseInset(value, 0)
function parseInset(value) {
const inset = { start: 0, end: 0 };

if (!value) return inset;

let parts = value;
// Parse string parts to
if (typeof value === 'string') {
// Split value into separate parts
const stringParts = value.split(/(?<!\([^\)]*)\s(?![^\(]*\))/);
parts = stringParts.map(str => {
if (str.trim() === 'auto') {
return 'auto';
} else {
try {
return CSSNumericValue.parse(str);
} catch (e) {
throw TypeError('Invalid inset');
}
}
});
}
if (parts.length === 0 || parts.length > 2) {
throw TypeError('Invalid inset');
}

// Validate that the parts are 'auto' or <length-percentage>
for (const part of parts) {
if (part === 'auto') {
continue;
}
const type = part.type();
if (!(type.length === 1 || type.percent === 1)) {
throw TypeError('Invalid inset');
}
}

return {
start: parts[0],
end: parts[1] ?? parts[0]
};
}

function parseInset(value, containerSize) {
function calculateInset(value, sizes) {
const inset = { start: 0, end: 0 };

if(!value)
return inset;

const parts = value.split(' ');
const insetParts = [];
parts.forEach(part => {
// TODO: Add support for relative lengths (e.g. em)
if(part.endsWith("%"))
insetParts.push(containerSize / 100 * parseFloat(part));
else if(part.endsWith("px"))
insetParts.push(parseFloat(part));
else if(part === "auto")
insetParts.push(0);
else
throw TypeError("Unsupported inset. Only % and px values are supported (for now).");
});
if (!value) return inset;

if (insetParts.length > 2) {
throw TypeError("Invalid inset");
}
const [start, end] = [value.start, value.end].map((part, i) => {
if (part === 'auto') {
return sizes.scrollPadding[i] === 'auto' ? 0 : parseFloat(sizes.scrollPadding[i]);
}

if(insetParts.length == 1) {
inset.start = insetParts[0];
inset.end = insetParts[0];
} else if(insetParts.length == 2) {
inset.start = insetParts[0];
inset.end = insetParts[1];
}
const simplifiedUnit = simplifyCalculation(part, {
percentageReference: CSS.px(sizes.containerSize),
fontSize: CSS.px(parseFloat(sizes.fontSize))
});
if (simplifiedUnit instanceof CSSUnitValue && simplifiedUnit.unit === 'px') {
return simplifiedUnit.value;
} else {
throw TypeError('Unsupported inset.');
}
});

return inset;
return { start, end };
}


// Calculate the fractional offset of a (phase, percent) pair relative to the
// full cover range.
export function relativePosition(timeline, phase, offset) {
Expand Down Expand Up @@ -763,11 +803,16 @@ export class ViewTimeline extends ScrollTimeline {
details.subject = options && options.subject ? options.subject : undefined;
// TODO: Handle insets.
if (options && options.inset) {
validateInset(options.inset)
details.inset = parseInset(options.inset);
}
if (details.subject) {
const mutationObserver = new MutationObserver(() => {
updateMeasurements(details.source);
});
mutationObserver.observe(details.subject, {attributes: true, attributeFilter: ['class', 'style']});
}

validateSource(this);
details.subjectMeasurements = measureSubject(details.source, details.subject)
details.subjectMeasurements = measureSubject(details.source, details.subject);
updateInternal(this);
}

Expand Down Expand Up @@ -805,4 +850,12 @@ export class ViewTimeline extends ScrollTimeline {
return CSS.percent(100 * progress);
}

get startOffset() {
return CSS.px(range(this,'cover').start);
}

get endOffset() {
return CSS.px(range(this,'cover').end);
}

}
Loading
Loading