Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/cms/src/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const {
ListingToggleAll,
LivePreview,
LivePreviewPopout,
MiddleEllipsis,
Modal,
ModalClose,
ModalTitle,
Expand Down
5 changes: 3 additions & 2 deletions resources/css/components/assets.css
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));

[data-placeholder-shown] {
@apply overflow-hidden mt-2 text-center font-mono text-xs text-ellipsis whitespace-nowrap lowercase;
@apply overflow-hidden mt-2 text-center text-xs text-ellipsis whitespace-nowrap lowercase;
&:has(+ .st-has-error) {
@apply text-red-600 dark:text-red-500;
}
Expand Down Expand Up @@ -158,7 +158,8 @@
}

.asset-filename {
@apply font-mono text-xs text-gray-500 dark:text-gray-300 mt-2 whitespace-nowrap text-center;
@apply text-xs text-gray-500 dark:text-gray-300 mt-2 whitespace-nowrap text-center st-text-legibility;
font-variant: tabular-nums; /* Disable common-ligatures from body default */
.selected & {
@apply text-blue-500;
.dark & {
Expand Down
1 change: 1 addition & 0 deletions resources/js/bootstrap/cms/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export {
ListingToggleAll,
LivePreview,
LivePreviewPopout,
MiddleEllipsis,
Modal,
ModalClose,
ModalTitle,
Expand Down
23 changes: 8 additions & 15 deletions resources/js/components/assets/Browser/Grid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<button @click="selectFolder(folder.path)" class="group h-[66px] w-[80px]">
<FolderSvg class="size-full text-blue-400/90 hover:text-blue-400" />
<div
class="overflow-hidden mt-2 text-center font-mono text-xs text-ellipsis whitespace-nowrap text-gray-500 dark:text-gray-300"
class="overflow-hidden mt-2 text-center text-xs text-ellipsis whitespace-nowrap text-gray-500 dark:text-gray-300"
v-text="folder.basename"
:title="folder.basename"
/>
Expand Down Expand Up @@ -67,7 +67,7 @@
submit-mode="enter"
:placeholder="__('Name')"
:class="[
'flex w-[80px] items-center placeholder:lowercase justify-center overflow-hidden mt-2 text-center font-mono text-xs text-ellipsis whitespace-nowrap placeholder:text-gray-400 dark:placeholder:text-gray-500 text-gray-500',
'flex w-[80px] items-center placeholder:lowercase justify-center overflow-hidden mt-2 text-center text-xs text-ellipsis whitespace-nowrap placeholder:text-gray-400 dark:placeholder:text-gray-500 text-gray-500',
{ 'st-has-error': creatingFolderError }
]"
@submit="$emit('create-folder', newFolderName)"
Expand Down Expand Up @@ -168,7 +168,9 @@
</ContextMenu>
</Context>
</ItemActions>
<div class="asset-filename" v-text="truncateFilename(asset.basename)" :title="asset.basename" />
<div class="asset-filename">
<MiddleEllipsis :text="asset.basename" />
</div>
</div>
</section>

Expand All @@ -193,7 +195,8 @@ import {
DropdownMenu,
DropdownLabel,
DropdownItem,
DropdownSeparator
DropdownSeparator,
MiddleEllipsis
} from '@ui';
import { injectListingContext } from '@/components/ui/Listing/Listing.vue';
import ItemActions from '@/components/actions/ItemActions.vue';
Expand All @@ -217,6 +220,7 @@ export default {
DropdownSeparator,
ItemActions,
FolderSvg,
MiddleEllipsis,
},

props: {
Expand Down Expand Up @@ -245,17 +249,6 @@ export default {
},

methods: {
truncateFilename(filename) {
const maxLength = Math.floor(this.thumbnailSize / 7);
if (filename.length <= maxLength) return filename;

const extension = filename.split('.').pop();
const name = filename.slice(0, -(extension.length + 1));
const charsToKeep = Math.floor((maxLength - 3 - extension.length) / 2);

return `${name.slice(0, charsToKeep)}…${name.slice(-charsToKeep)}.${extension}`;
},

isSelected(id) {
return this.selectedAssets.includes(id);
},
Expand Down
25 changes: 25 additions & 0 deletions resources/js/components/ui/MiddleEllipsis/MiddleEllipsis.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import truncateOnResize from './TruncateText.js';

const props = defineProps({
text: { type: String, required: true },
});

const truncatedRef = ref(null);
let cleanup = null;

onMounted(() => {
cleanup = truncateOnResize(truncatedRef.value, props.text);
});

onUnmounted(() => {
cleanup?.();
});
</script>

<template>
<div class="relative">
<div ref="truncatedRef" v-text="text" :title="text" :aria-label="text"></div>
</div>
</template>
85 changes: 85 additions & 0 deletions resources/js/components/ui/MiddleEllipsis/TruncateText.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import characterWidths from './TruncateTextCharacterMap.js';

const BASE_FONT_SIZE = 16;

/**
* Observes the parent element for resize events and truncates the text
* with a middle ellipsis when it overflows.
*
* Returns a cleanup function to disconnect the observer.
*/
export default function truncateOnResize(element, text, { ellipsis = '...' } = {}) {
if (!element.offsetParent || !text) {
return () => {};
}

const observer = new ResizeObserver(() => {
element.textContent = middleTruncate(element, text, ellipsis);
});

observer.observe(element.offsetParent);

return () => observer.disconnect();
}

/**
* Truncates text from the middle when it's too wide for the element,
* preserving the start and end of the string for readability.
*/
function middleTruncate(element, text, ellipsis) {
const style = window.getComputedStyle(element);
const fontSize = Number.parseFloat(style.fontSize);
const fontFamily = style.fontFamily.split(',')[0];

const availableWidth = measureAvailableWidth(element);

if (measureTextWidth(text, fontFamily, fontSize) <= availableWidth) {
return text;
}

let remainingWidth = availableWidth - measureTextWidth(ellipsis, fontFamily, fontSize);
let start = '';
let end = '';

// Build the truncated string from both ends, one character at a time,
// until we run out of space.
for (let i = 0; i < Math.floor(text.length / 2); i++) {
const startChar = text[i];
remainingWidth -= measureCharWidth(startChar, fontFamily, fontSize);
if (remainingWidth < 0) break;
start += startChar;

const endChar = text[text.length - i - 1];
remainingWidth -= measureCharWidth(endChar, fontFamily, fontSize);
if (remainingWidth < 0) break;
end = endChar + end;
}

return start + ellipsis + end;
}

// -- Text width estimation ------------------------------------------------
// Uses a precomputed character width map (at 16px base size) to estimate
// text width without triggering DOM layout. Unmapped characters fall back
// to 'W' width (the widest common character) for a safe overestimate.

function measureCharWidth(char, fontFamily, fontSize) {
const widths = characterWidths[fontFamily] ?? {};
const baseWidth = widths[char] ?? widths.W ?? BASE_FONT_SIZE;
return baseWidth * (fontSize / BASE_FONT_SIZE);
}

function measureTextWidth(text, fontFamily, fontSize) {
let width = 0;
for (const char of text) {
width += measureCharWidth(char, fontFamily, fontSize);
}
return width;
}

function measureAvailableWidth(element) {
const parent = element.parentElement;
if (!parent) return 0;

return Number.parseFloat(window.getComputedStyle(parent).width);
}
100 changes: 100 additions & 0 deletions resources/js/components/ui/MiddleEllipsis/TruncateTextCharacterMap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
export default {
"inter": {
"0": 10.062,
"1": 6.428,
"2": 9.671,
"3": 9.848,
"4": 10.285,
"5": 9.462,
"6": 9.857,
"7": 9.121,
"8": 9.833,
"9": 9.857,
" ": 4.444,
"!": 4.480,
"\"": 7.342,
"#": 10.072,
"$": 10.217,
"%": 15.465,
"&": 10.241,
"'": 4.715,
"(": 5.718,
")": 5.718,
"*": 7.958,
"+": 10.526,
",": 4.487,
"-": 7.303,
".": 4.487,
"/": 5.703,
":": 4.487,
";": 4.687,
"<": 10.526,
"=": 10.526,
">": 10.526,
"?": 8.202,
"@": 15.467,
"A": 11.086,
"B": 10.440,
"C": 11.672,
"D": 11.489,
"E": 9.578,
"F": 9.381,
"G": 11.909,
"H": 11.828,
"I": 4.232,
"J": 9.068,
"K": 10.683,
"L": 8.993,
"M": 14.371,
"N": 11.977,
"O": 12.206,
"P": 10.171,
"Q": 12.206,
"R": 10.276,
"S": 10.217,
"T": 10.265,
"U": 11.833,
"V": 11.099,
"W": 15.888,
"X": 10.848,
"Y": 10.795,
"Z": 10.034,
"[": 5.718,
"\\": 5.316,
"]": 5.718,
"^": 7.479,
"_": 7.293,
"`": 5.019,
"a": 8.906,
"b": 9.713,
"c": 9.054,
"d": 9.713,
"e": 9.244,
"f": 5.504,
"g": 9.727,
"h": 9.381,
"i": 3.810,
"j": 3.810,
"k": 8.707,
"l": 3.810,
"m": 13.949,
"n": 9.375,
"o": 9.503,
"p": 9.713,
"q": 9.713,
"r": 6.038,
"s": 8.350,
"t": 5.175,
"u": 9.381,
"v": 8.902,
"w": 12.975,
"x": 8.664,
"y": 8.902,
"z": 8.704,
"{": 6.746,
"|": 5.255,
"}": 6.746,
"~": 10.526,
"": 0
}
}
1 change: 1 addition & 0 deletions resources/js/components/ui/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export { default as InputGroup } from './Input/Group.vue';
export { default as InputGroupAppend } from './Input/GroupAppend.vue';
export { default as InputGroupPrepend } from './Input/GroupPrepend.vue';
export { default as Label } from './Label.vue';
export { default as MiddleEllipsis } from './MiddleEllipsis/MiddleEllipsis.vue';
export { default as Modal } from './Modal/Modal.vue';
export { default as ModalClose } from './Modal/Close.vue';
export { default as ModalTitle } from './Modal/Title.vue';
Expand Down
1 change: 1 addition & 0 deletions resources/js/tests/Package.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ it('exports ui', async () => {
'InputGroupAppend',
'InputGroupPrepend',
'Label',
'MiddleEllipsis',
'Modal',
'ModalClose',
'ModalTitle',
Expand Down
Loading