Skip to content

每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。

反白水彩 util ​

反白效果變成美美的水彩,原本醜醜的效果退散!◝( •ω• )◟

技術關鍵字 ​

名稱描述
Document getSelection()取得使用者目前選取的文字內容或游標位置
淺水流體模擬把水當成一層薄薄的流體來解:平流、黏滯、外力,再做一次投影把散度壓回零(不可壓縮)。水彩的暈染、回水、邊緣積色都是它自己跑出來的,不是畫上去的
Kubelka–Munk 模型由顏料的吸收 K 與散射 S 算出一層顏料的反射率與透射率,再疊到底材上。顏料學的標準模型——濃淡之間的色相位移是算出來的,不是對同一個色票調透明度
mix-blend-mode讓一個元素與它底下的頁面做混色運算,而不是單純疊上去。螢光筆用 multiply(顏料把紙壓暗)、深色底用 screen(顏料把紙提亮),底下的字因此照樣讀得到
Value Noise 與 fBm在格點上取亂數再平滑內插得到的連續噪聲;把數個頻率疊起來(fBm)就得到自然的粗細層次。紙紋、乾筆條紋都靠它
Noise比 Math.random 更自然隨機效果,常用於地形、雲朵、材質等

使用範例 ​

基本用法 ​

反白以下文字看看。

也可以自己拖曳反白下面的任何一段文字
鱈魚的自我介紹大家好,我是一隻鱈魚。擅長的球類是地瓜球,一打十輕輕鬆鬆。( •̀ ω •́ )✧熱愛軟體開發,但沒有手指可以打鍵盤,語音輸入都是泡泡,輸了個寂寞,還買不到能在水裡用的電腦。(╥ω╥`)
查看範例原始碼
vue
<template>
  <div
    ref="stageRef"
    class="w-full flex flex-col gap-5 py-2"
  >
    <!-- 六色與示範反白 -->
    <div class="example-ctrl flex flex-col items-center gap-3">
      <div class="flex flex-wrap justify-center gap-2">
        <button
          v-for="item in MARKER_COLOR_KEY_LIST"
          :key="item"
          class="swatch"
          :class="item === color ? 'is-active' : ''"
          :style="{ backgroundColor: swatchCss(item) }"
          :aria-label="item"
          @click="pickColor(item)"
        />
      </div>

      <div class="flex flex-wrap items-center justify-center gap-3">
        <base-btn
          :label="t('demo')"
          @click="selectSampleText()"
        />
        <span class="text-xs opacity-60">{{ t('hint') }}</span>
      </div>
    </div>

    <article class="demo-paper mx-auto max-w-2xl w-full flex flex-col gap-4 rounded-lg px-8 py-10 text-xl leading-[2]">
      <span class="text-2xl font-bold">
        {{ t('heading') }}
      </span>
      <span
        ref="sampleRef"
        class="text-xl"
      >
        {{ t('paragraphOne') }}
      </span>
      <span class="text-xl">
        {{ t('paragraphTwo') }}
      </span>
    </article>

    <util-selection-marker
      :color="color"
      :scope="stageRef"
      :z-index="30"
    />
  </div>
</template>

<script setup lang="ts">
import type { MarkerColorKey } from '../marker-paint'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import { MARKER_COLOR_KEY_LIST, resolveMarkerColor } from '../marker-paint'
import UtilSelectionMarker from '../util-selection-marker.vue'

const { t } = useI18n()

const stageRef = ref<HTMLElement>()
/** 示範反白的那一段:用 ref 指定,不靠標籤名稱找,改了標籤也不會失效 */
const sampleRef = ref<HTMLElement>()
const color = ref<MarkerColorKey>('yellow')

function pickColor(next: MarkerColorKey) {
  color.value = next
}

/** 色票只是給人點的,直接拿淺色那一組畫出來就好 */
function swatchCss(key: MarkerColorKey) {
  const [red, green, blue] = resolveMarkerColor(key, false)
  return `rgb(${Math.round(red * 255)} ${Math.round(green * 255)} ${Math.round(blue * 255)})`
}

/** 直接替使用者反白一段示範文字:不必自己拖曳也看得到效果 */
function selectSampleText() {
  const textNode = sampleRef.value?.firstChild
  if (!textNode || textNode.nodeType !== Node.TEXT_NODE)
    return

  const length = textNode.textContent?.length ?? 0
  const range = document.createRange()
  range.setStart(textNode, 0)
  range.setEnd(textNode, Math.min(length, 26))

  const selection = window.getSelection()
  selection?.removeAllRanges()
  selection?.addRange(range)
}
</script>

<style scoped lang="sass">
.demo-paper
  background-color: light-dark(#FDFCF8, #23252B)
  box-shadow: 0 1px 3px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.4))

.swatch
  width: 1.75rem
  height: 1.75rem
  border: 2px solid light-dark(rgba(0, 0, 0, 0.12), rgba(255, 255, 255, 0.18))
  border-radius: 9999px
  cursor: pointer

  &.is-active
    border-color: light-dark(#333, #FFF)
    transform: scale(1.15)
</style>

原理 ​

與頁面混色 ​

顏料疊在紙上會吸收一部分光線,疊越多越暗,這就是減法混色。元件在 canvas 上只畫顏料,混色交給 CSS 的 mix-blend-mode: multiply,底下的字照樣清楚。 深色底上 multiply 會把顏料壓暗到看不見,所以深色模式改用 screen 把底色提亮,每種顏料也另外備一組深色模式專用的顏色。

流體模擬 ​

水彩的積色、顆粒與暈開都靠模擬算出來,參考 Curtis 等人 1997 年的〈Computer-Generated Watercolor〉:

  • 淺水層:紙面上的水依水位高低流動,把顏料推到邊緣;水乾了,顏料就在邊緣積成一圈深色。
  • 顏料層:顏料跟著水移動,同時沉進紙紋凹處,形成顆粒。
  • 毛細層:水滲進紙纖維,讓邊緣稍微暈開。

筆刷也模仿真的筆,落筆處最濕、越刷越乾;起筆、收筆與拖曳停頓時筆會放慢,那些地方會積水;每根筆毛各自帶水,快乾時筆觸會斷斷續續露出紙色,也就是書法說的飛白。

最後用 Kubelka–Munk 模型(計算半透明顏料疊在紙上呈現什麼顏色的光學模型)上色, 顏料越厚顏色越深,濃處與積色處還會混進同色系較深的顏料,例如黃色的積色處偏赭石。 紙面模擬冷壓水彩紙的凸點紋理,再用側光打出一點立體感。( ´ ▽ ` )ノ

拖曳時筆觸不閃爍 ​

元件會記下每一行已經刷過的範圍。反白範圍變長就接著刷,變短就把多出來的那段擦掉,已經刷好的部分維持原樣,拖曳時畫面就不會一直閃。

原始碼 ​

API ​

Props ​

interface Props {
  /**
   * 顏料顏色。
   * @default 'yellow'
   */
  color?: MarkerColorKey;
  /**
   * 配色相。`auto` 跟著系統的深淺色偏好走,同時決定與頁面的混色方式:
   * 淺色用 `multiply`(顏料把紙壓暗),深色用 `screen`(顏料把紙提亮)。
   * @default 'auto'
   */
  theme?: MarkerTheme;
  /**
   * 只理會這個範圍內的反白。給 CSS 選擇器或元素,不給就是整份文件。
   * @default null
   */
  scope?: string | HTMLElement | null;
  /**
   * 至少要反白幾個字才畫。
   *
   * 藏掉原生反白之後這個值不該再往上調——低於門檻的反白會變成**完全沒有回饋**,
   * 使用者看不出自己選到了什麼。
   * @default 1
   */
  minTextLength?: number;
  /**
   * 把瀏覽器原生的反白底色藏起來,讓顏料成為唯一的反白指示。
   *
   * 兩套指示疊在同一塊面積上會互相打架——原生那塊不透明的底色會把顏料的濃淡整個壓平。
   * 給了 `scope` 時只藏該範圍內的反白,範圍外照常顯示;沒給 `scope` 就是整份文件。
   *
   * 只有在畫布真的能用時才會藏。瀏覽器不支援 WebGL2 的話一律保留原生反白,
   * 不然使用者會完全看不出自己選了什麼。
   * @default true
   */
  hideNativeSelection?: boolean;
  /**
   * 筆寬倍率,乘在預設的筆寬(略超出行高)上。1 是預設寬度,0.5 是一半、2 是兩倍。
   *
   * 筆寬本來就以行高為單位,所以倍率在任何字級下的效果一致。
   * 太寬會蓋到上下兩行,實際上限取決於行距。
   * @default 1
   */
  widthScale?: number;
  /** 停用,等同完全不掛載。 @default false */
  disabled?: boolean;
  /** 畫布的 z-index。 @default 20 */
  zIndex?: number;
}

Emits ​

const emit = defineEmits<{
  /** 開始畫。detail 帶筆數與反白的文字 */
  (event: 'draw', detail: MarkerDrawDetail): void;
  /** 整組刷完 */
  (event: 'settle'): void;
  /** 淡出完畢、畫布清空 */
  (event: 'clear'): void;
}>()

v0.90.0