All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.io.nacular.doodle.drawing.impl.CanvasImpl.kt Maven / Gradle / Ivy

There is a newer version: 0.10.2
Show newest version
package io.nacular.doodle.drawing.impl

import io.nacular.doodle.core.Camera
import io.nacular.doodle.dom.HTMLElement
import io.nacular.doodle.dom.HTMLImageElement
import io.nacular.doodle.dom.HtmlFactory
import io.nacular.doodle.dom.Node
import io.nacular.doodle.dom.Text
import io.nacular.doodle.dom.Visible
import io.nacular.doodle.dom.add
import io.nacular.doodle.dom.childAt
import io.nacular.doodle.dom.clear
import io.nacular.doodle.dom.index
import io.nacular.doodle.dom.left
import io.nacular.doodle.dom.numChildren
import io.nacular.doodle.dom.parent
import io.nacular.doodle.dom.remove
import io.nacular.doodle.dom.rgbaString
import io.nacular.doodle.dom.setBackgroundColor
import io.nacular.doodle.dom.setBorderRadius
import io.nacular.doodle.dom.setBounds
import io.nacular.doodle.dom.setClipPath
import io.nacular.doodle.dom.setColor
import io.nacular.doodle.dom.setLeft
import io.nacular.doodle.dom.setOpacity
import io.nacular.doodle.dom.setOverflow
import io.nacular.doodle.dom.setPerspectiveTransform
import io.nacular.doodle.dom.setSize
import io.nacular.doodle.dom.setTextAlignment
import io.nacular.doodle.dom.setTop
import io.nacular.doodle.dom.setTransform
import io.nacular.doodle.dom.top
import io.nacular.doodle.dom.translate
import io.nacular.doodle.dom.willChange
import io.nacular.doodle.drawing.AffineTransform
import io.nacular.doodle.drawing.AffineTransform.Companion.Identity
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.ColorPaint
import io.nacular.doodle.drawing.Font
import io.nacular.doodle.drawing.InnerShadow
import io.nacular.doodle.drawing.OuterShadow
import io.nacular.doodle.drawing.Paint
import io.nacular.doodle.drawing.Renderer.FillRule
import io.nacular.doodle.drawing.Shadow
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.TextFactory
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Ellipse
import io.nacular.doodle.geometry.Path
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Point.Companion.Origin
import io.nacular.doodle.geometry.Polygon
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.Size.Companion.Empty
import io.nacular.doodle.geometry.toPath
import io.nacular.doodle.image.Image
import io.nacular.doodle.image.impl.ImageImpl
import io.nacular.doodle.text.StyledText
import io.nacular.doodle.text.TextSpacing
import io.nacular.doodle.utils.TextAlignment
import io.nacular.doodle.utils.splitMatches
import io.nacular.measured.units.Angle
import io.nacular.measured.units.Measure
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract

internal open class CanvasImpl(
    private val renderParent   : HTMLElement,
    private val htmlFactory    : HtmlFactory,
    private val textFactory    : TextFactory,
    private val textMetrics    : TextMetrics,
    private val useShadowHack  : Boolean,
    rendererFactory: VectorRendererFactory): NativeCanvas {

    private inner class Context: CanvasContext {
        override var size
            get(   ) = [email protected]
            set(new) { [email protected] = new }

        override val shadows        get() = [email protected]
        override val renderRegion   get() = [email protected]
        override var renderPosition get() = [email protected]
            set(new) { [email protected] = new }

        override val isRawData get() = [email protected]

        override fun markDirty() { vectorRenderDirty = true }
    }

    override var size             = Empty
    private val shadows           = mutableListOf()
    private var renderRegion      = renderParent
    private var renderPosition    = null as Node?
    private var isRawData         = false
    private val vectorRenderer    by lazy { rendererFactory(Context()) }

    private var innerShadowCount  = 0
    private var vectorRenderDirty = false

    override fun rect(rectangle: Rectangle,                 fill: Paint ) = if (isSimple(fill)) present(fill = fill) { getRect(rectangle) } else vectorRenderer.rect(rectangle, fill)
    override fun rect(rectangle: Rectangle, stroke: Stroke, fill: Paint?) = vectorRenderer.rect(rectangle, stroke, fill)

    override fun rect(rectangle: Rectangle, radius: Double,                 fill: Paint ) = if (isSimple(fill)) present(fill = fill) { roundedRect(rectangle, radius) } else vectorRenderer.rect(rectangle, radius, fill)
    override fun rect(rectangle: Rectangle, radius: Double, stroke: Stroke, fill: Paint?) = vectorRenderer.rect(rectangle, radius, stroke, fill)

    override fun circle(circle: Circle,                 fill: Paint ) = if (isSimple(fill)) present(fill = fill) { roundedRect(circle.boundingRectangle, circle.radius) } else vectorRenderer.circle(circle, fill)
    override fun circle(circle: Circle, stroke: Stroke, fill: Paint?) = vectorRenderer.circle(circle, stroke, fill)

    override fun ellipse(ellipse: Ellipse,                 fill: Paint ) = if (isSimple(fill)) present(fill = fill) { roundedRect(ellipse.boundingRectangle, ellipse.xRadius, ellipse.yRadius) } else vectorRenderer.ellipse(ellipse, fill)
    override fun ellipse(ellipse: Ellipse, stroke: Stroke, fill: Paint?) = vectorRenderer.ellipse(ellipse, stroke, fill)

    // =============== Complex =============== //

    override fun line(start: Point, end: Point, stroke: Stroke) = vectorRenderer.line(start, end, stroke)

    override fun path(points: List, stroke: Stroke                                  ) = vectorRenderer.path(points, stroke                )
    override fun path(points: List,                 fill: Paint, fillRule: FillRule?) = vectorRenderer.path(points,         fill, fillRule)
    override fun path(points: List, stroke: Stroke, fill: Paint, fillRule: FillRule?) = vectorRenderer.path(points, stroke, fill, fillRule)

    override fun path(path: Path, stroke: Stroke                                  ) = vectorRenderer.path(path, stroke                )
    override fun path(path: Path,                 fill: Paint, fillRule: FillRule?) = vectorRenderer.path(path,         fill, fillRule)
    override fun path(path: Path, stroke: Stroke, fill: Paint, fillRule: FillRule?) = vectorRenderer.path(path, stroke, fill, fillRule)

    override fun poly(polygon: Polygon,                 fill: Paint ) = vectorRenderer.poly(polygon,         fill)
    override fun poly(polygon: Polygon, stroke: Stroke, fill: Paint?) = vectorRenderer.poly(polygon, stroke, fill)

    override fun arc(center: Point, radius: Double, sweep: Measure, rotation: Measure,                 fill: Paint ) = vectorRenderer.arc(center, radius, sweep, rotation,         fill)
    override fun arc(center: Point, radius: Double, sweep: Measure, rotation: Measure, stroke: Stroke, fill: Paint?) = vectorRenderer.arc(center, radius, sweep, rotation, stroke, fill)

    override fun wedge(center: Point, radius: Double, sweep: Measure, rotation: Measure,                 fill: Paint ) = vectorRenderer.wedge(center, radius, sweep, rotation,         fill)
    override fun wedge(center: Point, radius: Double, sweep: Measure, rotation: Measure, stroke: Stroke, fill: Paint?) = vectorRenderer.wedge(center, radius, sweep, rotation, stroke, fill)

    override fun text(text: StyledText, at: Point, textSpacing: TextSpacing) {
        when {
            isSimple(text) -> { updateRenderPosition(); completeOperation(createStyledTextGlyph(text, at, textSpacing)) }
            else           -> vectorRenderer.text(text, at, textSpacing)
        }
    }

    override fun text(text: String, font: Font?, at: Point, fill: Paint, textSpacing: TextSpacing) {
        when {
            text.isEmpty() || !fill.visible -> return
            isSimpleText(fill)              -> { updateRenderPosition(); completeOperation(createTextGlyph(fill, text, font, at, textSpacing)) }
            else                            -> vectorRenderer.text(text, font, at, fill, textSpacing)
        }
    }

    override fun wrapped(text: String, at: Point, width: Double, fill: Paint, font: Font?, indent: Double, alignment: TextAlignment, lineSpacing: Float, textSpacing: TextSpacing) {
        when {
            text.isEmpty() || !fill.visible -> return
            isSimpleText(fill)              -> {
                updateRenderPosition()
                completeOperation(
                    createWrappedTextGlyph(
                        fill        = fill,
                        text        = text,
                        font        = font,
                        at          = at,
                        width       = width,
                        indent      = indent,
                        alignment   = alignment,
                        lineSpacing = lineSpacing,
                        textSpacing = textSpacing
                    )
                )
            }
            else                            -> vectorRenderer.wrapped(text, at, width, fill, font, indent, alignment, lineSpacing, textSpacing)
        }
    }

    override fun wrapped(text: StyledText, at: Point, width: Double, indent: Double, alignment: TextAlignment, lineSpacing: Float, textSpacing: TextSpacing) {
        when {
            isSimple(text) -> {
                updateRenderPosition()
                completeOperation(createWrappedStyleTextGlyph(text, at, width, indent, alignment, lineSpacing, textSpacing))
            }
            else           -> vectorRenderer.wrapped(text, at, width, indent, alignment, lineSpacing, textSpacing)
        }
    }

    override fun image(image: Image, destination: Rectangle, opacity: Float, radius: Double, source: Rectangle) {
        if (image is ImageImpl && opacity > 0 && !(source.empty || destination.empty)) {
            updateRenderPosition()

            if (source.size == image.size && source.position == Origin) {
                completeOperation(createImage(image, destination, radius, opacity))
            } else {
                getRect(destination, clear = false)?.let { clipRect ->
                    val oldRenderPosition = renderPosition

                    renderPosition = clipRect.firstChild

                    val xRatio = destination.width  / source.width
                    val yRatio = destination.height / source.height

                    val imageElement = createImage(
                            image,
                            Rectangle(0 - xRatio * source.x,
                                      0 - yRatio * source.y,
                                      xRatio * image.size.width,
                                      yRatio * image.size.height),
                            0.0,
                            opacity)

                    if (renderPosition !== imageElement) {
                        clipRect.clear()
                        clipRect.add(imageElement)
                    }

                    renderPosition = oldRenderPosition

                    clipRect.style.setBorderRadius(radius)
                    completeOperation(clipRect)
                }
            }
        }
    }

    override fun transform(transform: AffineTransform, block: Canvas.() -> Unit) = when {
        transform.isIdentity -> block()
        else                 -> subFrame(block) {
            val point = -Point(size.width / 2, size.height / 2)
            it.style.setTransform(((Identity translate point) * transform) translate -point)
            it.style.setOverflow(Visible())
        }
    }

    override fun transform(transform: AffineTransform, camera: Camera, block: Canvas.() -> Unit) = when {
        transform.isIdentity -> block()
        else                 -> subFrame(block) {
            val point = -Point(size.width / 2, size.height / 2)
            val modifiedTransform = ((Identity translate point) * transform) translate -point
            it.style.setPerspectiveTransform((camera.projection(point) * modifiedTransform).matrix)
            it.style.setOverflow(Visible())
        }
    }

    override fun clear() {
        renderPosition = renderParent.firstChild

        vectorRenderDirty = false
        vectorRenderer.clear()
        isRawData = false // HACK: must be reset after vectorRenderer clear
    }

    override fun flush() {
        clearFromRenderPosition()

        vectorRenderer.flush()
    }

    private fun clearFromRenderPosition() {
        updateRenderPosition()

        renderPosition?.let { child -> child.parentNode?.let { it to it.index(child) } }?.let { (parent, index) ->
            if (index >= 0) {
                while (index < parent.numChildren) {
                    parent.remove(parent.childAt(index)!!)
                }
            }
        }
    }

    override fun clip(rectangle: Rectangle, radius: Double, block: Canvas.() -> Unit) = when (radius) {
        0.0  -> clip(rectangle, block)
        else -> subFrame({ translate(-rectangle.position, block) }) {
            it.style.setBounds      (rectangle)
            it.style.setBorderRadius(radius   )
        }
    }

    override fun clip(polygon: Polygon, block: Canvas.() -> Unit) = clip(polygon.toPath(), block)

    override fun clip(ellipse: Ellipse, block: Canvas.() -> Unit) = clip(ellipse.toPath(), block)

    override fun clip(path: Path, block: Canvas.() -> Unit) = subFrame(block) {
        it.style.setClipPath(path)
    }

    override fun shadow(shadow: Shadow, block: Canvas.() -> Unit) {
        shadows += shadow

        if (shadow is InnerShadow) ++innerShadowCount

        apply(block)

        if (shadow is InnerShadow) --innerShadowCount

        shadows -= shadow
    }

    override fun addData(elements: List, at: Point) = elements.forEach { element ->
        isRawData = true // HACK: to communicate to vectorRenderer that it should not dig into the current element
        updateRenderPosition()

        if (at.y != 0.0 ) element.style.setTop (element.top  + at.y)
        if (at.x != 0.0 ) element.style.setLeft(element.left + at.x)

        when (val position = renderPosition) {
            null -> renderRegion.add(element)
            else -> {
                val nextSibling = position.nextSibling

                if (element !== position) {
                    renderRegion.replaceChild(element, position)
                }

                renderPosition = nextSibling
            }
        }
    }.also {
        vectorRenderer.flush()
    }

    private fun isSimple(fill: Paint): Boolean = when {
        !fill.visible                               -> true
        fill is ColorPaint && innerShadowCount == 0 -> true
        else                                        -> false
    }


    @OptIn(ExperimentalContracts::class)
    private fun isSimpleText(fill: Paint): Boolean {
        contract {
            returns(true) implies (fill is ColorPaint)
        }

        return fill is ColorPaint && innerShadowCount <= 0
    }

    private fun isSimple(text: StyledText): Boolean {
        text.forEach { (_, style) ->
            val simpleForeground = style.foreground?.let { isSimple(it) } ?: true
            val simpleBackground = style.background?.let { isSimple(it) } ?: true

            if (!(simpleForeground && simpleBackground)) {
                return false
            }
        }

        return true
    }

    private fun subFrame(block: Canvas.() -> Unit, configure: (HTMLElement) -> Unit) {
        updateRenderPosition()

        // TODO: Not sure if this is causing more element creations than necessary on re-draw

        val clipRect = getRectElement(clear = false)

        if (clipRect.parent == null) {
            renderPosition?.let {
                it.parent?.replaceChild(clipRect, it)
            } ?: renderRegion.add(clipRect)
        }

        clipRect.style.setSize        (size)
        clipRect.style.setBorderRadius(null)
        clipRect.style.background = ""

        configure(clipRect)

        renderRegion   = clipRect
        renderPosition = clipRect.firstChild

        apply(block)

        // clear potentially unused elements from the clipRect
        clearFromRenderPosition()

        renderRegion   = renderRegion.parent as HTMLElement
        renderPosition = clipRect.nextSibling
    }

    private fun present(fill: Paint?, block: () -> HTMLElement?) {
        if (isVisibleColorPaint(fill)) {
            updateRenderPosition()

            block()?.let {
                it.style.setBackgroundColor(fill.color)

                completeOperation(it)
            }
        }
    }

    /**
     * Must only be called on Paints that have been considered simple via isSimple
     */
    @OptIn(ExperimentalContracts::class)
    private fun isVisibleColorPaint(fill: Paint?): Boolean {
        contract {
            // This works b/c isSimple is used before all calls to present, and only Paints that are
            // !visible || ColorPaint pass that test. So a visible Paint at this point must be
            // a ColorPaint
            returns(true) implies (fill is ColorPaint)
        }

        return fill?.visible == true
    }

    private fun getRectElement(clear: Boolean = true): HTMLElement = htmlFactory.createOrUse("B", renderPosition as? HTMLElement).also {
        if (clear) {
            it.clear()
        }

        it.style.filter = ""
        it.style.setOverflow     (null)
        it.style.setClipPath     (null)
        it.style.setTransform    (    )
        it.style.setBorderRadius (null)
        it.style.setTextAlignment(null)
    }

    private fun getRect(rectangle: Rectangle, clear: Boolean = true): HTMLElement? = rectangle.takeIf { !it.empty }?.let {
        getRectElement(clear).also {
            // This is done b/c there's an issue w/ handling half-pixels in Chrome: https://movier.me/blog/2017/realize-half-pixel-border-in-chrome/

            var width     = rectangle.width
            var height    = rectangle.height
            var transform = Identity.translate(rectangle.position)

            if (rectangle.height < 1) {
                height    *= 2
                transform  = transform.scale(y = 0.5)
            }

            if (rectangle.width < 1) {
                width     *= 2
                transform  = transform.scale(x = 0.5)
            }

            it.style.setSize        (Size(width, height))
            it.style.setTransform   (transform          )
        }
    }

    private fun roundedRect(rectangle: Rectangle,                   radius: Double) = getRect(rectangle)?.also { it.style.setBorderRadius(radius          ) }
    private fun roundedRect(rectangle: Rectangle, xRadius: Double, yRadius: Double) = getRect(rectangle)?.also { it.style.setBorderRadius(xRadius, yRadius) }

    private fun updateRenderPosition() {
        if (vectorRenderDirty) {
            renderPosition = renderPosition?.nextSibling
            vectorRenderer.flush()

            vectorRenderDirty = false
        }
    }

    private fun completeOperation(element: HTMLElement): HTMLElement {
        shadows.forEach {
            // FIXME: Need to move this to Style and avoid raw px
            // FIXME: Move text inner shadow to vector renderer
            val shadow = "${when(it) {
                is InnerShadow -> "inset "
                is OuterShadow -> ""
            }}${it.horizontal}px ${it.vertical}px ${it.blurRadius - if (it is InnerShadow) 1 else 0}px ${it.color.rgbaString}"

            when (element.firstChild) {
                is Text -> element.style.textShadow += shadow // FIXME: inset not supported, so using SVG for now
                else    -> {
                    element.style.filter += "drop-shadow($shadow)"
                    if (useShadowHack) {
                        element.style.willChange = "filter" // FIXME: This is a hack to avoid issues on Safari
                        if (shadows.size > 1) {
                            element.style.setOverflow(Visible())
                        }
                    }
                }
            }
        }

        if (renderPosition == null) {
            renderRegion.add(element)
        } else {
            if (element !== renderPosition) {
                renderPosition?.parent?.replaceChild(element, renderPosition!!)
            }

            renderPosition = element.nextSibling
        }

        return element
    }

    private fun createTextGlyph(fill: ColorPaint, text: String, font: Font?, at: Point, textSpacing: TextSpacing) = configure(textFactory.create(text, font, textSpacing, if (renderPosition is HTMLElement) renderPosition as HTMLElement else null), fill, at)

    private fun String.firstWord    (): String     = splitMatches("""\s""".toRegex()).matches.firstOrNull()?.match ?: ""
    private fun StyledText.firstWord(): StyledText = this.subString(0 .. text.firstWord().length)

    private fun createWrappedTextGlyph(fill: ColorPaint, text: String, font: Font?, at: Point, width: Double, indent: Double, alignment: TextAlignment, lineSpacing: Float, textSpacing: TextSpacing): HTMLElement {
        val firstWordWidth = if (indent > 0.0) textMetrics.width(text.firstWord(), font, textSpacing) else 0.0

        val element = textFactory.wrapped(
            text,
            font,
            width       = width,
            indent      = if (width > firstWordWidth + indent) indent else 0.0,
            possible    = if (renderPosition is HTMLElement) renderPosition as HTMLElement else null,
            alignment   = alignment,
            lineSpacing = lineSpacing,
            textSpacing = textSpacing
        )

        return configure(element, fill, at)
    }

    private fun createStyledTextGlyph(text: StyledText, at: Point, textSpacing: TextSpacing) = textFactory.create(text, textSpacing, if (renderPosition is HTMLElement) renderPosition as HTMLElement else null).apply {
        style.translate(at)
    }

    private fun createWrappedStyleTextGlyph(text: StyledText, at: Point, width: Double, indent: Double, alignment: TextAlignment, lineSpacing: Float, textSpacing: TextSpacing): HTMLElement {
        val firstWordWidth = if (indent > 0.0) textMetrics.width(text.firstWord(), textSpacing) else 0.0

        val element = textFactory.wrapped(
            text        = text,
            width       = width,
            indent      = if (width > firstWordWidth + indent) indent else 0.0,
            possible    = if (renderPosition is HTMLElement) renderPosition as HTMLElement else null,
            alignment   = alignment,
            lineSpacing = lineSpacing,
            textSpacing = textSpacing,
        )

        element.style.translate(at)

        return element
    }

    private fun configure(element: HTMLElement, fill: ColorPaint, position: Point): HTMLElement = element.also {
        it.style.apply {
            translate (position          )
            setColor  (fill.color        )
            setOpacity(fill.color.opacity)
        }
    }

    private fun createImage(image: ImageImpl, rectangle: Rectangle, radius: Double, opacity: Float): HTMLImageElement = pickImageElement(image.image, renderPosition).also {
        it.style.apply {
            translate      (rectangle.position)
            setSize        (rectangle.size    )
            setOpacity     (opacity           )
            setBorderRadius(radius            )
        }
    }

    private fun pickImageElement(image: HTMLImageElement, possible: Node?): HTMLImageElement {
        var result = possible

        if (result == null || result !is HTMLImageElement || result.parent != null && result.nodeName != image.nodeName) {
            result = image.cloneNode(false)
        } else {
            result.src              = image.src
            result.style.filter     = ""
            result.style.willChange = ""
            result.style.setOverflow(null)
        }

        return result as HTMLImageElement
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy