Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow creation of user annotations based on a selection #4596

Merged
merged 61 commits into from
May 17, 2023
Merged
Show file tree
Hide file tree
Changes from 50 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
1ea0fd5
Create new db fields
jorg-vr Apr 25, 2023
923ddd4
Render machine annotations
jorg-vr Apr 25, 2023
c7e003f
Show tooltip upon selection
jorg-vr Apr 26, 2023
1df4157
Turn selection into a range
jorg-vr Apr 26, 2023
9048e6f
Show form on button press
jorg-vr Apr 26, 2023
459df2b
Save range annotations
jorg-vr Apr 26, 2023
eed61b0
Uglly tooltip version
jorg-vr Apr 27, 2023
26a7d30
Move annotation to the left
jorg-vr Apr 27, 2023
fa234c7
Fix form button minor bugs
jorg-vr Apr 27, 2023
145d9d3
Limit actual selection to code
jorg-vr Apr 27, 2023
620786a
Only update selection to marker on form open
jorg-vr Apr 27, 2023
302b275
Fix css
jorg-vr Apr 27, 2023
d9dc5c2
Make range cisualy continuous
jorg-vr Apr 27, 2023
d681b6a
Remove hidden annotation dots
jorg-vr Apr 27, 2023
2514b9a
Add comments
jorg-vr Apr 27, 2023
59d8a25
Fix test
jorg-vr Apr 27, 2023
7c6e0eb
Fix tests
jorg-vr Apr 27, 2023
0e2aace
Adjust tooltip
jorg-vr Apr 28, 2023
51eff4b
Fix bug when clicking inside selection
jorg-vr Apr 28, 2023
1c7fa23
Fix annotations not reset bug
jorg-vr Apr 28, 2023
f6e4f97
Fix button style
jorg-vr Apr 28, 2023
1e22d54
Only add selection event listener if creation is allowed
jorg-vr Apr 28, 2023
1328473
Add comments
jorg-vr Apr 28, 2023
a972e71
Remove parameter
jorg-vr Apr 28, 2023
76c2cfe
Add comment
jorg-vr Apr 28, 2023
53032ae
Add comments and clean up select code
jorg-vr Apr 28, 2023
36014be
Add comments to selection marker
jorg-vr Apr 28, 2023
409d709
Rename create annotation button
jorg-vr Apr 28, 2023
58b3ecc
Give selection the correct color
jorg-vr May 2, 2023
c79aa74
Select full lines
jorg-vr May 2, 2023
7117108
Update the actual selection when fixing full lines
jorg-vr May 3, 2023
566c225
Make full text button when selecting
jorg-vr May 3, 2023
7fc505b
Fix firefox and chrome difference
jorg-vr May 3, 2023
7c53505
Improve button positioning
jorg-vr May 3, 2023
963593f
Fix empty line layout
jorg-vr May 3, 2023
5f6c689
Clean up selection code
jorg-vr May 3, 2023
d40bbd8
Don't allow edditing annotations in a tooltip
jorg-vr May 3, 2023
f5d076f
Disable tooltip when text is visible
jorg-vr May 3, 2023
14a29bf
Test getOffset
jorg-vr May 3, 2023
22cf9ad
Add tests for selection calculation
jorg-vr May 3, 2023
1a3605d
Fix marking order
jorg-vr May 3, 2023
8303fc4
Add useless tests
jorg-vr May 4, 2023
9690bcb
Fix single pixel border in chrome
jorg-vr May 4, 2023
33402fd
Ignore right click
jorg-vr May 4, 2023
d06519d
Avoid annotation delete errors
jorg-vr May 4, 2023
7522f34
Add comment to explain hack
jorg-vr May 4, 2023
d2c01e9
Fix null coalescing
jorg-vr May 5, 2023
505f4a1
Don't show an empty badge
jorg-vr May 5, 2023
6f974b4
Make button ellevated
jorg-vr May 5, 2023
9341620
Remove commented code
jorg-vr May 5, 2023
96f301e
Use fab buttons
jorg-vr May 10, 2023
05161e6
Show annotation on hover
jorg-vr May 11, 2023
2fcf778
Change color intensity on hover
jorg-vr May 11, 2023
bb01001
Fix tooltips in multiline annotation markings
jorg-vr May 11, 2023
143fa38
Drag en drop button
jorg-vr May 11, 2023
7380e55
Fix tests
jorg-vr May 12, 2023
2e4df6a
Improve drag and drop UX
jorg-vr May 15, 2023
a6e5d48
Only expand the buttn on larger screens
jorg-vr May 15, 2023
d73d65e
Put text on the left of the button
jorg-vr May 17, 2023
d371460
Darken arrow on hover
jorg-vr May 17, 2023
3098650
Remove drag and drop feature
jorg-vr May 17, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions app/assets/javascripts/code_listing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import "components/annotations/annotation_options";
import "components/annotations/annotations_count_badge";
import { annotationState } from "state/Annotations";
import { exerciseState } from "state/Exercises";
import { triggerSelectionEnd } from "components/annotations/select";

const MARKING_CLASS = "marked";

function initAnnotations(submissionId: number, courseId: number, exerciseId: number, userId: number, code: string, codeLines: number, questionMode = false): void {
userAnnotationState.reset();
submissionState.code = code;
courseState.id = courseId;
exerciseState.id = exerciseId;
Expand All @@ -28,8 +30,8 @@ function initAnnotations(submissionId: number, courseId: number, exerciseId: num
const codeListingRow = new CodeListingRow();
codeListingRow.row = i + 1;
codeListingRow.renderedCode = code;
rows[i].innerHTML = "";
render(codeListingRow, rows[i]);
render(codeListingRow, rows[i].parentElement, { renderBefore: rows[i] });
rows[i].remove();
}
}

Expand All @@ -39,6 +41,8 @@ function addMachineAnnotations(data: MachineAnnotationData[]): void {

function initAnnotateButtons(): void {
userState.addPermission("annotation.create");

document.addEventListener("pointerup", () => triggerSelectionEnd());
}

function loadUserAnnotations(): void {
Expand Down
125 changes: 125 additions & 0 deletions app/assets/javascripts/components/annotations/annotation_marker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { customElement, property } from "lit/decorators.js";
import { render, html, LitElement, TemplateResult } from "lit";
import tippy, { Instance as Tippy, createSingleton } from "tippy.js";
import { AnnotationData, annotationState, compareAnnotationOrders, isUserAnnotation } from "state/Annotations";
import { StateController } from "state/state_system/StateController";

/**
* A marker that styles the slotted content and shows a tooltip with annotations.
*
* @prop {AnnotationData[]} annotations The annotations to show in the tooltip.
*
* @element d-annotation-marker
*/
@customElement("d-annotation-marker")
export class AnnotationMarker extends LitElement {
@property({ type: Array })
annotations: AnnotationData[];

state = new StateController(this);


static colors = {
error: "var(--error-color, red)",
warning: "var(--warning-color, yellow)",
info: "var(--info-color, blue)",
annotation: "var(--annotation-color, green)",
question: "var(--question-color, orange)",
};

static tippyInstances: Tippy[] = [];
// Using a singleton to avoid multiple tooltips being open at the same time.
static tippySingleton = createSingleton([], {
placement: "bottom-start",
interactive: true,
interactiveDebounce: 25,
delay: [500, 25],
offset: [-10, 2],
// This transition fixes a bug where overlap with the previous tooltip was taken into account when positioning
moveTransition: "transform 0.001s ease-out",
appendTo: () => document.querySelector(".code-table"),
});
static registerTippyInstance(instance: Tippy): void {
this.tippyInstances.push(instance);
this.tippySingleton.setInstances(this.tippyInstances);
}
static unregisterTippyInstance(instance: Tippy): void {
this.tippyInstances = this.tippyInstances.filter(i => i !== instance);
this.tippySingleton.setInstances(this.tippyInstances);
}

// Annotations that are displayed inline should show up as tooltips.
get hiddenAnnotations(): AnnotationData[] {
return this.annotations.filter(a => !annotationState.isVisible(a)).sort(compareAnnotationOrders);
}

tippyInstance: Tippy;

renderTooltip(): void {
if (this.tippyInstance) {
AnnotationMarker.unregisterTippyInstance(this.tippyInstance);
this.tippyInstance.destroy();
this.tippyInstance = undefined;
}

if (this.hiddenAnnotations.length === 0) {
return;
}

const tooltip = document.createElement("div");
tooltip.classList.add("marker-tooltip");
render(this.hiddenAnnotations.map(a => isUserAnnotation(a) ?
html`<d-user-annotation .data=${a}></d-user-annotation>` :
html`<d-machine-annotation .data=${a}></d-machine-annotation>`), tooltip);

this.tippyInstance = tippy(this, {
content: tooltip,
});
AnnotationMarker.registerTippyInstance(this.tippyInstance);
}

get sortedAnnotations(): AnnotationData[] {
return this.annotations.sort(compareAnnotationOrders);
}

get machineAnnotationColor(): string | undefined {
const firstMachineAnnotation = this.sortedAnnotations.find(a => !isUserAnnotation(a));
return firstMachineAnnotation && AnnotationMarker.colors[firstMachineAnnotation.type];
}

get machineAnnotationMarkStyle(): string | undefined {
return this.machineAnnotationColor && `text-decoration: wavy underline ${this.machineAnnotationColor};`;
}

get machineAnnotationMarkerSVG(): TemplateResult | undefined {
return this.machineAnnotationColor && html`<svg style="position: absolute; top: 9px; left: -7px" width="14" height="14" viewBox="0 0 24 24">
<path fill="${this.machineAnnotationColor}" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6l-6 6l1.41 1.41Z"/>
</svg>`;
}

get userAnnotationMarkStyle(): string {
const firstUserAnnotation = this.sortedAnnotations.find(a => isUserAnnotation(a));
if (firstUserAnnotation) {
return `
background: ${AnnotationMarker.colors[firstUserAnnotation.type]};
padding-top: 2px;
padding-bottom: 2px;
margin-top: -2px;
margin-bottom: -2px;
`;
}
return "";
}

render(): TemplateResult {
this.renderTooltip();

return html`<style>
:host {
position: relative;
${this.userAnnotationMarkStyle}
${this.machineAnnotationMarkStyle}
}
</style><slot>${this.machineAnnotationMarkerSVG}</slot>`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ export class AnnotationsCell extends ShadowlessLitElement {
"saved_annotation_id": e.detail.savedAnnotationId || undefined,
};

if (this.row > 0 && userAnnotationState.selectedRange) {
annotationData["line_nr"] = userAnnotationState.selectedRange.row;
annotationData["rows"] = userAnnotationState.selectedRange.rows;
annotationData["column"] = userAnnotationState.selectedRange.column;
annotationData["columns"] = userAnnotationState.selectedRange.columns;
}

try {
const mode = annotationState.isQuestionMode ? "question" : "annotation";
await userAnnotationState.create(annotationData, submissionState.id, mode, e.detail.saveAnnotation, e.detail.savedAnnotationTitle);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export class AnnotationsCountBadge extends ShadowlessLitElement {
}

render(): TemplateResult {
return html`
return this.annotationsCount ? html`
<div class="badge rounded-pill">${this.annotationsCount}</div>
`;
` : html``;
}
}
108 changes: 75 additions & 33 deletions app/assets/javascripts/components/annotations/code_listing_row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@ import { ShadowlessLitElement } from "components/meta/shadowless_lit_element";
import { customElement, property } from "lit/decorators.js";
import { html, TemplateResult } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import "components/annotations/hidden_annotations_dot";
import "components/annotations/annotations_cell";
import "components/annotations/machine_annotation_marker";
import "components/annotations/annotation_marker";
import "components/annotations/hidden_annotations_dot";
import { i18nMixin } from "components/meta/i18n_mixin";
import { initTooltips } from "util.js";
import { PropertyValues } from "@lit/reactive-element";
import { userState } from "state/Users";
import { annotationState } from "state/Annotations";
import { AnnotationData, annotationState, compareAnnotationOrders } from "state/Annotations";
import { MachineAnnotationData, machineAnnotationState } from "state/MachineAnnotations";
import { MachineAnnotationMarker } from "components/annotations/machine_annotation_marker";
import { wrapRangesInHtml, range } from "mark";
import { SelectedRange, UserAnnotationData, userAnnotationState } from "state/UserAnnotations";
import { AnnotationMarker } from "components/annotations/annotation_marker";
import "components/annotations/selection_marker";
import "components/annotations/create_annotation_button";
import { triggerSelectionStart } from "components/annotations/select";

/**
* This component represents a row in the code listing.
Expand All @@ -28,22 +32,27 @@ import { wrapRangesInHtml, range } from "mark";
export class CodeListingRow extends i18nMixin(ShadowlessLitElement) {
@property({ type: Number })
row: number;
@property({ type: String })
@property({ type: String, attribute: "rendered-code" })
renderedCode: string;

@property({ state: true })
showForm: boolean;

/**
* Calculates the range of the code that is covered by the given annotation.
* If the annotation spans multiple lines, the range will be the whole line unless this is the first or last line.
* In that case, the range will be the part of the line that is covered by the annotation.
* @param annotation The annotation to calculate the range for.
*/
getRangeFromAnnotation(annotation: MachineAnnotationData): range {
getRangeFromAnnotation(annotation: AnnotationData | SelectedRange): range {
const isMachineAnnotation = ["error", "warning", "info"].includes((annotation as AnnotationData).type);
const rowsLength = annotation.rows ?? 1;
const lastRow = annotation.row + rowsLength ?? 0;
const firstRow = annotation.row + 1 ?? 0;
let lastRow = annotation.row ? annotation.row + rowsLength : 0;
let firstRow = annotation.row ? annotation.row + 1 : 0;

if (!isMachineAnnotation) {
// rows on user annotations are 1-based, so we need to subtract 1
firstRow -= 1;
lastRow -= 1;
}

let start = 0;
if (this.row === firstRow) {
Expand All @@ -53,66 +62,99 @@ export class CodeListingRow extends i18nMixin(ShadowlessLitElement) {
let length = Infinity;
if (this.row === lastRow) {
if (annotation.column !== undefined && annotation.column !== null) {
length = annotation.columns || 0;
const defaultLength = isMachineAnnotation ? 0 : Infinity;
length = annotation.columns || defaultLength;
}
}

return { start: start, length: length, data: annotation };
}

get wrappedCode(): string {
return wrapRangesInHtml(
this.renderedCode,
this.machineAnnotationToMark.map(a => this.getRangeFromAnnotation(a)),
"d-machine-annotation-marker",
(node: MachineAnnotationMarker, range) => {
const annotationsToMark = [...this.userAnnotationsToMark, ...this.machineAnnotationsToMark].sort(compareAnnotationOrders);
// This default value is a hack to be able to mark the whole line if there is no code on the line.
const codeToMark = this.renderedCode || "<span style=\"user-select: none;\"> </span>";
let annotationsMarked = wrapRangesInHtml(
codeToMark,
annotationsToMark.map(a => this.getRangeFromAnnotation(a)),
"d-annotation-marker",
(node: AnnotationMarker, range) => {
// these nodes will be recompiled to html, so we need to store the data in a json string
const annotations = JSON.parse(node.getAttribute("annotations")) || [];
annotations.push(range.data);
node.setAttribute("annotations", JSON.stringify(annotations));
});
if ( userAnnotationState.showForm && this.shouldMarkSelection ) {
annotationsMarked = wrapRangesInHtml(annotationsMarked, [this.getRangeFromAnnotation(userAnnotationState.selectedRange)], "d-selection-marker");
}
return annotationsMarked;
}

firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
initTooltips(this);
this.addEventListener("pointerdown", e => triggerSelectionStart(e));
}

get canCreateAnnotation(): boolean {
return userState.hasPermission("annotation.create");
}

get addAnnotationTitle(): string {
return annotationState.isQuestionMode ? I18n.t("js.annotations.options.add_question") : I18n.t("js.annotations.options.add_annotation");
get machineAnnotationsToMark(): MachineAnnotationData[] {
return machineAnnotationState.byMarkedLine.get(this.row) || [];
}

get machineAnnotationToMark(): MachineAnnotationData[] {
return machineAnnotationState.byMarkedLine.get(this.row) || [];
get userAnnotationsToMark(): UserAnnotationData[] {
return userAnnotationState.rootIdsByMarkedLine.get(this.row)?.map(i => userAnnotationState.byId.get(i)) || [];
}

get shouldMarkSelection(): boolean {
return userAnnotationState.selectedRange &&
userAnnotationState.selectedRange.row <= this.row &&
userAnnotationState.selectedRange.row + (userAnnotationState.selectedRange.rows ?? 1) > this.row;
}

get showForm(): boolean {
const range = userAnnotationState.selectedRange;
return userAnnotationState.showForm && range && range.row + range.rows - 1 === this.row;
}

closeForm(): void {
userAnnotationState.showForm = false;
userAnnotationState.selectedRange = undefined;
}

get codeLineClass(): string {
if (this.shouldMarkSelection && !userAnnotationState.selectedRange.column && !userAnnotationState.selectedRange.columns) {
return `code-line-${annotationState.isQuestionMode ? "question" : "annotation"}`;
}

const fullLineAnnotations = this.userAnnotationsToMark
.filter(a => !a.column&& !a.columns)
.sort(compareAnnotationOrders);
if (fullLineAnnotations.length > 0) {
return `code-line-${fullLineAnnotations[0].type}`;
}

return "";
}

render(): TemplateResult {
return html`
<tr id="line-${this.row}" class="lineno">
<td class="rouge-gutter gl">
${this.canCreateAnnotation ? html`
<button class="btn btn-icon btn-icon-filled bg-primary annotation-button"
@click=${() => this.showForm = true}
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-trigger="hover"
title="${this.addAnnotationTitle}">
<i class="mdi mdi-comment-plus-outline mdi-18"></i>
</button>
` : html``}
${this.canCreateAnnotation ? html`<d-create-annotation-button row="${this.row}"></d-create-annotation-button>` : html``}
<d-hidden-annotations-dot .row=${this.row}></d-hidden-annotations-dot>
<pre>${this.row}</pre>
<pre style="user-select: none;">${this.row}</pre>
</td>
<td class="rouge-code">
<pre style="overflow: visible; display: inline-block;" >${unsafeHTML(this.wrappedCode)}</pre>
<pre class="code-line ${this.codeLineClass}">${unsafeHTML(this.wrappedCode)}</pre>
<d-annotations-cell .row=${this.row}
.showForm="${this.showForm}"
@close-form=${() => this.showForm = false}
@close-form=${() => this.closeForm()}
></d-annotations-cell>
</td>
</tr>
`;
}
}
Loading