From c79cba2c917153199d99837a996c24c58e2a2b7a Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:20:30 +0200 Subject: [PATCH 01/10] feat: continued with web components --- .../skin/colibris/src/components/popup.css | 15 + assets/pad/pad.templ | 57 +- assets/pad/pad_templ.go | 775 ++-- pnpm-lock.yaml | 67 +- pnpm-workspace.yaml | 2 +- ui/src/js/ace.ts | 454 +-- ui/src/js/ace2_inner.ts | 3498 ----------------- ui/src/js/changesettracker.ts | 205 - ui/src/js/chat.ts | 31 +- ui/src/js/components/EpColorPicker.ts | 316 -- ui/src/js/components/EpDropdown.ts | 511 --- ui/src/js/components/EpModal.ts | 514 --- ui/src/js/components/EpNotification.ts | 323 -- ui/src/js/components/EpToast.ts | 349 -- ui/src/js/components/EpToolbarSelect.d.ts | 9 - ui/src/js/components/EpToolbarSelect.ts | 179 - ui/src/js/components/index.ts | 29 +- ui/src/js/core/ComponentBridge.ts | 4 +- ui/src/js/core/EventBus.ts | 10 +- ui/src/js/notifications.ts | 6 +- ui/src/js/pad.ts | 19 +- ui/src/js/pad_editor.ts | 70 +- ui/src/js/pad_userlist.ts | 46 +- ui/src/js/pad_utils.ts | 38 +- 24 files changed, 769 insertions(+), 6758 deletions(-) delete mode 100644 ui/src/js/ace2_inner.ts delete mode 100644 ui/src/js/changesettracker.ts delete mode 100644 ui/src/js/components/EpColorPicker.ts delete mode 100644 ui/src/js/components/EpDropdown.ts delete mode 100644 ui/src/js/components/EpModal.ts delete mode 100644 ui/src/js/components/EpNotification.ts delete mode 100644 ui/src/js/components/EpToast.ts delete mode 100644 ui/src/js/components/EpToolbarSelect.d.ts delete mode 100644 ui/src/js/components/EpToolbarSelect.ts diff --git a/assets/css/skin/colibris/src/components/popup.css b/assets/css/skin/colibris/src/components/popup.css index 07d85540..d7584cd2 100644 --- a/assets/css/skin/colibris/src/components/popup.css +++ b/assets/css/skin/colibris/src/components/popup.css @@ -49,6 +49,21 @@ .popup .dropdowns-container .nice-select { min-width: 180px; } +.popup .dropdowns-container .dropdown-select-trigger { + min-width: 180px; + padding: 6px 12px; + border: 1px solid var(--middle-color, #d2d2d2); + border-radius: 4px; + background: var(--bg-color, white); + color: var(--text-color, #485365); + font: inherit; + font-size: 14px; + text-align: left; + cursor: pointer; +} +.popup .dropdowns-container .dropdown-select-trigger:hover { + border-color: var(--dark-color, #576273); +} #delete-pad { background-color: #ff7b72; diff --git a/assets/pad/pad.templ b/assets/pad/pad.templ index ec7a2b76..05e125c9 100644 --- a/assets/pad/pad.templ +++ b/assets/pad/pad.templ @@ -96,44 +96,45 @@ templ SettingsPopup(translations map[string]string, availablefonts []string, set

{translations["pad.settings.padSettings"]}

{translations["pad.settings.myView"]}

- - +

- - +

- - +

- - +

- - +

@@ -152,8 +153,7 @@ templ SettingsPopup(translations map[string]string, availablefonts []string, set for _, group := range settingsGroups { for _, item := range group.Items {

- - +

} } @@ -168,8 +168,7 @@ templ EmbedPopup(translations map[string]string) { ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, " Etherpad") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1751,64 +1764,64 @@ func EmbedPopup(translations map[string]string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var124 := templ.GetChildren(ctx) - if templ_7745c5c3_Var124 == nil { - templ_7745c5c3_Var124 = templ.NopComponent + templ_7745c5c3_Var125 := templ.GetChildren(ctx) + if templ_7745c5c3_Var125 == nil { + templ_7745c5c3_Var125 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var125 string - templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.share"]) + var templ_7745c5c3_Var126 string + templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.share"]) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 169, Col: 44} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "\">

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var127 string - templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.share.link"]) + var templ_7745c5c3_Var128 string + templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.share.link"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 175, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 174, Col: 53} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var128 string - templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.share.emebdcode"]) + var templ_7745c5c3_Var129 string + templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.share.emebdcode"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 179, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 178, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var129)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1832,51 +1845,51 @@ func QrPopup(translations map[string]string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var129 := templ.GetChildren(ctx) - if templ_7745c5c3_Var129 == nil { - templ_7745c5c3_Var129 = templ.NopComponent + templ_7745c5c3_Var130 := templ.GetChildren(ctx) + if templ_7745c5c3_Var130 == nil { + templ_7745c5c3_Var130 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var130 string - templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.share"]) + var templ_7745c5c3_Var131 string + templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.share"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 189, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 188, Col: 48} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var130)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "

\"QR
\"QR
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1900,155 +1913,155 @@ func ImportExportPopup(translations map[string]string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var133 := templ.GetChildren(ctx) - if templ_7745c5c3_Var133 == nil { - templ_7745c5c3_Var133 = templ.NopComponent + templ_7745c5c3_Var134 := templ.GetChildren(ctx) + if templ_7745c5c3_Var134 == nil { + templ_7745c5c3_Var134 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var134 string - templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.import_export"]) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 206, Col: 65} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var135 string - templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.import"]) + templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.import_export"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 208, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 204, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var136 string - templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.abiword.innerHTML"]) + templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.import"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 209, Col: 122} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 206, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "


") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var137 string - templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.importSuccessful"]) + templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.abiword.innerHTML"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 216, Col: 125} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 207, Col: 122} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "


") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var138 string - templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.export"]) + templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.importSuccessful"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 226, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 214, Col: 125} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var139 string - templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.exportetherpad"]) + templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.export"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 228, Col: 151} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 224, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var140 string - templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.exporthtml"]) + templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.importExport.exportetherpad"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 231, Col: 137} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 226, Col: 151} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, " Markdown
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, " Markdown") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2072,519 +2085,519 @@ func ConnectivityPopup(translations map[string]string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var145 := templ.GetChildren(ctx) - if templ_7745c5c3_Var145 == nil { - templ_7745c5c3_Var145 = templ.NopComponent + templ_7745c5c3_Var146 := templ.GetChildren(ctx) + if templ_7745c5c3_Var146 == nil { + templ_7745c5c3_Var146 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var146 string - templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.connected"]) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 256, Col: 57} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var147 string - templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.reconnecting"]) + templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.connected"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 259, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 254, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "


") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var148 string - templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.userdup"]) + templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.reconnecting"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 265, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 257, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var148)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "


") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var149 string - templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.userdup.explanation"]) + templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.userdup"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 266, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 263, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var149)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var150 string - templ_7745c5c3_Var150, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.userdup.advice"]) + templ_7745c5c3_Var150, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.userdup.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 267, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 264, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var150)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var151 string - templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.forcereconnect"]) + templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.userdup.advice"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 268, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 265, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var153 string - templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.unauth.explanation"]) + templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.unauth"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 272, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 269, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var154 string - templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.forcereconnect"]) + templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.unauth.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 273, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 270, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var156 string - templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.looping.explanation"]) + templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 277, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 274, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var157 string - templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.looping.cause"]) + templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.looping.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 278, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 275, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var157)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var158 string - templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.initsocketfail"]) + templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.looping.cause"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 281, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 276, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var159 string - templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.initsocketfail.explanation"]) + templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.initsocketfail"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 282, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 279, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var160 string - templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.initsocketfail.cause"]) + templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.initsocketfail.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 283, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 280, Col: 74} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var161 string - templ_7745c5c3_Var161, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected"]) + templ_7745c5c3_Var161, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.initsocketfail.cause"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 286, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 281, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var161)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var162 string - templ_7745c5c3_Var162, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.slowcommit.explanation"]) + templ_7745c5c3_Var162, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 287, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 284, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var162)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var163 string - templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.slowcommit.cause"]) + templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.slowcommit.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 288, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 285, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var163)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var164 string - templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.forcereconnect"]) + templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.slowcommit.cause"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 289, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 286, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var164)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var166 string - templ_7745c5c3_Var166, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.badChangeset.explanation"]) + templ_7745c5c3_Var166, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 293, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 290, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var166)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var167 string - templ_7745c5c3_Var167, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.badChangeset.cause"]) + templ_7745c5c3_Var167, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.badChangeset.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 294, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 291, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var167)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var168 string - templ_7745c5c3_Var168, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.forcereconnect"]) + templ_7745c5c3_Var168, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.badChangeset.cause"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 295, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 292, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var168)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var170 string - templ_7745c5c3_Var170, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.corruptPad.explanation"]) + templ_7745c5c3_Var170, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 299, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 296, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var170)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var171 string - templ_7745c5c3_Var171, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.corruptPad.cause"]) + templ_7745c5c3_Var171, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.corruptPad.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 300, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 297, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var171)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var172 string - templ_7745c5c3_Var172, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.deleted"]) + templ_7745c5c3_Var172, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.corruptPad.cause"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 303, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 298, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var172)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var173 string - templ_7745c5c3_Var173, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.deleted.explanation"]) + templ_7745c5c3_Var173, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.deleted"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 304, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 301, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var173)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var174 string - templ_7745c5c3_Var174, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.kicked"]) + templ_7745c5c3_Var174, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.deleted.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 307, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 302, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var174)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var175 string - templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.kicked.explanation"]) + templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.kicked"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 308, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 305, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var175)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var176 string - templ_7745c5c3_Var176, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.rateLimited"]) + templ_7745c5c3_Var176, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.kicked.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 311, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 306, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var176)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var177 string - templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.rateLimited.explanation"]) + templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.rateLimited"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 312, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 309, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var177)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var178 string - templ_7745c5c3_Var178, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected"]) + templ_7745c5c3_Var178, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.rateLimited.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 315, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 310, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var178)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var179 string - templ_7745c5c3_Var179, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.rejected.explanation"]) + templ_7745c5c3_Var179, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 316, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 313, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var179)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var180 string - templ_7745c5c3_Var180, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.rejected.cause"]) + templ_7745c5c3_Var180, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.rejected.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 317, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 314, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var180)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var181 string - templ_7745c5c3_Var181, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected"]) + templ_7745c5c3_Var181, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.rejected.cause"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 320, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 315, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var181)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var182 string - templ_7745c5c3_Var182, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected.explanation"]) + templ_7745c5c3_Var182, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 321, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 318, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var182)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var183 string - templ_7745c5c3_Var183, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected.cause"]) + templ_7745c5c3_Var183, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected.explanation"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 322, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 319, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var183)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var184 string - templ_7745c5c3_Var184, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.forcereconnect"]) + templ_7745c5c3_Var184, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.modals.disconnected.cause"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 323, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 320, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var184)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2608,51 +2621,51 @@ func UserPopup(translations map[string]string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var185 := templ.GetChildren(ctx) - if templ_7745c5c3_Var185 == nil { - templ_7745c5c3_Var185 = templ.NopComponent + templ_7745c5c3_Var186 := templ.GetChildren(ctx) + if templ_7745c5c3_Var186 == nil { + templ_7745c5c3_Var186 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2676,38 +2689,38 @@ func ChatPopup(translations map[string]string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var189 := templ.GetChildren(ctx) - if templ_7745c5c3_Var189 == nil { - templ_7745c5c3_Var189 = templ.NopComponent + templ_7745c5c3_Var190 := templ.GetChildren(ctx) + if templ_7745c5c3_Var190 == nil { + templ_7745c5c3_Var190 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var191 string - templ_7745c5c3_Var191, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.chat"]) + var templ_7745c5c3_Var192 string + templ_7745c5c3_Var192, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.chat"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 364, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 362, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var191)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var192)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, " 0
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, " 0") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2731,51 +2744,51 @@ func ChatBox(translations map[string]string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var192 := templ.GetChildren(ctx) - if templ_7745c5c3_Var192 == nil { - templ_7745c5c3_Var192 = templ.NopComponent + templ_7745c5c3_Var193 := templ.GetChildren(ctx) + if templ_7745c5c3_Var193 == nil { + templ_7745c5c3_Var193 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var193 string - templ_7745c5c3_Var193, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.chat"]) + var templ_7745c5c3_Var194 string + templ_7745c5c3_Var194, templ_7745c5c3_Err = templ.JoinStringErrs(translations["pad.chat"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 376, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 374, Col: 59} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var193)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var194)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "

█  

█  
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2800,51 +2813,51 @@ func PadIndex(pad padModel.Model, jsScript string, translations map[string]strin }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var196 := templ.GetChildren(ctx) - if templ_7745c5c3_Var196 == nil { - templ_7745c5c3_Var196 = templ.NopComponent + templ_7745c5c3_Var197 := templ.GetChildren(ctx) + if templ_7745c5c3_Var197 == nil { + templ_7745c5c3_Var197 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "<html class=\"pad super-light-toolbar super-light-editor light-background\"><head><title>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var197 string - templ_7745c5c3_Var197, templ_7745c5c3_Err = templ.JoinStringErrs(settings.Title) + var templ_7745c5c3_Var198 string + templ_7745c5c3_Var198, templ_7745c5c3_Err = templ.JoinStringErrs(settings.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 399, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `assets/pad/pad.templ`, Line: 397, Col: 26} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var197)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var198)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "\" rel=\"stylesheet\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2852,7 +2865,7 @@ func PadIndex(pad padModel.Model, jsScript string, translations map[string]strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2880,7 +2893,7 @@ func PadIndex(pad padModel.Model, jsScript string, translations map[string]strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2896,7 +2909,7 @@ func PadIndex(pad padModel.Model, jsScript string, translations map[string]strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e279ea0a..f71633ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,15 +5,15 @@ settings: excludeLinksFromLockfile: false overrides: - etherpad-webcomponents: 0.0.4 + etherpad-webcomponents: link:C:/Users/samue/WebstormProjects/webcomponents importers: .: dependencies: etherpad-webcomponents: - specifier: 0.0.4 - version: 0.0.4(@lit/reactive-element@2.1.2)(lit-element@4.2.2)(lit-html@3.3.2)(lit@3.3.2) + specifier: link:C:/Users/samue/WebstormProjects/webcomponents + version: link:../../WebstormProjects/webcomponents devDependencies: typescript: specifier: ^5.6.3 @@ -119,8 +119,8 @@ importers: specifier: 0.28.0 version: 0.28.0 etherpad-webcomponents: - specifier: 0.0.4 - version: 0.0.4(@lit/reactive-element@2.1.2)(lit-element@4.2.2)(lit-html@3.3.2)(lit@3.3.2) + specifier: link:C:/Users/samue/WebstormProjects/webcomponents + version: link:../../../WebstormProjects/webcomponents typescript: specifier: ^6.0.2 version: 6.0.2 @@ -311,12 +311,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lit-labs/ssr-dom-shim@1.5.1': - resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} - - '@lit/reactive-element@2.1.2': - resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} - '@napi-rs/wasm-runtime@1.1.2': resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} peerDependencies: @@ -780,9 +774,6 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - '@types/trusted-types@2.0.7': - resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -853,14 +844,6 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - etherpad-webcomponents@0.0.4: - resolution: {integrity: sha512-fvOJChHwmleqhkoTlxGx0/2BvoYUiWxIgA0APrYORHSR6ovgpA5VVLN5J+8YTO+FPGQQOVBgZVdZE69fydnlZg==} - peerDependencies: - '@lit/reactive-element': ^2.1.2 - lit: ^3.3.2 - lit-element: ^4.2.2 - lit-html: ^3.3.2 - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -963,15 +946,6 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - lit-element@4.2.2: - resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} - - lit-html@3.3.2: - resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==} - - lit@3.3.2: - resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} - lucide-react@1.7.0: resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==} peerDependencies: @@ -1307,12 +1281,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lit-labs/ssr-dom-shim@1.5.1': {} - - '@lit/reactive-element@2.1.2': - dependencies: - '@lit-labs/ssr-dom-shim': 1.5.1 - '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 @@ -1626,8 +1594,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/trusted-types@2.0.7': {} - '@vitejs/plugin-react@6.0.1(vite@8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -1704,13 +1670,6 @@ snapshots: estree-walker@2.0.2: {} - etherpad-webcomponents@0.0.4(@lit/reactive-element@2.1.2)(lit-element@4.2.2)(lit-html@3.3.2)(lit@3.3.2): - dependencies: - '@lit/reactive-element': 2.1.2 - lit: 3.3.2 - lit-element: 4.2.2 - lit-html: 3.3.2 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -1784,22 +1743,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - lit-element@4.2.2: - dependencies: - '@lit-labs/ssr-dom-shim': 1.5.1 - '@lit/reactive-element': 2.1.2 - lit-html: 3.3.2 - - lit-html@3.3.2: - dependencies: - '@types/trusted-types': 2.0.7 - - lit@3.3.2: - dependencies: - '@lit/reactive-element': 2.1.2 - lit-element: 4.2.2 - lit-html: 3.3.2 - lucide-react@1.7.0(react@19.2.4): dependencies: react: 19.2.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 08d0f566..69403607 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,4 +5,4 @@ packages: - plugins/* overrides: - etherpad-webcomponents: 0.0.4 + etherpad-webcomponents: link:C:/Users/samue/WebstormProjects/webcomponents diff --git a/ui/src/js/ace.ts b/ui/src/js/ace.ts index 6c1a957b..2156207a 100644 --- a/ui/src/js/ace.ts +++ b/ui/src/js/ace.ts @@ -1,12 +1,14 @@ -// @ts-nocheck -/** - * This code is mostly from the old Etherpad. Please help us to comment this code. - * This helps other people to understand this code better and helps them to improve it. - * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED - */ - /** + * Ace2Editor — Wrapper around the WebComponent-based AceEditor from etherpad-webcomponents. + * + * Replaces the old iframe-based editor (ace2_inner) with a direct contenteditable div. + * Maintains the same public API so collab_client, pad_editor, and plugins work unchanged. + * + * The key pattern: a shared `info` object holds `ace_*` prefixed methods that plugins + * and callWithAce callbacks use. This mirrors the original ace2_inner architecture. + * * Copyright 2009 Google Inc. + * Copyright 2025 - Adapted for WebComponent-based editor. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,76 +23,21 @@ * limitations under the License. */ -// requires: top -// requires: undefined - +import {AceEditor} from 'etherpad-webcomponents'; import {editorBus} from './core/EventBus'; -import {makeCSSManager} from './cssmanager'; import * as pluginUtils from './pluginfw/shared'; -const debugLog = (...args) => {}; -// The inner and outer iframe's locations are about:blank, so relative URLs are relative to that. -// Firefox and Chrome seem to do what the developer intends if given a relative URL, but Safari -// errors out unless given an absolute URL for a JavaScript-created element. -const absUrl = (url) => new URL(url, window.location.href).href; - -const eventFired = async (obj, event, cleanups = [], predicate = () => true) => { - if (typeof cleanups === 'function') { - predicate = cleanups; - cleanups = []; - } - await new Promise((resolve, reject) => { - let cleanup; - const successCb = () => { - if (!predicate()) return; - debugLog(`Ace2Editor.init() ${event} event on`, obj); - cleanup(); - resolve(); - }; - const errorCb = () => { - const err = new Error(`Ace2Editor.init() error event while waiting for ${event} event`); - debugLog(`${err} on object`, obj); - cleanup(); - reject(err); - }; - cleanup = () => { - cleanup = () => {}; - obj.removeEventListener(event, successCb); - obj.removeEventListener('error', errorCb); - }; - cleanups.push(cleanup); - obj.addEventListener(event, successCb); - obj.addEventListener('error', errorCb); - }); -}; -// Resolves when the frame's document is ready to be mutated. Browsers seem to be quirky about -// iframe ready events so this function throws the kitchen sink at the problem. Maybe one day we'll -// find a concise general solution. -const frameReady = async (frame) => { - // Can't do `const doc = frame.contentDocument;` because Firefox seems to asynchronously replace - // the document object after the frame is first created for some reason. ¯\_(ツ)_/¯ - const doc = () => frame.contentDocument; - const cleanups = []; - try { - await Promise.race([ - eventFired(frame, 'load', cleanups), - eventFired(frame.contentWindow, 'load', cleanups), - eventFired(doc(), 'load', cleanups), - eventFired(doc(), 'DOMContentLoaded', cleanups), - eventFired(doc(), 'readystatechange', cleanups, () => doc.readyState === 'complete'), - ]); - } finally { - for (const cleanup of cleanups) cleanup(); - } -}; - -export const Ace2Editor = function () { - let info = {editor: this}; +export const Ace2Editor = function (this: any) { + let editor: AceEditor | null = null; let loaded = false; - let actionsPendingInit = []; + // Shared info object — plugins and callWithAce callbacks access ace_* methods on this. + // This replicates the original editorInfo pattern from ace2_inner. + const info: Record = {editor: this}; + + let actionsPendingInit: Array<() => void> = []; - const pendingInit = (func) => function (...args) { + const pendingInit = (func: (...args: any[]) => any) => function (this: any, ...args: any[]) { const action = () => func.apply(this, args); if (loaded) return action(); actionsPendingInit.push(action); @@ -101,7 +48,120 @@ export const Ace2Editor = function () { actionsPendingInit = []; }; - // The following functions (prefixed by 'ace_') are exposed by editor, but + /** + * Populates the info object with ace_* methods that delegate to the AceEditor. + * Called once after editor.init() completes. + */ + const populateInfo = () => { + const e = editor!; + + // --- Core --- + info.ace_getRep = () => e.rep; + info.ace_getAuthor = () => (e as any).thisAuthor; + info.ace_focus = () => e.focus(); + info.ace_setEditable = (val: boolean) => e.setEditable(val); + info.ace_getDocument = () => document; + info.ace_dispose = () => e.dispose(); + + // --- Text import/export --- + info.ace_importText = (text: string) => e.setText(text); + info.ace_importAText = (atext: any, apoolJsonObj: any) => e.setAttributedText(atext, apoolJsonObj); + info.ace_exportText = () => e.exportText(); + + // --- Properties --- + info.ace_setProperty = (key: string, value: any) => e.setProperty(key, value); + + // --- Formatting --- + info.ace_toggleAttributeOnSelection = (name: string) => e.toggleAttribute(name); + info.ace_setAttributeOnSelection = (name: string, value: any) => { + (e as any).setAttributeOnSelection(name, value); + }; + info.ace_getAttributeOnSelection = (name: string) => e.getAttribute(name); + + // --- Lists --- + // Use private methods directly because callWithAce wraps in inCallStack already + info.ace_doInsertUnorderedList = () => (e as any).doInsertUnorderedList(); + info.ace_doInsertOrderedList = () => (e as any).doInsertOrderedList(); + // doIndentOutdent returns boolean (used by pad_editbar), so call private method + info.ace_doIndentOutdent = (isOut: boolean) => (e as any).doIndentOutdent(isOut); + + // --- Undo/Redo --- + info.ace_doUndoRedo = (type: string) => { + if (type === 'undo') e.undo(); + else if (type === 'redo') e.redo(); + }; + + // --- Selection --- + info.ace_isCaret = () => e.isCaret(); + info.ace_caretLine = () => e.getCaretLine(); + info.ace_caretColumn = () => e.getCaretColumn(); + info.ace_setSelection = (selection: any) => { + (e as any).performSelectionChange?.(selection); + }; + + // --- Document operations --- + info.ace_performDocumentApplyAttributesToCharRange = (start: number, end: number, attribs: any[]) => { + (e as any).performDocumentApplyAttributesToCharRange?.(start, end, attribs); + }; + info.ace_performDocumentApplyAttributesToRange = (start: any, end: any, attribs: any[]) => { + if ((e as any).documentAttributeManager) { + (e as any).documentAttributeManager.setAttributesOnRange(start, end, attribs); + } + }; + info.ace_setAttributeOnLine = (lineNum: number, attrName: string, attrValue: any) => { + if ((e as any).documentAttributeManager) { + (e as any).documentAttributeManager.setAttributeOnLine(lineNum, attrName, attrValue); + } + }; + info.ace_removeAttributeOnLine = (lineNum: number, attrName: string) => { + if ((e as any).documentAttributeManager) { + (e as any).documentAttributeManager.removeAttributeOnLine(lineNum, attrName); + } + }; + + // --- Internal access (used by plugins) --- + info.ace_fastIncorp = (n: number) => (e as any).fastIncorp(n); + info.ace_inCallStack = (type: string, fn: () => any) => (e as any).inCallStack(type, fn); + info.ace_inCallStackIfNecessary = (type: string, fn: () => any) => (e as any).inCallStackIfNecessary(type, fn); + info.ace_getInInternationalComposition = () => e.getInInternationalComposition(); + info.ace_replaceRange = (start: any, end: any, text: string) => e.replaceRange(start, end, text); + info.ace_execCommand = (cmd: string, ...args: any[]) => e.execCommand(cmd, ...args); + + // --- Author --- + info.ace_setAuthorInfo = (author: string, i: any) => e.setAuthorInfo(author, i); + info.ace_getAuthorInfos = () => (e as any).authorInfos; + + // --- Key handlers --- + info.ace_setOnKeyPress = (handler: any) => e.setOnKeyPress(handler); + info.ace_setOnKeyDown = (handler: any) => e.setOnKeyDown(handler); + info.ace_setNotifyDirty = (handler: any) => e.setNotifyDirty(handler); + + // --- Collaboration --- + info.ace_setBaseText = (txt: string) => e.setBaseText(txt); + info.ace_setBaseAttributedText = (atxt: any, apoolJsonObj: any) => e.setBaseAttributedText(atxt, apoolJsonObj); + info.ace_applyChangesToBase = (c: string, optAuthor?: string, apoolJsonObj?: any) => e.applyChangesToBase(c, optAuthor, apoolJsonObj); + info.ace_prepareUserChangeset = () => e.prepareUserChangeset(); + info.ace_applyPreparedChangesetToBase = () => e.applyPreparedChangesetToBase(); + info.ace_setUserChangeNotificationCallback = (f: () => void) => e.setUserChangeNotificationCallback(f); + + // --- callWithAce --- + info.ace_callWithAce = (fn: (aceInfo: any) => any, callStack?: string, normalize?: boolean) => { + let wrapper = () => fn(info); + if (normalize !== undefined) { + const inner = wrapper; + wrapper = () => { + info.ace_fastIncorp(9); + return inner(); + }; + } + if (callStack !== undefined) { + return info.ace_inCallStackIfNecessary(callStack, wrapper); + } + return wrapper(); + }; + }; + + // The following functions are exposed on Ace2Editor but // execution is delayed until init is complete const aceFunctionsPendingInit = [ 'importText', @@ -124,199 +184,101 @@ export const Ace2Editor = function () { ]; for (const fnName of aceFunctionsPendingInit) { - // Note: info[`ace_${fnName}`] does not exist yet, so it can't be passed directly to - // pendingInit(). A simple wrapper is used to defer the info[`ace_${fnName}`] lookup until - // method invocation. - this[fnName] = pendingInit(function (...args) { - info[`ace_${fnName}`].apply(this, args); + this[fnName] = pendingInit(function (...args: any[]) { + info[`ace_${fnName}`].apply(info, args); }); } + // Methods that return values immediately (or fallback if not loaded) this.exportText = () => loaded ? info.ace_exportText() : '(awaiting init)\n'; - - this.getInInternationalComposition = - () => loaded ? info.ace_getInInternationalComposition() : null; - - // prepareUserChangeset: - // Returns null if no new changes or ACE not ready. Otherwise, bundles up all user changes - // to the latest base text into a Changeset, which is returned (as a string if encodeAsString). - // If this method returns a truthy value, then applyPreparedChangesetToBase can be called at some - // later point to consider these changes part of the base, after which prepareUserChangeset must - // be called again before applyPreparedChangesetToBase. Multiple consecutive calls to - // prepareUserChangeset will return an updated changeset that takes into account the latest user - // changes, and modify the changeset to be applied by applyPreparedChangesetToBase accordingly. + this.getInInternationalComposition = () => loaded ? info.ace_getInInternationalComposition() : null; this.prepareUserChangeset = () => loaded ? info.ace_prepareUserChangeset() : null; - const addStyleTagsFor = (doc, files) => { - for (const file of files) { - const normalizedFile = file.startsWith('/static/plugins/') || - file.startsWith('/static/') || - file.startsWith('../') || - file.startsWith('./') || - file.startsWith('http://') || - file.startsWith('https://') ? file : - file.startsWith('/') ? `/static/plugins${file}` : `/static/plugins/${file}`; - const link = doc.createElement('link'); - link.rel = 'stylesheet'; - link.type = 'text/css'; - link.href = absUrl(encodeURI(normalizedFile)); - doc.head.appendChild(link); - } - }; - this.destroy = pendingInit(() => { info.ace_dispose(); - info.frame.parentNode.removeChild(info.frame); - info = null; // prevent IE 6 closure memory leaks + const container = document.getElementById('editorcontainer'); + if (container) container.innerHTML = ''; + editor = null; }); - this.init = async function (containerId, initialCode) { - debugLog('Ace2Editor.init()'); - this.importText(initialCode); + this.init = async function (containerId: string, initialCode: string) { + if (initialCode) { + this.importText(initialCode); + } - const includedCSS = [ - `../static/css/iframe_editor.css?v=${clientVars.randomVersionString}`, - `../css/static/pad.css?v=${clientVars.randomVersionString}`, - `../css/skin/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`, - ]; - editorBus.emit('custom:ace:editor:css', {result: includedCSS, css: includedCSS}); + const container = document.getElementById(containerId); + if (!container) throw new Error(`Container #${containerId} not found`); + + const skinVariants = (window as any).clientVars?.skinVariants?.split(' ').filter((x: string) => x !== '') ?? []; + + // iframe_editor.css is loaded statically in pad.templ (no dynamic loading needed). + + // Set up the editor container structure. + // Original structure: #editorcontainer > iframe(ace_outer) > body#outerdocbody > [sidediv, iframe(ace_inner) > body#innerdocbody] + // New structure: #editorcontainer > div#outerdocbody > [sidediv, div#innerdocbody] + container.innerHTML = ''; - const skinVariants = clientVars.skinVariants.split(' ').filter((x) => x !== ''); - - const outerFrame = document.createElement('iframe'); - outerFrame.name = 'ace_outer'; - outerFrame.frameBorder = 0; // for IE - outerFrame.title = 'Ether'; - // Some browsers do strange things unless the iframe has a src or srcdoc property: - // - Firefox replaces the frame's contentWindow.document object with a different object after - // the frame is created. This can be worked around by waiting for the window's load event - // before continuing. - // - Chrome never fires any events on the frame or document. Eventually the document's - // readyState becomes 'complete' even though it never fires a readystatechange event. - // - Safari behaves like Chrome. - // srcdoc is avoided because Firefox's Content Security Policy engine does not properly handle - // 'self' with nested srcdoc iframes: https://bugzilla.mozilla.org/show_bug.cgi?id=1721296 - outerFrame.src = '../static/empty.html'; - info.frame = outerFrame; - document.getElementById(containerId).appendChild(outerFrame); - const outerWindow = outerFrame.contentWindow; - - debugLog('Ace2Editor.init() waiting for outer frame'); - await frameReady(outerFrame); - debugLog('Ace2Editor.init() outer frame ready'); - - // Firefox might replace the outerWindow.document object after iframe creation so this variable - // is assigned after the Window's load event. - const outerDocument = outerWindow.document; - - // tag - outerDocument.documentElement.classList.add('outer-editor', 'outerdoc', ...skinVariants); - - // tag - addStyleTagsFor(outerDocument, includedCSS); - const outerStyle = outerDocument.createElement('style'); - outerStyle.type = 'text/css'; - outerStyle.title = 'dynamicsyntax'; - outerDocument.head.appendChild(outerStyle); - - // tag - outerDocument.body.id = 'outerdocbody'; - outerDocument.body.classList.add('outerdocbody', ...pluginUtils.clientPluginNames()); - const sideDiv = outerDocument.createElement('div'); + // Apply skin variants to html element. Do NOT add outer-editor/inner-editor classes here — + // those trigger "background-color: transparent !important" which was meant for iframes only. + document.documentElement.classList.add(...skinVariants); + + // Create the outerdocbody container (replaces the outer iframe's body) + const outerBody = document.createElement('div'); + outerBody.id = 'outerdocbody'; + outerBody.classList.add('outerdocbody', ...pluginUtils.clientPluginNames()); + container.appendChild(outerBody); + + // Create sidediv for line numbers + const sideDiv = document.createElement('div'); sideDiv.id = 'sidediv'; sideDiv.classList.add('sidediv'); - outerDocument.body.appendChild(sideDiv); - const sideDivInner = outerDocument.createElement('div'); + const sideDivInner = document.createElement('div'); sideDivInner.id = 'sidedivinner'; sideDivInner.classList.add('sidedivinner'); sideDiv.appendChild(sideDivInner); - const lineMetricsDiv = outerDocument.createElement('div'); - lineMetricsDiv.id = 'linemetricsdiv'; - lineMetricsDiv.appendChild(outerDocument.createTextNode('x')); - outerDocument.body.appendChild(lineMetricsDiv); - - const innerFrame = outerDocument.createElement('iframe'); - innerFrame.name = 'ace_inner'; - innerFrame.title = 'pad'; - innerFrame.scrolling = 'no'; - innerFrame.frameBorder = 0; - innerFrame.allowTransparency = true; // for IE - // The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above - // outerFrame.srcdoc. - innerFrame.src = 'empty.html'; - outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild); - const innerWindow = innerFrame.contentWindow; - - debugLog('Ace2Editor.init() waiting for inner frame'); - await frameReady(innerFrame); - debugLog('Ace2Editor.init() inner frame ready'); - - // Firefox might replace the innerWindow.document object after iframe creation so this variable - // is assigned after the Window's load event. - const innerDocument = innerWindow.document; - - // tag - innerDocument.documentElement.classList.add('inner-editor', ...skinVariants); - - // tag - addStyleTagsFor(innerDocument, includedCSS); - //const requireKernel = innerDocument.createElement('script'); - //requireKernel.type = 'text/javascript'; - //requireKernel.src = - // absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`); - //innerDocument.head.appendChild(requireKernel); - // Pre-fetch modules to improve load performance. - /*for (const module of ['ace2_inner', 'ace2_common']) { - const script = innerDocument.createElement('script'); - script.type = 'text/javascript'; - script.src = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` + - `?callback=require.define&v=${clientVars.randomVersionString}`); - innerDocument.head.appendChild(script); - }*/ - const innerStyle = innerDocument.createElement('style'); - innerStyle.type = 'text/css'; - innerStyle.title = 'dynamicsyntax'; - innerDocument.head.appendChild(innerStyle); - const headLines = []; + outerBody.appendChild(sideDiv); + + // Create the contenteditable editor body (replaces the inner iframe's body) + const editorBody = document.createElement('div'); + editorBody.id = 'innerdocbody'; + editorBody.classList.add('innerdocbody'); + editorBody.setAttribute('spellcheck', 'false'); + // flex: 1 replaces the iframe rule "#outerdocbody iframe { flex: 1 auto; width: 100% }" + editorBody.style.flex = '1 auto'; + editorBody.style.width = '100%'; + // Remove browser focus outline (was invisible when inside an iframe) + editorBody.style.outline = 'none'; + outerBody.appendChild(editorBody); + + // Load plugin CSS + const includedCSS: string[] = []; + editorBus.emit('custom:ace:editor:css', {result: includedCSS, css: includedCSS}); + + // Load custom head content from plugins + const headLines: string[] = []; editorBus.emit('custom:ace:init:innerdocbody:head', {iframeHTML: headLines}); - innerDocument.head.appendChild( - innerDocument.createRange().createContextualFragment(headLines.join('\n'))); - - // tag - innerDocument.body.id = 'innerdocbody'; - innerDocument.body.classList.add('innerdocbody'); - innerDocument.body.setAttribute('spellcheck', 'false'); - innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); //   -/* - debugLog('Ace2Editor.init() waiting for require kernel load'); - await eventFired(requireKernel, 'load'); - debugLog('Ace2Editor.init() require kernel loaded'); - const require = innerWindow.require; - require.setRootURI(absUrl('../javascripts/src')); - require.setLibraryURI(absUrl('../javascripts/lib')); - require.setGlobalKeyPath('require'); -*/ - // intentionally moved before requiring client_plugins to save a 307 - const [ace2Inner, clPlugins] = await Promise.all([ - import('./ace2_inner'), - import('./pluginfw/client_plugins'), - ]); - innerWindow.Ace2Inner = ace2Inner; - innerWindow.plugins = clPlugins; - - debugLog('Ace2Editor.init() waiting for plugins'); - /*await new Promise((resolve, reject) => innerWindow.plugins.ensure( - (err) => err != null ? reject(err) : resolve()));*/ - debugLog('Ace2Editor.init() waiting for Ace2Inner.init()'); - await innerWindow.Ace2Inner.init(info, { - inner: makeCSSManager(innerStyle.sheet), - outer: makeCSSManager(outerStyle.sheet), - parent: makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet), - }); - debugLog('Ace2Editor.init() Ace2Inner.init() returned'); + if (headLines.length > 0) { + document.head.appendChild( + document.createRange().createContextualFragment(headLines.join('\n'))); + } + + // Create and initialize the AceEditor + editor = new AceEditor(editorBody); + await editor.init(); + + // Populate the info object with ace_* methods + populateInfo(); + + // Mark container as initialized (removes visibility:hidden from CSS rule + // #editorcontainerbox #editorcontainer:not(.initialized)) + container.classList.add('initialized'); + + // Emit the initialized event with info as editorInfo. + // Plugins set custom ace_* methods on this object, and callWithAce passes it to callbacks. + // This replaces the emit that was in ace2_inner.ts in the original code. + editorBus.emit('editor:ace:initialized', {editorInfo: info}); + loaded = true; doActionsPendingInit(); - debugLog('Ace2Editor.init() done'); }; }; - diff --git a/ui/src/js/ace2_inner.ts b/ui/src/js/ace2_inner.ts deleted file mode 100644 index 0396eafa..00000000 --- a/ui/src/js/ace2_inner.ts +++ /dev/null @@ -1,3498 +0,0 @@ -// @ts-nocheck -import {Builder} from "./Builder"; - -/** - * Copyright 2009 Google Inc. - * Copyright 2020 John McLear - The Etherpad Foundation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS-IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -let documentAttributeManager; - -import AttributeMap from './AttributeMap'; -import {browserFlags as browser} from './browser_flags'; -import padutils from './pad_utils' -import * as Ace2Common from './ace2_common'; -import {characterRangeFollow, checkRep, cloneAText, compose, deserializeOps, filterAttribNumbers, inverse, isIdentity, makeAText, makeAttribution, mapAttribNumbers, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, opsFromAText, pack, splitAttributionLines} from './Changeset' -import {colorutils} from './colorutils'; -import {makeContentCollector} from './contentcollector'; -import {domline} from './domline'; -import {linestylefilter} from './linestylefilter'; -import {undoModule} from './undomodule'; -import {makeChangesetTracker} from './changesettracker'; -import AttributeManager from './AttributeManager'; - - -const isNodeText = Ace2Common.isNodeText; -const getAssoc = Ace2Common.getAssoc; -const setAssoc = Ace2Common.setAssoc; -const noop = Ace2Common.noop; -import {editorBus} from './core/EventBus'; -import SkipList from "./skiplist"; -import Scroll from './scroll' -import AttribPool from './AttributePool' -import {SmartOpAssembler} from "./SmartOpAssembler"; -import Op from "./Op"; -import {buildKeepRange, buildKeepToStartOfRange, buildRemoveRange} from './ChangesetUtils' -import notifications from './notifications'; - -function Ace2Inner(editorInfo, cssManagers) { - const DEBUG = false; - - const THE_TAB = ' '; // 4 - const MAX_LIST_LEVEL = 16; - - const FORMATTING_STYLES = ['bold', 'italic', 'underline', 'strikethrough']; - const SELECT_BUTTON_CLASS = 'selected'; - - let thisAuthor = ''; - - let disposed = false; - const outerWin = document.getElementsByName("ace_outer")[0] - const targetDoc = outerWin.contentWindow.document.getElementsByName("ace_inner")[0].contentWindow.document - const targetBody = targetDoc.body - - const focus = () => { - targetBody.focus(); - }; - - const outerDoc = outerWin.contentWindow.document; - - const sideDiv = outerDoc.getElementById('sidediv'); - const lineMetricsDiv = outerDoc.getElementById('linemetricsdiv'); - const sideDivInner = outerDoc.getElementById('sidedivinner'); - const appendNewSideDivLine = () => { - const lineDiv = outerDoc.createElement('div'); - sideDivInner.appendChild(lineDiv); - const lineSpan = outerDoc.createElement('span'); - lineSpan.classList.add('line-number'); - lineSpan.appendChild(outerDoc.createTextNode(sideDivInner.children.length)); - lineDiv.appendChild(lineSpan); - }; - appendNewSideDivLine(); - - const scroll = new Scroll(outerWin); - - let outsideKeyDown = noop; - let outsideKeyPress = (e) => true; - let outsideNotifyDirty = noop; - - /** - * Document representation. - */ - const rep = { - /** - * The contents of the document. Each entry in this skip list is an object representing a - * line (actually paragraph) of text. The line objects are created by createDomLineEntry(). - */ - lines: new SkipList(), - /** - * Start of the selection. Represented as an array of two non-negative numbers that point to the - * first character of the selection: [zeroBasedLineNumber, zeroBasedColumnNumber]. Notes: - * - There is an implicit newline character (not actually stored) at the end of every line. - * Because of this, a selection that starts at the end of a line (column number equals the - * number of characters in the line, not including the implicit newline) is not equivalent - * to a selection that starts at the beginning of the next line. The same goes for the - * selection end. - * - If there are N lines, [N, 0] is valid for the start of the selection. [N, 0] indicates - * that the selection starts just after the implicit newline at the end of the document's - * last line (if the document has any lines). The same goes for the end of the selection. - * - If a line starts with a line marker, a selection that starts at the beginning of the line - * may start either immediately before (column = 0) or immediately after (column = 1) the - * line marker, and the two are considered to be semantically equivalent. For safety, all - * code should be written to accept either but only produce selections that start after the - * line marker (the column number should be 1, not 0, when there is a line marker). The same - * goes for the end of the selection. - */ - selStart: null, - /** - * End of the selection. Represented as an array of two non-negative numbers that point to the - * character just after the end of the selection: [zeroBasedLineNumber, zeroBasedColumnNumber]. - * See the above notes for selStart. - */ - selEnd: null, - /** - * Whether the selection extends "backwards", so that the focus point (controlled with the arrow - * keys) is at the beginning. This is not supported in IE, though native IE selections have that - * behavior (which we try not to interfere with). Must be false if selection is collapsed! - */ - selFocusAtStart: false, - alltext: '', - alines: [], - apool: new AttribPool(), - }; - - // lines, alltext, alines, and DOM are set up in init() - if (undoModule.enabled) { - undoModule.apool = rep.apool; - } - - let isEditable = true; - let doesWrap = true; - let hasLineNumbers = true; - let isStyled = true; - - let console = (DEBUG && window.console); - - if (!window.console) { - const names = [ - 'log', - 'debug', - 'info', - 'warn', - 'error', - 'assert', - 'dir', - 'dirxml', - 'group', - 'groupEnd', - 'time', - 'timeEnd', - 'count', - 'trace', - 'profile', - 'profileEnd', - ]; - console = {}; - for (const name of names) console[name] = noop; - } - - const scheduler = window; // hack for opera required - - const performDocumentReplaceRange = (start, end, newText) => { - if (start === undefined) start = rep.selStart; - if (end === undefined) end = rep.selEnd; - - // start[0]: <--- start[1] --->CCCCCCCCCCC\n - // CCCCCCCCCCCCCCCCCCCC\n - // CCCC\n - // end[0]: -------\n - const builder = new Builder(rep.lines.totalWidth()); - buildKeepToStartOfRange(rep, builder, start); - buildRemoveRange(rep, builder, start, end); - builder.insert(newText, [ - ['author', thisAuthor], - ], rep.apool); - const cs = builder.toString(); - - performDocumentApplyChangeset(cs); - }; - - const changesetTracker = makeChangesetTracker(scheduler, rep.apool, { - withCallbacks: (operationName, f) => { - inCallStackIfNecessary(operationName, () => { - fastIncorp(1); - f( - { - setDocumentAttributedText: (atext) => { - setDocAText(atext); - }, - applyChangesetToDocument: (changeset, preferInsertionAfterCaret) => { - const oldEventType = currentCallStack.editEvent.eventType; - currentCallStack.startNewEvent('nonundoable'); - - performDocumentApplyChangeset(changeset, preferInsertionAfterCaret); - - currentCallStack.startNewEvent(oldEventType); - }, - }); - }); - }, - }); - - const authorInfos = {}; // presence of key determines if author is present in doc - const getAuthorInfos = () => authorInfos; - editorInfo.ace_getAuthorInfos = getAuthorInfos; - - const setAuthorStyle = (author, info) => { - const authorSelector = getAuthorColorClassSelector(getAuthorClassName(author)); - - if (!info) { - cssManagers.inner.removeSelectorStyle(authorSelector); - cssManagers.parent.removeSelectorStyle(authorSelector); - } else if (info.bgcolor) { - let bgcolor = info.bgcolor; - if ((typeof info.fade) === 'number') { - bgcolor = fadeColor(bgcolor, info.fade); - } - const textColor = - colorutils.textColorFromBackgroundColor(bgcolor, window.clientVars.skinName); - const styles = [ - cssManagers.inner.selectorStyle(authorSelector), - cssManagers.parent.selectorStyle(authorSelector), - ]; - for (const style of styles) { - style.backgroundColor = bgcolor; - style.color = textColor; - style['padding-top'] = '3px'; - style['padding-bottom'] = '4px'; - } - } - }; - - const setAuthorInfo = (author, info) => { - if (!author) return; // author ID not set for some reason - if ((typeof author) !== 'string') { - // Potentially caused by: https://github.com/ether/etherpad-lite/issues/2802"); - throw new Error(`setAuthorInfo: author (${author}) is not a string`); - } - if (!info) { - delete authorInfos[author]; - } else { - authorInfos[author] = info; - } - setAuthorStyle(author, info); - }; - - const getAuthorClassName = (author) => `author-${author.replace(/[^a-y0-9]/g, (c) => { - if (c === '.') return '-'; - return `z${c.charCodeAt(0)}z`; - })}`; - - const className2Author = (className) => { - if (className.substring(0, 7) === 'author-') { - return className.substring(7).replace(/[a-y0-9]+|-|z.+?z/g, (cc) => { - if (cc === '-') { return '.'; } else if (cc.charAt(0) === 'z') { - return String.fromCharCode(Number(cc.slice(1, -1))); - } else { - return cc; - } - }); - } - return null; - }; - - const getAuthorColorClassSelector = (oneClassName) => `.authorColors .${oneClassName}`; - - const fadeColor = (colorCSS, fadeFrac) => { - let color = colorutils.css2triple(colorCSS); - color = colorutils.blend(color, [1, 1, 1], fadeFrac); - return colorutils.triple2css(color); - }; - - editorInfo.ace_getRep = () => rep; - - editorInfo.ace_getAuthor = () => thisAuthor; - - const _nonScrollableEditEvents = { - applyChangesToBase: 1, - }; - - // EventBus: also allow plugins to register non-scrollable edit events via the bus - // (No equivalent hook return needed — this is a one-time init registration.) - - const isScrollableEditEvent = (eventType) => !_nonScrollableEditEvents[eventType]; - - let currentCallStack = null; - - const inCallStack = (type, action) => { - if (disposed) return; - - const newEditEvent = (eventType) => ({ - eventType, - backset: null, - }); - - const submitOldEvent = (evt) => { - if (rep.selStart && rep.selEnd) { - const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1]; - const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1]; - evt.selStart = selStartChar; - evt.selEnd = selEndChar; - evt.selFocusAtStart = rep.selFocusAtStart; - } - if (undoModule.enabled) { - let undoWorked = false; - try { - if (isPadLoading(evt.eventType)) { - undoModule.clearHistory(); - } else if (evt.eventType === 'nonundoable') { - if (evt.changeset) { - undoModule.reportExternalChange(evt.changeset); - } - } else { - undoModule.reportEvent(evt); - } - undoWorked = true; - } finally { - if (!undoWorked) { - undoModule.enabled = false; // for safety - } - } - } - }; - - const startNewEvent = (eventType, dontSubmitOld) => { - const oldEvent = currentCallStack.editEvent; - if (!dontSubmitOld) { - submitOldEvent(oldEvent); - } - currentCallStack.editEvent = newEditEvent(eventType); - return oldEvent; - }; - - currentCallStack = { - type, - docTextChanged: false, - selectionAffected: false, - userChangedSelection: false, - domClean: false, - isUserChange: false, - // is this a "user change" type of call-stack - repChanged: false, - editEvent: newEditEvent(type), - startNewEvent, - }; - let cleanExit = false; - let result; - try { - result = action(); - - // EventBus: emit editor:content:changed - editorBus.emit('editor:content:changed', {text: rep.alltext}); - - cleanExit = true; - } finally { - const cs = currentCallStack; - if (cleanExit) { - submitOldEvent(cs.editEvent); - if (cs.domClean && cs.type !== 'setup') { - if (cs.selectionAffected) { - updateBrowserSelectionFromRep(); - } - if ((cs.docTextChanged || cs.userChangedSelection) && isScrollableEditEvent(cs.type)) { - scrollSelectionIntoView(); - } - if (cs.docTextChanged && cs.type.indexOf('importText') < 0) { - outsideNotifyDirty(); - } - } - } else if (currentCallStack.type === 'idleWorkTimer') { - idleWorkTimer.atLeast(1000); - } - currentCallStack = null; - } - return result; - }; - editorInfo.ace_inCallStack = inCallStack; - - const inCallStackIfNecessary = (type, action) => { - if (!currentCallStack) { - inCallStack(type, action); - } else { - action(); - } - }; - editorInfo.ace_inCallStackIfNecessary = inCallStackIfNecessary; - - const dispose = () => { - disposed = true; - if (idleWorkTimer) idleWorkTimer.never(); - teardown(); - }; - - const setWraps = (newVal) => { - doesWrap = newVal; - targetBody.classList.toggle('doesWrap', doesWrap); - scheduler.setTimeout(() => { - inCallStackIfNecessary('setWraps', () => { - fastIncorp(7); - recreateDOM(); - fixView(); - }); - }, 0); - }; - - const setStyled = (newVal) => { - const oldVal = isStyled; - isStyled = !!newVal; - - if (newVal !== oldVal) { - if (!newVal) { - // clear styles - inCallStackIfNecessary('setStyled', () => { - fastIncorp(12); - const clearStyles = []; - for (const k of Object.keys(STYLE_ATTRIBS)) { - clearStyles.push([k, '']); - } - performDocumentApplyAttributesToCharRange(0, rep.alltext.length, clearStyles); - }); - } - } - }; - - const setTextFace = (face) => { - targetBody.style.fontFamily = face; - lineMetricsDiv.style.fontFamily = face; - }; - - const recreateDOM = () => { - // precond: normalized - recolorLinesInRange(0, rep.alltext.length); - }; - - const setEditable = (newVal) => { - isEditable = newVal; - targetBody.contentEditable = isEditable ? 'true' : 'false'; - targetBody.classList.toggle('static', !isEditable); - }; - - const enforceEditability = () => setEditable(isEditable); - - const importText = (text, undoable, dontProcess) => { - let lines; - if (dontProcess) { - if (text.charAt(text.length - 1) !== '\n') { - throw new Error('new raw text must end with newline'); - } - if (/[\r\t\xa0]/.exec(text)) { - throw new Error('new raw text must not contain CR, tab, or nbsp'); - } - lines = text.substring(0, text.length - 1).split('\n'); - } else { - lines = text.split('\n').map(textify); - } - let newText = '\n'; - if (lines.length > 0) { - newText = `${lines.join('\n')}\n`; - } - - - inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => { - setDocText(newText); - }); - - if (dontProcess && rep.alltext !== text) { - throw new Error('mismatch error setting raw text in importText'); - } - }; - - const importAText = (atext, apoolJsonObj, undoable) => { - atext = cloneAText(atext); - if (apoolJsonObj) { - const wireApool = (new AttribPool()).fromJsonable(apoolJsonObj); - atext.attribs = moveOpsToNewPool(atext.attribs, wireApool, rep.apool); - } - inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => { - setDocAText(atext); - }); - }; - - const setDocAText = (atext) => { - if (atext.text === '') { - /* - * The server is fine with atext.text being an empty string, but the front - * end is not, and crashes. - * - * It is not clear if this is a problem in the server or in the client - * code, and this is a client-side hack fix. The underlying problem needs - * to be investigated. - * - * See for reference: - * - https://github.com/ether/etherpad-lite/issues/3861 - */ - atext.text = '\n'; - } - - fastIncorp(8); - - const oldLen = rep.lines.totalWidth(); - const numLines = rep.lines.length(); - const upToLastLine = rep.lines.offsetOfIndex(numLines - 1); - const lastLineLength = rep.lines.atIndex(numLines - 1).text.length; - const assem = new SmartOpAssembler(); - const o = new Op('-'); - o.chars = upToLastLine; - o.lines = numLines - 1; - assem.append(o); - o.chars = lastLineLength; - o.lines = 0; - assem.append(o); - for (const op of opsFromAText(atext)) assem.append(op); - const newLen = oldLen + assem.getLengthChange(); - const changeset = checkRep( - pack(oldLen, newLen, assem.toString(), atext.text.slice(0, -1))); - performDocumentApplyChangeset(changeset); - - performSelectionChange( - [0, rep.lines.atIndex(0).lineMarker], [0, rep.lines.atIndex(0).lineMarker]); - - idleWorkTimer.atMost(100); - - if (rep.alltext !== atext.text) { - throw new Error('mismatch error setting raw text in setDocAText'); - } - }; - - const setDocText = (text) => { - setDocAText(makeAText(text)); - }; - - const getDocText = () => { - const alltext = rep.alltext; - let len = alltext.length; - if (len > 0) len--; // final extra newline - return alltext.substring(0, len); - }; - - const exportText = () => { - if (currentCallStack && !currentCallStack.domClean) { - inCallStackIfNecessary('exportText', () => { - fastIncorp(2); - }); - } - return getDocText(); - }; - - const editorChangedSize = () => fixView(); - - const setOnKeyPress = (handler) => { - outsideKeyPress = handler; - }; - - const setOnKeyDown = (handler) => { - outsideKeyDown = handler; - }; - - const setNotifyDirty = (handler) => { - outsideNotifyDirty = handler; - }; - - const CMDS = { - clearauthorship: (prompt) => { - if ((!(rep.selStart && rep.selEnd)) || isCaret()) { - if (prompt) { - prompt(); - } else { - performDocumentApplyAttributesToCharRange(0, rep.alltext.length, [ - ['author', ''], - ]); - } - } else { - setAttributeOnSelection('author', ''); - } - }, - }; - - const execCommand = (cmd, ...args) => { - cmd = cmd.toLowerCase(); - if (CMDS[cmd]) { - inCallStackIfNecessary(cmd, () => { - fastIncorp(9); - CMDS[cmd](...args); - }); - } - }; - - const replaceRange = (start, end, text) => { - inCallStackIfNecessary('replaceRange', () => { - fastIncorp(9); - performDocumentReplaceRange(start, end, text); - }); - }; - - editorInfo.ace_callWithAce = (fn, callStack, normalize) => { - let wrapper = () => fn(editorInfo); - - if (normalize !== undefined) { - const wrapper1 = wrapper; - wrapper = () => { - editorInfo.ace_fastIncorp(9); - wrapper1(); - }; - } - - if (callStack !== undefined) { - return editorInfo.ace_inCallStack(callStack, wrapper); - } else { - return wrapper(); - } - }; - - /** - * This methed exposes a setter for some ace properties - * @param key the name of the parameter - * @param value the value to set to - */ - editorInfo.ace_setProperty = (key, value) => { - // These properties are exposed - const setters = { - wraps: setWraps, - showsauthorcolors: (val) => targetBody.classList.toggle('authorColors', !!val), - showsuserselections: (val) => targetBody.classList.toggle('userSelections', !!val), - showslinenumbers: (value) => { - hasLineNumbers = !!value; - sideDiv.parentNode.classList.toggle('line-numbers-hidden', !hasLineNumbers); - fixView(); - }, - userauthor: (value) => { - thisAuthor = String(value); - documentAttributeManager.author = thisAuthor; - }, - styled: setStyled, - textface: setTextFace, - rtlistrue: (value) => { - targetBody.classList.toggle('rtl', value); - targetBody.classList.toggle('ltr', !value); - document.documentElement.dir = value ? 'rtl' : 'ltr'; - }, - }; - - const setter = setters[key.toLowerCase()]; - - // check if setter is present - if (setter !== undefined) { - setter(value); - } - }; - - editorInfo.ace_setBaseText = (txt) => { - changesetTracker.setBaseText(txt); - }; - editorInfo.ace_setBaseAttributedText = (atxt, apoolJsonObj) => { - changesetTracker.setBaseAttributedText(atxt, apoolJsonObj); - }; - editorInfo.ace_applyChangesToBase = (c, optAuthor, apoolJsonObj) => { - changesetTracker.applyChangesToBase(c, optAuthor, apoolJsonObj); - }; - editorInfo.ace_prepareUserChangeset = () => changesetTracker.prepareUserChangeset(); - editorInfo.ace_applyPreparedChangesetToBase = () => { - changesetTracker.applyPreparedChangesetToBase(); - }; - editorInfo.ace_setUserChangeNotificationCallback = (f) => { - changesetTracker.setUserChangeNotificationCallback(f); - }; - editorInfo.ace_setAuthorInfo = (author, info) => { - setAuthorInfo(author, info); - }; - - editorInfo.ace_getDocument = () => document; - - const now = () => Date.now(); - - const newTimeLimit = (ms) => { - const startTime = now(); - let exceededAlready = false; - let printedTrace = false; - const isTimeUp = () => { - if (exceededAlready) { - if ((!printedTrace)) { - printedTrace = true; - } - return true; - } - const elapsed = now() - startTime; - if (elapsed > ms) { - exceededAlready = true; - return true; - } else { - return false; - } - }; - - isTimeUp.elapsed = () => now() - startTime; - return isTimeUp; - }; - - - const makeIdleAction = (func) => { - let scheduledTimeout = null; - let scheduledTime = 0; - - const unschedule = () => { - if (scheduledTimeout) { - scheduler.clearTimeout(scheduledTimeout); - scheduledTimeout = null; - } - }; - - const reschedule = (time) => { - unschedule(); - scheduledTime = time; - let delay = time - now(); - if (delay < 0) delay = 0; - scheduledTimeout = scheduler.setTimeout(callback, delay); - }; - - const callback = () => { - scheduledTimeout = null; - // func may reschedule the action - func(); - }; - - return { - atMost: (ms) => { - const latestTime = now() + ms; - if ((!scheduledTimeout) || scheduledTime > latestTime) { - reschedule(latestTime); - } - }, - // atLeast(ms) will schedule the action if not scheduled yet. - // In other words, "infinity" is replaced by ms, even though - // it is technically larger. - atLeast: (ms) => { - const earliestTime = now() + ms; - if ((!scheduledTimeout) || scheduledTime < earliestTime) { - reschedule(earliestTime); - } - }, - never: () => { - unschedule(); - }, - }; - }; - - const fastIncorp = (n) => { - // normalize but don't do any lexing or anything - incorporateUserChanges(); - }; - editorInfo.ace_fastIncorp = fastIncorp; - - const idleWorkTimer = makeIdleAction(() => { - if (inInternationalComposition) { - // don't do idle input incorporation during international input composition - idleWorkTimer.atLeast(500); - return; - } - - inCallStackIfNecessary('idleWorkTimer', () => { - const isTimeUp = newTimeLimit(250); - - let finishedImportantWork = false; - let finishedWork = false; - - try { - incorporateUserChanges(); - - if (isTimeUp()) return; - - updateLineNumbers(); // update line numbers if any time left - if (isTimeUp()) return; - finishedImportantWork = true; - finishedWork = true; - } finally { - if (finishedWork) { - idleWorkTimer.atMost(1000); - } else if (finishedImportantWork) { - // if we've finished highlighting the view area, - // more highlighting could be counter-productive, - // e.g. if the user just opened a triple-quote and will soon close it. - idleWorkTimer.atMost(500); - } else { - let timeToWait = Math.round(isTimeUp.elapsed() / 2); - if (timeToWait < 100) timeToWait = 100; - idleWorkTimer.atMost(timeToWait); - } - } - }); - }); - - let _nextId = 1; - - const uniqueId = (n) => { - // not actually guaranteed to be unique, e.g. if user copy-pastes - // nodes with ids - const nid = n.id; - if (nid) return nid; - return (n.id = `magicdomid${_nextId++}`); - }; - - - const recolorLinesInRange = (startChar, endChar) => { - if (endChar <= startChar) return; - if (startChar < 0 || startChar >= rep.lines.totalWidth()) return; - let lineEntry = rep.lines.atOffset(startChar); // rounds down to line boundary - let lineStart = rep.lines.offsetOfEntry(lineEntry); - let lineIndex = rep.lines.indexOfEntry(lineEntry); - let selectionNeedsResetting = false; - let firstLine = null; - - // tokenFunc function; accesses current value of lineEntry and curDocChar, - // also mutates curDocChar - const tokenFunc = (tokenText, tokenClass) => { - lineEntry.domInfo.appendSpan(tokenText, tokenClass); - }; - - while (lineEntry && lineStart < endChar) { - const lineEnd = lineStart + lineEntry.width; - lineEntry.domInfo.clearSpans(); - getSpansForLine(lineEntry, tokenFunc, lineStart); - lineEntry.domInfo.finishUpdate(); - - markNodeClean(lineEntry.lineNode); - - if (rep.selStart && rep.selStart[0] === lineIndex || - rep.selEnd && rep.selEnd[0] === lineIndex) { - selectionNeedsResetting = true; - } - - if (firstLine == null) firstLine = lineIndex; - lineStart = lineEnd; - lineEntry = rep.lines.next(lineEntry); - lineIndex++; - } - if (selectionNeedsResetting) { - currentCallStack.selectionAffected = true; - } - }; - - // like getSpansForRange, but for a line, and the func takes (text,class) - // instead of (width,class); excludes the trailing '\n' from - // consideration by func - - - const getSpansForLine = (lineEntry, textAndClassFunc, lineEntryOffsetHint) => { - let lineEntryOffset = lineEntryOffsetHint; - if ((typeof lineEntryOffset) !== 'number') { - lineEntryOffset = rep.lines.offsetOfEntry(lineEntry); - } - const text = lineEntry.text; - if (text.length === 0) { - // allow getLineStyleFilter to set line-div styles - const func = linestylefilter.getLineStyleFilter( - 0, '', textAndClassFunc, rep.apool); - func('', ''); - } else { - let filteredFunc = linestylefilter.getFilterStack(text, textAndClassFunc, browser); - const lineNum = rep.lines.indexOfEntry(lineEntry); - const aline = rep.alines[lineNum]; - filteredFunc = linestylefilter.getLineStyleFilter( - text.length, aline, filteredFunc, rep.apool); - filteredFunc(text, ''); - } - }; - - let observedChanges; - - const clearObservedChanges = () => { - observedChanges = { - cleanNodesNearChanges: {}, - }; - }; - clearObservedChanges(); - - const getCleanNodeByKey = (key) => { - let n = targetDoc.getElementById(key); - // copying and pasting can lead to duplicate ids - while (n && isNodeDirty(n)) { - n.id = ''; - n = targetDoc.getElementById(key); - } - return n; - }; - - const observeChangesAroundNode = (node) => { - // Around this top-level DOM node, look for changes to the document - // (from how it looks in our representation) and record them in a way - // that can be used to "normalize" the document (apply the changes to our - // representation, and put the DOM in a canonical form). - let cleanNode; - let hasAdjacentDirtyness; - if (!isNodeDirty(node)) { - cleanNode = node; - const prevSib = cleanNode.previousSibling; - const nextSib = cleanNode.nextSibling; - hasAdjacentDirtyness = ((prevSib && isNodeDirty(prevSib)) || - (nextSib && isNodeDirty(nextSib))); - } else { - // node is dirty, look for clean node above - let upNode = node.previousSibling; - while (upNode && isNodeDirty(upNode)) { - upNode = upNode.previousSibling; - } - if (upNode) { - cleanNode = upNode; - } else { - let downNode = node.nextSibling; - while (downNode && isNodeDirty(downNode)) { - downNode = downNode.nextSibling; - } - if (downNode) { - cleanNode = downNode; - } - } - if (!cleanNode) { - // Couldn't find any adjacent clean nodes! - // Since top and bottom of doc is dirty, the dirty area will be detected. - return; - } - hasAdjacentDirtyness = true; - } - - if (hasAdjacentDirtyness) { - // previous or next line is dirty - observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true; - } else { - // next and prev lines are clean (if they exist) - const lineKey = uniqueId(cleanNode); - const prevSib = cleanNode.previousSibling; - const nextSib = cleanNode.nextSibling; - const actualPrevKey = ((prevSib && uniqueId(prevSib)) || null); - const actualNextKey = ((nextSib && uniqueId(nextSib)) || null); - const repPrevEntry = rep.lines.prev(rep.lines.atKey(lineKey)); - const repNextEntry = rep.lines.next(rep.lines.atKey(lineKey)); - const repPrevKey = ((repPrevEntry && repPrevEntry.key) || null); - const repNextKey = ((repNextEntry && repNextEntry.key) || null); - if (actualPrevKey !== repPrevKey || actualNextKey !== repNextKey) { - observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true; - } - } - }; - - const observeChangesAroundSelection = () => { - if (currentCallStack.observedSelection) return; - currentCallStack.observedSelection = true; - - const selection = getSelection(); - - if (selection) { - const node1 = topLevel(selection.startPoint.node); - const node2 = topLevel(selection.endPoint.node); - if (node1) observeChangesAroundNode(node1); - if (node2 && node1 !== node2) { - observeChangesAroundNode(node2); - } - } - }; - - const observeSuspiciousNodes = () => { - // inspired by Firefox bug #473255, where pasting formatted text - // causes the cursor to jump away, making the new HTML never found. - if (targetBody.getElementsByTagName) { - const elts = targetBody.getElementsByTagName('style'); - for (const elt of elts) { - const n = topLevel(elt); - if (n && n.parentNode === targetBody) { - observeChangesAroundNode(n); - } - } - } - }; - - const incorporateUserChanges = () => { - if (currentCallStack.domClean) return false; - - currentCallStack.isUserChange = true; - - if (DEBUG && window.DONT_INCORP || window.DEBUG_DONT_INCORP) return false; - - // returns true if dom changes were made - if (!targetBody.firstChild) { - targetBody.innerHTML = '
'; - } - - observeChangesAroundSelection(); - observeSuspiciousNodes(); - let dirtyRanges = getDirtyRanges(); - let dirtyRangesCheckOut = true; - let j = 0; - let a, b; - let scrollToTheLeftNeeded = false; - - while (j < dirtyRanges.length) { - a = dirtyRanges[j][0]; - b = dirtyRanges[j][1]; - if (!((a === 0 || getCleanNodeByKey(rep.lines.atIndex(a - 1).key)) && - (b === rep.lines.length() || getCleanNodeByKey(rep.lines.atIndex(b).key)))) { - dirtyRangesCheckOut = false; - break; - } - j++; - } - if (!dirtyRangesCheckOut) { - for (const bodyNode of targetBody.childNodes) { - if ((bodyNode.tagName) && ((!bodyNode.id) || (!rep.lines.containsKey(bodyNode.id)))) { - observeChangesAroundNode(bodyNode); - } - } - dirtyRanges = getDirtyRanges(); - } - - clearObservedChanges(); - - const selection = getSelection(); - - let selStart, selEnd; // each one, if truthy, has [line,char] needed to set selection - let i = 0; - const splicesToDo = []; - let netNumLinesChangeSoFar = 0; - const toDeleteAtEnd = []; - const domInsertsNeeded = []; // each entry is [nodeToInsertAfter, [info1, info2, ...]] - while (i < dirtyRanges.length) { - const range = dirtyRanges[i]; - a = range[0]; - b = range[1]; - let firstDirtyNode = (((a === 0) && targetBody.firstChild) || - getCleanNodeByKey(rep.lines.atIndex(a - 1).key).nextSibling); - firstDirtyNode = (firstDirtyNode && isNodeDirty(firstDirtyNode) && firstDirtyNode); - - let lastDirtyNode = (((b === rep.lines.length()) && targetBody.lastChild) || - getCleanNodeByKey(rep.lines.atIndex(b).key).previousSibling); - - lastDirtyNode = (lastDirtyNode && isNodeDirty(lastDirtyNode) && lastDirtyNode); - if (firstDirtyNode && lastDirtyNode) { - const cc = makeContentCollector(isStyled, browser, rep.apool, className2Author); - cc.notifySelection(selection); - const dirtyNodes = []; - for (let n = firstDirtyNode; n && - !(n.previousSibling && n.previousSibling === lastDirtyNode); - n = n.nextSibling) { - cc.collectContent(n); - dirtyNodes.push(n); - } - cc.notifyNextNode(lastDirtyNode.nextSibling); - let lines = cc.getLines(); - if ((lines.length <= 1 || lines[lines.length - 1] !== '') && lastDirtyNode.nextSibling) { - // dirty region doesn't currently end a line, even taking the following node - // (or lack of node) into account, so include the following clean node. - // It could be SPAN or a DIV; basically this is any case where the contentCollector - // decides it isn't done. - // Note that this clean node might need to be there for the next dirty range. - b++; - const cleanLine = lastDirtyNode.nextSibling; - cc.collectContent(cleanLine); - toDeleteAtEnd.push(cleanLine); - cc.notifyNextNode(cleanLine.nextSibling); - } - - const ccData = cc.finish(); - const ss = ccData.selStart; - const se = ccData.selEnd; - lines = ccData.lines; - const lineAttribs = ccData.lineAttribs; - const linesWrapped = ccData.linesWrapped; - - if (linesWrapped > 0) { - // Chrome decides in its infinite wisdom that it's okay to put the browser's visisble - // window in the middle of the span. An outcome of this is that the first chars of the - // string are no longer visible to the user.. Yay chrome.. Move the browser's visible area - // to the left hand side of the span. Firefox isn't quite so bad, but it's still pretty - // quirky. - scrollToTheLeftNeeded = true; - } - - if (ss[0] >= 0) selStart = [ss[0] + a + netNumLinesChangeSoFar, ss[1]]; - if (se[0] >= 0) selEnd = [se[0] + a + netNumLinesChangeSoFar, se[1]]; - - const entries = []; - const nodeToAddAfter = lastDirtyNode; - const lineNodeInfos = []; - for (const lineString of lines) { - const newEntry = createDomLineEntry(lineString); - entries.push(newEntry); - lineNodeInfos.push(newEntry.domInfo); - } - domInsertsNeeded.push([nodeToAddAfter, lineNodeInfos]); - for (const n of dirtyNodes) toDeleteAtEnd.push(n); - const spliceHints = {}; - if (selStart) spliceHints.selStart = selStart; - if (selEnd) spliceHints.selEnd = selEnd; - splicesToDo.push([a + netNumLinesChangeSoFar, b - a, entries, lineAttribs, spliceHints]); - netNumLinesChangeSoFar += (lines.length - (b - a)); - } else if (b > a) { - splicesToDo.push([a + netNumLinesChangeSoFar, b - a, [], []]); - } - i++; - } - - const domChanges = (splicesToDo.length > 0); - - for (const splice of splicesToDo) doIncorpLineSplice(...splice); - for (const ins of domInsertsNeeded) insertDomLines(...ins); - for (const n of toDeleteAtEnd) n.remove(); - - // needed to stop chrome from breaking the ui when long strings without spaces are pasted - if (scrollToTheLeftNeeded) targetBody.scrollLeft = 0; - - // if the nodes that define the selection weren't encountered during - // content collection, figure out where those nodes are now. - if (selection && !selStart) { - selStart = getLineAndCharForPoint(selection.startPoint); - } - if (selection && !selEnd) { - selEnd = getLineAndCharForPoint(selection.endPoint); - } - - // selection from content collection can, in various ways, extend past final - // BR in firefox DOM, so cap the line - const numLines = rep.lines.length(); - if (selStart && selStart[0] >= numLines) { - selStart[0] = numLines - 1; - selStart[1] = rep.lines.atIndex(selStart[0]).text.length; - } - if (selEnd && selEnd[0] >= numLines) { - selEnd[0] = numLines - 1; - selEnd[1] = rep.lines.atIndex(selEnd[0]).text.length; - } - - // update rep if we have a new selection - // NOTE: IE loses the selection when you click stuff in e.g. the - // editbar, so removing the selection when it's lost is not a good - // idea. - if (selection) repSelectionChange(selStart, selEnd, selection && selection.focusAtStart); - // update browser selection - if (selection && (domChanges || isCaret())) { - // if no DOM changes (not this case), want to treat range selection delicately, - // e.g. in IE not lose which end of the selection is the focus/anchor; - // on the other hand, we may have just noticed a press of PageUp/PageDown - currentCallStack.selectionAffected = true; - } - - currentCallStack.domClean = true; - - fixView(); - - return domChanges; - }; - - const STYLE_ATTRIBS = { - bold: true, - italic: true, - underline: true, - strikethrough: true, - list: true, - }; - - const isStyleAttribute = (aname) => !!STYLE_ATTRIBS[aname]; - - const isDefaultLineAttribute = - (aname) => AttributeManager.DEFAULT_LINE_ATTRIBUTES.indexOf(aname) !== -1; - - const insertDomLines = (nodeToAddAfter, infoStructs) => { - let lastEntry; - let lineStartOffset; - for (const info of infoStructs) { - const node = info.node; - const key = uniqueId(node); - let entry; - if (lastEntry) { - // optimization to avoid recalculation - const next = rep.lines.next(lastEntry); - if (next && next.key === key) { - entry = next; - lineStartOffset += lastEntry.width; - } - } - if (!entry) { - entry = rep.lines.atKey(key); - lineStartOffset = rep.lines.offsetOfKey(key); - } - lastEntry = entry; - getSpansForLine(entry, (tokenText, tokenClass) => { - info.appendSpan(tokenText, tokenClass); - }, lineStartOffset); - info.prepareForAdd(); - entry.lineMarker = info.lineMarker; - if (!nodeToAddAfter) { - targetBody.insertBefore(node, targetBody.firstChild); - } else { - targetBody.insertBefore(node, nodeToAddAfter.nextSibling); - } - nodeToAddAfter = node; - info.notifyAdded(); - markNodeClean(node); - } - }; - - const isCaret = () => (rep.selStart && rep.selEnd && - rep.selStart[0] === rep.selEnd[0] && rep.selStart[1] === rep.selEnd[1]); - editorInfo.ace_isCaret = isCaret; - - // prereq: isCaret() - const caretLine = () => rep.selStart[0]; - - editorInfo.ace_caretLine = caretLine; - - const caretColumn = () => rep.selStart[1]; - - editorInfo.ace_caretColumn = caretColumn; - - const caretDocChar = () => rep.lines.offsetOfIndex(caretLine()) + caretColumn(); - - editorInfo.ace_caretDocChar = caretDocChar; - - const handleReturnIndentation = () => { - // on return, indent to level of previous line - if (isCaret() && caretColumn() === 0 && caretLine() > 0) { - const lineNum = caretLine(); - const thisLine = rep.lines.atIndex(lineNum); - const prevLine = rep.lines.prev(thisLine); - const prevLineText = prevLine.text; - let theIndent = /^ *(?:)/.exec(prevLineText)[0]; - const shouldIndent = window.clientVars.indentationOnNewLine; - if (shouldIndent && /[[(:{]\s*$/.exec(prevLineText)) { - theIndent += THE_TAB; - } - const cs = new Builder(rep.lines.totalWidth()).keep( - rep.lines.offsetOfIndex(lineNum), lineNum).insert( - theIndent, [ - ['author', thisAuthor], - ], rep.apool).toString(); - performDocumentApplyChangeset(cs); - performSelectionChange([lineNum, theIndent.length], [lineNum, theIndent.length]); - } - }; - - const getPointForLineAndChar = (lineAndChar) => { - const line = lineAndChar[0]; - let charsLeft = lineAndChar[1]; - const lineEntry = rep.lines.atIndex(line); - charsLeft -= lineEntry.lineMarker; - if (charsLeft < 0) { - charsLeft = 0; - } - const lineNode = lineEntry.lineNode; - let n = lineNode; - let after = false; - if (charsLeft === 0) { - return { - node: lineNode, - index: 0, - maxIndex: 1, - }; - } - while (!(n === lineNode && after)) { - if (after) { - if (n.nextSibling) { - n = n.nextSibling; - after = false; - } else { n = n.parentNode; } - } else if (isNodeText(n)) { - const len = n.nodeValue.length; - if (charsLeft <= len) { - return { - node: n, - index: charsLeft, - maxIndex: len, - }; - } - charsLeft -= len; - after = true; - } else if (n.firstChild) { n = n.firstChild; } else { after = true; } - } - return { - node: lineNode, - index: 1, - maxIndex: 1, - }; - }; - - const nodeText = (n) => n.textContent || n.nodeValue || ''; - - const getLineAndCharForPoint = (point) => { - // Turn DOM node selection into [line,char] selection. - // This method has to work when the DOM is not pristine, - // assuming the point is not in a dirty node. - if (point.node === targetBody) { - if (point.index === 0) { - return [0, 0]; - } else { - const N = rep.lines.length(); - const ln = rep.lines.atIndex(N - 1); - return [N - 1, ln.text.length]; - } - } else { - let n = point.node; - let col = 0; - // if this part fails, it probably means the selection node - // was dirty, and we didn't see it when collecting dirty nodes. - if (isNodeText(n)) { - col = point.index; - } else if (point.index > 0) { - col = nodeText(n).length; - } - let parNode, prevSib; - while ((parNode = n.parentNode) !== targetBody) { - if ((prevSib = n.previousSibling)) { - n = prevSib; - col += nodeText(n).length; - } else { - n = parNode; - } - } - if (n.firstChild && isBlockElement(n.firstChild)) { - col += 1; // lineMarker - } - const lineEntry = rep.lines.atKey(n.id); - const lineNum = rep.lines.indexOfEntry(lineEntry); - return [lineNum, col]; - } - }; - editorInfo.ace_getLineAndCharForPoint = getLineAndCharForPoint; - - const createDomLineEntry = (lineString) => { - const info = doCreateDomLine(lineString.length > 0); - const newNode = info.node; - return { - key: uniqueId(newNode), - text: lineString, - lineNode: newNode, - domInfo: info, - lineMarker: 0, - }; - }; - - const performDocumentApplyChangeset = (changes, insertsAfterSelection) => { - const domAndRepSplice = (startLine, deleteCount, newLineStrings) => { - const keysToDelete = []; - if (deleteCount > 0) { - let entryToDelete = rep.lines.atIndex(startLine); - for (let i = 0; i < deleteCount; i++) { - keysToDelete.push(entryToDelete.key); - entryToDelete = rep.lines.next(entryToDelete); - } - } - - const lineEntries = newLineStrings.map(createDomLineEntry); - - doRepLineSplice(startLine, deleteCount, lineEntries); - - let nodeToAddAfter; - if (startLine > 0) { - nodeToAddAfter = getCleanNodeByKey(rep.lines.atIndex(startLine - 1).key); - } else { nodeToAddAfter = null; } - - insertDomLines(nodeToAddAfter, lineEntries.map((entry) => entry.domInfo)); - - for (const k of keysToDelete) { - const n = targetDoc.getElementById(k); - n.parentNode.removeChild(n); - } - - if ( - (rep.selStart && - rep.selStart[0] >= startLine && - rep.selStart[0] <= startLine + deleteCount) || - (rep.selEnd && rep.selEnd[0] >= startLine && rep.selEnd[0] <= startLine + deleteCount)) { - currentCallStack.selectionAffected = true; - } - }; - - doRepApplyChangeset(changes, insertsAfterSelection); - - let requiredSelectionSetting = null; - if (rep.selStart && rep.selEnd) { - const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1]; - const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1]; - const result = - characterRangeFollow(changes, selStartChar, selEndChar, insertsAfterSelection); - requiredSelectionSetting = [result[0], result[1], rep.selFocusAtStart]; - } - - const linesMutatee = { - splice: (start, numRemoved, ...args) => { - domAndRepSplice(start, numRemoved, args.map((s) => s.slice(0, -1))); - }, - get: (i) => `${rep.lines.atIndex(i).text}\n`, - length: () => rep.lines.length(), - }; - - mutateTextLines(changes, linesMutatee); - - if (requiredSelectionSetting) { - performSelectionChange( - lineAndColumnFromChar(requiredSelectionSetting[0]), - lineAndColumnFromChar(requiredSelectionSetting[1]), - requiredSelectionSetting[2]); - } - }; - - const doRepApplyChangeset = (changes, insertsAfterSelection) => { - checkRep(changes); - - if (oldLen(changes) !== rep.alltext.length) { - const errMsg = `${oldLen(changes)}/${rep.alltext.length}`; - throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`); - } - - const editEvent = currentCallStack.editEvent; - if (editEvent.eventType === 'nonundoable') { - if (!editEvent.changeset) { - editEvent.changeset = changes; - } else { - editEvent.changeset = compose(editEvent.changeset, changes, rep.apool); - } - } else { - const inverseChangeset = inverse(changes, { - get: (i) => `${rep.lines.atIndex(i).text}\n`, - length: () => rep.lines.length(), - }, rep.alines, rep.apool); - - if (!editEvent.backset) { - editEvent.backset = inverseChangeset; - } else { - editEvent.backset = compose(inverseChangeset, editEvent.backset, rep.apool); - } - } - - mutateAttributionLines(changes, rep.alines, rep.apool); - - if (changesetTracker.isTracking()) { - changesetTracker.composeUserChangeset(changes); - } - }; - - /** - * Converts the position of a char (index in String) into a [row, col] tuple - */ - const lineAndColumnFromChar = (x) => { - const lineEntry = rep.lines.atOffset(x); - const lineStart = rep.lines.offsetOfEntry(lineEntry); - const lineNum = rep.lines.indexOfEntry(lineEntry); - return [lineNum, x - lineStart]; - }; - - const performDocumentReplaceCharRange = (startChar, endChar, newText) => { - if (startChar === endChar && newText.length === 0) { - return; - } - // Requires that the replacement preserve the property that the - // internal document text ends in a newline. Given this, we - // rewrite the splice so that it doesn't touch the very last - // char of the document. - if (endChar === rep.alltext.length) { - if (startChar === endChar) { - // an insert at end - startChar--; - endChar--; - newText = `\n${newText.substring(0, newText.length - 1)}`; - } else if (newText.length === 0) { - // a delete at end - startChar--; - endChar--; - } else { - // a replace at end - endChar--; - newText = newText.substring(0, newText.length - 1); - } - } - performDocumentReplaceRange( - lineAndColumnFromChar(startChar), lineAndColumnFromChar(endChar), newText); - }; - - const performDocumentApplyAttributesToCharRange = (start, end, attribs) => { - end = Math.min(end, rep.alltext.length - 1); - documentAttributeManager.setAttributesOnRange( - lineAndColumnFromChar(start), lineAndColumnFromChar(end), attribs); - }; - - editorInfo.ace_performDocumentApplyAttributesToCharRange = - performDocumentApplyAttributesToCharRange; - - const setAttributeOnSelection = (attributeName, attributeValue) => { - if (!(rep.selStart && rep.selEnd)) return; - - documentAttributeManager.setAttributesOnRange(rep.selStart, rep.selEnd, [ - [attributeName, attributeValue], - ]); - }; - editorInfo.ace_setAttributeOnSelection = setAttributeOnSelection; - - const getAttributeOnSelection = (attributeName, prevChar) => { - if (!(rep.selStart && rep.selEnd)) return; - const isNotSelection = (rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]); - if (isNotSelection) { - if (prevChar) { - // If it's not the start of the line - if (rep.selStart[1] !== 0) { - rep.selStart[1]--; - } - } - } - - const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString(); - const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); - const hasIt = (attribs) => withItRegex.test(attribs); - - const rangeHasAttrib = (selStart, selEnd) => { - // if range is collapsed -> no attribs in range - if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false; - - if (selStart[0] !== selEnd[0]) { // -> More than one line selected - let hasAttrib = true; - - // from selStart to the end of the first line - hasAttrib = hasAttrib && - rangeHasAttrib(selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]); - - // for all lines in between - for (let n = selStart[0] + 1; n < selEnd[0]; n++) { - hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]); - } - - // for the last, potentially partial, line - hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]); - - return hasAttrib; - } - - // Logic tells us we now have a range on a single line - - const lineNum = selStart[0]; - const start = selStart[1]; - const end = selEnd[1]; - let hasAttrib = true; - - let indexIntoLine = 0; - for (const op of deserializeOps(rep.alines[lineNum])) { - const opStartInLine = indexIntoLine; - const opEndInLine = opStartInLine + op.chars; - if (!hasIt(op.attribs)) { - // does op overlap selection? - if (!(opEndInLine <= start || opStartInLine >= end)) { - // since it's overlapping but hasn't got the attrib -> range hasn't got it - hasAttrib = false; - break; - } - } - indexIntoLine = opEndInLine; - } - - return hasAttrib; - }; - return rangeHasAttrib(rep.selStart, rep.selEnd); - }; - - editorInfo.ace_getAttributeOnSelection = getAttributeOnSelection; - - const toggleAttributeOnSelection = (attributeName) => { - if (!(rep.selStart && rep.selEnd)) return; - - let selectionAllHasIt = true; - const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString(); - const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); - - const hasIt = (attribs) => withItRegex.test(attribs); - - const selStartLine = rep.selStart[0]; - const selEndLine = rep.selEnd[0]; - for (let n = selStartLine; n <= selEndLine; n++) { - let indexIntoLine = 0; - let selectionStartInLine = 0; - if (documentAttributeManager.lineHasMarker(n)) { - selectionStartInLine = 1; // ignore "*" used as line marker - } - let selectionEndInLine = rep.lines.atIndex(n).text.length; // exclude newline - if (n === selStartLine) { - selectionStartInLine = rep.selStart[1]; - } - if (n === selEndLine) { - selectionEndInLine = rep.selEnd[1]; - } - for (const op of deserializeOps(rep.alines[n])) { - const opStartInLine = indexIntoLine; - const opEndInLine = opStartInLine + op.chars; - if (!hasIt(op.attribs)) { - // does op overlap selection? - if (!(opEndInLine <= selectionStartInLine || opStartInLine >= selectionEndInLine)) { - selectionAllHasIt = false; - break; - } - } - indexIntoLine = opEndInLine; - } - if (!selectionAllHasIt) { - break; - } - } - - - const attributeValue = selectionAllHasIt ? '' : 'true'; - documentAttributeManager.setAttributesOnRange( - rep.selStart, rep.selEnd, [[attributeName, attributeValue]]); - if (attribIsFormattingStyle(attributeName)) { - updateStyleButtonState(attributeName, !selectionAllHasIt); // italic, bold, ... - } - }; - editorInfo.ace_toggleAttributeOnSelection = toggleAttributeOnSelection; - - const performDocumentReplaceSelection = (newText) => { - if (!(rep.selStart && rep.selEnd)) return; - performDocumentReplaceRange(rep.selStart, rep.selEnd, newText); - }; - - // Change the abstract representation of the document to have a different set of lines. - // Must be called after rep.alltext is set. - const doRepLineSplice = (startLine, deleteCount, newLineEntries) => { - for (const entry of newLineEntries) entry.width = entry.text.length + 1; - - const startOldChar = rep.lines.offsetOfIndex(startLine); - const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount); - - rep.lines.splice(startLine, deleteCount, newLineEntries); - currentCallStack.docTextChanged = true; - currentCallStack.repChanged = true; - const newText = newLineEntries.map((e) => `${e.text}\n`).join(''); - - rep.alltext = rep.alltext.substring(0, startOldChar) + - newText + rep.alltext.substring(endOldChar, rep.alltext.length); - }; - - const doIncorpLineSplice = (startLine, deleteCount, newLineEntries, lineAttribs, hints) => { - const startOldChar = rep.lines.offsetOfIndex(startLine); - const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount); - - const oldRegionStart = rep.lines.offsetOfIndex(startLine); - - let selStartHintChar, selEndHintChar; - if (hints && hints.selStart) { - selStartHintChar = - rep.lines.offsetOfIndex(hints.selStart[0]) + hints.selStart[1] - oldRegionStart; - } - if (hints && hints.selEnd) { - selEndHintChar = rep.lines.offsetOfIndex(hints.selEnd[0]) + hints.selEnd[1] - oldRegionStart; - } - - const newText = newLineEntries.map((e) => `${e.text}\n`).join(''); - const oldText = rep.alltext.substring(startOldChar, endOldChar); - const oldAttribs = rep.alines.slice(startLine, startLine + deleteCount).join(''); - const newAttribs = `${lineAttribs.join('|1+1')}|1+1`; // not valid in a changeset - const analysis = - analyzeChange(oldText, newText, oldAttribs, newAttribs, selStartHintChar, selEndHintChar); - const commonStart = analysis[0]; - let commonEnd = analysis[1]; - let shortOldText = oldText.substring(commonStart, oldText.length - commonEnd); - let shortNewText = newText.substring(commonStart, newText.length - commonEnd); - let spliceStart = startOldChar + commonStart; - let spliceEnd = endOldChar - commonEnd; - let shiftFinalNewlineToBeforeNewText = false; - - // adjust the splice to not involve the final newline of the document; - // be very defensive - if (shortOldText.charAt(shortOldText.length - 1) === '\n' && - shortNewText.charAt(shortNewText.length - 1) === '\n') { - // replacing text that ends in newline with text that also ends in newline - // (still, after analysis, somehow) - shortOldText = shortOldText.slice(0, -1); - shortNewText = shortNewText.slice(0, -1); - spliceEnd--; - commonEnd++; - } - if (shortOldText.length === 0 && - spliceStart === rep.alltext.length && - shortNewText.length > 0) { - // inserting after final newline, bad - spliceStart--; - spliceEnd--; - shortNewText = `\n${shortNewText.slice(0, -1)}`; - shiftFinalNewlineToBeforeNewText = true; - } - if (spliceEnd === rep.alltext.length && - shortOldText.length > 0 && - shortNewText.length === 0) { - // deletion at end of rep.alltext - if (rep.alltext.charAt(spliceStart - 1) === '\n') { - // (if not then what the heck? it will definitely lead - // to a rep.alltext without a final newline) - spliceStart--; - spliceEnd--; - } - } - - if (!(shortOldText.length === 0 && shortNewText.length === 0)) { - const oldDocText = rep.alltext; - const oldLen = oldDocText.length; - - const spliceStartLine = rep.lines.indexOfOffset(spliceStart); - const spliceStartLineStart = rep.lines.offsetOfIndex(spliceStartLine); - - const startBuilder = () => { - const builder = new Builder(oldLen); - builder.keep(spliceStartLineStart, spliceStartLine); - builder.keep(spliceStart - spliceStartLineStart); - return builder; - }; - - const eachAttribRun = (attribs, func /* (startInNewText, endInNewText, attribs)*/) => { - let textIndex = 0; - const newTextStart = commonStart; - const newTextEnd = newText.length - commonEnd - (shiftFinalNewlineToBeforeNewText ? 1 : 0); - for (const op of deserializeOps(attribs)) { - const nextIndex = textIndex + op.chars; - if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { - func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs); - } - textIndex = nextIndex; - } - }; - - const justApplyStyles = (shortNewText === shortOldText); - let theChangeset; - - if (justApplyStyles) { - // create changeset that clears the incorporated styles on - // the existing text. we compose this with the - // changeset the applies the styles found in the DOM. - // This allows us to incorporate, e.g., Safari's native "unbold". - const incorpedAttribClearer = cachedStrFunc( - (oldAtts) => mapAttribNumbers(oldAtts, (n) => { - const k = rep.apool.getAttribKey(n); - if (isStyleAttribute(k)) { - return rep.apool.putAttrib([k, '']); - } - return false; - })); - - const builder1 = startBuilder(); - if (shiftFinalNewlineToBeforeNewText) { - builder1.keep(1, 1); - } - eachAttribRun(oldAttribs, (start, end, attribs) => { - builder1.keepText(newText.substring(start, end), incorpedAttribClearer(attribs)); - }); - const clearer = builder1.toString(); - - const builder2 = startBuilder(); - if (shiftFinalNewlineToBeforeNewText) { - builder2.keep(1, 1); - } - eachAttribRun(newAttribs, (start, end, attribs) => { - builder2.keepText(newText.substring(start, end), attribs); - }); - const styler = builder2.toString(); - - theChangeset = compose(clearer, styler, rep.apool); - } else { - const builder = startBuilder(); - - const spliceEndLine = rep.lines.indexOfOffset(spliceEnd); - const spliceEndLineStart = rep.lines.offsetOfIndex(spliceEndLine); - if (spliceEndLineStart > spliceStart) { - builder.remove(spliceEndLineStart - spliceStart, spliceEndLine - spliceStartLine); - builder.remove(spliceEnd - spliceEndLineStart); - } else { - builder.remove(spliceEnd - spliceStart); - } - - let isNewTextMultiauthor = false; - const authorizer = cachedStrFunc((oldAtts) => { - const attribs = AttributeMap.fromString(oldAtts, rep.apool); - if (!isNewTextMultiauthor || !attribs.has('author')) attribs.set('author', thisAuthor); - return attribs.toString(); - }); - - let foundDomAuthor = ''; - eachAttribRun(newAttribs, (start, end, attribs) => { - const a = AttributeMap.fromString(attribs, rep.apool).get('author'); - if (a && a !== foundDomAuthor) { - if (!foundDomAuthor) { - foundDomAuthor = a; - } else { - isNewTextMultiauthor = true; // multiple authors in DOM! - } - } - }); - - if (shiftFinalNewlineToBeforeNewText) { - builder.insert('\n', authorizer('')); - } - - eachAttribRun(newAttribs, (start, end, attribs) => { - builder.insert(newText.substring(start, end), authorizer(attribs)); - }); - theChangeset = builder.toString(); - } - - doRepApplyChangeset(theChangeset); - } - - // do this no matter what, because we need to get the right - // line keys into the rep. - doRepLineSplice(startLine, deleteCount, newLineEntries); - }; - - const cachedStrFunc = (func) => { - const cache = {}; - return (s) => { - if (!cache[s]) { - cache[s] = func(s); - } - return cache[s]; - }; - }; - - const analyzeChange = ( - oldText, newText, oldAttribs, newAttribs, optSelStartHint, optSelEndHint) => { - // we need to take into account both the styles attributes & attributes defined by - // the plugins, so basically we can ignore only the default line attribs used by - // Etherpad - const incorpedAttribFilter = (anum) => !isDefaultLineAttribute(rep.apool.getAttribKey(anum)); - - const attribRuns = (attribs) => { - const lengs = []; - const atts = []; - for (const op of deserializeOps(attribs)) { - lengs.push(op.chars); - atts.push(op.attribs); - } - return [lengs, atts]; - }; - - const attribIterator = (runs, backward) => { - const lengs = runs[0]; - const atts = runs[1]; - let i = (backward ? lengs.length - 1 : 0); - let j = 0; - const next = () => { - while (j >= lengs[i]) { - if (backward) i--; - else i++; - j = 0; - } - const a = atts[i]; - j++; - return a; - }; - return next; - }; - - const oldLen = oldText.length; - const newLen = newText.length; - const minLen = Math.min(oldLen, newLen); - - const oldARuns = attribRuns(filterAttribNumbers(oldAttribs, incorpedAttribFilter)); - const newARuns = attribRuns(filterAttribNumbers(newAttribs, incorpedAttribFilter)); - - let commonStart = 0; - const oldStartIter = attribIterator(oldARuns, false); - const newStartIter = attribIterator(newARuns, false); - while (commonStart < minLen) { - if (oldText.charAt(commonStart) === newText.charAt(commonStart) && - oldStartIter() === newStartIter()) { - commonStart++; - } else { break; } - } - - let commonEnd = 0; - const oldEndIter = attribIterator(oldARuns, true); - const newEndIter = attribIterator(newARuns, true); - while (commonEnd < minLen) { - if (commonEnd === 0) { - // assume newline in common - oldEndIter(); - newEndIter(); - commonEnd++; - } else if ( - oldText.charAt(oldLen - 1 - commonEnd) === newText.charAt(newLen - 1 - commonEnd) && - oldEndIter() === newEndIter()) { - commonEnd++; - } else { break; } - } - - let hintedCommonEnd = -1; - if ((typeof optSelEndHint) === 'number') { - hintedCommonEnd = newLen - optSelEndHint; - } - - - if (commonStart + commonEnd > oldLen) { - // ambiguous insertion - const minCommonEnd = oldLen - commonStart; - const maxCommonEnd = commonEnd; - if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) { - commonEnd = hintedCommonEnd; - } else { - commonEnd = minCommonEnd; - } - commonStart = oldLen - commonEnd; - } - if (commonStart + commonEnd > newLen) { - // ambiguous deletion - const minCommonEnd = newLen - commonStart; - const maxCommonEnd = commonEnd; - if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) { - commonEnd = hintedCommonEnd; - } else { - commonEnd = minCommonEnd; - } - commonStart = newLen - commonEnd; - } - - return [commonStart, commonEnd]; - }; - - const equalLineAndChars = (a, b) => { - if (!a) return !b; - if (!b) return !a; - return (a[0] === b[0] && a[1] === b[1]); - }; - - const performSelectionChange = (selectStart, selectEnd, focusAtStart) => { - if (repSelectionChange(selectStart, selectEnd, focusAtStart)) { - currentCallStack.selectionAffected = true; - } - }; - editorInfo.ace_performSelectionChange = performSelectionChange; - - // Change the abstract representation of the document to have a different selection. - // Should not rely on the line representation. Should not affect the DOM. - - - const repSelectionChange = (selectStart, selectEnd, focusAtStart) => { - focusAtStart = !!focusAtStart; - - const newSelFocusAtStart = (focusAtStart && ((!selectStart) || - (!selectEnd) || - (selectStart[0] !== selectEnd[0]) || - (selectStart[1] !== selectEnd[1]))); - - if ((!equalLineAndChars(rep.selStart, selectStart)) || - (!equalLineAndChars(rep.selEnd, selectEnd)) || - (rep.selFocusAtStart !== newSelFocusAtStart)) { - rep.selStart = selectStart; - rep.selEnd = selectEnd; - rep.selFocusAtStart = newSelFocusAtStart; - currentCallStack.repChanged = true; - - // select the formatting buttons when there is the style applied on selection - selectFormattingButtonIfLineHasStyleApplied(rep); - - // EventBus: emit editor:selection:changed - if (rep.selStart && rep.selEnd) { - editorBus.emit('editor:selection:changed', { - start: [rep.selStart[0], rep.selStart[1]] as [number, number], - end: [rep.selEnd[0], rep.selEnd[1]] as [number, number], - }); - } - - // we scroll when user places the caret at the last line of the pad - // when this settings is enabled - const docTextChanged = currentCallStack.docTextChanged; - if (!docTextChanged) { - const isScrollableEvent = !isPadLoading(currentCallStack.type) && - isScrollableEditEvent(currentCallStack.type); - const innerHeight = getInnerHeight(); - scroll.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary( - rep, isScrollableEvent, innerHeight * 2); - } - - return true; - } - return false; - }; - - const isPadLoading = (t) => t === 'setup' || t === 'setBaseText' || t === 'importText'; - - const updateStyleButtonState = (attribName, hasStyleOnRepSelection) => { - const formattingButton = document.querySelector(`[data-key="${attribName}"] a`); - formattingButton?.classList.toggle(SELECT_BUTTON_CLASS, hasStyleOnRepSelection); - }; - - const attribIsFormattingStyle = (attribName) => FORMATTING_STYLES.indexOf(attribName) !== -1; - - const selectFormattingButtonIfLineHasStyleApplied = (rep) => { - for (const style of FORMATTING_STYLES) { - const hasStyleOnRepSelection = - documentAttributeManager.hasAttributeOnSelectionOrCaretPosition(style); - updateStyleButtonState(style, hasStyleOnRepSelection); - } - }; - - const doCreateDomLine = - (nonEmpty) => domline.createDomLine(nonEmpty, doesWrap, browser, document); - - const textify = - (str) => str.replace(/[\n\r ]/g, ' ').replace(/\xa0/g, ' ').replace(/\t/g, ' '); - - const _blockElems = { - div: 1, - p: 1, - pre: 1, - li: 1, - ol: 1, - ul: 1, - }; - - // EventBus: allow plugins to register block elements via the bus - const busBlockResult: string[] = []; - editorBus.emit('editor:register:block:elements', {result: busBlockResult}); - for (const element of busBlockResult) _blockElems[element] = 1; - - const isBlockElement = (n) => !!_blockElems[(n.tagName || '').toLowerCase()]; - editorInfo.ace_isBlockElement = isBlockElement; - - const getDirtyRanges = () => { - // based on observedChanges, return a list of ranges of original lines - // that need to be removed or replaced with new user content to incorporate - // the user's changes into the line representation. ranges may be zero-length, - // indicating inserted content. for example, [0,0] means content was inserted - // at the top of the document, while [3,4] means line 3 was deleted, modified, - // or replaced with one or more new lines of content. ranges do not touch. - - const cleanNodeForIndexCache = {}; - const N = rep.lines.length(); // old number of lines - - - const cleanNodeForIndex = (i) => { - // if line (i) in the un-updated line representation maps to a clean node - // in the document, return that node. - // if (i) is out of bounds, return true. else return false. - if (cleanNodeForIndexCache[i] === undefined) { - let result; - if (i < 0 || i >= N) { - result = true; // truthy, but no actual node - } else { - const key = rep.lines.atIndex(i).key; - result = (getCleanNodeByKey(key) || false); - } - cleanNodeForIndexCache[i] = result; - } - return cleanNodeForIndexCache[i]; - }; - const isConsecutiveCache = {}; - - const isConsecutive = (i) => { - if (isConsecutiveCache[i] === undefined) { - isConsecutiveCache[i] = (() => { - // returns whether line (i) and line (i-1), assumed to be map to clean DOM nodes, - // or document boundaries, are consecutive in the changed DOM - const a = cleanNodeForIndex(i - 1); - const b = cleanNodeForIndex(i); - if ((!a) || (!b)) return false; // violates precondition - if ((a === true) && (b === true)) return !targetBody.firstChild; - if ((a === true) && b.previousSibling) return false; - if ((b === true) && a.nextSibling) return false; - if ((a === true) || (b === true)) return true; - return a.nextSibling === b; - })(); - } - return isConsecutiveCache[i]; - }; - - // returns whether line (i) in the un-updated representation maps to a clean node, - // or is outside the bounds of the document - const isClean = (i) => !!cleanNodeForIndex(i); - - // list of pairs, each representing a range of lines that is clean and consecutive - // in the changed DOM. lines (-1) and (N) are always clean, but may or may not - // be consecutive with lines in the document. pairs are in sorted order. - const cleanRanges = [ - [-1, N + 1], - ]; - - // returns index of cleanRange containing i, or -1 if none - const rangeForLine = (i) => { - for (const [idx, r] of cleanRanges.entries()) { - if (i < r[0]) return -1; - if (i < r[1]) return idx; - } - return -1; - }; - - const removeLineFromRange = (rng, line) => { - // rng is index into cleanRanges, line is line number - // precond: line is in rng - const a = cleanRanges[rng][0]; - const b = cleanRanges[rng][1]; - if ((a + 1) === b) cleanRanges.splice(rng, 1); - else if (line === a) cleanRanges[rng][0]++; - else if (line === (b - 1)) cleanRanges[rng][1]--; - else cleanRanges.splice(rng, 1, [a, line], [line + 1, b]); - }; - - const splitRange = (rng, pt) => { - // precond: pt splits cleanRanges[rng] into two non-empty ranges - const a = cleanRanges[rng][0]; - const b = cleanRanges[rng][1]; - cleanRanges.splice(rng, 1, [a, pt], [pt, b]); - }; - - const correctedLines = {}; - - const correctlyAssignLine = (line) => { - if (correctedLines[line]) return true; - correctedLines[line] = true; - // "line" is an index of a line in the un-updated rep. - // returns whether line was already correctly assigned (i.e. correctly - // clean or dirty, according to cleanRanges, and if clean, correctly - // attached or not attached (i.e. in the same range as) the prev and next lines). - const rng = rangeForLine(line); - const lineClean = isClean(line); - if (rng < 0) { - if (lineClean) { - // somehow lost clean line - } - return true; - } - if (!lineClean) { - // a clean-range includes this dirty line, fix it - removeLineFromRange(rng, line); - return false; - } else { - // line is clean, but could be wrongly connected to a clean line - // above or below - const a = cleanRanges[rng][0]; - const b = cleanRanges[rng][1]; - let didSomething = false; - // we'll leave non-clean adjacent nodes in the clean range for the caller to - // detect and deal with. we deal with whether the range should be split - // just above or just below this line. - if (a < line && isClean(line - 1) && !isConsecutive(line)) { - splitRange(rng, line); - didSomething = true; - } - if (b > (line + 1) && isClean(line + 1) && !isConsecutive(line + 1)) { - splitRange(rng, line + 1); - didSomething = true; - } - return !didSomething; - } - }; - - const detectChangesAroundLine = (line, reqInARow) => { - // make sure cleanRanges is correct about line number "line" and the surrounding - // lines; only stops checking at end of document or after no changes need - // making for several consecutive lines. note that iteration is over old lines, - // so this operation takes time proportional to the number of old lines - // that are changed or missing, not the number of new lines inserted. - let correctInARow = 0; - let currentIndex = line; - while (correctInARow < reqInARow && currentIndex >= 0) { - if (correctlyAssignLine(currentIndex)) { - correctInARow++; - } else { correctInARow = 0; } - currentIndex--; - } - correctInARow = 0; - currentIndex = line; - while (correctInARow < reqInARow && currentIndex < N) { - if (correctlyAssignLine(currentIndex)) { - correctInARow++; - } else { correctInARow = 0; } - currentIndex++; - } - }; - - if (N === 0) { - if (!isConsecutive(0)) { - splitRange(0, 0); - } - } else { - detectChangesAroundLine(0, 1); - detectChangesAroundLine(N - 1, 1); - - for (const k of Object.keys(observedChanges.cleanNodesNearChanges)) { - const key = k.substring(1); - if (rep.lines.containsKey(key)) { - const line = rep.lines.indexOfKey(key); - detectChangesAroundLine(line, 2); - } - } - } - - const dirtyRanges = []; - for (let r = 0; r < cleanRanges.length - 1; r++) { - dirtyRanges.push([cleanRanges[r][1], cleanRanges[r + 1][0]]); - } - - return dirtyRanges; - }; - - const markNodeClean = (n) => { - // clean nodes have knownHTML that matches their innerHTML - setAssoc(n, 'dirtiness', {nodeId: uniqueId(n), knownHTML: n.innerHTML}); - }; - - const isNodeDirty = (n) => { - if (n.parentNode !== targetBody) return true; - const data = getAssoc(n, 'dirtiness'); - if (!data) return true; - if (n.id !== data.nodeId) return true; - if (n.innerHTML !== data.knownHTML) return true; - return false; - }; - - const handleClick = (evt) => { - inCallStackIfNecessary('handleClick', () => { - idleWorkTimer.atMost(200); - }); - - const isLink = (n) => (n.tagName || '').toLowerCase() === 'a' && n.href; - - // only want to catch left-click - if ((evt.button !== 2) && (evt.button !== 3)) { - // find A tag with HREF - let n = evt.target; - while (n && n.parentNode && !isLink(n)) { - n = n.parentNode; - } - if (n && isLink(n)) { - try { - window.open(n.href, '_blank', 'noopener,noreferrer'); - if (evt.ctrlKey) window.focus(); - } catch (e) { - // absorb "user canceled" error in IE for certain prompts - } - evt.preventDefault(); - } - } - - hideEditBarDropdowns(); - }; - - const hideEditBarDropdowns = () => { - window.padeditbar.toggleDropDown('none'); - }; - - const renumberList = (lineNum) => { - // 1-check we are in a list - let type = getLineListType(lineNum); - if (!type) { - return null; - } - type = /([a-z]+)[0-9]+/.exec(type); - if (type[1] === 'indent') { - return null; - } - - // 2-find the first line of the list - while (lineNum - 1 >= 0 && (type = getLineListType(lineNum - 1))) { - type = /([a-z]+)[0-9]+/.exec(type); - if (type[1] === 'indent') break; - lineNum--; - } - - // 3-renumber every list item of the same level from the beginning, level 1 - // IMPORTANT: never skip a level because there imbrication may be arbitrary - const builder = new Builder(rep.lines.totalWidth()); - let loc = [0, 0]; - const applyNumberList = (line, level) => { - // init - let position = 1; - let curLevel = level; - let listType; - // loop over the lines - while ((listType = getLineListType(line))) { - // apply new num - listType = /([a-z]+)([0-9]+)/.exec(listType); - curLevel = Number(listType[2]); - if (isNaN(curLevel) || listType[0] === 'indent') { - return line; - } else if (curLevel === level) { - buildKeepRange(rep, builder, loc, (loc = [line, 0])); - buildKeepRange(rep, builder, loc, (loc = [line, 1]), [ - ['start', position], - ], rep.apool); - - position++; - line++; - } else if (curLevel < level) { - return line;// back to parent - } else { - line = applyNumberList(line, level + 1);// recursive call - } - } - return line; - }; - - applyNumberList(lineNum, 1); - const cs = builder.toString(); - if (!isIdentity(cs)) { - performDocumentApplyChangeset(cs); - } - - // 4-apply the modifications - }; - editorInfo.ace_renumberList = renumberList; - - const setLineListType = (lineNum, listType) => { - if (listType === '') { - documentAttributeManager.removeAttributeOnLine(lineNum, listAttributeName); - documentAttributeManager.removeAttributeOnLine(lineNum, 'start'); - } else { - documentAttributeManager.setAttributeOnLine(lineNum, listAttributeName, listType); - } - - // if the list has been removed, it is necessary to renumber - // starting from the *next* line because the list may have been - // separated. If it returns null, it means that the list was not cut, try - // from the current one. - if (renumberList(lineNum + 1) == null) { - renumberList(lineNum); - } - }; - - const doReturnKey = () => { - if (!(rep.selStart && rep.selEnd)) { - return; - } - - const lineNum = rep.selStart[0]; - let listType = getLineListType(lineNum); - - if (listType) { - const text = rep.lines.atIndex(lineNum).text; - listType = /([a-z]+)([0-9]+)/.exec(listType); - const type = listType[1]; - const level = Number(listType[2]); - - // detect empty list item; exclude indentation - if (text === '*' && type !== 'indent') { - // if not already on the highest level - if (level > 1) { - setLineListType(lineNum, type + (level - 1));// automatically decrease the level - } else { - setLineListType(lineNum, '');// remove the list - renumberList(lineNum + 1);// trigger renumbering of list that may be right after - } - } else if (lineNum + 1 <= rep.lines.length()) { - performDocumentReplaceSelection('\n'); - setLineListType(lineNum + 1, type + level); - } - } else { - performDocumentReplaceSelection('\n'); - handleReturnIndentation(); - } - }; - editorInfo.ace_doReturnKey = doReturnKey; - - const doIndentOutdent = (isOut) => { - if (!((rep.selStart && rep.selEnd) || - (rep.selStart[0] === rep.selEnd[0] && - rep.selStart[1] === rep.selEnd[1] && - rep.selEnd[1] > 1)) && - isOut !== true) { - return false; - } - - const firstLine = rep.selStart[0]; - const lastLine = Math.max(firstLine, rep.selEnd[0] - ((rep.selEnd[1] === 0) ? 1 : 0)); - const mods = []; - for (let n = firstLine; n <= lastLine; n++) { - let listType = getLineListType(n); - let t = 'indent'; - let level = 0; - if (listType) { - listType = /([a-z]+)([0-9]+)/.exec(listType); - if (listType) { - t = listType[1]; - level = Number(listType[2]); - } - } - const newLevel = Math.max(0, Math.min(MAX_LIST_LEVEL, level + (isOut ? -1 : 1))); - if (level !== newLevel) { - mods.push([n, (newLevel > 0) ? t + newLevel : '']); - } - } - - for (const mod of mods) setLineListType(mod[0], mod[1]); - return true; - }; - editorInfo.ace_doIndentOutdent = doIndentOutdent; - - const doTabKey = (shiftDown) => { - if (!doIndentOutdent(shiftDown)) { - performDocumentReplaceSelection(THE_TAB); - } - }; - - const doDeleteKey = (optEvt) => { - const evt = optEvt || {}; - let handled = false; - if (rep.selStart) { - if (isCaret()) { - const lineNum = caretLine(); - const col = caretColumn(); - const lineEntry = rep.lines.atIndex(lineNum); - const lineText = lineEntry.text; - const lineMarker = lineEntry.lineMarker; - if (evt.metaKey && col > lineMarker) { - // cmd-backspace deletes to start of line (if not already at start) - performDocumentReplaceRange([lineNum, lineMarker], [lineNum, col], ''); - handled = true; - } else if (/^ +$/.exec(lineText.substring(lineMarker, col))) { - const col2 = col - lineMarker; - const tabSize = THE_TAB.length; - const toDelete = ((col2 - 1) % tabSize) + 1; - performDocumentReplaceRange([lineNum, col - toDelete], [lineNum, col], ''); - handled = true; - } - } - if (!handled) { - if (isCaret()) { - const theLine = caretLine(); - const lineEntry = rep.lines.atIndex(theLine); - if (caretColumn() <= lineEntry.lineMarker) { - // delete at beginning of line - const prevLineListType = (theLine > 0 ? getLineListType(theLine - 1) : ''); - const thisLineListType = getLineListType(theLine); - const prevLineEntry = (theLine > 0 && rep.lines.atIndex(theLine - 1)); - const prevLineBlank = (prevLineEntry && - prevLineEntry.text.length === prevLineEntry.lineMarker); - - const thisLineHasMarker = documentAttributeManager.lineHasMarker(theLine); - - if (thisLineListType) { - // this line is a list - if (prevLineBlank && !prevLineListType) { - // previous line is blank, remove it - performDocumentReplaceRange( - [theLine - 1, prevLineEntry.text.length], [theLine, 0], ''); - } else { - // delistify - performDocumentReplaceRange([theLine, 0], [theLine, lineEntry.lineMarker], ''); - } - } else if (thisLineHasMarker && prevLineEntry) { - // If the line has any attributes assigned, remove them by removing the marker '*' - performDocumentReplaceRange( - [theLine - 1, prevLineEntry.text.length], [theLine, lineEntry.lineMarker], ''); - } else if (theLine > 0) { - // remove newline - performDocumentReplaceRange( - [theLine - 1, prevLineEntry.text.length], [theLine, 0], ''); - } - } else { - const docChar = caretDocChar(); - if (docChar > 0) { - if (evt.metaKey || evt.ctrlKey || evt.altKey) { - // delete as many unicode "letters or digits" in a row as possible; - // always delete one char, delete further even if that first char - // isn't actually a word char. - let deleteBackTo = docChar - 1; - while (deleteBackTo > lineEntry.lineMarker && - isWordChar(rep.alltext.charAt(deleteBackTo - 1))) { - deleteBackTo--; - } - performDocumentReplaceCharRange(deleteBackTo, docChar, ''); - } else { - // normal delete - performDocumentReplaceCharRange(docChar - 1, docChar, ''); - } - } - } - } else { - performDocumentReplaceSelection(''); - } - } - } - // if the list has been removed, it is necessary to renumber - // starting from the *next* line because the list may have been - // separated. If it returns null, it means that the list was not cut, try - // from the current one. - const line = caretLine(); - if (line !== -1 && renumberList(line + 1) == null) { - renumberList(line); - } - }; - - const isWordChar = (c) => padutils.wordCharRegex.test(c); - editorInfo.ace_isWordChar = isWordChar; - - const handleKeyEvent = (evt) => { - if (!isEditable) return; - const {type, charCode, keyCode, which, shiftKey} = evt; - - // If DOM3 support exists, ensure that the left ALT key was pressed. This - // allows keyboard layouts with special meaning for right-alt-char to - // continue working on Firefox / macOS. - let altKey = evt.altKey; - if (typeof evt.location === 'number') { - altKey = altKey && evt.location === KeyboardEvent.DOM_KEY_LOCATION_LEFT; - } - - // Don't take action based on modifier keys going up and down. - // Modifier keys do not generate "keypress" events. - // 224 is the command-key under Mac Firefox. - // 91 is the Windows key in IE; it is ASCII for open-bracket but isn't the keycode for that key - // 20 is capslock in IE. - const isModKey = !charCode && (type === 'keyup' || type === 'keydown') && - (keyCode === 16 || keyCode === 17 || keyCode === 18 || - keyCode === 20 || keyCode === 224 || keyCode === 91); - if (isModKey) return; - - // If the key is a keypress and the browser is opera and the key is enter, - // do nothign at all as this fires twice. - if (keyCode === 13 && browser.opera && type === 'keypress') { - // This stops double enters in Opera but double Tabs still show on single - // tab keypress, adding keyCode == 9 to this doesn't help as the event is fired twice - return; - } - - const isTypeForSpecialKey = browser.safari || browser.chrome || browser.firefox - ? type === 'keydown' : type === 'keypress'; - const isTypeForCmdKey = browser.safari || browser.chrome || browser.firefox - ? type === 'keydown' : type === 'keypress'; - - let stopped = false; - - inCallStackIfNecessary('handleKeyEvent', function () { - if (type === 'keypress' || (isTypeForSpecialKey && keyCode === 13 /* return*/)) { - // in IE, special keys don't send keypress, the keydown does the action - if (!outsideKeyPress(evt)) { - evt.preventDefault(); - stopped = true; - } - } else if (evt.key === 'Dead') { - // If it's a dead key we don't want to do any Etherpad behavior. - stopped = true; - return true; - } else if (type === 'keydown') { - outsideKeyDown(evt); - } - let specialHandled = false; - if (!stopped) { - const padShortcutEnabled = window.clientVars.padShortcutEnabled; - if (!specialHandled && isTypeForSpecialKey && - altKey && keyCode === 120 && - padShortcutEnabled.altF9) { - // Alt F9 focuses on the File Menu and/or editbar. - // Note that while most editors use Alt F10 this is not desirable - // As ubuntu cannot use Alt F10.... - // Focus on the editbar. - // -- TODO: Move Focus back to previous state (we know it so we can use it) - if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); - const firstEditbarElement = document.querySelector('#editbar ul li a button'); - firstEditbarElement?.focus(); - evt.preventDefault(); - } - if (!specialHandled && type === 'keydown' && - altKey && keyCode === 67 && - padShortcutEnabled.altC) { - // Alt c focuses on the Chat window - if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); - window.chat.show(); - document.getElementById('chatinput')?.focus(); - evt.preventDefault(); - } - if (!specialHandled && type === 'keydown' && - evt.ctrlKey && shiftKey && keyCode === 50 && - padShortcutEnabled.cmdShift2) { - // Control-Shift-2 shows a gritter popup showing a line author - const lineNumber = rep.selEnd[0]; - const alineAttrs = rep.alines[lineNumber]; - const apool = rep.apool; - - // TODO: support selection ranges - // TODO: Still work when authorship colors have been cleared - // TODO: i18n - // TODO: There appears to be a race condition or so. - const authorIds = new Set(); - if (alineAttrs) { - for (const op of deserializeOps(alineAttrs)) { - const authorId = AttributeMap.fromString(op.attribs, apool).get('author'); - if (authorId) authorIds.add(authorId); - } - } - const idToName = new Map(window.pad.userList().map((a) => [a.userId, a.name])); - const myId = window.clientVars.userId; - const authors = - [...authorIds].map((id) => id === myId ? 'me' : idToName.get(id) || 'unknown'); - - notifications.add({ - title: 'Line Authors', - text: - authors.length === 0 ? 'No author information is available' - : authors.length === 1 ? `The author of this line is ${authors[0]}` - : `The authors of this line are ${authors.join(' & ')}`, - sticky: false, - time: '4000', - }); - } - if (!specialHandled && isTypeForSpecialKey && - keyCode === 8 && - padShortcutEnabled.delete) { - // "delete" key; in mozilla, if we're at the beginning of a line, normalize now, - // or else deleting a blank line can take two delete presses. - // -- - // we do deletes completely customly now: - // - allows consistent (and better) meta-delete behavior - // - normalizing and then allowing default behavior confused IE - // - probably eliminates a few minor quirks - fastIncorp(3); - evt.preventDefault(); - doDeleteKey(evt); - specialHandled = true; - } - if (!specialHandled && isTypeForSpecialKey && - keyCode === 13 && - padShortcutEnabled.return) { - // return key, handle specially; - // note that in mozilla we need to do an incorporation for proper return behavior anyway. - fastIncorp(4); - evt.preventDefault(); - doReturnKey(); - scheduler.setTimeout(() => { - outerWin.scrollBy(-100, 0); - }, 0); - specialHandled = true; - } - if (!specialHandled && isTypeForSpecialKey && - keyCode === 27 && - padShortcutEnabled.esc) { - // prevent esc key; - // in mozilla versions 14-19 avoid reconnecting pad. - - fastIncorp(4); - evt.preventDefault(); - specialHandled = true; - - // close all gritters when the user hits escape key - notifications.removeAll(); - } - if (!specialHandled && isTypeForCmdKey && - /* Do a saved revision on ctrl S */ - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 's' && - !evt.altKey && - padShortcutEnabled.cmdS) { - evt.preventDefault(); - const revisionLink = document.getElementById('revisionlink'); - const originalBackground = revisionLink instanceof HTMLElement ? revisionLink.style.background : ''; - if (revisionLink instanceof HTMLElement) revisionLink.style.background = 'lightyellow'; - scheduler.setTimeout(() => { - if (revisionLink instanceof HTMLElement) revisionLink.style.background = originalBackground; - }, 1000); - - window.pad.collabClient.sendMessage({type: 'SAVE_REVISION'}); - specialHandled = true; - } - if (!specialHandled && isTypeForSpecialKey && - // tab - keyCode === 9 && - !(evt.metaKey || evt.ctrlKey) && - padShortcutEnabled.tab) { - fastIncorp(5); - evt.preventDefault(); - doTabKey(evt.shiftKey); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-Z (undo) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'z' && - !evt.altKey && - padShortcutEnabled.cmdZ) { - fastIncorp(6); - evt.preventDefault(); - if (evt.shiftKey) { - doUndoRedo('redo'); - } else { - doUndoRedo('undo'); - } - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-Y (redo) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'y' && - padShortcutEnabled.cmdY) { - fastIncorp(10); - evt.preventDefault(); - doUndoRedo('redo'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-B (bold) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'b' && - padShortcutEnabled.cmdB) { - fastIncorp(13); - evt.preventDefault(); - toggleAttributeOnSelection('bold'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-I (italic) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'i' && - padShortcutEnabled.cmdI) { - fastIncorp(14); - evt.preventDefault(); - toggleAttributeOnSelection('italic'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-U (underline) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'u' && - padShortcutEnabled.cmdU) { - fastIncorp(15); - evt.preventDefault(); - toggleAttributeOnSelection('underline'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-5 (strikethrough) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === '5' && - evt.altKey !== true && - padShortcutEnabled.cmd5) { - fastIncorp(13); - evt.preventDefault(); - toggleAttributeOnSelection('strikethrough'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-shift-L (unorderedlist) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'l' && - evt.shiftKey && - padShortcutEnabled.cmdShiftL) { - fastIncorp(9); - evt.preventDefault(); - doInsertUnorderedList(); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-shift-N and cmd-shift-1 (orderedlist) - (evt.metaKey || evt.ctrlKey) && evt.shiftKey && - ((String.fromCharCode(which).toLowerCase() === 'n' && padShortcutEnabled.cmdShiftN) || - (String.fromCharCode(which) === '1' && padShortcutEnabled.cmdShift1))) { - fastIncorp(9); - evt.preventDefault(); - doInsertOrderedList(); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-shift-C (clearauthorship) - (evt.metaKey || evt.ctrlKey) && evt.shiftKey && - String.fromCharCode(which).toLowerCase() === 'c' && - padShortcutEnabled.cmdShiftC) { - fastIncorp(9); - evt.preventDefault(); - CMDS.clearauthorship(); - } - if (!specialHandled && isTypeForCmdKey && - // cmd-H (backspace) - (evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'h' && - padShortcutEnabled.cmdH) { - fastIncorp(20); - evt.preventDefault(); - doDeleteKey(); - specialHandled = true; - } - if (evt.ctrlKey === true && evt.which === 36 && - // Control Home send to Y = 0 - padShortcutEnabled.ctrlHome) { - scroll.setScrollY(0); - } - if ((evt.which === 33 || evt.which === 34) && type === 'keydown' && !evt.ctrlKey) { - // This is required, browsers will try to do normal default behavior on - // page up / down and the default behavior SUCKS - evt.preventDefault(); - const oldVisibleLineRange = scroll.getVisibleLineRange(rep); - let topOffset = rep.selStart[0] - oldVisibleLineRange[0]; - if (topOffset < 0) { - topOffset = 0; - } - - const isPageDown = evt.which === 34; - const isPageUp = evt.which === 33; - - scheduler.setTimeout(() => { - // the visible lines IE 1,10 - const newVisibleLineRange = scroll.getVisibleLineRange(rep); - // total count of lines in pad IE 10 - const linesCount = rep.lines.length(); - // How many lines are in the viewport right now? - const numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0]; - - if (isPageUp && padShortcutEnabled.pageUp) { - // move to the bottom line +1 in the viewport (essentially skipping over a page) - rep.selEnd[0] -= numberOfLinesInViewport; - // move to the bottom line +1 in the viewport (essentially skipping over a page) - rep.selStart[0] -= numberOfLinesInViewport; - } - - // if we hit page down - if (isPageDown && padShortcutEnabled.pageDown) { - // If the new viewpoint position is actually further than where we are right now - if (rep.selEnd[0] >= oldVisibleLineRange[0]) { - // dont go further in the page down than what's visible IE go from 0 to 50 - // if 50 is visible on screen but dont go below that else we miss content - rep.selStart[0] = oldVisibleLineRange[1] - 1; - // dont go further in the page down than what's visible IE go from 0 to 50 - // if 50 is visible on screen but dont go below that else we miss content - rep.selEnd[0] = oldVisibleLineRange[1] - 1; - } - } - - // ensure min and max - if (rep.selEnd[0] < 0) { - rep.selEnd[0] = 0; - } - if (rep.selStart[0] < 0) { - rep.selStart[0] = 0; - } - if (rep.selEnd[0] >= linesCount) { - rep.selEnd[0] = linesCount - 1; - } - updateBrowserSelectionFromRep(); - // get the current caret selection, can't use rep. here because that only gives - // us the start position not the current - const myselection = targetDoc.getSelection(); - // get the carets selection offset in px IE 214 - let caretOffsetTop = myselection.focusNode.parentNode.offsetTop || - myselection.focusNode.offsetTop; - - // sometimes the first selection is -1 which causes problems - // (Especially with ep_page_view) - // so use focusNode.offsetTop value. - if (caretOffsetTop === -1) caretOffsetTop = myselection.focusNode.offsetTop; - // set the scrollY offset of the viewport on the document - scroll.setScrollY(caretOffsetTop); - }, 200); - } - } - - if (type === 'keydown') { - idleWorkTimer.atLeast(500); - } else if (type === 'keypress') { - // OPINION ASKED. What's going on here? :D - if (!specialHandled) { - idleWorkTimer.atMost(0); - } else { - idleWorkTimer.atLeast(500); - } - } else if (type === 'keyup') { - const wait = 0; - idleWorkTimer.atLeast(wait); - idleWorkTimer.atMost(wait); - } - - // Is part of multi-keystroke international character on Firefox Mac - const isFirefoxHalfCharacter = - (browser.firefox && evt.altKey && charCode === 0 && keyCode === 0); - - // Is part of multi-keystroke international character on Safari Mac - const isSafariHalfCharacter = - (browser.safari && evt.altKey && keyCode === 229); - - if (thisKeyDoesntTriggerNormalize || isFirefoxHalfCharacter || isSafariHalfCharacter) { - idleWorkTimer.atLeast(3000); // give user time to type - // if this is a keydown, e.g., the keyup shouldn't trigger a normalize - thisKeyDoesntTriggerNormalize = true; - } - - if (!specialHandled && !thisKeyDoesntTriggerNormalize && !inInternationalComposition && - type !== 'keyup') { - observeChangesAroundSelection(); - } - - if (type === 'keyup') { - thisKeyDoesntTriggerNormalize = false; - } - }); - }; - - let thisKeyDoesntTriggerNormalize = false; - - const doUndoRedo = (which) => { - // precond: normalized DOM - if (undoModule.enabled) { - let whichMethod; - if (which === 'undo') whichMethod = 'performUndo'; - if (which === 'redo') whichMethod = 'performRedo'; - if (whichMethod) { - const oldEventType = currentCallStack.editEvent.eventType; - currentCallStack.startNewEvent(which); - undoModule[whichMethod]((backset, selectionInfo) => { - if (backset) { - performDocumentApplyChangeset(backset); - } - if (selectionInfo) { - performSelectionChange( - lineAndColumnFromChar(selectionInfo.selStart), - lineAndColumnFromChar(selectionInfo.selEnd), - selectionInfo.selFocusAtStart); - } - const oldEvent = currentCallStack.startNewEvent(oldEventType, true); - return oldEvent; - }); - } - } - }; - editorInfo.ace_doUndoRedo = doUndoRedo; - - const setSelection = (selection) => { - const copyPoint = (pt) => ({ - node: pt.node, - index: pt.index, - maxIndex: pt.maxIndex, - }); - let isCollapsed; - - const pointToRangeBound = (pt) => { - const p = copyPoint(pt); - // Make sure Firefox cursor is deep enough; fixes cursor jumping when at top level, - // and also problem where cut/copy of a whole line selected with fake arrow-keys - // copies the next line too. - if (isCollapsed) { - const diveDeep = () => { - while (p.node.childNodes.length > 0) { - if (p.index === 0) { - p.node = p.node.firstChild; - p.maxIndex = nodeMaxIndex(p.node); - } else if (p.index === p.maxIndex) { - p.node = p.node.lastChild; - p.maxIndex = nodeMaxIndex(p.node); - p.index = p.maxIndex; - } else { break; } - } - }; - // now fix problem where cursor at end of text node at end of span-like element - // with background doesn't seem to show up... - if (isNodeText(p.node) && p.index === p.maxIndex) { - let n = p.node; - while (!n.nextSibling && n !== targetBody && n.parentNode !== targetBody) { - n = n.parentNode; - } - if (n.nextSibling && - !(typeof n.nextSibling.tagName === 'string' && - n.nextSibling.tagName.toLowerCase() === 'br') && - n !== p.node && n !== targetBody && n.parentNode !== targetBody) { - // found a parent, go to next node and dive in - p.node = n.nextSibling; - p.maxIndex = nodeMaxIndex(p.node); - p.index = 0; - diveDeep(); - } - } - // try to make sure insertion point is styled; - // also fixes other FF problems - if (!isNodeText(p.node)) { - diveDeep(); - } - } - if (isNodeText(p.node)) { - return { - container: p.node, - offset: p.index, - }; - } else { - // p.index in {0,1} - return { - container: p.node.parentNode, - offset: childIndex(p.node) + p.index, - }; - } - }; - const browserSelection = targetDoc.getSelection(); - if (browserSelection) { - browserSelection.removeAllRanges(); - if (selection) { - isCollapsed = (selection.startPoint.node === selection.endPoint.node && - selection.startPoint.index === selection.endPoint.index); - const start = pointToRangeBound(selection.startPoint); - const end = pointToRangeBound(selection.endPoint); - - if (!isCollapsed && selection.focusAtStart && - browserSelection.collapse && browserSelection.extend) { - // can handle "backwards"-oriented selection, shift-arrow-keys move start - // of selection - browserSelection.collapse(end.container, end.offset); - browserSelection.extend(start.container, start.offset); - } else { - const range = document.createRange(); - range.setStart(start.container, start.offset); - range.setEnd(end.container, end.offset); - browserSelection.removeAllRanges(); - browserSelection.addRange(range); - } - } - } - }; - - const updateBrowserSelectionFromRep = () => { - // requires normalized DOM! - const selStart = rep.selStart; - const selEnd = rep.selEnd; - - if (!(selStart && selEnd)) { - setSelection(null); - return; - } - - const selection = {}; - - const ss = [selStart[0], selStart[1]]; - selection.startPoint = getPointForLineAndChar(ss); - - const se = [selEnd[0], selEnd[1]]; - selection.endPoint = getPointForLineAndChar(se); - - selection.focusAtStart = !!rep.selFocusAtStart; - setSelection(selection); - }; - editorInfo.ace_updateBrowserSelectionFromRep = updateBrowserSelectionFromRep; - editorInfo.ace_focus = focus; - editorInfo.ace_importText = importText; - editorInfo.ace_importAText = importAText; - editorInfo.ace_exportText = exportText; - editorInfo.ace_editorChangedSize = editorChangedSize; - editorInfo.ace_setOnKeyPress = setOnKeyPress; - editorInfo.ace_setOnKeyDown = setOnKeyDown; - editorInfo.ace_setNotifyDirty = setNotifyDirty; - editorInfo.ace_dispose = dispose; - editorInfo.ace_setEditable = setEditable; - editorInfo.ace_execCommand = execCommand; - editorInfo.ace_replaceRange = replaceRange; - editorInfo.ace_getAuthorInfos = getAuthorInfos; - editorInfo.ace_performDocumentReplaceRange = performDocumentReplaceRange; - editorInfo.ace_performDocumentReplaceCharRange = performDocumentReplaceCharRange; - editorInfo.ace_setSelection = setSelection; - - const nodeMaxIndex = (nd) => { - if (isNodeText(nd)) return nd.nodeValue.length; - else return 1; - }; - - const getSelection = () => { - // returns null, or a structure containing startPoint and endPoint, - // each of which has node (a magicdom node), index, and maxIndex. If the node - // is a text node, maxIndex is the length of the text; else maxIndex is 1. - // index is between 0 and maxIndex, inclusive. - const browserSelection = targetDoc.getSelection(); - if (!browserSelection || browserSelection.type === 'None' || - browserSelection.rangeCount === 0) { - return null; - } - const range = browserSelection.getRangeAt(0); - - const isInBody = (n) => { - while (n && !(n.tagName && n.tagName.toLowerCase() === 'body')) { - n = n.parentNode; - } - return !!n; - }; - - const pointFromRangeBound = (container, offset) => { - if (!isInBody(container)) { - // command-click in Firefox selects whole document, HEAD and BODY! - return { - node: targetBody, - index: 0, - maxIndex: 1, - }; - } - const n = container; - const childCount = n.childNodes.length; - if (isNodeText(n)) { - return { - node: n, - index: offset, - maxIndex: n.nodeValue.length, - }; - } else if (childCount === 0) { - return { - node: n, - index: 0, - maxIndex: 1, - }; - // treat point between two nodes as BEFORE the second (rather than after the first) - // if possible; this way point at end of a line block-element is treated as - // at beginning of next line - } else if (offset === childCount) { - const nd = n.childNodes.item(childCount - 1); - const max = nodeMaxIndex(nd); - return { - node: nd, - index: max, - maxIndex: max, - }; - } else { - const nd = n.childNodes.item(offset); - const max = nodeMaxIndex(nd); - return { - node: nd, - index: 0, - maxIndex: max, - }; - } - }; - const selection = { - startPoint: pointFromRangeBound(range.startContainer, range.startOffset), - endPoint: pointFromRangeBound(range.endContainer, range.endOffset), - focusAtStart: - (range.startContainer !== range.endContainer || range.startOffset !== range.endOffset) && - browserSelection.anchorNode && - browserSelection.anchorNode === range.endContainer && - browserSelection.anchorOffset === range.endOffset, - }; - - if (selection.startPoint.node.ownerDocument !== targetDoc) { - return null; - } - - return selection; - }; - - const childIndex = (n) => { - let idx = 0; - while (n.previousSibling) { - idx++; - n = n.previousSibling; - } - return idx; - }; - - const fixView = () => { - // calling this method repeatedly should be fast - if (getInnerWidth() === 0 || getInnerHeight() === 0) { - return; - } - - enforceEditability(); - - sideDiv.classList.add('sidedivdelayed'); - }; - - const _teardownActions = []; - - const teardown = () => { for (const a of _teardownActions) a(); }; - - let inInternationalComposition = null; - editorInfo.ace_getInInternationalComposition = () => inInternationalComposition; - - const isLinkTarget = (target) => - target instanceof Element && (target.localName === 'a' || target.closest('a') != null); - - const bindTheEventHandlers = () => { - targetDoc.addEventListener('keydown', handleKeyEvent); - targetDoc.addEventListener('keypress', handleKeyEvent); - targetDoc.addEventListener('keyup', handleKeyEvent); - targetDoc.addEventListener('click', handleClick); - // dropdowns on edit bar need to be closed on clicks on both pad inner and pad outer - outerDoc.addEventListener('click', hideEditBarDropdowns); - - // If non-nullish, pasting on a link should be suppressed. - let suppressPasteOnLink = null; - - targetBody.addEventListener('auxclick', (e) => { - if (e.button === 1 && isLinkTarget(e.target)) { - // The user middle-clicked on a link. Usually users do this to open a link in a new tab, but - // in X11 (Linux) this will instead paste the contents of the primary selection at the mouse - // cursor. Users almost certainly do not want to paste when middle-clicking on a link, so - // tell the 'paste' event handler to suppress the paste. This is done by starting a - // short-lived timer that suppresses paste (when the target is a link) until either the - // paste event arrives or the timer fires. - // - // Why it is implemented this way: - // * Users want to be able to paste on a link via Ctrl-V, the Edit menu, or the context - // menu (https://github.com/ether/etherpad-lite/issues/2775) so we cannot simply - // suppress all paste actions when the target is a link. - // * Non-X11 systems do not paste when the user middle-clicks, so the paste suppression - // must be self-resetting. - // * On non-X11 systems, middle click should continue to open the link in a new tab. - // Suppressing the middle click here in the 'auxclick' handler (via e.preventDefault()) - // would break that behavior. - suppressPasteOnLink = scheduler.setTimeout(() => { suppressPasteOnLink = null; }, 0); - } - }); - - targetBody.addEventListener('paste', (e) => { - if (suppressPasteOnLink != null && isLinkTarget(e.target)) { - scheduler.clearTimeout(suppressPasteOnLink); - suppressPasteOnLink = null; - e.preventDefault(); - return; - } - - // EventBus: notify listeners about paste events - editorBus.emit('custom:ace:paste', { - editorInfo, - rep, - documentAttributeManager, - e, - }); - }); - - // We reference document here, this is because if we don't this will expose a bug - // in Google Chrome. This bug will cause the last character on the last line to - // not fire an event when dropped into.. - targetBody.addEventListener('drop', (e) => { - if (isLinkTarget(e.target)) { - e.preventDefault(); - } - - // Bug fix: when user drags some content and drop it far from its origin, we - // need to merge the changes into a single changeset. So mark origin with -
- ${colors.map((color, i) => this._renderSwatch(color, i)).join('')} -
- `; - - // Attach event listeners. - const swatches = this._shadow.querySelectorAll('.swatch'); - swatches.forEach((swatch) => { - swatch.addEventListener('click', () => { - const color = swatch.dataset.color!; - const index = parseInt(swatch.dataset.index!, 10); - this._selectColor(color, index); - }); - - swatch.addEventListener('keydown', (e: KeyboardEvent) => { - this._handleSwatchKeydown(e, swatches); - }); - }); - } - - private _renderSwatch(color: string, index: number): string { - const isSelected = this._value === color; - const light = isLightColor(color); - const checkColor = light ? '#000' : '#fff'; - - return ` - - `; - } - - private _selectColor(color: string, index: number): void { - this._value = color; - this.setAttribute('value', color); - this._updateSelection(); - - this.dispatchEvent( - new CustomEvent('ep-color-select', { - bubbles: true, - composed: true, - detail: {color, index}, - }), - ); - } - - private _updateSelection(): void { - const swatches = this._shadow.querySelectorAll('.swatch'); - swatches.forEach((swatch) => { - const isSelected = swatch.dataset.color === this._value; - swatch.setAttribute('aria-selected', String(isSelected)); - }); - } - - private _handleSwatchKeydown(e: KeyboardEvent, swatches: NodeListOf): void { - const current = e.currentTarget as HTMLElement; - const items = Array.from(swatches); - const idx = items.indexOf(current); - let nextIdx = -1; - - switch (e.key) { - case 'ArrowRight': - case 'ArrowDown': - nextIdx = (idx + 1) % items.length; - break; - case 'ArrowLeft': - case 'ArrowUp': - nextIdx = (idx - 1 + items.length) % items.length; - break; - case 'Home': - nextIdx = 0; - break; - case 'End': - nextIdx = items.length - 1; - break; - case 'Enter': - case ' ': - e.preventDefault(); - current.click(); - return; - default: - return; - } - - e.preventDefault(); - items[idx].setAttribute('tabindex', '-1'); - items[nextIdx].setAttribute('tabindex', '0'); - items[nextIdx].focus(); - } - - private _escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - private _escapeAttr(text: string): string { - return text.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''') - .replace(//g, '>'); - } -} - -customElements.define('ep-color-picker', EpColorPicker); diff --git a/ui/src/js/components/EpDropdown.ts b/ui/src/js/components/EpDropdown.ts deleted file mode 100644 index eacc4f34..00000000 --- a/ui/src/js/components/EpDropdown.ts +++ /dev/null @@ -1,511 +0,0 @@ -/** - * EpDropdown + EpDropdownItem — Dropdown menu Web Components for toolbar selects. - * - * Usage: - * - * - *
- * 12px - * 14px - *
- *
- */ - -/* ── Dropdown Item ─────────────────────────────────────────── */ - -const dropdownItemStyles = /* css */ ` - :host { - --ep-item-fg: #485365; - --ep-item-fg: var(--text-color, #485365); - --ep-item-hover-bg: #f2f3f4; - --ep-item-hover-bg: var(--bg-soft-color, #f2f3f4); - --ep-item-font: var(--main-font-family, Quicksand, Cantarell, "Open Sans", "Helvetica Neue", sans-serif); - --ep-item-focus: #64d29b; - --ep-item-focus: var(--primary-color, #64d29b); - - display: block; - font-family: var(--ep-item-font); - font-size: 14px; - } - - .item { - display: flex; - align-items: center; - width: 100%; - padding: 8px 12px; - border: none; - background: none; - color: var(--ep-item-fg); - cursor: pointer; - font: inherit; - text-align: left; - white-space: nowrap; - transition: background 0.1s ease; - outline: none; - box-sizing: border-box; - } - - .item:hover, - .item[aria-selected="true"], - :host([focused]) .item { - background: var(--ep-item-hover-bg); - } - - .item:focus-visible { - background: var(--ep-item-hover-bg); - outline: 2px solid var(--ep-item-focus); - outline-offset: -2px; - } - - :host([disabled]) .item { - opacity: 0.4; - cursor: not-allowed; - } -`; - -export class EpDropdownItem extends HTMLElement { - static observedAttributes = ['value', 'disabled']; - - private _shadow: ShadowRoot; - - constructor() { - super(); - this._shadow = this.attachShadow({mode: 'open'}); - } - - connectedCallback(): void { - this._render(); - this.setAttribute('role', 'option'); - } - - attributeChangedCallback(): void { - this._updateState(); - } - - get value(): string { - return this.getAttribute('value') ?? ''; - } - - set value(v: string) { - this.setAttribute('value', v); - } - - get disabled(): boolean { - return this.hasAttribute('disabled'); - } - - set disabled(v: boolean) { - if (v) { - this.setAttribute('disabled', ''); - } else { - this.removeAttribute('disabled'); - } - } - - /** Called by the parent dropdown to visually mark this item as focused. */ - setFocused(focused: boolean): void { - if (focused) { - this.setAttribute('focused', ''); - } else { - this.removeAttribute('focused'); - } - } - - private _render(): void { - this._shadow.innerHTML = ` - - - `; - } - - private _updateState(): void { - const itemEl = this._shadow.querySelector('.item'); - if (itemEl && this.disabled) { - itemEl.setAttribute('aria-disabled', 'true'); - } else if (itemEl) { - itemEl.removeAttribute('aria-disabled'); - } - } -} - -customElements.define('ep-dropdown-item', EpDropdownItem); - -/* ── Dropdown Container ────────────────────────────────────── */ - -const dropdownStyles = /* css */ ` - :host { - --ep-dd-bg: #fff; - --ep-dd-bg: var(--bg-color, #fff); - --ep-dd-border: #d2d2d2; - --ep-dd-border: var(--border-color, #d2d2d2); - --ep-dd-radius: 8px; - --ep-dd-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); - --ep-dd-font: var(--main-font-family, Quicksand, Cantarell, "Open Sans", "Helvetica Neue", sans-serif); - - display: inline-block; - position: relative; - font-family: var(--ep-dd-font); - font-size: 14px; - } - - .trigger-wrapper { - display: inline-flex; - } - - .content-wrapper { - display: none; - position: fixed; - top: 0; - left: 0; - min-width: 140px; - max-height: 280px; - overflow-y: auto; - background: var(--ep-dd-bg); - border: 1px solid var(--ep-dd-border); - border-radius: var(--ep-dd-radius); - box-shadow: var(--ep-dd-shadow); - z-index: 9999; - padding: 4px 0; - opacity: 0; - transform: translateY(-4px); - transition: opacity 0.15s ease, transform 0.15s ease; - } - - :host([open]) .content-wrapper { - display: block; - } - - :host([open]) .content-wrapper.visible { - opacity: 1; - transform: translateY(0); - } - - :host([align="right"]) .content-wrapper { - right: 0; - left: auto; - } - - :host([align="left"]) .content-wrapper, - :host(:not([align])) .content-wrapper { - left: 0; - right: auto; - } -`; - -type TriggerMode = 'click' | 'hover'; - -export class EpDropdown extends HTMLElement { - static observedAttributes = ['trigger', 'align', 'open']; - - private _shadow: ShadowRoot; - private _focusIndex = -1; - private _hoverCloseTimer: ReturnType | null = null; - - /* Bound handlers for clean add/remove */ - private _onDocClick = this._handleOutsideClick.bind(this); - private _onDocKeydown = this._handleDocKeydown.bind(this); - private _onViewportChange = this._positionContent.bind(this); - - constructor() { - super(); - this._shadow = this.attachShadow({mode: 'open'}); - } - - /* ── Lifecycle ────────────────────────────────────────────── */ - - connectedCallback(): void { - this._render(); - this._attachTriggerEvents(); - - // Listen for item clicks from slotted content. - this.addEventListener('click', (e: Event) => { - const target = e.target; - if (target instanceof EpDropdownItem && !target.disabled) { - this._selectItem(target); - } - }); - } - - disconnectedCallback(): void { - document.removeEventListener('click', this._onDocClick, true); - document.removeEventListener('keydown', this._onDocKeydown); - window.removeEventListener('resize', this._onViewportChange); - window.removeEventListener('scroll', this._onViewportChange, true); - if (this._hoverCloseTimer != null) clearTimeout(this._hoverCloseTimer); - } - - attributeChangedCallback(name: string, _old: string | null, _next: string | null): void { - if (name === 'open') { - if (this.isOpen) { - this._onOpened(); - } else { - this._onClosed(); - } - } - } - - /* ── Properties ───────────────────────────────────────────── */ - - get triggerMode(): TriggerMode { - return this.getAttribute('trigger') === 'hover' ? 'hover' : 'click'; - } - - set triggerMode(v: TriggerMode) { - this.setAttribute('trigger', v); - } - - get align(): 'left' | 'right' { - return this.getAttribute('align') === 'right' ? 'right' : 'left'; - } - - set align(v: 'left' | 'right') { - this.setAttribute('align', v); - } - - get isOpen(): boolean { - return this.hasAttribute('open'); - } - - set isOpen(v: boolean) { - if (v) { - this.setAttribute('open', ''); - } else { - this.removeAttribute('open'); - } - } - - /* ── Public ───────────────────────────────────────────────── */ - - toggle(): void { - this.isOpen = !this.isOpen; - } - - open(): void { - this.isOpen = true; - } - - close(): void { - this.isOpen = false; - } - - /* ── Private ──────────────────────────────────────────────── */ - - private _render(): void { - this._shadow.innerHTML = ` - -
- -
-
- -
- `; - } - - private _attachTriggerEvents(): void { - const triggerSlot = this._shadow.querySelector('slot[name="trigger"]') as HTMLSlotElement; - const contentWrapper = this._shadow.querySelector('.content-wrapper') as HTMLElement | null; - - const preserveEditorSelection = (e: MouseEvent) => { - // Toolbar clicks should not steal focus from the ACE iframe before the command runs. - e.preventDefault(); - }; - - triggerSlot?.addEventListener('mousedown', preserveEditorSelection); - contentWrapper?.addEventListener('mousedown', preserveEditorSelection); - - if (this.triggerMode === 'click') { - triggerSlot?.addEventListener('click', (e: Event) => { - e.stopPropagation(); - this.toggle(); - }); - } else { - // Hover mode. - this.addEventListener('mouseenter', () => { - if (this._hoverCloseTimer != null) { - clearTimeout(this._hoverCloseTimer); - this._hoverCloseTimer = null; - } - this.open(); - }); - - this.addEventListener('mouseleave', () => { - this._hoverCloseTimer = setTimeout(() => this.close(), 200); - }); - - // Also allow click to toggle in hover mode. - triggerSlot?.addEventListener('click', (e: Event) => { - e.stopPropagation(); - this.toggle(); - }); - } - } - - private _onOpened(): void { - this._focusIndex = -1; - this._clearItemFocus(); - this._positionContent(); - - // Animate in. - requestAnimationFrame(() => { - const content = this._shadow.querySelector('.content-wrapper'); - this._positionContent(); - content?.classList.add('visible'); - }); - - document.addEventListener('click', this._onDocClick, true); - document.addEventListener('keydown', this._onDocKeydown); - window.addEventListener('resize', this._onViewportChange); - window.addEventListener('scroll', this._onViewportChange, true); - } - - private _onClosed(): void { - const content = this._shadow.querySelector('.content-wrapper'); - content?.classList.remove('visible'); - this._focusIndex = -1; - this._clearItemFocus(); - - document.removeEventListener('click', this._onDocClick, true); - document.removeEventListener('keydown', this._onDocKeydown); - window.removeEventListener('resize', this._onViewportChange); - window.removeEventListener('scroll', this._onViewportChange, true); - } - - private _positionContent(): void { - const content = this._shadow.querySelector('.content-wrapper') as HTMLElement | null; - if (!content || !this.isOpen) return; - - const hostRect = this.getBoundingClientRect(); - const viewportPadding = 8; - const gap = 4; - - content.style.minWidth = `${Math.max(140, Math.ceil(hostRect.width))}px`; - content.style.maxWidth = `${Math.max(140, window.innerWidth - (viewportPadding * 2))}px`; - - const contentRect = content.getBoundingClientRect(); - const width = Math.max(contentRect.width, Math.ceil(hostRect.width), 140); - const height = contentRect.height; - - let left = this.align === 'right' ? hostRect.right - width : hostRect.left; - left = Math.min(Math.max(viewportPadding, left), Math.max(viewportPadding, window.innerWidth - width - viewportPadding)); - - let top = hostRect.bottom + gap; - if (top + height > window.innerHeight - viewportPadding) { - top = Math.max(viewportPadding, hostRect.top - height - gap); - } - - content.style.left = `${Math.round(left)}px`; - content.style.top = `${Math.round(top)}px`; - } - - private _handleOutsideClick(e: Event): void { - if (!this.isOpen) return; - const path = e.composedPath(); - if (!path.includes(this)) { - this.close(); - } - } - - private _handleDocKeydown(e: KeyboardEvent): void { - if (!this.isOpen) return; - - switch (e.key) { - case 'Escape': - e.preventDefault(); - this.close(); - // Return focus to trigger. - const triggerEl = this.querySelector('[slot="trigger"]'); - triggerEl?.focus(); - break; - - case 'ArrowDown': - e.preventDefault(); - this._moveFocus(1); - break; - - case 'ArrowUp': - e.preventDefault(); - this._moveFocus(-1); - break; - - case 'Home': - e.preventDefault(); - this._setFocusIndex(0); - break; - - case 'End': { - e.preventDefault(); - const items = this._getItems(); - this._setFocusIndex(items.length - 1); - break; - } - - case 'Enter': - case ' ': { - e.preventDefault(); - const items = this._getItems(); - if (this._focusIndex >= 0 && this._focusIndex < items.length) { - const item = items[this._focusIndex]; - if (!item.disabled) this._selectItem(item); - } - break; - } - } - } - - private _getItems(): EpDropdownItem[] { - return Array.from(this.querySelectorAll('ep-dropdown-item')); - } - - private _moveFocus(direction: number): void { - const items = this._getItems(); - if (items.length === 0) return; - - let nextIdx = this._focusIndex + direction; - // Wrap around. - if (nextIdx < 0) nextIdx = items.length - 1; - if (nextIdx >= items.length) nextIdx = 0; - - // Skip disabled items. - const startIdx = nextIdx; - while (items[nextIdx].disabled) { - nextIdx += direction; - if (nextIdx < 0) nextIdx = items.length - 1; - if (nextIdx >= items.length) nextIdx = 0; - if (nextIdx === startIdx) return; // All disabled. - } - - this._setFocusIndex(nextIdx); - } - - private _setFocusIndex(index: number): void { - const items = this._getItems(); - this._clearItemFocus(); - this._focusIndex = index; - if (index >= 0 && index < items.length) { - items[index].setFocused(true); - items[index].scrollIntoView({block: 'nearest'}); - } - } - - private _clearItemFocus(): void { - for (const item of this._getItems()) { - item.setFocused(false); - } - } - - private _selectItem(item: EpDropdownItem): void { - this.dispatchEvent( - new CustomEvent('ep-dropdown-select', { - bubbles: true, - composed: true, - detail: {value: item.value}, - }), - ); - this.close(); - } -} - -customElements.define('ep-dropdown', EpDropdown); diff --git a/ui/src/js/components/EpModal.ts b/ui/src/js/components/EpModal.ts deleted file mode 100644 index 430af4bb..00000000 --- a/ui/src/js/components/EpModal.ts +++ /dev/null @@ -1,514 +0,0 @@ -/** - * EpModal — A generic modal/dialog Web Component. - * - * Usage: - * - *

Are you sure?

- *
- * - * - *
- *
- * - * Static helpers: - * const ok = await EpModal.confirm({ title, message }) - * const val = await EpModal.prompt({ title, message, placeholder }) - */ - -const modalStyles = /* css */ ` - :host { - --ep-modal-bg: #fff; - --ep-modal-fg: #171717; - --ep-modal-border: #e5e5e5; - --ep-modal-overlay: rgba(0, 0, 0, 0.5); - --ep-modal-radius: 12px; - --ep-modal-shadow: 0 16px 48px rgba(0, 0, 0, 0.12); - --ep-modal-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; - - position: fixed; - inset: 0; - z-index: 10001; - display: none; - align-items: center; - justify-content: center; - font-family: var(--ep-modal-font); - font-size: 14px; - color: var(--ep-modal-fg); - } - - @media (prefers-color-scheme: dark) { - :host { - --ep-modal-bg: #1a1a1a; - --ep-modal-fg: #e5e5e5; - --ep-modal-border: #333; - --ep-modal-overlay: rgba(0, 0, 0, 0.7); - --ep-modal-shadow: 0 16px 48px rgba(0, 0, 0, 0.4); - } - } - - :host([open]) { - display: flex; - } - - .overlay { - position: fixed; - inset: 0; - background: var(--ep-modal-overlay); - animation: ep-modal-fade-in 0.15s ease; - } - - .dialog { - position: relative; - z-index: 1; - background: var(--ep-modal-bg); - border-radius: var(--ep-modal-radius); - box-shadow: var(--ep-modal-shadow); - max-width: 480px; - width: calc(100vw - 32px); - max-height: calc(100vh - 64px); - overflow: auto; - animation: ep-modal-scale-in 0.2s ease; - outline: none; - } - - .header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px 0; - } - - .title { - margin: 0; - font-size: 16px; - font-weight: 600; - line-height: 1.4; - } - - .close-btn { - background: none; - border: none; - cursor: pointer; - padding: 4px; - margin: -4px -4px 0 8px; - color: var(--ep-modal-fg); - opacity: 0.5; - transition: opacity 0.15s ease; - font-size: 18px; - line-height: 1; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - } - - .close-btn:hover, - .close-btn:focus-visible { - opacity: 1; - } - - .close-btn:focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; - } - - .body { - padding: 16px 20px; - line-height: 1.6; - } - - .actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - padding: 0 20px 16px; - } - - .actions ::slotted(button), - .actions button { - padding: 8px 16px; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease; - font-family: inherit; - line-height: 1; - } - - /* Built-in button styles for static helpers */ - .btn-cancel { - background: transparent; - border: 1px solid var(--ep-modal-border); - color: var(--ep-modal-fg); - } - - .btn-cancel:hover { - background: rgba(128, 128, 128, 0.1); - } - - .btn-confirm { - background: #171717; - border: 1px solid #171717; - color: #fff; - } - - .btn-confirm:hover { - background: #333; - } - - @media (prefers-color-scheme: dark) { - .btn-confirm { - background: #e5e5e5; - border-color: #e5e5e5; - color: #000; - } - .btn-confirm:hover { - background: #ccc; - } - } - - .prompt-input { - width: 100%; - box-sizing: border-box; - padding: 8px 12px; - border: 1px solid var(--ep-modal-border); - border-radius: 6px; - font-size: 14px; - font-family: inherit; - background: var(--ep-modal-bg); - color: var(--ep-modal-fg); - margin-top: 12px; - outline: none; - transition: border-color 0.15s ease; - } - - .prompt-input:focus { - border-color: #666; - } - - @keyframes ep-modal-fade-in { - from { opacity: 0; } - to { opacity: 1; } - } - - @keyframes ep-modal-scale-in { - from { opacity: 0; transform: scale(0.96); } - to { opacity: 1; transform: scale(1); } - } -`; - -interface ConfirmOptions { - title: string; - message: string; - confirmText?: string; - cancelText?: string; -} - -interface PromptOptions { - title: string; - message: string; - placeholder?: string; -} - -export class EpModal extends HTMLElement { - static observedAttributes = ['open', 'title']; - - private _shadow: ShadowRoot; - private _previousFocus: HTMLElement | null = null; - private _resolvePromise: ((value: unknown) => void) | null = null; - - /* bound handlers for clean add/remove */ - private _onKeyDown = this._handleKeyDown.bind(this); - - constructor() { - super(); - this._shadow = this.attachShadow({mode: 'open'}); - } - - /* ── Lifecycle ────────────────────────────────────────────── */ - - connectedCallback(): void { - this._render(); - if (this.hasAttribute('open')) { - this._onOpen(); - } - } - - disconnectedCallback(): void { - document.removeEventListener('keydown', this._onKeyDown); - } - - attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null): void { - if (name === 'open') { - if (newVal != null) { - this._onOpen(); - } else { - this._onClose(); - } - } - if (name === 'title') { - const titleEl = this._shadow.querySelector('.title'); - if (titleEl) titleEl.textContent = this.modalTitle; - } - } - - /* ── Properties ───────────────────────────────────────────── */ - - get modalTitle(): string { - return this.getAttribute('title') ?? ''; - } - - set modalTitle(v: string) { - this.setAttribute('title', v); - } - - get open(): boolean { - return this.hasAttribute('open'); - } - - set open(v: boolean) { - if (v) { - this.setAttribute('open', ''); - } else { - this.removeAttribute('open'); - } - } - - /* ── Public ───────────────────────────────────────────────── */ - - close(action?: string): void { - this.dispatchEvent( - new CustomEvent('ep-modal-close', {bubbles: true, composed: true, detail: {action}}), - ); - this.open = false; - } - - /* ── Static helpers ───────────────────────────────────────── */ - - static confirm(options: ConfirmOptions): Promise { - return new Promise((resolve) => { - const modal = document.createElement('ep-modal') as EpModal; - modal.setAttribute('title', options.title); - modal._resolvePromise = resolve as (v: unknown) => void; - - // Build internal DOM (bypass slots for programmatic usage) - const bodyContent = document.createElement('p'); - bodyContent.textContent = options.message; - bodyContent.style.margin = '0'; - modal.appendChild(bodyContent); - - const actionsDiv = document.createElement('div'); - actionsDiv.setAttribute('slot', 'actions'); - - const cancelBtn = document.createElement('button'); - cancelBtn.textContent = options.cancelText ?? 'Cancel'; - cancelBtn.setAttribute('data-action', 'cancel'); - - const confirmBtn = document.createElement('button'); - confirmBtn.textContent = options.confirmText ?? 'Confirm'; - confirmBtn.setAttribute('data-action', 'confirm'); - - actionsDiv.append(cancelBtn, confirmBtn); - modal.appendChild(actionsDiv); - document.body.appendChild(modal); - - // Style buttons after they render in the shadow DOM - requestAnimationFrame(() => { - const shadowActions = modal._shadow.querySelectorAll('.actions button'); - // No shadow buttons for slotted content; handle via action events - }); - - modal.addEventListener('ep-modal-action', ((e: CustomEvent) => { - const confirmed = e.detail?.action === 'confirm'; - resolve(confirmed); - modal.remove(); - }) as EventListener); - - modal.addEventListener('ep-modal-close', () => { - resolve(false); - modal.remove(); - }); - - modal.open = true; - }); - } - - static prompt(options: PromptOptions): Promise { - return new Promise((resolve) => { - const modal = document.createElement('ep-modal') as EpModal; - modal.setAttribute('title', options.title); - modal._resolvePromise = resolve as (v: unknown) => void; - - const container = document.createElement('div'); - const msg = document.createElement('p'); - msg.textContent = options.message; - msg.style.margin = '0'; - - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'prompt-input'; - input.placeholder = options.placeholder ?? ''; - - container.append(msg, input); - modal.appendChild(container); - - const actionsDiv = document.createElement('div'); - actionsDiv.setAttribute('slot', 'actions'); - - const cancelBtn = document.createElement('button'); - cancelBtn.textContent = 'Cancel'; - cancelBtn.setAttribute('data-action', 'cancel'); - - const confirmBtn = document.createElement('button'); - confirmBtn.textContent = 'OK'; - confirmBtn.setAttribute('data-action', 'confirm'); - - actionsDiv.append(cancelBtn, confirmBtn); - modal.appendChild(actionsDiv); - document.body.appendChild(modal); - - modal.addEventListener('ep-modal-action', ((e: CustomEvent) => { - if (e.detail?.action === 'confirm') { - // Try shadow DOM input first, then light DOM - const shadowInput = modal._shadow.querySelector('.prompt-input'); - const lightInput = modal.querySelector('input'); - resolve(shadowInput?.value ?? lightInput?.value ?? ''); - } else { - resolve(null); - } - modal.remove(); - }) as EventListener); - - modal.addEventListener('ep-modal-close', () => { - resolve(null); - modal.remove(); - }); - - modal.open = true; - - // Focus the input once rendered. - requestAnimationFrame(() => { - const lightInput = modal.querySelector('input'); - lightInput?.focus(); - }); - }); - } - - /* ── Private ──────────────────────────────────────────────── */ - - private _render(): void { - this._shadow.innerHTML = ` - -
- - `; - - this._shadow.querySelector('.overlay')?.addEventListener('click', () => this.close()); - this._shadow.querySelector('.close-btn')?.addEventListener('click', () => this.close()); - - // Listen for data-action clicks from slotted content - this.addEventListener('click', (e: Event) => { - const target = e.target; - if (!(target instanceof HTMLElement)) return; - const action = target.closest('[data-action]')?.dataset.action; - if (action) { - this.dispatchEvent( - new CustomEvent('ep-modal-action', {bubbles: true, composed: true, detail: {action}}), - ); - if (action === 'cancel') this.close(action); - } - }); - } - - private _onOpen(): void { - this._previousFocus = document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - - document.addEventListener('keydown', this._onKeyDown); - - // Focus the dialog itself - requestAnimationFrame(() => { - const dialog = this._shadow.querySelector('.dialog'); - dialog?.focus(); - }); - } - - private _onClose(): void { - document.removeEventListener('keydown', this._onKeyDown); - this._previousFocus?.focus(); - this._previousFocus = null; - } - - private _handleKeyDown(e: KeyboardEvent): void { - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - this.close(); - return; - } - - if (e.key === 'Tab') { - this._trapFocus(e); - } - } - - private _trapFocus(e: KeyboardEvent): void { - // Collect all focusable elements in both shadow and light DOM. - const focusable = this._getFocusableElements(); - if (focusable.length === 0) return; - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - // Determine the currently focused element, checking both shadow and light DOM. - const active = this._shadow.activeElement ?? document.activeElement; - - if (e.shiftKey) { - if (active === first || !focusable.includes(active as HTMLElement)) { - e.preventDefault(); - last.focus(); - } - } else { - if (active === last || !focusable.includes(active as HTMLElement)) { - e.preventDefault(); - first.focus(); - } - } - } - - private _getFocusableElements(): HTMLElement[] { - const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; - - // Shadow DOM focusable elements. - const shadowEls = Array.from(this._shadow.querySelectorAll(selector)); - // Light DOM (slotted) focusable elements. - const lightEls = Array.from(this.querySelectorAll(selector)); - - return [...shadowEls, ...lightEls].filter( - (el) => !el.hasAttribute('disabled') && el.offsetParent !== null, - ); - } - - private _escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } -} - -customElements.define('ep-modal', EpModal); diff --git a/ui/src/js/components/EpNotification.ts b/ui/src/js/components/EpNotification.ts deleted file mode 100644 index c319f8af..00000000 --- a/ui/src/js/components/EpNotification.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * EpNotification — Web Component replacement for the gritter/notification system. - * - * Usage: - * - * Message text here - * - * - * Static helpers: - * EpNotification.show({ text, type, duration, position }) - * EpNotification.success(text, duration?) - * EpNotification.error(text, duration?) - */ - -const notificationStyles = /* css */ ` - :host { - --ep-bg-success: #000; - --ep-bg-error: #dc2626; - --ep-bg-info: #171717; - --ep-fg: #fff; - --ep-radius: 8px; - --ep-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - --ep-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, - Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; - - display: block; - pointer-events: auto; - font-family: var(--ep-font); - font-size: 14px; - line-height: 1.5; - max-width: 420px; - width: 100%; - box-sizing: border-box; - opacity: 0; - transform: translateY(calc(var(--ep-slide-dir, -1) * 12px)); - transition: opacity 0.25s ease, transform 0.25s ease; - } - - :host([visible]) { - opacity: 1; - transform: translateY(0); - } - - :host([removing]) { - opacity: 0; - transform: translateY(calc(var(--ep-slide-dir, -1) * 12px)); - transition: opacity 0.2s ease, transform 0.2s ease; - } - - @media (prefers-color-scheme: light) { - :host { - --ep-bg-success: #000; - --ep-bg-error: #dc2626; - --ep-bg-info: #171717; - --ep-fg: #fff; - } - } - - @media (prefers-color-scheme: dark) { - :host { - --ep-bg-success: #22c55e; - --ep-bg-error: #ef4444; - --ep-bg-info: #e5e5e5; - --ep-fg: #000; - } - } - - .notification { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 12px 16px; - border-radius: var(--ep-radius); - box-shadow: var(--ep-shadow); - color: var(--ep-fg); - background: var(--ep-bg-info); - } - - :host([type="success"]) .notification { - background: var(--ep-bg-success); - } - - :host([type="error"]) .notification { - background: var(--ep-bg-error); - } - - .icon { - flex-shrink: 0; - width: 18px; - height: 18px; - margin-top: 1px; - } - - .body { - flex: 1; - min-width: 0; - word-wrap: break-word; - } - - .close { - flex-shrink: 0; - background: none; - border: none; - color: inherit; - cursor: pointer; - padding: 0; - margin: 0; - opacity: 0.6; - transition: opacity 0.15s ease; - line-height: 1; - font-size: 18px; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - } - - .close:hover, - .close:focus-visible { - opacity: 1; - } - - .close:focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; - border-radius: 2px; - } -`; - -const iconSvg: Record = { - success: ``, - error: ``, - info: ``, -}; - -type NotificationPosition = 'top' | 'bottom'; -type NotificationType = 'success' | 'error' | 'info'; - -interface NotificationOptions { - text: string; - type?: NotificationType; - duration?: number; - position?: NotificationPosition; -} - -/** - * Container element that manages stacking of notifications at a given position. - */ -const ensureContainer = (position: NotificationPosition): HTMLElement => { - const id = `ep-notification-container-${position}`; - let container = document.getElementById(id); - if (container) return container; - - container = document.createElement('div'); - container.id = id; - Object.assign(container.style, { - position: 'fixed', - [position === 'top' ? 'top' : 'bottom']: '16px', - right: '16px', - zIndex: '10000', - display: 'flex', - flexDirection: position === 'top' ? 'column' : 'column-reverse', - gap: '8px', - pointerEvents: 'none', - maxWidth: '100vw', - width: '420px', - } as CSSStyleDeclaration as Record); - document.body.appendChild(container); - return container; -}; - -export class EpNotification extends HTMLElement { - static observedAttributes = ['position', 'duration', 'type']; - - private _dismissTimer: ReturnType | null = null; - private _shadow: ShadowRoot; - - constructor() { - super(); - this._shadow = this.attachShadow({mode: 'open'}); - } - - /* ── Lifecycle ────────────────────────────────────────────── */ - - connectedCallback(): void { - this._render(); - this._startAutoClose(); - - // Slide direction: -1 for top (slide down from above), +1 for bottom (slide up from below) - const dir = this.position === 'bottom' ? '1' : '-1'; - this.style.setProperty('--ep-slide-dir', dir); - - // Trigger enter animation on the next frame. - requestAnimationFrame(() => this.setAttribute('visible', '')); - } - - disconnectedCallback(): void { - this._clearTimer(); - } - - attributeChangedCallback(name: string, _old: string | null, _next: string | null): void { - if (name === 'duration') { - this._clearTimer(); - this._startAutoClose(); - } - if (name === 'type') { - this._render(); - } - } - - /* ── Properties ───────────────────────────────────────────── */ - - get position(): NotificationPosition { - const val = this.getAttribute('position'); - return val === 'bottom' ? 'bottom' : 'top'; - } - - set position(v: NotificationPosition) { - this.setAttribute('position', v); - } - - get duration(): number { - const val = this.getAttribute('duration'); - const parsed = val != null ? parseInt(val, 10) : NaN; - return Number.isFinite(parsed) ? parsed : 3000; - } - - set duration(v: number) { - this.setAttribute('duration', String(v)); - } - - get type(): NotificationType { - const val = this.getAttribute('type'); - if (val === 'success' || val === 'error') return val; - return 'info'; - } - - set type(v: NotificationType) { - this.setAttribute('type', v); - } - - /* ── Public ───────────────────────────────────────────────── */ - - dismiss(): void { - this._clearTimer(); - this.removeAttribute('visible'); - this.setAttribute('removing', ''); - - const onDone = () => { - this.removeEventListener('transitionend', onDone); - this.remove(); - this._cleanupEmptyContainer(); - }; - this.addEventListener('transitionend', onDone); - - // Safety: remove even if transitionend never fires. - setTimeout(onDone, 350); - } - - /* ── Static helpers ───────────────────────────────────────── */ - - static show(options: NotificationOptions): EpNotification { - const el = document.createElement('ep-notification') as EpNotification; - el.type = options.type ?? 'info'; - el.duration = options.duration ?? 3000; - el.position = options.position ?? 'top'; - el.textContent = options.text; - - const container = ensureContainer(el.position); - container.appendChild(el); - return el; - } - - static success(text: string, duration?: number): EpNotification { - return EpNotification.show({text, type: 'success', duration}); - } - - static error(text: string, duration?: number): EpNotification { - return EpNotification.show({text, type: 'error', duration: duration ?? 5000}); - } - - /* ── Private ──────────────────────────────────────────────── */ - - private _render(): void { - const type = this.type; - const icon = iconSvg[type] ?? iconSvg.info; - - this._shadow.innerHTML = ` - - - `; - - this._shadow.querySelector('.close')?.addEventListener('click', () => this.dismiss()); - } - - private _startAutoClose(): void { - const d = this.duration; - if (d > 0) { - this._dismissTimer = setTimeout(() => this.dismiss(), d); - } - } - - private _clearTimer(): void { - if (this._dismissTimer != null) { - clearTimeout(this._dismissTimer); - this._dismissTimer = null; - } - } - - private _cleanupEmptyContainer(): void { - for (const pos of ['top', 'bottom'] as const) { - const c = document.getElementById(`ep-notification-container-${pos}`); - if (c && c.children.length === 0) c.remove(); - } - } -} - -customElements.define('ep-notification', EpNotification); diff --git a/ui/src/js/components/EpToast.ts b/ui/src/js/components/EpToast.ts deleted file mode 100644 index 5c50f194..00000000 --- a/ui/src/js/components/EpToast.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * EpToastContainer — A lightweight toast notification container. - * - * Usage: - * - * - * API: - * EpToastContainer.getInstance().addToast({ message, type, duration }) - */ - -const toastContainerStyles = /* css */ ` - :host { - --ep-toast-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; - - position: fixed; - z-index: 10002; - display: flex; - flex-direction: column; - gap: 8px; - pointer-events: none; - max-width: 380px; - width: 100%; - font-family: var(--ep-toast-font); - font-size: 14px; - } - - /* Position variants */ - :host([position="top-right"]), - :host(:not([position])) { - top: 16px; - right: 16px; - } - - :host([position="top-left"]) { - top: 16px; - left: 16px; - } - - :host([position="bottom-right"]) { - bottom: 16px; - right: 16px; - flex-direction: column-reverse; - } - - :host([position="bottom-left"]) { - bottom: 16px; - left: 16px; - flex-direction: column-reverse; - } -`; - -const toastItemStyles = /* css */ ` - :host { - --ep-toast-bg: #171717; - --ep-toast-fg: #fff; - --ep-toast-radius: 8px; - --ep-toast-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - --ep-toast-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; - - display: block; - pointer-events: auto; - font-family: var(--ep-toast-font); - font-size: 14px; - line-height: 1.5; - opacity: 0; - transform: translateX(16px); - transition: opacity 0.25s ease, transform 0.25s ease; - } - - :host([visible]) { - opacity: 1; - transform: translateX(0); - } - - :host([removing]) { - opacity: 0; - transform: translateX(16px); - transition: opacity 0.2s ease, transform 0.2s ease; - } - - /* Slide from left for left-positioned containers */ - :host([slide-from="left"]) { - transform: translateX(-16px); - } - - :host([slide-from="left"][visible]) { - transform: translateX(0); - } - - :host([slide-from="left"][removing]) { - transform: translateX(-16px); - } - - @media (prefers-color-scheme: dark) { - :host { - --ep-toast-bg: #262626; - --ep-toast-fg: #e5e5e5; - --ep-toast-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); - } - } - - .toast { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 12px 16px; - border-radius: var(--ep-toast-radius); - box-shadow: var(--ep-toast-shadow); - background: var(--ep-toast-bg); - color: var(--ep-toast-fg); - } - - :host([type="success"]) .toast { - border-left: 3px solid #22c55e; - } - - :host([type="error"]) .toast { - border-left: 3px solid #ef4444; - } - - :host([type="info"]) .toast { - border-left: 3px solid #3b82f6; - } - - .icon { - flex-shrink: 0; - width: 16px; - height: 16px; - margin-top: 2px; - } - - .message { - flex: 1; - min-width: 0; - word-wrap: break-word; - } - - .close { - flex-shrink: 0; - background: none; - border: none; - color: inherit; - cursor: pointer; - padding: 0; - opacity: 0.5; - transition: opacity 0.15s ease; - font-size: 16px; - line-height: 1; - display: flex; - align-items: center; - justify-content: center; - } - - .close:hover, - .close:focus-visible { - opacity: 1; - } - - .close:focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; - border-radius: 2px; - } - - .progress { - position: absolute; - bottom: 0; - left: 0; - height: 2px; - background: rgba(255, 255, 255, 0.3); - border-radius: 0 0 var(--ep-toast-radius) var(--ep-toast-radius); - transition: width linear; - } -`; - -type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; -type ToastType = 'success' | 'error' | 'info'; - -interface ToastOptions { - message: string; - type?: ToastType; - duration?: number; -} - -const toastIconSvg: Record = { - success: ``, - error: ``, - info: ``, -}; - -/* ── Internal Toast Item ───────────────────────────────────── */ - -class EpToastItem extends HTMLElement { - private _shadow: ShadowRoot; - private _dismissTimer: ReturnType | null = null; - - constructor() { - super(); - this._shadow = this.attachShadow({mode: 'open'}); - } - - connectedCallback(): void { - const type = this.getAttribute('type') ?? 'info'; - const message = this.getAttribute('message') ?? ''; - const icon = toastIconSvg[type] ?? toastIconSvg.info; - - this._shadow.innerHTML = ` - -
- ${icon} - ${this._escapeHtml(message)} - -
- `; - - this._shadow.querySelector('.close')?.addEventListener('click', () => this.dismiss()); - - // Slide animation entrance. - requestAnimationFrame(() => this.setAttribute('visible', '')); - - // Auto-dismiss. - const duration = parseInt(this.getAttribute('duration') ?? '4000', 10); - if (duration > 0) { - this._dismissTimer = setTimeout(() => this.dismiss(), duration); - } - } - - disconnectedCallback(): void { - if (this._dismissTimer != null) { - clearTimeout(this._dismissTimer); - this._dismissTimer = null; - } - } - - dismiss(): void { - if (this._dismissTimer != null) { - clearTimeout(this._dismissTimer); - this._dismissTimer = null; - } - this.removeAttribute('visible'); - this.setAttribute('removing', ''); - - const cleanup = () => { - this.removeEventListener('transitionend', cleanup); - this.remove(); - }; - this.addEventListener('transitionend', cleanup); - setTimeout(cleanup, 300); - } - - private _escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } -} - -// Register the toast item element. -customElements.define('ep-toast-item', EpToastItem); - -/* ── Toast Container ───────────────────────────────────────── */ - -const MAX_VISIBLE = 5; - -export class EpToastContainer extends HTMLElement { - static observedAttributes = ['position']; - - private _shadow: ShadowRoot; - - private static _instance: EpToastContainer | null = null; - - constructor() { - super(); - this._shadow = this.attachShadow({mode: 'open'}); - } - - /* ── Lifecycle ────────────────────────────────────────────── */ - - connectedCallback(): void { - this._shadow.innerHTML = ` - - - `; - - // Register as singleton instance. - EpToastContainer._instance = this; - } - - disconnectedCallback(): void { - if (EpToastContainer._instance === this) { - EpToastContainer._instance = null; - } - } - - /* ── Properties ───────────────────────────────────────────── */ - - get position(): ToastPosition { - const val = this.getAttribute('position'); - if (val === 'top-left' || val === 'bottom-right' || val === 'bottom-left') return val; - return 'top-right'; - } - - set position(v: ToastPosition) { - this.setAttribute('position', v); - } - - /* ── Static accessor ──────────────────────────────────────── */ - - /** - * Returns the singleton toast container, creating one if it does not exist. - */ - static getInstance(): EpToastContainer { - if (EpToastContainer._instance) return EpToastContainer._instance; - - const container = document.createElement('ep-toast-container') as EpToastContainer; - container.setAttribute('position', 'top-right'); - document.body.appendChild(container); - return container; - } - - /* ── Public API ───────────────────────────────────────────── */ - - addToast(options: ToastOptions): EpToastItem { - // Enforce max visible limit — remove oldest if necessary. - const existing = this.querySelectorAll('ep-toast-item'); - if (existing.length >= MAX_VISIBLE) { - const oldest = existing[0] as EpToastItem; - oldest.dismiss(); - } - - const toast = document.createElement('ep-toast-item') as EpToastItem; - toast.setAttribute('message', options.message); - toast.setAttribute('type', options.type ?? 'info'); - toast.setAttribute('duration', String(options.duration ?? 4000)); - - // Set slide direction based on container position. - const isLeft = this.position.includes('left'); - if (isLeft) { - toast.setAttribute('slide-from', 'left'); - } - - this.appendChild(toast); - return toast; - } -} - -customElements.define('ep-toast-container', EpToastContainer); diff --git a/ui/src/js/components/EpToolbarSelect.d.ts b/ui/src/js/components/EpToolbarSelect.d.ts deleted file mode 100644 index 7f8fb468..00000000 --- a/ui/src/js/components/EpToolbarSelect.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface ToolbarSelectOption { - label: string; - value: string; -} - -export interface EpToolbarSelectElement extends HTMLElement { - options: ToolbarSelectOption[]; - value: string; -} diff --git a/ui/src/js/components/EpToolbarSelect.ts b/ui/src/js/components/EpToolbarSelect.ts deleted file mode 100644 index eb3b37c4..00000000 --- a/ui/src/js/components/EpToolbarSelect.ts +++ /dev/null @@ -1,179 +0,0 @@ -type ToolbarSelectOption = { - label: string; - value: string; -}; - -const STYLE_ID = 'ep-toolbar-select-styles'; - -const ensureStyles = () => { - if (document.getElementById(STYLE_ID)) return; - const style = document.createElement('style'); - style.id = STYLE_ID; - style.textContent = ` - ep-toolbar-select { - display: flex; - align-items: center; - min-width: 0; - } - - ep-toolbar-select ep-dropdown { - display: flex; - align-items: center; - } - - ep-toolbar-select .ep-toolbar-select__button { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 28px; - height: 28px; - padding: 0 8px; - border: none; - border-radius: 3px; - background: transparent; - color: inherit; - cursor: pointer; - white-space: nowrap; - font: inherit; - } - - ep-toolbar-select .ep-toolbar-select__button:hover { - background-color: #f2f3f4; - background-color: var(--bg-soft-color, #f2f3f4); - color: #485365; - color: var(--text-color, #485365); - } - - ep-toolbar-select .ep-toolbar-select__button:focus-visible { - outline: 2px solid #64d29b; - outline-offset: 1px; - } - - ep-toolbar-select .ep-toolbar-select__icon { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - } - - ep-toolbar-select .ep-toolbar-select__text { - display: inline-block; - min-width: 0; - max-width: 96px; - overflow: hidden; - text-overflow: ellipsis; - font-size: 12px; - font-weight: 500; - } - - ep-toolbar-select .ep-toolbar-select__caret { - flex: 0 0 auto; - display: inline-block; - width: 8px; - height: 8px; - border-right: 2px solid currentColor; - border-bottom: 2px solid currentColor; - transform: translateY(-1px) rotate(45deg); - opacity: 0.7; - } - `; - document.head.appendChild(style); -}; - -export class EpToolbarSelect extends HTMLElement { - private _options: ToolbarSelectOption[] = []; - private _value = ''; - private _button?: HTMLButtonElement; - private _label?: HTMLSpanElement; - - connectedCallback(): void { - ensureStyles(); - this._render(); - } - - get options(): ToolbarSelectOption[] { - return this._options; - } - - set options(options: ToolbarSelectOption[]) { - this._options = Array.isArray(options) ? options : []; - this._render(); - } - - get value(): string { - return this._value; - } - - set value(value: string) { - this._value = value ?? ''; - this._updateTrigger(); - } - - private _render(): void { - this.replaceChildren(); - - const dropdown = document.createElement('ep-dropdown'); - dropdown.setAttribute('align', 'left'); - dropdown.setAttribute('trigger', 'click'); - - const button = document.createElement('button'); - button.type = 'button'; - button.slot = 'trigger'; - button.className = 'ep-toolbar-select__button'; - - const iconClass = this.getAttribute('icon-class'); - if (iconClass) { - const icon = document.createElement('span'); - icon.className = `buttonicon ${iconClass} ep-toolbar-select__icon`; - button.appendChild(icon); - } - - const label = document.createElement('span'); - label.className = 'ep-toolbar-select__text'; - button.appendChild(label); - - const caret = document.createElement('span'); - caret.className = 'ep-toolbar-select__caret'; - caret.setAttribute('aria-hidden', 'true'); - button.appendChild(caret); - - const content = document.createElement('div'); - content.slot = 'content'; - for (const option of this._options) { - const item = document.createElement('ep-dropdown-item'); - item.setAttribute('value', option.value); - item.textContent = option.label; - content.appendChild(item); - } - - dropdown.addEventListener('ep-dropdown-select', ((event: CustomEvent) => { - this._value = String(event.detail?.value ?? ''); - this._updateTrigger(); - this.dispatchEvent(new CustomEvent('ep-toolbar-select:change', { - bubbles: true, - composed: true, - detail: {value: this._value}, - })); - }) as EventListener); - - dropdown.append(button, content); - this.appendChild(dropdown); - - this._button = button; - this._label = label; - this._updateTrigger(); - } - - private _updateTrigger(): void { - if (!this._button || !this._label) return; - const selected = this._options.find((option) => option.value === this._value); - const visibleLabel = selected?.label ?? this.getAttribute('placeholder') ?? this.getAttribute('label') ?? ''; - const titlePrefix = this.getAttribute('label') ?? ''; - - this._label.textContent = visibleLabel; - this._button.title = titlePrefix && selected ? `${titlePrefix}: ${selected.label}` : (titlePrefix || visibleLabel); - this._button.setAttribute('aria-label', this._button.title); - } -} - -customElements.define('ep-toolbar-select', EpToolbarSelect); diff --git a/ui/src/js/components/index.ts b/ui/src/js/components/index.ts index 7a648a01..0047e8f5 100644 --- a/ui/src/js/components/index.ts +++ b/ui/src/js/components/index.ts @@ -2,22 +2,23 @@ * Etherpad UI Web Components * * Import this module to register all custom elements. - * Components are self-contained and work without the EventBus — they will be - * wired together with core/EventBus and core/BaseComponent later. + * Most components come from the etherpad-webcomponents npm package. + * EpPluginToolbar is local-only (not in the npm package). */ -import './EpNotification'; -import './EpModal'; -import './EpToast'; -import './EpColorPicker'; -import './EpDropdown'; +import 'etherpad-webcomponents/EpNotification.js'; +import 'etherpad-webcomponents/EpModal.js'; +import 'etherpad-webcomponents/EpToast.js'; +import 'etherpad-webcomponents/EpColorPicker.js'; +import 'etherpad-webcomponents/EpDropdown.js'; +import 'etherpad-webcomponents/EpToolbarSelect.js'; +import 'etherpad-webcomponents/EpCheckbox.js'; import './EpPluginToolbar'; -import './EpToolbarSelect'; -export {EpNotification} from './EpNotification'; -export {EpModal} from './EpModal'; -export {EpToastContainer} from './EpToast'; -export {EpColorPicker} from './EpColorPicker'; -export {EpDropdown, EpDropdownItem} from './EpDropdown'; +export {EpNotification} from 'etherpad-webcomponents'; +export {EpModal} from 'etherpad-webcomponents'; +export {EpToastContainer} from 'etherpad-webcomponents'; +export {EpColorPicker} from 'etherpad-webcomponents'; +export {EpDropdown, EpDropdownItem} from 'etherpad-webcomponents'; export {EpPluginToolbar} from './EpPluginToolbar'; -export {EpToolbarSelect} from './EpToolbarSelect'; +export {EpToolbarSelect} from 'etherpad-webcomponents'; diff --git a/ui/src/js/core/ComponentBridge.ts b/ui/src/js/core/ComponentBridge.ts index 421affd1..25684da6 100644 --- a/ui/src/js/core/ComponentBridge.ts +++ b/ui/src/js/core/ComponentBridge.ts @@ -1,7 +1,5 @@ import { editorBus } from './EventBus' -import { EpNotification } from '../components/EpNotification' -import { EpModal } from '../components/EpModal' -import { EpToastContainer } from '../components/EpToast' +import { EpNotification, EpToastContainer } from 'etherpad-webcomponents' // Initialize toast container const toasts = EpToastContainer.getInstance() diff --git a/ui/src/js/core/EventBus.ts b/ui/src/js/core/EventBus.ts index 4a45caf0..4bea0847 100644 --- a/ui/src/js/core/EventBus.ts +++ b/ui/src/js/core/EventBus.ts @@ -304,4 +304,12 @@ export class EventBus = EditorEvents> { // Singleton instance for the editor // --------------------------------------------------------------------------- -export const editorBus = new EventBus(); +// Uses a global reference so that when bundled with etherpad-webcomponents, +// both packages share the exact same EventBus instance. This is critical for +// plugin hooks (editor:attribs:to:classes, editor:process:line:attribs, etc.) +// to work across package boundaries. +const _global = globalThis as any; +if (!_global.__etherpadEditorBus) { + _global.__etherpadEditorBus = new EventBus(); +} +export const editorBus: EventBus = _global.__etherpadEditorBus; diff --git a/ui/src/js/notifications.ts b/ui/src/js/notifications.ts index b81cec56..38f30886 100644 --- a/ui/src/js/notifications.ts +++ b/ui/src/js/notifications.ts @@ -1,8 +1,8 @@ -import './components/EpNotification' -import { EpNotification } from './components/EpNotification' +import 'etherpad-webcomponents/EpNotification.js' +import { EpNotification } from 'etherpad-webcomponents' export const notifications = { - add(args: { title?: string; text: string | Node; class_name?: string; sticky?: boolean; time?: number; position?: 'top' | 'bottom' }): string { + add(args: { title?: string; text: string | Node; class_name?: string; sticky?: boolean; time?: number; position?: 'top' | 'bottom' }): EpNotification { const type = args.class_name?.includes('error') ? 'error' : 'success' const textContent = args.text instanceof Node ? (args.text as HTMLElement).textContent || '' : String(args.text) return EpNotification.show({ diff --git a/ui/src/js/pad.ts b/ui/src/js/pad.ts index fa73101f..c71cb31e 100644 --- a/ui/src/js/pad.ts +++ b/ui/src/js/pad.ts @@ -61,6 +61,8 @@ const hideById = (id: string) => setDisplay(id, 'none'); const showById = (id: string, display = 'block') => setDisplay(id, display); const setCheckedById = (id: string, value: boolean) => { const el = byId(id); + if (!el) return; + if (el.tagName === 'EP-CHECKBOX') { (el as any).checked = value; return; } if (el instanceof HTMLInputElement) el.checked = value; }; @@ -493,7 +495,8 @@ const pad = { setTimeout(() => { padeditor.ace.focus(); }, 0); - byId('options-stickychat')?.addEventListener('click', () => chat.stickToScreen()); + byId('options-stickychat')?.addEventListener('ep-change', () => chat.stickToScreen()); + byId('options-chatandusers')?.addEventListener('ep-change', () => chat.chatAndUsers()); if (padcookie.getPref('chatAlwaysVisible')) { chat.stickToScreen(true); setCheckedById('options-stickychat', true); @@ -519,13 +522,15 @@ const pad = { const checkChatAndUsersVisibility = (x) => { if (x.matches) { - const chatAndUsers = byId('options-chatandusers'); - if (chatAndUsers instanceof HTMLInputElement && chatAndUsers.checked) { - chatAndUsers.click(); + const chatAndUsers = byId('options-chatandusers') as any; + if (chatAndUsers?.checked) { + chatAndUsers.checked = false; + chatAndUsers.dispatchEvent(new CustomEvent('ep-change', {detail: {checked: false}})); } - const stickyChat = byId('options-stickychat'); - if (stickyChat instanceof HTMLInputElement && stickyChat.checked) { - stickyChat.click(); + const stickyChat = byId('options-stickychat') as any; + if (stickyChat?.checked) { + stickyChat.checked = false; + stickyChat.dispatchEvent(new CustomEvent('ep-change', {detail: {checked: false}})); } } }; diff --git a/ui/src/js/pad_editor.ts b/ui/src/js/pad_editor.ts index 603b0373..9cc0622b 100644 --- a/ui/src/js/pad_editor.ts +++ b/ui/src/js/pad_editor.ts @@ -44,14 +44,11 @@ export const padeditor = (() => { settings = pad.settings; self.ace = new Ace2Editor(); await self.ace.init('editorcontainer', ''); - // EventBus: emit editor:ace:initialized after the ACE editor is created - editorBus.emit('editor:ace:initialized', {editorInfo: self.ace}); + // editor:ace:initialized is emitted by ace.ts with the shared info object const editorLoading = q('#editorloadingbox'); if (editorLoading) editorLoading.style.display = 'none'; - // Listen for clicks on sidediv items - const outerFrame = q('iframe[name="ace_outer"]'); - const outerDoc = outerFrame?.contentDocument; - const sideDivInner = outerDoc?.querySelector('#sidedivinner'); + // Listen for clicks on sidediv items (now in main document, not iframe) + const sideDivInner = q('#sidedivinner'); sideDivInner?.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof HTMLElement) || target.tagName.toLowerCase() !== 'div') return; @@ -91,10 +88,13 @@ export const padeditor = (() => { // font family change - q('#viewfontmenu')?.addEventListener('change', () => { - const menu = q('#viewfontmenu'); - pad.changeViewOption('padFontFamily', menu?.value); - }); + q('#viewfontmenu')?.addEventListener('ep-dropdown-select', ((e: CustomEvent) => { + const font = e.detail?.value ?? ''; + // Update the trigger button text + const trigger = q('#viewfontmenu [slot="trigger"]'); + if (trigger) trigger.textContent = font || html10n.get('pad.settings.fontType.normal'); + pad.changeViewOption('padFontFamily', font); + }) as EventListener); // delete pad q('#delete-pad')?.addEventListener('click', () => { @@ -118,14 +118,13 @@ export const padeditor = (() => { // Language html10n.bind('localized', () => { - const menu = q('#languagemenu'); - if (menu) menu.value = html10n.getLanguage(); - // translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist + // Update the language trigger button text + const lang = html10n.getLanguage(); + const langItem = q(`#languagemenu ep-dropdown-item[value="${lang}"]`); + const trigger = q('#languagemenu [slot="trigger"]'); + if (trigger && langItem) trigger.textContent = langItem.textContent; - // this does not interfere with html10n's normal value-setting because - // html10n just ingores s - // also, a value which has been set by the user will be not overwritten - // since a user-edited does *not* have the editempty-class + // translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist qa('input[data-l10n-id]').forEach((input) => { if (!(input instanceof HTMLInputElement)) return; if (input.classList.contains('editempty')) { @@ -134,14 +133,18 @@ export const padeditor = (() => { } }); }); - const languageMenu = q('#languagemenu'); - if (languageMenu) languageMenu.value = html10n.getLanguage(); - languageMenu?.addEventListener('change', () => { - const value = languageMenu.value; + // Set initial language trigger text + const langTrigger = q('#languagemenu [slot="trigger"]'); + const currentLang = html10n.getLanguage(); + const currentLangItem = q(`#languagemenu ep-dropdown-item[value="${currentLang}"]`); + if (langTrigger && currentLangItem) langTrigger.textContent = currentLangItem.textContent; + + q('#languagemenu')?.addEventListener('ep-dropdown-select', ((e: CustomEvent) => { + const value = e.detail?.value ?? ''; Cookies.set('language', value, { expires: 36500 }); location.reload(); html10n.localize([value, 'en']); - }); + }) as EventListener); }, setViewOptions: (newOptions) => { const getOption = (key, defaultValue) => { @@ -164,7 +167,7 @@ export const padeditor = (() => { v = getOption('showAuthorColors', true); self.ace.setProperty('showsauthorcolors', v); q('#chattext')?.classList.toggle('authorColors', v); - const sideDivInner = q('iframe[name="ace_outer"]')?.contentDocument?.querySelector('#sidedivinner'); + const sideDivInner = q('#sidedivinner'); sideDivInner?.classList.toggle('authorColors', v); padutils.setCheckbox('#options-colorscheck', v); @@ -206,23 +209,16 @@ export const focusOnLine = (ace) => { if (lineNumber[0] === 'L') { const lineNumberInt = parseInt(lineNumber.substr(1)); if (lineNumberInt) { - const outerFrame = q('iframe[name="ace_outer"]'); - const outerDoc = outerFrame?.contentDocument; - const outerDocBody = outerDoc?.querySelector('#outerdocbody'); - const innerFrame = outerDoc?.querySelector('iframe'); - const innerDocBody = innerFrame?.contentDocument?.querySelector('#innerdocbody'); + const innerDocBody = document.getElementById('innerdocbody'); const line = innerDocBody?.querySelector(`div:nth-child(${lineNumberInt})`); - if (line && outerDocBody && innerDocBody) { - let offsetTop = line.getBoundingClientRect().top - innerDocBody.getBoundingClientRect().top; - offsetTop += parseInt(getComputedStyle(outerDocBody).paddingTop.replace('px', '')); - const hasMobileLayout = window.matchMedia('(max-width: 1000px)').matches; - if (!hasMobileLayout) { - offsetTop += parseInt(getComputedStyle(innerDocBody).paddingTop.replace('px', '')); + if (line && innerDocBody) { + const offsetTop = line.getBoundingClientRect().top - innerDocBody.getBoundingClientRect().top; + const editorContainer = document.getElementById('editorcontainer'); + if (editorContainer) { + editorContainer.scrollTop = offsetTop; } - (outerDocBody).style.top = `${offsetTop}px`; // Chrome - outerDoc?.documentElement?.scrollTo({top: offsetTop}); // needed for FF - const node = line; ace.callWithAce((ace) => { + const node = line; const selection = { startPoint: { index: 0, diff --git a/ui/src/js/pad_userlist.ts b/ui/src/js/pad_userlist.ts index 3dcb66cc..411cc368 100644 --- a/ui/src/js/pad_userlist.ts +++ b/ui/src/js/pad_userlist.ts @@ -18,6 +18,7 @@ import padutils from './pad_utils'; import {editorBus} from './core'; import html10n from './i18n'; import {pad} from "./pad.ts"; +import 'etherpad-webcomponents/EpUserBadge.js'; let myUserInfo: Record = {}; @@ -118,7 +119,7 @@ export const paduserlist = (() => { const {scheduleAnimation} = padutils.makeAnimationScheduler(animateStep, ANIMATION_STEP_TIME, LOWER_FRAMERATE_FACTOR); - const NUMCOLS = 4; + const NUMCOLS = 3; const setTdHeight = (tr: HTMLElement, height: number) => { tr.querySelectorAll('td').forEach((td) => { @@ -144,23 +145,21 @@ export const paduserlist = (() => { const replaceUserRowContents = (tr: HTMLElement, height: number, data: any) => { const tds = createUserRowTds(height, data); - if (isNameEditable(data) && tr.querySelector('td.usertdname input:enabled')) { - // preserve input field node - tds.forEach((td, i) => { - const oldTd = tr.querySelectorAll('td')[i]; - if (!oldTd?.classList.contains('usertdname')) oldTd?.replaceWith(td); - }); - } else { - tr.innerHTML = ''; - tr.append(...tds); - } + tr.innerHTML = ''; + tr.append(...tds); return tr; }; const createUserRowTds = (height: number, data: any): HTMLElement[] => { - let name: Node; + const tdBadge = document.createElement('td'); + tdBadge.style.height = `${height}px`; + tdBadge.className = 'usertdswatch'; + tdBadge.colSpan = 2; + const badge = document.createElement('ep-user-badge') as any; + badge.setAttribute('color', padutils.escapeHtml(data.color)); + badge.setAttribute('online', ''); if (data.name) { - name = document.createTextNode(data.name); + badge.setAttribute('name', data.name); } else { const input = document.createElement('input'); input.setAttribute('data-l10n-id', 'pad.userlist.unnamed'); @@ -168,29 +167,18 @@ export const paduserlist = (() => { input.classList.add('editempty', 'newinput'); input.value = html10n.get('pad.userlist.unnamed'); if (isNameEditable(data)) input.disabled = true; - name = input; + badge.setAttribute('name', input.value); + tdBadge.appendChild(input); + input.style.display = 'none'; } - - const tdSwatch = document.createElement('td'); - tdSwatch.style.height = `${height}px`; - tdSwatch.className = 'usertdswatch'; - const swatch = document.createElement('div'); - swatch.className = 'swatch'; - swatch.style.background = padutils.escapeHtml(data.color); - swatch.innerHTML = ' '; - tdSwatch.appendChild(swatch); - - const tdName = document.createElement('td'); - tdName.style.height = `${height}px`; - tdName.className = 'usertdname'; - tdName.append(name); + tdBadge.prepend(badge); const tdActivity = document.createElement('td'); tdActivity.style.height = `${height}px`; tdActivity.className = 'activity'; tdActivity.textContent = data.activity; - return [tdSwatch, tdName, tdActivity]; + return [tdBadge, tdActivity]; }; const createRow = (id: string, contents: HTMLElement[], authorId: string): HTMLElement => { diff --git a/ui/src/js/pad_utils.ts b/ui/src/js/pad_utils.ts index 7f8cde3e..1c383250 100644 --- a/ui/src/js/pad_utils.ts +++ b/ui/src/js/pad_utils.ts @@ -342,37 +342,25 @@ class PadUtils { return {clear: () => {}}; } getCheckbox = (node: HTMLElement | string) => { - if (typeof node === 'string') { - const el = document.querySelector(node); - return el instanceof HTMLInputElement ? el.checked : false; - } - if (node instanceof HTMLElement) { - return node instanceof HTMLInputElement ? node.checked : false; - } - return false; + const el = typeof node === 'string' ? document.querySelector(node) : node; + if (!el) return false; + if (el.tagName === 'EP-CHECKBOX') return (el as any).checked ?? false; + return el instanceof HTMLInputElement ? el.checked : false; } setCheckbox = (node: HTMLElement | string, value: boolean) => { - if (typeof node === 'string') { - const el = document.querySelector(node); - if (el instanceof HTMLInputElement) el.checked = value; - return; - } - if (node instanceof HTMLElement) { - if (node instanceof HTMLInputElement) node.checked = value; - return; - } + const el = typeof node === 'string' ? document.querySelector(node) : node; + if (!el) return; + if (el.tagName === 'EP-CHECKBOX') { (el as any).checked = value; return; } + if (el instanceof HTMLInputElement) el.checked = value; } bindCheckboxChange = (node: HTMLElement | string, func: Function) => { - if (typeof node === 'string') { - document.querySelector(node)?.addEventListener('change', () => func()); - return; - } - if (node instanceof HTMLElement) { - node.addEventListener('change', () => func()); - return; - } + const el = typeof node === 'string' ? document.querySelector(node) : node; + if (!el) return; + // ep-checkbox fires 'ep-change', native checkbox fires 'change' + const event = el.tagName === 'EP-CHECKBOX' ? 'ep-change' : 'change'; + el.addEventListener(event, () => func()); } encodeUserId = (userId: string) => userId.replace(/[^a-y0-9]/g, (c) => { From 6a2e9ac8697b4b4d61ff1970a4afe9a6546ce276 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:05:59 +0200 Subject: [PATCH 02/10] feat: added web component ref --- lib/api/static/dev_hmr.go | 26 ++++++++++++++-- package.json | 2 +- pnpm-lock.yaml | 63 ++++++++++++++++++++++++++++++++++----- pnpm-workspace.yaml | 3 -- ui/package.json | 2 +- 5 files changed, 81 insertions(+), 15 deletions(-) diff --git a/lib/api/static/dev_hmr.go b/lib/api/static/dev_hmr.go index cceb8daf..92a22273 100644 --- a/lib/api/static/dev_hmr.go +++ b/lib/api/static/dev_hmr.go @@ -2,7 +2,7 @@ package static import ( "encoding/json" - "path" + "path/filepath" "strings" "sync" @@ -67,6 +67,23 @@ func (h *esbuildDevHMR) serveBundle(c fiber.Ctx) error { return c.Send(output) } +// buildDevNodePaths exposes pnpm's isolated node_modules folders as fallback +// Node resolution paths. pnpm installs each package's transitive deps inside +// node_modules/.pnpm/@/node_modules/, reachable from the consuming +// package only through a symlink. On Windows the Go resolver in esbuild does +// not always follow those symlinks the same way Node does, so bare imports +// like `lit/decorators.js` coming out of a pnpm-installed library fail to +// resolve. Handing esbuild the isolated folders as NodePaths lets it find +// those deps without relying on symlink resolution. +func buildDevNodePaths(projectRoot string) []string { + pattern := filepath.Join(projectRoot, "node_modules", ".pnpm", "*", "node_modules") + matches, err := filepath.Glob(pattern) + if err != nil { + return nil + } + return matches +} + func buildDevAliases() map[string]string { relativePath := "./src/js" return map[string]string{ @@ -84,6 +101,7 @@ func newDevBundle( name string, entryPoint string, pathToBuild string, + nodePaths []string, notify func(string), ) (*devBundleState, error) { state := &devBundleState{name: name, entryPoint: entryPoint} @@ -118,6 +136,7 @@ func newDevBundle( LogLevel: api.LogLevelInfo, Target: api.ES2020, Alias: buildDevAliases(), + NodePaths: nodePaths, Sourcemap: api.SourceMapInline, Plugins: []api.Plugin{resultPlugin}, }) @@ -151,7 +170,8 @@ func startEsbuildDevHMR(store *lib.InitStore) (*esbuildDevHMR, error) { return nil, nil } - pathToBuild := path.Join(store.RetrievedSettings.Root, "ui") + pathToBuild := filepath.Join(store.RetrievedSettings.Root, "ui") + nodePaths := buildDevNodePaths(store.RetrievedSettings.Root) hmr := &esbuildDevHMR{ logger: store.Logger, bundles: map[string]*devBundleState{}, @@ -173,7 +193,7 @@ func startEsbuildDevHMR(store *lib.InitStore) (*esbuildDevHMR, error) { } for _, spec := range specs { - bundle, err := newDevBundle(store, spec.name, spec.entryPoint, pathToBuild, hmr.notify) + bundle, err := newDevBundle(store, spec.name, spec.entryPoint, pathToBuild, nodePaths, hmr.notify) if err != nil { return nil, err } diff --git a/package.json b/package.json index 988ba761..2591f47a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,6 @@ "typescript": "^5.6.3" }, "dependencies": { - "etherpad-webcomponents": "^0.0.4" + "etherpad-webcomponents": "^0.0.8" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f71633ea..bcff568b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,16 +4,13 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - etherpad-webcomponents: link:C:/Users/samue/WebstormProjects/webcomponents - importers: .: dependencies: etherpad-webcomponents: - specifier: link:C:/Users/samue/WebstormProjects/webcomponents - version: link:../../WebstormProjects/webcomponents + specifier: ^0.0.8 + version: 0.0.8 devDependencies: typescript: specifier: ^5.6.3 @@ -119,8 +116,8 @@ importers: specifier: 0.28.0 version: 0.28.0 etherpad-webcomponents: - specifier: link:C:/Users/samue/WebstormProjects/webcomponents - version: link:../../../WebstormProjects/webcomponents + specifier: ^0.0.8 + version: 0.0.8 typescript: specifier: ^6.0.2 version: 6.0.2 @@ -311,6 +308,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lit-labs/ssr-dom-shim@1.5.1': + resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} + + '@lit/reactive-element@2.1.2': + resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + '@napi-rs/wasm-runtime@1.1.2': resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} peerDependencies: @@ -774,6 +777,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -844,6 +850,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + etherpad-webcomponents@0.0.8: + resolution: {integrity: sha512-V4Tw4EpZvOnjevenx7b8allkoa0S/8vUdCIXrDLSIu261Mb5UoRAuWDuMD5GdzupFrgg+C2yovBtWE10m3lucA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -946,6 +955,15 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lit-element@4.2.2: + resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} + + lit-html@3.3.2: + resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==} + + lit@3.3.2: + resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + lucide-react@1.7.0: resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==} peerDependencies: @@ -1281,6 +1299,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lit-labs/ssr-dom-shim@1.5.1': {} + + '@lit/reactive-element@2.1.2': + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 @@ -1594,6 +1618,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/trusted-types@2.0.7': {} + '@vitejs/plugin-react@6.0.1(vite@8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -1670,6 +1696,13 @@ snapshots: estree-walker@2.0.2: {} + etherpad-webcomponents@0.0.8: + dependencies: + '@lit/reactive-element': 2.1.2 + lit: 3.3.2 + lit-element: 4.2.2 + lit-html: 3.3.2 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -1743,6 +1776,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lit-element@4.2.2: + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@lit/reactive-element': 2.1.2 + lit-html: 3.3.2 + + lit-html@3.3.2: + dependencies: + '@types/trusted-types': 2.0.7 + + lit@3.3.2: + dependencies: + '@lit/reactive-element': 2.1.2 + lit-element: 4.2.2 + lit-html: 3.3.2 + lucide-react@1.7.0(react@19.2.4): dependencies: react: 19.2.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 69403607..248a3489 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,3 @@ packages: - playwright - ui - plugins/* - -overrides: - etherpad-webcomponents: link:C:/Users/samue/WebstormProjects/webcomponents diff --git a/ui/package.json b/ui/package.json index 399ccee6..e1c965ed 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "devDependencies": { - "etherpad-webcomponents": "^0.0.4", + "etherpad-webcomponents": "^0.0.8", "@types/node": "^25.6.0", "esbuild": "0.28.0", "typescript": "^6.0.2", From 5cdd8f80f68c3ceb3683ae6c1d240f09fc5f9887 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:44:32 +0200 Subject: [PATCH 03/10] feat: fixed playwright tests --- lib/changeset/TextLinesMutator.go | 27 ++++++++++++-------- lib/ws/PadMessageHandler.go | 22 ++++++++++++++++ playwright/helper/padHelper.ts | 35 ++++++++++---------------- playwright/specs/bold.spec.ts | 10 ++------ playwright/specs/collab_client.spec.ts | 8 ++---- playwright/specs/enter.spec.ts | 8 ++---- playwright/specs/font_type.spec.ts | 4 +-- playwright/specs/italic.spec.ts | 10 ++------ playwright/specs/language.spec.ts | 2 +- playwright/specs/settings.spec.ts | 8 +++--- 10 files changed, 65 insertions(+), 69 deletions(-) diff --git a/lib/changeset/TextLinesMutator.go b/lib/changeset/TextLinesMutator.go index 78b85f11..07f0e621 100644 --- a/lib/changeset/TextLinesMutator.go +++ b/lib/changeset/TextLinesMutator.go @@ -4,6 +4,9 @@ import ( "errors" "fmt" "strings" + "unicode/utf8" + + "github.com/ether/etherpad-go/lib/utils" ) // TextLinesMutator is a class to iterate and modify texts which have several lines. @@ -200,10 +203,11 @@ func (m *TextLinesMutator) RemoveLines(L int) string { m.curSplice[1] = deleteCount + L - 1 sline := len(m.curSplice) - 1 slineStr := m.curSplice[sline].(string) - removed = slineStr[m.curCol:] + removed + slineRuneLen := utf8.RuneCountInString(slineStr) + removed = utils.RuneSlice(slineStr, m.curCol, slineRuneLen) + removed startIdx := m.curSplice[0].(int) deleteCount = m.curSplice[1].(int) - m.curSplice[sline] = slineStr[:m.curCol] + m.linesGet(startIdx+deleteCount) + m.curSplice[sline] = utils.RuneSlice(slineStr, 0, m.curCol) + m.linesGet(startIdx+deleteCount) m.curSplice[1] = deleteCount + 1 } } else { @@ -231,14 +235,15 @@ func (m *TextLinesMutator) Remove(N, L int) string { sline := m.putCurLineInSplice() slineStr := m.curSplice[sline].(string) + slineRuneLen := utf8.RuneCountInString(slineStr) endCol := m.curCol + N - if endCol > len(slineStr) { - endCol = len(slineStr) + if endCol > slineRuneLen { + endCol = slineRuneLen } - removed := slineStr[m.curCol:endCol] - m.curSplice[sline] = slineStr[:m.curCol] + slineStr[endCol:] + removed := utils.RuneSlice(slineStr, m.curCol, endCol) + m.curSplice[sline] = utils.RuneSlice(slineStr, 0, m.curCol) + utils.RuneSlice(slineStr, endCol, slineRuneLen) return removed } @@ -260,9 +265,10 @@ func (m *TextLinesMutator) Insert(text string, L int) error { sline := len(m.curSplice) - 1 theLine := m.curSplice[sline].(string) lineCol := m.curCol + theLineRuneLen := utf8.RuneCountInString(theLine) // Insert the chars up to curCol and the first new line - m.curSplice[sline] = theLine[:lineCol] + newLines[0] + m.curSplice[sline] = utils.RuneSlice(theLine, 0, lineCol) + newLines[0] m.curLine++ newLines = newLines[1:] @@ -273,7 +279,7 @@ func (m *TextLinesMutator) Insert(text string, L int) error { m.curLine += len(newLines) // Insert the remaining chars from the "old" line - m.curSplice = append(m.curSplice, theLine[lineCol:]) + m.curSplice = append(m.curSplice, utils.RuneSlice(theLine, lineCol, theLineRuneLen)) m.curCol = 0 } else { for _, line := range newLines { @@ -293,8 +299,9 @@ func (m *TextLinesMutator) Insert(text string, L int) error { } slineStr := m.curSplice[sline].(string) - m.curSplice[sline] = slineStr[:m.curCol] + text + slineStr[m.curCol:] - m.curCol += len(text) + slineRuneLen := utf8.RuneCountInString(slineStr) + m.curSplice[sline] = utils.RuneSlice(slineStr, 0, m.curCol) + text + utils.RuneSlice(slineStr, m.curCol, slineRuneLen) + m.curCol += utf8.RuneCountInString(text) } return nil diff --git a/lib/ws/PadMessageHandler.go b/lib/ws/PadMessageHandler.go index c43800bd..baf34381 100644 --- a/lib/ws/PadMessageHandler.go +++ b/lib/ws/PadMessageHandler.go @@ -713,6 +713,15 @@ func (p *PadMessageHandler) getChangesetInfo(retrievedPad pad2.Pad, startNum int println("Error getting inverse changeset", err) return nil, err } + + // Snapshot pre-forward text so we can verify that `backwards` truly inverts + // `forwards`. This surfaces the exact revision range responsible for the + // client-side "line count mismatch when composing changesets A*B" + // assertion, which fires when a server-generated '=' op is missing its + // |N line count prefix. + preForwardLines := make([]string, len(lines.TextLines)) + copy(preForwardLines, lines.TextLines) + if err := changeset.MutateAttributionLines(forwards, &lines.Alines, &retrievedPad.Pool); err != nil { println("Error mutating attribution lines", err) return nil, err @@ -725,6 +734,19 @@ func (p *PadMessageHandler) getChangesetInfo(retrievedPad pad2.Pad, startNum int forwards2 := changeset.MoveOpsToNewPool(forwards, &retrievedPad.Pool, &createdApool) backwards2 := changeset.MoveOpsToNewPool(*backwards, &retrievedPad.Pool, &createdApool) + if err := changeset.ValidateWellFormed(forwards2); err != nil { + p.Logger.Errorf("malformed forwards changeset for pad %s revs %d/%d: %v\n forwards: %s", + retrievedPad.Id, compositeStart, compositeEnd, err, forwards2) + } + if err := changeset.ValidateWellFormed(backwards2); err != nil { + p.Logger.Errorf("malformed backwards changeset for pad %s revs %d/%d: %v\n backwards: %s", + retrievedPad.Id, compositeStart, compositeEnd, err, backwards2) + } + if err := changeset.ValidateRoundTrip(*backwards, lines.TextLines, preForwardLines); err != nil { + p.Logger.Errorf("backwards changeset does not invert forwards for pad %s revs %d/%d: %v\n forwards: %s\n backwards: %s", + retrievedPad.Id, compositeStart, compositeEnd, err, forwards, *backwards) + } + var t1 int64 var t2 int64 if compositeStart == 0 { diff --git a/playwright/helper/padHelper.ts b/playwright/helper/padHelper.ts index c0bbfb4a..ad44ec77 100644 --- a/playwright/helper/padHelper.ts +++ b/playwright/helper/padHelper.ts @@ -1,16 +1,21 @@ -import {expect, Frame, Locator, Page} from "@playwright/test"; +import {expect, Locator, Page} from "@playwright/test"; import {randomUUID} from "node:crypto"; import os from "node:os"; const isMac = os.platform() === 'darwin'; const modifier = isMac ? 'Meta' : 'Control'; -export const getPadOuter = async (page: Page): Promise => { - return page.frame('ace_outer')!; +// After the WebComponents migration (ui/src/js/ace.ts) the editor no longer +// uses nested iframes — #outerdocbody and #innerdocbody are regular divs in +// the main document. getPadOuter is kept for backwards compatibility with +// specs that only needed a scope; it now returns the page itself as a +// Locator-returning helper. +export const getPadOuter = async (page: Page): Promise => { + return page; } export const getPadBody = async (page: Page): Promise => { - return page.frame('ace_inner')!.locator('#innerdocbody') + return page.locator('#innerdocbody'); } export const selectAllText = async (page: Page) => { @@ -106,19 +111,13 @@ export const appendQueryParams = async (page: Page, queryParameters: Record { - // Wait for the outer frame - await page.waitForSelector('iframe[name="ace_outer"]', { timeout, state: 'attached' }); - - // Use frameLocator to wait for inner frame content — avoids polling loop - const innerFrame = page.frameLocator('iframe[name="ace_outer"]') - .frameLocator('iframe[name="ace_inner"]'); - await innerFrame.locator('#innerdocbody').waitFor({ state: 'visible', timeout }); + await page.locator('#innerdocbody').waitFor({ state: 'visible', timeout }); }; const navigateToPad = async (page: Page, padId: string) => { @@ -153,11 +152,7 @@ export const goToPad = async (page: Page, padId: string) => { } export const clearPadContent = async (page: Page) => { - const innerFrame = page.frame('ace_inner'); - if (!innerFrame) { - throw new Error('Could not find ace_inner frame'); - } - const body = innerFrame.locator('#innerdocbody'); + const body = page.locator('#innerdocbody'); await body.click(); await selectAllText(page); await page.keyboard.press('Backspace'); @@ -166,11 +161,7 @@ export const clearPadContent = async (page: Page) => { } export const writeToPad = async (page: Page, text: string) => { - const innerFrame = page.frame('ace_inner'); - if (!innerFrame) { - throw new Error('Could not find ace_inner frame'); - } - const body = innerFrame.locator('#innerdocbody'); + const body = page.locator('#innerdocbody'); await body.click(); await page.keyboard.type(text, { delay: 5 }); } diff --git a/playwright/specs/bold.spec.ts b/playwright/specs/bold.spec.ts index 22d5b68f..ced6a0bf 100644 --- a/playwright/specs/bold.spec.ts +++ b/playwright/specs/bold.spec.ts @@ -12,10 +12,7 @@ test.describe('bold button', ()=>{ await writeToPad(page, "Hi Etherpad"); await page.waitForTimeout(300); - // Get the inner frame directly - const innerFrame = page.frame('ace_inner'); - if (!innerFrame) throw new Error('Could not find ace_inner frame'); - const body = innerFrame.locator('#innerdocbody'); + const body = page.locator('#innerdocbody'); // Triple-click to select the line await body.locator('div').first().click({ clickCount: 3 }); @@ -38,10 +35,7 @@ test.describe('bold button', ()=>{ await writeToPad(page, "Hi Etherpad"); await page.waitForTimeout(300); - // Get the inner frame directly - const innerFrame = page.frame('ace_inner'); - if (!innerFrame) throw new Error('Could not find ace_inner frame'); - const body = innerFrame.locator('#innerdocbody'); + const body = page.locator('#innerdocbody'); // Triple-click to select the line await body.locator('div').first().click({ clickCount: 3 }); diff --git a/playwright/specs/collab_client.spec.ts b/playwright/specs/collab_client.spec.ts index 0d91713d..f25593c5 100644 --- a/playwright/specs/collab_client.spec.ts +++ b/playwright/specs/collab_client.spec.ts @@ -15,9 +15,7 @@ test.describe('Messages in the COLLABROOM', function () { await clearPadContent(page1); await writeToPad(page1, 'Hello from User 1'); - const innerFrame1 = page1.frame('ace_inner'); - if (!innerFrame1) throw new Error('Could not find ace_inner frame'); - const body1 = innerFrame1.locator('#innerdocbody'); + const body1 = page1.locator('#innerdocbody'); // Verify User 1's content await expect(body1.locator('div').first()).toContainText('Hello from User 1'); @@ -27,9 +25,7 @@ test.describe('Messages in the COLLABROOM', function () { const page2 = await context2.newPage(); await goToPad(page2, padId); - const innerFrame2 = page2.frame('ace_inner'); - if (!innerFrame2) throw new Error('Could not find ace_inner frame'); - const body2 = innerFrame2.locator('#innerdocbody'); + const body2 = page2.locator('#innerdocbody'); // User 2 should see User 1's text await expect(body2.locator('div').first()).toContainText('Hello from User 1', { timeout: 20000 }); diff --git a/playwright/specs/enter.spec.ts b/playwright/specs/enter.spec.ts index 48f6dfd7..06af19ba 100644 --- a/playwright/specs/enter.spec.ts +++ b/playwright/specs/enter.spec.ts @@ -13,9 +13,7 @@ test.describe('enter keystroke', function () { await clearPadContent(page); await writeToPad(page, 'Test Line'); - const innerFrame = page.frame('ace_inner'); - if (!innerFrame) throw new Error('Could not find ace_inner frame'); - const body = innerFrame.locator('#innerdocbody'); + const body = page.locator('#innerdocbody'); // Verify we have one line with content await expect(body.locator('div').first()).toHaveText('Test Line'); @@ -38,9 +36,7 @@ test.describe('enter keystroke', function () { test('enter is always visible after event', async function ({page}) { await clearPadContent(page); - const innerFrame = page.frame('ace_inner'); - if (!innerFrame) throw new Error('Could not find ace_inner frame'); - const body = innerFrame.locator('#innerdocbody'); + const body = page.locator('#innerdocbody'); // Start with 1 line await expect(body.locator('div')).toHaveCount(1); diff --git a/playwright/specs/font_type.spec.ts b/playwright/specs/font_type.spec.ts index 49a4d13f..cace98f2 100644 --- a/playwright/specs/font_type.spec.ts +++ b/playwright/specs/font_type.spec.ts @@ -11,9 +11,7 @@ test.describe('font select', function () { test.skip(({ browserName }) => browserName === 'webkit', 'Skipping on WebKit due to dropdown issues'); const getBodyFontFamily = async (page: Page) => { - const innerFrame = page.frame('ace_inner'); - if (!innerFrame) throw new Error('Could not find ace_inner frame'); - const body = innerFrame.locator('#innerdocbody'); + const body = page.locator('#innerdocbody'); return await body.evaluate((e) => { return window.getComputedStyle(e).getPropertyValue("font-family").toLowerCase(); }); diff --git a/playwright/specs/italic.spec.ts b/playwright/specs/italic.spec.ts index 22a78f0e..ecfc23bd 100644 --- a/playwright/specs/italic.spec.ts +++ b/playwright/specs/italic.spec.ts @@ -14,10 +14,7 @@ test.describe('italic some text', function () { await writeToPad(page, 'Foo') await page.waitForTimeout(300); - // Get the inner frame directly - const innerFrame = page.frame('ace_inner'); - if (!innerFrame) throw new Error('Could not find ace_inner frame'); - const body = innerFrame.locator('#innerdocbody'); + const body = page.locator('#innerdocbody'); // Triple-click to select the line await body.locator('div').first().click({ clickCount: 3 }); @@ -43,10 +40,7 @@ test.describe('italic some text', function () { await writeToPad(page, 'Foo') await page.waitForTimeout(300); - // Get the inner frame directly - const innerFrame = page.frame('ace_inner'); - if (!innerFrame) throw new Error('Could not find ace_inner frame'); - const body = innerFrame.locator('#innerdocbody'); + const body = page.locator('#innerdocbody'); // Triple-click to select the line await body.locator('div').first().click({ clickCount: 3 }); diff --git a/playwright/specs/language.spec.ts b/playwright/specs/language.spec.ts index 6f38bcfa..cf97d0fa 100644 --- a/playwright/specs/language.spec.ts +++ b/playwright/specs/language.spec.ts @@ -8,7 +8,7 @@ test.beforeEach(async ({ page })=>{ const selectLanguage = async (page: Page, language: string) => { const languageMenu = page.locator('#languagemenu'); - await page.waitForSelector('iframe[name="ace_outer"]'); + await page.locator('#innerdocbody').waitFor({ state: 'visible' }); for (let i = 0; i < 3; i++) { if (await languageMenu.isVisible()) break; await page.locator("button[class~='buttonicon-settings']").click(); diff --git a/playwright/specs/settings.spec.ts b/playwright/specs/settings.spec.ts index 6ebd961f..4a420a14 100644 --- a/playwright/specs/settings.spec.ts +++ b/playwright/specs/settings.spec.ts @@ -5,7 +5,7 @@ const settingsButton = "button[class~='buttonicon-settings']"; const ensureSettingsVisible = async (page: Page) => { const settings = page.locator('#settings'); - await page.waitForSelector('iframe[name="ace_outer"]'); + await page.locator('#innerdocbody').waitFor({ state: 'visible' }); for (let i = 0; i < 3; i++) { const classes = await settings.getAttribute('class'); if (classes?.includes('popup-show')) return; @@ -34,18 +34,16 @@ test.describe('settings popup and options', () => { test('toggles line numbers visibility in editor gutter', async ({page}) => { await ensureSettingsVisible(page); const lineNumbersCheckbox = page.locator('#options-linenoscheck'); - const outerFrame = page.frame('ace_outer'); - if (!outerFrame) throw new Error('Could not find ace_outer frame'); await lineNumbersCheckbox.uncheck({force: true}); await expect.poll(async () => { - return await outerFrame.locator('#sidediv').evaluate((node) => + return await page.locator('#sidediv').evaluate((node) => node.parentElement?.classList.contains('line-numbers-hidden') ?? false); }).toBe(true); await lineNumbersCheckbox.check({force: true}); await expect.poll(async () => { - return await outerFrame.locator('#sidediv').evaluate((node) => + return await page.locator('#sidediv').evaluate((node) => node.parentElement?.classList.contains('line-numbers-hidden') ?? false); }).toBe(false); }); From f698756c2171c94d097efe21d1521bcd161cf446 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:47:06 +0200 Subject: [PATCH 04/10] feat: fixed playwright tests --- lib/changeset/inverse_repro_test.go | 64 ++++++++++++++++++ lib/changeset/validate.go | 100 ++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 lib/changeset/inverse_repro_test.go create mode 100644 lib/changeset/validate.go diff --git a/lib/changeset/inverse_repro_test.go b/lib/changeset/inverse_repro_test.go new file mode 100644 index 00000000..9809f323 --- /dev/null +++ b/lib/changeset/inverse_repro_test.go @@ -0,0 +1,64 @@ +package changeset + +import ( + "testing" + + "github.com/ether/etherpad-go/lib/apool" +) + +// TestInverseRoundTripRev170Repro captures the first failure from the live +// diagnostic at revs 170/171: the inverse of a simple '=' + '+' changeset +// applied to a pad whose text contains 'ü' (multi-byte rune) produces a +// backward changeset that, when applied to the post-forward text, does NOT +// restore the pre-forward text — there is a one-rune transposition near a 'ü'. +// +// Forward : Z:1x>9=1w*0+9$smdüapsmd +// Backward: Z:26<9=1w-9$ +// Pre-forward text (want): asdmasdümasüaüpsdmaüpsüapsdapsdpüasmdüpadüpasmdüpmdüpasdüpaassmmdmdmjg\n +// +// If this test fails, the bug is isolated to the changeset package (either +// Inverse or MutateTextLines handling rune-indexed positions on text that +// contains multi-byte characters). +func TestInverseRoundTripRev170Repro(t *testing.T) { + const forward = "Z:1x>9=1w*0+9$smdüapsmd" + const pre = "asdmasdümasüaüpsdmaüpsüapsdapsdpüasmdüpadüpasmdüpmdüpasdüpaassmmdmdmjg\n" + + preLines := []string{pre} + // alines for a single-attribute-free line of the above length: + // each line's alines entry is a simple "|1+" op describing the line. + // For this repro we use an empty-attribute run that covers the whole line. + pool := apool.NewAPool() + alines := []string{"|1+1x"} // |1 = one newline, +1x = 69 chars (including the \n) + + // 1) Generate the backward changeset from Inverse. + backward, err := Inverse(forward, preLines, alines, &pool) + if err != nil { + t.Fatalf("Inverse failed: %v", err) + } + t.Logf("backward = %s", *backward) + + // 2) Apply forward to a copy of pre and get post-forward. + postLines := append([]string(nil), preLines...) + if err := MutateTextLines(forward, &postLines); err != nil { + t.Fatalf("applying forward failed: %v", err) + } + if len(postLines) == 0 { + t.Fatalf("post-forward lines empty") + } + t.Logf("post-forward = %q", postLines[0]) + + // 3) Apply backward on top of post-forward and compare to pre. + roundTrip := append([]string(nil), postLines...) + if err := MutateTextLines(*backward, &roundTrip); err != nil { + t.Fatalf("applying backward failed: %v", err) + } + + if len(roundTrip) != len(preLines) { + t.Fatalf("round-trip line count: got %d, want %d", len(roundTrip), len(preLines)) + } + for i := range preLines { + if roundTrip[i] != preLines[i] { + t.Errorf("round-trip line %d mismatch:\n got: %q\n want: %q", i, roundTrip[i], preLines[i]) + } + } +} diff --git a/lib/changeset/validate.go b/lib/changeset/validate.go new file mode 100644 index 00000000..f436ef40 --- /dev/null +++ b/lib/changeset/validate.go @@ -0,0 +1,100 @@ +package changeset + +import ( + "fmt" + "unicode/utf8" + + "github.com/ether/etherpad-go/lib/utils" +) + +// ValidateWellFormed walks a changeset and checks per-op invariants that must +// hold for the string to be safely composable with another changeset: +// +// - every op has chars >= lines +// - for '+' ops, the slice of the char bank that the op consumes contains +// exactly op.Lines '\n' characters +// - all char bank characters are consumed by '+' ops (no leftover, no underflow) +// +// It does NOT catch malformed '=' ops whose lines count disagrees with the +// underlying document text — that requires applying the changeset against a +// text buffer. Use ValidateRoundTrip for that class of bug. +// +// A non-nil error describes the first violation found. +func ValidateWellFormed(cs string) error { + unpacked, err := Unpack(cs) + if err != nil { + return fmt.Errorf("unpack: %w", err) + } + ops, err := DeserializeOps(unpacked.Ops) + if err != nil { + return fmt.Errorf("deserialize ops: %w", err) + } + bankRunes := []rune(unpacked.CharBank) + bankLen := len(bankRunes) + bankPos := 0 + for i, op := range *ops { + if op.Chars < op.Lines { + return fmt.Errorf("op %d %q: chars (%d) < lines (%d)", i, op.String(), op.Chars, op.Lines) + } + if op.OpCode == "+" { + if bankPos+op.Chars > bankLen { + return fmt.Errorf("op %d %q: char bank underflow (need %d more, have %d)", i, op.String(), op.Chars, bankLen-bankPos) + } + nls := 0 + for _, r := range bankRunes[bankPos : bankPos+op.Chars] { + if r == '\n' { + nls++ + } + } + if nls != op.Lines { + return fmt.Errorf("op %d %q: char bank slice has %d newline(s), op declares %d", i, op.String(), nls, op.Lines) + } + bankPos += op.Chars + } + } + if bankPos != bankLen { + return fmt.Errorf("char bank length mismatch: consumed %d, bank has %d", bankPos, bankLen) + } + return nil +} + +// ValidateRoundTrip checks that applying `backward` to a copy of +// `postForward` yields `preForward`. This catches `=` ops whose declared +// line count disagrees with the document text — the exact class of bug that +// triggers the client-side "line count mismatch when composing changesets +// A*B" assertion. The caller's slices are not mutated. +func ValidateRoundTrip(backward string, postForward, preForward []string) error { + result := make([]string, len(postForward)) + copy(result, postForward) + if err := safeMutateTextLines(backward, &result); err != nil { + return fmt.Errorf("apply backward: %w", err) + } + if len(result) != len(preForward) { + return fmt.Errorf("round-trip line count mismatch: got %d, want %d", len(result), len(preForward)) + } + for i := range preForward { + if result[i] != preForward[i] { + return fmt.Errorf("round-trip text mismatch at line %d: got %q, want %q", + i, truncate(result[i], 120), truncate(preForward[i], 120)) + } + } + return nil +} + +// safeMutateTextLines wraps MutateTextLines in a recover so that panics from +// malformed changesets surface as errors instead of crashing the caller. +func safeMutateTextLines(cs string, textLines *[]string) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("MutateTextLines panicked: %v", r) + } + }() + return MutateTextLines(cs, textLines) +} + +func truncate(s string, max int) string { + if utf8.RuneCountInString(s) <= max { + return s + } + return utils.RuneSlice(s, 0, max) + "…" +} From 070d3a5ae6aec282056c7471933a91c21dd4b39d Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:17:35 +0200 Subject: [PATCH 05/10] feat: fixed playwright tests --- package.json | 2 +- playwright/helper/padHelper.ts | 16 ++++++++++++++++ playwright/specs/change_user_color.spec.ts | 6 ++---- playwright/specs/embed_value.spec.ts | 17 +++-------------- playwright/specs/qr_code.spec.ts | 7 ++----- playwright/specs/settings.spec.ts | 14 +++++++------- pnpm-lock.yaml | 14 +++++++------- ui/package.json | 2 +- 8 files changed, 39 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 2591f47a..7ca878c2 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,6 @@ "typescript": "^5.6.3" }, "dependencies": { - "etherpad-webcomponents": "^0.0.8" + "etherpad-webcomponents": "^0.0.9" } } diff --git a/playwright/helper/padHelper.ts b/playwright/helper/padHelper.ts index ad44ec77..bd5c6515 100644 --- a/playwright/helper/padHelper.ts +++ b/playwright/helper/padHelper.ts @@ -18,6 +18,22 @@ export const getPadBody = async (page: Page): Promise => { return page.locator('#innerdocbody'); } +// ep-checkbox is a Lit web component, not a native , +// so Playwright's .check()/.uncheck()/toBeChecked() do not apply. The +// component reflects its state via the `checked` attribute on the host +// element and toggles on click. Use this helper whenever a test needs to +// set or assert the state of an . +export const setEpCheckbox = async (locator: Locator, want: boolean) => { + const isChecked = () => locator.evaluate((el: Element) => el.hasAttribute('checked')); + if ((await isChecked()) !== want) { + await locator.click({force: true}); + } + await expect.poll(isChecked).toBe(want); +} + +export const isEpCheckboxChecked = (locator: Locator): Promise => + locator.evaluate((el: Element) => el.hasAttribute('checked')); + export const selectAllText = async (page: Page) => { await page.keyboard.down(modifier); await page.keyboard.press('a'); diff --git a/playwright/specs/change_user_color.spec.ts b/playwright/specs/change_user_color.spec.ts index bc6b609a..d583a221 100644 --- a/playwright/specs/change_user_color.spec.ts +++ b/playwright/specs/change_user_color.spec.ts @@ -1,5 +1,5 @@ import {expect, test} from "@playwright/test"; -import {goToNewPad, sendChatMessage, showChat} from "../helper/padHelper"; +import {goToNewPad, sendChatMessage, setEpCheckbox, showChat} from "../helper/padHelper"; test.beforeEach(async ({page}) => { await goToNewPad(page); @@ -59,9 +59,7 @@ test.describe('change user color', function () { test('Own user color is shown when you enter a chat', async function ({page}) { const colorOption = page.locator('#options-colorscheck'); - if (!(await colorOption.isChecked())) { - await colorOption.check(); - } + await setEpCheckbox(colorOption, true); // click on the settings button to make settings visible const $userButton = page.locator('.buttonicon-showusers'); diff --git a/playwright/specs/embed_value.spec.ts b/playwright/specs/embed_value.spec.ts index c22f70dc..863b7362 100644 --- a/playwright/specs/embed_value.spec.ts +++ b/playwright/specs/embed_value.spec.ts @@ -1,5 +1,5 @@ import {expect, Page, test} from "@playwright/test"; -import {goToNewPad} from "../helper/padHelper"; +import {goToNewPad, setEpCheckbox} from "../helper/padHelper"; test.beforeEach(async ({ page })=>{ // create a new pad before each test run @@ -102,11 +102,7 @@ test.describe('embed links', function () { await page.waitForTimeout(200); const readonlyCheckbox = page.locator('#readonlyinput') - await readonlyCheckbox.click({ - force: true - }) - // Wait for the checkbox to be checked - await expect(readonlyCheckbox).toBeChecked({ timeout: 5000 }); + await setEpCheckbox(readonlyCheckbox, true); // get the link of the share field + the actual pad url and compare them const shareLink = await page.locator('#linkinput').inputValue() @@ -122,15 +118,8 @@ test.describe('embed links', function () { await shareButton.click() await page.waitForTimeout(200); - // check read only checkbox, a bit hacky const readonlyCheckbox = page.locator('#readonlyinput') - await readonlyCheckbox.click({ - force: true - }) - - // Wait for the checkbox to be checked - await expect(readonlyCheckbox).toBeChecked({ timeout: 5000 }); - + await setEpCheckbox(readonlyCheckbox, true); // get the link of the share field + the actual pad url and compare them const embedCode = await page.locator('#embedinput').inputValue() diff --git a/playwright/specs/qr_code.spec.ts b/playwright/specs/qr_code.spec.ts index 09195719..e57b509c 100644 --- a/playwright/specs/qr_code.spec.ts +++ b/playwright/specs/qr_code.spec.ts @@ -1,5 +1,5 @@ import {expect, Page, test} from "@playwright/test"; -import {goToNewPad} from "../helper/padHelper"; +import {goToNewPad, setEpCheckbox} from "../helper/padHelper"; test.beforeEach(async ({page}) => { await goToNewPad(page); @@ -44,10 +44,7 @@ test.describe('QR share popup', () => { const qrLinkInput = page.locator('#qrcodelinkinput'); const qrImage = page.locator('#qrcodeimg'); - await qrReadonlyToggle.evaluate((element: HTMLInputElement) => { - element.checked = true; - element.dispatchEvent(new Event('click', {bubbles: true})); - }); + await setEpCheckbox(qrReadonlyToggle, true); await expect(qrLinkInput).toHaveValue(new RegExp(`/${readOnlyId}$`)); await expect(qrImage).toHaveAttribute('src', `${page.url().split('?')[0]}/qr?readonly=true`); await waitForQrImage(page); diff --git a/playwright/specs/settings.spec.ts b/playwright/specs/settings.spec.ts index 4a420a14..95543d2f 100644 --- a/playwright/specs/settings.spec.ts +++ b/playwright/specs/settings.spec.ts @@ -1,5 +1,5 @@ import {expect, Page, test} from "@playwright/test"; -import {goToNewPad} from "../helper/padHelper"; +import {goToNewPad, setEpCheckbox} from "../helper/padHelper"; const settingsButton = "button[class~='buttonicon-settings']"; @@ -35,13 +35,13 @@ test.describe('settings popup and options', () => { await ensureSettingsVisible(page); const lineNumbersCheckbox = page.locator('#options-linenoscheck'); - await lineNumbersCheckbox.uncheck({force: true}); + await setEpCheckbox(lineNumbersCheckbox, false); await expect.poll(async () => { return await page.locator('#sidediv').evaluate((node) => node.parentElement?.classList.contains('line-numbers-hidden') ?? false); }).toBe(true); - await lineNumbersCheckbox.check({force: true}); + await setEpCheckbox(lineNumbersCheckbox, true); await expect.poll(async () => { return await page.locator('#sidediv').evaluate((node) => node.parentElement?.classList.contains('line-numbers-hidden') ?? false); @@ -53,10 +53,10 @@ test.describe('settings popup and options', () => { const colorsCheckbox = page.locator('#options-colorscheck'); const chatText = page.locator('#chattext'); - await colorsCheckbox.uncheck({force: true}); + await setEpCheckbox(colorsCheckbox, false); await expect(chatText).not.toHaveClass(/authorColors/); - await colorsCheckbox.check({force: true}); + await setEpCheckbox(colorsCheckbox, true); await expect(chatText).toHaveClass(/authorColors/); }); @@ -64,10 +64,10 @@ test.describe('settings popup and options', () => { await ensureSettingsVisible(page); const rtlCheckbox = page.locator('#options-rtlcheck'); - await rtlCheckbox.check({force: true}); + await setEpCheckbox(rtlCheckbox, true); await expect(page.locator('html')).toHaveAttribute('dir', 'rtl'); - await rtlCheckbox.uncheck({force: true}); + await setEpCheckbox(rtlCheckbox, false); await expect(page.locator('html')).toHaveAttribute('dir', 'ltr'); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcff568b..08b5ee29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: etherpad-webcomponents: - specifier: ^0.0.8 - version: 0.0.8 + specifier: ^0.0.9 + version: 0.0.9 devDependencies: typescript: specifier: ^5.6.3 @@ -116,8 +116,8 @@ importers: specifier: 0.28.0 version: 0.28.0 etherpad-webcomponents: - specifier: ^0.0.8 - version: 0.0.8 + specifier: ^0.0.9 + version: 0.0.9 typescript: specifier: ^6.0.2 version: 6.0.2 @@ -850,8 +850,8 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - etherpad-webcomponents@0.0.8: - resolution: {integrity: sha512-V4Tw4EpZvOnjevenx7b8allkoa0S/8vUdCIXrDLSIu261Mb5UoRAuWDuMD5GdzupFrgg+C2yovBtWE10m3lucA==} + etherpad-webcomponents@0.0.9: + resolution: {integrity: sha512-w/jom2QxEjU0403sTAi44X2RPqL6kJhmjJLKrVa291jk3RQ50wZixDfTBIGDRRmkuwoLIOvTJ+ps5IiPbRrMIA==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1696,7 +1696,7 @@ snapshots: estree-walker@2.0.2: {} - etherpad-webcomponents@0.0.8: + etherpad-webcomponents@0.0.9: dependencies: '@lit/reactive-element': 2.1.2 lit: 3.3.2 diff --git a/ui/package.json b/ui/package.json index e1c965ed..3afcfaed 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "devDependencies": { - "etherpad-webcomponents": "^0.0.8", + "etherpad-webcomponents": "^0.0.9", "@types/node": "^25.6.0", "esbuild": "0.28.0", "typescript": "^6.0.2", From ce23f511011eb007ac2632819479f407f5d97ce9 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:42:25 +0200 Subject: [PATCH 06/10] feat: fixed playwright tests --- package.json | 2 +- playwright/helper/padHelper.ts | 43 +++++++++++++++++-------- playwright/helper/settingsHelper.ts | 11 +++---- playwright/specs/chat.spec.ts | 12 ++++--- playwright/specs/font_type.spec.ts | 16 ++++----- playwright/specs/language.spec.ts | 8 ++--- playwright/specs/ordered_list.spec.ts | 13 ++++++++ playwright/specs/unordered_list.spec.ts | 7 ++++ ui/package.json | 2 +- ui/src/js/pad_editbar.ts | 7 ++-- ui/src/js/pad_editor.ts | 11 +++++++ 11 files changed, 90 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index 7ca878c2..a0f15e3d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,6 @@ "typescript": "^5.6.3" }, "dependencies": { - "etherpad-webcomponents": "^0.0.9" + "etherpad-webcomponents": "^0.0.11" } } diff --git a/playwright/helper/padHelper.ts b/playwright/helper/padHelper.ts index bd5c6515..4e308d48 100644 --- a/playwright/helper/padHelper.ts +++ b/playwright/helper/padHelper.ts @@ -34,6 +34,17 @@ export const setEpCheckbox = async (locator: Locator, want: boolean) => { export const isEpCheckboxChecked = (locator: Locator): Promise => locator.evaluate((el: Element) => el.hasAttribute('checked')); +// is a Lit web component, not a native . Reading .checked + // works on both because the Lit component exposes a reflected `checked` property. + const isReadonly = Boolean((readonlyInput as any)?.checked); + const {link} = this.getShareLinks(isReadonly); qrLinkInput.value = link; - qrImage.src = this.getQrCodeSrc(Boolean(readonlyInput instanceof HTMLInputElement && readonlyInput.checked)); + qrImage.src = this.getQrCodeSrc(isReadonly); } _syncToolbarScrollState() { diff --git a/ui/src/js/pad_editor.ts b/ui/src/js/pad_editor.ts index 9cc0622b..dfd415da 100644 --- a/ui/src/js/pad_editor.ts +++ b/ui/src/js/pad_editor.ts @@ -158,10 +158,21 @@ export const padeditor = (() => { v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection())); self.ace.setProperty('rtlIsTrue', v); + // setProperty on the AceEditor only flips the editor body's direction. + // Etherpad's layout also depends on the dir attribute (the whole + // page flips), so mirror the setting here. The original ace2_inner.ts did + // this inside the editor; in the webcomponent port the editor is scoped + // to its own DOM, so the page-level update lives at the consumer layer. + document.documentElement.dir = v ? 'rtl' : 'ltr'; padutils.setCheckbox('#options-rtlcheck', v); v = getOption('showLineNumbers', true); self.ace.setProperty('showslinenumbers', v); + // #sidediv is outside the editor's scope (it's a sibling in + // #outerdocbody), so the webcomponent AceEditor cannot toggle its + // parent's class the way the original ace2_inner did. + document.getElementById('sidediv') + ?.parentElement?.classList.toggle('line-numbers-hidden', !v); padutils.setCheckbox('#options-linenoscheck', v); v = getOption('showAuthorColors', true); From 7ccc5865da10160ac5a04fe7851abb37a05fd518 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:20:42 +0200 Subject: [PATCH 07/10] Merging --- settings.template.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/settings.template.json b/settings.template.json index 33e11845..ed34d3bb 100644 --- a/settings.template.json +++ b/settings.template.json @@ -78,9 +78,11 @@ "trustProxy": false, "cookie": { "keyRotationInterval": 86400000, + "prefix": "", "sameSite": "Lax", "sessionLifetime": 864000000, - "sessionRefreshInterval": 86400000 + "sessionRefreshInterval": 86400000, + "sessionCleanup": true }, "disableIPlogging": false, "automaticReconnectionTimeout": 0, @@ -105,7 +107,7 @@ }, "socketTransportProtocols": ["websocket", "polling"], "socketIo": { - "maxHttpBufferSize": 50000 + "maxHttpBufferSize": 1048576 }, "loadTest": false, "dumpOnUncleanExit": false, From 1c6ea92914b44120062342a359ab5c5b84528aa6 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:22:47 +0200 Subject: [PATCH 08/10] Merging --- pnpm-lock.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08b5ee29..1870760f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: etherpad-webcomponents: - specifier: ^0.0.9 - version: 0.0.9 + specifier: ^0.0.11 + version: 0.0.11 devDependencies: typescript: specifier: ^5.6.3 @@ -116,8 +116,8 @@ importers: specifier: 0.28.0 version: 0.28.0 etherpad-webcomponents: - specifier: ^0.0.9 - version: 0.0.9 + specifier: ^0.0.11 + version: 0.0.11 typescript: specifier: ^6.0.2 version: 6.0.2 @@ -850,8 +850,8 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - etherpad-webcomponents@0.0.9: - resolution: {integrity: sha512-w/jom2QxEjU0403sTAi44X2RPqL6kJhmjJLKrVa291jk3RQ50wZixDfTBIGDRRmkuwoLIOvTJ+ps5IiPbRrMIA==} + etherpad-webcomponents@0.0.11: + resolution: {integrity: sha512-4QrJuCAXHIAm7hxffAv4cIPaXesLLSd1rfJhpOvI8gaOCvjhMSJb9wDLItwjfCMnbTHkRPQTVdAZGwwqVkyM0Q==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1696,7 +1696,7 @@ snapshots: estree-walker@2.0.2: {} - etherpad-webcomponents@0.0.9: + etherpad-webcomponents@0.0.11: dependencies: '@lit/reactive-element': 2.1.2 lit: 3.3.2 From 76f11d8d65fd97f658bf4a32bc1e3a108f18fecd Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:50:59 +0200 Subject: [PATCH 09/10] Merging --- playwright/helper/padHelper.ts | 24 +++++++++++++++++++++- playwright/helper/settingsHelper.ts | 10 +++++++++ playwright/specs/change_user_color.spec.ts | 21 ++++++++++++------- playwright/specs/change_user_name.spec.ts | 11 +++++++--- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/playwright/helper/padHelper.ts b/playwright/helper/padHelper.ts index 4e308d48..3d55165f 100644 --- a/playwright/helper/padHelper.ts +++ b/playwright/helper/padHelper.ts @@ -39,10 +39,32 @@ export const isEpCheckboxChecked = (locator: Locator): Promise => // dropdown requires clicking its trigger button; choosing a value means // clicking the matching . The component dispatches // 'ep-dropdown-select' on pick, which consumer code listens for. +// +// The opened/closed state is driven by the reflected `open` boolean +// attribute on the host. The component's shadow DOM keeps its +// `.content-wrapper` (and therefore the slotted items) `display: none` +// until `[open]` is set; clicking an item before that would race with +// Lit's async `updated()` lifecycle and time out. So we: +// 1. click the slotted trigger button, +// 2. wait for the host to report `open`, +// 3. confirm the item is present+visible, +// 4. click it. export const selectEpDropdownItem = async (page: Page, dropdownSelector: string, value: string) => { const dropdown = page.locator(dropdownSelector); + await dropdown.waitFor({ state: 'visible', timeout: 10000 }); await dropdown.locator('[slot="trigger"]').click(); - await dropdown.locator(`ep-dropdown-item[value="${value}"]`).click(); + // Re-open if a stale `_onDocClick` fired synchronously with our own + // click and immediately closed the dropdown. + await expect.poll(async () => { + const isOpen = await dropdown.evaluate((el: Element) => el.hasAttribute('open')); + if (!isOpen) { + await dropdown.locator('[slot="trigger"]').click().catch(() => {}); + } + return isOpen; + }, { timeout: 10000 }).toBe(true); + const item = dropdown.locator(`ep-dropdown-item[value="${value}"]`); + await item.waitFor({ state: 'visible', timeout: 10000 }); + await item.click(); } export const selectAllText = async (page: Page) => { diff --git a/playwright/helper/settingsHelper.ts b/playwright/helper/settingsHelper.ts index 54a33a6e..351071d5 100644 --- a/playwright/helper/settingsHelper.ts +++ b/playwright/helper/settingsHelper.ts @@ -10,6 +10,16 @@ export const showSettings = async (page: Page) => { if (await isSettingsShown(page)) return await page.locator("button[class~='buttonicon-settings']").click() await expect(page.locator('#settings')).toHaveClass(/popup-show/, { timeout: 5000 }) + // The popup's scale(0.7) → none transform animates over 300ms. While + // that transform is non-identity it forms a containing block for any + // position: fixed descendants — which includes 's + // content-wrapper. Clicking a dropdown item before the transform + // settles positions the content relative to the still-transforming + // popup instead of the viewport, and Playwright can time out waiting + // for a stable, visible item. Wait for the transition to complete. + await page.locator('#settings').evaluate((el) => + Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished.catch(() => {}))) + ); } export const hideSettings = async (page: Page) => { diff --git a/playwright/specs/change_user_color.spec.ts b/playwright/specs/change_user_color.spec.ts index d583a221..9e2bbcc5 100644 --- a/playwright/specs/change_user_color.spec.ts +++ b/playwright/specs/change_user_color.spec.ts @@ -86,16 +86,23 @@ test.describe('change user color', function () { await showChat(page) await sendChatMessage(page, 'O hi'); - // wait until the chat message shows up - const chatP = page.locator('#chattext').locator('p') - const chatText = await chatP.innerText(); + // wait until the chat message shows up — chat now renders as + // webcomponents rather than

elements. + const chatMsg = page.locator('#chattext').locator('ep-chat-message').first() + await expect(chatMsg).toBeVisible({timeout: 10000}); + const chatText = (await chatMsg.textContent()) ?? ''; expect(chatText).toContain('O hi'); - const color = await chatP.evaluate((el) => { - return window.getComputedStyle(el).getPropertyValue('background-color'); - }, chatText); + // The author color is rendered inside the shadow DOM on the + // `.author` span via inline `color: ${authorColor}`. Read it from + // the shadow root. + const authorColor = await chatMsg.evaluate((el) => { + const span = el.shadowRoot?.querySelector('.author') as HTMLElement | null; + if (!span) return ''; + return window.getComputedStyle(span).getPropertyValue('color'); + }); - expect(color).toBe(testColorRGB); + expect(authorColor).toBe(testColorRGB); }); }); diff --git a/playwright/specs/change_user_name.spec.ts b/playwright/specs/change_user_name.spec.ts index 37877240..605ec337 100644 --- a/playwright/specs/change_user_name.spec.ts +++ b/playwright/specs/change_user_name.spec.ts @@ -29,7 +29,12 @@ test('Own user name is shown when you enter a chat', async ({page})=> { await showChat(page); await sendChatMessage(page,chatMessage); - const chatText = await page.locator('#chattext').locator('p').innerText(); - expect(chatText).toContain('😃') - expect(chatText).toContain(chatMessage) + // Chat renders as webcomponents: the author name lives + // on the `author` attribute, and the message body is the slotted text. + const chatMsg = page.locator('#chattext').locator('ep-chat-message').first(); + await expect(chatMsg).toBeVisible({timeout: 10000}); + const author = (await chatMsg.getAttribute('author')) ?? ''; + const body = (await chatMsg.textContent()) ?? ''; + expect(author).toContain('😃'); + expect(body).toContain(chatMessage); }); From ef0b5c23f6df5a8619ec3ead6e02f7f61a778984 Mon Sep 17 00:00:00 2001 From: SamTV12345 <40429738+samtv12345@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:32:58 +0200 Subject: [PATCH 10/10] Merging --- playwright/helper/padHelper.ts | 59 ++++++++++++++++++---------------- ui/src/js/chat.ts | 35 +++++++++++++++++++- 2 files changed, 66 insertions(+), 28 deletions(-) diff --git a/playwright/helper/padHelper.ts b/playwright/helper/padHelper.ts index 3d55165f..07411655 100644 --- a/playwright/helper/padHelper.ts +++ b/playwright/helper/padHelper.ts @@ -35,36 +35,41 @@ export const isEpCheckboxChecked = (locator: Locator): Promise => locator.evaluate((el: Element) => el.hasAttribute('checked')); // is a Lit web component, not a native