Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/Resources/CrestApps.AI.Resources/Assets/css/ai-chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -448,13 +448,75 @@
display: inline-block;
vertical-align: top;
margin-top: 0.5rem;
/* The positioning context for the download that sits inside it. */
position: relative;
}

.generated-image-container img {
max-width: 100% !important;
max-height: 60vh;
height: auto;
object-fit: contain;
/* The cap above is what makes a figure a thumbnail: an ingested diagram lands at roughly half size,
legible as a picture but not as a drawing. Pointing at it says it can be opened. */
cursor: zoom-in;
}

/* The opened picture is a copy the library appends outside the figure, so it is matched on its own class
rather than through the container -- a rule written as a descendant of the figure never reaches it. The
copy carries the thumbnail's classes, so the frame is taken off here: its padding and white ground are
scaled up with the drawing and would otherwise ring the picture with a broad white border. The cap is
lifted for the same reason, and only here, so the thumbnail sitting in the conversation is left exactly
as it was. */
img.medium-zoom-image--opened {
max-width: none !important;
max-height: none !important;
padding: 0;
border: 0;
background: none;
}

/* Over the picture's lower corner rather than below it, so a figure costs only the height of the picture.
It is drawn on a ground of its own because it sits on whatever the drawing happens to put in that
corner. */
.generated-image-container .ai-download-image {
position: absolute;
right: 0.5rem;
bottom: 0.5rem;
z-index: 2;
opacity: 0;
color: #1f2933;
background-color: rgba(255, 255, 255, 0.92);
border-color: rgba(0, 0, 0, 0.15);
transition: opacity 0.15s ease-in-out;
}

/* The outline button this is built from inverts on hover: it fills itself with its own colour and turns
its label white. Against the pale ground kept here that leaves a white icon on white -- the button is
still there and still works, but there is nothing to see. Both colours are therefore stated for every
state the button can be in rather than only the resting one. */
.generated-image-container .ai-download-image:hover,
.generated-image-container .ai-download-image:focus,
.generated-image-container .ai-download-image:focus-visible,
.generated-image-container .ai-download-image:active {
color: #1f2933;
background-color: #ffffff;
border-color: rgba(0, 0, 0, 0.35);
}

/* Revealed by pointing at the figure. focus-within is what keeps it reachable by keyboard: the button is
invisible until it takes focus, and an invisible control that cannot be brought into view cannot be
used at all. */
.generated-image-container:hover .ai-download-image,
.generated-image-container:focus-within .ai-download-image {
opacity: 1;
}

/* A touch screen has no hover to reveal it with, so there it is simply always shown. */
@media (hover: none) {
.generated-image-container .ai-download-image {
opacity: 1;
}
}

/* ── Tables ── */
Expand Down
61 changes: 61 additions & 0 deletions src/Resources/CrestApps.AI.Resources/Assets/css/chat-widget.css
Original file line number Diff line number Diff line change
Expand Up @@ -419,12 +419,73 @@
display: inline-block;
vertical-align: top;
margin-top: 0.5rem;
/* The positioning context for the download that sits inside it. */
position: relative;
}

.ai-chat-widget-messages .generated-image-container img {
max-width: 100% !important;
max-height: 50vh;
object-fit: contain;
/* The panel is 380px wide, so a figure in it is a thumbnail whatever it was drawn at. Pointing at it
says it can be opened to the size of the window. */
cursor: zoom-in;
}

/* Deliberately not scoped to the panel. The opened picture is a copy the library appends to the document
body, which is outside the widget entirely, so a rule under .ai-chat-widget-messages never reaches it.
It is repeated from ai-chat.css because a page that mounts only the widget loads only this file. The
copy carries the thumbnail's classes, so the frame and the cap are taken off here -- both are scaled up
with the drawing and would otherwise ring the picture with a broad white border. */
img.medium-zoom-image--opened {
max-width: none !important;
max-height: none !important;
padding: 0;
border: 0;
background: none;
}

/* Over the picture's lower corner rather than below it. In a panel this narrow a row of its own cost a
band of vertical space per figure, which a conversation full of them pays for once each. */
.ai-chat-widget-messages .generated-image-container .ai-download-image {
position: absolute;
right: 0.5rem;
bottom: 0.5rem;
z-index: 2;
opacity: 0;
color: #1f2933;
background-color: rgba(255, 255, 255, 0.92);
border-color: rgba(0, 0, 0, 0.15);
transition: opacity 0.15s ease-in-out;
}

/* The outline button this is built from inverts on hover: it fills itself with its own colour and turns
its label white. Against the pale ground kept here that leaves a white icon on white -- the button is
still there and still works, but there is nothing to see. Both colours are therefore stated for every
state the button can be in rather than only the resting one. */
.ai-chat-widget-messages .generated-image-container .ai-download-image:hover,
.ai-chat-widget-messages .generated-image-container .ai-download-image:focus,
.ai-chat-widget-messages .generated-image-container .ai-download-image:focus-visible,
.ai-chat-widget-messages .generated-image-container .ai-download-image:active {
color: #1f2933;
background-color: #ffffff;
border-color: rgba(0, 0, 0, 0.35);
}

/* Revealed by pointing at the figure. focus-within is what keeps it reachable by keyboard: the button is
invisible until it takes focus, and an invisible control that cannot be brought into view cannot be
used at all. */
.ai-chat-widget-messages .generated-image-container:hover .ai-download-image,
.ai-chat-widget-messages .generated-image-container:focus-within .ai-download-image {
opacity: 1;
}

/* A touch screen has no hover to reveal it with, so there it is simply always shown. The widget is the
surface most likely to be read on one. */
@media (hover: none) {
.ai-chat-widget-messages .generated-image-container .ai-download-image {
opacity: 1;
}
}

/* ── Tables inside widget ── */
Expand Down
43 changes: 38 additions & 5 deletions src/Resources/CrestApps.AI.Resources/Assets/js/ai-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -489,13 +489,15 @@ window.coreAIChatManager = function () {
// at the first one, so the sentence is torn into fragments and the commas between the markers are
// left stranded on their own lines. Inline-block keeps the figure looking exactly the same while
// remaining something a paragraph can legally contain.
// The download sits over the picture's lower corner rather than on a line of its own beneath it.
// A row of its own cost a band of vertical space under every figure, which a conversation full of
// them pays for once per figure. The link itself is unchanged -- same address, same download
// attribute, same behaviour -- only where it is drawn.
return `<span class="generated-image-container">
<img src="${src}" alt="${alt}" class="img-thumbnail" style="max-width: ${maxWidth}px; height: auto;" />
<span class="mt-2 d-block">
<a href="${src}" target="_blank" download="${alt}" title="${defaultConfig.downloadImageTitle}" class="btn btn-sm btn-outline-secondary ai-download-image">
<i class="fa-solid fa-download"></i>
</a>
</span>
<a href="${src}" target="_blank" download="${alt}" title="${defaultConfig.downloadImageTitle}" class="btn btn-sm btn-outline-secondary ai-download-image">
<i class="fa-solid fa-download"></i>
</a>
</span>`;
};

Expand Down Expand Up @@ -578,6 +580,37 @@ window.coreAIChatManager = function () {
// Deferred for the same reason the charts below are: the render that produced these elements has
// not necessarily been flushed to the DOM when this runs.
requestAnimationFrame(wireBrokenImageHandlers);
requestAnimationFrame(wireImageZoom);
}

// A picture arrives after the markdown is handed back -- the view writes it, and a streamed answer
// rewrites it on every chunk -- so zooming cannot be attached once at load. It is attached on the same
// pass as the broken-image handler above, and the attribute marks a picture already attached so a
// re-render does not bind it a second time.
function wireImageZoom() {
// The library is the host's to load. Without it the picture is still shown and still downloadable;
// only the enlarging is missing, so this is not worth failing a render over.
if (typeof mediumZoom !== 'function') {
return;
}

const images = document.querySelectorAll('.generated-image-container img:not([data-zoom-wired])');

if (images.length === 0) {
return;
}

for (const image of images) {
image.setAttribute('data-zoom-wired', 'true');
}

mediumZoom(images, {
// The drawing is bounded by the window rather than by this margin, so the margin is kept
// small: an ingested figure is read detail by detail, and every pixel given away here comes
// off the size it is read at.
margin: 16,
background: 'rgba(15, 15, 15, 0.92)',
});
}

function wireBrokenImageHandlers() {
Expand Down
43 changes: 38 additions & 5 deletions src/Resources/CrestApps.AI.Resources/Assets/js/chat-interaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -411,13 +411,15 @@ window.chatInteractionManager = function () {
// at the first one, so the sentence is torn into fragments and the commas between the markers are
// left stranded on their own lines. Inline-block keeps the figure looking exactly the same while
// remaining something a paragraph can legally contain.
// The download sits over the picture's lower corner rather than on a line of its own beneath it.
// A row of its own cost a band of vertical space under every figure, which a conversation full of
// them pays for once per figure. The link itself is unchanged -- same address, same download
// attribute, same behaviour -- only where it is drawn.
return `<span class="generated-image-container">
<img src="${src}" alt="${alt}" class="img-thumbnail" style="max-width: ${maxWidth}px; height: auto;" />
<span class="mt-2 d-block">
<a href="${src}" target="_blank" download="${alt}" title="${defaultConfig.downloadImageTitle}" class="btn btn-sm btn-outline-secondary ai-download-image">
<i class="fa-solid fa-download"></i>
</a>
</span>
<a href="${src}" target="_blank" download="${alt}" title="${defaultConfig.downloadImageTitle}" class="btn btn-sm btn-outline-secondary ai-download-image">
<i class="fa-solid fa-download"></i>
</a>
</span>`;
};

Expand Down Expand Up @@ -498,6 +500,37 @@ window.chatInteractionManager = function () {
// Deferred for the same reason the charts below are: the render that produced these elements has
// not necessarily been flushed to the DOM when this runs.
requestAnimationFrame(wireBrokenImageHandlers);
requestAnimationFrame(wireImageZoom);
}

// A picture arrives after the markdown is handed back -- the view writes it, and a streamed answer
// rewrites it on every chunk -- so zooming cannot be attached once at load. It is attached on the same
// pass as the broken-image handler above, and the attribute marks a picture already attached so a
// re-render does not bind it a second time.
function wireImageZoom() {
// The library is the host's to load. Without it the picture is still shown and still downloadable;
// only the enlarging is missing, so this is not worth failing a render over.
if (typeof mediumZoom !== 'function') {
return;
}

const images = document.querySelectorAll('.generated-image-container img:not([data-zoom-wired])');

if (images.length === 0) {
return;
}

for (const image of images) {
image.setAttribute('data-zoom-wired', 'true');
}

mediumZoom(images, {
// The drawing is bounded by the window rather than by this margin, so the margin is kept
// small: an ingested figure is read detail by detail, and every pixel given away here comes
// off the size it is read at.
margin: 16,
background: 'rgba(15, 15, 15, 0.92)',
});
}

function wireBrokenImageHandlers() {
Expand Down
Loading
Loading