autolayout
Version:
Apple's Auto Layout and Visual Format Language for javascript (using cassowary constraints)
654 lines (630 loc) • 27.8 kB
JavaScript
import parser from './parser/parser';
import parserExt from './parser/parserExt';
import Attribute from './Attribute';
import Relation from './Relation';
const Orientation = {
HORIZONTAL: 1,
VERTICAL: 2,
ZINDEX: 4
};
/**
* Helper function that inserts equal spacers (~).
* @private
*/
function _processEqualSpacer(context, stackView) {
// Determine unique name for the spacer
context.equalSpacerIndex = context.equalSpacerIndex || 1;
const name = '_~' + context.lineIndex + ':' + context.equalSpacerIndex + '~';
if (context.equalSpacerIndex > 1) {
// Ensure that all spacers have the same width/height
context.constraints.push({
view1: '_~' + context.lineIndex + ':1~',
attr1: context.horizontal ? Attribute.WIDTH : Attribute.HEIGHT,
relation: context.relation.relation || Relation.EQU,
view2: name,
attr2: context.horizontal ? Attribute.WIDTH : Attribute.HEIGHT,
priority: context.relation.priority
});
}
context.equalSpacerIndex++;
// Enforce view/proportional width/height
if (context.relation.view || (context.relation.multiplier && (context.relation.multiplier !== 1))) {
context.constraints.push({
view1: name,
attr1: context.horizontal ? Attribute.WIDTH : Attribute.HEIGHT,
relation: context.relation.relation || Relation.EQU,
view2: context.relation.view,
attr2: context.horizontal ? Attribute.WIDTH : Attribute.HEIGHT,
priority: context.relation.priority,
multiplier: context.relation.multiplier
});
context.relation.multiplier = undefined;
}
else if (context.relation.constant) {
context.constraints.push({
view1: name,
attr1: context.horizontal ? Attribute.WIDTH : Attribute.HEIGHT,
relation: Relation.EQU,
view2: null,
attr2: Attribute.CONST,
priority: context.relation.priority,
constant: context.relation.constant
});
context.relation.constant = undefined;
}
// Add constraint
for (var i = 0; i < context.prevViews.length; i++) {
const prevView = context.prevViews[i];
switch (context.orientation) {
case Orientation.HORIZONTAL:
context.prevAttr = (prevView !== stackView) ? Attribute.RIGHT : Attribute.LEFT;
context.curAttr = Attribute.LEFT;
break;
case Orientation.VERTICAL:
context.prevAttr = (prevView !== stackView) ? Attribute.BOTTOM : Attribute.TOP;
context.curAttr = Attribute.TOP;
break;
case Orientation.ZINDEX:
context.prevAttr = Attribute.ZINDEX;
context.curAttr = Attribute.ZINDEX;
context.relation.constant = (prevView !== stackView) ? 'default' : 0;
break;
}
context.constraints.push({
view1: prevView,
attr1: context.prevAttr,
relation: context.relation.relation,
view2: name,
attr2: context.curAttr,
priority: context.relation.priority
});
}
context.prevViews = [name];
}
/**
* Helper function that inserts proportional spacers (-12%-).
* @private
*/
function _processProportionalSpacer(context, stackView) {
context.proportionalSpacerIndex = context.proportionalSpacerIndex || 1;
const name = '_-' + context.lineIndex + ':' + context.proportionalSpacerIndex + '-';
context.proportionalSpacerIndex++;
context.constraints.push({
view1: name,
attr1: context.horizontal ? Attribute.WIDTH : Attribute.HEIGHT,
relation: context.relation.relation || Relation.EQU,
view2: context.relation.view, // or relative to the stackView... food for thought
attr2: context.horizontal ? Attribute.WIDTH : Attribute.HEIGHT,
priority: context.relation.priority,
multiplier: context.relation.multiplier
});
context.relation.multiplier = undefined;
// Add constraint
for (var i = 0; i < context.prevViews.length; i++) {
const prevView = context.prevViews[i];
switch (context.orientation) {
case Orientation.HORIZONTAL:
context.prevAttr = (prevView !== stackView) ? Attribute.RIGHT : Attribute.LEFT;
context.curAttr = Attribute.LEFT;
break;
case Orientation.VERTICAL:
context.prevAttr = (prevView !== stackView) ? Attribute.BOTTOM : Attribute.TOP;
context.curAttr = Attribute.TOP;
break;
case Orientation.ZINDEX:
context.prevAttr = Attribute.ZINDEX;
context.curAttr = Attribute.ZINDEX;
context.relation.constant = (prevView !== stackView) ? 'default' : 0;
break;
}
context.constraints.push({
view1: prevView,
attr1: context.prevAttr,
relation: context.relation.relation,
view2: name,
attr2: context.curAttr,
priority: context.relation.priority
});
}
context.prevViews = [name];
}
/**
* In case of a stack-view, set constraints for opposite orientations
* @private
*/
function _processStackView(context, name, subView) {
let viewName;
for (var orientation = 1; orientation <= 4; orientation *= 2) {
if ((subView.orientations & orientation) &&
(subView.stack.orientation !== orientation) &&
!(subView.stack.processedOrientations & orientation)) {
subView.stack.processedOrientations = subView.stack.processedOrientations | orientation;
viewName = viewName || {
name: name,
type: 'stack'
};
for (var i = 0, j = subView.stack.subViews.length; i < j; i++) {
if (orientation === Orientation.ZINDEX) {
context.constraints.push({
view1: viewName,
attr1: Attribute.ZINDEX,
relation: Relation.EQU,
view2: subView.stack.subViews[i],
attr2: Attribute.ZINDEX
});
}
else {
context.constraints.push({
view1: viewName,
attr1: (orientation === Orientation.VERTICAL) ? Attribute.HEIGHT : Attribute.WIDTH,
relation: Relation.EQU,
view2: subView.stack.subViews[i],
attr2: (orientation === Orientation.VERTICAL) ? Attribute.HEIGHT : Attribute.WIDTH
});
context.constraints.push({
view1: viewName,
attr1: (orientation === Orientation.VERTICAL) ? Attribute.TOP : Attribute.LEFT,
relation: Relation.EQU,
view2: subView.stack.subViews[i],
attr2: (orientation === Orientation.VERTICAL) ? Attribute.TOP : Attribute.LEFT
});
}
}
}
}
}
/**
* Recursive helper function converts a view-name and a range to a series
* of view-names (e.g. [child1, child2, child3, ...]).
* @private
*/
function _getRange(name, range) {
if (range === true) {
range = name.match(/\.\.\d+$/);
if (range) {
name = name.substring(0, name.length - range[0].length);
range = parseInt(range[0].substring(2));
}
}
if (!range) {
return [name];
}
var start = name.match(/\d+$/);
var res = [];
var i;
if (start) {
name = name.substring(0, name.length - start[0].length);
for (i = parseInt(start); i <= range; i++) {
res.push(name + i);
}
}
else {
res.push(name);
for (i = 2; i <= range; i++) {
res.push(name + i);
}
}
return res;
}
/**
* Recursive helper function that processes the cascaded data.
* @private
*/
function _processCascade(context, cascade, parentItem) {
const stackView = parentItem ? parentItem.view : null;
const subViews = [];
let curViews = [];
let subView;
if (stackView) {
cascade.push({view: stackView});
curViews.push(stackView);
}
for (var i = 0; i < cascade.length; i++) {
let item = cascade[i];
if ((!Array.isArray(item) && item.hasOwnProperty('view')) ||
(Array.isArray(item) && item[0].view && !item[0].relation)) {
const items = Array.isArray(item) ? item : [item];
for (var z = 0; z < items.length; z++) {
item = items[z];
const viewRange = (item === ',') ? [] : item.view ? _getRange(item.view, item.range) : [null];
for (var r = 0; r < viewRange.length; r++) {
const curView = viewRange[r];
curViews.push(curView);
//
// Add this view to the collection of subViews
//
if (curView !== stackView) {
subViews.push(curView);
subView = context.subViews[curView];
if (!subView) {
subView = {orientations: 0};
context.subViews[curView] = subView;
}
subView.orientations = subView.orientations | context.orientation;
if (subView.stack) {
_processStackView(context, curView, subView);
}
}
//
// Process the relationship between this and the previous views
//
if ((context.prevViews !== undefined) && (curView !== undefined) && context.relation) {
if (context.relation.relation !== 'none') {
for (var p = 0; p < context.prevViews.length; p++) {
const prevView = context.prevViews[p];
switch (context.orientation) {
case Orientation.HORIZONTAL:
context.prevAttr = (prevView !== stackView) ? Attribute.RIGHT : Attribute.LEFT;
context.curAttr = (curView !== stackView) ? Attribute.LEFT : Attribute.RIGHT;
break;
case Orientation.VERTICAL:
context.prevAttr = (prevView !== stackView) ? Attribute.BOTTOM : Attribute.TOP;
context.curAttr = (curView !== stackView) ? Attribute.TOP : Attribute.BOTTOM;
break;
case Orientation.ZINDEX:
context.prevAttr = Attribute.ZINDEX;
context.curAttr = Attribute.ZINDEX;
context.relation.constant = !prevView ? 0 : (context.relation.constant || 'default');
break;
}
context.constraints.push({
view1: prevView,
attr1: context.prevAttr,
relation: context.relation.relation,
view2: curView,
attr2: context.curAttr,
multiplier: context.relation.multiplier,
constant: ((context.relation.constant === 'default') || !context.relation.constant) ? context.relation.constant : -context.relation.constant,
priority: context.relation.priority
});
}
}
}
//
// Process view size constraints
//
const constraints = item.constraints;
if (constraints) {
for (var n = 0; n < constraints.length; n++) {
context.prevAttr = context.horizontal ? Attribute.WIDTH : Attribute.HEIGHT;
context.curAttr = (constraints[n].view || constraints[n].multiplier) ?
(constraints[n].attribute || context.prevAttr) :
(constraints[n].variable ? Attribute.VARIABLE : Attribute.CONST);
context.constraints.push({
view1: curView,
attr1: context.prevAttr,
relation: constraints[n].relation,
view2: constraints[n].view,
attr2: context.curAttr,
multiplier: constraints[n].multiplier,
constant: constraints[n].constant,
priority: constraints[n].priority
});
}
}
//
// Process cascaded data (child stack-views)
//
if (item.cascade) {
_processCascade(context, item.cascade, item);
}
}
}
}
else if (item !== ',') {
context.prevViews = curViews;
curViews = [];
context.relation = item[0];
if (context.prevViews !== undefined) {
if (context.relation.equalSpacing) {
_processEqualSpacer(context, stackView);
}
if (context.relation.multiplier) {
_processProportionalSpacer(context, stackView);
}
}
}
}
if (stackView) {
subView = context.subViews[stackView];
if (!subView) {
subView = {orientations: context.orientation};
context.subViews[stackView] = subView;
}
else if (subView.stack) {
const err = new Error('A stack named "' + stackView + '" has already been created');
err.column = parentItem.$parserOffset + 1;
throw err;
}
subView.stack = {
orientation: context.orientation,
processedOrientations: context.orientation,
subViews: subViews
};
_processStackView(context, stackView, subView);
}
}
const metaInfoCategories = [
'viewport',
'spacing',
'colors',
'shapes',
'widths',
'heights'
];
/**
* VisualFormat
*
* @namespace VisualFormat
*/
class VisualFormat {
/**
* Parses a single line of vfl into an array of constraint definitions.
*
* When the visual-format could not be succesfully parsed an exception is thrown containing
* additional info about the parse error and column position.
*
* @param {String} visualFormat Visual format string (cannot contain line-endings!).
* @param {Object} [options] Configuration options.
* @param {Boolean} [options.extended] When set to true uses the extended syntax (default: false).
* @param {String} [options.outFormat] Output format (`constraints` or `raw`) (default: `constraints`).
* @param {Number} [options.lineIndex] Line-index used when auto generating equal-spacing constraints.
* @return {Array} Array of constraint definitions.
*/
static parseLine(visualFormat, options) {
if ((visualFormat.length === 0) ||
(options && options.extended && (visualFormat.indexOf('//') === 0))) {
return [];
}
const res = (options && options.extended) ? parserExt.parse(visualFormat) : parser.parse(visualFormat);
if (options && options.outFormat === 'raw') {
return [res];
}
let context = {
constraints: [],
lineIndex: (options ? options.lineIndex : undefined) || 1,
subViews: (options ? options.subViews : undefined) || {}
};
if (res.type === 'attribute') {
for (let n = 0; n < res.attributes.length; n++) {
const attr = res.attributes[n];
for (let m = 0; m < attr.predicates.length; m++) {
const predicate = attr.predicates[m];
context.constraints.push({
view1: res.view,
attr1: attr.attr,
relation: predicate.relation,
view2: predicate.view,
attr2: predicate.attribute || attr.attr,
multiplier: predicate.multiplier,
constant: predicate.constant,
priority: predicate.priority
});
}
}
} else {
switch (res.orientation) {
case 'horizontal':
context.orientation = Orientation.HORIZONTAL;
context.horizontal = true;
_processCascade(context, res.cascade, null);
break;
case 'vertical':
context.orientation = Orientation.VERTICAL;
_processCascade(context, res.cascade, null);
break;
case 'horzvert':
context.orientation = Orientation.HORIZONTAL;
context.horizontal = true;
_processCascade(context, res.cascade, null);
context = {
constraints: context.constraints,
lineIndex: context.lineIndex,
subViews: context.subViews,
orientation: Orientation.VERTICAL
};
_processCascade(context, res.cascade, null);
break;
case 'zIndex':
context.orientation = Orientation.ZINDEX;
_processCascade(context, res.cascade, null);
break;
}
}
return context.constraints;
}
/**
* Parses one or more visual format strings into an array of constraint definitions.
*
* When the visual-format could not be succesfully parsed an exception is thrown containing
* additional info about the parse error and column position.
*
* @param {String|Array} visualFormat One or more visual format strings.
* @param {Object} [options] Configuration options.
* @param {Boolean} [options.extended] When set to true uses the extended syntax (default: false).
* @param {Boolean} [options.strict] When set to false trims any leading/trailing spaces and ignores empty lines (default: true).
* @param {String} [options.lineSeparator] String that defines the end of a line (default `\n`).
* @param {String} [options.outFormat] Output format (`constraints` or `raw`) (default: `constraints`).
* @return {Array} Array of constraint definitions.
*/
static parse(visualFormat, options) {
const lineSeparator = (options && options.lineSeparator) ? options.lineSeparator : '\n';
if (!Array.isArray(visualFormat) && (visualFormat.indexOf(lineSeparator) < 0)) {
try {
return this.parseLine(visualFormat, options);
}
catch (err) {
err.source = visualFormat;
throw err;
}
}
// Decompose visual-format into an array of strings, and within those strings
// search for line-endings, and treat each line as a seperate visual-format.
visualFormat = Array.isArray(visualFormat) ? visualFormat : [visualFormat];
let lines;
let constraints = [];
let lineIndex = 0;
let line;
const parseOptions = {
lineIndex: lineIndex,
extended: (options && options.extended),
strict: (options && (options.strict !== undefined)) ? options.strict : true,
outFormat: options ? options.outFormat : undefined,
subViews: {}
};
try {
for (var i = 0; i < visualFormat.length; i++) {
lines = visualFormat[i].split(lineSeparator);
for (var j = 0; j < lines.length; j++) {
line = lines[j];
lineIndex++;
parseOptions.lineIndex = lineIndex;
if (!parseOptions.strict) {
line = line.trim();
}
if (parseOptions.strict || line.length) {
constraints = constraints.concat(this.parseLine(line, parseOptions));
}
}
}
}
catch (err) {
err.source = line;
err.line = lineIndex;
throw err;
}
return constraints;
}
/**
* Parses meta information from the comments in the VFL.
*
* Additional meta information can be specified in the comments
* for previewing and rendering purposes. For instance, the view-port
* aspect-ratio, sub-view widths and colors, can be specified. The
* following example renders three colored circles in the visual-format editor:
*
* ```vfl
* //viewport aspect-ratio:3/1 max-height:300
* //colors red:#FF0000 green:#00FF00 blue:#0000FF
* //shapes red:circle green:circle blue:circle
* H:|-[row:[red(green,blue)]-[green]-[blue]]-|
* V:|[row]|
* ```
*
* Supported categories and properties:
*
* |Category|Property|Example|
* |--------|--------|-------|
* |`viewport`|`aspect-ratio:{width}/{height}`|`//viewport aspect-ratio:16/9`|
* ||`width:[{number}/intrinsic]`|`//viewport width:10`|
* ||`height:[{number}/intrinsic]`|`//viewport height:intrinsic`|
* ||`min-width:{number}`|
* ||`max-width:{number}`|
* ||`min-height:{number}`|
* ||`max-height:{number}`|
* |`spacing`|`[{number}/array]`|`//spacing:8` or `//spacing:[10, 20, 5]`|
* |`widths`|`{view-name}:[{number}/intrinsic]`|`//widths subview1:100`|
* |`heights`|`{view-name}:[{number}/intrinsic]`|`//heights subview1:intrinsic`|
* |`colors`|`{view-name}:{color}`|`//colors redview:#FF0000 blueview:#00FF00`|
* |`shapes`|`{view-name}:[circle/square]`|`//shapes avatar:circle`|
*
* @param {String|Array} visualFormat One or more visual format strings.
* @param {Object} [options] Configuration options.
* @param {String} [options.lineSeparator] String that defines the end of a line (default `\n`).
* @param {String} [options.prefix] When specified, also processes the categories using that prefix (e.g. "-dev-viewport max-height:10").
* @return {Object} meta-info
*/
static parseMetaInfo(visualFormat, options) {
const lineSeparator = (options && options.lineSeparator) ? options.lineSeparator : '\n';
const prefix = options ? options.prefix : undefined;
visualFormat = Array.isArray(visualFormat) ? visualFormat : [visualFormat];
const metaInfo = {};
var key;
for (var k = 0; k < visualFormat.length; k++) {
const lines = visualFormat[k].split(lineSeparator);
for (var i = 0; i < lines.length; i++) {
const line = lines[i];
for (var c = 0; c < metaInfoCategories.length; c++) {
for (var s = 0; s < (prefix ? 2 : 1); s++) {
const category = metaInfoCategories[c];
const prefixedCategory = ((s === 0) ? '' : prefix) + category;
if (line.indexOf('//' + prefixedCategory + ' ') === 0) {
const items = line.substring(3 + prefixedCategory.length).split(' ');
for (var j = 0; j < items.length; j++) {
metaInfo[category] = metaInfo[category] || {};
const item = items[j].split(':');
const names = _getRange(item[0], true);
for (var r = 0; r < names.length; r++) {
metaInfo[category][names[r]] = (item.length > 1) ? item[1] : '';
}
}
}
else if (line.indexOf('//' + prefixedCategory + ':') === 0) {
metaInfo[category] = line.substring(3 + prefixedCategory.length);
}
}
}
}
}
if (metaInfo.viewport) {
const viewport = metaInfo.viewport;
var aspectRatio = viewport['aspect-ratio'];
if (aspectRatio) {
aspectRatio = aspectRatio.split('/');
viewport['aspect-ratio'] = parseInt(aspectRatio[0]) / parseInt(aspectRatio[1]);
}
if (viewport.height !== undefined) {
viewport.height = (viewport.height === 'intrinsic') ? true : parseInt(viewport.height);
}
if (viewport.width !== undefined) {
viewport.width = (viewport.width === 'intrinsic') ? true : parseInt(viewport.width);
}
if (viewport['max-height'] !== undefined) {
viewport['max-height'] = parseInt(viewport['max-height']);
}
if (viewport['max-width'] !== undefined) {
viewport['max-width'] = parseInt(viewport['max-width']);
}
if (viewport['min-height'] !== undefined) {
viewport['min-height'] = parseInt(viewport['min-height']);
}
if (viewport['min-width'] !== undefined) {
viewport['min-width'] = parseInt(viewport['min-width']);
}
}
if (metaInfo.widths) {
for (key in metaInfo.widths) {
const width = (metaInfo.widths[key] === 'intrinsic') ? true : parseInt(metaInfo.widths[key]);
metaInfo.widths[key] = width;
if ((width === undefined) || isNaN(width)) {
delete metaInfo.widths[key];
}
}
}
if (metaInfo.heights) {
for (key in metaInfo.heights) {
const height = (metaInfo.heights[key] === 'intrinsic') ? true : parseInt(metaInfo.heights[key]);
metaInfo.heights[key] = height;
if ((height === undefined) || isNaN(height)) {
delete metaInfo.heights[key];
}
}
}
if (metaInfo.spacing) {
const value = JSON.parse(metaInfo.spacing);
metaInfo.spacing = value;
if (Array.isArray(value)){
for (var sIdx = 0, len = value.length; sIdx < len; sIdx++) {
if (isNaN(value[sIdx])){
delete metaInfo.spacing;
break;
}
}
} else if (value === undefined || isNaN(value)){
delete metaInfo.spacing;
}
}
return metaInfo;
}
}
export default VisualFormat;