tag-dropdown on touch-device
authorPhiTux <redacted>
Thu, 2 Oct 2025 22:56:29 +0000 (00:56 +0200)
committerPhiTux <redacted>
Thu, 2 Oct 2025 22:56:29 +0000 (00:56 +0200)
frontend/src/lib/Sidenav.svelte
frontend/src/lib/TagModal.svelte
frontend/src/routes/(authed)/read/+page.svelte
frontend/src/routes/(authed)/write/+page.svelte

index f31c85f259655bd5674bf7e6ebe3c474634fbcfc..11e91f62b20a34cab1c8c0b6b83af6d98fdd9731 100644 (file)
                border-bottom-right-radius: 10px;
        }
 
+       @media (max-width: 1599px) {
+               .searchTagDropdown {
+                       left: 58px;
+               }
+       }
+
        :global(body[data-bs-theme='dark']) .searchTagDropdown {
                background-color: rgba(87, 87, 87, 0.5);
        }
index c93a82fca61bfde7c6c6138558382c5c6c2d1059..052da4198ddda4ee9e3f140193267fd80f2eb698 100644 (file)
                                                        type="text"
                                                        class="form-control mb-2"
                                                        placeholder={$t('modal.tag.name')}
+                                                       onkeydown={(e) => {
+                                                               if (e.key === 'Enter') {
+                                                                       createTag ? saveNewTag() : saveEditedTag();
+                                                               }
+                                                       }}
                                                />
                                        </div>
                                </div>
index f3c9779ac102c95ee179668c21f1983fc77b676c..049b7241a4748f64e2d6b5a27ea7f573b15bf1b7 100644 (file)
                        flex-direction: column !important;
                }
 
+               .files {
+                       margin-left: 0 !important;
+               }
+
                #scrollArea {
                        margin-top: 1rem !important;
                        margin-bottom: 1rem !important;
index 6458773b9e790f2aadc4dc0e0fa571d02df12d93..291c495ad3b57cc448d2ef633a4632c085c90209 100644 (file)
@@ -4,7 +4,6 @@
        import Sidenav from '$lib/Sidenav.svelte';
        import { selectedDate, cal, readingDate } from '$lib/calendarStore.js';
        import axios from 'axios';
-       import { goto } from '$app/navigation';
        import { mount, onMount } from 'svelte';
        import { searchString, searchResults } from '$lib/searchStore.js';
        import * as TinyMDE from 'tiny-markdown-editor';
@@ -26,7 +25,7 @@
        import { v4 as uuidv4 } from 'uuid';
        import { slide, fade } from 'svelte/transition';
        import { settings, autoLoadImagesThisDevice } from '$lib/settingsStore';
-       import { tags } from '$lib/tagStore';
+       import { tags, tagsLoaded } from '$lib/tagStore';
        import Tag from '$lib/Tag.svelte';
        import TagModal from '$lib/TagModal.svelte';
        import FileList from '$lib/FileList.svelte';
        let searchTab = $state('');
        let showTagDropdown = $state(false);
 
+       // General touch device detection (iPad & others) for simplified touch-friendly tag selection
+       let isTouchDevice = $state(false);
+       let showTouchTagPanel = $state(false);
+       onMount(() => {
+               try {
+                       const ua = navigator.userAgent || '';
+                       const platform = navigator.platform || '';
+                       const maxTP = navigator.maxTouchPoints || 0;
+                       const coarse = window.matchMedia ? window.matchMedia('(pointer: coarse)').matches : false;
+                       const iPadLike = /iPad/.test(ua) || (/Mac/.test(platform) && maxTP > 1);
+                       isTouchDevice = maxTP > 0 || coarse || iPadLike || 'ontouchstart' in window;
+               } catch (e) {
+                       isTouchDevice = false;
+               }
+       });
+
        let filteredTags = $state([]);
        let selectedTags = $state([]);
 
-       // Action: portal dropdown to <body> and position it under the input
+       // Action: portal dropdown to <body> and position it under the input (with iOS visualViewport handling)
        function portalDropdown(node, params) {
                let anchorEl;
+               let frameRequested = false;
+               let lastPlacement = 'bottom';
+               const GAP = 4;
 
                function getAnchor() {
                        if (params?.anchor) {
                        return document.getElementById('tag-input');
                }
 
-               function position() {
-                       if (!anchorEl) return;
+               function schedulePosition() {
+                       if (frameRequested) return;
+                       frameRequested = true;
+                       requestAnimationFrame(() => {
+                               frameRequested = false;
+                               _doPosition();
+                       });
+               }
+
+               function _doPosition() {
+                       if (!anchorEl || !node.isConnected) return;
                        const rect = anchorEl.getBoundingClientRect();
+                       const vv = window.visualViewport;
+                       // Use fixed positioning + visual viewport offsets
                        node.style.position = 'fixed';
-                       node.style.top = rect.bottom + 'px';
-                       node.style.left = rect.left + 'px';
-                       /* node.style.width = rect.width + 'px'; */
-                       // keep within viewport horizontally (basic guard)
-                       const maxLeft = Math.max(8, Math.min(rect.left, window.innerWidth - node.offsetWidth - 8));
-                       node.style.left = maxLeft + 'px';
+
+                       // Determine available space (visual viewport aware)
+                       const viewportHeight = vv ? vv.height : window.innerHeight;
+                       const viewportWidth = vv ? vv.width : window.innerWidth;
+                       const vOffsetTop = vv ? vv.offsetTop : 0; // when keyboard pushes visual viewport upward
+                       const vOffsetLeft = vv ? vv.offsetLeft : 0;
+                       // Safe-area bottom inset (iOS notch / home indicator) – cannot read env() directly here, fallback 0
+                       const safeBottomInset = 0;
+
+                       // First attempt: place below
+                       let top = rect.bottom + vOffsetTop + GAP;
+                       let left = rect.left + vOffsetLeft;
+
+                       // Temporarily set visibility hidden to measure height if not shown
+                       const prevVis = node.style.visibility;
+                       node.style.visibility = 'hidden';
+                       node.style.top = '0px';
+                       node.style.left = '0px';
+                       node.style.display = 'block';
+                       const menuH = node.offsetHeight || 180;
+                       const menuW = node.offsetWidth || 200;
+                       node.style.visibility = prevVis || '';
+
+                       const spaceBelow = viewportHeight + vOffsetTop - rect.bottom - GAP - safeBottomInset;
+                       const spaceAbove = rect.top - vOffsetTop - GAP;
+
+                       // Decide placement & dynamic max-height
+                       let desiredPlacement = 'bottom';
+                       // If below space is insufficient but above has more room, flip
+                       if (spaceBelow < Math.min(menuH, 220) && spaceAbove > spaceBelow) {
+                               desiredPlacement = 'top';
+                       }
+
+                       let available = desiredPlacement === 'bottom' ? spaceBelow : spaceAbove;
+                       // Cap maximal height to a reasonable viewport fraction
+                       const maxCap = Math.min(Math.max(available, 120), Math.floor(viewportHeight * 0.7));
+                       // Apply before final vertical positioning so scrollHeight reflects possible shrink
+                       node.style.maxHeight = maxCap + 'px';
+                       node.style.overflowY = 'auto';
+                       node.style.webkitOverflowScrolling = 'touch';
+
+                       if (desiredPlacement === 'top') {
+                               top = rect.top + vOffsetTop - GAP - maxCap;
+                               lastPlacement = 'top';
+                       } else {
+                               // keep below; if content smaller than available it will shrink naturally
+                               lastPlacement = 'bottom';
+                       }
+
+                       // Horizontal clamping
+                       if (left + menuW > viewportWidth + vOffsetLeft - 8) {
+                               left = viewportWidth + vOffsetLeft - menuW - 8;
+                       }
+                       left = Math.max(8 + vOffsetLeft, left);
+
+                       node.style.top = Math.round(top) + 'px';
+                       node.style.left = Math.round(left) + 'px';
+                       node.dataset.placement = lastPlacement;
                }
 
                function attach() {
-                       // move element into body so it's not clipped by ancestors and backdrop-filter works as expected
-                       document.body.appendChild(node);
-                       position();
+                       if (!node.isConnected) return;
+                       if (node.parentElement !== document.body) {
+                               document.body.appendChild(node);
+                       }
+                       // do an immediate position before RAF to avoid initial invisible state
+                       _doPosition();
+                       schedulePosition();
+                       // fallback in case rAF didn't fire yet or iOS delayed metrics
+                       setTimeout(() => {
+                               if (!node.dataset.placement) {
+                                       _doPosition();
+                               }
+                       }, 48);
                }
 
-               function onScroll() {
-                       position();
+               function onAny() {
+                       schedulePosition();
                }
-               function onResize() {
-                       position();
+               function onScrollCapture() {
+                       schedulePosition();
                }
 
                anchorEl = getAnchor();
                attach();
-               // use capture to react to scrolls on any ancestor
-               window.addEventListener('scroll', onScroll, true);
-               window.addEventListener('resize', onResize);
+
+               // Global listeners
+               window.addEventListener('scroll', onScrollCapture, true);
+               window.addEventListener('resize', onAny);
+               if (window.visualViewport) {
+                       window.visualViewport.addEventListener('resize', onAny);
+                       window.visualViewport.addEventListener('scroll', onAny);
+               }
 
                return {
                        update(newParams) {
                                params = newParams;
                                anchorEl = getAnchor();
-                               position();
+                               schedulePosition();
                        },
                        destroy() {
-                               window.removeEventListener('scroll', onScroll, true);
-                               window.removeEventListener('resize', onResize);
-                               // Do not manually remove node; Svelte will detach it.
+                               window.removeEventListener('scroll', onScrollCapture, true);
+                               window.removeEventListener('resize', onAny);
+                               if (window.visualViewport) {
+                                       window.visualViewport.removeEventListener('resize', onAny);
+                                       window.visualViewport.removeEventListener('scroll', onAny);
+                               }
                        }
                };
        }
                        });
        }
 
+       function loadTags() {
+               axios
+                       .get(API_URL + '/logs/getTags')
+                       .then((response) => {
+                               $tags = response.data;
+                               $tagsLoaded = true;
+                       })
+                       .catch((error) => {
+                               console.error(error);
+                               // toast
+                               const toast = new bootstrap.Toast(document.getElementById('toastErrorLoadingTags'));
+                               toast.show();
+                       });
+       }
+
        let history = $state([]);
        let historySelected = $state(0);
        function getHistory() {
        }
 </script>
 
+<!-- styles consolidated above -->
+
 <DatepickerLogic />
 <svelte:window
        onkeydown={on_key_down}
                                        >
                                </div>
                                <div class="tagRow d-flex flex-row">
-                                       <input
-                                               bind:value={searchTab}
-                                               onfocus={() => {
-                                                       showTagDropdown = true;
-                                                       selectedTagIndex = 0;
-                                               }}
-                                               onfocusout={() => {
-                                                       setTimeout(() => (showTagDropdown = false), 150);
-                                               }}
-                                               onkeydown={handleKeyDown}
-                                               type="text"
-                                               class="form-control"
-                                               id="tag-input"
-                                               placeholder={$t('tags.input')}
-                                       />
+                                       {#if isTouchDevice}
+                                               <button
+                                                       id="tag-input"
+                                                       type="button"
+                                                       class="btn btn-outline-secondary flex-grow-1 text-start"
+                                                       onclick={() => (showTouchTagPanel = !showTouchTagPanel)}
+                                               >
+                                                       {#if showTouchTagPanel}
+                                                               {$t('tags.hide_selector')}
+                                                       {:else}
+                                                               {$t('tags.input')}
+                                                       {/if}
+                                               </button>
+                                       {:else}
+                                               <input
+                                                       bind:value={searchTab}
+                                                       onfocus={() => {
+                                                               showTagDropdown = true;
+                                                               selectedTagIndex = 0;
+                                                       }}
+                                                       onfocusout={() => {
+                                                               setTimeout(() => (showTagDropdown = false), 150);
+                                                       }}
+                                                       onkeydown={handleKeyDown}
+                                                       type="text"
+                                                       class="form-control"
+                                                       id="tag-input"
+                                                       placeholder={$t('tags.input')}
+                                               />
+                                       {/if}
                                        <button class="newTagBtn btn btn-outline-secondary ms-2" onclick={openTagModal}>
                                                <Fa icon={faSquarePlus} fw />
                                                {$t('tags.new_tag')}
                                        </button>
                                </div>
-                               {#if showTagDropdown}
+                               {#if !isTouchDevice && showTagDropdown}
                                        <div id="tagDropdown" use:portalDropdown>
                                                {#if filteredTags.length === 0}
                                                        <em style="padding: 0.2rem;">{$t('tags.no_tags_found')}</em>
                                                {/if}
                                        </div>
                                {/if}
+                               {#if isTouchDevice && showTouchTagPanel}
+                                       <div transition:slide>
+                                               <div class="touch-tag-panel mt-2">
+                                                       {#if $tags.length === 0}
+                                                               <em style="padding:0.2rem;">{$t('tags.no_tags_found')}</em>
+                                                       {:else}
+                                                               <div class="d-flex flex-row flex-wrap gap-1 selectTagTouchDevice">
+                                                                       {#each $tags.filter((t) => !selectedTags.includes(t.id)) as tag (tag.id)}
+                                                                               <button
+                                                                                       type="button"
+                                                                                       class="touch-tag-item btn btn-sm btn-outline-none"
+                                                                                       onclick={() => selectTag(tag.id)}
+                                                                               >
+                                                                                       <Tag {tag} />
+                                                                               </button>
+                                                                       {/each}
+                                                               </div>
+                                                       {/if}
+                                               </div>
+                                       </div>
+                               {/if}
                                <div class="selectedTags d-flex flex-row flex-wrap">
                                        {#if $tags.length !== 0}
                                                {#each selectedTags as tag_id (tag_id)}
 </div>
 
 <style>
+       .selectTagTouchDevice {
+               background-color: #adadad65;
+               padding: 0.5rem;
+               border-radius: 10px;
+       }
+
        .a-look-back {
                height: 110px;
                min-height: 110px;
git clone https://git.99rst.org/PROJECT