클립보드 이미지를 1초만에 링크로 만드는 툴 개발하기

저는 이 블로그를 운영하면서 가장 귀찮은 일이 하나 있습니다. 바로 이미지 주소를 만드는 일인데요, 저는 @nuxt/content 모듈을 이용해 마크다운 포맷을 이용하는 정적 블로그를 운영 중이라 글 작성 중에 원격 이미지 주소를 삽입하는 기능을 사용하지 않습니다.

그래서 이미지를 삽입할 땐 클립보드에 저장된 이미지나 가지고 있는 이미지를 제 GitHub Issue 아무거나 골라 댓글에 붙여넣기해서 만들어진 URL을 사용하고 있습니다.

clipboard_to_link이런 식으로 블로그에 삽입할 이미지 주소를 무료로 만들어서 사용 중입니다

이렇게 하는 이유는 이 블로그 리파지토리에 이미지 리소스를 저장하기 싫고, 외부 저장소를 사용하는 비용을 지불하고 싶지 않기 때문입니다. (참고로 깃허브 저장소는 용량이 무제한이 아니다)

좀 더 편하게 링크를 만들어보자

사실 지금도 그렇게 불편하지는 않습니다. 그저 깃허브에 로그인하고 이슈 페이지까지 가는게 너무 귀찮을 뿐이죠..

근데 클립보드로 영역을 캡쳐하고, 필요할 때 원격 이미지 주소가 생기면 너무 좋지 않을까? 라는 생각이 들었습니다. 당연히 확장 프로그램이 좀 더 편한 UX가 될 것 같습니다.

클립보드 캡쳐해두고 확장 프로그램 실행하면 바로 원격 저장소에 저장한 뒤, URL이 복사되게끔 하는거죠.

수익화나 PMF 이런 건 일단 제쳐두고, 일단 만들어보도록 하겠습니다.

프로젝트 셋업

저는 Vue.js 를 좋아하기 때문에 ViteVue를 이용해 프로젝트를 만들도록 하겠습니다.

$ yarn create vite clipboard-to-url --template vue

프로젝트 이름은 Clipboard to URL 입니다. 클립보드에 저장된 이미지를 URL로 만들기라는 뜻입니다.

그리고 이미지를 저장하기 위한 저장소로 supabase.io 를 사용하도록 하겠습니다.

이 툴에 대해선 Firebase를 대체할 오픈소스 프로젝트, Supabase 포스팅에서 다루었던 적이 있으니 궁금하신 분들은 참고해주세요.

$ yarn add @supabase/supabase-js

supabase 의 공식 라이브러리도 설치해줍시다.

UI 만들기

일단 기존 컴포넌트는 전부 지워주시구요, 전 tailwindcss 를 이용해 디자인하는 걸 좋아하기 때문에 이것도 설치하도록 하겠습니다.

$ yarn add -D tailwindcss@latest postcss@latest autoprefixer@latest

$ npx tailwindcss init -p

생성된 tailwind.config.js 의 파일을 수정해주세요.

module.exports = {
- purge: [],
+ purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
  darkMode: false,
  theme: {
    extend: {},
  },
  variants: {
    extend: {},
  },
  plugins: [],
}

마지막으로 src 폴더에 index.css 파일을 만들어 tailwindcss 를 불러온 뒤, Vue의 진입점에 이 css 파일을 읽도록 하면 되겠습니다.

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  html {
    @apply text-gray-700;
    @apply bg-white;
    @apply leading-6;
    @apply antialiased;
  }
}
import { createApp } from "vue";
import App from "./App.vue";
import "./index.css";

createApp(App).mount("#app");

tailwindcss 설정은 이걸로 끝입니다.

먼저 상단 네비게이션 바 부터 만들어보도록 하겠습니다.

src 폴더에 NavigationBar 폴더를 만들고, 그 안에 index.vue 파일을 만들어주세요.

clipboard-to-url
|- src/
|-- components/
|--- NavigationBar/
|---- index.vue

여기서 NavigationBar.vue 로 만들지 않는 이유는, 혹시나 나중에 네비게이션 바에서 파생되는 컴포넌트를 또 만들어야 할 가능성이 있기 때문입니다.

크게 상관은 없지만 나중에 불필요하게 리팩토링하는 데 시간쓰지 않도록 하기 위함입니다.

<template>
  <nav class="border-b border-gray-200 shadow-sm">
    <div class="mx-auto max-w-4xl px-4">
      <div class="flex items-center justify-between">
        <h1 class="text-2xl font-bold text-black">
          <a href="/" class="block py-3">Clipboard to URL</a>
        </h1>
      </div>
    </div>
  </nav>
</template>

네비게이션 바 컴포넌트를 만들었습니다.

<template>
  <div>
    <NavigationBar />
  </div>
</template>

<script setup>
import NavigationBar from "~/components/NavigationBar.vue";
</script>

여기서 import 하는 파일의 경로를 보면 ~ 표시가 있습니다. 이는 현재 파일의 위치에 상관없이 파일을 불러오고 싶을 때 사용하는 alias 라는 개념입니다. vite.config.js 파일로 가서 설정해주도록 합니다.

import path from "path";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    alias: {
      "~": path.resolve(__dirname, "src"),
    },
  },
  plugins: [vue()],
});

image

상단 바는 만들었으니, 클립보드에 저장된 이미지를 붙여넣기 하라는 인터페이스가 있으면 좋겠네요.

components 폴더 아래 Image 폴더를 만들고, UploadZone.vue 파일을 만들어주세요.

<template>
  <div
    class="
      flex
      justify-center
      rounded-md
      border
      border-dashed
      border-gray-400 bg-white px-6
      pt-5
      pb-6
      transition-colors
      hover:border-gray-600
    "
  >
    <div class="space-y-1 text-center">
      <svg
        class="mx-auto h-12 w-12 text-gray-400"
        stroke="currentColor"
        fill="none"
        viewBox="0 0 48 48"
        aria-hidden="true"
      >
        <path
          d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
      <div class="flex text-sm text-gray-600">
        <span class="font-semibold text-indigo-600"
          >Paste a Clipboard Image</span
        >
      </div>
    </div>
  </div>
</template>

App.vue 파일에 방금 만든 컴포넌트를 불러와줍니다.

<template>
  <div>
    <NavigationBar />

    <ImageUploadZone />
  </div>
</template>

<script setup>
import NavigationBar from "~/components/NavigationBar/index.vue";
import ImageUploadZone from "~/components/Image/UploadZone.vue";
</script>

image

인터페이스는 그럭저럭 괜찮아 보이지만, 한가지 문제는 좌우 넓이 제한이 없어서 화면에 꽉차보이는 게 영 마음에 들지 않네요.

모든 화면에서 좌우 넓이를 균일하게 맞추기 위해 Container 라는 컴포넌트들 만들도록 하겠습니다.

<template>
  <div class="mx-auto max-w-4xl px-4">
    <slot />
  </div>
</template>

사실 이 스타일은 NavigationBar 에도 이미 적용이 되어 있었습니다. 같이 수정해주도록 합시다.

 <template>
  <nav class="border-b border-gray-200 shadow-sm">
-    <div class="mx-auto max-w-4xl px-4">
+    <Container>
      <div class="flex items-center justify-between">
        <h1 class="font-bold text-2xl text-black">
          <a href="/" class="block py-3">Clipboard to URL</a>
        </h1>
      </div>
+    </Container>
-    </div>
  </nav>
 </template>

+ <script setup>
+ import Container from "~/components/Container/index.vue";
+ </script>
 <template>
  <div>
    <NavigationBar />

+   <Container>
+     <ImageUploadZone class="mt-6" />
+   </Container>
  </div>
 </template>

 <script setup>
   import NavigationBar from '~/components/NavigationBar/index.vue'
+  import Container from '~/components/Container/index.vue'
   import ImageUploadZone from '~/components/Image/UploadZone.vue'
 </script>

image

전보다 조금 더 낫네요.

클립보드 저장 후 붙여넣기

핵심 로직이 될 자바스크립트는 이렇습니다.

자바스크립트 이벤트인 onpaste 를 이용해서 붙여넣기를 감지하고, Blob 형태의 파일을 supabase의 저장소로 바로 업로드할겁니다.

저는 클립보드에 저장된 이미지를 혹시나 여러번 저장하더라도 모두 다른 URL이 나오게 하기 위해, 타임스탬프를 기반으로 고유한 문자열을 생성해주는 uuid 를 사용하도록 하겠습니다.

$ yarn add uuid

uuid 패키지를 설치해주고, supabase 에 업로드하는 로직까지 작성해보도록 하겠습니다.

먼저 src 폴더 아래 utils 폴더를 만들고, supabase.js 파일을 만들어줍니다.

import { createClient } from "@supabase/supabase-js";

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);

그리고 프로젝트 루트 폴더에 .env 파일도 만들고 supabase 의 API 주소와, ANON_KEY 를 환경 변수로 저장합니다. 여기에는 자기 프로젝트에 해당하는 값을 넣어주면 됩니다.

VITE_SUPABASE_URL=https://yourprojectid.supabase.co
VITE_SUPABASE_ANON_KEY=ey...

여기까지 작성했다면 supabase 를 클라이언트 측에서 사용할 준비가 됐으니, 클립보드 이미지를 원격 저장소에 저장하는 로직을 작성해보도록 합시다.

<script setup>
import { v4 as uuidv4 } from "uuid";
import { supabase } from "./utils/supabase";

document.onpaste = async (event) => {
  try {
    const items = event.clipboardData.items;
    const blob = items?.[0]?.getAsFile();

    if (!blob) {
      return;
    }

    const key = uuidv4();
    const bucket = "images"; // supabase 에 미리 만들어둔 public 버킷 이름

    await supabase.storage.from(bucket).upload(key, blob, {
      cacheControl: "3600",
    });

    const { publicURL } = await supabase.storage.from(bucket).getPublicUrl(key);
    console.log(publicURL);
  } catch (err) {
    console.log(err);
  }
};
</script>

ani

gif가 잘 안보이긴 하지만, 화면 캡쳐 후 붙여넣기하면 정상적으로 저장소에 저장된 URL을 받았고 접속해보니 사진도 캡쳐한 영역만큼 잘 저장됐다는 걸 확인할 수 있습니다.

여기서 링크를 누르지 않더라도 잘 저장이 됐다는 걸 바로 인지하기 위해 조금만 더 인터페이스를 개선해봅시다.

 <template>
  <div>
    <NavigationBar />

    <Container>
+      <div v-if="url" class="mt-6">
+        <p class="font-semibold text-sm uppercase text-gray-400">
+          Image preview
+        </p>
+        <img
+          :src="url"
+          class="mt-2 w-full shadow border border-gray-200 rounded-lg"
+        />
+        <a
+          :href="url"
+          target="_blank"
+          class="block mt-2 text-gray-700 font-semibold"
+        >
+          {{ url }}
+        </a>
+      </div>

      <ImageUploadZone class="mt-6" />
    </Container>
  </div>
 </template>

 <script setup>
  import { supabase } from './utils/supabase'
+ import { ref } from '@vue/reactivity'
  import { v4 as uuidv4 } from 'uuid'

  import Container from '~/components/Container/index.vue'
  import NavigationBar from '~/components/NavigationBar/index.vue'
  import ImageUploadZone from '~/components/Image/UploadZone.vue'
+ const url = ref(null)

  document.onpaste = async (event) => {
    try {
      const items = event.clipboardData.items
      const blob = items?.[0]?.getAsFile()

      if (!blob) {
        return
      }

      const key = uuidv4()
      const bucket = 'images'

      await supabase.storage.from(bucket).upload(key, blob, {
        cacheControl: '3600',
      })

      const { publicURL } = await supabase.storage.from(bucket).getPublicUrl(key)
+     url.value = publicURL
    } catch (err) {
      console.log(err)
    }
  }
 </script>

2222

이제 붙여넣기를 하자마자 저장소에 잘 저장됐다는 걸 확인할 수 있습니다.

확장 프로그램

잘 작동하는 걸 확인했으니, 더 편리하기 사용하기 위해 확장 프로그램으로 만들어볼겁니다.

$ yarn add vite-plugin-chrome-extension

크롬 확장 프로그램은 manifest.json 파일만 있으면 작동합니다. 관련해서 개발을 좀 더 수월하게 도와주는 vite-plugin-chrome-extension 패키지를 설치합시다.

  import path from 'path'
  import vue from '@vitejs/plugin-vue'
  import { defineConfig } from 'vite'
+ import { chromeExtension } from 'vite-plugin-chrome-extension'

  export default defineConfig({
    resolve: {
      alias: {
        '~': path.resolve(__dirname, 'src'),
      },
    },
+  build: {
+    rollupOptions: {
+      input: 'src/manifest.json',
+    },
+  },
    plugins: [vue(), chromeExtension()],
  })

확장 프로그램으로 만들기 위해 약간의 파일 정리가 필요합니다.

manifest.json 파일을 src 폴더 아래에 만들어주고, 루트 폴더에 있던 index.html 파일을 src 폴더 아래로 옮겨주세요. 변경된 폴더 구조는 아래와 같아야 합니다.

clipboard-to-url/
|- src/
|-- components/
|-- utils/
|-- App.vue
|-- index.css
|-- index.html
|-- main.js
|-- manifest.json

마지막으로 package.json 파일의 scripts 부분도 수정해줍시다.

{
  "scripts": {
    "dev": "vite build --watch --mode=development",
    "build": "vite build",
    "serve": "vite preview"
  }
}

이 상태로 yarn dev 를 이용해 dist 폴더를 만들면 이 폴더가 확장 프로그램이 되는 겁니다.

근데 하.. 작성하면서 보니까 현재 vite-plugin-chrome-extension 플러그인이 프로젝트 빌드시에 tailwindcss 를 인식하지 못하는 이슈가 있습니다.

그래서 개발자 모드 켜서 확장 프로그램을 로드해보면 스타일링 적용되지 않는 현상이 있습니다. 이 허탈함..

다시 스타일링을 적용하기엔 먼 길을 와버렸으니 그냥 진행하도록 하겠습니다.

  <template>
-   <div>
+   <div style="width: 15rem; height: 15rem">
      <NavigationBar />

      <Container>
        <div v-if="url" class="mt-6">
          <p class="font-semibold text-sm uppercase text-gray-400">
            Image preview
          </p>
          <img
            :src="url"
            class="mt-2 w-full shadow border border-gray-200 rounded-lg"
+           style="width: 100%"
          />
          <a
            :href="url"
            target="_blank"
            class="block mt-2 text-gray-700 font-semibold"
          >
            {{ url }}
          </a>
        </div>

        <ImageUploadZone class="mt-6" />
      </Container>
    </div>
  </template>

일단 확장 프로그램 최소 사이즈를 맞추기 위해 최상단 div 태그에 높낮이를 적용합니다.

3333

마무리

짝짝짝! 여기까지 하셨으면 알파 버전 정도의 툴을 완성한겁니다. css 적용 안되는 문제만 없다면 참 좋았을텐데요..

추가로 이건 선택 사항인데, 붙여넣는 것도 귀찮다면 확장 프로그램을 누르는 순간 이미지를 만들어줘도 됩니다.

그리고 앱을 삭제하지 않는다면 기존 캡쳐 이미지 링크를 localStorage 를 이용해 저장해두고 언제든지 다시 링크를 가져올 수 있게 할 수도 있습니다. 이건 여러분들에게 과제로 남기겠습니다. 😄

같은 카테고리의 다른 글