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

Render machine annotations for multiple lines or columns #4568

Merged
merged 37 commits into from
Apr 24, 2023
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
60d4aa6
More precise marking machine annotations
jorg-vr Apr 14, 2023
501cd0b
Create floating annotations
jorg-vr Apr 14, 2023
a84e6ac
Make hovering tooltips easier
jorg-vr Apr 14, 2023
76565d4
Replace mark.js
jorg-vr Apr 17, 2023
bdd4a09
Mark zero length ranges
jorg-vr Apr 17, 2023
194f1b9
Put overlapping errors in the same tooltip
jorg-vr Apr 18, 2023
9a77cd3
Mark whole line when no collumns are specified
jorg-vr Apr 18, 2023
46cac59
Mark multiline annotations
jorg-vr Apr 18, 2023
fe77940
Use popper for tooltips
jorg-vr Apr 18, 2023
7ec1902
Set default annotations shown to important
jorg-vr Apr 18, 2023
616cfbc
Fix marking empty nodes
jorg-vr Apr 18, 2023
e9ba725
Add comments
jorg-vr Apr 18, 2023
55c6bc7
Merge branch 'develop' into feat/multiline-annotations
jorg-vr Apr 19, 2023
8706470
Fix multiline annotations with given collumns
jorg-vr Apr 19, 2023
1b48479
Fix default vallues for annotations and fix overlapping zero length m…
jorg-vr Apr 19, 2023
6ecda5a
Add extra test edge case
jorg-vr Apr 19, 2023
9623077
Fix test
jorg-vr Apr 19, 2023
b56c0cc
Fix yarn tests
jorg-vr Apr 19, 2023
db4af7c
Fix system tests
jorg-vr Apr 19, 2023
54b9cfe
Test mark.ts
jorg-vr Apr 19, 2023
cf42bf3
Replace popper with tippy
jorg-vr Apr 20, 2023
e902c7b
Popper config is no required
jorg-vr Apr 20, 2023
da784ff
Fix tests
jorg-vr Apr 20, 2023
75e3887
Always use most important mark class
jorg-vr Apr 20, 2023
514892b
Fix light mode css
jorg-vr Apr 20, 2023
3701b06
Remove incorrect closing divs
jorg-vr Apr 20, 2023
cbd606a
Remove vampireslot from marker
jorg-vr Apr 20, 2023
3652777
Remove vampire slots
jorg-vr Apr 20, 2023
70b7e07
Remove test annotations
jorg-vr Apr 20, 2023
78d02d5
Merge branch 'develop' into feat/multiline-annotations
jorg-vr Apr 20, 2023
e736277
Undo undesired changes
jorg-vr Apr 20, 2023
e6ce990
Update app/assets/javascripts/mark.ts
jorg-vr Apr 21, 2023
ed5b636
Always use same order for annotations
jorg-vr Apr 21, 2023
7d8efaa
Only render tooltips for invissible annotations
jorg-vr Apr 21, 2023
074e516
Show all annotations by default
jorg-vr Apr 21, 2023
5ea23cb
Default important visibilty if evaluation or any annotations
jorg-vr Apr 21, 2023
be013d7
Merge branch 'develop' into feat/multiline-annotations
jorg-vr Apr 24, 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
2 changes: 2 additions & 0 deletions app/assets/javascripts/code_listing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ function initAnnotateButtons(): void {

function loadUserAnnotations(): void {
userAnnotationState.fetch(submissionState.id);
// only show important annotations if any user annotations exist
annotationState.visibility = "important";
}


Expand Down
32 changes: 9 additions & 23 deletions app/assets/javascripts/components/annotations/annotations_cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { customElement, property } from "lit/decorators.js";
import { ShadowlessLitElement } from "components/meta/shadowless_lit_element";
import { html, TemplateResult } from "lit";
import { UserAnnotationFormData, userAnnotationState } from "state/UserAnnotations";
import { annotationState } from "state/Annotations";
import { annotationState, compareAnnotationOrders } from "state/Annotations";
import { submissionState } from "state/Submissions";
import { MachineAnnotationData, machineAnnotationState } from "state/MachineAnnotations";
import "components/annotations/machine_annotation";
Expand Down Expand Up @@ -60,14 +60,6 @@ export class AnnotationsCell extends ShadowlessLitElement {
}
}

getVisibleMachineAnnotationsOfType(type: string): TemplateResult[] {
return this.machineAnnotations
.filter(a => annotationState.isVisible(a))
.filter(a => a.type === type).map(a => html`
<d-machine-annotation .data=${a}></d-machine-annotation>
`);
}

closeForm(): void {
const event = new CustomEvent("close-form", { bubbles: true, composed: true });
this.dispatchEvent(event);
Expand All @@ -85,20 +77,14 @@ export class AnnotationsCell extends ShadowlessLitElement {
></d-annotation-form>
</div>
` : ""}
<div class="annotation-group-error">
${this.getVisibleMachineAnnotationsOfType("error")}
</div>
<div class="annotation-group-conversation">
${this.userAnnotationIds.map(a => html`
<d-thread .rootId=${a}></d-thread>
`)}
</div>
<div class="annotation-group-warning">
${this.getVisibleMachineAnnotationsOfType("warning")}
</div>
<div class="annotation-group-info">
${this.getVisibleMachineAnnotationsOfType("info")}
</div>
${this.userAnnotationIds.map(a => html`
<d-thread .rootId=${a}></d-thread>
`)}
${this.machineAnnotations
.filter(a => annotationState.isVisible(a))
.sort(compareAnnotationOrders).map(a => html`
<d-machine-annotation .data=${a}></d-machine-annotation>
`)}
</div>
`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ 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 { 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 { MachineAnnotationData, machineAnnotationState } from "state/MachineAnnotations";
import { MachineAnnotationMarker } from "components/annotations/machine_annotation_marker";
import { wrapRangesInHtml, range } from "mark";

/**
* This component represents a row in the code listing.
Expand All @@ -30,6 +34,45 @@ export class CodeListingRow extends i18nMixin(ShadowlessLitElement) {
@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 {
const rowsLength = annotation.rows ?? 1;
const lastRow = annotation.row + rowsLength ?? 0;
const firstRow = annotation.row + 1 ?? 0;

let start = 0;
if (this.row === firstRow) {
start = annotation.column || 0;
}

let length = Infinity;
if (this.row === lastRow) {
if (annotation.column !== undefined && annotation.column !== null) {
length = annotation.columns || 0;
}
}

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) => {
// 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));
});
}

firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
initTooltips(this);
Expand All @@ -43,6 +86,10 @@ export class CodeListingRow extends i18nMixin(ShadowlessLitElement) {
return annotationState.isQuestionMode ? I18n.t("js.annotations.options.add_question") : I18n.t("js.annotations.options.add_annotation");
}

get machineAnnotationToMark(): MachineAnnotationData[] {
return machineAnnotationState.byMarkedLine.get(this.row) || [];
}

render(): TemplateResult {
return html`
<td class="rouge-gutter gl">
Expand All @@ -60,7 +107,7 @@ export class CodeListingRow extends i18nMixin(ShadowlessLitElement) {
<pre>${this.row}</pre>
</td>
<td class="rouge-code">
<pre>${unsafeHTML(this.renderedCode)}</pre>
<pre style="overflow: visible; display: inline-block;" >${unsafeHTML(this.wrappedCode)}</pre>
<d-annotations-cell .row=${this.row}
.showForm="${this.showForm}"
@close-form=${() => this.showForm = false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { UserAnnotationData, userAnnotationState } from "state/UserAnnotations";
import { i18nMixin } from "components/meta/i18n_mixin";
import { PropertyValues } from "@lit/reactive-element/development/reactive-element";
import { initTooltips } from "util.js";
import { annotationState } from "state/Annotations";
import { annotationState, compareAnnotationOrders } from "state/Annotations";

/**
* This component represents a dot that shows the number of hidden annotations for a line.
Expand All @@ -33,8 +33,8 @@ export class HiddenAnnotationsDot extends i18nMixin(ShadowlessLitElement) {
}

get infoDotClasses(): string {
const hiddenTypes = this.hiddenAnnotations.map(a => a.type);
return [...new Set(hiddenTypes)].map(t => `dot-${t}`).join(" ");
const hiddenType = this.hiddenAnnotations.sort(compareAnnotationOrders)[0]?.type;
return `dot-${hiddenType}`;
}

get infoDotTitle(): string {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { customElement, property } from "lit/decorators.js";
import { render, html, LitElement, TemplateResult } from "lit";
import { MachineAnnotationData } from "state/MachineAnnotations";
import tippy, { Instance as Tippy, createSingleton } from "tippy.js";
import { annotationState, compareAnnotationOrders } from "state/Annotations";
import { StateController } from "state/state_system/StateController";

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

state = new StateController(this);

static tippyInstances: Tippy[] = [];
static tippySingleton = createSingleton([], {
placement: "bottom-start",
interactive: true,
interactiveDebounce: 25,
delay: [500, 25],
offset: [-10, 2],
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);
}

get hiddenAnnotations(): MachineAnnotationData[] {
return this.annotations.filter(a => !annotationState.isVisible(a)).sort(compareAnnotationOrders);
}

tippyInstance: Tippy;

renderTooltip(): void {
if (this.tippyInstance) {
MachineAnnotationMarker.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 => html`<d-machine-annotation .data=${a}></d-machine-annotation>`), tooltip);

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

get markColor(): string {
const type = this.annotations.sort(compareAnnotationOrders)[0].type;
const colors = {
error: "var(--error-color, red)",
warning: "var(--warning-color, yellow)",
info: "var(--info-color, blue)",
};
return colors[type];
}

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

return html`<style>
:host {
position: relative;
text-decoration: wavy underline ${this.markColor};
}
</style><slot><svg style="position: absolute; top: 9px; left: -7px" width="14" height="14" viewBox="0 0 24 24">
<path fill="${this.markColor}" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6l-6 6l1.41 1.41Z"/>
</svg></slot>`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export class SavedAnnotationList extends ShadowlessLitElement {
}

render(): TemplateResult {
console.log("rendering saved annotations sidecard");
return this.potentialSavedAnnotationsExist ? html`
<div class="card">
<div class="card-supporting-text">
Expand Down
133 changes: 133 additions & 0 deletions app/assets/javascripts/mark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
export type range = { start: number, length: number, data?: unknown }; // data is optional
type callback = (node: Node, range: range) => void;

/**
* Returns an array of text nodes or empty wrapper nodes and their start and end indices in the root node.
* @param root The root node to search for text nodes.
* @param wrapper The type of wrapper to search for.
*/
function getTextNodes(root: Node, wrapper: string): { start: number, end: number, node: Text; }[] {
let val = "";
const nodes = [];
const iterator = document.createNodeIterator(root, NodeFilter.SHOW_ALL, node => {
if (node.nodeType === Node.TEXT_NODE) {
return NodeFilter.FILTER_ACCEPT;
} else if (node.nodeName.toUpperCase() === wrapper.toUpperCase() && node.childNodes.length === 0) {
// Accept empty wrapper nodes
return NodeFilter.FILTER_ACCEPT;
} else {
return NodeFilter.FILTER_REJECT;
}
});

let node;
while (node = iterator.nextNode()) {
nodes.push({
start: val.length,
end: (val += node.textContent).length,
node
});
}
return nodes;
}

/**
* Returns the closest wrapper node of the given type that is an ancestor of the given node.
* @param node The node to search for a wrapper.
* @param wrapper The type of wrapper to search for.
*/
function closestWrapper(node: Node, wrapper: string): Node | null {
let parent = node;
while (parent !== null) {
if (parent.nodeName.toUpperCase() === wrapper.toUpperCase()) {
return parent;
}
parent = parent.parentNode;
}
return null;
}

/**
* Wraps all elements in the given range of the root node in the given wrapper node.
* For each part of the range that is already wrapped in the given wrapper node, the callback is called with the existing wrapper node.
* For each part of the range that is not wrapped in the given wrapper node, the callback is called for the newly created wrapper node.
*
* If no text nodes are found in the root node, an empty wrapper node is created and the callback is called for it.
*
* @param root The root node to search for text nodes.
* @param range The range to wrap.
* @param wrapper The type of wrapper to create.
* @param callback The callback to call for each wrapper node.
*/
function wrapRange(root: Node, range: range, wrapper: string, callback: callback): void {
const start = range.start;
const end = start + range.length;
const nodes = getTextNodes(root, wrapper);

let wrappedLength = 0;
for (const node of nodes) {
if (node.end > start && node.start <= end && node.node.textContent !== "\n" || (range.length === 0 && node.end === start)) {
const closest = closestWrapper(node.node, wrapper);
if (closest === null) {
const splitStart = Math.max(0, start - node.start);
let nodeToWrap = node.node;
if (start > node.start) {
nodeToWrap = node.node.splitText(splitStart);
}

if (node.end > end) {
nodeToWrap.splitText(end - node.start - splitStart);
}
const wrapperNode = document.createElement(wrapper);
wrapperNode.textContent = nodeToWrap.textContent;
nodeToWrap.parentNode.replaceChild(wrapperNode, nodeToWrap);
callback(wrapperNode, range);

// Avoid needless wrapping of empty text nodes
wrappedLength += wrapperNode.textContent.length;
if (wrappedLength >= range.length) {
return;
}
} else {
callback(closest, range);
// Avoid needless wrapping of empty text nodes
wrappedLength += closest.textContent.length;
if (wrappedLength >= range.length) {
return;
}
}
}
}

if (nodes.length === 0) {
const wrapperNode = document.createElement(wrapper);
wrapperNode.textContent = root.textContent;
root.appendChild(wrapperNode);
callback(wrapperNode, range);
}
}

/**
* Wraps all elements in the given ranges of the root node in the given wrapper node.
* @param root The root node to search for text nodes.
* @param ranges The ranges to wrap.
* @param wrapper The type of wrapper to create.
* @param callback The callback to call for each wrapper node.
*/
function wrapRanges(root: Node, ranges: range[], wrapper: string, callback: callback): void {
ranges.forEach(range => wrapRange(root, range, wrapper, callback));
}

/**
* Wraps all elements in the given ranges of the given target string in the given wrapper node.
* @param target a html string whose text nodes should be wrapped
* @param ranges the ranges of the textcontent to wrap
* @param wrapper the type of wrapper to create
* @param callback the callback to call for each wrapper node
*/
export function wrapRangesInHtml(target: string, ranges: range[], wrapper: string, callback: callback): string {
const root = document.createElement("div");
root.innerHTML = target;
wrapRanges(root, ranges, wrapper, callback);
return root.innerHTML;
}
Loading