js-draw 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.firebase/hosting.ZG9jcw.cache +338 -0
  2. package/.github/ISSUE_TEMPLATE/translation.md +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/dist/bundle.js +1 -1
  5. package/dist/src/Editor.d.ts +0 -1
  6. package/dist/src/Editor.js +4 -3
  7. package/dist/src/SVGLoader.js +2 -2
  8. package/dist/src/components/Stroke.js +1 -0
  9. package/dist/src/components/Text.d.ts +10 -5
  10. package/dist/src/components/Text.js +49 -15
  11. package/dist/src/components/builders/FreehandLineBuilder.d.ts +9 -2
  12. package/dist/src/components/builders/FreehandLineBuilder.js +127 -28
  13. package/dist/src/components/lib.d.ts +2 -2
  14. package/dist/src/components/lib.js +2 -2
  15. package/dist/src/rendering/renderers/CanvasRenderer.js +2 -2
  16. package/dist/src/rendering/renderers/SVGRenderer.d.ts +2 -0
  17. package/dist/src/rendering/renderers/SVGRenderer.js +49 -22
  18. package/dist/src/toolbar/IconProvider.d.ts +24 -18
  19. package/dist/src/toolbar/IconProvider.js +23 -21
  20. package/dist/src/toolbar/widgets/PenToolWidget.js +8 -5
  21. package/dist/src/tools/PasteHandler.js +2 -22
  22. package/dist/src/tools/TextTool.d.ts +4 -0
  23. package/dist/src/tools/TextTool.js +76 -15
  24. package/package.json +1 -1
  25. package/src/Editor.toSVG.test.ts +27 -0
  26. package/src/Editor.ts +4 -4
  27. package/src/SVGLoader.test.ts +20 -0
  28. package/src/SVGLoader.ts +4 -4
  29. package/src/components/Stroke.ts +1 -0
  30. package/src/components/Text.test.ts +3 -3
  31. package/src/components/Text.ts +62 -19
  32. package/src/components/builders/FreehandLineBuilder.ts +160 -32
  33. package/src/components/lib.ts +3 -3
  34. package/src/rendering/renderers/CanvasRenderer.ts +2 -2
  35. package/src/rendering/renderers/SVGRenderer.ts +50 -24
  36. package/src/toolbar/IconProvider.ts +24 -20
  37. package/src/toolbar/widgets/PenToolWidget.ts +9 -5
  38. package/src/tools/PasteHandler.ts +2 -24
  39. package/src/tools/TextTool.ts +86 -17
@@ -8,7 +8,7 @@ import Pen from '../tools/Pen';
8
8
  import { StrokeDataPoint } from '../types';
9
9
  import Viewport from '../Viewport';
10
10
 
11
-
11
+ type IconType = SVGSVGElement|HTMLImageElement;
12
12
 
13
13
  const svgNamespace = 'http://www.w3.org/2000/svg';
14
14
  const iconColorFill = `
@@ -36,13 +36,13 @@ const checkerboardPatternRef = 'url(#checkerboard)';
36
36
  // Extend this class and override methods to customize icons.
37
37
  export default class IconProvider {
38
38
 
39
- public makeUndoIcon() {
39
+ public makeUndoIcon(): IconType {
40
40
  return this.makeRedoIcon(true);
41
41
  }
42
42
 
43
43
  // @param mirror - reflect across the x-axis @internal
44
44
  // @returns a redo icon.
45
- public makeRedoIcon(mirror: boolean = false) {
45
+ public makeRedoIcon(mirror: boolean = false): IconType {
46
46
  const icon = document.createElementNS(svgNamespace, 'svg');
47
47
  icon.innerHTML = `
48
48
  <style>
@@ -65,7 +65,7 @@ export default class IconProvider {
65
65
  return icon;
66
66
  }
67
67
 
68
- public makeDropdownIcon() {
68
+ public makeDropdownIcon(): IconType {
69
69
  const icon = document.createElementNS(svgNamespace, 'svg');
70
70
  icon.innerHTML = `
71
71
  <g>
@@ -79,7 +79,7 @@ export default class IconProvider {
79
79
  return icon;
80
80
  }
81
81
 
82
- public makeEraserIcon() {
82
+ public makeEraserIcon(): IconType {
83
83
  const icon = document.createElementNS(svgNamespace, 'svg');
84
84
 
85
85
  // Draw an eraser-like shape
@@ -96,7 +96,7 @@ export default class IconProvider {
96
96
  return icon;
97
97
  }
98
98
 
99
- public makeSelectionIcon() {
99
+ public makeSelectionIcon(): IconType {
100
100
  const icon = document.createElementNS(svgNamespace, 'svg');
101
101
 
102
102
  // Draw a cursor-like shape
@@ -111,12 +111,16 @@ export default class IconProvider {
111
111
  return icon;
112
112
  }
113
113
 
114
+ /**
115
+ * @param pathData - SVG path data (e.g. `m10,10l30,30z`)
116
+ * @param fill - A valid CSS color (e.g. `var(--icon-color)` or `#f0f`). This can be `none`.
117
+ */
114
118
  protected makeIconFromPath(
115
119
  pathData: string,
116
120
  fill: string = 'var(--icon-color)',
117
121
  strokeColor: string = 'none',
118
122
  strokeWidth: string = '0px',
119
- ) {
123
+ ): IconType {
120
124
  const icon = document.createElementNS(svgNamespace, 'svg');
121
125
  const path = document.createElementNS(svgNamespace, 'path');
122
126
  path.setAttribute('d', pathData);
@@ -129,7 +133,7 @@ export default class IconProvider {
129
133
  return icon;
130
134
  }
131
135
 
132
- public makeHandToolIcon() {
136
+ public makeHandToolIcon(): IconType {
133
137
  const fill = 'none';
134
138
  const strokeColor = 'var(--icon-color)';
135
139
  const strokeWidth = '3';
@@ -158,7 +162,7 @@ export default class IconProvider {
158
162
  `, fill, strokeColor, strokeWidth);
159
163
  }
160
164
 
161
- public makeTouchPanningIcon() {
165
+ public makeTouchPanningIcon(): IconType {
162
166
  const fill = 'none';
163
167
  const strokeColor = 'var(--icon-color)';
164
168
  const strokeWidth = '3';
@@ -192,7 +196,7 @@ export default class IconProvider {
192
196
  `, fill, strokeColor, strokeWidth);
193
197
  }
194
198
 
195
- public makeAllDevicePanningIcon() {
199
+ public makeAllDevicePanningIcon(): IconType {
196
200
  const fill = 'none';
197
201
  const strokeColor = 'var(--icon-color)';
198
202
  const strokeWidth = '3';
@@ -248,7 +252,7 @@ export default class IconProvider {
248
252
  `, fill, strokeColor, strokeWidth);
249
253
  }
250
254
 
251
- public makeZoomIcon = () => {
255
+ public makeZoomIcon(): IconType {
252
256
  const icon = document.createElementNS(svgNamespace, 'svg');
253
257
  icon.setAttribute('viewBox', '0 0 100 100');
254
258
 
@@ -270,9 +274,9 @@ export default class IconProvider {
270
274
  addTextNode('-', 70, 75);
271
275
 
272
276
  return icon;
273
- };
277
+ }
274
278
 
275
- public makeTextIcon(textStyle: TextStyle) {
279
+ public makeTextIcon(textStyle: TextStyle): IconType {
276
280
  const icon = document.createElementNS(svgNamespace, 'svg');
277
281
  icon.setAttribute('viewBox', '0 0 100 100');
278
282
 
@@ -295,7 +299,7 @@ export default class IconProvider {
295
299
  return icon;
296
300
  }
297
301
 
298
- public makePenIcon(tipThickness: number, color: string|Color4) {
302
+ public makePenIcon(tipThickness: number, color: string|Color4): IconType {
299
303
  if (color instanceof Color4) {
300
304
  color = color.toHexString();
301
305
  }
@@ -334,7 +338,7 @@ export default class IconProvider {
334
338
  return icon;
335
339
  }
336
340
 
337
- public makeIconFromFactory(pen: Pen, factory: ComponentBuilderFactory) {
341
+ public makeIconFromFactory(pen: Pen, factory: ComponentBuilderFactory): IconType {
338
342
  const toolThickness = pen.getThickness();
339
343
 
340
344
  const nowTime = (new Date()).getTime();
@@ -365,7 +369,7 @@ export default class IconProvider {
365
369
  return icon;
366
370
  }
367
371
 
368
- public makePipetteIcon(color?: Color4) {
372
+ public makePipetteIcon(color?: Color4): IconType {
369
373
  const icon = document.createElementNS(svgNamespace, 'svg');
370
374
  const pipette = document.createElementNS(svgNamespace, 'path');
371
375
 
@@ -419,7 +423,7 @@ export default class IconProvider {
419
423
  return icon;
420
424
  }
421
425
 
422
- public makeResizeViewportIcon() {
426
+ public makeResizeViewportIcon(): IconType {
423
427
  return this.makeIconFromPath(`
424
428
  M 75 5 75 10 90 10 90 25 95 25 95 5 75 5 z
425
429
  M 15 15 15 30 20 30 20 20 30 20 30 15 15 15 z
@@ -432,14 +436,14 @@ export default class IconProvider {
432
436
  `);
433
437
  }
434
438
 
435
- public makeDuplicateSelectionIcon() {
439
+ public makeDuplicateSelectionIcon(): IconType {
436
440
  return this.makeIconFromPath(`
437
441
  M 45,10 45,55 90,55 90,10 45,10 z
438
442
  M 10,25 10,90 70,90 70,60 40,60 40,25 10,25 z
439
443
  `);
440
444
  }
441
445
 
442
- public makeDeleteSelectionIcon() {
446
+ public makeDeleteSelectionIcon(): IconType {
443
447
  const strokeWidth = '5px';
444
448
  const strokeColor = 'var(--icon-color)';
445
449
  const fillColor = 'none';
@@ -450,7 +454,7 @@ export default class IconProvider {
450
454
  `, fillColor, strokeColor, strokeWidth);
451
455
  }
452
456
 
453
- public makeSaveIcon() {
457
+ public makeSaveIcon(): IconType {
454
458
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
455
459
  svg.innerHTML = `
456
460
  <style>
@@ -105,12 +105,16 @@ export default class PenToolWidget extends BaseToolWidget {
105
105
  objectSelectLabel.innerText = this.localizationTable.selectObjectType;
106
106
  objectSelectLabel.setAttribute('for', objectTypeSelect.id);
107
107
 
108
+ // Use a logarithmic scale for thicknessInput (finer control over thinner strokewidths.)
109
+ const inverseThicknessInputFn = (t: number) => Math.log10(t);
110
+ const thicknessInputFn = (t: number) => 10**t;
111
+
108
112
  thicknessInput.type = 'range';
109
- thicknessInput.min = '2';
110
- thicknessInput.max = '20';
111
- thicknessInput.step = '1';
113
+ thicknessInput.min = `${inverseThicknessInputFn(2)}`;
114
+ thicknessInput.max = `${inverseThicknessInputFn(400)}`;
115
+ thicknessInput.step = '0.1';
112
116
  thicknessInput.oninput = () => {
113
- this.tool.setThickness(parseFloat(thicknessInput.value) ** 2);
117
+ this.tool.setThickness(thicknessInputFn(parseFloat(thicknessInput.value)));
114
118
  };
115
119
  thicknessRow.appendChild(thicknessLabel);
116
120
  thicknessRow.appendChild(thicknessInput);
@@ -142,7 +146,7 @@ export default class PenToolWidget extends BaseToolWidget {
142
146
 
143
147
  this.updateInputs = () => {
144
148
  colorInput.value = this.tool.getColor().toHexString();
145
- thicknessInput.value = Math.sqrt(this.tool.getThickness()).toString();
149
+ thicknessInput.value = inverseThicknessInputFn(this.tool.getThickness()).toString();
146
150
 
147
151
  objectTypeSelect.replaceChildren();
148
152
  for (let i = 0; i < this.penTypes.length; i ++) {
@@ -8,7 +8,7 @@ import { AbstractComponent, TextComponent } from '../components/lib';
8
8
  import { Command, uniteCommands } from '../commands/lib';
9
9
  import SVGLoader from '../SVGLoader';
10
10
  import { PasteEvent } from '../types';
11
- import { Mat33, Rect2, Vec2 } from '../math/lib';
11
+ import { Mat33, Rect2 } from '../math/lib';
12
12
  import BaseTool from './BaseTool';
13
13
  import EditorImage from '../EditorImage';
14
14
  import SelectionTool from './SelectionTool/SelectionTool';
@@ -125,29 +125,7 @@ export default class PasteHandler extends BaseTool {
125
125
  const pastedTextStyle: TextStyle = textTools[0]?.getTextStyle() ?? defaultTextStyle;
126
126
 
127
127
  const lines = text.split('\n');
128
- let lastComponent: TextComponent|null = null;
129
- const components: TextComponent[] = [];
130
-
131
- for (const line of lines) {
132
- let position = Vec2.zero;
133
- if (lastComponent) {
134
- const lineMargin = Math.floor(pastedTextStyle.size);
135
- position = lastComponent.getBBox().bottomLeft.plus(Vec2.unitY.times(lineMargin));
136
- }
137
-
138
- const component = new TextComponent([ line ], Mat33.translation(position), pastedTextStyle);
139
- components.push(component);
140
- lastComponent = component;
141
- }
142
-
143
- if (components.length === 1) {
144
- await this.addComponentsFromPaste([ components[0] ]);
145
- } else {
146
- // Wrap the existing `TextComponent`s --- dragging one component should drag all.
147
- await this.addComponentsFromPaste([
148
- new TextComponent(components, Mat33.identity, pastedTextStyle)
149
- ]);
150
- }
128
+ await this.addComponentsFromPaste([ TextComponent.fromLines(lines, Mat33.identity, pastedTextStyle) ]);
151
129
  }
152
130
 
153
131
  private async doImagePaste(dataURL: string) {
@@ -1,23 +1,29 @@
1
1
  import Color4 from '../Color4';
2
- import Text, { TextStyle } from '../components/Text';
2
+ import TextComponent, { TextStyle } from '../components/Text';
3
3
  import Editor from '../Editor';
4
4
  import EditorImage from '../EditorImage';
5
+ import Rect2 from '../math/Rect2';
5
6
  import Mat33 from '../math/Mat33';
6
7
  import { Vec2 } from '../math/Vec2';
7
8
  import { PointerDevice } from '../Pointer';
8
9
  import { EditorEventType, PointerEvt } from '../types';
9
10
  import BaseTool from './BaseTool';
10
11
  import { ToolLocalization } from './localization';
12
+ import Erase from '../commands/Erase';
13
+ import uniteCommands from '../commands/uniteCommands';
11
14
 
12
15
  const overlayCssClass = 'textEditorOverlay';
13
16
  export default class TextTool extends BaseTool {
14
17
  private textStyle: TextStyle;
15
18
 
16
19
  private textEditOverlay: HTMLElement;
17
- private textInputElem: HTMLInputElement|null = null;
20
+ private textInputElem: HTMLTextAreaElement|null = null;
18
21
  private textTargetPosition: Vec2|null = null;
19
22
  private textMeasuringCtx: CanvasRenderingContext2D|null = null;
20
23
  private textRotation: number;
24
+ private textScale: Vec2 = Vec2.of(1, 1);
25
+
26
+ private removeExistingCommand: Erase|null = null;
21
27
 
22
28
  public constructor(private editor: Editor, description: string, private localizationTable: ToolLocalization) {
23
29
  super(editor.notifier, description);
@@ -37,10 +43,19 @@ export default class TextTool extends BaseTool {
37
43
  overflow: visible;
38
44
  }
39
45
 
40
- .${overlayCssClass} input {
46
+ .${overlayCssClass} textarea {
41
47
  background-color: rgba(0, 0, 0, 0);
48
+
49
+ white-space: pre;
50
+ overflow: hidden;
51
+
52
+ padding: 0;
53
+ margin: 0;
42
54
  border: none;
43
55
  padding: 0;
56
+
57
+ min-width: 100px;
58
+ min-height: 1.1em;
44
59
  }
45
60
  `);
46
61
  this.editor.createHTMLOverlay(this.textEditOverlay);
@@ -50,7 +65,7 @@ export default class TextTool extends BaseTool {
50
65
  private getTextAscent(text: string, style: TextStyle): number {
51
66
  this.textMeasuringCtx ??= document.createElement('canvas').getContext('2d');
52
67
  if (this.textMeasuringCtx) {
53
- Text.applyTextStyles(this.textMeasuringCtx, style);
68
+ TextComponent.applyTextStyles(this.textMeasuringCtx, style);
54
69
  return this.textMeasuringCtx.measureText(text).actualBoundingBoxAscent;
55
70
  }
56
71
 
@@ -60,7 +75,7 @@ export default class TextTool extends BaseTool {
60
75
 
61
76
  private flushInput() {
62
77
  if (this.textInputElem && this.textTargetPosition) {
63
- const content = this.textInputElem.value;
78
+ const content = this.textInputElem.value.trimEnd();
64
79
  this.textInputElem.remove();
65
80
  this.textInputElem = null;
66
81
 
@@ -70,23 +85,33 @@ export default class TextTool extends BaseTool {
70
85
 
71
86
  const textTransform = Mat33.translation(
72
87
  this.textTargetPosition
88
+ ).rightMul(
89
+ this.getTextScaleMatrix()
73
90
  ).rightMul(
74
91
  Mat33.scaling2D(this.editor.viewport.getSizeOfPixelOnCanvas())
75
92
  ).rightMul(
76
93
  Mat33.zRotation(this.textRotation)
77
94
  );
78
95
 
79
- const textComponent = new Text(
80
- [ content ],
81
- textTransform,
82
- this.textStyle,
83
- );
96
+ const textComponent = TextComponent.fromLines(content.split('\n'), textTransform, this.textStyle);
84
97
 
85
98
  const action = EditorImage.addElement(textComponent);
86
- this.editor.dispatch(action);
99
+ if (this.removeExistingCommand) {
100
+ // Unapply so that `removeExistingCommand` can be added to the undo stack.
101
+ this.removeExistingCommand.unapply(this.editor);
102
+
103
+ this.editor.dispatch(uniteCommands([ this.removeExistingCommand, action ]));
104
+ this.removeExistingCommand = null;
105
+ } else {
106
+ this.editor.dispatch(action);
107
+ }
87
108
  }
88
109
  }
89
110
 
111
+ private getTextScaleMatrix() {
112
+ return Mat33.scaling2D(this.textScale.times(1/this.editor.viewport.getSizeOfPixelOnCanvas()));
113
+ }
114
+
90
115
  private updateTextInput() {
91
116
  if (!this.textInputElem || !this.textTargetPosition) {
92
117
  this.textInputElem?.remove();
@@ -95,7 +120,6 @@ export default class TextTool extends BaseTool {
95
120
 
96
121
  const viewport = this.editor.viewport;
97
122
  const textScreenPos = viewport.canvasToScreen(this.textTargetPosition);
98
- this.textInputElem.type = 'text';
99
123
  this.textInputElem.placeholder = this.localizationTable.enterTextToInsert;
100
124
  this.textInputElem.style.fontFamily = this.textStyle.fontFamily;
101
125
  this.textInputElem.style.fontVariant = this.textStyle.fontVariant ?? '';
@@ -108,24 +132,34 @@ export default class TextTool extends BaseTool {
108
132
  this.textInputElem.style.top = `${textScreenPos.y}px`;
109
133
  this.textInputElem.style.margin = '0';
110
134
 
135
+ this.textInputElem.style.width = `${this.textInputElem.scrollWidth}px`;
136
+ this.textInputElem.style.height = `${this.textInputElem.scrollHeight}px`;
137
+
111
138
  const rotation = this.textRotation + viewport.getRotationAngle();
112
139
  const ascent = this.getTextAscent(this.textInputElem.value || 'W', this.textStyle);
113
- this.textInputElem.style.transform = `rotate(${rotation * 180 / Math.PI}deg) translate(0, ${-ascent}px)`;
140
+ const scale: Mat33 = this.getTextScaleMatrix();
141
+ this.textInputElem.style.transform = `${scale.toCSSMatrix()} rotate(${rotation * 180 / Math.PI}deg) translate(0, ${-ascent}px)`;
114
142
  this.textInputElem.style.transformOrigin = 'top left';
115
143
  }
116
144
 
117
145
  private startTextInput(textCanvasPos: Vec2, initialText: string) {
118
146
  this.flushInput();
119
147
 
120
- this.textInputElem = document.createElement('input');
148
+ this.textInputElem = document.createElement('textarea');
121
149
  this.textInputElem.value = initialText;
150
+ this.textInputElem.style.display = 'inline-block';
122
151
  this.textTargetPosition = textCanvasPos;
123
152
  this.textRotation = -this.editor.viewport.getRotationAngle();
153
+ this.textScale = Vec2.of(1, 1).times(this.editor.viewport.getSizeOfPixelOnCanvas());
124
154
  this.updateTextInput();
125
155
 
156
+ // Update the input size/position/etc. after the placeHolder has had time to appear.
157
+ setTimeout(() => this.updateTextInput(), 0);
158
+
126
159
  this.textInputElem.oninput = () => {
127
160
  if (this.textInputElem) {
128
- this.textInputElem.size = this.textInputElem?.value.length || 10;
161
+ this.textInputElem.style.width = `${this.textInputElem.scrollWidth}px`;
162
+ this.textInputElem.style.height = `${this.textInputElem.scrollHeight}px`;
129
163
  }
130
164
  };
131
165
  this.textInputElem.onblur = () => {
@@ -134,7 +168,7 @@ export default class TextTool extends BaseTool {
134
168
  setTimeout(() => this.flushInput(), 0);
135
169
  };
136
170
  this.textInputElem.onkeyup = (evt) => {
137
- if (evt.key === 'Enter') {
171
+ if (evt.key === 'Enter' && !evt.shiftKey) {
138
172
  this.flushInput();
139
173
  this.editor.focus();
140
174
  } else if (evt.key === 'Escape') {
@@ -142,6 +176,9 @@ export default class TextTool extends BaseTool {
142
176
  this.textInputElem?.remove();
143
177
  this.textInputElem = null;
144
178
  this.editor.focus();
179
+
180
+ this.removeExistingCommand?.unapply(this.editor);
181
+ this.removeExistingCommand = null;
145
182
  }
146
183
  };
147
184
 
@@ -165,7 +202,33 @@ export default class TextTool extends BaseTool {
165
202
  }
166
203
 
167
204
  if (allPointers.length === 1) {
168
- this.startTextInput(current.canvasPos, '');
205
+
206
+ // Are we clicking on a text node?
207
+ const canvasPos = current.canvasPos;
208
+ const halfTestRegionSize = Vec2.of(2.5, 2.5).times(this.editor.viewport.getSizeOfPixelOnCanvas());
209
+ const testRegion = Rect2.fromCorners(canvasPos.minus(halfTestRegionSize), canvasPos.plus(halfTestRegionSize));
210
+ const targetNodes = this.editor.image.getElementsIntersectingRegion(testRegion);
211
+ const targetTextNodes = targetNodes.filter(node => node instanceof TextComponent) as TextComponent[];
212
+
213
+ if (targetTextNodes.length > 0) {
214
+ const targetNode = targetTextNodes[targetTextNodes.length - 1];
215
+ this.setTextStyle(targetNode.getTextStyle());
216
+
217
+ // Create and temporarily apply removeExistingCommand.
218
+ this.removeExistingCommand = new Erase([ targetNode ]);
219
+ this.removeExistingCommand.apply(this.editor);
220
+
221
+ this.startTextInput(targetNode.getBaselinePos(), targetNode.getText());
222
+
223
+ const transform = targetNode.getTransform();
224
+ this.textRotation = transform.transformVec3(Vec2.unitX).angle();
225
+ const scaleFactor = transform.transformVec3(Vec2.unitX).magnitude();
226
+ this.textScale = Vec2.of(1, 1).times(scaleFactor);
227
+ this.updateTextInput();
228
+ } else {
229
+ this.removeExistingCommand = null;
230
+ this.startTextInput(current.canvasPos, '');
231
+ }
169
232
  return true;
170
233
  }
171
234
 
@@ -224,4 +287,10 @@ export default class TextTool extends BaseTool {
224
287
  public getTextStyle(): TextStyle {
225
288
  return this.textStyle;
226
289
  }
290
+
291
+ private setTextStyle(style: TextStyle) {
292
+ // Copy the style — we may change parts of it.
293
+ this.textStyle = {...style};
294
+ this.dispatchUpdateEvent();
295
+ }
227
296
  }