1// Copyright (C) 2019 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use size file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import * as m from 'mithril'; 16 17import {Actions} from '../common/actions'; 18import {timeToString} from '../common/time'; 19 20import {globals} from './globals'; 21import {gridlines} from './gridline_helper'; 22import {Panel, PanelSize} from './panel'; 23import {TRACK_SHELL_WIDTH} from './track_constants'; 24import {hsl} from 'color-convert'; 25 26const FLAG_WIDTH = 16; 27const MOUSE_OFFSET = 6; 28const FLAG = `\uE153`; 29 30function toSummary(s: string) { 31 const newlineIndex = s.indexOf('\n') > 0 ? s.indexOf('\n') : s.length; 32 return s.slice(0, Math.min(newlineIndex, s.length, 16)); 33} 34 35export class NotesPanel extends Panel { 36 hoveredX: null|number = null; 37 38 oncreate({dom}: m.CVnodeDOM) { 39 dom.addEventListener('mousemove', (e: Event) => { 40 this.hoveredX = 41 (e as MouseEvent).layerX - TRACK_SHELL_WIDTH - MOUSE_OFFSET; 42 globals.rafScheduler.scheduleRedraw(); 43 }, {passive: true}); 44 dom.addEventListener('mouseenter', (e: Event) => { 45 this.hoveredX = 46 (e as MouseEvent).layerX - TRACK_SHELL_WIDTH - MOUSE_OFFSET; 47 globals.rafScheduler.scheduleRedraw(); 48 }); 49 dom.addEventListener('mouseout', () => { 50 this.hoveredX = null; 51 globals.frontendLocalState.setShowNotePreview(false); 52 globals.rafScheduler.scheduleRedraw(); 53 }, {passive: true}); 54 } 55 56 view() { 57 return m( 58 '.notes-panel', 59 { 60 onclick: (e: MouseEvent) => { 61 this.onClick(e.layerX - TRACK_SHELL_WIDTH, e.layerY); 62 e.stopPropagation(); 63 }, 64 }); 65 } 66 67 renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) { 68 const timeScale = globals.frontendLocalState.timeScale; 69 const range = globals.frontendLocalState.visibleWindowTime; 70 let aNoteIsHovered = false; 71 72 ctx.fillStyle = '#999'; 73 ctx.fillRect(TRACK_SHELL_WIDTH - 1, 0, 2, size.height); 74 for (const xAndTime of gridlines(size.width, range, timeScale)) { 75 ctx.fillRect(xAndTime[0], 0, 1, size.height); 76 } 77 78 ctx.textBaseline = 'bottom'; 79 ctx.font = '10px Helvetica'; 80 81 for (const note of Object.values(globals.state.notes)) { 82 const timestamp = note.timestamp; 83 if (!timeScale.timeInBounds(timestamp)) continue; 84 const x = timeScale.timeToPx(timestamp); 85 86 const currentIsHovered = 87 this.hoveredX && 88 x - MOUSE_OFFSET <= this.hoveredX && 89 this.hoveredX < x - MOUSE_OFFSET + FLAG_WIDTH; 90 const selection = globals.state.currentSelection; 91 const isSelected = selection !== null && selection.kind === 'NOTE' && 92 selection.id === note.id; 93 const left = Math.floor(x + TRACK_SHELL_WIDTH); 94 95 // Draw flag. 96 if (!aNoteIsHovered && currentIsHovered) { 97 aNoteIsHovered = true; 98 this.drawFlag(ctx, left, size.height, note.color, isSelected); 99 } else if (isSelected) { 100 this.drawFlag(ctx, left, size.height, note.color, /* fill */ true); 101 } else { 102 this.drawFlag(ctx, left, size.height, note.color); 103 } 104 105 if (note.text) { 106 const summary = toSummary(note.text); 107 const measured = ctx.measureText(summary); 108 // Add a white semi-transparent background for the text. 109 ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; 110 ctx.fillRect( 111 left + FLAG_WIDTH + 2, size.height, measured.width + 2, -12); 112 ctx.fillStyle = '#3c4b5d'; 113 ctx.fillText(summary, left + FLAG_WIDTH + 3, size.height - 1); 114 } 115 } 116 117 // A real note is hovered so we don't need to see the preview line. 118 if (aNoteIsHovered) globals.frontendLocalState.setShowNotePreview(false); 119 120 // View preview note flag when hovering on notes panel. 121 if (!aNoteIsHovered && this.hoveredX !== null) { 122 const timestamp = timeScale.pxToTime(this.hoveredX); 123 if (timeScale.timeInBounds(timestamp)) { 124 globals.frontendLocalState.setHoveredTimestamp(timestamp); 125 globals.frontendLocalState.setShowNotePreview(true); 126 const x = timeScale.timeToPx(timestamp); 127 const left = Math.floor(x + TRACK_SHELL_WIDTH); 128 this.drawFlag(ctx, left, size.height, '#aaa', /* fill */ true); 129 } 130 } 131 } 132 133 private drawFlag( 134 ctx: CanvasRenderingContext2D, x: number, height: number, color: string, 135 fill?: boolean) { 136 const prevFont = ctx.font; 137 const prevBaseline = ctx.textBaseline; 138 ctx.textBaseline = 'alphabetic'; 139 if (fill) { 140 ctx.font = '24px Material Icons'; 141 ctx.fillStyle = color; 142 // Adjust height for icon font. 143 ctx.fillText(FLAG, x - MOUSE_OFFSET, height + 2); 144 } else { 145 ctx.strokeStyle = color; 146 ctx.font = '24px Material Icons'; 147 // Adjust height for icon font. 148 ctx.strokeText(FLAG, x - MOUSE_OFFSET, height + 2.5); 149 } 150 ctx.font = prevFont; 151 ctx.textBaseline = prevBaseline; 152 } 153 154 private onClick(x: number, _: number) { 155 const timeScale = globals.frontendLocalState.timeScale; 156 const timestamp = timeScale.pxToTime(x - MOUSE_OFFSET); 157 for (const note of Object.values(globals.state.notes)) { 158 const noteX = timeScale.timeToPx(note.timestamp); 159 if (noteX <= x && x < noteX + FLAG_WIDTH) { 160 globals.dispatch(Actions.selectNote({id: note.id})); 161 return; 162 } 163 } 164 // 40 different random hues 9 degrees apart. 165 const hue = Math.floor(Math.random() * 40) * 9; 166 const color = '#' + hsl.hex([hue, 90, 30]); 167 globals.dispatch(Actions.addNote({timestamp, color})); 168 } 169} 170 171interface NotesEditorPanelAttrs { 172 id: string; 173} 174 175export class NotesEditorPanel extends Panel<NotesEditorPanelAttrs> { 176 view({attrs}: m.CVnode<NotesEditorPanelAttrs>) { 177 const note = globals.state.notes[attrs.id]; 178 const startTime = note.timestamp - globals.state.traceTime.startSec; 179 return m( 180 '.notes-editor-panel', 181 m('.notes-editor-panel-heading-bar', 182 m('.notes-editor-panel-heading', 183 `Annotation at ${timeToString(startTime)}`), 184 m('input[type=text]', { 185 onkeydown: (e: Event) => { 186 e.stopImmediatePropagation(); 187 }, 188 value: note.text, 189 onchange: m.withAttr( 190 'value', 191 newText => { 192 globals.dispatch(Actions.changeNoteText({ 193 id: attrs.id, 194 newText, 195 })); 196 }), 197 }), 198 m('span.color-change', `Change color: `, m('input[type=color]', { 199 value: note.color, 200 onchange: m.withAttr( 201 'value', 202 newColor => { 203 globals.dispatch(Actions.changeNoteColor({ 204 id: attrs.id, 205 newColor, 206 })); 207 }), 208 })), 209 m('button', 210 { 211 onclick: () => 212 globals.dispatch(Actions.removeNote({id: attrs.id})), 213 }, 214 'Remove')), ); 215 } 216 217 renderCanvas(_ctx: CanvasRenderingContext2D, _size: PanelSize) {} 218} 219