Skip to content

Commit

Permalink
Add wavy underline on error, using widget decoration as position anchor
Browse files Browse the repository at this point in the history
as getting position of char from ProseMirror was a bit flaky; problems at start of doc, end of doc
  • Loading branch information
jonathonherbert committed Aug 3, 2024
1 parent a450ff2 commit 4ed4fff
Show file tree
Hide file tree
Showing 5 changed files with 49 additions and 48 deletions.
18 changes: 6 additions & 12 deletions prosemirror-client/src/cqlInput/CqlInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ template.innerHTML = `
}
#cql-input {
display: block;
position: relative;
padding: 5px;
font-size: ${baseFontSize}px;
Expand Down Expand Up @@ -98,29 +99,22 @@ template.innerHTML = `
}
.Cql__TypeaheadPopover, .Cql__ErrorPopover {
position: absolute;
width: 500px;
margin: 0;
padding: 0;
top: anchor(end);
font-size: ${baseFontSize}px;
border-radius: ${baseBorderRadius}px;
position-anchor: --cql-input;
overflow: visible;
}
.Cql__ErrorPopover {
width: max-content;
background: transparent;
border: none;
color: red;
}
.Cql__PopoverArrow {
position: absolute;
width: 0;
height: 0;
border-left: ${popoverArrowSize}px solid transparent;
border-right: ${popoverArrowSize}px solid transparent;
border-bottom: ${popoverArrowSize}px solid white;
top: -${popoverArrowSize}px;
}
</style>
`;

Expand All @@ -144,7 +138,7 @@ export const createCqlInput = (
shadow.innerHTML = `
<div id="${cqlInputId}"></div>
<div id="${cqlTypeaheadId}" class="Cql__TypeaheadPopover" data-testid="${typeaheadTestId}" popover anchor="${cqlInputId}"></div>
<div id="${cqlErrorId}" class="Cql__ErrorPopover" data-testid="${errorTestId}" popover></div>
<div id="${cqlErrorId}" class="Cql__ErrorPopover" data-testid="${errorTestId}" popover>~</div>
`;
shadow.appendChild(template.content.cloneNode(true));
const cqlInput = shadow.getElementById(cqlInputId)!;
Expand Down
30 changes: 14 additions & 16 deletions prosemirror-client/src/cqlInput/ErrorPopover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { Mapping } from "prosemirror-transform";
import { EditorView } from "prosemirror-view";
import { CqlError } from "../services/CqlService";
import { Popover, VirtualElement } from "./Popover";
import { ERROR_CLASS } from "./plugin";

export class ErrorPopover extends Popover {
private debugContainer: HTMLElement | undefined;
private contentEl: HTMLElement;
private arrowEl: HTMLElement;

public constructor(
public view: EditorView,
Expand All @@ -15,10 +15,6 @@ export class ErrorPopover extends Popover {
) {
super(view, popoverEl);

this.arrowEl = document.createElement("div");
this.arrowEl.classList.add("Cql__PopoverArrow");
popoverEl.appendChild(this.arrowEl);

this.contentEl = document.createElement("div");
popoverEl.appendChild(this.contentEl);

Expand All @@ -30,10 +26,7 @@ export class ErrorPopover extends Popover {
popoverEl.hidePopover?.();
}

public updateErrorMessage = async (
error: CqlError | undefined,
mapping: Mapping
) => {
public updateErrorMessage = async (error: CqlError | undefined) => {
if (!error) {
this.contentEl.innerHTML = "";
this.popoverEl.hidePopover?.();
Expand All @@ -42,22 +35,26 @@ export class ErrorPopover extends Popover {

this.updateDebugContainer(error);

this.contentEl.innerHTML = error.message;
const referenceEl = this.view.dom.getElementsByClassName(ERROR_CLASS)?.[0];

const referenceEl = this.getVirtualElementFromView(
error.position ? mapping.map(error.position) - 1 : undefined
);
if (!referenceEl) {
console.warn(
"Attempt to render element popover, but no position widget found in document"
);
return;
}

const xOffset = -30;
await this.renderPopover(referenceEl, this.arrowEl, xOffset);
const xOffset = 0;
const yOffset = -25;
await this.renderPopover(referenceEl, xOffset, yOffset);

this.popoverEl.showPopover?.();
};

private updateDebugContainer = (error: CqlError) => {
if (this.debugContainer) {
this.debugContainer.innerHTML = `<div>
<h2>Error</h3>
<h2>Error</h2>
<div>Position: ${error.position ?? "No position given"}</div>
<div>Message: ${error.message}</div>
</div>`;
Expand All @@ -70,6 +67,7 @@ export class ErrorPopover extends Popover {
if (position) {
try {
const { top, right, bottom, left } = this.view.coordsAtPos(position);

return {
getBoundingClientRect: () => ({
width: right - left,
Expand Down
24 changes: 6 additions & 18 deletions prosemirror-client/src/cqlInput/Popover.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { arrow, computePosition, flip, offset, shift } from "@floating-ui/dom";
import { computePosition, flip, offset, shift } from "@floating-ui/dom";
import { EditorView } from "prosemirror-view";

export type VirtualElement = {
Expand All @@ -22,31 +22,19 @@ export abstract class Popover {

protected async renderPopover(
referenceElement: VirtualElement,
arrowEl?: HTMLElement,
xOffset: number = 0
xOffset: number = 0,
yOffset: number = 0
) {
const {
x,
y,
middlewareData: { arrow: arrowData },
} = await computePosition(referenceElement, this.popoverEl, {
const { x, y } = await computePosition(referenceElement, this.popoverEl, {
placement: "bottom-start",
middleware: [
flip(),
shift(),
offset({ mainAxis: 15, crossAxis: xOffset }),
...(arrowEl ? [arrow({ element: arrowEl })] : []),
offset({ mainAxis: yOffset, crossAxis: xOffset }),
],
});

this.popoverEl.style.left = `${x}px`;
this.popoverEl.style.right = `${y}px`;

if (arrowEl && arrowData) {
const { x, y } = arrowData;

arrowEl.style.left = x !== undefined ? `${x}px` : "";
arrowEl.style.top = y !== undefined ? `${y}px` : "";
}
this.popoverEl.style.top = `${y}px`;
}
}
14 changes: 12 additions & 2 deletions prosemirror-client/src/cqlInput/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
toProseMirrorTokens,
ProseMirrorToken,
applyDeleteIntent,
errorToDecoration,
} from "./utils";
import { Mapping } from "prosemirror-transform";
import { TypeaheadPopover } from "./TypeaheadPopover";
Expand All @@ -45,6 +46,8 @@ type ServiceState = {

const NEW_STATE = "NEW_STATE";

export const ERROR_CLASS = "Cql__ErrorWidget";

/**
* The CQL plugin handles most aspects of the editor behaviour, including
* - fetching results from the language server, and applying them to the document
Expand Down Expand Up @@ -178,9 +181,16 @@ export const createCqlPlugin = ({
},
},
decorations: (state) => {
const { tokens } = cqlPluginKey.getState(state)!;
const { tokens, error, mapping } = cqlPluginKey.getState(state)!;

const maybeErrorDeco = error?.position
? [errorToDecoration(mapping.map(error.position))]
: [];

return DecorationSet.create(state.doc, tokensToDecorations(tokens));
return DecorationSet.create(state.doc, [
...maybeErrorDeco,
...tokensToDecorations(tokens),
]);
},
handleKeyDown(view, event) {
switch (event.code) {
Expand Down
11 changes: 11 additions & 0 deletions prosemirror-client/src/cqlInput/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "./schema";
import { Node, NodeType } from "prosemirror-model";
import { Selection, TextSelection } from "prosemirror-state";
import { ERROR_CLASS } from "./plugin";

const tokensToPreserve = ["QUERY_FIELD_KEY", "QUERY_VALUE"];

Expand Down Expand Up @@ -326,3 +327,13 @@ export const logNode = (doc: Node) => {
);
});
};

export const errorToDecoration = (position: number): Decoration => {
const toDOM = () => {
const el = document.createElement("span");
el.classList.add(ERROR_CLASS);
return el;
};

return Decoration.widget(position, toDOM);
};

0 comments on commit 4ed4fff

Please sign in to comment.