diff --git a/.gitignore b/.gitignore
index ca99189..327140c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ build
coverage
preview/bundle.js
svg-typewriter.js
+*.log
# IDEs
.idea/
diff --git a/circle.yml b/circle.yml
index fdda359..7725b21 100644
--- a/circle.yml
+++ b/circle.yml
@@ -13,7 +13,8 @@ deployment:
preview:
branch: /.*/
commands:
- - ./preview.sh
+ - npm run preview # build the preview bundle
+ - ./preview/demo.js # comment back on github
npm:
tag: /release-.*/
owner: palantir
diff --git a/package.json b/package.json
index 050a7ed..9f9ae50 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"test": "npm-run-all build lint:ts test:mocha test:coverage",
"test:coverage": "istanbul check-coverage",
"test:mocha": "PATH=$PATH:$(npm bin) mochify --reporter spec --plugin [ mochify-istanbul --report text --report json --dir coverage --exclude '**/test/**/*' ] ${npm_package_testsGlob}",
+ "test:local": "PATH=$PATH:$(npm bin) mochify --reporter spec ${npm_package_testsGlob}",
"test:sauce": "mochify --reporter spec --wd ${npm_package_testsGlob}",
"echo": "echo"
},
@@ -41,6 +42,7 @@
"license": "MIT",
"dependencies": {
"@types/d3": "^3.5",
+ "circle-github-bot": "^0.4.0",
"d3": "^3.5"
},
"devDependencies": {
diff --git a/preview.sh b/preview.sh
deleted file mode 100755
index 7507b4e..0000000
--- a/preview.sh
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env bash
-
-# Echos the specified package.json variable
-function npmvar {
- npm run -s echo -- '$npm_package_'$1
-}
-
-# Compute all the fancy artifact variables for preview scripts
-BUILD_PATH="/home/ubuntu/$(npmvar 'name')"
-ARTIFACTS_URL="https://circleci.com/api/v1/project/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/$CIRCLE_BUILD_NUM/artifacts/0/$BUILD_PATH"
-GH_API_URL="x-oauth-basic@api.github.com"
-PROJECT_API_BASE_URL="https://$GH_AUTH_TOKEN:$GH_API_URL/repos/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME"
-PR_NUMBER=$(basename $CI_PULL_REQUEST)
-
-# Submit the comment
-function submitPreviewComment {
- COMMENT_JSON="{\"body\": \"$1\"}"
-
- if $PR_NUMBER; then
- # post comment to PR
- curl --data "$COMMENT_JSON" $PROJECT_API_BASE_URL/issues/$PR_NUMBER/comments
- else
- # PR not created yet; CircleCI doesn't know about it.
- # post comment to commi
- curl --data "$COMMENT_JSON" $PROJECT_API_BASE_URL/commits/$CIRCLE_SHA1/comments
- fi
-}
-
-# Create a artifact link string
-function artifactLink {
- local HREF="${ARTIFACTS_URL}${1}"
- local LINK="$2"
- echo "$LINK"
-}
-
-# Build previews
-echo "Building dev preview..."
-npm run preview
-echo "Building docs preview..."
-npm run docs
-
-# Submit comment
-echo "Submitting comment..."
-COMMIT_MESSAGE=$(git --no-pager log --pretty=format:"%s" -1)
-# escape commit message, see http://stackoverflow.com/a/10053951/3124288
-COMMIT_MESSAGE=${COMMIT_MESSAGE//\"/\\\"}
-PREVIEWS="$(artifactLink '/docs/index.html' 'docs') | $(artifactLink '/preview/index.html' 'dev')"
-submitPreviewComment "
-
SVGTypewriter
-
diff --git a/preview/preview.js b/preview/preview.js
index 2d7e99b..e1fdff5 100644
--- a/preview/preview.js
+++ b/preview/preview.js
@@ -15,30 +15,88 @@ function createUpdater(selector, options) {
const wrapper = new SVGTypewriter.Wrapper();
const writer = new SVGTypewriter.Writer(measurer, wrapper);
- return function(text) {
- const rect = element.getBoundingClientRect();
+ const update = function() {
+ const rect = writeOptions.rect == null ? element.getBoundingClientRect() : writeOptions.rect;
selection.selectAll("*").remove()
- writer.write(text, rect.width, rect.height, writeOptions);
- }
+ writer.write(this.text, rect.width, rect.height, this.options);
+ };
+
+ return {
+ update,
+ text: "",
+ options: writeOptions,
+ };
}
const updatables = [
createUpdater("#svg1"),
- createUpdater("#svg2"),
- createUpdater("#svg3", {textRotation: 90}),
- createUpdater("#svg4", {textRotation: -90}),
- createUpdater("#svg5", {xAlign: "right"}),
+ createUpdater("#svg2", {textRotation: -90}),
+ createUpdater("#svg3", {xAlign: "right"}),
];
+const configurable = createUpdater("#shearPreview", {
+ textRotation: -90,
+ textShear: 0,
+ xAlign: "right",
+ rect: {
+ width: 100,
+ height: 100
+ }
+});
+updatables.push(configurable);
+// bind text area
const textArea = document.querySelector("textarea");
-
-function update() {
+function updateText() {
const text = textArea.value;
- updatables.forEach(function (updatable) {
- updatable(text);
+ updatables.forEach((u) => {
+ u.text = text;
+ u.update.apply(u);
});
};
+textArea.addEventListener("change", updateText);
+textArea.addEventListener("keyup", updateText);
+updateText();
-textArea.addEventListener("change", update);
-textArea.addEventListener("keyup", update);
-update();
\ No newline at end of file
+// bind text setters
+const textSetters = document.querySelectorAll("input[data-text]");
+Array.prototype.forEach.call(textSetters, (textSetter) => {
+ textSetter.addEventListener("click", () => {
+ textArea.value = textSetter.getAttribute("data-text");
+ updateText();
+ });
+});
+
+// bind shear slider
+const slider = document.querySelector("input#shear");
+function updateShear() {
+ const value = parseInt(slider.value);
+ configurable.options.textShear = value;
+ configurable.update.apply(configurable);
+};
+slider.addEventListener("input", updateShear);
+updateShear();
+
+// bind angles
+const rotationSetters = document.querySelectorAll("input[data-rotation]");
+Array.prototype.forEach.call(rotationSetters, (button) => {
+ button.addEventListener("click", () => {
+ configurable.options.textRotation = parseInt(button.getAttribute("data-rotation"));
+ configurable.update.apply(configurable);
+ });
+});
+
+// bind x alignment
+Array.prototype.forEach.call(document.querySelectorAll("input[data-x-alignment]"), (button) => {
+ button.addEventListener("click", () => {
+ configurable.options.xAlign = button.getAttribute("data-x-alignment");
+ configurable.update.apply(configurable);
+ });
+});
+
+// bind y alignment
+Array.prototype.forEach.call(document.querySelectorAll("input[data-y-alignment]"), (button) => {
+ button.addEventListener("click", () => {
+ configurable.options.yAlign = button.getAttribute("data-y-alignment");
+ configurable.update.apply(configurable);
+ });
+});
diff --git a/src/wrappers/wrapper.ts b/src/wrappers/wrapper.ts
index 6a1e4bb..54a3741 100644
--- a/src/wrappers/wrapper.ts
+++ b/src/wrappers/wrapper.ts
@@ -133,9 +133,7 @@ export class Wrapper {
state.wrapping.noLines += +(wrappedText !== "");
if (state.wrapping.noLines === state.availableLines && this._textTrimming !== "none" && hasNextLine) {
- const ellipsisResult = this.addEllipsis(wrappedText, state.availableWidth, measurer);
- state.wrapping.wrappedText += ellipsisResult.wrappedToken;
- state.wrapping.truncatedText += ellipsisResult.remainingToken;
+ // Note: no need to add more ellipses, they were added in `wrapNextToken`
state.canFitText = false;
} else {
state.wrapping.wrappedText += wrappedText;
@@ -162,6 +160,7 @@ export class Wrapper {
}
let truncatedLine = line.substring(0).trim();
let lineWidth = measurer.measure(truncatedLine).width;
+
const ellipsesWidth = measurer.measure("...").width;
const prefix = (line.length > 0 && line[0] === "\n") ? "\n" : "";
diff --git a/src/writers/writer.ts b/src/writers/writer.ts
index 86fd684..9def028 100644
--- a/src/writers/writer.ts
+++ b/src/writers/writer.ts
@@ -16,6 +16,7 @@ export interface IWriteOptions {
xAlign: string;
yAlign: string;
textRotation: number;
+ textShear?: number;
animator?: Animators.BaseAnimator;
}
@@ -74,95 +75,127 @@ export class Writer {
public write(text: string, width: number, height: number, options: IWriteOptions) {
if (Writer.SupportedRotation.indexOf(options.textRotation) === -1) {
- throw new Error("unsupported rotation - " + options.textRotation);
+ throw new Error("unsupported rotation - " + options.textRotation +
+ ". Supported rotations are " + Writer.SupportedRotation.join(", "));
+ }
+ if (options.textShear != null && options.textShear < -80 || options.textShear > 80) {
+ throw new Error("unsupported shear angle - " + options.textShear + ". Must be between -80 and 80");
}
const orientHorizontally = Math.abs(Math.abs(options.textRotation) - 90) > 45;
const primaryDimension = orientHorizontally ? width : height;
const secondaryDimension = orientHorizontally ? height : width;
- const textContainer = options.selection.append("g").classed("text-container", true);
- if (this._addTitleElement) {
- textContainer.append("title").text(text);
- }
-
+ // compute shear parameters
+ const shearDegrees = (options.textShear || 0);
+ const shearRadians = shearDegrees * Math.PI / 180;
+ const lineHeight = this._measurer.measure().height;
+ const shearShift = lineHeight * Math.tan(shearRadians);
+
+ // When we apply text shear, the primary axis grows and the secondary axis
+ // shrinks, due to trigonometry. The text shear feature uses the normal
+ // wrapping logic with a subsituted bounding box of the corrected size
+ // (computed below). When rendering the wrapped lines, we rotate the text
+ // container by the text rotation angle AND the shear angle then carefully
+ // offset each one so that they are still aligned to the primary alignment
+ // option.
+ const shearCorrectedPrimaryDimension = primaryDimension / Math.cos(shearRadians) - Math.abs(shearShift);
+ const shearCorrectedSecondaryDimension = secondaryDimension * Math.cos(shearRadians);
+
+ // normalize and wrap text
const normalizedText = Utils.StringMethods.combineWhitespace(text);
-
- const textArea = textContainer.append("g").classed("text-area", true);
const wrappedText = this._wrapper ?
this._wrapper.wrap(
normalizedText,
this._measurer,
- primaryDimension,
- secondaryDimension,
+ shearCorrectedPrimaryDimension,
+ shearCorrectedSecondaryDimension,
).wrappedText : normalizedText;
+ const lines = wrappedText.split("\n");
- this.writeText(
- wrappedText,
+ // prepare svg components
+ const textContainer = options.selection.append("g").classed("text-container", true);
+ if (this._addTitleElement) {
+ textContainer.append("title").text(text);
+ }
+ const textArea = textContainer.append("g").classed("text-area", true);
+ this.writeLines(
+ lines,
textArea,
- primaryDimension,
- secondaryDimension,
+ shearCorrectedPrimaryDimension,
+ shearShift,
options.xAlign,
- options.yAlign,
);
- const xForm = d3.transform("");
- const xForm2 = d3.transform("");
- xForm.rotate = options.textRotation;
+ // correct the intial x/y offset of the text container accounting shear and alignment
+ const shearCorrectedXOffset = Writer.XOffsetFactor[options.xAlign] *
+ shearCorrectedPrimaryDimension * Math.sin(shearRadians);
+ const shearCorrectedYOffset = Writer.YOffsetFactor[options.yAlign] *
+ (shearCorrectedSecondaryDimension - (lines.length) * lineHeight);
+ const shearCorrection = shearCorrectedXOffset - shearCorrectedYOffset;
+ // build and apply transform
+ const xForm = d3.transform("");
+ xForm.rotate = options.textRotation + shearDegrees;
switch (options.textRotation) {
case 90:
- xForm.translate = [width, 0];
- xForm2.rotate = -90;
- xForm2.translate = [0, 200];
+ xForm.translate = [width + shearCorrection, 0];
break;
case -90:
- xForm.translate = [0, height];
- xForm2.rotate = 90;
- xForm2.translate = [width, 0];
+ xForm.translate = [-shearCorrection, height];
break;
case 180:
- xForm.translate = [width, height];
- xForm2.translate = [width, height];
- xForm2.rotate = 180;
+ xForm.translate = [width, height + shearCorrection];
break;
default:
+ xForm.translate = [0, -shearCorrection];
break;
}
-
textArea.attr("transform", xForm.toString());
- this.addClipPath(textContainer, xForm2);
+
+ // // DEBUG
+ // textArea.append("rect").attr({
+ // x: Math.max(0, shearShift),
+ // y: 0,
+ // width: shearCorrectedPrimaryDimension,
+ // height: shearCorrectedSecondaryDimension,
+ // fill: "none",
+ // stroke: "blue"
+ // });
+
+ // TODO This has never taken into account the transform at all, so it's
+ // certainly in the wrong place. Why do we need it?
+ this.addClipPath(textContainer);
if (options.animator) {
options.animator.animate(textContainer);
}
}
- private writeLine(line: string, g: d3.Selection
, width: number, xAlign: string, yOffset: number) {
+ private writeLine(
+ line: string, g: d3.Selection, width: number,
+ xAlign: string, xOffset: number, yOffset: number) {
const textEl = g.append("text");
textEl.text(line);
- const xOffset = width * Writer.XOffsetFactor[xAlign];
+ xOffset += width * Writer.XOffsetFactor[xAlign];
const anchor: string = Writer.AnchorConverter[xAlign];
textEl.attr("text-anchor", anchor).classed("text-line", true);
Utils.DOM.transform(textEl, xOffset, yOffset).attr("y", "-0.25em");
}
- private writeText(
- text: string,
+ private writeLines(
+ lines: string[],
writingArea: d3.Selection,
width: number,
- height: number,
- xAlign: string,
- yAlign: string) {
-
- const lines = text.split("\n");
+ shearShift: number,
+ xAlign: string) {
const lineHeight = this._measurer.measure().height;
- const yOffset = Writer.YOffsetFactor[yAlign] * (height - lines.length * lineHeight);
lines.forEach((line: string, i: number) => {
- this.writeLine(line, writingArea, width, xAlign, (i + 1) * lineHeight + yOffset);
+ const xOffset = (shearShift > 0) ? (i + 1) * shearShift : (i) * shearShift;
+ this.writeLine(line, writingArea, width, xAlign, xOffset, (i + 1) * lineHeight);
});
}
- private addClipPath(selection: d3.Selection, _transform: any) {
+ private addClipPath(selection: d3.Selection) {
const elementID = this._elementID++;
let prefix = /MSIE [5-9]/.test(navigator.userAgent) ? "" : document.location.href;
prefix = prefix.split("#")[0]; // To fix cases where an anchor tag was used
diff --git a/test/wrapperTests.ts b/test/wrapperTests.ts
index 294e48f..2f78ce3 100644
--- a/test/wrapperTests.ts
+++ b/test/wrapperTests.ts
@@ -399,14 +399,14 @@ describe("Wrapper Test Suite", () => {
it("multiple lines", () => {
text = "hello world!.\nhello world!.";
- const availableWidth = measurer.measure(text).width;
+ const availableWidth = measurer.measure("hello worl-").width;
const result = wrapper.wrap(text, measurer, availableWidth);
assert.deepEqual(result.originalText, text, "original text has been set");
assert.operator(result.wrappedText.indexOf("..."), ">", 0, "ellipsis has been added");
assert.deepEqual(result.wrappedText.substring(0, result.wrappedText.length - 3) + result.truncatedText,
text,
"non of letters disappeard");
- assert.deepEqual(result.noBrokeWords, 0, "one breaks");
+ assert.deepEqual(result.noBrokeWords, 1, "one breaks");
assert.deepEqual(result.noLines, 1, "wrapped text has one lines");
});
});
diff --git a/yarn.lock b/yarn.lock
index b7753a9..5af24a2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -542,6 +542,10 @@ cipher-base@^1.0.0, cipher-base@^1.0.1:
dependencies:
inherits "^2.0.1"
+circle-github-bot@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/circle-github-bot/-/circle-github-bot-0.4.0.tgz#d1702cea19277db4333936647828cd433fbcafd9"
+
cli-boxes@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"