Google slides - AUTOFIT_TEXT alternative - calculate based on dimensions of element - node.js

The google slides API documentation says on shapes/text elements it can accept "AUTOFIT_TEXT" as an autofit type, however this simply doesn't work and the API throws an error saying "autofitType can only be set to NONE", see here
My alternate pathway solution here is to attempt to come up with a method that can recalculate or estimate the font-size needed for the width of the element in question.
How would I go about doing this?
The font size could be anything it doesn't matter, I just need the text I'm replacing in the element to NOT wrap.
Simply just duplicating the slide with the API resets all the auto fit options already set in the google slide template.
Let's say the string length is "Hi-dilly-ho, neighborinos!", which has a length of 26, and let's say the width of the element is say 200PX. I'd need to come up with a method to fit the text on one line!
There could simply be a magic number I come up with here and multiply that by the length of the string which may work, but i was wondering if there's a nicer way with nodejs that anyone can think of

As i couldn't find a good example of this anywhere, i wrote a method to re-calculate the font-size based on the dimensions of the input element from the Google Presentation.
This workaround calculates the size of the element (width and height) and using node canvas, i created a text element and measure the size of the text which works for multiline text as well.
This is NOT as smart as the default autofit inside gslides, as it bases the amount of chars that can fit of the W key as it's the widest key usually in font faces.
There's a few helpers i've created here which are used by the main function:
// An English Metric Unit (EMU) is defined as 1/360,000 of a centimeter and thus there are 914,400 EMUs per inch, and 12,700 EMUs per point.
export const convertEMUToPT = (emu: number): number => emu / 12700;
// convert pixles to PT, there is 0.75pt to a px
export const covertPXtoPT = (px: number): number => px * 0.75;
// convert PT to PX, there is 0.75pt to a px
export const convertPTtoPX = (px: number): number => px / 0.75;
// this is a very simple example of what i have, obviously you'll need error handling if those values don't exist
// The below will return the dimensions in EMU, to convert to PT divide the EMU value by `12700`
export function getElementSize(element: slides_v1.Schema$PageElement) {
const width = element?.size?.width?.magnitude * element.transform?.scaleX;
const height = element?.size?.height?.magnitude * element.transform?.scaleY;
return { width, height };
}
/**
* #name findByKey
* #description This was introduced as the fontWeight key for example could be on a mixture of elements, and we
* want to find them whereever they may be on the element so we can average out the values
* #function
* #param obj - any object to search
* #param kee - representing the needle to search
* #returns any - returns the value by the key if found
*/
export const findByKey = (obj: any, kee: string): any | undefined => {
if (kee in obj) {
return obj[kee];
}
for (const n of Object.values(obj).filter(Boolean).filter(v => typeof v === 'object')) {
const found = findByKey(n, kee);
if (typeof found !== 'undefined') {
return found;
}
}
};
/**
* #name splitter
* #description Based on the maximum allowed characters on a single line, we split the lines
* based on this value so we can calculate multi line text wrapping and adjust the font size
* continually within a while loop
* #function
* #param str - the input string
* #param l - the length of each "line" of text
* #returns string[] - an array of strings representing each new line of text
*/
export function splitter(str: string, l: number): string[] {
const strs = [];
while (str.length > l) {
let pos = str.substring(0, l).lastIndexOf(' ');
pos = pos <= 0 ? l : pos;
strs.push(str.substring(0, pos));
let i = str.indexOf(' ', pos) + 1;
if (i < pos || i > pos + l)
i = pos;
str = str.substring(i);
}
strs.push(str);
return strs;
}
import { createCanvas } from 'canvas';
export function calculateFontSize(element: slides_v1.Schema$PageElement, text: string): number {
// get the dimensions of the element
const size = getElementSize(element, false);
// create a canvas with the same size as the element, this most likely does not matter as we're only measureing a fake
// representation of the text with ctx.measureText
const canvas = createCanvas(convertPTtoPX(size.width), convertPTtoPX(size.height));
const ctx = canvas.getContext('2d');
// try to extract all the font-sizes
const fontSizes = element.shape?.text?.textElements?.map(textElement => textElement.textRun?.style?.fontSize?.magnitude).filter((a): a is number => isNumber(a)) ?? [];
// try to extract all the font-weights
const fontWeights = element.shape?.text?.textElements?.map(textElement => textElement.textRun?.style?.weightedFontFamily?.weight).filter((a): a is number => isNumber(a)) ?? [];
// fallback to arial if not found, if there's more than one fontFamily used in a single element, we just pick the first one, no way i can think of
// to be smart here and not really necessary to create multiple strings with different fonts and calculate those, this seems to work fine
const fontFamily = findByKey(element, 'fontFamily') ?? 'Arial';
// calulate the average as there can be different fonts with different weights within a single text element
const averageFontWeight = fontWeights.reduce((a, n) => a + n, 0) / fontWeights.length;
const averageFontSize = fontSizes.reduce((a, n) => a + n, 0) / fontSizes.length;
// if the average font-weight is not a number, usae the default
const fontWeight = isNaN(averageFontWeight) ? DEFAULT_FONT_WEIGHT : averageFontWeight;
// use the average fontSize if available, else start at an arbitrary default
let fontSize = isNaN(averageFontSize) ? DEFAULT_FONT_SIZE : averageFontSize;
// if the input value is an empty string, don't bother with any calculations
if (text.length === 0) {
return fontSize;
}
// create the initial font value, this is overwritten during the while loop
ctx.font = `${fontWeight} ${fontSize}pt ${fontFamily}`;
// max chars we will fit horizontally based on the char width of W
const getCharWidth = (): number => convertPTtoPX(size.width) / ctx.measureText('W').width;
// used for the while loop, to continually resize the shape and multiline text, until it fits within the bounds
// of the element
const isOutsideBounds = (): boolean => {
// based on the maximum amount of chars available in the horizontal axis for this font size
// we split onto a new line to get the width/height correctly
const multiLineString = splitter(text, getCharWidth()).join('\n');
// get the measurements of the current multiline string
const metrics = ctx.measureText(multiLineString);
// get the width in PT
const width = covertPXtoPT(metrics.width);
// the emAcent/Decent values do exist, it's the types that are wrong from canvas
// #ts-expect-error
const emAcent = metrics.emHeightAscent as number;
// #ts-expect-error
const emDecent = metrics.emHeightDescent as number;
const height = covertPXtoPT(emAcent + emDecent);
return width > size.width || height > size.height;
};
// continually loop over until the size of the text element is less than the intiial size of the element in gslides
while (isOutsideBounds()) {
// decrease by 0.1 incrememnts until it fits within the width
fontSize = fontSize - 0.1;
// update the ctx with the new font style (shrinking the font size)
ctx.font = `${fontWeight} ${fontSize}pt ${fontFamily}`;
}
// returns the font size
return fontSize;
}
Then to use for a request to update the text after the text is inserted/replaced:
{
updateTextStyle: {
objectId: pageElement.objectId,
fields: 'fontSize',
style: {
fontSize: {
magnitude: calculateFontSize(pageElement, STRING_VALUE_HERE),
unit: 'PT'
}
}
}
}

Related

Where should I use ParseFloat or Number functions in my code to change my geojson data?

Editing for specificity
The data in my geojson is formatted as string variables and I need to change those fields to numbers so my legend on my choropleth will read the data. I'm struggling to figure out where to use either the ParseFloat or Number functions so my leaflet map reads the data as a number.
I'm attempting to map each feature using different buttons. One button to show Population, another to show Average Water Bill, etc. I want the legend to show the data called on for each button. Below is the code I have for the legend and for the buttons...
// Global variables
let map;
let lat = 34;
let lon = -118;
let zl = 9;
let geojsonPath = 'https://raw.githubusercontent.com/LCIWaterProjects/DRAFT-LA-County-Governance-Map/main/data/Data_Update.geojson';
let geojson_data;
let geojson_layer;
let brew = new classyBrew();
let legend = L.control({position: 'bottomright'});
let info_panel = L.control();
let fieldtomap;
// initialize+
$( document ).ready(function() {
createMap(lat,lon,zl);
getGeoJSON();
});
// create the map
function createMap(lat,lon,zl){
map = L.map('map').setView([lat,lon], zl);
L.tileLayer('https://api.mapbox.com/styles/v1/mapbox/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}',
{
attribution: 'Map data © OpenStreetMap contributors, Imagery © Mapbox',
maxZoom: 18,
id: 'light-v10',
tileSize: 512,
zoomOffset: -1,
accessToken: 'pk.eyJ1Ijoic2FyYWhwZXJlejEiLCJhIjoiY2t0MG9hZDNnMDZ2NDJ1c2M5dzBmb201OSJ9.5fv8NqX5cfA0NMcmEW_63g'
}).addTo(map);
}
// function to get the geojson data
$.getJSON(geojsonPath,function(data){
console.log(data)
// put the data in a global variable
geojson_data = data;
// call the map function
mapGeoJSON();
})
function mapGeoJSON(field,num_classes,color,scheme){
// clear layers in case it has been mapped already
if (geojson_layer){
geojson_layer.clearLayers()
}
// globalize the field to map
fieldtomap = field;
...
function createLegend(){
legend.onAdd = function (map) {
var div = L.DomUtil.create('div', 'info legend'),
breaks = brew.getBreaks(),
labels = [],
from, to;
for (var i = 0; i < breaks.length; i++) {
from = breaks[i];
to = breaks[i + 1];
if(to) {
labels.push(
'<i style="background:' + brew.getColorInRange(to) + '"></i> ' +
from.toFixed(0) + ' – ' + to.toFixed(0));
}
}
div.innerHTML = labels.join('<br>');
return div;
};
legend.addTo(map);
// create buttons function
function myPopFunction(){
mapGeoJSON('Population',5,'YlOrRd','jenks');}
function myServeFunction(){
mapGeoJSON('Service_Co',5,'BuPu','quantiles');}

Convert Milliseconds to Duration to String?

I have a min and max parameter which each require a double.
min: 0,
max: _duration!.inMilliseconds
These parameters are then converted to strings for use as dynamic text on top of the thumb ball of a slider.
But for the max value I'd like to use the actual duration instead;
IE: 1:34:30 instead of 94,500.
The final product required is a String.
So how do I get the Duration to a string while preserving the Duration formatting?
Keep in mind it is passed initially as a double.
Here's the full class for the thumb ball;
class CustomSliderThumbCircle extends SliderComponentShape {
final double thumbRadius;
final int min;
final int max;
const CustomSliderThumbCircle({
required this.thumbRadius,
this.min = 0,
this.max = 10,
});
#override
Size getPreferredSize(bool isEnabled, bool isDiscrete) {
return Size.fromRadius(thumbRadius);
}
#override
void paint(
PaintingContext context,
Offset center, {
Animation<double>? activationAnimation,
Animation<double>? enableAnimation,
bool? isDiscrete,
TextPainter? labelPainter,
RenderBox? parentBox,
SliderThemeData? sliderTheme,
TextDirection? textDirection,
double? value,
double? textScaleFactor,
Size? sizeWithOverflow,
}) {
final Canvas canvas = context.canvas;
final paint = Paint()
..color = Colors.white //Thumb Background Color
..style = PaintingStyle.fill;
TextSpan span = new TextSpan(
style: new TextStyle(
fontSize: thumbRadius,
//fontWeight: FontWeight.w700,
color: Colors.white, //Text Color of Value on Thumb
),
text: getValue(value!),
);
TextPainter tp = new TextPainter(
text: span,
textAlign: TextAlign.center,
textDirection: TextDirection.ltr);
tp.layout();
Offset textCenter =
Offset(center.dx - (tp.width / 2), center.dy - (tp.height / 2));
final rect = Rect.fromCircle(center: center, radius: thumbRadius);
final rrect = RRect.fromRectAndRadius(
Rect.fromPoints(
Offset(rect.left - 10, rect.top),
Offset(rect.right + 10, rect.bottom),
),
Radius.elliptical(20, 100),
);
final fillPaint = Paint()
..color = sliderTheme!.activeTrackColor!
..style = PaintingStyle.fill;
final borderPaint = Paint()
..color = Colors.black
..strokeWidth = 2.8
..style = PaintingStyle.stroke;
canvas.drawRRect(rrect, fillPaint);
canvas.drawRRect(rrect, borderPaint);
tp.paint(canvas, textCenter);
}
String getValue(double value) {
return (min + (max - min) * value).round().toString();
}
}
If CustomSliderThumbCircle is your own class, I would change it to take a callback function to format the value to a String (falling back to just .toString() if no callback is specified), something like:
class CustomSliderThumbCircle extends SliderComponentShape {
final String Function(int value)? formatValue;
...
const CustomSliderThumbCircle({
required this.thumbRadius,
this.min = 0,
this.max = 10,
this.formatValue,
});
String getValue(double value) {
var clamped = (min + (max - min) * value).round();
return (formatValue == null) ? clamped.toString() : formatValue!(clamped);
}
}
and then your caller could pass a callback:
CustomSliderThumbCircle(
thumbRadius: ...,
...,
formatValue: (value) => Duration(milliseconds: value).toString(),
)
This also would allow you to use a function that formats Durations as, say, 1m34.5s instead of as 0:01:34.500000. I generally dislike representing durations with colons because it can be ambiguous if fractional seconds are omitted. For example, using a different function to format Durations:
CustomSliderThumbCircle(
thumbRadius: ...,
...,
formatValue: (value) => prettyDuration(Duration(milliseconds: value)),
)
If CustomSliderThumbCircle is not under your control, then you could extend it with your own class that overrides getValue.
I doubt this is better than jamesdlin's solution but I don't know how to format it the way I'd like using the call back. Plus I realised I only need seconds and milliseconds as the audio is limited to 60 seconds anyway. So here's what I did;
String getValue(double value) {
double seconds = value * (max/1000);
String secstring = secs.truncate().toString();
var milliseconds = value * (max.remainder(1000));
String millistring = milliseconds.truncate().toString();
String stringDuration = secstring + ':' + millistring;
return stringDuration ;
}

Maintaining object size AND position while zooming in fabric js

I was trying to maintain the object size while zooming, i tried to get inspired by this answer in which the guy who wrote it didn't solve the controls issue in such as case, as a consequence you can see them not sticking to the object while zooming as in this screenshot.
But i came with this solution to maintain the object position and controls by updating its left and top after calculating them based on the inverted viewportTransform by calculating a new fabric.Point using the fabric.util.transformPoint function
fabric.Object.prototype.transform = function(ctx) {
const obj = this;
const {
ignoreZoom,
group,
canvas,
left,
top
} = obj;
const {
contextTop,
viewportTransform,
getZoom,
requestRenderAll,
} = canvas;
var needFullTransform = (group && !group._transformDone) || (group && canvas && ctx === contextTop);
if (ignoreZoom) {
const oldP = new fabric.Point(left, top);
const newP = fabric.util.transformPoint(oldP, fabric.util.invertTransform(viewportTransform));
var zoom = 1 / getZoom();
/* // here i tried to refresh the whole canvas with requestRenderAll()
this.set({
left: newP.x,
top: newP.y,
scaleX: zoom,
scaleY: zoom,
});
this.setCoords();
requestRenderAll();
*/
// but here i try refresh the object only which is better i think
this.left = newP.x;
this.top = newP.y;
this.scaleX = zoom;
this.scaleY = zoom;
this.drawObject(ctx);
}
var m = this.calcTransformMatrix(!needFullTransform);
ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
}
I have made this codesandbox as a demo for my code. As you can see in this screenshot, controls stick around the object but the whole of them doesn't maintain their position relatively to the background and sometimes they disappear completely.
I need the object to keep its position relatively to the background.
How to make it better ?
// EDIT
I tried to understand better what happens while zooming, i found the fabric.Canvas.zoomToPoint() which is used for zooming (as in their tutorial)
zoomToPoint: function (point, value) {
// TODO: just change the scale, preserve other transformations
var before = point, vpt = this.viewportTransform.slice(0);
point = transformPoint(point, invertTransform(this.viewportTransform));
vpt[0] = value;
vpt[3] = value;
var after = transformPoint(point, vpt);
vpt[4] += before.x - after.x;
vpt[5] += before.y - after.y;
return this.setViewportTransform(vpt);
},
i guess the best way to fix the object position relatively to the background will be to apply the inverse transformation of the one applied to the canvas for the zoom to the object.
So i wrote this function
function getNewVpt(point, value) {
var before = point,
vpt = canvas.viewportTransform.slice(0);
point = fabric.util.transformPoint(point, fabric.util.invertTransform(canvas.viewportTransform));
vpt[0] = value;
vpt[3] = value;
var after = fabric.util.transformPoint(point, vpt);
vpt[4] += before.x - after.x;
vpt[5] += before.y - after.y;
return vpt;
}
and i used it to rewrite the fabric.Object.prototype.transform
fabric.Object.prototype.transform = function (ctx) {
const obj = this;
const { ignoreZoom, group, canvas: objCanvas, left, top } = obj;
const {
contextTop,
viewportTransform,
} = objCanvas;
var needFullTransform =
(group && !group._transformDone) ||
(group && objCanvas && ctx === contextTop);
if (ignoreZoom && zoomingIsOn) {
zoomingIsOn = false;
var zoom = 1 / objCanvas.getZoom();
const oldP = new fabric.Point(left, top);
console.log('transform : oldP : ', oldP);
const newVpt = getNewVpt(oldP, zoom)
const newP = fabric.util.transformPoint(oldP, newVpt);
console.log('transform : newP : ', newP);
// here i tried to refresh the whole canvas with requestRenderAll()
this.set({
left: newP.x,
top: newP.y,
scaleX: zoom,
scaleY: zoom
});
this.setCoords();
console.log('transform : CALLING objCanvas.requestRenderAll() ');
objCanvas.requestRenderAll();
// but here i try refresh the object only which is better i think
// this.left = newP.x;
// this.top = newP.y;
// this.scaleX = zoom;
// this.scaleY = zoom;
// this.drawObject(ctx);
}
var m = this.calcTransformMatrix(!needFullTransform);
ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
};
And here i forked this new codesandbox for this second solution , the result seems to be better than the former solution but it still not perfect. What i may still be doing wrong ?!
// EDIT 2
I tried to pass objCanvas.getZoom() instead of zoom as second parameter to the getNewVpt() function. It seems there is some more improovement but still not perfect again
// Edit 3
In This codesandbox probably i got the best result i could get using another function which returns directly the new point:
function getNewPt(point, value) {
// TODO: just change the scale, preserve other transformations
var vpt = canvas.viewportTransform.slice(0);
point = fabric.util.transformPoint(point, fabric.util.invertTransform(canvas.viewportTransform));
vpt[0] = value;
vpt[3] = value;
return fabric.util.transformPoint(point, vpt);;
}
I still wish anybody who can tell me if there is a way to improove it more. As you can see the triangle returns back to its initial position after zooming/ dezooming and getting back to the same initial zoom value which is good but between those initial and final states , it still seems not to be in the right spot..
You just have to call zoomToPoint where it zooms and the objects will keep their position and scale relative to the background.
Try the following
canvas.on('mouse:wheel', function(opt) {
// console.log(opt.e.deltaY)
let zoomLevel = canvas.getZoom();
// console.log('zoom Level: ', (zoomLevel * 100).toFixed(0), '%');
zoomLevel += opt.e.deltaY * -0.01;
// Restrict scale
zoomLevel = Math.min(Math.max(.125, zoomLevel), 20);
canvas.zoomToPoint(
new fabric.Point(opt.e.offsetX, opt.e.offsetY),
zoomLevel,
);
canvas.renderAll();
})

Fixing data that contains numeric overflow

I have been given a dataset that I have identified contains records with a numeric overflow eg -214012688. Is there a way for me to fix this?
Here's a sample of data I have
2142936357
2143000225
2142936544
2142974737
2142935734
2143051458
-2143303677
-2142887448
2142866111
2142864212
2143020049
2143009445
2143300604
2143121125
-2142802104
2142801451
The dataset is not sorted so the numbers aren't in order. when I asked my client why there's negative numbers its because it went over the max integer amount.
If any of your values have become positive after rolling over, there is no way to fix it, but if that's not the case, you can write a custom function to get the absolute value of the difference between each negative value and the signed minimum, then add that to the signed maximum. Here is a JavaScript function for it:
function signedRolloverToUnsigned (x, signedMin, signedMax) {
if (x < 0) {
x = Math.abs((signedMin - 1) - x) + signedMax;
}
return x;
}
And here is the custom Google sheet function you'd need if you just want to copy + paste and use it in your document:
/**
* Converts signed rollover (negative) values to their equivalent unsigned positive values
*
* #param {number} value The value to convert.
* #return The unsigned representation of the input value.
* #customfunction
*/
function signedRolloverToUnsigned (value) {
if (value < 0) {
value = Math.abs((-2147483648 - 1) - value) + 2147483647;
}
return value;
}
Here is a working example using the data you provided:
'use strict';
function signedRolloverToUnsigned (x, signedMin, signedMax) {
if (x < 0) {
x = Math.abs((signedMin - 1) - x) + signedMax;
}
return x;
}
const text = `2142936357
2143000225
2142936544
2142974737
2142935734
2143051458
-2143303677
-2142887448
2142866111
2142864212
2143020049
2143009445
2143300604
2143121125
-2142802104
2142801451`;
const arrSource = text.split('\n').map((x) => {
return parseInt(x);
});
const signedRange = [-2147483648, 2147483647];
const arrFixed = arrSource.map((x) => signedRolloverToUnsigned(x, signedRange[0], signedRange[1]));
//---Ignore: For displaying output --->
document.body.style = 'background-color: #eeeeee; padding: 1rem;';
const pre = document.body.appendChild(document.createElement('pre'));
const samp = pre.appendChild(document.createElement('samp'));
samp.textContent = arrFixed.join('\n');
const styles = {
'box-sizing': 'border-box',
'background-color': 'white',
'border-radius': '0.5rem',
'display': 'block',
'padding': '1rem',
};
for (const [prop, value] of Object.entries(styles)) {
samp.style.setProperty(prop, value);
}

how to drag/resize and rotate rectangle in paperjs?

I want to drag and resize a rectangle in paperjs, I also want to rotate the rectangle and resize it while maintaining its relative dimensions.
Ideally I'd like to do so with my mouse by dragging one of its corners (anchors). What mathematics or feature is helpful in doing this in paperjs?
I have tried this by using scaling and modifying the corners but it doesn't work as I want it to. Could someone point me to a solution?
Thanks in advance.
Here's a simple solution that should get you started. It doesn't handle rotation because I'm not sure how you envision the UI working, but by modifying the bounding box to resize the rectangle you should be able to rotate it without problems.
paperjs sketch
I decided to make up my own UI and go ahead and make the example more complicated to address as much of you question as I can without more information. Here's the new sketch:
new sketch
The UI is
click in rectangle to move it by dragging
click on a corner and drag to resize it
control-click on a corner to rotate it
It's a bit tricky to click the corners, but that's an exercise left to the reader. They are colored circles just to emphasize where each segment point of the Path is located.
Key points of the code:
Use the rectangle's bounds to scale. Path.Rectangle is not a rectangle as far as paper is concerned. It is four curves (which happen to be straight) connecting four segment points. When you need to work with a rectangle to get its center, top left, etc., you need a Rectangle. Scale the visible rectangle by using the rectangle's bounds (Path.Rectangle.bounds). The code illustrates the bounds with an additional aqua rectangle so it's visible (it's easiest to see when rotating).
onMouseDown() sets the state for onMouseDrag() and sets up data needed for each state, e.g., saving the scale base for resizing.
onMouseDrag() implements moving, resizing, and rotating.
tool.onMouseDrag = function(e) {
if (rect.data.state === 'moving') {
rect.position = rect.position + e.point - e.lastPoint;
adjustRect(rect);
} else if (rect.data.state === 'resizing') {
// scale by distance from down point
var bounds = rect.data.bounds;
var scale = e.point.subtract(bounds.center).length /
rect.data.scaleBase.length;
var tlVec = bounds.topLeft.subtract(bounds.center).multiply(scale);
var brVec = bounds.bottomRight.subtract(bounds.center).multiply(scale);
var newBounds = new Rectangle(tlVec + bounds.center, brVec + bounds.center);
rect.bounds = newBounds;
adjustRect(rect);
} else if (rect.data.state === 'rotating') {
// rotate by difference of angles, relative to center, of
// the last two points.
var center = rect.bounds.center;
var baseVec = center - e.lastPoint;
var nowVec = center - e.point;
var angle = nowVec.angle - baseVec.angle;
rect.rotate(angle);
adjustRect(rect);
}
}
Moving is pretty easy - just calculate the difference between the current and last points from the event and change the position of the rectangle by that much.
Resizing is not as obvious. The strategy is to adjust the x and y bounds based on the original distance (scaleBase.length) between the mousedown point and the center of the rectangle. Note that while paper-full.js allows using operators ("+", "-", "*", "/") with points, I used the raw subtract() and multiply() methods a few times - I find it natural to chain the calculations that way.
Rotating uses the very nice paper concept that a point also defines a vector and a vector has an angle. It just notes the difference in the angles between the event lastPoint and point relative to the rectangle's center and rotates the rectangle by that difference.
moveCircles() and adjustRect() are just bookkeeping functions to update the corner circles and aqua rectangle.
Consider the following. I just went through the process of figuring this out, based on lots of examples.
My Goals:
use my own bounding box when selecting an item
Move, Resize, and Rotate (with snap to rotation [45 degrees]) the selected item
Show a title / name of the item
Example Sketch
Paper.js Code
var hitOptions = {
segments: true,
stroke: true,
fill: true,
tolerance: 5
};
function drawHex(w, c, n){
var h = new Path.RegularPolygon(new Point(100, 100), 6, w / 2);
h.selectedColor = 'transparent';
c = c != undefined ? c : "#e9e9ff";
n = n != undefined ? n : "Hexayurt";
h.name = n;
h.fillColor = c;
h.data.highlight = new Group({
children: [makeBounds(h), makeCorners(h), makeTitle(h)],
strokeColor: '#a2a2ff',
visible: false
});
return h;
}
function makeCorners(o, s){
s = s != undefined ? s : 5;
var g = new Group();
var corners = [
o.bounds.topLeft,
o.bounds.topRight,
o.bounds.bottomLeft,
o.bounds.bottomRight
];
corners.forEach(function(corner, i) {
var h = new Path.Rectangle({
center: corner,
size: s
});
g.addChild(h);
});
return g;
}
function makeBounds(o){
return new Path.Rectangle({
rectangle: o.bounds
});
}
function makeTitle(o, n, c){
c = c != undefined ? c : 'black';
var t = new PointText({
fillColor: c,
content: n != undefined ? n : o.name,
strokeWidth: 0
});
t.bounds.center = o.bounds.center;
return t;
}
function selectItem(o){
console.log("Select Item", o.name);
o.selected = true;
o.data.highlight.visible = true;
o.data.highlight.bringToFront();
}
function clearSelected(){
project.selectedItems.forEach(function(o, i){
console.log("Unselect Item", o.name);
o.data.highlight.visible = false;
});
project.activeLayer.selected = false;
}
function moveBoxes(o){
var boxes = o.data.highlight.children[1].children;
boxes[0].position = o.bounds.topLeft;
boxes[1].position = o.bounds.topRight;
boxes[2].position = o.bounds.bottomLeft;
boxes[3].position = o.bounds.bottomRight;
}
function moveTitle(o){
var t = o.data.highlight.children[2];
t.bounds.center = o.bounds.center;
}
function adjustBounds(o){
if(o.data.state == "moving"){
o.data.highlight.position = o.position;
} else {
o.data.highlight.children[0].bounds = o.bounds;
moveBoxes(o);
}
}
var hex1 = drawHex(200);
console.log(hex1.data, hex1.data.highlight);
var segment, path;
var movePath = false;
var tool = new Tool();
tool.minDistance = 10;
tool.onMouseDown = function(event) {
segment = path = null;
var hitResult = project.hitTest(event.point, hitOptions);
if (!hitResult){
clearSelected();
return;
}
if(hitResult && hitResult.type == "fill"){
path = hitResult.item;
}
if (hitResult && hitResult.type == "segment") {
path = project.selectedItems[0];
segment = hitResult.segment;
if(event.modifiers.control){
path.data.state = "rotating";
} else {
path.data.state = "resizing";
path.data.bounds = path.bounds.clone();
path.data.scaleBase = event.point - path.bounds.center;
}
console.log(path.data);
}
movePath = hitResult.type == 'fill';
if (movePath){
project.activeLayer.addChild(hitResult.item);
path.data.state = "moving";
selectItem(path);
console.log("Init Event", path.data.state);
}
};
tool.onMouseDrag = function(event) {
console.log(path, segment, path.data.state);
if (segment && path.data.state == "resizing") {
var bounds = path.data.bounds;
var scale = event.point.subtract(bounds.center).length / path.data.scaleBase.length;
var tlVec = bounds.topLeft.subtract(bounds.center).multiply(scale);
var brVec = bounds.bottomRight.subtract(bounds.center).multiply(scale);
var newBounds = new Rectangle(tlVec + bounds.center, brVec + bounds.center);
path.bounds = newBounds;
adjustBounds(path);
} else if(segment && path.data.state == "rotating") {
var center = path.bounds.center;
var baseVec = center - event.lastPoint;
var nowVec = center - event.point;
var angle = nowVec.angle - baseVec.angle;
if(angle < 0){
path.rotate(-45);
} else {
path.rotate(45);
}
adjustBounds(path);
} else if (path && path.data.state == "moving") {
path.position += event.delta;
adjustBounds(path);
}
};
This makes use of .data to store references of the bounding box, handles, and title as a Group. This way, they are always there, they can just visible true or false. This makes it easy to show and hide them as needed.
drawHex( width , color, name )
Width - Required, number of pixels wide
Color - Optional, string that defines the Fill Color. Default: #e9e9ff
Name - Optional, string to be used as the Name and Title. Default: "Hexayurt"
Interactions
click - Select item (show bounding box & Handles)
click + drag - Move item
click + drag handle - Resize item
Ctrl + click + drag handle - Rotate item
This is my first pass at it and I may cleanup a lot of the code. For example, I could bind events to the handles specifically instead of looking at more global events.

Resources