• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1
2function ASSERT(pred) {
3    console.assert(pred, 'assert failed');
4}
5
6function LOG(...args) {
7    // comment out for non-debugging
8//    console.log(args);
9}
10
11function MakeCursor(CanvasKit) {
12    const linePaint = new CanvasKit.Paint();
13    linePaint.setColor([0,0,1,1]);
14    linePaint.setStyle(CanvasKit.PaintStyle.Stroke);
15    linePaint.setStrokeWidth(2);
16    linePaint.setAntiAlias(true);
17
18    const pathPaint = new CanvasKit.Paint();
19    pathPaint.setColor([0,0,1,0.25]);
20    linePaint.setAntiAlias(true);
21
22    return {
23        _line_paint: linePaint,    // wrap in weak-ref so we can delete it?
24        _path_paint: pathPaint,
25        _x: 0,
26        _top: 0,
27        _bottom: 0,
28        _path: null,            // only use x,top,bottom if path is null
29        _draws_per_sec: 2,
30
31        // pass 0 for no-draw, pass inf. for always on
32        setBlinkRate: function(blinks_per_sec) {
33            this._draws_per_sec = blinks_per_sec;
34        },
35        place: function(x, top, bottom) {
36            this._x = x;
37            this._top = top;
38            this._bottom = bottom;
39
40            this.setPath(null);
41        },
42        setPath: function(path) {
43            if (this._path) {
44                this._path.delete();
45            }
46            this._path = path;
47        },
48        draw_before: function(canvas) {
49            if (this._path) {
50                canvas.drawPath(this._path, this._path_paint);
51            }
52        },
53        draw_after: function(canvas) {
54            if (this._path) {
55                return;
56            }
57            if (Math.floor(Date.now() * this._draws_per_sec / 1000) & 1) {
58                canvas.drawLine(this._x, this._top, this._x, this._bottom, this._line_paint);
59            }
60        },
61    };
62}
63
64function MakeMouse() {
65    return {
66        _start_x: 0, _start_y: 0,
67        _curr_x:  0,  _curr_y: 0,
68        _active: false,
69
70        isActive: function() {
71            return this._active;
72        },
73        setDown: function(x, y) {
74            this._start_x = this._curr_x = x;
75            this._start_y = this._curr_y = y;
76            this._active = true;
77        },
78        setMove: function(x, y) {
79            this._curr_x = x;
80            this._curr_y = y;
81        },
82        setUp: function(x, y) {
83            this._curr_x = x;
84            this._curr_y = y;
85            this._active = false;
86        },
87        getPos: function(dx, dy) {
88            return [ this._start_x + dx, this._start_y + dy, this._curr_x + dx, this._curr_y + dy ];
89        },
90    };
91}
92
93function runs_x_to_index(runs, x) {
94    for (const r of runs) {
95        for (let i = 1; i < r.offsets.length; i += 1) {
96            if (x < r.positions[i*2]) {
97                const mid = (r.positions[i*2-2] + r.positions[i*2]) * 0.5;
98                if (x <= mid) {
99                    return r.offsets[i-1];
100                } else {
101                    return r.offsets[i];
102                }
103            }
104        }
105    }
106    const r = runs[runs.length-1];
107    return r.offsets[r.offsets.length-1];
108}
109
110function lines_pos_to_index(lines, x, y) {
111    if (y < lines[0].top) {
112        return 0;
113    }
114    const l = lines.find((l) => y <= l.bottom);
115    return l ? runs_x_to_index(l.runs, x)
116             : lines[lines.length - 1].textRange.last;
117}
118
119function runs_index_to_run(runs, index) {
120    const r = runs.find((r) => index <= r.offsets[r.offsets.length-1]);
121    // return last if no run is found
122    return r ? r : runs[runs.length-1];
123}
124
125function runs_index_to_x(runs, index) {
126    const r = runs_index_to_run(runs, index);
127    const i = r.offsets.findIndex((offset) => index === offset);
128    return i >= 0 ? r.positions[i*2]
129                  : r.positions[r.positions.length-2]; // last x
130}
131
132function lines_index_to_line_index(lines, index) {
133    const l = lines.findIndex((l) => index <= l.textRange.last);
134    return l >= 0 ? l : lines.length-1;
135}
136
137function lines_index_to_line(lines, index) {
138  return lines[lines_index_to_line_index(lines, index)];
139}
140
141function lines_indices_to_path(lines, a, b, width) {
142    if (a == b) {
143        return null;
144    }
145    if (a > b) { [a, b] = [b, a]; }
146
147    const path = new CanvasKit.Path();
148    const la = lines_index_to_line(lines, a);
149    const lb = lines_index_to_line(lines, b);
150    const ax = runs_index_to_x(la.runs, a);
151    const bx = runs_index_to_x(lb.runs, b);
152    if (la == lb) {
153        path.addRect([ax, la.top, bx, la.bottom]);
154    } else {
155        path.addRect([ax, la.top, width, la.bottom]);
156        path.addRect([0, lb.top, bx, lb.bottom]);
157        if (la.bottom < lb.top) {
158            path.addRect([0, la.bottom, width, lb.top]);   // extra lines inbetween
159        }
160    }
161    return path;
162}
163
164function string_del(str, start, end) {
165    return str.slice(0, start) + str.slice(end, str.length);
166}
167
168function make_default_paint() {
169    const p = new CanvasKit.Paint();
170    p.setAntiAlias(true);
171    return p;
172}
173
174function make_default_font(tf) {
175    const font = new CanvasKit.Font(tf);
176    font.setSubpixel(true);
177    return font;
178}
179
180function MakeStyle(length) {
181    return {
182        _length: length,
183        typeface: null,
184        size: null,
185        color: null,
186        bold: null,
187        italic: null,
188
189        _check_toggle: function(src, dst) {
190            if (src == 'toggle') {
191                return !dst;
192            } else {
193                return src;
194            }
195        },
196
197        // returns true if we changed something affecting layout
198        mergeFrom: function(src) {
199            let layoutChanged = false;
200
201            if (src.typeface && this.typeface !== src.typeface) {
202                this.typeface = src.typeface;
203                layoutChanged = true;
204            }
205            if (src.size && this.size !== src.size) {
206                this.size = src.size;
207                layoutChanged = true;
208            }
209            if (src.color) {
210                this.color = src.color;
211                delete this.shaderIndex;    // we implicitly delete shader if there is a color
212            }
213
214            if (src.bold) {
215                this.bold = this._check_toggle(src.bold, this.bold);
216            }
217            if (src.italic) {
218                this.italic = this._check_toggle(src.italic, this.italic);
219            }
220            if (src.wavy) {
221                this.wavy = this._check_toggle(src.wavy, this.wavy);
222            }
223
224            if (src.size_add) {
225                this.size += src.size_add;
226                layoutChanged = true;
227            }
228
229            if ('shaderIndex' in src) {
230                if (src.shaderIndex >= 0) {
231                    this.shaderIndex = src.shaderIndex;
232                } else {
233                    delete this.shaderIndex;
234                }
235            }
236            return layoutChanged;
237        }
238    };
239}
240
241function MakeEditor(text, style, cursor, width) {
242    const ed = {
243        _text: text,
244        _lines: null,
245        _cursor: cursor,
246        _width: width,
247        _index: { start: 0, end: 0 },
248        _styles: null,
249        // drawing
250        _X: 0,
251        _Y: 0,
252        _paint: make_default_paint(),
253        _font: make_default_font(style.typeface),
254
255        getLines: function() { return this._lines; },
256
257        width: function() {
258            return this._width;
259        },
260        height: function() {
261            return this._lines[this._lines.length-1].bottom;
262        },
263        bounds: function() {
264            return [this._X, this._Y, this._X + this.width(), this._Y + this.height()];
265        },
266        setXY: function(x, y) {
267            this._X = x;
268            this._Y = y;
269        },
270
271        _rebuild_selection: function() {
272            const a = this._index.start;
273            const b = this._index.end;
274            ASSERT(a >= 0 && a <= b && b <= this._text.length);
275            if (a === b) {
276                const l = lines_index_to_line(this._lines, a);
277                const x = runs_index_to_x(l.runs, a);
278                this._cursor.place(x, l.top, l.bottom);
279            } else {
280                this._cursor.setPath(lines_indices_to_path(this._lines, a, b, this._width));
281            }
282        },
283        setIndex: function(i) {
284            this._index.start = this._index.end = i;
285            this._rebuild_selection();
286        },
287        setIndices: function(a, b) {
288            if (a > b) { [a, b] = [b, a]; }
289            this._index.start = a;
290            this._index.end = b;
291            this._rebuild_selection();
292        },
293        moveDX: function(dx) {
294            let index;
295            if (this._index.start == this._index.end) {
296                // just adjust and pin
297                index = Math.max(Math.min(this._index.start + dx, this._text.length), 0);
298            } else {
299                // 'deselect' the region, and turn it into just a single index
300                index = dx < 0 ? this._index.start : this._index.end;
301            }
302            this.setIndex(index);
303        },
304        moveDY: function(dy) {
305            let index = (dy < 0) ? this._index.start : this._index.end;
306            const i = lines_index_to_line_index(this._lines, index);
307            if (dy < 0 && i === 0) {
308                index = 0;
309            } else if (dy > 0 && i == this._lines.length - 1) {
310                index = this._text.length;
311            } else {
312                const x = runs_index_to_x(this._lines[i].runs, index);
313                // todo: statefully track "original" x when an up/down sequence started,
314                //       so we can avoid drift.
315                index = runs_x_to_index(this._lines[i+dy].runs, x);
316            }
317            this.setIndex(index);
318        },
319
320        _validateStyles: function() {
321            const len = this._styles.reduce((sum, style) => sum + style._length, 0);
322            ASSERT(len === this._text.length);
323        },
324        _validateBlocks: function(blocks) {
325            const len = blocks.reduce((sum, block) => sum + block.length, 0);
326            ASSERT(len === this._text.length);
327        },
328
329        _buildLines: function() {
330            this._validateStyles();
331
332            const blocks = [];
333            let block = null;
334            for (const s of this._styles) {
335                if (!block || (block.typeface === s.typeface && block.size === s.size)) {
336                    if (!block) {
337                        block = { length: 0, typeface: s.typeface, size: s.size };
338                    }
339                    block.length += s._length;
340                } else {
341                    blocks.push(block);
342                    block = { length: s._length, typeface: s.typeface, size: s.size };
343                }
344            }
345            blocks.push(block);
346            this._validateBlocks(blocks);
347
348            this._lines = CanvasKit.ParagraphBuilder.ShapeText(this._text, blocks, this._width);
349            this._rebuild_selection();
350
351            // add textRange to each run, to aid in drawing
352            this._runs = [];
353            for (const l of this._lines) {
354                for (const r of l.runs) {
355                    r.textRange = { start: r.offsets[0], end: r.offsets[r.offsets.length-1] };
356                    this._runs.push(r);
357                }
358            }
359        },
360
361        // note: this does not rebuild lines/runs, or update the cursor,
362        //       but it does edit the text and styles
363        // returns true if it deleted anything
364        _deleteRange: function(start, end) {
365            ASSERT(start >= 0 && end <= this._text.length);
366            ASSERT(start <= end);
367            if (start === end) {
368                return false;
369            }
370
371            this._delete_style_range(start, end);
372            // Do this after shrink styles (we use text.length in an assert)
373            this._text = string_del(this._text, start, end);
374        },
375        deleteSelection: function(direction) {
376            let start = this._index.start;
377            if (start == this._index.end) {
378                if (direction < 0) {
379                    if (start == 0) {
380                        return;     // nothing to do
381                    }
382                    this._deleteRange(start - 1, start);
383                    start -= 1;
384                } else {
385                    if (start >= this._text.length) {
386                        return;     // nothing to do
387                    }
388                    this._deleteRange(start, start + 1);
389                }
390            } else {
391                this._deleteRange(start, this._index.end);
392            }
393            this._index.start = this._index.end = start;
394            this._buildLines();
395        },
396        insert: function(charcode) {
397            const len = charcode.length;
398            if (this._index.start != this._index.end) {
399                this.deleteSelection();
400            }
401            const index = this._index.start;
402
403            // do this before edit the text (we use text.length in an assert)
404            const [i, prev_len] = this.find_style_index_and_prev_length(index);
405            this._styles[i]._length += len;
406
407            // now grow the text
408            this._text = this._text.slice(0, index) + charcode + this._text.slice(index);
409
410            this._index.start = this._index.end = index + len;
411            this._buildLines();
412        },
413
414        draw: function(canvas, shaders) {
415            canvas.save();
416            canvas.translate(this._X, this._Y);
417
418            this._cursor.draw_before(canvas);
419
420            const runs = this._runs;
421            const styles = this._styles;
422            const f = this._font;
423            const p = this._paint;
424
425            let s = styles[0];
426            let sindex = 0;
427            let s_start = 0;
428            let s_end = s._length;
429
430            let r = runs[0];
431            let rindex = 0;
432
433            let start = 0;
434            let end = 0;
435            while (start < this._text.length) {
436                while (r.textRange.end <= start) {
437                    r = runs[++rindex];
438                    if (!r) {
439                        // ran out of runs, so the remaining text must just be WS
440                        break;
441                    }
442                }
443                if (!r) break;
444                while (s_end <= start) {
445                    s = styles[++sindex];
446                    s_start = s_end;
447                    s_end += s._length;
448                }
449                end = Math.min(r.textRange.end, s_end);
450
451                LOG('New range: ', start, end,
452                    'from run', r.textRange.start, r.textRange.end,
453                    'style', s_start, s_end);
454
455                // check that we have anything to draw
456                if (r.textRange.start >= end) {
457                    start = end;
458                    continue;  // could be a span of WS with no glyphs
459                }
460
461//              f.setTypeface(r.typeface); // r.typeface is always null (for now)
462                f.setSize(r.size);
463                f.setEmbolden(s.bold);
464                f.setSkewX(s.italic ? -0.2 : 0);
465                p.setColor(s.color ? s.color : [0,0,0,1]);
466                p.setShader(s.shaderIndex >= 0 ? shaders[s.shaderIndex] : null);
467
468                let gly = r.glyphs;
469                let pos = r.positions;
470                if (start > r.textRange.start || end < r.textRange.end) {
471                    // search for the subset of glyphs to draw
472                    let glyph_start, glyph_end;
473                    for (let i = 0; i < r.offsets.length; ++i) {
474                        if (r.offsets[i] >= start) {
475                            glyph_start = i;
476                            break;
477                        }
478                    }
479                    for (let i = glyph_start+1; i < r.offsets.length; ++i) {
480                        if (r.offsets[i] >= end) {
481                            glyph_end = i;
482                            break;
483                        }
484                    }
485                    LOG('    glyph subrange', glyph_start, glyph_end);
486                    gly = gly.slice(glyph_start, glyph_end);
487                    pos = pos.slice(glyph_start*2, glyph_end*2);
488                } else {
489                    LOG('    use entire glyph run');
490                }
491
492                let working_pos = pos;
493                if (s.wavy) {
494                    const xscale = 0.05;
495                    const yscale = r.size * 0.125;
496                    let wavy = [];
497                    for (let i = 0; i < pos.length; i += 2) {
498                        const x = pos[i + 0];
499                        wavy.push(x);
500                        wavy.push(pos[i + 1] + Math.sin(x * xscale) * yscale);
501                    }
502                    working_pos = new Float32Array(wavy);
503                }
504                canvas.drawGlyphs(gly, working_pos, 0, 0, f, p);
505
506                p.setShader(null);  // in case our caller deletes their shader(s)
507
508                start = end;
509            }
510
511            this._cursor.draw_after(canvas);
512            canvas.restore();
513        },
514
515        // Styling
516
517        // returns [index, prev total length before this style]
518        find_style_index_and_prev_length: function(index) {
519            let len = 0;
520            for (let i = 0; i < this._styles.length; ++i) {
521                const l = this._styles[i]._length;
522                len += l;
523                // < favors the latter style if index is between two styles
524                if (index < len) {
525                    return [i, len - l];
526                }
527            }
528            ASSERT(len === this._text.length);
529            return [this._styles.length-1, len];
530        },
531        _delete_style_range: function(start, end) {
532            // shrink/remove styles
533            //
534            // [.....][....][....][.....]  styles
535            //    [..................]     start...end
536            //
537            // - trim the first style
538            // - remove the middle styles
539            // - trim the last style
540
541            let N = end - start;
542            let [i, prev_len] = this.find_style_index_and_prev_length(start);
543            let s = this._styles[i];
544            if (start > prev_len) {
545                // we overlap the first style (but not entirely
546                const skip = start - prev_len;
547                ASSERT(skip < s._length);
548                const shrink = Math.min(N, s._length - skip);
549                ASSERT(shrink > 0);
550                s._length -= shrink;
551                N -= shrink;
552                if (N === 0) {
553                    return;
554                }
555                i += 1;
556                ASSERT(i < this._styles.length);
557            }
558            while (N > 0) {
559                s = this._styles[i];
560                if (N >= s._length) {
561                    N -= s._length;
562                    this._styles.splice(i, 1);
563                } else {
564                    s._length -= N;
565                    break;
566                }
567            }
568        },
569
570        applyStyleToRange: function(style, start, end) {
571            if (start > end) { [start, end] = [end, start]; }
572            ASSERT(start >= 0 && end <= this._text.length);
573            if (start === end) {
574                return;
575            }
576
577            LOG('trying to apply', style, start, end);
578            let i;
579            for (i = 0; i < this._styles.length; ++i) {
580                if (start <= this._styles[i]._length) {
581                    break;
582                }
583                start -= this._styles[i]._length;
584                end -= this._styles[i]._length;
585            }
586
587            let s = this._styles[i];
588            // do we need to fission off a clean subset for the head of s?
589            if (start > 0) {
590                const ns = Object.assign({}, s);
591                s._length = start;
592                ns._length -= start;
593                LOG('initial splice', i, start, s._length, ns._length);
594                i += 1;
595                this._styles.splice(i, 0, ns);
596                end -= start;
597                // we don't use start any more
598            }
599            // merge into any/all whole styles we overlap
600            let layoutChanged = false;
601            while (end >= this._styles[i]._length) {
602                LOG('whole run merging for style index', i)
603                layoutChanged |= this._styles[i].mergeFrom(style);
604                end -= this._styles[i]._length;
605                i += 1;
606                if (end == 0) {
607                    break;
608                }
609            }
610            // do we partially cover the last run
611            if (end > 0) {
612                s = this._styles[i];
613                const ns = Object.assign({}, s);    // the new first half
614                ns._length = end;
615                s._length -= end;                   // trim the (unchanged) tail
616                LOG('merging tail', i, ns._length, s._length);
617                layoutChanged |= ns.mergeFrom(style);
618                this._styles.splice(i, 0, ns);
619            }
620
621            this._validateStyles();
622            LOG('after applying styles', this._styles);
623
624            if (layoutChanged) {
625                this._buildLines();
626            }
627        },
628        applyStyleToSelection: function(style) {
629            this.applyStyleToRange(style, this._index.start, this._index.end);
630        },
631    };
632
633    const s = MakeStyle(ed._text.length);
634    s.mergeFrom(style);
635    ed._styles = [ s ];
636    ed._buildLines();
637    return ed;
638}
639