反白水彩 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;
}>()