mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
91c293e4be
* new vdomevents to support click, change, keydown, etc. easier type signature * can now pass a prop type instead of always converting to map[string]any * implement DefineComponent to allow easier vdom creation using a component function directly * separate vdomclient Make from Connect * lots of bug fixes to get everything working again * PStyle and special "style" attribute handling
403 lines
9.3 KiB
Go
403 lines
9.3 KiB
Go
// Copyright 2024, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package vdom
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/wavetermdev/htmltoken"
|
|
"github.com/wavetermdev/waveterm/pkg/vdom/cssparser"
|
|
)
|
|
|
|
// can tokenize and bind HTML to Elems
|
|
|
|
const Html_BindPrefix = "#bind:"
|
|
const Html_ParamPrefix = "#param:"
|
|
const Html_GlobalEventPrefix = "#globalevent"
|
|
const Html_BindParamTagName = "bindparam"
|
|
const Html_BindTagName = "bind"
|
|
|
|
func appendChildToStack(stack []*VDomElem, child *VDomElem) {
|
|
if child == nil {
|
|
return
|
|
}
|
|
if len(stack) == 0 {
|
|
return
|
|
}
|
|
parent := stack[len(stack)-1]
|
|
parent.Children = append(parent.Children, *child)
|
|
}
|
|
|
|
func pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem {
|
|
if elem == nil {
|
|
return stack
|
|
}
|
|
return append(stack, elem)
|
|
}
|
|
|
|
func popElemStack(stack []*VDomElem) []*VDomElem {
|
|
if len(stack) <= 1 {
|
|
return stack
|
|
}
|
|
curElem := stack[len(stack)-1]
|
|
appendChildToStack(stack[:len(stack)-1], curElem)
|
|
return stack[:len(stack)-1]
|
|
}
|
|
|
|
func curElemTag(stack []*VDomElem) string {
|
|
if len(stack) == 0 {
|
|
return ""
|
|
}
|
|
return stack[len(stack)-1].Tag
|
|
}
|
|
|
|
func finalizeStack(stack []*VDomElem) *VDomElem {
|
|
if len(stack) == 0 {
|
|
return nil
|
|
}
|
|
for len(stack) > 1 {
|
|
stack = popElemStack(stack)
|
|
}
|
|
rtnElem := stack[0]
|
|
if len(rtnElem.Children) == 0 {
|
|
return nil
|
|
}
|
|
if len(rtnElem.Children) == 1 {
|
|
return &rtnElem.Children[0]
|
|
}
|
|
return rtnElem
|
|
}
|
|
|
|
// returns value, isjson
|
|
func getAttrString(token htmltoken.Token, key string) string {
|
|
for _, attr := range token.Attr {
|
|
if attr.Key == key {
|
|
return attr.Val
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func attrToProp(attrVal string, isJson bool, params map[string]any) any {
|
|
if isJson {
|
|
var val any
|
|
err := json.Unmarshal([]byte(attrVal), &val)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
unmStrVal, ok := val.(string)
|
|
if !ok {
|
|
return val
|
|
}
|
|
attrVal = unmStrVal
|
|
// fallthrough using the json str val
|
|
}
|
|
if strings.HasPrefix(attrVal, Html_ParamPrefix) {
|
|
bindKey := attrVal[len(Html_ParamPrefix):]
|
|
bindVal, ok := params[bindKey]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return bindVal
|
|
}
|
|
if strings.HasPrefix(attrVal, Html_BindPrefix) {
|
|
bindKey := attrVal[len(Html_BindPrefix):]
|
|
if bindKey == "" {
|
|
return nil
|
|
}
|
|
return &VDomBinding{Type: ObjectType_Binding, Bind: bindKey}
|
|
}
|
|
if strings.HasPrefix(attrVal, Html_GlobalEventPrefix) {
|
|
splitArr := strings.Split(attrVal, ":")
|
|
if len(splitArr) < 2 {
|
|
return nil
|
|
}
|
|
eventName := splitArr[1]
|
|
if eventName == "" {
|
|
return nil
|
|
}
|
|
return &VDomFunc{Type: ObjectType_Func, GlobalEvent: eventName}
|
|
}
|
|
return attrVal
|
|
}
|
|
|
|
func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem {
|
|
elem := &VDomElem{Tag: token.Data}
|
|
if len(token.Attr) > 0 {
|
|
elem.Props = make(map[string]any)
|
|
}
|
|
for _, attr := range token.Attr {
|
|
if attr.Key == "" || attr.Val == "" {
|
|
continue
|
|
}
|
|
propVal := attrToProp(attr.Val, attr.IsJson, params)
|
|
elem.Props[attr.Key] = propVal
|
|
}
|
|
return elem
|
|
}
|
|
|
|
func isWsChar(char rune) bool {
|
|
return char == ' ' || char == '\t' || char == '\n' || char == '\r'
|
|
}
|
|
|
|
func isWsByte(char byte) bool {
|
|
return char == ' ' || char == '\t' || char == '\n' || char == '\r'
|
|
}
|
|
|
|
func isFirstCharLt(s string) bool {
|
|
for _, char := range s {
|
|
if isWsChar(char) {
|
|
continue
|
|
}
|
|
return char == '<'
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isLastCharGt(s string) bool {
|
|
for i := len(s) - 1; i >= 0; i-- {
|
|
char := s[i]
|
|
if isWsByte(char) {
|
|
continue
|
|
}
|
|
return char == '>'
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isAllWhitespace(s string) bool {
|
|
for _, char := range s {
|
|
if !isWsChar(char) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func trimWhitespaceConditionally(s string) string {
|
|
// Trim leading whitespace if the first non-whitespace character is '<'
|
|
if isAllWhitespace(s) {
|
|
return ""
|
|
}
|
|
if isFirstCharLt(s) {
|
|
s = strings.TrimLeftFunc(s, func(r rune) bool {
|
|
return isWsChar(r)
|
|
})
|
|
}
|
|
// Trim trailing whitespace if the last non-whitespace character is '>'
|
|
if isLastCharGt(s) {
|
|
s = strings.TrimRightFunc(s, func(r rune) bool {
|
|
return isWsChar(r)
|
|
})
|
|
}
|
|
return s
|
|
}
|
|
|
|
func processWhitespace(htmlStr string) string {
|
|
lines := strings.Split(htmlStr, "\n")
|
|
var newLines []string
|
|
for _, line := range lines {
|
|
trimmedLine := trimWhitespaceConditionally(line + "\n")
|
|
if trimmedLine == "" {
|
|
continue
|
|
}
|
|
newLines = append(newLines, trimmedLine)
|
|
}
|
|
return strings.Join(newLines, "")
|
|
}
|
|
|
|
func processTextStr(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if isAllWhitespace(s) {
|
|
return " "
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
func makePathStr(elemPath []string) string {
|
|
return strings.Join(elemPath, " ")
|
|
}
|
|
|
|
func capitalizeAscii(s string) string {
|
|
if s == "" || s[0] < 'a' || s[0] > 'z' {
|
|
return s
|
|
}
|
|
return strings.ToUpper(s[:1]) + s[1:]
|
|
}
|
|
|
|
func toReactName(input string) string {
|
|
// Check for CSS custom properties (variables) which start with '--'
|
|
if strings.HasPrefix(input, "--") {
|
|
return input
|
|
}
|
|
parts := strings.Split(input, "-")
|
|
result := ""
|
|
index := 0
|
|
if parts[0] == "" && len(parts) > 1 {
|
|
// handle vendor prefixes
|
|
prefix := parts[1]
|
|
if prefix == "ms" {
|
|
result += "ms"
|
|
} else {
|
|
result += capitalizeAscii(prefix)
|
|
}
|
|
index = 2 // Skip the empty string and prefix
|
|
} else {
|
|
result += parts[0]
|
|
index = 1
|
|
}
|
|
// Convert remaining parts to CamelCase
|
|
for ; index < len(parts); index++ {
|
|
if parts[index] != "" {
|
|
result += capitalizeAscii(parts[index])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func convertStyleToReactStyles(styleMap map[string]string, params map[string]any) map[string]any {
|
|
if len(styleMap) == 0 {
|
|
return nil
|
|
}
|
|
rtn := make(map[string]any)
|
|
for key, val := range styleMap {
|
|
rtn[toReactName(key)] = attrToProp(val, false, params)
|
|
}
|
|
return rtn
|
|
}
|
|
|
|
func styleAttrStrToStyleMap(styleText string, params map[string]any) (map[string]any, error) {
|
|
parser := cssparser.MakeParser(styleText)
|
|
m, err := parser.Parse()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return convertStyleToReactStyles(m, params), nil
|
|
}
|
|
|
|
func fixStyleAttribute(elem *VDomElem, params map[string]any, elemPath []string) error {
|
|
styleText, ok := elem.Props["style"].(string)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
styleMap, err := styleAttrStrToStyleMap(styleText, params)
|
|
if err != nil {
|
|
return fmt.Errorf("%v (at %s)", err, makePathStr(elemPath))
|
|
}
|
|
elem.Props["style"] = styleMap
|
|
return nil
|
|
}
|
|
|
|
func fixupStyleAttributes(elem *VDomElem, params map[string]any, elemPath []string) {
|
|
if elem == nil {
|
|
return
|
|
}
|
|
// call fixStyleAttribute, and walk children
|
|
elemCountMap := make(map[string]int)
|
|
if len(elemPath) == 0 {
|
|
elemPath = append(elemPath, elem.Tag)
|
|
}
|
|
fixStyleAttribute(elem, params, elemPath)
|
|
for i := range elem.Children {
|
|
child := &elem.Children[i]
|
|
elemCountMap[child.Tag]++
|
|
subPath := child.Tag
|
|
if elemCountMap[child.Tag] > 1 {
|
|
subPath = fmt.Sprintf("%s[%d]", child.Tag, elemCountMap[child.Tag])
|
|
}
|
|
elemPath = append(elemPath, subPath)
|
|
fixupStyleAttributes(&elem.Children[i], params, elemPath)
|
|
elemPath = elemPath[:len(elemPath)-1]
|
|
}
|
|
}
|
|
|
|
func Bind(htmlStr string, params map[string]any) *VDomElem {
|
|
htmlStr = processWhitespace(htmlStr)
|
|
r := strings.NewReader(htmlStr)
|
|
iter := htmltoken.NewTokenizer(r)
|
|
var elemStack []*VDomElem
|
|
elemStack = append(elemStack, &VDomElem{Tag: FragmentTag})
|
|
var tokenErr error
|
|
outer:
|
|
for {
|
|
tokenType := iter.Next()
|
|
token := iter.Token()
|
|
switch tokenType {
|
|
case htmltoken.StartTagToken:
|
|
if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {
|
|
tokenErr = errors.New("bind tags must be self closing")
|
|
break outer
|
|
}
|
|
elem := tokenToElem(token, params)
|
|
elemStack = pushElemStack(elemStack, elem)
|
|
case htmltoken.EndTagToken:
|
|
if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {
|
|
tokenErr = errors.New("bind tags must be self closing")
|
|
break outer
|
|
}
|
|
if len(elemStack) <= 1 {
|
|
tokenErr = fmt.Errorf("end tag %q without start tag", token.Data)
|
|
break outer
|
|
}
|
|
if curElemTag(elemStack) != token.Data {
|
|
tokenErr = fmt.Errorf("end tag %q does not match start tag %q", token.Data, curElemTag(elemStack))
|
|
break outer
|
|
}
|
|
elemStack = popElemStack(elemStack)
|
|
case htmltoken.SelfClosingTagToken:
|
|
if token.Data == Html_BindParamTagName {
|
|
keyAttr := getAttrString(token, "key")
|
|
dataVal := params[keyAttr]
|
|
elemList := partToElems(dataVal)
|
|
for _, elem := range elemList {
|
|
appendChildToStack(elemStack, &elem)
|
|
}
|
|
continue
|
|
}
|
|
if token.Data == Html_BindTagName {
|
|
keyAttr := getAttrString(token, "key")
|
|
binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr}
|
|
appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}})
|
|
continue
|
|
}
|
|
elem := tokenToElem(token, params)
|
|
appendChildToStack(elemStack, elem)
|
|
case htmltoken.TextToken:
|
|
if token.Data == "" {
|
|
continue
|
|
}
|
|
textStr := processTextStr(token.Data)
|
|
if textStr == "" {
|
|
continue
|
|
}
|
|
elem := TextElem(textStr)
|
|
appendChildToStack(elemStack, &elem)
|
|
case htmltoken.CommentToken:
|
|
continue
|
|
case htmltoken.DoctypeToken:
|
|
tokenErr = errors.New("doctype not supported")
|
|
break outer
|
|
case htmltoken.ErrorToken:
|
|
if iter.Err() == io.EOF {
|
|
break outer
|
|
}
|
|
tokenErr = iter.Err()
|
|
break outer
|
|
}
|
|
}
|
|
if tokenErr != nil {
|
|
errTextElem := TextElem(tokenErr.Error())
|
|
appendChildToStack(elemStack, &errTextElem)
|
|
}
|
|
rtn := finalizeStack(elemStack)
|
|
fixupStyleAttributes(rtn, params, nil)
|
|
return rtn
|
|
}
|