| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935 |
- import dayjs, { Dayjs } from 'dayjs'
- import { Event, IEvent } from './Event'
- import { Ressource, IRessource } from './Ressource'
- import { HorizontalResizer } from './components/horizontal-resizer'
- import { LitElement, html, customElement, property, css, TemplateResult } from 'lit-element';
- import { styleMap } from 'lit-html/directives/style-map';
- import syncronizeElementsScrolling from './utils/syncroScroll';
- import Selectable from './utils/selectable';
- interface TimelineOptions {
- ressources?: Array<IRessource>
- items?: Array<IEvent>
- }
- interface TimelineContent {
- ressources: Array<Ressource>
- items: Array<Event>
- }
- interface TimeInterval {
- start: number,
- end: number,
- slots: Array<Event>
- }
- type dayjsUnit = "y"|"M"|"d"|"h"|"m"|'s'
- type UnitLegend = {
- [k in dayjsUnit]: string;
- };
- interface legendItem {
- colspan:number
- title:string
- }
- // TODO define std zoom level
- // TODO add selectable Slot
- // TODO Improve Selected Ressource style
- // TODO enable to rearrange between different component.
- @customElement('jc-timeline')
- class Timeline extends LitElement {
- static styles = css`
- body{
- font-family:Roboto;
- }
- div {
- box-sizing: border-box;
- }
- .jc-timeline-content,
- .jc-timeline-header{
- width:100%;
- position:relative;
- display: flex;
- flex-direction:row;
- height:max-content;
- align-items: stretch;
- }
-
- .jc-timeline-rows-title,
- .jc-timeline-rows > tr > td{
- padding: 8px;
- min-width:40px;
- }
- .jc-timeline-rows > tr > td {
- max-width:calc( var(--width) - 8px );
- padding: 0px;
- vertical-align:top;
- }
- .jc-timeline-rows > tr.empty > td{
- padding: 6px 0px 4px 8px;
- }
- i.jc-spacer {
- display:inline-block;
- width : 1rem;
- height: 1rem;
- position:relative;
- box-sizing: border-box;
- }
- i.jc-spacer:after{
- content: " ";
- position:absolute;
- background-repeat: no-repeat;
- background-size: 1.05rem;
- width: 1.05rem;
- height: 1.05rem;
- }
- .jc-spacer.extend,
- .jc-spacer.collapse {
- cursor:pointer;
- }
- i.jc-spacer.extend:after{
- background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iYmxhY2siIHdpZHRoPSIxOHB4IiBoZWlnaHQ9IjE4cHgiPjxwYXRoIGQ9Ik0wIDBoMjR2MjRIMFYweiIgZmlsbD0ibm9uZSIvPjxwYXRoIGQ9Ik0xOSAzSDVjLTEuMTEgMC0yIC45LTIgMnYxNGMwIDEuMS44OSAyIDIgMmgxNGMxLjEgMCAyLS45IDItMlY1YzAtMS4xLS45LTItMi0yem0wIDE2SDVWNWgxNHYxNHptLTgtMmgydi00aDR2LTJoLTRWN2gtMnY0SDd2Mmg0eiIvPjwvc3ZnPg==")
- }
- i.jc-spacer.collapse:after{
- background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0Ij48cGF0aCBkPSJNMCAwaDI0djI0SDB6IiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTE5IDNINWMtMS4xIDAtMiAuOS0yIDJ2MTRjMCAxLjEuOSAyIDIgMmgxNGMxLjEgMCAyLS45IDItMlY1YzAtMS4xLS45LTItMi0yem0wIDE2SDVWNWgxNHYxNHpNNyAxMWgxMHYySDd6Ii8+PC9zdmc+")
- }
-
- .jc-timeline-rows > tr{
- box-sizing: border-box;
- white-space: nowrap;
- border: 1px solid grey;
- border-style: solid none;
- }
- .jc-timeline-rows,
- .jc-timeline-rows-title{
- width:var(--width, 200px);
- overflow: hidden;
- border-collapse:collapse;
- }
- .jc-timeline-grid-title-container,
- .jc-timeline-grid-container{
- position:relative;
- width: 600px;
- display: block;
- overflow: hidden;
- }
- .jc-timeline-grid-container{
- overflow-x: auto;
- }
- .jc-timeline-grid-title-container > table,
- .jc-timeline-grid-container > table {
- width:100%;
- table-layout: fixed;
- border-collapse: collapse;
- box-sizing: border-box;
- }
- .jc-timeline-grid-title-container {
- white-space: nowrap;
- cursor: grab;
- user-select: none; /* supported by Chrome and Opera */
- -webkit-user-select: none; /* Safari */
- -khtml-user-select: none; /* Konqueror HTML */
- -moz-user-select: none; /* Firefox */
- -ms-user-select: none; /* Internet Explorer/Edge */
- }
- .jc-timeline-grid-title:first-child > th{
- border-top:0;
- }
- .jc-timeline-grid-title:first-child > th:before,
- .jc-timeline-grid-title:first-child > th:last-child:after {
- content:" ";
- display: block;
- position:absolute;
- left:-1px;
- top:0px;
- height:calc( 100% - 8px);
- border-left: 2px solid white;
- z-index:2;
- }
- .jc-timeline-grid-title:first-child > th:last-child:after
- {
- left:auto;
- right:-1px;
- }
- .jc-timeline-grid-title:first-child:last-child >th{
- padding:8px 0;
- }
- .jc-timeline-grid-title:last-child > th{
- border-bottom:none;
- }
- .jc-timeline-grid-title > th,
- .jc-slot {
- height:100%;
- border: solid 1px lightgray;
- border-left-style: dotted;
- border-right:0;
- text-align: center;
- position:relative;
- box-sizing: border-box;
- }
- .jc-timeline-grid-title > th:last-child,
- .jc-slot:last-child{
- border-right:solid 1px lightgray;
- }
- .jc-timeline-grid-title > th,
- .jc-major-slot{
- border-left-style: solid;
- }
- .jc-timeslots{
- position:absolute;
- top:0px;
- left:0px;
- bottom:0px;
- overflow: hidden;
- }
- .jc-timeslot{
- position:absolute;
- white-space: nowrap;
- overflow-x:hidden;
- background-color:var(--default-background);
- color:#fff;
- border-radius:3px;
- padding:4px;
- margin:2px 0px;
- z-index:1;
- cursor:auto;
- }
- .jc-timeslot.empty{
- height:5px;
- padding:2px 2px;
- margin:0px;
- cursor:pointer;
- }
- .jc-timeslot.moving{
- opacity:0.7;
- cursor:grabbing;
- }
- .jc-timeslot.selected:before{
- border:solid 2px black;
- position:absolute;
- top:0;
- bottom:0;
- left:0;
- right:0;
- content:" ";
- }
- .jc-timeslot-resizer-start,
- .jc-timeslot-resizer-end{
- position:absolute;
- top:0;
- bottom:0;
- width:4px;
- min-width:4px;
- display:block;
- cursor: ew-resize;
- }
- .jc-timeslot-resizer-start,
- .jc-timeslot-resizer-end{
- display:block;
- }
- .jc-timeslot-resizer-start{
- left:0px;
- }
- .jc-timeslot-resizer-end{
- right:0px;
- }
- .jc-timeline-rows > tr >td{
- height:100%;
- }
- .jc-ressource{
- padding-top: 2px;
- height: calc( 100% - 8px);
- }
- .jc-ressource > span {
- pointer-events: none;
- }
- .jc-ressource.target{
- background-color: lightgrey;
- }
- .jc-ressource-selected{
- border:1px solid var(--default-background, SteelBlue);
- border-right:0;
- border-left:0;
- background-color:#4682b46b;
- }
- .jc-ressource-above{
- height:4px;
- }
- .jc-ressource-above.target{
- margin-left: calc( var(--depth) * 16px );
- background-color: var(--default-background, SteelBlue);
- border-radius: 0 0 0 4px;
- }
- .jc-ressource-below{
- height:4px;
- }
- .jc-ressource-below.target{
- margin-left: calc( var(--depth) * 16px);
- background-color: var(--default-background, SteelBlue);
- border-radius: 4px 0 0 0;
- }
- `;
- @property({ type: Array })
- private rows: Array<Ressource>
- @property({ type: Array })
- private items: Array<Event>
- private selectedList: Array<Selectable>
- @property({ type: Number })
- private ressourceWidth: number
- @property({ type: Object })
- private _start: Dayjs
- @property({ type: String })
- public get start(): string {
- return this._start.toISOString();
- }
- public set start(value: string){
- this._start = dayjs(value);
- this.updateLegend();
- }
-
- private _end: Dayjs
- @property({ type: String })
- public get end(): string {
- return this._end.toISOString();
- }
- public set end(value: string){
- this._end = dayjs(value);
- this.updateLegend();
- }
- private _slotDuration: number = 30; // in minute
- @property({ type: Number })
- public get slotDuration(): number {
- return this._slotDuration;
- }
- public set slotDuration(value: number) {
- this._slotDuration = value;
- this.updateLegend();
- }
-
- private _legendSpan: number = 2; // in slot count
- @property({ type: Number })
- public get legendSpan(): number {
- return this._legendSpan;
- }
- public set legendSpan(value: number) {
- this._legendSpan = value;
- this.updateLegend();
- }
- @property({ type: Number })
- private rowHeight: number = 32 // in px
- @property({ type: Number })
- private slotWidth = 20 // in px
- @property({ type: String })
- private rowsTitle: string
- private legendUnitFormat:UnitLegend = {"y":"YYYY","M":"MMMM","d":'D',"h":"H[h]","m":"m'",'s':"s[s]"}
- @property({ type: Array })
- private legend: Array<Array<legendItem>>;
-
- constructor(options: TimelineOptions = {}) {
- super()
- this.rows = options.ressources ? options.ressources.map(Ressource.toRessource) : [];
- this.items = options.items ? options.items.map(Event.toTimeSlot) : []
- this._start = dayjs().startOf("day");
- this._end = this._start.endOf("day");
- this.rowsTitle = "Ressources"
- this.ressourceWidth = 200
- this.selectedList = [];
- this.legend = [];
- this.defaultBackground = "";
- this.updateLegend();
- this.render();
- }
- @property({type:String})
- set defaultBackground(value: string) {
- this.style.setProperty("--default-background", value);
- }
- get defaultBackground(): string {
- return this.style.getPropertyValue("--default-background");
-
- }
- setLegendUnitFormatAll(legend:Partial<UnitLegend>){
- this.legendUnitFormat = {...this.legendUnitFormat, ...legend}
- this.updateLegend()
- }
- setLegendUnitFormat(unit:dayjsUnit, format:string){
- this.legendUnitFormat[unit] = format;
- this.updateLegend()
- }
- addRessources(list:Array<IRessource>){
- return list.map(r=>this.addRessource(r));
- }
- // Ressource management
- addRessource(ressource: IRessource): IRessource | undefined {
- if (this.rows.filter(o => o.id == ressource.id).length > 0) {
- return
- }
- const r = Ressource.toRessource(ressource)
-
- if (r.parent !== undefined) {
- const idx = this.rows.indexOf(r.parent)
- if (idx > -1) {
- this.rows[idx].children.push(r);
- this.rows.splice(idx + 1, 0, r);
- } else {
- return
- }
- } else {
- this.rows = [...this.rows, r]
- }
- this.updateTimeslotPosition(r)
- return r;
- }
- removeRessourceById(id: string): TimelineContent {
- return this._removeRessourceById(id);
- }
- _removeRessourceById(id: string, depth:number = 0){
- const output: TimelineContent = { ressources: [], items: [] };
- for (let i = 0; i < this.rows.length; i) {
- const ressource = this.rows[i];
- if (ressource.id === id) {
- output.ressources.push(ressource);
- // remove the top level children from it's parent.
- if (ressource.parent && depth === 0 ){
- ressource.parent.children = ressource.parent.children.filter(o=> o.id !== ressource.id)
- }
- this.rows.splice(i, 1);
- } else if (ressource.parentId === id) {
- const partialOutput = this._removeRessourceById(ressource.id, depth + 1);
- output.ressources.push(...partialOutput.ressources)
- output.items.push(...partialOutput.items)
- } else {
- i++
- }
- }
- // Recover deleted items
- output.items.push(...this.items.filter(i => i.ressourceId === id))
- // Update items list
- this.items = this.items.filter(i => i.ressourceId !== id)
- return output;
- }
-
- getRessources(): Array<Ressource> {
- return this.rows;
- }
- getRessourceFromId(id:string):Ressource | null{
- const tmp = this.rows.filter(r=>r.id===id)
- return tmp.length > 0 ? tmp[0] : null;
- }
- setRowsTitle(title: string) {
- this.rowsTitle = title;
- }
- addTimeSlots(list:Array<IEvent>){
- return list.map((e)=>this.addTimeSlot(e));
- }
- // TimeSlot management
- addTimeSlot(slot: IEvent): Event | null {
- if (this.items.filter(o => o.id == slot.id).length > 0) {
- return null
- }
- const ressource = this.rows.find(r => r.id === slot.ressourceId);
- if (ressource === undefined) {
- return null
- }
- const timeslot = Event.toTimeSlot(slot);
- this.items = [...this.items, timeslot]
- // Update timeslot status
- timeslot.isDisplayed = timeslot.end > this._start.toDate() || timeslot.start < this._end.toDate();
- this.updateTimeslotPosition(ressource)
- return timeslot;
- }
- removeTimeslotById(id: string): Array<Event> {
- const output = this.items.filter(o => o.id === id);
- this.items = this.items.filter(o => o.id !== id);
- return output
- }
- updateTimeslotById(id: string): Event | null {
- const output = this.removeTimeslotById(id)
- if (output.length > 0) {
- this.addTimeSlot(output[0])
- return output[0];
- } else {
- return null;
- }
- }
- private updateTimeslotPosition(ressource: Ressource): void {
- const timeslots = this.items.filter(i => i.ressourceId === ressource.id);
- if (timeslots.length === 0) {
- ressource.height = this.rowHeight + (ressource.collapseChildren ? 5:0);
- return
- }
- const start = this._start.toDate().getTime();
- const end = this._end.toDate().getTime();
- // List potential interval
- let points = [start, end]
- const populateInterval = (d: Date) => {
- const t = d.getTime()
- if (start < t && t < end && !points.includes(t)) {
- points.push(t)
- }
- }
- timeslots.forEach(element => {
- populateInterval(element.start);
- populateInterval(element.end);
- });
- points.sort();
- // Count maximum number of interval intersection
- let intervals: Array<TimeInterval> = []
- for (let i = 0; i < points.length - 1; i++) {
- const startTime = points[i];
- const endTime = points[i+1];
- intervals.push({
- start: points[i],
- end: points[i+1],
- slots: timeslots.filter(slot => (slot.start.getTime() <= startTime && endTime <= slot.end.getTime()))
- })
- }
- // Update rows height
- const lineCount = intervals.reduce((acc, interval) => Math.max(acc, interval.slots.length), 0);
- ressource.height = this.rowHeight * Math.max(lineCount, 1) + (ressource.collapseChildren ? 5:0); // to avoid collapse rows
- // Solve the offset positioning of all items
- const sortTimeslots = (a: Event, b: Event): number => {
- const t = a.start.getTime() - b.start.getTime();
- if (t === 0) {
- const tend = b.end.getTime() - a.end.getTime();
- return tend === 0 ? ('' + a.id).localeCompare(b.id) : tend
- }
- return t;
- }
- // Remove all items offset
- timeslots.forEach(slot => slot.offset = -1);
- timeslots.sort(sortTimeslots);
- timeslots[0].offset = 0;
- const potentialOffset: Array<number> = []
- for (let i = 0; i < lineCount; i++) {
- potentialOffset.push(i)
- }
- intervals.forEach(intervals => {
- intervals.slots.sort(sortTimeslots);
- const usedOffset = intervals.slots.map(o => o.offset).filter(i => i > -1);
- const availableOffset = potentialOffset.filter(i => !usedOffset.includes(i));
- intervals.slots.forEach(slot => {
- if (slot.offset === -1) {
- slot.offset = availableOffset.shift() || 0;
- }
- })
- })
- }
- getTimeSlots(): Array<Event> {
- return this.items;
- }
- updateLegend() {
- const legend = [];
- const legendUnitList:Array<dayjsUnit> = [ "y","M","d","h","m",'s'];
- const legendMinUnitSpan = this.slotDuration * this.legendSpan;
- for (const legendUnit of legendUnitList) {
- let currentDate = dayjs(this._start);
- let nextColumn = currentDate.add(legendMinUnitSpan,"m");
- // Check is the starting and end date can accomodate fews cell of the given unit for this unit of time
- const isLegendPossible =
- // Enough time to fit 1 cell AND
- this._end.diff(this._start, legendUnit) > 0 &&
- // Starting & Ending date have different legend
- (nextColumn.format(this.legendUnitFormat[legendUnit])!==currentDate.format(this.legendUnitFormat[legendUnit])
- // OR 2 consecutive legends can accomodate at least 1 grid cell
- || currentDate.add(1,legendUnit).diff(currentDate,"m") >= legendMinUnitSpan)
- if(isLegendPossible){
- const row:Array<legendItem> = [];
- let i:number = 0;
- while(currentDate.isBefore(this._end)){
- i+=this.legendSpan;
- if(nextColumn.diff(currentDate,legendUnit) > 0 ){
- row.push({colspan:i,title:'' + currentDate.format(this.legendUnitFormat[legendUnit])})
- i = 0;
- currentDate = nextColumn;
- }
- nextColumn = nextColumn.add(legendMinUnitSpan,"m");
- }
- legend.push(row);
- }
- }
- this.legend = legend;
- }
- _handleResizeX(e: CustomEvent<number>) {
- e.stopPropagation();
- this.ressourceWidth += e.detail
- if (this.ressourceWidth < 0) {
- this.ressourceWidth = 0
- }
- }
- _grabHeader(e: MouseEvent) {
- const root = this.shadowRoot;
- if (root !== null) {
- const gridContainer = root.querySelector(".jc-timeline-grid-container") as HTMLBaseElement;
- const headerContainer = root.querySelector(".jc-timeline-grid-title-container") as HTMLBaseElement;
- let lastPosX = e.clientX;
- const scroll = function (e: MouseEvent) {
- const scrollLeft = (lastPosX - e.clientX)
- headerContainer.scrollLeft += scrollLeft
- gridContainer.scrollLeft += scrollLeft
- lastPosX = e.clientX
- }
- const mouseUpListener = function (e: MouseEvent) {
- window.removeEventListener("mousemove", scroll)
- window.removeEventListener("mouseup", mouseUpListener)
- }
- window.addEventListener("mousemove", scroll)
- window.addEventListener("mouseup", mouseUpListener)
- }
- }
- _getEventResizerHandler(slot: Event, direction: "end" | "start") {
- return (evt: MouseEvent) => {
- evt.stopPropagation();
- evt.preventDefault()
- const startPos = evt.clientX;
- const localSlot = slot
- const localDir = direction;
- const startDate = slot[direction];
- const resizeListener = (e: MouseEvent) => {
- const newDate = dayjs(startDate).add(Math.round((e.clientX - startPos) / this.slotWidth) * this.slotDuration, "m").toDate();
- if (direction === "start" ? (newDate < localSlot.end) : (localSlot.start < newDate)) {
- localSlot[localDir] = newDate;
- this.updateTimeslotById(slot.id);
- }
- }
- const mouseUpListener = (e: MouseEvent) => {
- window.removeEventListener("mousemove", resizeListener)
- window.removeEventListener("mouseup", mouseUpListener)
- localSlot.moving = false;
- this.updateTimeslotById(slot.id);
- }
- localSlot.moving = true
- window.addEventListener("mousemove", resizeListener)
- window.addEventListener("mouseup", mouseUpListener)
- }
- }
- _getEventGrabHandler(slot: Event, editable: boolean, ressourceEditable: boolean, callback: (e: MouseEvent, wasModified:boolean) => void) {
- return (evt: MouseEvent) => {
- evt.stopPropagation();
- evt.preventDefault();
- const startPos = evt.clientX;
- let hasChanged = false;
- const localSlot = slot;
- // Register all current selected timeslot
- let localSlots: Array<Event> = this.selectedList.filter(s => s instanceof Event).map(s => s as Event);
- if (! localSlots.includes(localSlot)){
- localSlots=[localSlot]
- }
- const startDates = localSlots.map(slot => slot.start);
- const endDates = localSlots.map(slot => slot.end);
- const updatePosition = editable ? (e: MouseEvent) => {
- const changeTime = Math.round((e.clientX - startPos) / this.slotWidth) * this.slotDuration;
- return localSlots.map((slot, index) => {
- const prevStart = slot.start;
- slot.start = dayjs(startDates[index]).add(changeTime, "m").toDate();
- slot.end = dayjs(endDates[index]).add(changeTime, "m").toDate();
- return prevStart.getTime()!==slot.start.getTime()
- }).reduce((prev,curr)=>prev || curr)
- } : (e: MouseEvent) => { return false };
- const updateRessource = ressourceEditable ? (e: MouseEvent) => {
- const rowId = this.shadowRoot?.elementsFromPoint(e.clientX, e.clientY)
- .find((e) => e.tagName == "TD")?.parentElement?.getAttribute('row-id');
- if (rowId) {
- const ressourceId = this.rows[Number(rowId)].id;
- if (ressourceId !== localSlot.ressourceId) {
- const oldRessource = this.getRessourceFromId(localSlot.ressourceId) as Ressource;
- localSlot.ressourceId = ressourceId;
- this.updateTimeslotPosition(oldRessource);
- return true;
- }
- }
- return false;
- } : (e: MouseEvent) => { return false };
- const moveListener = (e: MouseEvent) => {
- // force the handling of potential position move
- const a = updatePosition(e);
- if (updateRessource(e) || a) {
- hasChanged = true;
- this.updateTimeslotById(localSlot.id);
- }
- }
- const mouseUpListener = (e: MouseEvent) => {
- window.removeEventListener("mousemove", moveListener);
- window.removeEventListener("mouseup", mouseUpListener);
- localSlot.moving = false;
- this.updateTimeslotById(slot.id);
- callback(e,hasChanged);
- }
- localSlot.moving = true;
- window.addEventListener("mousemove", moveListener)
- window.addEventListener("mouseup", mouseUpListener)
- }
- }
- private _clearSelectedItems(){
- this.selectedList.map(e => {
- e.selected = false;
- this.updateTimeslotById(e.id)
- });
- this.selectedList = []
- }
- private _clearSelectionhandler = ()=>{
- this._clearSelectedItems()
- window.removeEventListener("click",this._clearSelectionhandler);
- }
- private _getEventClickHandler(clickedItem: Selectable) {
- const item = clickedItem;
- return (e: MouseEvent, wasModified:boolean = false) => {
- e.stopPropagation()
- const idx = this.selectedList.indexOf(item)
- if (idx > -1) {
- if(wasModified){
-
- }else if (e.ctrlKey) {
- this.selectedList.splice(idx, 1);
- item.selected = false;
- this.updateTimeslotById(item.id)
- } else {
- this._clearSelectedItems()
- }
- } else {
- if (this.selectedList.length > 0 && ! e.ctrlKey ) {
- this._clearSelectedItems()
- }
- item.selected = true;
- this.selectedList.push(item)
- this.updateTimeslotById(item.id)
- }
- let myEvent = new CustomEvent('item-selected', {
- detail: { items: this.selectedList },
- bubbles: true,
- composed: true
- });
- this.dispatchEvent(myEvent);
-
- //window.addEventListener("click",this._clearSelectionhandler);
- };
- }
- firstUpdated() {
- const root = this.shadowRoot;
- if (root !== null) {
- const gridContainer = root.querySelector(".jc-timeline-grid-container") as HTMLBaseElement;
- syncronizeElementsScrolling([gridContainer,
- root.querySelector(".jc-timeline-grid-title-container") as HTMLBaseElement], "h")
- syncronizeElementsScrolling([gridContainer,
- root.querySelector(".jc-timeline-rows") as HTMLBaseElement], "v")
- }
- if (this.defaultBackground === "") {
- this.style.setProperty("--default-background", "SteelBlue");
- }
- }
- // RENDERING
- renderTimeslot(slot: Event): TemplateResult {
- if (!slot.isDisplayed) {
- return html``
- }
- let rowTop: number = 0
- let ressource: Ressource;
- let i: number;
- for (i = 0; i < this.rows.length && this.rows[i].id !== slot.ressourceId; i++) {
- ressource = this.rows[i];
- if (ressource.show){
- rowTop += ressource.height ? ressource.height : this.rowHeight;
- }
- }
- ressource = this.rows[i];
- const minute2pixel = this.slotWidth / this.slotDuration;
- const left = dayjs(slot.start).diff(this._start, "m") * minute2pixel ;
- const right = - dayjs(slot.end).diff(this._end, "m") * minute2pixel ;
- const style = {
- height: this.rowHeight - 4 + "px",
- top: rowTop + slot.offset * this.rowHeight + "px",
- left: left + "px",
- right: right + "px",
- backgroundColor:""
- };
- const bgColor = slot.bgColor ? slot.bgColor : ressource.eventBgColor;
- if (bgColor) {
- style.backgroundColor = bgColor;
- }
- // Show collapsed ressource
- if (! ressource.show) {
- style.height = ""
- style.top = rowTop - 6 + "px";
- return html`<div class="jc-timeslot empty" style="${styleMap(style)}"></div>`
- }
- var content: TemplateResult = html`${slot.title}`
- const resizer = slot.editable === null ? ressource.eventEditable : slot.editable;
- const editableRessource = slot.ressourceEditable === null ? ressource.eventRessourceEditable : slot.ressourceEditable;
- if (resizer) {
- content = html`<div class="jc-timeslot-resizer-start" @mousedown="${this._getEventResizerHandler(slot, "start")}"></div>${content}
- <div class="jc-timeslot-resizer-end" @mousedown="${this._getEventResizerHandler(slot, "end")}"></div>`;
- }
- return html`<div class="jc-timeslot ${slot.moving ? "moving" : ""} ${slot.selected ? "selected" : ""}"
- start="${slot.start.getHours()}"
- end="${slot.end.getHours()}"
- style="${styleMap(style)}"
- @mousedown="${this._getEventGrabHandler(slot, resizer, editableRessource, this._getEventClickHandler(slot))}"
- >${content}</div>`;
- }
- _getCollapseRessourceHandler(item:Ressource):(e:MouseEvent)=>void{
- return (e:MouseEvent) => {
- item.collapseChildren = ! item.collapseChildren;
- this.updateTimeslotPosition(item);
- // Force rows refresh TODO improve this rerendering
- this.rows = [...this.rows];
- };
- }
- _onRessourceDragStart(item:Ressource){
- return (event:DragEvent)=>{
- event.dataTransfer?.setData("text", item.id);
- }
- }
- _onRessourceDragEnter(event:DragEvent){
- if (event.target instanceof HTMLElement){
- var tgt = event.target;
- tgt.classList.add("target");
- }else if(event.target instanceof Node){
- }
- }
- _onRessourceDragLeave(event:DragEvent){
- if (event.target instanceof HTMLElement){
- event.target.classList.remove("target");
- }
- }
- _onRessourceDrop(event:DragEvent){
- event.preventDefault()
- if (event.target instanceof HTMLElement){
- event.target.classList.remove("target");
- const srcId = event.dataTransfer?.getData("text");
- const destinationId = event.target.parentElement?.getAttribute("ressourceId")
- if (srcId && destinationId && (destinationId !== srcId) ){
- // Check if destination is not child of parent
- const src = this.getRessourceFromId(srcId) as Ressource;
- const destination = this.getRessourceFromId(destinationId) as Ressource;
- if(destination.childOf(src)){
- return
- }
- // Remove src item from the current Ressource
- const movedContent = this.removeRessourceById(src.id);
-
- // Update the moved ressource position
- if (event.target.classList.contains("jc-ressource")){
- movedContent.ressources[0].parent = destination;
- }else {
- movedContent.ressources[0].parent = destination.parent;
- let idx = this.rows.findIndex(v=>v.id === destinationId);
- if (event.target.classList.contains("jc-ressource-below")){
- idx += 1;
- while((idx < this.rows.length)
- && this.rows[idx].childOf(destination)){
- idx += 1;
- }
- }
- const arr = this.rows
- this.rows = [...arr.splice(0, idx), src, ...arr];
- }
- // Add moved children and associated slots
- this.addRessources(movedContent.ressources);
- this.addTimeSlots(movedContent.items);
- }
- }
- }
- renderRessource(item: Ressource): TemplateResult {
- const depth = item.depth;
- const style = `--depth:${depth};` + (item.height ? `height:${item.height}px;` : "");
- const hasChild = item.children.length > 0;
- const collapseHandler = this._getCollapseRessourceHandler(item);
- return html`<tr>
- <td class="${item.selected ? "jc-ressource-selected":""}" style="${style}" ressourceId="${item.id}" @click="${this._getEventClickHandler(item)}">
- <div class="jc-ressource-above"></div>
- <div class="jc-ressource" draggable="true" @dragstart="${this._onRessourceDragStart(item)}">
- ${Array(depth).fill(0).map(i => html`<i class="jc-spacer"></i>`)}${hasChild ? html`<i class="jc-spacer ${item.collapseChildren ? "extend" : "collapse"}" @click="${collapseHandler}"></i>` : html`<i class="jc-spacer"></i>`}
- <span>${item.title}</span>
- </div>
- <div class="jc-ressource-below"></div>
- </td>
- </tr>`;
- }
- renderGridRow(columns: Array<Dayjs>, rowId: number = -1, height: number = 30): TemplateResult {
- return html`<tr row-id="${rowId}">${columns.map((d,i) => html`<td style="height:${height}px;" class="jc-slot ${(i % this.legendSpan) === 0 ? "jc-major-slot" :""}" start="${d.toISOString()}"> </td>`)}</tr>`
- }
- render() {
- const nCol = Math.floor(this._end.diff(this._start, 'm') / this.slotDuration) + 1;
- const columns: Array<Dayjs> = []
- for (let i = 0; i < nCol; i++) {
- columns.push(this._start.add(this.slotDuration * i, 'm'))
- }
- const displayedRows = this.rows.map((r,i)=>{return{i:i,r:r}}).filter(o=>o.r.show);
- return html`
- <div class="jc-timeline-header">
- <div class="jc-timeline-rows-title" style=${styleMap({ minWidth: this.ressourceWidth + "px", width: this.ressourceWidth + "px" })}>${this.rowsTitle}</div>
- <horizontal-resizer @resize-x="${this._handleResizeX}"></horizontal-resizer>
- <div class="jc-timeline-grid-title-container">
- <table @mousedown="${this._grabHeader}" style="width:${nCol * this.slotWidth}px;">
- <colgroup>
- ${columns.map(o=>html`<col style="min-width:${this.slotWidth}px">`)}
- </colgroup>
- <tbody>
- ${this.legend.map(arr=>html`<tr class="jc-timeline-grid-title">${arr.map(o=>html`<th colspan="${o.colspan}">${o.title}</th>`)}</tr>`)}
- </tbody>
- </table>
- </div>
- </div>
- <div class="jc-timeline-content">
- <table class="jc-timeline-rows"
- style="${styleMap({ "--width": this.ressourceWidth + "px" })}"
- @dragover="${(e:DragEvent)=>e.preventDefault()}"
- @dragenter="${this._onRessourceDragEnter}"
- @dragleave="${this._onRessourceDragLeave}"
- @drop="${this._onRessourceDrop}">
- ${this.rows.length > 0 ? displayedRows.map(o=>this.renderRessource(o.r)) : html`<tr class="empty"><td>No ressource</td></tr>`}
- </table>
- <horizontal-resizer @resize-x="${this._handleResizeX}"></horizontal-resizer>
- <div class="jc-timeline-grid-container">
- <table style="width:${nCol * this.slotWidth}px;">
- <colgroup>
- ${columns.map(o=>html`<col style="min-width:${this.slotWidth}px">`)}
- </colgroup>
- <tbody>
- ${this.rows.length > 0 ? displayedRows.map(o => this.renderGridRow(columns, o.i, o.r.height)) : this.renderGridRow(columns)}
- </tbody>
- </table>
- <div class="jc-timeslots" style="width:${nCol * this.slotWidth}px;">
- ${this.items.map(slot => this.renderTimeslot(slot))}
- </div>
- </div>
- </div>
- `;
- }
- }
- export default Timeline;
|