Compare commits

..

1 Commits

Author SHA1 Message Date
Alexei Anoshenko 4f1969975d Added drag-and-drop support 2025-06-07 10:51:01 +03:00
5 changed files with 539 additions and 33 deletions

View File

@ -2124,4 +2124,104 @@ function createPath2D(svg) {
} else { } else {
return new Path2D(); return new Path2D();
} }
} }
function stringToBase64(str) {
const bytes = new TextEncoder().encode(str);
const binString = String.fromCodePoint(...bytes);
return btoa(binString);
}
function base64ToString(base64) {
const binString = atob(base64);
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0));
const result = new TextDecoder().decode(bytes);
return result;
}
function dragAndDropEvent(element, event, tag) {
event.stopPropagation();
//event.preventDefault()
let message = tag + "{session=" + sessionID + ",id=" + element.id + mouseEventData(element, event);
if (event.dataTransfer) {
if (event.target) {
message += ",target=" + event.target.id;
}
let dataText = ""
for (const item of event.dataTransfer.items) {
const data = event.dataTransfer.getData(item.type);
if (data) {
if (dataText != "") {
dataText += ";";
}
dataText += stringToBase64(item.type) + ":" + stringToBase64(data);
}
}
if (dataText != "") {
message += ',data="' + dataText + '"';
}
}
message += "}";
sendMessage(message);
}
function dragStartEvent(element, event) {
const data = element.getAttribute("data-drag");
if (data) {
const elements = data.split(";");
for (const line of elements) {
const pair = line.split(":");
if (pair.length == 2) {
event.dataTransfer.setData(base64ToString(pair[0]), base64ToString(pair[1]));
}
}
}
const image = element.getAttribute("data-drag-image");
if (image) {
let x = element.getAttribute("data-drag-image-x");
if (!x) {
x = 0;
}
let y = element.getAttribute("data-drag-image-y");
if (!y) {
y = 0;
}
let img = new Image();
img.src = image;
event.dataTransfer.setDragImage(img, x, y);
}
// TODO drag effect
dragAndDropEvent(element, event, "drag-start-event");
}
function dragEndEvent(element, event) {
dragAndDropEvent(element, event, "drag-end-event")
}
function dragEnterEvent(element, event) {
dragAndDropEvent(element, event, "drag-enter-event")
}
function dragLeaveEvent(element, event) {
dragAndDropEvent(element, event, "drag-leave-event")
}
function dragOverEvent(element, event) {
event.preventDefault();
if (element.getAttribute("data-drag-over") == "1") {
dragAndDropEvent(element, event, "drag-over-event")
} else {
event.stopPropagation();
}
}
function dropEvent(element, event) {
event.preventDefault();
dragAndDropEvent(element, event, "drop-event")
}

260
dragAndDrop.go Normal file
View File

@ -0,0 +1,260 @@
package rui
import (
"encoding/base64"
"maps"
"strings"
)
const (
// DragData is the constant for "drag-data" property tag.
//
// Used by View:
//
// Supported types: map[string]string.
DragData PropertyName = "drag-data"
// DragImage is the constant for "drag-image" property tag.
//
// Used by View:
// An url of image to use for the drag feedback image.
//
// Supported type: string.
DragImage PropertyName = "drag-image"
// DragImageXOffset is the constant for "drag-image-x-offset" property tag.
//
// Used by View:
// The horizontal offset in pixels within the drag feedback image.
//
// Supported types: float, int, string.
DragImageXOffset PropertyName = "drag-image-x-offset"
// DragImageYOffset is the constant for "drag-image-y-offset" property tag.
//
// Used by View.
// The vertical offset in pixels within the drag feedback image.
//
// Supported types: float, int, string.
DragImageYOffset PropertyName = "drag-image-y-offset"
// DragStartEvent is the constant for "drag-start-event" property tag.
//
// Used by View.
// Fired when the user starts dragging an element or text selection.
//
// General listener format:
//
DragStartEvent PropertyName = "drag-start-event"
// DragEndEvent is the constant for "drag-end-event" property tag.
//
// Used by View.
// Fired when a drag operation ends (by releasing a mouse button or hitting the escape key).
//
// General listener format:
//
DragEndEvent PropertyName = "drag-end-event"
// DragEnterEvent is the constant for "drag-enter-event" property tag.
//
// Used by View.
// Fired when a dragged element or text selection enters a valid drop target.
//
// General listener format:
//
DragEnterEvent PropertyName = "drag-enter-event"
// DragLeaveEvent is the constant for "drag-leave-event" property tag.
//
// Used by View.
// Fired when a dragged element or text selection leaves a valid drop target.
//
// General listener format:
//
DragLeaveEvent PropertyName = "drag-leave-event"
// DragOverEvent is the constant for "drag-over-event" property tag.
//
// Used by View.
// Fired when an element or text selection is being dragged over a valid drop target (every few hundred milliseconds).
//
// General listener format:
//
DragOverEvent PropertyName = "drag-over-event"
// DropEvent is the constant for "drop-event" property tag.
//
// Used by View.
// Fired when an element or text selection is dropped on a valid drop target.
//
// General listener format:
//
DropEvent PropertyName = "drop-event"
)
// MouseEvent represent a mouse event
type DragAndDropEvent struct {
MouseEvent
Data map[string]string
Target View
}
func (event *DragAndDropEvent) init(session Session, data DataObject) {
event.MouseEvent.init(data)
event.Data = map[string]string{}
if value, ok := data.PropertyValue("data"); ok {
data := strings.Split(value, ";")
for _, line := range data {
pair := strings.Split(line, ":")
if len(pair) == 2 {
mime, err := base64.StdEncoding.DecodeString(pair[0])
if err != nil {
ErrorLog(err.Error())
} else {
val, err := base64.StdEncoding.DecodeString(pair[1])
if err == nil {
event.Data[string(mime)] = string(val)
} else {
ErrorLog(err.Error())
}
}
}
}
}
if targetId, ok := data.PropertyValue("target"); ok {
event.Target = session.viewByHTMLID(targetId)
}
}
func handleDragAndDropEvents(view View, tag PropertyName, data DataObject) {
listeners := getOneArgEventListeners[View, DragAndDropEvent](view, nil, tag)
if len(listeners) > 0 {
var event DragAndDropEvent
event.init(view.Session(), data)
for _, listener := range listeners {
listener(view, event)
}
}
}
func base64DragData(view View) string {
if value := view.getRaw(DragData); value != nil {
if data, ok := value.(map[string]string); ok && len(data) > 0 {
buf := allocStringBuilder()
defer freeStringBuilder(buf)
for mime, value := range data {
if buf.Len() > 0 {
buf.WriteRune(';')
}
buf.WriteString(base64.StdEncoding.EncodeToString([]byte(mime)))
buf.WriteRune(':')
buf.WriteString(base64.StdEncoding.EncodeToString([]byte(value)))
}
return buf.String()
}
}
return ""
}
func dragAndDropHtml(view View, buffer *strings.Builder) {
if len(getOneArgEventListeners[View, DragAndDropEvent](view, nil, DropEvent)) > 0 {
buffer.WriteString(`ondragover="dragOverEvent(this, event)" ondrop="dropEvent(this, event)" `)
if len(getOneArgEventListeners[View, DragAndDropEvent](view, nil, DragOverEvent)) > 0 {
buffer.WriteString(`data-drag-over="1" `)
}
}
if dragData := base64DragData(view); dragData != "" {
buffer.WriteString(`draggable="true" data-drag="`)
buffer.WriteString(dragData)
buffer.WriteString(`" ondragstart="dragStartEvent(this, event)" `)
} else if len(getOneArgEventListeners[View, DragAndDropEvent](view, nil, DragStartEvent)) > 0 {
buffer.WriteString(` ondragstart="dragStartEvent(this, event)" `)
}
viewEventsHtml[DragAndDropEvent](view, []PropertyName{DragEndEvent, DragEnterEvent, DragLeaveEvent}, buffer)
session := view.Session()
if img, ok := stringProperty(view, DragImage, session); ok && img != "" {
img = strings.Trim(img, " \t")
if img[0] == '@' {
if img, ok = session.ImageConstant(img[1:]); ok {
buffer.WriteString(` data-drag-image="`)
buffer.WriteString(img)
buffer.WriteString(`" `)
}
} else {
buffer.WriteString(` data-drag-image="`)
buffer.WriteString(img)
buffer.WriteString(`" `)
}
}
if f, ok := floatTextProperty(view, DragImageXOffset, session, 0); ok {
buffer.WriteString(` data-drag-image-x="`)
buffer.WriteString(f)
buffer.WriteString(`" `)
}
if f, ok := floatTextProperty(view, DragImageYOffset, session, 0); ok {
buffer.WriteString(` data-drag-image-y="`)
buffer.WriteString(f)
buffer.WriteString(`" `)
}
}
// GetDragStartEventListeners returns the "drag-start-event" listener list. If there are no listeners then the empty list is returned.
// If the second argument (subviewID) is not specified or it is "" then a value from the first argument (view) is returned.
func GetDragStartEventListeners(view View, subviewID ...string) []func(View, DragAndDropEvent) {
return getOneArgEventListeners[View, DragAndDropEvent](view, subviewID, DragStartEvent)
}
// GetDragEndEventListeners returns the "drag-end-event" listener list. If there are no listeners then the empty list is returned.
// If the second argument (subviewID) is not specified or it is "" then a value from the first argument (view) is returned.
func GetDragEndEventListeners(view View, subviewID ...string) []func(View, DragAndDropEvent) {
return getOneArgEventListeners[View, DragAndDropEvent](view, subviewID, DragEndEvent)
}
// GetDragEnterEventListeners returns the "drag-enter-event" listener list. If there are no listeners then the empty list is returned.
// If the second argument (subviewID) is not specified or it is "" then a value from the first argument (view) is returned.
func GetDragEnterEventListeners(view View, subviewID ...string) []func(View, DragAndDropEvent) {
return getOneArgEventListeners[View, DragAndDropEvent](view, subviewID, DragEnterEvent)
}
// GetDragLeaveEventListeners returns the "drag-leave-event" listener list. If there are no listeners then the empty list is returned.
// If the second argument (subviewID) is not specified or it is "" then a value from the first argument (view) is returned.
func GetDragLeaveEventListeners(view View, subviewID ...string) []func(View, DragAndDropEvent) {
return getOneArgEventListeners[View, DragAndDropEvent](view, subviewID, DragLeaveEvent)
}
// GetDragOverEventListeners returns the "drag-over-event" listener list. If there are no listeners then the empty list is returned.
// If the second argument (subviewID) is not specified or it is "" then a value from the first argument (view) is returned.
func GetDragOverEventListeners(view View, subviewID ...string) []func(View, DragAndDropEvent) {
return getOneArgEventListeners[View, DragAndDropEvent](view, subviewID, DragOverEvent)
}
// GetDropEventListeners returns the "drag-start-event" listener list. If there are no listeners then the empty list is returned.
// If the second argument (subviewID) is not specified or it is "" then a value from the first argument (view) is returned.
func GetDropEventListeners(view View, subviewID ...string) []func(View, DragAndDropEvent) {
return getOneArgEventListeners[View, DragAndDropEvent](view, subviewID, DropEvent)
}
func GetDragData(view View, subviewID ...string) map[string]string {
result := map[string]string{}
if view = getSubview(view, subviewID); view != nil {
if value := view.getRaw(DragData); value != nil {
if data, ok := value.(map[string]string); ok {
maps.Copy(result, data)
}
}
}
return result
}

View File

@ -33,6 +33,9 @@ var eventJsFunc = map[PropertyName]struct{ jsEvent, jsFunc string }{
AnimationEndEvent: {jsEvent: "onanimationend", jsFunc: "animationEndEvent"}, AnimationEndEvent: {jsEvent: "onanimationend", jsFunc: "animationEndEvent"},
AnimationIterationEvent: {jsEvent: "onanimationiteration", jsFunc: "animationIterationEvent"}, AnimationIterationEvent: {jsEvent: "onanimationiteration", jsFunc: "animationIterationEvent"},
AnimationCancelEvent: {jsEvent: "onanimationcancel", jsFunc: "animationCancelEvent"}, AnimationCancelEvent: {jsEvent: "onanimationcancel", jsFunc: "animationCancelEvent"},
DragEndEvent: {jsEvent: "ondragend", jsFunc: "dragEndEvent"},
DragEnterEvent: {jsEvent: "ondragenter", jsFunc: "dragEnterEvent"},
DragLeaveEvent: {jsEvent: "ondragleave", jsFunc: "dragLeaveEvent"},
} }
func valueToNoArgEventListeners[V any](value any) ([]func(V), bool) { func valueToNoArgEventListeners[V any](value any) ([]func(V), bool) {

View File

@ -2,6 +2,7 @@ package rui
import ( import (
"math" "math"
"slices"
"strconv" "strconv"
"strings" "strings"
) )
@ -22,15 +23,16 @@ var colorProperties = []PropertyName{
AccentColor, AccentColor,
} }
func isPropertyInList(tag PropertyName, list []PropertyName) bool { /*
for _, prop := range list { func isPropertyInList(tag PropertyName, list []PropertyName) bool {
if prop == tag { for _, prop := range list {
return true if prop == tag {
return true
}
} }
return false
} }
return false */
}
var angleProperties = []PropertyName{ var angleProperties = []PropertyName{
From, From,
} }
@ -91,6 +93,8 @@ var floatProperties = map[PropertyName]struct{ min, max float64 }{
VideoWidth: {min: 0, max: 10000}, VideoWidth: {min: 0, max: 10000},
VideoHeight: {min: 0, max: 10000}, VideoHeight: {min: 0, max: 10000},
PushDuration: {min: 0, max: math.MaxFloat64}, PushDuration: {min: 0, max: math.MaxFloat64},
DragImageXOffset: {min: -math.MaxFloat64, max: math.MaxFloat64},
DragImageYOffset: {min: -math.MaxFloat64, max: math.MaxFloat64},
} }
var sizeProperties = map[PropertyName]string{ var sizeProperties = map[PropertyName]string{
@ -849,19 +853,19 @@ func propertiesSet(properties Properties, tag PropertyName, value any) []Propert
return setFloatProperty(properties, tag, value, limits.min, limits.max) return setFloatProperty(properties, tag, value, limits.min, limits.max)
} }
if isPropertyInList(tag, colorProperties) { if slices.Contains(colorProperties, tag) {
return setColorProperty(properties, tag, value) return setColorProperty(properties, tag, value)
} }
if isPropertyInList(tag, angleProperties) { if slices.Contains(angleProperties, tag) {
return setAngleProperty(properties, tag, value) return setAngleProperty(properties, tag, value)
} }
if isPropertyInList(tag, boolProperties) { if slices.Contains(boolProperties, tag) {
return setBoolProperty(properties, tag, value) return setBoolProperty(properties, tag, value)
} }
if isPropertyInList(tag, intProperties) { if slices.Contains(intProperties, tag) {
return setIntProperty(properties, tag, value) return setIntProperty(properties, tag, value)
} }
@ -874,18 +878,6 @@ func propertiesSet(properties Properties, tag PropertyName, value any) []Propert
return nil return nil
} }
/*
func (properties *propertyList) Set(tag PropertyName, value any) bool {
tag = properties.normalize(tag)
if value == nil {
properties.remove(properties, tag)
return true
}
return properties.set(properties, tag, value) != nil
}
*/
func (data *dataProperty) Set(tag PropertyName, value any) bool { func (data *dataProperty) Set(tag PropertyName, value any) bool {
if value == nil { if value == nil {
data.Remove(tag) data.Remove(tag)

169
view.go
View File

@ -2,6 +2,7 @@ package rui
import ( import (
"fmt" "fmt"
"maps"
"strconv" "strconv"
"strings" "strings"
) )
@ -399,6 +400,85 @@ func (view *viewData) setFunc(tag PropertyName, value any) []PropertyName {
case ResizeEvent, ScrollEvent: case ResizeEvent, ScrollEvent:
return setOneArgEventListener[View, Frame](view, tag, value) return setOneArgEventListener[View, Frame](view, tag, value)
case DragData:
switch value := value.(type) {
case map[string]string:
if len(value) == 0 {
view.setRaw(DragData, nil)
} else {
view.setRaw(DragData, maps.Clone(value))
}
case string:
if value == "" {
view.setRaw(DragData, nil)
} else {
data := map[string]string{}
for _, line := range strings.Split(value, ";") {
index := strings.IndexRune(line, ':')
if index < 0 {
invalidPropertyValue(DragData, value)
return nil
}
mime := line[:index]
val := line[index+1:]
if len(mime) > 0 || len(val) > 0 {
data[mime] = val
}
}
if len(data) == 0 {
view.setRaw(DragData, nil)
} else {
view.setRaw(DragData, data)
}
}
case DataObject:
data := map[string]string{}
count := value.PropertyCount()
for i := range count {
node := value.Property(i)
if node.Type() == TextNode {
data[node.Tag()] = node.Text()
} else {
invalidPropertyValue(DragData, value)
return nil
}
}
if len(data) == 0 {
view.setRaw(DragData, nil)
} else {
view.setRaw(DragData, data)
}
case DataNode:
switch value.Type() {
case TextNode:
return view.setFunc(DragData, value.Text())
case ObjectNode:
return view.setFunc(DragData, value.Object())
}
invalidPropertyValue(DragData, value)
return nil
case DataValue:
if value.IsObject() {
return view.setFunc(DragData, value.Object())
}
return view.setFunc(DragData, value.Value())
default:
notCompatibleType(DragData, value)
}
return []PropertyName{DragData}
case DragStartEvent, DragEndEvent, DragEnterEvent, DragLeaveEvent, DragOverEvent, DropEvent:
return setOneArgEventListener[View, DragAndDropEvent](view, tag, value)
} }
return viewStyleSet(view, tag, value) return viewStyleSet(view, tag, value)
@ -724,10 +804,81 @@ func (view *viewData) propertyChanged(tag PropertyName) {
PointerDown, PointerUp, PointerMove, PointerOut, PointerOver, PointerCancel, PointerDown, PointerUp, PointerMove, PointerOut, PointerOver, PointerCancel,
TouchStart, TouchEnd, TouchMove, TouchCancel, TouchStart, TouchEnd, TouchMove, TouchCancel,
TransitionRunEvent, TransitionStartEvent, TransitionEndEvent, TransitionCancelEvent, TransitionRunEvent, TransitionStartEvent, TransitionEndEvent, TransitionCancelEvent,
AnimationStartEvent, AnimationEndEvent, AnimationIterationEvent, AnimationCancelEvent: AnimationStartEvent, AnimationEndEvent, AnimationIterationEvent, AnimationCancelEvent,
DragEndEvent, DragEnterEvent, DragLeaveEvent:
updateEventListenerHtml(view, tag) updateEventListenerHtml(view, tag)
case DragStartEvent:
if view.getRaw(DragStartEvent) != nil || view.getRaw(DragData) != nil {
session.updateProperty(htmlID, "ondragstart", "dragStartEvent(this, event)")
} else {
session.removeProperty(view.htmlID(), "ondragstart")
}
case DropEvent:
if view.getRaw(DropEvent) != nil {
session.updateProperty(htmlID, "ondrop", "dropEvent(this, event)")
session.updateProperty(htmlID, "ondragover", "dragOverEvent(this, event)")
if view.getRaw(DragOverEvent) != nil {
session.updateProperty(htmlID, "data-drag-over", "1")
} else {
session.removeProperty(view.htmlID(), "data-drag-over")
}
} else {
session.removeProperty(view.htmlID(), "ondrop")
session.removeProperty(view.htmlID(), "ondragover")
}
case DragOverEvent:
if view.getRaw(DragOverEvent) != nil {
session.updateProperty(htmlID, "data-drag-over", "1")
} else {
session.removeProperty(view.htmlID(), "data-drag-over")
}
case DragData:
if data := base64DragData(view); data != "" {
session.updateProperty(htmlID, "draggable", "true")
session.updateProperty(htmlID, "data-drag", data)
session.updateProperty(htmlID, "ondragstart", "dragStartEvent(this, event)")
} else {
session.removeProperty(view.htmlID(), "draggable")
session.removeProperty(view.htmlID(), "data-drag")
if view.getRaw(DragStartEvent) == nil {
session.removeProperty(view.htmlID(), "ondragstart")
}
}
case DragImage:
if img, ok := stringProperty(view, DragImage, view.session); ok && img != "" {
img = strings.Trim(img, " \t")
if img[0] == '@' {
img, ok = view.session.ImageConstant(img[1:])
if !ok {
session.removeProperty(view.htmlID(), "data-drag-image")
return
}
}
session.updateProperty(htmlID, "data-drag-image", img)
} else {
session.removeProperty(view.htmlID(), "data-drag-image")
}
case DragImageXOffset:
if f, ok := floatTextProperty(view, DragImageXOffset, session, 0); ok {
session.updateProperty(htmlID, "data-drag-image-x", f)
} else {
session.removeProperty(view.htmlID(), "data-drag-image-x")
}
case DragImageYOffset:
if f, ok := floatTextProperty(view, DragImageYOffset, session, 0); ok {
session.updateProperty(htmlID, "data-drag-image-y", f)
} else {
session.removeProperty(view.htmlID(), "data-drag-image-y")
}
case DataList: case DataList:
updateInnerHTML(view.htmlID(), view.Session()) updateInnerHTML(view.htmlID(), view.Session())
@ -891,18 +1042,12 @@ func viewHTML(view View, buffer *strings.Builder, htmlTag string) {
keyEventsHtml(view, buffer) keyEventsHtml(view, buffer)
viewEventsHtml[MouseEvent](view, []PropertyName{ClickEvent, DoubleClickEvent, MouseDown, MouseUp, MouseMove, MouseOut, MouseOver, ContextMenuEvent}, buffer) viewEventsHtml[MouseEvent](view, []PropertyName{ClickEvent, DoubleClickEvent, MouseDown, MouseUp, MouseMove, MouseOut, MouseOver, ContextMenuEvent}, buffer)
//mouseEventsHtml(view, buffer, hasTooltip)
viewEventsHtml[PointerEvent](view, []PropertyName{PointerDown, PointerUp, PointerMove, PointerOut, PointerOver, PointerCancel}, buffer) viewEventsHtml[PointerEvent](view, []PropertyName{PointerDown, PointerUp, PointerMove, PointerOut, PointerOver, PointerCancel}, buffer)
//pointerEventsHtml(view, buffer)
viewEventsHtml[TouchEvent](view, []PropertyName{TouchStart, TouchEnd, TouchMove, TouchCancel}, buffer) viewEventsHtml[TouchEvent](view, []PropertyName{TouchStart, TouchEnd, TouchMove, TouchCancel}, buffer)
//touchEventsHtml(view, buffer)
viewEventsHtml[string](view, []PropertyName{TransitionRunEvent, TransitionStartEvent, TransitionEndEvent, TransitionCancelEvent, viewEventsHtml[string](view, []PropertyName{TransitionRunEvent, TransitionStartEvent, TransitionEndEvent, TransitionCancelEvent,
AnimationStartEvent, AnimationEndEvent, AnimationIterationEvent, AnimationCancelEvent}, buffer) AnimationStartEvent, AnimationEndEvent, AnimationIterationEvent, AnimationCancelEvent}, buffer)
//transitionEventsHtml(view, buffer)
//animationEventsHtml(view, buffer) dragAndDropHtml(view, buffer)
buffer.WriteRune('>') buffer.WriteRune('>')
view.htmlSubviews(view, buffer) view.htmlSubviews(view, buffer)
@ -952,6 +1097,12 @@ func (view *viewData) handleCommand(self View, command PropertyName, data DataOb
case TouchStart, TouchEnd, TouchMove, TouchCancel: case TouchStart, TouchEnd, TouchMove, TouchCancel:
handleTouchEvents(self, command, data) handleTouchEvents(self, command, data)
case DragStartEvent:
handleDragAndDropEvents(self, command, data)
case DragEndEvent, DragEnterEvent, DragLeaveEvent, DragOverEvent, DropEvent:
handleDragAndDropEvents(self, command, data)
case FocusEvent: case FocusEvent:
view.hasFocus = true view.hasFocus = true
for _, listener := range getNoArgEventListeners[View](view, nil, command) { for _, listener := range getNoArgEventListeners[View](view, nil, command) {