mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-23 09:08:32 +02:00
Clickable links in response viewer
This commit is contained in:
14
src-web/components/core/Editor/BetterMatchDecorator.ts
Normal file
14
src-web/components/core/Editor/BetterMatchDecorator.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
@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 {
|
&.cm-singleline {
|
||||||
@@ -103,10 +111,10 @@
|
|||||||
@apply font-mono text-[0.75rem];
|
@apply font-mono text-[0.75rem];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Round corners or they'll stick out of the editor bounds of editor is rounded.
|
* 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
|
* Could potentially be pushed up from the editor like we do with bg color but this
|
||||||
* is probably fine.
|
* is probably fine.
|
||||||
*/
|
*/
|
||||||
@apply rounded-lg;
|
@apply rounded-lg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,8 +175,8 @@
|
|||||||
@apply h-full flex items-center;
|
@apply h-full flex items-center;
|
||||||
|
|
||||||
/* Break characters on line wrapping mode, useful for URL field.
|
/* 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 {
|
&.cm-lineWrapping {
|
||||||
@apply break-all;
|
@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 */
|
/* NOTE: Extra selector required to override default styles */
|
||||||
.cm-tooltip.cm-tooltip {
|
.cm-tooltip.cm-tooltip-autocomplete {
|
||||||
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-[0.75rem];
|
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-300 z-50 pointer-events-auto text-xs;
|
||||||
|
|
||||||
.cm-completionIcon {
|
.cm-completionIcon {
|
||||||
@apply italic font-mono;
|
@apply italic font-mono;
|
||||||
|
|||||||
98
src-web/components/core/Editor/hyperlink/extension.ts
Normal file
98
src-web/components/core/Editor/hyperlink/extension.ts
Normal 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()];
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
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 {
|
class PlaceholderWidget extends WidgetType {
|
||||||
constructor(
|
constructor(readonly name: string, readonly isExistingVariable: boolean) {
|
||||||
readonly name: string,
|
|
||||||
readonly isExistingVariable: boolean,
|
|
||||||
) {
|
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
eq(other: PlaceholderWidget) {
|
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 }[]) {
|
export const placeholders = function (variables: { name: string }[]) {
|
||||||
const placeholderMatcher = new BetterMatchDecorator({
|
const placeholderMatcher = new BetterMatchDecorator({
|
||||||
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
|
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
|
||||||
import { useDebouncedState } from '../../hooks/useDebouncedState';
|
import { useDebouncedState } from '../../hooks/useDebouncedState';
|
||||||
import { useFilterResponse } from '../../hooks/useFilterResponse';
|
import { useFilterResponse } from '../../hooks/useFilterResponse';
|
||||||
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
|
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
|
||||||
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
|
|
||||||
import { useToggle } from '../../hooks/useToggle';
|
import { useToggle } from '../../hooks/useToggle';
|
||||||
import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
|
import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
|
||||||
import type { HttpResponse } from '../../lib/models';
|
import type { HttpResponse } from '../../lib/models';
|
||||||
import { Editor } from '../core/Editor';
|
import { Editor } from '../core/Editor';
|
||||||
|
import { hyperlink } from '../core/Editor/hyperlink/extension';
|
||||||
import { IconButton } from '../core/IconButton';
|
import { IconButton } from '../core/IconButton';
|
||||||
import { Input } from '../core/Input';
|
import { Input } from '../core/Input';
|
||||||
|
|
||||||
|
const extraExtensions = [hyperlink];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
pretty: boolean;
|
pretty: boolean;
|
||||||
@@ -87,6 +90,7 @@ export function TextViewer({ response, pretty }: Props) {
|
|||||||
defaultValue={body}
|
defaultValue={body}
|
||||||
contentType={contentType}
|
contentType={contentType}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
extraExtensions={extraExtensions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
a,
|
a,
|
||||||
a * {
|
a[href] * {
|
||||||
@apply cursor-pointer !important;
|
@apply cursor-pointer !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user