react-ruler/src/react-ruler/Ruler.tsx

import * as React from "react";
import { ref } from "framework-utils";
import { DrawRulerOptions, RulerInterface, RulerProps } from "./types";
import { convertUnitSize, findLast } from "@daybrush/utils";

export default class Ruler extends React.PureComponent<RulerProps> implements RulerInterface {
    public static defaultProps: Partial<RulerProps> = {
        type: "horizontal",
        zoom: 1,
        width: 0,
        height: 0,
        unit: 50,
        negativeRuler: true,
        mainLineSize: "100%",
        longLineSize: 10,
        shortLineSize: 7,
        segment: 10,
        direction: "end",
        style: { width: "100%", height: "100%" },
        backgroundColor: "#333333",
        font: "10px sans-serif",
        textColor: "#ffffff",
        textBackgroundColor: 'transparent',
        lineColor: "#777777",
        range: [-Infinity, Infinity],
        rangeBackgroundColor: 'transparent',
        lineWidth: 1,
        selectedBackgroundColor: "#555555",
        defaultScrollPos: 0,
        markColor: "#f55",
        marks: [],
    };
    public divisionsElement!: HTMLElement;
    public state = {
        scrollPos: 0,
    };
    public canvasElement!: HTMLCanvasElement;
    private canvasContext!: CanvasRenderingContext2D;
    private width: number = 0;
    private height: number = 0;
    private _zoom = 0;
    private _rulerScale = 0;
    private _observer: ResizeObserver | null = null;

    public render() {
        const props = this.props;
        this._zoom = props.zoom!;

        return <canvas
            ref={ref(this, "canvasElement")}
            style={this.props.style} />;

    }
    public componentDidMount() {
        const props = this.props;
        this.state.scrollPos = props.defaultScrollPos || 0;
        const canvas = this.canvasElement;
        const context = canvas.getContext("2d", { alpha: true })!;

        this.canvasContext = context;

        if (props.useResizeObserver) {
            this._observer = new ResizeObserver(this._checkResize);

            this._observer.observe(canvas, {
                box: "border-box",
            });
        } else {
            this.resize();
        }
    }
    public componentDidUpdate() {
        this.resize();
    }
    public componentWillUnmount(): void {
        this.state.scrollPos = 0;
        this._observer?.disconnect();
    }
    /**
     * Gets the scroll position of the ruler.
     */
    public getScrollPos() {
        return this.state.scrollPos;
    }
    /**
     * @method Ruler#scroll
     * @param scrollPos
     */
    public scroll(scrollPos: number, zoom?: number) {
        this.draw({
            scrollPos,
            zoom,
        });
    }
    /**
     * @method Ruler#resize
     */
    public resize(nextZoom?: number) {
        const canvas = this.canvasElement;
        const {
            width,
            height,
            scrollPos,
        } = this.props;
        const rulerScale = this._getRulerScale();

        this.width = width || canvas.offsetWidth;
        this.height = height || canvas.offsetHeight;
        canvas.width = this.width * rulerScale;
        canvas.height = this.height * rulerScale;

        this.draw({
            scrollPos,
            zoom: nextZoom,
        });
    }
    /**
     * draw a ruler
     * @param options - It is drawn with an external value, not the existing state.
     */
    public draw(options: DrawRulerOptions = {}) {
        const props = this.props;
        const {
            zoom: nextZoom = this._zoom,
            scrollPos = this.state.scrollPos,
            marks = props.marks,
            selectedRanges = props.selectedRanges!,
            segment = props.segment || 10,
            unit = props.unit!,
        } = options;

        this._zoom = nextZoom;

        const {
            type,
            backgroundColor,
            lineColor,
            textColor,
            textBackgroundColor,
            direction,
            negativeRuler = true,
            textFormat,
            range = [-Infinity, Infinity],
            rangeBackgroundColor,
            selectedBackgroundColor,
            lineWidth = 1,
            selectedRangesText,
            selectedRangesTextColor = "#44aaff",
            selectedRangesTextOffset = [0, 0],
            markColor = "#ff5",
        } = props as Required<RulerProps>;

        const rulerScale = this._getRulerScale();
        const width = this.width;
        const height = this.height;
        const state = this.state;
        state.scrollPos = scrollPos;
        const context = this.canvasContext;
        const isHorizontal = type === "horizontal";
        const isNegative = negativeRuler !== false;
        const font = props.font || "10px sans-serif";
        const textAlign = props.textAlign || "left";
        const textOffset = props.textOffset || [0, 0];
        const containerSize = isHorizontal ? height : width;
        const mainLineSize = convertUnitSize(`${props.mainLineSize || "100%"}`, containerSize);
        const longLineSize = convertUnitSize(`${props.longLineSize || 10}`, containerSize);
        const shortLineSize = convertUnitSize(`${props.shortLineSize || 7}`, containerSize);
        const lineOffset = props.lineOffset || [0, 0];

        if (backgroundColor === "transparent") {
            // Clear existing paths & text
            context.clearRect(0, 0, width * rulerScale, height * rulerScale);
        } else {
            // Draw the background
            context.rect(0, 0, width * rulerScale, height * rulerScale);
            context.fillStyle = backgroundColor;
            context.fill();
        }


        context.save();
        context.scale(rulerScale, rulerScale);
        context.strokeStyle = lineColor;
        context.lineWidth = lineWidth;
        context.font = font;
        context.fillStyle = textColor;
        context.textAlign = textAlign;

        switch (direction) {
            case "start":
                context.textBaseline = "top";
                break;
            case "center":
                context.textBaseline = "middle";
                break;
            case "end":
                context.textBaseline = "bottom";
                break;
        }

        context.translate(0.5, 0);
        context.beginPath();

        const size = isHorizontal ? width : height;
        const zoomUnit = nextZoom * unit;
        const minRange = Math.floor(scrollPos * nextZoom / zoomUnit);
        const maxRange = Math.ceil((scrollPos * nextZoom + size) / zoomUnit);
        const length = maxRange - minRange;
        const alignOffset = Math.max(["left", "center", "right"].indexOf(textAlign) - 1, -1);
        const barSize = isHorizontal ? height : width;
        const values: Array<{
            color: string;
            backgroundColor?: string;
            offset: number[];
            value: number;
            text: string;
            textSize: number;
        }> = [];

        for (let i = 0; i <= length; ++i) {
            const value = (i + minRange) * unit;
            let text = `${value}`;

            if (textFormat) {
                text = textFormat(value);
            }
            const textSize = context.measureText(text).width;

            values.push({
                color: textColor,
                offset: textOffset,
                backgroundColor: textBackgroundColor,
                value,
                text,
                textSize: textSize,
            });
        }


        // Draw Selected Range Background
        if (selectedBackgroundColor !== "transparent" && selectedRanges?.length) {
            selectedRanges.forEach(selectedRange => {
                const rangeStart = Math.max(selectedRange[0], range[0], negativeRuler ? -Infinity : 0);
                const rangeEnd = Math.min(selectedRange[1], range[1]);
                const rangeX = (rangeStart - scrollPos) * nextZoom;
                const rangeWidth = ((rangeEnd - rangeStart) * nextZoom);

                if (selectedRangesText) {
                    selectedRange.forEach(value => {
                        let text = `${value}`;

                        if (textFormat) {
                            text = textFormat(value);
                        }
                        const textSize = context.measureText(text).width;

                        const startPos = value * nextZoom;
                        const endPos = startPos + textSize;

                        findLast(values, ({ value: prevValue, textSize: prevTextSize }, index) => {
                            const prevStartPos = prevValue * nextZoom;
                            const prevEndPos = prevStartPos + prevTextSize;

                            if (prevStartPos <= endPos && startPos <= prevEndPos) {
                                values.splice(index, 1);
                            }
                        });
                        values.push({
                            value,
                            color: selectedRangesTextColor,
                            offset: selectedRangesTextOffset,
                            text,
                            textSize,
                        });
                    });
                }

                if (rangeWidth <= 0) {
                    return;
                }
                context.save();
                context.fillStyle = selectedBackgroundColor;

                if (isHorizontal) {
                    context.fillRect(rangeX, 0, rangeWidth, barSize);
                } else {
                    context.fillRect(0, rangeX, barSize, rangeWidth);
                }
                context.restore();
            });
        }
        // Draw Range Background
        if (rangeBackgroundColor !== "transparent" && range[0] !== -Infinity && range[1] !== Infinity) {
            const rangeStart = (range[0] - scrollPos) * nextZoom;
            const rangeEnd = ((range[1] - range[0]) * nextZoom);
            context.save();
            context.fillStyle = rangeBackgroundColor;
            if (isHorizontal) {
                context.fillRect(rangeStart, 0, rangeEnd, barSize);
            } else {
                context.fillRect(0, rangeStart, barSize, rangeEnd);
            }

            context.restore();
        }

        // Render Segments First

        for (let i = 0; i <= length; ++i) {
            const value = i + minRange;

            if (!isNegative && value < 0) {
                continue;
            }
            const startValue = value * unit;
            const startPos = (startValue - scrollPos) * nextZoom;

            for (let j = 0; j < segment; ++j) {
                const pos = startPos + j / segment * zoomUnit;
                const value = startValue + j / segment * unit;

                if (pos < 0 || pos >= size || value < range[0] || value > range[1]) {
                    continue;
                }

                const lineSize = j === 0
                    ? mainLineSize
                    : (j % 2 === 0 ? longLineSize : shortLineSize);

                let origin = 0
                switch (direction) {
                    case "start":
                        origin = 0;
                        break;
                    case "center":
                        origin = barSize / 2 - lineSize / 2;
                        break;
                    case "end":
                        origin = barSize - lineSize;
                        break;
                }

                const [x1, y1] = isHorizontal ?
                    [pos + lineOffset[0], origin + lineOffset[1]] :
                    [origin + lineOffset[0], pos + lineOffset[1]];

                const [x2, y2] = isHorizontal ? [x1, y1 + lineSize] : [x1 + lineSize, y1];

                context.moveTo(x1 + lineOffset[0], y1 + lineOffset[1]);
                context.lineTo(x2 + lineOffset[0], y2 + lineOffset[1]);
            }
        }
        context.stroke();

        context.beginPath();
        // Render marks
        context.strokeStyle = markColor;
        context.lineWidth = 1;
        (marks || []).forEach(value => {
            const pos = (-scrollPos + value) * nextZoom;

            if (pos < 0 || pos >= size || value < range[0] || value > range[1]) {
                return;
            }
            const [x1, y1] = isHorizontal ?
                [pos + lineOffset[0], lineOffset[1]] :
                [lineOffset[0], pos + lineOffset[1]];
            const [x2, y2] = isHorizontal ? [x1, y1 + containerSize] : [x1 + containerSize, y1];

            context.moveTo(x1 + lineOffset[0], y1 + lineOffset[1]);
            context.lineTo(x2 + lineOffset[0], y2 + lineOffset[1]);
        });
        context.stroke();

        // Render Labels

        values.forEach(({ value, offset, backgroundColor, color, text, textSize }) => {
            if (!isNegative && value < 0) {
                return;
            }
            const startPos = (value - scrollPos) * nextZoom;

            if (startPos < -zoomUnit || startPos >= size + unit * nextZoom || value < range[0] || value > range[1]) {
                return;
            }

            let origin = 0
            switch (direction) {
                case "start":
                    origin = 17;
                    break;
                case "center":
                    origin = barSize / 2;
                    break;
                case "end":
                    origin = barSize - 17;
                    break;
            }

            const [startX, startY] = isHorizontal
                ? [startPos + alignOffset * -3, origin]
                : [origin, startPos + alignOffset * 3];

            if (backgroundColor) {
                let backgroundOffset = 0
                switch (textAlign) {
                    case "left":
                        backgroundOffset = 0;
                        break;
                    case "center":
                        backgroundOffset = -textSize / 2;
                        break;
                    case "right":
                        backgroundOffset = -textSize;
                        break;
                }
                context.save();
                context.fillStyle = backgroundColor;
                if (isHorizontal) {
                    context.fillRect(startX + offset[0] + backgroundOffset, 0, textSize, mainLineSize);
                } else {
                    context.translate(0, startY + offset[1]);
                    context.rotate(-Math.PI / 2);
                    context.fillRect(backgroundOffset, 0, textSize, mainLineSize);
                }
                context.restore();
            }

            context.save();
            context.fillStyle = color;
            if (isHorizontal) {
                context.fillText(text, startX + offset[0], startY + offset[1]);
            } else {
                context.translate(startX + offset[0], startY + offset[1]);
                context.rotate(-Math.PI / 2);
                context.fillText(text, 0, 0);
            }
            context.restore();
        });

        context.restore();
    }
    private _getRulerScale() {
        const defaultPixelScale = this.props.defaultPixelScale || 2;

        if (!this._rulerScale) {
            let isHighDensity = window.devicePixelRatio > 1;

            if (!isHighDensity && window.matchMedia) {
                const mq = window.matchMedia('only screen and (min--moz-device-pixel-ratio: 1.3), only screen and (-o-min-device-pixel-ratio: 2.6/2), only screen and (-webkit-min-device-pixel-ratio: 1.3), only screen  and (min-device-pixel-ratio: 1.3), only screen and (min-resolution: 1.3dppx)');
                isHighDensity = mq && mq.matches;
            }
            this._rulerScale = isHighDensity ? 3 : defaultPixelScale;
        }
        return this._rulerScale;
    }
    private _checkResize = () => {
        this.resize();
    }
}