Add Timeline component (#2535)

Signed-off-by: Alexander Platov <sas_lord@mail.ru>
This commit is contained in:
Alexander Platov 2023-01-24 16:43:06 +03:00 committed by GitHub
parent ba8ab45dc2
commit 8a5b84802c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1469 additions and 126 deletions

View File

@ -87,8 +87,7 @@ export default mergeIds(viewId, view, {
General: '' as IntlString, General: '' as IntlString,
Navigation: '' as IntlString, Navigation: '' as IntlString,
Editor: '' as IntlString, Editor: '' as IntlString,
MarkdownFormatting: '' as IntlString, MarkdownFormatting: '' as IntlString
List: '' as IntlString
}, },
function: { function: {
FilterObjectInResult: '' as Resource<(filter: Filter, onUpdate: () => void) => Promise<ObjQueryType<any>>>, FilterObjectInResult: '' as Resource<(filter: Filter, onUpdate: () => void) => Promise<ObjQueryType<any>>>,

View File

@ -122,6 +122,9 @@
--incoming-msg: rgba(67, 67, 72, .3); --incoming-msg: rgba(67, 67, 72, .3);
--outcoming-msg: rgba(67, 67, 72, .6); --outcoming-msg: rgba(67, 67, 72, .6);
--trans-content-05: rgba(138, 143, 152, .05);
--trans-content-10: rgba(138, 143, 152, .1);
--theme-bg-color: #18181e; --theme-bg-color: #18181e;
--theme-bg-selection: #282830; --theme-bg-selection: #282830;
--theme-bg-checked: #262b39; --theme-bg-checked: #262b39;
@ -226,7 +229,7 @@
--dark-color: #90959d; --dark-color: #90959d;
--content-color: #3c4149; --content-color: #3c4149;
--accent-color: #282a30; --accent-color: #282a30;
--caption-color: #282a30; --caption-color: #131416;
--white-color: #fff; --white-color: #fff;
--caret-color: #6e5ed2; --caret-color: #6e5ed2;
--warning-color: #f2994a; // Dark --warning-color: #f2994a; // Dark
@ -275,6 +278,9 @@
--incoming-msg: rgba(67, 67, 72, .3); --incoming-msg: rgba(67, 67, 72, .3);
--outcoming-msg: rgba(67, 67, 72, .6); --outcoming-msg: rgba(67, 67, 72, .6);
--trans-content-05: rgba(60, 65, 73, .05);
--trans-content-10: rgba(60, 65, 73, .1);
--theme-bg-color: #FFFFFF; --theme-bg-color: #FFFFFF;
--theme-bg-selection: #F1F1F4; --theme-bg-selection: #F1F1F4;
--theme-menu-color: #E7E7E7; --theme-menu-color: #E7E7E7;

View File

@ -124,7 +124,10 @@ p:last-child { margin-block-end: 0; }
.text-center { text-align: center; } .text-center { text-align: center; }
.firstLetter span::first-letter { text-transform: uppercase; } .firstLetter span{
display: inline-block;
&::first-letter { text-transform: uppercase; }
}
.inline-height2 { .inline-height2 {
line-height: 200%; line-height: 200%;

View File

@ -30,6 +30,19 @@
export let noStretch: boolean = false export let noStretch: boolean = false
export let divScroll: HTMLElement | undefined = undefined export let divScroll: HTMLElement | undefined = undefined
export function scroll (top: number, left?: number, behavior: 'auto' | 'smooth' = 'auto') {
if (divScroll && divHScroll) {
if (top !== 0) divScroll.scroll({ top, left: 0, behavior })
if (left !== 0 || left !== undefined) divHScroll.scroll({ top: 0, left, behavior })
}
}
export function scrollBy (top: number, left?: number, behavior: 'auto' | 'smooth' = 'auto') {
if (divScroll && divHScroll) {
if (top !== 0) divScroll.scrollBy({ top, left: 0, behavior })
if (left !== 0 || left !== undefined) divHScroll.scrollBy({ top: 0, left, behavior })
}
}
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let mask: 'top' | 'bottom' | 'both' | 'none' = 'none' let mask: 'top' | 'bottom' | 'both' | 'none' = 'none'

View File

@ -43,6 +43,7 @@
{#if items.length > 0} {#if items.length > 0}
<div class="tablist-container {kind} {size}"> <div class="tablist-container {kind} {size}">
{#each items as item, i} {#each items as item, i}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
bind:this={tabs[i]} bind:this={tabs[i]}
class="button" class="button"

View File

@ -0,0 +1,776 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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.
-->
<script lang="ts">
import { fly } from 'svelte/transition'
import { Timestamp } from '@hcengineering/core'
import { TimelinePoint, TimelineRow, TimelineState } from '../types'
import ui, {
CheckBox,
Icon,
Scroller,
Button,
resizeObserver,
MILLISECONDS_IN_DAY,
MILLISECONDS_IN_WEEK,
IconArrowLeft,
IconArrowRight,
IconAdd
} from '..'
import { createEventDispatcher, onMount } from 'svelte'
export let selectedRows: number[] = []
export let selectedRow: number | undefined = undefined
export let lines: TimelineRow[] | undefined = undefined
export let currentTime: Timestamp = new Date().setHours(0, 0, 0, 0)
const dispatch = createEventDispatcher()
const NOT_ENDED = MILLISECONDS_IN_WEEK * 4
let currentDate: Date = new Date(currentTime)
$: currentDate = new Date(currentTime)
export const onObjectChecked = (row: number, value: boolean) => {
dispatch('check', { row, value })
}
export const selectRow = (row: number) => {
selectedRow = row
}
const handleRowFocused = (row: number) => {
dispatch('row-focus', row)
}
let panelWidth: number = 320
const dayWidth: number = 5
let container: HTMLElement
let viewbox: HTMLElement
let scroller: Scroller
let scrollDir: 'horizontal' | 'vertical' | 'none' = 'none'
const locale = new Intl.NumberFormat().resolvedOptions().locale
const nillPoint: TimelinePoint = { label: '', date: currentDate, x: 0 }
const nillRect: DOMRect = { x: 0, y: 0, width: 0, height: 0, left: 0, right: 0, top: 0, bottom: 0, toJSON: () => {} }
const time: TimelineState = {
todayMarker: nillPoint,
offsetView: 0,
renderedRange: { left: nillPoint, right: nillPoint, firstDays: [] },
rows: undefined,
months: [],
days: [],
timelineBox: nillRect,
viewBox: nillRect
}
const checkRange = (reverse: boolean) => {
if (reverse) {
if (time.offsetView * -1 - time.viewBox.width <= time.renderedRange.left.x) renderPrevMonth()
} else {
if (time.offsetView * -1 + time.viewBox.width * 2 >= time.renderedRange.right.x) renderNextMonth()
}
}
const getDateByOffset = (x: number): { date: Date; delta: number } => {
const deltaDays = Math.floor(x / dayWidth)
const calcDay = new Date(currentTime + deltaDays * MILLISECONDS_IN_DAY)
return { date: calcDay, delta: deltaDays }
}
const getOffsetByDate = (date: Timestamp | Date): number => {
const tempDay = new Date(date).setHours(0, 0, 0, 0)
const deltaDays = Math.floor((tempDay - currentTime) / MILLISECONDS_IN_DAY)
return deltaDays * dayWidth
}
const getNextMonth = (date: Date): TimelinePoint => {
const fDate = new Date(date.getFullYear(), date.getMonth() + 1, 1, 0, 0)
const offDate = getOffsetByDate(fDate)
const lDate = Intl.DateTimeFormat(locale, { month: 'long' }).format(fDate)
return { date: fDate, x: offDate, label: lDate }
}
const getNextWeek = (date: Date, reverse?: boolean): TimelinePoint => {
const fDate = new Date(date.getTime() + MILLISECONDS_IN_WEEK * (reverse ? -1 : 1))
const offDate = getOffsetByDate(fDate)
const lDate = fDate.getDate().toString()
return { date: fDate, x: offDate, label: lDate }
}
const renderPrevMonth = () => {
const oldRange: TimelinePoint = time.renderedRange.left
const newDate: Date = new Date(oldRange.date.getFullYear(), oldRange.date.getMonth() - 1, 1, 0, 0)
const newRange: number = getOffsetByDate(newDate)
const newLabel: string = Intl.DateTimeFormat(locale, { month: 'long' }).format(newDate)
const newPoint: TimelinePoint = {
x: newRange,
date: newDate,
label: newLabel
}
time.renderedRange.left = newPoint
time.months = [newPoint, ...time.months]
while (getNextWeek(time.days[0].date, true).x > newPoint.x) {
const prevDay: TimelinePoint = getNextWeek(time.days[0].date, true)
time.days = [prevDay, ...time.days]
}
}
const renderNextMonth = () => {
const oldRange: TimelinePoint = time.renderedRange.right
const newDate: Date = new Date(oldRange.date.getFullYear(), oldRange.date.getMonth() + 1, 1, 0, 0)
const newRange: number = getOffsetByDate(newDate)
const newLabel: string = Intl.DateTimeFormat(locale, { month: 'long' }).format(newDate)
const newPoint: TimelinePoint = {
x: newRange,
date: newDate,
label: newLabel
}
time.renderedRange.right = newPoint
time.months = [...time.months, newPoint]
while (getNextWeek(time.days[time.days.length - 1].date).x < newPoint.x) {
const nextDay: TimelinePoint = getNextWeek(time.days[time.days.length - 1].date)
time.days = [...time.days, nextDay]
}
}
const wheelEvent = (e: WheelEvent) => {
e = e || window.event
const deltaX = -e.deltaX
const deltaY = e.deltaY
if (scrollDir === 'none' && (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2)) {
if (Math.abs(deltaX) > Math.abs(deltaY)) scrollDir = 'horizontal'
else scrollDir = 'vertical'
} else if (Math.abs(deltaX) <= 4 && Math.abs(deltaY) <= 4) scrollDir = 'none'
time.offsetView += deltaX
if (scrollDir === 'horizontal') {
mouseMoveEvent(e)
checkRange(deltaX > 0)
}
if (scrollDir === 'vertical') scroller.scrollBy(deltaY)
e.preventDefault ? e.preventDefault() : (e.returnValue = false)
}
const mouseMoveEvent = (e: MouseEvent) => {
const cur = e.x - time.viewBox.left
if (cur >= 0 && cur <= time.viewBox.width) {
const offset = cur - time.offsetView
const t = getDateByOffset(offset)
time.cursorMarker = {
label: t.date.getDate().toString(),
x: offset,
date: t.date
}
}
}
const mouseOutEvent = (e: MouseEvent) => {
time.cursorMarker = undefined
}
const clickEvent = (e: MouseEvent) => {
console.log('[Timeline] Cursor: ', time.cursorMarker)
}
onMount(() => {
container.addEventListener('wheel', wheelEvent)
container.addEventListener('mousemove', mouseMoveEvent)
container.addEventListener('mouseout', mouseOutEvent)
container.addEventListener('click', clickEvent)
time.timelineBox = container.getBoundingClientRect()
time.viewBox = viewbox.getBoundingClientRect()
time.offsetView = Math.floor(time.viewBox.width / 2)
time.todayMarker.x = 0
time.todayMarker.date = currentDate
time.todayMarker.label = Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short' }).format(currentDate)
let mass: number[] = [currentTime]
lines?.forEach((line) => {
if (line.items !== undefined) {
let tr: number[] = []
line.items.forEach((it) => {
if (it.startDate) tr = [...tr, it.startDate]
if (it.targetDate) tr = [...tr, it.targetDate]
else if (it.startDate) tr = [...tr, it.startDate + NOT_ENDED]
})
if (tr.length > 0) {
mass = [...mass, ...tr]
tr.sort()
const minD: Date = new Date(tr[0])
const maxD: Date = new Date(tr[tr.length - 1])
const r = {
min: {
date: minD,
x: getOffsetByDate(minD)
},
max: {
date: maxD,
x: getOffsetByDate(maxD)
}
}
time.rows ? time.rows.push(r) : (time.rows = [r])
} else time.rows ? time.rows.push(null) : (time.rows = [null])
} else time.rows ? time.rows.push(null) : (time.rows = [null])
})
mass.sort()
let leftRange: number = getOffsetByDate(mass[0]) - time.viewBox.width * 1.5
const leftDate: Date = new Date(getDateByOffset(leftRange).date.setDate(1))
leftRange = getOffsetByDate(leftDate)
time.renderedRange.left = {
x: leftRange,
date: leftDate,
label: Intl.DateTimeFormat(locale, { month: 'long' }).format(leftDate)
}
let rightRange: number = getOffsetByDate(mass[mass.length - 1]) + time.viewBox.width * 1.5
const tr: Date = new Date(getDateByOffset(rightRange).date)
const rightDate: Date = new Date(new Date(tr.getFullYear(), tr.getMonth() + 1, 1, 0, 0).getTime() - 1)
rightRange = getOffsetByDate(rightDate)
time.renderedRange.right = {
x: rightRange,
date: rightDate,
label: Intl.DateTimeFormat(locale, { month: 'long' }).format(rightDate)
}
time.months = [time.renderedRange.left]
let i = 0
do {
const nextMonth: TimelinePoint = getNextMonth(time.months[i].date)
time.months = [...time.months, nextMonth]
i++
} while (getNextMonth(time.months[i].date).x <= time.renderedRange.right.x)
time.days = [
{
x: time.renderedRange.left.x,
date: time.renderedRange.left.date,
label: '1'
}
]
i = 0
do {
const nextWeek: TimelinePoint = getNextWeek(time.days[i].date)
time.days = [...time.days, nextWeek]
i++
} while (getNextWeek(time.days[i].date).x <= time.renderedRange.right.x)
})
let moving: boolean = false
let sX: number
const splitterStart = (e: MouseEvent) => {
if (time.timelineBox.width <= 450) return
sX = (e.x - time.viewBox.left) * -1
document.addEventListener('mouseup', splitterEnd)
document.addEventListener('mousemove', splitterMove)
moving = true
}
const splitterMove = (e: MouseEvent) => {
if (e.x - time.timelineBox.left + sX < 300) panelWidth = 300
else if (time.timelineBox.right - e.x + sX < 150) panelWidth = time.timelineBox.width - 150
else panelWidth = e.x - time.timelineBox.left + sX
}
const splitterEnd = (e: MouseEvent) => {
document.removeEventListener('mousemove', splitterMove)
document.removeEventListener('mouseup', splitterEnd)
time.viewBox = viewbox.getBoundingClientRect()
moving = false
}
</script>
<div
class="timeline-container"
bind:this={container}
use:resizeObserver={() => {
time.timelineBox = container.getBoundingClientRect()
time.viewBox = viewbox.getBoundingClientRect()
}}
>
<div class="timeline-header">
<div class="timeline-header__title" style:width={`${panelWidth}px`}>
<Button
label={ui.string.Today}
on:click={() => {
time.offsetView = Math.floor(time.viewBox.width / 2)
}}
/>
</div>
<div class="timeline-header__time" bind:this={viewbox}>
<div class="timeline-header__time-content" style:transform={`translateX(${time.offsetView}px)`}>
{#if time.months}
{#each time.months as month}
<div class="month firstLetter" style:left={`${month.x}px`}>
{#if month.date.getMonth() === 0}
<b class="caption-color">{month.date.getFullYear()}</b>
{/if}
<span style="firstLetter">{month.label}</span>
</div>
{/each}
{/if}
{#if time.days}
{#each time.days as day}
<div class="day" style:left={`${day.x}px`}>{day.label}</div>
{/each}
{/if}
<div class="cursor" style:left={`${time.todayMarker.x}px`}>{time.todayMarker.date.getDate()}</div>
<!-- {#if time.cursorMarker}
<div class="cursor" style:left={`${time.cursorMarker.x}px`}>{time.cursorMarker.label}</div>
{/if} -->
</div>
</div>
</div>
<div class="timeline-background__headers" style:width={`${panelWidth}px`} />
<div class="timeline-background__viewbox" style:left={`${panelWidth}px`}>
<div class="timeline-wrapped_content" style:transform={`translateX(${time.offsetView}px)`}>
{#if time.months}
{#each time.months as month}
<div class="monthMarker" style:left={`${month.x}px`} />
{/each}
{/if}
</div>
</div>
{#if lines}
<Scroller bind:this={scroller}>
{#each lines as line, row}
{@const rangeRow = time.rows ? time.rows[row] : null}
<div
class="listGrid"
class:mListGridChecked={selectedRows.find((x) => x === row) !== undefined}
class:mListGridSelected={selectedRow === row}
on:focus={() => {}}
on:mousemove={(ev) => {
if (row !== selectedRow) {
handleRowFocused(row)
}
ev.preventDefault()
}}
>
<div class="headerWrapper" style:width={`${panelWidth}px`}>
<div class="gridElement">
<div class="eListGridCheckBox">
<CheckBox
checked={selectedRows.filter((i) => i === row).length > 0}
on:value={(event) => onObjectChecked(row, event.detail)}
/>
</div>
</div>
<slot {row} />
</div>
<div class="contentWrapper" class:nullRow={rangeRow === null && !moving}>
<div class="timeline-wrapped_content" style:transform={`translateX(${time.offsetView}px)`}>
{#if line.items}
{#each line.items as item}
{#if item.startDate}
{@const target = item.targetDate ?? item.startDate + NOT_ENDED}
<div
class="project-item"
class:noTarget={item.targetDate === null}
style:left={`${getOffsetByDate(item.startDate)}px`}
style:right={`${getOffsetByDate(target) + dayWidth - 1}px`}
style:width={`${getOffsetByDate(target) - getOffsetByDate(item.startDate) + dayWidth - 1}px`}
>
<div class="project-presenter gap-2">
{#if item.icon}<Icon
icon={item.icon}
size={item.iconSize ?? 'small'}
iconProps={item.iconProps}
/>{/if}
{#if item.presenter}<svelte:component this={item.presenter} {...item.props} />{/if}
{#if item.label}<span>{item.label}</span>{/if}
</div>
</div>
{/if}
{/each}
{/if}
</div>
{#if line.items}
{#if rangeRow !== null && -time.offsetView + time.viewBox.width < rangeRow.min.x}
<button
transition:fly={{ duration: 150, x: 50, opacity: 0 }}
class="timeline-action__button right"
on:click={() => {
if (rangeRow !== null) time.offsetView = -getOffsetByDate(rangeRow.min.date) + dayWidth * 5
}}
>
<IconArrowRight size={'small'} />
</button>
{/if}
{#if rangeRow !== null && -time.offsetView > rangeRow.max.x}
<button
transition:fly={{ duration: 150, x: -50, opacity: 0 }}
class="timeline-action__button left"
on:click={() => {
if (rangeRow !== null) time.offsetView = -getOffsetByDate(rangeRow.min.date) + dayWidth * 5
}}
>
<IconArrowLeft size={'small'} />
</button>
{/if}
{/if}
{#if rangeRow === null && selectedRow === row && time.cursorMarker && !moving}
<button class="timeline-action__button add" style:left={`${time.offsetView + time.cursorMarker.x}px`}>
<IconAdd size={'small'} />
</button>
{/if}
</div>
</div>
{/each}
</Scroller>
<div class="timeline-foreground__viewbox" style:left={`${panelWidth}px`}>
<div class="timeline-wrapped_content" style:transform={`translateX(${time.offsetView}px)`}>
<div class="todayMarker" style:left={`${time.todayMarker.x}px`} />
</div>
</div>
{/if}
<div class="timeline-splitter" class:moving style:left={`${panelWidth}px`} on:mousedown={splitterStart} />
</div>
<style lang="scss">
.timeline-container {
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
& > * {
overscroll-behavior-x: contain;
}
}
.timeline-header {
display: flex;
align-items: center;
min-height: 4rem;
border-bottom: 1px solid var(--divider-color);
}
.timeline-header__title {
display: flex;
align-items: center;
flex-shrink: 0;
padding: 0 2.25rem;
height: 100%;
background-color: var(--body-accent);
box-shadow: var(--accent-shadow);
// z-index: 2;
}
.timeline-header__time {
// overflow: hidden;
position: relative;
flex-grow: 1;
height: 100%;
background-color: var(--body-color);
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 1) 2rem,
rgba(0, 0, 0, 1) calc(100% - 2rem),
rgba(0, 0, 0, 0) 100%
);
&-content {
width: 100%;
height: 100%;
will-change: transform;
.day,
.month {
position: absolute;
pointer-events: none;
}
.month {
width: max-content;
top: 0.25rem;
font-size: 1rem;
color: var(--accent-color);
&:first-letter {
text-transform: uppercase;
}
}
.day {
bottom: 0.5rem;
font-size: 1rem;
color: var(--content-color);
transform: translateX(-50%);
}
.cursor {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
padding-bottom: 1px;
width: 1.75rem;
height: 1.75rem;
bottom: 0.375rem;
font-size: 1rem;
font-weight: 600;
color: #fff;
background-color: var(--primary-bg-color);
border-radius: 50%;
transform: translateX(-50%);
pointer-events: none;
}
}
}
.todayMarker,
.monthMarker {
position: absolute;
top: 0;
bottom: 0;
width: 0;
height: 100%;
pointer-events: none;
}
.monthMarker {
border-left: 1px dashed var(--highlight-select);
}
.todayMarker {
border-left: 1px solid var(--primary-bg-color);
}
.timeline-background__headers,
.timeline-background__viewbox,
.timeline-foreground__viewbox {
overflow: hidden;
position: absolute;
top: 4rem;
bottom: 0;
height: 100%;
z-index: -1;
}
.timeline-background__headers {
left: 0;
background-color: var(--body-accent);
}
.timeline-background__viewbox,
.timeline-foreground__viewbox {
right: 0;
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 1) 2rem,
rgba(0, 0, 0, 1) calc(100% - 2rem),
rgba(0, 0, 0, 0) 100%
);
}
.timeline-foreground__viewbox {
z-index: 1;
pointer-events: none;
}
.timeline-splitter,
.timeline-splitter::before {
position: absolute;
top: 0;
bottom: 0;
height: 100%;
transform: translateX(-50%);
}
.timeline-splitter {
width: 1px;
background-color: var(--divider-color);
cursor: col-resize;
z-index: 3;
transition-property: width, background-color;
transition-timing-function: var(--timing-main);
transition-duration: 0.1s;
transition-delay: 0s;
&:hover {
width: 3px;
background-color: var(--button-border-hover);
transition-duration: 0.15s;
transition-delay: 0.3s;
}
&::before {
content: '';
width: 10px;
left: 50%;
}
&.moving {
width: 2px;
background-color: var(--primary-edit-border-color);
transition-duration: 0.1s;
transition-delay: 0s;
}
}
.headerWrapper {
display: flex;
align-items: center;
height: 100%;
min-width: 0;
padding-left: 0.75rem;
padding-right: 1.15rem;
// border-bottom: 1px solid var(--accent-bg-color);
}
.contentWrapper {
overflow: hidden;
position: relative;
display: flex;
align-items: center;
flex-grow: 1;
height: 100%;
min-width: 0;
min-height: 0;
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 1) 2rem,
rgba(0, 0, 0, 1) calc(100% - 2rem),
rgba(0, 0, 0, 0) 100%
);
&.nullRow {
cursor: pointer;
}
}
.timeline-wrapped_content {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
will-change: transform;
}
.timeline-action__button,
.project-item {
position: absolute;
display: flex;
align-items: center;
padding: 0.5rem;
box-shadow: var(--button-shadow);
}
.project-item {
top: 0.25rem;
bottom: 0.25rem;
background-color: var(--button-bg-color);
border: 1px solid var(--button-border-color);
border-radius: 0.75rem;
&:hover {
background-color: var(--button-bg-hover);
border-color: var(--button-border-hover);
}
&.noTarget {
mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 1) 2rem);
border-right-color: transparent;
}
.project-presenter {
display: flex;
align-items: center;
.space {
flex-shrink: 0;
width: 0.25rem;
min-width: 0.25rem;
max-width: 0.25rem;
}
}
}
.timeline-action__button {
top: 0.625rem;
bottom: 0.625rem;
width: 2rem;
color: var(--content-color);
background-color: var(--button-bg-color);
border: 1px solid var(--button-border-color);
border-radius: 0.5rem;
// color: var(--caption-color);
// font-size: 0.65rem;
// font-weight: 600;
&:hover {
color: var(--accent-color);
background-color: var(--button-bg-hover);
border-color: var(--button-border-hover);
}
&.left {
left: 1rem;
}
&.right {
right: 1rem;
}
&.add {
transform: translateX(-50%);
pointer-events: none;
}
}
.listGrid {
display: flex;
justify-content: stretch;
align-items: center;
flex-shrink: 0;
width: 100%;
height: 3.25rem;
min-height: 0;
color: var(--caption-color);
z-index: 2;
&.mListGridChecked {
.headerWrapper {
background-color: var(--highlight-select);
}
.contentWrapper {
background-color: var(--trans-content-05);
}
.eListGridCheckBox {
opacity: 1;
}
}
&.mListGridSelected {
.headerWrapper {
background-color: var(--highlight-select-hover);
}
.contentWrapper {
background-color: var(--trans-content-10);
}
}
.eListGridCheckBox {
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
&:hover .eListGridCheckBox {
opacity: 1;
}
}
.filler {
display: flex;
flex-grow: 1;
}
.gridElement {
display: flex;
align-items: center;
justify-content: flex-start;
margin-left: 0.5rem;
&:first-child {
margin-left: 0;
}
}
.iconPresenter {
padding-left: 0.45rem;
}
.projectPresenter {
display: flex;
align-items: center;
flex-shrink: 0;
width: 5.5rem;
margin-left: 0.5rem;
}
</style>

View File

@ -100,6 +100,7 @@ export { default as Scroller } from './components/Scroller.svelte'
export { default as ScrollerBar } from './components/ScrollerBar.svelte' export { default as ScrollerBar } from './components/ScrollerBar.svelte'
export { default as TabList } from './components/TabList.svelte' export { default as TabList } from './components/TabList.svelte'
export { default as Chevron } from './components/Chevron.svelte' export { default as Chevron } from './components/Chevron.svelte'
export { default as Timeline } from './components/Timeline.svelte'
export { default as IconAdd } from './components/icons/Add.svelte' export { default as IconAdd } from './components/icons/Add.svelte'
export { default as IconBack } from './components/icons/Back.svelte' export { default as IconBack } from './components/icons/Back.svelte'

View File

@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
// //
import type { Asset, IntlString } from '@hcengineering/platform' import type { Asset, IntlString } from '@hcengineering/platform'
import { Timestamp } from '@hcengineering/core'
import { /* Metadata, Plugin, plugin, */ Resource /*, Service */ } from '@hcengineering/platform' import { /* Metadata, Plugin, plugin, */ Resource /*, Service */ } from '@hcengineering/platform'
import { /* getContext, */ SvelteComponent } from 'svelte' import { /* getContext, */ SvelteComponent } from 'svelte'
@ -102,6 +103,7 @@ export interface TabItem {
icon?: Asset | AnySvelteComponent icon?: Asset | AnySvelteComponent
color?: string color?: string
tooltip?: IntlString tooltip?: IntlString
action?: () => void
} }
export type ButtonKind = export type ButtonKind =
@ -238,3 +240,43 @@ export interface DeviceOptions {
twoRows: boolean twoRows: boolean
theme?: any theme?: any
} }
export interface TimelineItem {
icon?: Asset | AnySvelteComponent
iconSize?: IconSize
iconProps?: Record<string, any>
presenter?: AnySvelteComponent
props?: Record<string, any>
label?: string
startDate: Timestamp
targetDate: Timestamp | undefined
}
export interface TimelineRow {
items: TimelineItem[] | undefined
}
export interface TimelinePoint {
date: Date
x: number
label?: string
}
export interface TimelineMinMax {
min: TimelinePoint
max: TimelinePoint
}
export type TTimelineRow = TimelineMinMax | null
export interface TimelineState {
todayMarker: TimelinePoint
cursorMarker?: TimelinePoint
offsetView: number
renderedRange: {
left: TimelinePoint
right: TimelinePoint
firstDays: number[]
}
rows: TTimelineRow[] | undefined
months: TimelinePoint[]
days: TimelinePoint[]
timelineBox: DOMRect
viewBox: DOMRect
}

View File

@ -70,7 +70,9 @@ loadMetadata(tracker.icon, {
CopyBranch: `${icons}#copyBranch`, CopyBranch: `${icons}#copyBranch`,
Duplicate: `${icons}#duplicate`, Duplicate: `${icons}#duplicate`,
TimeReport: `${icons}#timeReport`, TimeReport: `${icons}#timeReport`,
Estimation: `${icons}#timeReport` Estimation: `${icons}#timeReport`,
Timeline: `${icons}#timeline`
}) })
addStringsLoader(trackerId, async (lang: string) => await import(`../lang/${lang}.json`)) addStringsLoader(trackerId, async (lang: string) => await import(`../lang/${lang}.json`))

View File

@ -18,8 +18,10 @@
import { IntlString } from '@hcengineering/platform' import { IntlString } from '@hcengineering/platform'
import { createQuery } from '@hcengineering/presentation' import { createQuery } from '@hcengineering/presentation'
import { Project } from '@hcengineering/tracker' import { Project } from '@hcengineering/tracker'
import { Button, IconAdd, Label, showPopup } from '@hcengineering/ui' import { Button, IconAdd, Label, showPopup, TabList } from '@hcengineering/ui'
import type { TabItem } from '@hcengineering/ui'
import tracker from '../../plugin' import tracker from '../../plugin'
import view from '@hcengineering/view'
import { getIncludedProjectStatuses, projectsTitleMap, ProjectsViewMode } from '../../utils' import { getIncludedProjectStatuses, projectsTitleMap, ProjectsViewMode } from '../../utils'
import NewProject from './NewProject.svelte' import NewProject from './NewProject.svelte'
import ProjectsListBrowser from './ProjectsListBrowser.svelte' import ProjectsListBrowser from './ProjectsListBrowser.svelte'
@ -28,6 +30,7 @@
export let query: DocumentQuery<Project> = {} export let query: DocumentQuery<Project> = {}
export let search: string = '' export let search: string = ''
export let mode: ProjectsViewMode = 'all' export let mode: ProjectsViewMode = 'all'
export let viewMode: 'list' | 'timeline' = 'list'
const ENTRIES_LIMIT = 200 const ENTRIES_LIMIT = 200
const resultProjectsQuery = createQuery() const resultProjectsQuery = createQuery()
@ -72,6 +75,17 @@
mode = newMode mode = newMode
} }
const modeList: TabItem[] = [
{ id: 'all', labelIntl: tracker.string.AllProjects, action: () => handleViewModeChanged('all') },
{ id: 'backlog', labelIntl: tracker.string.BacklogProjects, action: () => handleViewModeChanged('backlog') },
{ id: 'active', labelIntl: tracker.string.ActiveProjects, action: () => handleViewModeChanged('active') },
{ id: 'closed', labelIntl: tracker.string.ClosedProjects, action: () => handleViewModeChanged('closed') }
]
const viewList: TabItem[] = [
{ id: 'list', icon: view.icon.List, tooltip: view.string.List },
{ id: 'timeline', icon: view.icon.Timeline, tooltip: view.string.Timeline }
]
</script> </script>
<div class="fs-title flex-between header"> <div class="fs-title flex-between header">
@ -84,45 +98,15 @@
<Button size="small" icon={IconAdd} label={tracker.string.Project} kind={'primary'} on:click={showCreateDialog} /> <Button size="small" icon={IconAdd} label={tracker.string.Project} kind={'primary'} on:click={showCreateDialog} />
</div> </div>
<div class="itemsContainer"> <div class="itemsContainer">
<div class="flex-center"> <div class="flex-row-center">
<div class="flex-center"> <TabList
<div class="buttonWrapper"> items={modeList}
<Button selected={mode}
size="small" kind={'normal'}
shape="rectangle-right" on:select={(result) => {
selected={mode === 'all'} if (result.detail !== undefined && result.detail.action) result.detail.action()
label={tracker.string.AllProjects} }}
on:click={() => handleViewModeChanged('all')} />
/>
</div>
<div class="buttonWrapper">
<Button
size="small"
shape="rectangle"
selected={mode === 'backlog'}
label={tracker.string.BacklogProjects}
on:click={() => handleViewModeChanged('backlog')}
/>
</div>
<div class="buttonWrapper">
<Button
size="small"
shape="rectangle"
selected={mode === 'active'}
label={tracker.string.ActiveProjects}
on:click={() => handleViewModeChanged('active')}
/>
</div>
<div class="buttonWrapper">
<Button
size="small"
shape="rectangle-left"
selected={mode === 'closed'}
label={tracker.string.ClosedProjects}
on:click={() => handleViewModeChanged('closed')}
/>
</div>
</div>
<!-- <div class="ml-3 filterButton"> <!-- <div class="ml-3 filterButton">
<Button <Button
size="small" size="small"
@ -134,19 +118,15 @@
/> />
</div> --> </div> -->
</div> </div>
<!-- <div class="flex-center"> <TabList
<div class="flex-center"> items={viewList}
<div class="buttonWrapper"> selected={viewMode}
<Button selected size="small" shape="rectangle-right" icon={tracker.icon.ProjectsList} /> kind={'secondary'}
</div> size={'small'}
<div class="buttonWrapper"> on:select={(result) => {
<Button size="small" shape="rectangle-left" icon={tracker.icon.ProjectsTimeline} /> if (result.detail !== undefined && result.detail.id !== viewMode) viewMode = result.detail.id
</div> }}
</div> />
<div class="ml-3">
<Button size="small" icon={IconOptions} />
</div>
</div> -->
</div> </div>
<ProjectsListBrowser <ProjectsListBrowser
_class={tracker.class.Project} _class={tracker.class.Project}
@ -163,6 +143,7 @@
{ key: '', presenter: tracker.component.ProjectStatusPresenter } { key: '', presenter: tracker.component.ProjectStatusPresenter }
]} ]}
projects={resultProjects} projects={resultProjects}
{viewMode}
/> />
<style lang="scss"> <style lang="scss">
@ -182,16 +163,10 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.65rem 1.35rem 0.65rem 2.25rem; padding: 0.65rem 0.75rem 0.65rem 2.25rem;
border-bottom: 1px solid var(--theme-button-border-hovered); background-color: var(--board-bg-color);
} border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
.buttonWrapper {
margin-right: 1px;
&:last-child {
margin-right: 0;
}
} }
// .filterButton { // .filterButton {

View File

@ -28,20 +28,7 @@
{#if value} {#if value}
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="flex-presenter flex-grow" on:click={navigateToProject}> <span title={value.label} class="fs-bold caption-color overflow-label clear-mins" on:click={navigateToProject}>
<span title={value.label} class="projectLabel flex-grow">{value.label}</span> {value.label}
</div> </span>
{/if} {/if}
<style lang="scss">
.projectLabel {
display: block;
min-width: 0;
font-weight: 500;
text-align: left;
color: var(--theme-caption-color);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View File

@ -40,28 +40,15 @@
</script> </script>
{#if value} {#if value}
{#if isEditable} <ProjectStatusSelector
<ProjectStatusSelector {kind}
{kind} {size}
{size} {width}
{width} {justify}
{justify} {isEditable}
{isEditable} {shouldShowLabel}
{shouldShowLabel} showTooltip={isEditable ? { label: tracker.string.SetStatus } : undefined}
showTooltip={{ label: tracker.string.SetStatus }} selectedProjectStatus={value.status}
selectedProjectStatus={value.status} onProjectStatusChange={handleProjectStatusChanged}
onProjectStatusChange={handleProjectStatusChanged} />
/>
{:else}
<ProjectStatusSelector
{kind}
{size}
{width}
{justify}
{isEditable}
{shouldShowLabel}
selectedProjectStatus={value.status}
onProjectStatusChange={handleProjectStatusChanged}
/>
{/if}
{/if} {/if}

View File

@ -26,19 +26,23 @@
import { Project } from '@hcengineering/tracker' import { Project } from '@hcengineering/tracker'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import ProjectsList from './ProjectsList.svelte' import ProjectsList from './ProjectsList.svelte'
import ProjectsTimeline from './ProjectsTimeline.svelte'
export let _class: Ref<Class<Doc>> export let _class: Ref<Class<Doc>>
export let itemsConfig: (BuildModelKey | string)[] export let itemsConfig: (BuildModelKey | string)[]
export let loadingProps: LoadingProps | undefined = undefined export let loadingProps: LoadingProps | undefined = undefined
export let projects: Project[] = [] export let projects: Project[] = []
export let viewMode: 'list' | 'timeline' = 'list'
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => { const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
if (dir === 'vertical') { if (dir === 'vertical') {
projectsList.onElementSelected(offset, of) if (viewMode === 'list') projectsList.onElementSelected(offset, of)
else projectsTimeline.onElementSelected(offset, of)
} }
}) })
let projectsList: ProjectsList let projectsList: ProjectsList
let projectsTimeline: ProjectsTimeline
$: if (projectsList !== undefined) { $: if (projectsList !== undefined) {
listProvider.update(projects) listProvider.update(projects)
@ -55,18 +59,36 @@
}} }}
/> />
<ProjectsList {#if viewMode === 'list'}
bind:this={projectsList} <ProjectsList
{_class} bind:this={projectsList}
{itemsConfig} {_class}
{loadingProps} {itemsConfig}
{projects} {loadingProps}
selectedObjectIds={$selectionStore ?? []} {projects}
selectedRowIndex={listProvider.current($focusStore)} selectedObjectIds={$selectionStore ?? []}
on:row-focus={(event) => { selectedRowIndex={listProvider.current($focusStore)}
listProvider.updateFocus(event.detail ?? undefined) on:row-focus={(event) => {
}} listProvider.updateFocus(event.detail ?? undefined)
on:check={(event) => { }}
listProvider.updateSelection(event.detail.docs, event.detail.value) on:check={(event) => {
}} listProvider.updateSelection(event.detail.docs, event.detail.value)
/> }}
/>
{:else}
<ProjectsTimeline
bind:this={projectsTimeline}
{_class}
{itemsConfig}
{loadingProps}
{projects}
selectedObjectIds={$selectionStore ?? []}
selectedRowIndex={listProvider.current($focusStore)}
on:row-focus={(event) => {
listProvider.updateFocus(event.detail ?? undefined)
}}
on:check={(event) => {
listProvider.updateSelection(event.detail.docs, event.detail.value)
}}
/>
{/if}

View File

@ -0,0 +1,516 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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.
-->
<script lang="ts">
import contact from '@hcengineering/contact'
import { Class, Doc, FindOptions, getObjectValue, Ref, Timestamp } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Issue, Project } from '@hcengineering/tracker'
import { CheckBox, Spinner, Timeline, TimelineRow } from '@hcengineering/ui'
import { AttributeModel, BuildModelKey } from '@hcengineering/view'
import { buildModel, getObjectPresenter, LoadingProps } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import ProjectPresenter from './ProjectPresenter.svelte'
export let _class: Ref<Class<Doc>>
export let itemsConfig: (BuildModelKey | string)[]
export let selectedObjectIds: Doc[] = []
export let selectedRowIndex: number | undefined = undefined
export let projects: Project[] | undefined = undefined
export let loadingProps: LoadingProps | undefined = undefined
const dispatch = createEventDispatcher()
const client = getClient()
const baseOptions: FindOptions<Issue> = {
lookup: {
assignee: contact.class.Employee,
status: tracker.class.IssueStatus
}
}
let personPresenter: AttributeModel
$: options = { ...baseOptions } as FindOptions<Project>
$: selectedObjectIdsSet = new Set<Ref<Doc>>(selectedObjectIds.map((it) => it._id))
let selectedRows: number[] = []
$: if (selectedObjectIdsSet.size > 0 && projects !== undefined) {
const tRows: number[] = []
selectedObjectIdsSet.forEach((it) => {
const index = projects?.findIndex((f) => f._id === it)
if (index !== undefined) tRows.push(index)
})
selectedRows = tRows
} else selectedRows = []
$: getObjectPresenter(client, contact.class.Person, { key: '' }).then((p) => {
personPresenter = p
})
export const onObjectChecked = (docs: Doc[], value: boolean) => {
dispatch('check', { docs, value })
}
const handleRowFocused = (object: Doc) => {
dispatch('row-focus', object)
}
export const onElementSelected = (offset: 1 | -1 | 0, docObject?: Doc) => {
if (!projects) return
let position =
(docObject !== undefined ? projects?.findIndex((x) => x._id === docObject?._id) : selectedRowIndex) ?? -1
position += offset
if (position < 0) position = 0
if (position >= projects.length) position = projects.length - 1
selectedRowIndex = position
handleRowFocused(projects[position])
// if (objectRef) {
// objectRef.scrollIntoView({ behavior: 'auto', block: 'nearest' })
// }
}
const getLoadingElementsLength = (props: LoadingProps, options?: FindOptions<Doc>) => {
if (options?.limit && options?.limit > 0) {
return Math.min(options.limit, props.length)
}
return props.length
}
let itemModels: AttributeModel[] | undefined = undefined
$: buildModel({ client, _class, keys: itemsConfig, lookup: options.lookup }).then((res) => (itemModels = res))
let lines: TimelineRow[] | undefined
$: lines = projects?.map((proj) => {
const tR: TimelineRow = { items: [] }
tR.items = [
{
icon: proj.icon,
presenter: ProjectPresenter,
props: { value: proj },
startDate: proj.startDate as Timestamp,
targetDate: proj.targetDate as Timestamp
}
]
return tR
})
</script>
{#if projects && itemModels && lines}
<Timeline
{lines}
{selectedRows}
selectedRow={selectedRowIndex}
on:row-focus={(ev) => {
if (ev.detail !== undefined && projects !== undefined) handleRowFocused(projects[ev.detail])
}}
on:check={(ev) => {
if (ev.detail !== undefined && projects !== undefined) onObjectChecked([projects[ev.detail.row]], ev.detail.value)
}}
>
<svelte:fragment let:row>
{#each itemModels as attributeModel, attributeModelIndex}
{#if attributeModelIndex === 0}
<div class="gridElement">
<div class="iconPresenter">
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, projects[row]) ?? ''}
{...attributeModel.props}
/>
</div>
</div>
{:else if attributeModelIndex === 1}
<div class="projectPresenter flex-grow">
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, projects[row]) ?? ''}
{...attributeModel.props}
/>
</div>
<div class="filler" />
{:else}
<div class="gridElement">
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, projects[row]) ?? ''}
parentId={projects[row]._id}
{...attributeModel.props}
/>
</div>
{/if}
{/each}
</svelte:fragment>
</Timeline>
{:else if loadingProps !== undefined}
{#each Array(getLoadingElementsLength(loadingProps, options)) as _, rowIndex}
<div class="listGrid" class:fixed={rowIndex === selectedRowIndex}>
<div class="contentWrapper">
<div class="gridElement">
<CheckBox checked={false} />
<div class="ml-4">
<Spinner size="small" />
</div>
</div>
</div>
</div>
{/each}
{/if}
<style lang="scss">
.timeline-container {
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
& > * {
overscroll-behavior-x: contain;
}
}
.timeline-header {
display: flex;
align-items: center;
min-height: 4rem;
border-bottom: 1px solid var(--divider-color);
}
.timeline-header__title {
display: flex;
align-items: center;
flex-shrink: 0;
padding: 0 2.25rem;
height: 100%;
background-color: var(--body-accent);
box-shadow: var(--accent-shadow);
// z-index: 2;
}
.timeline-header__time {
// overflow: hidden;
position: relative;
flex-grow: 1;
height: 100%;
background-color: var(--body-color);
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 1) 2rem,
rgba(0, 0, 0, 1) calc(100% - 2rem),
rgba(0, 0, 0, 0) 100%
);
&-content {
width: 100%;
height: 100%;
will-change: transform;
.day,
.month {
position: absolute;
pointer-events: none;
}
.month {
width: max-content;
top: 0.25rem;
font-size: 1rem;
color: var(--accent-color);
&:first-letter {
text-transform: uppercase;
}
}
.day {
bottom: 0.5rem;
font-size: 1rem;
color: var(--content-color);
transform: translateX(-50%);
}
.cursor {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
padding-bottom: 1px;
width: 1.75rem;
height: 1.75rem;
bottom: 0.375rem;
font-size: 1rem;
font-weight: 600;
color: #fff;
background-color: var(--primary-bg-color);
border-radius: 50%;
transform: translateX(-50%);
pointer-events: none;
}
}
}
.todayMarker,
.monthMarker {
position: absolute;
top: 0;
bottom: 0;
width: 0;
height: 100%;
pointer-events: none;
}
.monthMarker {
border-left: 1px dashed var(--highlight-select);
}
.todayMarker {
border-left: 1px solid var(--primary-bg-color);
}
.timeline-background__headers,
.timeline-background__viewbox,
.timeline-foreground__viewbox {
overflow: hidden;
position: absolute;
top: 4rem;
bottom: 0;
height: 100%;
z-index: -1;
}
.timeline-background__headers {
left: 0;
background-color: var(--body-accent);
}
.timeline-background__viewbox,
.timeline-foreground__viewbox {
right: 0;
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 1) 2rem,
rgba(0, 0, 0, 1) calc(100% - 2rem),
rgba(0, 0, 0, 0) 100%
);
}
.timeline-foreground__viewbox {
z-index: 1;
pointer-events: none;
}
.timeline-splitter,
.timeline-splitter::before {
position: absolute;
top: 0;
bottom: 0;
height: 100%;
transform: translateX(-50%);
}
.timeline-splitter {
width: 1px;
background-color: var(--divider-color);
cursor: col-resize;
z-index: 3;
transition-property: width, background-color;
transition-timing-function: var(--timing-main);
transition-duration: 0.1s;
transition-delay: 0s;
&:hover {
width: 3px;
background-color: var(--button-border-hover);
transition-duration: 0.15s;
transition-delay: 0.3s;
}
&::before {
content: '';
width: 10px;
left: 50%;
}
&.moving {
width: 2px;
background-color: var(--primary-edit-border-color);
transition-duration: 0.1s;
transition-delay: 0s;
}
}
.headerWrapper {
display: flex;
align-items: center;
height: 100%;
min-width: 0;
padding-left: 0.75rem;
padding-right: 1.15rem;
// border-bottom: 1px solid var(--accent-bg-color);
}
.contentWrapper {
overflow: hidden;
position: relative;
display: flex;
align-items: center;
flex-grow: 1;
height: 100%;
min-width: 0;
min-height: 0;
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 1) 2rem,
rgba(0, 0, 0, 1) calc(100% - 2rem),
rgba(0, 0, 0, 0) 100%
);
&.nullRow {
cursor: pointer;
}
}
.timeline-wrapped_content {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
will-change: transform;
}
.timeline-action__button,
.project-item {
position: absolute;
display: flex;
align-items: center;
padding: 0.5rem;
box-shadow: var(--button-shadow);
}
.project-item {
top: 0.25rem;
bottom: 0.25rem;
background-color: var(--button-bg-color);
border: 1px solid var(--button-border-color);
border-radius: 0.75rem;
&:hover {
background-color: var(--button-bg-hover);
border-color: var(--button-border-hover);
}
&.noTarget {
mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 1) 2rem);
border-right-color: transparent;
}
.project-presenter {
display: flex;
align-items: center;
.space {
flex-shrink: 0;
width: 0.25rem;
min-width: 0.25rem;
max-width: 0.25rem;
}
}
}
.timeline-action__button {
top: 0.625rem;
bottom: 0.625rem;
width: 2rem;
color: var(--content-color);
background-color: var(--button-bg-color);
border: 1px solid var(--button-border-color);
border-radius: 0.5rem;
&:hover {
color: var(--accent-color);
background-color: var(--button-bg-hover);
border-color: var(--button-border-hover);
}
&.left {
left: 1rem;
}
&.right {
right: 1rem;
}
&.add {
transform: translateX(-50%);
pointer-events: none;
}
}
.listGrid {
display: flex;
justify-content: stretch;
align-items: center;
flex-shrink: 0;
width: 100%;
height: 3.25rem;
min-height: 0;
color: var(--caption-color);
z-index: 2;
&.mListGridChecked {
.headerWrapper {
background-color: var(--highlight-select);
}
.contentWrapper {
background-color: var(--trans-content-05);
}
.eListGridCheckBox {
opacity: 1;
}
}
&.mListGridSelected {
.headerWrapper {
background-color: var(--highlight-select-hover);
}
.contentWrapper {
background-color: var(--trans-content-10);
}
}
.eListGridCheckBox {
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
&:hover .eListGridCheckBox {
opacity: 1;
}
}
.filler {
display: flex;
flex-grow: 1;
}
.gridElement {
display: flex;
align-items: center;
justify-content: flex-start;
margin-left: 0.5rem;
&:first-child {
margin-left: 0;
}
}
.projectPresenter {
display: flex;
align-items: center;
flex-shrink: 0;
width: 5.5rem;
margin-left: 0.5rem;
}
</style>

View File

@ -467,7 +467,9 @@ export default plugin(trackerId, {
Duplicate: '' as Asset, Duplicate: '' as Asset,
TimeReport: '' as Asset, TimeReport: '' as Asset,
Estimation: '' as Asset Estimation: '' as Asset,
Timeline: '' as Asset
}, },
category: { category: {
Other: '' as Ref<TagCategory>, Other: '' as Ref<TagCategory>,

View File

@ -19,6 +19,11 @@
<path d="M1 12.6C1 12.0399 1 11.7599 1.10899 11.546C1.20487 11.3578 1.35785 11.2049 1.54601 11.109C1.75992 11 2.03995 11 2.6 11H5.4C5.96005 11 6.24008 11 6.45399 11.109C6.64215 11.2049 6.79513 11.3578 6.89101 11.546C7 11.7599 7 12.0399 7 12.6V13.4C7 13.9601 7 14.2401 6.89101 14.454C6.79513 14.6422 6.64215 14.7951 6.45399 14.891C6.24008 15 5.96005 15 5.4 15H2.6C2.03995 15 1.75992 15 1.54601 14.891C1.35785 14.7951 1.20487 14.6422 1.10899 14.454C1 14.2401 1 13.9601 1 13.4V12.6Z" /> <path d="M1 12.6C1 12.0399 1 11.7599 1.10899 11.546C1.20487 11.3578 1.35785 11.2049 1.54601 11.109C1.75992 11 2.03995 11 2.6 11H5.4C5.96005 11 6.24008 11 6.45399 11.109C6.64215 11.2049 6.79513 11.3578 6.89101 11.546C7 11.7599 7 12.0399 7 12.6V13.4C7 13.9601 7 14.2401 6.89101 14.454C6.79513 14.6422 6.64215 14.7951 6.45399 14.891C6.24008 15 5.96005 15 5.4 15H2.6C2.03995 15 1.75992 15 1.54601 14.891C1.35785 14.7951 1.20487 14.6422 1.10899 14.454C1 14.2401 1 13.9601 1 13.4V12.6Z" />
<path d="M9 12.6C9 12.0399 9 11.7599 9.10899 11.546C9.20487 11.3578 9.35785 11.2049 9.54601 11.109C9.75992 11 10.0399 11 10.6 11H13.4C13.9601 11 14.2401 11 14.454 11.109C14.6422 11.2049 14.7951 11.3578 14.891 11.546C15 11.7599 15 12.0399 15 12.6V13.4C15 13.9601 15 14.2401 14.891 14.454C14.7951 14.6422 14.6422 14.7951 14.454 14.891C14.2401 15 13.9601 15 13.4 15H10.6C10.0399 15 9.75992 15 9.54601 14.891C9.35785 14.7951 9.20487 14.6422 9.10899 14.454C9 14.2401 9 13.9601 9 13.4V12.6Z" /> <path d="M9 12.6C9 12.0399 9 11.7599 9.10899 11.546C9.20487 11.3578 9.35785 11.2049 9.54601 11.109C9.75992 11 10.0399 11 10.6 11H13.4C13.9601 11 14.2401 11 14.454 11.109C14.6422 11.2049 14.7951 11.3578 14.891 11.546C15 11.7599 15 12.0399 15 12.6V13.4C15 13.9601 15 14.2401 14.891 14.454C14.7951 14.6422 14.6422 14.7951 14.454 14.891C14.2401 15 13.9601 15 13.4 15H10.6C10.0399 15 9.75992 15 9.54601 14.891C9.35785 14.7951 9.20487 14.6422 9.10899 14.454C9 14.2401 9 13.9601 9 13.4V12.6Z" />
</symbol> </symbol>
<symbol id="timeline" viewBox="0 0 14 14">
<rect x="6" y="1" width="7" height="2.5" rx="0.5" />
<rect x="1" y="5.75" width="9" height="2.5" rx="0.5" />
<rect x="4" y="10.5" width="9" height="2.5" rx="0.5" />
</symbol>
<symbol id="delete" viewBox="0 0 16 16"> <symbol id="delete" viewBox="0 0 16 16">
<path d="M13.1,3.8h-2c-0.2,0-0.4-0.2-0.5-0.4l-0.2-0.8c-0.1-0.6-0.6-1-1.2-1H6.8c-0.6,0-1.1,0.4-1.2,1L5.4,3.3 c0,0.3-0.2,0.4-0.5,0.4h-2C2.7,3.8,2.4,4,2.4,4.3s0.2,0.5,0.5,0.5h0.3c0.1,1.1,0.3,6.2,0.5,7.9c0.1,1.1,0.8,1.8,1.9,1.9 c0.8,0,1.6,0,2.4,0c0.8,0,1.5,0,2.3,0c1.1,0,1.8-0.7,1.9-1.9c0.2-1.7,0.4-6.7,0.5-7.9h0.3c0.3,0,0.5-0.2,0.5-0.5S13.4,3.8,13.1,3.8z M6.4,3.5l0.1-0.8c0-0.1,0.1-0.2,0.3-0.2h2.5c0.1,0,0.2,0.1,0.2,0.2l0.1,0.8c0,0.1,0.1,0.2,0.1,0.3H6.3C6.3,3.7,6.3,3.6,6.4,3.5z M11.3,12.5c-0.1,0.9-0.7,1-0.9,1c-1.6,0-3.2,0-4.7,0c-0.5,0-0.8-0.3-0.9-1C4.6,10.9,4.4,6,4.3,4.8h7.5C11.7,6,11.4,10.9,11.3,12.5z"/> <path d="M13.1,3.8h-2c-0.2,0-0.4-0.2-0.5-0.4l-0.2-0.8c-0.1-0.6-0.6-1-1.2-1H6.8c-0.6,0-1.1,0.4-1.2,1L5.4,3.3 c0,0.3-0.2,0.4-0.5,0.4h-2C2.7,3.8,2.4,4,2.4,4.3s0.2,0.5,0.5,0.5h0.3c0.1,1.1,0.3,6.2,0.5,7.9c0.1,1.1,0.8,1.8,1.9,1.9 c0.8,0,1.6,0,2.4,0c0.8,0,1.5,0,2.3,0c1.1,0,1.8-0.7,1.9-1.9c0.2-1.7,0.4-6.7,0.5-7.9h0.3c0.3,0,0.5-0.2,0.5-0.5S13.4,3.8,13.1,3.8z M6.4,3.5l0.1-0.8c0-0.1,0.1-0.2,0.3-0.2h2.5c0.1,0,0.2,0.1,0.2,0.2l0.1,0.8c0,0.1,0.1,0.2,0.1,0.3H6.3C6.3,3.7,6.3,3.6,6.4,3.5z M11.3,12.5c-0.1,0.9-0.7,1-0.9,1c-1.6,0-3.2,0-4.7,0c-0.5,0-0.8-0.3-0.9-1C4.6,10.9,4.4,6,4.3,4.8h7.5C11.7,6,11.4,10.9,11.3,12.5z"/>
</symbol> </symbol>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -51,6 +51,7 @@
"View": "View", "View": "View",
"MarkupEditor": "Edit of rich content field", "MarkupEditor": "Edit of rich content field",
"List": "List", "List": "List",
"Timeline": "Timeline",
"Select": "Select", "Select": "Select",
"NoGrouping": "No grouping", "NoGrouping": "No grouping",
"Grouping": "Grouping", "Grouping": "Grouping",

View File

@ -49,6 +49,7 @@
"View": "Вид", "View": "Вид",
"MarkupEditor": "Изменение форматированного поля", "MarkupEditor": "Изменение форматированного поля",
"List": "Список", "List": "Список",
"Timeline": "Временная шкала",
"Select": "Выбрать", "Select": "Выбрать",
"NoGrouping": "Нет группировки", "NoGrouping": "Нет группировки",
"Grouping": "Группировка", "Grouping": "Группировка",

View File

@ -21,6 +21,7 @@ loadMetadata(view.icon, {
Table: `${icons}#table`, Table: `${icons}#table`,
List: `${icons}#list`, List: `${icons}#list`,
Card: `${icons}#card`, Card: `${icons}#card`,
Timeline: `${icons}#timeline`,
Delete: `${icons}#delete`, Delete: `${icons}#delete`,
Move: `${icons}#move`, Move: `${icons}#move`,
MoreH: `${icons}#more-h`, MoreH: `${icons}#more-h`,

View File

@ -581,12 +581,15 @@ const view = plugin(viewId, {
View: '' as IntlString, View: '' as IntlString,
FilteredViews: '' as IntlString, FilteredViews: '' as IntlString,
NewFilteredView: '' as IntlString, NewFilteredView: '' as IntlString,
FilteredViewName: '' as IntlString FilteredViewName: '' as IntlString,
List: '' as IntlString,
Timeline: '' as IntlString
}, },
icon: { icon: {
Table: '' as Asset, Table: '' as Asset,
List: '' as Asset, List: '' as Asset,
Card: '' as Asset, Card: '' as Asset,
Timeline: '' as Asset,
Delete: '' as Asset, Delete: '' as Asset,
MoreH: '' as Asset, MoreH: '' as Asset,
Move: '' as Asset, Move: '' as Asset,