From e138a5f8461dc081843a04db7c7f635ba562f873 Mon Sep 17 00:00:00 2001 From: Andrew Bastin Date: Thu, 29 Jul 2021 18:13:17 -0400 Subject: [PATCH 1/3] fix: typescript error on clipboard --- components/http/CodegenModal.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/http/CodegenModal.vue b/components/http/CodegenModal.vue index 5149293cc..76a8bca81 100644 --- a/components/http/CodegenModal.vue +++ b/components/http/CodegenModal.vue @@ -151,7 +151,7 @@ export default defineComponent({ this.$emit("handle-import") }, copyRequestCode() { - this.$clipboard(this.requestCode) + ;(this.$clipboard as any)(this.requestCode) this.copyIcon = "done" this.$toast.success(this.$t("copied_to_clipboard").toString(), { icon: "done", From b524fa72488308687c8d78037377b0cac583f039 Mon Sep 17 00:00:00 2001 From: Andrew Bastin Date: Thu, 29 Jul 2021 22:13:06 -0400 Subject: [PATCH 2/3] feat: revamped keybinding implementation --- components/http/Request.vue | 191 +++++++++++++++++++++++------------ helpers/actions.ts | 53 ++++++++++ helpers/keybindings.ts | 107 ++++++++++++++++++++ helpers/platformutils.js | 8 +- helpers/utils/clipboard.ts | 14 +++ helpers/utils/composables.ts | 32 ++++++ layouts/default.vue | 4 + pages/index.vue | 56 ---------- 8 files changed, 341 insertions(+), 124 deletions(-) create mode 100644 helpers/actions.ts create mode 100644 helpers/keybindings.ts create mode 100644 helpers/utils/clipboard.ts diff --git a/components/http/Request.vue b/components/http/Request.vue index 6d848cef2..80960e9ee 100644 --- a/components/http/Request.vue +++ b/components/http/Request.vue @@ -29,7 +29,7 @@ truncate focus:outline-none focus:border-accent " - :value="newMethod$" + :value="newMethod" autofocus readonly /> @@ -39,10 +39,7 @@ :key="`method-${index}`" :label="method" class="font-mono" - @click.native=" - updateMethod(method) - $refs.options.tippy().hide() - " + @click.native="onSelectMethod(method)" /> @@ -50,7 +47,7 @@
+import { defineComponent, ref, useContext } from "@nuxtjs/composition-api" import { updateRESTResponse, restEndpoint$, @@ -200,71 +197,70 @@ import { } from "~/newstore/RESTSession" import { getPlatformSpecialKey } from "~/helpers/platformutils" import { runRESTRequest$ } from "~/helpers/RequestRunner" +import { subscribeToStream, useStream } from "~/helpers/utils/composables" +import { defineActionHandler } from "~/helpers/actions" +import { copyToClipboard } from "~/helpers/utils/clipboard" + +const methods = [ + "GET", + "HEAD", + "POST", + "PUT", + "DELETE", + "CONNECT", + "OPTIONS", + "TRACE", + "PATCH", + "CUSTOM", +] export default defineComponent({ setup() { - return { - requestName: useRESTRequestName(), - } - }, - data() { - return { - newMethod$: "", - methods: [ - "GET", - "HEAD", - "POST", - "PUT", - "DELETE", - "CONNECT", - "OPTIONS", - "TRACE", - "PATCH", - "CUSTOM", - ], - name: "", - newEndpoint$: "", - showCurlImportModal: false, - showCodegenModal: false, - navigatorShare: navigator.share, - loading: false, - showSaveRequestModal: false, - } - }, - subscriptions() { - return { - newMethod$: restMethod$, - newEndpoint$: restEndpoint$, - } - }, - watch: { - newEndpoint$(newVal) { - setRESTEndpoint(newVal) - }, - }, - methods: { - getSpecialKey: getPlatformSpecialKey, - updateMethod(method) { - updateRESTMethod(method) - }, - newSendRequest() { - this.loading = true - this.$subscribeTo( + const { + $toast, + app: { i18n }, + } = useContext() + const t = i18n.t.bind(i18n) + + const newEndpoint = useStream(restEndpoint$, "", setRESTEndpoint) + const newMethod = useStream(restMethod$, "", updateRESTMethod) + + const loading = ref(false) + + const showCurlImportModal = ref(false) + const showCodegenModal = ref(false) + const showSaveRequestModal = ref(false) + + const hasNavigatorShare = !!navigator.share + + const newSendRequest = () => { + loading.value = true + + subscribeToStream( runRESTRequest$(), (responseState) => { console.log(responseState) updateRESTResponse(responseState) }, () => { - this.loading = false + loading.value = false }, () => { - this.loading = false + loading.value = false } ) - }, - copyRequest() { - if (navigator.share) { + } + + const updateMethod = (method: string) => { + updateRESTMethod(method) + } + + const clearContent = () => { + resetRESTRequest() + } + + const copyRequest = () => { + if (!navigator.share) { const time = new Date().toLocaleTimeString() const date = new Date().toLocaleDateString() navigator @@ -276,14 +272,77 @@ export default defineComponent({ .then(() => {}) .catch(() => {}) } else { - this.$clipboard(window.location.href) - this.$toast.info(this.$t("copied_to_clipboard"), { + copyToClipboard(window.location.href) + $toast.info(t("copied_to_clipboard").toString(), { icon: "done", }) } - }, - clearContent() { - resetRESTRequest() + } + + const cycleUpMethod = () => { + const currentIndex = methods.indexOf(newMethod.value) + if (currentIndex === -1) { + // Most probs we are in CUSTOM mode + // Cycle up from CUSTOM is PATCH + updateMethod("PATCH") + } else if (currentIndex === 0) { + updateMethod("CUSTOM") + } else { + updateMethod(methods[currentIndex - 1]) + } + } + const cycleDownMethod = () => { + const currentIndex = methods.indexOf(newMethod.value) + if (currentIndex === -1) { + // Most probs we are in CUSTOM mode + // Cycle down from CUSTOM is GET + updateMethod("GET") + } else if (currentIndex === methods.length - 1) { + updateMethod("GET") + } else { + updateMethod(methods[currentIndex + 1]) + } + } + + defineActionHandler("request.send-cancel", newSendRequest) + defineActionHandler("request.reset", clearContent) + defineActionHandler("request.copy-link", copyRequest) + defineActionHandler("request.method.next", cycleDownMethod) + defineActionHandler("request.method.prev", cycleUpMethod) + defineActionHandler( + "request.save", + () => (showSaveRequestModal.value = true) + ) + defineActionHandler("request.method.get", () => updateMethod("GET")) + defineActionHandler("request.method.post", () => updateMethod("POST")) + defineActionHandler("request.method.put", () => updateMethod("PUT")) + defineActionHandler("request.method.delete", () => updateMethod("DELETE")) + defineActionHandler("request.method.head", () => updateMethod("HEAD")) + + return { + $t: t, + $toast, + newEndpoint, + newMethod, + methods, + loading, + newSendRequest, + requestName: useRESTRequestName(), + getSpecialKey: getPlatformSpecialKey, + showCurlImportModal, + showCodegenModal, + showSaveRequestModal, + hasNavigatorShare, + updateMethod, + clearContent, + copyRequest, + } + }, + methods: { + onSelectMethod(method: string) { + this.updateMethod(method) + // Something weird with prettier + ;(this.$refs.options as any).tippy().hide() }, }, }) diff --git a/helpers/actions.ts b/helpers/actions.ts new file mode 100644 index 000000000..aab12f01f --- /dev/null +++ b/helpers/actions.ts @@ -0,0 +1,53 @@ +/* An `action` is a unique verb that is associated with certain thing that can be done on Hoppscotch. + * For example, sending a request. + */ + +import { onBeforeUnmount, onMounted } from "@nuxtjs/composition-api" + +export type HoppAction = + | "request.send-cancel" // Send/Cancel a Hoppscotch Request + | "request.reset" // Clear request data + | "request.copy-link" // Copy Request Link + | "request.save" // Save to Collections + | "request.method.next" // Select Next Method + | "request.method.prev" // Select Previous Method + | "request.method.get" // Select GET Method + | "request.method.head" // Select HEAD Method + | "request.method.post" // Select POST Method + | "request.method.put" // Select PUT Method + | "request.method.delete" // Select DELETE Method + +type BoundActionList = { + // eslint-disable-next-line no-unused-vars + [_ in HoppAction]?: Array<() => void> +} + +const boundActions: BoundActionList = {} + +export function bindAction(action: HoppAction, handler: () => void) { + if (boundActions[action]) { + boundActions[action]?.push(handler) + } else { + boundActions[action] = [handler] + } +} + +export function invokeAction(action: HoppAction) { + boundActions[action]?.forEach((handler) => handler()) +} + +export function unbindAction(action: HoppAction, handler: () => void) { + boundActions[action] = boundActions[action]?.filter((x) => x !== handler) +} + +export function defineActionHandler(action: HoppAction, handler: () => void) { + onMounted(() => { + bindAction(action, handler) + console.log(`Action bound: ${action}`) + }) + + onBeforeUnmount(() => { + unbindAction(action, handler) + console.log(`Action unbound: ${action}`) + }) +} diff --git a/helpers/keybindings.ts b/helpers/keybindings.ts new file mode 100644 index 000000000..9b7ab99e4 --- /dev/null +++ b/helpers/keybindings.ts @@ -0,0 +1,107 @@ +import { onBeforeUnmount, onMounted } from "@nuxtjs/composition-api" +import { HoppAction, invokeAction } from "./actions" +import { isAppleDevice } from "./platformutils" + +/** + * Alt is also regarded as macOS OPTION (⌥) key + * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) + */ +type ModifierKeys = "ctrl" | "alt" + +/* eslint-disable prettier/prettier */ +type Key = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' +| 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' +| 'y' | 'z' | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' +| "up" | "down" | "left" | "right" +/* eslint-enable */ + +type ShortcutKey = `${ModifierKeys}-${Key}` + +export const bindings: { + // eslint-disable-next-line no-unused-vars + [_ in ShortcutKey]?: HoppAction +} = { + "ctrl-g": "request.send-cancel", + "ctrl-i": "request.reset", + "ctrl-k": "request.copy-link", + "ctrl-s": "request.save", + "alt-up": "request.method.next", + "alt-down": "request.method.prev", + "alt-g": "request.method.get", + "alt-h": "request.method.head", + "alt-p": "request.method.post", + "alt-u": "request.method.put", + "alt-x": "request.method.delete", +} + +/** + * A composable that hooks to the caller component's + * lifecycle and hooks to the keyboard events to fire + * the appropriate actions based on keybindings + */ +export function hookKeybindingsListener() { + onMounted(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onBeforeUnmount(() => { + document.removeEventListener("keydown", handleKeyDown) + }) +} + +function handleKeyDown(ev: KeyboardEvent) { + const binding = generateKeybindingString(ev) + if (!binding) return + + const boundAction = bindings[binding] + if (!boundAction) return + + ev.preventDefault() + invokeAction(boundAction) +} + +function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { + // All our keybinds need to have one modifier pressed atleast + const modifierKey = getActiveModifier(ev) + if (!modifierKey) return null + + const key = getPressedKey(ev) + if (!key) return null + + return `${modifierKey}-${key}` as ShortcutKey +} + +function getPressedKey(ev: KeyboardEvent): Key | null { + const val = ev.key.toLowerCase() + + // Check arrow keys + if (val === "arrowup") return "up" + else if (val === "arrowdown") return "down" + else if (val === "arrowleft") return "left" + else if (val === "arrowright") return "right" + + // Check letter keys + if (val.length === 1 && val.toUpperCase() !== val.toLowerCase()) + return val as Key + + // Check if number keys + if (val.length === 1 && !isNaN(val as any)) return val as Key + + // If no other cases match, this is not a valid key + return null +} + +function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null { + // Just ignore everything if Shift is pressed (for now) + if (ev.shiftKey) return null + + // We only allow one modifier key to be pressed (for now) + // Control key (+ Command) gets priority and if Alt is also pressed, it is ignored + if (isAppleDevice() && ev.metaKey) return "ctrl" + else if (!isAppleDevice() && ev.ctrlKey) return "ctrl" + + // Test for Alt key + if (ev.altKey) return "alt" + + return null +} diff --git a/helpers/platformutils.js b/helpers/platformutils.js index 0cc1d6917..deafe8093 100644 --- a/helpers/platformutils.js +++ b/helpers/platformutils.js @@ -1,7 +1,11 @@ +export function isAppleDevice() { + return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) +} + export function getPlatformSpecialKey() { - return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? "⌘" : "Ctrl" + return isAppleDevice() ? "⌘" : "Ctrl" } export function getPlatformAlternateKey() { - return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? "⌥" : "Alt" + return isAppleDevice() ? "⌥" : "Alt" } diff --git a/helpers/utils/clipboard.ts b/helpers/utils/clipboard.ts new file mode 100644 index 000000000..81a1a0c0f --- /dev/null +++ b/helpers/utils/clipboard.ts @@ -0,0 +1,14 @@ +/** + * Copies a given string to the clipboard using + * the legacy exec method + * + * @param content The content to be copied + */ +export function copyToClipboard(content: string) { + const dummy = document.createElement("input") + document.body.appendChild(dummy) + dummy.value = content + dummy.select() + document.execCommand("copy") + document.body.removeChild(dummy) +} diff --git a/helpers/utils/composables.ts b/helpers/utils/composables.ts index 651514176..cf366f6c8 100644 --- a/helpers/utils/composables.ts +++ b/helpers/utils/composables.ts @@ -88,3 +88,35 @@ export function pluckRef(ref: Ref, key: K): Ref { } }) } + +/** + * A composable that listens to the stream and fires update callbacks + * but respects the component lifecycle + * + * @param stream The stream to subscribe to + * @param next Callback called on value emission + * @param error Callback called on stream error + * @param complete Callback called on stream completion + */ +export function subscribeToStream( + stream: Observable, + next: (value: T) => void, + error: (e: any) => void, + complete: () => void +) { + let sub: Subscription | null = null + + // Don't perform anymore updates if the component is + // gonna unmount + onBeforeUnmount(() => { + if (sub) { + sub.unsubscribe() + } + }) + + sub = stream.subscribe({ + next, + error, + complete, + }) +} diff --git a/layouts/default.vue b/layouts/default.vue index 7b96d0be3..274bb3a9d 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -41,9 +41,13 @@ import { registerApolloAuthUpdate } from "~/helpers/apollo" import { initializeFirebase } from "~/helpers/fb" import { getSettingSubject } from "~/newstore/settings" import { logPageView } from "~/helpers/fb/analytics" +import { hookKeybindingsListener } from "~/helpers/keybindings" export default defineComponent({ components: { Splitpanes, Pane }, + setup() { + hookKeybindingsListener() + }, data() { return { LEFT_SIDEBAR: null, diff --git a/pages/index.vue b/pages/index.vue index 695b03d23..db56206a0 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1545,59 +1545,6 @@ export default defineComponent({ }, async mounted() { restRequest$.subscribe((x) => console.log(x)) - this._keyListener = function (e) { - if (e.key === "g" && (e.ctrlKey || e.metaKey)) { - e.preventDefault() - if (!this.runningRequest) { - this.sendRequest() - } else { - this.cancelRequest() - } - } - if (e.key === "s" && (e.ctrlKey || e.metaKey)) { - e.preventDefault() - this.saveRequest() - } - if (e.key === "k" && (e.ctrlKey || e.metaKey)) { - e.preventDefault() - this.copyRequest() - } - if (e.key === "i" && (e.ctrlKey || e.metaKey)) { - e.preventDefault() - this.$refs.clearAll.click() - } - if ((e.key === "g" || e.key === "G") && e.altKey) { - this.method = "GET" - } - if ((e.key === "h" || e.key === "H") && e.altKey) { - this.method = "HEAD" - } - if ((e.key === "p" || e.key === "P") && e.altKey) { - this.method = "POST" - } - if ((e.key === "u" || e.key === "U") && e.altKey) { - this.method = "PUT" - } - if ((e.key === "x" || e.key === "X") && e.altKey) { - this.method = "DELETE" - } - if (e.key == "ArrowUp" && e.altKey && this.currentMethodIndex > 0) { - this.method = - this.methodMenuItems[ - --this.currentMethodIndex % this.methodMenuItems.length - ] - } else if ( - e.key == "ArrowDown" && - e.altKey && - this.currentMethodIndex < 9 - ) { - this.method = - this.methodMenuItems[ - ++this.currentMethodIndex % this.methodMenuItems.length - ] - } - } - document.addEventListener("keydown", this._keyListener.bind(this)) await this.oauthRedirectReq() }, created() { @@ -1624,8 +1571,5 @@ export default defineComponent({ } ) }, - beforeDestroy() { - document.removeEventListener("keydown", this._keyListener) - }, }) From 09d552b17a439e6e4faef04d32ae9b27d15534c7 Mon Sep 17 00:00:00 2001 From: Andrew Bastin Date: Thu, 29 Jul 2021 22:44:43 -0400 Subject: [PATCH 3/3] fix: fix cancel request issues and stream setup issues --- components/http/Request.vue | 19 +++++++++++--- helpers/utils/composables.ts | 50 +++++++++++++++++++----------------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/components/http/Request.vue b/components/http/Request.vue index 80960e9ee..fac3e6677 100644 --- a/components/http/Request.vue +++ b/components/http/Request.vue @@ -197,7 +197,7 @@ import { } from "~/newstore/RESTSession" import { getPlatformSpecialKey } from "~/helpers/platformutils" import { runRESTRequest$ } from "~/helpers/RequestRunner" -import { subscribeToStream, useStream } from "~/helpers/utils/composables" +import { useStreamSubscriber, useStream } from "~/helpers/utils/composables" import { defineActionHandler } from "~/helpers/actions" import { copyToClipboard } from "~/helpers/utils/clipboard" @@ -221,6 +221,7 @@ export default defineComponent({ app: { i18n }, } = useContext() const t = i18n.t.bind(i18n) + const { subscribeToStream } = useStreamSubscriber() const newEndpoint = useStream(restEndpoint$, "", setRESTEndpoint) const newMethod = useStream(restMethod$, "", updateRESTMethod) @@ -240,7 +241,11 @@ export default defineComponent({ runRESTRequest$(), (responseState) => { console.log(responseState) - updateRESTResponse(responseState) + if (loading.value) { + // Check exists because, loading can be set to false + // when cancelled + updateRESTResponse(responseState) + } }, () => { loading.value = false @@ -251,6 +256,11 @@ export default defineComponent({ ) } + const cancelRequest = () => { + loading.value = false + updateRESTResponse(null) + } + const updateMethod = (method: string) => { updateRESTMethod(method) } @@ -304,7 +314,10 @@ export default defineComponent({ } } - defineActionHandler("request.send-cancel", newSendRequest) + defineActionHandler("request.send-cancel", () => { + if (!loading.value) newSendRequest() + else cancelRequest() + }) defineActionHandler("request.reset", clearContent) defineActionHandler("request.copy-link", copyRequest) defineActionHandler("request.method.next", cycleDownMethod) diff --git a/helpers/utils/composables.ts b/helpers/utils/composables.ts index cf366f6c8..ee903943f 100644 --- a/helpers/utils/composables.ts +++ b/helpers/utils/composables.ts @@ -90,33 +90,35 @@ export function pluckRef(ref: Ref, key: K): Ref { } /** - * A composable that listens to the stream and fires update callbacks - * but respects the component lifecycle - * - * @param stream The stream to subscribe to - * @param next Callback called on value emission - * @param error Callback called on stream error - * @param complete Callback called on stream completion + * A composable that provides the ability to run streams + * and subscribe to them and respect the component lifecycle. */ -export function subscribeToStream( - stream: Observable, - next: (value: T) => void, - error: (e: any) => void, - complete: () => void -) { - let sub: Subscription | null = null +export function useStreamSubscriber() { + const subs: Subscription[] = [] + + const runAndSubscribe = ( + stream: Observable, + next: (value: T) => void, + error: (e: any) => void, + complete: () => void + ) => { + const sub = stream.subscribe({ + next, + error, + complete: () => { + complete() + subs.splice(subs.indexOf(sub), 1) + }, + }) + + subs.push(sub) + } - // Don't perform anymore updates if the component is - // gonna unmount onBeforeUnmount(() => { - if (sub) { - sub.unsubscribe() - } + subs.forEach((sub) => sub.unsubscribe()) }) - sub = stream.subscribe({ - next, - error, - complete, - }) + return { + subscribeToStream: runAndSubscribe, + } }