Skip to content

Every little fish swimming around is a visitor on this page right now, and one of them is you ( ´ ▽ ` )ノ If they get in the way of reading, feel free to turn them off.

Selection Watercolor util ​

Turn plain text selection into pretty watercolour, and wave the ugly default highlight goodbye! ◝( •ω• )◟

Tech keywords ​

NameDescription
Document getSelection()Gets the text currently selected by the user, or the caret position
Shallow Water SimulationTreats water as a thin fluid layer: advection, viscosity, external forces, then a projection step that drives divergence back to zero. Blooms, backruns and edge darkening all fall out of the simulation rather than being drawn in
Kubelka–Munk ModelDerives a pigment layer's reflectance and transmittance from its absorption K and scattering S, then composites it over the substrate. The standard model in paint science: the hue shift between a thin wash and a heavy one is computed, not dialled in with opacity
mix-blend-modeBlends an element with the page beneath it instead of simply stacking on top. A highlighter uses multiply (pigment darkens the paper) and screen on dark backgrounds (pigment lightens it), which is what keeps the text underneath readable
Value Noise and fBmContinuous noise built by interpolating random values on a lattice; stacking several octaves (fBm) gives natural detail across scales. Paper grain and dry-brush streaks are both built from it
NoiseProduces more natural randomness than Math.random, commonly used for terrain, clouds, and textures

Examples ​

Basic usage ​

Try highlighting the text below.

Or drag to highlight any of the text below
Meet the CodfishHi, I'm a codfish. My best ball sport is sweet potato balls — I can easily take on ten at once. ( •̀ ω •́ )✧I love building software, but I have no fingers to type on a keyboard, voice input comes out as nothing but bubbles, and I can't even buy a computer that works underwater. (╥ω╥`)
View example source
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>

How it works ​

Blending with the page ​

Pigment on paper absorbs part of the light, and the more layers the darker it gets; that is subtractive blending. The component paints only pigment on a canvas and CSS mix-blend-mode: multiply does the blending, so the text underneath stays sharp. On a dark background multiply would darken the pigment into nothing, so dark mode switches to screen, which lightens the background instead, and each paint has its own colour for dark mode.

Fluid simulation ​

The rim, granulation and bleeding are all simulated, based on Curtis et al., "Computer-Generated Watercolor" (1997):

  • Shallow-water layer: water on the paper flows from high to low and carries pigment to the edge; once the water dries, the pigment is left there as a darker ring.
  • Pigment layer: pigment moves with the water and settles into the paper's valleys, which gives granulation.
  • Capillary layer: water soaks into the fibres and softens the edge a little.

The brush imitates a real one too. It is wettest where it lands and gets drier along the way; it slows down when it starts, finishes or pauses mid-drag, and water pools there; and each bristle carries its own water, so as it runs out the stroke breaks up and the paper shows through (dry brush).

Shading uses the Kubelka–Munk model (an optical model of what colour translucent pigment gives over paper): thicker pigment is darker, and dense areas and the rim mix in a deeper pigment of the same family, so the rim of yellow turns ochre. The paper imitates the bumps of cold-press watercolour paper, with a side light bringing out a little relief. ( ´ ▽ ` )ノ

No flicker while dragging ​

The component records the range each line has already painted. When the selection grows it keeps painting; when it shrinks the extra part is erased. What is already painted stays as it is, so nothing flickers while you drag.

Source ​

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