├── .editorconfig
├── .gitignore
├── .prettierignore
├── .storybook
├── config.js
└── webpack.config.js
├── .vscode
├── extensions.json
└── settings.json
├── LICENSE.md
├── README.md
├── package.json
├── src
├── arcTail.ts
├── bubble.ts
├── bubbleSpec.ts
├── captionBubble.ts
├── circleBubble.ts
├── comical.ts
├── containerData.ts
├── curveTail.ts
├── handle.ts
├── index.ts
├── lineTail.ts
├── random.ts
├── shoutBubble.svg
├── shoutBubble_ink.svg
├── speechBubble.svg
├── speechBubble.ts
├── straightTail.ts
├── tail.ts
├── thoughtBubble.ts
├── thoughtTail.ts
├── uniqueId.ts
└── utilities.ts
├── stories
├── bubbleDrag.ts
└── index.stories.ts
├── storyStatic
├── HowDidItGoMyDaughter.png
├── MotherNaomi.png
├── The Moon and The Cap_Page 031.jpg
└── The Moon and The Cap_Page 051.jpg
├── tsconfig.json
├── webpack.common.js
├── webpack.config-prod.js
├── webpack.config.js
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | # Defaults
4 | [*]
5 | trim_trailing_whitespace = true
6 | insert_final_newline = true
7 | indent_size = 4
8 |
9 | [*.{ts,json,js,md,jsx,css,less}]
10 | indent_style = space
11 | max_line_length = 120
12 |
13 | [ReleaseNotes.md]
14 | trim_trailing_whitespace=false
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | storybook-static
3 | dist
4 | build
5 | yarn-error.log
6 |
7 | *.orig
8 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | *.md
2 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure } from '@storybook/html';
2 |
3 | // automatically import all files ending in *.stories.js
4 | const req = require.context('../stories', true, /\.stories\.ts$/);
5 | function loadStories() {
6 | req.keys().forEach(filename => req(filename));
7 | }
8 |
9 | configure(loadStories, module);
10 |
--------------------------------------------------------------------------------
/.storybook/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = ({ config }) => {
2 | config.module.rules.push({
3 | test: /\.(ts|tsx)$/,
4 | use: [
5 | {
6 | loader: require.resolve('awesome-typescript-loader'),
7 | },
8 | // Optional
9 | // {
10 | // loader: require.resolve('react-docgen-typescript-loader'),
11 | // },
12 | ],
13 | });
14 | config.resolve.extensions.push('.ts', '.tsx'); // not yet using react, but just in case...
15 | return config;
16 | };
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
4 |
5 | // List of extensions which should be recommended for users of this workspace.
6 | "recommendations": ["editorconfig.editorconfig", "esbenp.prettier-vscode"],
7 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace.
8 | "unwantedRecommendations": []
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.defaultFormatter": "esbenp.prettier-vscode"
4 | }
5 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 |
2 | The MIT License (MIT)
3 |
4 | Copyright (c) 2019 SIL International
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Comical-JS
2 |
3 | 
4 |
5 | Comical-JS is a JavaScript library for displaying and editing comic balloons (speech bubbles), captions, callouts, and related text that floats above one or more images. Lacking a better term, we call all of these things _bubbles_.
6 |
7 | Comical-JS only provides ui elements (handles) to control bubble tails. In the future it may provide ui for controlling the location and bounds of the bubble. But ui for properties like bubble style (thought-bubble, whisper, etc.), background color, etc. will always be left to the client application.
8 |
9 | Similarly, Comical-JS does not provide any features related to the text inside the bubble. Instead, the client application must create the element containing the text, and then tell Comical-JS to attach to it. This gives client applications freedom to do whatever they need to with text.
10 |
11 | Comical-JS comes from the [Bloom](https://github.com/BloomBooks) project which is an HTML-based literacy material production app. So it is a bit unusual in that it is designed to work with HTML-based editors like Bloom which make changes to the DOM and then save that DOM. For example, when active, Comical draws all the bubbles above an image using the HTML canvas. But when deactivated, Comical-JS inserts an SVG into the DOM, so that you can display the page without having to fire up Comical. Since you might want to later edit the bubbles, it also stores the JSON that defines each bubble in an attribute named _data-comical_. Using this, it can recreate the interactive bubble as needed.
12 |
13 | [Demo of use inside of Bloom](https://i.imgur.com/cOLB8iQ.gif)
14 |
15 | 
16 |
17 | ## Project Status
18 |
19 | Comical has the main pieces in place and is in use within Bloom. We are gradually adding new bubble types and ways to style bubbles.
20 |
21 | ## Using Comical-JS
22 |
23 | To get started:
24 |
25 | `yarn add comicaljs`
26 |
27 | ### To make bubbles appear
28 |
29 | You need one or more parent elements, typically containing the picture to which you want to add bubbles, and one or more elements you want to wrap bubbles around, typically positioned relative to the parent. The child elements must have a data-bubble attribute giving an initial specification of the desired bubble (and possibly tails) for that element.
30 |
31 | A simple way to do this is, for each desired child, call
32 |
33 | `Bubble.setBubbleSpec(child, Bubble.getDefaultBubbleSpec(child, "speech));`
34 |
35 | To turn on editing mode, call
36 |
37 | `Comical.startEditing([parent]);`
38 |
39 | A user can then interactively click on a bubble to make handles appear and drag them to move the tail. You can specify more than one parent if you wish. Performance may well suffer with a large number of parents; Comical is designed for a number of parent images that would reasonably fit on a page.
40 |
41 | ### When done editing
42 |
43 | To put the document in a state where the bubbles can't be edited and Comical.js code is not needed to make them appear, call
44 |
45 | `Comical.stopEditing();`
46 |
47 | (Later, you can call startEditing() again to resume editing.)
48 |
49 | ### BubbleSpec
50 |
51 | The content of the data-comical attribute is a slightly modified JSON representation of a BubbleSpec. You can convert between them using Bubble.getBubbleSpec(element) and Bubble.setBubbleSpec(element, spec).
52 |
53 | While we are still defining things, the details of BubbleSpec and the related TailSpec classes can be found in the sources.
54 |
55 | If you setBubbleSpec() while Comical editing is happening (a valid way of changing an element's properties), or if you add or delete children, you should call
56 |
57 | `Comical.update(parent);`
58 |
59 | on the appropriate parent element to make the visible bubbles conform. Note that this must be done after calling Comical.startEditing() with 'parent' in the list of parents, and before the corresponding stopEditing() call.
60 |
61 | (This isn't necessary if you just move the element that the bubble is wrapped around; Comical will automatically adjust things, as long as editing is turned on.)
62 |
63 | ### Child bubbles
64 |
65 | Two of the properties of bubbles are level and order. Bubbles at the same level are considered to be a family and should have different orders. They will merge their outlines if they overlap. Bubbles in a family are expected to share most other properties; the one with the lowest order is considered the parent, and its properties control all the others in the family. Typically the parent is the bubble which has a tail linked to something in the picture. If the other bubbles don't overlap, joiner tails will be drawn linking them in order.
66 |
67 | ## Developing
68 |
69 | Do this once to get the dependencies
70 | `yarn`
71 |
72 | Do this to launch a browser window in which you can see various examples of ComicalJS running, or add your own
73 | `yarn storybook`
74 |
75 | Do this to create a 'dest' directory with the current version of the files that are npm-published as part of Comical.js
76 | `yarn build`
77 |
78 | ## Acknowledgements
79 |
80 | [paperjs](http://paperjs.org/)
81 |
82 | [Storybook](https://storybook.js.org/)
83 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "comicaljs",
3 | "//version": "major and minor come from here, the patch number comes from TeamCity",
4 | "version": "0.3.0",
5 | "description": "Edit cartoon bubble frames around an HTML element",
6 | "main": "dist/index.js",
7 | "types": "dist/index.d.ts",
8 | "//files": "we get package.json, README, and LICENSE automatically. Use `npm pack` to test.",
9 | "files": [
10 | "dist/"
11 | ],
12 | "keywords": [
13 | "comic",
14 | "balloon",
15 | "speech bubbles"
16 | ],
17 | "author": "SIL Bloom Team & Contributors",
18 | "license": "MIT",
19 | "private": false,
20 | "repository": "https://github.com/BloomBooks/comical-js.git",
21 | "devDependencies": {
22 | "@babel/core": "^7.5.5",
23 | "@storybook/html": "^6.1.15",
24 | "@types/svg.js": "^2.3.1",
25 | "awesome-typescript-loader": "^5.2.1",
26 | "babel-loader": "^8.0.6",
27 | "babel-preset-env": "^1.7.0",
28 | "babel-preset-react": "^6.24.1",
29 | "clean-publish": "^4.0.1",
30 | "copy-webpack-plugin": "^5.0.4",
31 | "globule": "^1.2.1",
32 | "husky": "^3.0.9",
33 | "prettier": "^1.18.2",
34 | "pretty-quick": "^2.0.0",
35 | "shx": "^0.3.2",
36 | "ts-loader": "^6.2.0",
37 | "typescript": "^3.7.5",
38 | "webpack": "^4.39.2",
39 | "webpack-cli": "^3.3.6",
40 | "webpack-merge": "^4.2.2"
41 | },
42 | "dependencies": {
43 | "paper": "0.12.8"
44 | },
45 | "scripts": {
46 | "storybook": "start-storybook -s ./storyStatic -p 6006",
47 | "build-storybook": "build-storybook",
48 | "We want to use a special package.json for the npm module we're making. That file is package-publish.json.": "//",
49 | "buildMin": "shx rm -rf dist/ && webpack --config webpack.config-prod.js",
50 | "build": "shx rm -rf dist/ && webpack",
51 | "//publish-clean": "strips out the scripts, dependencies, etc and then does `npm publish`",
52 | "publish-clean": "clean-publish"
53 | },
54 | "clean-publish": {
55 | "packageManager": "yarn"
56 | },
57 | "husky": {
58 | "hooks": {
59 | "pre-commit": "pretty-quick --staged"
60 | }
61 | },
62 | "packageManager": "yarn@1.22.19",
63 | "volta": {
64 | "node": "16.20.2",
65 | "yarn": "1.22.19"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/arcTail.ts:
--------------------------------------------------------------------------------
1 | import paper = require("paper");
2 | import { TailSpec } from "./bubbleSpec";
3 | import { Bubble } from "./bubble";
4 | import { activateLayer } from "./utilities";
5 | import { CurveTail } from "./curveTail";
6 | import { Comical } from "./comical";
7 |
8 | // An ArcTail is currently our default: a tail that is an arc from the tip through a third
9 | // control point, mid, which can also be dragged.
10 | export class ArcTail extends CurveTail {
11 | public constructor(
12 | root: paper.Point,
13 | tip: paper.Point,
14 | mid: paper.Point,
15 | lowerLayer: paper.Layer,
16 | upperLayer: paper.Layer,
17 | handleLayer: paper.Layer,
18 | spec: TailSpec,
19 | bubble: Bubble | undefined
20 | ) {
21 | super(root, tip, lowerLayer, upperLayer, handleLayer, spec, bubble);
22 | this.mid = mid;
23 | }
24 |
25 | // Make the shapes that implement the tail.
26 | // If there are existing shapes (typically representing an earlier tail position),
27 | // remove them after putting the new shapes in the same z-order and layer.
28 | public makeShapes() {
29 | console.assert(!!this.bubble, "ArcTail::makeShapes() - this.bubble is null or undefined");
30 | const oldFill = this.pathFill;
31 | const oldStroke = this.pathstroke;
32 |
33 | activateLayer(this.lowerLayer);
34 |
35 | const tailWidth = 18; // width of tail when measured at center of bubble
36 | const baseAlongPathLength = 20; // and when measured along the circumference of speech bubble
37 |
38 | // We want to make two bezier curves, basically from the tip to a bit either side
39 | // of the root, and passing near mid.
40 | // It's a bit nontrivial to get these to look good. The standard is higher for
41 | // speech bubbles, which are by far the most common.
42 |
43 | // First we want to find a starting point for the tail. In general this is
44 | // the center of the content box.
45 | let baseOfTail = this.root;
46 | let doingSpeechBubble = this.bubble && this.bubble.getFullSpec().style === "speech";
47 | if (doingSpeechBubble) {
48 | // We're going to make the base of the tail a point on the bubble itself,
49 | // from which we will eventually calculate a fixed distance along the
50 | // circumfererence to get the actual start and end points for the tail.
51 | // To find this point, we use the same method as we will eventually use
52 | // for the sides of the tail itself to make a curve from the root
53 | // through mid to the tip. Then we see where this intersects the bubble.
54 | const rootTipCurve = this.makeBezier(this.root, this.mid, this.tip);
55 | rootTipCurve.remove(); // don't want to see this, it's just for calculations.
56 | const bubblePath = this.getBubblePath();
57 | if (bubblePath instanceof paper.Path) {
58 | const intersections = rootTipCurve.getIntersections(bubblePath);
59 | if (intersections.length === 0) {
60 | // This is very unusual and only happens if the tip and mid are
61 | // both inside the bubble. In that case any tail will be inside the bubble
62 | // and invisible anyway, so we may as well just give up and not make any shapes.
63 | // As of Jan 2021, the only way we know to get this is to expand the bounds of the
64 | // the bubble to cover the area where the tail is. But we did see this in a real book. See BL-9451.
65 | return;
66 | }
67 | // in the pathological case where there's more than one intersection,
68 | // choose the one closest to the root. We're going to get a bizarre
69 | // tail that loops out of the bubble and back through it anyway,
70 | // so better to have it start at the natural place.
71 | intersections.sort((a, b) => a.curveOffset - b.curveOffset);
72 | baseOfTail = intersections[0].point;
73 | } else {
74 | console.assert(false, "speech bubble outline should be a path or a group with second element path");
75 | doingSpeechBubble = false; // fall back to default mode.
76 | }
77 | }
78 |
79 | const angleBaseTip = this.tip.subtract(baseOfTail).angle;
80 | // This is a starting point for figuring out how wide the tail should be
81 | // at its midpoint. Various things adjust it.
82 | let midPointWidth = tailWidth / 2; // default for non-speech
83 | // make an extra curve where the short side of the tail meets the bubble.
84 | // Only applies to speech, but used in a couple of places.
85 | let puckerShortLeg = false;
86 |
87 | // Figure out where the tail starts and ends...the 'base' of the 'triangle'.
88 | let begin: paper.Point; // where the tail path starts
89 | let end: paper.Point; // where it ends
90 | if (doingSpeechBubble) {
91 | // we want to move along the bubble curve a specified distance
92 | const bubblePath = this.getBubblePath();
93 | const offset = bubblePath.getOffsetOf(baseOfTail);
94 | const offsetBegin = (offset + baseAlongPathLength / 2) % bubblePath.length;
95 | // nudge it towards the center to make sure we don't get even a hint
96 | // of the bubble's border that it's not on top of.
97 | begin = this.nudgeTowards(bubblePath.getLocationAt(offsetBegin).point, this.root);
98 | let offsetEnd = offset - baseAlongPathLength / 2;
99 | if (offsetEnd < 0) {
100 | // % will leave it negative
101 | offsetEnd += bubblePath.length;
102 | }
103 | end = this.nudgeTowards(bubblePath.getLocationAt(offsetEnd).point, this.root);
104 |
105 | // At one point we found that the pucker curve became an ugly angle at very small sizes.
106 | // In those cases we don't do it. When we DO do it, we need to make the tail
107 | // a bit narrower so it doesn't bulge out again after the pucker curve.
108 | // If we're not doing a pucker curve, the 0.3 seems to look good.
109 | const baseToMid = this.mid.subtract(baseOfTail);
110 | puckerShortLeg = baseToMid.length > baseAlongPathLength * 0.5;
111 | midPointWidth = baseAlongPathLength * (puckerShortLeg ? 0.25 : 0.3);
112 | } else {
113 | // For most bubble shapes, we want to make the base of the tail a line of length tailWidth
114 | // at right angles to the line from root to mid centered at root.
115 | const angleBase = this.mid.subtract(this.root).angle;
116 | const deltaBase = new paper.Point(0, 0);
117 | deltaBase.angle = angleBase + 90;
118 | deltaBase.length = tailWidth / 2;
119 | begin = this.root.add(deltaBase);
120 | end = this.root.subtract(deltaBase);
121 | }
122 |
123 | // Now we need the other two points that define the beziers: the
124 | // ones close to the 'mid' point that the user controls.
125 | // We're going to start at mid and go in a direction at right
126 | // angles to the original line from base to tip.
127 | // To give the tail a rougly evenly reducing width, we make the
128 | // actual distance either side of mid depend on how far along the
129 | // original curve mid is.
130 | const midPath = this.makeBezier(baseOfTail, this.mid, this.tip);
131 | midPath.remove(); // don't want to see this, it's just for calculations.
132 | const midWidthRatio = midPath.curves[1].length / midPath.length;
133 | const deltaMid = new paper.Point(0, 0);
134 | deltaMid.angle = angleBaseTip + 90;
135 | deltaMid.length = midPointWidth * midWidthRatio;
136 | const mid1 = this.mid.add(deltaMid);
137 | const mid2 = this.mid.subtract(deltaMid);
138 | // In theory, we'd like the two beziers to come together at a perfectly sharp point.
139 | // But in practice, some browsers (at least, Chrome and things using that engine)
140 | // sometimes draw the narrowing border out well beyond where the tip point is supposed
141 | // to be. If we 'square off' the tip by even a very tiny amount, this behavior is
142 | // prevented. So the two beziers have 'tips' just slightly different.
143 | // See BL-8331, BL-8332.
144 | const deltaTip = deltaMid.divide(1000);
145 |
146 | // Now we can make the actual path, initially in two pieces.
147 | // Non-joiner tails (e.g. bubbles w/o a child) use a tapering algorithm where the root is wider and it narrows down to a tip.
148 | // Joiners (i.e. connectors between parent and child bubbles) use a different algo with a steady width.
149 | let bezier2: paper.Path;
150 | if (this.spec.joiner !== true) {
151 | // Normal tapering
152 | this.pathstroke = this.makeBezier(begin, mid1, this.tip.add(deltaTip));
153 | bezier2 = this.makeBezier(this.tip.subtract(deltaTip), mid2, end);
154 | } else {
155 | // No tapering for child connectors (See BL-9082)
156 | // At both the root and tip, it maintains width same as width at the mid.
157 | this.pathstroke = this.makeBezier(this.root.add(deltaMid), mid1, this.tip.add(deltaMid));
158 | bezier2 = this.makeBezier(this.tip.subtract(deltaMid), mid2, this.root.subtract(deltaMid));
159 | }
160 |
161 | // For now we decided to always do the pucker (except for child connectors)...the current algorithm seems
162 | // to have cleared up the sharp angle problem. Keeping the option to turn
163 | // it off in case we change our minds.
164 | if (this.spec.joiner !== true /* puckerShortLeg */) {
165 | // round the corner where it leaves the main bubble.
166 | let puckerHandleLength = baseAlongPathLength * 0.8; // experimentally determined
167 |
168 | const baseToMid = this.mid.subtract(baseOfTail);
169 | const midToTip = this.tip.subtract(this.mid);
170 | const midAngle = baseToMid.angle;
171 | const tipAngle = midToTip.angle;
172 | let deltaAngle = midAngle - tipAngle;
173 | // which way the tip bends from the midpoint.
174 | let clockwise = Math.sin((deltaAngle * Math.PI) / 180) < 0;
175 | // We don't want any pucker when the angle is zero; for one thing, it would jump
176 | // suddenly from one side to the other as we go through zero.
177 | // But we want it to rise rather rapidly as we get away from zero; our default
178 | // curve is not very much even at 45 degrees. The sin function does this rather well.
179 | // the multiplier is about as much as we can use without the tail having a bulge.
180 | // Likewise in the interests of avoiding a bulge, we need to give it a max length.
181 | puckerHandleLength *= Math.min(Math.abs(Math.sin((deltaAngle * Math.PI) / 180)) * 1.8, 1);
182 |
183 | // Depending on which way the tail initially curves, the pucker
184 | // may go at the begin or end point.
185 | // Enhance: at very mid-point distances, the handle may end up pointing into the interior
186 | // of the bubble. We think it might look better to prevent it being rotated past the
187 | // angle that is straight towards the other point. Haven't had time to actually try this.
188 | if (clockwise) {
189 | const beginHandle = mid1.subtract(begin);
190 | beginHandle.angle -= 70;
191 | beginHandle.length = puckerHandleLength;
192 | this.pathstroke.segments[0].handleOut = beginHandle;
193 | } else {
194 | const endHandle = mid2.subtract(end);
195 | endHandle.angle += 70;
196 | endHandle.length = puckerHandleLength;
197 | bezier2.segments[2].handleIn = endHandle;
198 | }
199 | }
200 | // Merge the two parts into a single path (so we only have one to
201 | // keep track of, but more importantly, so we can clone a filled shape
202 | // from it).
203 | this.pathstroke.addSegments(bezier2.segments);
204 | bezier2.remove();
205 |
206 | if (oldStroke) {
207 | this.pathstroke.insertBelow(oldStroke);
208 | oldStroke.remove();
209 | }
210 |
211 | let borderWidth = 1;
212 | if (this.bubble) {
213 | borderWidth = this.bubble.getBorderWidth();
214 | }
215 |
216 | this.pathstroke!.strokeWidth = borderWidth;
217 |
218 | if (this.bubble && this.bubble.usingOverlay()) {
219 | this.pathstroke.strokeColor = new paper.Color("black");
220 | activateLayer(this.upperLayer);
221 | this.pathFill = this.pathstroke.clone() as paper.Path;
222 | this.pathFill.remove();
223 | if (oldFill) {
224 | this.pathFill.insertBelow(oldFill);
225 | oldFill.remove();
226 | } else {
227 | this.upperLayer.addChild(this.pathFill);
228 | }
229 | this.pathFill.fillColor = this.getFillColor();
230 | this.pathFill.strokeColor = new paper.Color("white");
231 | this.pathFill.strokeColor.alpha = 0.01;
232 | if (this.clickAction) {
233 | Comical.setItemOnClick(this.pathFill, this.clickAction);
234 | }
235 | } else {
236 | // new approach, the tail is almost invisible, and its shape gets
237 | // "united" with the bubble so only that shape is seen.
238 | // This one would ideally be transparent, but we use a color that
239 | // is only almost transparent so Paper.js will recognize clicks on
240 | // the objects.
241 | this.pathstroke.fillColor = Bubble.almostInvisibleColor;
242 | this.pathstroke.strokeColor = Bubble.almostInvisibleColor;
243 | if (this.clickAction) {
244 | Comical.setItemOnClick(this.pathstroke, this.clickAction);
245 | }
246 | }
247 | }
248 |
249 | getBubblePath(): paper.Path {
250 | let bubblePath = this.bubble!.outline as paper.Path;
251 | if (!(bubblePath instanceof paper.Path)) {
252 | // We make a group when drawing the outer outline.
253 | let group = this.bubble!.outline as paper.Group;
254 | bubblePath = group.children![1] as paper.Path;
255 | }
256 | return bubblePath;
257 | }
258 |
259 | // Move the start point a single pixel towards the target point.
260 | nudgeTowards(start: paper.Point, target: paper.Point): paper.Point {
261 | const delta = target.subtract(start);
262 | delta.length = 1;
263 | return start.add(delta);
264 | }
265 |
266 | // Make a particular kind of bezier curve that produces better shapes
267 | // than an arc through the three points. One side of the tail is made
268 | // of one of these starting on the border of the bubble and passing near the
269 | // middle control point and ending at the tip; the other side goes in
270 | // the opposite direction, so it's imporant for the algorithm to
271 | // be symmetrical.
272 | makeBezier(start: paper.Point, mid: paper.Point, end: paper.Point): paper.Path {
273 | const result = new paper.Path();
274 | result.add(new paper.Segment(start));
275 | const baseToTip = end.subtract(start);
276 | // This makes the handles parallel to the line from start to end.
277 | // This seems to be a good default for a wide range of positions,
278 | // though eventually we may want to allow the user to drag them.
279 | const handleDeltaIn = baseToTip.multiply(0.3);
280 | const handleDeltaOut = baseToTip.multiply(0.3);
281 | handleDeltaIn.length = Math.min(handleDeltaIn.length, mid.subtract(start).length / 2);
282 | handleDeltaOut.length = Math.min(handleDeltaOut.length, end.subtract(mid).length / 2);
283 | result.add(new paper.Segment(mid, new paper.Point(0, 0).subtract(handleDeltaIn), handleDeltaOut));
284 | result.add(new paper.Segment(end));
285 | // Uncomment to see all the handles. Very useful for debugging.
286 | //result.fullySelected = true;
287 | return result;
288 | }
289 | }
290 |
--------------------------------------------------------------------------------
/src/bubble.ts:
--------------------------------------------------------------------------------
1 | import paper = require("paper");
2 | import { BubbleSpec, TailSpec, BubbleSpecPattern } from "bubbleSpec";
3 | import { Comical } from "./comical";
4 | import { Tail } from "./tail";
5 | import { ArcTail } from "./arcTail";
6 | import { ThoughtTail } from "./thoughtTail";
7 | import { LineTail } from "./lineTail";
8 | import { makeSpeechBubble, makeSpeechBubbleParts } from "./speechBubble";
9 | import { makeThoughtBubble } from "./thoughtBubble";
10 | import { makeCaptionBox } from "./captionBubble";
11 | import { activateLayer } from "./utilities";
12 | import { SimpleRandom } from "./random";
13 | import { makeCircleBubble } from "./circleBubble";
14 |
15 | // This class represents a bubble (including the tails, if any) wrapped around an HTML element
16 | // and handles:
17 | // - storing and retrieving the BubbleSpec that represents the persistent state of
18 | // the Bubble from the element's data-bubble attribute;
19 | // - creating paper.js shapes (technically Items) representing the shapes of the bubble and tails
20 | // - positioning and sizing those shapes based on the position and size of the wrapped element
21 | // - automatically repositioning them when the wrapped element changes
22 | // - creating handles on the tails to allow the user to drag them, and updating
23 | // the data-bubble as well as the shapes when this happens
24 | // - allowing the Bubble to be dragged, and updating the wrapped element's position (ToDo)
25 | export class Bubble {
26 | // The element to wrap with a bubble
27 | public content: HTMLElement;
28 | public static defaultBorderWidth = 3;
29 | // Represents the state which is persisted into
30 | // It is private because we want to try to ensure that callers go through the saveBubbleSpec() setter method,
31 | // because it's important that changes here get persisted not just in this instance's memory but additionally to the HTML as well.
32 | private spec: BubbleSpec;
33 | // the main shape of the bubble, including its border. Although we think of this as a shape,
34 | // and it determines the shape of the bubble, it may not actually be a paper.js Shape.
35 | // When it's simply obtained from an svg, it's usually some kind of group.
36 | // When we extract a single outline from the svg (or eventually make one algorithmically),
37 | // it will most likely be a Path.
38 | public outline: paper.Item;
39 | // The original stroke color of the outline. In some modes, the actual outline color is changed.
40 | private oulineStrokeColor: paper.Color | null = null;
41 | // When possible, the shapes of all bubbles and their tails at the same level are combined
42 | // into this one shape, allowing a partly transparent background color without
43 | // extra strokes showing through. The combinedShape is stored on the parent of the
44 | // bubble family.
45 | public combinedShapes: paper.Item | undefined = undefined;
46 | // If the item has a shadow, this makes it.
47 | // We would prefer to do this with the paper.js shadow properties applied to shape,
48 | // but experiment indicates that such shadows do not convert to SVG.
49 | private shadowShape: paper.Item;
50 | // a clone of this.outline with no border and an appropriate fill; drawn on top of all outlines
51 | // to fill them in and erase any overlapping borders.
52 | // When possible, this is left undefined and combinedShapes is set up instead.
53 | public fillArea: paper.Item | undefined;
54 | // contentHolder is a shape which is a required part of an SVG object used as
55 | // a bubble. It should be a rectangle in the SVG; it currently comes out as a Shape
56 | // when the SVG is converted to a paper.js object.
57 | // (We can also cause it to come out as a Path, by setting expandShapes: true
58 | // in the getItem options).
59 | // It has property size, with height, width as numbers matching the
60 | // height and width specified in the SVG for the rectangle.)
61 | // Also position, which surprisingly is about 50,50...probably a center.
62 | // It is identified by having id="contentHolder". The bubble shape gets stretched
63 | // and positioned so this rectangle corresponds to the element that the
64 | // bubble is wrapping.
65 | private contentHolder: paper.Item | undefined;
66 | // The tail objects (which include things like its PaperJs underlying objects and how to draw them).
67 | // Contains more details than the "tips" array in the spec object
68 | // The elements in each array should correspond, though.
69 | private tails: Tail[] = [];
70 | private observer: MutationObserver | undefined;
71 | private hScale: number = 1; // Horizontal scaling
72 | private vScale: number = 1; // Vertical scaling
73 |
74 | // The PaperJS layers in which to draw various pieces of the bubble into.
75 | private lowerLayer: paper.Layer;
76 | private upperLayer: paper.Layer;
77 | private handleLayer: paper.Layer;
78 |
79 | // true if we computed a shape for the bubble (in such a way that more than just
80 | // its size depends on the shape and size of the content element).
81 | private shapeIsComputed: boolean;
82 | // Remember the size of the content element when we last computed the bubble shape.
83 | oldContentWidth: number = 0;
84 | oldContentHeight: number = 0;
85 |
86 | public constructor(element: HTMLElement) {
87 | this.content = element;
88 |
89 | this.spec = Bubble.getBubbleSpec(this.content);
90 | }
91 |
92 | // True if this bubble uses the old approach, hiding outline strokes behind a fillArea.
93 | // False if the outline shape is one we can combine using uniteShapes into a combinedShapes.
94 | public usingOverlay(): boolean {
95 | return !!this.fillArea;
96 | }
97 |
98 | // Retrieves the bubble associated with the element
99 | public static getBubbleSpec(element: HTMLElement): BubbleSpec {
100 | const escapedJson = element.getAttribute("data-bubble");
101 | if (!escapedJson) {
102 | return Bubble.getDefaultBubbleSpec(element, "none");
103 | }
104 | const json = escapedJson.replace(/`/g, '"');
105 | return JSON.parse(json); // enhance: can we usefully validate it?
106 | }
107 |
108 | public static getDefaultBubbleSpec(element: HTMLElement, style?: string): BubbleSpec {
109 | if (!style || style === "none") {
110 | return {
111 | version: Comical.bubbleVersion,
112 | style: "none",
113 | tails: [],
114 | level: Comical.getMaxLevel(element) + 1,
115 | backgroundColors: ["transparent"],
116 | shadowOffset: 0,
117 | outerBorderColor: undefined
118 | };
119 | }
120 | const tailSpec = Bubble.makeDefaultTail(element);
121 | const result: BubbleSpec = {
122 | version: Comical.bubbleVersion,
123 | style: style,
124 | tails: [tailSpec],
125 | level: Comical.getMaxLevel(element) + 1
126 | };
127 | if (style === "caption") {
128 | result.backgroundColors = ["#FFFFFF", "#DFB28B"];
129 | result.tails = [];
130 | result.shadowOffset = 5;
131 | } else if (style === "pointedArcs" || style === "circle" || style === "rectangle") {
132 | result.tails = [];
133 | }
134 | return result;
135 | }
136 |
137 | //
138 | // Getter methods for various things saved in the spec field. They are Getters() so that consumers of this class will be encouraged to save them using getters/setters
139 | // because we probably need persistBubbleSpec() to be called afterward
140 | //
141 |
142 | // Gets the level (z-index) of this object
143 | public getSpecLevel(): number | undefined {
144 | return this.spec.level;
145 | }
146 |
147 | public getFullSpec(): BubbleSpec {
148 | const parents = Comical.findAncestors(this);
149 | if (parents.length == 0) {
150 | return this.spec;
151 | }
152 | const parent: Bubble = parents[0];
153 | // We probably don't need to be this careful, since functions that want
154 | // this bubble's tails or order go to its own spec. But these are
155 | // things that should NOT be inherited from parent, so let's get them right.
156 | const result: BubbleSpec = { ...parent.spec, tails: this.spec.tails };
157 | if (this.spec.hasOwnProperty("order")) {
158 | result.order = this.spec.order;
159 | } else {
160 | delete result.order;
161 | }
162 | return result;
163 | }
164 |
165 | // ENHANCE: Add more getters and setters, as they are needed
166 |
167 | // Returns the spec object. If you modify this object, make sure to use the setter to set the value again or use persistBubbleSpec() in order to get the changes to persist!
168 | public getBubbleSpec() {
169 | return this.spec;
170 | }
171 |
172 | // Setter for the spec field. Also persists the data into the HTML of the content element.
173 | public setBubbleSpec(spec: BubbleSpec): void {
174 | console.assert(!!(spec.version && spec.level && spec.tails && spec.style), "Bubble lacks minimum fields");
175 |
176 | this.spec = spec;
177 | this.persistBubbleSpec();
178 | }
179 |
180 | public persistBubbleSpecWithoutMonitoring() {
181 | this.callWithMonitoringDisabled(() => {
182 | this.persistBubbleSpec();
183 | });
184 | }
185 |
186 | // Persists the data into the content element's HTML. Should be called after making changes to the underlying spec object.
187 | public persistBubbleSpec(): void {
188 | const json = JSON.stringify(this.spec);
189 | const escapedJson = json.replace(/"/g, "`");
190 | this.content.setAttribute("data-bubble", escapedJson);
191 | }
192 |
193 | // Return true if the two arrays are equal (one level deep...items are ===)
194 | // Also if both are undefined.
195 | // This ought to be generic...arrays of any type...but I can't persuade Typescript
196 | // to allow it. For now I only need arrays of strings.
197 | private comparePossibleArrays(first: string[] | undefined, second: string[] | undefined): boolean {
198 | if (!first && !second) {
199 | return true;
200 | }
201 | if (!first || !second) {
202 | return false;
203 | }
204 | if (first.length != second.length) {
205 | return false;
206 | }
207 | for (let i = 0; i < first.length; i++) {
208 | if (first[i] !== second[i]) {
209 | return false;
210 | }
211 | }
212 | return true;
213 | }
214 |
215 | public mergeWithNewBubbleProps(newBubbleProps: BubbleSpecPattern): void {
216 | // Figure out a default that will supply any necessary properties not
217 | // specified in data, including a tail in a default position.
218 | // In certain cases some of these properties may override values in
219 | // oldData (but never newBubbleProps).
220 | const newDefaultData = Bubble.getDefaultBubbleSpec(this.content, newBubbleProps.style || this.spec.style);
221 |
222 | const oldData: BubbleSpec = this.spec;
223 | const oldDataOverrides: BubbleSpecPattern = {};
224 |
225 | // If newBubbleProps doesn't have a style defined, that means we want to keep the existing
226 | // style. Therefore, "extra work" for "the new style" (below) doesn't apply.
227 | if (newBubbleProps.style && oldData.style !== newBubbleProps.style) {
228 | // We will do some extra work to possibly switch other props
229 | // to their default values for the new style.
230 |
231 | // This gives the default properties associated with the OLD style
232 | const oldDefaultData = Bubble.getDefaultBubbleSpec(this.content, oldData.style);
233 | // For various properties, if oldData has the same value as oldDefaultData
234 | // (that is, the property in the current bubble is unchanged from
235 | // the default for the old style), we will update that property to the
236 | // default for the new style.
237 | // When bubble styles that normally have tails have the tail turned off, at least oldData.tails
238 | // could be undefined here.
239 | if (oldData.tails && oldDefaultData.tails && oldData.tails.length === oldDefaultData.tails.length) {
240 | // nothing has changed the number of tails, so we want the new spec to
241 | // have the default number for its style. First, we keep as many tails
242 | // as are wanted and present (currently always zero or one)
243 | oldDataOverrides.tails = oldData.tails.slice(0, newDefaultData.tails.length);
244 | if (oldDataOverrides.tails.length < newDefaultData.tails.length) {
245 | // if we don't already have enough, add another one from defaultData
246 | // May need to do something fancier here one day if we might need to add more than
247 | // one. I don't think that's likely.
248 | oldDataOverrides.tails.push(newDefaultData.tails[0]);
249 | }
250 | // Enhance: If different bubble styles have different default tail styles,
251 | // we may want to consider forcing the style of the tail.
252 | }
253 | if (this.comparePossibleArrays(oldData.backgroundColors, oldDefaultData.backgroundColors)) {
254 | oldDataOverrides.backgroundColors = newDefaultData.backgroundColors;
255 | }
256 | if (oldData.borderStyle === oldDefaultData.borderStyle) {
257 | oldDataOverrides.borderStyle = newDefaultData.borderStyle;
258 | }
259 | if (oldData.shadowOffset === oldDefaultData.shadowOffset) {
260 | oldDataOverrides.shadowOffset = newDefaultData.shadowOffset;
261 | }
262 | if (oldData.outerBorderColor === oldDefaultData.outerBorderColor) {
263 | oldDataOverrides.outerBorderColor = newDefaultData.outerBorderColor;
264 | }
265 | }
266 |
267 | // We get the default bubble for this style and parent to provide
268 | // any properties that have never before occurred for this bubble,
269 | // particularly a default tail placement if it was previously "none".
270 | // Any values already in oldData override these; for example, if
271 | // this bubble has ever had a tail, we'll keep its last known position.
272 | // If we put any values in oldDataOverrides (typically cases where we
273 | // prefer the defaultData value), they win next.
274 | // Finally, any values present in newBubbleProps override anything else.
275 | const mergedBubble = {
276 | ...newDefaultData,
277 | ...oldData,
278 | ...oldDataOverrides,
279 | ...(newBubbleProps as BubbleSpec)
280 | };
281 |
282 | this.setBubbleSpec(mergedBubble);
283 | }
284 |
285 | public getStyle(): string {
286 | return this.getFullSpec().style;
287 | }
288 | public setStyle(style: string): void {
289 | // TODO: Consider validating
290 | this.spec.style = style;
291 | this.persistBubbleSpec();
292 | }
293 |
294 | public setLayers(newLowerLayer: paper.Layer, newUpperLayer: paper.Layer, newHandleLayer: paper.Layer): void {
295 | this.setLowerLayer(newLowerLayer);
296 | this.setUpperLayer(newUpperLayer);
297 | this.setHandleLayer(newHandleLayer);
298 | }
299 |
300 | public getLowerLayer(): paper.Layer {
301 | return this.lowerLayer;
302 | }
303 |
304 | // Sets the value of lowerLayer. The "outline" shapes are drawn in the lower layer.
305 | public setLowerLayer(layer: paper.Layer): void {
306 | this.lowerLayer = layer;
307 | }
308 |
309 | public getUpperLayer(): paper.Layer {
310 | return this.upperLayer;
311 | }
312 |
313 | // Sets the value of upperLayer. The "fill" shapes are drawn in the upper layer.
314 | public setUpperLayer(layer: paper.Layer): void {
315 | this.upperLayer = layer;
316 | }
317 |
318 | // The layer containing the tip and midpoint curve handles
319 | public setHandleLayer(layer: paper.Layer): void {
320 | this.handleLayer = layer;
321 | }
322 |
323 | // Ensures that this bubble has all the required layers and creates them, if necessary
324 | private initializeLayers(): void {
325 | if (!this.lowerLayer) {
326 | this.lowerLayer = new paper.Layer(); // Note that the constructor automatically adds the newly-created layer to the project
327 | }
328 | if (!this.upperLayer) {
329 | this.upperLayer = new paper.Layer();
330 | }
331 | if (!this.handleLayer) {
332 | this.handleLayer = new paper.Layer();
333 | }
334 | }
335 |
336 | // The root method to call to cause this object to make its shapes,
337 | // adjust their sizes to match the content,
338 | // and sets up monitoring so the shapes continue to adjust as the content
339 | // element size and position change.
340 | public initialize() {
341 | this.initializeLayers();
342 |
343 | // To keep things clean we discard old tails before we start.
344 | for (let i = 0; i < this.tails.length; ++i) {
345 | // Erase it off the current canvas
346 | this.tails[i].remove();
347 | }
348 | this.tails = [];
349 |
350 | // Make the bubble part of the bubble+tail
351 | this.loadShapeAsync((newlyLoadedShape: paper.Item) => {
352 | this.makeShapes(newlyLoadedShape);
353 | if (this.isTransparent()) {
354 | this.outline.strokeWidth = 0;
355 | if (this.shadowShape) {
356 | this.shadowShape.strokeWidth = 0;
357 | this.shadowShape.fillColor = new paper.Color(0, 0, 0, 0);
358 | }
359 | }
360 |
361 | // If we're making the main shape first (especially, thoughtBubble),
362 | // we need to adjust its size and position before making the tail,
363 | // since we depend on that to decide where to stop making mini-bubbles.
364 | // If we're making the shape asynchronously, it's possible the call
365 | // that adjusted the tails already happened. But it won't hurt do do it
366 | // one more time once we have everything.
367 | this.adjustSizeAndPosition();
368 | });
369 |
370 | // Make any tails the bubble should have.
371 | // For some tail types (e.g., currently, thoughtTail), it's important
372 | // to make the main shape first, since making the tail shapes depends
373 | // on having it. Currently this can only be guaranteed with computational
374 | // shapes (that don't depend on loading an svg).
375 | if (this.spec.tails) {
376 | // mostly paranoia, but I ran into a problem here in one version of the code
377 | this.spec.tails.forEach(tail => {
378 | this.makeTail(tail);
379 | });
380 | }
381 |
382 | // Need to do this again, mainly to adjust the tail positions.
383 | // Note that in some cases, this might possibly happen before
384 | // the main bubble shape is created.
385 | this.adjustSizeAndPosition();
386 |
387 | this.monitorContent();
388 | }
389 |
390 | // Returns true if nothing in the bubble spec makes the bubble visible.
391 | public isTransparent() {
392 | if (this.spec.style !== "caption" && this.spec.style !== "none") {
393 | return false;
394 | }
395 | if (this.spec.tails.length > 0) {
396 | return false;
397 | }
398 | const outerBorderColor = this.spec.outerBorderColor;
399 | if (outerBorderColor && outerBorderColor !== "none") {
400 | return false;
401 | }
402 | if (this.spec.shadowOffset) {
403 | return false;
404 | }
405 | const backColors = this.spec.backgroundColors;
406 | if (backColors && backColors.length === 1 && backColors[0] === "transparent") {
407 | return true;
408 | }
409 | return false;
410 | }
411 |
412 | // Returns the SVG contents string corresponding to the specified input bubble style
413 | public static getShapeSvgString(bubbleStyle: string): string {
414 | let svg: string = "";
415 | switch (bubbleStyle) {
416 | case "ellipse":
417 | svg = Bubble.ellipseBubble();
418 | break;
419 | case "shout":
420 | svg = Bubble.shoutBubble();
421 | break;
422 | // "none" is now a "computed" shape.
423 | default:
424 | console.log("unknown bubble type; using default");
425 | svg = Bubble.ellipseBubble();
426 | }
427 |
428 | return svg;
429 | }
430 |
431 | // Get the main shape immediately if computed.
432 | // return undefined if the current bubble style is not a computed shape.
433 | private getComputedShape(): paper.Item | undefined {
434 | if (this.content) {
435 | // remember the shape of the content from the most recent call.
436 | this.oldContentHeight = this.content.offsetHeight;
437 | this.oldContentWidth = this.content.offsetWidth;
438 | }
439 | const bubbleStyle = this.getStyle();
440 | activateLayer(this.lowerLayer); // at least for now, the main shape always goes in this layer.
441 | switch (bubbleStyle) {
442 | case "pointedArcs":
443 | return this.makePointedArcBubble();
444 | case "thought":
445 | return makeThoughtBubble(this);
446 | case "speech":
447 | return makeSpeechBubble(this.content.offsetWidth, this.content.offsetHeight, 0.6, 0.8);
448 | case "circle":
449 | return makeCircleBubble(this.content.offsetWidth, this.content.offsetHeight);
450 | case "caption": // purposeful fall-through; these four types should have the same shape
451 | case "caption-withTail":
452 | case "rectangle":
453 | case "none":
454 | const spec = this.getFullSpec();
455 | const rounderCornerRadii =
456 | spec.cornerRadiusX && spec.cornerRadiusY
457 | ? new paper.Size(spec.cornerRadiusX, spec.cornerRadiusY)
458 | : undefined;
459 | // The padding thing is kind of backwards compatibility. In an earlier version,
460 | // the border set up by makeCaptionBox was always 3px, but we reduced the thickness
461 | // to zero for 'none' later. That actually left the box 3px larger, which acted as padding.
462 | // It's probably a good thing, might even be desirable to have some for other bubbles,
463 | // but we don't want to change things and break existing books. So, put the 3px padding
464 | // back in for 'none' (BL-11715).
465 | return makeCaptionBox(this, rounderCornerRadii, this.getBorderWidth(), bubbleStyle == "none" ? 3 : 0);
466 | default:
467 | return undefined; // not a computed shape, may be svg...caller has real default
468 | }
469 | }
470 |
471 | // Loads the shape (technically Item) corresponding to the specified bubbleStyle,
472 | // and calls the onShapeLoadeed() callback once the shape is finished loading
473 | // (passing it in as the shape parameter)
474 | private loadShapeAsync(onShapeLoaded: (shape: paper.Item) => void) {
475 | const bubbleStyle = this.getStyle();
476 | this.shapeIsComputed = false;
477 | var shape = this.getComputedShape();
478 | if (shape) {
479 | this.shapeIsComputed = true;
480 | onShapeLoaded(shape);
481 | return;
482 | }
483 | const svg = Bubble.getShapeSvgString(bubbleStyle);
484 |
485 | activateLayer(this.lowerLayer); // Sets this bubble's lowerLayer as the active layer, so that the SVG will be imported into the correct layer.
486 |
487 | // ImportSVG may return asynchronously if the input string is a URL.
488 | // Even though the string we pass contains the svg contents directly (not a URL), when I ran it in Bloom I still got a null shape out as the return value, so best to treat it as async.
489 | this.lowerLayer.project.importSVG(svg, {
490 | onLoad: (item: paper.Item) => {
491 | onShapeLoaded(item);
492 | }
493 | });
494 | }
495 |
496 | private hasOuterBorder(): boolean {
497 | return !!this.getFullSpec().outerBorderColor && this.spec.outerBorderColor !== "none";
498 | }
499 |
500 | // Attaches the specified shape to this object's content element
501 | private makeShapes(shape: paper.Item) {
502 | var oldOutline = this.outline;
503 | var oldFillArea = this.fillArea;
504 | activateLayer(this.lowerLayer);
505 | this.setOutline(shape);
506 |
507 | // if the SVG contains a single shape (marked with an ID) that is all
508 | // we need to draw, we can replace the whole-svg item with a path derived
509 | // from that one shape. Some benefits:
510 | // - paths painted with gradient colors convert correctly to SVG;
511 | // complex groups do not.
512 | // - simpler shape may help performance
513 | // - (future) it's possible to subtract one path from another, offering an
514 | // alternative way to hide overlapping line segments that is
515 | // compatible with bubbles having partly transparent fill colors.
516 | // Enhance: we could also look for a child, like the one in the shout
517 | // bubble, that is already a path, possibly by giving such elements
518 | // an outlinePath id. The only difference would be that the result from
519 | // getItem is already a path, so we don't need to cast it to shape and
520 | // call toPath().
521 | // If we add that, all our current bubbles can be converted to a single
522 | // path each. We may, however, not want to have the code assume that will
523 | // always be the case. For example, a bubble with a shadow or double outline
524 | // might not be doable with a single path.
525 | const outlineShape = shape.getItem({
526 | recursive: true,
527 | match: (x: any) => x.name === "outlineShape"
528 | });
529 | if (outlineShape) {
530 | shape.remove();
531 | this.setOutline((outlineShape as paper.Shape).toPath());
532 | this.lowerLayer.addChild(this.outline);
533 | }
534 | if (oldOutline) {
535 | this.outline.insertBelow(oldOutline);
536 | oldOutline.remove();
537 | }
538 | this.outline.strokeWidth = this.getBorderWidth();
539 | this.hScale = this.vScale = 1; // haven't scaled it at all yet.
540 | // recursive: true is required to see any but the root "g" element
541 | // (apparently contrary to documentation).
542 | // The 'name' of a paper item corresponds to the 'id' of an element in the SVG
543 | this.contentHolder = shape.getItem({
544 | recursive: true,
545 | match: (x: any) => {
546 | return x.name === "content-holder";
547 | }
548 | });
549 | if (this.spec.shadowOffset) {
550 | if (this.shadowShape) {
551 | this.shadowShape.remove();
552 | }
553 | this.shadowShape = this.outline.clone({ deep: true });
554 | this.shadowShape.insertBelow(this.outline);
555 | this.shadowShape.fillColor = this.shadowShape.strokeColor;
556 | }
557 |
558 | // If we don't do this it somehow shows up in the fill area clone,
559 | // on top of the outline if it dips inside the rectangle
560 | this.contentHolder.remove();
561 |
562 | if (outlineShape && !this.hasOuterBorder()) {
563 | // We can use the new strategy that supports partly transparent background colors
564 | this.outline.fillColor = this.getBackgroundColor();
565 | Comical.setItemOnClick(this.outline, () => {
566 | Comical.activateBubble(this);
567 | });
568 | } else {
569 | // This strategy does not work well with partly transparent background colors.
570 | // The partly-transparent background won't erase any part of the tail that is
571 | // inside the bubble. Nor will it properly erase the half of the border width
572 | // that is 'inside' the bubble. So new bubbles should use outlineShape if possible.
573 | this.fillArea = this.outline.clone({ insert: false });
574 | Comical.setItemOnClick(this.fillArea, () => {
575 | Comical.activateBubble(this);
576 | });
577 |
578 | // Any stroke on the fill just confuses things.
579 | // An earlier version made the stroke transparent so that the outline border
580 | // would show through and the fill area would be shrunk to inside the border.
581 | // Then (I think) Comical was changed so that the border grows both ways from the ideal
582 | // line, and the fill is painted (under the border) all the way to the ideal line.
583 | // Thus, even a transparent border didn't stop the background from overlaying
584 | // the outline border, which is at a lower layer. So now we just remove the stroke
585 | // here, and adjust the width of the outline border. Unfortunately, this only works
586 | // well if the background color has no transparency.
587 | this.fillArea.strokeWidth = 0;
588 | this.outline.strokeWidth *= 2;
589 |
590 | this.fillArea.fillColor = this.getBackgroundColor();
591 |
592 | if (oldFillArea) {
593 | this.fillArea.insertBelow(oldFillArea);
594 | oldFillArea.remove();
595 | } else {
596 | this.upperLayer.addChild(this.fillArea);
597 | }
598 | }
599 | if (this.hasOuterBorder()) {
600 | var outerBorder = this.outline.clone();
601 | // We want two more borders, a thick one in the outerBorderColor,
602 | // and a thin black one (or perhaps eventually the color of the main
603 | // border, if we allow that to be controlled). However, doing the main outer border as a
604 | // stroke color is problematic: there doesn't seem to be a way to put
605 | // more than one stroke on a single shape, and if we try to wrap another
606 | // shape around it and use stroke, we'll tend to get white space in between,
607 | // especially since I also can't find a way to grow a shape by an exact
608 | // distance in all directions. So instead, we make a clone shape and set
609 | // its FILL color to the outerBorderColor...and then put it behind the
610 | // main shape so only the part outside it shows. And we can use its stroke for
611 | // the second outer border.
612 | outerBorder.fillColor = new paper.Color(this.getFullSpec().outerBorderColor!);
613 | outerBorder.insertBelow(this.outline);
614 | // Now we have to get it the right size, which is also tricky.
615 | // We want about 8 px of red. The overall shape will eventually be scaled
616 | // by this.content.offsetWidth/(contentHolder width), so first we
617 | // apply the inverse of that to 16 to get the absolute increase in width
618 | // that we want (there's a factor of two for border on each side).
619 | // Scaling is a fraction, so to make the outerBorder 16/scale wider, we scale by
620 | // (width + 16/scale)/width, which when multiplied by width is 16/scale bigger.
621 | // And similarly for the vertical scale, which is very likely different.
622 | const chWidth = (this.contentHolder as any).size.width;
623 | const hScale = this.content.offsetWidth / chWidth;
624 | const chHeight = (this.contentHolder as any).size.height;
625 | const vScale = this.content.offsetHeight / chHeight;
626 | const obWidth = outerBorder.bounds.width;
627 | const obHeight = outerBorder.bounds.height;
628 | outerBorder.scale((obWidth + 16 / hScale) / obWidth, (obHeight + 16 / vScale) / obHeight);
629 |
630 | // Visually this seems to give the right effect. I have not yet
631 | // figured out why the main border is not coming out as thick as I think
632 | // it should (and does, in the absense of the overlaying fill shape).
633 | outerBorder.strokeWidth = 1;
634 | // We don't have to insert this group, just make it and set it as this.outline,
635 | // so that when we adjustShapes() both shapes get adjusted.
636 | const newOutline = new paper.Group([outerBorder, this.outline]);
637 | this.outline = newOutline;
638 | }
639 | }
640 |
641 | public getDefaultContentHolder(): paper.Shape {
642 | const contentTopLeft = new paper.Point(this.content.offsetLeft, this.content.offsetTop);
643 | const contentSize = new paper.Size(this.content.offsetWidth, this.content.offsetHeight);
644 | const contentHolder = new paper.Shape.Rectangle(contentTopLeft, contentSize);
645 | contentHolder.name = "content-holder";
646 |
647 | // the contentHolder is normally removed, but this might be useful in debugging.
648 | contentHolder.strokeColor = new paper.Color("red");
649 | contentHolder.fillColor = new paper.Color("transparent");
650 |
651 | return contentHolder;
652 | }
653 |
654 | public getBorderWidth() {
655 | const style = this.getStyle();
656 | if (style === "rectangle") {
657 | return 1; // BL-11618, we want rectangle thickness to match the tail line
658 | }
659 | return style === "none" ? 0 : Bubble.defaultBorderWidth;
660 | }
661 |
662 | public getBackgroundColor(): paper.Color {
663 | const spec = this.getFullSpec();
664 | // enhance: we want to do gradients if the spec calls for it by having more than one color.
665 | // Issue: sharing the gradation process with any tails (and maybe
666 | // other bubbles in family??)
667 | if (!spec.backgroundColors && spec.style === "none") {
668 | // default background Color for style 'none' is now transparent
669 | spec.backgroundColors = ["transparent"];
670 | this.setBubbleSpec(spec);
671 | }
672 | if (spec.backgroundColors && spec.backgroundColors.length) {
673 | if (spec.backgroundColors.length === 1) {
674 | return new paper.Color(spec.backgroundColors[0]);
675 | }
676 |
677 | const gradient = new paper.Gradient();
678 | const stops: paper.GradientStop[] = [];
679 | // We want the gradient offsets evenly spaced from 0 to 1.
680 | spec.backgroundColors!.forEach((x, index) =>
681 | stops.push(
682 | new paper.GradientStop(new paper.Color(x), (1 / (spec.backgroundColors!.length - 1)) * index)
683 | )
684 | );
685 | gradient.stops = stops;
686 |
687 | const xCenter = this.content.offsetWidth / 2;
688 |
689 | // enhance: we'd like the gradient to extend over the whole fillArea,
690 | // but we can't depend on that existing when we need this, especially when
691 | // called by one of the tails. So just make one from the top of the content
692 | // to the bottom.
693 | // After introducing new algorithmic captions, it seems to work in all cases to use
694 | // the Y coordinate of the top of the box to the Y coordinate of the bottom.
695 | // Previously, captions seemed to need using 0 to height instead.
696 | //
697 | // enhance 2: If the tail is above the bubble, might make more sense to do gradient bottom -> top instead of top -> bottom
698 | const gradientOrigin = new paper.Point(xCenter, this.outline.bounds.top);
699 | const gradientDestination = new paper.Point(xCenter, this.outline.bounds.top + this.outline.bounds.height);
700 |
701 | // Old code which used 0 to height (seemed necessary for SVG captions)
702 | // const gradientOrigin = new paper.Point(xCenter, 0);
703 | // const gradientDestination = new paper.Point(xCenter, this.outline ? this.outline.bounds.height : 50);
704 |
705 | const result: paper.Color = new paper.Color(gradient, gradientOrigin, gradientDestination);
706 | return result;
707 | }
708 | return Comical.backColor;
709 | }
710 |
711 | // Adjusts the size and position of the shapes/tails to match the content element
712 | adjustSizeAndPosition() {
713 | var contentWidth = -1;
714 | var contentHeight = -1;
715 |
716 | if (this.content) {
717 | contentWidth = this.content.offsetWidth;
718 | contentHeight = this.content.offsetHeight;
719 | }
720 | if (contentWidth < 1 || contentHeight < 1) {
721 | // Horrible kludge until I can find an event that fires when the object is ready.
722 | window.setTimeout(() => {
723 | this.adjustSizeAndPosition();
724 | }, 100);
725 | return;
726 | }
727 | if (
728 | this.shapeIsComputed &&
729 | Math.abs(contentWidth - this.oldContentWidth) + Math.abs(contentHeight - this.oldContentHeight) > 0.001
730 | ) {
731 | const shape = this.getComputedShape()!;
732 | this.makeShapes(shape);
733 | }
734 | if (this.contentHolder) {
735 | const [desiredHScale, desiredVScale] = this.getScaleFactors();
736 | const scaleXBy = desiredHScale / this.hScale;
737 | const scaleYBy = desiredVScale / this.vScale;
738 |
739 | this.outline.scale(scaleXBy, scaleYBy);
740 | if (this.shadowShape) {
741 | this.shadowShape.scale(scaleXBy, scaleYBy);
742 | }
743 | if (this.fillArea) {
744 | this.fillArea.scale(scaleXBy, scaleYBy);
745 | }
746 | this.hScale = desiredHScale;
747 | this.vScale = desiredVScale;
748 | }
749 | const contentLeft = this.content.offsetLeft;
750 | const contentTop = this.content.offsetTop;
751 | let contentCenter = new paper.Point(contentLeft + contentWidth / 2, contentTop + contentHeight / 2);
752 | if (this.outline) {
753 | // it's just possible if shape is created asynchronously from
754 | // an SVG that this method is called to adjust tails before the main shape
755 | // exists. If so, it will be called again when it does.
756 |
757 | if (this.getBorderWidth() % 2 === 1) {
758 | // To draw odd width lines properly, we need the coordinates where they are drawn to end
759 | // in 0.5. If not already, make it so.
760 | // (Otherwise, canvas draws the line one pixel wider, with the two outer pixels being a
761 | // lighter color. This is particularly bad when we want a one-pixel black line (e.g., for a
762 | // rectangle) and get a two-pixel grey one (a secondary problem in BL-14075).)
763 | // This fix is mainly focused on rectangles, the only thing we currently draw with width one.
764 | // Oddly, this is always achieved by adding 0.5. For example, if the width is even, the initial
765 | // contentCenter.x is a whole number, adding 0.5 makes it end in 0.5, and subtracting half of it
766 | // to get left makes left end in 0.5, since half of it is a whole number. If the width is odd,
767 | // contentCenter.x starts out ending in 0.5, adding 0.5 makes it a whole number, but now
768 | // half of the width ends in 0.5, so subtracting that to get left again makes left end in 0.5.
769 | contentCenter = contentCenter.add(new paper.Point(0.5, 0.5));
770 | }
771 | this.outline.position = contentCenter;
772 | if (this.fillArea) {
773 | this.fillArea.position = contentCenter;
774 | }
775 | if (this.shadowShape) {
776 | // We shouldn't have a shadowShape at all unless we have a shadowOffset.
777 | // In case somehow we do, hide the shadow completely when that offset is
778 | // falsy by putting it entirely behind the main shapes.
779 | this.shadowShape.position = this.outline.position.add(this.spec.shadowOffset || 0);
780 | }
781 | }
782 | // Enhance: I think we could extract from this a method updateTailSpec
783 | // which loops over all the tails and if any tail's spec doesn't match the tail,
784 | // it turns off the mutation observer while updating the spec to match.
785 | // Such a method would be useful for updating the spec when the tail is dragged,
786 | // and perhaps for other things.
787 | this.tails.forEach(tail => {
788 | tail.adjustRoot(contentCenter);
789 | });
790 | // Now, look for a child whose joiner should be our center, and adjust that.
791 | const child = Comical.findChild(this);
792 | if (child) {
793 | console.assert(child.tails.length <= 1, "A child may only have at most 1 tail.");
794 |
795 | // Note: I think it's better to adjust the joiners even if they would subsequently be hidden.
796 | // This keeps the internal state looking more up-to-date and reasonable, even if it's invisible.
797 | // However, it is also possible to only adjust the joiners if they are not overlapping
798 | child.adjustJoiners(contentCenter);
799 |
800 | const shouldTailsBeVisible = !this.isOverlapping(child);
801 | child.tails.forEach(tail => {
802 | tail.setTailAndHandleVisibility(shouldTailsBeVisible);
803 | });
804 | }
805 |
806 | const parent = Comical.findParent(this);
807 | if (parent) {
808 | // Need to check both child and parent, because even if we loaded the bubbles in a certain order,
809 | // due to async nature, we can't be sure which one will be loaded first.
810 | // (This should only applicable to determining whether the tail is visible or not.
811 | // Don't think we need to adjust the joiners, they should all be loaded at that time.)
812 | const shouldTailsBeVisible = !this.isOverlapping(parent);
813 |
814 | console.assert(this.tails.length <= 1, "A child bubble may have at most 1 tail.");
815 | this.tails.forEach(tail => {
816 | tail.setTailAndHandleVisibility(shouldTailsBeVisible);
817 | });
818 | }
819 | this.uniteShapes();
820 | }
821 |
822 | private setOutline(outline: paper.Item): void {
823 | this.outline = outline;
824 | this.oulineStrokeColor = outline.strokeColor;
825 | }
826 |
827 | public uniteShapes() {
828 | // Since we have set the includeSelf flag, we are guaranteed that selfAndRelatives will contain
829 | // at least this (self) bubble.
830 | const selfAndRelatives = Comical.findRelatives(this, true);
831 | // Get rid of any old combinedShapes. This is especially important to do first when
832 | // switching to 'none' as no new one will be created.
833 | selfAndRelatives.forEach(r => {
834 | if (r.combinedShapes) {
835 | r.combinedShapes.remove();
836 | r.combinedShapes = undefined;
837 | }
838 | });
839 | const parent = selfAndRelatives[0];
840 | if (selfAndRelatives.some(r => !r.outline || !(r.outline as paper.PathItem).unite)) {
841 | // We can't do this before the outlines have been created, or if the
842 | // outline is some complex object, like a Group or an SVG import,
843 | // that doesn't implement the unite() function.
844 | return;
845 | }
846 | // Accumulates tails that can't be "united" with their bubbles.
847 | // Currently these are LineTails.
848 | const tailsToClip: Tail[] = [];
849 | let combinedPath: paper.PathItem = parent.outline.clone({ insert: false }) as paper.PathItem;
850 | combinedPath = parent.uniteTails(combinedPath, tailsToClip);
851 | // Now merge any remaining family members (note the first one, the parent, is skipped).
852 | for (var i = 1; i < selfAndRelatives.length; i++) {
853 | if (!selfAndRelatives[i].isOverlapping(selfAndRelatives[i - 1])) {
854 | // Child tails should overlap the shape we already have.
855 | // Joining the tail first may help to keep the shape simple, or at least contiguous.
856 | // Not sure this is necessary, unite() seems to handle discontiguous shapes.
857 | combinedPath = selfAndRelatives[i].uniteTails(combinedPath, tailsToClip);
858 | } else {
859 | // If the bubbles overlap we don't need the joining tail; just hide it.
860 | selfAndRelatives[i].tails.forEach(t => t.pathstroke && this.hideShape(t.pathstroke));
861 | }
862 | // We can be confident the outline is a PathItem because we checked
863 | // above that it has the unite() function.
864 | combinedPath = combinedPath.unite(selfAndRelatives[i].outline as paper.PathItem);
865 | this.hideShape(selfAndRelatives[i].outline);
866 | }
867 | combinedPath.strokeWidth = this.getBorderWidth();
868 | combinedPath.strokeColor = this.oulineStrokeColor;
869 | combinedPath.fillColor = this.getBackgroundColor();
870 | parent.combinedShapes = combinedPath;
871 | if (tailsToClip.length) {
872 | const groupItems: paper.PathItem[] = [combinedPath];
873 | tailsToClip.forEach(t => {
874 | if (!t.pathstroke) return;
875 |
876 | let clippedTail = t.pathstroke.clone({ insert: false }) as paper.PathItem;
877 | this.hideShape(t.pathstroke);
878 | selfAndRelatives.forEach(r => {
879 | // The following didn't work, not sure why not.
880 | // clippedTail = clippedTail.subtract(r.outline as PathItem, { insert: false });
881 | // For now the only tails we need to clip are lines.
882 | // This simple algorithm is enough. If the line crosses other
883 | // boxes besides the one it belongs to, it will simply be drawn.
884 | if (r.tails.includes(t)) {
885 | const outlinePath = r.outline as paper.PathItem;
886 | const intersections = outlinePath.getIntersections(clippedTail);
887 | if (intersections.length > 0) {
888 | clippedTail = (t as LineTail).makeShape(intersections[0].point);
889 | clippedTail.remove();
890 | }
891 | }
892 | });
893 | groupItems.push(clippedTail);
894 | });
895 |
896 | parent.combinedShapes = new paper.Group(groupItems);
897 | }
898 | parent.combinedShapes.insertBelow(parent.outline);
899 | parent.combinedShapes.sendToBack(); // maybe redundant?
900 | this.hideShape(parent.outline);
901 | selfAndRelatives.forEach(r => {
902 | if (r.shadowShape) {
903 | // Shadow needs to be behind the combinedShape.
904 | r.shadowShape.sendToBack();
905 | }
906 | });
907 | }
908 |
909 | uniteTails(combinedPath: paper.PathItem, tailsToClip: Tail[]): paper.PathItem {
910 | let result = combinedPath;
911 | this.tails.forEach(t => {
912 | if (t.canUnite() && t.pathstroke) {
913 | result = result.unite(t.pathstroke, { insert: false });
914 | this.hideShape(t.pathstroke);
915 | } else {
916 | tailsToClip.push(t);
917 | }
918 | });
919 | return result;
920 | }
921 |
922 | public static almostInvisibleColor = new paper.Color(1, 1, 1, 0.001);
923 |
924 | // The original shapes that we merge into the combinedShapes path still have click
925 | // actions and so forth attached, and are the things we move and reshape and then
926 | // use again to make new combinedShapes. So they need to stay around. But we don't
927 | // want to actually see them...that would defeat the purpose of combining them
928 | // into a single shape that omits interior lines. OTOH, they can't be completely
929 | // transparent, or paper.js wouldn't recognize clicks on them. So make them very,
930 | // very nearly transparent.
931 | hideShape(shape: paper.Item): void {
932 | shape.strokeColor = Bubble.almostInvisibleColor;
933 | shape.fillColor = Bubble.almostInvisibleColor;
934 | }
935 |
936 | getScaleFactors(): [number, number] {
937 | const contentWidth = this.content.offsetWidth;
938 | const contentHeight = this.content.offsetHeight;
939 |
940 | var holderWidth = (this.contentHolder as any).size.width;
941 | var holderHeight = (this.contentHolder as any).size.height;
942 | const desiredHScale = contentWidth / holderWidth;
943 | const desiredVScale = contentHeight / holderHeight;
944 |
945 | return [desiredHScale, desiredVScale];
946 | }
947 |
948 | // Returns true if the bubbles overlap. Otherwise, returns false
949 | public isOverlapping(otherBubble: Bubble): boolean {
950 | if (!this.outline || !otherBubble.outline) {
951 | // If at least one of the bubbles doesn't have its shape (yet), we define this as being not overlapping
952 | return false;
953 | }
954 |
955 | const isIntersecting = this.outline.intersects(otherBubble.outline);
956 | if (isIntersecting) {
957 | // This is the standard case (at least if an overlap does exist) where this bubble's outline intersects the other's outline
958 | return true;
959 | } else {
960 | // TODO: Check pathological case where one bubble is entirely contained within the other
961 | return false;
962 | }
963 | }
964 |
965 | // Returns true if the point is contained within the bubble itself (not including the tail).
966 | public isHitByPoint(point: paper.Point): boolean {
967 | if (!this.fillArea) {
968 | if (this.outline?.fillColor && this.outline.fillColor.alpha > 0) {
969 | return !!this.outline.hitTest(point);
970 | }
971 | // If style = none, then fillArea and outline can both be undefined
972 | // Do a hit test against the underlying content element directly (rather than the bubble fillArea, which doesn't exist)
973 | return this.isContentHitByPoint(point);
974 | }
975 |
976 | const hitResult: paper.HitResult | null = this.fillArea.hitTest(point);
977 | return !!hitResult;
978 | }
979 |
980 | // Returns true if the point is contained within the content element's borders.
981 | public isContentHitByPoint(point: paper.Point): boolean {
982 | // The point is relative to the canvas... so you need to make sure to get the position of the content relative to the canvas too.
983 | // OffsetLeft/Top works perfectly. It is relative to the offsetParent (which is the imageContainer).
984 | // It goes from the inside edge of the parent's border (which coincides with where the canvas element is placed)
985 | // to the outside edge of the content's border.
986 | // That is perfect, because that means 0, 0 for content's offsetLeft/Top will be 0, 0 in the coordinate system our Comical canvas uses.
987 | // Both systems are also independent of the zoom scaling
988 | // So that works perfectly!
989 |
990 | const left = this.content.offsetLeft;
991 | const right = left + this.content.offsetWidth; // FYI, includes content's borders, which I think is good.
992 | const top = this.content.offsetTop;
993 | const bottom = top + this.content.offsetHeight;
994 |
995 | return left <= point.x && point.x <= right && top <= point.y && point.y <= bottom;
996 | }
997 |
998 | private adjustJoiners(newTip: paper.Point): void {
999 | this.tails.forEach((tail: Tail) => {
1000 | if (tail.spec.joiner && tail.adjustTip(newTip)) {
1001 | this.persistBubbleSpecWithoutMonitoring();
1002 | }
1003 | });
1004 | }
1005 |
1006 | // Disables monitoring, executes the callback, then returns monitoring back to its previous state
1007 | private callWithMonitoringDisabled(callback: () => void) {
1008 | const wasMonitoring = !!this.observer;
1009 | this.stopMonitoring();
1010 |
1011 | callback();
1012 |
1013 | if (wasMonitoring) {
1014 | this.monitorContent();
1015 | }
1016 | }
1017 |
1018 | public stopMonitoring() {
1019 | if (this.observer) {
1020 | this.observer.disconnect();
1021 | this.observer = undefined;
1022 | }
1023 | }
1024 |
1025 | // Monitors for changes to the content element, and update this object if the content element is updated
1026 | monitorContent() {
1027 | this.observer = new MutationObserver(() => this.adjustSizeAndPosition());
1028 | this.observer.observe(this.content, {
1029 | attributes: true,
1030 | characterData: true,
1031 | childList: true,
1032 | subtree: true
1033 | });
1034 | }
1035 |
1036 | // A callback for after the shape is loaded/place.
1037 | // Figures out the information for the tail, then draws the shape and tail
1038 | private makeTail(desiredTail: TailSpec) {
1039 | const currentBubbleStyle = this.spec.style;
1040 | if (this.spec.tails.length === 0) {
1041 | return;
1042 | }
1043 |
1044 | const tipPoint = new paper.Point(desiredTail.tipX, desiredTail.tipY);
1045 | const midPoint = new paper.Point(desiredTail.midpointX, desiredTail.midpointY);
1046 | let startPoint = this.calculateTailStartPoint();
1047 |
1048 | activateLayer(this.upperLayer);
1049 |
1050 | // This code was initially written to have a system of different tails based on
1051 | // a tail style parameter, but actually the type of tail used turns out to be more
1052 | // based on which style of bubble is used.
1053 | let tail: Tail;
1054 | switch (currentBubbleStyle) {
1055 | // Currently thought tails are specific to thought bubbles.
1056 | // So these tails don't have their own style; instead,
1057 | // failing to match a known tail style, we fall through here
1058 | // and make one based on the bubble style.
1059 | case "thought":
1060 | tail = new ThoughtTail(
1061 | startPoint,
1062 | tipPoint,
1063 | midPoint,
1064 | this.lowerLayer,
1065 | this.upperLayer,
1066 | this.handleLayer,
1067 | desiredTail,
1068 | this
1069 | );
1070 | break;
1071 | // Currently captions have optional tails. If they have a tail,
1072 | // it will be a straight line. The 'none' style may soon be able to have tails too.
1073 | case "caption":
1074 | case "rectangle":
1075 | case "none": // Purposeful fall-through.
1076 | tail = new LineTail(
1077 | startPoint,
1078 | tipPoint,
1079 | this.lowerLayer,
1080 | this.upperLayer,
1081 | this.handleLayer,
1082 | desiredTail,
1083 | this
1084 | );
1085 | break;
1086 | // Currently every other bubble type uses an arc tail.
1087 | default:
1088 | tail = new ArcTail(
1089 | startPoint,
1090 | tipPoint,
1091 | midPoint,
1092 | this.lowerLayer,
1093 | this.upperLayer,
1094 | this.handleLayer,
1095 | desiredTail,
1096 | this
1097 | );
1098 | }
1099 |
1100 | tail.makeShapes();
1101 | tail.onClick(() => {
1102 | Comical.activateBubble(this);
1103 | });
1104 |
1105 | // keep track of the Tail shapes; eventually adjustSize will adjust its start position.
1106 | this.tails.push(tail);
1107 | }
1108 |
1109 | public showHandles() {
1110 | this.tails.forEach((tail: Tail) => {
1111 | tail.showHandles();
1112 | });
1113 | }
1114 |
1115 | public calculateTailStartPoint(): paper.Point {
1116 | return new paper.Point(
1117 | this.content.offsetLeft + this.content.offsetWidth / 2,
1118 | this.content.offsetTop + this.content.offsetHeight / 2
1119 | );
1120 | }
1121 |
1122 | public static makeDefaultTail(targetDiv: HTMLElement): TailSpec {
1123 | // careful here to use dimensions like offset that don't get inflated
1124 | // by transform:scale. getBoundingContextRect() would need to be unscaled.
1125 | const parent: HTMLElement = targetDiv.parentElement as HTMLElement;
1126 | const targetLeft = targetDiv.offsetLeft;
1127 | const targetWidth = targetDiv.offsetWidth;
1128 | const targetRight = targetLeft + targetWidth;
1129 |
1130 | const targetTop = targetDiv.offsetTop;
1131 | const targetHeight = targetDiv.offsetHeight;
1132 | const targetBottom = targetTop + targetHeight;
1133 |
1134 | const parentLeft = 0; // offset of target is already relative to parent.
1135 | const parentWidth = parent.offsetWidth;
1136 | const parentRight = parentLeft + parentWidth;
1137 |
1138 | const parentTop = 0;
1139 | const parentHeight = parent.offsetHeight;
1140 | // center of targetbox relative to parent.
1141 | const rootCenter = new paper.Point(
1142 | targetLeft - parentLeft + targetWidth / 2,
1143 | targetTop - parentTop + targetHeight / 2
1144 | );
1145 | let targetX = targetLeft - parentLeft - targetWidth / 2;
1146 | if (targetLeft - parentLeft < parentRight - targetRight) {
1147 | // box is closer to left than right...make the tail point right
1148 | targetX = targetRight - parentLeft + targetWidth / 2;
1149 | }
1150 | let targetY = targetBottom - parentTop + 20;
1151 | if (targetY > parentHeight - 5) {
1152 | targetY = parentHeight - 5;
1153 | }
1154 | if (targetY < targetBottom - parentTop) {
1155 | // try pointing up
1156 | targetY = targetTop - parentTop - 20;
1157 | if (targetY < 5) {
1158 | targetY = 5;
1159 | }
1160 | }
1161 | // Final checks: make sure the target is at least in the picture.
1162 | if (targetX < 0) {
1163 | targetX = 0;
1164 | }
1165 | if (targetX > parentWidth) {
1166 | targetX = parentWidth;
1167 | }
1168 | if (targetY < 0) {
1169 | targetY = 0;
1170 | }
1171 | if (targetY > parentHeight) {
1172 | targetY = parentHeight;
1173 | }
1174 | const target = new paper.Point(targetX, targetY);
1175 | const mid: paper.Point = Bubble.defaultMid(
1176 | rootCenter,
1177 | target,
1178 | new paper.Size(targetDiv.offsetWidth, targetDiv.offsetHeight)
1179 | );
1180 | const result: TailSpec = {
1181 | tipX: targetX,
1182 | tipY: targetY,
1183 | midpointX: mid.x,
1184 | midpointY: mid.y,
1185 | autoCurve: true
1186 | };
1187 | return result;
1188 | }
1189 |
1190 | static adjustTowards(origin: paper.Point, target: paper.Point, originSize: paper.Size): paper.Point {
1191 | // Return the origin point adjusted along a line towards target far enough to fall on
1192 | // the border of a retangle of size originSize centered at origin.
1193 | let delta = target.subtract(origin);
1194 | const xRatio = delta.x == 0 ? Number.MAX_VALUE : originSize.width / 2 / Math.abs(delta.x);
1195 | const yRatio = delta.y == 0 ? Number.MAX_VALUE : originSize.height / 2 / Math.abs(delta.y);
1196 | const borderRatio = Math.min(xRatio, yRatio); // use whichever is closer
1197 | return origin.add(delta.multiply(borderRatio));
1198 | }
1199 |
1200 | // Find the default midpoint for a tail from start to target, given the sizes of the
1201 | // bubbles at start and possibly (if start is a child) at target.
1202 | // First, we compute a point half way between where the line from start to target
1203 | // crosses the rectangle(s) of the specified size(s) centered at the points...an
1204 | // approximation of half way between the bubbles, or between the bubble and the tip.
1205 | // Then we bump it a little to one side so that the curve bends slightly towards
1206 | // the y axis, by an amount that decreases to zero as the line approaches
1207 | // horizontal or vertical.
1208 | static defaultMid(
1209 | start: paper.Point,
1210 | target: paper.Point,
1211 | startSize: paper.Size,
1212 | targetSize?: paper.Size
1213 | ): paper.Point {
1214 | const startBorderPoint = Bubble.adjustTowards(start, target, startSize);
1215 | const targetBorderPoint = targetSize ? Bubble.adjustTowards(target, start, targetSize) : target;
1216 |
1217 | let delta = targetBorderPoint.subtract(startBorderPoint);
1218 | const mid = startBorderPoint.add(delta.divide(2));
1219 |
1220 | delta = delta.divide(5);
1221 | delta.angle -= 90;
1222 | // At this point, delta is 10% of the distance from start to target,
1223 | // at right angles to that line, and on the side of it toward
1224 | // the y axis. We prefer the line to curve in that direction,
1225 | // both above and below the x axis.
1226 |
1227 | // Now, we want to reduce the curvature if the line is close to
1228 | // horizontal or vertical. This is in line with comic expectations;
1229 | // it also has the benefit that as the tip is dragged from one
1230 | // quadrant to another, the transition is smooth, as the curve
1231 | // reduces to a line and then starts to bend the other way rather
1232 | // than suddenly jumping from one quadrant's rule to the other.
1233 | if (Math.abs(delta.x) > Math.abs(delta.y)) {
1234 | delta.length *= delta.y / delta.x;
1235 | } else {
1236 | delta.length *= delta.x / delta.y;
1237 | }
1238 | return mid.add(delta);
1239 | }
1240 |
1241 | // This is a helper method which is useful for making a variety of computed bubble types.
1242 | // It returns a Group containing a path and a content-holder rectangle
1243 | // as makeShapes requires, given a function that makes a path from
1244 | // an array of points and a center.
1245 | // The content-holder rectangle is made a bit smaller than the actual height and width
1246 | // of the bubble's content; makeShapes will scale it back up.
1247 | // The array of points is created by first making our standard speech bubble shape
1248 | // for a text box this shape (plus the requested padding, if any),
1249 | // then breaking it up into segments about 30px long.
1250 | // A little randomness (but predictable for a box of a given size) is
1251 | // introduced to make things look more natural.
1252 | // Enhance: could provide some parameter to control the ratio between
1253 | // the border length and the number of points.
1254 | makeBubbleItem(
1255 | padWidth: number,
1256 | pathMaker: (points: paper.Point[], center: paper.Point) => paper.Path
1257 | ): paper.Item {
1258 | const width = this.content.clientWidth;
1259 | const height = this.content.clientHeight;
1260 | const [outlineShape, contentHolder] = makeSpeechBubbleParts(
1261 | width + padWidth * 2,
1262 | height + padWidth * 2,
1263 | 0.6,
1264 | 0.8
1265 | );
1266 | outlineShape.remove(); // don't want it on the canvas, just use it to make points.
1267 |
1268 | // contentHolder isn't actually width+padWidth*2 wide and height + padWidth * 2 high.
1269 | // It's a rectangle fitting inside an oval that size.
1270 | // We want contentHolder to end up a size such that, when it is scaled to (width, height),
1271 | // the oval is padWidth outside it.
1272 | // This is difficult to visualize. The "playing with beziers" story may help. We've
1273 | // asked for a speech bubble width + padWidth*2 wide...that's the width of the blue
1274 | // oval. Along with it we get a contentHolder, like the red rectangle, that touches
1275 | // the oval. We now want to make contentHolder smaller, so the red rectangle doesn't
1276 | // touch the oval, but is 'padWidth' clear of it in both directions. However, we don't
1277 | // just want it to be smaller by padWidth...we want that much clearance AFTER other
1278 | // code makes the transformation that maps contentHolder onto our bubble's content
1279 | // (which is 'width' wide).
1280 | //
1281 | // We want to solve for xRatio, which is the scaling factor we need to apply to
1282 | // the eventual content holder (with the new width we are about to set) to grow
1283 | // it (and the final bubble shape) to fit our content (width wide).
1284 | // To do so, we can set up a system of 3 equations, 3 unknowns, and then use algebra
1285 | // The equations we know:
1286 | // 1) xRatio = shrunkPadWidth / padWidth
1287 | // 2) xRatio = newContentHolderWidth / width
1288 | // 3) newContentHolderWidth + 2 * shrunkPadWidth = contentHolderWidth
1289 | // where xRatio is the ratio between what we're going to make the contentHolder width
1290 | // and the actual width of the content, and shrunkPadWidth is the pad width
1291 | // we need in the shape we're making (that will be scaled up by xRatio to padWidth).
1292 | //
1293 | // Of these, xRatio, shrunkPadWidth, and newContentHolderWidth are variables.
1294 | // (padWidth, width, and contentHolderWidth are already known)
1295 | // From here, you can do algebra to get this:
1296 | const xRatio = contentHolder.size.width / (width + padWidth * 2);
1297 | const yRatio = contentHolder.size.height / (height + padWidth * 2);
1298 | contentHolder.set({
1299 | center: contentHolder.position,
1300 | size: new paper.Size(width * xRatio, height * yRatio)
1301 | });
1302 |
1303 | // aiming for arcs ~30px long, but fewer than 5 would look weird.
1304 | const computedArcCount = Math.round(((width + height) * 2) / 30);
1305 | const arcCount = Math.max(computedArcCount, 5);
1306 | const points: paper.Point[] = [];
1307 |
1308 | // We need a 'random' number generator that is predictable so
1309 | // the points don't move every time we open the page.
1310 | const rng = new SimpleRandom(width + height);
1311 |
1312 | let remainingLength = outlineShape.length;
1313 | const delta = remainingLength / arcCount;
1314 | const maxJitter = delta / 2;
1315 | for (let i = 0; i < arcCount; i++) {
1316 | const expectedPlace = i * delta;
1317 | // This introduces a bit of randomness to make it look more natural.
1318 | // (Since we're working around an oval, it doesn't matter whether
1319 | // all the jitters are positive or whether they go both ways,
1320 | // except that we have to be careful not to produce a negative
1321 | // actualPlace or one beyond the length of the curve, since that
1322 | // will throw an exception.)
1323 | const jitter = maxJitter * rng.nextDouble();
1324 | const actualPlace = expectedPlace + jitter;
1325 | const point = outlineShape.getLocationAt(actualPlace).point;
1326 | points.push(point);
1327 | }
1328 |
1329 | const outline = pathMaker(points, contentHolder.position!);
1330 | return new paper.Group([outline, contentHolder]);
1331 | }
1332 |
1333 | // Make a computed bubble shape by drawing an inward arc between each pair of points
1334 | // in the array produced by makeBubbleItem.
1335 | makePointedArcBubble(): paper.Item {
1336 | const arcDepth = 7;
1337 | return this.makeBubbleItem(arcDepth, (points, center) => {
1338 | const outline = new paper.Path();
1339 | for (let i = 0; i < points.length; i++) {
1340 | const start = points[i];
1341 | const end = i < points.length - 1 ? points[i + 1] : points[0];
1342 | const mid = new paper.Point((start.x + end.x) / 2, (start.y + end.y) / 2);
1343 | const deltaCenter = mid.subtract(center);
1344 | deltaCenter.length = arcDepth;
1345 | const arcPoint = mid.subtract(deltaCenter);
1346 | const arc = new paper.Path.Arc(start, arcPoint, end);
1347 | arc.remove();
1348 | outline.addSegments(arc.segments);
1349 | }
1350 | outline.strokeWidth = 1;
1351 | outline.strokeColor = new paper.Color("black");
1352 | outline.closed = true; // It should already be, but may help paper.js to treat it so.
1353 | outline.name = "outlineShape";
1354 | return outline;
1355 | });
1356 | }
1357 |
1358 | // The SVG contents of a round (elliptical) bubble
1359 | //
1360 | // Note: An algorithmic (getComputedShapes) version of an ellipse exists here: https://github.com/BloomBooks/comical-js/blob/algorithimicEllipses/src/ellipseBubble.ts
1361 | // The scaled ellipse bubble there is mathematically better proportioned and positioned, simpler, and probably faster than the SVG one here
1362 | // However, as a result of the improvement, it is only very similar but not 100% identical to this existing SVG one
1363 | public static ellipseBubble() {
1364 | return `
1365 | `;
1410 | }
1411 |
1412 | // The SVG contents of a shout bubble (jagged / exploding segments coming out)
1413 | public static shoutBubble() {
1414 | return `
1415 | `;
1456 | }
1457 | }
1458 |
--------------------------------------------------------------------------------
/src/bubbleSpec.ts:
--------------------------------------------------------------------------------
1 | // This is the interface for storing the state of a bubble.
2 |
3 | // BubbleSpecPattern actually defines all the BubbleSpec properties, but
4 | // all of them are optional, for use in methods designed to allow a subset
5 | // of properties to be changed. BubbleSpec overrides to make a minimal set required.
6 | // The main purpose of this class is to store the state of a bubble in a modified JSON
7 | // string in the data-bubble attribute of an HTML element. See the Bubble methods
8 | // setBubbleSpec and getBubbleSpec. This allows re-creatting the paper.js editable
9 | // state of the bubble even after we have discarded all that in favor of an SVG
10 | // for a finished HTML document that doesn't depend on Javascript.
11 | // If you add a property here, consider handling it in Bubble.mergeWithNewBubbleProps()
12 | export interface BubbleSpecPattern {
13 | version?: string; // currently 1.0
14 | style?: string; // The style of bubble, e.g. speech, shout, caption, pointedArcs, ellipse
15 | tails?: TailSpec[];
16 | level?: number; // relative z-index, bubbles with same one merge, larger overlay (not implemented yet)
17 | borderStyle?: string; // not implemented or fully designed yet
18 |
19 | // Optional: Support rounded corners. Currently only applicable if style=caption or rectangle.
20 | // For square corners, leave undefined or set to 0.
21 | // For round corners, both must be defined and non-zero.
22 | cornerRadiusX?: number;
23 | cornerRadiusY?: number;
24 |
25 | // Just 1 color for solid, multiple for gradient (top to bottom). Omit for white.
26 | // Individual strings can be things that can be passed to paper.js to define colors.
27 | // Typical CSS color names are supported, and also #RRGGBB. Possibly others, but lets not
28 | // count on any more options yet.
29 | backgroundColors?: string[];
30 | outerBorderColor?: string; // omit for none.
31 |
32 | // bubbles on the same level with this property are linked in an order specified by this.
33 | // bubbles without order (or with order zero) are not linked.
34 | // Do not use negative numbers or zero as an order.
35 | order?: number;
36 | shadowOffset?: number;
37 | }
38 |
39 | export interface BubbleSpec extends BubbleSpecPattern {
40 | // A real bubble stored in data-bubble should always have at least version, style, tips, and level.
41 | // The only things overridden here should be to change something from optional to required.
42 | version: string; // currently 1.0
43 | style: string; // currently one of speech or shout
44 | tails: TailSpec[];
45 | }
46 | // Design has a parentBubble attribute...not sure whether we need this, so leaving out for now.
47 | // Do we need to control things like angle of gradient?
48 |
49 | export interface TailSpec {
50 | tipX: number; // tip point, relative to the main image on which the bubble is placed
51 | tipY: number;
52 | midpointX: number; // notionally, tip's curve passes through this point
53 | midpointY: number;
54 | joiner?: boolean; // true if it joins to its parent bubble
55 |
56 | // [This optional tail style parameter is not currently used at all.]
57 | style?: string; // currently one of straight, or arc
58 | // true to automatically keep the shape of the tail matching
59 | // Comical's default shape for the current root and tip when
60 | // either moves. False for the user to control the shape using
61 | // other handles, if any (though Comical may still move the
62 | // other handles to some extent when the tip or root move).
63 | autoCurve?: boolean;
64 | }
65 | // Do we need to specify a width? Other attributes for bezier curve?
66 | // Current design: start with three points, the target, midpoint, and the root (center of the text block).
67 | // imagine a straight line from the root to the target.
68 | // Imagine two line segments at right angles to this, a base length centered at the root,
69 | // and half that length centered at the midpoint.
70 | // The tip is made up of two curves each defined by the target, one end of the root segment,
71 | // and the corresponding end of the midpoint segment.
72 |
--------------------------------------------------------------------------------
/src/captionBubble.ts:
--------------------------------------------------------------------------------
1 | import paper = require("paper");
2 | import { Bubble } from "./bubble";
3 |
4 | // bubble: The bubble around which to make a caption
5 | // cornerRadii - Omit or pass undefined for square corners. For rounded corners, pass in the two radius amounts as a Size object.
6 |
7 | export function makeCaptionBox(
8 | bubble: Bubble,
9 | cornerRadii: paper.Size | undefined,
10 | thickness: number,
11 | padding: number
12 | ): paper.Item {
13 | const contentHolder = bubble.getDefaultContentHolder();
14 | const contentBounds = contentHolder.bounds;
15 |
16 | const outline = makeOutline(contentBounds, cornerRadii, thickness, padding);
17 |
18 | const result = new paper.Group([outline, contentHolder]);
19 | return result;
20 | }
21 |
22 | // The outline will be delta outside the bounds. Typically delta is the border thickness.
23 | function makeOutline(
24 | bounds: paper.Rectangle,
25 | cornerRadii: paper.Size | undefined,
26 | thickness: number,
27 | padding: number
28 | ): paper.Path.Rectangle {
29 | const outlineTopLeft = bounds.topLeft.subtract(thickness + padding);
30 | const outlineSize = new paper.Size(bounds.size.add((padding + thickness) * 2));
31 |
32 | const outlineRect = new paper.Rectangle(outlineTopLeft, outlineSize);
33 | const outline = new paper.Path.Rectangle(outlineRect, cornerRadii);
34 |
35 | outline.name = "outlineShape";
36 | outline.strokeColor = new paper.Color("black");
37 | return outline;
38 | }
39 |
--------------------------------------------------------------------------------
/src/circleBubble.ts:
--------------------------------------------------------------------------------
1 | import paper = require("paper");
2 |
3 | // Implements a simple circular bubble.
4 | // Eventually we'd like to use https://developer.mozilla.org/en-US/docs/Web/CSS/shape-outside to try to make the
5 | // text wrap optimally to fill the bubble. But that requires at least Firefox 62 and Bloom Editor is still stuck
6 | // on GeckoFx 60. (And it may not be trivial, even so.)
7 | export function makeCircleBubble(width: number, height: number): paper.Item {
8 | if (width <= 0) {
9 | console.assert(false, `Invalid width. Received: ${width}. Expected: width > 0`);
10 | width = 1;
11 | }
12 | if (height <= 0) {
13 | console.assert(false, `Invalid height. Received: ${height}. Expected: height > 0`);
14 | height = 1;
15 | }
16 | // Because of the way the bubble code aligns the content-holder
17 | // rectangle with the content element, the values used for top and left
18 | // currently make absolutely no difference. They translate the bubble,
19 | // and the content-holder alignment adjusts for it. I'm keeping the
20 | // variables in case we might want control over the position one day
21 | // (maybe in debugging?).
22 | const top = 0;
23 | const left = 0;
24 | const xCenter = left + width / 2;
25 | const yCenter = top + height / 2;
26 | const radius = Math.sqrt(xCenter * xCenter + yCenter * yCenter); // fix if top/left not zero
27 | const path = new paper.Path.Circle(new paper.Point(xCenter, yCenter), radius);
28 | path.name = "outlineShape";
29 | path.strokeColor = new paper.Color("black");
30 | const contentHolder = new paper.Shape.Rectangle(
31 | new paper.Point(left, top),
32 | new paper.Point(left + width, top + height)
33 | );
34 | contentHolder.name = "content-holder";
35 |
36 | // the contentHolder is normally removed, but this might be useful in debugging.
37 | contentHolder.strokeColor = new paper.Color("red");
38 | contentHolder.fillColor = new paper.Color("transparent");
39 | const result = new paper.Group([path, contentHolder]);
40 | return result;
41 | }
42 |
--------------------------------------------------------------------------------
/src/comical.ts:
--------------------------------------------------------------------------------
1 | import paper = require("paper");
2 |
3 | import { Bubble } from "./bubble";
4 | import { uniqueIds } from "./uniqueId";
5 | import { BubbleSpec } from "bubbleSpec";
6 | import { ContainerData } from "./containerData";
7 |
8 | // Manages a collection of comic bubbles wrapped around HTML elements that share a common parent.
9 | // Each element that has a comic bubble has a data-bubble attribute specifying the appearance
10 | // of the bubble. Comical can help with initializing this to add a bubble to an element.
11 | // The data-bubble attributes contain a modified JSON representation of a BubbleSpec
12 | // describing the bubble.
13 | // Comical is designed to be the main class exported by Comical.js, and provides methods
14 | // for setting things up (using a canvas overlayed on the common parent of the bubbles
15 | // and paper.js shapes) so that the bubbles can be edited by dragging handles.
16 | // It also supports drawing groups of bubbles in layers, with appropriate merging
17 | // of bubbles at the same level.
18 | // As the bubbles are edited using Comical handles, the data-bubble attributes are
19 | // automatically updated. It's also possible to alter a data-bubble attribute using
20 | // external code, and tell Comical to update things to match.
21 | // Finally, Comical can replace a finished bubble canvas with a single SVG, resulting in
22 | // a visually identical set of bubbles that can be rendered without using Canvas and
23 | // Javascript.
24 |
25 | interface IUserInterfaceProperties {
26 | tailHandleColor: string;
27 | }
28 |
29 | export class Comical {
30 | static backColor = new paper.Color("white");
31 |
32 | //client can change this using setUserInterfaceProperties
33 | public static tailHandleColor = new paper.Color("orange");
34 |
35 | private static selectorForBubblesWhichTailMidpointMayOverlap = "";
36 |
37 | static activeContainers = new Map();
38 |
39 | static activeBubble: Bubble | undefined;
40 |
41 | static activeBubbleListener: ((active: HTMLElement | undefined) => void) | undefined;
42 |
43 | // This is something the client calls only once when setting up comical. After that,
44 | // there is no attempt to update anything already showing on screen.
45 | public static setUserInterfaceProperties(props: IUserInterfaceProperties) {
46 | Comical.tailHandleColor = new paper.Color(props.tailHandleColor);
47 | }
48 |
49 | public static setSelectorForBubblesWhichTailMidpointMayOverlap(selector: string) {
50 | Comical.selectorForBubblesWhichTailMidpointMayOverlap = selector;
51 | }
52 | static getSelectorForBubblesWhichTailMidpointMayOverlap(): string {
53 | return Comical.selectorForBubblesWhichTailMidpointMayOverlap;
54 | }
55 |
56 | public static startEditing(parents: HTMLElement[]): void {
57 | parents.forEach(parent => Comical.convertBubbleJsonToCanvas(parent));
58 | }
59 |
60 | public static stopEditing(): void {
61 | const keys: HTMLElement[] = [];
62 | Comical.activeContainers.forEach((value, key: HTMLElement) => {
63 | // Possibly we could just call convertCanvasToSvgImg(key) here,
64 | // but each such call deletes key from Comical.editElements,
65 | // so we'd be modifying the collection we're iterating over,
66 | // which feels dangerous.
67 | keys.push(key);
68 | });
69 | keys.forEach(key => Comical.convertCanvasToSvgImg(key));
70 | }
71 |
72 | public static convertCanvasToSvgImg(parent: HTMLElement) {
73 | const canvas = parent.getElementsByTagName("canvas")[0];
74 | if (!canvas) {
75 | return;
76 | }
77 | const containerData = this.activeContainers.get(parent);
78 | if (!containerData) {
79 | console.error("attempting convertCanvasToSvgImg on non-active element");
80 | return;
81 | }
82 | const bubbles = containerData.bubbleList;
83 | if (bubbles.length !== 0 && Comical.isAnyBubbleVisible(bubbles)) {
84 | // It's quite plausible for there to be no bubbles;
85 | // we may have turned on bubble editing just in case one
86 | // got added. But if none did, we have no handles to clean up,
87 | // and more importantly, no need to create an SVG.
88 |
89 | // Remove drag handles
90 | containerData.project
91 | .getItems({
92 | recursive: true,
93 | match: (x: any) => {
94 | return x.name && x.name.startsWith("handle");
95 | }
96 | })
97 | .forEach(x => x.remove());
98 | const svg = containerData.project.exportSVG() as SVGElement;
99 | svg.classList.add("comical-generated");
100 | uniqueIds(svg);
101 | canvas.parentElement!.insertBefore(svg, canvas);
102 | }
103 | canvas.remove();
104 | Comical.stopMonitoring(parent);
105 | this.activeContainers.delete(parent);
106 | }
107 |
108 | private static isAnyBubbleVisible(bubbles: Bubble[]): boolean {
109 | for (let i = 0; i < bubbles.length; i++) {
110 | if (!bubbles[i].isTransparent()) {
111 | return true;
112 | }
113 | }
114 | return false;
115 | }
116 |
117 | // This logic is designed to prevent accumulating mutation observers.
118 | // Not yet fully tested.
119 | private static stopMonitoring(parent: HTMLElement) {
120 | const containerData = Comical.activeContainers.get(parent);
121 | if (containerData) {
122 | containerData.bubbleList.forEach(bubble => bubble.stopMonitoring());
123 | }
124 | }
125 |
126 | // Make the bubble for the specified element (if any) active. This means
127 | // showing its edit handles. Must first call convertBubbleJsonToCanvas(),
128 | // passing the appropriate parent element.
129 | public static activateElement(contentElement: HTMLElement | undefined) {
130 | let newActiveBubble: Bubble | undefined = undefined;
131 | if (contentElement) {
132 | newActiveBubble = Comical.getBubblesInSameCanvas(contentElement).find(x => x.content === contentElement);
133 | }
134 | Comical.activateBubble(newActiveBubble);
135 | }
136 |
137 | // Make active (show handles) the specified bubble.
138 | public static activateBubble(newActiveBubble: Bubble | undefined) {
139 | if (newActiveBubble == Comical.activeBubble) {
140 | return;
141 | }
142 | Comical.hideHandles();
143 | Comical.activeBubble = newActiveBubble;
144 | if (Comical.activeBubble) {
145 | Comical.activeBubble.showHandles();
146 | }
147 | if (Comical.activeBubbleListener) {
148 | Comical.activeBubbleListener(Comical.activeBubble ? Comical.activeBubble.content : undefined);
149 | }
150 | }
151 |
152 | public static hideHandles() {
153 | Comical.activeContainers.forEach(container => {
154 | if (container.handleLayer) {
155 | container.handleLayer.removeChildren();
156 | }
157 | });
158 | }
159 |
160 | // call after adding or deleting elements with data-bubble
161 | // assumes convertBubbleJsonToCanvas has been called and canvas exists
162 | public static update(container: HTMLElement) {
163 | Comical.stopMonitoring(container);
164 | const containerData = this.activeContainers.get(container);
165 | if (!containerData) {
166 | console.error("invoked update on an element that is not active");
167 | return; // nothing sensible we can do
168 | }
169 | containerData.project.activate();
170 | while (containerData.project.layers.length > 1) {
171 | const layer = containerData.project.layers.pop();
172 | if (layer) {
173 | layer.remove(); // Erase this layer
174 | }
175 | }
176 | if (containerData.project.layers.length > 0) {
177 | containerData.project.layers[0].activate();
178 | }
179 | containerData.project.activeLayer.removeChildren();
180 |
181 | const elements = container.ownerDocument!.evaluate(
182 | ".//*[@data-bubble]",
183 | container,
184 | null,
185 | XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
186 | null
187 | );
188 | const bubbles: Bubble[] = [];
189 | containerData.bubbleList = bubbles;
190 |
191 | var zLevelList: number[] = [];
192 | for (let i = 0; i < elements.snapshotLength; i++) {
193 | const element = elements.snapshotItem(i) as HTMLElement;
194 | const bubble = new Bubble(element);
195 | bubbles.push(bubble);
196 |
197 | let zLevel = bubble.getSpecLevel();
198 | if (!zLevel) {
199 | zLevel = 0;
200 | }
201 | zLevelList.push(zLevel);
202 | }
203 |
204 | // Ensure that they are in ascending order
205 | zLevelList.sort();
206 |
207 | // First we need to create all the layers in order. (Because they automatically get added to the end of the project's list of layers)
208 | // Precondition: Assumes zLevelList is sorted.
209 | const levelToLayer = {};
210 | for (let i = 0; i < zLevelList.length; ++i) {
211 | // Check if different than previous. (Ignore duplicate z-indices)
212 | if (i == 0 || zLevelList[i - 1] != zLevelList[i]) {
213 | const zLevel = zLevelList[i];
214 | var lowerLayer = new paper.Layer();
215 | var upperLayer = new paper.Layer();
216 | levelToLayer[zLevel] = [lowerLayer, upperLayer];
217 | }
218 | }
219 | containerData.handleLayer = new paper.Layer();
220 |
221 | // Now that the layers are created, we can go back and place objects into the correct layers and ask them to draw themselves.
222 | for (let i = 0; i < bubbles.length; ++i) {
223 | const bubble = bubbles[i];
224 |
225 | let zLevel = bubble.getSpecLevel();
226 | if (!zLevel) {
227 | zLevel = 0;
228 | }
229 |
230 | const [lowerLayer, upperLayer] = levelToLayer[zLevel];
231 | bubble.setLayers(lowerLayer, upperLayer, containerData.handleLayer);
232 | bubble.initialize();
233 | }
234 | }
235 |
236 | // Sorts an array of bubbles such that the highest level comes first
237 | // Does an in-place sort
238 | private static sortBubbleListTopLevelFirst(bubbleList: Bubble[]): void {
239 | bubbleList.sort((a, b) => {
240 | let levelA = a.getBubbleSpec().level;
241 | if (!levelA) {
242 | levelA = 0;
243 | }
244 |
245 | let levelB = b.getBubbleSpec().level;
246 | if (!levelB) {
247 | levelB = 0;
248 | }
249 |
250 | // Sort in DESCENDING order, highest level first
251 | return levelB - levelA;
252 | });
253 | }
254 | // Get max level of elements in the same canvas as element
255 | public static getMaxLevel(element: HTMLElement): number {
256 | const bubblesInSameCanvas = Comical.getBubblesInSameCanvas(element);
257 | if (bubblesInSameCanvas.length === 0) {
258 | return 0;
259 | }
260 | let maxLevel = Number.MIN_VALUE;
261 | bubblesInSameCanvas.forEach(b => (maxLevel = Math.max(maxLevel, b.getBubbleSpec().level || 0)));
262 | return maxLevel;
263 | }
264 |
265 | // Arrange for the specified action to occur when the item is clicked.
266 | // The natural way to do this would be item.onClick = clickAction.
267 | // However, paper.js has a bug that makes it treat clicks as being in
268 | // the wrong place when a scale is applied to the canvas (as at least one
269 | // client does to produce a zoom effect). So we instead attach our own
270 | // click handler to the whole canvas, perform the hit test correctly,
271 | // and if an item is clicked and its data object has this property,
272 | // we invoke it. Item.data is not used by Paper.js; it's just an 'any' we
273 | // can use for anything we want. In Comical, we use it for some mouse
274 | // event handlers.
275 | static setItemOnClick(item: paper.Item, clickAction: () => void): void {
276 | if (!item.data) {
277 | item.data = {};
278 | }
279 | item.data.onClick = clickAction;
280 | }
281 |
282 | // Return true if a click at the specified point (relative to the top left
283 | // of the specified container element) will hit something draggable.
284 | // Note: the name of this method reflects the intention to identify points NEAR
285 | // something draggable...say, close enough that it would be good to convert a
286 | // hand cursor to a pointer for accurate dragging of small targets like tail handles.
287 | // The current implementation is cruder and returns true only if the point directly
288 | // hits something draggable.
289 | public static isDraggableNear(element: HTMLElement, x: number, y: number): boolean {
290 | const containerData = Comical.activeContainers.get(element);
291 | if (!containerData) {
292 | return false;
293 | }
294 | const where = new paper.Point(x, y);
295 | const hit = containerData.project.hitTest(where);
296 | return hit && hit.item && hit.item.data && hit.item.data.hasOwnProperty("onDrag");
297 | }
298 |
299 | public static convertBubbleJsonToCanvas(parent: HTMLElement) {
300 | const canvas = parent.ownerDocument!.createElement("canvas");
301 | canvas.style.position = "absolute";
302 | canvas.style.top = "0";
303 | canvas.style.left = "0";
304 | canvas.classList.add("comical-generated");
305 | canvas.classList.add("comical-editing");
306 | const oldSvg = parent.getElementsByClassName("comical-generated")[0];
307 | if (oldSvg) {
308 | oldSvg.parentElement!.insertBefore(canvas, oldSvg);
309 | oldSvg.remove();
310 | } else {
311 | parent.insertBefore(canvas, parent.firstChild); // want to use prepend, not in FF45.
312 | }
313 |
314 | // Even though clientWidth seems to return the unscaled values, we observe that
315 | // when this gets written to HTML, the width attribute is parent.clientWidth * scalingFactor.
316 | // Then, the browser will apply the scaling factor to it A SECOND TIME, which is bad :(
317 | //
318 | // To get around this, we prematurely divided by the scaling factor, so that later on the width/height attributes
319 | // will return their unscaled values.
320 | const scaling = this.getScaling(parent);
321 | canvas.width = parent.clientWidth / scaling.x;
322 | canvas.height = parent.clientHeight / scaling.y;
323 |
324 | paper.setup(canvas); // updates the global project variable to a new project associated with this canvas
325 |
326 | // Now we set up some mouse event handlers. It would be much nicer to use the paper.js
327 | // handlers, like the mousedown and mousedrag properties of each paper.js Item,
328 | // but they get the coordinates wrong when mousing on an object that has transform:scale.
329 | // (See https://github.com/paperjs/paper.js/issues/1729; also the 'playing with scale'
330 | // test case (which demonstrates the problem) and the "three bubbles on picture" one
331 | // which can be used to check that Comical works when scaled).
332 | // So instead, we set up canvas-level event handlers for the few mouse events we care about.
333 |
334 | // We have to keep track of the most recent mouse-down item, because if it has a drag handler,
335 | // we want to keep invoking it even if the mouse gets out of the item, as long as it is down.
336 | let dragging: paper.Item | null = null; // null instead of undefined to match HitResult.item.
337 | const myProject = paper.project!; // event handlers must use the one that is now active, not what is current when they run
338 | canvas.addEventListener("mousedown", (event: MouseEvent) => {
339 | const where = new paper.Point(event.offsetX, event.offsetY);
340 | const hit = myProject.hitTest(where);
341 | dragging = hit ? hit.item : null;
342 | });
343 | canvas.addEventListener("mousemove", (event: MouseEvent) => {
344 | const where = new paper.Point(event.offsetX, event.offsetY);
345 | if (dragging && event.buttons === 1 && dragging.data && dragging.data.hasOwnProperty("onDrag")) {
346 | dragging.data.onDrag(where);
347 | }
348 | });
349 | canvas.addEventListener("mouseup", (event: MouseEvent) => {
350 | dragging = null;
351 | });
352 | canvas.addEventListener("click", (event: MouseEvent) => {
353 | const where = new paper.Point(event.offsetX, event.offsetY);
354 | const hit = myProject.hitTest(where);
355 | if (hit && hit.item && hit.item.data && hit.item.data.hasOwnProperty("onClick")) {
356 | hit.item.data.onClick();
357 | }
358 | });
359 | canvas.addEventListener("dblclick", (event: MouseEvent) => {
360 | const where = new paper.Point(event.offsetX, event.offsetY);
361 | const hit = myProject.hitTest(where);
362 | if (hit && hit.item && hit.item.data && hit.item.data.hasOwnProperty("onDoubleClick")) {
363 | hit.item.data.onDoubleClick();
364 | }
365 | });
366 | var containerData: ContainerData = {
367 | project: paper.project!,
368 | bubbleList: []
369 | };
370 | this.activeContainers.set(parent, containerData);
371 | Comical.update(parent);
372 | }
373 |
374 | private static getScaling(element: HTMLElement): paper.Point {
375 | // getBoundingClientRect() returns the rendering size (aka scaled)
376 | // of the border box (i.e., what is needed to include everything inside and including the border)
377 | const scaledBounds = element.getBoundingClientRect();
378 | const scaledWidth = scaledBounds.width;
379 | const scaledHeight = scaledBounds.height;
380 |
381 | // offsetWidth returns the layout size (aka unscaled) that the element occupies (i.e., how much space the content, scrollbar, padding, and border take up)
382 | // So this is a more apples-to-apples comparison than clientWidth/Height
383 | //
384 | // See https://developer.mozilla.org/en-US/docs/Web/API/CSS_Object_Model/Determining_the_dimensions_of_elements
385 | // "Most of the time these are the same as width and height of Element.getBoundingClientRect(), when there aren't any transforms applied to the element.
386 | // In case of transforms, the offsetWidth and offsetHeight returns the element's layout width and height,
387 | // while getBoundingClientRect() returns the rendering width and height. As an example, if the element has width: 100px; and transform: scale(0.5);
388 | // the getBoundingClientRect() will return 50 as the width, while offsetWidth will return 100.
389 | const unscaledWidth = element.offsetWidth;
390 | const unscaledHeight = element.offsetHeight;
391 |
392 | const scaleX = scaledWidth / unscaledWidth;
393 | const scaleY = scaledHeight / unscaledHeight;
394 |
395 | return new paper.Point(scaleX, scaleY);
396 | }
397 |
398 | public static setActiveBubbleListener(listener: ((selected: HTMLElement | undefined) => void) | undefined) {
399 | Comical.activeBubbleListener = listener;
400 | }
401 |
402 | // Make appropriate JSON changes so that childElement becomes a child of parentElement.
403 | // This means they are at the same level and, if they don't overlap, a joiner is drawn
404 | // between them.
405 | // The conceptual model is that all elements at the same level form a family, provided
406 | // they have distinct order properties. The one with the lowest order is considered
407 | // the overall parent. A child can be added to a family by specifying any member of the
408 | // family as a parentElement. It is expected that both elements are children of
409 | // the root element most recently configured for Comical with convertBubbleJsonToCanvas().
410 | public static initializeChild(childElement: HTMLElement, parentElement: HTMLElement) {
411 | const bubblesInSameCanvas = Comical.getBubblesInSameCanvas(parentElement);
412 | const parentBubble = bubblesInSameCanvas.find(x => x.content === parentElement);
413 | if (!parentBubble) {
414 | console.error("trying to make child of element not already active in Comical");
415 | return;
416 | }
417 | const parentSpec = parentBubble.getBubbleSpec();
418 | if (!parentSpec.order) {
419 | // It's important not to use zero for order, since that will be treated
420 | // as an unspecified order.
421 | parentSpec.order = 1;
422 | parentBubble.persistBubbleSpec();
423 | }
424 | // enhance: if familyLevel is undefined, set it to a number one greater than
425 | // any level that occurs in bubblesInSameCanvas.
426 | let childBubble = bubblesInSameCanvas.find(x => x.content === childElement);
427 | if (!childBubble) {
428 | childBubble = new Bubble(childElement);
429 | }
430 | const lastInFamily = Comical.getLastInFamily(parentElement);
431 | const maxOrder = lastInFamily.getBubbleSpec().order || 1;
432 | const tip = lastInFamily.calculateTailStartPoint();
433 | const root = childBubble.calculateTailStartPoint();
434 | const mid = Bubble.defaultMid(
435 | root,
436 | tip,
437 | new paper.Size(childElement.offsetWidth, childElement.offsetHeight),
438 | new paper.Size(lastInFamily.content.offsetWidth, lastInFamily.content.offsetHeight)
439 | );
440 | // We deliberately do NOT keep any properties the child bubble already has.
441 | // Apart from the necessary properties for being a child, it will take
442 | // all its properties from the parent.
443 | // BL-7908: If parent has outerBorderColor defined, child should have the same. And if the parent's
444 | // outerBorderColor is undefined, this will make sure the child's is undefined as well.
445 | const newBubbleSpec: BubbleSpec = {
446 | version: Comical.bubbleVersion,
447 | style: parentSpec.style,
448 | tails: [
449 | {
450 | tipX: tip.x,
451 | tipY: tip.y,
452 | midpointX: mid.x,
453 | midpointY: mid.y,
454 | joiner: true,
455 | autoCurve: true
456 | }
457 | ],
458 | outerBorderColor: parentSpec.outerBorderColor,
459 | level: parentSpec.level,
460 | order: maxOrder + 1
461 | };
462 | childBubble.setBubbleSpec(newBubbleSpec);
463 | // enhance: we could possibly do something here to make the appropriate
464 | // shapes for childBubble and the tail that links it to the previous bubble.
465 | // However, currently our only client always does a fresh convertBubbleJsonToCanvas
466 | // after making a new child. That will automatically sort things out.
467 | // Note that getting all the shapes updated properly could be nontrivial
468 | // if childElement already has a bubble...it may need to change shape, lose tails,
469 | // change other properties,...
470 | }
471 |
472 | // Return true if a click at the specified point (relative to the top left
473 | // of the specified container element) hits something Comical has put into
474 | // the canvas...any of the bubble shapes, tail shapes, handles.
475 | // Note that this must be a point in real pixels (like MouseEvent.offsetX/Y), not scaled pixels,
476 | // if some transform:scale has been applied to the Comical canvas.
477 | public static somethingHit(element: HTMLElement, x: number, y: number): boolean {
478 | const containerData = Comical.activeContainers.get(element);
479 | if (!containerData) {
480 | return false;
481 | }
482 | const hitResult = containerData.project.hitTest(new paper.Point(x, y));
483 | return !!hitResult;
484 | }
485 |
486 | // Return true if a specified area (like a dragHandle) completely overlaps with something in the
487 | // Comical canvas...any of the bubble shapes, tail shapes, handles.
488 | // This is an approximation. We are testing the 4 corners of the element's area and assuming that
489 | // if they all 'hit' then the element is completely covered. Actually, there could be a number of
490 | // corner cases where this is not true, but this is good enough for now.
491 | // Note that the coordinates specified must be points in real pixels (like MouseEvent.offsetX/Y),
492 | // not scaled pixels, if some transform:scale has been applied to the Comical canvas.
493 | public static isAreaCompletelyIntersected(
494 | element: HTMLElement,
495 | left: number,
496 | right: number,
497 | top: number,
498 | bottom: number
499 | ): boolean {
500 | const upperLeftHit = this.somethingHit(element, left, top);
501 | const upperRightHit = this.somethingHit(element, right, top);
502 | const lowerLeftHit = this.somethingHit(element, left, bottom);
503 | const lowerRightHit = this.somethingHit(element, right, bottom);
504 | // Enhance: an argument could be made for testing what got hit to see if it's the same item or not.
505 | return upperLeftHit && upperRightHit && lowerLeftHit && lowerRightHit;
506 | }
507 |
508 | // Returns the first bubble at the point (x, y), or undefined if no bubble is present at that point.
509 | // Note that this must be a point in real pixels (like MouseEvent.offsetX/Y), not scaled pixels,
510 | // if some transform:scale has been applied to the Comical canvas.
511 | public static getBubbleHit(
512 | parentContainer: HTMLElement,
513 | x: number,
514 | y: number,
515 | onlyIfEnabled?: boolean,
516 | ignoreSelector?: string
517 | ): Bubble | undefined {
518 | const containerData = Comical.activeContainers.get(parentContainer);
519 | if (!containerData) {
520 | return undefined;
521 | }
522 |
523 | // I think it's easier to just iterate through the bubbles and check if they're hit or not.
524 | // You could try to run hitTest, but that gives you a Paper Item, and then you have to figure out which Bubble the Paper Item belongs to... not any easier.
525 |
526 | // Filtering also serves to give us our own copy we can manipulate without affecting the original.
527 | let bubbleList = containerData.bubbleList.filter(bubble => {
528 | if (ignoreSelector && bubble.content.matches(ignoreSelector)) return false;
529 |
530 | // Always filter out bubbles that are completely invisible.
531 | // (There are other ways bubbles could be invisible, but this is enough for current purposes.)
532 | const cs = window.getComputedStyle(bubble.content);
533 | if (cs.display === "none") {
534 | return false;
535 | }
536 | // And if requested, filter out bubbles whose main content is not enabled for pointer events.
537 | if (onlyIfEnabled && cs.pointerEvents === "none") {
538 | return false;
539 | }
540 | return true;
541 | });
542 |
543 | if (bubbleList.length === 0) {
544 | return undefined;
545 | }
546 |
547 | // Sort them so that bubbles with higher level come first.
548 | Comical.sortBubbleListTopLevelFirst(bubbleList);
549 |
550 | // Now find the first bubble hit, highest precedence first
551 | return bubbleList.find(bubble => bubble.isHitByPoint(new paper.Point(x, y)));
552 | }
553 |
554 | // Return the comical container that the element is part of (or undefined if it is
555 | // not part of any), along with the corresponding containerData.
556 | static comicalParentOf(element: HTMLElement): [HTMLElement | undefined, ContainerData | undefined] {
557 | let target: HTMLElement | null = element;
558 | while (target) {
559 | const containerData = Comical.activeContainers.get(target);
560 | if (containerData) {
561 | return [target, containerData];
562 | }
563 | target = target.parentElement;
564 | }
565 | return [undefined, undefined];
566 | }
567 |
568 | // If the given point is inside some bubble's content area that belongs to
569 | // the given container, answer that bubble.
570 | // (If it is somehow in more than one, answer one of them.)
571 | // Answer undefined if it is not in any bubble's content area.
572 | // Note: for most purposes, getBubbleHit() is a better function to use.
573 | // This one is mainly useful for keeping handles outside the spaces
574 | // where they can't be grabbed.
575 | static bubbleWithContentAtPoint(parentContainer: HTMLElement, x: number, y: number): Bubble | undefined {
576 | const containerData = Comical.activeContainers.get(parentContainer);
577 | if (!containerData) {
578 | return undefined;
579 | }
580 | for (let i = 0; i < containerData.bubbleList.length; i++) {
581 | var bubble = containerData.bubbleList[i];
582 | const contentPosition = Comical.getBoundsRelativeToParent(parentContainer, bubble.content);
583 | if (
584 | x >= contentPosition.left &&
585 | x <= contentPosition.right &&
586 | y >= contentPosition.top &&
587 | y <= contentPosition.bottom
588 | ) {
589 | return bubble;
590 | }
591 | }
592 | return undefined;
593 | }
594 |
595 | // Answer target.getBoundingClientRect(), but relative to the top left of the specified parent.
596 | static getBoundsRelativeToParent(parentContainer: HTMLElement, target: HTMLElement): ClientRect {
597 | const parentBounds = parentContainer.getBoundingClientRect();
598 | const targetBounds = target.getBoundingClientRect();
599 | const xOffset = parentBounds.left;
600 | const yOffset = parentBounds.top;
601 | return {
602 | left: targetBounds.left - xOffset,
603 | right: targetBounds.right - xOffset,
604 | width: targetBounds.width,
605 | top: targetBounds.top - yOffset,
606 | bottom: targetBounds.bottom - yOffset,
607 | height: targetBounds.height
608 | };
609 | }
610 |
611 | // Gets a point along the line from start to end that is not inside any bubble
612 | // of parentContainer. Tests the specified number of points evenly spaced
613 | // along the line, including the end point but not the start. If all such
614 | // points are in bubbles, returns undefined.
615 | static getPointOutsideBubblesAlong(
616 | parentContainer: HTMLElement,
617 | start: paper.Point,
618 | end: paper.Point,
619 | tries: number
620 | ): paper.Point | undefined {
621 | const delta = end.subtract(start).divide(tries);
622 | for (var i = 1; i <= tries; i++) {
623 | const tryPoint = start.add(delta.multiply(i));
624 | if (!this.getBubbleHit(parentContainer, tryPoint.x, tryPoint.y)) {
625 | return tryPoint;
626 | }
627 | }
628 | return undefined;
629 | }
630 |
631 | // If the specified position is inside a bubble in the same
632 | // parentContainer as element, return
633 | // a nearby point that is not inside any bubble content area.
634 | // If the point is not inside a bubble, return it unmodified.
635 | // We choose a 'nearby' point by moving along the line from position
636 | // to 'towards' until we find a point that isn't in a bubble.
637 | // If 'towards' is inside a bubble, we'll try for the closest point
638 | // It is assumed that 'towards' itself isn't in a bubble.
639 | // If it is, we may return 'towards' itself even though it's not
640 | // outside a bubble.
641 | static movePointOutsideBubble(
642 | element: HTMLElement,
643 | position: paper.Point,
644 | towards: paper.Point,
645 | from: paper.Point,
646 | ignoreSelector?: string
647 | ): paper.Point {
648 | const [parentContainer] = Comical.comicalParentOf(element);
649 | if (!parentContainer) {
650 | return position;
651 | }
652 | let bubble = this.getBubbleHit(parentContainer, position.x, position.y, false, ignoreSelector);
653 | if (!bubble) {
654 | return position;
655 | }
656 |
657 | // get a starting point, somewhere along the path from 'from' to 'towards' via 'position'
658 | // that is not in a bubble, as close to position as we can easily manage, preferring on the
659 | // side from position to 'towards'.
660 | let farPoint: paper.Point | undefined = undefined;
661 | let maxDivisions = Math.max(position.subtract(towards).length, position.subtract(from).length);
662 | let divisions = Math.max(Math.min(maxDivisions - 0.1, 5), 2); // at least one iteration, try at least both ends.
663 | while (!farPoint && divisions < maxDivisions) {
664 | farPoint = Comical.getPointOutsideBubblesAlong(parentContainer, position, towards, divisions);
665 | // See if we can find one back towards its own bubble.
666 | const altFarPoint = Comical.getPointOutsideBubblesAlong(parentContainer, position, from, divisions);
667 | if (
668 | (altFarPoint && !farPoint) ||
669 | (altFarPoint && farPoint && altFarPoint.subtract(position).length < farPoint.subtract(position).length)
670 | ) {
671 | farPoint = altFarPoint;
672 | }
673 | // If we didn't find one yet, do a more detailed, slower search.
674 | divisions *= 1.9; // roughly twice as many, don't repeat ones we tried.
675 | }
676 | if (!farPoint) {
677 | return position; // we can't improve things.
678 | }
679 |
680 | let nearPoint = position; // the least distance we might move position
681 | // We will return a point along the line from position to farPoint,
682 | // generally as close to position as allowed.
683 | // If there are other bubbles in the way, or the original bubble has
684 | // an irregular shape, the binary search algorithm
685 | // may do something a little odd, moving the point unnecessarily
686 | // far; but it should still be a plausible
687 | // (and definitely valid, outside any bubble) place to put it.
688 | while (true) {
689 | const delta: paper.Point = farPoint.subtract(nearPoint).divide(2);
690 | if (delta.length < 1) {
691 | return farPoint;
692 | }
693 | const newPoint = nearPoint.add(delta);
694 | const newBubble = this.getBubbleHit(parentContainer, newPoint.x, newPoint.y, false, ignoreSelector);
695 | // Basic idea here is a binary search for a point that's not in a bubble. If newPoint
696 | // is in the original bubble, we need to move further, so we move badPoint. If it's not
697 | // in a bubble, we can try closer to the bubble, so we move goodPoint.
698 | // If newBubble is different from bubble and doesn't intersect, there must be some
699 | // space between them. newPoint is not actually good, but we want to move the search
700 | // in that direction so we end up with a point closer to the bubble that contains
701 | // position. (There's probably a pathological case involving convex bubbles where
702 | // they do intersect and yet there's open space along the line closer to position.
703 | // But I think this is good enough. The user can always take control and position
704 | // the handle manually.)
705 | if (newBubble && (newBubble === bubble || newBubble.outline.intersects(bubble.outline))) {
706 | nearPoint = newPoint;
707 | } else {
708 | farPoint = newPoint;
709 | }
710 | }
711 | }
712 |
713 | static okToMoveTo(element: HTMLElement, dest: paper.Point): boolean {
714 | const [parentContainer] = Comical.comicalParentOf(element);
715 | if (!parentContainer) {
716 | return true; // shouldn't happen, I think.
717 | }
718 | // We don't allow tails to extend into other bubbles, except ones that have no style
719 | // or tails. This might be too strong; we mainly want to prevent a tail being
720 | // drawn underneath a bubble, which doesn't work well visually, and a tail connected
721 | // back to the bubble it started from, which also doesn't work well. The main case
722 | // where we DO want to allow it is where a tail is dragged inside an overlay image.
723 | // Currently those have no bubble or tail so this is a reasonable test.
724 | const bubbleHit = this.getBubbleHit(parentContainer, dest.x, dest.y);
725 | const spec = bubbleHit?.getFullSpec();
726 | if (bubbleHit && (spec?.style !== "none" || spec.tails.length > 0)) {
727 | return false;
728 | }
729 |
730 | if (
731 | dest.x < 0 ||
732 | dest.y < 0 ||
733 | dest.x >= parentContainer.clientWidth ||
734 | dest.y >= parentContainer.clientHeight
735 | ) {
736 | return false;
737 | }
738 | return true;
739 | }
740 |
741 | private static getBubblesInSameCanvas(element: HTMLElement): Bubble[] {
742 | const iterator = Comical.activeContainers.entries();
743 | let result = iterator.next();
744 | while (!result.done) {
745 | // result.value is a [container, containerData] pair.
746 | if (result.value[0].contains(element)) {
747 | return result.value[1].bubbleList;
748 | }
749 |
750 | result = iterator.next();
751 | }
752 | return [];
753 | }
754 |
755 | // Get the last element in the family of the given element (belonging to the same
756 | // canvas and having the same level). Any element in the family can be passed.
757 | private static getLastInFamily(element: HTMLElement): Bubble {
758 | const familyLevel = Bubble.getBubbleSpec(element).level;
759 | const family = Comical.getBubblesInSameCanvas(element)
760 | .filter(x => x.getBubbleSpec().level === familyLevel && x.getBubbleSpec().order)
761 | .sort((a, b) => a.getBubbleSpec().order! - b.getBubbleSpec().order!);
762 | // we set order on parentBubble, so there is at least one in the family.
763 | return family[family.length - 1];
764 | }
765 |
766 | public static findChild(bubble: Bubble): Bubble | undefined {
767 | const familyLevel = bubble.getSpecLevel();
768 | const orderWithinFamily = bubble.getBubbleSpec().order;
769 | if (!orderWithinFamily) {
770 | return undefined;
771 | }
772 | const family = Comical.getBubblesInSameCanvas(bubble.content)
773 | .filter(
774 | x =>
775 | x.getBubbleSpec().level === familyLevel &&
776 | x.getBubbleSpec().order &&
777 | x.getBubbleSpec().order! > orderWithinFamily
778 | )
779 | .sort((a, b) => a.getBubbleSpec().order! - b.getBubbleSpec().order!);
780 | if (family.length > 0) {
781 | return family[0];
782 | }
783 | return undefined;
784 | }
785 |
786 | // Return the immediate parent of the bubble, or undefined if it doesn't have one
787 | public static findParent(bubble: Bubble): Bubble | undefined {
788 | const ancestors = Comical.findAncestors(bubble);
789 |
790 | if (ancestors && ancestors.length > 0) {
791 | return ancestors[ancestors.length - 1];
792 | } else {
793 | return undefined;
794 | }
795 | }
796 |
797 | // Return the ancestors of the bubble. The first item in the array
798 | // is the earliest ancestor (if any); any intermediate bubbles are returned too.
799 | public static findAncestors(bubble: Bubble): Bubble[] {
800 | const familyLevel = bubble.getSpecLevel();
801 | const orderWithinFamily = bubble.getBubbleSpec().order;
802 | if (!orderWithinFamily) {
803 | return [];
804 | }
805 | return Comical.getBubblesInSameCanvas(bubble.content)
806 | .filter(
807 | x =>
808 | x.getBubbleSpec().level === familyLevel &&
809 | x.getBubbleSpec().order &&
810 | x.getBubbleSpec().order! < orderWithinFamily
811 | )
812 | .sort((a, b) => a.getBubbleSpec().order! - b.getBubbleSpec().order!);
813 | }
814 |
815 | // Get the bubbles that are in the same canvas at the same level as the one passed, that is,
816 | // the ones that are displayed linked to it. They will be sorted by their "orderWithinFamily".
817 | // The bubble passed as an argument will be included in the list only if "includeSelf" is true.
818 | public static findRelatives(bubble: Bubble, includeSelf = false): Bubble[] {
819 | const familyLevel = bubble.getSpecLevel();
820 | const orderWithinFamily = bubble.getBubbleSpec().order;
821 | if (!orderWithinFamily) {
822 | return includeSelf ? [bubble] : [];
823 | }
824 | return Comical.getBubblesInSameCanvas(bubble.content)
825 | .filter(
826 | x =>
827 | x.getBubbleSpec().level === familyLevel &&
828 | x.getBubbleSpec().order &&
829 | (includeSelf || x.getBubbleSpec().order !== orderWithinFamily)
830 | )
831 | .sort((a, b) => a.getBubbleSpec().order! - b.getBubbleSpec().order!);
832 | }
833 |
834 | // Removes the bubble associated with 'elementToDelete' from its 'family' of bubbles.
835 | // This can either be the parent of this family or one of the child bubbles.
836 | public static deleteBubbleFromFamily(elementToDelete: HTMLElement, container: HTMLElement) {
837 | const specToDelete = Bubble.getBubbleSpec(elementToDelete);
838 | const orderOfGoner = specToDelete.order;
839 | if (orderOfGoner && orderOfGoner > 0) {
840 | // We're in a family of more than one bubble; adjust order (at least).
841 | const tempBubble = new Bubble(elementToDelete);
842 | // By default 'includeSelf' is false, which is fine whether we're deleting self or not.
843 | // In any case, 'relatives' will be in order of... 'order'.
844 | const relatives = this.findRelatives(tempBubble);
845 | relatives.forEach((relatedBubble, index) => {
846 | let relationSpec: BubbleSpec = relatedBubble.getBubbleSpec();
847 | if (index === 0 && orderOfGoner === 1) {
848 | // We're deleting the patriarch bubble and this relation will be the new patriarch.
849 | // Since everything in the patriarch bubble's spec should still be in the new
850 | // patriarch's spec, we just copy it over.
851 | // Most fields in the bubble spec apply to the entire family, not just to the
852 | // individual bubble. The exceptions to that right now are "order" and "tails".
853 | // But once relationSpec becomes the parent, its order and tail values just happen
854 | // to become the same as that of the old parent.
855 | relationSpec = specToDelete;
856 | }
857 | if (relationSpec.order && relationSpec.order > orderOfGoner) {
858 | relationSpec.order--;
859 | }
860 | relatedBubble.setBubbleSpec(relationSpec);
861 | });
862 | }
863 |
864 | // Now remove the text div and update
865 | container.removeChild(elementToDelete);
866 | this.update(container);
867 | }
868 |
869 | public static bubbleVersion = "1.0";
870 | }
871 |
872 | // planned next steps
873 | // 1. When we wrap a shape around an element, record the shape as the data-bubble attr, a block of json as indicted in the design doc.
874 | // Tricks will be needed if it is an arbitrary SVG.
875 | // 2. Add function ConvertSvgToCanvas(parent). Does more or less the opposite of ConvertCanvasToSvg,
876 | // but using the data-X attributes of children of parent that have them to initialize the canvas paper elements.
877 | // Enhance test code to make Finish button toggle between Save and Edit.
878 | // (Once the logic to create a canvas as an overlay on a parent is in place, can probably get all the paper.js
879 | // stuff out of the test code.)
880 |
--------------------------------------------------------------------------------
/src/containerData.ts:
--------------------------------------------------------------------------------
1 | import { Bubble } from "./bubble";
2 |
3 | // The data that is kept in Comical's editElements map
4 | // regarding a particular parent element that might contain bubbles.
5 | export class ContainerData {
6 | project: paper.Project; // The project corresponding to the canvas made to cover the parent
7 | bubbleList: Bubble[]; // Bubbles for child elements of the parent
8 | handleLayer?: paper.Layer; // The layer in which handles should be drawn for that parent
9 | }
10 |
--------------------------------------------------------------------------------
/src/curveTail.ts:
--------------------------------------------------------------------------------
1 | import { Tail } from "./tail";
2 | import paper = require("paper");
3 | import { Comical } from "./comical";
4 | import { Bubble } from "./bubble";
5 | import { Handle } from "./handle";
6 |
7 | // An abstract class for tails which, like ArcTail and ThoughtTail,
8 | // have a handle to control a mid-point which configures their shape.
9 | // Typically something follows a curve through the midpoint to the tip.
10 | export class CurveTail extends Tail {
11 | mid: paper.Point;
12 |
13 | // This may be set to ensure that when the tail's midpoint is moved
14 | // automatically (e.g., to adjust for the root moving), the corresponding
15 | // handle is moved too.
16 | midHandle: Handle;
17 |
18 | adjustForChangedRoot(delta: paper.Point): void {
19 | let newPosition = this.mid.add(delta.divide(2));
20 | if (this.bubble && this.spec.autoCurve) {
21 | const parent = Comical.findParent(this.bubble);
22 | newPosition = Bubble.defaultMid(
23 | this.currentStartPoint(),
24 | this.tip,
25 | new paper.Size(this.bubble.content.offsetWidth, this.bubble.content.offsetHeight),
26 | parent ? new paper.Size(parent.content.offsetWidth, parent.content.offsetHeight) : undefined
27 | );
28 | }
29 | if (this.bubble) {
30 | newPosition = Comical.movePointOutsideBubble(
31 | this.bubble.content,
32 | newPosition,
33 | this.tip,
34 | this.root,
35 | Comical.getSelectorForBubblesWhichTailMidpointMayOverlap()
36 | );
37 | }
38 | this.mid = newPosition;
39 | if (this.midHandle) {
40 | this.midHandle.setPosition(newPosition);
41 | }
42 | if (this.spec) {
43 | this.spec.midpointX = newPosition.x;
44 | this.spec.midpointY = newPosition.y;
45 | }
46 | }
47 |
48 | adjustForChangedTip(delta: paper.Point): void {
49 | this.adjustForChangedRoot(delta);
50 | }
51 |
52 | protected showHandlesInternal(): void {
53 | super.showHandlesInternal();
54 | this.midHandle = new Handle(this.handleLayer, this.mid, !!this.spec.autoCurve);
55 | this.midHandle.bringToFront();
56 | this.midHandle.onDrag = (where: paper.Point) => {
57 | if (this.bubble) {
58 | const [parentElement] = Comical.comicalParentOf(this.bubble.content);
59 | if (
60 | parentElement &&
61 | Comical.getBubbleHit(
62 | parentElement,
63 | where.x,
64 | where.y,
65 | false,
66 | Comical.getSelectorForBubblesWhichTailMidpointMayOverlap()
67 | )
68 | ) {
69 | return; // refuse to drag mid to a point inside a bubble
70 | }
71 | }
72 | this.spec.autoCurve = false;
73 | this.midHandle!.setAutoMode(false);
74 | this.midHandle!.setPosition(where);
75 | this.mid = where;
76 | this.makeShapes();
77 |
78 | // Update this.spec.tips to reflect the new handle positions
79 | this.spec.midpointX = where.x;
80 | this.spec.midpointY = where.y;
81 | this.persistSpecChanges();
82 | this.uniteBubbleShapes();
83 | };
84 |
85 | this.midHandle.onDoubleClick = () => {
86 | this.spec.autoCurve = true;
87 | this.adjustForChangedRoot(new paper.Point(0, 0));
88 | this.makeShapes();
89 | this.persistSpecChanges();
90 | this.midHandle.setAutoMode(true);
91 | this.uniteBubbleShapes();
92 | };
93 | }
94 |
95 | public setTailAndHandleVisibility(newVisibility: boolean) {
96 | super.setTailAndHandleVisibility(newVisibility);
97 | if (this.midHandle) {
98 | this.midHandle.setVisibility(newVisibility);
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/handle.ts:
--------------------------------------------------------------------------------
1 | import paper = require("paper");
2 | import { activateLayer } from "./utilities";
3 | import { Tail } from "./tail";
4 | import { Comical } from "./comical";
5 |
6 | // This class represents one of the handles used to manipulate tails.
7 | export class Handle {
8 | private circle: paper.Path.Circle;
9 |
10 | // Helps determine unique names for the handles
11 | static handleIndex = 0;
12 |
13 | constructor(layer: paper.Layer, position: paper.Point, autoMode: boolean) {
14 | activateLayer(layer);
15 | this.circle = new paper.Path.Circle(position, 5);
16 | this.circle.strokeColor = Comical.tailHandleColor;
17 | this.circle.strokeWidth = 1;
18 | this.circle.name = "handle" + Handle.handleIndex++;
19 | this.circle.visible = true;
20 | this.circle.data = this;
21 | this.setAutoMode(autoMode);
22 | }
23 |
24 | setAutoMode(autoMode: boolean) {
25 | this.circle.fillColor = autoMode ? Tail.transparentColor : Comical.tailHandleColor;
26 | }
27 |
28 | getPosition(): paper.Point {
29 | return this.circle.position!;
30 | }
31 | setPosition(p: paper.Point) {
32 | this.circle.position = p;
33 | }
34 | setVisibility(visibility: boolean) {
35 | this.circle.visible = visibility;
36 | }
37 | bringToFront() {
38 | this.circle.bringToFront();
39 | }
40 |
41 | // This gets called because of some mouse event handlers added to the whole canvas by
42 | // code in comical.ts (convertBubbleJsonToCanvas).
43 | onDrag: (p: paper.Point) => void;
44 | onDoubleClick: () => void;
45 | }
46 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | // This file exists to import everything else (and define the module exports).
2 | // It is the root for building the bundle.
3 |
4 | import { Comical } from "./comical";
5 | import { Bubble } from "./bubble";
6 | import { BubbleSpec, BubbleSpecPattern, TailSpec } from "./bubbleSpec";
7 |
8 | export { Comical, Bubble, BubbleSpec, BubbleSpecPattern, TailSpec };
9 |
--------------------------------------------------------------------------------
/src/lineTail.ts:
--------------------------------------------------------------------------------
1 | import { Tail } from "./tail";
2 | import paper = require("paper");
3 | import { TailSpec } from "./bubbleSpec";
4 | import { Bubble } from "./bubble";
5 | import { Comical } from "./comical";
6 |
7 | export class LineTail extends Tail {
8 | private tailWidth: number = 1;
9 |
10 | public constructor(
11 | root: paper.Point,
12 | tip: paper.Point,
13 | lowerLayer: paper.Layer,
14 | upperLayer: paper.Layer,
15 | handleLayer: paper.Layer,
16 | spec: TailSpec,
17 | bubble: Bubble | undefined
18 | ) {
19 | super(root, tip, lowerLayer, upperLayer, handleLayer, spec, bubble);
20 | }
21 |
22 | public canUnite() {
23 | return false;
24 | }
25 |
26 | public makeShapes() {
27 | const oldStroke = this.pathstroke;
28 | this.lowerLayer.activate();
29 |
30 | this.pathstroke = this.makeShape(this.root);
31 |
32 | if (oldStroke) {
33 | this.pathstroke.insertBelow(oldStroke);
34 | oldStroke.remove();
35 | }
36 | }
37 |
38 | public makeShape(from: paper.Point): paper.Path {
39 | const result = new paper.Path.Line(from, this.tip);
40 | result.strokeColor = new paper.Color("black");
41 | result.strokeWidth = this.tailWidth;
42 | return result;
43 | }
44 |
45 | public onClick(action: () => void) {
46 | this.clickAction = action;
47 | if (this.pathstroke) {
48 | // create onMouseEnter and onMouseLeave events for making it easier for a user to grab
49 | // the tail. Otherwise clicking on it is really hard. The onMouseLeave event is so that it
50 | // returns the tail to the default width (this.tailWidth) of the LineTail
51 |
52 | // Enhance: If we still want this behavior, we have to enhance it to cope with scale
53 | // this.pathstroke.onMouseEnter = () => {
54 | // this.pathstroke.strokeWidth = 4;
55 | // };
56 |
57 | // this.pathstroke.onMouseLeave = () => {
58 | // this.pathstroke.strokeWidth = this.tailWidth;
59 | // };
60 | Comical.setItemOnClick(this.pathstroke, action);
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/random.ts:
--------------------------------------------------------------------------------
1 | // Simple seedable RNG, adapted from https://gist.github.com/lsenta/15d7f6fcfc2987176b54
2 | // We don't need terribly good randomness, but we do need to be able to provide a seed,
3 | // so that the bubble will not change shape every time we generate it.
4 | export class SimpleRandom {
5 | private seed: number;
6 |
7 | constructor(seed: number) {
8 | this.seed = seed;
9 | }
10 |
11 | private next(min: number, max: number): number {
12 | max = max || 0;
13 | min = min || 0;
14 |
15 | this.seed = (this.seed * 9301 + 49297) % 233280;
16 | var rnd = this.seed / 233280;
17 |
18 | return min + rnd * (max - min);
19 | }
20 |
21 | public nextDouble(): number {
22 | return this.next(0, 1);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/shoutBubble.svg:
--------------------------------------------------------------------------------
1 |
2 |
44 |
--------------------------------------------------------------------------------
/src/shoutBubble_ink.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
70 |
--------------------------------------------------------------------------------
/src/speechBubble.svg:
--------------------------------------------------------------------------------
1 |
2 |
47 |
--------------------------------------------------------------------------------
/src/speechBubble.ts:
--------------------------------------------------------------------------------
1 | import paper = require("paper");
2 |
3 | // This file contains the definition for how a speech bubble is made.
4 | // It is roughly an oval, but actually made as a sequence of four bezier curves,
5 | // which allows it to vary in shape. You can imagine (or see, in the "playing with beziers"
6 | // test case) that there is a control point in the middle of each side of the rectangle,
7 | // with handles in the direction of the corners. The handleFraction arguments control
8 | // what fraction of the distance to the corner the handles are placed. 0.6, 0.8 seems
9 | // to give a nice default. Larger values make the shape more like a rounded rectangle,
10 | // smaller ones more like an ellipse (or even a diamond).
11 | export function makeSpeechBubbleParts(
12 | width: number,
13 | height: number,
14 | xHandleFraction: number,
15 | yHandleFraction: number
16 | ): [paper.Path, paper.Shape.Rectangle] {
17 | if (width <= 0) {
18 | console.assert(false, `Invalid width. Received: ${width}. Expected: width > 0`);
19 | width = 1;
20 | }
21 | if (height <= 0) {
22 | console.assert(false, `Invalid height. Received: ${height}. Expected: height > 0`);
23 | height = 1;
24 | }
25 | // Because of the way the bubble code aligns the content-holder
26 | // rectangle with the content element, the values used for top and left
27 | // currently make absolutely no difference. They translate the bubble,
28 | // and the content-holder alignment adjusts for it. I'm keeping the
29 | // variables in case we might want control over the position one day
30 | // (maybe in debugging?).
31 | const top = 0;
32 | const left = 0;
33 | const xCenter = left + width / 2;
34 | const yCenter = top + height / 2;
35 | const right = left + width;
36 | const bottom = top + height;
37 | const xHandleOffset = (width / 2) * xHandleFraction;
38 | const yHandleOffset = (height / 2) * yHandleFraction;
39 | var firstSegment = new paper.Segment({
40 | point: new paper.Point(xCenter, top),
41 | handleOut: new paper.Point(xHandleOffset, 0),
42 | handleIn: new paper.Point(-xHandleOffset, 0)
43 | });
44 | var secondSegment = new paper.Segment({
45 | point: new paper.Point(right, yCenter),
46 | handleIn: new paper.Point(0, -yHandleOffset),
47 | handleOut: new paper.Point(0, yHandleOffset)
48 | });
49 | var thirdSegment = new paper.Segment({
50 | point: new paper.Point(xCenter, bottom),
51 | handleIn: new paper.Point(xHandleOffset, 0),
52 | handleOut: new paper.Point(-xHandleOffset, 0)
53 | });
54 | var fourthSegment = new paper.Segment({
55 | point: new paper.Point(left, yCenter),
56 | handleIn: new paper.Point(0, yHandleOffset),
57 | handleOut: new paper.Point(0, -yHandleOffset)
58 | });
59 | const path = new paper.Path({
60 | segments: [firstSegment, secondSegment, thirdSegment, fourthSegment],
61 | strokeColor: new paper.Color("black")
62 | });
63 | path.name = "outlineShape";
64 | // This calculation was a bit of inspired guess-work, but it seems to
65 | // give a pair of points that are a good place for the corners of the rectangle
66 | // that defines where the text will go inside the bubble.
67 | const topRightCurve = path.curves[0];
68 | const topRight = topRightCurve.getLocationAt((topRightCurve.length * width) / (width + height)).point;
69 | const bottomLeftCurve = path.curves[2];
70 | const bottomLeft = bottomLeftCurve.getLocationAt((bottomLeftCurve.length * width) / (width + height)).point;
71 | const contentHolder = new paper.Shape.Rectangle(topRight, bottomLeft);
72 | contentHolder.name = "content-holder";
73 |
74 | // the contentHolder is normally removed, but this might be useful in debugging.
75 | contentHolder.strokeColor = new paper.Color("red");
76 | contentHolder.fillColor = new paper.Color("transparent");
77 | path.closed = true; // causes it to fill in the curve back to the start
78 | return [path, contentHolder];
79 | }
80 |
81 | export function makeSpeechBubble(
82 | width: number,
83 | height: number,
84 | xHandleFraction: number,
85 | yHandleFraction: number
86 | ): paper.Item {
87 | const [path, contentHolder] = makeSpeechBubbleParts(width, height, xHandleFraction, yHandleFraction);
88 | const result = new paper.Group([path, contentHolder]);
89 | return result;
90 | }
91 |
--------------------------------------------------------------------------------
/src/straightTail.ts:
--------------------------------------------------------------------------------
1 | import { Tail } from "./tail";
2 | import paper = require("paper");
3 | import { TailSpec } from "./bubbleSpec";
4 | import { Bubble } from "./bubble";
5 | import { activateLayer } from "./utilities";
6 | import { Comical } from "./comical";
7 |
8 | // straight tail is a simple triangle, with only the tip handle
9 | export class StraightTail extends Tail {
10 | public constructor(
11 | root: paper.Point,
12 | tip: paper.Point,
13 | lowerLayer: paper.Layer,
14 | upperLayer: paper.Layer,
15 | handleLayer: paper.Layer,
16 | spec: TailSpec,
17 | bubble: Bubble | undefined
18 | ) {
19 | super(root, tip, lowerLayer, upperLayer, handleLayer, spec, bubble);
20 | }
21 |
22 | // Make the shapes that implement the tail.
23 | // If there are existing shapes (typically representing an earlier tail position),
24 | // remove them after putting the new shapes in the same z-order and layer.
25 | public makeShapes() {
26 | const oldFill = this.pathFill;
27 | const oldStroke = this.pathstroke;
28 |
29 | activateLayer(this.lowerLayer);
30 |
31 | const tailWidth = 12;
32 |
33 | // We want to make two lines from the tip to a bit either side
34 | // of the root.
35 |
36 | // we want to make the base of the tail a line of length tailWidth
37 | // at right angles to the line from root to tip
38 | // centered at root.
39 | const angleBase = new paper.Point(this.tip.x - this.root.x, this.tip.y - this.root.y).angle;
40 | const deltaBase = new paper.Point(0, 0);
41 | deltaBase.angle = angleBase + 90;
42 | deltaBase.length = tailWidth / 2;
43 | const begin = this.root.add(deltaBase);
44 | const end = this.root.subtract(deltaBase);
45 |
46 | this.pathstroke = new paper.Path.Line(begin, this.tip);
47 | const pathLine2 = new paper.Path.Line(this.tip, end);
48 | this.pathstroke.addSegments(pathLine2.segments);
49 | pathLine2.remove();
50 | if (oldStroke) {
51 | this.pathstroke.insertBelow(oldStroke);
52 | oldStroke.remove();
53 | }
54 | this.pathstroke!.strokeWidth = this.bubble!.getBorderWidth();
55 | activateLayer(this.upperLayer);
56 | this.pathFill = this.pathstroke.clone({ insert: false }) as paper.Path;
57 | if (oldFill) {
58 | this.pathFill.insertAbove(oldFill);
59 | oldFill.remove();
60 | } else {
61 | this.upperLayer.addChild(this.pathFill);
62 | }
63 | this.pathstroke.strokeColor = new paper.Color("black");
64 | this.pathFill.fillColor = this.getFillColor();
65 | if (this.clickAction) {
66 | Comical.setItemOnClick(this.pathFill, this.clickAction);
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/tail.ts:
--------------------------------------------------------------------------------
1 | import paper = require("paper");
2 | import { Comical } from "./comical";
3 | import { TailSpec } from "bubbleSpec";
4 | import { Bubble } from "./bubble";
5 | import { activateLayer } from "./utilities";
6 | import { Handle } from "./handle";
7 |
8 | // This is an abstract base class for tails. A concrete class must at least
9 | // override makeShapes; if it has additional control points, it will probably
10 | // override showHandles, adjustForChangedRoot(), and adjustForChangedTip().
11 | // If it involves additional shapes not stored in pathStroke and pathFill,
12 | // it should override fillPaths() and allPaths().
13 | export class Tail {
14 | // The path representing the line around the tail.
15 | // This is usually defined but will be undefined in the
16 | // unusual case of the tail being inside the bubble.
17 | pathstroke?: paper.Path;
18 | // The path representing the space within the tail.
19 | // It will be undefined if the bubble is transparent
20 | // or the tail is inside the bubble.
21 | pathFill?: paper.Path;
22 |
23 | public debugMode: boolean;
24 |
25 | lowerLayer: paper.Layer;
26 | upperLayer: paper.Layer;
27 | handleLayer: paper.Layer;
28 |
29 | root: paper.Point;
30 | tip: paper.Point;
31 | spec: TailSpec;
32 | bubble: Bubble | undefined;
33 | clickAction: () => void;
34 | state: string; // various values used during handle drag
35 |
36 | public constructor(
37 | root: paper.Point,
38 | tip: paper.Point,
39 | lowerLayer: paper.Layer,
40 | upperLayer: paper.Layer,
41 | handleLayer: paper.Layer,
42 | spec: TailSpec,
43 | bubble: Bubble | undefined
44 | ) {
45 | this.lowerLayer = lowerLayer;
46 | this.upperLayer = upperLayer;
47 | this.handleLayer = handleLayer;
48 | this.spec = spec;
49 |
50 | this.root = root;
51 | this.tip = tip;
52 | this.bubble = bubble;
53 | }
54 |
55 | getFillColor(): paper.Color {
56 | if (this.debugMode) {
57 | return new paper.Color("yellow");
58 | }
59 | if (this.bubble) {
60 | return this.bubble.getBackgroundColor();
61 | }
62 | return Comical.backColor;
63 | }
64 |
65 | // Make the shapes that implement the tail.
66 | // If there are existing shapes (typically representing an earlier tail position),
67 | // remove them after putting the new shapes in the same z-order and layer.
68 | public makeShapes() {
69 | throw new Error("Each subclass must implement makeShapes");
70 | }
71 |
72 | public fillPaths(): paper.Path[] {
73 | if (this.pathFill) {
74 | return [this.pathFill];
75 | } else {
76 | return [];
77 | }
78 | }
79 |
80 | public allPaths(): paper.Path[] {
81 | const result = this.fillPaths();
82 | if (this.pathstroke) {
83 | result.push(this.pathstroke);
84 | }
85 | return result;
86 | }
87 |
88 | public onClick(action: () => void): void {
89 | this.clickAction = action;
90 | this.allPaths().forEach(p => {
91 | Comical.setItemOnClick(p, action);
92 | });
93 | }
94 |
95 | adjustForChangedRoot(delta: paper.Point) {
96 | // a hook for subclasses to adjust anything AFTER the root has moved distance delta.
97 | // Called from inside adjustRoot, which takes care of calling makeShapes() and
98 | // persistSpecChanges() AFTER calling this.
99 | }
100 |
101 | adjustRoot(newRoot: paper.Point): void {
102 | const delta = newRoot.subtract(this.root!);
103 | if (Math.abs(delta.x) + Math.abs(delta.y) < 0.0001) {
104 | // hasn't moved; very likely adjustSize triggered by an irrelevant change to object;
105 | // We MUST NOT trigger the mutation observer again, or we get an infinte loop that
106 | // freezes the whole page.
107 | return;
108 | }
109 | this.root = newRoot;
110 | this.adjustForChangedRoot(delta);
111 | this.makeShapes();
112 | this.persistSpecChanges();
113 | }
114 |
115 | adjustForChangedTip(delta: paper.Point) {
116 | // a hook for subclasses to adjust anything AFTER the tip has moved distance delta.
117 | // Called from inside adjustTip, which takes care of calling makeShapes() and
118 | // persistSpecChanges() AFTER calling this.
119 | }
120 |
121 | // Override in subclasses which always can't (like lineTail).
122 | public canUnite(): boolean {
123 | return this.pathstroke !== undefined;
124 | }
125 |
126 | public uniteBubbleShapes() {
127 | if (this.bubble) {
128 | this.bubble.uniteShapes();
129 | }
130 | }
131 |
132 | adjustTip(newTip: paper.Point): void {
133 | const delta = newTip.subtract(this.tip);
134 | if (Math.abs(delta.x) + Math.abs(delta.y) < 0.0001) {
135 | // hasn't moved; very likely adjustSize triggered by an irrelevant change to object;
136 | // We MUST NOT trigger the mutation observer again, or we get an infinte loop that
137 | // freezes the whole page.
138 | return;
139 | }
140 | this.tip = newTip;
141 | this.adjustForChangedTip(delta);
142 | this.makeShapes();
143 | if (this.spec) {
144 | this.spec.tipX = this.tip.x;
145 | this.spec.tipY = this.tip.y;
146 | }
147 | this.persistSpecChanges();
148 | this.uniteBubbleShapes();
149 | }
150 |
151 | // Erases the tail from the canvas
152 | remove() {
153 | this.allPaths().forEach(p => p.remove());
154 | }
155 |
156 | currentStartPoint(): paper.Point {
157 | if (this.bubble) {
158 | return this.bubble.calculateTailStartPoint();
159 | }
160 | return this.root;
161 | }
162 |
163 | public showHandles() {
164 | this.showHandlesInternal();
165 |
166 | if (this.isBubbleOverlappingParent()) {
167 | this.setTailAndHandleVisibility(false);
168 | }
169 | }
170 |
171 | okToMoveHandleTo(p: paper.Point): boolean {
172 | if (!this.bubble) {
173 | return true; // pathological, or maybe in testing...can't really test
174 | }
175 | return Comical.okToMoveTo(this.bubble.content, p);
176 | }
177 |
178 | protected showHandlesInternal() {
179 | // Setup event handlers
180 | this.state = "idle";
181 | activateLayer(this.handleLayer);
182 |
183 | this.handleLayer.visible = true;
184 | let tipHandle: Handle;
185 |
186 | if (!this.spec.joiner) {
187 | tipHandle = new Handle(this.handleLayer, this.tip, true /* auto mode*/);
188 |
189 | tipHandle.onDrag = (where: paper.Point) => {
190 | if (!this.okToMoveHandleTo(where)) {
191 | return; // refuse to drag tip to a point inside a bubble
192 | }
193 | // tipHandle can't be undefined at this point
194 | const delta = where.subtract(tipHandle!.getPosition()).divide(2);
195 | tipHandle!.setPosition(where);
196 | this.tip = where;
197 | this.adjustForChangedTip(delta);
198 | this.makeShapes();
199 |
200 | // Update this.spec.tips to reflect the new handle positions
201 | this.spec.tipX = this.tip.x;
202 | this.spec.tipY = this.tip.y;
203 | this.persistSpecChanges();
204 | this.uniteBubbleShapes();
205 | };
206 | }
207 | }
208 |
209 | persistSpecChanges() {
210 | if (this.bubble) {
211 | this.bubble.persistBubbleSpecWithoutMonitoring();
212 | }
213 | }
214 |
215 | private isBubbleOverlappingParent(): boolean {
216 | if (this.bubble) {
217 | // Assumes that the parent is already drawn, which is probably reasonable because showHandles() doesn't happen until activateElement() is called, which isn't right away.
218 | const parentBubble = Comical.findParent(this.bubble);
219 | if (parentBubble) {
220 | if (this.bubble.isOverlapping(parentBubble)) {
221 | return true;
222 | }
223 | }
224 | }
225 |
226 | return false;
227 | }
228 |
229 | public setTailAndHandleVisibility(newVisibility: boolean): void {
230 | this.allPaths().forEach(p => (p.visible = newVisibility));
231 |
232 | // ENHANCE: It'd be nice to hide the tipHandle too, but that doesn't make a difference yet.
233 | }
234 |
235 | // We basically want non-solid bubbles transparent, especially for the tip, so
236 | // you can see where the tip actually ends up. But if it's perfectly transparent,
237 | // paper.js doesn't register hit tests on the transparent part. So go for a very
238 | // small alpha.
239 | public static transparentColor: paper.Color = new paper.Color("#FFFFFF11");
240 | }
241 |
--------------------------------------------------------------------------------
/src/thoughtBubble.ts:
--------------------------------------------------------------------------------
1 | import paper = require("paper");
2 | import { Bubble } from "./bubble";
3 | import { SimpleRandom } from "./random";
4 |
5 | // Make a computed bubble shape by drawing an outward arc between each pair of points
6 | // in the array produced by makeBubbleItem.
7 | // This is currently very similar to Bubble.makePointedArcBubble, differing only
8 | // in some constants and adding deltaCenter instead of subtracting it.
9 | // However, I'm not sure things will stay that way, so I'm leaving the
10 | // duplication for now. It's a fairly small chunk of code.
11 | export function makeThoughtBubble(bubble: Bubble): paper.Item {
12 | const arcDepth = 9;
13 | // Seed the random number generator with a value predictable enough
14 | // that it will look the same each time the page is opened...
15 | // in fact it will go back to the same shape if the bubble grows
16 | // and then shrinks back to its original size.
17 | const width = bubble.content.clientWidth;
18 | const height = bubble.content.clientHeight;
19 | const rng = new SimpleRandom(width + height);
20 |
21 | return bubble.makeBubbleItem(0, (points, center) => {
22 | const outline = new paper.Path();
23 | const maxJitter = arcDepth / 2;
24 | for (let i = 0; i < points.length; i++) {
25 | const start = points[i];
26 | const end = i < points.length - 1 ? points[i + 1] : points[0];
27 | const mid = new paper.Point((start.x + end.x) / 2, (start.y + end.y) / 2);
28 | const deltaCenter = mid.subtract(center);
29 | // The rng here gives the bubbles a slightly 'random' depth of curve
30 | const jitter = maxJitter * rng.nextDouble();
31 | deltaCenter.length = arcDepth - jitter;
32 | const arcPoint = mid.add(deltaCenter);
33 | const arc = new paper.Path.Arc(start, arcPoint, end);
34 | arc.remove();
35 | outline.addSegments(arc.segments);
36 | }
37 | outline.strokeWidth = bubble.getBorderWidth();
38 | outline.strokeColor = new paper.Color("black");
39 | outline.closed = true; // It should already be, but may help paper.js to treat it so.
40 | outline.name = "outlineShape";
41 | return outline;
42 | });
43 | }
44 |
--------------------------------------------------------------------------------
/src/thoughtTail.ts:
--------------------------------------------------------------------------------
1 | import paper = require("paper");
2 | import { TailSpec } from "bubbleSpec";
3 | import { Bubble } from "./bubble";
4 | import { activateLayer, makeArc } from "./utilities";
5 | import { CurveTail } from "./curveTail";
6 | import { Comical } from "./comical";
7 |
8 | // A ThoughtTail is a succession of mini-bubbles, ellipses drawn along the curve.
9 | // One of them may partly overlap the main bubble.
10 | // Enhance: all the handle-related code could usefully be refactored into a
11 | // common base class shared by ArcTail, perhaps MidHandleTail
12 | export class ThoughtTail extends CurveTail {
13 | mark1: paper.Path.Circle | undefined;
14 | mark2: paper.Path.Circle | undefined;
15 |
16 | miniBubbleStrokePaths: paper.Path[];
17 | miniBubbleFillPaths: paper.Path[];
18 |
19 | public constructor(
20 | root: paper.Point,
21 | tip: paper.Point,
22 | mid: paper.Point,
23 | lowerLayer: paper.Layer,
24 | upperLayer: paper.Layer,
25 | handleLayer: paper.Layer,
26 | spec: TailSpec,
27 | bubble: Bubble | undefined
28 | ) {
29 | super(root, tip, lowerLayer, upperLayer, handleLayer, spec, bubble);
30 | this.mid = mid;
31 | }
32 |
33 | // Make the shapes that implement the tail.
34 | // If there are existing shapes (typically representing an earlier tail position),
35 | // remove them.
36 | public makeShapes() {
37 | if (this.miniBubbleFillPaths) {
38 | this.miniBubbleFillPaths.forEach(x => x.remove());
39 | }
40 | if (this.miniBubbleStrokePaths) {
41 | this.miniBubbleStrokePaths.forEach(x => x.remove());
42 | }
43 |
44 | const rootWidth = 20;
45 | const tipWidth = 7;
46 |
47 | // We want to make an arc from the tip to the root, and passing through mid.
48 | const centerPath = makeArc(this.tip, this.mid, this.root);
49 | centerPath.remove();
50 |
51 | const length = centerPath.length;
52 |
53 | let bubbleSep = 3;
54 | this.miniBubbleFillPaths = [];
55 | this.miniBubbleStrokePaths = [];
56 | // bubbleRim is roughly the distance from the tip where
57 | // the closer rim of the next mini-bubble will go.
58 | // It advances along the centerPath as the main loop iterates.
59 | let bubbleRim = bubbleSep;
60 |
61 | // Uncomment the three lastPoint lines to make the ellipses
62 | // align along the curve.
63 | //let lastPoint = this.tip;
64 |
65 | // Unusually, all thought-bubble tail shapes are drawn in the upper layer.
66 | // This allows one mini-bubble to overlap the main bubble and still be
67 | // drawn completely. It also means that a mini-bubble can be drawn on top
68 | // of some other bubble in the same layer, but that should be vanishingly rare.
69 | activateLayer(this.upperLayer);
70 |
71 | while (bubbleRim < length) {
72 | // Normal tails: bubble Radius grows from tipWidth to rootWidth along the path.
73 | // Joiner tails: Use a steady width along the entire path (See BL-9082).
74 | const bubbleRadius =
75 | this.spec.joiner !== true
76 | ? ((rootWidth - tipWidth) * bubbleRim) / length + tipWidth
77 | : (rootWidth + tipWidth) / 2;
78 | const bubbleCenter = bubbleRim + bubbleRadius;
79 | if (bubbleCenter >= length) {
80 | break; // We can't compute center!
81 | }
82 | const center = centerPath.getLocationAt(bubbleCenter).point;
83 | // This is a point roughly half way between the center and rim of the
84 | // mini-bubble along the arc. If that's inside the main bubble, we stop.
85 | // The effect is that up to about 3/4 of a mini-bubble can overlap the
86 | // main bubble. We must stop there, because the mini-bubbles are drawn
87 | // on top of the main bubble, and ones inside the text would be wrong.
88 | // This is the usual exit from this loop.
89 | const testPoint = center.add(centerPath.getLocationAt(bubbleRim).point).divide(2);
90 | if (this.bubble!.isHitByPoint(testPoint)) {
91 | break;
92 | }
93 | const newStroke = new paper.Path.Ellipse({
94 | center: center,
95 | size: new paper.Size(bubbleRadius + 5, bubbleRadius)
96 | });
97 | // Anything with a matching fill element needs doubled border
98 | // because it grows both ways from the ideal line, and the inner
99 | // half is covered by the fill.
100 | newStroke.strokeWidth = this.bubble!.getBorderWidth() * 2;
101 | newStroke.strokeColor = new paper.Color("black");
102 | //newStroke.rotation = center.subtract(lastPoint).angle;
103 | this.miniBubbleStrokePaths.push(newStroke);
104 | const newFill = newStroke.clone() as paper.Path;
105 | newFill.fillColor = this.getFillColor();
106 | // Any border on the fill area just confuses things.
107 | newFill.strokeWidth = 0;
108 |
109 | if (this.clickAction) {
110 | Comical.setItemOnClick(newFill, this.clickAction);
111 | }
112 |
113 | this.miniBubbleFillPaths.push(newFill);
114 |
115 | // prepare for next iteration
116 | bubbleSep += 2;
117 | bubbleRim += bubbleRadius * 2 + bubbleSep;
118 | //lastPoint = center;
119 | }
120 | }
121 |
122 | public fillPaths(): paper.Path[] {
123 | return this.miniBubbleFillPaths || [];
124 | }
125 |
126 | public allPaths(): paper.Path[] {
127 | let result = this.fillPaths();
128 | if (this.miniBubbleStrokePaths) {
129 | result = [...result, ...this.miniBubbleStrokePaths];
130 | }
131 | return result;
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/uniqueId.ts:
--------------------------------------------------------------------------------
1 | // modify an SVG element to use unique internal IDs.
2 | // based on ideas from https://github.com/elderapo/react-svg-unique-id
3 | // This seems to do enough for what Comical currently needs;
4 | // it's not clear that it's enough for any conceivable svg that might be
5 | // used to define a bubble.
6 |
7 | export function uniqueIds(e: Element) {
8 | const idElements = e.ownerDocument!.evaluate(".//*[@id]", e, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
9 | const map = {};
10 | const guid: string = "i" + createUuid();
11 | for (let i = 0; i < idElements.snapshotLength; i++) {
12 | const idElement = idElements.snapshotItem(i) as HTMLElement;
13 | const id = idElement.getAttribute("id");
14 | if (id) {
15 | const newId = guid + id;
16 | map[id] = newId;
17 | idElement.setAttribute("id", newId);
18 | }
19 | }
20 | fixElement(e, map);
21 | }
22 |
23 | // adapted from http://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
24 | function createUuid(): string {
25 | // http://www.ietf.org/rfc/rfc4122.txt
26 | var s: string[] = [];
27 | var hexDigits = "0123456789abcdef";
28 | for (var i = 0; i < 36; i++) {
29 | s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
30 | }
31 | s[14] = "4"; // bits 12-15 of the time_hi_and_version field to 0010
32 | s[19] = hexDigits.substr((s[19] as any & 0x3) | 0x8, 1); // bits 6-7 of the clock_seq_hi_and_reserved to 01
33 | s[8] = s[13] = s[18] = s[23] = "-";
34 |
35 | var uuid = s.join("");
36 | return uuid;
37 | }
38 |
39 | function fixElement(e: Element, map: object) {
40 | for (let i = 0; i < e.children.length; i++) {
41 | fixElement(e.children[i], map);
42 | }
43 | for (let j = 0; j < e.attributes.length; j++) {
44 | var attrib = e.attributes[j];
45 | if (attrib.value && attrib.value.startsWith("url(#") && attrib.value.endsWith(")")) {
46 | const id = attrib.value.substring("url(#".length, attrib.value.length - 1);
47 | const newId = map[id];
48 | if (newId) {
49 | e.setAttribute(attrib.name, "url(#" + newId + ")");
50 | }
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/utilities.ts:
--------------------------------------------------------------------------------
1 | import paper = require("paper");
2 |
3 | // a place for useful functions that don't seem to belong in any particular class.
4 |
5 | // Surprisingly, activating a layer does NOT activate its project automatically,
6 | // resulting in new objects that were expected to go into the just-activated
7 | // layer actually going into the activeLayer of the active project.
8 | // This function activates both the layer and its project, so new objects
9 | // automatically go into this layer.
10 | export function activateLayer(layer: paper.Layer) {
11 | if (layer) {
12 | layer.project.activate();
13 | layer.activate();
14 | }
15 | }
16 |
17 | export function makeArc(start: paper.Point, mid: paper.Point, end: paper.Point): paper.Path {
18 | // Path.Arc fails when the points are on a straight line.
19 | // In that case, just return the line.
20 | // This includes the pathological case where mid on the line through start and end,
21 | // but not between them. In that case, it's not possible to draw an arc
22 | // that includes the three points, so we'll still go with a line from
23 | // start to end.
24 | const angleDiff = Math.abs(mid.subtract(start).angle - end.subtract(start).angle);
25 | if (angleDiff < 0.0001 || Math.abs(angleDiff - 180) < 0.0001) {
26 | return new paper.Path.Line(start, end);
27 | }
28 | return new paper.Path.Arc(start, mid, end);
29 | }
30 |
--------------------------------------------------------------------------------
/stories/bubbleDrag.ts:
--------------------------------------------------------------------------------
1 | import { Comical } from "../src/comical";
2 |
3 | // rather crude support for dragging bubbles around. Sometimes a bubble gets caught
4 | // when aiming for a handle that's up against it, and it doesn't yet handle scaling.
5 | // But it's good enough to help us try out the effects of moving bubbles in StoryBook.
6 | // Assumes bubbles are positioned by style attributes containing left: and top: in px.
7 | export function startDragging(containerIn: HTMLElement) {
8 | const container = containerIn;
9 | let startDragX = 0;
10 | let startDragY = 0;
11 | let dragWhat: HTMLElement | undefined = undefined;
12 | document.addEventListener("mousedown", (ev: MouseEvent) => {
13 | startDragX = ev.clientX;
14 | startDragY = ev.clientY;
15 | const dragBubble = Comical.getBubbleHit(container, startDragX, startDragY);
16 | dragWhat = dragBubble ? dragBubble.content : undefined;
17 | });
18 | document.addEventListener("mousemove", (ev: MouseEvent) => {
19 | if (ev.buttons === 1 && dragWhat) {
20 | const deltaX = ev.clientX - startDragX;
21 | const deltaY = ev.clientY - startDragY;
22 | if (deltaX != 0) {
23 | dragWhat.style.left = parseInt(dragWhat.style.left, 10) + deltaX + "px";
24 | }
25 | if (deltaY != 0) {
26 | dragWhat.style.top = parseInt(dragWhat.style.top, 10) + deltaY + "px";
27 | }
28 | startDragX = ev.clientX;
29 | startDragY = ev.clientY;
30 | }
31 | });
32 | }
33 |
--------------------------------------------------------------------------------
/storyStatic/HowDidItGoMyDaughter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BloomBooks/comical-js/9ee05d0ff6f12a1d735a87a11fb4672f5322ee23/storyStatic/HowDidItGoMyDaughter.png
--------------------------------------------------------------------------------
/storyStatic/MotherNaomi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BloomBooks/comical-js/9ee05d0ff6f12a1d735a87a11fb4672f5322ee23/storyStatic/MotherNaomi.png
--------------------------------------------------------------------------------
/storyStatic/The Moon and The Cap_Page 031.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BloomBooks/comical-js/9ee05d0ff6f12a1d735a87a11fb4672f5322ee23/storyStatic/The Moon and The Cap_Page 031.jpg
--------------------------------------------------------------------------------
/storyStatic/The Moon and The Cap_Page 051.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BloomBooks/comical-js/9ee05d0ff6f12a1d735a87a11fb4672f5322ee23/storyStatic/The Moon and The Cap_Page 051.jpg
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "dist",
4 | "module": "commonjs",
5 | "target": "es5",
6 | "lib": ["es5", "es6", "es7", "es2017", "dom"],
7 | "sourceMap": true,
8 | "allowJs": false,
9 | "jsx": "react",
10 | "moduleResolution": "node",
11 | "rootDirs": ["src", "stories"],
12 | "baseUrl": "src",
13 | "forceConsistentCasingInFileNames": true,
14 | "noImplicitReturns": true,
15 | "noImplicitThis": true,
16 | "noImplicitAny": true,
17 | "strictNullChecks": true,
18 | "suppressImplicitAnyIndexErrors": true,
19 | "noUnusedLocals": true,
20 | "declaration": true,
21 | "allowSyntheticDefaultImports": true,
22 | "experimentalDecorators": true,
23 | "emitDecoratorMetadata": true
24 | },
25 | "include": ["src/**/*"],
26 | "exclude": ["node_modules", "build", "scripts"]
27 | }
28 |
--------------------------------------------------------------------------------
/webpack.common.js:
--------------------------------------------------------------------------------
1 | var path = require("path");
2 | var webpack = require("webpack");
3 | var node_modules = path.resolve(__dirname, "node_modules");
4 |
5 | var globule = require("globule");
6 | const CopyPlugin = require("copy-webpack-plugin");
7 | var outputDir = "dist";
8 |
9 | // From Bloom's webpack, it seems this is needed
10 | // if ever our output directory does not have the same parent as our node_modules. We then
11 | // need to resolve the babel related presets (and plugins). This mapping function was
12 | // suggested at https://github.com/babel/babel-loader/issues/166.
13 | // Since our node_modules DOES have the same parent, maybe we could do without it?
14 | function localResolve(preset) {
15 | return Array.isArray(preset)
16 | ? [require.resolve(preset[0]), preset[1]]
17 | : require.resolve(preset);
18 | }
19 |
20 | module.exports = {
21 | // mode must be set to either "production" or "development" in webpack 4.
22 | // Webpack-common is intended to be 'required' by something that provides that.
23 | context: __dirname,
24 | entry: {
25 | index: "./src/index.ts"
26 | },
27 |
28 | output: {
29 | path: path.join(__dirname, outputDir),
30 | filename: "[name].js",
31 | library: "ComicalJS",
32 | // Exporting the library in umd format allows its various classes to
33 | // be imported in typescript using import (rather than only by
34 | // using require on the whole library) in a way that is consistent
35 | // with the d.ts files we are generating. Using various other formats
36 | // we found that a client could import the classes apparently successfully,
37 | // but they were undefined at runtime.
38 | // UMD is also a good universal library format that supports both AMD and commonjs.
39 | libraryTarget: "umd"
40 | },
41 |
42 | resolve: {
43 | modules: [".", node_modules],
44 | extensions: [".js", ".jsx", ".ts", ".tsx"]
45 | },
46 |
47 | optimization: {
48 | minimize: false,
49 | namedModules: true,
50 | splitChunks: {
51 | cacheGroups: {
52 | default: false
53 | }
54 | }
55 | },
56 | module: {
57 | rules: [
58 | {
59 | test: /\.ts(x?)$/,
60 | use: [{ loader: "ts-loader" }]
61 | },
62 | {
63 | test: /\.less$/i,
64 | use: [
65 | {
66 | loader: "style-loader" // creates style nodes from JS strings
67 | },
68 | {
69 | loader: "css-loader" // translates CSS into CommonJS
70 | },
71 | {
72 | loader: "less-loader" // compiles Less to CSS
73 | }
74 | ]
75 | },
76 | {
77 | test: /\.css$/,
78 | loader: "style-loader!css-loader"
79 | },
80 | // WOFF Font--needed?
81 | {
82 | test: /\.woff(\?v=\d+\.\d+\.\d+)?$/,
83 | use: {
84 | loader: "url-loader",
85 | options: {
86 | limit: 10000,
87 | mimetype: "application/font-woff"
88 | }
89 | }
90 | },
91 | {
92 | // this allows things like background-image: url("myComponentsButton.svg") and have the resulting path look for the svg in the stylesheet's folder
93 | // the last few seem to be needed for (at least) slick-carousel to build.
94 | test: /\.(svg|jpg|png|ttf|eot|gif)$/,
95 | use: {
96 | loader: "file-loader"
97 | }
98 | }
99 | ]
100 | }
101 | };
102 |
--------------------------------------------------------------------------------
/webpack.config-prod.js:
--------------------------------------------------------------------------------
1 | const merge = require("webpack-merge");
2 | const common = require("./webpack.common.js");
3 | const TerserPlugin = require("terser-webpack-plugin");
4 |
5 | module.exports = merge(common, {
6 | mode: "production",
7 | devtool: "source-map",
8 | output: {filename: "[name].min.js", sourceMapFilename: "[file].map" },
9 |
10 | optimization: {
11 | minimize: true,
12 | minimizer: [new TerserPlugin({
13 | sourceMap: true
14 | })]
15 | }
16 | });
17 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const merge = require("webpack-merge");
2 | const common = require("./webpack.common.js");
3 |
4 | module.exports = merge(common, {
5 | mode: "development",
6 | devtool: "source-map",
7 | devServer: {
8 | contentBase: "./dist"
9 | }
10 | });
11 |
--------------------------------------------------------------------------------