@teammates/consolonia 0.2.7 → 0.3.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.
- package/dist/drawing/context.d.ts +4 -0
- package/dist/drawing/context.js +13 -0
- package/dist/widgets/chat-view.d.ts +23 -0
- package/dist/widgets/chat-view.js +199 -18
- package/package.json +1 -1
|
@@ -74,4 +74,8 @@ export declare class DrawingContext {
|
|
|
74
74
|
setPixel(x: number, y: number, pixel: Pixel): void;
|
|
75
75
|
/** Blend a pixel at (x, y) with what's already there. Respects clip. */
|
|
76
76
|
blendPixel(x: number, y: number, pixel: Pixel): void;
|
|
77
|
+
/** Replace the background color of an existing cell. Respects clip. */
|
|
78
|
+
highlightCell(x: number, y: number, bgColor: Color): void;
|
|
79
|
+
/** Read the character at absolute buffer coordinates (ignores translate). */
|
|
80
|
+
readCharAbsolute(x: number, y: number): string;
|
|
77
81
|
}
|
package/dist/drawing/context.js
CHANGED
|
@@ -272,4 +272,17 @@ export class DrawingContext {
|
|
|
272
272
|
return;
|
|
273
273
|
this.bufSet(x, y, blendPixel(pixel, this.bufGet(x, y)));
|
|
274
274
|
}
|
|
275
|
+
// ── Selection helpers ─────────────────────────────────────────
|
|
276
|
+
/** Replace the background color of an existing cell. Respects clip. */
|
|
277
|
+
highlightCell(x, y, bgColor) {
|
|
278
|
+
if (!this.isVisible(x, y))
|
|
279
|
+
return;
|
|
280
|
+
const existing = this.bufGet(x, y);
|
|
281
|
+
const newBg = background(bgColor);
|
|
282
|
+
this.bufSet(x, y, { foreground: existing.foreground, background: newBg });
|
|
283
|
+
}
|
|
284
|
+
/** Read the character at absolute buffer coordinates (ignores translate). */
|
|
285
|
+
readCharAbsolute(x, y) {
|
|
286
|
+
return this.buffer.get(x, y).foreground.symbol.text;
|
|
287
|
+
}
|
|
275
288
|
}
|
|
@@ -142,6 +142,15 @@ export declare class ChatView extends Control {
|
|
|
142
142
|
private _dragging;
|
|
143
143
|
/** The Y offset within the thumb where the drag started. */
|
|
144
144
|
private _dragOffsetY;
|
|
145
|
+
private _selAnchor;
|
|
146
|
+
private _selEnd;
|
|
147
|
+
private _selecting;
|
|
148
|
+
/** Timer for auto-scrolling the feed during drag-to-select. */
|
|
149
|
+
private _selScrollTimer;
|
|
150
|
+
/** Direction of auto-scroll: -1 = up, 1 = down, 0 = none. */
|
|
151
|
+
private _selScrollDir;
|
|
152
|
+
/** DrawingContext reference from the last render (for text extraction). */
|
|
153
|
+
private _ctx;
|
|
145
154
|
/** Optional widget that replaces the input area (e.g. Interview). */
|
|
146
155
|
private _inputOverride;
|
|
147
156
|
constructor(options?: ChatViewOptions);
|
|
@@ -235,5 +244,19 @@ export declare class ChatView extends Control {
|
|
|
235
244
|
render(ctx: DrawingContext): void;
|
|
236
245
|
private _renderFeed;
|
|
237
246
|
private _renderDropdown;
|
|
247
|
+
/** Whether a non-zero text selection is active. */
|
|
248
|
+
private _hasSelection;
|
|
249
|
+
/** Copy the selected text and clear the selection. */
|
|
250
|
+
private _copySelection;
|
|
251
|
+
/** Extract the plain text within the current selection from the pixel buffer. */
|
|
252
|
+
private _getSelectedText;
|
|
253
|
+
/** Render the selection highlight overlay within the feed area. */
|
|
254
|
+
private _renderSelection;
|
|
255
|
+
/** Clear any active text selection. */
|
|
256
|
+
clearSelection(): void;
|
|
257
|
+
/** Start interval-based scroll during drag-to-select at feed edges. */
|
|
258
|
+
private _startSelScroll;
|
|
259
|
+
/** Stop auto-scroll timer. */
|
|
260
|
+
private _stopSelScroll;
|
|
238
261
|
private _autoScrollToBottom;
|
|
239
262
|
}
|
|
@@ -33,8 +33,11 @@ import { Control } from "../layout/control.js";
|
|
|
33
33
|
import { StyledText } from "./styled-text.js";
|
|
34
34
|
import { Text } from "./text.js";
|
|
35
35
|
import { TextInput, } from "./text-input.js";
|
|
36
|
-
// ── URL detection
|
|
36
|
+
// ── URL / file path detection ───────────────────────────────────────
|
|
37
37
|
const URL_REGEX = /https?:\/\/[^\s)>\]]+/g;
|
|
38
|
+
const FILE_PATH_REGEX = /(?:[A-Za-z]:[/\\]|\/)[^\s:*?"<>|)>\]]*[^\s:*?"<>|)>\].,:;!]/g;
|
|
39
|
+
// ── Selection highlight color ─────────────────────────────────────
|
|
40
|
+
const SELECTION_BG = { r: 60, g: 100, b: 180, a: 255 };
|
|
38
41
|
// ── ChatView ───────────────────────────────────────────────────────
|
|
39
42
|
export class ChatView extends Control {
|
|
40
43
|
// ── Child controls ─────────────────────────────────────────────
|
|
@@ -82,6 +85,16 @@ export class ChatView extends Control {
|
|
|
82
85
|
_dragging = false;
|
|
83
86
|
/** The Y offset within the thumb where the drag started. */
|
|
84
87
|
_dragOffsetY = 0;
|
|
88
|
+
// ── Selection state ──────────────────────────────────────────
|
|
89
|
+
_selAnchor = null;
|
|
90
|
+
_selEnd = null;
|
|
91
|
+
_selecting = false;
|
|
92
|
+
/** Timer for auto-scrolling the feed during drag-to-select. */
|
|
93
|
+
_selScrollTimer = null;
|
|
94
|
+
/** Direction of auto-scroll: -1 = up, 1 = down, 0 = none. */
|
|
95
|
+
_selScrollDir = 0;
|
|
96
|
+
/** DrawingContext reference from the last render (for text extraction). */
|
|
97
|
+
_ctx = null;
|
|
85
98
|
/** Optional widget that replaces the input area (e.g. Interview). */
|
|
86
99
|
_inputOverride = null;
|
|
87
100
|
constructor(options = {}) {
|
|
@@ -452,11 +465,24 @@ export class ChatView extends Control {
|
|
|
452
465
|
handleInput(event) {
|
|
453
466
|
if (event.type === "key") {
|
|
454
467
|
const ke = event.event;
|
|
455
|
-
// Ctrl+C
|
|
468
|
+
// Ctrl+C: if selection active, copy; otherwise emit for the app
|
|
456
469
|
if (ke.key === "c" && ke.ctrl && !ke.alt && !ke.shift) {
|
|
470
|
+
if (this._hasSelection()) {
|
|
471
|
+
this._copySelection();
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
457
474
|
this.emit("ctrlc");
|
|
458
475
|
return true;
|
|
459
476
|
}
|
|
477
|
+
// Enter: if selection active, copy; otherwise fall through
|
|
478
|
+
if (ke.key === "enter" && this._hasSelection()) {
|
|
479
|
+
this._copySelection();
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
// Any key clears active selection
|
|
483
|
+
if (this._hasSelection()) {
|
|
484
|
+
this.clearSelection();
|
|
485
|
+
}
|
|
460
486
|
// Dropdown navigation
|
|
461
487
|
if (this._dropdownItems.length > 0) {
|
|
462
488
|
if (ke.key === "up")
|
|
@@ -492,7 +518,7 @@ export class ChatView extends Control {
|
|
|
492
518
|
return true;
|
|
493
519
|
}
|
|
494
520
|
}
|
|
495
|
-
// Mouse events: wheel scrolling
|
|
521
|
+
// Mouse events: wheel scrolling, scrollbar drag, selection, actions
|
|
496
522
|
if (event.type === "mouse") {
|
|
497
523
|
const me = event.event;
|
|
498
524
|
if (me.type === "wheelup") {
|
|
@@ -503,21 +529,21 @@ export class ChatView extends Control {
|
|
|
503
529
|
this.scrollFeed(3);
|
|
504
530
|
return true;
|
|
505
531
|
}
|
|
532
|
+
// Precompute scrollbar hit for reuse
|
|
533
|
+
const onScrollbar = this._scrollbarVisible &&
|
|
534
|
+
me.x === this._scrollbarX &&
|
|
535
|
+
me.y >= this._feedY &&
|
|
536
|
+
me.y < this._feedY + this._feedH;
|
|
506
537
|
// Scrollbar drag
|
|
507
538
|
if (this._scrollbarVisible) {
|
|
508
|
-
const onScrollbar = me.x === this._scrollbarX &&
|
|
509
|
-
me.y >= this._feedY &&
|
|
510
|
-
me.y < this._feedY + this._feedH;
|
|
511
539
|
if (me.type === "press" && me.button === "left" && onScrollbar) {
|
|
512
540
|
const relY = me.y - this._feedY;
|
|
513
541
|
if (relY >= this._thumbPos &&
|
|
514
542
|
relY < this._thumbPos + this._thumbSize) {
|
|
515
|
-
// Clicked on thumb — start dragging
|
|
516
543
|
this._dragging = true;
|
|
517
544
|
this._dragOffsetY = relY - this._thumbPos;
|
|
518
545
|
}
|
|
519
546
|
else {
|
|
520
|
-
// Clicked on track — jump to that position
|
|
521
547
|
const ratio = relY / this._feedH;
|
|
522
548
|
this._feedScrollOffset = Math.round(ratio * this._maxScroll);
|
|
523
549
|
this._feedScrollOffset = Math.max(0, Math.min(this._feedScrollOffset, this._maxScroll));
|
|
@@ -540,28 +566,82 @@ export class ChatView extends Control {
|
|
|
540
566
|
return true;
|
|
541
567
|
}
|
|
542
568
|
}
|
|
543
|
-
// Ctrl+click to open URLs
|
|
569
|
+
// Ctrl+click to open URLs or file paths
|
|
544
570
|
if (me.type === "press" && me.button === "left" && me.ctrl) {
|
|
545
571
|
const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
|
|
546
572
|
if (feedLineIdx >= 0) {
|
|
547
573
|
const text = this._extractFeedLineText(feedLineIdx);
|
|
574
|
+
// Collect all clickable targets: URLs and absolute file paths
|
|
548
575
|
URL_REGEX.lastIndex = 0;
|
|
576
|
+
FILE_PATH_REGEX.lastIndex = 0;
|
|
549
577
|
const urls = [...text.matchAll(URL_REGEX)];
|
|
550
|
-
|
|
551
|
-
|
|
578
|
+
const paths = [...text.matchAll(FILE_PATH_REGEX)];
|
|
579
|
+
const allTargets = [
|
|
580
|
+
...urls.map((m) => ({ index: m.index, text: m[0], type: "link" })),
|
|
581
|
+
...paths.map((m) => ({ index: m.index, text: m[0], type: "file" })),
|
|
582
|
+
].sort((a, b) => a.index - b.index);
|
|
583
|
+
if (allTargets.length === 1) {
|
|
584
|
+
this.emit(allTargets[0].type, allTargets[0].text);
|
|
552
585
|
return true;
|
|
553
586
|
}
|
|
554
|
-
if (
|
|
555
|
-
// Try to resolve which URL based on click position
|
|
587
|
+
if (allTargets.length > 1) {
|
|
556
588
|
const row = this._screenToFeedRow.get(me.y) ?? 0;
|
|
557
589
|
const col = me.x - this._feedX;
|
|
558
590
|
const charOffset = row * this._contentWidth + col;
|
|
559
|
-
const hit =
|
|
560
|
-
|
|
591
|
+
const hit = allTargets.find((t) => charOffset >= t.index && charOffset < t.index + t.text.length);
|
|
592
|
+
const target = hit ?? allTargets[0];
|
|
593
|
+
this.emit(target.type, target.text);
|
|
561
594
|
return true;
|
|
562
595
|
}
|
|
563
596
|
}
|
|
564
597
|
}
|
|
598
|
+
// Text selection: start on left press in feed area
|
|
599
|
+
if (me.type === "press" &&
|
|
600
|
+
me.button === "left" &&
|
|
601
|
+
!me.ctrl &&
|
|
602
|
+
!onScrollbar) {
|
|
603
|
+
const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
|
|
604
|
+
const isAction = feedLineIdx >= 0 && this._feedActions.has(feedLineIdx);
|
|
605
|
+
if (!isAction) {
|
|
606
|
+
this._selAnchor = { x: me.x, y: me.y };
|
|
607
|
+
this._selEnd = { x: me.x, y: me.y };
|
|
608
|
+
this._selecting = true;
|
|
609
|
+
this.invalidate();
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Text selection: extend on move (with auto-scroll at edges)
|
|
614
|
+
if (me.type === "move" && this._selecting) {
|
|
615
|
+
this._selEnd = { x: me.x, y: me.y };
|
|
616
|
+
const feedTop = this._feedY;
|
|
617
|
+
const feedBot = this._feedY + this._feedH;
|
|
618
|
+
if (me.y < feedTop) {
|
|
619
|
+
this._startSelScroll(-1);
|
|
620
|
+
}
|
|
621
|
+
else if (me.y >= feedBot) {
|
|
622
|
+
this._startSelScroll(1);
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
this._stopSelScroll();
|
|
626
|
+
}
|
|
627
|
+
this.invalidate();
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
// Text selection: finalize on release
|
|
631
|
+
if (me.type === "release" && this._selecting) {
|
|
632
|
+
this._selecting = false;
|
|
633
|
+
this._stopSelScroll();
|
|
634
|
+
// If anchor == end (just a click, no drag), clear selection
|
|
635
|
+
if (this._selAnchor &&
|
|
636
|
+
this._selEnd &&
|
|
637
|
+
this._selAnchor.x === this._selEnd.x &&
|
|
638
|
+
this._selAnchor.y === this._selEnd.y) {
|
|
639
|
+
this._selAnchor = null;
|
|
640
|
+
this._selEnd = null;
|
|
641
|
+
}
|
|
642
|
+
this.invalidate();
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
565
645
|
// Action hover/click in feed area
|
|
566
646
|
if (this._feedActions.size > 0) {
|
|
567
647
|
const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
|
|
@@ -570,14 +650,12 @@ export class ChatView extends Control {
|
|
|
570
650
|
const newHover = entry ? feedLineIdx : -1;
|
|
571
651
|
if (newHover !== this._hoveredAction ||
|
|
572
652
|
(entry && entry.items.length > 1)) {
|
|
573
|
-
// Restore previous hover
|
|
574
653
|
if (this._hoveredAction >= 0) {
|
|
575
654
|
const prev = this._feedActions.get(this._hoveredAction);
|
|
576
655
|
if (prev) {
|
|
577
656
|
this._feedLines[this._hoveredAction].lines = [prev.normalStyle];
|
|
578
657
|
}
|
|
579
658
|
}
|
|
580
|
-
// Apply new hover — highlight only the hovered action item
|
|
581
659
|
if (entry && newHover >= 0) {
|
|
582
660
|
const hitItem = this._resolveActionItem(entry, me.x);
|
|
583
661
|
const hoverLine = this._buildHoverLine(entry, hitItem);
|
|
@@ -586,7 +664,6 @@ export class ChatView extends Control {
|
|
|
586
664
|
this._hoveredAction = newHover;
|
|
587
665
|
this.invalidate();
|
|
588
666
|
}
|
|
589
|
-
// Don't consume — let other handlers run too
|
|
590
667
|
}
|
|
591
668
|
if (me.type === "press" && me.button === "left" && entry) {
|
|
592
669
|
const hitItem = this._resolveActionItem(entry, me.x);
|
|
@@ -681,6 +758,7 @@ export class ChatView extends Control {
|
|
|
681
758
|
}
|
|
682
759
|
// ── Render ─────────────────────────────────────────────────────
|
|
683
760
|
render(ctx) {
|
|
761
|
+
this._ctx = ctx;
|
|
684
762
|
const b = this.bounds;
|
|
685
763
|
if (!b || b.width < 1 || b.height < 3)
|
|
686
764
|
return;
|
|
@@ -930,6 +1008,10 @@ export class ChatView extends Control {
|
|
|
930
1008
|
else {
|
|
931
1009
|
this._scrollbarVisible = false;
|
|
932
1010
|
}
|
|
1011
|
+
// Render selection highlight overlay
|
|
1012
|
+
if (this._selAnchor && this._selEnd) {
|
|
1013
|
+
this._renderSelection(ctx, x, y, width, height);
|
|
1014
|
+
}
|
|
933
1015
|
ctx.popClip();
|
|
934
1016
|
}
|
|
935
1017
|
// ── Dropdown rendering ─────────────────────────────────────────
|
|
@@ -947,7 +1029,106 @@ export class ChatView extends Control {
|
|
|
947
1029
|
ctx.drawText(x, y + i, truncated, style);
|
|
948
1030
|
}
|
|
949
1031
|
}
|
|
1032
|
+
// ── Selection ──────────────────────────────────────────────────
|
|
1033
|
+
/** Whether a non-zero text selection is active. */
|
|
1034
|
+
_hasSelection() {
|
|
1035
|
+
return (this._selAnchor !== null &&
|
|
1036
|
+
this._selEnd !== null &&
|
|
1037
|
+
(this._selAnchor.x !== this._selEnd.x ||
|
|
1038
|
+
this._selAnchor.y !== this._selEnd.y));
|
|
1039
|
+
}
|
|
1040
|
+
/** Copy the selected text and clear the selection. */
|
|
1041
|
+
_copySelection() {
|
|
1042
|
+
const text = this._getSelectedText();
|
|
1043
|
+
if (text) {
|
|
1044
|
+
this.emit("copy", text);
|
|
1045
|
+
}
|
|
1046
|
+
this.clearSelection();
|
|
1047
|
+
}
|
|
1048
|
+
/** Extract the plain text within the current selection from the pixel buffer. */
|
|
1049
|
+
_getSelectedText() {
|
|
1050
|
+
if (!this._selAnchor || !this._selEnd || !this._ctx)
|
|
1051
|
+
return "";
|
|
1052
|
+
let startY = this._selAnchor.y;
|
|
1053
|
+
let startX = this._selAnchor.x;
|
|
1054
|
+
let endY = this._selEnd.y;
|
|
1055
|
+
let endX = this._selEnd.x;
|
|
1056
|
+
if (startY > endY || (startY === endY && startX > endX)) {
|
|
1057
|
+
[startY, endY] = [endY, startY];
|
|
1058
|
+
[startX, endX] = [endX, startX];
|
|
1059
|
+
}
|
|
1060
|
+
const lines = [];
|
|
1061
|
+
for (let row = startY; row <= endY; row++) {
|
|
1062
|
+
const colStart = row === startY ? startX : this._feedX;
|
|
1063
|
+
const colEnd = row === endY ? endX : this._feedX + this._contentWidth - 1;
|
|
1064
|
+
let line = "";
|
|
1065
|
+
for (let col = colStart; col <= colEnd; col++) {
|
|
1066
|
+
const ch = this._ctx.readCharAbsolute(col, row);
|
|
1067
|
+
line += ch || " ";
|
|
1068
|
+
}
|
|
1069
|
+
lines.push(line.trimEnd());
|
|
1070
|
+
}
|
|
1071
|
+
return lines.join("\n");
|
|
1072
|
+
}
|
|
1073
|
+
/** Render the selection highlight overlay within the feed area. */
|
|
1074
|
+
_renderSelection(ctx, feedX, feedY, feedW, feedH) {
|
|
1075
|
+
if (!this._selAnchor || !this._selEnd)
|
|
1076
|
+
return;
|
|
1077
|
+
let startY = this._selAnchor.y;
|
|
1078
|
+
let startX = this._selAnchor.x;
|
|
1079
|
+
let endY = this._selEnd.y;
|
|
1080
|
+
let endX = this._selEnd.x;
|
|
1081
|
+
if (startY > endY || (startY === endY && startX > endX)) {
|
|
1082
|
+
[startY, endY] = [endY, startY];
|
|
1083
|
+
[startX, endX] = [endX, startX];
|
|
1084
|
+
}
|
|
1085
|
+
// Clamp to feed area
|
|
1086
|
+
startY = Math.max(startY, feedY);
|
|
1087
|
+
endY = Math.min(endY, feedY + feedH - 1);
|
|
1088
|
+
for (let row = startY; row <= endY; row++) {
|
|
1089
|
+
const colStart = row === startY ? startX : feedX;
|
|
1090
|
+
const colEnd = row === endY ? endX : feedX + feedW - 2;
|
|
1091
|
+
for (let col = colStart; col <= colEnd; col++) {
|
|
1092
|
+
ctx.highlightCell(col, row, SELECTION_BG);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
/** Clear any active text selection. */
|
|
1097
|
+
clearSelection() {
|
|
1098
|
+
this._selAnchor = null;
|
|
1099
|
+
this._selEnd = null;
|
|
1100
|
+
this._selecting = false;
|
|
1101
|
+
this._stopSelScroll();
|
|
1102
|
+
this.invalidate();
|
|
1103
|
+
}
|
|
950
1104
|
// ── Auto-scroll ────────────────────────────────────────────────
|
|
1105
|
+
/** Start interval-based scroll during drag-to-select at feed edges. */
|
|
1106
|
+
_startSelScroll(dir) {
|
|
1107
|
+
if (this._selScrollDir === dir && this._selScrollTimer)
|
|
1108
|
+
return;
|
|
1109
|
+
this._stopSelScroll();
|
|
1110
|
+
this._selScrollDir = dir;
|
|
1111
|
+
this._selScrollTimer = setInterval(() => {
|
|
1112
|
+
this.scrollFeed(this._selScrollDir * 3);
|
|
1113
|
+
// Move selEnd to keep extending the selection while scrolling
|
|
1114
|
+
if (this._selEnd) {
|
|
1115
|
+
this._selEnd = {
|
|
1116
|
+
x: this._selEnd.x,
|
|
1117
|
+
y: this._selScrollDir < 0
|
|
1118
|
+
? this._feedY
|
|
1119
|
+
: this._feedY + this._feedH - 1,
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
}, 80);
|
|
1123
|
+
}
|
|
1124
|
+
/** Stop auto-scroll timer. */
|
|
1125
|
+
_stopSelScroll() {
|
|
1126
|
+
if (this._selScrollTimer) {
|
|
1127
|
+
clearInterval(this._selScrollTimer);
|
|
1128
|
+
this._selScrollTimer = null;
|
|
1129
|
+
}
|
|
1130
|
+
this._selScrollDir = 0;
|
|
1131
|
+
}
|
|
951
1132
|
_autoScrollToBottom() {
|
|
952
1133
|
// Set scroll to a very large value; it will be clamped during render
|
|
953
1134
|
this._feedScrollOffset = Number.MAX_SAFE_INTEGER;
|
package/package.json
CHANGED