UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

605 lines (507 loc) 16.4 kB
import { dom, env } from '../../helper'; import Controller from './Controller'; const { isTouchDevice } = env; const SIZE = 240; const BAR_H = 28; const MIDDLE = SIZE / 2; const LIGHTNESS_CONT_VALUE = 50; const CLOSE_TO_CENTER_THRESHOLD = 3; const FIXED_DEC = 10; const SATURATION = 1; const GRADIENT_RADIUS = 14; const DEFAULT_COLOR_VALUE = { hex: '#FFFFFF', r: 255, g: 255, b: 255, h: 0, s: 1, l: 1 }; let LIGHTNESS = 0; let isWheelragging = false; let isBarDragging = false; let wheelX = SIZE / 2; let wheelY = SIZE / 2; let finalColor = DEFAULT_COLOR_VALUE; let ctx; let _bootstrapped = false; /** * @returns {{slider: HTMLElement, offscreenCanvas: HTMLCanvasElement, offscreenCtx: CanvasRenderingContext2D, wheel: HTMLCanvasElement, wheelCtx: CanvasRenderingContext2D, wheelPointer: HTMLElement, gradientBar: HTMLCanvasElement, gradientPointer: HTMLElement, fanalColorHex: HTMLElement, fanalColorBackground: HTMLElement}} */ function CreateSliderCtx() { const offscreenCanvas = document.createElement('canvas'); offscreenCanvas.width = SIZE; offscreenCanvas.height = SIZE; const html = /*html*/ ` <div class="se-hue-slider-container" style="width: ${SIZE}px; height: ${SIZE}px;"> <canvas class="se-hue-wheel" width="${SIZE}" height="${SIZE}"></canvas> <div class="se-hue-wheel-pointer"></div> </div> <div class="se-hue-gradient-container"> <canvas class="se-hue-gradient" width="${SIZE}" height="${BAR_H}"></canvas> <div class="se-hue-gradient-pointer"></div> </div> <div class="se-hue-final-hex" style="width:${SIZE}px; height: ${BAR_H}px;"> <div style="flex: 3; line-height: 1.5;">${DEFAULT_COLOR_VALUE.hex}</div> <div style="flex: 1; height: 100%; border: 1px solid #fff; outline: 1px solid #000;"></div> </div> `; const slider = dom.utils.createElement('DIV', { class: 'se-hue-slider' }, html); const wheelCanvas = /** @type {HTMLCanvasElement} */ (slider.querySelector('.se-hue-wheel')); const gradientBarCanvas = /** @type {HTMLCanvasElement} */ (slider.querySelector('.se-hue-gradient')); const currentColors = slider.querySelector('.se-hue-final-hex').children; return { slider, offscreenCanvas, offscreenCtx: offscreenCanvas.getContext('2d'), wheel: wheelCanvas, wheelCtx: wheelCanvas.getContext('2d'), wheelPointer: /** @type {HTMLElement} */ (slider.querySelector('.se-hue-wheel-pointer')), gradientBar: gradientBarCanvas, gradientPointer: /** @type {HTMLElement} */ (slider.querySelector('.se-hue-gradient-pointer')), fanalColorHex: /** @type {HTMLElement} */ (currentColors[0]), fanalColorBackground: /** @type {HTMLElement} */ (currentColors[1]), }; } /** * HueSlider color information object * @typedef {Object} HueSliderColor * @property {string} hex - HEX color * @property {number} r - Red color value * @property {number} g - Green color value * @property {number} b - Blue color value * @property {number} h - Hue color value * @property {number} s - Saturation color value * @property {number} l - Lightness color value */ /** * @typedef {Object} HueSliderParams * @property {boolean} [isNewForm] Whether to create a new form element. * @property {Array<HTMLElement>} [parents] Parent elements for controller positioning. * @property {import('./Controller').ControllerParams} [controllerOptions] Controller options (excluding 'parents') */ /** * @class * @description Create a Hue slider. (only create one at a time) * - When you call the `.attach()` method, the hue slider is appended to the form element. * It must be called every time it is used. */ class HueSlider { #$; #globalMouseDown; #globalTouchMove; #globalMouseUp; #globalMouseMove; #globalTouchStart; #globalTouchEnd; /** * @constructor * @param {import('./ColorPicker').default} inst The instance object that called the constructor. * @param {SunEditor.Deps} $ Kernel dependencies * @param {HueSliderParams} [params={}] Hue slider options * @param {string} [className=""] The class name of the hue slider. */ constructor(inst, $, params = {}, className = '') { this.#$ = $; // members this.inst = inst; this.ctx = { wheelX: wheelX, wheelY: wheelY, lightness: LIGHTNESS, wheelPointerX: '50%', wheelPointerY: '50%', gradientPointerX: 'calc(100% - 14px)', color: DEFAULT_COLOR_VALUE, }; this.isOpen = false; this.controlle = null; this.#globalMouseDown = null; this.#globalTouchMove = null; this.#globalMouseUp = null; this.#globalMouseMove = null; this.#globalTouchStart = null; this.#globalTouchEnd = null; // init default controller if (!params.isNewForm) { const hueController = CreateHTML_basicControllerForm($, className); this.circle = hueController.querySelector('.se-hue'); this.controller = new Controller(this, $, hueController, { position: 'bottom', isWWTarget: false, parents: [inst.form], parentsHide: true, ...params.controllerOptions }); } } /** * @hook Module.Controller * @type {SunEditor.Hook.Controller.Action} */ controllerAction(target) { const command = target.getAttribute('data-command'); if (command === 'submit') { this.inst.hueSliderAction?.(this.get()); this.close(); } else if (command === 'close') { this.close(); } } /** * @hook Module.Controller * @type {SunEditor.Hook.Controller.Close} */ controllerClose() { this.init(); this.inst.hueSliderCancelAction?.(); } /** * @description Get the current color information. * @returns {HueSliderColor} color information */ get() { return finalColor; } /** * @description Open the hue slider. * @param {Node} target The element to attach the hue slider. */ open(target) { this.attach(); this.controller.open(target, null, { isWWTarget: false, initMethod: null, addOffset: null }); } /** * @description Close the hue slider. * - Call the instance's `hueSliderCancelAction` method. */ close() { this.ctx = { gradientPointerX: gradientPointer.style.left, wheelPointerX: wheelPointer.style.left, wheelPointerY: wheelPointer.style.top, wheelX: wheelX, wheelY: wheelY, lightness: LIGHTNESS, color: ctx?.color || '', }; this.controller.close(); } /** * @description Attach the hue slider to the form element. * @param {?Node} [form] The element to attach the hue slider. */ attach(form) { if (!_bootstrapped) InitRender(); // drow this.init(); (form || this.circle).appendChild(slider); ctx = this.ctx; if (ctx) { wheelX = ctx.wheelX; wheelY = ctx.wheelY; LIGHTNESS = ctx.lightness; wheelPointer.style.left = ctx.wheelPointerX; wheelPointer.style.top = ctx.wheelPointerY; gradientPointer.style.left = ctx.gradientPointerX; setHex(ctx.color.hex); drawColorWheel(); createGradientBar(getDefaultColor()); } // touch event if (isTouchDevice) { // mobile name this.#globalTouchStart = this.#$.eventManager.addGlobalEvent('touchstart', OnTouchstart, { passive: false, capture: true }); this.#globalTouchMove = this.#$.eventManager.addGlobalEvent('touchmove', OnTouchmove, true); this.#globalTouchEnd = this.#$.eventManager.addGlobalEvent( 'touchend', () => { isWheelragging = false; isBarDragging = false; }, true, ); } // mouse event this.#globalMouseDown = this.#$.eventManager.addGlobalEvent('mousedown', OnMousedown, { passive: false, capture: true }); this.#globalMouseMove = this.#$.eventManager.addGlobalEvent('mousemove', OnMousemove, true); this.#globalMouseUp = this.#$.eventManager.addGlobalEvent( 'mouseup', () => { isWheelragging = false; isBarDragging = false; }, true, ); // open this.isOpen = true; } /** * @description Initialize the hue slider information. */ init() { this.isOpen = false; isWheelragging = false; isBarDragging = false; this.#globalMouseDown &&= this.#$.eventManager.removeGlobalEvent(this.#globalMouseDown); this.#globalMouseMove &&= this.#$.eventManager.removeGlobalEvent(this.#globalMouseMove); this.#globalMouseUp &&= this.#$.eventManager.removeGlobalEvent(this.#globalMouseUp); this.#globalTouchStart &&= this.#$.eventManager.removeGlobalEvent(this.#globalTouchStart); this.#globalTouchMove &&= this.#$.eventManager.removeGlobalEvent(this.#globalTouchMove); this.#globalTouchEnd &&= this.#$.eventManager.removeGlobalEvent(this.#globalTouchEnd); } } // init const { slider, offscreenCanvas, offscreenCtx, wheel, wheelCtx, wheelPointer, gradientBar, gradientPointer, fanalColorHex, fanalColorBackground } = CreateSliderCtx(); // mobile function OnTouchstart(event) { const { target, touches } = event; const clientX = touches[0].clientX; const clientY = touches[0].clientY; if (target === wheel) { event.preventDefault(); isBarDragging = false; isWheelragging = true; updatePointer_wheel(clientX, clientY); } else if (target === gradientBar) { event.preventDefault(); isBarDragging = true; isWheelragging = false; updatePointer_bar(clientX); } } function OnTouchmove(event) { event.preventDefault(); const { touches } = event; const clientX = touches[0].clientX; const clientY = touches[0].clientY; if (isWheelragging) { updatePointer_wheel(clientX, clientY); } else if (isBarDragging) { updatePointer_bar(clientX); } } // pc function OnMousedown({ target, clientX, clientY }) { if (target === wheel) { isBarDragging = false; isWheelragging = true; updatePointer_wheel(clientX, clientY); } else if (target === gradientBar) { isBarDragging = true; isWheelragging = false; updatePointer_bar(clientX); } } function OnMousemove({ clientX, clientY }) { if (isWheelragging) { updatePointer_wheel(clientX, clientY); } else if (isBarDragging) { updatePointer_bar(clientX); } } function updatePointer_wheel(x, y) { const rect = wheel.getBoundingClientRect(); x = x - rect.left - MIDDLE; y = y - rect.top - MIDDLE; const angle = (Math.atan2(y, x) * 180) / Math.PI; const distance = Math.min(Math.sqrt(x * x + y * y), MIDDLE); const posX = MIDDLE + distance * Math.cos((angle * Math.PI) / 180); const posY = MIDDLE + distance * Math.sin((angle * Math.PI) / 180); wheelPointer.style.left = `${posX}px`; wheelPointer.style.top = `${posY}px`; wheelPickedColor(posX, posY); setFinalColor(); } function updatePointer_bar(x) { const rect = gradientBar.getBoundingClientRect(); let posX = x - rect.left; posX = Math.max(GRADIENT_RADIUS, Math.min(posX, rect.width - GRADIENT_RADIUS)); gradientPointer.style.left = `${posX}px`; selectGradientColor(x); setFinalColor(); } function wheelPickedColor(posX, posY) { wheelX = posX; wheelY = posY; createGradientBar(getDefaultColor()); } function createGradientBar(color) { const gradientBarCtx = gradientBar.getContext('2d'); const gradient = gradientBarCtx.createLinearGradient(0, 0, gradientBar.width, 0); gradient.addColorStop(0, 'black'); // 왼쪽은 검은색 gradient.addColorStop(1, color.hex); // 오른쪽은 선택한 색상 gradientBarCtx.fillStyle = gradient; gradientBarCtx.fillRect(0, 0, gradientBar.width, gradientBar.height); } function getDefaultColor() { return getWheelColor(offscreenCtx); } function setFinalColor() { ctx.color = finalColor = getWheelColor(wheelCtx); setHex(finalColor.hex); } function setHex(hex) { fanalColorBackground.style.backgroundColor = fanalColorHex.textContent = hex; } function getWheelColor(wCtx) { const pixel = wCtx.getImageData(wheelX, wheelY, 1, 1).data; // eslint-disable-next-line prefer-const let [h, s, l] = rgbToHsl(pixel); // Calculate distance from the center of the wheel const dx = wheelX - MIDDLE; const dy = wheelY - MIDDLE; const distance = Math.sqrt(dx * dx + dy * dy); if (distance < CLOSE_TO_CENTER_THRESHOLD) { l = 1 - LIGHTNESS; } if (l > 1) l = 1; if (l < 0) l = 0; // Adjust lightness based on LIGHTNESS value const { r, g, b } = hslToRgb([h, s, l]); // Convert RGB to HEX const hex = `#${rgbToHex({ r, g, b })}`; return { hex, r, g, b, h, s, l: roundNumber(l), }; } function selectGradientColor(x) { const boundingRect = gradientBar.getBoundingClientRect(); let posX = x - boundingRect.left; if (posX < 0) posX = 0; if (posX > boundingRect.width) posX = boundingRect.width; const tolerance = GRADIENT_RADIUS; // If a click occurs near the end, the value is corrected all the way to the end. if (posX >= gradientBar.width - tolerance) { posX = gradientBar.width; } else if (posX <= tolerance) { posX = 0; } const normalizedLightness = 1 - posX / boundingRect.width; // 1 ~ 0 LIGHTNESS = normalizedLightness; // 0 ~ 1 drawColorWheel(); } function drawColorWheel() { // init main canvas wheelCtx.clearRect(0, 0, SIZE, SIZE); // copy offscreen to main canvas wheelCtx.drawImage(offscreenCanvas, 0, 0); // drow dark wheel drawWheelGradient(); } function drawWheelGradient() { wheelCtx.globalAlpha = LIGHTNESS; // 0: white, 1: black wheelCtx.fillStyle = 'black'; wheelCtx.beginPath(); wheelCtx.arc(MIDDLE, MIDDLE, MIDDLE, 0, 2 * Math.PI); wheelCtx.fill(); wheelCtx.globalAlpha = 1.0; } function drawColorWheelToContext(context) { if (!context) { console.warn('[HueSlider.fail] Context not found.'); return; } const fixedSaturation = SATURATION * 100; for (let h = 0; h <= 360; h += 0.5) { for (let distance = 0; distance <= MIDDLE; distance += 1) { context.beginPath(); const dynamicLightness = LIGHTNESS_CONT_VALUE + ((MIDDLE - distance) / MIDDLE) * 50; context.fillStyle = `hsl(${h}, ${fixedSaturation}%, ${dynamicLightness}%)`; const posX = MIDDLE + Math.cos(degreeToRadian(h)) * distance; const posY = MIDDLE - Math.sin(degreeToRadian(h)) * distance; context.arc(posX, posY, 1.5, 0, 2 * Math.PI); context.fill(); } } _bootstrapped = true; } function degreeToRadian(deg) { return (deg * Math.PI) / 180; } function rgbToHsl([r, g, b]) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h, s; const l = (max + min) / 2; if (max === min) { h = s = 0; // achromatic } else { const d = max - min; s = l > 0.5 ? d / (2.0 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return [roundNumber(h), roundNumber(s), roundNumber(l)]; } function hslToRgb([h, s, l]) { let r, g, b; if (s === 0) { r = g = b = l; // achromatic } else { const hue2rgb = function hue2rgb(p, q, t) { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h + 1 / 3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1 / 3); } return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255), }; } function rgbToHex({ r, g, b }) { let hexR = Math.floor(r).toString(16); if (r < 16) hexR = `0${hexR}`; let hexG = Math.floor(g).toString(16); if (g < 16) hexG = `0${hexG}`; let hexB = Math.floor(b).toString(16); if (b < 16) hexB = `0${hexB}`; return `${hexR}${hexG}${hexB}`.toUpperCase(); } function roundNumber(num) { const factor = Math.pow(10, FIXED_DEC); return Math.round(num * factor) / factor; } function InitRender() { // create drawColorWheelToContext(offscreenCtx); if (_bootstrapped) drawColorWheel(); } InitRender(); /** * @param {SunEditor.Deps} $ - Kernel dependencies * @param {string} className - Controller CSS class name * @returns {HTMLElement} */ function CreateHTML_basicControllerForm({ lang, icons }, className) { const hueController = dom.utils.createElement( 'DIV', { class: `se-controller ${className}` }, /*html*/ ` <div class="se-hue"></div> <div class="se-form-group se-form-w0 se-form-flex-btn"> <button type="button" class="se-btn se-btn-success" title="${lang.submitButton}" aria-label="${lang.submitButton}" data-command="submit">${icons.checked}</button> <button type="button" class="se-btn se-btn-danger" title="${lang.close}" aria-label="${lang.close}" data-command="close">${icons.cancel}</button> </div> `, ); return hueController; } export { CreateSliderCtx }; export default HueSlider;