703 lines
27 KiB
QML
703 lines
27 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.Common
|
|
import qs.Widgets
|
|
import qs.Modals.FileBrowser
|
|
import "../lib/Providers.js" as Providers
|
|
|
|
// Composer: transparent input, send/stop right; input grows to a
|
|
// cap then scrolls. Attachments stage above the input (clipboard
|
|
// or file picker) and pass Providers.sanitizeImages.
|
|
Item {
|
|
id: composer
|
|
|
|
signal submitted(string text, var images)
|
|
signal escapePressed()
|
|
signal newChatRequested()
|
|
|
|
property var service: null
|
|
property string placeholder: "Ask anything…"
|
|
readonly property bool busy: service ? service.isStreaming : false
|
|
|
|
// Staged image attachments ([{mime, data}]), capped by
|
|
// sanitizeImages (4). attachHint surfaces a rejection reason
|
|
// briefly, then auto-clears.
|
|
readonly property int maxImages: 4
|
|
property var attachments: []
|
|
property string attachHint: ""
|
|
|
|
// Input grows with content up to ~10 lines, then scrolls internally.
|
|
readonly property int maxInputHeight: 220
|
|
|
|
implicitHeight: divider.height + 12
|
|
+ (thumbStrip.visible ? thumbStrip.height + 8 : 0)
|
|
+ Math.max(48, Math.min(input.implicitHeight, maxInputHeight))
|
|
|
|
function send() {
|
|
var text = input.text.trim();
|
|
if (busy) return;
|
|
if (!text && composer.attachments.length === 0) return;
|
|
submitted(text, composer.attachments);
|
|
input.clear();
|
|
composer.attachments = [];
|
|
attachHint = "";
|
|
}
|
|
|
|
function showAttachHint(msg) {
|
|
attachHint = String(msg || "");
|
|
hintTimer.restart();
|
|
}
|
|
|
|
// Single ingest gate: sanitize (mime whitelist, base64 shape, size,
|
|
// cap) then stage. Reports a transient hint when the new image was
|
|
// rejected.
|
|
function addAttachment(mime, data) {
|
|
var before = composer.attachments.length;
|
|
var clean = Providers.sanitizeImages(
|
|
composer.attachments.concat([{ mime: mime, data: data }]),
|
|
composer.maxImages);
|
|
composer.attachments = clean;
|
|
showAttachHint(clean.length > before
|
|
? "" : "Image dropped — unsupported type, over 10 MB, or limit of "
|
|
+ composer.maxImages + " reached");
|
|
}
|
|
|
|
function removeAttachment(idx) {
|
|
var arr = composer.attachments.slice();
|
|
if (idx < 0 || idx >= arr.length) return;
|
|
arr.splice(idx, 1);
|
|
composer.attachments = arr;
|
|
if (arr.length === 0) attachHint = "";
|
|
}
|
|
|
|
// Clipboard ingest: ask wl-paste for the offered types; on an
|
|
// image, re-read that type as base64. Without an image the key
|
|
// press falls through to a normal text paste.
|
|
function pasteFromClipboard() {
|
|
clipProc.phase = 1;
|
|
clipProc.command = ["wl-paste", "--list-types"];
|
|
clipProc.running = true;
|
|
}
|
|
|
|
function pickImageMime(typesText) {
|
|
var types = String(typesText).split("\n").map(
|
|
function (t) { return t.trim(); });
|
|
var prefs = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
for (var i = 0; i < prefs.length; i++)
|
|
if (types.indexOf(prefs[i]) >= 0) return prefs[i];
|
|
return "";
|
|
}
|
|
|
|
// Magic-byte sniffing over the base64 head: PNG \x89PNG, JPEG
|
|
// \xFF\xD8\xFF, WEBP RIFF, GIF8. "" for anything else.
|
|
function sniffImageMime(b64) {
|
|
if (b64.indexOf("iVBOR") === 0) return "image/png";
|
|
if (b64.indexOf("/9j/") === 0) return "image/jpeg";
|
|
if (b64.indexOf("UklGR") === 0) return "image/webp";
|
|
if (b64.indexOf("R0lGOD") === 0) return "image/gif";
|
|
return "";
|
|
}
|
|
|
|
// File picker: DMS's native FileBrowser modal — Qt's FileDialog
|
|
// (GTK3 helper) aborts the whole shell process here.
|
|
function attachFromFile() {
|
|
imageBrowserLoader.active = true;
|
|
if (imageBrowserLoader.item)
|
|
imageBrowserLoader.item.open();
|
|
}
|
|
|
|
function ingestFilePath(path) {
|
|
if (!path || path.length === 0) return;
|
|
var p = String(path).replace(/'/g, "'\\''");
|
|
fileProc.command = ["sh", "-c", "base64 -w0 '" + p + "'"];
|
|
fileProc.running = true;
|
|
}
|
|
|
|
// Discard an in-progress edit: leave edit mode and clear the
|
|
// draft, returning the composer to normal new-message state.
|
|
function discardEdit() {
|
|
if (service) service.cancelEditUserMessage();
|
|
input.clear();
|
|
focusInput();
|
|
}
|
|
|
|
function focusInput() {
|
|
input.forceActiveFocus();
|
|
input.cursorPosition = input.text.length;
|
|
}
|
|
|
|
// Modifier flags required for Enter to send.
|
|
readonly property int sendModifiers: {
|
|
var m = service ? service.sendModifier : "none";
|
|
if (m === "shift") return Qt.ShiftModifier;
|
|
if (m === "ctrl") return Qt.ControlModifier;
|
|
if (m === "alt") return Qt.AltModifier;
|
|
return Qt.NoModifier;
|
|
}
|
|
|
|
// Enter fires a send only for the configured chord; every other
|
|
// chord falls through (accepted = false) and inserts a newline.
|
|
// KeypadModifier is masked so numpad Enter matches Key_Enter.
|
|
function handleReturnKey(event) {
|
|
var mods = event.modifiers & ~Qt.KeypadModifier;
|
|
if (mods === composer.sendModifiers) composer.send();
|
|
else event.accepted = false;
|
|
}
|
|
|
|
// Load a draft into the input (used by message editing: the text
|
|
// of the edited user message lands here; submitting performs the
|
|
// edit — see NexusChat's onSubmitted routing).
|
|
function setText(t) {
|
|
input.text = String(t || "");
|
|
input.cursorPosition = input.text.length;
|
|
}
|
|
|
|
Rectangle {
|
|
id: divider
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
height: 1
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
GradientStop { position: 0.0; color: "transparent" }
|
|
GradientStop { position: 0.5; color: Theme.primary }
|
|
GradientStop { position: 1.0; color: "transparent" }
|
|
}
|
|
}
|
|
|
|
// Staged attachment thumbnails + rejection hint. Zero-height while
|
|
// empty so the input row sits right under the divider.
|
|
Row {
|
|
id: thumbStrip
|
|
visible: composer.attachments.length > 0 || composer.attachHint.length > 0
|
|
anchors.top: divider.bottom
|
|
anchors.topMargin: 8
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 4
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
spacing: 8
|
|
|
|
Repeater {
|
|
model: composer.attachments.length
|
|
|
|
delegate: Item {
|
|
id: thumb
|
|
required property int index
|
|
width: 92
|
|
height: 68
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: 6
|
|
color: Theme.withAlpha(Theme.surfaceContainerLowest, 0.6)
|
|
clip: true
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
asynchronous: true
|
|
fillMode: Image.PreserveAspectCrop
|
|
source: composer.attachments[thumb.index]
|
|
? "data:" + composer.attachments[thumb.index].mime
|
|
+ ";base64," + composer.attachments[thumb.index].data
|
|
: ""
|
|
}
|
|
}
|
|
|
|
// Remove badge: overlaps the top-right corner.
|
|
Item {
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.rightMargin: -6
|
|
anchors.topMargin: -6
|
|
width: 20
|
|
height: 20
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: 10
|
|
color: thumbRemoveHover.hovered
|
|
? Theme.withAlpha(Theme.primary, 0.25)
|
|
: Theme.surfaceContainerHigh
|
|
border.width: 1
|
|
border.color: Theme.outlineVariant
|
|
}
|
|
DankIcon {
|
|
anchors.centerIn: parent
|
|
name: "close"
|
|
size: 12
|
|
color: thumbRemoveHover.hovered
|
|
? Theme.primary : Theme.surfaceText
|
|
}
|
|
HoverHandler {
|
|
id: thumbRemoveHover
|
|
cursorShape: Qt.PointingHandCursor
|
|
}
|
|
TapHandler {
|
|
onTapped: composer.removeAttachment(thumb.index)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
visible: composer.attachHint.length > 0
|
|
text: composer.attachHint
|
|
color: Theme.error
|
|
font.pixelSize: 11
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
anchors.top: thumbStrip.visible ? thumbStrip.bottom : divider.bottom
|
|
anchors.topMargin: 8
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
anchors.leftMargin: 4
|
|
anchors.rightMargin: 4
|
|
anchors.bottomMargin: 4
|
|
spacing: 8
|
|
|
|
Flickable {
|
|
id: inputScroll
|
|
Layout.fillWidth: true
|
|
Layout.alignment: Qt.AlignVCenter
|
|
Layout.maximumHeight: composer.maxInputHeight
|
|
Layout.preferredHeight: Math.min(input.implicitHeight, composer.maxInputHeight)
|
|
contentHeight: input.contentHeight
|
|
clip: true
|
|
interactive: contentHeight > height
|
|
flickableDirection: Flickable.VerticalFlick
|
|
|
|
ScrollBar.vertical: DankScrollbar { _scrollBarActive: true }
|
|
|
|
TextEdit {
|
|
id: input
|
|
width: inputScroll.width
|
|
textFormat: TextEdit.PlainText
|
|
wrapMode: TextEdit.Wrap
|
|
color: Theme.surfaceText
|
|
font.pixelSize: 14
|
|
|
|
// keep the cursor visible while typing past the cap
|
|
onCursorRectangleChanged: {
|
|
var y = cursorRectangle.y - inputScroll.contentY;
|
|
if (y < 0)
|
|
inputScroll.contentY += y;
|
|
else if (y + cursorRectangle.height > inputScroll.height)
|
|
inputScroll.contentY += y + cursorRectangle.height - inputScroll.height;
|
|
}
|
|
|
|
Keys.onEscapePressed: (event) => {
|
|
event.accepted = true;
|
|
composer.escapePressed();
|
|
}
|
|
Keys.onPressed: (event) => {
|
|
if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_N) {
|
|
event.accepted = true;
|
|
composer.newChatRequested();
|
|
return;
|
|
}
|
|
// Ctrl+V: an image on the clipboard is staged as an
|
|
// attachment; plain text falls through to the
|
|
// normal paste inside pasteFromClipboard's flow.
|
|
if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_V) {
|
|
event.accepted = true;
|
|
composer.pasteFromClipboard();
|
|
return;
|
|
}
|
|
// Up in an empty input = edit the last user message
|
|
// (Slack/Discord convention). Non-empty inputs keep
|
|
// normal cursor navigation.
|
|
if (event.key === Qt.Key_Up && input.text.length === 0
|
|
&& !busy && service && service.messageCount > 0) {
|
|
event.accepted = true;
|
|
service.beginEditLastUserMessage();
|
|
}
|
|
}
|
|
Keys.onReturnPressed: (event) => handleReturnKey(event)
|
|
Keys.onEnterPressed: (event) => handleReturnKey(event)
|
|
}
|
|
|
|
// Placeholder: sits exactly where typing starts, non-
|
|
// interactive so clicks land on the input. Shown only for a
|
|
// brand-new chat (no messages yet); re-rolled on clear.
|
|
Text {
|
|
anchors.left: parent.left
|
|
anchors.top: parent.top
|
|
width: input.width
|
|
text: composer.placeholder
|
|
color: Theme.surfaceVariantText
|
|
opacity: 0.5
|
|
font.pixelSize: 14
|
|
visible: input.text.length === 0
|
|
&& (!composer.service || composer.service.messageCount === 0)
|
|
}
|
|
}
|
|
|
|
// Discard affordance while a message edit is in progress:
|
|
// drops the draft and leaves edit mode.
|
|
DankButton {
|
|
iconName: "close"
|
|
text: ""
|
|
buttonHeight: 36
|
|
Layout.alignment: Qt.AlignVCenter
|
|
visible: !busy && service && service.editingMessageId.length > 0
|
|
onClicked: composer.discardEdit()
|
|
}
|
|
|
|
// Split send button: left half = send/stop/confirm-edit, right
|
|
// half = chord picker. Hover/press brightens only that half.
|
|
Rectangle {
|
|
id: splitSend
|
|
height: 36
|
|
radius: Theme.cornerRadius
|
|
Layout.alignment: Qt.AlignVCenter
|
|
implicitWidth: sendZone.width + arrowZone.width
|
|
color: Theme.buttonBg
|
|
readonly property bool sendHover: sendArea.containsMouse
|
|
readonly property bool sendPressed: sendArea.pressed
|
|
readonly property bool arrowHover: arrowArea.containsMouse
|
|
readonly property bool arrowPressed: arrowArea.pressed
|
|
// Split line as a gradient position (0..1).
|
|
readonly property real splitPos:
|
|
sendZone.width / Math.max(1, width)
|
|
|
|
// Rest shading: gradient hard-stops at the split line so
|
|
// the tint rounds cleanly into the corners.
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: splitSend.radius
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
GradientStop { position: 0; color: "transparent" }
|
|
GradientStop {
|
|
position: splitSend.splitPos
|
|
color: "transparent"
|
|
}
|
|
GradientStop {
|
|
position: Math.min(1, splitSend.splitPos + 0.001)
|
|
color: Theme.withAlpha(Theme.buttonText, 0.10)
|
|
}
|
|
GradientStop {
|
|
position: 1
|
|
color: Theme.withAlpha(Theme.buttonText, 0.10)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Disabled veil on the send half only (picker stays lit).
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: splitSend.radius
|
|
opacity: sendArea.enabled ? 0 : 0.55
|
|
Behavior on opacity { NumberAnimation { duration: 180 } }
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
GradientStop { position: 0; color: Theme.surfaceContainer }
|
|
GradientStop {
|
|
position: splitSend.splitPos
|
|
color: Theme.surfaceContainer
|
|
}
|
|
GradientStop {
|
|
position: Math.min(1, splitSend.splitPos + 0.001)
|
|
color: "transparent"
|
|
}
|
|
GradientStop { position: 1; color: "transparent" }
|
|
}
|
|
}
|
|
|
|
// White hover/press wash (buttonText tints misbehave on
|
|
// some buttonBg modes); hard-stops at the split line.
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: splitSend.radius
|
|
opacity: splitSend.sendHover || splitSend.sendPressed ? 1 : 0
|
|
Behavior on opacity { NumberAnimation { duration: 180 } }
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
GradientStop {
|
|
position: 0
|
|
color: splitSend.sendPressed
|
|
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
|
|
}
|
|
GradientStop {
|
|
position: splitSend.splitPos
|
|
color: splitSend.sendPressed
|
|
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
|
|
}
|
|
GradientStop {
|
|
position: Math.min(1, splitSend.splitPos + 0.001)
|
|
color: "transparent"
|
|
}
|
|
GradientStop { position: 1; color: "transparent" }
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: splitSend.radius
|
|
opacity: splitSend.arrowHover || splitSend.arrowPressed ? 1 : 0
|
|
Behavior on opacity { NumberAnimation { duration: 180 } }
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
GradientStop { position: 0; color: "transparent" }
|
|
GradientStop {
|
|
position: splitSend.splitPos
|
|
color: "transparent"
|
|
}
|
|
GradientStop {
|
|
position: Math.min(1, splitSend.splitPos + 0.001)
|
|
color: splitSend.arrowPressed
|
|
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
|
|
}
|
|
GradientStop {
|
|
position: 1
|
|
color: splitSend.arrowPressed
|
|
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Left half: send / stop streaming / confirm edit. Dimmed
|
|
// when there is nothing to do (empty input, idle).
|
|
Item {
|
|
id: sendZone
|
|
anchors.left: parent.left
|
|
width: 64
|
|
height: parent.height
|
|
|
|
DankIcon {
|
|
anchors.centerIn: parent
|
|
name: busy ? "stop_circle"
|
|
: (service && service.editingMessageId.length > 0
|
|
? "check" : "send")
|
|
size: 18
|
|
color: Theme.buttonText
|
|
opacity: sendArea.enabled ? 1 : 0.4
|
|
Behavior on opacity { NumberAnimation { duration: 180 } }
|
|
}
|
|
|
|
MouseArea {
|
|
id: sendArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
enabled: busy || input.text.trim().length > 0
|
|
|| composer.attachments.length > 0
|
|
cursorShape: enabled ? Qt.PointingHandCursor
|
|
: Qt.ArrowCursor
|
|
onClicked: {
|
|
if (busy) service.cancelStream();
|
|
else composer.send();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Right half: send-chord picker (always active).
|
|
Item {
|
|
id: arrowZone
|
|
anchors.right: parent.right
|
|
width: 30
|
|
height: parent.height
|
|
|
|
DankIcon {
|
|
anchors.centerIn: parent
|
|
name: "arrow_drop_down"
|
|
size: 18
|
|
color: Theme.buttonText
|
|
}
|
|
|
|
MouseArea {
|
|
id: arrowArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: modPopup.visible ? modPopup.close()
|
|
: modPopup.open()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send-chord picker: opens above the composer, right-aligned
|
|
// to the split button.
|
|
Popup {
|
|
id: modPopup
|
|
parent: arrowZone
|
|
x: parent.width - width
|
|
y: -height - 8
|
|
width: 168
|
|
height: modCol.implicitHeight + 12
|
|
padding: 6
|
|
|
|
background: Rectangle {
|
|
radius: 8
|
|
color: Theme.surfaceContainerHigh
|
|
border.width: 1
|
|
border.color: Theme.outlineVariant
|
|
}
|
|
|
|
contentItem: ColumnLayout {
|
|
id: modCol
|
|
spacing: 2
|
|
|
|
Repeater {
|
|
model: [
|
|
{ key: "none", label: "Return only", icon: "keyboard_return" },
|
|
{ key: "shift", label: "Shift", icon: "shift" },
|
|
{ key: "ctrl", label: "Ctrl", icon: "keyboard" },
|
|
{ key: "alt", label: "Alt", icon: "buttons_alt" }
|
|
]
|
|
|
|
delegate: Item {
|
|
id: modRow
|
|
required property var modelData
|
|
readonly property bool active: composer.service
|
|
&& composer.service.sendModifier === modRow.modelData.key
|
|
readonly property bool hovered: modHover.hovered
|
|
width: modCol.width
|
|
height: 32
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: 6
|
|
color: modRow.hovered
|
|
? Theme.withAlpha(Theme.primary, 0.14) : "transparent"
|
|
Behavior on color { ColorAnimation { duration: 150 } }
|
|
}
|
|
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 10
|
|
anchors.rightMargin: 10
|
|
spacing: 8
|
|
|
|
DankIcon {
|
|
name: modRow.modelData.icon
|
|
size: 16
|
|
color: modRow.active || modRow.hovered
|
|
? Theme.primary : Theme.surfaceVariantText
|
|
Behavior on color { ColorAnimation { duration: 150 } }
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: modRow.modelData.label
|
|
color: modRow.active || modRow.hovered
|
|
? Theme.primary : Theme.surfaceText
|
|
font.pixelSize: 13
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
DankIcon {
|
|
visible: modRow.active
|
|
name: "check"
|
|
size: 16
|
|
color: Theme.primary
|
|
}
|
|
}
|
|
|
|
HoverHandler {
|
|
id: modHover
|
|
cursorShape: Qt.PointingHandCursor
|
|
}
|
|
|
|
TapHandler {
|
|
onTapped: {
|
|
if (composer.service)
|
|
composer.service.setSendModifier(modRow.modelData.key);
|
|
modPopup.close();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Attachment ingest (clipboard) ──────────────────────────
|
|
Process {
|
|
id: clipProc
|
|
property int phase: 0 // 0 idle | 1 types | 2 data
|
|
property string _mime: ""
|
|
command: ["true"]
|
|
|
|
stdout: StdioCollector {
|
|
id: clipOut
|
|
onStreamFinished: {
|
|
if (clipProc.phase === 1) {
|
|
var pick = composer.pickImageMime(clipOut.text);
|
|
if (pick.length === 0) {
|
|
clipProc.phase = 0;
|
|
input.paste();
|
|
return;
|
|
}
|
|
clipProc._mime = pick;
|
|
clipProc.phase = 2;
|
|
clipProc.command = ["sh", "-c",
|
|
"wl-paste -t '" + pick + "' | base64 -w0"];
|
|
clipProc.running = true;
|
|
} else if (clipProc.phase === 2) {
|
|
var b64 = clipOut.text.replace(/\s+/g, "");
|
|
clipProc.phase = 0;
|
|
if (b64.length > 0)
|
|
composer.addAttachment(clipProc._mime, b64);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Picked file bytes → base64; the mime is sniffed from the
|
|
// magic bytes rather than trusting the extension.
|
|
Process {
|
|
id: fileProc
|
|
command: ["true"]
|
|
|
|
stdout: StdioCollector {
|
|
id: fileOut
|
|
onStreamFinished: {
|
|
var b64 = fileOut.text.replace(/\s+/g, "");
|
|
var mime = composer.sniffImageMime(b64);
|
|
if (mime.length > 0)
|
|
composer.addAttachment(mime, b64);
|
|
else
|
|
composer.showAttachHint("Unsupported image type");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Native DMS file browser, lazily created on first use and
|
|
// dropped again when it closes (same pattern as Notepad).
|
|
LazyLoader {
|
|
id: imageBrowserLoader
|
|
active: false
|
|
|
|
FileBrowserSurfaceModal {
|
|
browserTitle: "Attach image"
|
|
browserIcon: "attach_file"
|
|
browserType: "nexus_attach"
|
|
fileExtensions: ["*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif"]
|
|
allowStacking: true
|
|
|
|
onFileSelected: (path) => {
|
|
close();
|
|
var p = String(path).replace(/^file:\/\//, "");
|
|
try { p = decodeURIComponent(p); }
|
|
catch (e) { /* literal % in the name — keep raw */ }
|
|
composer.ingestFilePath(p);
|
|
}
|
|
onDialogClosed: imageBrowserLoader.active = false
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: hintTimer
|
|
interval: 4000
|
|
onTriggered: composer.attachHint = ""
|
|
}
|
|
}
|
|
}
|