Clickable links in response viewer

This commit is contained in:
Gregory Schier
2024-03-10 13:41:44 -07:00
parent d51c58aa3d
commit ef8528d2b4
6 changed files with 158 additions and 28 deletions

View File

@@ -0,0 +1,14 @@
import { type DecorationSet, MatchDecorator, type ViewUpdate } from '@codemirror/view';
/**
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
*/
export class BetterMatchDecorator extends MatchDecorator {
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
if (!update.startState.selection.eq(update.state.selection)) {
return super.createDeco(update.view);
} else {
return super.updateDeco(update, deco);
}
}
}

View File

@@ -69,6 +69,14 @@
@apply text-red-700 dark:text-red-800 bg-red-300/30 border-red-300/80 border-opacity-40 hover:border-red-300 hover:bg-red-300/40;
}
}
.hyperlink-widget {
& > * {
@apply underline;
}
-webkit-text-security: none;
}
}
&.cm-singleline {
@@ -103,10 +111,10 @@
@apply font-mono text-[0.75rem];
/*
* Round corners or they'll stick out of the editor bounds of editor is rounded.
* Could potentially be pushed up from the editor like we do with bg color but this
* is probably fine.
*/
* Round corners or they'll stick out of the editor bounds of editor is rounded.
* Could potentially be pushed up from the editor like we do with bg color but this
* is probably fine.
*/
@apply rounded-lg;
}
}
@@ -167,8 +175,8 @@
@apply h-full flex items-center;
/* Break characters on line wrapping mode, useful for URL field.
* We can make this dynamic if we need it to be configurable later
*/
* We can make this dynamic if we need it to be configurable later
*/
&.cm-lineWrapping {
@apply break-all;
@@ -176,9 +184,30 @@
}
}
.cm-tooltip.cm-tooltip-hover {
@apply shadow-lg bg-gray-200 rounded text-gray-700 border border-gray-500 z-50 pointer-events-auto text-xs;
@apply px-2 py-1;
a {
@apply text-yellow-500 font-bold;
&:hover {
@apply underline;
}
&::after {
@apply text-yellow-600 bg-yellow-600 h-3 w-3 ml-1;
content: '';
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='black' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E");
-webkit-mask-size: contain;
display: inline-block;
}
}
}
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-[0.75rem];
.cm-tooltip.cm-tooltip-autocomplete {
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-300 z-50 pointer-events-auto text-xs;
.cm-completionIcon {
@apply italic font-mono;

View File

@@ -0,0 +1,98 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, hoverTooltip, MatchDecorator, ViewPlugin } from '@codemirror/view';
import { EditorView } from 'codemirror';
const REGEX =
/(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))/g;
const tooltip = hoverTooltip(
(view, pos, side) => {
const { from, text } = view.state.doc.lineAt(pos);
let match;
let found: { start: number; end: number } | null = null;
while ((match = REGEX.exec(text))) {
const start = from + match.index;
const end = start + match[0].length;
if (pos >= start && pos <= end) {
found = { start, end };
break;
}
}
if (found == null) {
return null;
}
if ((found.start == pos && side < 0) || (found.end == pos && side > 0)) {
return null;
}
return {
pos: found.start,
end: found.end,
create() {
const dom = document.createElement('a');
dom.textContent = 'Open in browser';
dom.href = text.substring(found!.start - from, found!.end - from);
dom.target = '_blank';
dom.rel = 'noopener noreferrer';
return { dom };
},
};
},
{
hoverTime: 100,
},
);
const decorator = function () {
const placeholderMatcher = new MatchDecorator({
regexp: REGEX,
decoration(match, view, matchStartPos) {
const matchEndPos = matchStartPos + match[0].length - 1;
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) {
return Decoration.replace({});
}
}
const groupMatch = match[1];
if (groupMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
return Decoration.replace({});
}
return Decoration.mark({
class: 'hyperlink-widget',
});
},
});
return ViewPlugin.fromClass(
class {
placeholders: DecorationSet;
constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
}
},
{
decorations: (instance) => instance.placeholders,
provide: (plugin) =>
EditorView.bidiIsolatedRanges.of((view) => {
return view.plugin(plugin)?.placeholders || Decoration.none;
}),
},
);
};
export const hyperlink = [tooltip, decorator()];

View File

@@ -1,11 +1,9 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import { BetterMatchDecorator } from '../BetterMatchDecorator';
class PlaceholderWidget extends WidgetType {
constructor(
readonly name: string,
readonly isExistingVariable: boolean,
) {
constructor(readonly name: string, readonly isExistingVariable: boolean) {
super();
}
eq(other: PlaceholderWidget) {
@@ -25,19 +23,6 @@ class PlaceholderWidget extends WidgetType {
}
}
/**
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
*/
class BetterMatchDecorator extends MatchDecorator {
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
if (!update.startState.selection.eq(update.state.selection)) {
return super.createDeco(update.view);
} else {
return super.updateDeco(update, deco);
}
}
}
export const placeholders = function (variables: { name: string }[]) {
const placeholderMatcher = new BetterMatchDecorator({
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,

View File

@@ -1,17 +1,20 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
import { useDebouncedState } from '../../hooks/useDebouncedState';
import { useFilterResponse } from '../../hooks/useFilterResponse';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
import { useToggle } from '../../hooks/useToggle';
import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
import type { HttpResponse } from '../../lib/models';
import { Editor } from '../core/Editor';
import { hyperlink } from '../core/Editor/hyperlink/extension';
import { IconButton } from '../core/IconButton';
import { Input } from '../core/Input';
const extraExtensions = [hyperlink];
interface Props {
response: HttpResponse;
pretty: boolean;
@@ -87,6 +90,7 @@ export function TextViewer({ response, pretty }: Props) {
defaultValue={body}
contentType={contentType}
actions={actions}
extraExtensions={extraExtensions}
/>
);
}

View File

@@ -29,7 +29,7 @@
}
a,
a * {
a[href] * {
@apply cursor-pointer !important;
}