diff --git a/src-web/components/core/Editor/BetterMatchDecorator.ts b/src-web/components/core/Editor/BetterMatchDecorator.ts new file mode 100644 index 00000000..9c53fd82 --- /dev/null +++ b/src-web/components/core/Editor/BetterMatchDecorator.ts @@ -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); + } + } +} diff --git a/src-web/components/core/Editor/Editor.css b/src-web/components/core/Editor/Editor.css index b9a46299..7333dc89 100644 --- a/src-web/components/core/Editor/Editor.css +++ b/src-web/components/core/Editor/Editor.css @@ -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; diff --git a/src-web/components/core/Editor/hyperlink/extension.ts b/src-web/components/core/Editor/hyperlink/extension.ts new file mode 100644 index 00000000..09d3412c --- /dev/null +++ b/src-web/components/core/Editor/hyperlink/extension.ts @@ -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()]; diff --git a/src-web/components/core/Editor/twig/placeholder.ts b/src-web/components/core/Editor/twig/placeholder.ts index 511c9a5e..728eaa1f 100644 --- a/src-web/components/core/Editor/twig/placeholder.ts +++ b/src-web/components/core/Editor/twig/placeholder.ts @@ -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, diff --git a/src-web/components/responseViewers/TextViewer.tsx b/src-web/components/responseViewers/TextViewer.tsx index a0b05555..b9da6ae2 100644 --- a/src-web/components/responseViewers/TextViewer.tsx +++ b/src-web/components/responseViewers/TextViewer.tsx @@ -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} /> ); } diff --git a/src-web/main.css b/src-web/main.css index 07aef3f7..2dda3cc5 100644 --- a/src-web/main.css +++ b/src-web/main.css @@ -29,7 +29,7 @@ } a, - a * { + a[href] * { @apply cursor-pointer !important; }