From 4f1969975d8466a561537bed62d5b3c0fe96e646 Mon Sep 17 00:00:00 2001 From: Alexei Anoshenko <2277098+anoshenko@users.noreply.github.com> Date: Sat, 7 Jun 2025 10:51:01 +0300 Subject: [PATCH] Added drag-and-drop support --- app_scripts.js | 102 ++++++++++++++++++- dragAndDrop.go | 260 +++++++++++++++++++++++++++++++++++++++++++++++++ events.go | 3 + propertySet.go | 38 +++----- view.go | 169 ++++++++++++++++++++++++++++++-- 5 files changed, 539 insertions(+), 33 deletions(-) create mode 100644 dragAndDrop.go diff --git a/app_scripts.js b/app_scripts.js index df8abb9..13f2c88 100644 --- a/app_scripts.js +++ b/app_scripts.js @@ -2124,4 +2124,104 @@ function createPath2D(svg) { } else { return new Path2D(); } -} \ No newline at end of file +} + +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") +} diff --git a/dragAndDrop.go b/dragAndDrop.go new file mode 100644 index 0000000..579017f --- /dev/null +++ b/dragAndDrop.go @@ -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 +} diff --git a/events.go b/events.go index 8bf6a9d..84c344b 100644 --- a/events.go +++ b/events.go @@ -33,6 +33,9 @@ var eventJsFunc = map[PropertyName]struct{ jsEvent, jsFunc string }{ AnimationEndEvent: {jsEvent: "onanimationend", jsFunc: "animationEndEvent"}, AnimationIterationEvent: {jsEvent: "onanimationiteration", jsFunc: "animationIterationEvent"}, 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) { diff --git a/propertySet.go b/propertySet.go index 6cac614..c788382 100644 --- a/propertySet.go +++ b/propertySet.go @@ -2,6 +2,7 @@ package rui import ( "math" + "slices" "strconv" "strings" ) @@ -22,15 +23,16 @@ var colorProperties = []PropertyName{ AccentColor, } -func isPropertyInList(tag PropertyName, list []PropertyName) bool { - for _, prop := range list { - if prop == tag { - return true +/* + func isPropertyInList(tag PropertyName, list []PropertyName) bool { + for _, prop := range list { + if prop == tag { + return true + } } + return false } - return false -} - +*/ var angleProperties = []PropertyName{ From, } @@ -91,6 +93,8 @@ var floatProperties = map[PropertyName]struct{ min, max float64 }{ VideoWidth: {min: 0, max: 10000}, VideoHeight: {min: 0, max: 10000}, 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{ @@ -849,19 +853,19 @@ func propertiesSet(properties Properties, tag PropertyName, value any) []Propert return setFloatProperty(properties, tag, value, limits.min, limits.max) } - if isPropertyInList(tag, colorProperties) { + if slices.Contains(colorProperties, tag) { return setColorProperty(properties, tag, value) } - if isPropertyInList(tag, angleProperties) { + if slices.Contains(angleProperties, tag) { return setAngleProperty(properties, tag, value) } - if isPropertyInList(tag, boolProperties) { + if slices.Contains(boolProperties, tag) { return setBoolProperty(properties, tag, value) } - if isPropertyInList(tag, intProperties) { + if slices.Contains(intProperties, tag) { return setIntProperty(properties, tag, value) } @@ -874,18 +878,6 @@ func propertiesSet(properties Properties, tag PropertyName, value any) []Propert 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 { if value == nil { data.Remove(tag) diff --git a/view.go b/view.go index 2338651..2a46471 100644 --- a/view.go +++ b/view.go @@ -2,6 +2,7 @@ package rui import ( "fmt" + "maps" "strconv" "strings" ) @@ -399,6 +400,85 @@ func (view *viewData) setFunc(tag PropertyName, value any) []PropertyName { case ResizeEvent, ScrollEvent: 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) @@ -724,10 +804,81 @@ func (view *viewData) propertyChanged(tag PropertyName) { PointerDown, PointerUp, PointerMove, PointerOut, PointerOver, PointerCancel, TouchStart, TouchEnd, TouchMove, TouchCancel, TransitionRunEvent, TransitionStartEvent, TransitionEndEvent, TransitionCancelEvent, - AnimationStartEvent, AnimationEndEvent, AnimationIterationEvent, AnimationCancelEvent: + AnimationStartEvent, AnimationEndEvent, AnimationIterationEvent, AnimationCancelEvent, + DragEndEvent, DragEnterEvent, DragLeaveEvent: 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: updateInnerHTML(view.htmlID(), view.Session()) @@ -891,18 +1042,12 @@ func viewHTML(view View, buffer *strings.Builder, htmlTag string) { keyEventsHtml(view, 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) - //pointerEventsHtml(view, buffer) - viewEventsHtml[TouchEvent](view, []PropertyName{TouchStart, TouchEnd, TouchMove, TouchCancel}, buffer) - //touchEventsHtml(view, buffer) - viewEventsHtml[string](view, []PropertyName{TransitionRunEvent, TransitionStartEvent, TransitionEndEvent, TransitionCancelEvent, AnimationStartEvent, AnimationEndEvent, AnimationIterationEvent, AnimationCancelEvent}, buffer) - //transitionEventsHtml(view, buffer) - //animationEventsHtml(view, buffer) + + dragAndDropHtml(view, buffer) buffer.WriteRune('>') view.htmlSubviews(view, buffer) @@ -952,6 +1097,12 @@ func (view *viewData) handleCommand(self View, command PropertyName, data DataOb case TouchStart, TouchEnd, TouchMove, TouchCancel: handleTouchEvents(self, command, data) + case DragStartEvent: + handleDragAndDropEvents(self, command, data) + + case DragEndEvent, DragEnterEvent, DragLeaveEvent, DragOverEvent, DropEvent: + handleDragAndDropEvents(self, command, data) + case FocusEvent: view.hasFocus = true for _, listener := range getNoArgEventListeners[View](view, nil, command) {